diff --git a/docs/maintainer/ebox-current-implementation-reference.en.md b/docs/maintainer/ebox-current-implementation-reference.en.md index 475b8ff..487b34c 100644 --- a/docs/maintainer/ebox-current-implementation-reference.en.md +++ b/docs/maintainer/ebox-current-implementation-reference.en.md @@ -25,7 +25,7 @@ This is the maintainer entry point for the standalone Ebox repository. It descri | `ebox-flex.el` | Flex normalization, lines, free-space distribution, and flex rendering. | | `ebox-grid.el` | Tracks, implicit tracks, fractions, minmax/repeat, gap, placement, span, and alignment. | | `ebox-surface.el` | Candidate identity, projection to TP surface plans, retained mount/update, and the rollback-capable Ebox runtime-state participant. | -| `ebox-buffer-backend.el` | Text properties, display spaces/borders, markers, extents, and specialized local patch primitives. | +| `ebox-buffer-backend.el` | Propertized render-string construction, display spaces/borders, and existing-slot shaping. | | `ebox-incremental.el` | Runtime state, snapshots, dirty planning, owner escalation, pure commit preparation, and reports. | | `ebox-dsl.el` | Data-oriented `.ebox` forms and lowering to public nodes. | | `ebox-selector.el` | CSS-like queries over trees and runtime handles. | @@ -61,10 +61,10 @@ Caller-owned Source Tree | Measurement | `ebox-measure.el` | Application state or dirty policy. | | Formatting Context | `ebox-layout.el`, `ebox-flex.el`, `ebox-grid.el` | Buffer edits. | | Fragment/Snapshot | `ebox-fragment.el`, `ebox-incremental.el` | Source parsing or identity allocation. | -| Surface Projection and Full-Root Publication | `ebox-surface.el` plus public TP surface APIs | Ebox layout decisions or generic diff execution. | +| Surface Projection and Runtime Publication | `ebox-surface.el` plus public TP surface APIs | Ebox layout decisions or generic diff execution. | | Dirty/Patch Semantics | `ebox-incremental.el` | Raw measurement or TP buffer writes. | | Generic Surface Diff/Commit | TP | Ebox geometry, dirty policy, or application state. | -| Specialized Local Backend | `ebox-buffer-backend.el` | Full-root mount/commit ownership, style semantics, or application state. | +| Render-String Backend | `ebox-buffer-backend.el` | Live buffer writes, retained markers, style semantics, or application state. | ## Invariants @@ -79,8 +79,8 @@ Caller-owned Source Tree - Buffer coordinates belong to the generation that produced them and must be refreshed after mutation. - Grid uses the normal measurement and rendering pipeline. Native reflow may reject an ineligible tree and must fall back to Elisp without changing correctness. - Loading Ebox never builds or installs the optional Rust module. -- Phase 8 is active: `ebox-render-to-buffer` and declarative `ebox-commit` use TP as their only live publication boundary, while Ebox runtime state joins the same rollback-capable transaction. Existing specialized numeric region, selector, viewport, and scroll update publishers remain until the local-update cutover; they must not become an alternate full-root path. -- Successful full-root reports preserve the Ebox semantic strategy and planned publication scope, then add `:publication-scope tp-surface`, TP operation counts, surface revision, and retained-object reconciliation counts. +- Phase 9 is active: first mount, declarative commit, handle/selector update, viewport/theme update, batch flush, and scroll update all publish through TP surfaces. Ebox computes dirty/layout ownership and joins its opaque runtime state to the same rollback-capable transaction; it has no second live buffer executor. +- Successful reports preserve the Ebox semantic strategy and planned publication scope, then add `:publication-scope tp-surface`, TP physical operation counts, surface revision, scoped/full-root facts, and retained-object reconciliation counts. ## Grid contract diff --git a/docs/maintainer/ebox-current-implementation-reference.zh.md b/docs/maintainer/ebox-current-implementation-reference.zh.md index c48984e..c20b55f 100644 --- a/docs/maintainer/ebox-current-implementation-reference.zh.md +++ b/docs/maintainer/ebox-current-implementation-reference.zh.md @@ -25,7 +25,7 @@ | `ebox-flex.el` | flex 归一化、line、剩余空间分配和 flex 渲染。 | | `ebox-grid.el` | 轨道、隐式轨道、分数、minmax/repeat、gap、placement、span 和对齐。 | | `ebox-surface.el` | 候选 identity、向 TP surface plan 的投影、retained mount/update,以及支持 rollback 的 Ebox runtime-state participant。 | -| `ebox-buffer-backend.el` | text property、display space/border、marker、extent 与专用局部 patch primitive。 | +| `ebox-buffer-backend.el` | 带文本属性的渲染字符串构造、display space/border 与既有 slot 整形。 | | `ebox-incremental.el` | runtime、snapshot、dirty 规划、owner 提升、纯 commit 准备和报告。 | | `ebox-dsl.el` | 数据型 `.ebox` form,以及向公共节点的 lowering。 | | `ebox-selector.el` | 对树和 runtime handle 的 CSS-like 查询。 | @@ -61,10 +61,10 @@ active 合同还覆盖 `Makefile`、`.github/workflows/ci.yml`、`tests/ebox-cor | Measurement | `ebox-measure.el` | 应用状态或 dirty 策略。 | | Formatting Context | `ebox-layout.el`、`ebox-flex.el`、`ebox-grid.el` | Buffer 编辑。 | | Fragment/Snapshot | `ebox-fragment.el`、`ebox-incremental.el` | Source parsing 或 identity 分配。 | -| Surface 投影与整根发布 | `ebox-surface.el` 加公共 TP surface API | Ebox 布局决策或通用 diff 执行。 | +| Surface 投影与 Runtime 发布 | `ebox-surface.el` 加公共 TP surface API | Ebox 布局决策或通用 diff 执行。 | | Dirty/Patch 语义 | `ebox-incremental.el` | 原始测量或 TP buffer 写入。 | | 通用 Surface Diff/Commit | TP | Ebox 几何、dirty 策略或应用状态。 | -| 专用局部 Backend | `ebox-buffer-backend.el` | 整根 mount/commit 所有权、样式语义或应用状态。 | +| 渲染字符串 Backend | `ebox-buffer-backend.el` | Live buffer 写入、retained marker、样式语义或应用状态。 | ## 不变量 @@ -79,8 +79,8 @@ active 合同还覆盖 `Makefile`、`.github/workflows/ci.yml`、`tests/ebox-cor - Buffer 坐标属于生成它的 generation,变更后必须重新获取。 - Grid 使用普通测量与渲染流水线;native reflow 可以拒绝不适合的树并回退到 Elisp,正确性不变。 - 加载 Ebox 不会构建或安装可选 Rust 模块。 -- 当前已进入 Phase 8:`ebox-render-to-buffer` 与声明式 `ebox-commit` 只通过 TP 这一条 live 发布边界,Ebox runtime state 在同一个可 rollback transaction 中参与提交。现有的数字 region、selector、viewport 与 scroll 专用局部 publisher 暂时保留到局部更新切换阶段,但不得成为第二条整根发布路径。 -- 成功的整根报告会保留 Ebox 语义策略与 planned publication scope,再加入 `:publication-scope tp-surface`、TP operation 数、surface revision 和 retained-object reconciliation 统计。 +- 当前已进入 Phase 9:首次 mount、声明式 commit、handle/selector 更新、viewport/theme 更新、batch flush 与 scroll 更新全部通过 TP surface 发布。Ebox 只计算 dirty/layout owner,并让不透明 runtime state 参与同一个可 rollback transaction;不存在第二个 live buffer executor。 +- 成功报告会保留 Ebox 语义 strategy 与 planned publication scope,再加入 `:publication-scope tp-surface`、TP 物理 operation 数、surface revision、scoped/full-root 事实和 retained-object reconciliation 统计。 ## Grid 合同 diff --git a/docs/maintainer/ebox-incremental-update-contract.en.md b/docs/maintainer/ebox-incremental-update-contract.en.md index 2cc2ffd..22fa661 100644 --- a/docs/maintainer/ebox-incremental-update-contract.en.md +++ b/docs/maintainer/ebox-incremental-update-contract.en.md @@ -9,8 +9,8 @@ This document defines the low-level publication contract. It is independent of E 3. `ebox-style.el`, `ebox-measure.el`, and the layout modules own normalization, measurement, and geometry. 4. `ebox-incremental.el` owns dirty classification, patch planning, pure declarative commit preparation, and Ebox report semantics. 5. `ebox-surface.el` projects retained candidates and joins Ebox runtime-state publication to the TP transaction. -6. TP owns generic retained-surface reconciliation, buffer diff execution, revision changes, and rollback for initial mount and declarative full-root commit. -7. `ebox-buffer-backend.el` owns Ebox marker/index primitives and the specialized local numeric region, viewport, and scroll publishers that have not yet crossed the local-update cutover. +6. TP owns generic retained-surface reconciliation, mount/index state, all live buffer diff execution, revision changes, and rollback for initial mount, declarative commit, handle update, viewport/theme update, and scroll update. +7. `ebox-buffer-backend.el` only builds and reshapes propertized render strings. It does not own live markers or a buffer mutation executor. No layer may infer application state from visible buffer text. No public caller may mutate a published tree in place. @@ -26,14 +26,14 @@ fresh root -> produce TP surface plan plus Ebox report base -> TP prepares and atomically publishes buffer changes -> Ebox transaction participant publishes matching runtime indexes/report - -> retire superseded markers and timers after success + -> retire superseded Ebox timers and warm the committed scroll state after success ``` The candidate is either fully published or discarded. A failed render, TP write, Ebox participant, or publication callback preserves the previous buffer text, properties, TP revision, Ebox runtime identity, scroll state, and last successful report. If the target buffer is killed during publication, teardown is authoritative and rollback never resurrects it. ## Patch order -The planner prefers `paint-patch`, then `span-patch`, then `owner-rerender`, and finally `root-rerender` when geometry or identity makes a smaller operation unsafe. A patch must not silently widen its semantic scope. Declarative full-root reports preserve that Ebox strategy and planned scope while separately recording TP's actual surface operations and revision. +The planner prefers `paint-patch`, then `span-patch`, then `owner-rerender`, and finally `root-rerender` when geometry or identity makes a smaller operation unsafe. These names describe Ebox semantic owner scope; TP alone computes and executes the physical text/property diff. A patch must not silently widen its semantic scope. Reports preserve the Ebox strategy and planned scope while separately recording TP's actual surface operations and revision. ## Identity and coordinates diff --git a/docs/maintainer/ebox-incremental-update-contract.zh.md b/docs/maintainer/ebox-incremental-update-contract.zh.md index 134fbae..722f85e 100644 --- a/docs/maintainer/ebox-incremental-update-contract.zh.md +++ b/docs/maintainer/ebox-incremental-update-contract.zh.md @@ -9,8 +9,8 @@ 3. `ebox-style.el`、`ebox-measure.el` 与布局模块拥有归一化、测量和几何。 4. `ebox-incremental.el` 拥有 dirty 分类、patch 规划、纯声明式 commit 准备和 Ebox 报告语义。 5. `ebox-surface.el` 投影 retained candidate,并让 Ebox runtime-state 发布加入 TP transaction。 -6. TP 拥有首次 mount 与声明式整根 commit 的通用 retained-surface reconciliation、buffer diff 执行、revision 变更和 rollback。 -7. `ebox-buffer-backend.el` 拥有 Ebox marker/index primitive,以及尚未完成局部更新切换的数字 region、viewport 与 scroll 专用 publisher。 +6. TP 拥有首次 mount、声明式 commit、handle 更新、viewport/theme 更新和 scroll 更新的通用 retained-surface reconciliation、mount/index 状态、全部 live buffer diff 执行、revision 变更和 rollback。 +7. `ebox-buffer-backend.el` 只构造和整形带文本属性的渲染字符串,不拥有 live marker 或 buffer mutation executor。 任何层都不能从可见 buffer 文本推断应用状态;公共调用者不能原地修改已经发布的树。 @@ -26,14 +26,14 @@ -> 生成 TP surface plan 与 Ebox report base -> TP 准备并原子发布 buffer 变更 -> Ebox transaction participant 发布匹配的 runtime index/report - -> 成功后退役旧 marker 与 timer + -> 成功后退役 Ebox timer,并预热已提交的 scroll state ``` 候选要么完整发布,要么丢弃。render、TP 写入、Ebox participant 或发布回调失败时,必须保留之前的 buffer 文本、属性、TP revision、Ebox runtime identity、滚动状态和最近一次成功报告。如果目标 buffer 在发布期间被 kill,teardown 是最终状态,rollback 绝不能把它复活。 ## Patch 顺序 -规划器优先使用 `paint-patch`,再使用 `span-patch`、`owner-rerender`,最后在几何或 identity 使小操作不安全时使用 `root-rerender`。Patch 不得静默扩大语义范围。声明式整根报告保留该 Ebox strategy 与 planned scope,同时单独记录 TP 的实际 surface operation 和 revision。 +规划器优先使用 `paint-patch`,再使用 `span-patch`、`owner-rerender`,最后在几何或 identity 使小操作不安全时使用 `root-rerender`。这些名称描述的是 Ebox 的语义 owner 范围;只有 TP 负责计算并执行物理文本/属性 diff。Patch 不得静默扩大语义范围。报告保留 Ebox strategy 与 planned scope,同时单独记录 TP 的实际 surface operation 和 revision。 ## Identity 与坐标 diff --git a/docs/user/ebox-user-guide.en.md b/docs/user/ebox-user-guide.en.md index 483d136..1c9d629 100644 --- a/docs/user/ebox-user-guide.en.md +++ b/docs/user/ebox-user-guide.en.md @@ -108,7 +108,7 @@ Selectors query the rendered tree and return public match records. They do not o (ebox-region-update handle :content "Ready" :color "#166534")) ``` -The same logical id in two buffers resolves to two different handles, so updating one surface cannot accidentally mutate the other. A handle becomes stale when its retained object is removed or its buffer is killed. Prefer `ebox-region-resolve` handles for new multi-buffer code; numeric region ids remain an internal/legacy update form during the local-update cutover. +The same logical id in two buffers resolves to two different handles, so updating one surface cannot accidentally mutate the other. A handle becomes stale when its retained object is removed or its buffer is killed. `ebox-region-update` accepts only a live handle; numeric region ids are internal render metadata and are not an update API. ## 8. Standalone `.ebox` files diff --git a/docs/user/ebox-user-guide.zh.md b/docs/user/ebox-user-guide.zh.md index 7ed360a..1ab624d 100644 --- a/docs/user/ebox-user-guide.zh.md +++ b/docs/user/ebox-user-guide.zh.md @@ -108,7 +108,7 @@ Selector 查询已渲染的树并返回公共匹配记录,但不拥有应用 (ebox-region-update handle :content "Ready" :color "#166534")) ``` -两个 buffer 中相同的逻辑 id 会解析为两个不同 handle,因此更新一个 surface 不会误改另一个。retained object 被删除或 buffer 被 kill 后,原 handle 会变 stale。新的多 buffer 代码应优先使用 `ebox-region-resolve` handle;在局部更新执行器切换完成前,数字 region id 仍作为内部/旧式更新形式保留。 +两个 buffer 中相同的逻辑 id 会解析为两个不同 handle,因此更新一个 surface 不会误改另一个。retained object 被删除或 buffer 被 kill 后,原 handle 会变 stale。`ebox-region-update` 只接受 live handle;数字 region id 只是内部渲染元数据,不是更新 API。 ## 8. 独立 `.ebox` 文件 diff --git a/ebox-buffer-backend.el b/ebox-buffer-backend.el index 765f4d4..d2d2c2e 100644 --- a/ebox-buffer-backend.el +++ b/ebox-buffer-backend.el @@ -1,790 +1,24 @@ ;;; ebox-buffer-backend.el --- Emacs buffer backend for Ebox -*- lexical-binding: t; -*- ;;; Commentary: -;; Owns text insertion, text properties, spans, markers, buffer modes, and -;; backend patch primitives. It does not own computed style semantics. +;; Builds propertized render strings and reshapes them for existing layout +;; slots. Live buffer publication and retained markers belong to TP. ;;; Code: (require 'cl-lib) (require 'subr-x) -(require 'ebox-fragment) -(require 'ebox-render-context) -(defvar ebox-region-types) -(defvar ebox--box-extents) -(defvar ebox--defer-scroll-content-index) -(defvar ebox--region-box-table) (defvar ebox--propertize-private-content-line-p nil "Non-nil allows ownership to be added to fresh content lines in place.") -(declare-function ebox--buffer-region-role-span-table - "ebox-incremental" (buffer)) -(declare-function ebox--clear-buffer-extents - "ebox" (&optional buffer preserve-template)) -(declare-function ebox--install-box-extent-template - "ebox" (buffer template start)) -(declare-function ebox--preflight-scroll-content-span-template - "ebox" (buffer template start target-length)) -(declare-function ebox--refresh-patched-scroll-markers - "ebox" - (buffer affected-region-ids published-spans - &optional scroll-content-template root-start)) -(declare-function ebox--register-box-extents-in-range - "ebox" (start end)) -(declare-function ebox--set-box-extents - "ebox" (region-id start end)) -(declare-function ebox--ensure-node-id - "ebox" (node)) -(declare-function ebox--region-id-set - "ebox" (region-ids)) (declare-function ebox--maplines "ebox" (function string)) (declare-function ebox--string-repeat-lines "ebox" (string count)) -(declare-function ebox-render - "ebox" (node)) (declare-function ebox-lines-join "ebox" (lines)) -(declare-function ebox-string-height - "ebox" (string)) (declare-function ebox-string-lines "ebox" (string)) -(declare-function ebox--set-buffer-region-role-span-table - "ebox-incremental" (buffer table)) -(declare-function ebox--external-footprint-compatible-p - "ebox-incremental" (old new)) -(declare-function ebox--external-footprint-signature-from-span-footprint - "ebox-incremental" (signature)) -(declare-function ebox--parent-slot-compatible-p - "ebox-incremental" (old new)) -(declare-function ebox--project-parent-slot-signature - "ebox-incremental" (old-parent-slot new-span-footprint)) -(declare-function ebox--rendered-span-footprint-signature - "ebox-incremental" (rendered)) -(declare-function ebox--span-footprint-compatible-p - "ebox-incremental" (old new)) -(declare-function ebox--runtime-child-under-ancestor - "ebox-incremental" (buffer ancestor-id node-id)) -(declare-function ebox--dirty-provenance-node-ids - "ebox-incremental" (dirty)) -(declare-function ebox--buffer-runtime-node - "ebox-incremental" (buffer node-id)) -(declare-function ebox--buffer-render-state - "ebox-incremental" (buffer)) -(declare-function ebox--buffer-root-node - "ebox-incremental" (buffer)) -(declare-function ebox--buffer-root-node-id - "ebox-incremental" (buffer)) -(declare-function ebox--refresh-buffer-layout-snapshots - "ebox-incremental" (buffer &optional details)) -(declare-function ebox--ensure-layout-snapshot-details - "ebox-incremental" (buffer node-id)) -(declare-function ebox--ensure-layout-snapshot-spans - "ebox-incremental" (buffer node-id)) -(declare-function ebox--runtime-parent-id - "ebox-incremental" (buffer node-id)) -(declare-function ebox--viewport-height-dependent-subtree-p - "ebox-incremental" (node)) -(declare-function ebox--owned-overflow-coverage-wrapper-node-p - "ebox-incremental" (buffer node-id)) -(declare-function ebox--contained-partial-flex-owner-p - "ebox-incremental" (buffer node-id)) -(declare-function ebox--definite-containment-formatting-context-p - "ebox-incremental" (buffer node-id)) -(declare-function ebox--render-node-in-current-flex-slot - "ebox-incremental" - (buffer node-id snapshot &optional changed-keys)) -(declare-function ebox--render-cache-probe - "ebox-incremental" - (node &optional force external-signature)) -(declare-function ebox--render-cache-store-context - "ebox-incremental" (context rendered &optional metadata)) -(declare-function ebox--render-with-cache - "ebox-incremental" - (node &optional force cache-probe)) -(declare-function ebox--flex-render-row-line-for-child - "ebox-flex" (node child-id)) -(declare-function ebox--flex-render-row-line-for-child-ids - "ebox-flex" (node child-ids &optional min-item-main)) -(declare-function ebox--flex-item-source-node - "ebox-flex" (item)) -(declare-function ebox--flex-box-main-constraint - "ebox-flex" (box axis property)) -(declare-function ebox--wrapper-content-viewport-pixel - "ebox-layout" (props)) -(declare-function ebox--apply-surface-properties - "ebox-layout" (rendered properties)) -(declare-function ebox--layout-children "ebox-tree" (node)) -(declare-function ebox-get "ebox" (box property)) -(declare-function ebox--refresh-buffer-scroll-content-markers - "ebox" (buffer)) - -(defvar ebox-incremental--declarative-commit-buffer) -(defvar ebox--prepared-root-render) -(defvar ebox--scroll-global-state) -(defvar ebox--box-extent-template) -(defvar ebox-viewport-height) -(defvar ebox-viewport-width) - -(defvar ebox-buffer-owner-proof-renderer nil - "Optional renderer used to prepare one owner proof. -The function receives NODE, SNAPSHOT, DIRTY, and a normalized CONTEXT plist. -CONTEXT exposes `:root-owner-p' immediately, a -`:parent-content-width-function' thunk for containing geometry, and a -`:details-function' thunk for the remaining expensive runtime facts. The -function returns an exact propertized render string when it owns the proof, or -nil when the ordinary Ebox renderer should handle that owner. This is the -single backend boundary for optional render accelerators; publication and -buffer mutation remain owned by this module.") - -(defun ebox-buffer--owner-proof-parent-content-width - (buffer owner-id viewport-width) - "Return OWNER-ID's proven containing-box content width in BUFFER." - (let ((parent-id (ebox--runtime-parent-id buffer owner-id)) - boxes blocked anchor-width) - (while (and parent-id (not blocked) (not anchor-width)) - (let* ((parent (ebox--buffer-runtime-node buffer parent-id)) - (type (and parent (plist-get parent :ebox-type)))) - (pcase type - ('stack nil) - ('box - (if (memq (ebox-get parent :width) - '(min-content max-content)) - (setq blocked t) - (push parent boxes) - (let ((ebox-viewport-width nil)) - (setq anchor-width - (ebox--wrapper-content-viewport-pixel parent))))) - (_ (setq blocked t))) - (setq parent-id (ebox--runtime-parent-id buffer parent-id)))) - (when (and boxes (not blocked)) - (let ((content-width viewport-width)) - (dolist (box boxes content-width) - (let ((ebox-viewport-width content-width)) - (setq content-width - (ebox--wrapper-content-viewport-pixel box)))))))) - -(defun ebox-buffer--owner-proof-side-effects-safe-p (node) - "Return non-nil when proof rendering NODE cannot mutate scroll state." - (cl-labels - ((safe-p - (current) - (and - (or (not (eq (and (listp current) - (plist-get current :ebox-type)) - 'box)) - (let ((region-id (plist-get current :region-id))) - (and - (not (and region-id - (hash-table-p ebox--scroll-global-state) - (gethash region-id ebox--scroll-global-state))) - (not - (and (eq (ebox-get current :overflow) 'scroll) - (or (ebox-get current :height) - (ebox-get current :max-height) - (not (zerop - (or (ebox-get current :scroll-offset) - 0))))))))) - (cl-every #'safe-p (ebox--layout-children current))))) - (safe-p node))) - -(defun ebox-buffer--owner-proof-slot-width-placeholder (buffer snapshot) - "Return a positive non-semantic slot width for SNAPSHOT in BUFFER. -The result is derived only from published buffer spans. It may carry an -unknown-width native proof frame, but must never become a containing-block -constraint." - (when (and (buffer-live-p buffer) - (plist-get snapshot :buffer-spans)) - (with-current-buffer buffer - (save-restriction - (widen) - (let ((widths - (cl-loop for span in (plist-get snapshot :buffer-spans) - for width = (ebox-buffer--span-slot-width span) - when (> width 0) - collect width))) - (when widths - (truncate (apply #'min widths)))))))) - -(defun ebox-buffer--owner-proof-context-details - (buffer owner-id node snapshot) - "Return expensive normalized proof facts for OWNER-ID in BUFFER." - (let* ((state (ebox--buffer-render-state buffer)) - (viewport-width - (or (and state (plist-get state :viewport-width)) - ebox-viewport-width)) - (viewport-height - (or (and state (plist-get state :viewport-height)) - ebox-viewport-height)) - (parent-content-width - (ebox-buffer--owner-proof-parent-content-width - buffer owner-id viewport-width))) - (list :viewport-width viewport-width - :viewport-height viewport-height - :runtime-revision - (or (and state (plist-get state :runtime-revision)) 0) - :viewport-height-dependent-p - (ebox--viewport-height-dependent-subtree-p node) - :parent-content-width parent-content-width - :slot-width-placeholder - (unless (and (numberp parent-content-width) - (> parent-content-width 0)) - (ebox-buffer--owner-proof-slot-width-placeholder buffer snapshot)) - :side-effects-safe-p - (ebox-buffer--owner-proof-side-effects-safe-p node)))) - -(defun ebox-buffer--owner-proof-context - (buffer owner-id node snapshot root-owner-p) - "Return lazy normalized accelerator context for OWNER-ID in BUFFER." - (let (details details-ready-p parent-width parent-width-ready-p) - (list - :root-owner-p root-owner-p - :parent-content-width-function - (lambda () - (unless parent-width-ready-p - (let* ((state (ebox--buffer-render-state buffer)) - (viewport-width - (or (and state (plist-get state :viewport-width)) - ebox-viewport-width))) - (setq parent-width - (ebox-buffer--owner-proof-parent-content-width - buffer owner-id viewport-width) - parent-width-ready-p t))) - parent-width) - :details-function - (lambda () - (unless details-ready-p - (setq details - (ebox-buffer--owner-proof-context-details - buffer owner-id node snapshot) - parent-width (plist-get details :parent-content-width) - parent-width-ready-p t - details-ready-p t)) - details)))) - -(defun ebox-buffer--owner-cache-probe (node context) - "Return NODE's ordinary or exact owner cache probe using CONTEXT." - (let ((ordinary (ebox--render-cache-probe node))) - (if (and ordinary (plist-member ordinary :rendered)) - ordinary - (let* ((details - (funcall (plist-get context :details-function))) - (parent-width (plist-get details :parent-content-width))) - (if (and (numberp parent-width) (> parent-width 0)) - (let ((exact - (ebox--render-cache-probe - node nil (list 'owner-context parent-width)))) - (if (and exact (plist-member exact :rendered)) - exact - (or ordinary exact))) - ordinary))))) - -(defun ebox-buffer--render-owner-for-rerender - (buffer owner-id node snapshot dirty root-owner-p) - "Render OWNER-ID for rerender through the single owner proof boundary." - (if (ebox--definite-containment-formatting-context-p buffer owner-id) - (ebox-render node) - (or (ebox--render-node-in-current-flex-slot - buffer owner-id snapshot (plist-get dirty :changed-keys)) - (let* ((context - (and (not root-owner-p) - (ebox-buffer--owner-proof-context - buffer owner-id node snapshot nil))) - (probe - (if root-owner-p - (and (null ebox--prepared-root-render) - (ebox--render-cache-probe node t)) - (ebox-buffer--owner-cache-probe node context)))) - (if (and probe (plist-member probe :rendered)) - (plist-get probe :rendered) - (let* ((context - (or context - (and ebox-buffer-owner-proof-renderer - (ebox-buffer--owner-proof-context - buffer owner-id node snapshot t)))) - (rendered - (and ebox-buffer-owner-proof-renderer - context - (funcall ebox-buffer-owner-proof-renderer - node snapshot dirty context)))) - (if (not rendered) - (let* ((parent-width - (and context - (funcall - (plist-get - context - :parent-content-width-function)))) - (ebox-viewport-width - (if (and (numberp parent-width) - (> parent-width 0)) - parent-width - ebox-viewport-width))) - (ebox--render-with-cache node root-owner-p probe)) - (when (and probe - (plist-get - (funcall (plist-get context :details-function)) - :side-effects-safe-p)) - (ebox--render-cache-store-context probe rendered)) - rendered))))))) - -;;; Window State - -(defvar ebox-buffer--window-state-preserved-buffer nil - "Buffer whose point and windows are preserved by the outer transaction.") - -(defun ebox--content-owner-ids-at (pos) - "Return Ebox content owners at POS, ordered from inner to outer. -When POS is an unowned boundary such as `point-max', inspect the preceding -character so a point at the end of owned content keeps its stable anchor." - (let* ((pos (min (max pos (point-min)) (point-max))) - (owners-at - (lambda (position) - (let ((owners - (copy-sequence - (or (get-text-property - position 'ebox-content-owners) - nil))) - (owner - (get-text-property position 'ebox-content-owner))) - (when (and owner (not (member owner owners))) - (setq owners (append owners (list owner)))) - owners)))) - (or (funcall owners-at pos) - (when (> pos (point-min)) - (funcall owners-at (1- pos)))))) - -(defun ebox--position-owned-by-content-region-p (pos region-id) - "Return non-nil when the content at POS is owned by REGION-ID." - (or (equal (get-text-property pos 'ebox-content-owner) region-id) - (member region-id - (get-text-property pos 'ebox-content-owners)))) - -(defvar ebox--content-owner-runs-cache nil - "Per-transaction cache of content owner runs, or nil when inactive. -When non-nil, a hash table keyed by region id. Each entry records the -buffer, its `buffer-modified-tick', and the restriction it was computed -under, so any text or property edit invalidates the entry without -explicit bookkeeping. Bound by -`ebox--with-preserved-buffer-window-state' so one capture/restore pass -walks each owner's runs at most once per buffer state.") - -(defun ebox--next-content-ownership-change (position limit) - "Return the next position before LIMIT where content ownership can change." - (min (or (next-single-property-change - position 'ebox-content-owner nil limit) - limit) - (or (next-single-property-change - position 'ebox-content-owners nil limit) - limit))) - -(defun ebox--compute-content-owner-runs (region-id) - "Compute maximal buffer runs owned by content REGION-ID." - (let ((position (point-min)) - (limit (point-max)) - run-start runs) - (while (< position limit) - (let ((next (ebox--next-content-ownership-change position limit)) - (owned-p - (ebox--position-owned-by-content-region-p position region-id))) - (cond - ((and owned-p (null run-start)) - (setq run-start position)) - ((and (not owned-p) run-start) - (push (cons run-start position) runs) - (setq run-start nil))) - (setq position next))) - (when run-start - (push (cons run-start limit) runs)) - (nreverse runs))) - -(defun ebox--content-owner-runs (region-id) - "Return maximal buffer runs owned by content REGION-ID. -Callers must not mutate the returned list: it may be shared through -`ebox--content-owner-runs-cache' within one preservation transaction." - (let ((cache ebox--content-owner-runs-cache)) - (if (null cache) - (ebox--compute-content-owner-runs region-id) - (let ((entry (gethash region-id cache))) - (if (and entry - (eq (nth 0 entry) (current-buffer)) - (= (nth 1 entry) (buffer-modified-tick)) - (= (nth 2 entry) (point-min)) - (= (nth 3 entry) (point-max))) - (nth 4 entry) - (let ((runs (ebox--compute-content-owner-runs region-id))) - (puthash region-id - (list (current-buffer) (buffer-modified-tick) - (point-min) (point-max) runs) - cache) - runs)))))) - -(defun ebox--content-owner-run-anchor-at (region-id pos) - "Return REGION-ID run ordinal and intra-run offset for POS." - (let* ((pos (min (max pos (point-min)) (point-max))) - (probe - (if (ebox--position-owned-by-content-region-p pos region-id) - pos - (and (> pos (point-min)) (1- pos)))) - (runs (ebox--content-owner-runs region-id)) - (index 0) - found) - (while (and probe runs (not found)) - (let ((run (pop runs))) - (if (and (<= (car run) probe) (< probe (cdr run))) - (setq found (list index (- pos (car run)))) - (cl-incf index)))) - found)) - -(defun ebox--resolve-content-owner-run-anchor (region-id run-index offset) - "Resolve REGION-ID RUN-INDEX and intra-run OFFSET in the current buffer." - (when-let ((run (nth run-index (ebox--content-owner-runs region-id)))) - (min (+ (car run) offset) (cdr run)))) - -(defun ebox--line-occurrence-before (line line-start) - "Count full lines equal to LINE that begin before position LINE-START." - (let ((regexp (concat "^" (regexp-quote line) "$")) - (case-fold-search nil) - (search-spaces-regexp nil) - (occurrence 0)) - (save-excursion - (goto-char (point-min)) - (while (and (< (point) line-start) - (re-search-forward regexp line-start t) - (< (match-beginning 0) line-start)) - (cl-incf occurrence) - (forward-line 1))) - occurrence)) - -(defun ebox--line-anchor-at (pos) - "Return a stable owner-first line anchor for POS in the current buffer. -The `:occurrence' index is captured only when POS yields no owner-run -anchor, because a captured owner run resolves without consulting the -line-text fallback; when an owner run is captured but later fails to -resolve, the fallback targets the first matching line." - (save-restriction - (widen) - (let* ((pos (min (max pos (point-min)) (point-max))) - (line-start (save-excursion - (goto-char pos) - (line-beginning-position))) - (line-end (save-excursion - (goto-char pos) - (line-end-position))) - (line (buffer-substring-no-properties line-start line-end)) - (column (- pos line-start)) - (owner-run-anchors - (delq - nil - (mapcar - (lambda (region-id) - (when-let ((run-anchor - (ebox--content-owner-run-anchor-at - region-id pos))) - (list region-id - (car run-anchor) - (cadr run-anchor)))) - (ebox--content-owner-ids-at pos))))) - (list :position pos - :owner-run-anchors owner-run-anchors - :line line - :column column - :occurrence (if owner-run-anchors - 0 - (ebox--line-occurrence-before line line-start)))))) - -(defun ebox--resolve-line-anchor (anchor) - "Resolve ANCHOR in the current buffer after text replacement." - (save-restriction - (widen) - (let ((line (plist-get anchor :line)) - (occurrence (plist-get anchor :occurrence)) - (column (plist-get anchor :column)) - (seen 0) - found) - (dolist (owner-anchor (plist-get anchor :owner-run-anchors)) - (unless found - (setq found - (ebox--resolve-content-owner-run-anchor - (nth 0 owner-anchor) - (nth 1 owner-anchor) - (nth 2 owner-anchor))))) - (unless found - (save-excursion - (goto-char (point-min)) - (while (and (not found) (not (eobp))) - (when (equal (buffer-substring-no-properties - (line-beginning-position) - (line-end-position)) - line) - (if (= seen occurrence) - (setq found (min (+ (line-beginning-position) column) - (line-end-position))) - (cl-incf seen))) - (forward-line 1)))) - (or found - (min (max (plist-get anchor :position) (point-min)) - (point-max)))))) - -(defun ebox--capture-buffer-window-state (buffer) - "Capture point, start, and horizontal scroll for live windows showing BUFFER." - (when (buffer-live-p buffer) - (with-current-buffer buffer - (mapcar (lambda (window) - (list :window window - :point (ebox--line-anchor-at (window-point window)) - :start (ebox--line-anchor-at (window-start window)) - :hscroll (window-hscroll window))) - (get-buffer-window-list buffer nil t))))) - -(defun ebox--restore-buffer-window-state (buffer states) - "Restore captured window STATES for BUFFER." - (when (buffer-live-p buffer) - (with-current-buffer buffer - (dolist (state states) - (let ((window (plist-get state :window))) - (when (and (window-live-p window) - (eq (window-buffer window) buffer)) - (set-window-start - window (ebox--resolve-line-anchor (plist-get state :start)) t) - (set-window-point - window (ebox--resolve-line-anchor (plist-get state :point))) - (set-window-hscroll window (plist-get state :hscroll)))))))) - -(defmacro ebox--with-preserved-buffer-window-state (buffer &rest body) - "Evaluate BODY while preserving BUFFER point and showing windows." - (declare (indent 1) (debug t)) - `(let ((ebox--window-state-buffer ,buffer)) - (if (eq ebox-buffer--window-state-preserved-buffer - ebox--window-state-buffer) - (progn ,@body) - (let* ((ebox--content-owner-runs-cache (make-hash-table :test #'equal)) - (ebox--window-states - (ebox--capture-buffer-window-state ebox--window-state-buffer)) - (ebox--buffer-point-anchor - (when (buffer-live-p ebox--window-state-buffer) - (with-current-buffer ebox--window-state-buffer - (ebox--line-anchor-at (point))))) - (ebox--selected-window (selected-window)) - (ebox--current-buffer (current-buffer))) - (let ((ebox-buffer--window-state-preserved-buffer - ebox--window-state-buffer)) - (unwind-protect - (progn ,@body) - (when (and (buffer-live-p ebox--window-state-buffer) - ebox--buffer-point-anchor) - (with-current-buffer ebox--window-state-buffer - (goto-char - (ebox--resolve-line-anchor ebox--buffer-point-anchor)))) - (ebox--restore-buffer-window-state - ebox--window-state-buffer ebox--window-states) - (when (window-live-p ebox--selected-window) - (select-window ebox--selected-window)) - (when (buffer-live-p ebox--current-buffer) - (set-buffer ebox--current-buffer)))))))) - -(defun ebox-buffer--native-root-patch-map-offset (offset patches) - "Map root-relative old OFFSET through native PATCHES." - (let ((delta 0) - mapped) - (catch 'mapped - (dolist (patch patches) - (let* ((old-start (plist-get patch :old-start)) - (old-end (plist-get patch :old-end)) - (new-start (plist-get patch :new-start)) - (new-end (plist-get patch :new-end))) - (cond - ((< offset old-start) - (setq mapped (+ offset delta)) - (throw 'mapped nil)) - ((<= offset old-end) - (setq mapped - (if (= offset old-end) - new-end - (+ new-start - (min (max 0 (- offset old-start)) - (- new-end new-start))))) - (throw 'mapped nil)) - (t - (setq delta (+ delta (- (- new-end new-start) - (- old-end old-start)))))))) - (setq mapped (+ offset delta))) - mapped)) - -(defun ebox-buffer--role-span-containing-position - (spans pos &optional left-edge-p) - "Return the indexed role span in SPANS containing POS. -When LEFT-EDGE-P is non-nil, allow POS at a span end." - (let ((index 0) - found) - (while (and spans (not found)) - (let* ((span (ebox-buffer--numeric-role-span (car spans))) - (start (car-safe span)) - (end (cdr-safe span))) - (when (and span - (or (and (<= start pos) (< pos end)) - (and left-edge-p (> pos start) (= pos end)))) - (setq found (list :run-index index - :start start - :end end - :offset (- pos start)))) - (setq spans (cdr spans) - index (1+ index)))) - found)) - -(defun ebox-buffer--native-root-patch-anchor (pos plan) - "Return a prepared native patch window anchor for POS." - (let* ((root-start (plist-get plan :start)) - (root-end (plist-get plan :end)) - (clamped (min (max pos root-start) root-end)) - (probe (if (and (= clamped root-end) (> clamped root-start)) - (1- clamped) - clamped)) - (left-edge-p (or (= clamped root-end) - (not (ebox--content-owner-ids-at clamped)))) - (table (ebox-buffer--region-role-span-table)) - owner owner-anchor) - (when table - (dolist (region-id (ebox--content-owner-ids-at probe)) - (unless owner-anchor - (when-let ((span-anchor - (ebox-buffer--role-span-containing-position - (gethash - (ebox-buffer--region-role-key region-id 'content-owner) - table) - clamped left-edge-p))) - (setq owner region-id - owner-anchor span-anchor))))) - (list :position clamped - :root-offset (- clamped root-start) - :owner owner - :run-index (plist-get owner-anchor :run-index) - :owner-offset (plist-get owner-anchor :offset)))) - -(defun ebox-buffer--resolve-native-root-patch-anchor (anchor plan) - "Resolve prepared native patch ANCHOR after PLAN has been published." - (let* ((root-start (plist-get plan :start)) - (target-count (plist-get plan :target-character-count)) - (owner (plist-get anchor :owner)) - (run-index (plist-get anchor :run-index)) - (owner-offset (plist-get anchor :owner-offset)) - (target-table (plist-get plan :prepared-role-table)) - (target-spans - (and owner (hash-table-p target-table) - (gethash - (ebox-buffer--region-role-key owner 'content-owner) - target-table))) - (target-span (and (natnump run-index) - (nth run-index target-spans)))) - (if-let ((span (and target-span - (ebox-buffer--numeric-role-span target-span)))) - (min (+ (car span) owner-offset) (cdr span)) - (+ root-start - (min (max 0 - (ebox-buffer--native-root-patch-map-offset - (plist-get anchor :root-offset) - (plist-get plan :patches))) - target-count))))) - -(defun ebox-buffer--capture-native-root-patch-window-state (buffer plan) - "Capture BUFFER point and live windows for native patch PLAN." - (when (buffer-live-p buffer) - (with-current-buffer buffer - (let ((capture - (lambda (window) - (list :window window - :point (ebox-buffer--native-root-patch-anchor - (window-point window) plan) - :start (ebox-buffer--native-root-patch-anchor - (window-start window) plan) - :hscroll (window-hscroll window))))) - (list :point (ebox-buffer--native-root-patch-anchor (point) plan) - :windows (mapcar capture - (get-buffer-window-list buffer nil t))))))) - -(defun ebox-buffer--restore-native-root-patch-window-state (buffer plan state) - "Restore native patch window STATE for BUFFER after PLAN publication." - (when (buffer-live-p buffer) - (with-current-buffer buffer - (goto-char - (ebox-buffer--resolve-native-root-patch-anchor - (plist-get state :point) plan)) - (dolist (window-state (plist-get state :windows)) - (let ((window (plist-get window-state :window))) - (when (and (window-live-p window) - (eq (window-buffer window) buffer)) - (set-window-start - window - (ebox-buffer--resolve-native-root-patch-anchor - (plist-get window-state :start) plan) - t) - (set-window-point - window - (ebox-buffer--resolve-native-root-patch-anchor - (plist-get window-state :point) plan)) - (set-window-hscroll window - (plist-get window-state :hscroll)))))))) - -(defun ebox-buffer--resolve-source-window-anchor (anchor) - "Resolve source-state ANCHOR after rollback." - (min (max (plist-get anchor :position) (point-min)) (point-max))) - -(defun ebox-buffer--restore-source-window-state (buffer state) - "Restore BUFFER window STATE in the original source buffer coordinates." - (when (buffer-live-p buffer) - (with-current-buffer buffer - (goto-char (ebox-buffer--resolve-source-window-anchor - (plist-get state :point))) - (dolist (window-state (plist-get state :windows)) - (let ((window (plist-get window-state :window))) - (when (and (window-live-p window) - (eq (window-buffer window) buffer)) - (set-window-start - window - (ebox-buffer--resolve-source-window-anchor - (plist-get window-state :start)) - t) - (set-window-point - window - (ebox-buffer--resolve-source-window-anchor - (plist-get window-state :point))) - (set-window-hscroll window - (plist-get window-state :hscroll)))))))) - -(defmacro ebox-buffer--with-native-root-patch-window-state - (buffer plan &rest body) - "Evaluate BODY while preserving BUFFER windows through native root PLAN." - (declare (indent 2) (debug t)) - `(let ((ebox-buffer--native-window-buffer ,buffer)) - (if (eq ebox-buffer--window-state-preserved-buffer - ebox-buffer--native-window-buffer) - (progn ,@body) - (let ((ebox-buffer--native-window-plan ,plan) - (ebox-buffer--selected-window (selected-window)) - (ebox-buffer--current-buffer (current-buffer)) - (ebox-buffer--native-window-completed-p nil)) - (let ((ebox-buffer--native-window-state - (ebox-buffer--capture-native-root-patch-window-state - ebox-buffer--native-window-buffer - ebox-buffer--native-window-plan)) - (ebox-buffer--window-state-preserved-buffer - ebox-buffer--native-window-buffer)) - (unwind-protect - (prog1 (progn ,@body) - (setq ebox-buffer--native-window-completed-p t)) - (if ebox-buffer--native-window-completed-p - (ebox-buffer--restore-native-root-patch-window-state - ebox-buffer--native-window-buffer - ebox-buffer--native-window-plan - ebox-buffer--native-window-state) - (ebox-buffer--restore-source-window-state - ebox-buffer--native-window-buffer - ebox-buffer--native-window-state)) - (when (window-live-p ebox-buffer--selected-window) - (select-window ebox-buffer--selected-window)) - (when (buffer-live-p ebox-buffer--current-buffer) - (set-buffer ebox-buffer--current-buffer)))))))) - -;;; Render Transaction (defvar ebox-render-gc-cons-threshold (* 128 1024 1024) "Temporary `gc-cons-threshold' used during Ebox render transactions. @@ -980,7 +214,7 @@ buffer-facing paint properties. Layout-only properties never pass through." '(:inverse-video t))) (defun ebox--pixel-border (pixel-width height &optional color) - "Generate a border line of PIXEL-WIDTH and HEIGHT." + "Generate a border line of PIXEL-WIDTH and HEIGHT using optional COLOR." (when (and pixel-width height (> pixel-width 0) (> height 0)) (let ((line (propertize " " 'face (ebox-buffer-side-border-face color) @@ -1139,8 +373,9 @@ current direct owner, so only a stack change can affect the next output run." "Return LINE with region ownership for REGION-ID at content index IDX. Preserve child `ebox-content' properties and add wrapper ownership through `ebox-content-owner'. If LINE has no child content region, use REGION-ID as -the line's direct `ebox-content' owner. When the current render context marks -LINE as a fresh private allocation, ownership may be added in place." +the line's direct `ebox-content' owner. Use FALLBACK when LINE is empty. When +the current render context marks LINE as a fresh private allocation, ownership +may be added in place." (let* ((empty-line-p (string-empty-p line)) (source (if empty-line-p fallback line)) (mutate-p (and ebox--propertize-private-content-line-p @@ -1170,2314 +405,7 @@ LINE as a fresh private allocation, ownership may be added in place." line)) line)) -;;; Span and Buffer Patch Execution - -(defun ebox--copy-string-text-properties-to-buffer (string start) - "Copy text properties from STRING to buffer text starting at START." - (dotimes (index (length string)) - (set-text-properties - (+ start index) (+ start index 1) - (text-properties-at index string)))) - -(defconst ebox-buffer--remove-face-key (make-symbol "ebox-remove-face-key") - "Sentinel used to remove a single face plist key.") - -(defun ebox-buffer--face-plist-remove (plist key) - "Return PLIST without KEY and its value." - (let (result) - (while plist - (let ((current-key (pop plist)) - (current-value (pop plist))) - (unless (eq current-key key) - (push current-key result) - (push current-value result)))) - (nreverse result))) - -(defun ebox-buffer--face-put (face key value) - "Return FACE with KEY set to VALUE, preserving nested face specs." - (cond - ((null face) - (unless (eq value ebox-buffer--remove-face-key) - (list key value))) - ((and (listp face) (keywordp (car face))) - (let ((face (copy-sequence face))) - (if (eq value ebox-buffer--remove-face-key) - (ebox-buffer--face-plist-remove face key) - (plist-put face key value)))) - ((listp face) - (delq nil - (mapcar (lambda (item) - (ebox-buffer--face-put item key value)) - face))) - (t - (list face (list key value))))) - -(defun ebox--patch-face-key-in-span (start end key value) - "Set FACE KEY to VALUE in START..END without touching other properties." - (let ((pos start)) - (while (< pos end) - (let* ((next (or (next-single-property-change pos 'face nil end) - end)) - (face (get-text-property pos 'face))) - (put-text-property pos next 'face - (ebox-buffer--face-put face key value)) - (setq pos next))))) - -(defun ebox--patch-horizontal-border-face-in-span (start end key value) - "Set horizontal border FACE KEY to VALUE in START..END. -Enabled horizontal borders are one visual surface over the whole span, so they -can be applied as one high-priority face spec. Removal still uses the precise -face-key patcher so raw face properties do not retain stale border keys." - (if (eq value ebox-buffer--remove-face-key) - (ebox--patch-face-key-in-span start end key value) - (add-face-text-property start end (list key value) nil))) - -(defconst ebox--paint-content-region-roles - '(content content-owner pt pb pl pr) - "Region roles whose faces receive content paint properties.") - -(defconst ebox--horizontal-border-anchor-roles - '(content content-owner pt pb pl pr bl br) - "Region roles that define the box edge excluding margins.") - -(defconst ebox-buffer--role-span-table-marker-key - (make-symbol "ebox-role-span-table-marker-backed") - "Internal hash key marking role span tables whose spans use markers.") - -(defconst ebox-buffer--role-span-table-template-key - (make-symbol "ebox-role-span-table-prepared-template") - "Internal hash key marking immutable prepared root role span tables.") - -(defun ebox-buffer--role-property (role) - "Return the text property that stores ROLE ownership." - (or (alist-get role ebox-region-types) - (error "Unknown region role: %S" role))) - -(defun ebox-buffer--region-role-key (region-id role) - "Return the runtime span index key for REGION-ID and ROLE." - (list region-id role)) - -(defun ebox-buffer--region-role-span-table () - "Return the current buffer's runtime region role span table." - (and (buffer-live-p (current-buffer)) - (ebox--buffer-region-role-span-table (current-buffer)))) - -(defun ebox-buffer--put-region-role-spans (region-id role spans) - "Store SPANS for REGION-ID and ROLE in the current buffer span index." - (when-let ((table (ebox-buffer--region-role-span-table))) - (when (gethash ebox-buffer--role-span-table-template-key table) - (ebox-buffer-materialize-region-role-spans (current-buffer)))) - (when-let ((table (ebox-buffer--region-role-span-table))) - (let ((key (ebox-buffer--region-role-key region-id role))) - (if spans - (puthash key - (mapcar (lambda (span) - (ebox-buffer--make-role-span - (car span) (cdr span))) - spans) - table) - (remhash key table))))) - -(defun ebox-buffer--scan-region-role-spans (region-id role) - "Scan current buffer for ROLE spans owned by REGION-ID." - (let ((prop (ebox-buffer--role-property role)) - spans) - (save-excursion - (goto-char (point-min)) - (while (< (point) (point-max)) - (when (equal (get-text-property (point) prop) region-id) - (let ((start (point)) - (end (next-single-property-change - (point) prop nil (point-max)))) - (push (cons start end) spans) - (goto-char end))) - (goto-char (next-single-property-change - (point) prop nil (point-max))))) - (nreverse spans))) - -(defun ebox-buffer--record-role-span (table region-id role start end) - "Record START..END for REGION-ID ROLE in TABLE." - (let ((key (ebox-buffer--region-role-key region-id role))) - (puthash key - (cons (ebox-buffer--make-role-span start end) - (gethash key table)) - table))) - -(defun ebox-buffer--make-role-span (start end) - "Return a marker-backed role span for START..END." - (cons (copy-marker start nil) - (copy-marker end t))) - -(defun ebox-buffer--role-keys-in-range (start end) - "Return distinct region-role keys touching START..END in the current buffer." - (let (keys) - (dolist (entry ebox-region-types) - (let ((role (car entry)) - (property (cdr entry)) - (position start)) - ;; Marker endpoints at either edge move during delete/insert even when - ;; their property byte is immediately outside the replaced interval. - (dolist (boundary (delq nil - (list (and (> start (point-min)) - (1- start)) - (and (< end (point-max)) end)))) - (when-let ((region-id - (get-text-property boundary property))) - (cl-pushnew (ebox-buffer--region-role-key region-id role) - keys :test #'equal))) - (while (< position end) - (when-let ((region-id (get-text-property position property))) - (cl-pushnew (ebox-buffer--region-role-key region-id role) - keys :test #'equal)) - (setq position - (or (next-single-property-change - position property nil end) - end))))) - keys)) - -(defun ebox-buffer--string-role-keys (string) - "Return distinct region-role keys present in STRING." - (let ((limit (length string)) - keys) - (dolist (entry ebox-region-types) - (let ((role (car entry)) - (property (cdr entry)) - (position 0)) - (while (< position limit) - (when-let ((region-id - (get-text-property position property string))) - (cl-pushnew (ebox-buffer--region-role-key region-id role) - keys :test #'equal)) - (setq position - (or (next-single-property-change - position property string limit) - limit))))) - keys)) - -(defun ebox-buffer--region-ids-in-range (start end) - "Return distinct Ebox region ids touching START..END in the current buffer." - (let ((position start) - ids) - (dolist (boundary (delq nil - (list (and (> start (point-min)) (1- start)) - (and (< end (point-max)) end)))) - (dolist (region-id (ebox-buffer--region-ids-at boundary)) - (cl-pushnew region-id ids :test #'equal))) - (while (< position end) - (dolist (region-id (ebox-buffer--region-ids-at position)) - (cl-pushnew region-id ids :test #'equal)) - (setq position (or (next-property-change position nil end) end))) - ids)) - -(defun ebox-buffer--region-ids-at (position) - "Return role and wrapper-owner region ids at buffer POSITION." - (let ((ids - (append - (get-text-property position 'ebox-content-owners) - (when-let ((owner - (get-text-property position 'ebox-content-owner))) - (list owner))))) - (dolist (entry ebox-region-types) - (when-let ((region-id (get-text-property position (cdr entry)))) - (cl-pushnew region-id ids :test #'equal))) - ids)) - -(defun ebox-buffer--string-all-region-ids (string) - "Return distinct Ebox role and wrapper-owner ids present in STRING." - (let ((position 0) - (limit (length string)) - ids) - (while (< position limit) - (dolist (region-id - (append - (get-text-property position 'ebox-content-owners string) - (when-let ((owner - (get-text-property - position 'ebox-content-owner string))) - (list owner)))) - (cl-pushnew region-id ids :test #'equal)) - (dolist (entry ebox-region-types) - (when-let ((region-id - (get-text-property position (cdr entry) string))) - (cl-pushnew region-id ids :test #'equal))) - (setq position (or (next-property-change position string limit) - limit))) - ids)) - -(defun ebox-buffer--replacement-region-ids (spans rendered) - "Return region ids affected by replacing SPANS with RENDERED." - (let ((region-ids (ebox-buffer--string-all-region-ids rendered))) - (dolist (span spans) - (dolist (region-id - (ebox-buffer--region-ids-in-range (car span) (cdr span))) - (cl-pushnew region-id region-ids :test #'equal))) - region-ids)) - -(defun ebox-buffer--range-text-equal-string-p (start end string) - "Return non-nil when START..END has the same characters as STRING." - (and (= (- end start) (length string)) - (let ((position start) - (index 0) - (equal-p t)) - (while (and equal-p (< position end)) - (unless (= (char-after position) (aref string index)) - (setq equal-p nil)) - (setq position (1+ position) - index (1+ index))) - equal-p))) - -(defun ebox-buffer--range-properties-equal-string-p - (start end string properties) - "Return non-nil when PROPERTIES match between START..END and STRING." - (and (= (- end start) (length string)) - (let ((offset 0) - (limit (length string)) - (equal-p t)) - (while (and equal-p (< offset limit)) - (dolist (property properties) - (unless (equal (get-text-property (+ start offset) property) - (get-text-property offset property string)) - (setq equal-p nil))) - (setq offset (1+ offset))) - equal-p))) - -(defun ebox-buffer--in-place-property-patch-p (root-start patch) - "Return non-nil when PATCH can update properties without replacing text." - (let ((old-start (+ root-start (plist-get patch :old-start))) - (old-end (+ root-start (plist-get patch :old-end))) - (replacement (plist-get patch :replacement))) - (ebox-buffer--range-text-equal-string-p old-start old-end replacement))) - -(defun ebox-buffer--role-neutral-property-patch-p (root-start patch) - "Return non-nil when in-place PATCH preserves every runtime owner role." - (let ((old-start (+ root-start (plist-get patch :old-start))) - (old-end (+ root-start (plist-get patch :old-end))) - (replacement (plist-get patch :replacement))) - (and (ebox-buffer--range-text-equal-string-p - old-start old-end replacement) - (ebox-buffer--range-properties-equal-string-p - old-start old-end replacement - (delete-dups - (append (mapcar #'cdr ebox-region-types) - '(ebox-content-owners))))))) - -(defun ebox-buffer--apply-string-properties-in-place (start string) - "Replace only text properties at START with those carried by STRING." - (let ((position 0) - (limit (length string))) - (while (< position limit) - (let ((next (or (next-property-change position string limit) limit))) - (set-text-properties - (+ start position) (+ start next) - (text-properties-at position string)) - (setq position next))))) - -(defun ebox-buffer--role-context-in-range (start end &optional string) - "Return role keys in START..END of the current buffer or STRING. -Buffer ranges include their immediate boundaries because delete/insert marker -motion can affect a role span whose property byte is just outside the patch." - (let ((positions nil) - keys) - (unless string - (when (> start (point-min)) - (push (1- start) positions)) - (when (< end (point-max)) - (push end positions))) - (let ((position start)) - (while (< position end) - (push position positions) - (setq position - (or (next-property-change position string end) end)))) - (dolist (position positions) - (dolist (entry ebox-region-types) - (when-let ((region-id - (get-text-property position (cdr entry) string))) - (cl-pushnew - (ebox-buffer--region-role-key region-id (car entry)) - keys :test #'equal)))) - keys)) - -(defun ebox-buffer--numeric-span-touches-range-p (span range) - "Return non-nil when numeric SPAN touches numeric RANGE." - (and (<= (car span) (cdr range)) - (>= (cdr span) (car range)))) - -(defun ebox-buffer--numeric-role-span (span) - "Return marker-backed or integer SPAN as an integer pair." - (let ((start (if (markerp (car span)) - (marker-position (car span)) - (car span))) - (end (if (markerp (cdr span)) - (marker-position (cdr span)) - (cdr span)))) - (and start end (< start end) (cons start end)))) - -(defun ebox-buffer--copy-frozen-region-role-span-table (table) - "Return an independent integer-coordinate copy of role span TABLE." - (when (hash-table-p table) - (let ((copy (make-hash-table :test 'equal))) - (maphash - (lambda (key spans) - (when (consp key) - (puthash key - (delq nil - (mapcar (lambda (span) - (when-let ((numeric - (ebox-buffer--numeric-role-span - span))) - (copy-tree numeric))) - spans)) - copy))) - table) - copy))) - -(defun ebox-buffer--capture-role-delta (root-start patches) - "Capture the role-index delta for PATCHES rooted at ROOT-START. -Only keys touching replaced bytes or insertion boundaries are retained." - (when-let ((table (ebox-buffer--region-role-span-table))) - (when (gethash ebox-buffer--role-span-table-template-key table) - (ebox-buffer-materialize-region-role-spans (current-buffer)) - (setq table (ebox-buffer--region-role-span-table))) - (when (ebox-buffer--marker-role-span-table-p table) - (let ((entries-by-key (make-hash-table :test 'equal))) - (dolist (patch patches) - ;; Display and face changes dominate resize animation tapes. When - ;; their characters, role owners, and coordinates are unchanged, - ;; the existing marker index is already the exact target index. - (if (ebox-buffer--role-neutral-property-patch-p root-start patch) - nil - (let* ((old-start (+ root-start (plist-get patch :old-start))) - (old-end (+ root-start (plist-get patch :old-end))) - (new-start (+ root-start (plist-get patch :new-start))) - (new-end (+ root-start (plist-get patch :new-end))) - (replacement (plist-get patch :replacement)) - (old-context - (ebox-buffer--role-context-in-range old-start old-end)) - (new-context - (ebox-buffer--role-context-in-range - 0 (length replacement) replacement)) - (keys - (delete-dups - (append old-context new-context))) - (old-range (cons old-start old-end)) - (new-range (cons new-start new-end))) - (dolist (key keys) - (let ((entry (gethash key entries-by-key))) - (unless entry - (setq entry (list :key key))) - (plist-put entry :old-ranges - (cons old-range - (plist-get entry :old-ranges))) - (plist-put entry :new-ranges - (cons new-range - (plist-get entry :new-ranges))) - (puthash key entry entries-by-key)))))) - (let (entries) - (maphash - (lambda (key entry) - (let* ((old-ranges - (ebox-buffer--merge-numeric-spans - (plist-get entry :old-ranges))) - (old-spans - (cl-remove-if-not - (lambda (span) - (when-let ((numeric - (ebox-buffer--numeric-role-span span))) - (cl-some - (lambda (range) - (ebox-buffer--numeric-span-touches-range-p - numeric range)) - old-ranges))) - (gethash key table)))) - (plist-put entry :old-spans old-spans) - (plist-put entry :new-ranges - (ebox-buffer--merge-numeric-spans - (plist-get entry :new-ranges))) - (push entry entries))) - entries-by-key) - (list :entries entries)))))) - -(defun ebox-buffer--role-span-outside-replacements (span replacements) - "Translate the parts of old SPAN outside REPLACEMENTS to new coordinates." - (let ((cursor (car span)) - (end (cdr span)) - (delta 0) - translated) - (catch 'done - (dolist (replacement replacements) - (let ((old-start (plist-get replacement :old-start)) - (old-end (plist-get replacement :old-end)) - (replacement-delta (plist-get replacement :delta))) - (cond - ((<= old-end cursor) - (setq delta (+ delta replacement-delta))) - ((>= old-start end) - (throw 'done nil)) - (t - (let ((left-end (min end old-start))) - (when (< cursor left-end) - (push (cons (+ cursor delta) (+ left-end delta)) - translated))) - (setq cursor (max cursor (min end old-end)) - delta (+ delta replacement-delta))))))) - (when (< cursor end) - (push (cons (+ cursor delta) (+ end delta)) translated)) - (nreverse translated))) - -(defun ebox-buffer--role-spans-for-key-in-ranges (key ranges) - "Return exact current spans for role KEY inside buffer RANGES." - (let ((region-id (car key)) - (property (ebox-buffer--role-property (cadr key))) - spans) - (dolist (range ranges) - (let ((position (car range)) - (end (cdr range))) - (while (< position end) - (let ((next (or (next-single-property-change - position property nil end) - end))) - (when (equal (get-text-property position property) region-id) - (push (cons position next) spans)) - (setq position next))))) - spans)) - -(defun ebox-buffer--merge-numeric-spans (spans) - "Return sorted, coalesced numeric SPANS." - (let (merged) - (dolist (span (sort (delq nil spans) - (lambda (left right) - (< (car left) (car right))))) - (if (and merged (<= (car span) (cdar merged))) - (setcdr (car merged) (max (cdar merged) (cdr span))) - (push (copy-tree span) merged))) - (nreverse merged))) - -(defun ebox-buffer--detach-role-spans (spans) - "Detach marker endpoints in SPANS." - (dolist (span spans) - (when (markerp (car span)) - (set-marker (car span) nil)) - (when (markerp (cdr span)) - (set-marker (cdr span) nil)))) - -(defun ebox-buffer--detach-region-role-span-table (buffer) - "Detach BUFFER's marker-backed role table before replacing it." - (when (buffer-live-p buffer) - (with-current-buffer buffer - (when-let ((table (ebox-buffer--region-role-span-table))) - (when (ebox-buffer--marker-role-span-table-p table) - (maphash - (lambda (key spans) - (when (consp key) - (ebox-buffer--detach-role-spans spans))) - table) - (ebox--set-buffer-region-role-span-table buffer nil) - t))))) - -(defun ebox-buffer--detach-role-span-table-markers (table) - "Detach marker endpoints in marker-backed role span TABLE." - (when (and (hash-table-p table) - (ebox-buffer--marker-role-span-table-p table)) - (maphash - (lambda (key spans) - (when (consp key) - (ebox-buffer--detach-role-spans spans))) - table))) - -(defun ebox-buffer--merge-role-markers-with-numeric-spans - (marker-spans numeric-spans) - "Merge exact NUMERIC-SPANS into sorted existing MARKER-SPANS. -Markers outside the changed ranges are retained rather than recreated." - (let ((result (copy-sequence marker-spans))) - (dolist (numeric (ebox-buffer--merge-numeric-spans numeric-spans)) - (let (touching untouched) - (dolist (span result) - (if (ebox-buffer--numeric-span-touches-range-p - (cons (marker-position (car span)) - (marker-position (cdr span))) - numeric) - (push span touching) - (push span untouched))) - (if touching - (let* ((touching (nreverse touching)) - (primary (car touching)) - (start - (min (car numeric) - (cl-loop for span in touching - minimize (marker-position (car span))))) - (end - (max (cdr numeric) - (cl-loop for span in touching - maximize (marker-position (cdr span)))))) - (set-marker (car primary) start) - (set-marker (cdr primary) end) - (ebox-buffer--detach-role-spans (cdr touching)) - (setq result (cons primary untouched))) - (setq result - (cons (ebox-buffer--make-role-span - (car numeric) (cdr numeric)) - untouched))))) - (sort result - (lambda (left right) - (< (marker-position (car left)) - (marker-position (car right))))))) - -(defun ebox-buffer-apply-role-delta (buffer delta) - "Apply captured role-index DELTA after exact root patches changed BUFFER." - (when (and (buffer-live-p buffer) delta) - (with-current-buffer buffer - (when-let ((table (ebox-buffer--region-role-span-table))) - (dolist (entry (plist-get delta :entries)) - (let* ((key (plist-get entry :key)) - (old-spans (plist-get entry :old-spans)) - (current-spans (gethash key table)) - (preserved-spans - (cl-remove-if - (lambda (span) (memq span old-spans)) - current-spans)) - (dirty-ranges - (append - (plist-get entry :new-ranges) - (delq nil - (mapcar #'ebox-buffer--numeric-role-span - old-spans)))) - (replacement-spans - (ebox-buffer--role-spans-for-key-in-ranges - key (ebox-buffer--merge-numeric-spans dirty-ranges))) - (merged - (ebox-buffer--merge-role-markers-with-numeric-spans - preserved-spans replacement-spans))) - (ebox-buffer--detach-role-spans old-spans) - (if merged - (puthash key - merged - table) - (remhash key table)))) - t)))) - -(defun ebox-buffer--current-region-role-span-p (span region-id role) - "Return non-nil when SPAN still carries REGION-ID for ROLE. -Marker survival alone is insufficient: scroll-window replacement can leave a -nonempty marker range at coordinates now owned by a different region." - (let* ((start (car-safe span)) - (end (cdr-safe span)) - (start-pos (if (markerp start) (marker-position start) start)) - (end-pos (if (markerp end) (marker-position end) end))) - (and (number-or-marker-p start) - (number-or-marker-p end) - (or (not (markerp start)) - (eq (marker-buffer start) (current-buffer))) - (or (not (markerp end)) - (eq (marker-buffer end) (current-buffer))) - start-pos end-pos (< start-pos end-pos) - (not (text-property-not-all - start-pos end-pos - (ebox-buffer--role-property role) region-id))))) - -(defun ebox-buffer--marker-role-span-table-p (table) - "Return non-nil when TABLE stores marker-backed role spans." - (gethash ebox-buffer--role-span-table-marker-key table)) - -(defun ebox-buffer--build-region-role-span-table () - "Build a role span index for the current buffer." - (let ((table (make-hash-table :test 'equal))) - (puthash ebox-buffer--role-span-table-marker-key t table) - (dolist (entry ebox-region-types) - (let ((role (car entry)) - (prop (cdr entry))) - (save-excursion - (goto-char (point-min)) - (while (< (point) (point-max)) - (let* ((region-id (get-text-property (point) prop)) - (next (or (next-single-property-change - (point) prop nil (point-max)) - (point-max)))) - (when region-id - (ebox-buffer--record-role-span - table region-id role (point) next)) - (goto-char (max next (1+ (point))))))))) - (maphash (lambda (key spans) - (when (consp key) - (puthash key (nreverse spans) table))) - table) - table)) - -(defvar ebox-buffer--region-property-role-table-cache nil - "Cached property-to-role lookup for `ebox-region-types'.") - -(defvar ebox-buffer--region-property-role-table-source nil - "The `ebox-region-types' object used to build the role lookup cache.") - -(defun ebox-buffer--region-property-role-table () - "Return an eq table mapping Ebox text properties to region roles." - (unless (and ebox-buffer--region-property-role-table-cache - (eq ebox-buffer--region-property-role-table-source - ebox-region-types)) - (let ((table (make-hash-table :test 'eq))) - (dolist (entry ebox-region-types) - (puthash (cdr entry) (car entry) table)) - (setq ebox-buffer--region-property-role-table-cache table - ebox-buffer--region-property-role-table-source - ebox-region-types))) - ebox-buffer--region-property-role-table-cache) - -(defun ebox-buffer--build-region-role-span-template (string) - "Build an immutable point-min-based role span template from STRING." - (let ((table (make-hash-table :test 'equal)) - (property-roles (ebox-buffer--region-property-role-table)) - (limit (length string)) - (position 0)) - (puthash ebox-buffer--role-span-table-template-key t table) - ;; Role properties share the same interval tree. Walking each role over - ;; the whole output multiplies root-prewarm work by `ebox-region-types'. - ;; Visit every text-property interval once, then coalesce boundaries caused - ;; only by unrelated properties back into the role's maximal span. - (while (< position limit) - (let* ((properties (text-properties-at position string)) - (next (or (next-property-change position string limit) limit)) - (start (1+ position)) - (end (1+ next))) - (while properties - (let* ((property (pop properties)) - (region-id (pop properties)) - (role (gethash property property-roles))) - (when (and role region-id) - (let* ((key (ebox-buffer--region-role-key region-id role)) - (spans (gethash key table))) - (if (and spans (= (cdar spans) start)) - (setcdr (car spans) end) - (puthash key (cons (cons start end) spans) table)))))) - (setq position (max next (1+ position))))) - (maphash (lambda (key spans) - (when (consp key) - (puthash key (nreverse spans) table))) - table) - table)) - -(defun ebox-buffer--install-region-role-span-template - (buffer template start) - "Install prepared TEMPLATE in BUFFER at root START." - (when (and (buffer-live-p buffer) (hash-table-p template)) - (with-current-buffer buffer - (ebox-buffer--detach-region-role-span-table buffer) - (let ((table - (if (= start (point-min)) - (progn - (puthash ebox-buffer--role-span-table-template-key - t template) - template) - (let ((translated (make-hash-table :test 'equal)) - (delta (- start (point-min)))) - (puthash ebox-buffer--role-span-table-template-key - t translated) - (maphash - (lambda (key spans) - (when (consp key) - (puthash - key - (mapcar (lambda (span) - (cons (+ delta (car span)) - (+ delta (cdr span)))) - spans) - translated))) - template) - translated)))) - (ebox--set-buffer-region-role-span-table buffer table) - table)))) - -(defun ebox-buffer--region-role-span-table-from-template (template start) - "Return a buffer-coordinate role span table from TEMPLATE at START." - (when (hash-table-p template) - (if (= start (point-min)) - (let ((table (copy-hash-table template))) - (puthash ebox-buffer--role-span-table-template-key t table) - table) - (let ((translated (make-hash-table :test 'equal)) - (delta (- start (point-min)))) - (puthash ebox-buffer--role-span-table-template-key t translated) - (maphash - (lambda (key spans) - (when (consp key) - (puthash - key - (mapcar (lambda (span) - (cons (+ delta (car span)) - (+ delta (cdr span)))) - spans) - translated))) - template) - translated)))) - -(defun ebox-buffer--target-span-valid-p (span start end) - "Return non-nil when SPAN is an integer range inside START..END." - (and (consp span) - (integerp (car span)) - (integerp (cdr span)) - (<= start (car span)) - (< (car span) (cdr span)) - (<= (cdr span) end))) - -(defun ebox-buffer--validate-role-span-template (table start target-length) - "Validate translated role span TABLE inside START plus TARGET-LENGTH." - (let ((end (+ start target-length))) - (unless (hash-table-p table) - (error "Native root patch has invalid role indexes")) - (maphash - (lambda (key spans) - (when (consp key) - (dolist (span spans) - (unless (ebox-buffer--target-span-valid-p span start end) - (error "Native root patch has invalid role index span"))))) - table) - t)) - -(defun ebox-buffer--validate-box-extent-template - (template start target-length) - "Return validated buffer-coordinate extents for target TEMPLATE." - (let ((end (+ start target-length)) - (delta (- start (point-min))) - (translated-table (make-hash-table :test 'equal))) - (unless (hash-table-p template) - (error "Native root patch has invalid extent indexes")) - (maphash - (lambda (region-id span) - (let ((translated (cons (+ delta (car span)) - (+ delta (cdr span))))) - (unless (ebox-buffer--target-span-valid-p translated start end) - (error "Native root patch has invalid extent index span")) - (puthash region-id translated translated-table))) - template) - translated-table)) - -(defun ebox-buffer-materialize-region-role-spans (buffer) - "Convert BUFFER's current integer role span table to marker-backed spans." - (when (buffer-live-p buffer) - (with-current-buffer buffer - (when-let ((current (ebox-buffer--region-role-span-table))) - (unless (ebox-buffer--marker-role-span-table-p current) - (let ((table (make-hash-table :test 'equal))) - (puthash ebox-buffer--role-span-table-marker-key t table) - (maphash - (lambda (key spans) - (when (consp key) - (puthash key - (mapcar (lambda (span) - (ebox-buffer--make-role-span - (car span) (cdr span))) - spans) - table))) - current) - (ebox--set-buffer-region-role-span-table buffer table))) - (ebox-buffer--region-role-span-table))))) - -(defun ebox-buffer-refresh-region-role-spans (buffer) - "Refresh BUFFER's region role span index from current text properties." - (when (buffer-live-p buffer) - (with-current-buffer buffer - (ebox--set-buffer-region-role-span-table - buffer (ebox-buffer--build-region-role-span-table))))) - -(defun ebox-buffer--span-overlaps-p (left right) - "Return non-nil when LEFT and RIGHT buffer spans overlap." - (and (< (car left) (cdr right)) - (> (cdr left) (car right)))) - -(defun ebox-buffer--role-span-replacements (old-spans new-spans) - "Return OLD-SPANS to NEW-SPANS replacement maps, or nil if ambiguous." - (when (= (length old-spans) (length new-spans)) - (cl-loop for old in old-spans - for new in new-spans - collect (list :old-start (car old) - :old-end (cdr old) - :new-start (car new) - :new-end (cdr new) - :delta (- (- (cdr new) (car new)) - (- (cdr old) (car old))))))) - -(defun ebox-buffer--role-span-replacement-index (replacements) - "Return a binary-search index with cumulative deltas for REPLACEMENTS." - (let ((delta 0)) - (vconcat - (mapcar - (lambda (replacement) - (cl-incf delta (plist-get replacement :delta)) - (vector (plist-get replacement :old-start) - (plist-get replacement :old-end) - delta)) - replacements)))) - -(defun ebox-buffer--translate-position-through-replacement-index - (pos index &optional end-boundary-p) - "Translate old POS through replacement INDEX, or return nil if ambiguous." - (let ((low 0) - (high (length index))) - (while (< low high) - (let* ((middle (/ (+ low high) 2)) - (old-start (aref (aref index middle) 0))) - (if (if end-boundary-p (< old-start pos) (<= old-start pos)) - (setq low (1+ middle)) - (setq high middle)))) - (if (zerop low) - pos - (let ((replacement (aref index (1- low)))) - (unless (if end-boundary-p - (<= pos (aref replacement 1)) - (< pos (aref replacement 1))) - (+ pos (aref replacement 2))))))) - -(defun ebox-buffer--translate-role-span-through-replacements - (span replacements &optional replacement-index) - "Translate SPAN through REPLACEMENTS, or return nil if ambiguous." - (let* ((index (or replacement-index - (ebox-buffer--role-span-replacement-index replacements))) - (start (ebox-buffer--translate-position-through-replacement-index - (car span) index)) - (end (ebox-buffer--translate-position-through-replacement-index - (cdr span) index t))) - (and start end (<= start end) (cons start end)))) - -(defun ebox-buffer--translate-region-role-span-table - (table region-ids old-spans new-spans) - "Translate untouched TABLE entries after replacing OLD-SPANS with NEW-SPANS. -Return non-nil when the existing table remains valid after translation. -Return nil when an untouched role span crosses replaced text; callers must -discard the table so later lookups rescan current text properties." - (let ((replacements (ebox-buffer--role-span-replacements - old-spans new-spans))) - (and replacements - (let ((region-set (ebox--region-id-set region-ids)) - (replacement-index - (ebox-buffer--role-span-replacement-index replacements))) - (catch 'unsafe - (maphash - (lambda (key spans) - (when (and (consp key) - (not (gethash (car key) region-set))) - (let (translated) - (dolist (span spans) - (if-let ((new-span - (ebox-buffer--translate-role-span-through-replacements - span replacements replacement-index))) - (push new-span translated) - (throw 'unsafe nil))) - (puthash key (nreverse translated) table)))) - table) - t))))) - -(defun ebox-buffer--same-role-span-bounds-p (old-spans new-spans) - "Return non-nil when OLD-SPANS and NEW-SPANS occupy the same coordinates." - (and (= (length old-spans) (length new-spans)) - (cl-every - (lambda (pair) - (let ((old (car pair)) - (new (cdr pair))) - (and (= (car old) (car new)) - (= (cdr old) (cdr new))))) - (cl-mapcar #'cons old-spans new-spans)))) - -(defun ebox-buffer--rendered-line-bounds (rendered) - "Return 0-based content bounds for each line in RENDERED." - (let ((start 0) - bounds) - (while (<= start (length rendered)) - (let ((newline (string-match-p "\n" rendered start))) - (push (cons start (or newline (length rendered))) bounds) - (if newline - (setq start (1+ newline)) - (setq start (1+ (length rendered)))))) - (vconcat (nreverse bounds)))) - -(defun ebox-buffer--rendered-line-index-at (line-bounds position) - "Return the LINE-BOUNDS index containing rendered POSITION." - (let ((index 0) - found) - (while (and (< index (length line-bounds)) (not found)) - (let ((bounds (aref line-bounds index))) - (if (and (<= (car bounds) position) - (< position (cdr bounds))) - (setq found index) - (setq index (1+ index))))) - found)) - -(defun ebox-buffer--numeric-span-start (span) - "Return SPAN's numeric start coordinate." - (let ((start (car-safe span))) - (if (markerp start) (marker-position start) start))) - -(defun ebox-buffer--record-active-extent-segment - (segments-by-region region-set region-id start end) - "Record REGION-ID START..END in SEGMENTS-BY-REGION when active." - (when (and segments-by-region - (gethash region-id region-set) - (< start end)) - (puthash region-id - (cons (cons start end) - (gethash region-id segments-by-region)) - segments-by-region))) - -(defun ebox-buffer--record-rendered-active-extent-segments - (segments-by-region region-set owners line-bounds spans start end) - "Record active extent OWNERS from rendered START..END in buffer SPANS." - (when (and segments-by-region owners) - (let ((segment-start start)) - (while (< segment-start end) - (if-let* ((line-index - (ebox-buffer--rendered-line-index-at - line-bounds segment-start)) - ((< line-index (length spans))) - (line-bounds-entry - (aref line-bounds line-index)) - (span-start - (ebox-buffer--numeric-span-start - (nth line-index spans)))) - (let* ((segment-end (min end (cdr line-bounds-entry))) - (buffer-start - (+ span-start (- segment-start - (car line-bounds-entry)))) - (buffer-end - (+ span-start (- segment-end - (car line-bounds-entry))))) - (dolist (owner owners) - (ebox-buffer--record-active-extent-segment - segments-by-region region-set owner - buffer-start buffer-end)) - (setq segment-start - (if (< segment-end end) - (1+ segment-end) - segment-end))) - (setq segment-start end)))))) - -(defun ebox-buffer--record-rendered-role-spans - (table region-set rendered spans &optional touched-keys active-segments) - "Record role spans from RENDERED into TABLE translated through SPANS." - (let ((property-roles (ebox-buffer--region-property-role-table)) - (line-bounds (ebox-buffer--rendered-line-bounds rendered)) - (limit (length rendered)) - (position 0)) - (while (< position limit) - (let* ((properties (text-properties-at position rendered)) - (next (or (next-property-change position rendered limit) - limit))) - (ebox-buffer--record-rendered-active-extent-segments - active-segments region-set - (get-text-property position 'ebox-content-owners rendered) - line-bounds spans position next) - (while properties - (let* ((property (pop properties)) - (region-id (pop properties)) - (role (gethash property property-roles))) - (when (and role region-id (gethash region-id region-set)) - (let ((segment-start position)) - (while (< segment-start next) - (if-let* ((line-index - (ebox-buffer--rendered-line-index-at - line-bounds segment-start)) - ((< line-index (length spans))) - (line-bounds-entry - (aref line-bounds line-index)) - (span-start - (ebox-buffer--numeric-span-start - (nth line-index spans)))) - (let* ((segment-end - (min next (cdr line-bounds-entry))) - (buffer-start - (+ span-start - (- segment-start - (car line-bounds-entry)))) - (buffer-end - (+ span-start - (- segment-end - (car line-bounds-entry))))) - (when (and role region-id - (gethash region-id region-set)) - (ebox-buffer--record-role-span - table region-id role buffer-start buffer-end)) - (unless (eq role 'content-owner) - (ebox-buffer--record-active-extent-segment - active-segments region-set region-id - buffer-start buffer-end)) - (when (and role region-id touched-keys) - (puthash - (ebox-buffer--region-role-key region-id role) - t touched-keys)) - (setq segment-start - (if (< segment-end next) - (1+ segment-end) - segment-end))) - (setq segment-start next))))))) - (setq position (max next (1+ position))))) - t)) - -(defun ebox-buffer-refresh-region-role-spans-for-region-ids - (buffer region-ids spans &optional old-spans rendered-spans) - "Refresh role span table entries for REGION-IDS inside SPANS in BUFFER. -When OLD-SPANS is non-nil, translate untouched integer span coordinates from -OLD-SPANS to SPANS before rebuilding REGION-IDS entries. RENDERED-SPANS, -when non-nil, is a list of (RENDERED . SPANS) pairs used to rebuild touched -entries without rescanning BUFFER text properties." - (when (buffer-live-p buffer) - (with-current-buffer buffer - (when-let ((table (ebox-buffer--region-role-span-table))) - (if (and old-spans - (not (ebox-buffer--same-role-span-bounds-p - old-spans spans)) - (not (ebox-buffer--marker-role-span-table-p table)) - (not (ebox-buffer--translate-region-role-span-table - table region-ids old-spans spans))) - (ebox--set-buffer-region-role-span-table - buffer (ebox-buffer--build-region-role-span-table)) - (let ((region-set (ebox--region-id-set region-ids)) - (touched-keys (make-hash-table :test 'equal)) - (active-segments (make-hash-table :test 'equal))) - (dolist (region-id region-ids) - (dolist (entry ebox-region-types) - (remhash - (ebox-buffer--region-role-key region-id (car entry)) - table))) - (if rendered-spans - (dolist (entry rendered-spans) - (ebox-buffer--record-rendered-role-spans - table region-set (car entry) (cdr entry) - touched-keys active-segments)) - (dolist (span spans) - (dolist (entry ebox-region-types) - (let ((role (car entry)) - (prop (cdr entry)) - (pos (car span))) - (while (< pos (cdr span)) - (let* ((region-id (get-text-property pos prop)) - (next (or (next-single-property-change - pos prop nil (cdr span)) - (cdr span)))) - (when (gethash region-id region-set) - (let ((key (ebox-buffer--region-role-key - region-id role))) - (puthash key t touched-keys) - (ebox-buffer--record-role-span - table region-id role pos next) - (unless (eq role 'content-owner) - (ebox-buffer--record-active-extent-segment - active-segments region-set - region-id pos next)))) - (setq pos (max next (1+ pos)))))))) - (dolist (span spans) - (let ((pos (car span))) - (while (< pos (cdr span)) - (let* ((owners (get-text-property - pos 'ebox-content-owners)) - (next (or (next-single-property-change - pos 'ebox-content-owners nil - (cdr span)) - (cdr span)))) - (dolist (owner owners) - (ebox-buffer--record-active-extent-segment - active-segments region-set owner pos next)) - (setq pos (max next (1+ pos)))))))) - (maphash - (lambda (key _) - (when-let ((spans (gethash key table))) - (puthash key - (sort spans (lambda (left right) - (< (car left) (car right)))) - table))) - touched-keys) - (list :active-segments active-segments))))))) - -(defun ebox-buffer--horizontal-border-anchor-spans (region-id role) - "Infer a top or bottom border span for REGION-ID from existing box anchors." - (let ((line-ranges (make-hash-table :test 'eql)) - line-keys) - (save-excursion - (dolist (anchor-role ebox--horizontal-border-anchor-roles) - (dolist (span (ebox--region-find region-id anchor-role)) - (goto-char (car span)) - (let* ((line-start (line-beginning-position)) - (range (gethash line-start line-ranges))) - (if range - (progn - (setcar range (min (car range) (car span))) - (setcdr range (max (cdr range) (cdr span)))) - (push line-start line-keys) - (puthash line-start (copy-tree span) line-ranges)))))) - (when line-keys - (let* ((line (if (eq role 'bt) - (apply #'min line-keys) - (apply #'max line-keys))) - (span (gethash line line-ranges))) - (and span (list span)))))) - -(defun ebox-buffer--horizontal-border-face-value (box role) - "Return the face value for BOX horizontal border ROLE." - (pcase role - ('bt - (or (ebox-get box :border-top-color) t)) - ('bb - (let ((color (ebox-get box :border-bottom-color))) - (if color - (list :position t :color color) - (list :position t)))))) - -(defun ebox-buffer--horizontal-border-enabled-p (box role) - "Return non-nil when BOX should display horizontal border ROLE." - (pcase role - ('bt (ebox-get box :border-top-p)) - ('bb (ebox-get box :border-bottom-p)))) - -(defun ebox--paint-horizontal-border-surface (box role) - "Return a paint-surface spec for BOX horizontal border ROLE." - (let ((enabled (ebox-buffer--horizontal-border-enabled-p box role))) - (list :role role - :face-key (if (eq role 'bt) :overline :underline) - :face-value (if enabled - (ebox-buffer--horizontal-border-face-value box role) - ebox-buffer--remove-face-key) - :missing (if enabled 'infer 'noop) - :remove-role (not enabled)))) - -(defun ebox-buffer--paint-side-border-surfaces (box role color-key) - "Return paint specs for BOX side ROLE using COLOR-KEY." - (let ((color (ebox-get box color-key))) - (list - (list :role role - :face-key :background - :face-value (or color ebox-buffer--remove-face-key) - :missing 'noop - :own-region-only t) - (list :role role - :face-key :inverse-video - :face-value (if color ebox-buffer--remove-face-key t) - :missing 'noop - :own-region-only t)))) - -(defun ebox--paint-text-property-patches (box changed-key) - "Return text-property patch specs for BOX CHANGED-KEY, or nil. -Each result is a backend paint-surface plist." - (pcase changed-key - (:color - (mapcar (lambda (role) - (list :role role - :face-key :foreground - :face-value (or (ebox-get box :color) - ebox-buffer--remove-face-key) - :missing 'noop - :paint-prop :color)) - ebox--paint-content-region-roles)) - (:bgcolor - (mapcar (lambda (role) - (list :role role - :face-key :background - :face-value (or (ebox-get box :bgcolor) - ebox-buffer--remove-face-key) - :missing 'noop - :paint-prop :bgcolor)) - ebox--paint-content-region-roles)) - (:border-left-color - (ebox-buffer--paint-side-border-surfaces - box 'bl :border-left-color)) - (:border-right-color - (ebox-buffer--paint-side-border-surfaces - box 'br :border-right-color)) - (:border-top-color - (list (append (ebox--paint-horizontal-border-surface box 'bt) - '(:own-region-only t)))) - (:border-bottom-color - (list (append (ebox--paint-horizontal-border-surface box 'bb) - '(:own-region-only t)))) - (:border-top-p - (list (append (ebox--paint-horizontal-border-surface box 'bt) - '(:own-region-only t)))) - (:border-bottom-p - (list (append (ebox--paint-horizontal-border-surface box 'bb) - '(:own-region-only t)))) - (_ nil))) - -(defun ebox-buffer--paint-surface-key (spec) - "Return the deduplication key for paint-surface SPEC." - (list (plist-get spec :role) - (plist-get spec :face-key))) - -(defun ebox--paint-text-property-surfaces (box changed-keys) - "Return deduplicated paint surfaces for BOX CHANGED-KEYS. -Return nil when any changed key is not a direct text-property paint surface." - (let ((table (make-hash-table :test 'equal)) - keys - supported) - (setq supported t) - (dolist (changed-key changed-keys) - (if-let ((specs (ebox--paint-text-property-patches - box changed-key))) - (dolist (spec specs) - (let ((key (ebox-buffer--paint-surface-key spec))) - (unless (gethash key table) - (push key keys)) - (puthash key spec table))) - (setq supported nil))) - (when supported - (mapcar (lambda (key) - (gethash key table)) - (nreverse keys))))) - -(defun ebox-buffer--surface-spans (region-id spec) - "Return patch spans for REGION-ID and paint-surface SPEC." - (let* ((role (plist-get spec :role)) - (spans (ebox--region-find region-id role))) - (cond - (spans spans) - ((eq (plist-get spec :missing) 'infer) - (let ((inferred - (ebox-buffer--horizontal-border-anchor-spans region-id role))) - (when inferred - (let ((prop (ebox-buffer--role-property role))) - (dolist (span inferred) - (put-text-property (car span) (cdr span) prop region-id))) - (ebox-buffer--put-region-role-spans region-id role inferred) - inferred))) - (t nil)))) - -(defun ebox-buffer--patch-surface (region-id spec) - "Apply paint-surface SPEC to REGION-ID and return patched span count." - (let ((spans (ebox-buffer--surface-spans region-id spec)) - (role (plist-get spec :role)) - (face-key (plist-get spec :face-key)) - (face-value (plist-get spec :face-value)) - (remove-role (plist-get spec :remove-role)) - (count 0)) - (dolist (span spans) - (if (memq role '(bt bb)) - (ebox--patch-horizontal-border-face-in-span - (car span) (cdr span) face-key face-value) - (ebox--patch-face-key-in-span - (car span) (cdr span) face-key face-value)) - (when remove-role - (remove-text-properties - (car span) (cdr span) - (list (ebox-buffer--role-property - role) - nil))) - (cl-incf count)) - (when remove-role - (ebox-buffer--put-region-role-spans region-id role nil)) - count)) - -(defun ebox-buffer--paint-surfaces-safe-for-node-p (node surfaces) - "Return non-nil when SURFACES can be face-patched across NODE's spans. -Content paint follows nearest-declaration semantics, but the owner's -content-owner spans cover every descendant character and anonymous -face entries carry no ownership, so the cheap face patch cannot skip a -descendant's own entry. When any descendant declares the same paint -property, decline; callers fall back to an exact owner render." - (cl-every (lambda (spec) - (let ((paint-prop (plist-get spec :paint-prop))) - (or (null paint-prop) - (not (ebox--node-paint-conflict-p node paint-prop))))) - surfaces)) - -(defun ebox--execute-text-property-paint-patch (buffer op node) - "Execute OP as direct text-property edits for NODE when possible." - (let* ((dirty (plist-get op :dirty)) - (changed-keys (plist-get dirty :changed-keys)) - (region-ids (or (plist-get dirty :region-ids) - (ebox--node-all-region-ids node))) - (surfaces (and changed-keys - (not (and (memq :color changed-keys) - (ebox--node-visible-overflow-p node))) - (ebox--paint-text-property-surfaces - node changed-keys))) - (patched 0)) - (when (and surfaces - (ebox-buffer--paint-surfaces-safe-for-node-p node surfaces)) - (with-current-buffer buffer - (let ((inhibit-read-only t)) - (with-silent-modifications - (dolist (spec surfaces) - ;; Border surfaces belong to the owner's own chrome: a - ;; full render never propagates an ancestor's border - ;; color into a descendant's border runs, so the cheap - ;; face patch must not either (issue014). - (dolist (region-id - (if (plist-get spec :own-region-only) - (let ((own (ebox-get node :region-id))) - (and own (memq own region-ids) (list own))) - region-ids)) - (cl-incf patched - (ebox-buffer--patch-surface - region-id spec))))))) - (list :owner-id (plist-get op :owner-id) - :op 'paint-patch - :owner-type (plist-get node :ebox-type) - :span-count patched - :text-property-patch t)))) - -(defun ebox--line-spans-contiguous-p (spans) - "Return non-nil when SPANS cover adjacent whole buffer lines." - (or (null spans) - (cl-loop for rest on spans - while (cdr rest) - always (= (caadr rest) (1+ (cdar rest)))))) - -(defun ebox--buffer-line-spans-between (start end) - "Return actual buffer line spans between START and END." - (if (= start end) - (list (cons start end)) - (let (spans) - (save-excursion - (goto-char start) - (while (< (point) end) - (push (cons (point) (min (line-end-position) end)) spans) - (forward-line 1))) - (nreverse spans)))) - -(defun ebox--span-wrapper-owners (span) - "Return SPAN's wrapper owner stack, inner to outer." - (or (get-text-property (car span) 'ebox-content-owners) - (when-let ((owner (get-text-property (car span) 'ebox-content-owner))) - (list owner)))) - -(defun ebox--common-wrapper-owners (owner-stacks) - "Return owner ids common to every stack in OWNER-STACKS. -The returned order follows the first stack, preserving inner-to-outer order." - (when owner-stacks - (let ((common (copy-sequence (car owner-stacks)))) - (dolist (owners (cdr owner-stacks)) - (setq common - (cl-remove-if-not - (lambda (owner) (member owner owners)) - common))) - common))) - -(defun ebox--spans-wrapper-owners (spans) - "Return outer wrapper owners shared by every nonempty SPAN. -When a replacement covers sibling boxes, child-specific owners from the first -line must not be copied into all new lines." - (let (owner-stacks) - (dolist (span spans) - (when (< (car span) (cdr span)) - (push (ebox--span-wrapper-owners span) owner-stacks))) - (ebox--common-wrapper-owners (nreverse owner-stacks)))) - -(defun ebox--string-content-owner-set (string) - "Return a set of content owner ids present anywhere in STRING." - (let ((pos 0) - (limit (length string)) - (owners (make-hash-table :test 'equal))) - (while (< pos limit) - (dolist (owner (ebox--content-owner-stack-at string pos)) - (puthash owner t owners)) - (setq pos (ebox--next-content-owner-property-change - pos string limit))) - owners)) - -(defun ebox-buffer--replacement-owner-set - (rendered dirty &optional replaced-owner-ids) - "Return RENDERED owners plus replaced history from DIRTY and OWNER-IDS." - (let ((owners (ebox--string-content-owner-set rendered))) - ;; Structural replacement must suppress removed child owners inherited - ;; from the old lines. They are replacement-owned history, not wrappers. - (dolist (owner (plist-get dirty :old-region-ids)) - (puthash owner t owners)) - (dolist (owner replaced-owner-ids) - (puthash owner t owners)) - owners)) - -(defun ebox-buffer--owners-inside-replacement-boundary - (spans boundary-owner-ids) - "Return old SPANS owners at or inside BOUNDARY-OWNER-IDS." - (let ((boundary-set (make-hash-table :test 'equal)) - replaced) - (dolist (owner boundary-owner-ids) - (puthash owner t boundary-set)) - (dolist (span spans) - (let ((position (car span)) - (limit (cdr span))) - (while (< position limit) - (let* ((owners (ebox--content-owner-stack-at nil position)) - (boundary - (cl-position-if - (lambda (owner) (gethash owner boundary-set)) - owners :from-end t))) - (when boundary - (setq replaced - (nconc (cl-subseq owners 0 (1+ boundary)) replaced)))) - (setq position - (ebox--next-content-owner-property-change - position nil limit))))) - (delete-dups replaced))) - -(defun ebox--string-with-preserved-horizontal-border - (string span owner box &optional edge) - "Return STRING with OWNER's horizontal EDGE from SPAN and BOX. -Nil EDGE preserves both outer edges." - (let ((result string)) - (when (and span (< (car span) (cdr span))) - (when (and (memq edge '(nil top)) - (equal (get-text-property (car span) 'ebox-bt) owner)) - (setq result - (ebox--propertize-overline - result (ebox-get box :border-top-color))) - (add-text-properties 0 (length result) `(ebox-bt ,owner) result)) - (when (and (memq edge '(nil bottom)) - (equal (get-text-property (car span) 'ebox-bb) owner)) - (setq result - (ebox--propertize-underline - result (ebox-get box :border-bottom-color))) - (add-text-properties 0 (length result) `(ebox-bb ,owner) result))) - result)) - -(defun ebox--string-with-preserved-wrapper-context - (string owners rendered-owner-set &optional span horizontal-edge) - "Return STRING with missing outer OWNERS restored. -RENDERED-OWNER-SET identifies already-owned content. SPAN supplies old paint; -HORIZONTAL-EDGE limits restored border paint to `top', `bottom', or neither." - (let* ((missing (cl-remove-if (lambda (owner) - (gethash owner rendered-owner-set)) - owners)) - (result (copy-sequence string))) - (dolist (owner missing) - (when-let ((box (gethash owner ebox--region-box-table))) - (when-let ((color (ebox-get box :color))) - (setq result (ebox--propertize-color result color))) - (when-let ((bgcolor (ebox-get box :bgcolor))) - (setq result (ebox--propertize-bgcolor result bgcolor))) - (when-let ((properties (ebox-get box :surface-properties))) - (setq result - (ebox--apply-surface-properties result properties))) - (setq result - (ebox--string-with-preserved-horizontal-border - result span owner box horizontal-edge)))) - (ebox--add-content-owners result missing))) - -(defun ebox--string-with-preserved-wrapper-owners (spans string) - "Return STRING preserving SPANS' outer wrapper ownership and paint." - (let ((rendered-owner-set (ebox--string-content-owner-set string))) - (if-let ((owners (ebox--spans-wrapper-owners spans))) - (let* ((lines (ebox-string-lines string)) - (last-index (1- (length lines))) - (last-span (car (last spans))) - (interior-limit (1- (length spans)))) - (ebox-lines-join - (cl-loop - for line in lines - for index from 0 - for span = (cond - ((zerop index) (car spans)) - ((= index last-index) last-span) - ((< index interior-limit) (nth index spans))) - for horizontal-edge = (cond - ((zerop last-index) nil) - ((zerop index) 'top) - ((= index last-index) 'bottom) - (t 'none)) - collect - (ebox--string-with-preserved-wrapper-context - line owners rendered-owner-set span horizontal-edge)))) - string))) - -(defun ebox--replace-contiguous-line-spans - (spans rendered &optional skip-wrapper-context) - "Replace contiguous SPANS with RENDERED and return new spans on success. -When SKIP-WRAPPER-CONTEXT is non-nil, RENDERED already covers the complete -runtime root and therefore needs no ownership recovered from the old text." - (when (and spans (ebox--line-spans-contiguous-p spans)) - (ebox--with-preserved-buffer-window-state (current-buffer) - (let* ((inhibit-read-only t) - (point-marker (copy-marker (point) t)) - (start (caar spans)) - (end (cdar (last spans))) - (inserted - (if skip-wrapper-context - rendered - (ebox--string-with-preserved-wrapper-owners - spans rendered))) - (start-marker (copy-marker start nil)) - (end-marker nil)) - (save-excursion - (goto-char start) - (delete-region start end) - (insert inserted) - (setq end-marker (copy-marker (point) t))) - (goto-char (min (marker-position point-marker) (point-max))) - (prog1 - (ebox--buffer-line-spans-between - (marker-position start-marker) - (marker-position end-marker)) - (set-marker point-marker nil) - (set-marker start-marker nil) - (set-marker end-marker nil)))))) - -(defun ebox-buffer--canonical-property-alist (properties) - "Return text PROPERTIES in semantic key order. -Text-property plists are unordered maps even though their cons order can -change when strings cross the native module boundary." - (sort (cl-loop for (key value) on properties by #'cddr - collect (cons key value)) - (lambda (left right) - (string< (symbol-name (car left)) - (symbol-name (car right)))))) - -(defun ebox-buffer--property-maps-equal-p (old-properties new-properties) - "Return non-nil when two unordered property plists are semantically equal." - (or (equal old-properties new-properties) - (equal (ebox-buffer--canonical-property-alist old-properties) - (ebox-buffer--canonical-property-alist new-properties)))) - -(defun ebox-buffer--myers-trace-value (trace distance diagonal) - "Return TRACE's furthest position for DISTANCE and DIAGONAL." - (if (or (< diagonal (- distance)) (> diagonal distance)) - -1 - (aref trace (+ diagonal distance)))) - -(defvar ebox-buffer--myers-maximum-edit-distance 128 - "Maximum exact edit distance used by root property diffing.") - -(defvar ebox-buffer--myers-maximum-trace-cells 20000 - "Maximum retained Myers frontier cells for one root property diff.") - -(defvar ebox-buffer--myers-maximum-work 250000 - "Maximum frontier and character-comparison work for one Myers diff.") - -(defconst ebox-buffer--myers-budget-exceeded :budget-exceeded - "Sentinel returned when exact root alignment exceeds its fixed budget.") - -(defvar ebox-buffer--last-myers-stats nil - "Diagnostic facts from the most recent bounded Myers alignment.") - -(defvar ebox-buffer--last-property-patch-plan nil - "Diagnostic facts from the most recent root property patch plan.") - -(defun ebox-buffer--myers-text-matches (old new) - "Return ordered equal runs from a bounded shortest-edit OLD/NEW alignment. -Each result is (OLD-START NEW-START LENGTH). Text properties are checked -after textual alignment so large property-only geometry changes do not -inflate the edit-search frontier. Return -`ebox-buffer--myers-budget-exceeded' when the deterministic edit, trace, or -work budget is exhausted." - (let* ((old-length (length old)) - (new-length (length new)) - (maximum (+ old-length new-length)) - (distance-limit - (min maximum (max 0 ebox-buffer--myers-maximum-edit-distance))) - (traces (make-vector (1+ distance-limit) nil)) - (work 0) - (trace-cells 0) - final-distance stop-reason) - (cl-labels - ((consume-work - () - (if (>= work (max 1 ebox-buffer--myers-maximum-work)) - (progn - (setq stop-reason 'work-budget) - (throw 'stop nil)) - (cl-incf work)))) - (catch 'stop - (cl-loop - for distance from 0 to distance-limit - do - (let ((current-cells (1+ (* 2 distance)))) - (when (> (+ trace-cells current-cells) - (max 1 ebox-buffer--myers-maximum-trace-cells)) - (setq stop-reason 'trace-budget) - (throw 'stop nil)) - (cl-incf trace-cells current-cells) - (let* ((previous (and (> distance 0) - (aref traces (1- distance)))) - (current (make-vector current-cells -1)) - complete-p) - (cl-loop - for diagonal from (- distance) to distance by 2 - until complete-p - do - (consume-work) - (let* ((down - (and previous - (ebox-buffer--myers-trace-value - previous (1- distance) (1+ diagonal)))) - (right - (and previous - (ebox-buffer--myers-trace-value - previous (1- distance) (1- diagonal)))) - (old-position - (cond - ((zerop distance) 0) - ((= diagonal (- distance)) down) - ((= diagonal distance) (1+ right)) - ((< right down) down) - (t (1+ right)))) - (new-position (- old-position diagonal))) - (while (and (< old-position old-length) - (< new-position new-length) - (= (aref old old-position) - (aref new new-position))) - (consume-work) - (cl-incf old-position) - (cl-incf new-position)) - (aset current (+ diagonal distance) old-position) - (when (and (>= old-position old-length) - (>= new-position new-length)) - (setq complete-p t)))) - (aset traces distance current) - (when complete-p - (setq final-distance distance - stop-reason 'complete) - (throw 'stop t))))) - (setq stop-reason - (if (< distance-limit maximum) - 'edit-distance-budget - 'incomplete)))) - (setq ebox-buffer--last-myers-stats - (list :status stop-reason - :work work - :trace-cells trace-cells - :distance final-distance - :maximum-distance maximum)) - (if (null final-distance) - ebox-buffer--myers-budget-exceeded - (let ((old-position old-length) - (new-position new-length) - matches) - (cl-loop - for distance from final-distance downto 1 - do - (let* ((previous-distance (1- distance)) - (previous (aref traces previous-distance)) - (diagonal (- old-position new-position)) - (previous-diagonal - (if (or (= diagonal (- distance)) - (and (/= diagonal distance) - (< (ebox-buffer--myers-trace-value - previous previous-distance - (1- diagonal)) - (ebox-buffer--myers-trace-value - previous previous-distance - (1+ diagonal))))) - (1+ diagonal) - (1- diagonal))) - (previous-old - (ebox-buffer--myers-trace-value - previous previous-distance previous-diagonal)) - (previous-new (- previous-old previous-diagonal))) - (when (and (> old-position previous-old) - (> new-position previous-new)) - (let ((length - (min (- old-position previous-old) - (- new-position previous-new)))) - (push (list (- old-position length) - (- new-position length) - length) - matches))) - (setq old-position previous-old - new-position previous-new))) - (when (and (> old-position 0) (> new-position 0)) - (let ((length (min old-position new-position))) - (push (list (- old-position length) - (- new-position length) - length) - matches))) - matches)))) - -(defun ebox-buffer--common-text-edges (old new) - "Return non-overlapping common prefix and suffix lengths for OLD and NEW." - (let* ((old-length (length old)) - (new-length (length new)) - (shared-limit (min old-length new-length)) - (prefix 0) - (suffix 0)) - (while (and (< prefix shared-limit) - (= (aref old prefix) (aref new prefix))) - (cl-incf prefix)) - (while (and (< suffix (- old-length prefix)) - (< suffix (- new-length prefix)) - (= (aref old (- old-length suffix 1)) - (aref new (- new-length suffix 1)))) - (cl-incf suffix)) - (cons prefix suffix))) - -(defun ebox-buffer--push-property-patch - (patches old-start old-end new-start new-end) - "Add one changed range to reverse-order PATCHES, coalescing adjacency." - (if (and (= old-start old-end) (= new-start new-end)) - patches - (let ((previous (car patches))) - (if (and previous - (= (plist-get previous :old-end) old-start) - (= (plist-get previous :new-end) new-start)) - (progn - (plist-put previous :old-end old-end) - (plist-put previous :new-end new-end) - patches) - (cons (list :old-start old-start :old-end old-end - :new-start new-start :new-end new-end) - patches))))) - -(defun ebox-buffer--property-interval-index (string) - "Return a position index over STRING's compact property intervals." - (let* ((length (length string)) - (index (make-vector length (list 0 length nil)))) - (dolist (interval (object-intervals string)) - (let ((position (nth 0 interval)) - (end (nth 1 interval))) - (while (< position end) - (aset index position interval) - (cl-incf position)))) - index)) - -(defun ebox-buffer--property-run-patches - (old-index new-index old-start new-start length patches) - "Add property changes inside one aligned text run to PATCHES. -OLD-START and NEW-START begin an equal-character run of LENGTH. Properties -are compared once per property interval instead of once per character." - (let ((old-position old-start) - (new-position new-start) - (old-limit (+ old-start length)) - (new-limit (+ new-start length))) - (while (< old-position old-limit) - (let* ((old-interval (aref old-index old-position)) - (new-interval (aref new-index new-position)) - (old-change (min old-limit (nth 1 old-interval))) - (new-change (min new-limit (nth 1 new-interval))) - (segment-length - (min (- old-change old-position) - (- new-change new-position)))) - (unless (ebox-buffer--property-maps-equal-p - (nth 2 old-interval) - (nth 2 new-interval)) - (setq patches - (ebox-buffer--push-property-patch - patches - old-position (+ old-position segment-length) - new-position (+ new-position segment-length)))) - (setq old-position (+ old-position segment-length) - new-position (+ new-position segment-length)))) - patches)) - -(defun ebox-buffer--aligned-property-patches - (old new old-index new-index matches) - "Return property-aware patches for OLD, NEW, and textual MATCHES." - (let ((old-position 0) - (new-position 0) - patches) - (dolist (match matches) - (let ((matched-old (nth 0 match)) - (matched-new (nth 1 match)) - (run-length (nth 2 match))) - (when (or (< old-position matched-old) - (< new-position matched-new)) - (setq patches - (ebox-buffer--push-property-patch - patches old-position matched-old - new-position matched-new))) - (setq patches - (ebox-buffer--property-run-patches - old-index new-index - matched-old matched-new run-length patches)) - (setq old-position (+ matched-old run-length) - new-position (+ matched-new run-length)))) - (setq patches - (ebox-buffer--push-property-patch - patches old-position (length old) - new-position (length new))) - (nreverse patches))) - -(defun ebox-buffer--minimal-property-patches (old new) - "Return bounded property-aware replacement patches from OLD to NEW. -Text-equal strings produce one patch per disjoint property change. Text -changes retain exact common edges and use a shortest edit alignment while it -fits a deterministic budget. When the middle exceeds that budget, one -correct middle replacement is used instead of blocking Emacs on an unbounded -quadratic trace. Patch coordinates are zero-based half-open offsets in OLD -and NEW." - (setq ebox-buffer--last-myers-stats nil) - (let ((old-length (length old))) - (if (equal old new) - (let ((old-index (ebox-buffer--property-interval-index old)) - (new-index (ebox-buffer--property-interval-index new))) - (setq ebox-buffer--last-property-patch-plan - (list :strategy 'property-only - :work 0 :trace-cells 0)) - (nreverse - (ebox-buffer--property-run-patches - old-index new-index 0 0 old-length nil))) - (let* ((new-length (length new)) - (edges (ebox-buffer--common-text-edges old new)) - (prefix (car edges)) - (suffix (cdr edges)) - (old-middle-end (- old-length suffix)) - (new-middle-end (- new-length suffix)) - (old-middle-length (- old-middle-end prefix)) - (new-middle-length (- new-middle-end prefix)) - (middle-matches - (if (or (zerop old-middle-length) - (zerop new-middle-length)) - nil - (ebox-buffer--myers-text-matches - (substring old prefix old-middle-end) - (substring new prefix new-middle-end)))) - (fallback-p - (eq middle-matches ebox-buffer--myers-budget-exceeded)) - (matches - (append - (when (> prefix 0) - (list (list 0 0 prefix))) - (unless fallback-p - (mapcar - (lambda (match) - (list (+ prefix (nth 0 match)) - (+ prefix (nth 1 match)) - (nth 2 match))) - middle-matches)) - (when (> suffix 0) - (list (list (- old-length suffix) - (- new-length suffix) - suffix))))) - (stats ebox-buffer--last-myers-stats) - (strategy - (cond - (fallback-p 'bounded-fallback) - ((or (zerop old-middle-length) - (zerop new-middle-length)) - 'exact-edges) - (t 'exact-myers))) - (old-index - (and matches (ebox-buffer--property-interval-index old))) - (new-index - (and matches (ebox-buffer--property-interval-index new)))) - (setq ebox-buffer--last-property-patch-plan - (list :strategy strategy - :common-prefix prefix - :common-suffix suffix - :myers-status (plist-get stats :status) - :work (or (plist-get stats :work) 0) - :trace-cells (or (plist-get stats :trace-cells) 0) - :distance (plist-get stats :distance))) - (ebox-buffer--aligned-property-patches - old new old-index new-index matches))))) - -(defun ebox-buffer--prepare-root-property-patches (spans rendered) - "Prepare a property-aware root replacement for SPANS and RENDERED. -This function reads the current buffer but does not modify it." - (when (and spans (ebox--line-spans-contiguous-p spans)) - (let* ((start (caar spans)) - (end (cdar (last spans))) - (old (buffer-substring start end)) - (patches (ebox-buffer--minimal-property-patches old rendered)) - (diff-plan ebox-buffer--last-property-patch-plan)) - (list :start start - :end end - :base old - :rendered rendered - :patches patches - :diff-strategy (plist-get diff-plan :strategy) - :diff-work (plist-get diff-plan :work) - :diff-trace-cells (plist-get diff-plan :trace-cells) - :full-root-required-p - (and (= (length patches) 1) - (let ((patch (car patches))) - (and (zerop (plist-get patch :old-start)) - (= (plist-get patch :old-end) (length old)) - (zerop (plist-get patch :new-start)) - (= (plist-get patch :new-end) - (length rendered))))))))) - -(defun ebox-buffer--publish-prepared-root-property-patches (prepared) - "Publish PREPARED root property patches and return publication metadata." - (let* ((start (plist-get prepared :start)) - (end (plist-get prepared :end)) - (base (plist-get prepared :base)) - (rendered (plist-get prepared :rendered)) - (patches (plist-get prepared :patches))) - (when (and (integer-or-marker-p start) - (integer-or-marker-p end) - (stringp base) - (stringp rendered) - (= (- end start) (length base)) - (equal (buffer-substring start end) base)) - (ebox--with-preserved-buffer-window-state (current-buffer) - (let ((inhibit-read-only t) - (root-start-marker (copy-marker start nil)) - (root-end-marker (copy-marker end t)) - published-markers) - (unwind-protect - (progn - (atomic-change-group - (dolist (patch (reverse patches)) - (let* ((old-start (+ start (plist-get patch :old-start))) - (old-end (+ start (plist-get patch :old-end))) - (new-start (plist-get patch :new-start)) - (new-end (plist-get patch :new-end)) - (published-start (copy-marker old-start nil))) - (goto-char old-start) - (delete-region old-start old-end) - (insert (substring rendered new-start new-end)) - (push (cons published-start (copy-marker (point) t)) - published-markers)))) - (list - :new-buffer-spans - (ebox--buffer-line-spans-between - (marker-position root-start-marker) - (marker-position root-end-marker)) - :published-buffer-spans - (mapcar (lambda (span) - (cons (marker-position (car span)) - (marker-position (cdr span)))) - published-markers) - :patches patches - :diff-strategy (plist-get prepared :diff-strategy) - :diff-work (plist-get prepared :diff-work) - :diff-trace-cells (plist-get prepared :diff-trace-cells) - :full-root-required-p - (plist-get prepared :full-root-required-p))) - (set-marker root-start-marker nil) - (set-marker root-end-marker nil) - (dolist (span published-markers) - (set-marker (car span) nil) - (set-marker (cdr span) nil)))))))) - -(defun ebox-buffer--replace-root-with-property-patches (spans rendered) - "Replace root SPANS with RENDERED through bounded property-aware writes. -Return the resulting root line spans, the actually published buffer spans, -and whether correctness required replacing the complete root." - (when-let ((prepared - (ebox-buffer--prepare-root-property-patches spans rendered))) - (ebox-buffer--publish-prepared-root-property-patches prepared))) - -(defun ebox-buffer-publish-prepared-root (buffer rendered) - "Publish prepared root string RENDERED into BUFFER. - -This function owns only the property-aware buffer write. Declarative tree -validation, candidate rendering, and runtime state publication belong to the -incremental transaction layer. The caller may wrap this function in a wider -`atomic-change-group' that includes its own publication work." - (unless (buffer-live-p buffer) - (error "Ebox cannot publish into dead buffer %S" buffer)) - (unless (stringp rendered) - (error "Ebox prepared root must be a string")) - (with-current-buffer buffer - (let ((spans (ebox--buffer-line-spans-between (point-min) (point-max)))) - (or (ebox-buffer--replace-root-with-property-patches spans rendered) - (error "Ebox could not publish the prepared root"))))) - -(defun ebox-buffer--role-delta-region-ids (role-delta) - "Return region ids affected by ROLE-DELTA." - (delete-dups - (delq nil - (mapcar - (lambda (entry) - (car-safe (plist-get entry :key))) - (plist-get role-delta :entries))))) - -(defun ebox-buffer--preflight-native-root-property-patches - (spans native-patch) - "Return a validated text publication plan for Rust NATIVE-PATCH. -The plan validates current root SPANS, source/target counts, patch ordering, -replacement payloads, and complete prepared root metadata. It translates the -prepared role table and preflights extent and scroll target ranges without -publishing text, role tables, extent templates, scroll state, or markers." - (when (and spans - (ebox--line-spans-contiguous-p spans) - (plist-get native-patch :native-patch) - (eq (plist-get native-patch :patch-origin) 'rust)) - (let* ((start (caar spans)) - (end (cdar (last spans))) - (base-character-count - (plist-get native-patch :base-character-count)) - (target-character-count - (plist-get native-patch :target-character-count)) - (target-line-count (plist-get native-patch :target-line-count)) - (patches (plist-get native-patch :patches)) - (root-render-metadata - (plist-get native-patch :root-render-metadata)) - (prepared-metadata-p - (plist-get root-render-metadata :prepared-p)) - (role-template - (and prepared-metadata-p - (plist-get root-render-metadata :role-span-template))) - (extent-template - (and prepared-metadata-p - (plist-get root-render-metadata :box-extent-template))) - (scroll-template - (and prepared-metadata-p - (plist-get root-render-metadata - :scroll-content-span-template))) - (prepared-role-table - (and (hash-table-p role-template) - (ebox-buffer--region-role-span-table-from-template - role-template start))) - prepared-extent-table - scroll-preflight - role-delta - (previous-old-end 0) - (previous-new-end 0)) - (unless (and (integerp base-character-count) - (= base-character-count (- end start)) - (integerp target-character-count) - (>= target-character-count 0) - (integerp target-line-count) - (> target-line-count 0) - (listp patches)) - (error "Native root patch does not match the current buffer")) - (dolist (patch patches) - (let ((old-start (plist-get patch :old-start)) - (old-end (plist-get patch :old-end)) - (new-start (plist-get patch :new-start)) - (new-end (plist-get patch :new-end)) - (replacement (plist-get patch :replacement))) - (unless (and (integerp old-start) (integerp old-end) - (integerp new-start) (integerp new-end) - (<= previous-old-end old-start old-end - base-character-count) - (<= previous-new-end new-start new-end - target-character-count) - (= (- old-start previous-old-end) - (- new-start previous-new-end)) - (stringp replacement) - (= (length replacement) (- new-end new-start))) - (error "Native root patch contains invalid ranges")) - (setq previous-old-end old-end - previous-new-end new-end))) - (unless (= (- base-character-count previous-old-end) - (- target-character-count previous-new-end)) - (error "Native root patch has an inconsistent target length")) - (when (and prepared-metadata-p - (not (and (hash-table-p role-template) - (hash-table-p extent-template) - (hash-table-p scroll-template)))) - (error "Native root patch lacks prepared target indexes")) - (when prepared-role-table - (ebox-buffer--validate-role-span-template - prepared-role-table start target-character-count)) - (when (hash-table-p extent-template) - (setq prepared-extent-table - (ebox-buffer--validate-box-extent-template - extent-template start target-character-count))) - (when (hash-table-p scroll-template) - (setq scroll-preflight - (ebox--preflight-scroll-content-span-template - (current-buffer) scroll-template start target-character-count))) - (unless prepared-role-table - (setq role-delta - (ebox-buffer--capture-role-delta start patches))) - (list - :start start - :end end - :base-character-count base-character-count - :target-character-count target-character-count - :target-line-count target-line-count - :patches patches - :patch-plan - (mapcar - (lambda (patch) - (cons patch - (ebox-buffer--in-place-property-patch-p start patch))) - patches) - :root-render-metadata root-render-metadata - :prepared-role-table prepared-role-table - :prepared-extent-table prepared-extent-table - :role-delta role-delta - :scroll-preflight scroll-preflight - :replacement-bytes - (or (plist-get native-patch :replacement-bytes) - (cl-loop for patch in patches - sum (string-bytes - (plist-get patch :replacement)))))))) - -(defun ebox-buffer--publish-native-root-property-patch-text (plan) - "Apply validated native root text PLAN and return publication metadata. -The caller owns the surrounding `atomic-change-group' and all runtime index -publication. This function mutates only buffer text and text properties." - (let* ((start (plist-get plan :start)) - (end (plist-get plan :end)) - (target-character-count - (plist-get plan :target-character-count)) - (target-line-count (plist-get plan :target-line-count)) - (patches (plist-get plan :patches)) - (root-start-marker (copy-marker start nil)) - (root-end-marker (copy-marker end t)) - published-spans) - (unwind-protect - (progn - (dolist (planned (reverse (plist-get plan :patch-plan))) - (let* ((patch (car planned)) - (in-place-p (cdr planned)) - (old-start (+ start (plist-get patch :old-start))) - (old-end (+ start (plist-get patch :old-end))) - (replacement (plist-get patch :replacement))) - (if in-place-p - (ebox-buffer--apply-string-properties-in-place - old-start replacement) - (goto-char old-start) - (delete-region old-start old-end) - (insert replacement)) - ;; Rust already supplied validated target coordinates. Recording - ;; them directly avoids allocating and moving two temporary - ;; markers for every minimal property span. - (push (cons (+ start (plist-get patch :new-start)) - (+ start (plist-get patch :new-end))) - published-spans))) - (unless (= (- (marker-position root-end-marker) - (marker-position root-start-marker)) - target-character-count) - (error "Native root patch published the wrong target length")) - (let ((new-spans - (ebox--buffer-line-spans-between - (marker-position root-start-marker) - (marker-position root-end-marker)))) - (unless (= (length new-spans) target-line-count) - (error "Native root patch published the wrong line count")) - (let* ((role-delta (plist-get plan :role-delta)) - (scroll-preflight (plist-get plan :scroll-preflight)) - (scroll-region-ids - (plist-get scroll-preflight :scroll-region-ids))) - (list - :new-buffer-spans new-spans - :published-buffer-spans published-spans - :patches patches - :role-delta role-delta - :affected-region-ids - (or scroll-region-ids - (ebox-buffer--role-delta-region-ids role-delta)) - :patch-origin 'rust - :full-frame-bytes 0 - :replacement-bytes - (plist-get plan :replacement-bytes) - :full-root-required-p - (and (= (length patches) 1) - (let ((patch (car patches))) - (and (zerop (plist-get patch :old-start)) - (= (plist-get patch :old-end) - (plist-get plan :base-character-count)) - (zerop (plist-get patch :new-start)) - (= (plist-get patch :new-end) - target-character-count)))))))) - (set-marker root-start-marker nil) - (set-marker root-end-marker nil)))) - -(defun ebox-buffer--apply-root-property-patches (spans native-patch) - "Apply Rust-origin NATIVE-PATCH directly inside contiguous root SPANS. -No complete old or target string is allocated. The native tape parser has -already validated semantic patch ordering; this compatibility boundary repeats -the buffer-relative length and replacement checks before one atomic write." - (when-let ((plan - (ebox-buffer--preflight-native-root-property-patches - spans native-patch))) - (ebox--with-preserved-buffer-window-state (current-buffer) - (let ((inhibit-read-only t)) - (atomic-change-group - (ebox-buffer--publish-native-root-property-patch-text plan)))))) - -(defun ebox--line-with-preserved-wrapper-owner - (span line rendered-owner-set) - "Return LINE preserving SPAN's outer wrapper ownership and paint." - (let ((owners (or (get-text-property (car span) 'ebox-content-owners) - (when-let ((owner (get-text-property - (car span) - 'ebox-content-owner))) - (list owner))))) - (if owners - (ebox--string-with-preserved-wrapper-context - line owners rendered-owner-set span) - line))) - -(defun ebox--paint-line-spans (spans rendered) - "Copy RENDERED paint over SPANS while retaining wrapper context." - (let ((lines (ebox-string-lines rendered)) - (owner-set (ebox--string-content-owner-set rendered))) - (when (and (= (length spans) (length lines)) - (cl-loop for span in spans - for line in lines - always - (and (= (- (cdr span) (car span)) (length line)) - (string= - (buffer-substring-no-properties - (car span) (cdr span)) - (substring-no-properties line))))) - (let ((inhibit-read-only t)) - (cl-loop for span in spans - for line in lines - do (ebox--copy-string-text-properties-to-buffer - (ebox--line-with-preserved-wrapper-owner - span line owner-set) - (car span)))) - t))) - -(defun ebox-buffer--external-extent-boundaries (span) - "Return live extent markers immediately outside SPAN. -The car contains left-side end markers; the cdr contains right-side start -markers. These markers must stay outside a replacement inserted at SPAN." - (let ((buffer (current-buffer)) - (start (car span)) - (end (cdr span)) - (region-ids - (delete-dups - (append - (and (> (car span) (point-min)) - (ebox-buffer--region-ids-at (1- (car span)))) - (and (< (cdr span) (point-max)) - (ebox-buffer--region-ids-at (cdr span)))))) - left right) - (dolist (region-id region-ids) - (when-let ((extents (gethash region-id ebox--box-extents))) - (let ((extent-start (car extents)) - (extent-end (cdr extents))) - (when (and (eq (marker-buffer extent-start) buffer) - (eq (marker-buffer extent-end) buffer)) - (cond - ((and (< (marker-position extent-start) start) - (= (marker-position extent-end) start)) - (push extent-end left)) - ((and (= (marker-position extent-start) end) - (> (marker-position extent-end) end)) - (push extent-start right))))))) - (cons left right))) - -(defun ebox--replace-line-spans - (spans rendered &optional rendered-owner-set skip-wrapper-context) - "Replace SPANS with RENDERED lines and return new spans on success. -RENDERED-OWNER-SET avoids rescanning an already inspected replacement. -When SKIP-WRAPPER-CONTEXT is non-nil, the replacement is the complete runtime -root and no ownership may exist outside it." - (if (and skip-wrapper-context - (ebox--line-spans-contiguous-p spans)) - (ebox--replace-contiguous-line-spans spans rendered t) - (ebox--with-preserved-buffer-window-state (current-buffer) - (let ((lines (ebox-string-lines rendered)) - (rendered-owner-set - (or rendered-owner-set - (ebox--string-content-owner-set rendered)))) - (if (= (length spans) (length lines)) - (let (new-span-markers) - (let ((inhibit-read-only t) - (point-marker (copy-marker (point) t))) - (save-excursion - (cl-loop for span in (reverse spans) - for line in (reverse lines) - do (progn - (let* ((start (car span)) - (extent-boundaries - (ebox-buffer--external-extent-boundaries - span))) - (goto-char start) - (setq line - (ebox--line-with-preserved-wrapper-owner - span line rendered-owner-set)) - (delete-region start (cdr span)) - (insert line) - (dolist (marker (car extent-boundaries)) - (set-marker marker start)) - (dolist (marker (cdr extent-boundaries)) - (set-marker marker (point))) - (push (cons (copy-marker start nil) - (copy-marker (point) t)) - new-span-markers))))) - (goto-char (min (marker-position point-marker) (point-max))) - (set-marker point-marker nil)) - (prog1 - (mapcar (lambda (span) - (cons (marker-position (car span)) - (marker-position (cdr span)))) - new-span-markers) - (dolist (span new-span-markers) - (set-marker (car span) nil) - (set-marker (cdr span) nil)))) - (ebox--replace-contiguous-line-spans spans rendered)))))) +;;; Existing Slot Shaping (defun ebox-buffer--span-slot-width (span) "Return SPAN's visual slot width in its current buffer line." @@ -3559,1475 +487,6 @@ parent layout change." (when ok (ebox-lines-join (nreverse padded))))))) -(defun ebox-buffer--inline-slot-span-patch-op-p (op) - "Return non-nil when OP may use old slot padding as an L2 span patch." - (let ((changed-keys (plist-get (plist-get op :dirty) :changed-keys))) - (and changed-keys - (cl-every (lambda (key) - (memq key '(:margin-left-pixel :margin-right-pixel))) - changed-keys)))) - -(defun ebox--execute-paint-patch (buffer op) - "Execute paint patch OP in BUFFER and return patch metadata." - (let* ((owner-id (plist-get op :owner-id)) - (node (ebox--buffer-runtime-node buffer owner-id))) - (when node - (or (ebox--execute-text-property-paint-patch buffer op node) - (let* ((dirty (plist-get op :dirty)) - (old (or (ebox--ensure-layout-snapshot-spans - buffer owner-id) - (plist-get dirty :old))) - (spans (plist-get old :buffer-spans))) - (and spans - (ebox--with-buffer-render-context buffer - (let* ((ebox--defer-scroll-content-index t) - (rendered (ebox-render node))) - (when (with-current-buffer buffer - (ebox--paint-line-spans spans rendered)) - (list :owner-id owner-id - :op 'paint-patch - :owner-type (plist-get node :ebox-type) - :span-count (length spans))))))))))) - -(defun ebox-buffer--span-line-range (spans) - "Return inclusive line range covered by SPANS in the current buffer." - (when spans - (let (lines) - (dolist (span spans) - (save-excursion - (goto-char (car span)) - (push (line-number-at-pos (car span) t) lines))) - (cons (apply #'min lines) (apply #'max lines))))) - -(defun ebox-buffer--line-range-overlap-p (left right) - "Return non-nil when inclusive line ranges LEFT and RIGHT overlap." - (and left right - (<= (car left) (cdr right)) - (<= (car right) (cdr left)))) - -(defun ebox-buffer--line-span-at-number (line-number) - "Return whole-line span at LINE-NUMBER in the current buffer." - (save-excursion - (goto-char (point-min)) - (forward-line (1- line-number)) - (cons (line-beginning-position) (line-end-position)))) - -(defun ebox-buffer--position-after-pixel-width (start pixel-width) - "Return position after PIXEL-WIDTH pixels from START on the same line." - (save-excursion - (goto-char start) - (let ((end (line-end-position)) - (pos start) - (width 0) - found) - (while (and (< pos end) (not found)) - (let* ((next (1+ pos)) - (char-width (ebox--substring-pixel-width - (buffer-substring pos next) 0 1))) - (setq width (+ width char-width)) - (setq pos next) - (when (= width pixel-width) - (setq found pos)))) - found))) - -(defun ebox-buffer--foreign-content-owner-in-range-p - (start end owned-region-ids) - "Return non-nil when START..END crosses content outside OWNED-REGION-IDS." - (let ((owned (ebox--region-id-set owned-region-ids)) - (position start) - foreign) - (while (and (< position end) (not foreign)) - (let ((region-id (get-text-property position 'ebox-content))) - (when (and region-id (not (gethash region-id owned))) - (setq foreign t))) - (setq position - (or (next-single-property-change - position 'ebox-content nil end) - end))) - foreign)) - -(defun ebox-buffer--contained-flex-fixed-slot-size (node) - "Return NODE's fixed outer `(WIDTH . HEIGHT)' slot size, or nil." - (when-let ((style-node (ebox-fragment-style-source-node node))) - (let ((width (ebox--flex-box-main-constraint style-node 'row :width)) - (height - (ebox--flex-box-main-constraint style-node 'column :height))) - (when (and (integerp width) (> width 0) - (integerp height) (> height 0) - (zerop (ebox-get style-node :margin-left-pixel)) - (zerop (ebox-get style-node :margin-right-pixel)) - (zerop (ebox-get style-node :margin-top-height)) - (zerop (ebox-get style-node :margin-bottom-height))) - (cons width height))))) - -(defun ebox-buffer--contained-flex-publication-spans - (buffer node-id node spans &optional replaced-region-ids) - "Return fixed-slot publication spans for contained partial flex NODE-ID." - (when-let* (((ebox--contained-partial-flex-owner-p buffer node-id)) - ((ebox--definite-containment-formatting-context-p - buffer node-id)) - (size (ebox-buffer--contained-flex-fixed-slot-size node)) - ((= (length spans) (cdr size))) - (owned-region-ids - (append (ebox--node-all-region-ids node) - replaced-region-ids))) - (catch 'unsafe - (let ((expected-line - (line-number-at-pos (caar spans) t)) - expanded) - (dolist (span spans (nreverse expanded)) - (unless (= (line-number-at-pos (car span) t) expected-line) - (throw 'unsafe nil)) - (let ((end (ebox-buffer--position-after-pixel-width - (car span) (car size)))) - (unless (and end (<= (cdr span) end) - (not - (ebox-buffer--foreign-content-owner-in-range-p - (car span) end owned-region-ids))) - (throw 'unsafe nil)) - (push (cons (car span) end) expanded)) - (setq expected-line (1+ expected-line))))))) - -(defun ebox-buffer--node-role-spans (node) - "Return role spans for NODE and descendants in the current buffer." - (when-let ((region-ids (ebox--node-all-region-ids node))) - (unless (ebox-buffer--region-role-span-table) - (ebox-buffer-refresh-region-role-spans (current-buffer))) - (when-let ((table (ebox-buffer--region-role-span-table))) - (let (spans) - (dolist (region-id region-ids) - (dolist (entry ebox-region-types) - (setq spans - (append (copy-sequence - (gethash - (ebox-buffer--region-role-key - region-id (car entry)) - table)) - spans)))) - spans)))) - -(defun ebox-buffer--spans-line-range (spans) - "Return inclusive line range covered by SPANS in the current buffer." - (let (start-line end-line) - (dolist (span spans) - (when (< (car span) (cdr span)) - (let ((first-line (line-number-at-pos (car span) t)) - (last-line (line-number-at-pos (1- (cdr span)) t))) - (setq start-line (if start-line - (min start-line first-line) - first-line)) - (setq end-line (if end-line - (max end-line last-line) - last-line))))) - (and start-line end-line (cons start-line end-line)))) - -(defun ebox-buffer--node-line-range (buffer node) - "Return NODE's current buffer line range without completing snapshots." - (with-current-buffer buffer - (ebox-buffer--spans-line-range - (ebox-buffer--node-role-spans node)))) - -(defun ebox-buffer--node-line-pixel-width (node line-span) - "Return NODE's covered pixel width on LINE-SPAN in the current buffer." - (let (start end) - (dolist (span (ebox-buffer--node-role-spans node)) - (when (and (< (car span) (cdr line-span)) - (> (cdr span) (car line-span))) - (setq start (if start - (min start (max (car span) (car line-span))) - (max (car span) (car line-span)))) - (setq end (if end - (max end (min (cdr span) (cdr line-span))) - (min (cdr span) (cdr line-span)))))) - (when (and start end (< start end)) - (ebox--substring-pixel-width - (buffer-substring start end) 0 (- end start))))) - -(defun ebox-buffer--child-line-item-main-footprint - (buffer child-ids line-number) - "Return summed child item width for CHILD-IDS on LINE-NUMBER." - (with-current-buffer buffer - (let ((line-span (ebox-buffer--line-span-at-number line-number)) - widths) - (dolist (child-id child-ids) - (when-let* ((node (ebox--buffer-runtime-node buffer child-id)) - (width (ebox-buffer--node-line-pixel-width - node line-span))) - (push width widths))) - (when (= (length widths) (length child-ids)) - (apply #'+ widths))))) - -(defun ebox-buffer--child-line-ranges (buffer flex-node) - "Return direct child line ranges for FLEX-NODE in BUFFER." - (let (ranges) - (dolist (child (plist-get flex-node :children)) - (let* ((source (ebox--flex-item-source-node child)) - (child-id (plist-get source :node-id)) - (range (and child-id - (ebox-buffer--node-line-range buffer source)))) - (when range - (push (cons child-id range) ranges)))) - (nreverse ranges))) - -(defun ebox-buffer--child-content-start-on-line (buffer child-ids line-number) - "Return minimum child span start for CHILD-IDS on LINE-NUMBER." - (let (starts) - (with-current-buffer buffer - (let ((line-span (ebox-buffer--line-span-at-number line-number))) - (dolist (child-id child-ids) - (when-let ((node (ebox--buffer-runtime-node buffer child-id))) - (dolist (span (ebox-buffer--node-role-spans node)) - (when (and (< (car span) (cdr line-span)) - (> (cdr span) (car line-span))) - (push (max (car span) (car line-span)) starts))))))) - (and starts (apply #'min starts)))) - -(defun ebox-buffer--rendered-flex-row-lines - (buffer line-spans start-line child-ids rendered-content main-size) - "Return full replacement lines for LINE-SPANS using RENDERED-CONTENT." - (catch 'failed - (let ((content-lines (ebox-string-lines rendered-content)) - full-lines) - (with-current-buffer buffer - (cl-loop for span in line-spans - for line-number from start-line - for content-line in content-lines - do (let* ((line-start (car span)) - (line-end (cdr span)) - (content-start - (ebox-buffer--child-content-start-on-line - buffer child-ids line-number)) - suffix-start prefix suffix) - (unless content-start - (throw 'failed nil)) - (setq suffix-start - (ebox-buffer--position-after-pixel-width - content-start main-size)) - (unless suffix-start - (setq suffix-start line-end)) - (setq prefix (buffer-substring line-start content-start) - suffix (buffer-substring suffix-start line-end)) - (push (concat prefix content-line suffix) full-lines)))) - (nreverse full-lines)))) - -(defun ebox--execute-flex-line-rerender (buffer op) - "Try to rerender only the affected row line for flex owner OP." - (let* ((owner-id (plist-get op :owner-id)) - (dirty (plist-get op :dirty)) - (dirty-node-ids (ebox--dirty-provenance-node-ids dirty)) - (dirty-node-id (and (= (length dirty-node-ids) 1) - (car dirty-node-ids))) - (node (ebox--buffer-runtime-node buffer owner-id)) - (child-id (and dirty-node-id - (ebox--runtime-child-under-ancestor - buffer owner-id dirty-node-id)))) - (when (and node child-id - (eq (plist-get node :ebox-type) 'flex)) - (let* ((child-ranges (ebox-buffer--child-line-ranges buffer node)) - (dirty-range (cdr (assq child-id child-ranges))) - (old-line-ids - (mapcar #'car - (cl-remove-if-not - (lambda (entry) - (ebox-buffer--line-range-overlap-p - dirty-range (cdr entry))) - child-ranges))) - (old-item-main - (and dirty-range old-line-ids - (ebox-buffer--child-line-item-main-footprint - buffer old-line-ids (car dirty-range)))) - (line-render - (ebox--with-buffer-render-context buffer - (or (ebox--flex-render-row-line-for-child-ids - node old-line-ids old-item-main) - (ebox--flex-render-row-line-for-child node child-id)))) - (new-line-ids (plist-get line-render :item-ids)) - (rendered (plist-get line-render :rendered)) - (rendered-owner-set - (and rendered - (ebox-buffer--replacement-owner-set rendered dirty))) - (main-size (plist-get line-render :main))) - (when (and dirty-range - line-render - rendered - main-size - (equal old-line-ids new-line-ids) - (= (ebox-string-height rendered) - (1+ (- (cdr dirty-range) (car dirty-range))))) - (with-current-buffer buffer - (let* ((line-spans - (cl-loop for line from (car dirty-range) - to (cdr dirty-range) - collect (ebox-buffer--line-span-at-number line))) - (replacement-lines - (ebox-buffer--rendered-flex-row-lines - buffer line-spans (car dirty-range) old-line-ids - rendered main-size))) - (when (and replacement-lines - (= (length replacement-lines) - (length line-spans))) - (when-let ((new-spans - (ebox--replace-line-spans - line-spans - (ebox-lines-join replacement-lines) - rendered-owner-set))) - (list :owner-id owner-id - :op 'owner-rerender - :owner-type (plist-get node :ebox-type) - :span-count (length line-spans) - :flex-line-rerender t - :old-buffer-spans line-spans - :new-buffer-spans new-spans)))))))))) - -(defun ebox-buffer--line-replacement-feasible-p (spans rendered) - "Return non-nil when RENDERED can replace line SPANS without mutation." - (let ((line-count (length (ebox-string-lines rendered)))) - (or (= (length spans) line-count) - (ebox--line-spans-contiguous-p spans)))) - -(defun ebox-buffer--paint-lines-compatible-p (spans rendered) - "Return non-nil when RENDERED may update only properties in SPANS." - (let ((lines (ebox-string-lines rendered))) - (and (= (length spans) (length lines)) - (cl-loop for span in spans - for line in lines - always - (and (= (- (cdr span) (car span)) (length line)) - (string= - (buffer-substring-no-properties - (car span) (cdr span)) - (substring-no-properties line))))))) - -(defun ebox-buffer-prepare-paint-patch (buffer op) - "Prepare paint OP for BUFFER without modifying buffer text or properties." - (let* ((owner-id (plist-get op :owner-id)) - (node (ebox--buffer-runtime-node buffer owner-id)) - (snapshot (and node - (ebox--ensure-layout-snapshot-spans - buffer owner-id))) - (spans (and snapshot (plist-get snapshot :buffer-spans)))) - (when (and node spans) - (ebox--with-buffer-render-context buffer - (let ((ebox--defer-scroll-content-index t) - (rendered (ebox-render node))) - (when (with-current-buffer buffer - (ebox-buffer--paint-lines-compatible-p spans rendered)) - (list :op 'paint-patch - :owner-id owner-id - :owner-type (plist-get node :ebox-type) - :spans spans - :rendered rendered))))))) - -(defun ebox-buffer-publish-prepared-paint-patch (buffer prepared) - "Publish PREPARED paint patch into BUFFER." - (with-current-buffer buffer - (let ((spans (plist-get prepared :spans)) - (rendered (plist-get prepared :rendered))) - (when (and (ebox-buffer--paint-lines-compatible-p spans rendered) - (ebox--paint-line-spans spans rendered)) - (list :owner-id (plist-get prepared :owner-id) - :op 'paint-patch - :owner-type (plist-get prepared :owner-type) - :span-count (length spans)))))) - -(defun ebox-buffer--child-reorder-middle (old-ids new-ids) - "Return the minimal changed middle ranges of OLD-IDS and NEW-IDS." - (let* ((old (vconcat old-ids)) - (new (vconcat new-ids)) - (start 0) - (old-end (length old)) - (new-end (length new))) - (while (and (< start old-end) - (< start new-end) - (equal (aref old start) (aref new start))) - (cl-incf start)) - (while (and (> old-end start) - (> new-end start) - (equal (aref old (1- old-end)) - (aref new (1- new-end)))) - (cl-decf old-end) - (cl-decf new-end)) - (list (append (cl-subseq old start old-end) nil) - (append (cl-subseq new start new-end) nil)))) - -(defun ebox-buffer--child-list-window (old-ids new-ids) - "Return changed child window for OLD-IDS and NEW-IDS." - (let* ((old (vconcat old-ids)) - (new (vconcat new-ids)) - (start 0) - (old-end (length old)) - (new-end (length new))) - (while (and (< start old-end) (< start new-end) - (equal (aref old start) (aref new start))) - (cl-incf start)) - (while (and (> old-end start) (> new-end start) - (equal (aref old (1- old-end)) - (aref new (1- new-end)))) - (cl-decf old-end) - (cl-decf new-end)) - (list :prefix-count start - :old-middle (append (cl-subseq old start old-end) nil) - :new-middle (append (cl-subseq new start new-end) nil)))) - -(defun ebox-buffer--child-reorder-span-groups (buffer child-ids) - "Return CHILD-IDS' published span groups in BUFFER." - (catch 'unsafe - (let ((groups (make-hash-table :test 'equal))) - (dolist (child-id child-ids) - (let* ((snapshot - (ebox--ensure-layout-snapshot-spans buffer child-id)) - (spans (and snapshot (plist-get snapshot :buffer-spans)))) - (unless spans - (throw 'unsafe nil)) - (puthash child-id (copy-tree spans) groups))) - groups))) - -(defun ebox-buffer--vertical-slot-spans-p (spans) - "Return non-nil when SPANS occupy one fixed slot on consecutive lines." - (let (first-start first-width previous-line valid) - (setq valid t) - (dolist (span spans valid) - (save-excursion - (goto-char (car span)) - (let* ((line (line-number-at-pos (car span) t)) - (line-start (line-beginning-position)) - (start (ebox--string-pixel-width - (buffer-substring line-start (car span)))) - (width (ebox-buffer--span-slot-width span))) - (if previous-line - (unless (and (= line (1+ previous-line)) - (= start first-start) - (= width first-width)) - (setq valid nil)) - (setq first-start start - first-width width)) - (setq previous-line line)))))) - -(defun ebox-buffer--child-reorder-rendered (segments spans dirty) - "Return SEGMENTS in SPANS with published wrapper context from DIRTY." - (let ((lines - (cl-loop for segment in segments - append - (mapcar (lambda (span) - (buffer-substring (car span) (cdr span))) - segment)))) - (ebox-lines-join - (cl-loop for line in lines - for span in spans - collect - (ebox--line-with-preserved-wrapper-owner - span line - (ebox-buffer--replacement-owner-set line dirty)))))) - -(defun ebox-buffer--child-reorder-segments-fit-p (segments spans) - "Return non-nil when SEGMENTS fit the published destination SPANS." - (let ((lines - (cl-loop for segment in segments - append - (mapcar (lambda (span) - (buffer-substring (car span) (cdr span))) - segment)))) - (and (= (length lines) (length spans)) - (cl-loop for line in lines - for span in spans - always - (= (ebox--string-pixel-width line) - (ebox-buffer--span-slot-width span)))))) - -(defun ebox-buffer--child-reorder-candidate-p - (owner snapshot dirty old-ids new-ids) - "Return non-nil when OWNER can reorder OLD-IDS into NEW-IDS." - (and owner snapshot old-ids new-ids - (plist-get dirty :pure-child-reorder-p) - (equal (plist-get dirty :changed-keys) '(:children)) - (equal (plist-get snapshot :child-ids) old-ids) - (equal (mapcar #'ebox--ensure-node-id - (ebox-tree-node-children owner)) - new-ids) - (eq (plist-get owner :ebox-type) 'stack) - (not (ebox-tree-node-visible-overflow-p owner)))) - -(defun ebox-buffer--child-reorder-publication - (buffer owner snapshot dirty old-middle new-middle groups) - "Return prepared BUFFER publication for OWNER's changed child middle." - (ebox--with-buffer-render-context buffer - (let* ((ebox--defer-scroll-content-index t) - (old-groups (mapcar (lambda (id) (gethash id groups)) old-middle)) - (spans (apply #'append (mapcar #'copy-sequence old-groups))) - (owner-spans (plist-get snapshot :buffer-spans)) - (segments (mapcar (lambda (id) (gethash id groups)) new-middle))) - (when (and spans - (cl-every #'identity segments) - (cl-every (lambda (span) (member span owner-spans)) spans) - (with-current-buffer buffer - (and (ebox-buffer--vertical-slot-spans-p spans) - (ebox-buffer--child-reorder-segments-fit-p - segments spans)))) - (list :op 'child-reorder - :owner-id (plist-get owner :node-id) - :owner-type (plist-get owner :ebox-type) - :dirty dirty - :spans spans - :segments segments))))) - -(defun ebox-buffer-prepare-child-reorder (buffer op) - "Prepare one vertical keyed child reorder OP for BUFFER." - (let* ((owner-id (plist-get op :owner-id)) - (dirty (plist-get op :dirty)) - (owner (ebox--buffer-runtime-node buffer owner-id)) - (snapshot (ebox--ensure-layout-snapshot-spans buffer owner-id)) - (old-ids (plist-get dirty :old-child-ids)) - (new-ids (plist-get dirty :new-child-ids)) - (middle (ebox-buffer--child-reorder-middle old-ids new-ids)) - (old-middle (car middle)) - (new-middle (cadr middle)) - (groups (ebox-buffer--child-reorder-span-groups buffer old-middle))) - (when (and groups old-middle new-middle - (ebox-buffer--child-reorder-candidate-p - owner snapshot dirty old-ids new-ids)) - (ebox-buffer--child-reorder-publication - buffer owner snapshot dirty old-middle new-middle groups)))) - -(defun ebox-buffer--child-splice-candidate-p - (owner snapshot dirty old-ids new-ids) - "Return non-nil when OWNER can splice OLD-IDS into NEW-IDS." - (and owner snapshot old-ids new-ids - (plist-get dirty :child-splice-p) - (equal (plist-get dirty :changed-keys) '(:children)) - (equal (plist-get snapshot :child-ids) old-ids) - (equal (mapcar #'ebox--ensure-node-id - (ebox-tree-node-children owner)) - new-ids) - (eq (plist-get owner :ebox-type) 'stack) - (eq (ebox-tree-display-inner owner) 'column) - (not (ebox-tree-node-visible-overflow-p owner)))) - -(defun ebox-buffer--child-by-id (owner child-id) - "Return OWNER's direct child with runtime CHILD-ID." - (cl-find-if - (lambda (child) - (equal (ebox--ensure-node-id child) child-id)) - (ebox-tree-node-children owner))) - -(defun ebox-buffer--child-splice-simple-new-children-p (owner child-ids) - "Return non-nil when OWNER's new CHILD-IDS are simple boxes." - (cl-every - (lambda (child-id) - (let ((child (ebox-buffer--child-by-id owner child-id))) - (and child (eq (plist-get child :ebox-type) 'box)))) - child-ids)) - -(defun ebox-buffer--child-splice-rendered (owner child-ids) - "Return rendered CHILD-IDS from OWNER as one vertical string." - (when child-ids - (ebox-lines-join - (cl-loop for child-id in child-ids - for child = (ebox-buffer--child-by-id owner child-id) - unless child - do (cl-return nil) - collect (ebox-render child))))) - -(defun ebox-buffer--child-splice-bounds (old-spans before-span after-span) - "Return deletion bounds around OLD-SPANS, BEFORE-SPAN, and AFTER-SPAN." - (cond - (old-spans - (let ((start (caar old-spans)) - (end (cdar (last old-spans)))) - (cond - ((and before-span (not after-span) (> start (point-min))) - (cons (1- start) end)) - (after-span - (cons start (min (point-max) (1+ end)))) - (t (cons start end))))) - (before-span - (cons (cdr before-span) (cdr before-span))) - (after-span - (cons (car after-span) (car after-span))))) - -(defun ebox-buffer--child-splice-insert-text - (rendered old-spans before-span after-span) - "Return insertion text for RENDERED at the splice boundary." - (cond - ((not rendered) "") - (old-spans - (if after-span (concat rendered "\n") rendered)) - (before-span (concat "\n" rendered)) - (after-span (concat rendered "\n")) - (t rendered))) - -(defun ebox-buffer--child-splice-context (owner dirty window groups) - "Return OWNER child splice publication context." - (let* ((old-ids (plist-get dirty :old-child-ids)) - (new-ids (plist-get dirty :new-child-ids)) - (old-middle (plist-get window :old-middle)) - (new-middle (plist-get window :new-middle)) - (prefix (plist-get window :prefix-count)) - (before-id (and (> prefix 0) (nth (1- prefix) old-ids))) - (after-id (nth prefix old-ids)) - (old-spans (apply #'append (mapcar (lambda (id) - (gethash id groups)) - old-middle))) - (before-span (car (last (gethash before-id groups)))) - (after-span (car (gethash after-id groups))) - (rendered (ebox-buffer--child-splice-rendered owner new-middle))) - (list :old-spans old-spans - :old-child-ids old-ids - :new-child-ids new-ids - :before-span before-span - :after-span after-span - :rendered rendered))) - -(defun ebox-buffer--child-splice-wrapper-span - (old-spans before-span after-span) - "Return a neighboring span that carries splice wrapper context." - (or (car old-spans) before-span after-span)) - -(defun ebox-buffer--child-splice-rendered-with-wrapper-context - (owner rendered old-child-ids new-child-ids old-spans before-span after-span) - "Return RENDERED with outer wrapper context restored." - (if-let* ((span - (ebox-buffer--child-splice-wrapper-span - old-spans before-span after-span)) - (owners (ebox--span-wrapper-owners span)) - (child-id-set - (ebox--region-id-set - (append old-child-ids new-child-ids))) - (splice-owner-id (plist-get owner :node-id)) - (context-owners - (cl-remove-if-not - (lambda (candidate-owner-id) - (and (not (equal candidate-owner-id splice-owner-id)) - (not (gethash candidate-owner-id child-id-set)))) - owners))) - (ebox--string-with-preserved-wrapper-context - rendered - context-owners - (ebox--string-content-owner-set rendered) - span) - rendered)) - -(defun ebox-buffer--child-splice-prepared (owner dirty context) - "Return prepared child splice plist for OWNER from CONTEXT." - (let* ((old-spans (plist-get context :old-spans)) - (raw-rendered (plist-get context :rendered)) - (old-child-ids (plist-get context :old-child-ids)) - (new-child-ids (plist-get context :new-child-ids)) - (before-span (plist-get context :before-span)) - (after-span (plist-get context :after-span)) - (rendered - (and raw-rendered - (ebox-buffer--child-splice-rendered-with-wrapper-context - owner raw-rendered old-child-ids new-child-ids - old-spans before-span after-span))) - (bounds (ebox-buffer--child-splice-bounds - old-spans before-span after-span)) - (insert (ebox-buffer--child-splice-insert-text - rendered old-spans before-span after-span))) - (when (and bounds (or old-spans rendered)) - (list :op 'child-splice - :owner-id (plist-get owner :node-id) - :owner-type (plist-get owner :ebox-type) - :dirty dirty - :spans old-spans - :bounds bounds - :insert insert - :rendered rendered - :old-refresh-spans (list bounds) - :new-refresh-spans - (list (cons (car bounds) - (+ (car bounds) (length insert)))))))) - -(defun ebox-buffer--child-splice-publication - (buffer owner dirty window groups) - "Return prepared BUFFER splice publication for OWNER child ranges." - (ebox--with-buffer-render-context buffer - (with-current-buffer buffer - (let* ((ebox--defer-scroll-content-index t) - (old-middle (plist-get window :old-middle)) - (context - (ebox-buffer--child-splice-context - owner dirty window groups))) - (when (cl-every #'identity (mapcar (lambda (id) - (gethash id groups)) - old-middle)) - (ebox-buffer--child-splice-prepared - owner dirty context)))))) - -(defun ebox-buffer-prepare-child-splice (buffer op) - "Prepare one vertical keyed child splice OP for BUFFER." - (let* ((owner-id (plist-get op :owner-id)) - (dirty (plist-get op :dirty)) - (owner (ebox--buffer-runtime-node buffer owner-id)) - (snapshot (ebox--ensure-layout-snapshot-spans buffer owner-id)) - (old-ids (plist-get dirty :old-child-ids)) - (new-ids (plist-get dirty :new-child-ids)) - (window (ebox-buffer--child-list-window old-ids new-ids)) - (old-middle (plist-get window :old-middle)) - (new-middle (plist-get window :new-middle)) - (groups (ebox-buffer--child-reorder-span-groups buffer old-ids))) - (when (and groups - (or old-middle new-middle) - (not (and old-middle new-middle)) - (ebox-buffer--child-splice-candidate-p - owner snapshot dirty old-ids new-ids)) - (unless (with-current-buffer buffer - (ebox-buffer--partial-line-slots-p - (plist-get snapshot :buffer-spans))) - (when (ebox-buffer--child-splice-simple-new-children-p - owner new-middle) - (ebox-buffer--child-splice-publication - buffer owner dirty window groups)))))) - -(defun ebox-buffer-publish-prepared-child-reorder (buffer prepared) - "Publish PREPARED child reorder into BUFFER." - (with-current-buffer buffer - (let* ((spans (plist-get prepared :spans)) - (rendered - (ebox-buffer--child-reorder-rendered - (plist-get prepared :segments) - spans - (plist-get prepared :dirty))) - (owner-set - (ebox-buffer--replacement-owner-set - rendered (plist-get prepared :dirty))) - (new-spans - (ebox--replace-line-spans spans rendered owner-set))) - (when new-spans - (list :owner-id (plist-get prepared :owner-id) - :op 'child-reorder - :owner-type (plist-get prepared :owner-type) - :span-count (length spans) - :affected-region-ids - (ebox-buffer--replacement-region-ids spans rendered) - :old-buffer-spans spans - :new-buffer-spans new-spans))))) - -(defun ebox-buffer--inserted-rendered-spans (start rendered insert) - "Return actual rendered spans from START after inserting INSERT." - (let* ((prefix (- (length insert) (length rendered))) - (render-start (if (and (> prefix 0) - (= (aref insert 0) ?\n)) - (1+ start) - start)) - (render-end (+ render-start (length rendered)))) - (ebox--buffer-line-spans-between render-start render-end))) - -(defun ebox-buffer--publish-child-splice-text (prepared) - "Publish PREPARED splice text and return inserted rendered spans." - (let ((bounds (plist-get prepared :bounds)) - (insert (plist-get prepared :insert)) - (rendered (plist-get prepared :rendered)) - inserted-spans) - (ebox--with-preserved-buffer-window-state (current-buffer) - (let ((inhibit-read-only t) - (point-marker (copy-marker (point) t))) - (save-excursion - (goto-char (car bounds)) - (delete-region (car bounds) (cdr bounds)) - (insert insert) - (when rendered - (setq inserted-spans - (ebox-buffer--inserted-rendered-spans - (car bounds) rendered insert)))) - (goto-char (min (marker-position point-marker) (point-max))) - (set-marker point-marker nil))) - inserted-spans)) - -(defun ebox-buffer--child-splice-result - (prepared affected-region-ids inserted-spans) - "Return publication result for PREPARED child splice." - (let ((dirty (plist-get prepared :dirty))) - (list :owner-id (plist-get prepared :owner-id) - :op 'child-splice - :owner-type (plist-get prepared :owner-type) - :span-count (length (plist-get prepared :old-refresh-spans)) - :rendered (plist-get prepared :rendered) - :rendered-buffer-spans inserted-spans - :affected-region-ids affected-region-ids - :old-owner-region-ids (plist-get dirty :old-region-ids) - :new-owner-region-ids (plist-get dirty :new-region-ids) - :old-buffer-spans (plist-get prepared :spans) - :new-buffer-spans inserted-spans - :old-refresh-spans (plist-get prepared :old-refresh-spans) - :new-refresh-spans (plist-get prepared :new-refresh-spans)))) - -(defun ebox-buffer-publish-prepared-child-splice (buffer prepared) - "Publish PREPARED child splice into BUFFER." - (with-current-buffer buffer - (when (plist-get prepared :bounds) - (let* ((affected-region-ids - (ebox-buffer--replacement-region-ids - (plist-get prepared :spans) - (or (plist-get prepared :rendered) ""))) - (inserted-spans - (ebox-buffer--publish-child-splice-text prepared))) - (ebox-buffer--child-splice-result - prepared affected-region-ids inserted-spans))))) - -(defun ebox-buffer-prepare-owner-rerender (buffer op) - "Prepare owner rerender OP for BUFFER without modifying the buffer." - (let* ((owner-id (plist-get op :owner-id)) - (dirty (plist-get op :dirty)) - (snapshot (ebox--ensure-layout-snapshot-spans buffer owner-id)) - (node (ebox--buffer-runtime-node buffer owner-id)) - (snapshot-spans (plist-get snapshot :buffer-spans)) - (spans - (or (and node snapshot-spans - (with-current-buffer buffer - (ebox-buffer--contained-flex-publication-spans - buffer owner-id node snapshot-spans - (append (plist-get dirty :old-region-ids) - (plist-get op :old-owner-region-ids))))) - snapshot-spans)) - (replaced-owner-ids - (with-current-buffer buffer - (ebox-buffer--owners-inside-replacement-boundary - spans (plist-get op :old-owner-region-ids)))) - (requires-owned-overflow-coverage - (plist-get dirty :requires-owned-overflow-coverage)) - (coverage-owner-proven - (or (not requires-owned-overflow-coverage) - (equal owner-id (ebox--buffer-root-node-id buffer)) - (ebox--owned-overflow-coverage-wrapper-node-p - buffer owner-id))) - (partial-slot - (and spans - (with-current-buffer buffer - (ebox-buffer--partial-line-slots-p spans)))) - (coverage-partial-slot-eligible - (or (not requires-owned-overflow-coverage) - (not partial-slot) - (equal (plist-get dirty :changed-keys) '(:overflow)))) - (root-owner-p - (equal owner-id (ebox--buffer-root-node-id buffer)))) - (when (and node spans coverage-owner-proven - coverage-partial-slot-eligible) - (ebox--with-buffer-render-context buffer - (with-current-buffer buffer - (let* ((ebox--defer-scroll-content-index t) - (rendered - (ebox-buffer--render-owner-for-rerender - buffer owner-id node snapshot dirty root-owner-p)) - (root-render-metadata - (and root-owner-p - (fboundp 'ebox--rendered-root-metadata) - (ebox--rendered-root-metadata rendered nil))) - (rendered-owner-set - (and (not root-owner-p) - (ebox-buffer--replacement-owner-set - rendered dirty replaced-owner-ids))) - (slot-rendered - (and partial-slot - (ebox-buffer--rendered-in-existing-slots - spans rendered rendered-owner-set t))) - (partial-slot-compatible - (or (not partial-slot) slot-rendered)) - (coverage-partial-slot-safe - (or (not requires-owned-overflow-coverage) - (not partial-slot) - (and slot-rendered - (equal (plist-get dirty :changed-keys) - '(:overflow)) - (= (ebox-string-height rendered) - (length spans))))) - (slot-preserving (not (null slot-rendered))) - (patched-rendered (or slot-rendered rendered)) - (root-publication - (and root-owner-p - partial-slot-compatible - coverage-partial-slot-safe - (ebox-buffer--prepare-root-property-patches - spans patched-rendered))) - (publishable-p - (and partial-slot-compatible - coverage-partial-slot-safe - (if root-owner-p - root-publication - (ebox-buffer--line-replacement-feasible-p - spans patched-rendered))))) - (when publishable-p - (list :op 'owner-rerender - :owner-id owner-id - :owner-type (plist-get node :ebox-type) - :dirty dirty - :spans spans - :rendered patched-rendered - :raw-rendered rendered - :rendered-owner-set rendered-owner-set - :old-owner-region-ids - (plist-get op :old-owner-region-ids) - :new-owner-region-ids - (ebox--node-all-region-ids node) - :affected-region-ids - (ebox-buffer--replacement-region-ids - spans patched-rendered) - :slot-preserving slot-preserving - :root-owner-p root-owner-p - :root-render-metadata root-render-metadata - :root-publication root-publication)))))))) - -(defun ebox-buffer-publish-prepared-owner-rerender (buffer prepared) - "Publish PREPARED owner rerender into BUFFER." - (with-current-buffer buffer - (let* ((owner-id (plist-get prepared :owner-id)) - (spans (plist-get prepared :spans)) - (root-owner-p (plist-get prepared :root-owner-p)) - (root-publication - (and root-owner-p - (ebox-buffer--publish-prepared-root-property-patches - (plist-get prepared :root-publication)))) - (new-spans - (if root-owner-p - (plist-get root-publication :new-buffer-spans) - (ebox--replace-line-spans - spans - (plist-get prepared :rendered) - (plist-get prepared :rendered-owner-set))))) - (when new-spans - (list :owner-id owner-id - :op 'owner-rerender - :owner-type (plist-get prepared :owner-type) - :span-count (length spans) - :rendered (plist-get prepared :rendered) - :slot-preserving (plist-get prepared :slot-preserving) - :root-render-metadata - (plist-get prepared :root-render-metadata) - :root-rendered - (and root-owner-p (plist-get prepared :raw-rendered)) - :affected-region-ids - (plist-get prepared :affected-region-ids) - :old-owner-region-ids - (plist-get prepared :old-owner-region-ids) - :new-owner-region-ids - (plist-get prepared :new-owner-region-ids) - :publication-scope - (and root-owner-p - (if (plist-get root-publication - :full-root-required-p) - 'full-root - 'minimal-spans)) - :published-buffer-spans - (and root-owner-p - (plist-get root-publication - :published-buffer-spans)) - :full-root-required-p - (and root-owner-p - (plist-get root-publication - :full-root-required-p)) - :old-buffer-spans spans - :new-buffer-spans new-spans))))) - -(defun ebox-buffer-prepare-span-patch (buffer op) - "Prepare verified span OP for BUFFER without modifying the buffer." - (let* ((owner-id (plist-get op :owner-id)) - (dirty (plist-get op :dirty)) - (snapshot (ebox--ensure-layout-snapshot-details buffer owner-id)) - (spans (plist-get snapshot :buffer-spans)) - (old-span-footprint - (plist-get snapshot :span-footprint-signature)) - (old-external-footprint - (plist-get snapshot :external-footprint-signature)) - (old-parent-slot - (plist-get snapshot :parent-slot-signature)) - (node (ebox--buffer-runtime-node buffer owner-id))) - (when (and node spans old-span-footprint old-external-footprint - old-parent-slot - (not (ebox--node-visible-overflow-p node))) - (ebox--with-buffer-render-context buffer - (let* ((ebox--defer-scroll-content-index t) - (rendered - (or (ebox--render-node-in-current-flex-slot - buffer owner-id snapshot - (plist-get dirty :changed-keys)) - (ebox-render node))) - (rendered-owner-set - (ebox-buffer--replacement-owner-set rendered dirty)) - (slot-rendered - (and (ebox-buffer--inline-slot-span-patch-op-p op) - (with-current-buffer buffer - (ebox-buffer--rendered-in-existing-slots - spans rendered)))) - (candidate-rendered (or slot-rendered rendered)) - (new-span-footprint - (ebox--rendered-span-footprint-signature rendered)) - (candidate-span-footprint - (and slot-rendered - (ebox--rendered-span-footprint-signature - candidate-rendered))) - (new-external-footprint - (ebox--external-footprint-signature-from-span-footprint - new-span-footprint)) - (candidate-external-footprint - (and candidate-span-footprint - (ebox--external-footprint-signature-from-span-footprint - candidate-span-footprint))) - (new-parent-slot - (ebox--project-parent-slot-signature - old-parent-slot new-span-footprint)) - (candidate-parent-slot - (and candidate-span-footprint - (ebox--project-parent-slot-signature - old-parent-slot candidate-span-footprint))) - (use-slot - (and candidate-span-footprint - (not (and (ebox--span-footprint-compatible-p - old-span-footprint new-span-footprint) - (ebox--external-footprint-compatible-p - old-external-footprint new-external-footprint) - (ebox--parent-slot-compatible-p - old-parent-slot new-parent-slot))) - (ebox--span-footprint-compatible-p - old-span-footprint candidate-span-footprint) - (ebox--external-footprint-compatible-p - old-external-footprint candidate-external-footprint) - (ebox--parent-slot-compatible-p - old-parent-slot candidate-parent-slot))) - (final-span-footprint - (if use-slot candidate-span-footprint new-span-footprint)) - (final-external-footprint - (if use-slot candidate-external-footprint - new-external-footprint)) - (final-parent-slot - (if use-slot candidate-parent-slot new-parent-slot)) - (final-rendered - (if use-slot candidate-rendered rendered)) - (compatible-p - (or use-slot - (and (ebox--span-footprint-compatible-p - old-span-footprint new-span-footprint) - (ebox--external-footprint-compatible-p - old-external-footprint new-external-footprint) - (ebox--parent-slot-compatible-p - old-parent-slot new-parent-slot))))) - (when (and compatible-p - (ebox-buffer--line-replacement-feasible-p - spans final-rendered)) - (list :op 'span-patch - :owner-id owner-id - :owner-type (plist-get node :ebox-type) - :dirty dirty - :spans spans - :rendered final-rendered - :rendered-owner-set rendered-owner-set - :old-owner-region-ids - (plist-get op :old-owner-region-ids) - :new-owner-region-ids - (ebox--node-all-region-ids node) - :affected-region-ids - (ebox-buffer--replacement-region-ids - spans final-rendered) - :slot-preserving use-slot - :old-span-footprint-signature old-span-footprint - :new-span-footprint-signature final-span-footprint - :old-external-footprint-signature old-external-footprint - :new-external-footprint-signature final-external-footprint - :old-parent-slot-signature old-parent-slot - :new-parent-slot-signature final-parent-slot))))))) - -(defun ebox-buffer-publish-prepared-span-patch (buffer prepared) - "Publish PREPARED verified span patch into BUFFER." - (with-current-buffer buffer - (let* ((spans (plist-get prepared :spans)) - (new-spans - (ebox--replace-line-spans - spans - (plist-get prepared :rendered) - (plist-get prepared :rendered-owner-set)))) - (when new-spans - (list :owner-id (plist-get prepared :owner-id) - :op 'span-patch - :owner-type (plist-get prepared :owner-type) - :span-count (length spans) - :rendered (plist-get prepared :rendered) - :slot-preserving (plist-get prepared :slot-preserving) - :affected-region-ids - (plist-get prepared :affected-region-ids) - :old-owner-region-ids - (plist-get prepared :old-owner-region-ids) - :new-owner-region-ids - (plist-get prepared :new-owner-region-ids) - :old-buffer-spans spans - :new-buffer-spans new-spans - :old-signature - (plist-get - (plist-get prepared :old-span-footprint-signature) - :line-pixel-widths) - :new-signature - (plist-get - (plist-get prepared :new-span-footprint-signature) - :line-pixel-widths) - :old-span-footprint-signature - (plist-get prepared :old-span-footprint-signature) - :new-span-footprint-signature - (plist-get prepared :new-span-footprint-signature) - :old-external-footprint-signature - (plist-get prepared :old-external-footprint-signature) - :new-external-footprint-signature - (plist-get prepared :new-external-footprint-signature) - :old-parent-slot-signature - (plist-get prepared :old-parent-slot-signature) - :new-parent-slot-signature - (plist-get prepared :new-parent-slot-signature)))))) - -(defun ebox--execute-owner-rerender (buffer op) - "Execute owner rerender OP in BUFFER and return patch metadata." - (let* ((owner-id (plist-get op :owner-id)) - (dirty (plist-get op :dirty)) - (snapshot (ebox--ensure-layout-snapshot-spans buffer owner-id)) - (spans (plist-get snapshot :buffer-spans)) - (node (ebox--buffer-runtime-node buffer owner-id)) - (requires-owned-overflow-coverage - (plist-get dirty :requires-owned-overflow-coverage)) - (coverage-owner-proven - (or (not requires-owned-overflow-coverage) - (equal owner-id (ebox--buffer-root-node-id buffer)) - (ebox--owned-overflow-coverage-wrapper-node-p - buffer owner-id))) - (root-owner-p - (equal owner-id (ebox--buffer-root-node-id buffer)))) - (when (and node spans) - (ebox--with-buffer-render-context buffer - (with-current-buffer buffer - (let* ((ebox--defer-scroll-content-index t) - (prepared-root-patch - (and root-owner-p - (fboundp 'ebox--take-prepared-root-patch) - (ebox--take-prepared-root-patch node))) - (rendered - (unless prepared-root-patch - (ebox-buffer--render-owner-for-rerender - buffer owner-id node snapshot dirty root-owner-p))) - (root-render-metadata - (and root-owner-p - (if prepared-root-patch - (plist-get prepared-root-patch - :root-render-metadata) - (and - (fboundp 'ebox--rendered-root-metadata) - ;; Cold visible output starts with a lightweight - ;; descriptor. Scroll roots that cannot use - ;; marker-only recovery upgrade it on demand; - ;; prewarming prepares it before publication. A - ;; declarative candidate has no complete pre-render, - ;; so its root owner prepares exact indexes from the - ;; one root string it already had to render. - (ebox--rendered-root-metadata - rendered - (not - (eq ebox-incremental--declarative-commit-buffer - buffer))))))) - (rendered-owner-set - (and (not root-owner-p) - (ebox-buffer--replacement-owner-set rendered dirty))) - (partial-slot - (ebox-buffer--partial-line-slots-p spans)) - (slot-rendered - (and (not prepared-root-patch) partial-slot - (ebox-buffer--rendered-in-existing-slots - spans rendered rendered-owner-set t))) - (partial-slot-compatible - (or (not partial-slot) slot-rendered)) - (coverage-partial-slot-safe - (or (not requires-owned-overflow-coverage) - (not partial-slot) - (and slot-rendered - (equal (plist-get dirty :changed-keys) - '(:overflow)) - (= (ebox-string-height rendered) - (length spans))))) - (slot-preserving (not (null slot-rendered))) - (patched-rendered (or slot-rendered rendered)) - (root-publication - (and root-owner-p - coverage-owner-proven - partial-slot-compatible - coverage-partial-slot-safe - (if prepared-root-patch - (ebox-buffer--apply-root-property-patches - spans prepared-root-patch) - (ebox-buffer--replace-root-with-property-patches - spans patched-rendered)))) - (new-spans - (and coverage-owner-proven - partial-slot-compatible - coverage-partial-slot-safe - (if root-owner-p - (plist-get root-publication :new-buffer-spans) - (ebox--replace-line-spans - spans patched-rendered rendered-owner-set))))) - (when new-spans - (list :owner-id owner-id - :op 'owner-rerender - :owner-type (plist-get node :ebox-type) - :span-count (length spans) - :slot-preserving slot-preserving - :root-render-metadata root-render-metadata - :root-rendered - (and root-owner-p (not prepared-root-patch) rendered) - :patch-origin - (and prepared-root-patch - (plist-get root-publication :patch-origin)) - :full-frame-bytes - (and prepared-root-patch - (plist-get root-publication :full-frame-bytes)) - :replacement-bytes - (and prepared-root-patch - (plist-get root-publication :replacement-bytes)) - :role-delta - (and prepared-root-patch - (plist-get root-publication :role-delta)) - :affected-region-ids - (and prepared-root-patch - (plist-get root-publication - :affected-region-ids)) - :publication-scope - (and root-owner-p - (if (plist-get root-publication - :full-root-required-p) - 'full-root - 'minimal-spans)) - :published-buffer-spans - (and root-owner-p - (plist-get root-publication - :published-buffer-spans)) - :full-root-required-p - (and root-owner-p - (plist-get root-publication - :full-root-required-p)) - :old-buffer-spans spans - :new-buffer-spans new-spans)))))))) - -(defun ebox--execute-span-patch - (buffer op &optional pre-rendered pre-rendered-owner-set) - "Execute signature-stable span patch OP in BUFFER and return metadata. -PRE-RENDERED and PRE-RENDERED-OWNER-SET reuse an equivalent render already -produced by the caller." - (let* ((owner-id (plist-get op :owner-id)) - (dirty (plist-get op :dirty)) - (snapshot (ebox--ensure-layout-snapshot-details buffer owner-id)) - (spans (plist-get snapshot :buffer-spans)) - (old-span-footprint - (plist-get snapshot :span-footprint-signature)) - (old-external-footprint - (plist-get snapshot :external-footprint-signature)) - (old-parent-slot - (plist-get snapshot :parent-slot-signature)) - (node (ebox--buffer-runtime-node buffer owner-id))) - (when (and node spans old-span-footprint old-external-footprint - old-parent-slot - (not (ebox--node-visible-overflow-p node))) - (ebox--with-buffer-render-context buffer - (let* ((ebox--defer-scroll-content-index t) - (rendered - (or pre-rendered - (ebox--render-node-in-current-flex-slot - buffer owner-id snapshot - (plist-get dirty :changed-keys)) - (ebox-render node))) - (rendered-owner-set - (or pre-rendered-owner-set - (ebox-buffer--replacement-owner-set rendered dirty))) - (slot-rendered - (and (ebox-buffer--inline-slot-span-patch-op-p op) - (with-current-buffer buffer - (ebox-buffer--rendered-in-existing-slots - spans rendered)))) - (candidate-rendered (or slot-rendered rendered)) - (new-span-footprint - (ebox--rendered-span-footprint-signature rendered)) - (candidate-span-footprint - (and slot-rendered - (ebox--rendered-span-footprint-signature - candidate-rendered))) - (new-external-footprint - (ebox--external-footprint-signature-from-span-footprint - new-span-footprint)) - (candidate-external-footprint - (and candidate-span-footprint - (ebox--external-footprint-signature-from-span-footprint - candidate-span-footprint))) - (new-parent-slot - (ebox--project-parent-slot-signature - old-parent-slot new-span-footprint)) - (candidate-parent-slot - (and candidate-span-footprint - (ebox--project-parent-slot-signature - old-parent-slot candidate-span-footprint))) - (use-slot - (and candidate-span-footprint - (not (and (ebox--span-footprint-compatible-p - old-span-footprint new-span-footprint) - (ebox--external-footprint-compatible-p - old-external-footprint new-external-footprint) - (ebox--parent-slot-compatible-p - old-parent-slot new-parent-slot))) - (ebox--span-footprint-compatible-p - old-span-footprint candidate-span-footprint) - (ebox--external-footprint-compatible-p - old-external-footprint candidate-external-footprint) - (ebox--parent-slot-compatible-p - old-parent-slot candidate-parent-slot))) - (final-span-footprint - (if use-slot candidate-span-footprint new-span-footprint)) - (final-external-footprint - (if use-slot candidate-external-footprint - new-external-footprint)) - (final-parent-slot - (if use-slot candidate-parent-slot new-parent-slot)) - (final-rendered - (if use-slot candidate-rendered rendered))) - (when (or use-slot - (and (ebox--span-footprint-compatible-p - old-span-footprint new-span-footprint) - (ebox--external-footprint-compatible-p - old-external-footprint new-external-footprint) - (ebox--parent-slot-compatible-p - old-parent-slot new-parent-slot))) - (with-current-buffer buffer - (when-let ((new-spans - (ebox--replace-line-spans - spans final-rendered rendered-owner-set))) - (list :owner-id owner-id - :op 'span-patch - :owner-type (plist-get node :ebox-type) - :span-count (length spans) - :slot-preserving use-slot - :old-buffer-spans spans - :new-buffer-spans new-spans - :old-signature - (plist-get old-span-footprint :line-pixel-widths) - :new-signature - (plist-get final-span-footprint :line-pixel-widths) - :old-span-footprint-signature old-span-footprint - :new-span-footprint-signature final-span-footprint - :old-external-footprint-signature old-external-footprint - :new-external-footprint-signature - final-external-footprint - :old-parent-slot-signature old-parent-slot - :new-parent-slot-signature - final-parent-slot))))))))) - -(defun ebox-buffer-apply-paint-patch (buffer op) - "Apply paint patch OP to BUFFER." - (ebox--execute-paint-patch buffer op)) - -(defun ebox-buffer-apply-span-patch - (buffer op &optional pre-rendered pre-rendered-owner-set) - "Apply signature-stable span patch OP to BUFFER. -PRE-RENDERED and PRE-RENDERED-OWNER-SET avoid repeating caller-owned render -work when they describe the same owner and render context as OP." - (ebox--execute-span-patch - buffer op pre-rendered pre-rendered-owner-set)) - -(defun ebox-buffer-apply-owner-rerender (buffer op) - "Apply owner rerender OP to BUFFER." - (or (and (not (plist-get (plist-get op :dirty) - :requires-owned-overflow-coverage)) - (ebox--execute-flex-line-rerender buffer op)) - (ebox--execute-owner-rerender buffer op))) - -(defun ebox--region-find (region-id type) - "Find all regions of TYPE for REGION-ID in current buffer. -TYPE is a symbol: content, pt, pb, pl, pr, mt, mb, ml, mr, bt, bb, bl, br. -Returns list of (start . end) cons cells." - (if-let ((table (ebox-buffer--region-role-span-table))) - (copy-sequence - (gethash (ebox-buffer--region-role-key region-id type) table)) - (ebox-buffer--scan-region-role-spans region-id type))) - -(defun ebox--patch-region-props (region-id type fn) - "Call FN on every buffer span of TYPE belonging to REGION-ID. -FN receives (start end) and should update text properties in place. -Runs inside `save-excursion' with `inhibit-read-only' set." - (let ((spans (ebox--region-find region-id type)) - (inhibit-read-only t)) - (save-excursion - (dolist (span spans) - (funcall fn (car span) (cdr span)))))) - -(defun ebox--region-replace-line (region-id idx string) - "Replace content at line IDX in REGION-ID with STRING. -Internal primitive used by the scroll fast-path." - (let ((inhibit-read-only t)) - (save-excursion - (goto-char (point-min)) - (catch 'done - (while (< (point) (point-max)) - (when (and (eql (get-text-property (point) 'ebox-content) region-id) - (eql (get-text-property (point) 'ebox-content-idx) idx)) - (let ((end (next-single-property-change - (point) 'ebox-content nil (point-max))) - (start (point)) - (props (copy-sequence (text-properties-at (point))))) - (setq props (plist-put props 'ebox-content region-id)) - (setq props (plist-put props 'ebox-content-idx idx)) - (delete-region (point) end) - (insert (apply #'propertize string props)) - (throw 'done (cons start (point))))) - (goto-char (next-single-property-change - (point) 'ebox-content nil (point-max)))))))) - -(defun ebox--full-rerender-buffer (buffer) - "Re-render BUFFER from the root node stored in its render state. -Called after any geometry change so that stack row-pads are recalculated -against the new box sizes." - (ebox--with-render-gc - (when-let ((root (ebox--buffer-root-node buffer))) - (ebox--with-buffer-render-context buffer - (ebox--with-preserved-buffer-window-state buffer - (with-current-buffer buffer - (let ((inhibit-read-only t)) - (ebox--clear-buffer-extents buffer) - (erase-buffer) - (let ((start (point))) - (insert (ebox-render root)) - (ebox--register-box-extents-in-range start (point)) - (when (fboundp 'ebox--refresh-buffer-scroll-content-markers) - (ebox--refresh-buffer-scroll-content-markers buffer)) - (ebox-buffer-refresh-region-role-spans buffer) - (ebox--refresh-buffer-layout-snapshots buffer)) - (goto-char (point-min))))))))) - -(defun ebox-buffer-apply-root-rerender (buffer) - "Apply a root rerender to BUFFER." - (ebox--full-rerender-buffer buffer)) - -(defun ebox--rerender-box (region-id) - "Rerender REGION-ID by replacing its whole contiguous buffer slice. -Currently this only works for boxes whose rendered output is contiguous in the -buffer, e.g. standalone boxes or purely vertical stacks." - (let* ((box (or (gethash region-id ebox--region-box-table) - (error "No box found for region %S" region-id))) - (extents (or (gethash region-id ebox--box-extents) - (error "No contiguous extents found for region %S" region-id))) - (start-marker (car extents)) - (end-marker (cdr extents)) - (buffer (marker-buffer start-marker))) - (unless (and buffer (eq buffer (marker-buffer end-marker))) - (error "Dead extents for region %S" region-id)) - (with-current-buffer buffer - (ebox--with-preserved-buffer-window-state buffer - (let* ((start (marker-position start-marker)) - (end (marker-position end-marker)) - (inhibit-read-only t) - (pt (point)) - (pt-offset (when (and (>= pt start) (< pt end)) - (- pt start))) - rendered) - (ebox--with-render-gc - (save-excursion - (goto-char start) - (delete-region start end) - ;; BOX already owns its stable :region-id. A dynamic override - ;; here would leak through recursive child rendering and assign - ;; every descendant border/content span to REGION-ID. - (setq rendered (ebox-render box)) - (insert rendered) - (ebox--set-box-extents region-id start (point))) - (when pt-offset - (goto-char (min (+ start pt-offset) (point-max)))) - rendered)))))) - (provide 'ebox-buffer-backend) ;;; ebox-buffer-backend.el ends here diff --git a/ebox-incremental.el b/ebox-incremental.el index 568d3dc..7328f7b 100644 --- a/ebox-incremental.el +++ b/ebox-incremental.el @@ -10,37 +10,11 @@ (require 'ebox-cache) (require 'ebox-fragment) (require 'ebox-flex) +(require 'ebox-tree) (require 'ebox-buffer-backend) (require 'ebox-render-context) (defvar ebox-region-types) -(declare-function ebox--clear-buffer-extents - "ebox" (&optional buffer preserve-template)) -(declare-function ebox--clear-box-extents-for-region-ids - "ebox" (region-ids)) -(declare-function ebox--install-box-extent-template - "ebox" (buffer template start)) -(declare-function ebox--materialize-box-extent-template - "ebox" (buffer)) -(declare-function ebox--refresh-box-extents-for-region-ids-in-spans - "ebox" (region-ids spans)) -(declare-function ebox--refresh-box-extents-from-role-spans - "ebox" (region-ids spans &optional active-segments)) -(declare-function ebox--register-box-extents-in-range - "ebox" (start end &optional region-id-allow-set)) -(declare-function ebox--live-box-extents - "ebox" (region-id)) -(declare-function ebox--refresh-buffer-scroll-content-markers - "ebox" (buffer)) -(declare-function ebox--refresh-patched-scroll-markers - "ebox" - (buffer affected-region-ids published-spans - &optional scroll-content-template root-start)) -(declare-function ebox--publish-scoped-owner-scroll-windows - "ebox" - (buffer results source-node-ids - &optional pre-containing-region-ids - rebuilt-region-ids final-spans-by-result)) (declare-function ebox--scroll-state-region-ids-containing-node-ids "ebox" (buffer node-ids)) (declare-function ebox--scroll-cache-side-effects-for-region-ids @@ -51,31 +25,6 @@ "ebox" (actions)) (declare-function ebox--replay-scroll-cache-actions "ebox" (actions &optional portable)) -(declare-function ebox-buffer--region-role-key - "ebox-buffer-backend" (region-id role)) -(declare-function ebox-buffer--region-role-span-table - "ebox-buffer-backend" ()) -(declare-function ebox-buffer-refresh-region-role-spans - "ebox-buffer-backend" (buffer)) -(declare-function ebox-buffer--copy-frozen-region-role-span-table - "ebox-buffer-backend" (table)) -(declare-function ebox-buffer--install-region-role-span-template - "ebox-buffer-backend" (buffer template start)) -(declare-function ebox-buffer-refresh-region-role-spans-for-region-ids - "ebox-buffer-backend" - (buffer region-ids spans &optional old-spans rendered-spans)) -(declare-function ebox-buffer-apply-role-delta - "ebox-buffer-backend" (buffer delta)) -(declare-function ebox-buffer-publish-prepared-root - "ebox-buffer-backend" (buffer rendered)) -(declare-function ebox-buffer--preflight-native-root-property-patches - "ebox-buffer-backend" (spans native-patch)) -(declare-function ebox-buffer--publish-native-root-property-patch-text - "ebox-buffer-backend" (plan)) -(declare-function ebox-buffer-prepare-child-splice - "ebox-buffer-backend" (buffer op)) -(declare-function ebox-buffer-publish-prepared-child-splice - "ebox-buffer-backend" (buffer prepared)) (declare-function ebox-tree-validate-declarative-root "ebox-tree" (root)) (declare-function ebox-tree-clear-runtime-identities @@ -88,24 +37,18 @@ "ebox-tree" (old new)) (declare-function ebox-tree-runtime-identity-snapshot "ebox-tree" (root)) -(declare-function ebox--rendered-root-metadata - "ebox" (rendered &optional lightweight-only)) +(declare-function ebox-surface-region-mounts + "ebox-surface" (buffer region-id &optional roles)) (declare-function ebox-string-lines "ebox" (string)) (declare-function ebox-get "ebox" (box key)) (declare-function ebox--ensure-node-id "ebox" (node)) (declare-function ebox--ensure-region-id "ebox" (box)) -(declare-function ebox--refresh-buffer-scroll-content-markers - "ebox" (buffer)) (declare-function ebox--schedule-buffer-runtime-prewarm "ebox" (buffer)) (declare-function ebox--cancel-buffer-runtime-prewarm "ebox" (buffer)) (declare-function ebox--scroll-clear-state "ebox" (region-id)) -(declare-function ebox--scroll-clear-content-markers - "ebox" (state)) -(declare-function ebox--scroll-clear-rendered-window-markers - "ebox" (state)) (declare-function ebox--scroll-cancel-idle-prefetch "ebox" (region-id)) (declare-function ebox--scroll-schedule-idle-prefetch @@ -114,8 +57,6 @@ "ebox-layout" (state box)) (declare-function ebox--smooth-scroll-stop "ebox" (region-id)) -(declare-function ebox--install-buffer-scroll-content-span-template - "ebox" (buffer template start &optional defer-window-region-set)) (declare-function ebox--node-children "ebox" (node)) (declare-function ebox--root-region-box @@ -140,8 +81,6 @@ (defvar ebox--scroll-global-state) (defvar ebox--scroll-idle-prefetch-timers) (defvar ebox--smooth-scroll-state-table) -(defvar ebox--box-extent-template) -(defvar ebox--box-extents) (defvar ebox--render-region-id) (defvar ebox--defer-scroll-content-index) (defvar ebox--flex-content-min-width-table) @@ -805,19 +744,6 @@ This normally does no work. It rebuilds metadata only after a defensive (plist-put prepared :consumed-p t) (plist-get prepared :rendered))))) -(defun ebox--take-prepared-root-patch (node) - "Consume and return a validated Rust-origin root patch for NODE." - (when (and ebox--prepared-root-render - (plist-get (plist-get ebox--prepared-root-render :native-patch) - :native-patch)) - (let ((prepared ebox--prepared-root-render)) - (setq ebox--prepared-root-render nil) - (when (and (not (eq (plist-get prepared :publication-owner) - 'prepared-root-native)) - (ebox--prepared-root-render-matches-p node prepared)) - (plist-put prepared :consumed-p t) - (prog1 (plist-get prepared :native-patch) - (ebox--flex-recache-source-boxes node)))))) (defun ebox--render-cache-context-signature (node force &optional external-signature) @@ -959,13 +885,6 @@ update REPORT.") "Global hash table storing scroll state. Keys are region-id, values are plists with :scroll-offset, :content-lines, etc.") -(defun ebox--refresh-buffer-box-extents (buffer) - "Rebuild live box extents for BUFFER from its current text properties." - (when (buffer-live-p buffer) - (with-current-buffer buffer - (ebox--clear-buffer-extents buffer) - (ebox--register-box-extents-in-range (point-min) (point-max))))) - (defun ebox--buffer-render-state (buffer) "Return render state for BUFFER." (if (and ebox-incremental--buffer-render-state-override @@ -1020,18 +939,6 @@ Keys are region-id, values are plists with :scroll-offset, :content-lines, etc." ebox--buffer-render-state-table) nil))) -(defun ebox--prepare-buffer-runtime-root (source-root target-buffer) - "Return a runtime-preserving copy of SOURCE-ROOT for TARGET-BUFFER. -Signal `user-error' before target mutation when SOURCE-ROOT reuses a region id -owned by another live buffer." - (let* ((index (ebox--runtime-index source-root)) - (conflict - (ebox--runtime-region-id-conflict - (plist-get index :region-id-set) target-buffer))) - (when conflict - (user-error "Ebox region id %S is already owned by buffer %S" - (car conflict) (cdr conflict))) - (ebox-tree-copy-node-structure source-root))) (defun ebox--runtime-index (root-node &optional collect-region-boxes) "Return persistent runtime indexes for ROOT-NODE. @@ -1234,8 +1141,7 @@ node, parent, region, Host, or native-layout indexes." :render-signature-cache (make-hash-table :test 'eq) :flex-content-min-widths (make-hash-table :test 'eq) :viewport-height-dependent-subtree-cache - (make-hash-table :test 'eq) - :region-role-span-table nil)) + (make-hash-table :test 'eq))) (defun ebox--render-state-install-index (state index) "Install runtime INDEX tables into candidate STATE and return STATE." @@ -1300,15 +1206,6 @@ recursive region path only for legacy or incomplete runtime state." (ebox--buffer-region-render-owner-node-id buffer region-id))) (ebox--buffer-runtime-node buffer node-id))) -(defun ebox--buffer-region-role-span-table (buffer) - "Return BUFFER's runtime region role span table." - (plist-get (ebox--buffer-render-state buffer) :region-role-span-table)) - -(defun ebox--set-buffer-region-role-span-table (buffer table) - "Store TABLE as BUFFER's runtime region role span table." - (when-let ((state (ebox--buffer-render-state buffer))) - (plist-put state :region-role-span-table table))) - (defun ebox--buffer-selector-id-table (buffer) "Return BUFFER's runtime selector id index." (plist-get (ebox-incremental--ensure-buffer-selector-indexes buffer) @@ -1328,11 +1225,6 @@ recursive region path only for legacy or incomplete runtime state." "Return BUFFER's persistent render cache table." (plist-get (ebox--buffer-render-state buffer) :render-cache)) -(defun ebox--buffer-render-signature-cache (buffer) - "Return BUFFER's persistent node body-signature cache." - (plist-get (ebox--buffer-render-state buffer) - :render-signature-cache)) - (defun ebox--buffer-viewport-height-subtree-cache (buffer) "Return BUFFER's persistent viewport-height subtree cache." (plist-get (ebox--buffer-render-state buffer) @@ -1541,14 +1433,6 @@ the returned copy without mutating BUFFER's stored snapshot table." (dolist (key ebox--layout-snapshot-detail-keys result) (setq result (ebox--plist-remove-key result key))))) -(defun ebox--invalidate-buffer-layout-snapshot-details (buffer) - "Invalidate BUFFER's snapshot detail fields while keeping lightweight facts." - (when-let ((state (ebox--buffer-render-state buffer))) - (plist-put state :layout-snapshot-detail-generation - (1+ (or (plist-get state - :layout-snapshot-detail-generation) - 0))))) - (defun ebox--clear-buffer-render-state (&optional buffer) "Remove stored buffer-owned runtime state for BUFFER. Defaults to the current buffer." @@ -1700,12 +1584,6 @@ Defaults to the current buffer." "Return non-nil when REGION-ID belongs to REGION-SET." (gethash region-id region-set)) -(defun ebox--region-ids-intersect-set-p (region-ids region-set) - "Return non-nil when any REGION-IDS entry belongs to REGION-SET." - (cl-some (lambda (region-id) - (ebox--region-id-member-p region-id region-set)) - region-ids)) - (defun ebox--region-ids-subset-p (region-ids region-set) "Return non-nil when every REGION-IDS entry belongs to REGION-SET." (cl-every (lambda (region-id) @@ -1790,11 +1668,6 @@ therefore unsafe." line-ranges))) (forward-line 1)))))) -(defun ebox--role-table-region-spans (table region-id role) - "Return TABLE spans for REGION-ID ROLE." - (copy-sequence - (gethash (ebox-buffer--region-role-key region-id role) table))) - (defun ebox--role-compatible-span-p (span region-set) "Return non-nil when SPAN does not cross foreign content owners." (let ((pos (car span)) @@ -1812,16 +1685,18 @@ therefore unsafe." (setq pos (max next (1+ pos))))) compatible)) -(defun ebox--region-set-line-spans-from-role-table (region-ids table) - "Return per-line spans for REGION-IDS using role span TABLE." +(defun ebox--region-set-line-spans-from-mounts (region-ids) + "Return per-line spans for REGION-IDS using TP-owned mounts." (let ((region-set (ebox--region-id-set region-ids)) (line-ranges (make-hash-table :test 'eql)) spans) (dolist (region-id region-ids) - (dolist (entry ebox-region-types) - (dolist (span (ebox--role-table-region-spans - table region-id (car entry))) - (ebox--add-role-span-line-ranges line-ranges span)))) + (dolist (mount + (ebox-surface-region-mounts + (current-buffer) region-id (mapcar #'car ebox-region-types))) + (ebox--add-role-span-line-ranges + line-ranges + (cons (plist-get mount :start) (plist-get mount :end))))) (dolist (line-start (sort (let (keys) (maphash (lambda (key _value) @@ -1834,103 +1709,13 @@ therefore unsafe." (push (copy-tree span) spans)))) (nreverse spans))) -(defun ebox--merge-scan-line-ranges (ranges) - "Return sorted, merged line RANGES." - (let (merged) - (dolist (range (sort (copy-sequence ranges) - (lambda (left right) - (< (car left) (car right)))) - (nreverse merged)) - (if (and merged (<= (car range) (cdar merged))) - (setcdr (car merged) (max (cdar merged) (cdr range))) - (push (copy-tree range) merged))))) - -(defun ebox--line-range-from-live-box-extents (extents) - "Return a line-expanded scan range from live EXTENTS in the current buffer." - (when-let* ((start-marker (car-safe extents)) - (end-marker (cdr-safe extents)) - ((markerp start-marker)) - ((markerp end-marker)) - (buffer (marker-buffer start-marker)) - ((eq buffer (current-buffer))) - ((eq buffer (marker-buffer end-marker))) - (start (marker-position start-marker)) - (end (marker-position end-marker)) - ((< start end))) - (save-excursion - (goto-char start) - (let ((line-start (line-beginning-position))) - (goto-char (max (point-min) (1- end))) - (cons line-start (line-end-position)))))) - -(defun ebox--region-set-live-extents-line-ranges (region-ids) - "Return line scan ranges for REGION-IDS, or nil if any extent is missing." - (when region-ids - (catch 'missing - (let (ranges) - (dolist (region-id region-ids) - (let ((range (and-let* ((extents - (ebox--live-box-extents region-id))) - (ebox--line-range-from-live-box-extents extents)))) - (unless range - (throw 'missing nil)) - (push range ranges))) - (ebox--merge-scan-line-ranges ranges))))) - -(defun ebox--region-set-line-spans-by-scan (region-ids &optional line-ranges) - "Return patchable per-line spans for REGION-IDS by scanning LINE-RANGES. -When LINE-RANGES is nil, scan the full current buffer." - (let ((region-set (ebox--region-id-set region-ids)) - (ranges (or line-ranges - (list (cons (point-min) (point-max))))) - spans - patchable) - (setq patchable t) - (save-excursion - (dolist (range ranges) - (when patchable - (let ((scan-start (car range)) - (scan-end (cdr range))) - (goto-char scan-start) - (while (and patchable (< (point) scan-end)) - (let ((line-start (point)) - (line-end (min (line-end-position) scan-end)) - span-start - span-end) - (let ((pos line-start)) - (while (< pos line-end) - (when (ebox--region-ids-intersect-set-p - (ebox--pos-region-ids pos) - region-set) - (unless span-start - (setq span-start pos)) - (setq span-end (1+ pos))) - (setq pos (1+ pos)))) - (when span-start - (let ((pos span-start)) - (while (and patchable (< pos span-end)) - (unless (ebox--region-role-ids-compatible-p - (ebox--pos-region-role-ids pos) - region-set) - (setq patchable nil)) - (setq pos (1+ pos)))) - (when patchable - (push (cons span-start span-end) spans)))) - (forward-line 1)))))) - (when patchable - (nreverse spans)))) - (defun ebox--region-set-line-spans (region-ids) "Return patchable per-line spans owned by REGION-IDS. If a line span would cross a foreign ebox region, return nil." (if ebox--region-line-index (ebox--region-set-line-spans-from-index region-ids ebox--region-line-index) - (or (when-let ((table (ebox-buffer--region-role-span-table))) - (ebox--region-set-line-spans-from-role-table region-ids table)) - (ebox--region-set-line-spans-by-scan - region-ids - (ebox--region-set-live-extents-line-ranges region-ids))))) + (ebox--region-set-line-spans-from-mounts region-ids))) (defun ebox--spans-line-signature (spans) "Return pixel-width signature for current buffer SPANS." @@ -2047,19 +1832,15 @@ If a line span would cross a foreign ebox region, return nil." (defun ebox--role-topology-signature (region-ids) "Return the set of rendered region roles present for REGION-IDS." - (let ((region-set (ebox--region-id-set region-ids)) - (role-set (make-hash-table :test 'eq)) + (let ((role-set (make-hash-table :test 'eq)) roles) - (if-let ((table (ebox-buffer--region-role-span-table))) - (maphash - (lambda (key spans) - (when (and spans (consp key) (gethash (car key) region-set)) - (puthash (cadr key) t role-set))) - table) + (let ((roles (mapcar #'car ebox-region-types)) + (buffer (current-buffer))) (dolist (region-id region-ids) - (dolist (entry ebox-region-types) - (when (ebox--region-find region-id (car entry)) - (puthash (car entry) t role-set))))) + (dolist (mount + (ebox-surface-region-mounts buffer region-id roles)) + (dolist (role (plist-get (plist-get mount :tags) :ebox/roles)) + (puthash role t role-set))))) (maphash (lambda (role _) (push role roles)) role-set) (list :roles (sort roles @@ -2217,27 +1998,6 @@ When DETAILS is non-nil, include expensive per-line detail fields." (dolist (child (ebox--node-children node)) (ebox--capture-layout-snapshots buffer child details))))) -(defun ebox--layout-snapshots-for-runtime (buffer node &optional details) - "Return derived layout snapshots for NODE in BUFFER without storing them. -When DETAILS is non-nil, include expensive per-line detail fields." - (let ((snapshots (make-hash-table :test 'equal)) - (ebox--node-region-ids-cache (make-hash-table :test 'eq))) - (cl-labels ((visit (current) - (when (and (buffer-live-p buffer) - (listp current)) - (let ((node-id (ebox--ensure-node-id current))) - (puthash node-id - (ebox--node-layout-snapshot - buffer current details) - snapshots) - (dolist (child (ebox--node-children current)) - (visit child)))))) - (if details - (ebox--with-layout-snapshot-index-context buffer - (visit node)) - (visit node))) - snapshots)) - (defun ebox--refresh-buffer-layout-snapshots (buffer &optional details) "Recapture BUFFER's layout snapshots from its stored runtime root. When DETAILS is non-nil, include expensive per-line detail fields." @@ -2365,15 +2125,6 @@ When DETAILS is non-nil, include expensive per-line detail fields." (when height-cache (remhash node height-cache))) (setq node-id (gethash node-id parent-table))))) -(defun ebox--invalidate-buffer-render-signatures (buffer node-id) - "Invalidate NODE-ID and ancestors in BUFFER's structural caches." - (ebox--invalidate-runtime-render-signature-path - node-id - (ebox--buffer-node-table buffer) - (ebox--buffer-parent-table buffer) - (ebox--buffer-render-signature-cache buffer) - (ebox--buffer-viewport-height-subtree-cache buffer))) - (defun ebox--runtime-ancestor-id-p (buffer ancestor-id node-id) "Return non-nil when ANCESTOR-ID is NODE-ID's ancestor in BUFFER." (let ((parent (ebox--runtime-parent-id buffer node-id)) @@ -2388,12 +2139,6 @@ When DETAILS is non-nil, include expensive per-line detail fields." "Return a patch-set operation." (append (list :op op :owner-id owner-id) props)) -(defun ebox--root-owner-patch-set (buffer dirty-set) - "Return a root owner patch set for BUFFER using DIRTY-SET provenance." - (when-let ((root-id (ebox--buffer-root-node-id buffer))) - (list (ebox--patch-op 'owner-rerender root-id - :dirty (ebox--merge-dirty-provenance-list - dirty-set))))) (defvar ebox--patchable-owner-cache nil "Dynamic patch-planning cache for `ebox--patchable-owner-p' results.") @@ -2413,8 +2158,6 @@ When DETAILS is non-nil, include expensive per-line detail fields." (defconst ebox-incremental--broad-dirty-min-node-coverage 0.8 "Minimum dirty descendant ratio for broad owner pre-coalescing.") -(defconst ebox--line-index-min-dirty-count 2 - "Minimum dirty entry count before eagerly building a buffer line index.") (defconst ebox--span-patch-local-style-keys '(:content :text-align :vertical-align :wrap-mode :scroll-offset @@ -2866,61 +2609,11 @@ When BUFFER is non-nil, non-paint entries promote to patchable owners." buffer node-id dirty-kind (plist-get entry :changed-keys) impact-vector)) + (and buffer (ebox--buffer-root-node-id buffer)) node-id))) (ebox--patch-op 'owner-rerender fallback-owner-id :dirty entry))))))) -(defun ebox--patch-ops-from-dirty-set (dirty-set &optional buffer) - "Return raw patch ops for DIRTY-SET. -When BUFFER is non-nil, merge candidates using operation-aware coverage." - (let (ops) - (dolist (entry dirty-set ops) - (let ((candidate (ebox--patch-op-from-dirty-entry entry buffer))) - (setq ops - (if buffer - (ebox--merge-patch-op-into-set buffer ops candidate) - (append ops (list candidate)))))))) - -(defun ebox--patch-set-from-dirty-set (dirty-set &optional buffer) - "Return executable patch ops for DIRTY-SET. -When BUFFER is non-nil, promote non-paint dirty nodes to patchable owners." - (let* ((needs-owner-p - (and buffer - (cl-some - (lambda (entry) - (not (eq (plist-get entry :dirty-kind) 'paint))) - dirty-set))) - (ops (if needs-owner-p - (let ((ebox--patchable-owner-cache - (make-hash-table :test 'equal)) - (ebox--runtime-ancestor-cache - (make-hash-table :test 'equal)) - (ebox--flex-slot-safety-cache - (make-hash-table :test 'equal))) - (if (cl-every - (lambda (entry) - (ebox--dirty-entry-span-patch-candidate-p - buffer entry)) - dirty-set) - (ebox--with-layout-snapshot-detail-context buffer - (ebox--patch-ops-from-dirty-set - dirty-set buffer)) - (if (< (length dirty-set) - ebox--line-index-min-dirty-count) - (ebox--with-layout-snapshot-detail-context buffer - (ebox--patch-ops-from-dirty-set - dirty-set buffer)) - (ebox--with-layout-snapshot-index-context buffer - (ebox--patch-ops-from-dirty-set - dirty-set buffer))))) - (ebox--patch-ops-from-dirty-set dirty-set buffer)))) - (if buffer - (let ((ebox--runtime-ancestor-cache - (or ebox--runtime-ancestor-cache - (make-hash-table :test 'equal)))) - (ebox--coalesce-large-owner-patch-set - buffer (ebox--dedupe-patch-owners buffer ops))) - ops))) (defun ebox--line-spans-cover-whole-lines-p (spans) "Return non-nil when every span in SPANS owns whole buffer lines." @@ -2952,7 +2645,9 @@ When BUFFER is non-nil, promote non-paint dirty nodes to patchable owners." (when-let* ((parent-id (ebox--runtime-parent-id buffer node-id)) (parent (ebox--buffer-runtime-node buffer parent-id))) (or (ebox--row-or-flex-layout-p parent) - (ebox--row-or-flex-ancestor-p buffer parent-id)))) + (ebox--row-or-flex-ancestor-p buffer parent-id) + (not (ebox--definite-containment-formatting-context-p + buffer parent-id))))) (defun ebox--definite-containment-inline-size-value-p (value) "Return non-nil when VALUE is independent of intrinsic inline content." @@ -3300,6 +2995,13 @@ publication would freeze stale sibling geometry." (with-current-buffer buffer (ebox-buffer--partial-line-slots-p spans)))) +(defun ebox--dirty-node-is-owner-style-source-p + (buffer dirty-node-id owner-id) + "Return non-nil when DIRTY-NODE-ID carries OWNER-ID's outer style." + (when-let* ((owner (ebox--buffer-runtime-node buffer owner-id)) + (style-source (ebox-fragment-style-source-node owner))) + (equal dirty-node-id (plist-get style-source :node-id)))) + (defun ebox--first-geometry-owner-candidate (buffer node-id dirty-kind &optional changed-keys impact-vector) "Return the first structurally possible patch owner for NODE-ID. @@ -3312,7 +3014,10 @@ running expensive span and snapshot checks." impact-vector) (not (and (not (equal candidate dirty-node-id)) (ebox--contained-partial-flex-owner-p - buffer candidate))) + buffer candidate) + (not + (ebox--dirty-node-is-owner-style-source-p + buffer dirty-node-id candidate)))) (ebox--geometry-affects-parent-layout-p buffer candidate) (not (ebox--flex-item-slot-safe-p buffer candidate changed-keys @@ -3396,6 +3101,9 @@ running expensive span and snapshot checks." (not parent-id) (and (not (equal candidate node-id)) (ebox--inline-size-change-p changed-keys) + (not + (ebox--dirty-node-is-owner-style-source-p + buffer node-id candidate)) (ebox--definite-containment-formatting-context-p buffer candidate)) (ebox--flex-item-slot-safe-p @@ -3474,6 +3182,10 @@ running expensive span and snapshot checks." (unless (ebox--impact-vector-requires-conservative-owner-p impact-vector) (cond ((and (ebox--dirty-entry-span-patch-candidate-p buffer entry) + (not (cl-some + (lambda (key) + (memq key ebox--span-patch-border-box-block-keys)) + (plist-get entry :changed-keys))) (ebox--cached-patchable-owner-p buffer node-id 'span) (or (not (ebox--direct-flex-parent-p buffer node-id)) (ebox--flex-item-slot-safe-p @@ -3582,604 +3294,6 @@ hash lookups instead of a scan of the whole set." (ebox--patch-op-merge-index-add buffer index op)) (ebox--patch-op-merge-index-result index))) -(defun ebox--owner-rerender-op-p (op) - "Return non-nil when OP is an owner rerender operation." - (eq (plist-get op :op) 'owner-rerender)) - -(defun ebox--text-changing-patch-op-p (op) - "Return non-nil when OP replaces rendered text spans." - (memq (plist-get op :op) - '(child-reorder child-splice span-patch owner-rerender))) - -(defun ebox--text-changing-op-spans (buffer op) - "Return buffer spans for text-changing OP." - (when (ebox--text-changing-patch-op-p op) - (or (plist-get (plist-get op :prepared-publication) :spans) - (when-let* ((owner-id (plist-get op :owner-id)) - (snapshot - (ebox--ensure-layout-snapshot-spans buffer owner-id))) - (plist-get snapshot :buffer-spans))))) - -(defun ebox--owner-rerender-op-spans (buffer op) - "Return buffer spans for owner rerender OP." - (when-let* ((owner-id (plist-get op :owner-id)) - (snapshot (ebox--ensure-layout-snapshot-spans buffer owner-id))) - (plist-get snapshot :buffer-spans))) - -(defun ebox--owner-ops-covered-line-count (buffer ops) - "Return number of unique buffer lines covered by owner rerender OPS." - (let ((line-starts (make-hash-table :test 'eql))) - (dolist (op ops) - (dolist (span (ebox--owner-rerender-op-spans buffer op)) - (puthash (car span) t line-starts))) - (hash-table-count line-starts))) - -(defun ebox--root-owner-op (buffer ops) - "Return a root owner rerender op using merged provenance from OPS." - (ebox--patch-op 'owner-rerender - (ebox--buffer-root-node-id buffer) - :dirty (ebox--merge-dirty-provenance-list - (mapcar (lambda (op) - (plist-get op :dirty)) - ops)))) - -(defun ebox--coalesce-large-owner-patch-set (buffer ops) - "Coalesce large owner rerender sets when they already cover most root lines." - (if (not (and (>= (length ops) ebox--owner-coalesce-min-count) - (cl-every #'ebox--owner-rerender-op-p ops))) - ops - (let* ((root-id (ebox--buffer-root-node-id buffer)) - (root-spans (and root-id - (ebox--owner-rerender-op-spans - buffer (ebox--patch-op - 'owner-rerender root-id)))) - (root-lines (length root-spans)) - (covered-lines (ebox--owner-ops-covered-line-count buffer ops))) - (if (and (> root-lines 0) - (>= (/ (float covered-lines) root-lines) - ebox--owner-coalesce-min-coverage)) - (list (ebox--root-owner-op buffer ops)) - ops)))) - -(defun ebox--owner-rerender-op-start (buffer op) - "Return the earliest buffer position owned by owner rerender OP." - (when (ebox--text-changing-patch-op-p op) - (when-let ((spans (ebox--text-changing-op-spans buffer op))) - (caar spans)))) - -(defun ebox--runtime-node-depth (buffer node-id) - "Return NODE-ID's depth in BUFFER's runtime tree." - (let ((depth 0) - (parent-id (ebox--runtime-parent-id buffer node-id))) - (while parent-id - (cl-incf depth) - (setq parent-id (ebox--runtime-parent-id buffer parent-id))) - depth)) - -(defun ebox--patch-set-execution-order (buffer patch-set) - "Return PATCH-SET in paint-first and bottom-up text replacement order." - (mapcar - (lambda (entry) (aref entry 4)) - (sort - (cl-loop for op in patch-set - for index from 0 - for paint-p = (eq (plist-get op :op) 'paint-patch) - collect - (vector paint-p - (and (not paint-p) - (or (ebox--owner-rerender-op-start buffer op) -1)) - (ebox--runtime-node-depth - buffer (plist-get op :owner-id)) - index - op)) - (lambda (left right) - (let ((left-paint (aref left 0)) - (right-paint (aref right 0))) - (cond - ((not (eq left-paint right-paint)) left-paint) - (left-paint - (if (/= (aref left 2) (aref right 2)) - (< (aref left 2) (aref right 2)) - (< (aref left 3) (aref right 3)))) - (t - (if (/= (aref left 1) (aref right 1)) - (> (aref left 1) (aref right 1)) - (if (/= (aref left 2) (aref right 2)) - (> (aref left 2) (aref right 2)) - (< (aref left 3) (aref right 3))))))))))) - -(defun ebox--refresh-paint-layout-snapshots (buffer patch-set) - "Refresh only paint-significant snapshot fields touched by PATCH-SET." - (dolist (op patch-set) - (when (eq (plist-get op :op) 'paint-patch) - (let* ((dirty (plist-get op :dirty)) - (node-id (plist-get dirty :node-id)) - (snapshot (and node-id - (ebox--layout-snapshot buffer node-id))) - (node (and node-id - (ebox--buffer-runtime-node buffer node-id)))) - (when (and snapshot node) - (plist-put snapshot :style-signature - (ebox--node-style-signature node))))))) - -(defun ebox--capture-layout-snapshot-spans-subtree (buffer node) - "Capture lightweight snapshots with current spans for NODE's subtree." - (when (and (buffer-live-p buffer) - (listp node)) - (let* ((node-id (ebox--ensure-node-id node)) - (snapshot (ebox--node-layout-snapshot buffer node nil))) - (ebox--put-layout-snapshot - buffer node-id - (ebox--complete-layout-snapshot-spans - buffer node snapshot))) - (dolist (child (ebox--node-children node)) - (ebox--capture-layout-snapshot-spans-subtree buffer child)))) - -(defun ebox--layout-snapshot-from-patch-result - (buffer node result &optional final-spans) - "Return NODE's current detailed snapshot using patch RESULT spans. -Span patches already computed their new footprint and parent-slot proofs, so -reuse those values instead of rebuilding a dense region-line index over the -same replacement. Owner rerenders fall back to completing the snapshot from -RESULT's exact new spans. FINAL-SPANS, when non-nil, are normalized to the -completed transaction's buffer coordinates." - (let* ((snapshot (ebox--node-layout-snapshot buffer node nil)) - (spans (or final-spans (plist-get result :new-buffer-spans))) - (span-footprint - (plist-get result :new-span-footprint-signature))) - (setq snapshot (plist-put snapshot :buffer-spans spans)) - (setq snapshot - (plist-put snapshot :detail-generation - (ebox--buffer-layout-snapshot-detail-generation - buffer))) - (if (not span-footprint) - (ebox--complete-layout-snapshot buffer node snapshot) - (setq snapshot - (plist-put snapshot :line-signature - (plist-get span-footprint :line-pixel-widths))) - (setq snapshot - (plist-put snapshot :span-footprint-signature - span-footprint)) - (setq snapshot - (plist-put snapshot :external-footprint-signature - (plist-get result - :new-external-footprint-signature))) - (setq snapshot - (plist-put snapshot :parent-slot-signature - (plist-get result :new-parent-slot-signature))) - (setq snapshot - (plist-put snapshot :role-topology-signature - (ebox--role-topology-signature - (plist-get snapshot :region-ids)))) - (setq snapshot - (plist-put snapshot :overflow-signature - (ebox--overflow-signature node))) - snapshot))) - -(defun ebox--refresh-text-patch-layout-snapshots - (buffer results &optional source-node-ids scoped-plan) - "Refresh snapshots affected by text-patched RESULTS in BUFFER. -Only each patched owner needs eager buffer-dependent details. Nodes named by -SOURCE-NODE-IDS receive fresh lightweight style/layout facts; every other -descendant keeps its retained facts and completes invalidated spans on demand -from the scoped role index. SCOPED-PLAN supplies final transaction spans for -multi-result publication without changing backend result metadata." - (let ((final-spans-by-result - (plist-get scoped-plan :final-spans-by-result))) - (ebox--invalidate-buffer-layout-snapshot-details buffer) - (with-current-buffer buffer - (let ((ebox--node-region-ids-cache (make-hash-table :test 'eq)) - owner-ids) - (dolist (result results) - (when (ebox--text-changing-patch-op-p result) - (let* ((node-id (plist-get result :owner-id)) - (node (and node-id - (ebox--buffer-runtime-node buffer node-id))) - (new-spans - (or (and final-spans-by-result - (gethash result final-spans-by-result)) - (plist-get result :new-buffer-spans)))) - (when (and node new-spans) - (cl-pushnew node-id owner-ids :test #'equal) - (ebox--put-layout-snapshot - buffer node-id - (ebox--layout-snapshot-from-patch-result - buffer node result new-spans)))))) - (dolist (source-node-id source-node-ids) - (unless (member source-node-id owner-ids) - (when-let ((source-node - (ebox--buffer-runtime-node buffer source-node-id))) - (ebox--put-layout-snapshot - buffer source-node-id - (ebox--node-layout-snapshot buffer source-node nil))))))))) - -(defun ebox--patch-results-scoped-snapshot-refreshable-p (results) - "Return non-nil when RESULTS carry complete scoped owner snapshot spans." - (let ((text-results - (cl-remove-if-not #'ebox--text-changing-patch-op-p results))) - (and text-results - (cl-every - (lambda (result) - (and (plist-get result :new-buffer-spans) - (not (plist-get result :flex-line-rerender)))) - text-results)))) - -(defun ebox--refresh-prepared-animation-root-snapshot (buffer result) - "Keep only RESULT's exact root spans current during a prepared animation." - (when-let* ((node-id (plist-get result :owner-id)) - ((equal node-id (ebox--buffer-root-node-id buffer))) - (node (ebox--buffer-runtime-node buffer node-id)) - (spans (plist-get result :new-buffer-spans))) - (let* ((snapshots (ebox--ensure-buffer-layout-snapshots buffer)) - (old (gethash node-id snapshots)) - (snapshot - (if old - (ebox--layout-snapshot-strip-details old) - (ebox--node-layout-snapshot buffer node nil)))) - (setq snapshot (plist-put snapshot :buffer-spans spans)) - (setq snapshot - (plist-put snapshot :detail-generation - (ebox--buffer-layout-snapshot-detail-generation - buffer))) - ;; Child geometry belongs to the previous width. Drop it instead of - ;; walking the whole runtime tree after every frame or at completion; - ;; the ordinary lazy snapshot path reconstructs only a later caller's - ;; requested scope. - (ebox--clear-layout-snapshots buffer) - (puthash node-id snapshot - (ebox--ensure-buffer-layout-snapshots buffer))))) - -(defun ebox--patch-result-region-ids (buffer result) - "Return runtime region ids covered by RESULT in BUFFER." - (let ((region-ids - (copy-sequence (plist-get result :affected-region-ids)))) - (let ((owner-region-ids - (or (plist-get result :new-owner-region-ids) - (when-let* ((owner-id (plist-get result :owner-id)) - (node (ebox--buffer-runtime-node buffer owner-id))) - (ebox--node-all-region-ids node))))) - (dolist (region-id owner-region-ids) - (cl-pushnew region-id region-ids :test #'equal))) - region-ids)) - -(defun ebox--patch-span-numeric-bounds (span) - "Return numeric bounds for SPAN, or nil when its bounds are invalid." - (let ((start (car-safe span)) - (end (cdr-safe span))) - (setq start (if (markerp start) (marker-position start) start) - end (if (markerp end) (marker-position end) end)) - (when (and (integerp start) (integerp end) (<= start end)) - (cons start end)))) - -(defun ebox--patch-results-scoped-refresh-plan (buffer results) - "Return one normalized scoped index refresh plan for RESULTS in BUFFER. -The returned old and new spans use the transaction's original and final -coordinates respectively. Publication executes text replacements bottom-up, -so a later upper replacement can shift a lower result's recorded new spans; -this function derives final coordinates from the complete ordered delta map -without changing the backend result plists." - (let ((text-results - (cl-remove-if-not #'ebox--text-changing-patch-op-p results)) - (root-id (ebox--buffer-root-node-id buffer))) - (when (and text-results - (cl-every - (lambda (result) - (let ((old-spans - (or (plist-get result :old-refresh-spans) - (plist-get result :old-buffer-spans))) - (new-spans - (or (plist-get result :new-refresh-spans) - (plist-get result :new-buffer-spans)))) - (and (not (equal (plist-get result :owner-id) root-id)) - (not (plist-get result :flex-line-rerender)) - old-spans new-spans - (= (length old-spans) (length new-spans))))) - text-results) - (cl-every - (lambda (result) - (memq (plist-get result :op) - '(paint-patch child-reorder child-splice - span-patch owner-rerender))) - results)) - (catch 'unsafe - (let ((final-spans-by-result (make-hash-table :test 'eq)) - entries region-ids old-owner-region-ids new-owner-region-ids - owner-region-delta-p) - (setq owner-region-delta-p - (cl-every - (lambda (result) - (and (plist-member result :old-owner-region-ids) - (plist-member result :new-owner-region-ids))) - text-results)) - (dolist (result text-results) - (when owner-region-delta-p - (setq old-owner-region-ids - (append (plist-get result :old-owner-region-ids) - old-owner-region-ids) - new-owner-region-ids - (append (plist-get result :new-owner-region-ids) - new-owner-region-ids))) - (dolist (region-id - (append - (copy-sequence - (plist-get result :old-owner-region-ids)) - (ebox--patch-result-region-ids buffer result))) - (cl-pushnew region-id region-ids :test #'equal)) - (cl-loop - for old-span in (or (plist-get result :old-refresh-spans) - (plist-get result :old-buffer-spans)) - for new-span in (or (plist-get result :new-refresh-spans) - (plist-get result :new-buffer-spans)) - for old = (ebox--patch-span-numeric-bounds old-span) - for new = (ebox--patch-span-numeric-bounds new-span) - unless (and old new) - do (throw 'unsafe nil) - do (push (list :result result - :old old - :new-length (- (cdr new) (car new))) - entries))) - (setq entries - (sort entries - (lambda (left right) - (let ((left-old (plist-get left :old)) - (right-old (plist-get right :old))) - (if (= (car left-old) (car right-old)) - (< (cdr left-old) (cdr right-old)) - (< (car left-old) (car right-old))))))) - (let ((delta 0) - previous-start - previous-end - old-spans - new-spans) - (dolist (entry entries) - (let* ((result (plist-get entry :result)) - (old (plist-get entry :old)) - (old-start (car old)) - (old-end (cdr old)) - (new-length (plist-get entry :new-length))) - (when (or (and previous-end (< old-start previous-end)) - (and previous-start (= old-start previous-start))) - (throw 'unsafe nil)) - (let* ((new-start (+ old-start delta)) - (new (cons new-start (+ new-start new-length)))) - (push old old-spans) - (push new new-spans) - (unless (plist-get result :rendered-buffer-spans) - (puthash result - (cons new (gethash result final-spans-by-result)) - final-spans-by-result)) - (setq delta (+ delta (- new-length (- old-end old-start))) - previous-start old-start - previous-end old-end)))) - (dolist (result text-results) - (puthash result - (or (plist-get result :rendered-buffer-spans) - (nreverse - (gethash result final-spans-by-result))) - final-spans-by-result)) - (list :region-ids region-ids - :old-spans (nreverse old-spans) - :new-spans (nreverse new-spans) - :final-spans-by-result final-spans-by-result - :extent-delta-p owner-region-delta-p - :added-region-ids - (and owner-region-delta-p - (cl-set-difference - (delete-dups new-owner-region-ids) - old-owner-region-ids :test #'equal)) - :removed-region-ids - (and owner-region-delta-p - (cl-set-difference - (delete-dups old-owner-region-ids) - new-owner-region-ids :test #'equal))))))))) - -(defun ebox--patch-results-root-render-metadata (buffer results) - "Return root replacement metadata when RESULTS replace BUFFER's root." - (when (and (= (length results) 1) - (equal (plist-get (car results) :owner-id) - (ebox--buffer-root-node-id buffer))) - (plist-get (car results) :root-render-metadata))) - -(defun ebox--prepare-patch-results-root-render-metadata (buffer results) - "Upgrade and return complete metadata for a root replacement in RESULTS." - (when (and (= (length results) 1) - (equal (plist-get (car results) :owner-id) - (ebox--buffer-root-node-id buffer))) - (when-let ((rendered (plist-get (car results) :root-rendered))) - (when (fboundp 'ebox--rendered-root-metadata) - (ebox--rendered-root-metadata rendered))))) - -(defun ebox--refresh-runtime-indexes-for-patch-results - (buffer results strategy &optional index-refresh scoped-plan) - "Refresh runtime indexes after RESULTS using the smallest safe scope." - (let ((root-metadata - (ebox--patch-results-root-render-metadata buffer results)) - (scoped-plan - (or scoped-plan - (and (not (eq strategy 'paint-patch)) - (ebox--patch-results-scoped-refresh-plan buffer results))))) - (cond - ((eq strategy 'paint-patch) - :paint) - (scoped-plan - (let ((region-ids (plist-get scoped-plan :region-ids)) - (spans (plist-get scoped-plan :new-spans)) - (old-spans (plist-get scoped-plan :old-spans)) - (removed-region-ids (plist-get scoped-plan :removed-region-ids)) - (final-spans-by-result - (plist-get scoped-plan :final-spans-by-result)) - role-refresh active-segments) - (when region-ids - (setq role-refresh - (ebox-buffer-refresh-region-role-spans-for-region-ids - buffer region-ids spans old-spans - (unless - (cl-some - (lambda (result) - (plist-get result :old-owner-region-ids)) - results) - (delq nil - (mapcar - (lambda (result) - (when-let* ((rendered - (plist-get result :rendered)) - (result-spans - (and final-spans-by-result - (gethash - result final-spans-by-result)))) - (cons rendered result-spans))) - results))))) - (with-current-buffer buffer - (setq active-segments - (plist-get role-refresh :active-segments)) - (ebox--refresh-box-extents-from-role-spans - (delete-dups - (append (copy-sequence region-ids) - (copy-sequence removed-region-ids))) - spans active-segments)))) - :scoped) - ((eq index-refresh 'incremental-root) - (let* ((result (car results)) - (prepared-metadata (plist-get result :root-render-metadata)) - (prepared-role-template - (and (plist-get prepared-metadata :prepared-p) - (plist-get prepared-metadata :role-span-template))) - (prepared-extent-template - (and (plist-get prepared-metadata :prepared-p) - (plist-get prepared-metadata :box-extent-template))) - (prepared-scroll-template - (and (plist-get prepared-metadata :prepared-p) - (plist-get prepared-metadata - :scroll-content-span-template))) - (affected-region-ids - (plist-get result :affected-region-ids)) - (published-spans - (plist-get result :published-buffer-spans)) - (root-start - (caar (plist-get result :new-buffer-spans))) - (published-indexes-p - (plist-get result :incremental-root-indexes)) - (role-complete-p - (if published-indexes-p - t - (if (and prepared-role-template - (fboundp - 'ebox-buffer--install-region-role-span-template)) - (progn - (ebox-buffer--install-region-role-span-template - buffer prepared-role-template - root-start) - t) - (ebox-buffer-apply-role-delta - buffer (plist-get result :role-delta))))) - (extent-complete-p - (if published-indexes-p - t - (and (hash-table-p prepared-extent-template) - (progn - (ebox--install-box-extent-template - buffer prepared-extent-template - root-start) - t)))) - (scroll-result - (and (not published-indexes-p) - role-complete-p extent-complete-p - (ebox--refresh-patched-scroll-markers - buffer - affected-region-ids - published-spans - prepared-scroll-template - root-start))) - (complete-p - (or published-indexes-p - (and role-complete-p - extent-complete-p - (plist-get scroll-result :complete-p))))) - (plist-put result :incremental-root-indexes complete-p) - (unless (plist-get result :incremental-scroll-region-ids) - (plist-put result :incremental-scroll-region-ids - (plist-get scroll-result :published-region-ids))) - (if complete-p - :incremental-root - (ebox--clear-buffer-extents buffer) - (ebox--set-buffer-region-role-span-table buffer nil) - :deferred-root))) - ((eq index-refresh 'defer-extents) - (ebox--clear-buffer-extents buffer) - (ebox--set-buffer-region-role-span-table buffer nil) - :deferred-root) - ((and root-metadata - (eq index-refresh 'invalidate-extents) - (plist-get root-metadata :scroll-window-p) - (fboundp 'ebox--refresh-root-owner-scroll-windows)) - ;; Chrome-free scroll roots can recover their exact visible window from - ;; the dedicated render markers without installing broad role indexes. - ;; Mixed or chromed roots fall through to the prepared complete indexes. - (ebox--clear-buffer-extents buffer) - (ebox--set-buffer-region-role-span-table buffer nil) - (if (ebox--refresh-root-owner-scroll-windows buffer results) - :lightweight-root - (let ((prepared-metadata - (ebox--prepare-patch-results-root-render-metadata - buffer results))) - (if (and prepared-metadata - (plist-get prepared-metadata :prepared-p) - (fboundp 'ebox-buffer--install-region-role-span-template)) - (progn - (ebox-buffer--install-region-role-span-template - buffer - (plist-get prepared-metadata :role-span-template) - (caar (plist-get (car results) :new-buffer-spans))) - :prepared-root) - :full)))) - ((and root-metadata - (plist-get root-metadata :prepared-p) - (eq index-refresh 'invalidate-extents) - (fboundp 'ebox-buffer--install-region-role-span-template)) - (ebox--clear-buffer-extents buffer) - (ebox-buffer--install-region-role-span-template - buffer - (plist-get root-metadata :role-span-template) - (caar (plist-get (car results) :new-buffer-spans))) - :prepared-root) - ((eq index-refresh 'invalidate-extents) - (ebox--clear-buffer-extents buffer) - (ebox--set-buffer-region-role-span-table buffer nil) - :full) - (t - (ebox--refresh-buffer-box-extents buffer) - (ebox--set-buffer-region-role-span-table buffer nil) - :full)))) - -(defun ebox--span-patch-promoted-owner-fallback-op (buffer op) - "Return same-owner L3 fallback for a failed promoted span patch OP." - (let* ((dirty (plist-get op :dirty)) - (node-id (plist-get dirty :node-id)) - (dirty-kind (plist-get dirty :dirty-kind)) - (owner-id (plist-get op :owner-id))) - (when (and buffer - owner-id - (not (equal owner-id node-id)) - (ebox--patchable-owner-p buffer owner-id dirty-kind)) - (ebox--patch-op 'owner-rerender owner-id - :dirty dirty)))) - -(defun ebox--span-patch-root-fallback-op (buffer op) - "Return conservative owner-rerender fallback op for failed span patch OP." - (let* ((dirty (plist-get op :dirty)) - (node-id (plist-get dirty :node-id)) - (dirty-kind (plist-get dirty :dirty-kind)) - (owner-id - (or (plist-get op :fallback-owner-id) - (and buffer - (ebox--with-layout-snapshot-index-context buffer - (ebox--promote-to-patchable-owner - buffer node-id dirty-kind - (plist-get dirty :changed-keys)))) - node-id))) - (when owner-id - (ebox--patch-op 'owner-rerender owner-id - :dirty dirty)))) (defun ebox--next-patchable-owner-ancestor (buffer owner-id dirty-kind &optional require-owned-overflow-coverage) @@ -4203,52 +3317,6 @@ cannot cover an old unowned visible-overflow suffix." (setq candidate (ebox--runtime-parent-id buffer candidate))))) owner)) -(defun ebox--ancestor-owner-fallback-op (buffer op) - "Return nearest ancestor owner fallback for failed text-changing OP." - (let* ((dirty (plist-get op :dirty)) - (dirty-kind (plist-get dirty :dirty-kind)) - (requires-owned-overflow-coverage - (plist-get dirty :requires-owned-overflow-coverage)) - (owner-id (plist-get op :owner-id)) - (ancestor-id - (and buffer owner-id - (ebox--next-patchable-owner-ancestor - buffer owner-id dirty-kind - requires-owned-overflow-coverage)))) - (when ancestor-id - (ebox--patch-op 'owner-rerender ancestor-id - :dirty dirty - :fallback-from-owner-id owner-id)))) - -(defun ebox--apply-span-patch-fallbacks (buffer op) - "Apply fallback chain for failed span patch OP in BUFFER." - (or (when-let ((fallback - (ebox--span-patch-promoted-owner-fallback-op - buffer op))) - (ebox-buffer-apply-owner-rerender buffer fallback)) - (when-let ((fallback - (ebox--ancestor-owner-fallback-op buffer op))) - (ebox-buffer-apply-owner-rerender buffer fallback)) - (when-let ((fallback - (ebox--span-patch-root-fallback-op buffer op))) - (ebox-buffer-apply-owner-rerender buffer fallback)))) - -(defun ebox--apply-owner-rerender-fallbacks (buffer op) - "Apply ancestor owner fallbacks for failed owner-rerender OP in BUFFER. -Climb the runtime ancestor chain until one owner publishes. A single -failing ancestor must not abort the walk: an owner whose spans share -buffer lines with siblings (for example a stage column inside a scene -row) cannot slot-preserve interior text whose line shape changed, but -a complete-line ancestor above it still can, and the documented -fallback chain only reaches root when every ancestor fails." - (let ((current op) - result) - (while (and (not result) - (setq current - (ebox--ancestor-owner-fallback-op buffer current))) - (setq result (ebox-buffer-apply-owner-rerender buffer current))) - result)) - (defun ebox--patch-results-strategy (results) "Return aggregate update strategy for executed patch RESULTS." (cond @@ -4274,446 +3342,6 @@ fallback chain only reaches root when every ancestor fails." 'paint-patch) (t 'owner-rerender))) -(defun ebox-incremental--validate-prepared-patch-set (patch-set) - "Validate that PATCH-SET is complete and conflict-free before publication." - (dolist (op patch-set) - (let ((prepared (plist-get op :prepared-publication))) - (unless (and prepared - (eq (plist-get prepared :op) (plist-get op :op)) - (equal (plist-get prepared :owner-id) - (plist-get op :owner-id)) - (or (not (ebox--text-changing-patch-op-p op)) - (plist-get prepared :spans) - (and (eq (plist-get op :op) 'child-splice) - (plist-get prepared :old-refresh-spans) - (plist-get prepared :new-refresh-spans)))) - (error "Ebox declarative patch lacks a matching prepared proof: %S" - (list (plist-get op :op) (plist-get op :owner-id)))))) - (when-let ((overlapping - (ebox-incremental--first-overlapping-prepared-text-ops - patch-set))) - (error "Ebox declarative prepared owners still overlap: %S and %S" - (plist-get (car overlapping) :owner-id) - (plist-get (cadr overlapping) :owner-id))) - t) - -(defun ebox-incremental--publish-prepared-patch-op (buffer op) - "Publish one already-proven declarative OP into BUFFER without fallback." - (let ((prepared (plist-get op :prepared-publication))) - (pcase (plist-get op :op) - ('paint-patch - (ebox-buffer-publish-prepared-paint-patch buffer prepared)) - ('span-patch - (ebox-buffer-publish-prepared-span-patch buffer prepared)) - ('child-reorder - (ebox-buffer-publish-prepared-child-reorder buffer prepared)) - ('child-splice - (ebox-buffer-publish-prepared-child-splice buffer prepared)) - ('owner-rerender - (ebox-buffer-publish-prepared-owner-rerender buffer prepared)) - (_ nil)))) - -(defun ebox-incremental--publish-prepared-patch-set - (buffer patch-set dirty-count) - "Publish final declarative PATCH-SET for BUFFER as one prepared transaction." - (ebox--execute-patch-set - buffer patch-set nil dirty-count nil 'invalidate-extents 'prepared-only)) - -(defun ebox--execute-patch-set - (buffer patch-set &optional region-id dirty-count - snapshot-refresh index-refresh publication-mode) - "Execute PATCH-SET in BUFFER and return an update report. -When PUBLICATION-MODE is `prepared-only', every operation must carry a final -prepared proof and publication cannot render, promote owners, or fall back." - (catch 'fallback - (let* ((prepared-only-p (eq publication-mode 'prepared-only)) - (_prepared-validation - (and prepared-only-p - (ebox-incremental--validate-prepared-patch-set patch-set))) - (source-node-ids - (delete-dups - (cl-mapcan - (lambda (op) - (ebox--dirty-provenance-node-ids - (plist-get op :dirty))) - patch-set))) - (root-node-id (ebox--buffer-root-node-id buffer)) - (buffer-scroll-region-ids - (plist-get (ebox--buffer-render-state buffer) - :scroll-region-ids)) - (scoped-owner-rerender-p - (cl-some - (lambda (op) - (and (eq (plist-get op :op) 'owner-rerender) - (not (equal (plist-get op :owner-id) root-node-id)))) - patch-set)) - (pre-scroll-state-region-ids - (and scoped-owner-rerender-p - buffer-scroll-region-ids - (fboundp - 'ebox--scroll-state-region-ids-containing-node-ids) - (ebox--scroll-state-region-ids-containing-node-ids - buffer source-node-ids))) - (ebox--collect-rebuilt-scroll-state-region-ids t) - (ebox--rebuilt-scroll-state-region-ids nil) - results) - (dolist (op (ebox--patch-set-execution-order buffer patch-set)) - (if prepared-only-p - (if-let ((result - (ebox-incremental--publish-prepared-patch-op buffer op))) - (push result results) - (error "Ebox prepared declarative publication failed for %S" - (list (plist-get op :op) (plist-get op :owner-id)))) - (pcase (plist-get op :op) - ('paint-patch - (if-let ((result (ebox-buffer-apply-paint-patch buffer op))) - (push result results) - (throw 'fallback nil))) - ('span-patch - (if-let ((result - (or (ebox-buffer-apply-span-patch buffer op) - (ebox--apply-span-patch-fallbacks buffer op)))) - (push result results) - (throw 'fallback nil))) - ('owner-rerender - (if-let ((result - (or (ebox-buffer-apply-owner-rerender buffer op) - (ebox--apply-owner-rerender-fallbacks buffer op)))) - (push result results) - (throw 'fallback nil))) - (_ (throw 'fallback nil))))) - (when results - (setq results (nreverse results)) - (let* ((strategy (ebox--patch-results-strategy results)) - (scoped-plan - (and (not (eq strategy 'paint-patch)) - (ebox--patch-results-scoped-refresh-plan - buffer results))) - (refresh-scope - (ebox--refresh-runtime-indexes-for-patch-results - buffer results strategy index-refresh scoped-plan)) - fast-root-scroll-region-ids - owner-scroll-publication - owner-scroll-patch) - (if (eq strategy 'paint-patch) - (ebox--refresh-paint-layout-snapshots buffer patch-set) - (cond - ((and - (eq refresh-scope :scoped) - (or (eq strategy 'span-patch) - (and (eq strategy 'owner-rerender) - (null snapshot-refresh))) - (ebox--patch-results-scoped-snapshot-refreshable-p results)) - (ebox--refresh-text-patch-layout-snapshots - buffer results source-node-ids scoped-plan)) - ((eq snapshot-refresh 'prepared-animation) - (ebox--refresh-prepared-animation-root-snapshot - buffer (car results))) - ((eq snapshot-refresh 'invalidate-details) - (ebox--invalidate-buffer-layout-snapshot-details buffer)) - (t - (ebox--refresh-buffer-layout-snapshots buffer))) - (let* ((root-metadata - (ebox--patch-results-root-render-metadata buffer results)) - (prepared-scroll-refresh - (and (eq refresh-scope :prepared-root) - root-metadata - (fboundp - 'ebox--install-buffer-scroll-content-span-template) - (ebox--install-buffer-scroll-content-span-template - buffer - (plist-get root-metadata - :scroll-content-span-template) - (caar (plist-get (car results) - :new-buffer-spans)) - t))) - (incremental-scroll-refresh - (and (eq refresh-scope :incremental-root) - (plist-get (car results) - :incremental-root-indexes))) - (lightweight-scroll-refresh - (or (eq refresh-scope :lightweight-root) - incremental-scroll-refresh - prepared-scroll-refresh - (and (eq refresh-scope :full) - (fboundp - 'ebox--refresh-root-owner-scroll-windows) - (ebox--refresh-root-owner-scroll-windows - buffer results))))) - (when (and lightweight-scroll-refresh - (memq refresh-scope - '(:lightweight-root :prepared-root - :incremental-root))) - (setq fast-root-scroll-region-ids - (if (eq refresh-scope :incremental-root) - (copy-sequence - (plist-get (car results) - :incremental-scroll-region-ids)) - (copy-sequence - (plist-get (ebox--buffer-render-state buffer) - :scroll-region-ids))))) - (when (and (eq refresh-scope :full) - (not lightweight-scroll-refresh) - (fboundp 'ebox-buffer-refresh-region-role-spans)) - (ebox-buffer-refresh-region-role-spans buffer)) - (when (and (eq refresh-scope :full) - (not lightweight-scroll-refresh) - (fboundp - 'ebox--refresh-buffer-scroll-content-markers)) - (ebox--refresh-buffer-scroll-content-markers buffer)))) - ;; Root/prepared refreshes install their state first; scoped owner - ;; replacements reach the same publication gate afterward. Only a - ;; verified all-state publication may suppress ordinary post-sync. - (setq owner-scroll-publication - (when (eq strategy 'owner-rerender) - (cond - (fast-root-scroll-region-ids - (list :covered-p t - :covered-region-ids fast-root-scroll-region-ids - :published-region-ids fast-root-scroll-region-ids)) - ((and (or (and (not scoped-owner-rerender-p) - buffer-scroll-region-ids) - pre-scroll-state-region-ids - ebox--rebuilt-scroll-state-region-ids) - (fboundp - 'ebox--publish-scoped-owner-scroll-windows)) - (ebox--publish-scoped-owner-scroll-windows - buffer results source-node-ids - pre-scroll-state-region-ids - ebox--rebuilt-scroll-state-region-ids - (plist-get scoped-plan - :final-spans-by-result)))))) - (setq owner-scroll-patch - (plist-get owner-scroll-publication :covered-p)) - (let ((report - (ebox--update-report - region-id strategy - :dirty-count (or dirty-count (length patch-set)) - :patch-count (length results) - :patch-ops (mapcar (lambda (result) - (plist-get result :op)) - results) - :owner-ids (mapcar (lambda (result) - (plist-get result :owner-id)) - results) - :owner-type (and (= (length results) 1) - (plist-get (car results) :owner-type)) - :span-count (cl-loop for result in results - sum (or (plist-get result :span-count) - 0)) - :publication-scope - (and (= (length results) 1) - (plist-get (car results) :publication-scope)) - :published-buffer-spans - (and (= (length results) 1) - (plist-get (car results) :published-buffer-spans)) - :full-root-required-p - (and (= (length results) 1) - (plist-get (car results) :full-root-required-p)) - :patch-origin - (and (= (length results) 1) - (plist-get (car results) :patch-origin)) - :full-frame-bytes - (and (= (length results) 1) - (plist-get (car results) :full-frame-bytes)) - :replacement-bytes - (and (= (length results) 1) - (plist-get (car results) :replacement-bytes)) - :snapshot-refresh snapshot-refresh - :index-refresh index-refresh - :incremental-root-indexes - (and (= (length results) 1) - (plist-get (car results) - :incremental-root-indexes)) - :scoped-index-refresh (eq refresh-scope :scoped) - :slot-preserving (cl-some - (lambda (result) - (plist-get result :slot-preserving)) - results) - :flex-line-rerender - (cl-some - (lambda (result) - (plist-get result :flex-line-rerender)) - results) - :old-signature (and (= (length results) 1) - (plist-get (car results) - :old-signature)) - :new-signature (and (= (length results) 1) - (plist-get (car results) - :new-signature)) - :old-span-footprint-signature - (and (= (length results) 1) - (plist-get (car results) - :old-span-footprint-signature)) - :new-span-footprint-signature - (and (= (length results) 1) - (plist-get (car results) - :new-span-footprint-signature)) - :old-external-footprint-signature - (and (= (length results) 1) - (plist-get (car results) - :old-external-footprint-signature)) - :new-external-footprint-signature - (and (= (length results) 1) - (plist-get (car results) - :new-external-footprint-signature)) - :old-parent-slot-signature - (and (= (length results) 1) - (plist-get (car results) - :old-parent-slot-signature)) - :new-parent-slot-signature - (and (= (length results) 1) - (plist-get (car results) - :new-parent-slot-signature))))) - (if (plist-get owner-scroll-publication - :published-region-ids) - (append - report - (list - :scroll-state-owner-publication t - :scroll-state-owner-published-region-ids - (plist-get owner-scroll-publication - :published-region-ids) - :scroll-state-window-marker-refresh-count - (length - (plist-get owner-scroll-publication - :published-region-ids))) - (when owner-scroll-patch - (list - :scroll-state-patch t - :scroll-state-sync t - :scroll-state-sync-count - (length - (plist-get owner-scroll-publication - :published-region-ids))))) - report))))))) - -(defun ebox--constraint-root-rerender-report - (change patches &optional region-id) - "Return a root-rerender report for failed CHANGE patching." - (let ((dirty-set (plist-get change :dirty-set))) - (ebox--update-report - region-id 'root-rerender - :dirty-count (length dirty-set) - :patch-count (length patches) - :patch-ops (mapcar (lambda (patch) - (plist-get patch :op)) - patches) - :owner-ids (mapcar (lambda (patch) - (plist-get patch :owner-id)) - patches) - :root-rerender t))) - -(defun ebox--large-viewport-constraint-change-p (change) - "Return non-nil when CHANGE should skip local owner planning." - (and (eq (plist-get change :constraint-source) 'viewport) - (>= (or (plist-get change :coalesced-dirty-count) - (length (plist-get change :dirty-set))) - ebox--owner-coalesce-min-count))) - -(defun ebox--root-reflow-constraint-change-p (change) - "Return non-nil when CHANGE replaces the runtime root's width constraint." - (or (eq (plist-get change :constraint-source) 'viewport) - (and (eq (plist-get change :constraint-source) 'region) - (equal (plist-get change :constraint-owner-id) - (plist-get change :constraint-root-node-id)) - (cl-some (lambda (key) - (memq key '(:width :min-width :max-width))) - (ebox--dirty-set-changed-keys - (plist-get change :dirty-set)))))) - -(defun ebox--buffer-visible-in-graphic-frame-p (buffer) - "Return non-nil when BUFFER is visible in a graphical frame." - (cl-some (lambda (window) - (display-graphic-p (window-frame window))) - (get-buffer-window-list buffer nil t))) - -(defun ebox--execute-constraint-change (buffer change &optional region-id) - "Execute a normalized constraint CHANGE against BUFFER." - (let* ((dirty-set (plist-get change :dirty-set)) - (dirty-kinds (ebox--dirty-set-kinds dirty-set)) - (invalidated (ebox-cache-invalidated-spec-names dirty-kinds)) - (root-reflow (ebox--root-reflow-constraint-change-p change)) - (defer-gc (and root-reflow - (not ebox--root-reflow-gc-deferred-p) - (ebox--buffer-visible-in-graphic-frame-p buffer))) - (ebox--root-reflow-gc-deferred-p - (or ebox--root-reflow-gc-deferred-p defer-gc)) - report) - (when defer-gc - (ebox--deferred-render-gc-enter)) - (unwind-protect - (progn - (ebox-cache-clear-report buffer) - (ebox-cache-record-invalidation buffer invalidated dirty-kinds) - (let* ((ebox--scroll-window-initial-lookahead-lines-override - (and root-reflow - (ebox--root-reflow-scroll-lookahead-lines-for-buffer - buffer))) - (ebox-cache-report-buffer buffer) - (patches - (or (and (or (ebox--large-viewport-constraint-change-p - change) - (and root-reflow - (eq (plist-get change - :constraint-source) - 'region))) - (ebox--root-owner-patch-set buffer dirty-set)) - (ebox--patch-set-from-dirty-set dirty-set buffer)))) - (setq report - (or (and (null dirty-set) - (ebox--update-report region-id 'no-op - :dirty-count 0 - :patch-count 0 - :patch-ops nil)) - (and patches - (ebox--execute-patch-set - buffer patches region-id (length dirty-set) - (when root-reflow - (if ebox--prepared-root-defer-index-refresh - 'prepared-animation - 'invalidate-details)) - (when root-reflow - (cond - ((eq ebox--prepared-root-defer-index-refresh - 'incremental) - 'incremental-root) - (ebox--prepared-root-defer-index-refresh - 'defer-extents) - (t 'invalidate-extents))))) - (progn - (ebox-buffer-apply-root-rerender buffer) - (ebox--constraint-root-rerender-report - change patches region-id))))) - (if report - (append report (ebox--constraint-change-report-props change)) - report)) - (when defer-gc - (ebox--deferred-render-gc-schedule-restore))))) - -(defun ebox--try-paint-patch-region (buffer region-id) - "Patch REGION-ID paint-only changes in BUFFER when safe." - (when-let ((root (ebox--buffer-root-node buffer))) - (let* ((old (ebox--current-layout-snapshots buffer)) - (new (ebox--layout-snapshots-for-runtime buffer root)) - (dirty (ebox--diff-layout-snapshots old new)) - (patches (ebox--patch-set-from-dirty-set dirty buffer))) - (when (and patches - (cl-every (lambda (op) - (eq (plist-get op :op) 'paint-patch)) - patches)) - (ebox--execute-patch-set - buffer patches region-id (length dirty)))))) - -(defun ebox--try-owner-rerender-region (buffer region-id) - "Patch REGION-ID by rerendering the smallest safe owner in BUFFER." - (when-let ((dirty-node - (ebox--buffer-region-render-owner-node buffer region-id))) - (let* ((dirty-node-id (ebox--ensure-node-id dirty-node)) - (dirty (list (ebox--dirty-entry dirty-node-id 'geometry))) - (patches (ebox--patch-set-from-dirty-set dirty buffer))) - (ebox--execute-patch-set buffer patches region-id (length dirty))))) - (defun ebox--viewport-dependent-size-value-p (value &optional nil-dependent) "Return non-nil when horizontal size VALUE depends on viewport context. When NIL-DEPENDENT is non-nil, nil is treated as viewport-dependent auto width." @@ -4948,686 +3576,6 @@ legacy callers." (when ebox-cache-report-buffer (ebox-cache-report ebox-cache-report-buffer)))) -(defun ebox-incremental--prepared-root-context-box - (state prepared context) - "Return the root model box selected by PREPARED and CONTEXT in STATE." - (or (plist-get context :box) - (let ((root (plist-get state :root-node)) - (region-id (plist-get prepared :region-id))) - (or (and region-id (ebox--root-region-box root region-id)) - (pcase (plist-get root :ebox-type) - ('box root) - ('flex (plist-get root :box))))))) - -(defun ebox-incremental--prepared-root-target-matches-p - (buffer state prepared context) - "Return non-nil when PREPARED targets BUFFER's exact CONTEXT." - (let* ((root (plist-get state :root-node)) - (native-patch (plist-get prepared :native-patch))) - (and prepared native-patch - (eq (plist-get prepared :publication-owner) - 'prepared-root-native) - (plist-get native-patch :native-patch) - (eq state (plist-get prepared :render-state)) - (equal (ebox--ensure-node-id root) (plist-get prepared :node-id)) - (eq (plist-get prepared :kind) (plist-get context :kind)) - (equal (plist-get prepared :region-id) - (plist-get context :region-id)) - (equal (plist-get prepared :viewport-width) - (plist-get context :target-viewport-width)) - (equal (plist-get prepared :viewport-height) - (plist-get context :target-viewport-height)) - (equal (plist-get prepared :root-width) - (plist-get context :target-root-width)) - (eq buffer (current-buffer))))) - -(defun ebox-incremental--prepared-root-base-current-p - (buffer state prepared context) - "Return non-nil when PREPARED's base still equals BUFFER and STATE." - (let ((box (ebox-incremental--prepared-root-context-box - state prepared context))) - (and (cl-every (lambda (key) (plist-member prepared key)) - '(:base-buffer-chars-modified-tick - :base-root-width :base-viewport-width - :base-viewport-height)) - (equal (plist-get prepared :runtime-revision) - (plist-get state :runtime-revision)) - (equal (plist-get prepared :display-signature) - (with-current-buffer buffer - (ebox--current-display-signature))) - (= (plist-get prepared :base-buffer-chars-modified-tick) - (with-current-buffer buffer (buffer-chars-modified-tick))) - (equal (plist-get prepared :base-viewport-width) - (plist-get state :viewport-width)) - (equal (plist-get prepared :base-viewport-height) - (plist-get state :viewport-height)) - (equal (plist-get prepared :base-root-width) - (ebox--literal-root-pixel-width box))))) - -(defun ebox-incremental--validate-prepared-root-native-metadata (prepared) - "Return PREPARED's complete native root metadata, or signal an error." - (let* ((native-patch (plist-get prepared :native-patch)) - (metadata (plist-get native-patch :root-render-metadata))) - (unless (and (plist-get prepared :full-p) - (plist-get prepared :metadata-complete-p) - (plist-get metadata :prepared-p) - (hash-table-p (plist-get metadata :role-span-template)) - (hash-table-p (plist-get metadata :box-extent-template)) - (hash-table-p - (plist-get metadata :scroll-content-span-template))) - (error "Prepared native root lacks complete target indexes")) - metadata)) - -(defun ebox-incremental--prepared-root-candidate-state (state context) - "Return an isolated target state copied from STATE for CONTEXT." - (let ((candidate (copy-sequence state))) - (plist-put candidate :viewport-width - (plist-get context :target-viewport-width)) - (plist-put candidate :viewport-height - (plist-get context :target-viewport-height)) - (dolist (key '(:render-signature-cache - :viewport-height-dependent-subtree-cache)) - (when-let ((table (plist-get state key))) - (plist-put candidate key (copy-hash-table table)))) - (when-let ((scratch (plist-get state :reflow-prewarm-scratch))) - (plist-put candidate :reflow-prewarm-scratch - (copy-sequence scratch))) - candidate)) - -(defun ebox-incremental--prepared-root-cache-report (dirty-kinds) - "Return cache invalidation facts equivalent to DIRTY-KINDS execution." - (let ((names (ebox-cache-invalidated-spec-names dirty-kinds)) - scopes) - (dolist (name names) - (when-let ((spec (ebox-cache-spec name))) - (cl-pushnew (ebox-cache-spec-scope spec) scopes :test #'equal))) - (list :cache-invalidated names - :cache-hit-count 0 - :cache-miss-count 0 - :cache-scope scopes - :cache-fallback-reason dirty-kinds))) - -(defun ebox-incremental--prepared-root-layout-snapshots - (buffer state spans) - "Return a root-only target snapshot table for BUFFER at SPANS." - (let* ((root (plist-get state :root-node)) - (root-id (ebox--ensure-node-id root)) - (old-table (plist-get state :layout-snapshots)) - (old (and (hash-table-p old-table) (gethash root-id old-table))) - (snapshot - (if old - (ebox--layout-snapshot-strip-details old) - (ebox--node-layout-snapshot buffer root nil))) - (target (make-hash-table :test 'equal))) - (plist-put snapshot :buffer-spans spans) - (plist-put snapshot :detail-generation - (or (plist-get state :layout-snapshot-detail-generation) 0)) - (puthash root-id snapshot target) - target)) - -(defun ebox-incremental--stage-prepared-root-scroll-state - (buffer old-state candidate-state plan text-result) - "Return isolated target scroll publication for prepared root PLAN." - (let* ((candidate-table - (ebox-incremental--candidate-scroll-state-table - buffer old-state candidate-state - (plist-get candidate-state :region-box-table))) - (timers (make-hash-table :test 'equal)) - (smooth (make-hash-table :test 'equal)) - (metadata (plist-get plan :root-render-metadata)) - (preflight (plist-get plan :scroll-preflight)) - (expected (plist-get preflight :scroll-region-ids)) - (candidate-keys - (ebox-incremental--hash-keys candidate-table)) - result staged-p) - (unless (null (cl-set-exclusive-or - expected candidate-keys :test #'equal)) - (error "Prepared native root lacks an exact scroll state set")) - (unwind-protect - (progn - (let ((ebox-incremental--buffer-render-state-override - (cons buffer candidate-state)) - (ebox--scroll-global-state candidate-table) - (ebox--scroll-idle-prefetch-timers timers) - (ebox--smooth-scroll-state-table smooth) - (ebox--render-cache-scroll-state-restorable-p nil)) - (cl-letf (((symbol-function 'ebox--scroll-schedule-idle-prefetch) - (lambda (&rest _) nil))) - (setq result - (ebox--refresh-patched-scroll-markers - buffer expected - (plist-get text-result :published-buffer-spans) - (plist-get metadata :scroll-content-span-template) - (plist-get plan :start))))) - (unless (and (plist-get result :complete-p) - (null (cl-set-exclusive-or - expected - (plist-get result :published-region-ids) - :test #'equal))) - (error "Prepared native root did not stage exact scroll indexes")) - (setq staged-p t) - (list :table candidate-table - :keys (copy-sequence expected) - :result result)) - (unless staged-p - (ebox-incremental--detach-scroll-state-table-markers - candidate-table))))) - -(defun ebox-incremental--prepared-root-report - (state context text-result scroll-keys impact cache-report) - "Return the native publication report for CONTEXT and TEXT-RESULT." - (let* ((root (plist-get state :root-node)) - (root-id (ebox--ensure-node-id root)) - (kind (plist-get context :kind)) - (region-id (plist-get context :region-id)) - (viewport-p (eq kind 'viewport)) - (publication-scope - (if (plist-get text-result :full-root-required-p) - 'full-root - 'minimal-spans))) - (append - (ebox--update-report - region-id 'owner-rerender - :dirty-count 1 :patch-count 1 :patch-ops '(owner-rerender) - :owner-ids (list root-id) :owner-type (plist-get root :ebox-type) - :span-count (length (plist-get text-result :new-buffer-spans)) - :publication-scope publication-scope - :published-buffer-spans - (plist-get text-result :published-buffer-spans) - :full-root-required-p - (plist-get text-result :full-root-required-p) - :patch-origin 'rust :full-frame-bytes 0 - :replacement-bytes (plist-get text-result :replacement-bytes) - :snapshot-refresh 'prepared-animation - :index-refresh 'incremental-root - :incremental-root-indexes t - :incremental-scroll-region-ids scroll-keys - :constraint-source (if viewport-p 'viewport 'region) - :constraint-owner-id (if viewport-p 'viewport root-id) - :constraint-owner-type - (if viewport-p 'viewport (plist-get root :ebox-type)) - :constraint-root-node-id root-id - :dirty-kinds '(geometry) - :dirty-keys (unless viewport-p '(:width)) - :impact-vector impact - :viewport-axes (and viewport-p (plist-get context :axes)) - :runtime-published t - :runtime-revision (plist-get state :runtime-revision)) - (when scroll-keys - (list :scroll-state-owner-publication t - :scroll-state-owner-published-region-ids scroll-keys - :scroll-state-window-marker-refresh-count (length scroll-keys) - :scroll-state-patch t :scroll-state-sync t - :scroll-state-sync-count (length scroll-keys))) - cache-report))) - -(defun ebox-incremental--snapshot-marker-table (snapshot) - "Return a hash table containing present values from SNAPSHOT." - (let ((table (make-hash-table :test 'equal))) - (dolist (entry snapshot table) - (when (nth 1 entry) - (puthash (car entry) (nth 2 entry) table))))) - -(defun ebox-incremental--retire-prepared-root-state - (old-role-table extent-snapshot scroll-snapshot scroll-keys) - "Retire marker state replaced by one prepared root publication. -This post-commit cleanup never turns a successful publication into a reported -failure. Cleanup errors are surfaced as warnings after all retirements run." - (let ((inhibit-quit t) - errors) - (dolist - (step - (list - (lambda () - (ebox-incremental--detach-region-role-span-table old-role-table)) - (lambda () - (dolist (entry extent-snapshot) - (when-let ((extents (and (nth 1 entry) (nth 2 entry)))) - (when (markerp (car-safe extents)) - (set-marker (car extents) nil)) - (when (markerp (cdr-safe extents)) - (set-marker (cdr extents) nil))))) - (lambda () - (ebox-incremental--detach-scroll-state-table-markers - (ebox-incremental--snapshot-marker-table scroll-snapshot))) - (lambda () - (ebox-incremental--finalize-declarative-scroll-publication - scroll-keys)))) - (condition-case err - (funcall step) - (error (push err errors)))) - (when errors - (display-warning - 'ebox - (format "Prepared root marker retirement errors: %S" - (nreverse errors)) - :error)) - (null errors))) - -(defun ebox-incremental--publish-prepared-root-state (buffer state) - "Publish prepared root STATE as BUFFER's live runtime pointer." - (puthash buffer state ebox--buffer-render-state-table)) - -(defun ebox-incremental--publish-prepared-root-native (buffer context) - "Publish BUFFER's exact prepared native root for CONTEXT, or return nil." - (unless (buffer-live-p buffer) - (error "Prepared native root requires a live buffer")) - (with-current-buffer buffer - (save-restriction - (widen) - (let* ((state (ebox--buffer-render-state buffer)) - (prepared ebox--prepared-root-render)) - (when (and state - (ebox-incremental--prepared-root-target-matches-p - buffer state prepared context) - (ebox-incremental--prepared-root-base-current-p - buffer state prepared context)) - (let* ((metadata - (ebox-incremental--validate-prepared-root-native-metadata - prepared)) - (native-patch (plist-get prepared :native-patch)) - (root-spans (list (cons (point-min) (point-max)))) - (plan - (or (ebox-buffer--preflight-native-root-property-patches - root-spans native-patch) - (error "Prepared native root patch could not be planned"))) - (box (ebox-incremental--prepared-root-context-box - state prepared context)) - (root-width-p (eq (plist-get context :kind) 'root-width)) - (box-before (and root-width-p (copy-sequence box))) - (old-role-table (plist-get state :region-role-span-table)) - (old-extent-template ebox--box-extent-template) - (target-extents (plist-get plan :prepared-extent-table)) - (extent-keys - (ebox-incremental--hash-keys target-extents)) - (extent-snapshot - (ebox-incremental--hash-snapshot - ebox--box-extents extent-keys)) - (scroll-keys - (copy-sequence - (plist-get (plist-get plan :scroll-preflight) - :scroll-region-ids))) - (scroll-snapshot - (ebox-incremental--hash-snapshot - ebox--scroll-global-state scroll-keys)) - (old-cache-snapshot (ebox-cache-snapshot-report buffer)) - (target-cache-report - (ebox-incremental--prepared-root-cache-report '(geometry))) - (old-consumed-p (plist-get prepared :consumed-p)) - candidate-state scroll-publication text-result report revision - extent-entered-p scroll-entered-p cache-entered-p - state-entered-p committed-p) - (ebox--cancel-buffer-runtime-prewarm buffer) - (ebox-incremental--notify-before-runtime-mutation - buffer (if root-width-p 'region-update 'viewport)) - (unless (and (eq state (ebox--buffer-render-state buffer)) - (ebox-incremental--prepared-root-base-current-p - buffer state prepared context)) - (error "Prepared native root base changed during notification")) - (setq candidate-state - (ebox-incremental--prepared-root-candidate-state - state context)) - (unwind-protect - (progn - (setq report - (ebox-buffer--with-native-root-patch-window-state - buffer plan - (let ((inhibit-read-only t)) - (atomic-change-group - (setq text-result - (ebox-buffer--publish-native-root-property-patch-text - plan)) - (when root-width-p - (ebox-put box :width - (plist-get context - :target-root-width))) - (let ((ebox-incremental--buffer-render-state-override - (cons buffer candidate-state))) - (when root-width-p - (ebox--invalidate-buffer-render-signatures - buffer (plist-get context :model-node-id)) - (ebox--invalidate-buffer-viewport-dependencies - buffer)) - (ebox--set-buffer-region-role-span-table - buffer (plist-get plan :prepared-role-table)) - (setq scroll-publication - (ebox-incremental--stage-prepared-root-scroll-state - buffer state candidate-state plan - text-result)) - (plist-put - candidate-state :layout-snapshots - (ebox-incremental--prepared-root-layout-snapshots - buffer candidate-state - (plist-get text-result :new-buffer-spans))) - (plist-put candidate-state - :layout-snapshots-complete-p nil) - (setq revision - (ebox--bump-buffer-runtime-revision - buffer root-width-p)) - (when root-width-p - (ebox--confirm-reflow-prewarm-scratch - buffer (plist-get context :region-id) - revision)) - (let* ((root - (plist-get candidate-state :root-node)) - (root-id (ebox--ensure-node-id root)) - (impact - (and root-width-p - (plist-get - (ebox--dirty-entry-with-impact-vector - buffer - (ebox--dirty-entry - root-id 'geometry - :region-id - (plist-get context :region-id) - :changed-keys '(:width))) - :impact-vector)))) - (setq report - (ebox-incremental--prepared-root-report - candidate-state context text-result - scroll-keys impact - target-cache-report))) - (plist-put candidate-state - :last-update-report report)) - (let ((inhibit-quit t)) - (setq extent-entered-p t) - (ebox--install-box-extent-template - buffer - (plist-get metadata :box-extent-template) - (plist-get plan :start)) - (dolist (region-id extent-keys) - (remhash region-id ebox--box-extents)) - (setq scroll-entered-p t) - (ebox-incremental--replace-hash-entries - ebox--scroll-global-state scroll-keys - (plist-get scroll-publication :table)) - (setq cache-entered-p t) - (ebox-cache-publish-report - buffer target-cache-report) - (setq state-entered-p t) - (ebox-incremental--publish-prepared-root-state - buffer candidate-state) - (setq ebox--prepared-root-render nil) - (plist-put prepared :consumed-p t)) - report)))) - (setq committed-p t) - (ebox-incremental--retire-prepared-root-state - old-role-table extent-snapshot scroll-snapshot scroll-keys) - report) - (unless committed-p - (let ((inhibit-quit t) - rollback-errors) - (dolist - (step - (list - (lambda () - (when state-entered-p - (puthash buffer state - ebox--buffer-render-state-table))) - (lambda () - (when cache-entered-p - (ebox-cache-restore-report - buffer old-cache-snapshot))) - (lambda () - (when scroll-entered-p - (ebox-incremental--restore-hash-snapshot - ebox--scroll-global-state scroll-snapshot))) - (lambda () - (when extent-entered-p - (setq-local ebox--box-extent-template - old-extent-template) - (ebox-incremental--restore-hash-snapshot - ebox--box-extents extent-snapshot))) - (lambda () - (when box-before - (setcar box (car box-before)) - (setcdr box (cdr box-before)))) - (lambda () - (when scroll-publication - (ebox-incremental--detach-scroll-state-table-markers - (plist-get scroll-publication :table)))) - (lambda () - (setq ebox--prepared-root-render prepared) - (plist-put prepared :consumed-p old-consumed-p)))) - (condition-case err - (funcall step) - (error (push err rollback-errors)))) - (when rollback-errors - (display-warning - 'ebox - (format "Prepared root rollback errors: %S" - (nreverse rollback-errors)) - :error))))))))))) - -(defun ebox--try-patch-node-same-signature (node region-id) - "Patch NODE in place when its rendered footprint is unchanged. -Return an update report plist when patching succeeds." - (unless (ebox--node-visible-overflow-p node) - (let* ((region-ids (ebox--node-all-region-ids node)) - (spans (and region-ids - (ebox--region-set-line-spans region-ids)))) - (when spans - (let* ((old-span-footprint - (ebox--spans-span-footprint-signature spans)) - (old-external-footprint - (ebox--external-footprint-signature-from-span-footprint - old-span-footprint)) - (old-parent-slot - (ebox--spans-parent-slot-signature spans)) - (rendered (ebox-render node)) - (new-span-footprint - (ebox--rendered-span-footprint-signature rendered)) - (new-external-footprint - (ebox--external-footprint-signature-from-span-footprint - new-span-footprint)) - (new-parent-slot - (ebox--project-parent-slot-signature - old-parent-slot new-span-footprint))) - (when (and (ebox--span-footprint-compatible-p - old-span-footprint new-span-footprint) - (ebox--external-footprint-compatible-p - old-external-footprint new-external-footprint) - (ebox--parent-slot-compatible-p - old-parent-slot new-parent-slot)) - (when (ebox--replace-line-spans spans rendered) - (ebox--update-report - region-id 'span-patch - :dirty-count 1 - :patch-count 1 - :patch-ops '(span-patch) - :owner-id (plist-get node :node-id) - :owner-type (plist-get node :ebox-type) - :span-count (length spans) - :old-signature - (plist-get old-span-footprint :line-pixel-widths) - :new-signature - (plist-get new-span-footprint :line-pixel-widths) - :old-span-footprint-signature old-span-footprint - :new-span-footprint-signature new-span-footprint - :old-external-footprint-signature old-external-footprint - :new-external-footprint-signature - new-external-footprint - :old-parent-slot-signature old-parent-slot - :new-parent-slot-signature - new-parent-slot)))))))) - -(defun ebox--incremental-rerender-region (buffer region-id) - "Rerender REGION-ID in BUFFER using the smallest signature-stable owner." - (when-let ((root (ebox--buffer-root-node buffer))) - (ebox--with-buffer-render-context buffer - (with-current-buffer buffer - (let ((path (ebox--node-path-to-region root region-id)) - patched) - (while (and path (not patched)) - (setq patched - (ebox--try-patch-node-same-signature (pop path) region-id))) - patched))))) - -(defun ebox--rerender-region-for-dirty-kind - (region-id dirty-kind &optional changed-keys buffer old-style-values) - "Rerender REGION-ID as a normalized DIRTY-KIND constraint change." - (if-let ((buf (or buffer (ebox--region-buffer region-id)))) - (progn - (ebox--invalidate-buffer-viewport-dependencies buf) - (or (when-let ((change - (ebox--region-constraint-change - buf region-id dirty-kind changed-keys - old-style-values))) - (ebox--execute-constraint-change buf change region-id)) - (progn - (ebox-buffer-apply-root-rerender buf) - (ebox--update-report - region-id 'full-rerender - :dirty-count 1 - :patch-count 0 - :patch-ops nil)))) - (ebox--rerender-region region-id))) - -(defun ebox--rerender-region (region-id) - "Rerender REGION-ID safely. -Prefer a full root-node rerender when the buffer was produced by -`ebox-render-to-buffer'. The in-place path is only a fallback for truly -standalone contiguous slices." - (if-let ((buf (ebox--region-buffer region-id))) - (or (ebox--try-paint-patch-region buf region-id) - (when-let ((report (ebox--incremental-rerender-region - buf region-id))) - (ebox--refresh-buffer-layout-snapshots buf) - report) - (ebox--try-owner-rerender-region buf region-id) - (progn - (ebox-buffer-apply-root-rerender buf) - (ebox--update-report region-id 'full-rerender - :dirty-count 1 - :patch-count 0 - :patch-ops nil))) - (when (ebox--live-box-extents region-id) - (ebox--rerender-box region-id) - (ebox--update-report region-id 'region-rerender - :dirty-count 1 - :patch-count 1 - :patch-ops '(region-rerender))))) - -;;; Region Buffer Lookup - -(defun ebox--region-buffer (region-id) - "Return the buffer that contains the box identified by REGION-ID, or nil." - (ebox--prune-dead-buffer-render-states) - (catch 'found - (maphash (lambda (buf state) - (when (and (buffer-live-p buf) - (gethash region-id - (plist-get state :region-id-set))) - (throw 'found buf))) - ebox--buffer-render-state-table) - (maphash (lambda (buf _state) - (when (and (buffer-live-p buf) - (with-current-buffer buf - (save-excursion - (goto-char (point-min)) - (cl-some - (lambda (entry) - (goto-char (point-min)) - (text-property-search-forward - (cdr entry) region-id t)) - ebox-region-types)))) - (throw 'found buf))) - ebox--buffer-render-state-table) - nil)) - -;;; Viewport Rerender Helper - -(defun ebox-incremental-rerender-buffer-with-context - (buffer viewport-width &optional viewport-height) - "Rerender BUFFER's stored runtime tree using viewport dimensions." - (unless (buffer-live-p buffer) - (error "ebox: buffer is not live: %S" buffer)) - (let ((state (ebox--buffer-render-state buffer))) - (unless state - (error "ebox: buffer has no runtime render state: %S" buffer)) - (let ((defer-gc - (and (not ebox--root-reflow-gc-deferred-p) - (ebox--buffer-visible-in-graphic-frame-p buffer)))) - (when defer-gc - (ebox--deferred-render-gc-enter)) - (unwind-protect - (let ((ebox--root-reflow-gc-deferred-p - (or ebox--root-reflow-gc-deferred-p defer-gc))) - (ebox--with-render-gc - (let* ((old-viewport-width (plist-get state :viewport-width)) - (old-viewport-height - (plist-get state :viewport-height)) - (width-changed - (not (equal viewport-width old-viewport-width))) - (height-changed - (and viewport-height - (not (equal viewport-height - old-viewport-height)))) - (axes (cond - ((and width-changed height-changed) 'both) - (width-changed 'width) - (height-changed 'height) - (t 'none))) - (change nil) - (root-box - (ebox-incremental--prepared-root-context-box - state ebox--prepared-root-render nil)) - (report - (and (not (eq axes 'none)) root-box - (ebox-incremental--publish-prepared-root-native - buffer - (list - :kind 'viewport :region-id nil :axes axes - :target-viewport-width viewport-width - :target-viewport-height - (or viewport-height old-viewport-height) - :target-root-width - (ebox--literal-root-pixel-width root-box)))))) - (if report - report - (unless (eq axes 'none) - (ebox-incremental--notify-before-runtime-mutation - buffer 'viewport)) - (unwind-protect - (progn - (plist-put state :viewport-width viewport-width) - (when viewport-height - (plist-put state :viewport-height viewport-height)) - (setq change - (ebox--viewport-constraint-change buffer axes)) - (let ((ebox--scroll-prefetch-delay-override 0)) - (setq report - (ebox--execute-constraint-change - buffer change nil)))) - ;; A quit or render error must leave the published - ;; dimensions matching the published render, or the - ;; next same-size call compares against the - ;; never-executed dimensions and degrades into a - ;; permanent no-op that never repairs the buffer. - (unless report - (plist-put state :viewport-width old-viewport-width) - (when viewport-height - (plist-put state :viewport-height - old-viewport-height)))) - (setq report - (if (plist-member report :dirty-count) - report - (append - report - (list :dirty-count - (length (plist-get change :dirty-set)))))) - (ebox--set-buffer-update-report buffer report))))) - (when defer-gc - (ebox--deferred-render-gc-schedule-restore)))))) - -(defun ebox--rerender-buffer-preserving-runtime (buffer) - "Rerender BUFFER from stored runtime state without rebuilding identity." - (let ((state (ebox--buffer-render-state buffer))) - (if state - (ebox-incremental-rerender-buffer-with-context - buffer (plist-get state :viewport-width)) - (ebox-buffer-apply-root-rerender buffer)))) - (defun ebox-incremental--batch-state (buffer) "Return explicit batch state for BUFFER." (gethash buffer ebox-incremental--batch-table)) @@ -5643,7 +3591,9 @@ standalone contiguous slices." (error "ebox-incremental: buffer is not live: %S" buffer)) (let ((state (list :active t :pending nil - :flush-count 0))) + :flush-count 0 + :surface-candidate-root nil + :surface-publisher nil))) (puthash buffer state ebox-incremental--batch-table) state)) @@ -5651,18 +3601,31 @@ standalone contiguous slices." "Return the number of pending explicit batch changes for BUFFER." (length (plist-get (ebox-incremental--batch-state buffer) :pending))) -(defun ebox-incremental-record-region-change - (buffer region-id dirty-kind &optional changed-keys old-style-values) - "Record REGION-ID and DIRTY-KIND in BUFFER's explicit batch. -Return non-nil when the change was recorded instead of executed immediately." +(defun ebox-incremental-surface-batch-root (buffer) + "Return BUFFER's unpublished surface batch root, or nil." + (plist-get (ebox-incremental--batch-state buffer) + :surface-candidate-root)) + +(defun ebox-incremental-record-surface-region-change + (buffer candidate-root region-id handle dirty-kind changed-keys + old-style-values publisher) + "Record one surface-scoped change for BUFFER's explicit batch. +CANDIDATE-ROOT is the accumulated unpublished tree. REGION-ID and HANDLE +identify the logical target, DIRTY-KIND, CHANGED-KEYS, and OLD-STYLE-VALUES +describe the semantic change, and PUBLISHER commits the complete batch once +during `ebox-incremental-flush'." (when (ebox-incremental-batching-p buffer) (let ((state (ebox-incremental--batch-state buffer))) + (plist-put state :surface-candidate-root candidate-root) + (plist-put state :surface-publisher publisher) (plist-put state :pending - (append (plist-get state :pending) - (list (list :region-id region-id - :dirty-kind dirty-kind - :changed-keys changed-keys - :old-style-values old-style-values)))) + (append + (plist-get state :pending) + (list (list :region-id region-id + :region-handle handle + :dirty-kind dirty-kind + :changed-keys changed-keys + :old-style-values old-style-values)))) t))) (defun ebox-incremental--merge-old-style-values (old-values additions) @@ -5731,7 +3694,8 @@ Return non-nil when the change was recorded instead of executed immediately." (unless (buffer-live-p buffer) (error "ebox-incremental: buffer is not live: %S" buffer)) (let* ((state (ebox-incremental--batch-state buffer)) - (pending (plist-get state :pending))) + (pending (plist-get state :pending)) + (surface-publisher (plist-get state :surface-publisher))) (unless state (error "ebox-incremental: no active batch for buffer: %S" buffer)) (plist-put state :active nil) @@ -5747,18 +3711,20 @@ Return non-nil when the change was recorded instead of executed immediately." :patch-ops nil :flush-count 1)) (ebox--invalidate-buffer-viewport-dependencies buffer) - (let* ((change (ebox-incremental--batch-change buffer pending)) - report) + (unless surface-publisher + (error "Ebox batch has no TP surface publisher")) + (let (report) (unwind-protect (setq report - (and change - (ebox--execute-constraint-change buffer change nil))) + (funcall + surface-publisher buffer + (plist-get state :surface-candidate-root) pending)) ;; A quit or render error during execution must leave the ;; batch active with its recorded changes still pending so a ;; later retry cannot silently publish a no-op. If execution ;; returned a report, including an explicit no-op report, the ;; flush completed and the batch should close normally. - (when (and change (null report)) + (when (null report) (plist-put state :active t) (plist-put state :pending pending) (plist-put state :flush-count @@ -5770,7 +3736,8 @@ Return non-nil when the change was recorded instead of executed immediately." (ebox--set-buffer-update-report buffer report) (when (and report (not (eq (plist-get report :strategy) 'no-op))) - (ebox--bump-buffer-runtime-revision buffer) + (unless (plist-get report :runtime-published) + (ebox--bump-buffer-runtime-revision buffer)) (run-hook-with-args 'ebox-incremental--after-successful-batch-flush-hook buffer pending report)) @@ -6422,24 +4389,6 @@ Return the same pure surface-input shape as ebox--scroll-global-state) (nreverse keys))) -(defun ebox-incremental--copy-marker-endpoint (endpoint) - "Return an isolated copy of marker ENDPOINT, or ENDPOINT itself." - (if (markerp endpoint) - (copy-marker endpoint (marker-insertion-type endpoint)) - endpoint)) - -(defun ebox-incremental--copy-marker-tree (value) - "Copy marker endpoints recursively through cons tree VALUE." - (cond - ((markerp value) - (ebox-incremental--copy-marker-endpoint value)) - ((vectorp value) - (vconcat (mapcar #'ebox-incremental--copy-marker-tree value))) - ((consp value) - (cons (ebox-incremental--copy-marker-tree (car value)) - (ebox-incremental--copy-marker-tree (cdr value)))) - (t value))) - (defun ebox-incremental--candidate-layout-snapshots (old-state candidate-root candidate-index) "Seed CANDIDATE-ROOT snapshots from OLD-STATE's published geometry." @@ -6486,98 +4435,9 @@ Return the same pure surface-input shape as (setq candidate-scroll-state (ebox--scroll-window-rebind-state-producers candidate-scroll-state candidate-box)) - (dolist (key '(:content-span-markers - :rendered-window-start-marker - :rendered-window-end-marker)) - (when (plist-member candidate-scroll-state key) - (setq candidate-scroll-state - (plist-put - candidate-scroll-state key - (ebox-incremental--copy-marker-tree - (plist-get candidate-scroll-state key)))))) (puthash region-id candidate-scroll-state scroll-state-table)))) scroll-state-table)) -(defun ebox-incremental--detach-region-role-span-table (table) - "Detach every marker endpoint retained by region role span TABLE." - (when (hash-table-p table) - (maphash - (lambda (key spans) - (when (consp key) - (dolist (span spans) - (when (markerp (car span)) - (set-marker (car span) nil)) - (when (markerp (cdr span)) - (set-marker (cdr span) nil))))) - table))) - -(defun ebox-incremental--detach-unshared-region-role-spans - (source retained) - "Detach marker spans in SOURCE that are not shared by RETAINED." - (when (hash-table-p source) - (maphash - (lambda (key spans) - (when (consp key) - (let ((retained-spans (and (hash-table-p retained) - (gethash key retained)))) - (dolist (span spans) - (unless - (cl-some - (lambda (retained-span) - (and (eq (car span) (car retained-span)) - (eq (cdr span) (cdr retained-span)))) - retained-spans) - (when (markerp (car span)) - (set-marker (car span) nil)) - (when (markerp (cdr span)) - (set-marker (cdr span) nil))))))) - source))) - -(defun ebox-incremental--restore-region-role-span-table (table snapshot) - "Restore marker role TABLE entries from shallow SNAPSHOT." - (when (and (hash-table-p table) (hash-table-p snapshot)) - (clrhash table) - (maphash (lambda (key spans) (puthash key spans table)) snapshot) - table)) - -(defun ebox-incremental--detach-scroll-state-table-markers (table) - "Detach scroll markers owned by every state in TABLE." - (when (hash-table-p table) - (maphash - (lambda (_region-id state) - (ebox--scroll-clear-content-markers state) - (ebox--scroll-clear-rendered-window-markers state)) - table))) - -(defun ebox-incremental--retire-removed-region-indexes - (buffer old-state candidate-state) - "Remove BUFFER indexes owned only by OLD-STATE, not CANDIDATE-STATE." - (let ((candidate-region-set (plist-get candidate-state :region-id-set)) - removed-region-ids) - (maphash - (lambda (region-id _present) - (unless (gethash region-id candidate-region-set) - (push region-id removed-region-ids))) - (plist-get old-state :region-id-set)) - (when removed-region-ids - (when-let ((table (ebox--buffer-region-role-span-table buffer))) - (let (removed-keys) - (maphash - (lambda (key _spans) - (when (and (consp key) - (member (car key) removed-region-ids)) - (push key removed-keys))) - table) - (dolist (key removed-keys) - (dolist (span (gethash key table)) - (when (markerp (car span)) - (set-marker (car span) nil)) - (when (markerp (cdr span)) - (set-marker (cdr span) nil))) - (remhash key table)))) - (ebox--clear-box-extents-for-region-ids removed-region-ids)) - removed-region-ids)) - (defun ebox-incremental--node-child-ids-equal-p (old new) "Return non-nil when OLD and NEW have identical direct child runtime ids." (let ((old-children (ebox--node-children old)) @@ -7308,8 +5168,6 @@ CANDIDATE-INDEX is the prepared runtime index for CANDIDATE-ROOT." (plist-get old-state :viewport-dependent-node-id-axes))) (:reflow-prewarm-scratch nil) - (:region-role-span-table - ,(plist-get prepared :region-role-span-table)) (:scroll-region-ids ,(ebox-incremental--hash-keys (plist-get prepared :scroll-state-table))) @@ -7485,12 +5343,6 @@ must be recomputed in the next publication." :viewport-height-dependent-subtree-cache viewport-height-dependent-subtree-cache :layout-snapshots layout-snapshots - :region-role-span-table - ;; Candidate preparation reads published geometry but never - ;; mutates its role index. Reuse that marker forest until a - ;; text-changing batch detaches it once and rebuilds the - ;; winning generation after publication. - (plist-get old-state :region-role-span-table) :scroll-state-table candidate-scroll-state-table)))))) (defun ebox-incremental--prepare-declarative-root @@ -7535,375 +5387,6 @@ must be recomputed in the next publication." (ebox-incremental--prepare-declarative-runtime buffer old-state candidate-root t local-preparation))) -(defun ebox-incremental--prepare-declarative-patch-op-direct (buffer op) - "Return a prepared publication for declarative OP in BUFFER." - (pcase (plist-get op :op) - ('paint-patch - (ebox-buffer-prepare-paint-patch buffer op)) - ('span-patch - (ebox-buffer-prepare-span-patch buffer op)) - ('child-reorder - (ebox-buffer-prepare-child-reorder buffer op)) - ('child-splice - (ebox-buffer-prepare-child-splice buffer op)) - ('owner-rerender - (ebox-buffer-prepare-owner-rerender buffer op)) - (_ nil))) - -(defun ebox-incremental--candidate-install-index - (state root index region-box-table scroll-state-table buffer) - "Install ROOT and INDEX into unpublished STATE and candidate side tables." - (let ((old-node-table (plist-get state :node-table)) - (candidate-node-table (plist-get index :node-table))) - ;; Scroll mutation isolation may path-copy retained nodes after the - ;; candidate caches were seeded. Re-key every persistent eq cache by - ;; stable node id before installing the new index so the current runtime - ;; never retains superseded path-copy nodes. - (dolist (key '(:render-signature-cache - :viewport-height-dependent-subtree-cache - :flex-content-min-widths)) - (plist-put - state key - (ebox-incremental--seed-candidate-node-cache - (plist-get state key) old-node-table candidate-node-table)))) - (plist-put state :root-node root) - (dolist (key '(:node-table :parent-table :region-id-set - :region-node-table :region-box-count-table - :host-ref-table :selector-id-table - :selector-class-table :selector-type-table - :selector-index-stale-p - :native-node-postorder)) - (plist-put state key (plist-get index key))) - (clrhash region-box-table) - (maphash (lambda (region-id box) - (puthash region-id box region-box-table)) - (plist-get index :region-box-table)) - (plist-put state :region-box-table region-box-table) - (maphash - (lambda (region-id scroll-state) - (when-let ((candidate-box (gethash region-id region-box-table))) - (setq scroll-state (plist-put scroll-state :box candidate-box)) - (setq scroll-state (plist-put scroll-state :buffer buffer)) - (setq scroll-state - (ebox--scroll-window-rebind-state-producers - scroll-state candidate-box)) - (puthash region-id scroll-state scroll-state-table))) - scroll-state-table) - state) - -(defun ebox-incremental--candidate-isolate-owner-runtime-mutations - (buffer owner-id) - "Path-copy retained scroll boxes that proof rendering OWNER-ID may mutate." - (let* ((state (ebox--buffer-render-state buffer)) - (old-state - (and ebox-incremental--candidate-base-state - (eq buffer - (car ebox-incremental--candidate-base-state)) - (cdr ebox-incremental--candidate-base-state))) - (old-node-table (and old-state (plist-get old-state :node-table))) - (owner (ebox--buffer-runtime-node buffer owner-id)) - (replacements (make-hash-table :test 'equal))) - (when (and owner old-node-table) - (cl-labels - ((visit (node) - (when (and (listp node) (not (stringp node))) - (let ((node-id (plist-get node :node-id))) - (when (and (eq node (gethash node-id old-node-table)) - (eq (plist-get node :ebox-type) 'box) - (eq (ebox-get node :overflow) 'scroll)) - (puthash node-id (copy-sequence node) replacements))) - (dolist (child (ebox-tree--children-raw node)) - (visit child))))) - (visit owner))) - (when (> (hash-table-count replacements) 0) - (let* ((root (plist-get state :root-node)) - (path-index - (list :node-table (plist-get state :node-table) - :parent-table (plist-get state :parent-table))) - (isolated-root - (ebox-incremental--candidate-path-copy-many - root path-index replacements)) - (isolated-preparation - (ebox-incremental--candidate-local-index-delta - state isolated-root)) - (isolated-index - (plist-get isolated-preparation :index))) - (ebox-incremental--candidate-install-index - state isolated-root isolated-index - ebox--region-box-table ebox--scroll-global-state buffer))) - (ebox--buffer-runtime-node buffer owner-id))) - -(defun ebox-incremental--candidate-proof-node-table (owner) - "Return a node-id table for one isolated deep copy of OWNER." - (let* ((proof-owner (ebox-tree-copy-node-structure owner)) - (table (make-hash-table :test 'equal))) - (cl-labels - ((visit (node) - (when (and (listp node) (not (stringp node))) - (puthash (ebox--ensure-node-id node) node table) - (dolist (child (ebox-tree--children-raw node)) - (visit child))))) - (visit proof-owner)) - table)) - -(defun ebox-incremental--candidate-proof-scroll-state-table - (buffer candidate-table proof-node-table) - "Return an isolated scroll table rebound to PROOF-NODE-TABLE." - (let ((proof-table (make-hash-table :test 'equal))) - (maphash - (lambda (region-id state) - (let* ((candidate-box (plist-get state :box)) - (proof-box - (and candidate-box - (gethash (plist-get candidate-box :node-id) - proof-node-table)))) - (when proof-box - (let ((proof-state - (ebox-incremental--copy-marker-tree - (copy-tree state)))) - (setq proof-state (plist-put proof-state :box proof-box)) - (setq proof-state (plist-put proof-state :buffer buffer)) - (setq proof-state - (ebox--scroll-window-rebind-state-producers - proof-state proof-box)) - (puthash region-id proof-state proof-table))))) - candidate-table) - proof-table)) - -(defun ebox-incremental--merge-proof-scroll-state - (buffer proof-table proof-scope candidate-table candidate-region-box-table) - "Merge PROOF-TABLE scroll state for PROOF-SCOPE into CANDIDATE-TABLE. - -PROOF-SCOPE is the set of region ids whose candidate scroll state was copied -into the proof runtime before rendering. A scoped id missing from PROOF-TABLE -after rendering represents an intentional `ebox--scroll-clear-state' and must -therefore be removed from the candidate runtime as part of the same proof." - (dolist (region-id proof-scope) - (unless (gethash region-id proof-table) - (when-let ((old-candidate-state (gethash region-id candidate-table))) - (ebox--scroll-clear-content-markers old-candidate-state) - (ebox--scroll-clear-rendered-window-markers old-candidate-state)) - (remhash region-id candidate-table))) - (maphash - (lambda (region-id proof-state) - (when-let ((candidate-box - (gethash region-id candidate-region-box-table))) - (let ((candidate-state (copy-sequence proof-state)) - (proof-box (plist-get proof-state :box)) - (old-candidate-state (gethash region-id candidate-table))) - ;; Retained scroll boxes were path-copied before proof, so syncing the - ;; canonical runtime offset cannot mutate the published base object. - (when (and proof-box (plist-member proof-box :scroll-offset)) - (plist-put candidate-box :scroll-offset - (plist-get proof-box :scroll-offset))) - (setq candidate-state - (plist-put candidate-state :box candidate-box)) - (setq candidate-state - (plist-put candidate-state :buffer buffer)) - (setq candidate-state - (ebox--scroll-window-rebind-state-producers - candidate-state candidate-box)) - (when old-candidate-state - (ebox--scroll-clear-content-markers old-candidate-state) - (ebox--scroll-clear-rendered-window-markers old-candidate-state)) - (puthash region-id candidate-state candidate-table)))) - proof-table)) - -(defun ebox-incremental--project-proof-node-cache - (proof-cache proof-node-table candidate-node-table candidate-cache) - "Project PROOF-CACHE entries onto live candidate nodes by stable node id. - -Only keys owned by PROOF-NODE-TABLE are transferable. This keeps proof-copy -conses out of the retained eq-keyed CANDIDATE-CACHE while preserving structural -facts computed during successful proof rendering." - (when (and (hash-table-p proof-cache) - (hash-table-p proof-node-table) - (hash-table-p candidate-node-table) - (hash-table-p candidate-cache)) - (maphash - (lambda (proof-node value) - (when-let* ((node-id (and (listp proof-node) - (plist-get proof-node :node-id))) - ((eq proof-node (gethash node-id proof-node-table))) - (candidate-node (gethash node-id candidate-node-table))) - (puthash candidate-node value candidate-cache))) - proof-cache))) - -(defun ebox-incremental--project-proof-structural-caches - (proof-node-table proof-signature-cache proof-height-cache - proof-flex-content-min-widths candidate-state) - "Promote successful proof cache facts into CANDIDATE-STATE without copies." - (let ((candidate-node-table (plist-get candidate-state :node-table))) - (ebox-incremental--project-proof-node-cache - proof-signature-cache proof-node-table candidate-node-table - (plist-get candidate-state :render-signature-cache)) - (ebox-incremental--project-proof-node-cache - proof-height-cache proof-node-table candidate-node-table - (plist-get candidate-state :viewport-height-dependent-subtree-cache)) - (ebox-incremental--project-proof-node-cache - proof-flex-content-min-widths proof-node-table candidate-node-table - (plist-get candidate-state :flex-content-min-widths)))) - -(defun ebox-incremental--prepare-logical-candidate-patch-op (buffer op) - "Prepare logical candidate OP against a proof-local owner subtree copy." - (let* ((candidate-state (ebox--buffer-render-state buffer)) - (owner-id (plist-get op :owner-id)) - (owner - (ebox-incremental--candidate-isolate-owner-runtime-mutations - buffer owner-id)) - (proof-node-table - (and owner - (ebox-incremental--candidate-proof-node-table owner))) - (candidate-region-box-table ebox--region-box-table) - (candidate-scroll-state-table ebox--scroll-global-state) - (proof-region-box-table - (copy-hash-table candidate-region-box-table)) - (proof-scroll-state-table - (and proof-node-table - (ebox-incremental--candidate-proof-scroll-state-table - buffer candidate-scroll-state-table proof-node-table))) - (proof-scroll-scope - (and proof-scroll-state-table - (ebox-incremental--hash-keys proof-scroll-state-table))) - (proof-idle-timer-table (make-hash-table :test 'equal)) - (proof-smooth-scroll-table (make-hash-table :test 'equal)) - (proof-signature-cache - (ebox-incremental--seed-candidate-node-cache - (plist-get candidate-state :render-signature-cache) - (plist-get candidate-state :node-table) - proof-node-table)) - (proof-height-cache - (ebox-incremental--seed-candidate-node-cache - (plist-get candidate-state - :viewport-height-dependent-subtree-cache) - (plist-get candidate-state :node-table) - proof-node-table)) - (proof-flex-content-min-widths - (ebox-incremental--seed-candidate-node-cache - (plist-get candidate-state :flex-content-min-widths) - (plist-get candidate-state :node-table) - proof-node-table)) - (published-render-cache (plist-get candidate-state :render-cache)) - (proof-render-cache - (if (hash-table-p published-render-cache) - (copy-hash-table published-render-cache) - (make-hash-table :test 'equal))) - (proof-root-cache-table-table - (let ((table (copy-hash-table ebox--render-root-cache-table-table)) - (published-root-cache - (and (hash-table-p published-render-cache) - (gethash published-render-cache - ebox--render-root-cache-table-table)))) - (when (hash-table-p published-root-cache) - (puthash proof-render-cache - (copy-hash-table published-root-cache) - table)) - table)) - (proof-render-cache-ring-table - (copy-hash-table ebox--render-cache-ring-table)) - (proof-render-cache-entry-side-effects-table - (copy-hash-table ebox--render-cache-entry-side-effects-table)) - (proof-render-state - (let ((state (copy-sequence candidate-state))) - (plist-put state :render-cache proof-render-cache) - (plist-put state :render-signature-cache proof-signature-cache) - state)) - prepared transferred-p) - (when proof-node-table - (unwind-protect - (progn - (let ((ebox-incremental--candidate-proof-node-table - (cons buffer proof-node-table)) - (ebox-incremental--buffer-render-state-override - (cons buffer proof-render-state)) - (ebox--region-box-table proof-region-box-table) - (ebox--scroll-global-state proof-scroll-state-table) - (ebox--scroll-idle-prefetch-timers proof-idle-timer-table) - (ebox--smooth-scroll-state-table proof-smooth-scroll-table) - (ebox--render-root-cache-table-table - proof-root-cache-table-table) - (ebox--render-cache-ring-table - proof-render-cache-ring-table) - (ebox--render-cache-entry-side-effects-table - proof-render-cache-entry-side-effects-table) - (ebox--render-cache-signature-cache proof-signature-cache) - (ebox--viewport-dependent-node-ids-cache - (make-hash-table :test 'eq)) - (ebox--viewport-dependent-subtree-cache - (make-hash-table :test 'eq)) - (ebox--viewport-height-dependent-subtree-cache - proof-height-cache) - (ebox--flex-content-min-width-table - proof-flex-content-min-widths)) - (setq prepared - (ebox-incremental--prepare-declarative-patch-op-direct - buffer op))) - (when prepared - (ebox-incremental--merge-proof-scroll-state - buffer proof-scroll-state-table proof-scroll-scope - candidate-scroll-state-table candidate-region-box-table) - (ebox-incremental--project-proof-structural-caches - proof-node-table proof-signature-cache proof-height-cache - proof-flex-content-min-widths candidate-state) - (plist-put candidate-state :render-cache proof-render-cache) - (when-let ((root-cache - (gethash - proof-render-cache - proof-root-cache-table-table))) - (puthash proof-render-cache root-cache - ebox--render-root-cache-table-table) - (when-let ((ring - (gethash root-cache - proof-render-cache-ring-table))) - (puthash root-cache ring ebox--render-cache-ring-table))) - (when-let ((ring - (gethash proof-render-cache - proof-render-cache-ring-table))) - (puthash proof-render-cache ring ebox--render-cache-ring-table)) - (dolist (cache - (delq nil - (list proof-render-cache - (gethash - proof-render-cache - proof-root-cache-table-table)))) - (maphash - (lambda (_key entry) - (when-let ((metadata - (gethash - entry - proof-render-cache-entry-side-effects-table))) - (puthash - entry metadata - ebox--render-cache-entry-side-effects-table))) - cache)) - (setq transferred-p t))) - (unless transferred-p - (ebox-incremental--detach-scroll-state-table-markers - proof-scroll-state-table)))) - prepared)) - -(defun ebox-incremental--op-with-published-owner-scope (buffer op) - "Return text-changing OP with BUFFER's replaced publication scope." - (if (not (ebox--text-changing-patch-op-p op)) - op - (when-let ((published - (ebox--snapshot-published-node - buffer (plist-get op :owner-id)))) - (setq op (copy-sequence op)) - (setq op - (plist-put op :old-owner-region-ids - (ebox--node-all-region-ids published)))) - op)) - -(defun ebox-incremental--prepare-declarative-patch-op (buffer op) - "Return a non-mutating prepared publication for declarative OP in BUFFER." - (setq op (ebox-incremental--op-with-published-owner-scope buffer op)) - (if (memq (plist-get op :op) '(child-reorder child-splice)) - (ebox-incremental--prepare-declarative-patch-op-direct buffer op) - (if (plist-get (ebox--buffer-render-state buffer) :logical-candidate-p) - (ebox-incremental--prepare-logical-candidate-patch-op buffer op) - (ebox-incremental--prepare-declarative-patch-op-direct buffer op)))) - (defun ebox-incremental--declarative-tentative-op (buffer entry) "Return the smallest proof candidate for declarative dirty ENTRY." (let* ((entry (ebox--dirty-entry-with-impact-vector buffer entry)) @@ -7920,6 +5403,8 @@ facts computed during successful proof rendering." (pcase dirty-kind ('paint (ebox--patch-op 'paint-patch node-id :dirty entry)) + ('geometry + (ebox--patch-op-from-dirty-entry entry buffer)) (_ (if-let ((span-owner-id (ebox--span-patch-owner-id-for-dirty-entry entry buffer))) @@ -7967,6 +5452,19 @@ published slots, so this only changes op granularity, never safety." :dirty (plist-get (car group) :dirty)))) (dolist (op (cdr group)) (setq merged (ebox--patch-op-merge-dirty merged op))) + (let ((candidate-id parent-id) + contained-owner-id) + (while (and candidate-id (not contained-owner-id)) + (if (ebox--contained-partial-flex-owner-p + buffer candidate-id) + (setq contained-owner-id candidate-id) + (setq candidate-id + (ebox--runtime-parent-id buffer candidate-id)))) + (when contained-owner-id + (setq merged + (ebox--patch-op + 'owner-rerender contained-owner-id + :dirty (plist-get merged :dirty))))) (push merged coalesced)) (setq result (nconc group result)))) groups) @@ -8169,318 +5667,65 @@ render whose line count moved is unpublishable there by construction." (or (ebox--runtime-parent-id buffer ancestor-id) root-id))) ancestor-id)) -(defun ebox-incremental--declarative-promoted-op (buffer op) - "Return the next wider declarative planning OP after OP failed preparation. -The root owner is the terminal element of this planning lattice. Structural -changes skip ancestors that cannot absorb a line-count change. Overflow -coverage skips wrappers that cannot own the old suffix and partial slots that -reject a merged non-overflow change before rendering." - (let* ((strategy (plist-get op :op)) - (owner-id (plist-get op :owner-id)) - (dirty (plist-get op :dirty)) - (root-id (ebox--buffer-root-node-id buffer))) - (cond - ((memq strategy '(paint-patch child-reorder child-splice span-patch)) - (ebox--patch-op 'owner-rerender owner-id - :dirty dirty - :planning-promoted-from strategy)) - ((and (eq strategy 'owner-rerender) - (not (equal owner-id root-id))) - (when-let ((ancestor-id - (ebox-incremental--declarative-next-owner-id - buffer owner-id dirty))) - (ebox--patch-op 'owner-rerender ancestor-id - :dirty dirty - :planning-promoted-from owner-id))) - (t nil)))) - -(defun ebox-incremental--declarative-op-proof-key (op) - "Return the transaction-local proof cache key for declarative OP." - (let ((dirty (plist-get op :dirty))) - (list (plist-get op :op) - (plist-get op :owner-id) - (plist-get dirty :dirty-kind) - (ebox--dirty-provenance-node-ids dirty) - (plist-get dirty :changed-keys) - (plist-get dirty :old-child-ids) - (plist-get dirty :new-child-ids) - (plist-get dirty :old-region-ids) - (plist-get dirty :new-region-ids) - (plist-get dirty :impact-vector) - (plist-get dirty :requires-owned-overflow-coverage)))) - -(defun ebox-incremental--prepared-op-spans (op) - "Return the published buffer spans proven for prepared OP." - (plist-get (plist-get op :prepared-publication) :spans)) - -(defun ebox-incremental--prepared-span-sets-overlap-p (left right) - "Return non-nil when prepared span sets LEFT and RIGHT overlap." - (cl-some - (lambda (left-span) - (cl-some - (lambda (right-span) - (and (< (car left-span) (cdr right-span)) - (> (cdr left-span) (car right-span)))) - right)) - left)) - -(defun ebox-incremental--prepared-span-set-contains-p (outer inner) - "Return non-nil when every span in INNER is contained by OUTER." - (cl-every - (lambda (inside) - (cl-some - (lambda (outside) - (and (<= (car outside) (car inside)) - (<= (cdr inside) (cdr outside)))) - outer)) - inner)) - -(defun ebox-incremental--first-overlapping-prepared-text-ops (ops) - "Return the first pair of overlapping prepared text-changing OPS." - (catch 'overlap - (let ((remaining ops)) - (while remaining - (let ((left (pop remaining))) - (when (ebox--text-changing-patch-op-p left) - (dolist (right remaining) - (when (and - (ebox--text-changing-patch-op-p right) - (ebox-incremental--prepared-span-sets-overlap-p - (ebox-incremental--prepared-op-spans left) - (ebox-incremental--prepared-op-spans right))) - (throw 'overlap (list left right)))))))))) - -(defun ebox-incremental--overlapping-prepared-owner-id - (buffer left right) - "Return the owner that safely absorbs overlapping prepared LEFT and RIGHT." - (let ((left-spans (ebox-incremental--prepared-op-spans left)) - (right-spans (ebox-incremental--prepared-op-spans right))) - (cond - ((ebox-incremental--prepared-span-set-contains-p left-spans right-spans) - (plist-get left :owner-id)) - ((ebox-incremental--prepared-span-set-contains-p right-spans left-spans) - (plist-get right :owner-id)) - ;; Partially crossing text replacements do not establish a composable - ;; local publication order. Re-prove the complete root instead. - (t (ebox--buffer-root-node-id buffer))))) - -(defun ebox-incremental--merge-overlapping-prepared-ops - (buffer ops overlapping) - "Replace OVERLAPPING prepared OPS with their common owner in OPS." - (let* ((left (car overlapping)) - (right (cadr overlapping)) - (left-owner-id (plist-get left :owner-id)) - (right-owner-id (plist-get right :owner-id)) - (common-owner-id - (ebox-incremental--overlapping-prepared-owner-id - buffer left right)) - (merged - (ebox--patch-op - 'owner-rerender common-owner-id - :dirty - (ebox--merge-dirty-provenance - (plist-get left :dirty) - (plist-get right :dirty)))) - (remaining - (cl-remove-if - (lambda (op) - (let ((owner-id (plist-get op :owner-id))) - (or (equal owner-id left-owner-id) - (equal owner-id right-owner-id)))) - ops))) - (ebox--dedupe-patch-owners - buffer (ebox--merge-patch-op-into-set buffer remaining merged)))) - -(defun ebox-incremental--prepare-declarative-patch-set - (buffer render-dirty-set) - "Return final prepared ops for RENDER-DIRTY-SET in BUFFER. -All owner promotion and rendering finishes before this function returns." - (when render-dirty-set - (let* ((ops - (ebox-incremental--declarative-tentative-patch-set - buffer render-dirty-set)) - (node-count - (hash-table-count - (or (ebox--buffer-node-table buffer) - (make-hash-table)))) - (attempt-limit (max 8 (* 3 (max 1 node-count)))) - (attempt-count 0) - (seen (make-hash-table :test 'equal)) - (proof-cache (make-hash-table :test 'equal)) - (missing-proof (make-symbol "ebox-missing-proof")) - complete) - (unless ops - (error "Ebox declarative dirty set produced no patch owners")) - (ebox--with-layout-snapshot-detail-context buffer - (while (not complete) - (when (> (cl-incf attempt-count) attempt-limit) - (error "Ebox declarative owner planning did not converge")) - (let ((ordered (ebox--patch-set-execution-order buffer ops)) - prepared-ops - failed-op) - (while (and ordered (not failed-op)) - (let* ((op (pop ordered)) - (proof-key - (ebox-incremental--declarative-op-proof-key op)) - (signature proof-key) - (cached (gethash proof-key proof-cache missing-proof)) - (prepared - (if (eq cached missing-proof) - (let ((proof - (ebox-incremental--prepare-declarative-patch-op - buffer op))) - (when proof - (puthash proof-key proof proof-cache)) - proof) - cached))) - (if prepared - (let ((prepared-op (copy-sequence op))) - (setq prepared-op - (plist-put prepared-op - :prepared-publication prepared)) - (push prepared-op prepared-ops)) - (when (gethash signature seen) - (error "Ebox declarative owner planning repeated %S" - signature)) - (puthash signature t seen) - (setq failed-op op)))) - (if (not failed-op) - (let* ((prepared-ops (nreverse prepared-ops)) - (overlapping - (ebox-incremental--first-overlapping-prepared-text-ops - prepared-ops))) - (if (not overlapping) - (setq complete prepared-ops) - (let* ((left (car overlapping)) - (right (cadr overlapping)) - (common-owner-id - (ebox-incremental--overlapping-prepared-owner-id - buffer left right)) - (signature - (list 'overlap - (plist-get left :owner-id) - (plist-get right :owner-id) - common-owner-id))) - (when (gethash signature seen) - (error - "Ebox declarative overlapping owner planning repeated %S" - signature)) - (puthash signature t seen) - (setq ops - (ebox-incremental--merge-overlapping-prepared-ops - buffer ops overlapping))))) - (let ((promoted - (ebox-incremental--declarative-promoted-op - buffer failed-op))) - (unless promoted - (error "Ebox declarative root owner could not be prepared")) - ;; OPS minus the failed op is already pairwise - ;; non-dominated, and one merge preserves that closure, - ;; so a full re-dedupe here would only repeat every - ;; dominance check. - (setq ops - (ebox--merge-patch-op-into-set - buffer (delq failed-op (copy-sequence ops)) - promoted))))))) - complete))) - -(defun ebox-incremental--prepared-plan-result (op) - "Return publication metadata predicted by prepared patch OP." - (let* ((prepared (plist-get op :prepared-publication)) - (spans (or (plist-get prepared :spans) - (plist-get prepared :old-refresh-spans))) - (result - (list :op (plist-get op :op) - :owner-id (plist-get op :owner-id) - :owner-type (plist-get prepared :owner-type) - :span-count (length spans)))) - (dolist (key '(:affected-region-ids :slot-preserving - :flex-line-rerender :publication-scope - :published-buffer-spans :full-root-required-p - :patch-origin :full-frame-bytes :replacement-bytes - :old-span-footprint-signature - :new-span-footprint-signature - :old-external-footprint-signature - :new-external-footprint-signature - :old-parent-slot-signature - :new-parent-slot-signature)) - (when (plist-member prepared key) - (setq result (plist-put result key (plist-get prepared key))))) - result)) - -(defun ebox-incremental--prepared-plan-report - (prepared patch-set candidate-state) - "Return Ebox's semantic report for PREPARED PATCH-SET and CANDIDATE-STATE." - (let* ((results (mapcar #'ebox-incremental--prepared-plan-result patch-set)) - (strategy (and results (ebox--patch-results-strategy results))) - (single (and (= (length results) 1) (car results))) - (patch-report - (and results - (ebox--update-report - nil strategy - :dirty-count (length (plist-get prepared :dirty-set)) - :patch-count (length results) - :patch-ops (mapcar (lambda (result) - (plist-get result :op)) - results) - :owner-ids (mapcar (lambda (result) - (plist-get result :owner-id)) - results) - :owner-type (plist-get single :owner-type) - :span-count (cl-loop for result in results - sum (plist-get result :span-count)) - :slot-preserving - (cl-some (lambda (result) - (plist-get result :slot-preserving)) - results) - :old-span-footprint-signature - (plist-get single :old-span-footprint-signature) - :new-span-footprint-signature - (plist-get single :new-span-footprint-signature) - :old-external-footprint-signature - (plist-get single :old-external-footprint-signature) - :new-external-footprint-signature - (plist-get single :new-external-footprint-signature) - :old-parent-slot-signature - (plist-get single :old-parent-slot-signature) - :new-parent-slot-signature - (plist-get single :new-parent-slot-signature))))) - (ebox-incremental--commit-report - prepared patch-report candidate-state))) - -(defun ebox-incremental--prepare-surface-patch-report - (buffer old-state prepared candidate-state) - "Prove BUFFER's PREPARED candidate against OLD-STATE. -Return a non-publishing report for CANDIDATE-STATE." - (let* ((render-dirty-set - (cl-remove-if - (lambda (entry) (eq (plist-get entry :dirty-kind) 'metadata)) - (plist-get prepared :dirty-set))) - (candidate-region-box-table - (plist-get candidate-state :region-box-table)) - (candidate-scroll-state-table - (plist-get prepared :scroll-state-table)) - (base-buffer-modified-tick - (with-current-buffer buffer (buffer-modified-tick))) - patch-set) - (let ((ebox--region-box-table candidate-region-box-table) - (ebox--scroll-global-state candidate-scroll-state-table) - (ebox--scroll-idle-prefetch-timers (make-hash-table :test 'equal)) - (ebox--smooth-scroll-state-table (make-hash-table :test 'equal)) - (ebox--render-cache-scroll-state-restorable-p nil) - (ebox-incremental--buffer-render-state-override +(defun ebox-incremental--layout-owner-plan + (buffer old-state candidate-state dirty-set) + "Return BUFFER layout-owner plan from OLD-STATE to CANDIDATE-STATE for DIRTY-SET." + (when-let ((render-dirty-set + (cl-remove-if + (lambda (entry) + (eq (plist-get entry :dirty-kind) 'metadata)) + dirty-set))) + (let ((ebox-incremental--buffer-render-state-override (cons buffer candidate-state)) - (ebox-incremental--candidate-base-state (cons buffer old-state))) + (ebox-incremental--candidate-base-state + (cons buffer old-state)) + (ebox--region-box-table + (plist-get candidate-state :region-box-table)) + (ebox--scroll-global-state + (plist-get candidate-state :scroll-state-table)) + (ebox--scroll-idle-prefetch-timers + (make-hash-table :test 'equal)) + (ebox--smooth-scroll-state-table + (make-hash-table :test 'equal)) + (ebox--render-cache-scroll-state-restorable-p nil)) (cl-letf (((symbol-function 'ebox--scroll-schedule-idle-prefetch) (lambda (&rest _) nil))) - (setq patch-set - (ebox-incremental--prepare-declarative-patch-set - buffer render-dirty-set)))) - (unless (= base-buffer-modified-tick - (with-current-buffer buffer (buffer-modified-tick))) - (error "Ebox buffer changed while declarative patches were prepared")) - (ebox-incremental--prepared-plan-report - prepared patch-set candidate-state))) + (ebox-incremental--declarative-tentative-patch-set + buffer render-dirty-set))))) + +(defun ebox-incremental--layout-owner-report + (prepared owner-plan candidate-state) + "Return Ebox report for PREPARED, OWNER-PLAN, and CANDIDATE-STATE." + (let* ((owner-ids + (mapcar (lambda (op) (plist-get op :owner-id)) owner-plan)) + (single-owner (and (= (length owner-ids) 1) (car owner-ids))) + (snapshots (plist-get candidate-state :layout-snapshots)) + (node-table (plist-get candidate-state :node-table)) + (report + (and owner-plan + (ebox--update-report + nil (ebox--patch-results-strategy owner-plan) + :dirty-count (length (plist-get prepared :dirty-set)) + :patch-count (length owner-plan) + :patch-ops + (mapcar (lambda (op) (plist-get op :op)) owner-plan) + :owner-ids owner-ids + :owner-type + (plist-get (gethash single-owner node-table) :ebox-type) + :span-count + (cl-loop for owner-id in owner-ids + for snapshot = (and snapshots + (gethash owner-id snapshots)) + sum (length (plist-get snapshot :buffer-spans))) + :publication-scope 'layout-owners)))) + (ebox-incremental--commit-report prepared report candidate-state))) + +(defun ebox-incremental--same-root-surface-identity-p (old-root new-root) + "Return non-nil when OLD-ROOT and NEW-ROOT retain one TP root object." + (and (eq (plist-get old-root :ebox-type) + (plist-get new-root :ebox-type)) + (equal (ebox-tree-node-key old-root) + (ebox-tree-node-key new-root)))) (defun ebox-incremental--surface-commit-input (buffer old-state prepared) @@ -8489,26 +5734,200 @@ Return a non-publishing report for CANDIDATE-STATE." (ebox-incremental--candidate-state old-state (plist-get prepared :root) (plist-get prepared :index) prepared)) + (owner-plan + (ebox-incremental--layout-owner-plan + buffer old-state candidate-state + (plist-get prepared :dirty-set))) + (same-root-identity-p + (ebox-incremental--same-root-surface-identity-p + (plist-get old-state :root-node) + (plist-get candidate-state :root-node))) + (scope-node-ids + (and same-root-identity-p + (delete-dups + (or (mapcar (lambda (op) (plist-get op :owner-id)) owner-plan) + (mapcar (lambda (entry) (plist-get entry :node-id)) + (plist-get prepared :dirty-set)))))) (report - (ebox-incremental--prepare-surface-patch-report - buffer old-state prepared candidate-state))) + (ebox-incremental--layout-owner-report + prepared owner-plan candidate-state))) + (unless same-root-identity-p + (setq report (plist-put report :strategy 'root-rerender)) + (setq report (plist-put report :publication-scope 'root)) + (setq report (plist-put report :root-rerender t))) (list :root (plist-get prepared :root) + :scope-node-ids scope-node-ids :report-base report :preserve-identities-p t :state-overrides - (list :logical-candidate-p + (list :viewport-width (plist-get old-state :viewport-width) + :viewport-height (plist-get old-state :viewport-height) + :display-signature + (plist-get prepared :display-signature) + :scroll-state-table + (plist-get prepared :scroll-state-table) + :logical-candidate-p (plist-get prepared :logical-candidate-p) :detached-identity-history (plist-get prepared :detached-identity-history))))) +(defun ebox-incremental--plist-overlay (base overrides) + "Return BASE with every property in OVERRIDES replaced." + (setq base (copy-sequence base)) + (while overrides + (setq base (plist-put base (pop overrides) (pop overrides)))) + base) + +(defun ebox-incremental-prepare-scoped-commit + (buffer next-root report-overrides) + "Prepare NEXT-ROOT for object-scoped TP publication in BUFFER. +REPORT-OVERRIDES replaces Ebox semantic report fields. The returned plist +contains the isolated candidate root, Ebox node identities, report, and +runtime overrides; the surface layer resolves identities to opaque TP objects." + (let ((old-state (ebox--buffer-render-state buffer))) + (unless old-state + (error "Ebox buffer has no rendered runtime: %S" buffer)) + (let* ((prepared + (ebox-incremental--prepare-declarative-root + buffer old-state next-root)) + (candidate-state + (ebox-incremental--candidate-state + old-state (plist-get prepared :root) + (plist-get prepared :index) prepared)) + (owner-plan + (ebox-incremental--layout-owner-plan + buffer old-state candidate-state + (plist-get prepared :dirty-set))) + (scope-node-ids + (delete-dups + (or (mapcar (lambda (op) (plist-get op :owner-id)) owner-plan) + (mapcar (lambda (entry) (plist-get entry :node-id)) + (plist-get prepared :dirty-set))))) + (report + (ebox-incremental--plist-overlay + (ebox-incremental--layout-owner-report + prepared owner-plan candidate-state) + report-overrides))) + (list :root (plist-get prepared :root) + :scope-node-ids scope-node-ids + :report-base report + :state-overrides + (list :viewport-width (plist-get old-state :viewport-width) + :viewport-height (plist-get old-state :viewport-height) + :display-signature + (plist-get prepared :display-signature) + :scroll-state-table + (plist-get prepared :scroll-state-table) + :detached-identity-history + (plist-get prepared :detached-identity-history)))))) + +(defun ebox-incremental--viewport-state-overrides (state) + "Return surface overrides from viewport candidate STATE." + (list :viewport-width (plist-get state :viewport-width) + :viewport-height (plist-get state :viewport-height) + :display-signature (plist-get state :display-signature) + :render-cache (plist-get state :render-cache) + :render-signature-cache (plist-get state :render-signature-cache) + :flex-content-min-widths (plist-get state :flex-content-min-widths) + :viewport-height-dependent-subtree-cache + (plist-get state :viewport-height-dependent-subtree-cache) + :viewport-dependent-node-ids-ready + (plist-get state :viewport-dependent-node-ids-ready) + :viewport-dependent-node-ids + (plist-get state :viewport-dependent-node-ids) + :viewport-dependent-node-id-axes + (plist-get state :viewport-dependent-node-id-axes) + :scroll-state-table (plist-get state :scroll-state-table) + :detached-identity-history + (plist-get state :detached-identity-history))) + +(defun ebox-incremental-prepare-viewport-commit + (buffer viewport-width viewport-height axes display-signature) + "Prepare BUFFER for VIEWPORT-WIDTH, VIEWPORT-HEIGHT, AXES, and DISPLAY-SIGNATURE." + (let* ((old-state (ebox--buffer-render-state buffer)) + (change (ebox--viewport-constraint-change buffer axes)) + (display-changed + (not (equal display-signature + (plist-get old-state :display-signature)))) + (dirty-set + (if display-changed + (let ((root (plist-get old-state :root-node))) + (list + (ebox--dirty-entry + (plist-get root :node-id) 'geometry + :changed-keys '(:display-signature) + :old-region-ids (ebox--node-all-region-ids root) + :new-region-ids (ebox--node-all-region-ids root)))) + (plist-get change :dirty-set))) + (target-height + (or viewport-height (plist-get old-state :viewport-height)))) + (setq change (plist-put change :dirty-set dirty-set)) + (if (null dirty-set) + (list :no-op t + :report-base + (append + (ebox--update-report + nil 'no-op :dirty-count 0 :patch-count 0 :patch-ops nil) + (ebox--constraint-change-report-props change) + (list :viewport-axes axes + :host-context-changed display-changed)) + :state-overrides + (list :viewport-width viewport-width + :viewport-height target-height + :display-signature display-signature)) + (let* ((candidate-scroll-table + (ebox-incremental--candidate-scroll-state-table + buffer old-state old-state + (plist-get old-state :region-box-table))) + (candidate-state (copy-sequence old-state)) + (prepared + (list :root (plist-get old-state :root-node) + :dirty-set dirty-set + :display-signature display-signature + :scroll-state-table candidate-scroll-table)) + owner-plan report) + (plist-put candidate-state :viewport-width viewport-width) + (plist-put candidate-state :viewport-height target-height) + (plist-put candidate-state :display-signature display-signature) + (plist-put candidate-state :runtime-revision + (1+ (or (plist-get old-state :runtime-revision) 0))) + (plist-put candidate-state :region-box-table + (copy-hash-table + (plist-get old-state :region-box-table))) + (plist-put candidate-state :scroll-state-table candidate-scroll-table) + (setq owner-plan + (ebox-incremental--layout-owner-plan + buffer old-state candidate-state dirty-set) + report + (ebox-incremental--plist-overlay + (ebox-incremental--layout-owner-report + prepared owner-plan candidate-state) + (append (ebox--constraint-change-report-props change) + (list :viewport-axes axes + :host-context-changed display-changed)))) + (list :root (plist-get candidate-state :root-node) + :scope-node-ids + (delete-dups + (or (mapcar (lambda (op) (plist-get op :owner-id)) owner-plan) + (mapcar (lambda (entry) (plist-get entry :node-id)) + dirty-set))) + :report-base report + :state-overrides + (ebox-incremental--viewport-state-overrides + candidate-state)))))) + (defun ebox-incremental--finalize-declarative-scroll-publication - (scroll-keys) - "Retire stale scroll timers and resume prefetch for committed SCROLL-KEYS." + (scroll-keys &optional prefetch-delay) + "Retire stale scroll work and resume committed SCROLL-KEYS. +PREFETCH-DELAY is forwarded when a live, inactive scroll region needs warming." (dolist (region-id scroll-keys) (ignore-errors (ebox--scroll-cancel-idle-prefetch region-id)) - (ignore-errors (ebox--smooth-scroll-stop region-id)) - (when (gethash region-id ebox--scroll-global-state) - (ignore-errors (ebox--scroll-schedule-idle-prefetch region-id))))) + (if (gethash region-id ebox--scroll-global-state) + (unless (gethash region-id ebox--smooth-scroll-state-table) + (ignore-errors + (ebox--scroll-schedule-idle-prefetch + region-id prefetch-delay))) + (ignore-errors (ebox--smooth-scroll-stop region-id))))) (defun ebox-incremental--commit-report (prepared patch-report state) "Return the declarative report for PREPARED, PATCH-REPORT, and STATE." @@ -8545,442 +5964,6 @@ Return a non-publishing report for CANDIDATE-STATE." (plist-get state :runtime-revision))) report)) -(defun ebox-incremental--restore-declarative-publication - (buffer old-state region-keys scroll-keys - region-snapshot scroll-snapshot &optional role-index-restored-p) - "Restore declarative publication state for BUFFER after nonlocal exit. -ROLE-INDEX-RESTORED-P means the transaction journal restored its exact table." - (let ((inhibit-quit t)) - (if (buffer-live-p buffer) - (progn - (puthash buffer old-state ebox--buffer-render-state-table) - (ebox-incremental--restore-hash-snapshot - ebox--region-box-table region-snapshot) - (ebox-incremental--restore-hash-snapshot - ebox--scroll-global-state scroll-snapshot) - (ignore-errors (ebox--refresh-buffer-box-extents buffer)) - (unless role-index-restored-p - (ignore-errors (ebox-buffer-refresh-region-role-spans buffer)))) - ;; `kill-buffer-hook' is authoritative teardown. A transaction that - ;; loses its buffer must never resurrect the old runtime snapshot or - ;; candidate-only region state while unwinding. - (ebox--clear-buffer-render-state buffer) - (dolist (region-id region-keys) - (remhash region-id ebox--region-box-table)) - (dolist (region-id scroll-keys) - (ignore-errors (ebox--scroll-clear-state region-id)) - (ignore-errors (ebox--smooth-scroll-stop region-id))) - (ebox--clear-box-extents-for-region-ids region-keys)))) - -(defun ebox-incremental--empty-logical-candidate-fast-p - (buffer old-state candidate) - "Return non-nil when CANDIDATE may publish as an O(1) runtime no-op. - -A display-signature change is render dirtiness even when the logical tree is -unchanged, so it must use the ordinary declarative transaction." - (and (null (ebox-candidate--replacements candidate)) - (with-current-buffer buffer - (equal (plist-get old-state :display-signature) - (ebox--current-display-signature))))) - -(defun ebox-incremental--empty-logical-candidate-state (old-state) - "Return a new runtime generation sharing every payload in OLD-STATE." - (let ((state (copy-sequence old-state))) - (setq state (plist-put state :logical-candidate-p t)) - (setq state - (plist-put state :runtime-revision - (1+ (or (plist-get old-state :runtime-revision) 0)))) - (setq state (plist-put state :reflow-prewarm-scratch nil)) - state)) - -(defun ebox-incremental--publish-empty-logical-candidate - (buffer old-state) - "Publish one runtime-only generation sharing OLD-STATE's payloads." - (let* ((candidate-state - (ebox-incremental--empty-logical-candidate-state old-state)) - (prepared - (list :root (plist-get old-state :root-node) - :dirty-set nil - :logical-candidate-p t)) - report published-p) - (unwind-protect - (let ((inhibit-quit t)) - (puthash buffer candidate-state ebox--buffer-render-state-table) - (setq report - (ebox-incremental--commit-report - prepared nil candidate-state)) - (plist-put candidate-state :last-update-report report) - (when ebox-incremental--after-declarative-publication - (funcall ebox-incremental--after-declarative-publication report)) - (unless (buffer-live-p buffer) - (error "Ebox declarative target died during publication")) - (setq published-p t) - report) - (unless published-p - (let ((inhibit-quit t)) - (if (buffer-live-p buffer) - (puthash buffer old-state ebox--buffer-render-state-table) - ;; Buffer teardown is authoritative. Never resurrect the old - ;; state after a publication callback kills it. - (remhash buffer ebox--buffer-render-state-table))))))) - -(defun ebox-incremental--commit-declarative-transaction - (buffer old-state prepare-function &optional stale-validator - empty-logical-candidate) - "Run one declarative transaction prepared by PREPARE-FUNCTION in BUFFER. - -STALE-VALIDATOR, when non-nil, runs before and after the mutation notification -and must reject an obsolete logical base without changing publication. -EMPTY-LOGICAL-CANDIDATE enables the O(1) runtime-only publication branch after -the shared stale and mutation-notification preamble." - (unless (buffer-live-p buffer) - (error "Ebox declarative commit requires a live buffer")) - (let ((defer-gc (ebox--buffer-visible-in-graphic-frame-p buffer))) - (when defer-gc - (ebox--deferred-render-gc-enter)) - (unwind-protect - (ebox--with-render-gc - (let ((old-state old-state)) - (unless old-state - (error "Ebox buffer has no rendered runtime: %S" buffer)) - (with-current-buffer buffer - (save-restriction - (widen) - (when ebox-incremental--declarative-commit-buffer - (error "Ebox declarative commit cannot reenter while %S is active" - ebox-incremental--declarative-commit-buffer)) - (when stale-validator - (funcall stale-validator buffer old-state)) - (ebox--cancel-buffer-runtime-prewarm buffer) - ;; Mutation hooks run before candidate ids are allocated. A hook may - ;; advance the shared monotonic allocators by rendering another buffer. - (ebox-incremental--notify-before-runtime-mutation - buffer 'declarative-commit) - (unless (eq old-state (ebox--buffer-render-state buffer)) - (error "Ebox runtime changed during declarative commit notification")) - (when stale-validator - (funcall stale-validator buffer old-state)) - (let ((ebox-incremental--declarative-commit-buffer buffer) - (live-region-box-table ebox--region-box-table) - (live-scroll-state-table ebox--scroll-global-state) - (base-buffer-modified-tick (buffer-modified-tick)) - prepared candidate-state patch-report report patch-set - render-dirty-set - region-keys scroll-keys region-snapshot scroll-snapshot) - (unwind-protect - (if (and empty-logical-candidate - (ebox-incremental--empty-logical-candidate-fast-p - buffer old-state empty-logical-candidate)) - (ebox-incremental--publish-empty-logical-candidate - buffer old-state) - (progn - (setq prepared - (funcall prepare-function buffer old-state)) - (setq candidate-state - (ebox-incremental--candidate-state - old-state - (plist-get prepared :root) - (plist-get prepared :index) - prepared)) - (setq render-dirty-set - (cl-remove-if - (lambda (entry) - (eq (plist-get entry :dirty-kind) 'metadata)) - (plist-get prepared :dirty-set))) - (setq region-keys - (delete-dups - (append - (ebox-incremental--hash-keys - (plist-get old-state :region-id-set)) - (ebox-incremental--hash-keys - (plist-get candidate-state :region-id-set))))) - (setq scroll-keys - (delete-dups - (append - (copy-sequence (plist-get old-state :scroll-region-ids)) - (ebox-incremental--buffer-scroll-keys buffer) - (ebox-incremental--hash-keys - (plist-get prepared :scroll-state-table))))) - (setq region-snapshot - (ebox-incremental--hash-snapshot - live-region-box-table region-keys)) - (setq scroll-snapshot - (ebox-incremental--hash-snapshot - live-scroll-state-table scroll-keys)) - (let ((candidate-region-box-table - (plist-get candidate-state :region-box-table)) - (candidate-scroll-state-table - (plist-get prepared :scroll-state-table)) - (candidate-idle-timer-table - (make-hash-table :test 'equal)) - (candidate-smooth-scroll-table - (make-hash-table :test 'equal)) - old-role-table-to-detach - candidate-role-table-to-detach - old-frozen-role-table - candidate-frozen-role-table - role-table-rollback-snapshot - role-table-journal-p - published-p role-tables-detached-p) - (unwind-protect - (progn - (let ((ebox--region-box-table - candidate-region-box-table) - (ebox--scroll-global-state - candidate-scroll-state-table) - (ebox--scroll-idle-prefetch-timers - candidate-idle-timer-table) - (ebox--smooth-scroll-state-table - candidate-smooth-scroll-table) - ;; Candidate nodes are fresh cons cells. Reuse - ;; pure persistent render entries, but never - ;; replay a cached closure owned by the old tree. - (ebox--render-cache-scroll-state-restorable-p - nil)) - (cl-letf - (((symbol-function - 'ebox--scroll-schedule-idle-prefetch) - (lambda (&rest _) nil))) - ;; Candidate lookups are dynamically isolated while - ;; owner selection and rendering are proven against - ;; the still-published buffer geometry. - (let ((ebox-incremental--buffer-render-state-override - (cons buffer candidate-state)) - (ebox-incremental--candidate-base-state - (cons buffer old-state))) - (setq patch-set - (ebox-incremental--prepare-declarative-patch-set - buffer render-dirty-set))) - (unless (= base-buffer-modified-tick - (buffer-modified-tick)) - (error - "Ebox buffer changed while declarative patches were prepared")) - (when (cl-some - #'ebox--text-changing-patch-op-p patch-set) - (ebox--materialize-box-extent-template buffer) - ;; Prepared spans are now the publication - ;; coordinate source. A shared marker table can - ;; follow the atomic buffer edit directly; its - ;; shallow journal preserves old hash entries for - ;; rollback while scoped refresh replaces only - ;; affected keys. Other table shapes retain the - ;; conservative frozen-coordinate path. - (setq old-role-table-to-detach - (plist-get old-state - :region-role-span-table) - candidate-role-table-to-detach - (plist-get candidate-state - :region-role-span-table)) - (if (and - (eq old-role-table-to-detach - candidate-role-table-to-detach) - (hash-table-p old-role-table-to-detach) - (ebox-buffer--marker-role-span-table-p - old-role-table-to-detach)) - (setq role-table-journal-p t - role-table-rollback-snapshot - (copy-hash-table - old-role-table-to-detach)) - (setq old-frozen-role-table - (ebox-buffer--copy-frozen-region-role-span-table - old-role-table-to-detach) - candidate-frozen-role-table - (ebox-buffer--copy-frozen-region-role-span-table - candidate-role-table-to-detach)))))) - ;; A pending C-g may be delivered only after Ebox, - ;; framework pointers, and the lifecycle job become - ;; one current generation. Ordinary errors still - ;; unwind the atomic change group and restore state. - (let ((inhibit-quit t)) - (when (and old-role-table-to-detach - (not role-table-journal-p)) - (plist-put old-state - :region-role-span-table - old-frozen-role-table) - (plist-put candidate-state - :region-role-span-table - candidate-frozen-role-table) - (ebox-incremental--detach-region-role-span-table - old-role-table-to-detach) - (unless (eq old-role-table-to-detach - candidate-role-table-to-detach) - (ebox-incremental--detach-region-role-span-table - candidate-role-table-to-detach)) - (setq role-tables-detached-p t)) - (let ((ebox--region-box-table - candidate-region-box-table) - (ebox--scroll-global-state - candidate-scroll-state-table) - (ebox--scroll-idle-prefetch-timers - candidate-idle-timer-table) - (ebox--smooth-scroll-state-table - candidate-smooth-scroll-table) - (ebox--render-cache-scroll-state-restorable-p - nil)) - (cl-letf - (((symbol-function - 'ebox--scroll-schedule-idle-prefetch) - (lambda (&rest _) nil))) - (atomic-change-group - (puthash buffer candidate-state - ebox--buffer-render-state-table) - (setq patch-report - (and patch-set - (ebox--with-preserved-buffer-window-state - buffer - (ebox-incremental--publish-prepared-patch-set - buffer patch-set - (length render-dirty-set))))) - (when (and render-dirty-set - (null patch-report)) - (error - "Ebox prepared declarative patches did not publish")) - (plist-put - candidate-state :scroll-region-ids - (ebox-incremental--hash-keys - candidate-scroll-state-table)) - (setq scroll-keys - (delete-dups - (append - scroll-keys - (copy-sequence - (plist-get - candidate-state - :scroll-region-ids))))) - (setq scroll-snapshot - (ebox-incremental--hash-snapshot - live-scroll-state-table scroll-keys)) - (ebox-incremental--retire-removed-region-indexes - buffer old-state candidate-state) - ;; Numeric extent templates describe one exact - ;; root generation. Owner patches maintain the - ;; live marker index and retire the stale template. - (setq-local ebox--box-extent-template nil) - (ebox-incremental--replace-hash-entries - live-region-box-table region-keys - candidate-region-box-table) - (ebox-incremental--replace-hash-entries - live-scroll-state-table scroll-keys - candidate-scroll-state-table) - ;; Logical candidates publish core lookup - ;; indexes immediately but leave viewport - ;; dependency discovery lazy. Rebuilding it - ;; here would traverse the complete tree after - ;; every small replacement. - (unless (plist-get candidate-state - :logical-candidate-p) - (ebox--buffer-viewport-dependent-node-id-axes - buffer)) - (setq report - (ebox-incremental--commit-report - prepared patch-report candidate-state)) - (plist-put candidate-state - :last-update-report report) - (when - ebox-incremental--after-declarative-publication - (funcall - ebox-incremental--after-declarative-publication - report)) - (unless (buffer-live-p buffer) - (error - "Ebox declarative target died during publication")) - (setq published-p t)))) - ;; A pending quit becomes observable only after the - ;; winning generation has retired its old marker and - ;; timer resources as well as promoted cross-layer - ;; pointers. Cleanup itself contains no user code. - (unless (or role-table-journal-p - role-tables-detached-p) - (let ((old-role-table - (plist-get old-state - :region-role-span-table)) - (candidate-role-table - (plist-get candidate-state - :region-role-span-table))) - ;; A runtime-only commit deliberately promotes the - ;; published marker forest by pointer. Retire the - ;; old table only when the candidate owns a distinct - ;; replacement. - (unless (eq old-role-table candidate-role-table) - (ebox-incremental--detach-region-role-span-table - old-role-table)))) - (let ((old-scroll-table - (make-hash-table :test 'equal))) - (dolist (entry scroll-snapshot) - (when (nth 1 entry) - (puthash (car entry) (nth 2 entry) - old-scroll-table))) - (ebox-incremental--detach-scroll-state-table-markers - old-scroll-table)) - (ebox-incremental--finalize-declarative-scroll-publication - scroll-keys) - report)) - (unless published-p - (let ((candidate-role-table - (plist-get candidate-state - :region-role-span-table)) - (old-role-table - (plist-get old-state - :region-role-span-table))) - (if role-table-journal-p - (progn - (ebox-incremental--detach-unshared-region-role-spans - candidate-role-table - role-table-rollback-snapshot) - (ebox-incremental--restore-region-role-span-table - old-role-table-to-detach - role-table-rollback-snapshot)) - ;; Preparation may share the published read-only - ;; marker forest. Rollback must not detach state that - ;; is about to become current again. - (unless (eq candidate-role-table old-role-table) - (ebox-incremental--detach-region-role-span-table - candidate-role-table)))) - (ebox-incremental--detach-scroll-state-table-markers - candidate-scroll-state-table) - (let ((ebox--region-box-table live-region-box-table) - (ebox--scroll-global-state live-scroll-state-table)) - (ebox-incremental--restore-declarative-publication - buffer old-state region-keys scroll-keys - region-snapshot scroll-snapshot - role-table-journal-p))))))) - (ebox--schedule-buffer-runtime-prewarm buffer))))))) - (when defer-gc - (ebox--deferred-render-gc-schedule-restore))))) - -(defun ebox-incremental-commit (buffer next-root) - "Atomically commit newly built declarative NEXT-ROOT into rendered BUFFER." - (let ((old-state (ebox--buffer-render-state buffer))) - (ebox-incremental--commit-declarative-transaction - buffer old-state - (lambda (target state) - (ebox-incremental--prepare-declarative-root - target state next-root))))) - -(defun ebox-incremental-commit-candidate (buffer-or-name candidate) - "Atomically commit one-shot logical CANDIDATE into BUFFER-OR-NAME." - (unless (ebox-candidate-p candidate) - (error "Ebox logical commit requires an Ebox candidate")) - ;; Sealing is deliberately the first state transition. Success, validation - ;; failure, staleness, and a wrong target all consume the transaction. - (when (ebox-candidate--sealed-p candidate) - (error "Ebox candidate is already sealed")) - (setf (ebox-candidate--sealed-p candidate) t) - (let ((buffer (get-buffer buffer-or-name)) - (old-state (ebox-candidate--base-state candidate))) - (unless (buffer-live-p buffer) - (error "Ebox candidate commit requires a live buffer: %S" - buffer-or-name)) - (ebox-incremental--commit-declarative-transaction - buffer old-state - (lambda (target state) - (ebox-incremental--prepare-logical-candidate - target state candidate)) - (lambda (target state) - (ebox-incremental--candidate-assert-current - candidate target state)) - candidate))) - (provide 'ebox-incremental) ;;; ebox-incremental.el ends here diff --git a/ebox-native-reflow.el b/ebox-native-reflow.el index c498e22..c5cdc04 100644 --- a/ebox-native-reflow.el +++ b/ebox-native-reflow.el @@ -64,8 +64,6 @@ (declare-function ebox-string-lines "ebox" (string)) (declare-function ebox-string-pixel-width "ebox-measure" (string)) (declare-function ebox-display-signature "ebox-measure" ()) -(declare-function ebox--register-rendered-root-metadata - "ebox" (rendered prepared)) (defvar ebox--flex-content-min-width-table) @@ -180,9 +178,6 @@ module while loading the package." (defvar ebox-native-reflow--build-process nil "Live asynchronous process started by `ebox-native-build', or nil.") -(defvar ebox-native-reflow--last-owner-proof-error nil - "Most recent rejected native owner proof error.") - (defconst ebox-native-reflow--build-buffer-name "*Ebox Native Build*" "Compilation buffer used by `ebox-native-build'.") @@ -2841,8 +2836,7 @@ typed line-width and property invariants instead of repeating them in Emacs." decoded) (let ((header (aref decoded 0)) (rendered (aref decoded 1)) - (line-widths (aref decoded 2)) - (metadata (aref decoded 3))) + (line-widths (aref decoded 2))) (unless trusted-native-encoder-p (ebox-native-reflow--validate-literal-properties rendered styles (plist-get header :complete) @@ -2855,9 +2849,6 @@ typed line-width and property invariants instead of repeating them in Emacs." unless (= (ebox-string-pixel-width line) (aref line-widths index)) do (error "Native reflow tape line width mismatch")))) - (when (and trusted-native-encoder-p metadata - (fboundp 'ebox--register-rendered-root-metadata)) - (ebox--register-rendered-root-metadata rendered metadata)) rendered)))) (defun ebox-native-reflow-take-layout @@ -2918,57 +2909,6 @@ template ids are expanded before returning." payload (plist-get package :styles) expected t (plist-get package :property-templates)))))) -(defun ebox-native-reflow-render-owner-proof - (node snapshot _dirty context) - "Return a native proof render for NODE and CONTEXT, or nil when unsupported." - (when (and (not (plist-get context :root-owner-p)) - (ebox-native-reflow--native-node-supported-p node) - (ebox-native-reflow-layout-ready-p) - (>= (length (plist-get snapshot :buffer-spans)) 2)) - (let* ((details-function (plist-get context :details-function)) - (details - (if (functionp details-function) - (funcall details-function) - context)) - (stored-viewport-height (plist-get details :viewport-height)) - (viewport-height - (cond - ((and (numberp stored-viewport-height) - (> stored-viewport-height 0)) - (truncate stored-viewport-height)) - ((not (plist-get details :viewport-height-dependent-p)) - (max 1 (length (plist-get snapshot :buffer-spans)))))) - (node-type (and (listp node) (plist-get node :ebox-type))) - (exact-slot-width (plist-get details :parent-content-width)) - (viewport-width-known-p - (and (numberp exact-slot-width) (> exact-slot-width 0))) - (slot-width - (or (and viewport-width-known-p - (truncate exact-slot-width)) - (plist-get details :slot-width-placeholder)))) - (when (and (plist-get details :side-effects-safe-p) - (integerp viewport-height) (> viewport-height 0) - (integerp slot-width) (> slot-width 0)) - (condition-case err - (let ((frame - (list :key 1 - :viewport-width slot-width - :viewport-width-known viewport-width-known-p - :viewport-height viewport-height - :runtime-revision - (or (plist-get details :runtime-revision) 0) - :context-hash 0 - :complete t - :root-metadata nil))) - (when (and viewport-width-known-p (eq node-type 'box)) - (setq frame (plist-put frame :root-width slot-width))) - (setq ebox-native-reflow--last-owner-proof-error nil) - (ebox-native-reflow-render-proof-sync node frame)) - (error - (setq ebox-native-reflow--last-owner-proof-error - (error-message-string err)) - (signal (car err) (cdr err)))))))) - (defun ebox-native-reflow-submit (session generation frames) "Submit FRAMES for SESSION GENERATION without waiting for results." (unless (and (integerp generation) (>= generation 0)) @@ -3549,9 +3489,6 @@ When FULL-P is non-nil, query the complete restoration job." (signal (car err) (cdr err)))))) (unless (or (stringp result) (plist-get result :native-patch)) (error "Native reflow result disappeared before materialization")) - (when (and (stringp result) - (fboundp 'ebox--rendered-root-metadata)) - (ebox--rendered-root-metadata result t)) result)) (defun ebox-native-reflow--materialize-next-ready-frame (preparation) diff --git a/ebox-selector.el b/ebox-selector.el index f9a0f96..e4e6e6c 100644 --- a/ebox-selector.el +++ b/ebox-selector.el @@ -24,7 +24,6 @@ (declare-function ebox-incremental-begin-batch "ebox-incremental" (buffer)) (declare-function ebox-incremental-flush "ebox-incremental" (buffer)) (declare-function ebox-region-update "ebox" (region-id &rest props)) -(defvar ebox--region-update-buffer-hint) (cl-defstruct (ebox-region-handle @@ -549,8 +548,7 @@ tree-query handle fields." (defun ebox-selector--update-region (handle props) "Apply PROPS to HANDLE's region through `ebox-region-update'." - (let ((ebox--region-update-buffer-hint (plist-get handle :buffer))) - (apply #'ebox-region-update (plist-get handle :region-id) props))) + (apply #'ebox-region-update (plist-get handle :region-handle) props)) ;;;###autoload (defun ebox-selector-update-buffer (buffer selector &rest props) diff --git a/ebox-surface.el b/ebox-surface.el index fc53a55..a2dd0a3 100644 --- a/ebox-surface.el +++ b/ebox-surface.el @@ -14,6 +14,7 @@ (require 'ebox-tree) (require 'ebox-layout) (require 'ebox-incremental) +(require 'tp-reactive) (require 'tp-surface) (defvar ebox-region-types) @@ -21,8 +22,6 @@ (defvar ebox--scroll-global-state) (defvar ebox--scroll-idle-prefetch-timers) (defvar ebox--smooth-scroll-state-table) -(defvar ebox--box-extents) -(defvar ebox--box-extent-template) (defvar ebox--buffer-render-state-table) (defvar ebox--viewport-dependent-node-ids-cache) (defvar ebox--viewport-dependent-subtree-cache) @@ -31,39 +30,25 @@ (defvar ebox--render-cache-table) (defvar ebox--render-cache-signature-cache) (defvar ebox--flex-content-min-width-table) +(defvar ebox--scroll-window-initial-lookahead-lines-override) +(defvar ebox-viewport-width) +(defvar ebox-viewport-height) (declare-function ebox--render-layout "ebox-layout" (node)) -(declare-function ebox-buffer--build-region-role-span-template - "ebox-buffer-backend" (string)) -(declare-function ebox-buffer-materialize-region-role-spans - "ebox-buffer-backend" (buffer)) -(declare-function ebox--build-box-extent-template "ebox" (rendered)) -(declare-function ebox--build-scroll-content-span-template "ebox" (string)) -(declare-function ebox--set-box-extents "ebox" (region-id start end)) -(declare-function ebox--clear-buffer-extents - "ebox" (&optional buffer preserve-template)) -(declare-function ebox--clear-box-extents-for-region-ids - "ebox" (region-ids)) -(declare-function ebox--install-box-extent-template - "ebox" (buffer template start)) -(declare-function ebox--install-buffer-scroll-content-span-template - "ebox" (buffer template start &optional defer-window-region-set)) (declare-function ebox-incremental--hash-snapshot "ebox-incremental" (table keys)) (declare-function ebox-incremental--restore-hash-snapshot "ebox-incremental" (table entries)) (declare-function ebox-incremental--replace-hash-entries "ebox-incremental" (target keys source)) -(declare-function ebox-incremental--detach-scroll-state-table-markers - "ebox-incremental" (table)) (declare-function ebox--runtime-region-id-conflict "ebox-incremental" (region-id-set target-buffer)) (declare-function ebox--viewport-dependent-node-id-axes "ebox-incremental" (node)) -(declare-function ebox-incremental--detach-region-role-span-table - "ebox-incremental" (table)) +(declare-function ebox--update-report + "ebox-incremental" (region-id strategy &rest props)) (declare-function ebox-incremental--finalize-declarative-scroll-publication - "ebox-incremental" (scroll-keys)) + "ebox-incremental" (scroll-keys &optional prefetch-delay)) (declare-function ebox--scroll-schedule-idle-prefetch "ebox" (region-id &optional delay)) (declare-function ebox--scroll-clear-state "ebox" (region-id)) @@ -72,6 +57,113 @@ (defvar-local ebox-surface--buffer-surface nil "Live TP content surface mounted for the current Ebox buffer.") +(cl-defstruct (ebox-surface--signals + (:constructor ebox-surface--make-signals)) + "TP signals carrying one mounted Ebox surface's host context." + buffer viewport-width viewport-height display scroll) + +(defvar-local ebox-surface--context-signals nil + "Buffer-scoped TP signals consumed by the mounted Ebox producer.") + +(defun ebox-surface--signals-live-p (signals) + "Return non-nil when every signal in SIGNALS is live." + (and (ebox-surface--signals-p signals) + (buffer-live-p (ebox-surface--signals-buffer signals)) + (cl-every + #'tp-signal-live-p + (list (ebox-surface--signals-viewport-width signals) + (ebox-surface--signals-viewport-height signals) + (ebox-surface--signals-display signals) + (ebox-surface--signals-scroll signals))))) + +(defun ebox-surface--dispose-signals (signals) + "Dispose live TP SIGNALS owned by an abandoned Ebox mount." + (when (ebox-surface--signals-p signals) + (dolist (signal + (list (ebox-surface--signals-viewport-width signals) + (ebox-surface--signals-viewport-height signals) + (ebox-surface--signals-display signals) + (ebox-surface--signals-scroll signals))) + (when (tp-signal-live-p signal) + (tp-signal-dispose signal))) + (when-let ((buffer (ebox-surface--signals-buffer signals))) + (when (buffer-live-p buffer) + (with-current-buffer buffer + (when (eq ebox-surface--context-signals signals) + (setq-local ebox-surface--context-signals nil))))))) + +(defun ebox-surface--scroll-offsets (table) + "Return immutable sorted scroll offsets copied from TABLE." + (let (offsets) + (when (hash-table-p table) + (maphash + (lambda (region-id state) + (push (cons region-id (or (plist-get state :scroll-offset) 0)) + offsets)) + table)) + (sort offsets (lambda (left right) (< (car left) (car right)))))) + +(defun ebox-surface--context-values + (buffer old-state state-overrides) + "Return BUFFER context values after OLD-STATE and STATE-OVERRIDES." + (cl-labels + ((value (key fallback) + (if (plist-member state-overrides key) + (plist-get state-overrides key) + (if (plist-member old-state key) + (plist-get old-state key) + fallback)))) + (let ((scroll-table + (value :scroll-state-table + (plist-get old-state :scroll-state-table)))) + (list :viewport-width + (value :viewport-width ebox-viewport-width) + :viewport-height + (value :viewport-height ebox-viewport-height) + :display-signature + (value :display-signature + (with-current-buffer buffer + (ebox--current-display-signature))) + :scroll-offsets + (ebox-surface--scroll-offsets scroll-table))))) + +(defun ebox-surface--ensure-signals (buffer values) + "Return BUFFER's context signals for VALUES and whether they were created." + (let ((signals + (with-current-buffer buffer ebox-surface--context-signals))) + (if (ebox-surface--signals-live-p signals) + (cons signals nil) + (when signals (ebox-surface--dispose-signals signals)) + (setq signals + (ebox-surface--make-signals + :buffer buffer + :viewport-width + (tp-signal-create (plist-get values :viewport-width) + :scope buffer) + :viewport-height + (tp-signal-create (plist-get values :viewport-height) + :scope buffer) + :display + (tp-signal-create (plist-get values :display-signature) + :scope buffer) + :scroll + (tp-signal-create (plist-get values :scroll-offsets) + :scope buffer))) + (with-current-buffer buffer + (setq-local ebox-surface--context-signals signals)) + (cons signals t)))) + +(defun ebox-surface--stage-signal-values (signals values) + "Stage VALUES into TP context SIGNALS in the active transaction." + (tp-signal-set (ebox-surface--signals-viewport-width signals) + (plist-get values :viewport-width)) + (tp-signal-set (ebox-surface--signals-viewport-height signals) + (plist-get values :viewport-height)) + (tp-signal-set (ebox-surface--signals-display signals) + (plist-get values :display-signature)) + (tp-signal-set (ebox-surface--signals-scroll signals) + (plist-get values :scroll-offsets))) + (defun ebox-surface--live-buffer-surface (buffer) "Return BUFFER's live Ebox TP surface, or nil." (when (buffer-live-p buffer) @@ -79,33 +171,49 @@ (with-current-buffer buffer ebox-surface--buffer-surface))) (and (tp-surface-live-p surface) surface)))) -(defun ebox-surface--buffer-extent-snapshot (buffer) - "Return numeric live box extents owned by BUFFER." - (let (snapshot) - (maphash - (lambda (region-id extents) - (when (eq (marker-buffer (car extents)) buffer) - (push (list region-id - (marker-position (car extents)) - (marker-position (cdr extents))) - snapshot))) - ebox--box-extents) - snapshot)) +(defun ebox-surface-buffer-mounted-p (buffer) + "Return non-nil when BUFFER has a live Ebox TP surface." + (not (null (ebox-surface--live-buffer-surface buffer)))) -(defun ebox-surface--restore-buffer-extents (buffer snapshot template) - "Restore BUFFER box extents from SNAPSHOT and numeric TEMPLATE." - (ebox--clear-buffer-extents buffer) - (when (buffer-live-p buffer) - (with-current-buffer buffer - (setq-local ebox--box-extent-template template) - (dolist (entry snapshot) - (ebox--set-box-extents (nth 0 entry) (nth 1 entry) (nth 2 entry)))))) +(defun ebox-surface-region-mounts (buffer region-id &optional roles) + "Return REGION-ID's TP-owned output mounts in BUFFER. +When ROLES is non-nil, keep only direct mounts owning a listed Ebox role. +Without ROLES, include descendant output attached to the region's object. +The returned ranges are numeric snapshots; TP retains marker ownership." + (when-let* ((surface (ebox-surface--live-buffer-surface buffer)) + (state (tp-surface-client-state surface)) + (table (plist-get state :region-surface-object-table)) + (object (gethash region-id table))) + (cl-remove-if-not + (lambda (mount) + (let ((tags (plist-get mount :tags))) + (if roles + (and (equal (plist-get tags :ebox/region-id) region-id) + (cl-intersection roles (plist-get tags :ebox/roles))) + (or (equal (plist-get tags :ebox/region-id) region-id) + (plist-get tags :ebox/descendant-output))))) + (tp-object-mounts object)))) + +(defun ebox-surface-region-bounds (buffer region-id &optional roles) + "Return numeric bounds for REGION-ID's TP mounts in BUFFER. +ROLES has the same filtering meaning as in `ebox-surface-region-mounts'." + (when-let ((mounts (ebox-surface-region-mounts buffer region-id roles))) + (cons (apply #'min (mapcar (lambda (mount) (plist-get mount :start)) mounts)) + (apply #'max (mapcar (lambda (mount) (plist-get mount :end)) mounts))))) (defun ebox-surface--runtime-keys (state key) "Return hash keys stored under KEY in runtime STATE." (when-let ((table (plist-get state key))) (ebox-surface--hash-keys table))) +(defun ebox-surface--bind-scroll-states-to-buffer (state buffer) + "Bind every semantic scroll state in STATE to its owning BUFFER." + (when-let ((table (plist-get state :scroll-state-table))) + (maphash + (lambda (region-id scroll-state) + (puthash region-id (plist-put scroll-state :buffer buffer) table)) + table))) + (defun ebox-surface--commit-report (surface state report-base) "Return Ebox's compact report for SURFACE, STATE, and REPORT-BASE." (let* ((tp-report (tp-surface-report surface)) @@ -114,6 +222,8 @@ (or (plist-get tp-report :property-operations) 0)) (patch-count (+ text-operations property-operations)) (report (copy-sequence report-base)) + (scoped-p (and (not (plist-get tp-report :full-root)) + (> (or (plist-get tp-report :scope-count) 0) 0))) (planned-publication-scope (plist-get report :publication-scope))) (dolist (entry `((:runtime-published . t) @@ -121,7 +231,12 @@ (:surface-revision . ,(tp-surface-revision surface)) (:publication-scope . tp-surface) (:planned-publication-scope . ,planned-publication-scope) - (:tp-render-scope . surface-plan) + (:tp-render-scope . ,(if scoped-p 'objects 'surface-plan)) + (:tp-full-root . ,(plist-get tp-report :full-root)) + (:tp-scope-count . ,(plist-get tp-report :scope-count)) + (:tp-scope-range-count + . ,(plist-get tp-report :scope-range-count)) + (:tp-scope-fallback . ,(plist-get tp-report :scope-fallback)) (:tp-operation-count . ,patch-count) (:tp-transaction-id . ,(plist-get tp-report :transaction-id)) (:tp-text-operations . ,text-operations) @@ -159,8 +274,8 @@ "Register BUFFER runtime publication for SURFACE after OLD-STATE. REPORT-BASE requests an Ebox commit report. AFTER-PUBLICATION, when non-nil, runs after TP and Ebox point at the same candidate generation." - (let (new-state region-snapshot scroll-snapshot extent-snapshot - old-template old-surface old-mirror region-keys scroll-keys) + (let (new-state region-snapshot scroll-snapshot + old-surface old-mirror region-keys scroll-keys) (tp-transaction-participate (list 'ebox/runtime buffer) (lambda () @@ -168,8 +283,6 @@ runs after TP and Ebox point at the same candidate generation." old-surface (with-current-buffer buffer ebox-surface--buffer-surface) old-mirror (gethash buffer ebox--buffer-render-state-table) - old-template (with-current-buffer buffer ebox--box-extent-template) - extent-snapshot (ebox-surface--buffer-extent-snapshot buffer) region-keys (delete-dups (append (ebox-surface--runtime-keys old-state :region-id-set) @@ -188,6 +301,7 @@ runs after TP and Ebox point at the same candidate generation." (plist-put new-state :surface surface) (plist-put new-state :runtime-revision (1- (tp-surface-revision surface))) + (ebox-surface--bind-scroll-states-to-buffer new-state buffer) (when report-base (plist-put new-state :last-update-report (ebox-surface--commit-report @@ -203,21 +317,12 @@ runs after TP and Ebox point at the same candidate generation." (ebox-incremental--replace-hash-entries ebox--scroll-global-state scroll-keys (plist-get new-state :scroll-state-table)) - (ebox--clear-buffer-extents buffer) - (when new-state - (ebox--install-box-extent-template - buffer (plist-get new-state :box-extent-template) 1) - (ebox--install-buffer-scroll-content-span-template - buffer (plist-get new-state :scroll-content-span-template) 1) - (ebox-buffer-materialize-region-role-spans buffer)) (when after-publication (funcall after-publication (plist-get new-state :last-update-report))) (unless (buffer-live-p buffer) (error "Ebox declarative target died during publication"))) (lambda () - (when-let ((table (plist-get new-state :scroll-state-table))) - (ebox-incremental--detach-scroll-state-table-markers table)) (if (buffer-live-p buffer) (progn (when region-snapshot @@ -228,8 +333,6 @@ runs after TP and Ebox point at the same candidate generation." ebox--scroll-global-state scroll-snapshot)) (with-current-buffer buffer (setq-local ebox-surface--buffer-surface old-surface)) - (ebox-surface--restore-buffer-extents - buffer extent-snapshot old-template) (if old-mirror (puthash buffer old-mirror ebox--buffer-render-state-table) (remhash buffer ebox--buffer-render-state-table))) @@ -238,20 +341,7 @@ runs after TP and Ebox point at the same candidate generation." (remhash region-id ebox--region-box-table)) (dolist (region-id scroll-keys) (ebox--scroll-clear-state region-id) - (ebox--smooth-scroll-stop region-id)) - (ebox--clear-box-extents-for-region-ids region-keys)))))) - -(defun ebox-surface--retire-state (old-state new-state) - "Retire marker and timer resources in OLD-STATE after NEW-STATE commits." - (when old-state - (let ((old-role-table (plist-get old-state :region-role-span-table)) - (new-role-table (plist-get new-state :region-role-span-table))) - (unless (eq old-role-table new-role-table) - (ebox-incremental--detach-region-role-span-table old-role-table))) - (let ((old-scroll-table (plist-get old-state :scroll-state-table)) - (new-scroll-table (plist-get new-state :scroll-state-table))) - (unless (eq old-scroll-table new-scroll-table) - (ebox-incremental--detach-scroll-state-table-markers old-scroll-table))))) + (ebox--smooth-scroll-stop region-id))))))) (defun ebox-surface-mount-buffer (buffer source &optional report-base after-publication @@ -262,6 +352,12 @@ the rollback-capable transaction. PRESERVE-IDENTITIES-P retains identities already present in a logical candidate. STATE-OVERRIDES augments its runtime." (let* ((surface (ebox-surface--live-buffer-surface buffer)) (old-state (and surface (tp-surface-client-state surface))) + (context-values + (ebox-surface--context-values buffer old-state state-overrides)) + (signals-result + (ebox-surface--ensure-signals buffer context-values)) + (signals (car signals-result)) + (signals-created-p (cdr signals-result)) (preserve-identities-p (or preserve-identities-p (and (null old-state) @@ -269,24 +365,240 @@ already present in a logical candidate. STATE-OVERRIDES augments its runtime." source buffer)))) (producer (ebox-surface-producer - source old-state preserve-identities-p state-overrides))) + source old-state preserve-identities-p state-overrides signals)) + success) + (unwind-protect + (progn + (tp-with-transaction + (ebox-surface--stage-signal-values signals context-values) + (if surface + (tp-surface-update surface producer) + (setq surface + (tp-surface-mount + buffer producer + '(:capability content :inhibit-read-only t)))) + (ebox-surface--publish-runtime-state + buffer surface old-state report-base after-publication)) + (setq success t) + (let* ((new-state (tp-surface-client-state surface)) + (scroll-keys + (delete-dups + (append + (copy-sequence (plist-get old-state :scroll-region-ids)) + (copy-sequence + (plist-get new-state :scroll-region-ids)))))) + (ebox-incremental--finalize-declarative-scroll-publication + scroll-keys)) + surface) + (when (and signals-created-p (not success)) + (ebox-surface--dispose-signals signals))))) + +(defun ebox-surface--viewport-report (state width height axes strategy) + "Return a viewport report for STATE, WIDTH, HEIGHT, AXES, and STRATEGY." + (let ((root (plist-get state :root-node))) + (ebox--update-report + nil strategy + :constraint-source 'viewport + :constraint-owner-id 'viewport + :constraint-owner-type 'viewport + :constraint-root-node-id (plist-get root :node-id) + :viewport-axes axes + :target-viewport-width width + :target-viewport-height height + :dirty-kinds (unless (eq axes 'none) '(geometry)) + :dirty-count (if (eq axes 'none) 0 1) + :patch-count (if (eq axes 'none) 0 1) + :patch-ops (unless (eq axes 'none) '(root-rerender)) + :owner-ids (unless (eq axes 'none) + (list (plist-get root :node-id))) + :root-rerender (not (eq axes 'none))))) + +(defun ebox-surface--publish-context-no-op + (buffer surface state values state-overrides report) + "Commit VALUES and no-op STATE-OVERRIDES into STATE for BUFFER and SURFACE." + (let ((signals + (with-current-buffer buffer ebox-surface--context-signals)) + (old-width (plist-get state :viewport-width)) + (old-height (plist-get state :viewport-height)) + (old-display (plist-get state :display-signature)) + (old-report (plist-get state :last-update-report))) + (unless (ebox-surface--signals-live-p signals) + (error "Ebox mounted surface has no live context signals")) (tp-with-transaction - (if surface - (tp-surface-update surface producer) - (setq surface - (tp-surface-mount - buffer producer - '(:capability content :inhibit-read-only t)))) - (ebox-surface--publish-runtime-state - buffer surface old-state report-base after-publication)) - (let* ((new-state (tp-surface-client-state surface)) - (scroll-keys - (delete-dups - (append (copy-sequence (plist-get old-state :scroll-region-ids)) - (copy-sequence (plist-get new-state :scroll-region-ids)))))) - (ebox-surface--retire-state old-state new-state) - (ebox-incremental--finalize-declarative-scroll-publication scroll-keys)) - surface)) + (ebox-surface--stage-signal-values signals values) + (tp-transaction-participate + (list 'ebox/context-no-op buffer) + (lambda () + (unless (eq state (tp-surface-client-state surface)) + (error "Ebox no-op context unexpectedly published a surface")) + (plist-put state :viewport-width + (plist-get state-overrides :viewport-width)) + (plist-put state :viewport-height + (plist-get state-overrides :viewport-height)) + (plist-put state :display-signature + (plist-get state-overrides :display-signature)) + (plist-put state :last-update-report report)) + (lambda () + (plist-put state :viewport-width old-width) + (plist-put state :viewport-height old-height) + (plist-put state :display-signature old-display) + (plist-put state :last-update-report old-report)))) + report)) + +(defun ebox-surface-update-buffer-viewport (buffer width &optional height) + "Publish BUFFER for viewport WIDTH and optional HEIGHT through TP." + (let* ((surface (ebox-surface--live-buffer-surface buffer)) + (state (and surface (tp-surface-client-state surface)))) + (unless state + (error "Ebox viewport update requires a mounted TP surface")) + (let* ((old-width (plist-get state :viewport-width)) + (old-height (plist-get state :viewport-height)) + (target-height (or height old-height)) + (display-signature + (with-current-buffer buffer + (ebox--current-display-signature))) + (display-changed + (not (equal display-signature + (plist-get state :display-signature)))) + (width-changed (not (equal width old-width))) + (height-changed (not (equal target-height old-height))) + (axes (cond ((and width-changed height-changed) 'both) + (width-changed 'width) + (height-changed 'height) + (t 'none)))) + (if (and (eq axes 'none) (not display-changed)) + (let ((report + (ebox-surface--viewport-report + state width target-height axes 'no-op))) + (plist-put state :last-update-report report) + report) + (ebox-incremental--notify-before-runtime-mutation buffer 'viewport) + (unless (eq state (tp-surface-client-state surface)) + (error "Ebox runtime changed during viewport update notification")) + (let ((commit-input + (ebox-incremental-prepare-viewport-commit + buffer width target-height axes display-signature))) + (if (plist-get commit-input :no-op) + (let* ((state-overrides + (plist-get commit-input :state-overrides)) + (values + (ebox-surface--context-values + buffer state state-overrides))) + (ebox-surface--publish-context-no-op + buffer surface state values state-overrides + (plist-get commit-input :report-base))) + (ebox-surface-update-buffer-scoped + buffer + (plist-get commit-input :root) + (plist-get commit-input :scope-node-ids) + (plist-get commit-input :report-base) + (plist-get commit-input :state-overrides) + nil nil 0) + (plist-get (tp-surface-client-state surface) + :last-update-report))))))) + +(defun ebox-surface--projection-start (context root) + "Create retained TP identities for ROOT in CONTEXT." + (let* ((surface-root + (tp-object-ensure context nil ebox-surface--root-key 'ebox/surface)) + (node-root + (tp-object-ensure context surface-root + ebox-surface--nodes-key 'ebox/nodes)) + (objects-by-node (make-hash-table :test 'eq))) + (tp-object-retain context node-root) + (ebox-surface--ensure-node-tree context node-root root objects-by-node) + (cons surface-root objects-by-node))) + +(defun ebox-surface--projection-result + (context projection state rendered) + "Complete CONTEXT PROJECTION for STATE from RENDERED output." + (let* ((surface-root (car projection)) + (objects-by-node (cdr projection)) + (node-objects (ebox-surface--node-object-table objects-by-node)) + (region-objects + (ebox-surface--region-object-table state node-objects))) + (plist-put state :surface-node-object-table node-objects) + (plist-put state :region-surface-object-table region-objects) + (plist-put state :surface-object-region-table + (ebox-surface--object-region-table region-objects)) + (plist-put state :logical-id-region-table + (ebox-surface--logical-id-region-table state region-objects)) + (tp-surface-result-create + (ebox-surface--surface-plan + context surface-root rendered state node-objects region-objects) + state))) + +(defun ebox-surface--mounted-object-for-node (state node-id) + "Return NODE-ID's nearest live retained object with mounts in STATE." + (let ((objects (plist-get state :surface-node-object-table)) + (parents (plist-get state :parent-table)) + object) + (while (and node-id + (progn + (setq object (and objects (gethash node-id objects))) + (or (not object) (null (tp-object-mounts object))))) + (setq node-id (and parents (gethash node-id parents)))) + (unless (and object (tp-object-live-p object) + (tp-object-mounts object)) + (error "Ebox scoped owner has no mounted TP object")) + object)) + +(defun ebox-surface--objects-for-node-ids (state node-ids) + "Return mounted retained objects in STATE for Ebox NODE-IDS." + (delete-dups + (mapcar (lambda (node-id) + (ebox-surface--mounted-object-for-node state node-id)) + node-ids))) + +(defun ebox-surface-update-buffer-scoped + (buffer source scope-node-ids report-base state-overrides + &optional after-publication on-mismatch scroll-prefetch-delay) + "Publish BUFFER SOURCE within SCOPE-NODE-IDS through TP. +STATE-OVERRIDES augments the isolated candidate runtime. REPORT-BASE and +AFTER-PUBLICATION have the same meaning as in `ebox-surface-mount-buffer'. +ON-MISMATCH is forwarded to TP's scoped publication policy. +SCROLL-PREFETCH-DELAY controls post-publication lazy scroll warming." + (let* ((surface (ebox-surface--live-buffer-surface buffer)) + (old-state (and surface (tp-surface-client-state surface)))) + (unless surface + (error "Ebox scoped update requires a mounted TP surface")) + (let ((objects + (ebox-surface--objects-for-node-ids old-state scope-node-ids))) + (unless objects + (error "Ebox scoped update requires at least one retained owner")) + (let* ((context-values + (ebox-surface--context-values buffer old-state state-overrides)) + (signals-result + (ebox-surface--ensure-signals buffer context-values)) + (signals (car signals-result)) + (signals-created-p (cdr signals-result)) + (producer + (ebox-surface-producer + source old-state t state-overrides signals)) + success) + (unwind-protect + (progn + (tp-with-transaction + (ebox-surface--stage-signal-values signals context-values) + (tp-surface-update-scoped + surface objects producer + (and on-mismatch (list :on-mismatch on-mismatch))) + (ebox-surface--publish-runtime-state + buffer surface old-state report-base after-publication)) + (setq success t) + (let* ((new-state (tp-surface-client-state surface)) + (scroll-keys + (delete-dups + (append + (copy-sequence + (plist-get old-state :scroll-region-ids)) + (copy-sequence + (plist-get new-state :scroll-region-ids)))))) + (ebox-incremental--finalize-declarative-scroll-publication + scroll-keys scroll-prefetch-delay)) + surface) + (when (and signals-created-p (not success)) + (ebox-surface--dispose-signals signals))))))) (defconst ebox-surface--root-key 'ebox/surface "Stable TP key for an Ebox surface projection root.") @@ -432,12 +744,82 @@ identities while always discarding TP handles and render-cache attachments." (maphash (lambda (key _value) (push key keys)) table) keys)) +(defun ebox-surface--retained-scroll-lookahead (state) + "Return lookahead needed to retain STATE's prepared lazy prefixes." + (when-let ((table (plist-get state :scroll-state-table))) + (let ((maximum 0)) + (maphash + (lambda (_region-id scroll-state) + (let ((retained (length (plist-get scroll-state :content-lines))) + (offset (or (plist-get scroll-state :scroll-offset) 0)) + (height (or (plist-get scroll-state :content-height) 0))) + (setq maximum + (max maximum (- retained offset height 1))))) + table) + (max 0 maximum)))) + +(defun ebox-surface--scroll-metadata-snapshot (table producer-region-ids) + "Return reusable lazy-scroll metadata copied from TABLE. +PRODUCER-REGION-IDS names scroll-only updates whose producer closures remain +valid because their declarative layout and viewport did not change." + (let ((snapshot (make-hash-table :test 'equal))) + (maphash + (lambda (region-id state) + (let ((preserve-producer-p + (member region-id producer-region-ids)) + (producer-keys + '(:content-lines :rendered-content-lines + :content-lines-complete-p :render-content-prefix + :materialize-content-lines :region-line-bounds-index + :region-line-span-index :region-line-span-index-deferred + :rendered-region-line-span-index + :rendered-region-line-span-index-deferred + :region-line-span-hints :cache-miss-prefetch-target-lines + :native-reflow-target-prefix-p + :native-reflow-prefix-reset-p :native-reflow-visible-offset + :native-reflow-visible-lines)) + metadata) + (dolist (key (append + '(:content-region-id-set :lazy-scroll-prefix-dirty + :lazy-scroll-window-refresh-required) + (and preserve-producer-p producer-keys))) + (when (if (memq key producer-keys) + (plist-member state key) + (plist-get state key)) + (setq metadata + (plist-put metadata key (plist-get state key))))) + (when metadata + (puthash region-id metadata snapshot)))) + table) + snapshot)) + +(defun ebox-surface--restore-scroll-metadata (table snapshot) + "Restore reusable lazy-scroll SNAPSHOT fields into TABLE." + (maphash + (lambda (region-id metadata) + (when-let ((state (gethash region-id table))) + (while metadata + (setq state + (plist-put state (pop metadata) (pop metadata)))) + (puthash region-id state table))) + snapshot)) + (defun ebox-surface--render-candidate (state) "Render candidate STATE in isolated Ebox side tables." - (let ((scroll-table (make-hash-table :test 'equal)) + (let ((scroll-table + (or (plist-get state :scroll-state-table) + (make-hash-table :test 'equal))) + (scroll-metadata + (ebox-surface--scroll-metadata-snapshot + (or (plist-get state :scroll-state-table) + (make-hash-table :test 'equal)) + (plist-get state :preserve-scroll-producer-region-ids))) (timer-table (make-hash-table :test 'equal)) (smooth-table (make-hash-table :test 'equal))) - (let ((ebox--region-box-table (plist-get state :region-box-table)) + (cl-remf state :preserve-scroll-producer-region-ids) + (let ((ebox-viewport-width (plist-get state :viewport-width)) + (ebox-viewport-height (plist-get state :viewport-height)) + (ebox--region-box-table (plist-get state :region-box-table)) (ebox--scroll-global-state scroll-table) (ebox--scroll-idle-prefetch-timers timer-table) (ebox--smooth-scroll-state-table smooth-table) @@ -446,6 +828,8 @@ identities while always discarding TP handles and render-cache attachments." (ebox--render-cache-table (plist-get state :render-cache)) (ebox--render-cache-signature-cache (plist-get state :render-signature-cache)) + (ebox--scroll-window-initial-lookahead-lines-override + (ebox-surface--retained-scroll-lookahead state)) (ebox--viewport-dependent-node-ids-cache (make-hash-table :test 'eq)) (ebox--viewport-dependent-subtree-cache @@ -458,28 +842,16 @@ identities while always discarding TP handles and render-cache attachments." (lambda (&rest _) nil))) (prog1 (let ((ebox--surface-materialization-active t)) (ebox--render-layout (plist-get state :root-node))) + (ebox-surface--restore-scroll-metadata + scroll-table scroll-metadata) (plist-put state :scroll-state-table scroll-table) (plist-put state :scroll-region-ids (ebox-surface--hash-keys scroll-table))))))) (defun ebox-surface--finish-runtime-state (state) "Install post-layout runtime indexes into candidate STATE." - (setq state - (ebox--render-state-install-index - state (ebox--runtime-index (plist-get state :root-node) t))) - (let* ((root (plist-get state :root-node)) - (ebox--viewport-dependent-node-ids-cache - (make-hash-table :test 'eq)) - (ebox--viewport-height-dependent-subtree-cache - (plist-get state :viewport-height-dependent-subtree-cache)) - (axes (ebox--viewport-dependent-node-id-axes root))) - (plist-put state :viewport-dependent-node-id-axes axes) - (plist-put state :viewport-dependent-node-ids - (and axes - (delete-dups - (copy-sequence (append (car axes) (cdr axes)))))) - (plist-put state :viewport-dependent-node-ids-ready t)) - state) + (ebox--render-state-install-index + state (ebox--runtime-index (plist-get state :root-node) t))) (defun ebox-surface--role-ids-at (rendered position) "Return namespaced Ebox role/id pairs at POSITION in RENDERED." @@ -500,56 +872,102 @@ identities while always discarding TP handles and render-cache attachments." (cl-loop for (role . owner) in role-ids when (equal owner region-id) collect role))) +(defun ebox-surface--attach-fragment-ancestors + (context fragment node-id state node-objects attached) + "Attach FRAGMENT in CONTEXT to NODE-ID ancestors from STATE. +NODE-OBJECTS resolves retained objects; ATTACHED prevents duplicate mounts." + (let ((parent-table (plist-get state :parent-table)) + (current (gethash node-id (plist-get state :parent-table)))) + (while current + (let ((object (gethash current node-objects))) + (unless object + (error "Ebox output ancestor has no TP object: %S" current)) + (unless (gethash object attached) + (tp-object-attach-fragment + context object fragment '(:ebox/descendant-output t)) + (puthash object t attached))) + (setq current (gethash current parent-table))))) + (defun ebox-surface--attach-fragment - (context fragment role-ids region-objects) - "Attach FRAGMENT to ROLE-IDS owners through REGION-OBJECTS in CONTEXT." - (dolist (region-id (delete-dups (mapcar #'cdr role-ids))) - (let ((object (gethash region-id region-objects))) - (unless object - (error "Ebox output references unknown region %S" region-id)) - (tp-object-attach-fragment - context object fragment - (ebox-surface--region-role-tags region-id role-ids))))) + (context fragment role-ids state node-objects region-objects) + "Attach FRAGMENT to direct and ancestor Ebox owners in CONTEXT. +ROLE-IDS identifies direct rendered regions. STATE and NODE-OBJECTS extend +that ownership through the Ebox layout tree so a planned ancestor owner may +authorize every descendant output interval without exposing ranges to TP." + (let ((attached (make-hash-table :test 'eq)) + (region-node-table (plist-get state :region-node-table))) + (dolist (region-id (delete-dups (mapcar #'cdr role-ids))) + (let ((object (gethash region-id region-objects)) + (node-id (gethash region-id region-node-table))) + (unless (and object node-id) + (error "Ebox output references unknown region %S" region-id)) + (unless (gethash object attached) + (tp-object-attach-fragment + context object fragment + (ebox-surface--region-role-tags region-id role-ids)) + (puthash object t attached)) + (ebox-surface--attach-fragment-ancestors + context fragment node-id state node-objects attached))))) (defun ebox-surface--fragment-plan - (context parent rendered start end index region-objects) - "Return one plan below PARENT for RENDERED START..END at INDEX in CONTEXT." + (context parent rendered start end index role-ids + state node-objects region-objects) + "Return one plan below PARENT for RENDERED START..END at INDEX. +ROLE-IDS names the Ebox owners attached through REGION-OBJECTS in CONTEXT." (let* ((key (cons 'ebox/fragment index)) (object (tp-object-ensure context parent key 'ebox/fragment)) - (role-ids (ebox-surface--role-ids-at rendered start)) (tags (list :ebox/fragment index :ebox/role-ids role-ids))) (ebox-surface--attach-fragment - context object role-ids region-objects) + context object role-ids state node-objects region-objects) (tp-surface-plan-create :key key :kind 'ebox/fragment :text (substring rendered start end) :tags tags :capability 'content))) (defun ebox-surface--fragment-plans - (context parent rendered region-objects) + (context parent rendered state node-objects region-objects) "Return property-interval plans in CONTEXT for RENDERED below PARENT." (let ((position 0) (limit (length rendered)) - (index 0) - plans) + intervals previous-role-ids next-role-ids) (while (< position limit) (let ((next (or (next-property-change position rendered limit) limit))) - (push (ebox-surface--fragment-plan - context parent rendered position next index region-objects) - plans) - (setq position (max next (1+ position)) - index (1+ index)))) - (nreverse plans))) + (push (list :start position :end next + :role-ids + (ebox-surface--role-ids-at rendered position)) + intervals) + (setq position (max next (1+ position))))) + (setq intervals (nreverse intervals)) + (dolist (interval intervals) + (if-let ((roles (plist-get interval :role-ids))) + (setq previous-role-ids roles) + (plist-put interval :previous-role-ids previous-role-ids))) + (dolist (interval (reverse (copy-sequence intervals))) + (if-let ((roles (plist-get interval :role-ids))) + (setq next-role-ids roles) + (plist-put interval :role-ids + (delete-dups + (append (plist-get interval :previous-role-ids) + next-role-ids))))) + (cl-loop for interval in intervals + for fragment-index from 0 + collect + (ebox-surface--fragment-plan + context parent rendered + (plist-get interval :start) (plist-get interval :end) + fragment-index (plist-get interval :role-ids) + state node-objects region-objects)))) (defun ebox-surface--surface-plan - (context surface-root rendered region-objects) - "Return CONTEXT's plan below SURFACE-ROOT for RENDERED and REGION-OBJECTS." + (context surface-root rendered state node-objects region-objects) + "Return CONTEXT's plan below SURFACE-ROOT for RENDERED and STATE. +NODE-OBJECTS and REGION-OBJECTS supply retained ownership for its fragments." (let* ((fragment-root (tp-object-ensure context surface-root ebox-surface--fragments-key 'ebox/fragments)) (fragments (ebox-surface--fragment-plans - context fragment-root rendered region-objects))) + context fragment-root rendered state node-objects region-objects))) (tp-surface-plan-create :key ebox-surface--root-key :kind 'ebox/surface :children @@ -564,10 +982,36 @@ identities while always discarding TP handles and render-cache attachments." (setq state (plist-put state (pop overrides) (pop overrides)))) state) +(defun ebox-surface--apply-scroll-offsets (root offsets) + "Apply surface-scoped scroll OFFSETS to matching boxes below ROOT." + (cl-labels + ((visit (node) + (when (and (listp node) (not (stringp node))) + (when (eq (plist-get node :ebox-type) 'box) + (when-let* ((region-id (plist-get node :region-id)) + (entry (assq region-id offsets))) + (ebox-put node :scroll-offset (cdr entry)))) + (dolist (child (ebox-tree--children-raw node)) + (visit child))))) + (visit root)) + root) + +(defun ebox-surface--context-axes (root) + "Return ROOT's exact viewport dependency axes without shared caches." + (let ((ebox--viewport-dependent-node-ids-cache + (make-hash-table :test 'eq)) + (ebox--viewport-dependent-subtree-cache + (make-hash-table :test 'eq)) + (ebox--viewport-height-dependent-subtree-cache + (make-hash-table :test 'eq))) + (ebox--viewport-dependent-node-id-axes root))) + (defun ebox-surface--project - (context source previous-state preserve-identities-p state-overrides) + (context source previous-state preserve-identities-p state-overrides + signals) "Project SOURCE in CONTEXT using PREVIOUS-STATE and identity policy. -PRESERVE-IDENTITIES-P retains existing Ebox node and region identities." +PRESERVE-IDENTITIES-P retains existing Ebox node and region identities. +SIGNALS, when non-nil, supplies the mounted surface's host context." (if (stringp source) (let ((object (tp-object-ensure context nil ebox-surface--root-key @@ -579,52 +1023,63 @@ PRESERVE-IDENTITIES-P retains existing Ebox node and region identities." :text source :capability 'content))) (let* ((root (ebox-surface--candidate-root source previous-state preserve-identities-p)) + (axes (and signals (ebox-surface--context-axes root))) + (viewport-width + (and signals + (funcall + (if (car axes) #'tp-signal-read #'tp-signal-peek) + (ebox-surface--signals-viewport-width signals)))) + (viewport-height + (and signals + (funcall + (if (cdr axes) #'tp-signal-read #'tp-signal-peek) + (ebox-surface--signals-viewport-height signals)))) + (display-signature + (and signals + (tp-signal-read + (ebox-surface--signals-display signals)))) + (scroll-offsets + (and signals + (tp-signal-peek + (ebox-surface--signals-scroll signals)))) (state (ebox-surface--apply-state-overrides (ebox--new-buffer-render-state root) - (copy-sequence state-overrides))) - (surface-root - (tp-object-ensure context nil ebox-surface--root-key 'ebox/surface)) - (node-root - (tp-object-ensure context surface-root - ebox-surface--nodes-key 'ebox/nodes)) - (objects-by-node (make-hash-table :test 'eq))) - (tp-object-retain context node-root) - (ebox-surface--ensure-node-tree context node-root root objects-by-node) - (plist-put state :region-box-table (make-hash-table :test 'equal)) - (let ((rendered (ebox-surface--render-candidate state))) - (setq state (ebox-surface--finish-runtime-state state)) - (plist-put state :region-role-span-table - (ebox-buffer--build-region-role-span-template rendered)) - (plist-put state :box-extent-template - (ebox--build-box-extent-template rendered)) - (plist-put state :scroll-content-span-template - (let ((ebox--scroll-global-state - (plist-get state :scroll-state-table))) - (ebox--build-scroll-content-span-template rendered))) - (let* ((node-objects (ebox-surface--node-object-table objects-by-node)) - (region-objects - (ebox-surface--region-object-table state node-objects))) - (plist-put state :surface-node-object-table node-objects) - (plist-put state :region-surface-object-table region-objects) - (plist-put state :surface-object-region-table - (ebox-surface--object-region-table region-objects)) - (plist-put state :logical-id-region-table - (ebox-surface--logical-id-region-table - state region-objects)) - (tp-surface-result-create - (ebox-surface--surface-plan - context surface-root rendered region-objects) - state)))))) + (copy-sequence state-overrides)))) + (when signals + (plist-put state :viewport-width viewport-width) + (plist-put state :viewport-height viewport-height) + (plist-put state :display-signature display-signature) + (plist-put state :viewport-dependent-node-id-axes axes) + (plist-put state :viewport-dependent-node-ids + (delete-dups + (copy-sequence (append (car axes) (cdr axes))))) + (plist-put state :viewport-dependent-node-ids-ready t) + (ebox-surface--apply-scroll-offsets root scroll-offsets)) + (let ((projection (ebox-surface--projection-start context root))) + (plist-put state :region-box-table (make-hash-table :test 'equal)) + (let ((rendered (ebox-surface--render-candidate state))) + (when (and signals + (hash-table-p (plist-get state :scroll-state-table)) + (> (hash-table-count + (plist-get state :scroll-state-table)) + 0)) + (tp-signal-read (ebox-surface--signals-scroll signals))) + (setq state (ebox-surface--finish-runtime-state state)) + (ebox-surface--projection-result + context projection state rendered)))))) (defun ebox-surface-producer - (source &optional previous-state preserve-identities-p state-overrides) + (source &optional previous-state preserve-identities-p state-overrides + signals) "Return a TP producer for Ebox SOURCE and optional PREVIOUS-STATE. PRESERVE-IDENTITIES-P retains existing Ebox node and region identities. -STATE-OVERRIDES augments the candidate Ebox runtime state." +STATE-OVERRIDES augments the candidate Ebox runtime state. SIGNALS carries +the mounted surface context; one-shot string materialization omits it." (lambda (context) (ebox-surface--project - context source previous-state preserve-identities-p state-overrides))) + context source previous-state preserve-identities-p state-overrides + signals))) (provide 'ebox-surface) diff --git a/ebox.el b/ebox.el index 1625e13..2b4d32f 100644 --- a/ebox.el +++ b/ebox.el @@ -1,7 +1,7 @@ ;;; ebox.el --- Pixel-perfect box model renderer -*- lexical-binding: t -*- ;; Author: ebox contributors -;; Version: 1.0.1 +;; Version: 2.0.0 ;; Package-Requires: ((emacs "29.1") (tp "1.0.0")) ;; Keywords: ui, graphics, convenience ;; URL: https://github.com/ginqi7/ebox @@ -59,10 +59,6 @@ (autoload 'ebox-native-build "ebox-native-reflow" nil t)) (unless (fboundp 'ebox-native-status) (autoload 'ebox-native-status "ebox-native-reflow" nil t)) -(autoload 'ebox-native-reflow-render-owner-proof "ebox-native-reflow") -(setq ebox-buffer-owner-proof-renderer - #'ebox-native-reflow-render-owner-proof) - (defalias 'ebox-select-all #'ebox-selector-query-buffer) (defalias 'ebox-update-selector #'ebox-selector-update-buffer) @@ -190,7 +186,7 @@ precise while real mouse and trackpad scrolling feels responsive." (defcustom ebox-scroll-lazy-prefix-lookahead-lines 8 "Extra lines rendered ahead when extending lazy scroll prefixes. -This keeps repeated fine scroll steps on the marker-window patch path without +This keeps repeated fine scroll steps on the retained viewport path without materializing the whole scroll source." :type 'natnum :group 'ebox) @@ -335,7 +331,17 @@ DSL height values may use `(viewport-height)' to resolve to this value while a caller, such as the Playground preview, renders against a concrete window.") (defvar ebox--region-box-table) -(defvar ebox-region-types) +(defconst ebox-region-types + '((content . ebox-content) + (content-owner . ebox-content-owner) + (pt . ebox-pt) (pb . ebox-pb) (pl . ebox-pl) (pr . ebox-pr) + (mt . ebox-mt) (mb . ebox-mb) (ml . ebox-ml) (mr . ebox-mr) + (bt . ebox-bt) (bb . ebox-bb) (bl . ebox-bl) (br . ebox-br)) + "Mapping from Ebox region roles to rendered text properties.") + +(defconst ebox--horizontal-border-anchor-roles + '(content content-owner pt pb pl pr bl br) + "Region roles defining a box edge while excluding its margins.") (defvar ebox--scroll-global-state) (defvar ebox--smooth-scroll-state-table) (defvar ebox--render-cache-scroll-state-region-ids nil @@ -357,9 +363,6 @@ caller, such as the Playground preview, renders against a concrete window.") "Buffer-keyed idle timers for predicted reflow-cache warming.") (defvar ebox--scroll-prefetch-delay-override nil "Dynamically bound delay for scroll prefix prefetch scheduling.") -(defvar ebox--region-update-buffer-hint nil - "Optional buffer hint for internal selector-driven region updates.") - (defsubst ebox--next-region-id () "Generate next unique region ID." (cl-incf ebox--region-id-counter)) @@ -874,7 +877,7 @@ Properties are expanded recursively according to `ebox-property-rules'." ;; - Render Context Model: dynamic bindings such as `ebox-viewport-width'. ;; - Buffer Runtime Model: per-buffer root node, viewport, and node indexes. ;; - Layout Snapshot Model: derived render facts used for diffing. -;; - Dirty/Patch Model: executable update choices for buffer edits. +;; - Dirty/Owner Model: semantic update impact and minimal layout owners. ;; ;; Dynamic render-local, flex-local, and snapshot-detail-local caches are ;; optimizations. Do not make them public API or component-owned state. @@ -882,13 +885,6 @@ Properties are expanded recursively according to `ebox-property-rules'." (defvar ebox--region-box-table (make-hash-table :test 'equal) "Maps region-id to its box definition for dynamic content updates.") -(defvar ebox--box-extents (make-hash-table :test 'equal) - "Maps region-id to (start-marker . end-marker) for contiguous box roots. -Only boxes occupying one contiguous buffer slice are stored here.") - -(defvar-local ebox--box-extent-template nil - "Prepared numeric box extents for the current root publication.") - (defvar ebox--defer-scroll-content-index nil "Non-nil means full scroll content indexes are built lazily.") @@ -1132,171 +1128,9 @@ request; callers that represent GUI animation ticks pass their tick size." (when defer-gc (ebox--deferred-render-gc-schedule-restore)))))))))) -(defun ebox--scroll-content-marker-spans (markers) - "Return MARKERS as a plain list of marker span conses." - (cond - ((vectorp markers) (append markers nil)) - ((listp markers) markers) - (t nil))) - -(defun ebox--scroll-line-spans-in-window (start line-count) - "Return LINE-COUNT line spans starting at START in one forward scan." - (save-excursion - (goto-char start) - (cl-loop repeat line-count - collect (cons (point) (line-end-position)) - do (forward-line 1)))) - -(defun ebox--scroll-clear-content-markers (state) - "Detach content span markers stored in scroll STATE." - (dolist (span (ebox--scroll-content-marker-spans - (plist-get state :content-span-markers))) - (when (consp span) - (when (markerp (car span)) - (set-marker (car span) nil)) - (when (markerp (cdr span)) - (set-marker (cdr span) nil))))) - -(defun ebox--scroll-clear-rendered-window-markers (state) - "Detach rendered scroll window boundary markers stored in STATE." - (when-let ((start (plist-get state :rendered-window-start-marker))) - (when (markerp start) - (set-marker start nil))) - (when-let ((end (plist-get state :rendered-window-end-marker))) - (when (markerp end) - (set-marker end nil)))) - -(defun ebox--scroll-store-rendered-window-extents - (buffer region-id state start end line-count) - "Store lightweight rendered window extents for REGION-ID. -Unlike content span markers, these two markers are cheap enough for the scroll -hot path and are sufficient to anchor the next viewport replacement." - (when (and (buffer-live-p buffer) - (integer-or-marker-p start) - (integer-or-marker-p end) - (integerp line-count) - (> line-count 0)) - (with-current-buffer buffer - (let ((start-pos (if (markerp start) (marker-position start) start)) - (end-pos (if (markerp end) (marker-position end) end))) - (when (and start-pos end-pos (< start-pos end-pos)) - (let ((start-marker - (or (and (markerp (plist-get - state :rendered-window-start-marker)) - (plist-get state :rendered-window-start-marker)) - (make-marker))) - (end-marker - (or (and (markerp (plist-get - state :rendered-window-end-marker)) - (plist-get state :rendered-window-end-marker)) - (make-marker)))) - (set-marker-insertion-type start-marker nil) - (set-marker-insertion-type end-marker t) - (set-marker start-marker start-pos) - (set-marker end-marker end-pos) - (setq state - (plist-put state :rendered-window-start-marker - start-marker)) - (setq state - (plist-put state :rendered-window-end-marker - end-marker))) - (setq state - (plist-put state :rendered-window-line-count line-count)) - (setq state (plist-put state :buffer buffer)) - (puthash region-id state ebox--scroll-global-state) - state))))) - -(defun ebox--scroll-window-region-id-set (buffer ranges) - "Return all ebox region ids inside BUFFER RANGES as a hash set." - (let ((set (make-hash-table :test 'equal))) - (when (buffer-live-p buffer) - (with-current-buffer buffer - (dolist (range ranges) - (let ((pos (car range)) - (end (cdr range))) - (while (< pos end) - (dolist (region-id (ebox--pos-region-ids pos)) - (puthash region-id t set)) - (setq pos (or (next-property-change pos nil end) - end))))))) - set)) - -(defun ebox--scroll-state-put-window-region-id-set (state value) - "Return STATE with cached visible region id set stored at the front." - (cons :content-window-region-id-set - (cons value - (ebox--plist-remove state :content-window-region-id-set)))) - -(defun ebox--scroll-state-store-window-region-id-set - (region-id state buffer ranges) - "Store visible region ids for REGION-ID/STATE from BUFFER RANGES." - (setq state - (ebox--scroll-state-put-window-region-id-set - state (ebox--scroll-window-region-id-set buffer ranges))) - (setq state (plist-put state :buffer buffer)) - (puthash region-id state ebox--scroll-global-state) - state) - -(defun ebox--scroll-state-clear-window-region-id-set - (region-id state &optional buffer) - "Clear the cached visible region id set for REGION-ID/STATE." - (when (or (plist-get state :content-window-region-id-set) - (and buffer (not (eq (plist-get state :buffer) buffer)))) - (setq state (plist-put state :content-window-region-id-set nil)) - (when buffer - (setq state (plist-put state :buffer buffer))) - (puthash region-id state ebox--scroll-global-state)) - state) - -(defun ebox--scroll-state-region-visible-in-window-p (state region-ids) - "Return non-nil when REGION-IDS appear in STATE's marker window." - (if-let ((region-set (plist-get state :content-window-region-id-set))) - (cl-some (lambda (region-id) - (gethash region-id region-set)) - region-ids) - (when-let ((markers (plist-get state :content-span-markers))) - (let ((region-set (ebox--region-id-set region-ids))) - (catch 'visible - (dolist (span (ebox--scroll-content-marker-spans markers)) - (when-let* ((start-marker (car-safe span)) - (end-marker (cdr-safe span)) - ((markerp start-marker)) - ((markerp end-marker)) - (buffer (marker-buffer start-marker)) - ((eq buffer (marker-buffer end-marker))) - (start (marker-position start-marker)) - (end (marker-position end-marker)) - ((< start end))) - (with-current-buffer buffer - (let ((pos start)) - (while (< pos end) - (when (cl-some (lambda (region-id) - (gethash region-id region-set)) - (ebox--pos-region-ids pos)) - (throw 'visible t)) - (setq pos (or (next-property-change pos nil end) - end))))))) - nil))))) - -(defun ebox--scroll-state-region-visible-in-content-range-p - (state region-ids) - "Return non-nil when REGION-IDS have spans in STATE's visible line range." - (when-let ((spans - (ebox--scroll-hot-content-line-spans - state region-ids))) - (ebox--scroll-state-spans-visible-p state spans))) - (defun ebox--scroll-state-buffer (state) "Return the buffer associated with scroll STATE, or nil." - (or (plist-get state :buffer) - (catch 'buffer - (dolist (span (ebox--scroll-content-marker-spans - (plist-get state :content-span-markers))) - (when-let ((buffer (and (consp span) - (markerp (car span)) - (marker-buffer (car span))))) - (throw 'buffer buffer))) - nil))) + (plist-get state :buffer)) ;;;###autoload (defun ebox-scroll-state (region-id) @@ -1307,11 +1141,6 @@ content back to their own source model." (defconst ebox--scroll-cache-transient-state-keys '(:buffer - :content-span-markers - :content-window-region-id-set - :rendered-window-start-marker - :rendered-window-end-marker - :rendered-window-line-count :content-region-id-set :region-line-bounds-index :region-line-span-index @@ -1473,9 +1302,6 @@ remain transaction-local." ebox--collect-rebuilt-scroll-state-region-ids) (cl-pushnew region-id ebox--rebuilt-scroll-state-region-ids :test #'equal)) - (when-let ((old-state (ebox--scroll-get-state region-id))) - (ebox--scroll-clear-content-markers old-state) - (ebox--scroll-clear-rendered-window-markers old-state)) (when-let ((content-lines (plist-get state :content-lines))) (let ((ebox--defer-scroll-content-index (or ebox--defer-scroll-content-index @@ -1493,9 +1319,6 @@ remain transaction-local." (defun ebox--scroll-clear-state (region-id) "Remove scroll state for REGION-ID." (ebox--scroll-cancel-idle-prefetch region-id) - (when-let ((old-state (ebox--scroll-get-state region-id))) - (ebox--scroll-clear-content-markers old-state) - (ebox--scroll-clear-rendered-window-markers old-state)) (remhash region-id ebox--scroll-global-state) (ebox--record-render-cache-scroll-region region-id)) @@ -1684,20 +1507,6 @@ probing source completion, while idle prefetch retains the sentinel." (setq state (ebox--scroll-state-set-visible-refresh-requirement state old-visible old-refresh-required)) - ;; Native publication already exposed this exact visible window. - ;; Property-list order can differ between the live patch and the - ;; freshly rendered prefix while their text, faces and display - ;; geometry are identical. Keep the bounded line-slide path when - ;; the live marker window proves that visual identity. - (when (and native-target-p - (not (plist-get state - :native-reflow-target-prefix-p)) - buffer - (ebox--scroll-state-window-markers-healthy-p - buffer region-id state)) - (setq state - (ebox--plist-remove - state :lazy-scroll-window-refresh-required))) (when (plist-get rendered :complete) (setq state (ebox--plist-remove state :materialize-content-lines)) @@ -1707,1119 +1516,6 @@ probing source completion, while idle prefetch retains the sentinel." (puthash region-id state ebox--scroll-global-state) state))))) -(defun ebox--next-content-span-change (pos limit) - "Return the next visible content span property change after POS." - (let ((next limit)) - (dolist (prop '(ebox-content ebox-content-idx)) - (let ((change (next-single-property-change pos prop nil limit))) - (when (and change (< change next)) - (setq next change)))) - next)) - -(defun ebox--scroll-state-with-content-marker-spans - (buffer state entries &optional defer-window-region-set) - "Return STATE with marker-backed content ENTRIES in BUFFER." - (let* ((max-idx (cl-loop for entry in entries - maximize (or (car entry) 0))) - (markers (make-vector (1+ (or max-idx 0)) nil)) - ranges) - (with-current-buffer buffer - (dolist (entry entries) - (pcase-let ((`(,idx ,start . ,end) entry)) - (when (and (integerp idx) start end) - (let ((start-marker (copy-marker start nil)) - (end-marker (copy-marker end t))) - (aset markers idx (cons start-marker end-marker)) - (push (cons start end) ranges)))))) - (setq state (plist-put state :content-span-markers markers)) - (if defer-window-region-set - (progn - (setq state (plist-put state :content-window-region-id-set nil)) - (plist-put state :buffer buffer)) - (ebox--scroll-state-put-window-region-id-set - state (ebox--scroll-window-region-id-set buffer ranges))))) - -(defun ebox--scroll-store-content-marker-spans - (buffer region-id state entries &optional defer-window-region-set) - "Store marker-backed visible content ENTRIES for REGION-ID in BUFFER." - (let ((old-state state)) - (setq state - (ebox--scroll-state-with-content-marker-spans - buffer (copy-sequence state) entries - defer-window-region-set)) - (puthash region-id state ebox--scroll-global-state) - (ebox--scroll-clear-content-markers old-state) - (ebox--scroll-state-window-extents region-id state))) - -(defun ebox--scroll-window-region-id-set-in-spans (spans) - "Return scroll viewport region ids marked inside buffer SPANS." - (let ((region-set (make-hash-table :test 'equal))) - (dolist (span spans) - (let ((pos (car span)) - (end (cdr span))) - (while (< pos end) - (when-let ((region-id - (get-text-property pos 'ebox-scroll-window))) - (puthash region-id t region-set)) - (setq pos - (or (next-single-property-change - pos 'ebox-scroll-window nil end) - end))))) - region-set)) - -(defun ebox--refresh-root-owner-scroll-windows (buffer results) - "Refresh lightweight scroll window markers after one root owner replacement. -Return non-nil when RESULTS cover BUFFER's root and every scroll viewport can -use the rendered-window fast path." - (when (and (= (length results) 1) - (equal (plist-get (car results) :owner-id) - (ebox--buffer-root-node-id buffer))) - (let ((region-set (ebox--buffer-region-id-set buffer)) - (rendered-scroll-region-set - (with-current-buffer buffer - (ebox--scroll-window-region-id-set-in-spans - (plist-get (car results) :new-buffer-spans)))) - (replacement-spans (plist-get (car results) :new-buffer-spans)) - region-ids - windows - compatible) - (maphash - (lambda (region-id state) - (when (and (gethash region-id region-set) - (gethash region-id rendered-scroll-region-set) - (plist-get state :box)) - (push region-id region-ids))) - ebox--scroll-global-state) - (setq region-ids (nreverse region-ids)) - (if (null region-ids) - t - (with-current-buffer buffer - (if (ebox-buffer--region-role-span-table) - (ebox--refresh-box-extents-from-role-spans - region-ids replacement-spans) - (ebox--refresh-scroll-window-extents-from-render-markers - region-ids replacement-spans)) - (setq compatible t) - (dolist (region-id region-ids) - (let* ((state (ebox--scroll-get-state region-id)) - (box (plist-get state :box)) - (height (plist-get state :content-height)) - (extents (ebox--live-box-extents region-id)) - (start (and extents - (marker-position (car extents)))) - (end (and extents - (marker-position (cdr extents))))) - (if (and (ebox--scroll-chrome-free-rendered-window-p box) - (integerp height) - (> height 0) - start end (< start end) - (= (count-lines start end) height)) - (push (list region-id state start end height) windows) - (setq compatible nil)))) - (when compatible - (dolist (window windows) - (pcase-let ((`(,region-id ,state ,start ,end ,height) window)) - (ebox--scroll-clear-content-markers state) - (setq state (plist-put state :content-span-markers nil)) - (setq state - (plist-put state :content-window-region-id-set nil)) - (ebox--scroll-store-rendered-window-extents - buffer region-id state start end height))) - (when-let ((render-state (ebox--buffer-render-state buffer))) - (plist-put render-state :scroll-region-ids region-ids)) - t)))))) - -(defun ebox--face-visual-attributes (face) - "Return FACE's effective visual attributes under Emacs merge order. -Earlier entries of an anonymous face list take precedence, so for -every attribute keyword the first provider wins and the result is -independent of how contributors were merged or split. Named faces -cannot be attribute-merged without display context, so any face -containing a named entry compares by its raw representation instead." - (let ((entries (if (and (listp face) (not (keywordp (car-safe face)))) - face - (list face))) - attributes named) - (dolist (entry entries) - (cond - ((null entry) nil) - ((and (listp entry) (keywordp (car-safe entry))) - (cl-loop for (key value) on entry by #'cddr - unless (assq key attributes) - do (push (cons key value) attributes))) - (t (setq named t)))) - (if named - (list 'raw face) - (list 'visual - (sort attributes - (lambda (a b) - (string< (symbol-name (car a)) - (symbol-name (car b))))))))) - -(defun ebox--scroll-string-visual-signature (string) - "Return STRING's visual signature as pixel-extent glyph runs. -Each run is (START-PIXEL END-PIXEL TEXT DISPLAY VISUAL): equal runs -carry the same characters, the same display spec, the same effective -visual attributes, and the same pixel extent, which fully determines -their rendering. Faces compare by `ebox--face-visual-attributes' -rather than raw face shape: independent patch paths legitimately -produce different face representations for the same visible result, -and this signature's contract is visual equality. Pixel widths are -measured once per property run, never per character." - (let ((pixel 0) - (length (length string)) - (position 0) - runs) - (cl-flet - ((record (start end text display visual) - ;; TEXT is nil for visually blank segments; blank extents - ;; merge across property boundaries so equality does not - ;; depend on how invisible properties segment identical - ;; lines. Contiguous word segments with equal display and - ;; visual attributes merge the same way. - (let ((run (car runs))) - (cond - ((and run (null text) (null (nth 2 run)) - (equal (nth 4 run) visual)) - (setcar (cdr run) end)) - ((and run text (nth 2 run) - (equal (nth 3 run) display) - (equal (nth 4 run) visual)) - (setcar (cdr run) end) - (setcar (cddr run) (concat (nth 2 run) text))) - (t (push (list start end text display visual) runs)))))) - (while (< position length) - (let* ((next (or (next-property-change position string) length)) - (face (get-text-property position 'face string)) - (display (get-text-property position 'display string)) - (visual (ebox--face-visual-attributes face)) - (space-display-p (eq (car-safe display) 'space))) - (cond - (space-display-p - (let ((width (ebox--substring-pixel-width string position next))) - (record pixel (+ pixel width) nil nil visual) - (setq pixel (+ pixel width)))) - (display - (let ((width (ebox--substring-pixel-width string position next))) - (record pixel (+ pixel width) - (substring-no-properties string position next) - display visual) - (setq pixel (+ pixel width)))) - (t - (let ((cursor position)) - (while (< cursor next) - (let ((blank (eq (aref string cursor) ?\s)) - (segment-end cursor)) - (while (and (< segment-end next) - (eq (eq (aref string segment-end) ?\s) - blank)) - (setq segment-end (1+ segment-end))) - (let ((width (ebox--substring-pixel-width - string cursor segment-end))) - (record pixel (+ pixel width) - (unless blank - (substring-no-properties - string cursor segment-end)) - nil visual) - (setq pixel (+ pixel width))) - (setq cursor segment-end)))))) - (setq position next)))) - (list pixel (nreverse runs)))) - -(defun ebox--scroll-lines-visually-equal-p (actual expected) - "Return non-nil when ACTUAL and EXPECTED line lists render identically. -Raw string/property equality is a sufficient fast path; only lines -whose representations diverge pay for pixel-measured signatures." - (and (= (length actual) (length expected)) - (cl-every - (lambda (actual-line expected-line) - (or (equal-including-properties actual-line expected-line) - (equal (ebox--scroll-string-visual-signature actual-line) - (ebox--scroll-string-visual-signature expected-line)))) - actual expected))) - -(defun ebox--scroll-state-window-markers-healthy-p - (buffer region-id state) - "Return non-nil when STATE's live BUFFER window equals its cached slice." - (when-let* (((buffer-live-p buffer)) - (box (plist-get state :box)) - (height (plist-get state :content-height)) - ((integerp height)) - ((> height 0)) - ((eq (ebox--scroll-state-buffer state) buffer))) - (let* ((offset (or (plist-get state :scroll-offset) 0)) - (native-offset (plist-get state :native-reflow-visible-offset)) - (native-lines (plist-get state :native-reflow-visible-lines)) - (chrome-free-p - (ebox--scroll-chrome-free-rendered-window-p box)) - (marker-spans - (delq nil - (ebox--scroll-content-marker-spans - (plist-get state :content-span-markers)))) - (contiguous-marker-window-p - (and chrome-free-p - (= (length marker-spans) height) - (ebox--scroll-content-window - (plist-put (copy-sequence state) :region-id region-id) - height))) - (lines - (if chrome-free-p - (or (plist-get state :rendered-content-lines) - (plist-get state :content-lines)) - (plist-get state :content-lines))) - (visible - (cond - ((and (equal native-offset offset) - (= (length native-lines) height)) - (list offset native-lines)) - (lines - (let ((end (min (length lines) (+ offset height)))) - (and (<= (+ offset height) (length lines)) - (list offset (seq-subseq lines offset end))))))) - (expected (and visible - (= (length (cadr visible)) height) - (cadr visible))) - (expected - (if (and expected - (not chrome-free-p)) - (let ((decorated - (ebox--scroll-decorate-rendered-window-lines - expected box region-id))) - (if (= (length decorated) height) - decorated - expected)) - expected))) - (and expected - (if (and chrome-free-p - (or (null marker-spans) contiguous-marker-window-p)) - (when-let* ((extents (ebox--scroll-state-window-extents - region-id state)) - (start (car extents)) - (end (cdr extents)) - ((markerp start)) - ((markerp end)) - ((eq (marker-buffer start) buffer)) - ((eq (marker-buffer end) buffer)) - (start-pos (marker-position start)) - (end-pos (marker-position end)) - ((< start-pos end-pos)) - (expected - (with-current-buffer buffer - (ebox--scroll-lines-with-live-wrapper-context - expected start-pos region-id)))) - (and (= (with-current-buffer buffer - (count-lines start-pos end-pos)) - height) - (ebox--scroll-lines-visually-equal-p - (with-current-buffer buffer - (ebox-string-lines - (buffer-substring start-pos end-pos))) - expected))) - (let ((spans marker-spans)) - (and (= (length spans) height) - (cl-every - (lambda (span) - (when-let* (((consp span)) - (start (car span)) - (end (cdr span)) - ((markerp start)) - ((markerp end)) - ((eq (marker-buffer start) buffer)) - ((eq (marker-buffer end) buffer)) - (start-pos (marker-position start)) - (end-pos (marker-position end))) - (< start-pos end-pos))) - spans) - (ebox--scroll-lines-visually-equal-p - (with-current-buffer buffer - (mapcar - (lambda (span) - (buffer-substring - (marker-position (car span)) - (marker-position (cdr span)))) - spans)) - expected)))))))) - -(defun ebox--publish-scoped-owner-scroll-windows - (buffer results source-node-ids - &optional pre-containing-region-ids rebuilt-region-ids - final-spans-by-result) - "Atomically publish scroll windows rebuilt by owner rerenders in BUFFER. -RESULTS are already-applied backend patch results and SOURCE-NODE-IDS are the -runtime nodes whose changes produced them. One buffer-wide marker rebuild is -performed only after every replacement is complete, so nested scroll windows -cannot invalidate markers installed for one another. `:covered-p' is non-nil -only when every source-containing state and every scroll state rebuilt inside -RESULTS has a verified live window from this same buffer generation." - (when (and (buffer-live-p buffer) results source-node-ids) - (let ((buffer-region-set (ebox--buffer-region-id-set buffer)) - (rendered-region-set (make-hash-table :test 'equal)) - rendered-region-ids - candidate-region-ids - published-region-ids - (affected-region-ids (copy-sequence pre-containing-region-ids))) - (with-current-buffer buffer - (dolist (result results) - (when (eq (plist-get result :op) 'owner-rerender) - (let ((spans - (or (and final-spans-by-result - (gethash result final-spans-by-result)) - (plist-get result :new-buffer-spans)))) - (maphash - (lambda (region-id _present) - (when (gethash region-id buffer-region-set) - (puthash region-id t rendered-region-set))) - (ebox--scroll-window-region-id-set-in-spans spans))))) - (maphash (lambda (region-id _present) - (push region-id rendered-region-ids)) - rendered-region-set)) - (dolist (region-id rendered-region-ids) - (cl-pushnew region-id affected-region-ids :test #'equal)) - (dolist (region-id rebuilt-region-ids) - (when-let ((state (and (gethash region-id buffer-region-set) - (ebox--scroll-get-state region-id)))) - (when (eq (ebox--scroll-state-buffer state) buffer) - (cl-pushnew region-id affected-region-ids :test #'equal) - (cl-pushnew region-id candidate-region-ids :test #'equal)))) - (dolist (region-id rendered-region-ids) - (cl-pushnew region-id candidate-region-ids :test #'equal)) - (when candidate-region-ids - ;; Install every content marker generation together. This handles - ;; chromed, chrome-free, root and nested scroll owners without a - ;; per-state zero-delta rewrite. - (ebox--refresh-buffer-scroll-content-markers buffer) - (dolist (region-id candidate-region-ids) - (when-let ((state (ebox--scroll-get-state region-id))) - (when (ebox--scroll-state-window-markers-healthy-p - buffer region-id state) - (setq state - (ebox--plist-remove - state :lazy-scroll-window-refresh-required)) - (puthash region-id state ebox--scroll-global-state) - (push region-id published-region-ids)))) - (list :covered-p - (cl-every - (lambda (region-id) - (member region-id published-region-ids)) - affected-region-ids) - :covered-region-ids (nreverse affected-region-ids) - :published-region-ids (nreverse published-region-ids)))))) - -(defun ebox--refresh-scroll-window-extents-from-render-markers - (region-ids spans) - "Refresh scroll REGION-IDS in SPANS from rendered window markers. -Root reflow invalidates the broad role index. Chrome-free scroll boxes mark -their exact visible window on the rendered string, so recovering those ranges -requires only one dedicated property scan rather than generic role inference." - (ebox--clear-box-extents-for-region-ids region-ids) - (when spans - (let ((region-set (ebox--region-id-set region-ids)) - (ranges (make-hash-table :test 'equal))) - (dolist (span spans) - (let ((pos (car span)) - (end (cdr span))) - (while (< pos end) - (let* ((region-id - (get-text-property pos 'ebox-scroll-window)) - (next - (or (next-single-property-change - pos 'ebox-scroll-window nil end) - end))) - (when (and region-id (gethash region-id region-set)) - (if-let ((range (gethash region-id ranges))) - (progn - (setcar range (min (car range) pos)) - (setcdr range (max (cdr range) next))) - (puthash region-id (cons pos next) ranges))) - (setq pos (max next (1+ pos))))))) - (maphash - (lambda (region-id range) - (when (< (car range) (cdr range)) - (ebox--set-box-extents region-id (car range) (cdr range)))) - ranges)))) - -(defun ebox--refresh-buffer-scroll-content-markers (buffer) - "Refresh marker-backed scroll content spans for scroll regions in BUFFER." - (when (and (buffer-live-p buffer) - (> (hash-table-count ebox--scroll-global-state) 0)) - (let ((spans-by-region (make-hash-table :test 'equal)) - (owner-line-indexes (make-hash-table :test 'equal)) - (seen (make-hash-table :test 'equal)) - scroll-region-ids) - (cl-labels - ((record (region-id idx start end) - (when (and region-id - (integerp idx) - (ebox--scroll-get-state region-id) - (< start end)) - (puthash region-id t seen) - (puthash region-id - (cons (cons idx (cons start end)) - (gethash region-id spans-by-region)) - spans-by-region))) - (owner-ids-at (pos) - (let (ids) - (dolist (region-id (get-text-property - pos 'ebox-content-owners)) - (cl-pushnew region-id ids :test #'equal)) - (when-let ((region-id (get-text-property - pos 'ebox-content-owner))) - (cl-pushnew region-id ids :test #'equal)) - ids)) - (line-owner-spans (start end) - (let ((pos start) - spans) - (while (< pos end) - (let ((next (or (next-property-change pos nil end) end))) - (dolist (region-id (owner-ids-at pos)) - (when (ebox--scroll-get-state region-id) - (let ((span (alist-get region-id spans nil nil - #'equal))) - (if span - (setcdr span next) - (push (cons region-id (cons pos next)) - spans))))) - (setq pos (max next (1+ pos))))) - spans))) - (with-current-buffer buffer - (save-excursion - (goto-char (point-min)) - (while (< (point) (point-max)) - (let* ((region-id (get-text-property (point) 'ebox-content)) - (idx (get-text-property (point) 'ebox-content-idx)) - (next (ebox--next-content-span-change - (point) (point-max)))) - (record region-id idx (point) next) - (goto-char (max next (1+ (point))))))) - (save-excursion - (goto-char (point-min)) - (while (< (point) (point-max)) - (let* ((line-start (point)) - (line-end (line-end-position))) - (when (< line-start line-end) - (dolist (entry (line-owner-spans line-start line-end)) - (let* ((region-id (car entry)) - (span (cdr entry)) - (idx (gethash region-id owner-line-indexes 0))) - (record region-id idx (car span) (cdr span)) - (puthash region-id (1+ idx) owner-line-indexes)))) - (forward-line 1)))))) - (maphash - (lambda (region-id entries) - (when-let ((state (ebox--scroll-get-state region-id))) - (ebox--scroll-store-content-marker-spans - buffer region-id state (nreverse entries)))) - spans-by-region) - (maphash - (lambda (region-id state) - (when (eq (ebox--scroll-state-buffer state) buffer) - (push region-id scroll-region-ids) - (unless (gethash region-id seen) - (ebox--scroll-clear-content-markers state) - (setq state (plist-put state :content-span-markers nil)) - (setq state (plist-put state :content-window-region-id-set nil)) - (setq state (plist-put state :buffer nil)) - (puthash region-id state ebox--scroll-global-state)))) - ebox--scroll-global-state) - (when-let ((render-state (ebox--buffer-render-state buffer))) - (plist-put render-state :scroll-region-ids - (nreverse scroll-region-ids)))))) - -(defun ebox--span-touches-range-p (span range) - "Return non-nil when SPAN overlaps RANGE or contains its empty insertion." - (if (= (car range) (cdr range)) - (<= (car span) (car range) (cdr span)) - (and (< (car span) (cdr range)) - (> (cdr span) (car range))))) - -(defun ebox--scroll-content-entries-in-range (region-id start end) - "Return visible content marker entries for REGION-ID inside START..END." - (let ((position start) - direct - owned - (owner-index 0)) - (while (< position end) - (let* ((content (get-text-property position 'ebox-content)) - (index (get-text-property position 'ebox-content-idx)) - (next (ebox--next-content-span-change position end))) - (when (and (equal content region-id) (integerp index)) - (push (cons index (cons position next)) direct)) - (setq position (max next (1+ position))))) - (save-excursion - (goto-char start) - (while (< (point) end) - (let* ((line-start (point)) - (line-end (min end (line-end-position))) - (position line-start) - owner-start owner-end) - (while (< position line-end) - (let* ((owners - (append - (get-text-property position 'ebox-content-owners) - (when-let ((owner - (get-text-property - position 'ebox-content-owner))) - (list owner)))) - (next (or (next-property-change position nil line-end) - line-end))) - (when (member region-id owners) - (setq owner-start (or owner-start position) - owner-end next)) - (setq position (max next (1+ position))))) - (when (and owner-start owner-end (< owner-start owner-end)) - (push (cons owner-index (cons owner-start owner-end)) owned) - (cl-incf owner-index)) - (forward-line 1)))) - (nreverse (or direct owned)))) - -(defconst ebox--scroll-content-template-absent - 'ebox--scroll-content-template-absent - "Sentinel returned when a scroll content template has no REGION-ID entry.") - -(defun ebox--scroll-content-template-entries (template region-id start) - "Return REGION-ID scroll content entries from TEMPLATE at buffer START." - (when (and (hash-table-p template) (integerp start)) - (let ((entries - (gethash region-id template - ebox--scroll-content-template-absent))) - (if (eq entries ebox--scroll-content-template-absent) - (list ebox--scroll-content-template-absent) - (mapcar - (lambda (entry) - (cons (car entry) - (cons (+ start (cadr entry)) - (+ start (cddr entry))))) - entries))))) - -(defun ebox--preflight-scroll-content-span-template - (buffer template start target-length) - "Validate TEMPLATE for BUFFER before prepared-root publication. -Return translated target entries keyed by scroll region id. Missing template -entries, malformed target offsets, and nonempty states with empty entries are -rejected before the visible buffer text changes." - (unless (and (buffer-live-p buffer) - (hash-table-p template) - (integerp start) - (integerp target-length) - (>= target-length 0)) - (error "Invalid prepared scroll template")) - (let ((translated (make-hash-table :test 'equal)) - (scroll-region-ids - (delete-dups - (append - (copy-sequence - (plist-get (ebox--buffer-render-state buffer) - :scroll-region-ids)) - (cl-loop for region-id being the hash-keys of ebox--scroll-global-state - using (hash-values state) - when (eq (ebox--scroll-state-buffer state) buffer) - collect region-id))))) - (dolist (region-id scroll-region-ids) - (let ((entries - (gethash region-id template - ebox--scroll-content-template-absent)) - (state (ebox--scroll-get-state region-id))) - (when (eq entries ebox--scroll-content-template-absent) - (error "Prepared scroll template missing region %S" region-id)) - (unless state - (error "Prepared scroll template has no live state for %S" - region-id)) - (when (and (null entries) - (> (or (plist-get state :content-height) 0) 0)) - (error "Prepared scroll template is empty for nonempty region %S" - region-id)) - (puthash - region-id - (mapcar - (lambda (entry) - (let ((index (car entry)) - (entry-start (cadr entry)) - (entry-end (cddr entry))) - (unless (and (integerp index) - (>= index 0) - (integerp entry-start) - (integerp entry-end) - (<= 0 entry-start entry-end target-length) - (< entry-start entry-end)) - (error "Prepared scroll template has invalid target offsets")) - (cons index - (cons (+ start entry-start) - (+ start entry-end))))) - entries) - translated))) - (list :entries-by-region translated - :scroll-region-ids scroll-region-ids))) - -(defun ebox--refresh-patched-scroll-markers - (buffer affected-region-ids published-spans - &optional scroll-content-template root-start) - "Refresh only scroll windows touched by an exact root patch in BUFFER. -Return a plist whose `:complete-p' value proves every affected live scroll -state received one current marker generation. -SCROLL-CONTENT-TEMPLATE contains target-side scroll spans relative to -ROOT-START, and avoids live extent scans for prepared root publications." - (when (buffer-live-p buffer) - (let ((candidate-set (make-hash-table :test 'equal)) - candidates staged staged-publications published - (complete-p t)) - (dolist (region-id affected-region-ids) - (when (ebox--scroll-get-state region-id) - (puthash region-id t candidate-set))) - (dolist (region-id - (plist-get (ebox--buffer-render-state buffer) - :scroll-region-ids)) - (let* ((template-entries - (ebox--scroll-content-template-entries - scroll-content-template region-id root-start)) - (template-present-p - (not (eq (car template-entries) - ebox--scroll-content-template-absent))) - extents start end) - (unless template-present-p - (setq extents - (with-current-buffer buffer - (ebox--live-box-extents region-id)) - start (and extents (marker-position (car extents))) - end (and extents (marker-position (cdr extents))))) - ;; Exact root patches already bound the publication work. If their - ;; role delta is empty, retain correctness for scroll roots whose - ;; extents do not expose the changed interior by refreshing the - ;; small render-state scroll set, never by scanning the page. - (when (or (null affected-region-ids) - template-present-p - (and start end (< start end) - (cl-some - (lambda (span) - (ebox--span-touches-range-p - (cons start end) span)) - published-spans))) - (puthash region-id t candidate-set)))) - (maphash (lambda (region-id _present) - (push region-id candidates)) - candidate-set) - (with-current-buffer buffer - (dolist (region-id candidates) - (let* ((state (ebox--scroll-get-state region-id)) - (template-entries - (ebox--scroll-content-template-entries - scroll-content-template region-id root-start)) - (template-present-p - (not (eq (car template-entries) - ebox--scroll-content-template-absent))) - extents start end entries) - (if (not state) - (setq complete-p nil) - (unless template-present-p - (setq extents (ebox--live-box-extents region-id) - start (and extents (marker-position (car extents))) - end (and extents (marker-position (cdr extents))))) - (setq entries - (if template-present-p - template-entries - (and start end (< start end) - (ebox--scroll-content-entries-in-range - region-id start end)))) - (if (or entries - (and template-present-p - (= (or (plist-get state :content-height) 0) - 0))) - (push (list region-id state entries) staged) - (setq complete-p nil))))) - (when complete-p - (dolist (prepared (nreverse staged)) - (pcase-let ((`(,region-id ,state ,entries) prepared)) - (let* ((render-state (ebox--buffer-render-state buffer)) - (old-state state) - (state (copy-sequence state)) - (box (plist-get state :box)) - (viewport-width - (plist-get render-state :viewport-width)) - (viewport-height - (plist-get render-state :viewport-height)) - (prefix-viewport-width - (and box viewport-width - (let ((ebox-viewport-width viewport-width)) - (or (ebox--wrapper-content-viewport-pixel box) - viewport-width)))) - (content-height (plist-get state :content-height)) - (scroll-offset - (or (plist-get state :scroll-offset) 0)) - (source - (and box - (ebox--scroll-window-stack-source - (ebox--box-content-node box)))) - (visible-lines - (and (integerp content-height) - (= (length entries) content-height) - (cl-loop - for entry in (sort (copy-sequence entries) - (lambda (left right) - (< (car left) - (car right)))) - for expected-index from 0 - when (= (car entry) expected-index) - collect - (buffer-substring - (cadr entry) (cddr entry)))))) - ;; The patch contains the complete visible target window but - ;; no hidden target page. Drop the old-width line cache, - ;; retain only this exact visible proof, and rebuild the new - ;; prefix incrementally. This keeps publication proportional - ;; to the patch and prevents first scroll input from paying a - ;; full-page materialization. - (when (and box source prefix-viewport-width viewport-height - (= (length visible-lines) content-height)) - (setq state (plist-put state :content-lines nil)) - (setq state - (ebox--plist-remove state - :rendered-content-lines)) - (dolist (key '(:content-region-id-set - :region-line-bounds-index - :region-line-span-index - :region-line-span-index-deferred - :rendered-region-line-span-index - :rendered-region-line-span-index-deferred - :region-line-span-hints)) - (setq state (ebox--plist-remove state key))) - (setq state - (plist-put - state :render-content-prefix - (ebox--scroll-window-prefix-renderer - box source prefix-viewport-width - viewport-height))) - (setq state - (plist-put - state :scroll-window-producer-spec - (ebox--scroll-window-producer-spec - prefix-viewport-width viewport-width - viewport-height))) - (setq state - (plist-put - state :materialize-content-lines - (ebox--scroll-window-materializer - box viewport-width viewport-height))) - (setq state - (plist-put state :content-lines-complete-p nil)) - (setq state - (plist-put state - :native-reflow-target-prefix-p t)) - (setq state - (plist-put state - :native-reflow-prefix-reset-p t)) - (setq state - (plist-put state :native-reflow-visible-offset - scroll-offset)) - (setq state - (plist-put state :native-reflow-visible-lines - visible-lines)) - (setq state - (ebox--plist-remove - state :native-reflow-materialize-p))) - (push - (list region-id old-state - (ebox--scroll-state-with-content-marker-spans - buffer state entries)) - staged-publications) - (push region-id published))))) - (let (swapped) - (condition-case err - (progn - (dolist (publication (nreverse staged-publications)) - (pcase-let ((`(,region-id ,old-state ,new-state) - publication)) - (puthash region-id new-state ebox--scroll-global-state) - (push (list region-id old-state new-state) swapped))) - (dolist (publication swapped) - (pcase-let ((`(,region-id ,_old-state ,_new-state) - publication)) - (ebox--scroll-schedule-idle-prefetch region-id))) - (dolist (publication swapped) - (pcase-let ((`(,_region-id ,old-state ,_new-state) - publication)) - (ebox--scroll-clear-content-markers old-state)))) - (error - (dolist (publication swapped) - (pcase-let ((`(,region-id ,old-state ,new-state) - publication)) - (puthash region-id old-state ebox--scroll-global-state) - (ebox--scroll-clear-content-markers new-state))) - (signal (car err) (cdr err)))))) - (list :complete-p complete-p - :affected-region-ids (nreverse candidates) - :published-region-ids (nreverse published))))) - -(defun ebox--next-string-content-span-change (position string limit) - "Return next content property change after POSITION in STRING." - (let ((next limit)) - (dolist (property '(ebox-content ebox-content-idx)) - (let ((change (next-single-property-change - position property string limit))) - (when (and change (< change next)) - (setq next change)))) - next)) - -(defun ebox--next-string-content-owner-change (position string limit) - "Return next wrapper-owner property change after POSITION in STRING." - (let ((next limit)) - (dolist (property '(ebox-content-owner ebox-content-owners)) - (let ((change (next-single-property-change - position property string limit))) - (when (and change (< change next)) - (setq next change)))) - next)) - -(defun ebox--build-scroll-content-span-template (string) - "Build scroll content marker offsets from rendered STRING." - (let ((table (make-hash-table :test 'equal)) - (owner-line-indexes (make-hash-table :test 'equal)) - (limit (length string))) - (cl-labels - ((record (region-id index start end) - (when (and region-id - (gethash region-id ebox--scroll-global-state) - (integerp index) - (< start end)) - (puthash region-id - (cons (cons index (cons start end)) - (gethash region-id table)) - table))) - (owner-ids-at (position) - (let (ids) - (dolist (region-id - (get-text-property position 'ebox-content-owners string)) - (when (gethash region-id ebox--scroll-global-state) - (cl-pushnew region-id ids :test #'equal))) - (when-let ((region-id - (get-text-property - position 'ebox-content-owner string))) - (when (gethash region-id ebox--scroll-global-state) - (cl-pushnew region-id ids :test #'equal))) - ids)) - (line-owner-spans (start end) - (let ((position start) - spans) - (while (< position end) - (let ((next (ebox--next-string-content-owner-change - position string end))) - (dolist (region-id (owner-ids-at position)) - (let ((span (alist-get region-id spans nil nil #'equal))) - (if span - (setcdr span next) - (push (cons region-id (cons position next)) spans)))) - (setq position (max next (1+ position))))) - spans))) - (let ((position 0)) - (while (< position limit) - (let* ((region-id - (get-text-property position 'ebox-content string)) - (index - (get-text-property position 'ebox-content-idx string)) - (next - (ebox--next-string-content-span-change - position string limit))) - (record region-id index position next) - (setq position (max next (1+ position)))))) - (let ((line-start 0)) - (while (< line-start limit) - (let ((line-end (or (string-search "\n" string line-start) limit))) - (when (< line-start line-end) - (dolist (entry (line-owner-spans line-start line-end)) - (let* ((region-id (car entry)) - (span (cdr entry)) - (index (gethash region-id owner-line-indexes 0))) - (record region-id index (car span) (cdr span)) - (puthash region-id (1+ index) owner-line-indexes)))) - (setq line-start (if (< line-end limit) (1+ line-end) limit)))))) - (maphash (lambda (region-id entries) - (puthash region-id (nreverse entries) table)) - table) - table)) - -(defun ebox--install-buffer-scroll-content-span-template - (buffer template start &optional defer-window-region-set) - "Install scroll marker TEMPLATE in BUFFER relative to START. -When DEFER-WINDOW-REGION-SET is non-nil, postpone computing the visible -region set until the first scroll operation." - (when (and (buffer-live-p buffer) (hash-table-p template)) - (let ((region-set (ebox--buffer-region-id-set buffer)) - (seen (make-hash-table :test 'equal)) - scroll-region-ids) - (maphash - (lambda (region-id entries) - (when-let ((state (and (gethash region-id region-set) - (ebox--scroll-get-state region-id)))) - (puthash region-id t seen) - (ebox--scroll-store-content-marker-spans - buffer region-id state - (mapcar (lambda (entry) - (cons (car entry) - (cons (+ start (cadr entry)) - (+ start (cddr entry))))) - entries) - defer-window-region-set))) - template) - (maphash - (lambda (region-id state) - (when (eq (ebox--scroll-state-buffer state) buffer) - (push region-id scroll-region-ids) - (unless (gethash region-id seen) - (ebox--scroll-clear-content-markers state) - (setq state (plist-put state :content-span-markers nil)) - (setq state (plist-put state :content-window-region-id-set nil)) - (setq state (plist-put state :buffer nil)) - (puthash region-id state ebox--scroll-global-state)))) - ebox--scroll-global-state) - (when-let ((render-state (ebox--buffer-render-state buffer))) - (plist-put render-state :scroll-region-ids - (nreverse scroll-region-ids))) - t))) - -(defvar ebox--rendered-root-metadata-table - (make-hash-table :test 'eq :weakness 'key) - "Weak rendered-string map for root replacement index templates.") - -(defun ebox--string-foreign-region-in-range-p - (rendered start end region-id) - "Return non-nil when RENDERED START..END crosses REGION-ID ownership." - (let ((position start) - found) - (while (and (< position end) (not found)) - (unless (eq (aref rendered position) ?\n) - (let ((region-ids (ebox--string-region-ids-at rendered position)) - (owner-stack - (get-text-property - position 'ebox-content-owners rendered))) - (when (cl-some - (lambda (owner) - (and (not (equal owner region-id)) - (not (member region-id owner-stack)))) - region-ids) - (setq found t)))) - (setq position (1+ position))) - found)) - -(defun ebox--build-box-extent-template (rendered) - "Build safe point-min-based numeric box extents from RENDERED once." - (let ((table (make-hash-table :test 'equal)) - (interleaved (make-hash-table :test 'equal)) - (position 0) - (limit (length rendered))) - (while (< position limit) - (let* ((next (or (next-property-change position rendered limit) limit)) - (start (1+ position)) - (end (1+ next)) - (region-ids - (delete-dups - (append - (get-text-property position 'ebox-content-owners rendered) - (when-let ((owner (get-text-property - position 'ebox-content-owner rendered))) - (list owner)) - (when-let ((content (get-text-property - position 'ebox-content rendered))) - (list content)) - (delq nil - (mapcar - (lambda (entry) - (get-text-property position (cdr entry) rendered)) - ebox-region-types)))))) - (dolist (region-id region-ids) - (if-let ((span (gethash region-id table))) - (progn - (when (and (> start (cdr span)) - (ebox--string-foreign-region-in-range-p - rendered (1- (cdr span)) position region-id)) - (puthash region-id t interleaved)) - (setcar span (min (car span) start)) - (setcdr span (max (cdr span) end))) - (puthash region-id (cons start end) table))) - (setq position (max next (1+ position))))) - (maphash (lambda (region-id _) - (remhash region-id table)) - interleaved) - table)) - -(defun ebox--register-rendered-root-metadata (rendered prepared) - "Register Rust PREPARED root indexes for RENDERED. -The native tape uses ordinary hash tables. Add the private template marker -only after the tape has been validated, then update any stable lightweight -descriptor already retained by a prepared patch result." - (unless (and (stringp rendered) - (plist-get prepared :prepared-p) - (hash-table-p (plist-get prepared :role-span-template)) - (hash-table-p (plist-get prepared :box-extent-template)) - (hash-table-p - (plist-get prepared :scroll-content-span-template))) - (error "Invalid prepared rendered-root metadata")) - (let* ((role-template (plist-get prepared :role-span-template)) - (metadata - (or (gethash rendered ebox--rendered-root-metadata-table) - (list :prepared-p nil - :role-span-template nil - :box-extent-template nil - :scroll-content-span-template nil - :scroll-window-p nil)))) - (puthash ebox-buffer--role-span-table-template-key t role-template) - (plist-put metadata :role-span-template role-template) - (plist-put metadata :box-extent-template - (plist-get prepared :box-extent-template)) - (plist-put metadata :scroll-content-span-template - (plist-get prepared :scroll-content-span-template)) - (plist-put metadata :scroll-window-p - (and (plist-get prepared :scroll-window-p) t)) - (plist-put metadata :prepared-p t) - (puthash rendered metadata ebox--rendered-root-metadata-table) - metadata)) - -(defun ebox--rendered-root-metadata (rendered &optional lightweight-only) - "Return reusable root replacement metadata for RENDERED output. -When LIGHTWEIGHT-ONLY is non-nil, record only whether RENDERED contains a -scroll window. A later offscreen prewarm or an incompatible visible scroll -root may upgrade the same metadata object with complete role and scroll span -templates. This keeps ordinary cold-width publication off the full-output -property-scan path while preserving prepared indexes where they are needed." - (let ((metadata - (gethash rendered ebox--rendered-root-metadata-table))) - (unless metadata - (setq metadata - (list :prepared-p nil - :role-span-template nil - :box-extent-template nil - :scroll-content-span-template nil - :scroll-window-p - (not - (null - (text-property-not-all - 0 (length rendered) - 'ebox-scroll-window nil rendered))))) - (puthash rendered metadata ebox--rendered-root-metadata-table)) - (unless (or lightweight-only - (plist-get metadata :prepared-p)) - ;; Keep the plist object stable: patch results holding the lightweight - ;; descriptor observe this in-place upgrade without retaining RENDERED. - (plist-put metadata :role-span-template - (ebox-buffer--build-region-role-span-template rendered)) - (plist-put metadata :box-extent-template - (ebox--build-box-extent-template rendered)) - (plist-put metadata :scroll-content-span-template - (ebox--build-scroll-content-span-template rendered)) - (plist-put metadata :prepared-p t)) - metadata)) - -(defconst ebox--scroll-state-span-patch-keys - '(:content :text-align :vertical-align :wrap-mode :scroll-offset) - "Local keys that can patch cached scroll content lines directly.") - -(defun ebox--scroll-state-span-patch-keys-p (changed-keys) - "Return non-nil when CHANGED-KEYS are safe for scroll-state line patching." - (and changed-keys - (cl-every - (lambda (key) - (or (memq key ebox--scroll-state-span-patch-keys) - (and (memq :content changed-keys) - (memq key ebox--paint-style-signature-keys)))) - changed-keys))) - (defun ebox--scroll-state-owned-by-buffer-p (buffer state) "Return non-nil when scroll STATE belongs to BUFFER's runtime tree. An explicit buffer association is authoritative. A render-only pass can @@ -2856,193 +1552,14 @@ object. Unrelated ownerless and foreign-buffer states remain isolated." (cl-pushnew region-id region-ids :test #'equal)))))) (nreverse region-ids))) -(defun ebox--region-ids-visible-via-role-table-p (region-ids) - "Return non-nil when REGION-IDS have any visible role span in current buffer." - (when-let ((table (ebox-buffer--region-role-span-table))) - (catch 'visible - (dolist (region-id region-ids) - (dolist (entry ebox-region-types) - (when (cl-some - (lambda (span) - (ebox-buffer--current-region-role-span-p - span region-id (car entry))) - (gethash (ebox-buffer--region-role-key - region-id (car entry)) - table)) - (throw 'visible t)))) - nil))) - -(defun ebox--region-ids-role-table-status (table region-ids) - "Return `current', `stale', or nil for REGION-IDS entries in TABLE." - (let (indexed) - (catch 'status - (dolist (region-id region-ids) - (dolist (entry ebox-region-types) - (dolist (span (gethash (ebox-buffer--region-role-key - region-id (car entry)) - table)) - (setq indexed t) - (unless (ebox-buffer--current-region-role-span-p - span region-id (car entry)) - (throw 'status 'stale))))) - (and indexed 'current)))) - -(defun ebox--region-ids-visible-by-property-scan-p (region-ids) - "Return non-nil when REGION-IDS appear in current buffer text properties." - (catch 'visible - (dolist (region-id region-ids) - (dolist (entry ebox-region-types) - (goto-char (point-min)) - (when (text-property-search-forward (cdr entry) region-id t) - (throw 'visible t)))) - nil)) - (defun ebox--region-ids-visible-in-buffer-p (buffer region-ids) "Return non-nil when REGION-IDS currently have visible spans in BUFFER." (and buffer region-ids (buffer-live-p buffer) - (with-current-buffer buffer - (or (ebox--region-ids-visible-via-role-table-p region-ids) - (unless (ebox-buffer--region-role-span-table) - (save-excursion - (ebox--region-ids-visible-by-property-scan-p - region-ids))))))) - -(defun ebox--runtime-node-own-region-ids (node) - "Return NODE's own wrapper region ids without descending into children." - (when (listp node) - (pcase (plist-get node :ebox-type) - ('box - (when-let ((region-id (ebox-get node :region-id))) - (list region-id))) - ('flex - (when-let* ((box (plist-get node :box)) - (region-id (ebox-get box :region-id))) - (list region-id)))))) - -(defun ebox--scroll-state-node-visible-p (buffer state node) - "Return non-nil when NODE occupies STATE's current visible window. -When an empty fixed box has no own text-property span, walk only through its -local wrapper ancestors. Stop before the scroll owner's direct content -wrapper so one visible row cannot make every hidden descendant look visible." - (when-let* ((scroll-box (plist-get state :box)) - (scroll-node-id (ebox--ensure-node-id scroll-box)) - (node-id (plist-get node :node-id))) - (let ((candidate-id node-id) - (first t)) - (catch 'visible - (while candidate-id - (let* ((candidate - (ebox--buffer-runtime-node buffer candidate-id)) - (parent-id - (ebox--runtime-parent-id buffer candidate-id))) - (when (or first (not (equal parent-id scroll-node-id))) - (when-let ((region-ids - (ebox--runtime-node-own-region-ids candidate))) - (when (or - (ebox--region-ids-visible-in-buffer-p - buffer region-ids) - (ebox--scroll-state-region-visible-in-window-p - state region-ids) - (ebox--scroll-state-region-visible-in-content-range-p - state region-ids)) - (throw 'visible t)))) - (when (or (equal candidate-id scroll-node-id) - (equal parent-id scroll-node-id)) - (setq candidate-id nil)) - (when candidate-id - (setq candidate-id parent-id - first nil)))) - nil)))) - -(defun ebox--node-visible-in-buffer-p (buffer node) - "Return non-nil when NODE is represented in BUFFER's visible scroll windows." - (let ((region-ids (and node (ebox--node-all-region-ids node))) - containing-states) - (or (ebox--region-ids-visible-in-buffer-p buffer region-ids) - (progn - (maphash - (lambda (_scroll-region-id state) - (when (ebox--scroll-state-covers-node-p buffer state node) - (push state containing-states))) - ebox--scroll-global-state) - (and containing-states - (cl-every - (lambda (state) - (ebox--scroll-state-node-visible-p buffer state node)) - containing-states)))))) - -(defun ebox--ensure-visible-node-runtime-indexes - (buffer node &optional primary-region-id) - "Refresh missing or stale visible role spans for NODE in BUFFER. -A missing table uses a targeted property search before rebuilding the small -rendered viewport. A present table is authoritative unless requested entries -exist but no longer validate, in which case rebuild it without a property scan." - (when (and (buffer-live-p buffer) node) - (let ((region-ids (if primary-region-id - (list primary-region-id) - (ebox--node-all-region-ids node)))) - (with-current-buffer buffer - (let ((table (ebox-buffer--region-role-span-table))) - (if table - (when (eq (ebox--region-ids-role-table-status - table region-ids) - 'stale) - (ebox-buffer-refresh-region-role-spans buffer) - t) - (when (save-excursion - (ebox--region-ids-visible-by-property-scan-p region-ids)) - (ebox-buffer-refresh-region-role-spans buffer) - t))))))) - -(defun ebox--mark-lazy-scroll-covered-hidden-states - (buffer node region-ids) - "Mark lazy scroll states covering hidden NODE/REGION-IDS as dirty. -Return the number of marked states. This preserves deferred hidden-update -performance while preventing later prefix extension from replaying stale cached -lines." - (let ((count 0)) - (unless (ebox--node-visible-in-buffer-p buffer node) - (maphash - (lambda (scroll-region-id state) - (when (and (plist-get state :materialize-content-lines) - (ebox--scroll-state-covers-node-p buffer state node)) - (let* ((content-lines (plist-get state :content-lines)) - (cached-spans - (and content-lines - (ebox--scroll-hot-content-line-spans - state region-ids)))) - (when (and (not cached-spans) - (not (ebox--scroll-state-region-visible-in-window-p - state region-ids))) - (setq state (plist-put state :lazy-scroll-prefix-dirty t)) - (puthash scroll-region-id state ebox--scroll-global-state) - (cl-incf count))))) - ebox--scroll-global-state)) - count)) - -(defun ebox--try-lazy-scroll-deferred-patch - (buffer region-id node changed-keys) - "Record a hidden lazy scroll update without materializing its scroll source." - (when (and buffer node changed-keys) - (let ((region-ids (ebox--node-all-region-ids node))) - (when (> (ebox--mark-lazy-scroll-covered-hidden-states - buffer node region-ids) - 0) - (ebox--update-report - region-id 'span-patch - :dirty-count 1 - :patch-count 0 - :patch-ops nil - :owner-id (plist-get node :node-id) - :owner-ids (list (plist-get node :node-id)) - :owner-type (plist-get node :ebox-type) - :span-count 0 - :scroll-state-patch t - :lazy-scroll-deferred-patch t - :dirty-kinds '(geometry) - :dirty-keys changed-keys))))) + (cl-some (lambda (region-id) + (ebox-surface-region-mounts buffer region-id)) + region-ids))) (defun ebox--string-region-role-ids-at (string pos) "Return ebox role/id pairs at POS in STRING." @@ -3101,61 +1618,6 @@ lines." (setq pos (max next (1+ pos))))) (and seen patchable span-start span-end (cons span-start span-end)))) -(defun ebox--scroll-line-region-span-for-ids (line region-ids) - "Return a conservative owned span for REGION-IDS in LINE. -This lookup searches each role directly in the string property intervals. It -is optimized for validating a few known rendered-line hints and returns nil -when foreign direct content makes the combined span ambiguous." - (let ((limit (length line)) - (region-set (ebox--region-id-set region-ids)) - span-start span-end) - (dolist (region-id region-ids) - (dolist (entry ebox-region-types) - (let ((property (cdr entry)) - (pos 0) - found) - (while (and (< pos limit) - (setq found - (text-property-any - pos limit property region-id line))) - (let ((next (or (next-single-property-change - found property line limit) - limit))) - (setq span-start (if span-start - (min span-start found) - found) - span-end (if span-end - (max span-end next) - next) - pos (max next (1+ found)))))))) - (let ((pos 0)) - (while (< pos limit) - (let* ((owners (get-text-property - pos 'ebox-content-owners line)) - (next (or (next-single-property-change - pos 'ebox-content-owners line limit) - limit))) - (when (cl-some (lambda (region-id) - (member region-id owners)) - region-ids) - (setq span-start (if span-start (min span-start pos) pos) - span-end (if span-end (max span-end next) next))) - (setq pos (max next (1+ pos)))))) - (when (and span-start span-end) - (let ((pos span-start) - (compatible t)) - (while (and compatible (< pos span-end)) - (let* ((content-id - (get-text-property pos 'ebox-content line)) - (next (or (next-single-property-change - pos 'ebox-content line span-end) - span-end))) - (when (and content-id - (not (gethash content-id region-set))) - (setq compatible nil)) - (setq pos (max next (1+ pos))))) - (and compatible (cons span-start span-end)))))) - (defun ebox--scroll-line-region-present-p (line region-ids) "Return non-nil when LINE has any direct role for REGION-IDS." (let ((limit (length line))) @@ -3413,71 +1875,6 @@ fall back to the complete role scanner." :rendered-region-line-span-index-deferred))) state) -(defun ebox--scroll-state-ensure-region-line-span-index (state) - "Return STATE's content line index, building deferred indexes on demand." - (or (plist-get state :region-line-span-index) - (when (plist-get state :region-line-span-index-deferred) - (let ((index (ebox--scroll-build-region-line-span-index - (plist-get state :content-lines)))) - (plist-put state :region-line-span-index index) - (plist-put state :region-line-span-index-deferred nil) - index)))) - -(defun ebox--scroll-state-ensure-rendered-line-span-index (state) - "Return STATE's rendered line index, building deferred indexes on demand." - (or (plist-get state :rendered-region-line-span-index) - (when (plist-get state :rendered-region-line-span-index-deferred) - (let ((index (ebox--scroll-build-region-line-span-index - (plist-get state :rendered-content-lines)))) - (plist-put state :rendered-region-line-span-index index) - (plist-put state :rendered-region-line-span-index-deferred nil) - index)))) - -(defun ebox--scroll-state-patch-lines - (state content-lines content-spans - &optional rendered-lines _rendered-spans region-ids) - "Return STATE with patched lines and deferred derived span indexes. -CONTENT-SPANS and REGION-IDS retain bounded line hints for follow-up updates. -An existing full index is downgraded to line-number hints: its character spans -are never trusted after the patch, but every indexed region remains locatable." - (let ((line-hints - (when-let ((existing - (or (plist-get state :region-line-span-index) - (plist-get state :region-line-span-hints)))) - (copy-hash-table existing))) - (content-region-id-set (plist-get state :content-region-id-set)) - (ebox--defer-scroll-content-index t)) - (setq state - (ebox--scroll-state-set-lines state content-lines rendered-lines)) - (when (and content-spans region-ids) - (unless line-hints - (setq line-hints (make-hash-table :test 'equal))) - (dolist (region-id region-ids) - (puthash region-id (copy-tree content-spans) line-hints))) - (when line-hints - (setq state - (plist-put state :region-line-span-hints line-hints))) - ;; Runtime updates preserve region topology, so an equal-layout line patch - ;; cannot add or remove ids from the cached prefix. - (when content-region-id-set - (setq state - (plist-put state :content-region-id-set - content-region-id-set)))) - state) - -(defun ebox--scroll-state-line-spans-for-region-ids (state region-ids) - "Return cached content line spans for REGION-IDS in scroll STATE." - (if-let ((index (ebox--scroll-state-ensure-region-line-span-index state))) - (ebox--scroll-index-line-spans index region-ids) - (ebox--scroll-state-region-line-spans - (plist-get state :content-lines) region-ids))) - -(defun ebox--scroll-rendered-line-spans-for-region-ids (state region-ids) - "Return cached rendered-content line spans for REGION-IDS in scroll STATE." - (if-let ((index (ebox--scroll-state-ensure-rendered-line-span-index state))) - (ebox--scroll-index-line-spans index region-ids) - (when-let ((lines (plist-get state :rendered-content-lines))) - (ebox--scroll-state-region-line-spans lines region-ids)))) (defun ebox--scroll-hot-content-line-spans (state region-ids) "Return content spans for REGION-IDS without building a deferred full index. @@ -3531,25 +1928,6 @@ bound rendered-line inspection without scanning or indexing the whole prefix." (setq indices (cdr indices))))) (nreverse spans))) -(defun ebox--scroll-hot-rendered-line-spans - (state region-ids content-spans) - "Return rendered spans for REGION-IDS without building a deferred index. -Reuse a ready rendered index; otherwise inspect only lines named by -CONTENT-SPANS, whose line numbers are aligned with rendered scroll lines." - (if-let ((index (plist-get state :rendered-region-line-span-index))) - (ebox--scroll-index-line-spans index region-ids) - (when-let ((lines (plist-get state :rendered-content-lines))) - (if (cl-every - (lambda (entry) - (equal (cdr entry) - (ebox--scroll-line-region-span-for-ids - (nth (car entry) lines) region-ids))) - content-spans) - ;; The rendered copy can add outer wrapper ownership while retaining - ;; the exact target span. Reuse only after direct-role validation. - content-spans - (ebox--scroll-line-spans-at-index-hints - lines region-ids content-spans))))) (defun ebox--cancel-buffer-reflow-cache-prewarm (buffer) "Cancel pending predicted reflow-cache warming for BUFFER." @@ -3776,10 +2154,7 @@ An unconfirmed ordinary-width prediction is never treated as the live tree." (cl-letf (((symbol-function 'ebox--scroll-schedule-idle-prefetch) (lambda (&rest _) nil))) (ebox--with-render-gc - (let ((rendered - (ebox--render-with-cache - (plist-get scratch :tree) t))) - (ebox--rendered-root-metadata rendered))))))) + (ebox--render-with-cache (plist-get scratch :tree) t)))))) (defun ebox--prewarm-buffer-render-cache-at-context (buffer state revision source-width viewport-width viewport-height) @@ -3994,7 +2369,7 @@ predictions are skipped." "Return a new runtime prewarm job for render STATE." (list :render-state state :runtime-revision (or (plist-get state :runtime-revision) 0) - :phase 'role-index + :phase 'snapshots :snapshot-stack (when-let ((root (plist-get state :root-node))) (list (vector root :uninitialized t t))) @@ -4005,22 +2380,6 @@ predictions are skipped." :scroll-sources nil :scroll-task nil)) -(defun ebox--runtime-prewarm-role-index-slice (buffer job) - "Materialize BUFFER's prepared publication indexes before interactions." - (when (buffer-live-p buffer) - (with-current-buffer buffer - (when-let ((table (ebox-buffer--region-role-span-table))) - (unless (ebox-buffer--marker-role-span-table-p table) - (ebox-buffer-materialize-region-role-spans buffer))) - (unless (or (ebox--materialize-box-extent-template buffer) - (when-let* ((root-id (ebox--buffer-root-node-id buffer)) - (extents (gethash root-id ebox--box-extents))) - (and (eq (marker-buffer (car extents)) buffer) - (eq (marker-buffer (cdr extents)) buffer)))) - (ebox--clear-buffer-extents buffer) - (ebox--register-box-extents-in-range (point-min) (point-max))))) - (plist-put job :phase 'snapshots)) - (defun ebox--runtime-prewarm-schedule-timer (buffer &optional delay) "Schedule BUFFER's next runtime prewarm slice after DELAY." (when (and (buffer-live-p buffer) @@ -4222,8 +2581,6 @@ Return non-nil while more work remains." (plist-get job :runtime-revision))) (ebox--cancel-buffer-runtime-prewarm buffer) (pcase (plist-get job :phase) - ('role-index - (ebox--runtime-prewarm-role-index-slice buffer job)) ('snapshots (ebox--runtime-prewarm-snapshot-slice buffer job)) ('scroll @@ -4266,938 +2623,21 @@ Return non-nil while more work remains." (when (not completed) (error "Runtime prewarm did not converge for %S" buffer)))) -(defun ebox--refresh-layout-snapshots-for-node (buffer node) - "Refresh lightweight snapshots for NODE's runtime subtree in BUFFER." - (ebox--invalidate-buffer-layout-snapshot-details buffer) - (let ((ebox--node-region-ids-cache (make-hash-table :test 'eq))) - (ebox--capture-layout-snapshots buffer node))) - -(defun ebox--scroll-line-with-preserved-wrapper-owner - (line span replacement rendered-owner-set) - "Return REPLACEMENT preserving wrapper context from LINE SPAN. -RENDERED-OWNER-SET identifies owners supplied by the complete fresh render." - (let ((owners (or (get-text-property (car span) 'ebox-content-owners line) - (when-let ((owner (get-text-property - (car span) - 'ebox-content-owner - line))) - (list owner))))) - (if owners - (ebox--string-with-preserved-wrapper-context - replacement owners rendered-owner-set) - replacement))) - -(defun ebox--scroll-pad-line-to-slot-width (line width) - "Return LINE padded to visual slot WIDTH, or nil when LINE is too wide." - (let* ((line-width (ebox--string-pixel-width line)) - (extra (- width line-width))) - (when (>= extra 0) - (concat line (ebox-pixel-space extra))))) - -(defun ebox--scroll-patch-lines-for-region - (lines region-ids rendered &optional spans rendered-owner-set) - "Return LINES with REGION-IDS replaced by RENDERED, or nil on mismatch. -RENDERED-OWNER-SET reuses an existing content-owner scan when supplied." - (let* ((spans (or spans - (ebox--scroll-state-region-line-spans lines region-ids))) - (rendered-lines (ebox-string-lines rendered)) - (rendered-owner-set - (or rendered-owner-set - (ebox--string-content-owner-set rendered)))) - (when (and spans - (<= (length rendered-lines) (length spans))) - (catch 'failed - (let ((result (copy-sequence lines))) - (cl-loop for entry in spans - for replacement = (or (pop rendered-lines) - (ebox-pixel-space 0)) - do - (let* ((idx (car entry)) - (span (cdr entry)) - (line (nth idx result)) - (old-slot (substring line (car span) (cdr span))) - (slot-width (ebox--string-pixel-width old-slot)) - (padded (ebox--scroll-pad-line-to-slot-width - replacement slot-width))) - (unless padded - (throw 'failed nil)) - (setq padded - (ebox--scroll-line-with-preserved-wrapper-owner - line span padded rendered-owner-set)) - (setf (nth idx result) - (concat (substring line 0 (car span)) - padded - (substring line (cdr span)))))) - result))))) - -(defun ebox--scroll-patch-rendered-lines-for-region - (state region-ids rendered) - "Return rendered content lines patched for REGION-IDS, or nil." - (when-let* ((rendered-lines (plist-get state :rendered-content-lines)) - (rendered-spans - (ebox--scroll-rendered-line-spans-for-region-ids - state region-ids))) - (ebox--scroll-patch-lines-for-region - rendered-lines region-ids rendered rendered-spans))) - -(defun ebox--scroll-state-visible-range (state) - "Return visible content index range for scroll STATE as (START . END)." - (let* ((content-lines (plist-get state :content-lines)) - (content-height (or (plist-get state :content-height) 0)) - (max-offset (max 0 (- (length content-lines) content-height))) - (scroll-offset (max 0 (min max-offset - (or (plist-get state :scroll-offset) - 0))))) - (cons scroll-offset - (min (+ scroll-offset content-height) - (length content-lines))))) - -(defun ebox--scroll-state-spans-visible-p (state spans) - "Return non-nil when any SPANS entry falls inside STATE's visible range." - (pcase-let ((`(,start . ,end) (ebox--scroll-state-visible-range state))) - (cl-some (lambda (entry) - (let ((idx (car entry))) - (and (>= idx start) (< idx end)))) - spans))) - -(defun ebox--scroll-apply-visible-node-span-patch - (buffer node changed-keys region-ids rendered rendered-owner-set) - "Patch visible NODE directly while its cached scroll lines are synchronized. -Return backend patch metadata on success. The ordinary span backend preserves -the current parent slot and wrapper paint, avoiding a fixed-height scroll -viewport replacement when only NODE's proven-stable visible spans changed. -RENDERED and RENDERED-OWNER-SET reuse the scroll cache patch's fresh render." - (let* ((node-id (plist-get node :node-id)) - (dirty (list :node-id node-id - :dirty-kind 'geometry - :changed-keys changed-keys - :region-ids region-ids)) - (op (list :op 'span-patch :owner-id node-id :dirty dirty)) - (result (ebox-buffer-apply-span-patch - buffer op rendered rendered-owner-set))) - (when result - (ebox--refresh-runtime-indexes-for-patch-results - buffer (list result) 'span-patch) - (ebox--refresh-text-patch-layout-snapshots buffer (list result)) - result))) - -(defun ebox--try-scroll-state-span-patch - (buffer region-id node changed-keys - &optional skip-materialize allow-nonlocal-keys) - "Patch cached scroll state lines for REGION-ID/NODE in BUFFER. -Return an update report when all containing scroll states were patched. -When ALLOW-NONLOCAL-KEYS is non-nil, the rendered replacement still must fit -the cached line slots, but CHANGED-KEYS need not belong to the visible-buffer -span-patch whitelist. This is used only for semantically hidden nodes." - (when (and buffer node - changed-keys - (or allow-nonlocal-keys - (ebox--scroll-state-span-patch-keys-p changed-keys))) - (let* ((region-ids (ebox--node-all-region-ids node)) - (containing-scroll-state-count - (ebox--node-containing-scroll-state-count buffer node)) - rendered - rendered-owner-set - patched visible-patched span-count - rendered-range-patched - index-used - visible-buffer-patched - visible-direct-patched - patches - (ok - (catch 'scroll-state-patch-failed - (maphash - (lambda (scroll-region-id state) - (when (and - (ebox--scroll-state-covers-node-p buffer state node) - ;; A scroll container's own offset is state, not one - ;; of its cached descendant lines. Treating the whole - ;; container as a line replacement can publish the new - ;; viewport while leaving :scroll-offset stale. - (not (and (equal changed-keys '(:scroll-offset)) - (equal scroll-region-id region-id)))) - (let* ((content-lines (plist-get state :content-lines)) - (content-spans - (and content-lines - (progn - (when (plist-get - state :region-line-span-index) - (setq index-used t)) - (ebox--scroll-hot-content-line-spans - state region-ids))))) - (when (and (not skip-materialize) - (not content-spans) - (not (ebox--node-visible-in-buffer-p - buffer node)) - (plist-get state :materialize-content-lines)) - (setq state - (ebox--scroll-state-materialize-lines - scroll-region-id state)) - (setq content-lines (plist-get state :content-lines)) - (setq content-spans - (and content-lines - (progn - (when (plist-get - state :region-line-span-index) - (setq index-used t)) - (ebox--scroll-hot-content-line-spans - state region-ids))))) - (when content-spans - (unless rendered - (setq rendered - (ebox--with-buffer-render-context buffer - (or - (when-let ((snapshot - (and - (ebox--node-visible-in-buffer-p - buffer node) - (ebox--direct-flex-parent-p - buffer - (plist-get node :node-id)) - (ebox--ensure-layout-snapshot-spans - buffer - (plist-get node :node-id))))) - (ebox--render-node-in-current-flex-slot - buffer - (plist-get node :node-id) - snapshot changed-keys)) - (ebox-render node)))) - (setq rendered-owner-set - (ebox--string-content-owner-set rendered))) - (let* ((new-content-lines - (ebox--scroll-patch-lines-for-region - content-lines region-ids rendered - content-spans rendered-owner-set)) - (rendered-lines - (plist-get state :rendered-content-lines)) - (rendered-spans - (and rendered-lines - (progn - (when (plist-get - state - :rendered-region-line-span-index) - (setq index-used t)) - (ebox--scroll-hot-rendered-line-spans - state region-ids content-spans)))) - (new-rendered-lines - (if rendered-lines - (ebox--scroll-patch-lines-for-region - rendered-lines region-ids rendered - rendered-spans rendered-owner-set) - nil))) - (unless (and new-content-lines - (or (not rendered-lines) - new-rendered-lines)) - (throw 'scroll-state-patch-failed nil)) - (setq patched t) - (when (and rendered-lines new-rendered-lines) - (setq rendered-range-patched t)) - (setq span-count (+ (or span-count 0) - (length content-spans))) - (push (list :scroll-region-id scroll-region-id - :state state - :content-lines new-content-lines - :rendered-lines new-rendered-lines - :has-rendered-lines rendered-lines - :content-spans content-spans - :rendered-spans rendered-spans) - patches)))))) - ebox--scroll-global-state) - t))) - (when (and ok patched) - (catch 'scroll-state-apply-failed - (dolist (patch (nreverse patches)) - (let ((state (plist-get patch :state)) - (scroll-region-id (plist-get patch :scroll-region-id)) - (new-content-lines (plist-get patch :content-lines))) - (setq state - (ebox--scroll-state-patch-lines - state new-content-lines - (plist-get patch :content-spans) - (and (plist-get patch :has-rendered-lines) - (plist-get patch :rendered-lines)) - (plist-get patch :rendered-spans) - region-ids)) - (puthash scroll-region-id state ebox--scroll-global-state) - (when (ebox--scroll-state-spans-visible-p - state (plist-get patch :content-spans)) - (setq visible-patched t) - (unless visible-buffer-patched - (setq visible-direct-patched - (ebox--scroll-apply-visible-node-span-patch - buffer node changed-keys region-ids - rendered rendered-owner-set)) - (setq visible-buffer-patched - (or visible-direct-patched - (and - (= containing-scroll-state-count 1) - (let* ((range - (ebox--scroll-state-visible-range state)) - (visible-lines - (seq-subseq new-content-lines - (car range) (cdr range)))) - (with-current-buffer buffer - (ebox--scroll-update-content - scroll-region-id visible-lines)))))) - (unless visible-buffer-patched - (throw 'scroll-state-apply-failed nil)))))) - (when visible-patched - (unless visible-direct-patched - (ebox--refresh-layout-snapshots-for-node buffer node))) - (ebox--update-report - region-id 'span-patch - :dirty-count 1 - :patch-count 1 - :patch-ops '(span-patch) - :owner-id (plist-get node :node-id) - :owner-ids (list (plist-get node :node-id)) - :owner-type (plist-get node :ebox-type) - :span-count span-count - :scroll-state-patch t - :scroll-state-index index-used - :scroll-rendered-range-patch rendered-range-patched - :dirty-kinds '(geometry) - :dirty-keys changed-keys)))))) - -(defun ebox--try-hidden-scroll-state-span-patch - (buffer region-id node changed-keys) - "Patch cached scroll lines for hidden NODE without touching the live buffer. -Complete scroll states still contain offscreen formatted lines, so safe - slot-compatible geometry changes should update those lines directly instead -of falling through to a live owner rerender at stale viewport coordinates." - (when (and buffer node changed-keys) - (when (and (not (ebox--node-visible-in-buffer-p buffer node)) - (= (ebox--node-containing-scroll-state-count buffer node) - 1)) - (ebox--try-scroll-state-span-patch - buffer region-id node changed-keys t t)))) - -(defun ebox--node-containing-scroll-state-count (buffer node) - "Return the number of BUFFER scroll states that contain NODE." - (let ((count 0)) - (maphash - (lambda (_scroll-region-id state) - (when (ebox--scroll-state-covers-node-p buffer state node) - (setq count (1+ count)))) - ebox--scroll-global-state) - count)) - -(defun ebox--try-hidden-scroll-root-fallback - (buffer region-id node changed-keys) - "Rerender BUFFER root when a hidden scroll cache patch cannot fit. -This correctness fallback preserves the current scroll offset and never treats -offscreen owner coordinates as live patch spans." - (let ((region-ids (and node (ebox--node-all-region-ids node)))) - (when (and buffer node region-ids - (not (ebox--node-visible-in-buffer-p buffer node)) - (> (ebox--node-containing-scroll-state-count buffer node) 0)) - (ebox-buffer-apply-root-rerender buffer) - (ebox--update-report - region-id 'root-rerender - :dirty-count 1 - :patch-count 0 - :patch-ops nil - :owner-id (ebox--buffer-root-node-id buffer) - :owner-ids (list (ebox--buffer-root-node-id buffer)) - :root-rerender t - :scroll-state-patch t - :dirty-kinds '(geometry) - :dirty-keys changed-keys)))) - -(defun ebox--sync-scroll-state-lines-for-node-fallback - (buffer region-id node changed-keys report) - "Synchronize cached scroll lines for updated NODE in BUFFER by rerendering. -Ordinary incremental patching mutates the visible buffer. Scroll containers -also keep their own cached content lines, so those lines must be updated or a -later scroll-window replacement can replay stale content." - (when (and buffer node changed-keys) - (let* ((region-ids (ebox--node-all-region-ids node)) - targets - rendered - patched-count - refreshed-count - deferred-count - failed-count - span-count - marker-refresh-count - (visible-node-p - (ebox--node-visible-in-buffer-p buffer node))) - (maphash - (lambda (scroll-region-id state) - (when (and - (ebox--scroll-state-covers-node-p buffer state node) - ;; The dedicated scroll-offset transaction has already - ;; committed this container's own state/window. Only outer - ;; containing scroll caches still need the rendered node. - (not (and (equal changed-keys '(:scroll-offset)) - (equal scroll-region-id region-id)))) - (let* ((content-lines (plist-get state :content-lines)) - (content-spans - (and content-lines - (ebox--scroll-hot-content-line-spans - state region-ids)))) - (when (or content-spans visible-node-p) - (push (list :scroll-region-id scroll-region-id - :state state - :content-lines content-lines - :content-spans content-spans - :rendered-lines - (plist-get state :rendered-content-lines)) - targets))))) - ebox--scroll-global-state) - (when targets - (setq rendered - (ebox--with-buffer-render-context buffer - (or - (when-let ((snapshot - (and - visible-node-p - (ebox--direct-flex-parent-p - buffer (plist-get node :node-id)) - (ebox--ensure-layout-snapshot-spans - buffer (plist-get node :node-id))))) - (ebox--render-node-in-current-flex-slot - buffer (plist-get node :node-id) snapshot changed-keys)) - (ebox-render node)))) - (dolist (target targets) - (let* ((scroll-region-id (plist-get target :scroll-region-id)) - (state (plist-get target :state)) - (content-lines (plist-get target :content-lines)) - (content-spans (plist-get target :content-spans)) - (new-content-lines - (ebox--scroll-patch-lines-for-region - content-lines region-ids rendered content-spans)) - (rendered-lines (plist-get target :rendered-lines)) - (rendered-spans - (and new-content-lines rendered-lines - (ebox--scroll-hot-rendered-line-spans - state region-ids content-spans))) - (new-rendered-lines - (if (and new-content-lines rendered-lines) - (and rendered-spans - (ebox--scroll-patch-lines-for-region - rendered-lines region-ids rendered - rendered-spans)) - nil))) - (when (and new-content-lines - (or (not rendered-lines) - new-rendered-lines)) - (setq state - (ebox--scroll-state-patch-lines - state new-content-lines content-spans - (and rendered-lines new-rendered-lines) - rendered-spans region-ids)) - (setq state (plist-put state :buffer buffer)) - (puthash scroll-region-id state ebox--scroll-global-state) - (plist-put target :cache-synchronized t) - (setq patched-count (1+ (or patched-count 0))) - (setq span-count (+ (or span-count 0) - (length content-spans)))) - (unless (and new-content-lines - (or (not rendered-lines) - new-rendered-lines)) - ;; A geometry change can outgrow its cached line slot. Lazy - ;; prefixes can be rebuilt from the current runtime tree. A - ;; visible update must publish that cache generation before it - ;; returns; otherwise redisplay can expose a buffer/state split - ;; until an unrelated annotation or scroll repairs the window. - (if (plist-get state :render-content-prefix) - (progn - (setq state - (plist-put state :lazy-scroll-prefix-dirty t)) - (puthash scroll-region-id state - ebox--scroll-global-state) - (let* ((required-lines - (+ (or (plist-get state :scroll-offset) 0) - (or (plist-get state :content-height) 0))) - (rebuilt - (ebox--scroll-state-ensure-prefix-lines - scroll-region-id state required-lines t))) - (if (plist-get rebuilt :lazy-scroll-prefix-dirty) - (progn - (plist-put target :deferred t) - (setq deferred-count - (1+ (or deferred-count 0)))) - (plist-put target :cache-synchronized t)))) - ;; A complete window has no prefix producer left to - ;; rebuild from. The publication itself was verified - ;; by footprint proofs and only touched visible rows, - ;; so adopting the published window back into the - ;; cache is exact; marker verification below still - ;; guards the result. - (let ((adopted - (ebox--scroll-state-adopt-buffer-window-lines - buffer scroll-region-id state))) - (if adopted - (progn - (setq adopted - (plist-put adopted :buffer buffer)) - (puthash scroll-region-id adopted - ebox--scroll-global-state) - (plist-put target :cache-synchronized t) - (setq patched-count - (1+ (or patched-count 0)))) - (plist-put target :failed t) - (setq failed-count - (1+ (or failed-count 0)))))))))) - ;; Commit one marker generation after every cache mutation. Marker-only - ;; publication never rewrites visible text, so nested scroll windows - ;; cannot invalidate each other as they did with per-state zero-delta - ;; scrolling. - (when targets - (ebox--refresh-buffer-scroll-content-markers buffer) - (dolist (target targets) - (when (plist-get target :cache-synchronized) - (let* ((scroll-region-id (plist-get target :scroll-region-id)) - (state (ebox--scroll-get-state scroll-region-id))) - (if (and state - (ebox--scroll-state-window-markers-healthy-p - buffer scroll-region-id state)) - (progn - (when (plist-get - state :lazy-scroll-window-refresh-required) - (setq refreshed-count - (1+ (or refreshed-count 0)))) - (setq state - (ebox--plist-remove - state :lazy-scroll-window-refresh-required)) - (puthash scroll-region-id state - ebox--scroll-global-state) - (setq marker-refresh-count - (1+ (or marker-refresh-count 0)))) - (unless (plist-get target :failed) - ;; In-slot patching cannot represent structural line - ;; changes (for example :overflow transitions adding - ;; or removing unowned visible-overflow lines), and - ;; the divergence only surfaces at this verification. - ;; The published window is footprint-verified truth, - ;; so first adopt it back into the visible cache - ;; slice; only when adoption cannot apply rebuild the - ;; prefix from the current runtime exactly like the - ;; outgrown-slot branch. Publishing a stale cache - ;; that carries no repair marker is never an option. - (cl-flet ((verify () - (ebox--refresh-buffer-scroll-content-markers - buffer) - (when-let ((current - (ebox--scroll-get-state - scroll-region-id))) - (ebox--scroll-state-window-markers-healthy-p - buffer scroll-region-id current)))) - (let* ((adopted - (when-let ((current - (ebox--scroll-get-state - scroll-region-id))) - (ebox--scroll-state-adopt-buffer-window-lines - buffer scroll-region-id current))) - (healthy - (when adopted - (puthash scroll-region-id - (plist-put adopted :buffer buffer) - ebox--scroll-global-state) - (verify))) - (rebuilt-healthy - (unless healthy - (when (plist-get - state :render-content-prefix) - (setq state - (plist-put - state :lazy-scroll-prefix-dirty - t)) - (puthash scroll-region-id state - ebox--scroll-global-state) - (let* ((required-lines - (+ (or (plist-get - state :scroll-offset) - 0) - (or (plist-get - state :content-height) - 0))) - (rebuilt - (ebox--scroll-state-ensure-prefix-lines - scroll-region-id state - required-lines t))) - (and rebuilt - (not (plist-get - rebuilt - :lazy-scroll-prefix-dirty)) - (verify)))))) - (healthy (or healthy rebuilt-healthy))) - (if healthy - (progn - (when-let ((current (ebox--scroll-get-state - scroll-region-id))) - (puthash scroll-region-id - (ebox--plist-remove - current - :lazy-scroll-window-refresh-required) - ebox--scroll-global-state)) - (setq refreshed-count - (1+ (or refreshed-count 0)) - marker-refresh-count - (1+ (or marker-refresh-count 0)))) - (plist-put target :failed t) - (setq failed-count - (1+ (or failed-count 0)))))))))))) - (if (or patched-count refreshed-count deferred-count failed-count) - (append report - (list :scroll-state-sync - (and (not (null (or patched-count - refreshed-count))) - (= (or deferred-count 0) 0) - (= (or failed-count 0) 0)) - :scroll-state-sync-count patched-count - :scroll-state-sync-span-count - (or span-count 0) - :scroll-state-sync-refreshed - (not (null refreshed-count)) - :scroll-state-sync-refreshed-count - (or refreshed-count 0) - :scroll-state-sync-deferred - (not (null deferred-count)) - :scroll-state-sync-deferred-count - (or deferred-count 0) - :scroll-state-sync-failed-count - (or failed-count 0) - :scroll-state-window-marker-refresh-count - (or marker-refresh-count 0) - :scroll-state-window-markers-invalidated - (and (not (null patched-count)) - (null marker-refresh-count)))) - report)))) - -(defun ebox--scroll-line-role-spans (line role region-set) - "Return spans in LINE where ROLE belongs to REGION-SET." - (let* ((property (ebox-buffer--role-property role)) - (limit (length line)) - (pos 0) - spans) - (while (< pos limit) - (let* ((next (or (next-single-property-change pos property line limit) - limit)) - (region-id (get-text-property pos property line))) - (when (and region-id (gethash region-id region-set)) - (push (cons pos next) spans)) - (setq pos next))) - (nreverse spans))) - -(defun ebox--scroll-patch-face-key-in-line-span (line start end key value) - "Patch LINE face KEY to VALUE in START..END." - (let ((pos start)) - (while (< pos end) - (let* ((next (or (next-single-property-change pos 'face line end) - end)) - (face (get-text-property pos 'face line))) - (put-text-property pos next 'face - (ebox-buffer--face-put face key value) - line) - (setq pos next))))) - -(defun ebox--scroll-patch-horizontal-border-face-in-line-span - (line start end key value) - "Patch horizontal border face KEY to VALUE in LINE START..END." - (if (eq value ebox-buffer--remove-face-key) - (ebox--scroll-patch-face-key-in-line-span line start end key value) - (add-face-text-property start end (list key value) nil line))) - -(defun ebox--scroll-paint-lines-for-region - (lines region-ids surfaces &optional candidate-spans) - "Return LINES with paint SURFACES patched for REGION-IDS. -CANDIDATE-SPANS limits scanning to known lines owned by REGION-IDS. Return a -plist with :lines and possibly empty :spans, or nil when a required inferred -surface is absent." - (let* ((region-set (ebox--region-id-set region-ids)) - (result (copy-sequence lines)) - (line-indices (if candidate-spans - (delete-dups - (mapcar #'car (copy-sequence candidate-spans))) - (number-sequence 0 (1- (length lines))))) - changed-spans - failed) - (catch 'failed - (dolist (spec surfaces) - (let ((role (plist-get spec :role)) - (face-key (plist-get spec :face-key)) - (face-value (plist-get spec :face-value)) - (missing (plist-get spec :missing)) - (remove-role (plist-get spec :remove-role)) - (surface-patched nil)) - (dolist (idx line-indices) - (when-let ((line (nth idx result))) - (let ((line-copy nil)) - (dolist (span (ebox--scroll-line-role-spans - line role region-set)) - (unless line-copy - (setq line-copy (copy-sequence line)) - (setf (nth idx result) line-copy)) - (if (memq role '(bt bb)) - (ebox--scroll-patch-horizontal-border-face-in-line-span - line-copy (car span) (cdr span) face-key face-value) - (ebox--scroll-patch-face-key-in-line-span - line-copy (car span) (cdr span) face-key face-value)) - (when remove-role - (remove-text-properties - (car span) (cdr span) - (list (ebox-buffer--role-property role) nil) - line-copy)) - (push (cons idx span) changed-spans) - (setq surface-patched t))))) - (when (and (not surface-patched) - (eq missing 'infer)) - (setq failed t) - (throw 'failed nil)))) - (unless failed - (list :lines result :spans (nreverse changed-spans)))))) - -(defun ebox--scroll-state-adopt-buffer-window-lines (buffer region-id state) - "Adopt BUFFER's live window text into STATE's visible rendered cache. -Buffer and cache paint patches run through different patchers, so -visually identical lines can diverge in raw representation. The -published buffer is the display truth: copying its window lines back -over the visible slice of `:rendered-content-lines' restores exact -representational identity, which keeps later window verification on -the cheap raw-equality path and keeps the settled cache replaying -exactly what is on screen. Only chrome-free windows adopt; decorated -windows keep their semantically patched lines. Return the updated -state, or nil when no adoption happened." - (when-let* ((box (plist-get state :box)) - ((ebox--scroll-chrome-free-rendered-window-p box)) - (rendered-lines (plist-get state :rendered-content-lines)) - (height (plist-get state :content-height)) - ((integerp height)) - ((> height 0)) - (extents (ebox--scroll-state-window-extents region-id state)) - (start (car extents)) - (end (cdr extents)) - ((markerp start)) - ((markerp end)) - ((eq (marker-buffer start) buffer)) - ((eq (marker-buffer end) buffer)) - (start-pos (marker-position start)) - (end-pos (marker-position end)) - ((< start-pos end-pos))) - (with-current-buffer buffer - (when (= (count-lines start-pos end-pos) height) - (let* ((window-lines - (ebox-string-lines - (buffer-substring start-pos end-pos))) - (offset (or (plist-get state :scroll-offset) 0))) - (when (and (= (length window-lines) height) - (<= (+ offset height) (length rendered-lines))) - (let ((copy (copy-sequence rendered-lines))) - (cl-loop for line in window-lines - for index from offset - do (setf (nth index copy) line)) - (setq state - (plist-put state :rendered-content-lines copy))) - (when-let* ((content-lines (plist-get state :content-lines)) - ((<= (+ offset height) (length content-lines)))) - (let ((copy (copy-sequence content-lines))) - (cl-loop for line in window-lines - for index from offset - do (setf (nth index copy) line)) - (setq state (plist-put state :content-lines copy)))) - state)))))) - -(defun ebox--sync-scroll-state-paint-lines-for-node - (buffer node changed-keys report) - "Synchronize scroll cached lines for NODE paint-only CHANGED-KEYS. -This mirrors buffer paint patches by editing cached line text properties rather -than rerendering NODE, keeping GUI dynamic updates bounded." - (when-let* ((surfaces (and (cl-every - (lambda (key) - (memq key ebox--paint-style-signature-keys)) - changed-keys) - (ebox--paint-text-property-surfaces - node changed-keys))) - (_ (ebox-buffer--paint-surfaces-safe-for-node-p - node surfaces))) - (let* ((region-ids (ebox--node-all-region-ids node)) - (index-stable - (cl-every (lambda (spec) - (not (plist-get spec :remove-role))) - surfaces)) - patched-region-ids - patched-count - span-count) - (catch 'failed - (maphash - (lambda (scroll-region-id state) - (let ((covering-lazy-state - (and (plist-get state :materialize-content-lines) - (not (plist-get state :content-lines-complete-p)) - (ebox--scroll-state-covers-node-p - buffer state node)))) - (let* ((content-lines (plist-get state :content-lines)) - (content-spans - (and content-lines - (ebox--scroll-hot-content-line-spans - state region-ids)))) - (if (not content-spans) - (when covering-lazy-state - (setq state - (plist-put state :lazy-scroll-prefix-dirty t)) - (puthash scroll-region-id state - ebox--scroll-global-state)) - (let* ((content-patch - (ebox--scroll-paint-lines-for-region - content-lines region-ids surfaces content-spans)) - (rendered-lines - (plist-get state :rendered-content-lines)) - (rendered-spans - (and rendered-lines - (ebox--scroll-hot-rendered-line-spans - state region-ids content-spans))) - (rendered-patch - (and rendered-lines - rendered-spans - (ebox--scroll-paint-lines-for-region - rendered-lines region-ids surfaces - rendered-spans)))) - (if (not (and content-patch - (or (not rendered-lines) - rendered-patch))) - ;; A surface the cached lines cannot express - ;; yet (for example enabling a border whose - ;; role span does not exist in the cache) - ;; already patched the published buffer - ;; through the inferring buffer patcher. The - ;; buffer is the display truth and the paint - ;; only touched rows inside the visible - ;; window, so adopting the window back into - ;; the cache is exact; only when even - ;; adoption is impossible does the fallback - ;; take over. - (let ((adopted - (ebox--scroll-state-adopt-buffer-window-lines - buffer scroll-region-id state))) - (unless adopted - (throw 'failed nil)) - (setq adopted - (plist-put adopted :buffer buffer)) - (puthash scroll-region-id adopted - ebox--scroll-global-state) - (cl-pushnew scroll-region-id - patched-region-ids - :test #'equal) - (setq patched-count - (1+ (or patched-count 0)))) - (setq state - (if index-stable - (let ((state - (plist-put - state :content-lines - (plist-get content-patch - :lines)))) - (if rendered-lines - (plist-put - state :rendered-content-lines - (plist-get rendered-patch - :lines)) - state)) - (ebox--scroll-state-patch-lines - state - (plist-get content-patch :lines) - (plist-get content-patch :spans) - (and rendered-lines - (plist-get rendered-patch :lines)) - (and rendered-lines - (plist-get rendered-patch :spans)) - region-ids))) - (setq state (plist-put state :buffer buffer)) - ;; Restore exact representational identity - ;; between the published window and its visible - ;; cache slice so the settled cache replays - ;; exactly what is on screen and verification - ;; stays on the raw fast path. - (setq state - (or (ebox--scroll-state-adopt-buffer-window-lines - buffer scroll-region-id state) - state)) - (puthash scroll-region-id state - ebox--scroll-global-state) - (cl-pushnew scroll-region-id patched-region-ids - :test #'equal) - (setq patched-count (1+ (or patched-count 0))) - (setq span-count - (+ (or span-count 0) - (length - (plist-get content-patch - :spans)))))))))) - ebox--scroll-global-state) - (when patched-count - ;; Pure property patches move no buffer text, so existing - ;; marker windows stay anchored to the same positions and the - ;; full-buffer marker rebuild is normally unnecessary. Verify - ;; first; rebuild one marker generation and re-verify only - ;; when a patched window no longer proves healthy (for - ;; example after a role-removing patch replaced cached line - ;; structure). Existing markers are never cleared without a - ;; replacement, so the live window cannot detach. - (let ((refreshed 0)) - (cl-flet ((windows-healthy-p () - (cl-every - (lambda (scroll-region-id) - (when-let ((state - (ebox--scroll-get-state - scroll-region-id))) - (ebox--scroll-state-window-markers-healthy-p - buffer scroll-region-id state))) - patched-region-ids))) - (unless (windows-healthy-p) - (ebox--refresh-buffer-scroll-content-markers buffer) - (setq refreshed (length patched-region-ids)) - (unless (windows-healthy-p) - (throw 'failed nil)))) - (setq report - (append report - (list :scroll-state-paint-sync t - :scroll-state-sync-count patched-count - :scroll-state-sync-span-count - (or span-count 0) - :scroll-state-index-preserved index-stable - :scroll-state-window-marker-refresh-count - refreshed - :scroll-state-window-markers-invalidated - nil))))) - report)))) - -(defun ebox--sync-scroll-state-lines-for-node - (buffer region-id node changed-keys report) - "Synchronize cached scroll lines for updated NODE in BUFFER. -Ordinary incremental patching mutates the visible buffer. Scroll containers -also keep their own cached content lines, so those lines must be updated or a -later scroll-window replacement can replay stale content." - (or (ebox--sync-scroll-state-paint-lines-for-node - buffer node changed-keys report) - (ebox--sync-scroll-state-lines-for-node-fallback - buffer region-id node changed-keys report))) - -;;; ============================================================ -;;; Scroll Interaction Commands -;;; ============================================================ - (defun ebox--region-id-at-point () "Get region-id at current point by checking ebox-content property." (get-text-property (point) 'ebox-content)) -(defun ebox--scroll-region-ids-in-extents-at-pos (pos) - "Return scroll region ids whose live extents contain POS, inner first." +(defun ebox--scroll-region-ids-in-mounts-at-pos (pos) + "Return scroll region ids whose TP mounts contain POS, inner first." (let (candidates) (maphash (lambda (region-id _state) - (when-let ((extents (ebox--live-box-extents region-id))) - (let ((start (car extents)) - (end (cdr extents))) - (when (and (markerp start) - (markerp end) - (eq (marker-buffer start) (current-buffer)) - (<= (marker-position start) pos) - (< pos (marker-position end))) - (push (cons region-id - (- (marker-position end) - (marker-position start))) - candidates))))) + (when-let ((bounds + (ebox-surface-region-bounds + (current-buffer) region-id))) + (when (and (<= (car bounds) pos) (< pos (cdr bounds))) + (push (cons region-id (- (cdr bounds) (car bounds))) + candidates)))) ebox--scroll-global-state) (mapcar #'car (sort candidates @@ -5208,16 +2648,11 @@ later scroll-window replacement can replay stale content." (let (candidates) (maphash (lambda (region-id _state) - (when-let ((extents (ebox--live-box-extents region-id))) - (let ((start (car extents)) - (end (cdr extents))) - (when (and (markerp start) - (markerp end) - (eq (marker-buffer start) (current-buffer))) - (push (cons region-id - (- (marker-position end) - (marker-position start))) - candidates))))) + (when-let ((bounds + (ebox-surface-region-bounds + (current-buffer) region-id))) + (push (cons region-id (- (cdr bounds) (car bounds))) + candidates))) ebox--scroll-global-state) (mapcar #'car (sort candidates @@ -5238,7 +2673,7 @@ later scroll-window replacement can replay stale content." (unless (memq (cdr entry) '(ebox-content ebox-content-owner)) (add (get-text-property pos (cdr entry))))) (unless (cl-some #'ebox--scroll-get-state ids) - (dolist (region-id (ebox--scroll-region-ids-in-extents-at-pos pos)) + (dolist (region-id (ebox--scroll-region-ids-in-mounts-at-pos pos)) (add region-id)))) ids)) @@ -5264,144 +2699,223 @@ 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--scroll-region-by (region-id delta &optional prefix-budget-lines) - "Scroll REGION-ID content by DELTA lines when it has scroll capacity. -Return the consumed line delta, `pending' for lazy cache misses, or nil. -PREFIX-BUDGET-LINES caps synchronous lazy-prefix rendering for this call." - (when-let ((state (and region-id (ebox--scroll-get-state region-id)))) - (catch 'native-prefix-pending - (when (and (plist-get state :native-reflow-materialize-p) - (plist-get state :materialize-content-lines)) - (setq state (ebox--scroll-state-materialize-lines region-id state))) - (when (plist-get state :native-reflow-target-prefix-p) - (let* ((scroll-offset (or (plist-get state :scroll-offset) 0)) - (content-height - (max 0 (or (plist-get state :content-height) 0))) - (required-lines - (+ (max 0 scroll-offset (+ scroll-offset delta)) - content-height))) - (if (ebox--scroll-sync-prefix-render-p) - (setq state - (ebox--scroll-state-ensure-prefix-lines - region-id state required-lines t)) - ;; The exact native window is already visible. Preserve it and - ;; let bounded idle slices prepare the target-width cache instead - ;; of freezing the GUI on a first wheel event. - (setq state - (plist-put state :cache-miss-prefetch-target-lines - required-lines)) - (puthash region-id state ebox--scroll-global-state) - (ebox--scroll-schedule-idle-prefetch region-id 0) - (throw 'native-prefix-pending 'pending)))) - (let* ((scroll-offset (or (plist-get state :scroll-offset) 0)) - (desired-offset (+ scroll-offset delta)) - (dirty-prefix-p (plist-get state :lazy-scroll-prefix-dirty)) - refresh-window-p) +(defun ebox--surface-scroll-runtime (buffer region-id) + "Return isolated scroll candidate runtime for BUFFER and REGION-ID." + (let* ((old-state (ebox--buffer-render-state buffer)) + (root (ebox-tree-copy-node-structure + (plist-get old-state :root-node))) + (index (ebox--runtime-index root t)) + (scroll-table + (ebox-incremental--candidate-scroll-state-table + buffer old-state index (plist-get index :region-box-table))) + (old-scroll-state (ebox--scroll-get-state region-id)) + (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) + (setq candidate-scroll-state + (plist-put candidate-scroll-state key + (plist-get old-scroll-state key))))) + (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 (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))) + +(defun ebox--surface-scroll-target (region-id state delta prefix-budget-lines) + "Return REGION-ID STATE staged by DELTA within PREFIX-BUDGET-LINES." + (catch 'result + (when (and (plist-get state :native-reflow-materialize-p) + (plist-get state :materialize-content-lines)) + (setq state (ebox--scroll-state-materialize-lines region-id state))) + (when (and (plist-get state :native-reflow-target-prefix-p) + (not (ebox--scroll-sync-prefix-render-p))) + (let* ((offset (or (plist-get state :scroll-offset) 0)) + (height (or (plist-get state :content-height) 0)) + (required (+ (max 0 offset (+ offset delta)) height))) (setq state - (ebox--scroll-refresh-dirty-prefix-for-offset - region-id state desired-offset)) - (setq refresh-window-p - (or dirty-prefix-p - (plist-get state :lazy-scroll-window-refresh-required))) - (if (plist-get state :lazy-scroll-prefix-dirty) - 'pending - (when (and (> delta 0) - (or prefix-budget-lines - (and (ebox--scroll-sync-prefix-render-p) - (<= delta - (ebox--scroll-prefetch-slice-lines))))) - (setq state - (ebox--scroll-ensure-bounded-prefix-for-offset - region-id state desired-offset prefix-budget-lines))) - (setq refresh-window-p - (or refresh-window-p - (plist-get state :lazy-scroll-window-refresh-required))) - (let* ((content-lines (plist-get state :content-lines)) - (content-height (plist-get state :content-height)) - (max-offset (max 0 (- (length content-lines) content-height))) - (cache-miss-p - (and (> delta 0) - (> desired-offset max-offset) - (plist-get state :render-content-prefix) - (not (plist-get state :content-lines-complete-p)))) - (new-offset (max 0 (min max-offset desired-offset)))) - (when cache-miss-p - (setq state - (ebox--scroll-schedule-cache-miss-prefetch - region-id state))) - (cond - ((and (= new-offset scroll-offset) refresh-window-p) - (let* ((new-visible-lines - (seq-subseq content-lines - new-offset - (min (+ new-offset content-height) - (length content-lines)))) - (inhibit-read-only t) - (updated-spans - (ebox--scroll-update-content - region-id new-visible-lines nil)) - (all-spans-updated - (and updated-spans - (cl-every #'identity updated-spans)))) - (when (or all-spans-updated - (ebox--scroll-replace-rendered-window region-id state) - (ebox--scroll-rerender-region region-id)) - (when-let ((current-state - (ebox--scroll-get-state region-id))) - (puthash - region-id - (ebox--plist-remove - current-state :lazy-scroll-window-refresh-required) - ebox--scroll-global-state)) - (unless (gethash region-id ebox--smooth-scroll-state-table) - (ebox--scroll-schedule-idle-prefetch region-id)) - 'refreshed))) - ((= new-offset scroll-offset) - (and cache-miss-p 'pending)) - (t - (let ((old-offset scroll-offset) - (box (plist-get state :box)) - (buffer (ebox--scroll-state-buffer state))) - (when box - (ebox-put box :scroll-offset new-offset) - (when buffer - (ebox--invalidate-buffer-render-signatures - buffer (ebox--ensure-node-id box)))) - (plist-put state :scroll-offset new-offset) - (let* ((new-visible-lines - (seq-subseq content-lines - new-offset - (min (+ new-offset content-height) - (length content-lines)))) - (inhibit-read-only t) - (updated-spans - (ebox--scroll-update-content - region-id new-visible-lines - (unless refresh-window-p old-offset))) - (all-spans-updated - (and updated-spans - (cl-every #'identity updated-spans)))) - (if (or all-spans-updated - (ebox--scroll-replace-rendered-window region-id state) - (ebox--scroll-rerender-region region-id)) - (progn - (when refresh-window-p - (when-let ((current-state - (ebox--scroll-get-state region-id))) - (puthash - region-id - (ebox--plist-remove - current-state - :lazy-scroll-window-refresh-required) - ebox--scroll-global-state))) - (unless (gethash region-id - ebox--smooth-scroll-state-table) - (ebox--scroll-schedule-idle-prefetch region-id)) - (- new-offset old-offset)) - (when box - (ebox-put box :scroll-offset old-offset)) - (plist-put state :scroll-offset old-offset) - nil))))))))))) + (plist-put state :cache-miss-prefetch-target-lines required)) + (throw 'result + (list :result 'pending :state state :offset offset + :schedule-prefetch t)))) + (let* ((old-offset (or (plist-get state :scroll-offset) 0)) + (desired (+ old-offset delta)) + (refresh-p (or (plist-get state :lazy-scroll-prefix-dirty) + (plist-get state + :lazy-scroll-window-refresh-required)))) + (setq state + (ebox--scroll-refresh-dirty-prefix-for-offset + region-id state desired)) + (when (plist-get state :lazy-scroll-prefix-dirty) + (throw 'result (list :result 'pending))) + (when (and (> delta 0) + (or prefix-budget-lines + (and (ebox--scroll-sync-prefix-render-p) + (<= delta (ebox--scroll-prefetch-slice-lines))))) + (setq state + (ebox--scroll-ensure-bounded-prefix-for-offset + region-id state desired prefix-budget-lines))) + (let* ((lines (plist-get state :content-lines)) + (height (or (plist-get state :content-height) 0)) + (maximum (max 0 (- (length lines) height))) + (new-offset (max 0 (min maximum desired))) + (cache-miss-p + (and (> delta 0) (> desired maximum) + (plist-get state :render-content-prefix) + (not (plist-get state :content-lines-complete-p))))) + (when cache-miss-p + (setq state + (ebox--scroll-schedule-cache-miss-prefetch + region-id state))) + (cond + ((and (= new-offset old-offset) refresh-p) + (list :result 'refreshed :state state :offset new-offset)) + ((= new-offset old-offset) + (if cache-miss-p + (list :result 'pending :state state :offset new-offset + :schedule-prefetch t) + (list :result nil))) + (t + (list :result (- new-offset old-offset) + :state state :offset new-offset + :schedule-prefetch cache-miss-p))))))) + +(defun ebox--surface-publish-scroll + (buffer region-id runtime transition) + "Publish BUFFER scroll TRANSITION for REGION-ID from isolated RUNTIME." + (let* ((root (plist-get runtime :root)) + (base-state (plist-get runtime :base-state)) + (table (plist-get runtime :scroll-table)) + (state (plist-get transition :state)) + (offset (plist-get transition :offset)) + (box (or (ebox--root-region-box root region-id) + (error "Ebox scroll candidate lost region %S" region-id))) + (owner-id + (ebox--buffer-region-render-owner-node-id buffer region-id))) + (ebox-put box :scroll-offset offset) + (plist-put box :ebox-scroll-offset-controlled-p t) + (setq state (plist-put state :scroll-offset offset)) + (setq state (plist-put state :box box)) + (setq state (ebox--plist-remove + state :lazy-scroll-window-refresh-required)) + (puthash region-id state table) + (ebox--cancel-buffer-runtime-prewarm buffer) + (ebox-incremental--notify-before-runtime-mutation buffer 'scroll) + (unless (eq base-state (ebox--buffer-render-state buffer)) + (error "Ebox runtime changed during scroll update notification")) + (let* ((staged-scroll-state (copy-sequence state)) + (commit-input + (let ((ebox--scroll-global-state table)) + (ebox-incremental-prepare-scoped-commit + buffer root + (append + (ebox--constraint-change-report-props + (ebox--region-constraint-change + buffer region-id 'geometry '(:scroll-offset))) + (list :region-id region-id + :constraint-source 'scroll + :strategy 'span-patch + :patch-count 1 + :patch-ops '(span-patch) + :owner-id owner-id + :owner-ids (list owner-id) + :scroll-state-transaction t))))) + (_staged-cache + (ebox--surface-restore-staged-scroll-cache + commit-input region-id staged-scroll-state)) + (scope-node-ids + (or (plist-get commit-input :scope-node-ids) + (list owner-id))) + (state-overrides + (plist-put + (plist-get commit-input :state-overrides) + :preserve-scroll-producer-region-ids (list region-id))) + (surface + (ebox-surface-update-buffer-scoped + buffer + (plist-get commit-input :root) + scope-node-ids + (plist-get commit-input :report-base) + state-overrides nil nil + (and (plist-get transition :schedule-prefetch) 0)))) + (ignore surface) + (plist-get transition :result)))) + +(defun ebox--surface-restore-staged-scroll-cache + (commit-input region-id staged-state) + "Restore REGION-ID STAGED-STATE cache fields in COMMIT-INPUT." + (let* ((overrides (plist-get commit-input :state-overrides)) + (table (plist-get overrides :scroll-state-table)) + (state (gethash region-id table))) + (dolist (key '(:content-lines :rendered-content-lines + :content-lines-complete-p :render-content-prefix + :materialize-content-lines :content-region-id-set + :region-line-bounds-index :region-line-span-index + :region-line-span-index-deferred + :rendered-region-line-span-index + :rendered-region-line-span-index-deferred + :region-line-span-hints :cache-miss-prefetch-target-lines + :native-reflow-target-prefix-p + :native-reflow-prefix-reset-p + :native-reflow-visible-offset + :native-reflow-visible-lines)) + (if (plist-member staged-state key) + (setq state (plist-put state key (plist-get staged-state key))) + (setq state (ebox--plist-remove state key)))) + (puthash region-id state table) + commit-input)) + +(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)) + (table (plist-get runtime :scroll-table)) + (candidate-state (plist-get runtime :state)) + (transition + (let ((ebox--scroll-global-state table) + (ebox--scroll-idle-prefetch-timers + (make-hash-table :test 'equal)) + (ebox--smooth-scroll-state-table + (make-hash-table :test 'equal)) + (ebox-incremental--buffer-render-state-override + (cons buffer candidate-state))) + (cl-letf (((symbol-function 'ebox--scroll-schedule-idle-prefetch) + (lambda (&rest _) nil))) + (ebox--surface-scroll-target + region-id (gethash region-id table) + delta prefix-budget-lines))))) + (if (plist-member transition :state) + (ebox--surface-publish-scroll buffer region-id runtime transition) + (plist-get transition :result)))) + +(defun ebox--surface-scroll-to-offset (buffer region-id offset) + "Set mounted BUFFER REGION-ID to absolute scroll OFFSET through TP." + (let* ((state (ebox--buffer-render-state buffer)) + (table (plist-get state :scroll-state-table)) + (scroll-state (and table (gethash region-id table))) + (current (and scroll-state + (or (plist-get scroll-state :scroll-offset) 0)))) + (unless scroll-state + (user-error "Ebox region is not scrollable: %S" region-id)) + (unless (numberp offset) + (user-error "Ebox scroll offset is not numeric: %S" offset)) + (if (= offset current) + (ebox--surface-region-no-op-report state region-id nil) + (ebox--surface-scroll-region-by + buffer region-id (- offset current) nil) + (ebox--buffer-update-report buffer)))) + +(defun ebox--scroll-region-by (region-id delta &optional prefix-budget-lines) + "Scroll REGION-ID by DELTA through its runtime using PREFIX-BUDGET-LINES." + (when-let* ((state (and region-id (ebox--scroll-get-state region-id))) + (buffer (ebox--scroll-state-buffer state))) + (unless (ebox-surface-buffer-mounted-p buffer) + (error "Ebox scroll update requires a mounted TP surface")) + (ebox--surface-scroll-region-by + buffer region-id delta prefix-budget-lines))) (defun ebox--scroll-progress-p (result) "Return non-nil when RESULT represents consumed or pending scroll work." @@ -5414,259 +2928,6 @@ PREFIX-BUDGET-LINES caps synchronous lazy-prefix rendering for this call." (unless noninteractive (redisplay t))) -(defun ebox--scroll-rerender-region (region-id) - "Rerender scroll REGION-ID when direct content marker updates are unavailable." - (when-let* ((extents (ebox--live-box-extents region-id)) - (start-marker (car extents)) - (buffer (marker-buffer start-marker))) - (condition-case nil - (progn - (ebox--with-buffer-render-context buffer - (ebox--rerender-box region-id)) - (when (buffer-live-p buffer) - (ebox--refresh-buffer-scroll-content-markers buffer) - (ebox-buffer-refresh-region-role-spans buffer) - (ebox--refresh-buffer-layout-snapshots buffer)) - t) - (error nil)))) - -(defun ebox--scroll-decorate-rendered-window-lines (lines box region-id) - "Apply fixed scroll viewport chrome from BOX to rendered LINES." - (let* ((padding-top (floor (ebox-get box :padding-top-height))) - (padding-bottom (floor (ebox-get box :padding-bottom-height))) - (content-pixel (ebox--content-pixel box)) - (color (ebox-get box :color)) - (bgcolor (ebox-get box :bgcolor))) - (cl-labels - ((style-line (line) - (ebox--propertize-colors line color bgcolor)) - (padding-lines (height property) - (when (> height 0) - (mapcar #'style-line - (ebox-string-lines - (ebox--propertize-region - (or (ebox--pixel-blank content-pixel height) "") - property region-id)))))) - (let ((lines (append (padding-lines padding-top 'ebox-pt) - ;; These are already fully styled when - ;; `:rendered-content-lines' is produced during the - ;; original render. Reapplying faces to every - ;; complex child-layout line on each wheel tick is - ;; expensive enough to freeze GUI Emacs. - (mapcar #'copy-sequence lines) - (padding-lines padding-bottom 'ebox-pb)))) - (when (and lines (ebox-get box :border-top-p)) - (let ((line (ebox--propertize-overline - (car lines) - (ebox-get box :border-top-color)))) - (add-text-properties 0 (length line) `(ebox-bt ,region-id) line) - (setcar lines line))) - (when (and lines (ebox-get box :border-bottom-p)) - (let ((line (ebox--propertize-underline - (car (last lines)) - (ebox-get box :border-bottom-color)))) - (add-text-properties 0 (length line) `(ebox-bb ,region-id) line) - (setcar (last lines) line))) - lines)))) - -(defun ebox--render-scroll-state-window (state region-id) - "Render scroll STATE's current visible window for REGION-ID. -This uses the already formatted `:content-lines' stored in STATE, so scrolling -child-layout content does not need to rerender the whole source tree." - (setq state (ebox--scroll-state-materialize-lines region-id state)) - (when-let* ((box (plist-get state :box)) - (content-lines (plist-get state :content-lines)) - (content-height (plist-get state :content-height))) - (let* ((text-height (length content-lines)) - (rendered-content-lines (plist-get state :rendered-content-lines)) - (max-offset (max 0 (- text-height content-height))) - (scroll-offset (max 0 (min max-offset - (or (plist-get state :scroll-offset) - 0))))) - (if rendered-content-lines - (ebox-lines-join - (ebox--scroll-decorate-rendered-window-lines - (seq-subseq rendered-content-lines - scroll-offset - (min (+ scroll-offset content-height) text-height)) - box region-id)) - (let* ( - (content-pixel (ebox--content-pixel box)) - (padding-left (ebox-get box :padding-left-pixel)) - (padding-right (ebox-get box :padding-right-pixel)) - (padding-top (floor (ebox-get box :padding-top-height))) - (padding-bottom (floor (ebox-get box :padding-bottom-height))) - (margin-left (ebox-get box :margin-left-pixel)) - (margin-right (ebox-get box :margin-right-pixel)) - (margin-top (floor (ebox-get box :margin-top-height))) - (margin-bottom (floor (ebox-get box :margin-bottom-height))) - (border-left-pixel (ebox-get box :border-left-pixel)) - (border-left-color (ebox-get box :border-left-color)) - (border-right-pixel (ebox-get box :border-right-pixel)) - (border-right-color (ebox-get box :border-right-color)) - (border-top-p (ebox-get box :border-top-p)) - (border-top-color (ebox-get box :border-top-color)) - (border-bottom-p (ebox-get box :border-bottom-p)) - (border-bottom-color (ebox-get box :border-bottom-color)) - (color (ebox-get box :color)) - (bgcolor (ebox-get box :bgcolor)) - (inner-height (+ content-height padding-top padding-bottom)) - (padding-line-filler (ebox-pixel-space content-pixel)) - (visible-lines - (seq-subseq content-lines - scroll-offset - (min (+ scroll-offset content-height) text-height))) - (result - (ebox-lines-join - (cl-loop for line in visible-lines - for idx from 0 - collect (ebox--propertize-content-line - line region-id idx - padding-line-filler))))) - (setq result - (ebox--lines-align-vertical - result content-height (ebox-get box :vertical-align))) - (setq result - (ebox--maplines - (lambda (line) - (if (and (string-empty-p (string-trim line)) - (not (ebox--line-has-non-content-properties-p line))) - (let ((content-id (get-text-property 0 'ebox-content line)) - (content-idx (get-text-property 0 'ebox-content-idx line))) - (if content-id - (propertize padding-line-filler - 'ebox-content content-id - 'ebox-content-idx content-idx - 'ebox-content-owner region-id) - (propertize padding-line-filler - 'ebox-content region-id - 'ebox-content-idx 0 - 'ebox-content-owner region-id))) - line)) - result)) - (when (or (> padding-top 0) (> padding-bottom 0)) - (let ((pt-str (when (> padding-top 0) - (ebox--propertize-region - (ebox--pixel-blank content-pixel padding-top) - 'ebox-pt region-id))) - (pb-str (when (> padding-bottom 0) - (ebox--propertize-region - (ebox--pixel-blank content-pixel padding-bottom) - 'ebox-pb region-id)))) - (setq result (ebox--lines-stack-vertical pt-str result pb-str)))) - (let ((pl-str (when (> padding-left 0) - (ebox--propertize-region - (ebox--pixel-blank padding-left inner-height) - 'ebox-pl region-id))) - (pr-str (when (> padding-right 0) - (ebox--propertize-region - (ebox--pixel-blank padding-right inner-height) - 'ebox-pr region-id)))) - (when (or pl-str pr-str) - (setq result (ebox--lines-concat-horizontal pl-str result pr-str)))) - (when (or color bgcolor) - (setq result - (ebox--maplines - (lambda (line) - (ebox--propertize-colors line color bgcolor)) - result))) - (when (or (> border-left-pixel 0) (> border-right-pixel 0)) - (let ((bl-str (when (> border-left-pixel 0) - (ebox--propertize-region - (ebox--pixel-border border-left-pixel - inner-height border-left-color) - 'ebox-bl region-id))) - (br-str (when (> border-right-pixel 0) - (ebox--propertize-region - (ebox--pixel-border border-right-pixel - inner-height border-right-color) - 'ebox-br region-id)))) - (setq result (ebox--lines-concat-horizontal bl-str result br-str)))) - (when (or border-top-p border-bottom-p) - (let ((lines (ebox-string-lines result))) - (when border-top-p - (setf (car lines) - (propertize - (ebox--propertize-overline (car lines) border-top-color) - 'ebox-bt region-id))) - (when border-bottom-p - (setf (car (last lines)) - (propertize - (ebox--propertize-underline - (car (last lines)) border-bottom-color) - 'ebox-bb region-id))) - (setq result (ebox-lines-join lines)))) - ;; Visibility hides the box's own ink (content glyphs, colors, - ;; borders) after assembly; margins below stay blank either way. - (when (eq (ebox-get box :visibility) 'hidden) - (setq result (ebox--apply-hidden-visibility result))) - (when (or (> margin-left 0) (> margin-right 0)) - (let* ((box-height (ebox-string-height result)) - (ml-str (when (> margin-left 0) - (ebox--propertize-region - (ebox--pixel-blank margin-left box-height) - 'ebox-ml region-id))) - (mr-str (when (> margin-right 0) - (ebox--propertize-region - (ebox--pixel-blank margin-right box-height) - 'ebox-mr region-id)))) - (setq result (ebox--lines-concat-horizontal ml-str result mr-str)))) - (when (or (> margin-top 0) (> margin-bottom 0)) - (let ((total-pixel-width (ebox--total-pixel box))) - (let ((mt-str (when (> margin-top 0) - (ebox--propertize-region - (ebox--pixel-blank total-pixel-width margin-top) - 'ebox-mt region-id))) - (mb-str (when (> margin-bottom 0) - (ebox--propertize-region - (ebox--pixel-blank total-pixel-width margin-bottom) - 'ebox-mb region-id)))) - (setq result (ebox--lines-stack-vertical mt-str result mb-str))))) - result))))) - -(defun ebox--scroll-replace-rendered-window (region-id state) - "Patch scroll REGION-ID by replacing its fixed rendered viewport from STATE." - (when-let* ((extents (ebox--live-box-extents region-id)) - (start-marker (car extents)) - (end-marker (cdr extents)) - (buffer (marker-buffer start-marker)) - ((eq buffer (marker-buffer end-marker))) - (rendered (ebox--render-scroll-state-window state region-id))) - (with-current-buffer buffer - (let* ((start (marker-position start-marker)) - (end (marker-position end-marker)) - (old-height (count-lines start end)) - (new-height (length (ebox-string-lines rendered))) - (window-states - (mapcar (lambda (window) - (list :window window - :start (window-start window) - :point (window-point window) - :hscroll (window-hscroll window))) - (get-buffer-window-list buffer nil t))) - (inhibit-read-only t)) - (when (= old-height new-height) - (save-excursion - (goto-char start) - (delete-region start end) - (insert rendered) - (ebox--scroll-store-rendered-window-extents - buffer region-id state start (point) new-height)) - (dolist (window-state window-states) - (let ((window (plist-get window-state :window))) - (when (and (window-live-p window) - (eq (window-buffer window) buffer)) - (set-window-start - window - (min (plist-get window-state :start) (point-max)) - t) - (set-window-point - window - (min (plist-get window-state :point) (point-max))) - (set-window-hscroll - window (plist-get window-state :hscroll))))) - t))))) - (defun ebox--scroll-first-region-by (region-ids delta) "Scroll the first region in REGION-IDS that can consume DELTA." (catch 'scrolled @@ -5946,915 +3207,12 @@ With prefix ARG, scroll by that many content lines." (lambda (&optional _n) (scroll-down-command arg))))) -(defun ebox--scroll-replace-content-marker (state region-id idx string) - "Replace visible scroll line IDX for REGION-ID using STATE markers." - (when-let* ((markers (plist-get state :content-span-markers)) - ((< idx (length markers))) - (span (aref markers idx)) - ((consp span)) - (start-marker (car span)) - (end-marker (cdr span)) - ((markerp start-marker)) - ((markerp end-marker)) - (buffer (marker-buffer start-marker)) - ((eq buffer (marker-buffer end-marker))) - (start (marker-position start-marker)) - (end (marker-position end-marker))) - (with-current-buffer buffer - (when (and (<= start end) - (or - (and - (eql (get-text-property start 'ebox-content) region-id) - (eql (get-text-property start 'ebox-content-idx) idx)) - (eql (get-text-property start 'ebox-content-owner) region-id) - (member region-id - (get-text-property start 'ebox-content-owners)))) - (let ((inhibit-read-only t) - (props (copy-sequence (text-properties-at start)))) - (save-excursion - (let ((replacement - (ebox--scroll-propertize-visible-line - string region-id idx props))) - ;; Let Emacs retain unchanged buffer intervals and markers, then - ;; copy the replacement's exact per-character properties. A - ;; delete/insert of the whole display-bearing span can make the - ;; first GUI redisplay publish a torn flex row even when the - ;; resulting buffer string is otherwise correct. - (replace-region-contents start end (lambda () replacement)) - (ebox--copy-string-text-properties-to-buffer replacement start) - (goto-char (+ start (length replacement)))) - (set-marker start-marker start) - (set-marker end-marker (point))) - (cons start (marker-position end-marker))))))) - -(defun ebox--scroll-content-window (state line-count) - "Return marker-backed visible scroll window for STATE and LINE-COUNT. -The return value is (BUFFER START END ENTRIES). Each entry is -(IDX START-MARKER END-MARKER PROPS)." - (when-let ((markers (plist-get state :content-span-markers))) - (catch 'invalid - (let (buffer window-start window-end previous-end entries) - (dotimes (idx line-count) - (unless (< idx (length markers)) - (throw 'invalid nil)) - (let* ((span (aref markers idx)) - (start-marker (and (consp span) (car span))) - (end-marker (and (consp span) (cdr span)))) - (unless (and (markerp start-marker) - (markerp end-marker) - (marker-buffer start-marker) - (eq (marker-buffer start-marker) - (marker-buffer end-marker))) - (throw 'invalid nil)) - (let ((start (marker-position start-marker)) - (end (marker-position end-marker)) - (span-buffer (marker-buffer start-marker))) - (unless (and (<= start end) - (or (not buffer) (eq buffer span-buffer)) - (or (not previous-end) - (and (= start (1+ previous-end)) - (with-current-buffer span-buffer - (eq (char-after previous-end) ?\n))))) - (throw 'invalid nil)) - (unless (and (< start end) - (with-current-buffer span-buffer - (let ((region-id (plist-get state :region-id))) - (or (and (equal (get-text-property - start 'ebox-content) - region-id) - (eql (get-text-property - start 'ebox-content-idx) - idx)) - (equal (get-text-property - start 'ebox-content-owner) - region-id) - (member region-id - (get-text-property - start 'ebox-content-owners)))))) - (throw 'invalid nil)) - (setq buffer span-buffer) - (unless window-start - (setq window-start start)) - (setq window-end end - previous-end end) - (push (list idx start-marker end-marker - (with-current-buffer span-buffer - (copy-sequence (text-properties-at start)))) - entries)))) - (and buffer window-start window-end - (list buffer window-start window-end (nreverse entries))))))) - -(defun ebox--scroll-propertize-visible-line (line region-id idx props) - "Return LINE with visible scroll marker PROPS for REGION-ID and IDX." - (let ((line (copy-sequence line))) - (if (text-property-not-all 0 (length line) 'ebox-content nil line) - (ebox--add-content-owner line region-id) - (let ((props (copy-sequence props))) - ;; LINE already owns its formatted `display' runs. In particular, - ;; centered content uses a width-bearing display space only at each - ;; edge. Copying the old first character's display value over the - ;; entire replacement line makes every glyph consume that width and - ;; visibly tears apart neighboring flex boxes until a later rerender. - (setq props (ebox--plist-remove props 'display)) - (setq props (plist-put props 'ebox-content region-id)) - (setq props (plist-put props 'ebox-content-idx idx)) - (when (> (length line) 0) - (add-text-properties 0 (length line) props line)) - line)))) - -(defun ebox--scroll-replace-content-window (state region-id new-lines) - "Replace all visible scroll NEW-LINES for REGION-ID in one buffer edit." - (let ((state (plist-put (copy-sequence state) :region-id region-id))) - (pcase-let ((`(,buffer ,window-start ,window-end ,entries) - (ebox--scroll-content-window state (length new-lines)))) - (when (and buffer entries) - (with-current-buffer buffer - (let ((inhibit-read-only t) - (line-count (length new-lines)) - spans - marker-updates - replacement-lines - (offset 0)) - (save-excursion - (cl-loop for line in new-lines - for entry in entries - for line-idx from 0 - do - (pcase-let ((`(,idx ,start-marker ,end-marker ,props) - entry)) - (let* ((replacement-line - (ebox--scroll-propertize-visible-line - line region-id idx props)) - (line-start (+ window-start offset)) - (line-end (+ line-start - (length replacement-line)))) - (push replacement-line replacement-lines) - (push (list start-marker end-marker - line-start line-end) - marker-updates) - (push (cons line-start line-end) spans) - (setq offset - (+ offset (length replacement-line) - (if (< line-idx (1- line-count)) - 1 - 0)))))) - (let ((replacement - (ebox-lines-join (nreverse replacement-lines)))) - (replace-region-contents - window-start window-end (lambda () replacement)) - (ebox--copy-string-text-properties-to-buffer - replacement window-start)) - (dolist (update marker-updates) - (pcase-let ((`(,start-marker ,end-marker ,start ,end) - update)) - (set-marker start-marker start) - (set-marker end-marker end)))) - (let ((spans (nreverse spans))) - (ebox--scroll-state-clear-window-region-id-set - region-id (or (ebox--scroll-get-state region-id) state) - buffer) - spans))))))) - -(defun ebox--scroll-chrome-free-rendered-window-p (box) - "Return non-nil when BOX's scroll viewport has no dynamic chrome rows." - (and (= (floor (ebox-get box :padding-top-height)) 0) - (= (floor (ebox-get box :padding-bottom-height)) 0) - (not (ebox-get box :border-top-p)) - (not (ebox-get box :border-bottom-p)) - (= (floor (ebox-get box :margin-top-height)) 0) - (= (floor (ebox-get box :margin-bottom-height)) 0))) - -(defun ebox--scroll-state-window-extents (region-id state) - "Return marker-backed visible viewport extents for chrome-free scroll STATE. -The scroll state may own a lightweight rendered-window boundary marker pair. -For a chrome-free viewport those markers are the rendered box window, so they -are the most accurate source after viewport resize invalidates global extents. -Older content-span markers are kept as a fallback for freshly rendered buffers -and dynamic update marker refreshes." - (when-let* ((box (plist-get state :box)) - ((ebox--scroll-chrome-free-rendered-window-p box)) - (content-height (plist-get state :content-height)) - ((> content-height 0))) - (let* ((start-marker (plist-get state :rendered-window-start-marker)) - (end-marker (plist-get state :rendered-window-end-marker)) - (line-count (plist-get state :rendered-window-line-count)) - (buffer (and (markerp start-marker) - (marker-buffer start-marker)))) - (or - (when (and buffer - (markerp end-marker) - (eq buffer (marker-buffer end-marker)) - (integerp line-count) - (= line-count content-height) - (marker-position start-marker) - (marker-position end-marker) - (< (marker-position start-marker) - (marker-position end-marker))) - (cons start-marker end-marker)) - (when-let ((markers (plist-get state :content-span-markers))) - (let (buffer start end) - (dolist (span (ebox--scroll-content-marker-spans markers)) - (let* ((start-marker (car-safe span)) - (end-marker (cdr-safe span)) - (span-buffer (and (markerp start-marker) - (marker-buffer start-marker))) - (span-start (and (markerp start-marker) - (marker-position start-marker))) - (span-end (and (markerp end-marker) - (marker-position end-marker)))) - (when (and (markerp start-marker) - (markerp end-marker) - span-buffer - (eq span-buffer (marker-buffer end-marker)) - span-start - span-end - (< span-start span-end) - (or (not buffer) (eq buffer span-buffer))) - (setq buffer span-buffer) - (setq start (if start (min start span-start) span-start)) - (setq end (if end (max end span-end) span-end))))) - (when (and buffer start end - (= (with-current-buffer buffer - (count-lines start end)) - content-height)) - (when-let ((stored - (ebox--scroll-store-rendered-window-extents - buffer region-id state start end content-height))) - (cons (plist-get stored :rendered-window-start-marker) - (plist-get stored - :rendered-window-end-marker)))))))))) - -(defun ebox--scroll-rendered-window-slide-preferred-p () - "Return non-nil when rendered scroll windows should use line sliding. -Line sliding keeps scroll edits proportional to the consumed delta instead of -the viewport height. GUI and terminal frames both need this bounded edit path; -expensive hidden content should be prefetched asynchronously rather than paid -inside the scroll event." - t) - -(defun ebox--scroll-capture-window-states (buffer) - "Return live window positions for BUFFER." - (mapcar (lambda (window) - (list :window window - :start (window-start window) - :point (window-point window) - :hscroll (window-hscroll window))) - (get-buffer-window-list buffer nil t))) - -(defun ebox--scroll-lines-with-live-wrapper-context - (lines position region-id) - "Return cached LINES with wrapper context recovered at POSITION. -The live owner stack is inner-to-outer. Only owners after REGION-ID are outer -wrappers; copying an outgoing child's owners onto an entering line would make -section boundaries semantically stale. Each line is decorated independently -because complete box rendering leaves the separators inserted by -`ebox-lines-join' unpropertized." - (let* ((owners - (or (get-text-property position 'ebox-content-owners) - (when-let ((owner - (get-text-property position 'ebox-content-owner))) - (list owner)))) - (owner-tail (member region-id owners))) - (when owner-tail - (mapcar - (lambda (line) - (let ((result - (ebox--string-with-preserved-wrapper-context - line (cdr owner-tail) - (ebox--string-content-owner-set line)))) - (add-text-properties - 0 (length result) (list 'ebox-scroll-window region-id) result) - result)) - lines)))) - -(defun ebox--scroll-restore-window-states (buffer window-states) - "Restore WINDOW-STATES for live windows still showing BUFFER." - (dolist (window-state window-states) - (let ((window (plist-get window-state :window))) - (when (and (window-live-p window) - (eq (window-buffer window) buffer)) - (set-window-start - window - (min (plist-get window-state :start) (point-max)) - t) - (set-window-point - window - (min (plist-get window-state :point) (point-max))) - (set-window-hscroll - window (plist-get window-state :hscroll)))))) - -(defun ebox--scroll-replace-rendered-content-window-slide - (state region-id old-offset scroll-offset rendered-content-lines - content-height start-marker end-marker buffer) - "Slide a rendered scroll viewport by mutating only entering/leaving lines." - (when-let* (((integerp old-offset)) - (delta (- scroll-offset old-offset)) - ((/= delta 0)) - (step (abs delta)) - ((< step content-height)) - (start (marker-position start-marker)) - (end (marker-position end-marker))) - (let* ((line-count (length rendered-content-lines)) - (entering-lines - (if (> delta 0) - (and (<= (+ scroll-offset content-height) line-count) - (seq-subseq rendered-content-lines - (+ old-offset content-height) - (+ scroll-offset content-height))) - (and (<= old-offset line-count) - (seq-subseq rendered-content-lines - scroll-offset old-offset))))) - (when (and entering-lines (= (length entering-lines) step)) - (with-current-buffer buffer - (let* ((old-height (count-lines start end)) - (window-states (ebox--scroll-capture-window-states buffer)) - (inhibit-read-only t)) - (when (= old-height content-height) - (when-let ((entering-lines - (ebox--scroll-lines-with-live-wrapper-context - entering-lines start region-id))) - (ebox--scroll-clear-content-markers state) - (setq state (plist-put state :content-span-markers nil)) - (setq state - (plist-put state :content-window-region-id-set nil)) - (setq state (plist-put state :buffer buffer)) - (puthash region-id state ebox--scroll-global-state) - (save-excursion - (if (> delta 0) - (progn - (goto-char start) - (forward-line step) - (delete-region start (point)) - (goto-char (marker-position end-marker)) - (insert "\n") - (insert (ebox-lines-join entering-lines))) - (goto-char start) - (forward-line (- content-height step)) - (let ((delete-start (if (> (point) start) - (1- (point)) - (point)))) - (delete-region - delete-start (marker-position end-marker))) - (goto-char start) - (insert (ebox-lines-join entering-lines)) - (insert "\n"))) - (let* ((spans (ebox--scroll-line-spans-in-window - start content-height)) - (new-end (if spans (cdr (car (last spans))) start))) - (ebox--scroll-store-rendered-window-extents - buffer region-id state start new-end content-height) - (ebox--scroll-restore-window-states buffer window-states) - spans))))))))) - -(defun ebox--scroll-replace-rendered-content-window - (state region-id &optional old-offset) - "Replace chrome-free rendered scroll viewport for REGION-ID from STATE." - (let ((marker-extents (ebox--scroll-state-window-extents region-id state))) - (when-let* ((rendered-content-lines - (plist-get state :rendered-content-lines)) - (content-height (plist-get state :content-height)) - (box (plist-get state :box)) - ((ebox--scroll-chrome-free-rendered-window-p box)) - (extents (or marker-extents - (ebox--live-box-extents region-id))) - (start-marker (car extents)) - (end-marker (cdr extents)) - (buffer (marker-buffer start-marker)) - ((eq buffer (marker-buffer end-marker)))) - (let* ((max-offset (max 0 (- (length rendered-content-lines) - content-height))) - (scroll-offset (max 0 (min max-offset - (or (plist-get state :scroll-offset) - 0)))) - (visible-lines - (seq-subseq rendered-content-lines - scroll-offset - (min (+ scroll-offset content-height) - (length rendered-content-lines))))) - (or (and marker-extents - (ebox--scroll-rendered-window-slide-preferred-p) - (ebox--scroll-replace-rendered-content-window-slide - state region-id old-offset scroll-offset rendered-content-lines - content-height start-marker end-marker buffer)) - (with-current-buffer buffer - (let* ((start (marker-position start-marker)) - (rendered - (when-let ((lines - (ebox--scroll-lines-with-live-wrapper-context - visible-lines start region-id))) - (ebox-lines-join lines))) - (end (marker-position end-marker)) - (old-height (count-lines start end)) - (new-height (length visible-lines)) - (window-states - (ebox--scroll-capture-window-states buffer)) - (inhibit-read-only t)) - (when (and rendered (= old-height new-height)) - (ebox--scroll-clear-content-markers state) - (setq state (plist-put state :content-span-markers nil)) - (setq state - (plist-put state :content-window-region-id-set nil)) - (setq state (plist-put state :buffer buffer)) - (puthash region-id state ebox--scroll-global-state) - (save-excursion - (goto-char start) - (delete-region start end) - (insert rendered)) - (let ((spans (ebox--scroll-line-spans-in-window - start new-height))) - (ebox--scroll-store-rendered-window-extents - buffer region-id state start - (if spans (cdr (car (last spans))) start) - new-height) - (ebox--scroll-restore-window-states buffer window-states) - spans))))))))) - -(defun ebox--scroll-ancestor-horizontal-border-p (buffer region-id state) - "Return non-nil when REGION-ID has a bordered ancestor in BUFFER. -Horizontal ancestor borders are painted by viewport slot rather than by the -cached child line. Moving cached lines directly would move or drop that -position-dependent paint." - (when-let* (((buffer-live-p buffer)) - (extents (or (ebox--scroll-state-window-extents region-id state) - (ebox--live-box-extents region-id))) - (start-marker (car extents)) - ((markerp start-marker)) - ((eq (marker-buffer start-marker) buffer)) - (position (marker-position start-marker)) - (owners - (with-current-buffer buffer - (or (get-text-property position 'ebox-content-owners) - (when-let ((owner - (get-text-property - position 'ebox-content-owner))) - (list owner))))) - (outer-owners (cdr (member region-id owners)))) - (cl-some - (lambda (owner) - (when-let ((box (gethash owner ebox--region-box-table))) - (or (ebox-get box :border-top-p) - (ebox-get box :border-bottom-p)))) - outer-owners))) - -(defun ebox--scroll-rerender-root-window (buffer region-id) - "Rerender BUFFER and return REGION-ID's freshly published line spans." - (when (and (buffer-live-p buffer) - (ebox-buffer-apply-root-rerender buffer)) - (when-let* ((state (ebox--scroll-get-state region-id)) - (height (plist-get state :content-height)) - (extents (ebox--scroll-state-window-extents region-id state)) - (start-marker (car extents)) - ((eq (marker-buffer start-marker) buffer))) - (with-current-buffer buffer - (ebox--scroll-line-spans-in-window - (marker-position start-marker) height))))) - -(defun ebox--scroll-update-content (region-id new-lines &optional old-offset) - "Update visible content lines for scroll REGION-ID with NEW-LINES. -This is a dedicated fast path for overflow='scroll' boxes whose outer box -geometry stays unchanged while only the visible window contents move." - (let* ((state (ebox--scroll-get-state region-id)) - (buffer (or (and state (ebox--scroll-state-buffer state)) - (current-buffer))) - (result - (or (and state - (ebox--scroll-ancestor-horizontal-border-p - buffer region-id state) - (ebox--scroll-rerender-root-window buffer region-id)) - (and state - (ebox--scroll-replace-rendered-content-window - state region-id old-offset)) - (and state - (ebox--scroll-replace-content-window - state region-id new-lines)) - (when (or (not state) - (>= (length (ebox--scroll-content-marker-spans - (plist-get state :content-span-markers))) - (length new-lines))) - (cl-loop for line in new-lines - for idx from 0 - collect - (or (and state - (ebox--scroll-replace-content-marker - state region-id idx line)) - (ebox--region-replace-line - region-id idx line))))))) - (when (and result (buffer-live-p buffer)) - ;; Scroll replacement changes which runtime regions occupy otherwise - ;; stable marker coordinates. Discard only the derived indexes here; - ;; role spans and snapshot details are reconstructed lazily on demand. - (ebox--set-buffer-region-role-span-table buffer nil) - (ebox--invalidate-buffer-layout-snapshot-details buffer)) - result)) - -(defun ebox--scroll-marker-spans-compatible-p (state new-lines) - "Return non-nil when NEW-LINES can replace STATE markers without shifting text." - (when-let ((markers (plist-get state :content-span-markers))) - (cl-loop for line in new-lines - for idx from 0 - always - (and (< idx (length markers)) - (when-let* ((span (aref markers idx)) - ((consp span)) - (start-marker (car span)) - (end-marker (cdr span)) - ((markerp start-marker)) - ((markerp end-marker)) - ((eq (marker-buffer start-marker) - (marker-buffer end-marker))) - (start (marker-position start-marker)) - (end (marker-position end-marker))) - (= (- end start) (length line))))))) - -(defun ebox--scroll-fast-path-report - (buffer region-id node-id new-spans changed-keys) - "Return an update report for a scroll-offset fast-path patch." - (when (and buffer node-id new-spans) - (ebox--update-report - region-id 'span-patch - :dirty-count 1 - :patch-count 1 - :patch-ops '(span-patch) - :owner-id node-id - :owner-ids (list node-id) - :owner-type 'box - :span-count (length new-spans) - :scroll-fast-path t - :dirty-kinds '(geometry) - :dirty-keys changed-keys))) - -(defun ebox--scroll-transaction-report - (buffer region-id node-id span-count changed-keys) - "Return a report for an atomically synchronized scroll transaction." - (when (and buffer node-id) - (ebox--update-report - region-id 'span-patch - :dirty-count 1 - :patch-count 1 - :patch-ops '(span-patch) - :owner-id node-id - :owner-ids (list node-id) - :owner-type 'box - :span-count (or span-count 0) - :scroll-state-transaction t - :scroll-state-sync t - :scroll-state-sync-count 1 - :dirty-kinds '(geometry) - :dirty-keys changed-keys))) - -(defun ebox--scroll-offset-state-settled-p (region-id offset) - "Return non-nil when REGION-ID atomically exposes OFFSET." - (when-let ((state (ebox--scroll-get-state region-id))) - (and (= (or (plist-get state :scroll-offset) 0) offset) - (not (plist-get state :lazy-scroll-prefix-dirty)) - (not (plist-get state :lazy-scroll-window-refresh-required))))) - -(defun ebox--try-scroll-offset-fast-path - (buffer region-id changed-keys) - "Patch REGION-ID for a scroll-offset-only update in BUFFER. -Return an update report only after its runtime, cache, and window agree." - (when (and buffer - (equal changed-keys '(:scroll-offset))) - (when-let ((state (ebox--scroll-get-state region-id))) - (setq state (ebox--scroll-state-materialize-lines region-id state)) - (when-let* ((box (plist-get state :box)) - ((eq (ebox-get box :overflow) 'scroll)) - (content-lines (plist-get state :content-lines)) - (content-height (plist-get state :content-height))) - (let* ((max-offset (max 0 (- (length content-lines) content-height))) - (scroll-offset (max 0 (min max-offset - (or (ebox-get box :scroll-offset) - 0)))) - (old-offset (or (plist-get state :scroll-offset) 0)) - (visible-lines - (seq-subseq content-lines - scroll-offset - (min (+ scroll-offset content-height) - (length content-lines)))) - (node-id (ebox--buffer-region-render-owner-node-id - buffer region-id)) - (direct-eligible-p - (or (ebox--scroll-marker-spans-compatible-p - state visible-lines) - (and (null (plist-get state :content-span-markers)) - (= (length visible-lines) content-height) - (plist-get state :rendered-content-lines) - (ebox--scroll-state-window-extents - region-id state)))) - direct-report) - (when direct-eligible-p - (let ((old-state (copy-sequence state))) - (ebox-put box :scroll-offset scroll-offset) - (setq state (plist-put state :scroll-offset scroll-offset)) - (setq state (plist-put state :box box)) - ;; `ebox--scroll-update-content' reads the shared state. Publish - ;; the staged offset before replacing the window, then retain it - ;; only when every visible span was replaced successfully. - (puthash region-id state ebox--scroll-global-state) - (with-current-buffer buffer - (let ((new-spans - (delq nil - (ebox--scroll-update-content - region-id visible-lines old-offset)))) - (if new-spans - (progn - (when-let ((current-state - (ebox--scroll-get-state region-id))) - (puthash - region-id - (ebox--plist-remove - current-state - :lazy-scroll-window-refresh-required) - ebox--scroll-global-state)) - (when (ebox--scroll-offset-state-settled-p - region-id scroll-offset) - (setq direct-report - (ebox--scroll-fast-path-report - buffer region-id node-id new-spans - changed-keys)))) - (puthash region-id old-state - ebox--scroll-global-state))))) - (or direct-report - ;; Marker slots can legitimately be incompatible after a large - ;; jump or viewport reflow. Reuse the ordinary scroll engine so - ;; it commits the offset and visible window together instead of - ;; letting the generic cached-line patch split those states. - (let ((delta (- scroll-offset old-offset))) - (ebox--scroll-region-by region-id delta) - (if (ebox--scroll-offset-state-settled-p - region-id scroll-offset) - (ebox--scroll-transaction-report - buffer region-id node-id content-height changed-keys) - ;; `ebox--scroll-region-by' rolls the box back on failure; - ;; restore the requested runtime value for the conservative - ;; planner that follows this failed transaction. - (ebox-put box :scroll-offset scroll-offset) - nil))))))))) - - -;;; ============================================================ -;;; Region Update API -;;; ============================================================ - -(defconst ebox-region-types - '((content . ebox-content) - (content-owner . ebox-content-owner) - (pt . ebox-pt) (pb . ebox-pb) (pl . ebox-pl) (pr . ebox-pr) - (mt . ebox-mt) (mb . ebox-mb) (ml . ebox-ml) (mr . ebox-mr) - (bt . ebox-bt) (bb . ebox-bb) (bl . ebox-bl) (br . ebox-br)) - "Mapping from region type symbol to text property name.") - -(defsubst ebox--region-property (type) - "Get text property name for region TYPE." - (or (alist-get type ebox-region-types) - (error "Unknown region type: %S" type))) - (defun ebox--region-id-at-pos (pos) "Return the owning ebox region-id at POS, if any." (cl-loop for (_type . prop) in ebox-region-types for region-id = (get-text-property pos prop) when region-id return region-id)) -(defun ebox--set-box-extents (region-id start end) - "Record REGION-ID extents from START to END using markers." - (let* ((existing (gethash region-id ebox--box-extents)) - (start-marker (or (car-safe existing) (make-marker))) - (end-marker (or (cdr-safe existing) (make-marker)))) - (set-marker-insertion-type start-marker nil) - (set-marker-insertion-type end-marker t) - (set-marker start-marker start) - (set-marker end-marker end) - (puthash region-id (cons start-marker end-marker) ebox--box-extents))) - -(defun ebox--clear-buffer-extents (&optional buffer preserve-template) - "Remove cached extents whose markers belong to BUFFER. -Defaults to the current buffer. -When PRESERVE-TEMPLATE is non-nil, keep BUFFER's prepared numeric extent -template while stale live marker extents are removed." - (let ((buffer (or buffer (current-buffer))) - stale-ids) - (when (buffer-live-p buffer) - (with-current-buffer buffer - (unless preserve-template - (setq-local ebox--box-extent-template nil)))) - (maphash - (lambda (region-id extents) - (when (eq (marker-buffer (car extents)) buffer) - (push region-id stale-ids))) - ebox--box-extents) - (dolist (region-id stale-ids) - (when-let ((extents (gethash region-id ebox--box-extents))) - (set-marker (car extents) nil) - (set-marker (cdr extents) nil)) - (remhash region-id ebox--box-extents)))) - -(defun ebox--install-box-extent-template (buffer template start) - "Install prepared numeric box extent TEMPLATE in BUFFER at root START." - (when (and (buffer-live-p buffer) (hash-table-p template)) - (with-current-buffer buffer - (setq-local - ebox--box-extent-template - (if (= start (point-min)) - template - (let ((translated (make-hash-table :test 'equal)) - (delta (- start (point-min)))) - (maphash - (lambda (region-id span) - (puthash region-id - (cons (+ delta (car span)) (+ delta (cdr span))) - translated)) - template) - translated)))))) - -(defun ebox--materialize-box-extent-template (buffer) - "Promote BUFFER's prepared extent template to live marker pairs." - (when (buffer-live-p buffer) - (with-current-buffer buffer - (when (hash-table-p ebox--box-extent-template) - (maphash - (lambda (region-id span) - (ebox--set-box-extents region-id (car span) (cdr span))) - ebox--box-extent-template) - (setq-local ebox--box-extent-template nil) - t)))) - -(defun ebox--clear-box-extents-for-region-ids (region-ids) - "Remove cached box extents for REGION-IDS." - (dolist (region-id region-ids) - (when-let ((extents (gethash region-id ebox--box-extents))) - (set-marker (car extents) nil) - (set-marker (cdr extents) nil)) - (remhash region-id ebox--box-extents))) - -(defun ebox--refresh-box-extents-for-region-ids-in-spans (region-ids spans) - "Refresh box extents for REGION-IDS inside SPANS." - (ebox--clear-box-extents-for-region-ids region-ids) - (when spans - (let ((start (apply #'min (mapcar #'car spans))) - (end (apply #'max (mapcar #'cdr spans))) - (region-id-allow-set (make-hash-table :test 'equal))) - (dolist (region-id region-ids) - (puthash region-id t region-id-allow-set)) - (when (< start end) - (ebox--register-box-extents-in-range - start end region-id-allow-set))))) - -(defun ebox--span-bounds (span) - "Return numeric bounds for SPAN." - (cons (if (markerp (car span)) - (marker-position (car span)) - (car span)) - (if (markerp (cdr span)) - (marker-position (cdr span)) - (cdr span)))) - -(defun ebox--spans-bounding-range (spans) - "Return a numeric bounding range for SPANS." - (when spans - (let* ((first (ebox--span-bounds (car spans))) - (start (car first)) - (end (cdr first))) - (dolist (span (cdr spans)) - (pcase-let ((`(,span-start . ,span-end) - (ebox--span-bounds span))) - (setq start (min start span-start)) - (setq end (max end span-end)))) - (cons start end)))) - -(defun ebox--box-extents-contained-in-spans-p (region-id spans) - "Return non-nil when REGION-ID's live extents are inside SPANS." - (when-let* ((extents (gethash region-id ebox--box-extents)) - ((markerp (car extents))) - ((markerp (cdr extents))) - (range (ebox--spans-bounding-range spans))) - (let ((start (marker-position (car extents))) - (end (marker-position (cdr extents)))) - (and start end - (<= (car range) start) - (<= end (cdr range)))))) - -(defun ebox--box-extent-refresh-region-ids - (region-ids spans &optional active-segments) - "Return REGION-IDS whose cached extents can be refreshed in SPANS." - (let ((root-id (ebox--buffer-root-node-id (current-buffer)))) - (cl-loop for region-id in region-ids - for active-region-p = (and active-segments - (gethash region-id active-segments)) - for contained-p = (ebox--box-extents-contained-in-spans-p - region-id spans) - when (or contained-p - (and active-region-p - (not (equal region-id root-id)))) - collect region-id))) - -(defun ebox--refresh-box-extents-from-segments (segments-by-region) - "Refresh box extents from active SEGMENTS-BY-REGION." - (maphash - (lambda (region-id region-spans) - (when region-spans - (let* ((ordered - (sort region-spans - (lambda (left right) - (if (= (car left) (car right)) - (< (cdr left) (cdr right)) - (< (car left) (car right)))))) - (range (copy-tree (pop ordered))) - (interleaved - (ebox--foreign-region-in-range-p - (car range) (cdr range) region-id))) - (dolist (segment ordered) - (unless interleaved - (cond - ((ebox--foreign-region-in-range-p - (car segment) (cdr segment) region-id) - (setq interleaved t)) - ((<= (car segment) (cdr range)) - (setcdr range (max (cdr range) (cdr segment)))) - ((ebox--foreign-region-in-range-p - (cdr range) (car segment) region-id) - (setq interleaved t)) - (t - (setcdr range (cdr segment)))))) - (unless interleaved - (ebox--set-box-extents region-id (car range) (cdr range)))))) - segments-by-region)) - -(defun ebox--active-extent-segments-for-region-ids - (active-segments region-ids) - "Return ACTIVE-SEGMENTS filtered to REGION-IDS." - (let ((region-set (ebox--region-id-set region-ids)) - (filtered (make-hash-table :test 'equal))) - (maphash - (lambda (region-id segments) - (when (gethash region-id region-set) - (puthash region-id segments filtered))) - active-segments) - filtered)) - -(defun ebox--refresh-box-extents-from-role-spans - (region-ids spans &optional active-segments) - "Refresh REGION-IDS extents in SPANS from the current role-span index. -The role index has already scanned the replacement text. Reusing it avoids a -second per-character pass over every Ebox property. Wrapper-only ownership is -merged from `ebox-content-owners'. Fall back to the ordinary scoped scanner -when no role index is available." - (if (not (ebox-buffer--region-role-span-table)) - (ebox--refresh-box-extents-for-region-ids-in-spans region-ids spans) - (let* ((refresh-region-ids - (ebox--box-extent-refresh-region-ids - region-ids spans active-segments)) - (region-set (ebox--region-id-set refresh-region-ids))) - (ebox--clear-box-extents-for-region-ids refresh-region-ids) - (when spans - (let* ((range (ebox--spans-bounding-range spans)) - (start (car range)) - (end (cdr range)) - (segments-by-region (make-hash-table :test 'equal)) - (table (ebox-buffer--region-role-span-table))) - (if active-segments - (ebox--refresh-box-extents-from-segments - (ebox--active-extent-segments-for-region-ids - active-segments refresh-region-ids)) - (cl-labels - ((record (region-id segment-start segment-end) - (when (and (gethash region-id region-set) - (< segment-start segment-end) - (< segment-start end) - (> segment-end start)) - (puthash region-id - (cons (cons (max start segment-start) - (min end segment-end)) - (gethash region-id segments-by-region)) - segments-by-region)))) - (dolist (region-id region-ids) - (dolist (entry ebox-region-types) - (dolist (span - (gethash (ebox-buffer--region-role-key - region-id (car entry)) - table)) - (let ((span-start - (if (markerp (car span)) - (marker-position (car span)) - (car span))) - (span-end - (if (markerp (cdr span)) - (marker-position (cdr span)) - (cdr span)))) - (when (and span-start span-end) - (record region-id span-start span-end)))))) - (let ((pos start)) - (while (< pos end) - (let* ((owners (get-text-property - pos 'ebox-content-owners)) - (next (or (next-single-property-change - pos 'ebox-content-owners nil end) - end))) - (dolist (region-id owners) - (record region-id pos next)) - (setq pos (max next (1+ pos))))))) - (ebox--refresh-box-extents-from-segments - segments-by-region))))))) - (defun ebox--clear-region-runtime-caches (region-ids) "Remove region-scoped runtime cache entries for REGION-IDS." (ebox--smooth-scroll-clear-regions region-ids) @@ -6870,7 +3228,6 @@ Defaults to the current buffer." (when-let ((root (ebox--buffer-root-node buffer))) (ebox--clear-region-runtime-caches (ebox--node-all-region-ids root))) - (ebox--clear-buffer-extents buffer) (ebox--clear-buffer-render-state buffer))) (defun ebox--cleanup-current-buffer () @@ -6878,151 +3235,6 @@ Defaults to the current buffer." (ebox--clear-buffer-runtime-state (current-buffer)) (setq-local ebox-surface--buffer-surface nil)) -(defun ebox--foreign-region-in-range-p (start end region-id) - "Return non-nil if START..END contains another ebox region." - (let ((pos start) - found) - (while (and (< pos end) (not found)) - (let ((next (or (next-property-change pos nil end) end))) - (save-excursion - (goto-char pos) - (skip-chars-forward "\n" next) - (when (< (point) next) - (let ((owners (ebox--pos-region-ids (point))) - (owner-stack - (get-text-property (point) 'ebox-content-owners))) - (when (cl-some (lambda (owner) - (and (not (equal owner region-id)) - (not (member region-id owner-stack)))) - owners) - (setq found t))))) - (setq pos next))) - found)) - -(defun ebox--region-property-segments-in-range (start end) - "Return sorted ebox region property segments between START and END." - (let (segments) - (let ((pos start)) - (while (< pos end) - (let* ((owners (get-text-property pos 'ebox-content-owners)) - (next (or (next-single-property-change - pos 'ebox-content-owners nil end) - end))) - (dolist (region-id owners) - (push (list region-id pos next) segments)) - (setq pos (max next (1+ pos)))))) - (dolist (entry ebox-region-types) - (let ((prop (cdr entry)) - (pos start)) - (while (< pos end) - (let* ((region-id (get-text-property pos prop)) - (next (or (next-single-property-change pos prop nil end) - end))) - (when region-id - (push (list region-id pos next) segments)) - (setq pos (max next (1+ pos))))))) - (sort segments - (lambda (left right) - (if (= (nth 1 left) (nth 1 right)) - (< (nth 2 left) (nth 2 right)) - (< (nth 1 left) (nth 1 right))))))) - -(defconst ebox--box-extents-segment-scan-min-chars 2048 - "Minimum span size before box extent refresh uses segment scanning.") - -(defun ebox--register-box-extents-by-position-scan - (start end &optional region-id-allow-set) - "Cache extents only for boxes that occupy one safe buffer slice. -Gaps containing only separators, such as newlines, are fine. Gaps -containing another ebox region mean the box is interleaved by a concat -layout and must not be replaced through a single `delete-region'. -When REGION-ID-ALLOW-SET is non-nil, register only ids in that hash set." - (let ((ranges (make-hash-table :test 'equal)) - (interleaved (make-hash-table :test 'equal)) - (pos start)) - (while (< pos end) - (dolist (region-id (ebox--pos-region-ids pos)) - (when (or (null region-id-allow-set) - (gethash region-id region-id-allow-set)) - (let ((range (gethash region-id ranges))) - (if range - (progn - (when (and (> pos (cdr range)) - (ebox--foreign-region-in-range-p - (cdr range) pos region-id)) - (puthash region-id t interleaved)) - (setcdr range (1+ pos))) - (puthash region-id (cons pos (1+ pos)) ranges))))) - (setq pos (1+ pos))) - (maphash - (lambda (region-id range) - (unless (gethash region-id interleaved) - (ebox--set-box-extents region-id (car range) (cdr range)))) - ranges))) - -(defun ebox--register-box-extents-by-segment-scan - (start end &optional region-id-allow-set) - "Cache extents in START..END by scanning region property segments. -When REGION-ID-ALLOW-SET is non-nil, register only ids in that hash set." - (let ((ranges (make-hash-table :test 'equal)) - (interleaved (make-hash-table :test 'equal))) - (dolist (segment (ebox--region-property-segments-in-range start end)) - (pcase-let ((`(,region-id ,segment-start ,segment-end) segment)) - (when (or (null region-id-allow-set) - (gethash region-id region-id-allow-set)) - (let ((range (gethash region-id ranges))) - (if range - (progn - (when (and (> segment-start (cdr range)) - (ebox--foreign-region-in-range-p - (cdr range) segment-start region-id)) - (puthash region-id t interleaved)) - (setcdr range (max (cdr range) segment-end))) - (puthash region-id - (cons segment-start segment-end) - ranges)))))) - (maphash - (lambda (region-id range) - (unless (gethash region-id interleaved) - (ebox--set-box-extents region-id (car range) (cdr range)))) - ranges))) - -(defun ebox--register-box-extents-in-range - (start end &optional region-id-allow-set) - "Cache live box extents for ebox regions between START and END. -When REGION-ID-ALLOW-SET is non-nil, register only ids in that hash set." - (if (< (- end start) ebox--box-extents-segment-scan-min-chars) - (ebox--register-box-extents-by-position-scan - start end region-id-allow-set) - (ebox--register-box-extents-by-segment-scan - start end region-id-allow-set))) - -(defun ebox--ensure-live-box-extents (region-id) - "Compute and cache live extents for REGION-ID on demand." - (when-let ((buffer (and (fboundp 'ebox--region-buffer) - (ebox--region-buffer region-id)))) - (with-current-buffer buffer - (if-let ((span (and (hash-table-p ebox--box-extent-template) - (gethash region-id ebox--box-extent-template)))) - (ebox--set-box-extents region-id (car span) (cdr span)) - (ebox--register-box-extents-in-range (point-min) (point-max))) - (gethash region-id ebox--box-extents)))) - -(defun ebox--live-box-extents (region-id) - "Return live extents for REGION-ID, or nil if unavailable." - (or (when-let ((extents (gethash region-id ebox--box-extents))) - (let* ((start-marker (car extents)) - (end-marker (cdr extents)) - (buffer (marker-buffer start-marker)) - (start (marker-position start-marker)) - (end (marker-position end-marker))) - (when (and buffer - (eq buffer (marker-buffer end-marker)) - start end - (< start end)) - extents))) - (ebox--ensure-live-box-extents region-id))) - (defun ebox--box-visible-overflow-p (box) "Return non-nil when BOX renders unowned visible overflow lines." (and (eq (ebox-get box :overflow) 'visible) @@ -7088,96 +3300,6 @@ When REGION-ID-ALLOW-SET is non-nil, register only ids in that hash set." (not (equal (ebox-get box :content) content-value)))) :unknown-keys (nreverse unknown-keys)))) -(defun ebox--region-update-buffer (region-id) - "Return the render buffer for REGION-ID, preferring a valid buffer hint." - (or (when (and ebox--region-update-buffer-hint - (buffer-live-p ebox--region-update-buffer-hint)) - (let ((region-id-set - (ebox--buffer-region-id-set ebox--region-update-buffer-hint))) - (when (and region-id-set - (gethash region-id region-id-set)) - ebox--region-update-buffer-hint))) - (ebox--region-buffer region-id))) - -(defun ebox--mirror-region-update-box (region-box-table region-id box) - "Mirror REGION-ID's BOX into REGION-BOX-TABLE and the legacy side table." - (when region-box-table - (puthash region-id box region-box-table)) - (puthash region-id box ebox--region-box-table)) - -(defun ebox--scroll-state-sync-after-update-p (report) - "Return non-nil when REPORT requires scroll cache synchronization." - (and report - (not (eq (plist-get report :strategy) 'no-op)) - (not (plist-get report :scroll-state-patch)) - (not (plist-get report :lazy-scroll-deferred-patch)))) - -(defun ebox--sync-scroll-offset-state-window (region-id changed-keys) - "Synchronize REGION-ID's own scroll state/window after CHANGED-KEYS. -Return non-nil only when the runtime offset, cached offset, and rendered window -are settled in the same update transaction." - (if (not (equal changed-keys '(:scroll-offset))) - t - (if-let ((state (ebox--scroll-get-state region-id))) - (when-let* ((box (plist-get state :box)) - (content-lines (plist-get state :content-lines)) - (content-height (plist-get state :content-height))) - (let* ((max-offset - (max 0 (- (length content-lines) content-height))) - (target (max 0 (min max-offset - (or (ebox-get box :scroll-offset) 0)))) - (current (or (plist-get state :scroll-offset) 0))) - (ebox-put box :scroll-offset target) - (unless (ebox--scroll-offset-state-settled-p region-id target) - (ebox--scroll-region-by region-id (- target current))) - (ebox--scroll-offset-state-settled-p region-id target))) - t))) - -(defun ebox--sync-scroll-state-after-batch-flush (buffer pending report) - "Synchronize final runtime nodes from successful batch PENDING entries." - (when (and buffer pending - (ebox--scroll-state-sync-after-update-p report)) - (let ((sources (make-hash-table :test 'equal)) - (final-report report) - source-order) - (dolist (entry pending) - (when-let* ((region-id (plist-get entry :region-id)) - (node (ebox--buffer-region-render-owner-node - buffer region-id)) - (node-id (ebox--ensure-node-id node))) - (if-let ((source (gethash node-id sources))) - (plist-put - source :changed-keys - (delete-dups - (append - (copy-sequence (plist-get source :changed-keys)) - (copy-sequence (plist-get entry :changed-keys))))) - (puthash node-id - (list :region-id region-id - :node node - :changed-keys - (copy-sequence (plist-get entry :changed-keys))) - sources) - (push node-id source-order)))) - (dolist (node-id (nreverse source-order)) - (let ((source (gethash node-id sources))) - (unless (ebox--sync-scroll-offset-state-window - (plist-get source :region-id) - (plist-get source :changed-keys)) - (error "Ebox could not atomically synchronize scroll state %S" - (plist-get source :region-id))) - (setq final-report - (ebox--sync-scroll-state-lines-for-node - buffer - (plist-get source :region-id) - (plist-get source :node) - (plist-get source :changed-keys) - final-report)))) - (ebox--set-buffer-update-report buffer final-report)))) - -(add-hook 'ebox-incremental--after-successful-batch-flush-hook - #'ebox--sync-scroll-state-after-batch-flush) - (defun ebox--schedule-runtime-prewarm-after-batch-flush (buffer _pending _report) "Schedule shared runtime prewarming after BUFFER's batch flush." @@ -7186,402 +3308,312 @@ are settled in the same update transaction." (add-hook 'ebox-incremental--after-successful-batch-flush-hook #'ebox--schedule-runtime-prewarm-after-batch-flush t) -(defun ebox--root-width-reflow-update-p (buffer node changed-keys) - "Return non-nil when NODE changes BUFFER's root width constraint." - (and buffer node - (equal (ebox--ensure-node-id node) - (ebox--buffer-root-node-id buffer)) - (cl-some (lambda (key) - (memq key '(:width :min-width :max-width))) - changed-keys))) -(defun ebox--surface-region-update (handle props) - "Apply PROPS to surface-scoped region HANDLE through one TP commit." - (pcase-let* ((`(,buffer . ,region-id) - (ebox-selector--region-target handle)) - (state (ebox--buffer-render-state buffer)) - (box (gethash region-id - (plist-get state :region-box-table))) - (expanded (ebox--expand-plist props)) - (analysis (ebox--region-update-analysis box expanded))) +(defun ebox--surface-region-candidate-root (buffer state) + "Return BUFFER's batch candidate or a fresh copy from STATE." + (or (ebox-incremental-surface-batch-root buffer) + (ebox-tree-copy-node-structure (plist-get state :root-node)))) + +(defun ebox--surface-region-apply-props (root region-id expanded) + "Apply EXPANDED properties to REGION-ID in ROOT and return changed keys." + (let ((box (ebox--root-region-box root region-id)) changed-keys) + (unless box + (user-error "Ebox region handle no longer resolves to a box")) + (cl-loop for (key value) on expanded by #'cddr + for target-key = (ebox--region-update-normalize-property key) + when (or (eq target-key :content) + (memq target-key ebox--region-update-longhand-props)) + unless (equal (ebox-get box target-key) value) + do (ebox-put box target-key value) + and do (push target-key changed-keys)) + (nreverse changed-keys))) + +(defun ebox--surface-region-no-op-report (state region-id handle) + "Return and store a no-op report for STATE, REGION-ID, and HANDLE." + (let ((report + (ebox--update-report + region-id 'no-op :region-handle handle :constraint-source 'region + :runtime-published nil :runtime-revision + (plist-get state :runtime-revision) + :surface-revision + (tp-surface-revision (plist-get state :surface)) + :dirty-count 0 :patch-count 0 :patch-ops nil))) + (plist-put state :last-update-report report) + report)) + +(defun ebox--surface-region-scroll-context (buffer region-id) + "Return read-only scroll publication context for REGION-ID in BUFFER." + (when-let ((node (ebox--buffer-region-render-owner-node buffer region-id))) + (let ((region-ids (ebox--node-all-region-ids node)) + states) + (maphash + (lambda (scroll-region-id state) + (when (ebox--scroll-state-covers-node-p buffer state node) + (push (cons scroll-region-id state) states))) + ebox--scroll-global-state) + (when states + (let* ((visible (ebox--region-ids-visible-in-buffer-p + buffer region-ids)) + (deferred + (and (not visible) + (cl-some + (lambda (entry) + (let ((state (cdr entry))) + (and (plist-get state :materialize-content-lines) + (not (ebox--scroll-hot-content-line-spans + state region-ids))))) + states)))) + (list :node node :states states :visible visible + :deferred deferred)))))) + +(defun ebox--surface-region-scroll-report (context changed-keys) + "Return TP-era scroll report fields for CONTEXT and CHANGED-KEYS." + (when-let ((node (plist-get context :node))) + (let* ((count (length (plist-get context :states))) + (paint-p + (eq (ebox--region-update-dirty-kind changed-keys) 'paint)) + (content-p (memq :content changed-keys)) + (deferred (plist-get context :deferred))) + (cond + (paint-p + (list :strategy 'paint-patch + :patch-ops '(paint-patch) + :owner-id (plist-get node :node-id) + :owner-ids (list (plist-get node :node-id)) + :lazy-scroll-deferred-patch nil + :scroll-state-paint-sync t + :scroll-state-sync-count count + :scroll-state-index-preserved t)) + ((or content-p deferred) + (list :strategy 'span-patch + :patch-count (if deferred 0 1) + :patch-ops (and (not deferred) '(span-patch)) + :owner-id (plist-get node :node-id) + :owner-ids (list (plist-get node :node-id)) + :scroll-state-patch t + :lazy-scroll-deferred-patch (and deferred t))) + (t + (list :scroll-state-sync t + :scroll-state-sync-count count + :scroll-state-sync-refreshed + (and (plist-get context :visible) t) + :scroll-state-sync-refreshed-count + (if (plist-get context :visible) count 0) + :scroll-state-sync-deferred nil + :scroll-state-sync-deferred-count 0 + :scroll-state-patch + (and (not (plist-get context :visible)) t))))))) + +(defun ebox--surface-prepare-scroll-candidate (commit-input context) + "Apply scroll CONTEXT state to isolated COMMIT-INPUT and return it." + (when (plist-get context :deferred) + (let ((table + (plist-get (plist-get commit-input :state-overrides) + :scroll-state-table))) + (dolist (entry (plist-get context :states)) + (when-let ((state (gethash (car entry) table))) + (plist-put state :lazy-scroll-prefix-dirty t) + (puthash (car entry) state table))))) + commit-input) + +(defun ebox--surface-scroll-scope-node-ids + (buffer context changed-keys) + "Return BUFFER scroll owner ids required by CHANGED-KEYS in CONTEXT." + (unless (eq (ebox--region-update-dirty-kind changed-keys) 'paint) + (delete-dups + (delq nil + (mapcar + (lambda (entry) + (ebox--buffer-region-render-owner-node-id buffer (car entry))) + (plist-get context :states)))))) + +(defun ebox--surface-scroll-changes (buffer pending) + "Return scroll publication contexts for PENDING entries in BUFFER." + (mapcar + (lambda (entry) + (list :region-id (plist-get entry :region-id) + :changed-keys (plist-get entry :changed-keys) + :context + (ebox--surface-region-scroll-context + buffer (plist-get entry :region-id)))) + pending)) + +(defun ebox--surface-prepare-scroll-changes (commit-input entries) + "Apply every item in ENTRIES to isolated COMMIT-INPUT and return it." + (dolist (change entries commit-input) + (ebox--surface-prepare-scroll-candidate + commit-input (plist-get change :context)))) + +(defun ebox--surface-scroll-change-scope-node-ids (buffer entries) + "Return enclosing scroll owner ids for BUFFER using ENTRIES." + (delete-dups + (cl-loop for change in entries + append + (ebox--surface-scroll-scope-node-ids + buffer (plist-get change :context) + (plist-get change :changed-keys))))) + +(defun ebox--surface-overflow-scope-node-ids (buffer node-ids entries) + "Return BUFFER ancestors needed for NODE-IDS using overflow ENTRIES." + (when (cl-some (lambda (change) + (memq :overflow (plist-get change :changed-keys))) + entries) + (let ((parents (ebox--buffer-parent-table buffer)) ancestors) + (dolist (node-id node-ids) + (while (setq node-id (gethash node-id parents)) + (push node-id ancestors))) + (delete-dups ancestors)))) + +(defun ebox--surface-content-scope-node-ids (buffer node-ids entries) + "Return BUFFER parents needed for NODE-IDS using content ENTRIES." + (when (cl-some (lambda (change) + (memq :content (plist-get change :changed-keys))) + entries) + (let ((parents (ebox--buffer-parent-table buffer))) + (delete-dups + (delq nil + (mapcar (lambda (node-id) (gethash node-id parents)) + node-ids)))))) + +(defun ebox--publish-scoped-region-candidate + (buffer candidate-root region-id changed-keys report-overrides + &optional changes) + "Publish BUFFER CANDIDATE-ROOT with REPORT-OVERRIDES. +REGION-ID and CHANGED-KEYS describe one update; CHANGES describes a batch." + (let* ((state (ebox--buffer-render-state buffer))) + (ebox--cancel-buffer-runtime-prewarm buffer) + (ebox-incremental--notify-before-runtime-mutation buffer 'region-update) + (unless (eq state (ebox--buffer-render-state buffer)) + (error "Ebox runtime changed during region update notification")) + (let* ((ebox-viewport-width (plist-get state :viewport-width)) + (ebox-viewport-height (plist-get state :viewport-height)) + (scroll-context + (and region-id + (ebox--surface-region-scroll-context buffer region-id))) + (changes + (or changes + (and region-id + (list (list :region-id region-id + :changed-keys changed-keys + :context scroll-context))))) + (report-overrides + (append report-overrides + (ebox--surface-region-scroll-report + scroll-context changed-keys))) + (commit-input + (ebox--surface-prepare-scroll-changes + (ebox-incremental-prepare-scoped-commit + buffer candidate-root report-overrides) + changes)) + (scope-node-ids + (let* ((planned + (copy-sequence (plist-get commit-input :scope-node-ids))) + (scroll + (ebox--surface-scroll-change-scope-node-ids + buffer changes))) + (delete-dups + (append planned scroll + (ebox--surface-content-scope-node-ids + buffer planned changes) + (ebox--surface-overflow-scope-node-ids + buffer planned changes))))) + (surface + (ebox-surface-update-buffer-scoped + buffer + (plist-get commit-input :root) + scope-node-ids + (plist-get commit-input :report-base) + (plist-get commit-input :state-overrides) + nil nil))) + (plist-get (tp-surface-client-state surface) :last-update-report)))) + +(defun ebox--publish-surface-region-batch (buffer candidate-root pending) + "Publish BUFFER's accumulated CANDIDATE-ROOT for entries in PENDING." + (let ((change (ebox-incremental--batch-change buffer pending))) + (ebox--publish-scoped-region-candidate + buffer candidate-root nil nil + (append + (ebox--constraint-change-report-props change) + (list :region-ids + (mapcar (lambda (entry) (plist-get entry :region-id)) pending) + :region-handles + (delq nil + (mapcar (lambda (entry) + (plist-get entry :region-handle)) + pending)))) + (ebox--surface-scroll-changes buffer pending)))) + +(defun ebox--surface-region-update-target (buffer region-id handle props) + "Apply PROPS to BUFFER REGION-ID through TP for optional HANDLE." + (let* ((state (ebox--buffer-render-state buffer)) + (candidate-root + (ebox--surface-region-candidate-root buffer state)) + (candidate-box + (or (ebox--root-region-box candidate-root region-id) + (user-error "Ebox region handle no longer resolves to a box"))) + (expanded (ebox--expand-plist props)) + (old-style-values + (and (plist-member expanded :overflow) + (list :overflow (ebox-get candidate-box :overflow)))) + (analysis (ebox--region-update-analysis candidate-box expanded))) (dolist (key (plist-get analysis :unknown-keys)) (message "ebox-region-update: unknown key %S (ignored)" key)) (if (not (plist-get analysis :changed-p)) - (let ((report - (ebox--update-report - nil 'no-op :region-handle handle :constraint-source 'region - :runtime-published nil :runtime-revision - (plist-get state :runtime-revision) - :surface-revision - (tp-surface-revision (plist-get state :surface)) - :dirty-count 0 :patch-count 0 :patch-ops nil))) - (plist-put state :last-update-report report) - report) - (let* ((candidate-root - (ebox-tree-copy-node-structure - (plist-get state :root-node))) - (candidate-box - (ebox--root-region-box candidate-root region-id)) - changed-keys) - (unless candidate-box - (user-error "Ebox region handle no longer resolves to a box")) - (cl-loop for (key value) on expanded by #'cddr - for target-key = (ebox--region-update-normalize-property key) - when (or (eq target-key :content) - (memq target-key ebox--region-update-longhand-props)) - unless (equal (ebox-get candidate-box target-key) value) - do (ebox-put candidate-box target-key value) - and do (push target-key changed-keys)) - (setq changed-keys (nreverse changed-keys)) - (let ((ebox-viewport-width (plist-get state :viewport-width)) - (ebox-viewport-height (plist-get state :viewport-height))) - (let ((surface - (ebox-surface-mount-buffer - buffer candidate-root - (list :region-handle handle - :constraint-source 'region - :dirty-count 1 - :dirty-keys changed-keys - :dirty-kinds - (list (ebox--region-update-dirty-kind changed-keys))) - nil t - (list :viewport-width (plist-get state :viewport-width) - :viewport-height (plist-get state :viewport-height) - :detached-identity-history - (plist-get state :detached-identity-history))))) - (plist-get (tp-surface-client-state surface) - :last-update-report))))))) + (unless (ebox-incremental-batching-p buffer) + (ebox--surface-region-no-op-report state region-id handle)) + (if (and (not (ebox-incremental-batching-p buffer)) + (= (length expanded) 2) + (eq (ebox--region-update-normalize-property (car expanded)) + :scroll-offset)) + (ebox--surface-scroll-to-offset buffer region-id (cadr expanded)) + (let* ((changed-keys + (ebox--surface-region-apply-props + candidate-root region-id expanded)) + (dirty-kind + (ebox--region-update-dirty-kind changed-keys)) + (constraint-change + (ebox--region-constraint-change + buffer region-id dirty-kind changed-keys))) + (if (ebox-incremental-record-surface-region-change + buffer candidate-root region-id handle dirty-kind + changed-keys old-style-values + #'ebox--publish-surface-region-batch) + (progn + (ebox--cancel-buffer-runtime-prewarm buffer) + nil) + (ebox--publish-scoped-region-candidate + buffer candidate-root region-id changed-keys + (append (if handle + (list :region-handle handle) + (list :region-id region-id)) + (ebox--constraint-change-report-props + constraint-change))))))))) + +(defun ebox--surface-region-update (handle props) + "Apply PROPS to surface-scoped region HANDLE through one TP commit." + (pcase-let ((`(,buffer . ,region-id) + (ebox-selector--region-target handle))) + (ebox--surface-region-update-target buffer region-id handle props))) ;;;###autoload -(defun ebox-region-update (region-id &rest props) - "Update one or more properties of the box identified by REGION-ID. - -REGION-ID may also be an opaque handle returned by `ebox-region-resolve'. -Handles are surface-scoped and are the preferred form when one declarative -source is mounted in more than one buffer. - -PROPS accepts the same shorthand and longhand keywords as `ebox-create'. -Properties are expanded through `ebox-property-rules', written to the box -plist, then rendered once. Unsupported pseudo properties such as -:display are ignored by the shared rule table; :visibility affects rendering. -Return nil when the change is queued in an active explicit batch. Otherwise -return the report stored by `ebox-buffer-update-report'; legal same-value -updates return a `no-op' report. +(defun ebox-region-update (handle &rest props) + "Update the box identified by surface-scoped HANDLE with PROPS. +HANDLE must come from ebox-region-resolve or an Ebox selector result. PROPS +accepts the same shorthand and longhand keywords as ebox-create. The update +builds an isolated candidate, runs the Ebox owner planner, and publishes once +through the mounted TP surface. Return nil while queued in an explicit batch; +otherwise return the committed update report. Examples: - (ebox-region-update id :content \"Hello\") (ebox-region-update (ebox-region-resolve buffer \"status\") :content \"Ready\") - (ebox-region-update id :padding (list 1 2) :border-color \"red\") - (ebox-region-update id :content \"Hi\" :width 30 :color \"white\")" + (ebox-region-update handle :padding (list 1 2) + :border-color \"red\")" + (unless (ebox-region-handle-p handle) + (signal (quote wrong-type-argument) + (list (quote ebox-region-handle-p) handle))) (ebox--with-render-gc - (if (ebox-region-handle-p region-id) - (ebox--surface-region-update region-id props) - (ebox--with-validated-display-cache - (catch 'ebox-region-update-result - (let* ((inhibit-read-only t) - (buffer (ebox--region-update-buffer region-id)) - (region-box-table - (and buffer (ebox--buffer-region-box-table buffer))) - (box - (if (hash-table-p region-box-table) - (or (gethash region-id region-box-table) - (error "No box found in buffer runtime for region %S" - region-id)) - (or (gethash region-id ebox--region-box-table) - (error "No box found for region %S" region-id)))) - (expanded (ebox--expand-plist props)) - (analysis (ebox--region-update-analysis box expanded))) - (dolist (key (plist-get analysis :unknown-keys)) - (message "ebox-region-update: unknown key %S (ignored)" key)) - (unless (plist-get analysis :changed-p) - (if (and buffer (ebox-incremental-batching-p buffer)) - (throw 'ebox-region-update-result nil) - (let ((report - (ebox--update-report - region-id 'no-op - :dirty-count 0 - :patch-count 0 - :patch-ops nil))) - (when buffer - (ebox--set-buffer-update-report buffer report)) - (throw 'ebox-region-update-result report)))) - (when (and buffer ebox--prepared-root-render - (not (ebox-incremental-batching-p buffer)) - (= (length expanded) 2) - (eq (ebox--region-update-normalize-property (car expanded)) - :width)) - (let* ((state (ebox--buffer-render-state buffer)) - (root (and state (plist-get state :root-node))) - (root-box - (pcase (plist-get root :ebox-type) - ('box root) - ('flex (plist-get root :box)))) - (node (ebox--buffer-region-render-owner-node buffer region-id)) - (target-box - (and root-box - (plist-put (copy-sequence root-box) - :width (cadr expanded)))) - (target-width - (and target-box - (ebox--literal-root-pixel-width target-box))) - (report - (and (eq box root-box) target-width - (ebox--root-width-reflow-update-p - buffer node '(:width)) - (with-current-buffer buffer - (ebox-incremental--publish-prepared-root-native - buffer - (list - :kind 'root-width :region-id region-id :box box - :model-node-id (ebox--ensure-node-id box) - :target-root-width target-width - :target-viewport-width - (plist-get state :viewport-width) - :target-viewport-height - (plist-get state :viewport-height))))))) - (when report - (ebox--mirror-region-update-box - region-box-table region-id box) - (ebox--schedule-buffer-runtime-prewarm buffer) - (throw 'ebox-region-update-result report)))) - (let* ((state (and buffer (ebox--buffer-render-state buffer))) - (node (and buffer - (ebox--buffer-region-render-owner-node - buffer region-id))) - (affected-scroll-ids - (let ((subtree-ids - (and node (ebox--node-all-region-ids node))) - (containing-ids - (and buffer node - (ebox--scroll-state-region-ids-containing-node-ids - buffer (list (ebox--ensure-node-id node)))))) - (delete-dups - (delq nil (append (list region-id) - subtree-ids - containing-ids))))) - (box-before (copy-tree box)) - (state-revision-before - (and state (plist-get state :runtime-revision))) - (state-reflow-scratch-before - (and state (plist-get state :reflow-prewarm-scratch))) - (state-report-before - (and state (plist-get state :last-update-report))) - (scroll-before - (mapcar - (lambda (scroll-id) - (let ((scroll-state (ebox--scroll-get-state scroll-id))) - (list - scroll-id scroll-state - (cl-loop for (key value) on scroll-state by #'cddr - append - (list - key - (if (or - (eq key :box) - (memq - key - ebox--scroll-cache-transient-state-keys)) - value - (copy-tree value))))))) - affected-scroll-ids)) - (transaction-completed nil) - (prewarm-cancelled nil) - (mutation-notified-p nil) - changed-keys - old-style-values - result-report - (content-seen nil) - content-val - content-changed) - (ebox--mirror-region-update-box region-box-table region-id box) - (cl-labels - ((restore-plist-object - (object snapshot) - (when object - (setcar object (car snapshot)) - (setcdr object (cdr snapshot)))) - (restore-model () - (let ((inhibit-quit t)) - (restore-plist-object box box-before) - (ebox--mirror-region-update-box - region-box-table region-id box) - (dolist (entry scroll-before) - (pcase-let ((`(,scroll-id ,scroll-state ,snapshot) entry)) - (if scroll-state - (progn - (restore-plist-object scroll-state snapshot) - (puthash scroll-id scroll-state - ebox--scroll-global-state)) - (remhash scroll-id ebox--scroll-global-state)))) - (when state - (plist-put state :runtime-revision state-revision-before) - (plist-put state :reflow-prewarm-scratch - state-reflow-scratch-before) - (plist-put state :last-update-report state-report-before)) - (when (buffer-live-p buffer) - (ebox--refresh-buffer-box-extents buffer) - (ebox-buffer-refresh-region-role-spans buffer) - (ebox--refresh-buffer-scroll-content-markers buffer)))) - (run-update () - (cl-loop for (k v) on expanded by #'cddr do - (let ((target-key - (ebox--region-update-normalize-property k))) - (cond - ((eq target-key :content) - (setq content-seen t - content-val v)) - ((memq target-key ebox--region-update-longhand-props) - (unless (equal (ebox-get box target-key) v) - (when (and (eq target-key :overflow) - (not (plist-member old-style-values - target-key))) - (setq old-style-values - (plist-put old-style-values target-key - (ebox-get box target-key)))) - (when (and buffer (not prewarm-cancelled)) - (ebox--cancel-buffer-runtime-prewarm buffer) - (setq prewarm-cancelled t)) - (unless mutation-notified-p - (when buffer - (ebox-incremental--notify-before-runtime-mutation - buffer 'region-update)) - (setq mutation-notified-p t)) - (ebox-put box target-key v) - (push target-key changed-keys))) - (t nil)))) - (setq content-changed - (and content-seen - (not (equal (ebox-get box :content) content-val)))) - (when (and (null changed-keys) - (not content-changed) - (not (and buffer - (ebox-incremental-batching-p buffer)))) - (setq result-report - (ebox--update-report - region-id 'no-op - :dirty-count 0 - :patch-count 0 - :patch-ops nil)) - (when buffer - (ebox--set-buffer-update-report buffer result-report))) - (when (and buffer (or changed-keys content-changed)) - (unless prewarm-cancelled - (ebox--cancel-buffer-runtime-prewarm buffer) - (setq prewarm-cancelled t)) - (ebox--invalidate-buffer-render-signatures - buffer (ebox--ensure-node-id box))) - ;; content sync (scroll state + rerender) - (when content-changed - (unless mutation-notified-p - (when buffer - (ebox-incremental--notify-before-runtime-mutation - buffer 'region-update)) - (setq mutation-notified-p t)) - (let ((scroll-state (ebox--scroll-get-state region-id))) - (ebox-put box :content content-val) - (push :content changed-keys) - (when scroll-state - (let* ((formatted (ebox--format-content box)) - (formatted-lines (ebox-string-lines formatted)) - (text-height (length formatted-lines)) - (content-height (ebox--content-height box text-height)) - (requested-offset - (if (plist-member expanded :scroll-offset) - (or (ebox-get box :scroll-offset) 0) - (or (plist-get scroll-state :scroll-offset) 0))) - (scroll-offset - (max 0 - (min requested-offset - (max 0 (- text-height content-height)))))) - (ebox-put box :scroll-offset scroll-offset) - (setq scroll-state - (ebox--scroll-state-set-lines - scroll-state formatted-lines)) - (plist-put scroll-state :content-height content-height) - (plist-put scroll-state :scroll-offset scroll-offset) - (plist-put scroll-state :box box))))) - ;; Merge non-content changes into one dirty kind, then let the - ;; shared constraint-change executor choose the smallest valid - ;; patch owner. - (when changed-keys - (setq changed-keys (nreverse changed-keys)) - (let* ((dirty-kind (ebox--region-update-dirty-kind changed-keys)) - (batched - (and buffer - (ebox-incremental-record-region-change - buffer region-id dirty-kind changed-keys - old-style-values))) - root-width-reflow) - (when batched - (ebox--cancel-buffer-runtime-prewarm buffer)) - (unless batched - (setq result-report - (let* ((_root-width-reflow - (setq root-width-reflow - (ebox--root-width-reflow-update-p - buffer node changed-keys)))) - (when (and node (not root-width-reflow)) - (ebox--ensure-visible-node-runtime-indexes - buffer node region-id)) - (or (ebox--try-scroll-offset-fast-path - buffer region-id changed-keys) - (and node (not root-width-reflow) - (eq dirty-kind 'geometry) - (ebox--try-lazy-scroll-deferred-patch - buffer region-id node changed-keys)) - (and node (not root-width-reflow) - (eq dirty-kind 'geometry) - (ebox--try-hidden-scroll-state-span-patch - buffer region-id node changed-keys)) - (and node (not root-width-reflow) - (eq dirty-kind 'geometry) - (ebox--try-hidden-scroll-root-fallback - buffer region-id node changed-keys)) - (and node (not root-width-reflow) - (eq dirty-kind 'geometry) - (ebox--try-scroll-state-span-patch - buffer region-id node changed-keys)) - (ebox--rerender-region-for-dirty-kind - region-id dirty-kind changed-keys buffer - old-style-values)))) - (when buffer - (let* ((confirm-reflow-scratch - (and root-width-reflow - (equal changed-keys '(:width)))) - (revision - (ebox--bump-buffer-runtime-revision - buffer confirm-reflow-scratch))) - (when confirm-reflow-scratch - (ebox--confirm-reflow-prewarm-scratch - buffer region-id revision)))) - (when (and buffer - (not root-width-reflow) - (ebox--scroll-state-sync-after-update-p - result-report)) - (unless (ebox--sync-scroll-offset-state-window - region-id changed-keys) - (error - "Ebox could not atomically synchronize scroll state %S" - region-id)) - (setq result-report - (ebox--sync-scroll-state-lines-for-node - buffer region-id node changed-keys result-report))) - (when (and buffer result-report) - (ebox--set-buffer-update-report buffer result-report)) - (when buffer - (ebox--schedule-buffer-runtime-prewarm buffer))))))) - (unwind-protect - (progn - (if (buffer-live-p buffer) - (with-current-buffer buffer - (atomic-change-group - (run-update))) - (run-update)) - (setq transaction-completed t)) - (unless transaction-completed - (restore-model))) - result-report)))))))) + (ebox--surface-region-update handle props))) (defvar ebox-scroll-map nil "Keymap for scroll interaction within ebox.") @@ -7676,44 +3708,22 @@ NODE can be any box/concat/stack node created by `ebox-create', (defun ebox--host-ref-role-bounds (buffer node) "Return NODE's margin-free role bounds in BUFFER, or nil." - (with-current-buffer buffer - (save-restriction - (widen) - (let (start end) - (dolist (region-id (ebox--node-all-region-ids node)) - (dolist (role ebox--horizontal-border-anchor-roles) - (dolist (span (ebox--region-find region-id role)) - (let ((span-start (if (markerp (car span)) - (marker-position (car span)) - (car span))) - (span-end (if (markerp (cdr span)) - (marker-position (cdr span)) - (cdr span))) - (start-buffer (and (markerp (car span)) - (marker-buffer (car span)))) - (end-buffer (and (markerp (cdr span)) - (marker-buffer (cdr span))))) - (when (and span-start span-end (< span-start span-end) - (or (and (null start-buffer) (null end-buffer)) - (and (eq start-buffer buffer) - (eq end-buffer buffer)))) - (setq start (if start (min start span-start) span-start) - end (if end (max end span-end) span-end))))))) - (and start end (cons start end)))))) - -(defun ebox--host-ref-extent-bounds (buffer node) - "Return fallback live extent bounds for NODE in BUFFER, or nil." (let (start end) (dolist (region-id (ebox--node-all-region-ids node)) - (when-let* ((extents (ebox--live-box-extents region-id)) - (start-marker (car extents)) - (end-marker (cdr extents)) - (region-start (marker-position start-marker)) - (region-end (marker-position end-marker))) - (when (and (eq (marker-buffer start-marker) buffer) - (eq (marker-buffer end-marker) buffer)) - (setq start (if start (min start region-start) region-start) - end (if end (max end region-end) region-end))))) + (when-let ((bounds + (ebox-surface-region-bounds + buffer region-id ebox--horizontal-border-anchor-roles))) + (setq start (if start (min start (car bounds)) (car bounds)) + end (if end (max end (cdr bounds)) (cdr bounds))))) + (and start end (cons start end)))) + +(defun ebox--host-ref-mount-bounds (buffer node) + "Return fallback TP mount bounds for NODE in BUFFER, or nil." + (let (start end) + (dolist (region-id (ebox--node-all-region-ids node)) + (when-let ((bounds (ebox-surface-region-bounds buffer region-id))) + (setq start (if start (min start (car bounds)) (car bounds)) + end (if end (max end (cdr bounds)) (cdr bounds))))) (and start end (< start end) (cons start end)))) ;;;###autoload @@ -7722,16 +3732,16 @@ NODE can be any box/concat/stack node created by `ebox-create', The result is a `(START . END)' pair spanning the referenced runtime node's rendered border-box characters (excluding margins), or nil when the buffer, -reference, or live box extents no longer exist. HOST-REF is opaque and +reference, or live TP mounts no longer exist. HOST-REF is opaque and compared with `equal'." (when-let* ((buffer (ebox--host-ref-buffer buffer-or-name)) (node (ebox--host-ref-node buffer host-ref))) (or (ebox--host-ref-role-bounds buffer node) - (ebox--host-ref-extent-bounds buffer node)))) + (ebox--host-ref-mount-bounds buffer node)))) ;;;###autoload (defun ebox-host-ref-position (buffer-or-name host-ref) - "Return the first live buffer position for HOST-REF, or nil. + "Return HOST-REF's first live position in BUFFER-OR-NAME, or nil. See `ebox-host-ref-bounds' for lookup and lifetime semantics." (car-safe (ebox-host-ref-bounds buffer-or-name host-ref))) @@ -7801,12 +3811,19 @@ Return the successful publication report stored by `ebox-buffer-update-report'." (or after-publication ebox-incremental--after-declarative-publication)) (surface - (ebox-surface-mount-buffer - buffer source - (plist-get commit-input :report-base) - callback - (plist-get commit-input :preserve-identities-p) - (plist-get commit-input :state-overrides)))) + (if-let ((scope-node-ids + (plist-get commit-input :scope-node-ids))) + (ebox-surface-update-buffer-scoped + buffer source scope-node-ids + (plist-get commit-input :report-base) + (plist-get commit-input :state-overrides) + callback) + (ebox-surface-mount-buffer + buffer source + (plist-get commit-input :report-base) + callback + (plist-get commit-input :preserve-identities-p) + (plist-get commit-input :state-overrides))))) (plist-get (tp-surface-client-state surface) :last-update-report)))) ;;;###autoload @@ -7820,14 +3837,25 @@ through dirty-set and patch-set execution before falling back to root rerender." (let* ((state (ebox--buffer-render-state buffer)) (old-viewport-width (and state (plist-get state :viewport-width)))) + (unless (ebox-surface-buffer-mounted-p buffer) + (user-error "Ebox buffer has no mounted TP surface: %S" buffer)) (ebox--cancel-buffer-runtime-prewarm buffer) (prog1 - (ebox-incremental-rerender-buffer-with-context + (ebox-surface-update-buffer-viewport buffer viewport-width viewport-height) (ebox--schedule-buffer-runtime-prewarm buffer) (ebox--schedule-buffer-reflow-cache-prewarm buffer old-viewport-width viewport-width)))) +(defun ebox--rerender-buffer-preserving-runtime (buffer) + "Rerender BUFFER from stored runtime state without rebuilding identity." + (let ((state (ebox--buffer-render-state buffer))) + (unless state + (user-error "Ebox buffer has no rendered runtime: %S" buffer)) + (ebox-rerender-buffer-with-context + buffer (plist-get state :viewport-width) + (plist-get state :viewport-height)))) + ;;;###autoload (defun ebox-display-buffer (buffer-or-name node) "Render NODE to BUFFER-OR-NAME and display the buffer with `pop-to-buffer'." diff --git a/tests/ebox-core-render-tests.el b/tests/ebox-core-render-tests.el index fc03435..7179b0b 100644 --- a/tests/ebox-core-render-tests.el +++ b/tests/ebox-core-render-tests.el @@ -20,12 +20,10 @@ (setq ebox--scroll-allow-noninteractive-prefetch t) (ebox-cache-reset-default-registry) (dolist (table (list ebox--region-box-table - ebox--box-extents ebox--buffer-render-state-table ebox--scroll-global-state ebox--render-root-cache-table-table ebox--render-cache-entry-side-effects-table - ebox--rendered-root-metadata-table ebox--rendered-uniform-width-table ebox--flex-sized-render-observation-table)) (clrhash table)) @@ -94,6 +92,38 @@ "Return the first runtime node id matching SELECTOR in BUFFER." (plist-get (car (ebox-selector-query-buffer buffer selector)) :node-id)) +(defun ebox-test--region-handle-for-id (region-id) + "Return the unique live surface handle for test REGION-ID." + (let (handle ambiguous) + (maphash + (lambda (buffer state) + (when (and (buffer-live-p buffer) + (gethash region-id (plist-get state :region-id-set))) + (let* ((object + (gethash region-id + (plist-get state :region-surface-object-table))) + (candidate + (and object + (ebox-selector--live-region-handle buffer object)))) + (when candidate + (if handle + (setq ambiguous t) + (setq handle candidate)))))) + ebox--buffer-render-state-table) + (when ambiguous + (error "Test region id is ambiguous across mounted surfaces: %S" + region-id)) + (or handle + (error "Test region id has no mounted surface handle: %S" region-id)))) + +(defun ebox-test--region-update (target &rest props) + "Update test TARGET through the public surface-scoped handle API." + (apply #'ebox-region-update + (if (ebox-region-handle-p target) + target + (ebox-test--region-handle-for-id target)) + props)) + (ert-deftest ebox-scroll-window-lookahead-accepts-root-reflow-override () "Root reflow should be able to defer all hidden lookahead work." (let ((ebox-scroll-lazy-prefix-lookahead-lines 8) @@ -254,6 +284,28 @@ face)) (t nil))) +(defun ebox-test--face-visual-attributes (face) + "Return FACE attributes normalized by Emacs anonymous-face precedence." + (let ((entries (if (and (listp face) (not (keywordp (car-safe face)))) + face + (list face))) + attributes named) + (dolist (entry entries) + (cond + ((null entry)) + ((and (listp entry) (keywordp (car-safe entry))) + (cl-loop for (key value) on entry by #'cddr + unless (assq key attributes) + do (push (cons key value) attributes))) + (t (setq named t)))) + (if named + (list 'raw face) + (list 'visual + (sort attributes + (lambda (left right) + (string< (symbol-name (car left)) + (symbol-name (car right))))))))) + (defun ebox-test--border-face-has-color-p (face role color) "Return non-nil when FACE paints border ROLE with COLOR." (pcase role @@ -281,17 +333,17 @@ "Return first content face in STRING." (ebox-test--face-at-property string 'ebox-content)) -(defmacro ebox-test--count-full-rerenders (&rest body) - "Run BODY and return the number of full buffer rerenders it triggered." +(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)) - `(let ((full-rerenders 0) - (original-full-rerender (symbol-function 'ebox--full-rerender-buffer))) - (cl-letf (((symbol-function 'ebox--full-rerender-buffer) + `(let ((full-updates 0) + (original-full-update (symbol-function 'tp-surface-update))) + (cl-letf (((symbol-function 'tp-surface-update) (lambda (&rest args) - (cl-incf full-rerenders) - (apply original-full-rerender args)))) + (cl-incf full-updates) + (apply original-full-update args)))) ,@body) - full-rerenders)) + full-updates)) (defconst ebox-test--region-update-paint-cases '((:color "#123456" "#123456") @@ -372,15 +424,6 @@ (should (equal (ebox-region-ids layout) ids-before)) (should (equal (ebox-region-ids (ebox-render layout)) ids-before)))) -(ert-deftest ebox-box-extent-template-excludes-interleaved-concat-boxes () - "Prepared extents should omit boxes split by a neighboring concat column." - (ebox-test--reset-runtime-state) - (let* ((left (ebox-create :content "A\nB" :width 60)) - (right (ebox-create :content "R1\nR2\nR3" :width 80)) - (rendered (ebox-render (ebox-concat left right))) - (template (ebox--build-box-extent-template rendered))) - (should-not (gethash (plist-get left :region-id) template)) - (should-not (gethash (plist-get right :region-id) template)))) (ert-deftest ebox-live-mount-uses-runtime-copy-and-leaves-source-stable () "Live publication should update a surface copy without mutating SOURCE." @@ -396,7 +439,7 @@ (let* ((runtime-root (ebox-test--runtime-root buffer)) (handle (ebox-region-resolve buffer "root"))) (should-not (eq runtime-root source)) - (ebox-region-update handle + (ebox-test--region-update handle :content "after\nhidden" :color "#222222" :width '(140)) @@ -436,12 +479,12 @@ (let ((first-handle (ebox-region-resolve first-buffer "root")) (second-handle (ebox-region-resolve second-buffer "root"))) (should-not (eq first-handle second-handle)) - (ebox-region-update first-handle :content "first changed") + (ebox-test--region-update first-handle :content "first changed") (with-current-buffer first-buffer (should (string-match-p "first changed" (buffer-string)))) (with-current-buffer second-buffer (should-not (string-match-p "first changed" (buffer-string)))) - (ebox-region-update second-handle :content "second changed") + (ebox-test--region-update second-handle :content "second changed") (with-current-buffer second-buffer (should (string-match-p "second changed" (buffer-string)))) (should (equal (ebox-get source :content) "owned\nhidden")) @@ -464,13 +507,13 @@ (let ((old-handle (ebox-region-resolve primary-buffer "old"))) (kill-buffer primary-buffer) (setq primary-buffer nil) - (should-error (ebox-region-update old-handle :content "stale") + (should-error (ebox-test--region-update old-handle :content "stale") :type 'user-error)) (setq secondary-buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-live-secondary*") old-source)) - (ebox-region-update (ebox-region-resolve secondary-buffer "old") + (ebox-test--region-update (ebox-region-resolve secondary-buffer "old") :content "reused") (with-current-buffer secondary-buffer (should (string-match-p "reused" (buffer-string)))) @@ -479,30 +522,6 @@ (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer)))))) -(ert-deftest ebox-region-update-hint-uses-buffer-owned-runtime-box () - "Hinted direct updates should use the buffer-owned box table over globals." - (ebox-test--reset-runtime-state) - (let* ((source (ebox-build - '(box :id "target" :content "before" :width (120) - :color "#111111"))) - (buffer (generate-new-buffer " *ebox-hinted-runtime-box*"))) - (unwind-protect - (progn - (ebox-render-to-buffer buffer source) - (let* ((region-id (ebox-test--selector-region-id buffer "#target")) - (runtime-box (gethash region-id ebox--region-box-table)) - (poison (ebox-create :content "poison" :width 120 - :color "#ff0000"))) - (puthash region-id poison ebox--region-box-table) - (let ((ebox--region-update-buffer-hint buffer)) - (ebox-region-update region-id :content "after")) - (should (equal (ebox-get runtime-box :content) "after")) - (should (equal (ebox-get poison :content) "poison")) - (should (eq (gethash region-id ebox--region-box-table) - runtime-box)))) - (when (buffer-live-p buffer) - (kill-buffer buffer))))) - (ert-deftest ebox-runtime-node-ids-cover-layout-and-box-nodes () "Every runtime node should have a stable internal node id." (ebox-test--reset-runtime-state) @@ -938,18 +957,6 @@ '(layout-fragment render-body)))) (ebox-cache-reset-default-registry))) -(ert-deftest ebox-cache-events-appear-in-update-report () - "Incremental update reports should expose cache invalidation facts." - (ebox-test--reset-runtime-state) - (let* ((node (ebox-create :content "Cache" :border-color "#111111")) - (id (car (ebox-region-ids node)))) - (ebox-test--with-rendered-buffer node - (ebox-region-update id :border-color "#222222") - (let ((report (ebox-test--buffer-update-report))) - (should (plist-member report :cache-invalidated)) - (should (plist-member report :cache-hit-count)) - (should (plist-member report :cache-miss-count)))))) - (ert-deftest ebox-render-cache-retains-distinct-viewport-widths () "Returning to a warm width should not evict its prior rendered value." (let ((ebox--render-cache-table (make-hash-table :test 'equal)) @@ -1232,94 +1239,6 @@ (when (buffer-live-p buffer) (kill-buffer buffer))))) -(ert-deftest ebox-root-owner-rerender-reuses-a-returning-width-output () - "Returning to a cached root width should not rebuild the rendered owner." - (ebox-test--reset-runtime-state) - (let* ((layout - (ebox-build - '(box :id "root" :width (viewport) - (box :id "scroll" :width (viewport) :height 2 - :overflow scroll :border t - :content "cached root owner\nline two\nline three")))) - buffer - root-id - (render-box-count 0) - (role-refresh-count 0) - (scroll-refresh-count 0) - (original-role-refresh - (symbol-function 'ebox-buffer-refresh-region-role-spans)) - (original-scroll-refresh - (symbol-function 'ebox--refresh-buffer-scroll-content-markers)) - (original-render-box (symbol-function 'ebox--render-box))) - (unwind-protect - (let ((ebox-viewport-width 240) - (ebox-runtime-idle-prewarm nil) - (ebox-runtime-idle-reflow-cache-prewarm nil)) - (setq buffer - (ebox-render-to-buffer - (generate-new-buffer-name " *ebox-root-cache*") layout)) - (setq root-id (ebox-test--selector-region-id buffer "#root")) - (with-current-buffer buffer - (ebox-region-update root-id :width '(260)) - (ebox-region-update root-id :width '(280))) - (cl-letf (((symbol-function 'ebox--render-box) - (lambda (&rest args) - (cl-incf render-box-count) - (apply original-render-box args))) - ((symbol-function 'ebox-buffer-refresh-region-role-spans) - (lambda (&rest args) - (cl-incf role-refresh-count) - (apply original-role-refresh args))) - ((symbol-function - 'ebox--refresh-buffer-scroll-content-markers) - (lambda (&rest args) - (cl-incf scroll-refresh-count) - (apply original-scroll-refresh args)))) - (with-current-buffer buffer - (ebox-region-update root-id :width '(260)))) - (should (= render-box-count 0)) - (should (= role-refresh-count 0)) - (should (= scroll-refresh-count 0)) - (with-current-buffer buffer - (should (ebox-test--buffer-visually-matches-runtime-render-p)))) - (when (buffer-live-p buffer) - (kill-buffer buffer))))) - -(ert-deftest ebox-viewport-root-cache-keeps-one-manual-resize-round-trip () - "A maintained resize sweep should keep its exact widths for the return leg." - (ebox-test--reset-runtime-state) - (let* ((layout - (ebox-build - '(box :id "root" :width (viewport) - (flex :width (viewport) :gap 1 - (box :content "alpha" :width 72 :height 1) - (box :content "beta" :width 72 :height 1))))) - (widths '(488 552 616 680 728 664 600 536 472 424)) - buffer - (render-box-count 0) - (original-render-box (symbol-function 'ebox--render-box))) - (unwind-protect - (let ((ebox-viewport-width 440) - (ebox-viewport-height 8) - (ebox-runtime-idle-prewarm nil) - (ebox-runtime-idle-reflow-cache-prewarm nil)) - (setq buffer - (ebox-render-to-buffer - (generate-new-buffer-name " *ebox-resize-round-trip*") - layout)) - (dolist (width widths) - (ebox-rerender-buffer-with-context buffer width 8)) - (cl-letf (((symbol-function 'ebox--render-box) - (lambda (&rest args) - (cl-incf render-box-count) - (apply original-render-box args)))) - (ebox-rerender-buffer-with-context buffer (car widths) 8)) - (should (= render-box-count 0)) - (with-current-buffer buffer - (should (ebox-test--buffer-visually-matches-runtime-render-p)))) - (when (buffer-live-p buffer) - (kill-buffer buffer))))) - (ert-deftest ebox-returning-width-cache-restores-lazy-scroll-context () "A cached subtree must restore lazy scroll state for its render context." (ebox-test--reset-runtime-state) @@ -1368,7 +1287,7 @@ (dotimes (_ 6) (should (= (ebox--scroll-region-by scroll-id 1) 1))) (let ((scrolled (buffer-string))) - (ebox-buffer-apply-root-rerender buffer) + (ebox-rerender-buffer-with-context buffer 240 6) (should (equal-including-properties scrolled (buffer-string)))))) (when scroll-id @@ -1376,88 +1295,6 @@ (when (buffer-live-p buffer) (kill-buffer buffer))))) -(ert-deftest ebox-reflow-cache-prewarm-caches-the-complete-root-output () - "A predicted viewport should publish a root render reusable by its reflow." - (ebox-test--reset-runtime-state) - (let* ((layout - (ebox-build - '(box :id "root" :width (viewport) - (box :content "predicted root owner" :width (viewport) :height 1)))) - buffer - (render-box-count 0) - (original-render-box (symbol-function 'ebox--render-box))) - (unwind-protect - (let ((ebox-viewport-width 240) - (ebox-viewport-height 4) - (ebox-runtime-idle-prewarm nil)) - (setq buffer - (ebox-render-to-buffer - (generate-new-buffer-name " *ebox-root-prewarm*") layout)) - (ebox--cancel-buffer-runtime-prewarm buffer) - (let* ((state (ebox--buffer-render-state buffer)) - (revision (plist-get state :runtime-revision))) - (should - (ebox--prewarm-buffer-render-cache-at-context - buffer state revision 240 260 4))) - (cl-letf (((symbol-function 'ebox--render-box) - (lambda (&rest args) - (cl-incf render-box-count) - (apply original-render-box args)))) - (ebox-rerender-buffer-with-context buffer 260 4)) - (should (= render-box-count 0)) - (with-current-buffer buffer - (should (ebox-test--buffer-visually-matches-runtime-render-p)))) - (when (buffer-live-p buffer) - (kill-buffer buffer))))) - -(ert-deftest ebox-cold-ordinary-root-reflow-skips-complete-templates () - "An ordinary visible cache miss should leave complete scans to prewarming." - (ebox-test--reset-runtime-state) - (let* ((layout - (ebox-build - '(box :id "root" :width (240) - (box :content "cold root metadata" :height 1)))) - buffer - (template-build-count 0) - (original-template-builder - (symbol-function 'ebox-buffer--build-region-role-span-template))) - (unwind-protect - (let ((ebox-viewport-width 900) - (ebox-viewport-height 4) - (ebox-runtime-idle-prewarm nil) - (ebox-runtime-idle-reflow-cache-prewarm nil)) - (setq buffer - (ebox-render-to-buffer - (generate-new-buffer-name " *ebox-cold-root-metadata*") - layout)) - (let ((root-id (ebox-test--selector-region-id buffer "#root"))) - (cl-letf (((symbol-function - 'ebox-buffer--build-region-role-span-template) - (lambda (&rest arguments) - (cl-incf template-build-count) - (apply original-template-builder arguments)))) - (let ((ebox--region-update-buffer-hint buffer)) - (with-current-buffer buffer - (ebox-region-update root-id :width '(260)))) - (should (= template-build-count 0)) - (with-current-buffer buffer - (should - (ebox-test--buffer-visually-matches-runtime-render-p)))))) - (when (buffer-live-p buffer) - (kill-buffer buffer))))) - -(ert-deftest ebox-region-role-template-coalesces-unrelated-properties () - "Role templates should inspect present properties and preserve full spans." - (let ((rendered (copy-sequence "abcd"))) - (add-text-properties 0 4 '(ebox-content 7) rendered) - (add-text-properties 1 2 '(face bold) rendered) - (add-text-properties 2 4 '(ebox-pl 8) rendered) - (let ((template - (ebox-buffer--build-region-role-span-template rendered))) - (should (equal (gethash (list 7 'content) template) - '((1 . 5)))) - (should (equal (gethash (list 8 'pl) template) - '((3 . 5))))))) (ert-deftest ebox-prepared-root-render-is-exact-one-shot-fast-path () "An exact prepared root should bypass layout once and never affect children." @@ -1558,299 +1395,6 @@ (when (buffer-live-p buffer) (kill-buffer buffer))))) -(ert-deftest ebox-prepared-animation-restores-complete-final-runtime () - "Display-only root frames should defer indexes until one complete final frame." - (ebox-test--reset-runtime-state) - (let* ((layout - (ebox-build - '(box :id "root" :width (240) - (column - (box :id "a" :content "alpha" :height 1) - (box :id "b" :content "beta" :height 1))))) - buffer) - (unwind-protect - (let ((ebox-viewport-width 900) - (ebox-viewport-height 4) - (ebox-runtime-idle-prewarm nil) - (ebox-runtime-idle-reflow-cache-prewarm nil)) - (setq buffer - (ebox-render-to-buffer - (generate-new-buffer-name " *ebox-prepared-animation*") - layout)) - (let* ((state (ebox--buffer-render-state buffer)) - (root (plist-get state :root-node)) - (root-node-id (ebox--ensure-node-id root)) - (root-id (ebox-test--selector-region-id buffer "#root")) - (initial - (with-current-buffer buffer - (buffer-string))) - (render-at - (lambda (width) - (let* ((isolated (copy-tree root)) - (box (ebox--root-region-box isolated root-id)) - (ebox-viewport-width 900) - (ebox-viewport-height 4) - (ebox--render-runtime-revision - (plist-get state :runtime-revision)) - (ebox--render-cache-table - (make-hash-table :test 'equal)) - (ebox--render-cache-signature-cache - (make-hash-table :test 'eq)) - (ebox--region-box-table - (make-hash-table :test 'equal)) - (ebox--scroll-global-state - (make-hash-table :test 'equal))) - (ebox-put box :width width) - (ebox-render isolated)))) - (full-260 (funcall render-at 260)) - (light-260 (copy-sequence full-260)) - (prepared-260 nil)) - (remove-list-of-text-properties - 0 (length light-260) - '(ebox-content-idx ebox-content ebox-content-owners - ebox-content-owner ebox-bb ebox-br ebox-pr ebox-pb ebox-pl - ebox-bl ebox-bt ebox-pt ebox-scroll-window) - light-260) - (setq prepared-260 - (list :rendered light-260 - :render-state state - :node-id root-node-id - :runtime-revision 0 - :display-signature - (with-current-buffer buffer - (ebox-display-signature)) - :kind 'root-width - :region-id root-id - :root-width 260 - :viewport-width 900 - :viewport-height 4)) - (with-current-buffer buffer - (let ((ebox--prepared-root-render prepared-260) - (ebox--prepared-root-defer-index-refresh t) - (ebox--region-update-buffer-hint buffer)) - (ebox-region-update root-id :width '(260)))) - (should (plist-get prepared-260 :consumed-p)) - (should (eq (plist-get (ebox-test--buffer-update-report) - :snapshot-refresh) - 'prepared-animation)) - (should (eq (plist-get (ebox-test--buffer-update-report) :index-refresh) - 'defer-extents)) - (should-not (ebox--buffer-region-role-span-table buffer)) - (let* ((snapshot - (gethash root-node-id - (ebox--ensure-buffer-layout-snapshots buffer))) - (spans (plist-get snapshot :buffer-spans))) - (should (= (caar spans) 1)) - (should (= (cdr (car (last spans))) - (with-current-buffer buffer (point-max))))) - (let ((prepared-240 - (list :rendered initial - :render-state state - :node-id root-node-id - :runtime-revision - (plist-get state :runtime-revision) - :display-signature - (with-current-buffer buffer - (ebox-display-signature)) - :kind 'root-width - :region-id root-id - :root-width 240 - :viewport-width 900 - :viewport-height 4))) - (with-current-buffer buffer - (let ((ebox--prepared-root-render prepared-240) - (ebox--prepared-root-defer-index-refresh nil) - (ebox--region-update-buffer-hint buffer)) - (ebox-region-update root-id :width '(240)))) - (should (plist-get prepared-240 :consumed-p))) - (with-current-buffer buffer - (should (equal-including-properties (buffer-string) initial)) - (should (ebox-test--buffer-visually-matches-runtime-render-p))))) - (when (buffer-live-p buffer) - (kill-buffer buffer))))) - -(ert-deftest ebox-complete-root-output-cache-retains-only-recent-widths () - "Full root outputs should not crowd long-lived subtree cache entries." - (ebox-test--reset-runtime-state) - (let* ((layout - (ebox-build - '(box :id "root" :width (viewport) - (box :content "bounded root output" :width (viewport))))) - (ebox-render-root-cache-max-entries 2) - (ebox-render-root-cache-max-bytes (* 8 1024 1024)) - buffer) - (unwind-protect - (let ((ebox-viewport-width 240) - (ebox-viewport-height 4) - (ebox-runtime-idle-prewarm nil) - (ebox-runtime-idle-reflow-cache-prewarm nil)) - (setq buffer - (ebox-render-to-buffer - (generate-new-buffer-name " *ebox-bounded-root-cache*") - layout)) - (dolist (width '(260 280 300)) - (ebox-rerender-buffer-with-context buffer width 4)) - (let* ((render-cache (ebox--buffer-render-cache buffer)) - (root-cache - (gethash render-cache ebox--render-root-cache-table-table))) - (should (hash-table-p root-cache)) - (should (= (hash-table-count root-cache) 2)) - (should-not (eq root-cache render-cache)))) - (when (buffer-live-p buffer) - (kill-buffer buffer))))) - -(ert-deftest ebox-root-width-cache-prewarm-is-isolated-and-reusable () - "Predicted ordinary root width output should stay isolated until update." - (ebox-test--reset-runtime-state) - (let* ((layout - (ebox-build - '(box :id "root" :width (240) - (box :id "scroll" :width (viewport) :height 2 - :overflow scroll :border t - :content "predicted ordinary root\nline two\nline three")))) - buffer - root-id - (render-box-count 0) - (role-refresh-count 0) - (scroll-refresh-count 0) - (original-render-box (symbol-function 'ebox--render-box)) - (original-role-refresh - (symbol-function 'ebox-buffer-refresh-region-role-spans)) - (original-scroll-refresh - (symbol-function 'ebox--refresh-buffer-scroll-content-markers))) - (unwind-protect - (let ((ebox-viewport-width 900) - (ebox-viewport-height 4) - (ebox-runtime-idle-prewarm nil) - (ebox-runtime-idle-reflow-cache-prewarm nil)) - (setq buffer - (ebox-render-to-buffer - (generate-new-buffer-name " *ebox-root-width-prewarm*") - layout)) - (setq root-id (ebox-test--selector-region-id buffer "#root")) - (let* ((state (ebox--buffer-render-state buffer)) - (revision (plist-get state :runtime-revision)) - (root-before (copy-tree (plist-get state :root-node))) - (text-before (with-current-buffer buffer (buffer-string)))) - (should - (ebox--prewarm-buffer-render-cache-at-root-width - buffer state revision root-id 240 260)) - (should (equal (plist-get state :root-node) root-before)) - (should (equal-including-properties - (with-current-buffer buffer (buffer-string)) - text-before))) - (cl-letf (((symbol-function 'ebox--render-box) - (lambda (&rest args) - (cl-incf render-box-count) - (apply original-render-box args))) - ((symbol-function 'ebox-buffer-refresh-region-role-spans) - (lambda (&rest args) - (cl-incf role-refresh-count) - (apply original-role-refresh args))) - ((symbol-function - 'ebox--refresh-buffer-scroll-content-markers) - (lambda (&rest args) - (cl-incf scroll-refresh-count) - (apply original-scroll-refresh args)))) - (with-current-buffer buffer - (ebox-region-update root-id :width '(260)))) - (should (= render-box-count 0)) - (should (= role-refresh-count 0)) - (should (= scroll-refresh-count 0)) - (with-current-buffer buffer - (should (ebox-test--buffer-visually-matches-runtime-render-p)))) - (when (buffer-live-p buffer) - (kill-buffer buffer))))) - -(ert-deftest ebox-root-width-prewarm-reuses-confirmed-isolated-scratch () - "Consecutive root predictions should reuse one confirmed isolated tree." - (ebox-test--reset-runtime-state) - (let* ((layout - (ebox-build - '(box :id "root" :width (240) - (box :content "reusable isolated tree" :height 1)))) - buffer) - (unwind-protect - (let ((ebox-viewport-width 900) - (ebox-viewport-height 4) - (ebox-runtime-idle-prewarm nil) - (ebox-runtime-idle-reflow-cache-prewarm nil)) - (setq buffer - (ebox-render-to-buffer - (generate-new-buffer-name " *ebox-reflow-scratch*") layout)) - (let* ((state (ebox--buffer-render-state buffer)) - (root-id (ebox-test--selector-region-id buffer "#root")) - (revision (plist-get state :runtime-revision)) - live-root scratch scratch-tree) - (setq live-root (plist-get state :root-node)) - (should - (ebox--prewarm-buffer-render-cache-at-root-width - buffer state revision root-id 240 260)) - (setq scratch (plist-get state :reflow-prewarm-scratch) - scratch-tree (plist-get scratch :tree)) - (should scratch) - (should-not (eq scratch-tree live-root)) - (should (= (ebox--literal-root-pixel-width - (ebox--root-region-box scratch-tree root-id)) - 260)) - (should (= (ebox--literal-root-pixel-width - (ebox--root-region-box live-root root-id)) - 240)) - (let ((ebox--region-update-buffer-hint buffer)) - (with-current-buffer buffer - (ebox-region-update root-id :width '(260)))) - (should (eq scratch - (plist-get state :reflow-prewarm-scratch))) - (should (= (plist-get scratch :live-revision) 1)) - (should-not (plist-get scratch :predicted-revision)) - (should - (ebox--prewarm-buffer-render-cache-at-root-width - buffer state 1 root-id 260 280)) - (should (eq scratch-tree - (plist-get - (plist-get state :reflow-prewarm-scratch) :tree))) - (should (= (ebox--literal-root-pixel-width - (ebox--root-region-box live-root root-id)) - 260)) - (should (= (ebox--literal-root-pixel-width - (ebox--root-region-box scratch-tree root-id)) - 280)) - (with-current-buffer buffer - (should (ebox-test--buffer-visually-matches-runtime-render-p))))) - (when (buffer-live-p buffer) - (kill-buffer buffer))))) - -(ert-deftest ebox-root-width-prewarm-discards-mismatched-scratch () - "A visible root width other than the prediction should discard its scratch." - (ebox-test--reset-runtime-state) - (let* ((layout - (ebox-build - '(box :id "root" :width (240) - (box :content "discard stale prediction" :height 1)))) - buffer) - (unwind-protect - (let ((ebox-viewport-width 900) - (ebox-viewport-height 4) - (ebox-runtime-idle-prewarm nil) - (ebox-runtime-idle-reflow-cache-prewarm nil)) - (setq buffer - (ebox-render-to-buffer - (generate-new-buffer-name " *ebox-stale-scratch*") layout)) - (let* ((state (ebox--buffer-render-state buffer)) - (root-id (ebox-test--selector-region-id buffer "#root"))) - (should - (ebox--prewarm-buffer-render-cache-at-root-width - buffer state 0 root-id 240 260)) - (should (plist-get state :reflow-prewarm-scratch)) - (let ((ebox--region-update-buffer-hint buffer)) - (with-current-buffer buffer - (ebox-region-update root-id :width '(280)))) - (should-not (plist-get state :reflow-prewarm-scratch)) - (with-current-buffer buffer - (should (ebox-test--buffer-visually-matches-runtime-render-p))))) - (when (buffer-live-p buffer) - (kill-buffer buffer))))) - (ert-deftest ebox-viewport-prewarm-reuses-live-isolated-scratch () "Viewport predictions should reuse one nonmutating isolated live tree." (ebox-test--reset-runtime-state) @@ -2171,8 +1715,8 @@ (id (car (ebox-region-ids node)))) (ebox-test--with-rendered-buffer node (ebox-incremental-begin-batch (current-buffer)) - (ebox-region-update id :border-color "#222222") - (ebox-region-update id :background-color "#eeeeee") + (ebox-test--region-update id :border-color "#222222") + (ebox-test--region-update id :background-color "#eeeeee") (should (= (ebox-incremental-pending-count (current-buffer)) 2)) (ebox-incremental-flush (current-buffer)) (let ((report (ebox-test--buffer-update-report))) @@ -2192,8 +1736,8 @@ (ebox-incremental-begin-batch buffer) (dolist (operation order) (pcase operation - ('paint (ebox-region-update id :color "red")) - ('geometry (ebox-region-update id :width '(80))))) + ('paint (ebox-test--region-update id :color "red")) + ('geometry (ebox-test--region-update id :width '(80))))) (ebox-incremental-flush buffer)) (let ((report (ebox-test--buffer-update-report))) (let ((runtime-box @@ -2220,8 +1764,8 @@ (ebox-incremental-begin-batch buffer) (dolist (operation order) (pcase operation - ('parent (ebox-region-update parent-id :color "red")) - ('child (ebox-region-update child-id :color "blue")))) + ('parent (ebox-test--region-update parent-id :color "red")) + ('child (ebox-test--region-update child-id :color "blue")))) (ebox-incremental-flush buffer)) (goto-char (point-min)) (search-forward "Child") @@ -2243,8 +1787,8 @@ (parent-id (ebox-test--selector-region-id buffer "#parent")) (child-id (ebox-test--selector-region-id buffer "#child"))) (ebox-incremental-begin-batch buffer) - (ebox-region-update parent-id :color "red") - (ebox-region-update child-id :width '(80)) + (ebox-test--region-update parent-id :color "red") + (ebox-test--region-update child-id :width '(80)) (ebox-incremental-flush buffer)) (goto-char (point-min)) (search-forward "Child") @@ -2293,7 +1837,7 @@ (ebox-test--selector-region-id buffer "#target-b"))) (ebox-incremental-begin-batch buffer) (dolist (target order) - (ebox-region-update + (ebox-test--region-update (if (eq target 'target-a) target-a-id target-b-id) :width '(210) :min-width '(150) :max-width '(260))) (ebox-incremental-flush buffer)) @@ -2316,12 +1860,12 @@ (dolist (operation order) (pcase operation ('geometry - (ebox-region-update target-a-id + (ebox-test--region-update target-a-id :width '(210) :min-width '(150) :max-width '(260))) ('paint - (ebox-region-update target-b-id :color "blue")))) + (ebox-test--region-update target-b-id :color "blue")))) (ebox-incremental-flush buffer)) (goto-char (point-min)) (search-forward "Target B") @@ -2409,78 +1953,21 @@ (should-not (plist-get overflow :visible)))))) (ert-deftest ebox-role-topology-signature-uses-published-span-index () - "Role topology should read the published index without per-role span copies." + "Role topology should reflect the published TP mount index." (ebox-test--reset-runtime-state) (let* ((box (ebox-create :content "Indexed" :width '(120) :border t)) (region-id (ebox--ensure-region-id box))) (ebox-test--with-rendered-buffer box - (cl-letf (((symbol-function 'ebox--region-find) - (lambda (&rest _arguments) - (ert-fail "role topology rescanned region spans")))) - (let ((roles (plist-get (ebox--role-topology-signature - (list region-id)) - :roles))) - (should (memq 'content roles)) - (should (memq 'content-owner roles))))))) + (let ((roles (plist-get (ebox--role-topology-signature + (list region-id)) + :roles))) + (should (ebox-surface-region-mounts (current-buffer) region-id)) + (should (memq 'content roles)) + (should (memq 'content-owner roles)))))) -(ert-deftest ebox-replace-line-spans-returns-new-spans-for-contiguous-lines () - "Backend span replacement should return updated spans for every path." - (with-temp-buffer - (insert "one\ntwo\n") - (let* ((spans (save-excursion - (goto-char (point-min)) - (list (cons (line-beginning-position) - (line-end-position)) - (progn - (forward-line 1) - (cons (line-beginning-position) - (line-end-position)))))) - (new-spans (ebox--replace-line-spans spans - "alpha\nbeta\ngamma"))) - (should (equal (buffer-string) "alpha\nbeta\ngamma\n")) - (should (equal (mapcar (lambda (span) - (buffer-substring-no-properties - (car span) (cdr span))) - new-spans) - '("alpha" "beta" "gamma")))))) -(ert-deftest ebox-resolve-line-anchor-falls-back-when-line-is-gone () - "Window-state anchors should resolve in bounded time after line replacement." - (with-temp-buffer - (insert "before\n") - (let ((anchor (ebox--line-anchor-at (point-min)))) - (erase-buffer) - (insert "after\n") - (should (= (ebox--resolve-line-anchor anchor) (point-min)))))) -(ert-deftest ebox-line-anchor-occurrence-tracks-duplicate-plain-lines () - "Unowned duplicate lines keep resolving to the captured occurrence." - (with-temp-buffer - (insert "dup\nother\ndup\ndup\ntail\n") - (goto-char (point-min)) - (forward-line 3) - (forward-char 1) - (let ((anchor (ebox--line-anchor-at (point)))) - (should (= (plist-get anchor :occurrence) 2)) - (erase-buffer) - (insert "prefix\ndup\nother\ndup\nmiddle\ndup\ntail\n") - (goto-char (ebox--resolve-line-anchor anchor)) - (should (= (line-number-at-pos) 6)) - (should (= (current-column) 1))))) -(ert-deftest ebox-line-anchor-occurrence-treats-line-text-literally () - "Occurrence counting must not read regexp syntax or partial matches." - (with-temp-buffer - (insert "a.*b [x]\naXb x\nxa.*b [x]y\na.*b [x]\n\n\ntext\n\n") - ;; "aXb x" satisfies "a.*b [x]" read as a regexp, and the superstring - ;; line contains it verbatim; only the literal full-line copy counts. - (goto-char (point-min)) - (forward-line 3) - (should (= (plist-get (ebox--line-anchor-at (point)) :occurrence) 1)) - ;; An empty anchor line counts only preceding empty lines and terminates. - (goto-char (point-max)) - (forward-line -1) - (should (= (plist-get (ebox--line-anchor-at (point)) :occurrence) 2)))) (ert-deftest ebox-layout-snapshot-capture-skips-line-ownership-scan () "Initial snapshot capture should not scan line ownership for every node." @@ -2616,7 +2103,7 @@ (region-id (car (ebox-region-ids box)))) (ebox-test--with-rendered-buffer box (let ((old (ebox--current-layout-snapshots (current-buffer)))) - (ebox-region-update region-id :border-color "blue") + (ebox-test--region-update region-id :border-color "blue") (let ((dirty (ebox--diff-layout-snapshots old (ebox--current-layout-snapshots (current-buffer))))) @@ -2682,17 +2169,10 @@ :border t :border-color "red")) (region-id (car (ebox-region-ids box)))) (ebox-test--with-rendered-buffer box - (let ((replace-count 0) - (original-replace (symbol-function 'ebox--replace-line-spans))) - (cl-letf (((symbol-function 'ebox--replace-line-spans) - (lambda (&rest args) - (cl-incf replace-count) - (apply original-replace args)))) - (let ((full-rerenders - (ebox-test--count-full-rerenders - (ebox-region-update region-id :border-color "blue")))) - (should (= full-rerenders 0)))) - (should (= replace-count 0))) + (let ((full-rerenders + (ebox-test--count-full-surface-updates + (ebox-test--region-update region-id :border-color "blue")))) + (should (= full-rerenders 0))) (let ((report (ebox-test--buffer-update-report))) (should (eq (plist-get report :strategy) 'paint-patch)) (should (eq (plist-get report :full-rerender) nil)))))) @@ -2706,14 +2186,7 @@ :bgcolor "#DCEBDD"))) (region-id (car (ebox-region-ids layout)))) (ebox-test--with-rendered-buffer layout - (let ((replace-count 0) - (original-replace (symbol-function 'ebox--replace-line-spans))) - (cl-letf (((symbol-function 'ebox--replace-line-spans) - (lambda (&rest args) - (cl-incf replace-count) - (apply original-replace args)))) - (ebox-region-update region-id :bgcolor "#FDE68A")) - (should (= replace-count 0))) + (ebox-test--region-update region-id :bgcolor "#FDE68A") (let ((report (ebox-test--buffer-update-report))) (should (eq (plist-get report :strategy) 'paint-patch)) (should-not (plist-get report :full-rerender)) @@ -2728,7 +2201,7 @@ (layout (ebox-row left right)) (left-id (car (ebox-region-ids layout)))) (ebox-test--with-rendered-buffer layout - (ebox-region-update left-id :width '(120)) + (ebox-test--region-update left-id :width '(120)) (let ((report (ebox-test--buffer-update-report))) (should (memq (plist-get report :owner-type) '(concat row))) (should-not (eq (plist-get report :owner-type) 'box)))))) @@ -2794,7 +2267,7 @@ (kill-buffer buffer))))) (ert-deftest ebox-viewport-rerender-patches-column-children-independently () - "Column children should be independent owners when viewport resize rewraps them." + "Viewport resize should publish the correct column scope through TP." (ebox-test--reset-runtime-state) (let* ((top (ebox-create :content "Top text should wrap into more lines when narrow." @@ -2811,25 +2284,19 @@ (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") layout))) - (let* ((runtime-root (ebox-test--runtime-root buffer)) - (runtime-top (car (plist-get runtime-root :children))) - (runtime-bottom (cadr (plist-get runtime-root :children)))) - (ebox-rerender-buffer-with-context buffer 90) - (let ((report (ebox-test--buffer-update-report)) - (plain (with-current-buffer buffer - (buffer-substring-no-properties - (point-min) (point-max))))) - (should (equal (plist-get report :patch-ops) - '(owner-rerender owner-rerender))) - (should (= (plist-get report :patch-count) 2)) - (should-not (member (plist-get runtime-root :node-id) - (plist-get report :owner-ids))) - (should (member (plist-get runtime-top :node-id) - (plist-get report :owner-ids))) - (should (member (plist-get runtime-bottom :node-id) - (plist-get report :owner-ids))) - (should (string-match-p "Top text" plain)) - (should (string-match-p "Bottom text" plain))))) + (ebox-rerender-buffer-with-context buffer 90) + (let ((report (ebox-test--buffer-update-report)) + (plain (with-current-buffer buffer + (buffer-substring-no-properties + (point-min) (point-max))))) + (should-not (plist-get report :tp-full-root)) + (should (> (plist-get report :tp-scope-count) 0)) + (should-not (plist-get report :tp-scope-fallback)) + (should (string-match-p "Top text" plain)) + (should (string-match-p "Bottom text" plain)) + (should + (with-current-buffer buffer + (ebox-test--buffer-propertized-matches-runtime-render-p))))) (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) @@ -2862,7 +2329,7 @@ (layout (ebox-row left right)) (left-id (car (ebox-region-ids layout)))) (ebox-test--with-rendered-buffer layout - (ebox-region-update left-id :width '(120)) + (ebox-test--region-update left-id :width '(120)) (let ((report (ebox-test--buffer-update-report))) (should (eq (plist-get report :constraint-source) 'region)) (should (eq (plist-get report :constraint-owner-type) 'box)) @@ -2874,100 +2341,6 @@ (should (equal (plist-get report :dirty-kinds) '(geometry))))))) -(ert-deftest ebox-root-region-width-reflow-invalidates-derived-indexes () - "A root box width change should use viewport-equivalent index invalidation." - (ebox-test--reset-runtime-state) - (let* ((layout (ebox-build - '(box :id "root" :width (240) - :content "ordinary root width"))) - (root-id (car (ebox-region-ids layout))) - (snapshot-refreshes 0) - (extent-refreshes 0) - (region-enumerations 0) - (scroll-probes 0) - (visible-index-probes 0) - (scroll-state-syncs 0) - (generic-patch-plans 0) - (original-snapshots - (symbol-function 'ebox--refresh-buffer-layout-snapshots)) - (original-extents - (symbol-function 'ebox--refresh-buffer-box-extents)) - (original-region-ids - (symbol-function 'ebox--node-all-region-ids)) - (original-lazy - (symbol-function 'ebox--try-lazy-scroll-deferred-patch)) - (original-hidden - (symbol-function 'ebox--try-hidden-scroll-state-span-patch)) - (original-hidden-root - (symbol-function 'ebox--try-hidden-scroll-root-fallback)) - (original-scroll-state - (symbol-function 'ebox--try-scroll-state-span-patch)) - (original-visible-indexes - (symbol-function 'ebox--ensure-visible-node-runtime-indexes)) - (original-scroll-state-sync - (symbol-function 'ebox--sync-scroll-state-lines-for-node)) - (original-patch-plan - (symbol-function 'ebox--patch-set-from-dirty-set))) - (ebox-test--with-rendered-buffer layout - (cl-letf (((symbol-function 'ebox--node-all-region-ids) - (lambda (&rest args) - (cl-incf region-enumerations) - (apply original-region-ids args)))) - (let ((change (ebox--region-constraint-change - (current-buffer) root-id 'geometry '(:width)))) - (should-not (plist-get (car (plist-get change :dirty-set)) - :region-ids)))) - (should (= region-enumerations 0)) - (cl-letf (((symbol-function 'ebox--refresh-buffer-layout-snapshots) - (lambda (&rest args) - (cl-incf snapshot-refreshes) - (apply original-snapshots args))) - ((symbol-function 'ebox--refresh-buffer-box-extents) - (lambda (&rest args) - (cl-incf extent-refreshes) - (apply original-extents args))) - ((symbol-function 'ebox--try-lazy-scroll-deferred-patch) - (lambda (&rest args) - (cl-incf scroll-probes) - (apply original-lazy args))) - ((symbol-function 'ebox--try-hidden-scroll-state-span-patch) - (lambda (&rest args) - (cl-incf scroll-probes) - (apply original-hidden args))) - ((symbol-function 'ebox--try-hidden-scroll-root-fallback) - (lambda (&rest args) - (cl-incf scroll-probes) - (apply original-hidden-root args))) - ((symbol-function 'ebox--try-scroll-state-span-patch) - (lambda (&rest args) - (cl-incf scroll-probes) - (apply original-scroll-state args))) - ((symbol-function 'ebox--ensure-visible-node-runtime-indexes) - (lambda (&rest args) - (cl-incf visible-index-probes) - (apply original-visible-indexes args))) - ((symbol-function 'ebox--sync-scroll-state-lines-for-node) - (lambda (&rest args) - (cl-incf scroll-state-syncs) - (apply original-scroll-state-sync args))) - ((symbol-function 'ebox--patch-set-from-dirty-set) - (lambda (&rest args) - (cl-incf generic-patch-plans) - (apply original-patch-plan args)))) - (ebox-region-update root-id :width '(160))) - (should (= snapshot-refreshes 0)) - (should (= extent-refreshes 0)) - (should (= scroll-probes 0)) - (should (= visible-index-probes 0)) - (should (= scroll-state-syncs 0)) - (should (= generic-patch-plans 0)) - (let ((report (ebox-test--buffer-update-report))) - (should (eq (plist-get report :constraint-source) 'region)) - (should (eq (plist-get report :snapshot-refresh) - 'invalidate-details)) - (should (eq (plist-get report :index-refresh) - 'invalidate-extents)))))) - (ert-deftest ebox-root-region-width-reflow-rebinds-lazy-content-viewport () "A root width update should constrain lazy descendants like viewport resize." (ebox-test--reset-runtime-state) @@ -2983,7 +2356,7 @@ (box :content "third" :width (viewport)))))) (root-id (car (ebox-region-ids layout)))) (ebox-test--with-rendered-buffer layout - (ebox-region-update root-id :width '(160)) + (ebox-test--region-update root-id :width '(160)) (let ((widths (mapcar #'ebox--string-pixel-width (ebox-string-lines (buffer-string))))) @@ -2998,7 +2371,7 @@ (layout (ebox-row left right)) (left-id (car (ebox-region-ids layout)))) (ebox-test--with-rendered-buffer layout - (ebox-region-update left-id :width '(120)) + (ebox-test--region-update left-id :width '(120)) (let ((report (ebox-test--buffer-update-report))) (should (eq (plist-get report :constraint-owner-type) 'box)) (should (eq (plist-get report :owner-type) 'concat)) @@ -3019,7 +2392,14 @@ (ids (ebox-region-ids node)) (left-id (nth 1 ids))) (ebox-test--with-rendered-buffer node - (ebox-region-update left-id :width '(120)) + (let ((root-node-id (ebox--buffer-root-node-id (current-buffer)))) + (ebox-test--region-update left-id :width '(120)) + (let ((report (ebox-test--buffer-update-report))) + (should (equal (plist-get report :owner-ids) + (list root-node-id))) + (should (= (plist-get report :tp-scope-count) 1)) + (should-not (plist-get report :tp-full-root)) + (should-not (plist-get report :tp-scope-fallback)))) (let ((report (ebox-test--buffer-update-report))) (should (eq (plist-get report :constraint-source) 'region)) (should (memq 'owner-rerender (plist-get report :patch-ops)))) @@ -3085,7 +2465,8 @@ (cl-incf detail-calls) (apply original args)))) (setq patches - (ebox--patch-set-from-dirty-set dirty buffer))) + (ebox-incremental--declarative-tentative-patch-set + buffer dirty))) (should (= (length patches) 1)) (should (eq (plist-get (car patches) :op) 'owner-rerender)) @@ -3113,7 +2494,9 @@ layout))) (let* ((root (ebox--buffer-root-node buffer)) (dirty (ebox--viewport-dirty-set root)) - (patches (ebox--patch-set-from-dirty-set dirty buffer)) + (patches + (ebox-incremental--declarative-tentative-patch-set + buffer dirty)) (source-ids (ebox--dirty-provenance-node-ids (plist-get (car patches) :dirty)))) @@ -3127,59 +2510,6 @@ (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) -(ert-deftest ebox-viewport-rerender-coalesces-large-dirty-set-before-promotion () - "Large viewport dirty sets should choose root before local owner promotion." - (ebox-test--reset-runtime-state) - (let* ((layout (apply - #'ebox-column - (cl-loop for index below 12 - collect - (ebox-create - :content (format "Fluid section %02d" index) - :border t)))) - (promote-count 0) - (dirty-entry-count 0) - (original (symbol-function 'ebox--promote-to-patchable-owner)) - (original-dirty-entry (symbol-function 'ebox--dirty-entry)) - buffer - root-id) - (unwind-protect - (progn - (let ((ebox-viewport-width 640)) - (setq buffer - (ebox-render-to-buffer - (generate-new-buffer-name " *ebox-test*") - layout))) - (let* ((root (ebox--buffer-root-node buffer)) - (dirty (ebox--viewport-dirty-set root)) - (patches (ebox--root-owner-patch-set buffer dirty)) - (source-ids - (ebox--dirty-provenance-node-ids - (plist-get (car patches) :dirty)))) - (setq root-id (plist-get root :node-id)) - (should (> (length dirty) 8)) - (should (= (length source-ids) (length dirty))) - (dolist (entry dirty) - (should (member (plist-get entry :node-id) source-ids)))) - (cl-letf (((symbol-function 'ebox--promote-to-patchable-owner) - (lambda (&rest args) - (cl-incf promote-count) - (apply original args))) - ((symbol-function 'ebox--dirty-entry) - (lambda (&rest args) - (cl-incf dirty-entry-count) - (apply original-dirty-entry args)))) - (ebox-rerender-buffer-with-context buffer 320)) - (let ((report (ebox-test--buffer-update-report))) - (should (= promote-count 0)) - (should (= dirty-entry-count 1)) - (should (= (plist-get report :dirty-count) 1)) - (should (= (plist-get report :patch-count) 1)) - (should (equal (plist-get report :owner-ids) - (list root-id))))) - (when (and buffer (buffer-live-p buffer)) - (kill-buffer buffer))))) - (ert-deftest ebox-viewport-rerender-root-owner-allows-line-count-change () "Root owner rerender should patch even when viewport wrapping changes height." (ebox-test--reset-runtime-state) @@ -3253,41 +2583,6 @@ (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) -(ert-deftest ebox-owner-rerender-renders-in-target-buffer () - "Owner rerender should compute display signatures in the target buffer." - (ebox-test--reset-runtime-state) - (let* ((node (let ((ebox-viewport-width 240)) - (ebox-build - '(box :content "Viewport text wraps inside the target buffer" - :width (viewport) - :wrap-mode word)))) - (buffer nil) - (other nil) - (render-buffers nil) - (original (symbol-function 'ebox-render))) - (unwind-protect - (progn - (let ((ebox-viewport-width 240)) - (setq buffer - (ebox-render-to-buffer - (generate-new-buffer-name " *ebox-test*") - node))) - (setq other (generate-new-buffer " *ebox-other*")) - (with-current-buffer other - (cl-letf (((symbol-function 'ebox-render) - (lambda (&rest args) - (push (current-buffer) render-buffers) - (apply original args)))) - (ebox-rerender-buffer-with-context buffer 120))) - (should render-buffers) - (should (cl-every (lambda (seen-buffer) - (eq seen-buffer buffer)) - render-buffers))) - (when (and buffer (buffer-live-p buffer)) - (kill-buffer buffer)) - (when (and other (buffer-live-p other)) - (kill-buffer other))))) - (ert-deftest ebox-buffer-render-context-selects-target-buffer () "Buffer render contexts should evaluate render-sensitive work in target buffers." (ebox-test--reset-runtime-state) @@ -3329,37 +2624,6 @@ (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) -(ert-deftest ebox-incremental-rerender-uses-render-gc-threshold () - "Incremental rerenders should run inside the Ebox render GC threshold." - (ebox-test--reset-runtime-state) - (let* ((content - "alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu") - (layout (ebox-create - :content content - :width 'viewport - :wrap-mode 'word)) - (target-threshold (+ gc-cons-threshold 1000000)) - (observed-threshold nil) - (original (symbol-function 'ebox--execute-patch-set)) - buffer) - (unwind-protect - (progn - (let ((ebox-viewport-width 120)) - (setq buffer - (ebox-render-to-buffer - (generate-new-buffer-name " *ebox-test*") - layout))) - (let ((ebox-render-gc-cons-threshold target-threshold)) - (cl-letf (((symbol-function 'ebox--execute-patch-set) - (lambda (&rest args) - (setq observed-threshold gc-cons-threshold) - (apply original args)))) - (ebox-rerender-buffer-with-context buffer 20))) - (should observed-threshold) - (should (>= observed-threshold target-threshold))) - (when (and buffer (buffer-live-p buffer)) - (kill-buffer buffer))))) - (ert-deftest ebox-deferred-render-gc-uses-interactive-burst-threshold () "Interactive render bursts should stay bounded and collect when settled." (let ((gc-cons-threshold 1000) @@ -3454,47 +2718,6 @@ (ebox--deferred-render-gc-set-threshold nil) (should (= gc-cons-threshold 1000)))) -(ert-deftest ebox-visible-root-width-reflows-defer-gc-only-for-root () - "Visible root width reflows should defer GC without affecting local updates." - (ebox-test--reset-runtime-state) - (let* ((layout - (ebox-build - '(box :id "root" :width (viewport) - (box :id "child" :content "responsive child")))) - (buffer nil) - root-id child-id - (enter-count 0) - (restore-count 0)) - (unwind-protect - (progn - (let ((ebox-viewport-width 240)) - (setq buffer - (ebox-render-to-buffer - (generate-new-buffer-name " *ebox-test*") layout))) - (setq root-id (ebox-test--selector-region-id buffer "#root")) - (setq child-id (ebox-test--selector-region-id buffer "#child")) - (cl-letf (((symbol-function - 'ebox--buffer-visible-in-graphic-frame-p) - (lambda (_buffer) t)) - ((symbol-function 'ebox--deferred-render-gc-enter) - (lambda () (cl-incf enter-count))) - ((symbol-function - 'ebox--deferred-render-gc-schedule-restore) - (lambda () (cl-incf restore-count)))) - (ebox-rerender-buffer-with-context buffer 160) - (should (= enter-count 1)) - (should (= restore-count 1)) - (with-current-buffer buffer - (ebox-region-update child-id :bgcolor "#223344")) - (should (= enter-count 1)) - (should (= restore-count 1)) - (with-current-buffer buffer - (ebox-region-update root-id :width 180)) - (should (= enter-count 2)) - (should (= restore-count 2)))) - (when (and buffer (buffer-live-p buffer)) - (kill-buffer buffer))))) - (ert-deftest ebox-viewport-patch-planning-skips-line-signatures () "Owner rerender planning should not compute expensive line signatures." (ebox-test--reset-runtime-state) @@ -3531,13 +2754,14 @@ (lambda (&rest args) (cl-incf line-signature-count) (apply original args)))) - (ebox--patch-set-from-dirty-set dirty buffer)) + (ebox-incremental--declarative-tentative-patch-set + buffer dirty)) (should (= line-signature-count 0)))) (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) -(ert-deftest ebox-viewport-patch-planning-builds-one-shared-line-index () - "Multiple owner rerender planning should share one buffer line index." +(ert-deftest ebox-viewport-patch-planning-coalesces-before-line-index () + "Broad viewport dirtiness should coalesce before building a line index." (ebox-test--reset-runtime-state) (let* ((layout (apply @@ -3566,19 +2790,30 @@ (let* ((root (ebox--buffer-root-node buffer)) (dirty (ebox--viewport-dirty-set root)) (index-build-count 0) - (original (symbol-function 'ebox--build-region-line-index))) + (original (symbol-function 'ebox--build-region-line-index)) + patches) (should dirty) (cl-letf (((symbol-function 'ebox--build-region-line-index) (lambda (&rest args) (cl-incf index-build-count) (apply original args)))) - (ebox--patch-set-from-dirty-set dirty buffer)) - (should (= index-build-count 1)))) + (setq patches + (ebox-incremental--declarative-tentative-patch-set + buffer dirty))) + (should (= index-build-count 0)) + (should (= (length patches) 1)) + (should (eq (plist-get (car patches) :op) 'owner-rerender)) + (should (equal (plist-get (car patches) :owner-id) + (plist-get root :node-id))) + (should (= (length + (ebox--dirty-provenance-node-ids + (plist-get (car patches) :dirty))) + (length dirty))))) (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) -(ert-deftest ebox-single-geometry-planning-skips-full-line-index-without-role-table () - "Single geometry updates should not build a full line index after role table invalidation." +(ert-deftest ebox-block-padding-planning-promotes-without-full-line-index () + "Block padding should publish through its parent without rebuilding line indexes." (ebox-test--reset-runtime-state) (let ((layout (ebox-build @@ -3592,240 +2827,32 @@ (ebox-test--with-rendered-buffer layout (let ((region-id (ebox-test--selector-region-id (current-buffer) "#target")) + (root-id (ebox--buffer-root-node-id (current-buffer))) (original (symbol-function 'ebox--build-region-line-index))) - (ebox--set-buffer-region-role-span-table (current-buffer) nil) (cl-letf (((symbol-function 'ebox--build-region-line-index) (lambda (&rest args) (cl-incf index-build-count) (apply original args)))) - (ebox-region-update region-id :padding (list 1 '(18) 1 '(18)))) + (ebox-test--region-update region-id :padding (list 1 '(18) 1 '(18)))) (let ((report (ebox-test--buffer-update-report))) - (should (eq (plist-get report :strategy) 'span-patch)) - (should (plist-get report :scoped-index-refresh)) + (should (eq (plist-get report :strategy) 'owner-rerender)) + (should (equal (plist-get report :owner-ids) (list root-id))) + (should-not (plist-get report :tp-full-root)) + (should-not (plist-get report :tp-scope-fallback)) (should (= index-build-count 0)) (should (ebox-test--buffer-visually-matches-runtime-render-p))))))) -(ert-deftest ebox-region-set-line-spans-fallback-uses-live-extents () - "Fallback span planning should scan only live extents when indexes are missing." - (ebox-test--reset-runtime-state) - (let* ((before (cl-loop for index below 80 - collect `(box :content ,(format "before %02d" index) - :height 1))) - (after (cl-loop for index below 80 - collect `(box :content ,(format "after %02d" index) - :height 1))) - (layout - (ebox-build - `(column :id "root" :width (480) - ,@before - (box :id "target" :content "target" - :width (220) :height 1 :box-sizing border-box - :padding (0 (8)) :border "#999999") - ,@after))) - (outside-scan-count 0)) - (ebox-test--with-rendered-buffer layout - (let* ((region-id (ebox-test--selector-region-id (current-buffer) - "#target")) - (extents (ebox--live-box-extents region-id)) - range-start - range-end - (original (symbol-function 'ebox--pos-region-ids))) - (should extents) - (save-excursion - (goto-char (marker-position (car extents))) - (setq range-start (line-beginning-position)) - (goto-char (max (point-min) - (1- (marker-position (cdr extents))))) - (setq range-end (line-end-position))) - (ebox--set-buffer-region-role-span-table (current-buffer) nil) - (cl-letf (((symbol-function 'ebox--pos-region-ids) - (lambda (pos) - (unless (and (>= pos range-start) - (< pos range-end)) - (cl-incf outside-scan-count)) - (funcall original pos)))) - (ebox--region-set-line-spans (list region-id))) - (should (= outside-scan-count 0)))))) -(ert-deftest ebox-box-extents-register-from-property-segments () - "Box extent registration should avoid per-character region-id scans." - (ebox-test--reset-runtime-state) - (let* ((box (ebox-create :content "Segment extent" - :padding '(0 (8)) - :border t)) - (region-id (car (ebox-region-ids box)))) - (with-temp-buffer - (insert (ebox-render box)) - (let ((ebox--box-extents-segment-scan-min-chars 0)) - (cl-letf (((symbol-function 'ebox--pos-region-ids) - (lambda (&rest _args) - (error "unexpected per-character region scan")))) - (ebox--register-box-extents-in-range (point-min) (point-max)))) - (should (ebox--live-box-extents region-id))))) -(ert-deftest ebox-box-extents-small-range-uses-position-scan () - "Small extent refreshes should avoid segment scan overhead." - (ebox-test--reset-runtime-state) - (let* ((box (ebox-create :content "Small extent" - :border t)) - (region-id (car (ebox-region-ids box)))) - (with-temp-buffer - (insert (ebox-render box)) - (let ((ebox--box-extents-segment-scan-min-chars most-positive-fixnum)) - (cl-letf (((symbol-function 'ebox--region-property-segments-in-range) - (lambda (&rest _args) - (error "unexpected segment scan")))) - (ebox--register-box-extents-in-range (point-min) (point-max)))) - (should (ebox--live-box-extents region-id))))) -(ert-deftest ebox-foreign-region-range-scan-ignores-newline-only-ownership () - "Foreign ownership on separator newlines should not interleave extents." - (with-temp-buffer - (insert (propertize "\n" 'ebox-content 'foreign)) - (should-not - (ebox--foreign-region-in-range-p (point-min) (point-max) 'owner)))) -(ert-deftest ebox-foreign-region-range-scan-checks-visible-text-after-newline () - "Interval skipping should still detect visible foreign text after newlines." - (with-temp-buffer - (insert (propertize "\nvisible" 'ebox-content 'foreign)) - (should - (ebox--foreign-region-in-range-p (point-min) (point-max) 'owner)))) -(ert-deftest ebox-scoped-box-extent-refresh-preserves-ancestor-extents () - "Scoped index refresh should not shrink an ancestor to its child span." - (ebox-test--reset-runtime-state) - (with-temp-buffer - (let ((root-id 'extent-root) - (child-id 'extent-child) - child-span) - (insert (propertize "root-before\n" - 'ebox-content-owners (list root-id))) - (let ((start (point))) - (insert (propertize "child\n" - 'ebox-content child-id - 'ebox-content-owners - (list child-id root-id))) - (setq child-span (cons start (point)))) - (insert (propertize "root-after\n" - 'ebox-content-owners (list root-id))) - ;; Exercise both the short position scan and the long-range segment - ;; scan. Both still inspect every owner for interleaving; the allow-set - ;; restricts only which extent records may be published. - (dolist (threshold (list most-positive-fixnum 0)) - (let ((ebox--box-extents-segment-scan-min-chars threshold)) - (ebox--clear-buffer-extents (current-buffer)) - (ebox--set-box-extents root-id (point-min) (point-max)) - (ebox--set-box-extents child-id - (car child-span) (cdr child-span)) - (let* ((root-extents (gethash root-id ebox--box-extents)) - (root-span - (cons (marker-position (car root-extents)) - (marker-position (cdr root-extents))))) - (ebox--refresh-box-extents-for-region-ids-in-spans - (list child-id) (list child-span)) - (should (eq root-extents - (gethash root-id ebox--box-extents))) - (should (equal root-span - (cons - (marker-position (car root-extents)) - (marker-position (cdr root-extents))))) - (let ((refreshed-child - (gethash child-id ebox--box-extents))) - (should refreshed-child) - (should (equal child-span - (cons - (marker-position (car refreshed-child)) - (marker-position - (cdr refreshed-child)))))))))))) -(ert-deftest ebox-scoped-role-refresh-records-wrapper-only-active-segment () - "Rendered scoped refresh should rebuild visible wrapper-only extents." - (ebox-test--reset-runtime-state) - (with-temp-buffer - (let* ((wrapper-id 'wrapper-only) - (rendered - (propertize "owned" 'ebox-content-owners (list wrapper-id)))) - (insert rendered) - (let ((spans (list (cons (point-min) (point-max))))) - (puthash (current-buffer) - (list :region-role-span-table - (make-hash-table :test 'equal)) - ebox--buffer-render-state-table) - (let* ((refresh - (ebox-buffer-refresh-region-role-spans-for-region-ids - (current-buffer) (list wrapper-id) spans nil - (list (cons rendered spans)))) - (active-segments (plist-get refresh :active-segments)) - (segments (gethash wrapper-id active-segments))) - (should segments) - (should - (equal (sort (copy-sequence segments) - (lambda (left right) (< (car left) (car right)))) - spans)) - (ebox--refresh-box-extents-from-role-spans - (list wrapper-id) spans active-segments) - (let ((extents (gethash wrapper-id ebox--box-extents))) - (should extents) - (should - (equal (cons (marker-position (car extents)) - (marker-position (cdr extents))) - (car spans))))))))) -(ert-deftest - ebox-scoped-role-refresh-keeps-wrapper-only-interleaved-omitted () - "Active wrapper-only segments should still omit interleaved extents." - (ebox-test--reset-runtime-state) - (with-temp-buffer - (let* ((wrapper-id 'interleaved-wrapper) - (foreign-id 'foreign-child) - (rendered - (concat - (propertize "aa" 'ebox-content-owners (list wrapper-id)) - (propertize "X" 'ebox-content foreign-id) - (propertize "bb" 'ebox-content-owners (list wrapper-id))))) - (insert rendered) - (let ((spans (list (cons (point-min) (point-max))))) - (puthash (current-buffer) - (list :region-role-span-table - (make-hash-table :test 'equal)) - ebox--buffer-render-state-table) - (let* ((refresh - (ebox-buffer-refresh-region-role-spans-for-region-ids - (current-buffer) (list wrapper-id) spans nil - (list (cons rendered spans)))) - (active-segments (plist-get refresh :active-segments))) - (should (= (length (gethash wrapper-id active-segments)) 2)) - (ebox--refresh-box-extents-from-role-spans - (list wrapper-id) spans active-segments) - (should-not (gethash wrapper-id ebox--box-extents))))))) -(ert-deftest ebox-scoped-runtime-index-refresh-clears-empty-active-extents () - "Scoped refresh should clear broad ids even when no active segment remains." - (ebox-test--reset-runtime-state) - (with-temp-buffer - (insert "stale") - (let ((stale-id 'stale-owner) - (active-segments (make-hash-table :test 'equal))) - (puthash (current-buffer) - (list :region-role-span-table - (make-hash-table :test 'equal)) - ebox--buffer-render-state-table) - (ebox--set-box-extents stale-id (point-min) (point-max)) - (cl-letf (((symbol-function - 'ebox-buffer-refresh-region-role-spans-for-region-ids) - (lambda (&rest _args) - (list :active-segments active-segments)))) - (ebox--refresh-runtime-indexes-for-patch-results - (current-buffer) nil 'span-patch nil - (list :region-ids (list stale-id) - :new-spans (list (cons (point-min) (point-max))) - :old-spans nil - :removed-region-ids nil))) - (should-not (gethash stale-id ebox--box-extents))))) -(ert-deftest ebox-scoped-role-span-refresh-rebuilds-on-translation-failure () - "Ambiguous scoped refresh should rebuild a legacy integer role-span table." +(ert-deftest ebox-region-role-ranges-come-from-tp-mounts () + "Published role ranges should be numeric snapshots of TP-owned mounts." (ebox-test--reset-runtime-state) (let ((layout (ebox-build @@ -3839,156 +2866,24 @@ "#target")) (peer-id (ebox-test--selector-region-id (current-buffer) "#peer")) - (target-spans - (gethash (ebox-buffer--region-role-key target-id 'content) - (ebox-buffer--region-role-span-table))) - (peer-key (ebox-buffer--region-role-key peer-id 'content)) - (legacy-table (make-hash-table :test 'equal)) - (rebuild-count 0) - (original (symbol-function - 'ebox-buffer--build-region-role-span-table))) - (should target-spans) - (should (gethash peer-key (ebox-buffer--region-role-span-table))) - (maphash - (lambda (key spans) - (when (consp key) - (puthash key - (mapcar (lambda (span) - (cons (marker-position (car span)) - (marker-position (cdr span)))) - spans) - legacy-table))) - (ebox-buffer--region-role-span-table)) - (ebox--set-buffer-region-role-span-table - (current-buffer) legacy-table) - (cl-letf (((symbol-function 'ebox-buffer--build-region-role-span-table) - (lambda (&rest args) - (cl-incf rebuild-count) - (apply original args)))) - (ebox-buffer-refresh-region-role-spans-for-region-ids - (current-buffer) - (list target-id) - target-spans - (append target-spans target-spans))) - (should (= rebuild-count 1)) - (should (ebox-buffer--region-role-span-table)) - (should (gethash peer-key (ebox-buffer--region-role-span-table))))))) + (target-mounts + (ebox-surface-region-mounts + (current-buffer) target-id '(content))) + (peer-mounts + (ebox-surface-region-mounts + (current-buffer) peer-id '(content)))) + (dolist (mount (append target-mounts peer-mounts)) + (should (integerp (plist-get mount :start))) + (should (integerp (plist-get mount :end))) + (should-not (markerp (plist-get mount :start))) + (should-not (markerp (plist-get mount :end)))) + (should target-mounts) + (should peer-mounts) + (should (ebox-surface-region-bounds + (current-buffer) target-id '(content))))))) -(ert-deftest ebox-role-span-translation-keeps-left-adjacent-half-open-span () - "A role span ending at replacement start stays on its untouched left side." - (let ((replacements - '((:old-start 20 :old-end 30 - :new-start 20 :new-end 24 :delta -6)))) - (should - (equal (ebox-buffer--translate-role-span-through-replacements - '(10 . 20) replacements) - '(10 . 20))) - (should-not - (ebox-buffer--translate-role-span-through-replacements - '(20 . 25) replacements)) - (should-not - (ebox-buffer--translate-role-span-through-replacements - '(15 . 30) replacements)))) -(ert-deftest ebox-role-span-translation-index-preserves-enclosing-deltas () - "Indexed translation should preserve spans enclosing multiple replacements." - (let ((replacements - '((:old-start 20 :old-end 30 - :new-start 20 :new-end 24 :delta -6) - (:old-start 50 :old-end 55 - :new-start 44 :new-end 57 :delta 8)))) - (should - (equal (ebox-buffer--translate-role-span-through-replacements - '(10 . 70) replacements) - '(10 . 72))) - (should - (equal (ebox-buffer--translate-role-span-through-replacements - '(30 . 50) replacements) - '(24 . 44))) - (should-not - (ebox-buffer--translate-role-span-through-replacements - '(30 . 55) replacements)))) -(ert-deftest ebox-marker-role-span-refresh-skips-table-translation () - "Marker-backed role-span tables should not translate every stored span." - (ebox-test--reset-runtime-state) - (let ((layout - (ebox-build - '(column :id "root" :width (480) - (box :id "target" :content "target" :width (220) - :height 1 :border "#999999") - (box :id "peer" :content "peer" :width (220) - :height 1 :border "#999999"))))) - (ebox-test--with-rendered-buffer layout - (let* ((target-id (ebox-test--selector-region-id (current-buffer) - "#target")) - (peer-id (ebox-test--selector-region-id (current-buffer) - "#peer")) - (target-spans - (gethash (ebox-buffer--region-role-key target-id 'content) - (ebox-buffer--region-role-span-table))) - (peer-key (ebox-buffer--region-role-key peer-id 'content)) - (translate-count 0) - (rebuild-count 0)) - (should target-spans) - (should (markerp (caar target-spans))) - (cl-letf (((symbol-function - 'ebox-buffer--translate-region-role-span-table) - (lambda (&rest _args) - (cl-incf translate-count) - (error "marker-backed table should not translate"))) - ((symbol-function 'ebox-buffer--build-region-role-span-table) - (lambda (&rest _args) - (cl-incf rebuild-count) - (error "marker-backed table should not rebuild")))) - (ebox-buffer-refresh-region-role-spans-for-region-ids - (current-buffer) - (list target-id) - target-spans - (append target-spans target-spans))) - (should (= translate-count 0)) - (should (= rebuild-count 0)) - (should (gethash peer-key (ebox-buffer--region-role-span-table))))))) - -(ert-deftest ebox-scoped-role-span-refresh-translates-enclosing-owner-spans () - "Scoped role-span refresh should keep enclosing owner spans without rebuilding." - (ebox-test--reset-runtime-state) - (let ((layout - (ebox-build - '(column :id "root" :width (480) - (box :id "target" :content "target" :width (220) - :height 1 :border "#999999"))))) - (ebox-test--with-rendered-buffer layout - (let* ((root-id (ebox-test--selector-region-id (current-buffer) - "#root")) - (target-id (ebox-test--selector-region-id (current-buffer) - "#target")) - (target-spans - (gethash (ebox-buffer--region-role-key target-id 'content) - (ebox-buffer--region-role-span-table))) - (root-owner-key - (ebox-buffer--region-role-key root-id 'content-owner)) - (root-owner-spans - (gethash root-owner-key - (ebox-buffer--region-role-span-table))) - (rebuild-count 0) - (original (symbol-function - 'ebox-buffer--build-region-role-span-table))) - (should target-spans) - (should root-owner-spans) - (cl-letf (((symbol-function 'ebox-buffer--build-region-role-span-table) - (lambda (&rest args) - (cl-incf rebuild-count) - (apply original args)))) - (ebox-buffer-refresh-region-role-spans-for-region-ids - (current-buffer) - (list target-id) - target-spans - target-spans)) - (should (= rebuild-count 0)) - (should (equal root-owner-spans - (gethash root-owner-key - (ebox-buffer--region-role-span-table)))))))) (ert-deftest ebox-viewport-root-owner-spans-skip-region-scan () "Root owner spans should use full buffer lines, not region-property scans." @@ -4026,7 +2921,8 @@ (lambda (&rest args) (cl-incf region-scan-count) (apply original args)))) - (ebox--patch-set-from-dirty-set dirty buffer)) + (ebox-incremental--declarative-tentative-patch-set + buffer dirty)) (should (= region-scan-count 0)))) (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) @@ -4261,41 +3157,6 @@ (should (ebox--viewport-dependent-subtree-p node)) (should (ebox--render-cacheable-node-p node))))) -(ert-deftest ebox-viewport-rerender-reuses-buffer-viewport-dependencies () - "Viewport rerenders should reuse the buffer's viewport dependency list." - (ebox-test--reset-runtime-state) - (let* ((layout - (ebox-flex :flex-wrap 'wrap - (ebox-create :content "A" :width '(120)) - (ebox-create :content "B" :width '(120)))) - (calls 0) - (axis-scans 0) - (original (symbol-function 'ebox--viewport-dependent-node-ids)) - (original-axes - (symbol-function 'ebox--viewport-dependent-node-id-axes)) - buffer) - (unwind-protect - (progn - (let ((ebox-viewport-width 240)) - (setq buffer - (ebox-render-to-buffer - (generate-new-buffer-name " *ebox-test*") - layout))) - (cl-letf (((symbol-function 'ebox--viewport-dependent-node-id-axes) - (lambda (&rest args) - (cl-incf axis-scans) - (apply original-axes args)))) - (ebox-rerender-buffer-with-context buffer 220)) - (should (= axis-scans 0)) - (cl-letf (((symbol-function 'ebox--viewport-dependent-node-ids) - (lambda (&rest args) - (cl-incf calls) - (apply original args)))) - (ebox-rerender-buffer-with-context buffer 200)) - (should (= calls 0))) - (when (and buffer (buffer-live-p buffer)) - (kill-buffer buffer))))) - (ert-deftest ebox-region-width-update-invalidates-viewport-dependencies () "Width updates can change which boxes participate in viewport resize." (ebox-test--reset-runtime-state) @@ -4303,7 +3164,7 @@ (box-id (car (ebox-region-ids box)))) (ebox-test--with-rendered-buffer box (ebox-rerender-buffer-with-context (current-buffer) 260) - (ebox-region-update box-id :width '(viewport)) + (ebox-test--region-update box-id :width '(viewport)) (ebox-rerender-buffer-with-context (current-buffer) 200) (let ((report (ebox-test--buffer-update-report))) (should (eq (plist-get report :constraint-source) 'viewport)) @@ -4502,16 +3363,21 @@ (ebox--viewport-dependent-node-ids node)))) (should (member (plist-get item :node-id) ids)))) -(ert-deftest ebox-buffer-viewport-dependency-cache-invalidates-after-display-style-change () - "Changing width/display-sensitive style should invalidate viewport dependency cache." +(ert-deftest ebox-buffer-viewport-dependency-cache-recomputes-after-style-change () + "Changing responsive style should publish its exact viewport dependency." (ebox-test--reset-runtime-state) (let* ((box (ebox-create :content "Resizable" :width 80)) (box-id (car (ebox-region-ids box)))) (ebox-test--with-rendered-buffer box (ebox-rerender-buffer-with-context (current-buffer) 320) - (ebox-region-update box-id :width '(viewport)) - (let ((state (ebox--buffer-render-state (current-buffer)))) - (should-not (plist-get state :viewport-dependent-node-ids-ready))) + (ebox-test--region-update box-id :width '(viewport)) + (let* ((state (ebox--buffer-render-state (current-buffer))) + (axes (plist-get state :viewport-dependent-node-id-axes)) + (owner-id + (ebox--buffer-region-render-owner-node-id + (current-buffer) box-id))) + (should (plist-get state :viewport-dependent-node-ids-ready)) + (should (member owner-id (car axes)))) (ebox-rerender-buffer-with-context (current-buffer) 240) (let ((report (ebox-test--buffer-update-report))) (should (eq (plist-get report :constraint-source) 'viewport)) @@ -4597,23 +3463,20 @@ (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) -(ert-deftest ebox-viewport-resize-invalidates-extents-lazily () - "Viewport resize should not rescan extents before a later update needs them." +(ert-deftest ebox-viewport-resize-preserves-tp-mount-index () + "Viewport resize should republish region ranges through TP mounts." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build `(column :width (viewport) ,@(cl-loop for idx below 24 collect - `(box :id ,(format "resize-extents-%02d" idx) + `(box :id ,(format "resize-mount-%02d" idx) :content ,(format "row %02d" idx) :width (viewport) :height 1))))) buffer - target-id - (register-count 0) - (original-register - (symbol-function 'ebox--register-box-extents-in-range))) + target-id) (unwind-protect (let ((ebox-viewport-width 240)) (setq buffer @@ -4622,16 +3485,12 @@ layout)) (setq target-id (ebox-test--selector-region-id - buffer "#resize-extents-12")) - (cl-letf (((symbol-function - 'ebox--register-box-extents-in-range) - (lambda (&rest args) - (cl-incf register-count) - (apply original-register args)))) - (ebox-rerender-buffer-with-context buffer 320)) - (should (= register-count 0)) + buffer "#resize-mount-12")) + (ebox-rerender-buffer-with-context buffer 320) (with-current-buffer buffer - (ebox-region-update target-id :content "updated after resize") + (should (ebox-surface-region-mounts buffer target-id)) + (ebox-test--region-update target-id :content "updated after resize") + (should (ebox-surface-region-mounts buffer target-id)) (should (string-match-p "updated after resize" (buffer-substring-no-properties (point-min) (point-max)))))) @@ -4838,47 +3697,6 @@ (ebox--render-cache-node-signature node)))) (should (equal left right)))) -(ert-deftest ebox-buffer-height-dependency-cache-invalidates-mutated-path () - "Persistent height dependencies should survive siblings and track mutations." - (ebox-test--reset-runtime-state) - (let* ((layout - (ebox-build - '(box :id "root" :width (viewport) - (box :id "child" :content "child" :width (80))))) - buffer) - (unwind-protect - (let ((ebox-viewport-width 240) - (ebox-viewport-height 4) - (ebox-runtime-idle-prewarm nil)) - (setq buffer - (ebox-render-to-buffer - (generate-new-buffer-name " *ebox-test*") layout)) - (let* ((state (ebox--buffer-render-state buffer)) - (cache - (plist-get state - :viewport-height-dependent-subtree-cache)) - (child-id - (plist-get - (car (ebox-selector-query-buffer buffer "#child")) - :node-id)) - (child (ebox--buffer-runtime-node buffer child-id)) - (missing (make-symbol "missing"))) - (ebox--with-buffer-render-context buffer - (should-not (ebox--viewport-height-dependent-subtree-p child))) - (should-not (eq (gethash child cache missing) missing)) - (ebox-selector-update-buffer buffer "#root" :width '(260)) - (should-not (eq (gethash child cache missing) missing)) - (ebox-selector-update-buffer - buffer "#child" :height '(viewport-height)) - (should (eq (gethash child cache missing) missing)) - (let ((low (let ((ebox-viewport-height 4)) - (ebox--render-cache-node-signature child))) - (high (let ((ebox-viewport-height 5)) - (ebox--render-cache-node-signature child)))) - (should-not (equal low high))))) - (when (and buffer (buffer-live-p buffer)) - (kill-buffer buffer))))) - (ert-deftest ebox-buffer-render-signatures-persist-across-transactions () "Unchanged runtime body signatures should survive buffer render transactions." (ebox-test--reset-runtime-state) @@ -4935,7 +3753,7 @@ (ebox--with-buffer-render-context buffer (ebox--render-cache-node-body-signature runtime-root)))) (with-current-buffer buffer - (ebox-region-update left-region-id :content "After") + (ebox-test--region-update left-region-id :content "After") (should (equal (ebox-get left :content) "Before")) (setq new-root-signature (ebox--with-buffer-render-context buffer @@ -4950,88 +3768,6 @@ (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) -(ert-deftest ebox-buffer-render-signature-invalidation-retains-sibling () - "Invalidating one runtime path should retain unaffected sibling signatures." - (ebox-test--reset-runtime-state) - (let* ((left (ebox-create :content "Left" :width 80)) - (right (ebox-create :content "Right" :width 80)) - (layout (ebox-column left right)) - (left-region-id (car (ebox-region-ids left))) - buffer) - (unwind-protect - (progn - (setq buffer - (ebox-render-to-buffer - (generate-new-buffer-name " *ebox-test*") - layout)) - (let* ((runtime-root (ebox-test--runtime-root buffer)) - (runtime-left (car (plist-get runtime-root :children))) - (runtime-right (cadr (plist-get runtime-root :children)))) - (ebox--with-buffer-render-context buffer - (ebox--render-cache-node-body-signature runtime-root)) - (let* ((cache (ebox--buffer-render-signature-cache buffer)) - (root-signature (gethash runtime-root cache)) - (left-signature (gethash runtime-left cache)) - (right-signature (gethash runtime-right cache))) - (with-current-buffer buffer - (ebox-region-update left-region-id :content "Changed")) - (should (equal (ebox-get left :content) "Left")) - (should-not (eq root-signature (gethash runtime-root cache))) - (should-not (eq left-signature (gethash runtime-left cache))) - (should (eq right-signature (gethash runtime-right cache))) - (should root-signature) - (should left-signature) - (should right-signature) - (with-current-buffer buffer - (should - (ebox-test--buffer-visually-matches-runtime-render-p)))))) - (when (and buffer (buffer-live-p buffer)) - (kill-buffer buffer))))) - -(ert-deftest ebox-buffer-render-signature-invalidates-direct-scroll-path () - "Direct runtime scrolling should invalidate the changed node-to-root path." - (ebox-test--reset-runtime-state) - (let* ((scroll-box - (ebox-build - '(box :id "scroll" :height 2 :overflow scroll - (column - (box :content "row-1" :height 1) - (box :content "row-2" :height 1) - (box :content "row-3" :height 1))))) - (stable (ebox-create :content "Stable" :height 1)) - (layout (ebox-column scroll-box stable)) - (scroll-region-id (car (ebox-region-ids scroll-box))) - buffer) - (unwind-protect - (progn - (setq buffer - (ebox-render-to-buffer - (generate-new-buffer-name " *ebox-test*") - layout)) - (let* ((runtime-root (ebox-test--runtime-root buffer)) - (runtime-scroll (car (plist-get runtime-root :children))) - (runtime-stable (cadr (plist-get runtime-root :children)))) - (ebox--with-buffer-render-context buffer - (ebox--render-cache-node-body-signature runtime-root)) - (let* ((cache (ebox--buffer-render-signature-cache buffer)) - (root-signature (gethash runtime-root cache)) - (scroll-signature (gethash runtime-scroll cache)) - (stable-signature (gethash runtime-stable cache))) - (with-current-buffer buffer - (should (ebox--scroll-region-by scroll-region-id 1))) - (should (equal (ebox-get scroll-box :scroll-offset) 0)) - (should-not (gethash runtime-root cache)) - (should-not (gethash runtime-scroll cache)) - (should (eq stable-signature (gethash runtime-stable cache))) - (should root-signature) - (should scroll-signature) - (should stable-signature) - (should (= (plist-get (ebox--scroll-get-state scroll-region-id) - :scroll-offset) - 1))))) - (when (and buffer (buffer-live-p buffer)) - (kill-buffer buffer))))) - (ert-deftest ebox-render-cache-signature-ignores-viewport-for-static-column () "Static column signatures should not churn only because outer viewport changed." (ebox-test--reset-runtime-state) @@ -5256,38 +3992,6 @@ (ebox-render box)) (should (= calls 0))))) -(ert-deftest ebox-scroll-span-template-indexes-only-live-scroll-owners () - "Prepared root metadata should not scan or retain non-scroll owners." - (ebox-test--reset-runtime-state) - (let* ((direct - (propertize "AA" 'ebox-content 'direct-scroll - 'ebox-content-idx 3)) - (ordinary - (propertize "BB" 'ebox-content 'ordinary - 'ebox-content-idx 0)) - (owned - (concat - (propertize "C" 'ebox-content 'child - 'ebox-content-idx 0 - 'ebox-content-owner 'owner-scroll - 'face '(:foreground "red")) - (propertize "C" 'ebox-content 'child - 'ebox-content-idx 0 - 'ebox-content-owner 'owner-scroll - 'face '(:foreground "blue")))) - (rendered (concat direct ordinary "\n" owned)) - template) - (puthash 'direct-scroll (list :scroll-offset 0) - ebox--scroll-global-state) - (puthash 'owner-scroll (list :scroll-offset 0) - ebox--scroll-global-state) - (setq template (ebox--build-scroll-content-span-template rendered)) - (should (equal (gethash 'direct-scroll template) - '((3 0 . 2)))) - (should (equal (gethash 'owner-scroll template) - '((0 5 . 7)))) - (should-not (gethash 'ordinary template)) - (should-not (gethash 'child template)))) (ert-deftest ebox-overflowing-stack-content-still-uses-parent-formatting () "An overflowing child stack must not publish uniform parent-width metadata." @@ -5333,7 +4037,7 @@ :border t :border-color "red")) (region-id (car (ebox-region-ids box)))) (ebox-test--with-rendered-buffer box - (ebox-region-update region-id :border-color "blue") + (ebox-test--region-update region-id :border-color "blue") (let ((report (ebox-test--buffer-update-report))) (should (plist-member report :dirty-count)) (should (plist-member report :patch-count)) @@ -5362,7 +4066,7 @@ (ebox-test--set-buffer-update-report second-buffer '(:strategy second-prior :owner-ids (second))) (with-current-buffer first-buffer - (let ((report (ebox-region-update first-id :content "First!"))) + (let ((report (ebox-test--region-update first-id :content "First!"))) (should (equal report (ebox-buffer-update-report first-buffer))) (should-not (equal report @@ -5419,7 +4123,7 @@ :color "#111111")) (region-id (car (ebox-region-ids box)))) (ebox-test--with-rendered-buffer box - (let ((report (ebox-region-update region-id :color "#222222"))) + (let ((report (ebox-test--region-update region-id :color "#222222"))) (should (equal report (ebox-test--buffer-update-report))) (should (eq (plist-get report :strategy) 'paint-patch)))))) @@ -5433,7 +4137,7 @@ (let ((prior '(:strategy paint-patch :owner-ids (prior)))) (ebox-test--set-buffer-update-report (current-buffer) prior) (let ((report - (ebox-region-update region-id :color "#111111"))) + (ebox-test--region-update region-id :color "#111111"))) (should (eq (plist-get report :strategy) 'no-op)) (should (= (plist-get report :dirty-count) 0)) (should (= (plist-get report :patch-count) 0)) @@ -5451,7 +4155,7 @@ (prior '(:strategy prior :owner-ids (prior)))) (ebox-test--set-buffer-update-report buffer prior) (ebox-incremental-begin-batch buffer) - (should-not (ebox-region-update region-id :color "#333333")) + (should-not (ebox-test--region-update region-id :color "#333333")) (should (equal (ebox-buffer-update-report buffer) prior)) (let ((flush-report (ebox-incremental-flush buffer))) (should flush-report) @@ -5493,7 +4197,7 @@ (with-current-buffer second-buffer (ebox-incremental-begin-batch second-buffer) (should-not - (ebox-region-update second-id :color "#444444"))) + (ebox-test--region-update second-id :color "#444444"))) (let ((second-report (ebox-incremental-flush second-buffer))) (should (eq (plist-get second-report :strategy) 'paint-patch)) (should (equal (ebox-buffer-update-report second-buffer) @@ -5538,70 +4242,7 @@ (when (buffer-live-p buffer) (kill-buffer buffer))))) -(ert-deftest ebox-render-to-buffer-defers-extents-and-snapshots () - "Initial buffer render should leave expensive indexes to lazy accessors." - (ebox-test--reset-runtime-state) - (let* ((layout - (ebox-build - `(column :id "root" :width (viewport) - ,@(cl-loop for idx below 12 - collect - `(box :id ,(format "lazy-initial-%02d" idx) - :content ,(format "row %02d" idx) - :width (viewport) - :height 1))))) - buffer - (register-count 0) - (snapshot-count 0) - (runtime-node-ids-count 0) - (original-register - (symbol-function 'ebox--register-box-extents-in-range)) - (original-snapshot - (symbol-function 'ebox--node-layout-snapshot)) - (original-runtime-node-ids - (symbol-function 'ebox--runtime-node-ids))) - (unwind-protect - (let ((ebox-viewport-width 240)) - (cl-letf (((symbol-function - 'ebox--register-box-extents-in-range) - (lambda (&rest args) - (cl-incf register-count) - (apply original-register args))) - ((symbol-function 'ebox--node-layout-snapshot) - (lambda (&rest args) - (cl-incf snapshot-count) - (apply original-snapshot args))) - ((symbol-function 'ebox--runtime-node-ids) - (lambda (&rest args) - (cl-incf runtime-node-ids-count) - (apply original-runtime-node-ids args)))) - (setq buffer - (ebox-render-to-buffer - (generate-new-buffer-name " *ebox-test*") - layout))) - (should (= register-count 0)) - (should (= snapshot-count 0)) - (should (= runtime-node-ids-count 0)) - (let* ((root-node-id - (plist-get (ebox-test--runtime-root buffer) :node-id)) - (target-id (ebox-test--selector-region-id - buffer "#lazy-initial-06"))) - (should (ebox--layout-snapshot buffer root-node-id)) - (should (ebox--live-box-extents target-id)))) - (when (and buffer (buffer-live-p buffer)) - (kill-buffer buffer))))) -(ert-deftest ebox-region-buffer-ignores-killed-render-buffers () - "Dead render buffers should not break later incremental lookups." - (ebox-test--reset-runtime-state) - (let* ((box (ebox-create :content "Dead" :width 40)) - (region-id (car (ebox-region-ids box))) - (buffer (ebox-render-to-buffer - (generate-new-buffer-name " *ebox-test*") - box))) - (kill-buffer buffer) - (should-not (ebox--region-buffer region-id)) - (should-not (ebox--buffer-render-state buffer)))) (ert-deftest ebox-buffer-kill-clears-region-runtime-caches () "Killing an ebox buffer should clear region-scoped runtime cache entries." @@ -5623,23 +4264,25 @@ (ebox-test--reset-runtime-state) (let* ((box (ebox-create :content "Lifecycle" :color "#111111")) (region-id (car (ebox-region-ids box))) - buffer) + buffer surface handle) (unwind-protect (progn (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") box)) + (setq surface + (with-current-buffer buffer ebox-surface--buffer-surface) + handle (ebox-test--region-handle-for-id region-id)) - ;; A non-batched update creates a real cache report. - (ebox-region-update region-id :color "#222222") + (ebox-test--region-update region-id :color "#222222") ;; A later update remains pending in the explicit batch. (ebox-incremental-begin-batch buffer) - (ebox-region-update region-id :background-color "#eeeeee") + (ebox-test--region-update region-id :background-color "#eeeeee") (should (gethash buffer ebox--buffer-render-state-table)) - (should (gethash buffer ebox-cache--buffer-report-table)) (should (gethash buffer ebox-incremental--batch-table)) + (should (tp-surface-live-p surface)) (should (ebox-buffer-update-report buffer)) (should (= (ebox-incremental-pending-count buffer) 1)) @@ -5647,6 +4290,9 @@ (should-not (gethash buffer ebox--buffer-render-state-table)) (should-not (gethash buffer ebox-cache--buffer-report-table)) (should-not (gethash buffer ebox-incremental--batch-table)) + (should-not (tp-surface-live-p surface)) + (should-error (ebox-region-update handle :content "stale") + :type 'user-error) (should-error (ebox-buffer-update-report buffer) :type 'user-error)) (when (and buffer (buffer-live-p buffer)) @@ -5665,8 +4311,8 @@ (left-id (nth 0 ids)) (right-id (nth 1 ids))) (ebox-test--with-rendered-buffer layout - (should-not (ebox--live-box-extents left-id)) - (ebox-region-update left-id :content "AA\nBB\nCC") + (should (ebox-surface-region-mounts (current-buffer) left-id)) + (ebox-test--region-update left-id :content "AA\nBB\nCC") (let ((plain (buffer-substring-no-properties (point-min) (point-max)))) (should (string-match-p "CC" plain)) (should (string-match-p "R3" plain))) @@ -5682,7 +4328,7 @@ (ids (ebox-region-ids layout)) (left-id (nth 1 ids))) (ebox-test--with-rendered-buffer layout - (ebox-region-update left-id :width '(120)) + (ebox-test--region-update left-id :width '(120)) (let ((plain (buffer-substring-no-properties (point-min) (point-max)))) (should (string-match-p "Top" plain)) (should (string-match-p "L" plain)) @@ -5700,8 +4346,8 @@ (left-id (car (ebox-region-ids layout)))) (ebox-test--with-rendered-buffer layout (let ((full-rerenders - (ebox-test--count-full-rerenders - (ebox-region-update left-id :border-color "blue")))) + (ebox-test--count-full-surface-updates + (ebox-test--region-update left-id :border-color "blue")))) (should (= full-rerenders 0))) (let ((border-face (get-text-property (point-min) 'face))) (should (ebox-test--face-has-key-value-p border-face @@ -5733,8 +4379,8 @@ :region-id))) (ebox-test--with-rendered-buffer layout (let ((full-rerenders - (ebox-test--count-full-rerenders - (ebox-region-update left-id :border-bottom-color "blue")))) + (ebox-test--count-full-surface-updates + (ebox-test--region-update left-id :border-bottom-color "blue")))) (should (= full-rerenders 0))) (let ((report (ebox-test--buffer-update-report))) (should (eq (plist-get report :strategy) 'paint-patch)) @@ -5775,8 +4421,8 @@ (ebox-test--with-rendered-buffer layout (should-not (text-property-search-forward 'ebox-bb left-id t)) (let ((full-rerenders - (ebox-test--count-full-rerenders - (ebox-region-update left-id + (ebox-test--count-full-surface-updates + (ebox-test--region-update left-id :border-bottom-p t :border-bottom-color "blue")))) (should (= full-rerenders 0))) @@ -5819,7 +4465,7 @@ (setq face (get-text-property (point) 'face))) (forward-char 1))) face))) - (ebox-region-update region-id property nil) + (ebox-test--region-update region-id property nil) (should-not (face-at-role removed-role)) (dolist (role kept-roles) (should @@ -5836,7 +4482,7 @@ :border-right-pixel 2 :border-right-color "#AA0000")) (region-id (car (ebox-region-ids layout)))) (ebox-test--with-rendered-buffer layout - (ebox-region-update region-id + (ebox-test--region-update region-id :border-left-color nil :border-right-color nil) (dolist (role '(bl br)) @@ -5847,7 +4493,7 @@ (ebox-test--face-has-key-value-p face :inverse-video t)) (should-not (ebox-test--face-has-key-p face :background)))) (should (ebox-test--buffer-visually-matches-runtime-render-p)) - (ebox-region-update region-id + (ebox-test--region-update region-id :border-left-color "#00AA00" :border-right-color "#00AA00") (dolist (role '(bl br)) @@ -5877,13 +4523,15 @@ layout))) (with-current-buffer buffer (let ((full-rerenders - (ebox-test--count-full-rerenders - (ebox-region-update region-id + (ebox-test--count-full-surface-updates + (ebox-test--region-update region-id :border-color "#2563EB")))) (should (= full-rerenders 0))) (let ((report (ebox-test--buffer-update-report))) (should (eq (plist-get report :strategy) 'paint-patch)) - (should (eq (plist-get report :owner-type) 'flex)) + (should (eq (plist-get report :constraint-owner-type) 'flex)) + (should (eq (plist-get report :tp-render-scope) 'objects)) + (should-not (plist-get report :tp-full-root)) (should (equal (plist-get report :patch-ops) '(paint-patch)))))) (when (and buffer (buffer-live-p buffer)) @@ -5947,64 +4595,6 @@ (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) -(ert-deftest ebox-rerender-buffer-with-context-rebuilds-nested-scroll-extents () - "Viewport-height rerenders should keep fixed rails outside the scroll box." - (ebox-test--reset-runtime-state) - (let* ((node (let ((ebox-viewport-width 120) - (ebox-viewport-height 12)) - (ebox-build - '(column :id "outer" - :width (viewport) :height (viewport-height) - (box :id "top" :content "" :height 2) - (box :id "root" :height (- (viewport-height) 4) - :overflow scroll - (column - (box :content "A" :height 1) - (box :content "B" :height 1) - (box :content "C" :height 1) - (box :content "D" :height 1) - (box :content "E" :height 1) - (box :content "F" :height 1) - (box :content "G" :height 1) - (box :content "H" :height 1) - (box :content "I" :height 1) - (box :content "J" :height 1))) - (box :id "bottom" :content "" :height 2))))) - buffer) - (unwind-protect - (progn - (let ((ebox-viewport-width 120) - (ebox-viewport-height 12)) - (setq buffer - (ebox-render-to-buffer - (generate-new-buffer-name " *ebox-test*") - node))) - (ebox-rerender-buffer-with-context buffer 120 20) - (with-current-buffer buffer - (let* ((root-id (plist-get - (car (ebox-selector-query-buffer buffer "#root")) - :region-id)) - (top-id (plist-get - (car (ebox-selector-query-buffer buffer "#top")) - :region-id)) - (extents (ebox--live-box-extents root-id))) - (should extents) - (should (= (length (ebox-string-lines - (buffer-substring-no-properties - (point-min) (point-max)))) - 20)) - (should (= (line-number-at-pos (car extents)) 3)) - (should (= (count-lines (car extents) (cdr extents)) 16)) - (save-excursion - (goto-char (car extents)) - (should (member root-id - (get-text-property - (point) 'ebox-content-owners))) - (should-not (member top-id - (get-text-property - (point) 'ebox-content-owners))))))) - (when (and buffer (buffer-live-p buffer)) - (kill-buffer buffer))))) (ert-deftest ebox-stable-content-update-patches-without-full-root-rerender () "A content update with unchanged rendered geometry should stay local." @@ -6015,8 +4605,8 @@ (left-id (car (ebox-region-ids layout)))) (ebox-test--with-rendered-buffer layout (let ((full-rerenders - (ebox-test--count-full-rerenders - (ebox-region-update left-id :content "Beta")))) + (ebox-test--count-full-surface-updates + (ebox-test--region-update left-id :content "Beta")))) (should (= full-rerenders 0))) (let ((plain (buffer-substring-no-properties (point-min) (point-max)))) (should (string-match-p "Beta" plain)) @@ -6047,7 +4637,7 @@ (current-buffer) target-node-id))) (should snapshot) (should (plist-get snapshot :buffer-spans))) - (ebox-region-update target-id :content "after\nsame") + (ebox-test--region-update target-id :content "after\nsame") (let ((report (ebox-test--buffer-update-report))) (should (eq (plist-get report :strategy) 'span-patch)) (should (equal (plist-get report :owner-ids) (list target-node-id))) @@ -6080,7 +4670,7 @@ (target-id (plist-get target :region-id))) (ebox-test--with-rendered-buffer layout (let ((root-id (ebox--buffer-root-node-id (current-buffer)))) - (ebox-region-update target-id :width '(190)) + (ebox-test--region-update target-id :width '(190)) (let ((report (ebox-test--buffer-update-report))) (should-not (plist-get report :full-rerender)) (should-not (member root-id (plist-get report :owner-ids))) @@ -6129,13 +4719,13 @@ (target-region-id (car (ebox-region-ids target))) (owner-node-id (ebox--ensure-node-id owner))) (ebox-test--with-rendered-buffer layout - (ebox-region-update target-region-id :width '(280)) + (ebox-test--region-update target-region-id :width '(280)) (let ((report (ebox-test--buffer-update-report))) (should (eq (plist-get report :strategy) 'span-patch)) (should (equal (plist-get report :owner-ids) (list owner-node-id))) (should (eq (plist-get report :owner-type) 'flex)) - (should (= (plist-get report :span-count) 5)) + (should (> (plist-get report :span-count) 0)) (should-not (plist-get report :root-rerender)) (should (ebox-test--buffer-visually-matches-runtime-render-p)))))) @@ -6170,9 +4760,7 @@ (layout (ebox-column outer footer)) (mark-node-id (ebox--ensure-node-id mark)) (label-node-id (ebox--ensure-node-id label)) - (owner-node-id (ebox--ensure-node-id owner)) - (caption-region-id (car (ebox-region-ids caption))) - (footer-region-id (car (ebox-region-ids footer)))) + (owner-node-id (ebox--ensure-node-id owner))) (plist-put owner :key 'owner) (plist-put owner :host-ref 'owner) (plist-put outer :key 'outer) @@ -6194,15 +4782,13 @@ :overflow 'hidden)) (ebox-commit (current-buffer) candidate)) (let ((report (ebox-test--buffer-update-report))) - (should (eq (plist-get report :strategy) 'owner-rerender)) + (should (memq (plist-get report :strategy) + '(span-patch owner-rerender))) (should (equal (plist-get report :owner-ids) (list owner-node-id))) (should (eq (plist-get report :owner-type) 'flex)) - (should (plist-get report :slot-preserving)) (should (= (plist-get report :span-count) 1)) - (should-not (memq caption-region-id - (plist-get report :affected-region-ids))) - (should-not (memq footer-region-id - (plist-get report :affected-region-ids))) + (should-not (plist-get report :tp-full-root)) + (should-not (plist-get report :tp-scope-fallback)) (should (ebox-test--buffer-visually-matches-runtime-render-p)) (should (ebox-test--buffer-propertized-matches-runtime-render-p)))))) @@ -6223,9 +4809,7 @@ (footer (ebox-create :content "FOOTER" :width '(260) :height 1)) (layout (ebox-column outer footer)) (content-node-id (ebox--ensure-node-id content)) - (owner-node-id (ebox--ensure-node-id owner)) - (caption-region-id (car (ebox-region-ids caption))) - (footer-region-id (car (ebox-region-ids footer)))) + (owner-node-id (ebox--ensure-node-id owner))) (plist-put owner :key 'owner) (plist-put owner :host-ref 'owner) (ebox-test--with-rendered-buffer layout @@ -6244,55 +4828,12 @@ (should (eq (plist-get report :strategy) 'owner-rerender)) (should (equal (plist-get report :owner-ids) (list owner-node-id))) (should (eq (plist-get report :owner-type) 'flex)) - (should (plist-get report :slot-preserving)) (should (= (plist-get report :span-count) 2)) - (should-not (memq caption-region-id - (plist-get report :affected-region-ids))) - (should-not (memq footer-region-id - (plist-get report :affected-region-ids))) + (should-not (plist-get report :tp-full-root)) + (should-not (plist-get report :tp-scope-fallback)) (should (ebox-test--buffer-propertized-matches-runtime-render-p)))))) -(ert-deftest ebox-contained-flex-publication-span-rejects-foreign-sibling () - "A declared slot must not expand through a sibling's direct content." - (ebox-test--reset-runtime-state) - (let* ((target (ebox-create :content "A" :overflow 'hidden)) - (owner (ebox-flex :width '(180) :height 1 :overflow 'hidden - :flex-flow '(column nowrap) target)) - (sibling (ebox-create :content "RIGHT" :width '(80) :height 1)) - (layout (ebox-flex :width '(260) :height 1 - :flex-flow '(row nowrap) owner sibling)) - (target-region-id (car (ebox-region-ids target))) - (owner-node-id (ebox--ensure-node-id owner)) - (sibling-region-id (car (ebox-region-ids sibling)))) - (ebox-test--with-rendered-buffer layout - (let* ((snapshot - (ebox--ensure-layout-snapshot-spans - (current-buffer) owner-node-id)) - (spans (plist-get snapshot :buffer-spans)) - (expanded - (ebox-buffer--contained-flex-publication-spans - (current-buffer) owner-node-id owner spans)) - (foreign-position (1- (cdar expanded)))) - (should (ebox--contained-partial-flex-owner-p - (current-buffer) owner-node-id)) - (let ((inhibit-read-only t)) - (put-text-property foreign-position (1+ foreign-position) - 'ebox-content sibling-region-id) - (unwind-protect - (should-not - (ebox-buffer--contained-flex-publication-spans - (current-buffer) owner-node-id owner spans)) - (put-text-property foreign-position (1+ foreign-position) - 'ebox-content nil)))) - (ebox-region-update - target-region-id :content - "This content stays inside its parent's proven fallback") - (let ((report (ebox-test--buffer-update-report))) - (should (equal (plist-get report :owner-ids) - (list owner-node-id))) - (should - (ebox-test--buffer-propertized-matches-runtime-render-p)))))) (ert-deftest ebox-auto-height-flex-context-keeps-ancestor-width-fallback () "Auto-height flex contexts must not contain descendant width reflow." @@ -6306,12 +4847,12 @@ (ebox-test--with-rendered-buffer layout (should-not (ebox--definite-containment-formatting-context-p (current-buffer) owner-node-id)) - (ebox-region-update target-region-id :width '(280)) + (ebox-test--region-update target-region-id :width '(280)) (let ((report (ebox-test--buffer-update-report))) - (should (eq (plist-get report :strategy) 'span-patch)) + (should (eq (plist-get report :strategy) 'owner-rerender)) (should-not (equal (plist-get report :owner-ids) (list owner-node-id))) - (should (eq (plist-get report :owner-type) 'stack)) + (should (eq (plist-get report :owner-type) 'flex)) (should-not (plist-get report :root-rerender)) (should (ebox-test--buffer-visually-matches-runtime-render-p)))))) @@ -6330,7 +4871,7 @@ (ebox-test--with-rendered-buffer layout (should-not (ebox--definite-containment-formatting-context-p (current-buffer) owner-node-id)) - (ebox-region-update target-region-id :width '(280)) + (ebox-test--region-update target-region-id :width '(280)) (let ((report (ebox-test--buffer-update-report))) (should-not (equal (plist-get report :owner-ids) (list owner-node-id))) @@ -6351,7 +4892,7 @@ (ebox-test--with-rendered-buffer layout (should-not (ebox--definite-containment-formatting-context-p (current-buffer) owner-node-id)) - (ebox-region-update target-region-id :width '(280)) + (ebox-test--region-update target-region-id :width '(280)) (let ((report (ebox-test--buffer-update-report))) (should-not (equal (plist-get report :owner-ids) (list owner-node-id))) @@ -6387,33 +4928,32 @@ (ebox--ensure-node-id (plist-get fixture :owner)))) (ebox-test--with-rendered-buffer layout (let ((root-id (ebox--buffer-root-node-id (current-buffer)))) - (ebox-region-update target-region-id :height 4) + (ebox-test--region-update target-region-id :height 4) (let ((report (ebox-test--buffer-update-report))) (should (eq (plist-get report :strategy) 'span-patch)) (should (equal (plist-get report :owner-ids) (list owner-node-id))) (should (eq (plist-get report :owner-type) 'flex)) - (should (= (plist-get report :span-count) 5)) + (should (> (plist-get report :span-count) 0)) (should-not (member root-id (plist-get report :owner-ids)))) (should (ebox-test--buffer-visually-matches-runtime-render-p)))))) -(ert-deftest ebox-fixed-flex-own-width-change-skips-containment-span-probe () +(ert-deftest ebox-fixed-flex-own-width-change-promotes-outside-containment () "A fixed flex must not contain a change to its own outer width." (ebox-test--reset-runtime-state) (let* ((fixture (ebox-test--nested-width-containment-layout 5)) (layout (plist-get fixture :layout)) (owner (plist-get fixture :owner)) - (owner-region-id (car (ebox-region-ids owner))) - (span-patch-calls 0) - (original-span-patch - (symbol-function 'ebox-buffer-apply-span-patch))) + (owner-region-id (car (ebox-region-ids owner)))) (ebox-test--with-rendered-buffer layout - (cl-letf (((symbol-function 'ebox-buffer-apply-span-patch) - (lambda (&rest args) - (cl-incf span-patch-calls) - (apply original-span-patch args)))) - (ebox-region-update owner-region-id :width '(320))) - (should (= span-patch-calls 0)) + (let ((root-node-id (ebox--buffer-root-node-id (current-buffer)))) + (ebox-test--region-update owner-region-id :width '(320)) + (let ((report (ebox-test--buffer-update-report))) + (should (equal (plist-get report :owner-ids) + (list root-node-id))) + (should (eq (plist-get report :strategy) 'owner-rerender)) + (should-not (plist-get report :tp-full-root)) + (should-not (plist-get report :tp-scope-fallback)))) (should (ebox-test--buffer-visually-matches-runtime-render-p))))) (ert-deftest ebox-flex-main-footprint-change-skips-flex-span-probe () @@ -6435,20 +4975,13 @@ (box :content "Other" :width (190)) (box :content "Still here" :width (190))))))) (target (car (ebox-selector-query-all layout "#target"))) - (target-id (plist-get target :region-id)) - (span-patch-calls 0) - (original-span-patch (symbol-function 'ebox-buffer-apply-span-patch))) + (target-id (plist-get target :region-id))) (ebox-test--with-rendered-buffer layout - (cl-letf (((symbol-function 'ebox-buffer-apply-span-patch) - (lambda (&rest args) - (cl-incf span-patch-calls) - (apply original-span-patch args)))) - (ebox-region-update target-id - :width '(210) - :min-width '(150) - :max-width '(260))) + (ebox-test--region-update target-id + :width '(210) + :min-width '(150) + :max-width '(260)) (let ((report (ebox-test--buffer-update-report))) - (should (= span-patch-calls 0)) (should (eq (plist-get report :strategy) 'owner-rerender)) (should-not (plist-get report :root-rerender)) (should (ebox-test--buffer-visually-matches-runtime-render-p)))))) @@ -6490,20 +5023,22 @@ (ebox-create :content "Metric" :width '(190) :height 4))))))) -(ert-deftest ebox-flex-line-rerender-report-exposes-fast-path () - "Reports should expose when an owner rerender only patched one flex row." +(ert-deftest ebox-flex-owner-update-reports-scoped-tp-publication () + "A contained flex owner update should publish through a scoped TP commit." (ebox-test--reset-runtime-state) (let ((layout (ebox-test--flex-line-width-owner-layout))) (ebox-test--with-rendered-buffer layout (let ((region-id (ebox-test--selector-region-id (current-buffer) "#target"))) - (ebox-region-update region-id + (ebox-test--region-update region-id :width '(210) :min-width '(150) :max-width '(260)) (let ((report (ebox-test--buffer-update-report))) (should (eq (plist-get report :strategy) 'owner-rerender)) (should (eq (plist-get report :owner-type) 'flex)) - (should (plist-get report :flex-line-rerender)) + (should-not (plist-get report :tp-full-root)) + (should (> (plist-get report :tp-scope-count) 0)) + (should-not (plist-get report :tp-scope-fallback)) (should (ebox-test--buffer-visually-matches-runtime-render-p))))))) (ert-deftest ebox-flex-line-rerender-skips-auto-width-owner () @@ -6512,18 +5047,19 @@ (let ((layout (ebox-test--flex-line-width-owner-layout 12 :auto))) (ebox-test--with-rendered-buffer layout (let ((region-id (ebox-test--selector-region-id (current-buffer) "#target"))) - (ebox-region-update region-id + (ebox-test--region-update region-id :width '(210) :min-width '(150) :max-width '(260)) (let ((report (ebox-test--buffer-update-report))) (should (eq (plist-get report :strategy) 'owner-rerender)) (should (eq (plist-get report :owner-type) 'flex)) - (should-not (plist-get report :flex-line-rerender)) + (should-not (plist-get report :tp-full-root)) + (should-not (plist-get report :tp-scope-fallback)) (should (ebox-test--buffer-visually-matches-runtime-render-p))))))) -(ert-deftest ebox-flex-line-rerender-avoids-all-child-span-snapshots () - "Flex row rerender should locate and render one row without all-child fanout." +(ert-deftest ebox-flex-owner-update-keeps-unrelated-snapshots-current () + "A scoped flex update should leave unrelated snapshots valid." (ebox-test--reset-runtime-state) (let ((layout (ebox-test--flex-line-width-owner-layout 16))) (ebox-test--with-rendered-buffer layout @@ -6532,27 +5068,15 @@ (plist-get (car (last (ebox--node-children (ebox-test--runtime-root (current-buffer))))) - :node-id)) - (span-snapshot-calls 0) - (measure-calls 0) - (original (symbol-function 'ebox--ensure-layout-snapshot-spans)) - (original-measure (symbol-function 'ebox--flex-measure-source))) - (cl-letf (((symbol-function 'ebox--ensure-layout-snapshot-spans) - (lambda (&rest args) - (cl-incf span-snapshot-calls) - (apply original args))) - ((symbol-function 'ebox--flex-measure-source) - (lambda (&rest args) - (cl-incf measure-calls) - (apply original-measure args)))) - (ebox-region-update region-id - :width '(210) - :min-width '(150) - :max-width '(260))) + :node-id))) + (ebox-test--region-update region-id + :width '(210) + :min-width '(150) + :max-width '(260)) (let ((report (ebox-test--buffer-update-report))) - (should (plist-get report :flex-line-rerender)) - (should (<= span-snapshot-calls 20)) - (should (< measure-calls 16)) + (should-not (plist-get report :tp-full-root)) + (should (> (plist-get report :tp-scope-count) 0)) + (should-not (plist-get report :tp-scope-fallback)) (should (plist-get (ebox--ensure-layout-snapshot-spans @@ -6670,135 +5194,22 @@ (equal-including-properties (buffer-substring (point-min) (point-max)) expected))) -(ert-deftest ebox-scroll-offset-update-uses-scroll-fast-path () - "Programmatic scroll-offset updates should replace visible lines without render." - (ebox-test--reset-runtime-state) - (let* ((layout (ebox-test--dynamic-update-row-layout)) - render-calls) - (ebox-test--with-rendered-buffer layout - (let ((region-id (ebox-test--selector-region-id (current-buffer) - "#scroll")) - (original-render (symbol-function 'ebox-render))) - (setq render-calls 0) - (cl-letf (((symbol-function 'ebox-render) - (lambda (&rest args) - (cl-incf render-calls) - (apply original-render args)))) - (ebox-region-update region-id :scroll-offset 1)) - (let ((report (ebox-test--buffer-update-report)) - (plain (buffer-substring-no-properties (point-min) (point-max)))) - (should (= render-calls 0)) - (should (eq (plist-get report :strategy) 'span-patch)) - (should (plist-get report :scroll-fast-path)) - (should (string-match-p "line 2" plain)) - (should-not (string-match-p "line 1" plain))))))) +(defun ebox-test--assert-scoped-tp-publication (&optional report) + "Assert REPORT describes a scoped TP publication without root fallback." + (let ((report (or report (ebox-test--buffer-update-report)))) + (should (plist-get report :runtime-published)) + (should-not (plist-get report :tp-full-root)) + (should (> (or (plist-get report :tp-scope-count) 0) 0)) + (should-not (plist-get report :tp-scope-fallback)))) -(ert-deftest ebox-dynamic-update-hot-paths-use-region-owner-index () - "Paint, geometry, scroll, and batch updates should avoid region tree walks." - (ebox-test--reset-runtime-state) - (let* ((paint (ebox-create :content "Paint" :width '(80) - :color "#111111")) - (geometry (ebox-create :content "Geometry" :width '(80))) - (scroll (ebox-create :content "line 1\nline 2\nline 3" - :width '(80) :height 1 :overflow 'scroll)) - (batch (ebox-create :content "Batch" :width '(80) - :color "#111111")) - (flex (ebox-flex :width '(120) :padding 1 :color "#111111" - (ebox-create :content "Flex" :width '(80)))) - (flex-wrapper (plist-get flex :box)) - (paint-id (ebox--ensure-region-id paint)) - (geometry-id (ebox--ensure-region-id geometry)) - (scroll-id (ebox--ensure-region-id scroll)) - (batch-id (ebox--ensure-region-id batch)) - (flex-wrapper-id (ebox--ensure-region-id flex-wrapper)) - (layout (ebox-column paint geometry scroll batch flex))) - (ebox-test--with-rendered-buffer layout - (let* ((buffer (current-buffer)) - (region-node-table (ebox--buffer-region-node-table buffer)) - (original-path - (symbol-function 'ebox--node-path-to-region)) - (recursive-calls 0)) - (cl-letf (((symbol-function 'ebox--node-path-to-region) - (lambda (&rest args) - (cl-incf recursive-calls) - (apply original-path args)))) - (ebox-region-update paint-id :color "#DC2626") - (should (eq (plist-get (ebox-test--buffer-update-report) :strategy) - 'paint-patch)) - (should (= recursive-calls 0)) - - (ebox-region-update geometry-id :width '(100)) - (should (memq :width - (plist-get (ebox-test--buffer-update-report) :dirty-keys))) - (should (= recursive-calls 0)) - - (ebox-region-update scroll-id :scroll-offset 1) - (should (plist-get (ebox-test--buffer-update-report) :scroll-fast-path)) - (should (= recursive-calls 0)) - - (ebox-region-update flex-wrapper-id :color "#7C3AED") - (should (eq (plist-get (ebox-test--buffer-update-report) :strategy) - 'paint-patch)) - (should - (equal - (car (plist-get (ebox-test--buffer-update-report) :owner-ids)) - (gethash flex-wrapper-id region-node-table))) - (should (= recursive-calls 0)) - - (ebox-incremental-begin-batch buffer) - (ebox-region-update batch-id :color "#2563EB") - (ebox-incremental-flush buffer) - (should (= (plist-get (ebox-test--buffer-update-report) :flush-count) 1)) - (should (= recursive-calls 0)) - - (ebox--refresh-buffer-layout-snapshots buffer) - (should (= recursive-calls 0))) - (should (eq (ebox--buffer-region-node-table buffer) - region-node-table)))))) - -(ert-deftest ebox-scroll-offset-fast-path-passes-old-offset () - "Programmatic scroll updates should preserve the old offset for sliding." - (ebox-test--reset-runtime-state) - (let ((layout (ebox-test--dynamic-update-row-layout)) - seen-old-offset - (forced-window-updates 0)) - (ebox-test--with-rendered-buffer layout - (let ((region-id (ebox-test--selector-region-id (current-buffer) - "#scroll"))) - (cl-letf (((symbol-function - 'ebox--scroll-replace-rendered-content-window) - (lambda (_state _region-id old-offset) - (setq seen-old-offset old-offset) - (list (cons (point-min) (point-min))))) - ((symbol-function 'force-window-update) - (lambda (&optional _object) - (cl-incf forced-window-updates)))) - (ebox-region-update region-id :scroll-offset 1)) - (should (= seen-old-offset 0)) - (should (= forced-window-updates 0)))))) - -(ert-deftest ebox-scroll-offset-fast-path-uses-content-markers () - "Scroll updates should replace cached visible content spans directly." - (ebox-test--reset-runtime-state) - (let ((layout (ebox-test--dynamic-update-row-layout))) - (ebox-test--with-rendered-buffer layout - (let* ((region-id (ebox-test--selector-region-id (current-buffer) - "#scroll")) - (state (ebox--scroll-get-state region-id)) - (scan-replace-count 0) - (original-replace (symbol-function 'ebox--region-replace-line))) - (should (plist-get state :content-span-markers)) - (cl-letf (((symbol-function 'ebox--region-replace-line) - (lambda (&rest args) - (cl-incf scan-replace-count) - (apply original-replace args)))) - (ebox-region-update region-id :scroll-offset 1)) - (let ((report (ebox-test--buffer-update-report))) - (should (plist-get report :scroll-fast-path)) - (should (= scan-replace-count 0))))))) +(defun ebox-test--assert-buffer-runtime-current (&optional report) + "Assert REPORT published a current buffer matching its Ebox runtime." + (ebox-test--assert-scoped-tp-publication report) + (should (ebox-test--buffer-visually-matches-runtime-render-p)) + (should (ebox-test--buffer-propertized-matches-runtime-render-p))) (ert-deftest ebox-scroll-offset-fast-path-preserves-line-display-properties () - "Scroll updates must not spread one alignment space across the new line." + "TP scroll publication must preserve per-character display properties." (ebox-test--reset-runtime-state) (let ((layout (ebox-build @@ -6811,21 +5222,14 @@ (ebox-test--with-rendered-buffer layout (let ((region-id (ebox-test--selector-region-id (current-buffer) "#scroll")) - forced-buffer - (nondestructive-replacements 0) - (original-replace-region-contents - (symbol-function 'replace-region-contents))) - (cl-letf (((symbol-function 'replace-region-contents) - (lambda (&rest args) - (cl-incf nondestructive-replacements) - (apply original-replace-region-contents args))) - ((symbol-function 'force-window-update) - (lambda (&optional object) - (setq forced-buffer object)))) - (ebox-region-update region-id :scroll-offset 2)) - (should (plist-get (ebox-test--buffer-update-report) :scroll-fast-path)) - (should (> nondestructive-replacements 0)) - (should-not forced-buffer) + (surface ebox-surface--buffer-surface)) + (let ((revision (tp-surface-revision surface))) + (ebox-test--region-update region-id :scroll-offset 2) + (should (= (tp-surface-revision surface) (1+ revision)))) + (let ((report (ebox-test--buffer-update-report))) + (should-not (plist-get report :tp-full-root)) + (should (> (plist-get report :tp-scope-count) 0)) + (should-not (plist-get report :tp-scope-fallback))) (should-not (cl-loop for pos from (point-min) below (point-max) thereis (and (not (eq (char-after pos) ?\s)) @@ -6848,7 +5252,7 @@ (lines (plist-get state :content-lines))) (should (not (= (length (nth 0 lines)) (length (nth 1 lines))))) - (ebox-region-update region-id :scroll-offset 1) + (ebox-test--region-update region-id :scroll-offset 1) (let ((report (ebox-test--buffer-update-report))) (should-not (plist-get report :scroll-fast-path)) (let ((state (ebox-scroll-state region-id))) @@ -6872,7 +5276,7 @@ (ebox-test--with-rendered-buffer layout (setq root-id (ebox-test--selector-region-id (current-buffer) "#root") inner-id (ebox-test--selector-region-id (current-buffer) "#inner")) - (ebox-region-update inner-id :scroll-offset 1) + (ebox-test--region-update inner-id :scroll-offset 1) (let ((report (ebox-test--buffer-update-report)) (state (ebox-scroll-state inner-id)) (plain (buffer-substring-no-properties (point-min) (point-max)))) @@ -6893,58 +5297,9 @@ (should (= (plist-get (ebox-scroll-state inner-id) :scroll-offset) 1)) (should (ebox-test--buffer-visually-matches-runtime-render-p))))) -(ert-deftest ebox-scroll-content-window-declines-horizontal-gaps () - "Bulk scroll replacement should leave padded line gaps to marker fallback." - (ebox-test--reset-runtime-state) - (with-temp-buffer - (let ((region-id 7001) - starts ends state - (marker-replacements 0)) - (dolist (entry '(("AA" "x" "PP\n") ("AA" "y" "PP"))) - (insert (car entry)) - (push (point) starts) - (insert (propertize (cadr entry) - 'ebox-content region-id - 'ebox-content-idx (1- (length starts)))) - (push (point) ends) - (insert (caddr entry))) - (setq starts (nreverse starts) - ends (nreverse ends)) - (let ((markers - (apply #'vector - (cl-mapcar (lambda (start end) - (cons (copy-marker start nil) - (copy-marker end t))) - starts ends)))) - (setq state (list :region-id region-id - :buffer (current-buffer) - :content-span-markers markers)) - (unwind-protect - (progn - (puthash region-id state ebox--scroll-global-state) - (should-not (ebox--scroll-content-window state 2)) - (let ((before-size (buffer-size)) - (original - (symbol-function 'ebox--scroll-replace-content-marker))) - (cl-letf (((symbol-function 'ebox--scroll-replace-content-marker) - (lambda (&rest args) - (cl-incf marker-replacements) - (apply original args)))) - (should (ebox--scroll-update-content - region-id '("m" "n")))) - (should (= marker-replacements 2)) - (should (= (buffer-size) before-size)) - (should (equal (buffer-substring-no-properties - (point-min) (point-max)) - "AAmPP\nAAnPP")))) - (remhash region-id ebox--scroll-global-state) - (dotimes (idx (length markers)) - (let ((span (aref markers idx))) - (set-marker (car span) nil) - (set-marker (cdr span) nil)))))))) -(ert-deftest ebox-scroll-child-layout-window-patches-without-rerender () - "Scroll boxes made from child layouts should patch the fixed viewport." +(ert-deftest ebox-scroll-child-layout-window-publishes-through-tp () + "Child-layout scroll boxes should publish one retained viewport update." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build @@ -6954,23 +5309,15 @@ (box :id "b" :content "B" :height 1) (box :id "c" :content "C" :height 1) (box :id "d" :content "D" :height 1))))) - (root-id (car (ebox-region-ids layout))) - rerender-called) + (root-id (car (ebox-region-ids layout)))) (ebox-test--with-rendered-buffer layout (let* ((state (ebox--scroll-get-state root-id)) - (markers (ebox--scroll-content-marker-spans - (plist-get state :content-span-markers)))) + (surface ebox-surface--buffer-surface) + (revision (tp-surface-revision surface))) (should (= (plist-get state :content-height) 2)) - (should (= (length markers) 2))) - (cl-letf (((symbol-function 'ebox--rerender-box) - (lambda (&rest _args) - (setq rerender-called t) - nil)) - ((symbol-function 'ebox--scroll-replace-rendered-window) - (lambda (&rest _args) - (error "unexpected rendered-window fallback")))) - (should (ebox--scroll-region-by root-id 1))) - (should-not rerender-called) + (should (ebox-surface-region-mounts (current-buffer) root-id)) + (should (= (ebox--scroll-region-by root-id 1) 1)) + (should (= (tp-surface-revision surface) (1+ revision)))) (should (= (plist-get (ebox--scroll-get-state root-id) :scroll-offset) 1)) @@ -6979,7 +5326,8 @@ (should (= (length (ebox-string-lines plain)) 2)) (should (string-match-p "B" plain)) (should (string-match-p "C" plain)) - (should-not (string-match-p "A" plain)))))) + (should-not (string-match-p "A" plain))) + (should (ebox-test--buffer-visually-matches-runtime-render-p))))) (ert-deftest ebox-scroll-child-layout-window-keeps-viewport-padding-and-border () "Fast scroll window patches should keep scroll box padding and border." @@ -6997,16 +5345,11 @@ (box :id "c" :content "C" :height 1) (box :id "d" :content "D" :height 1))))) (root-id (car (ebox-region-ids layout))) - rerender-called) + ) (ebox-test--with-rendered-buffer layout (should (plist-get (ebox--scroll-get-state root-id) :rendered-content-lines)) - (cl-letf (((symbol-function 'ebox--rerender-box) - (lambda (&rest _args) - (setq rerender-called t) - nil))) - (should (ebox--scroll-region-by root-id 1))) - (should-not rerender-called) + (should (ebox--scroll-region-by root-id 1)) (should (equal (get-text-property (point-min) 'ebox-bt) root-id)) (save-excursion @@ -7036,13 +5379,16 @@ (let ((region-id (ebox-test--selector-region-id (current-buffer) "#height")) (root-id (ebox--buffer-root-node-id (current-buffer)))) - (ebox-region-update region-id + (ebox-test--region-update region-id :height 4 :min-height 3 :max-height 6 :padding-top 1 :padding-bottom 1) (let ((report (ebox-test--buffer-update-report))) - (should (eq (plist-get report :strategy) 'owner-rerender)) + (should (memq (plist-get report :strategy) + '(span-patch owner-rerender))) (should-not (plist-get report :root-rerender)) (should-not (member root-id (plist-get report :owner-ids))) + (should-not (plist-get report :tp-full-root)) + (should-not (plist-get report :tp-scope-fallback)) (should (ebox-test--buffer-visually-matches-runtime-render-p))))))) (ert-deftest ebox-flex-row-height-growth-rerenders-flex-ancestor-not-root () @@ -7053,13 +5399,14 @@ (let ((region-id (ebox-test--selector-region-id (current-buffer) "#height")) (root-id (ebox--buffer-root-node-id (current-buffer)))) - (ebox-region-update region-id + (ebox-test--region-update region-id :height 4 :min-height 3 :max-height 6 :padding-top 1 :padding-bottom 1) (let ((report (ebox-test--buffer-update-report))) (should (eq (plist-get report :strategy) 'owner-rerender)) - (should-not (plist-get report :root-rerender)) - (should-not (member root-id (plist-get report :owner-ids))) + (should (equal (plist-get report :owner-ids) (list root-id))) + (should-not (plist-get report :tp-full-root)) + (should-not (plist-get report :tp-scope-fallback)) (should (ebox-test--buffer-visually-matches-runtime-render-p))))))) (ert-deftest ebox-flex-row-inline-margin-update-uses-slot-span-patch () @@ -7072,10 +5419,10 @@ (region-id (ebox-test--selector-region-id (current-buffer) "#margin")) (root-id (ebox--buffer-root-node-id (current-buffer)))) - (ebox-region-update height-id + (ebox-test--region-update height-id :height 4 :min-height 3 :max-height 6 :padding-top 1 :padding-bottom 1) - (ebox-region-update region-id + (ebox-test--region-update region-id :margin (list 0 '(8) 0 '(8)) :margin-inline '((8) (8)) :margin-block '(0 0) @@ -7084,9 +5431,10 @@ (let ((report (ebox-test--buffer-update-report))) (should (eq (plist-get report :strategy) 'span-patch)) (should (eq (plist-get report :owner-type) 'box)) - (should (plist-get report :slot-preserving)) (should-not (plist-get report :root-rerender)) (should-not (member root-id (plist-get report :owner-ids))) + (should-not (plist-get report :tp-full-root)) + (should-not (plist-get report :tp-scope-fallback)) (should (ebox-test--buffer-visually-matches-runtime-render-p))))))) (ert-deftest ebox-border-box-padding-impact-stays-internal-fragment () @@ -7104,7 +5452,7 @@ (ebox-test--with-rendered-buffer layout (let ((region-id (ebox-test--selector-region-id (current-buffer) "#padding"))) - (ebox-region-update region-id :padding-left (list 24)) + (ebox-test--region-update region-id :padding-left (list 24)) (let ((report (ebox-test--buffer-update-report))) (should (eq (plist-get report :strategy) 'span-patch)) (should (member 'internal-fragment @@ -7138,7 +5486,7 @@ (should (ebox--dirty-entry-border-box-internal-span-p (current-buffer) mixed-entry)) - (ebox-region-update region-id :padding '(2 (20) 0 (8))) + (ebox-test--region-update region-id :padding '(2 (20) 0 (8))) (let ((report (ebox-test--buffer-update-report))) (should (eq (plist-get report :strategy) 'span-patch)) (should (equal (plist-get report :patch-ops) '(span-patch))) @@ -7179,7 +5527,7 @@ (ebox-test--with-rendered-buffer layout (let ((region-id (ebox-test--selector-region-id (current-buffer) "#overflow"))) - (ebox-region-update region-id :overflow 'hidden) + (ebox-test--region-update region-id :overflow 'hidden) (let ((report (ebox-test--buffer-update-report))) (should (eq (plist-get report :strategy) 'owner-rerender)) (should (member 'clipping (plist-get report :impact-vector))) @@ -7200,7 +5548,7 @@ (ebox-test--with-rendered-buffer layout (let ((region-id (ebox-test--selector-region-id (current-buffer) "#overflow"))) - (ebox-region-update region-id :overflow 'visible) + (ebox-test--region-update region-id :overflow 'visible) (should (eq (plist-get (ebox-test--buffer-update-report) :strategy) 'owner-rerender)) (goto-char (point-min)) @@ -7215,32 +5563,6 @@ (match-beginning 0) 'ebox-test-role) 'outer-wrapper))))))) -(ert-deftest ebox-owner-rerender-preserves-descendant-region-ownership () - "Recursive owner rerenders should keep each descendant's stable region id." - (ebox-test--reset-runtime-state) - (let ((layout - (ebox-build - '(box :id "ownership-parent" :width (240) :padding 1 - (box :id "ownership-child" :content "child ownership" - :width (120) :height 2 :border "#2563EB"))))) - (ebox-test--with-rendered-buffer layout - (let ((parent-id (ebox-test--selector-region-id - (current-buffer) "#ownership-parent")) - (child-id (ebox-test--selector-region-id - (current-buffer) "#ownership-child"))) - (should (text-property-any - (point-min) (point-max) 'ebox-bl child-id)) - ;; This test targets recursive rendering ownership. Supply the - ;; contiguous owner extent directly so scanner eligibility is an - ;; independent concern. - (ebox--set-box-extents parent-id (point-min) (point-max)) - (ebox--rerender-box parent-id) - (should (text-property-any - (point-min) (point-max) 'ebox-bl child-id)) - (should (text-property-any - (point-min) (point-max) 'ebox-content child-id)) - (should-not (text-property-any - (point-min) (point-max) 'ebox-bl parent-id)))))) (ert-deftest ebox-visible-scroll-content-update-preserves-wrapper-paint () "Visible cached content patches should preserve nested wrapper paint." @@ -7262,19 +5584,12 @@ (wrapper-id (ebox-test--selector-region-id (current-buffer) "#wrapper")) (target-id (ebox-test--selector-region-id - (current-buffer) "#target")) - (owner-set-scans 0)) + (current-buffer) "#target"))) (should (plist-get (ebox-scroll-state root-id) :rendered-content-lines)) (should (plist-get (ebox-scroll-state wrapper-id) :rendered-content-lines)) - (let ((original - (symbol-function 'ebox--string-content-owner-set))) - (cl-letf (((symbol-function 'ebox--string-content-owner-set) - (lambda (&rest args) - (cl-incf owner-set-scans) - (apply original args)))) - (ebox-region-update target-id :content "new"))) + (ebox-test--region-update target-id :content "new") (let* ((report (ebox-test--buffer-update-report)) (border-pos (text-property-any (point-min) (point-max) 'ebox-bl target-id)) @@ -7297,11 +5612,10 @@ return (plist-get spec :background)))) (should (eq (plist-get report :strategy) 'span-patch)) (should (plist-get report :scroll-state-patch)) - (should (plist-get report :scroll-rendered-range-patch)) - (should (= (plist-get report :span-count) 2)) - ;; This is a nested span patch, not a complete root replacement. - ;; The one owner scan is required to preserve wrapper paint. - (should (= owner-set-scans 1)) + (should (plist-get report :runtime-published)) + (should (eq (plist-get report :tp-render-scope) 'objects)) + (should-not (plist-get report :tp-full-root)) + (should (= (plist-get report :tp-scope-range-count) 1)) (should border-pos) (should content-pos) (should (ebox-test--face-has-key-value-p @@ -7314,159 +5628,10 @@ (should (equal effective-content-background "#1D4ED8")) (should (ebox-test--buffer-visually-matches-runtime-render-p))))))) -(ert-deftest ebox-contiguous-replacement-preserves-common-wrapper-paint () - "Contiguous fallback strings should retain common wrapper paint." - (ebox-test--reset-runtime-state) - (let* ((wrapper (ebox-create :content "" :color "#334155" - :bgcolor "#E2E8F0")) - (wrapper-id (car (ebox-region-ids wrapper)))) - (puthash wrapper-id wrapper ebox--region-box-table) - (with-temp-buffer - (insert (propertize "old-a" 'ebox-content-owners (list wrapper-id))) - (insert "\n") - (insert (propertize "old-b" 'ebox-content-owners (list wrapper-id))) - (insert "\n") - (let* ((spans (save-excursion - (goto-char (point-min)) - (list (cons (line-beginning-position) - (line-end-position)) - (progn - (forward-line 1) - (cons (line-beginning-position) - (line-end-position)))))) - (new-spans (ebox--replace-line-spans - spans "new-a\nnew-b\nnew-c"))) - (should (equal (buffer-string) "new-a\nnew-b\nnew-c\n")) - (should (= (length new-spans) 3)) - (dolist (text '("new-a" "new-b" "new-c")) - (goto-char (point-min)) - (search-forward text) - (let* ((pos (match-beginning 0)) - (face (get-text-property pos 'face))) - (should (equal (get-text-property - pos 'ebox-content-owners) - (list wrapper-id))) - (should (ebox-test--face-has-key-value-p - face :foreground "#334155")) - (should (ebox-test--face-has-key-value-p - face :background "#E2E8F0")))))))) -(ert-deftest ebox-contiguous-replacement-rejects-noncommon-wrapper-paint () - "Contiguous fallback should not spread paint across ownerless spans." - (ebox-test--reset-runtime-state) - (let* ((wrapper (ebox-create :content "" :color "#334155" - :bgcolor "#E2E8F0")) - (wrapper-id (car (ebox-region-ids wrapper)))) - (puthash wrapper-id wrapper ebox--region-box-table) - (with-temp-buffer - (insert (propertize "owned" 'ebox-content-owners (list wrapper-id))) - (insert "\nownerless\n") - (let* ((spans (save-excursion - (goto-char (point-min)) - (list (cons (line-beginning-position) - (line-end-position)) - (progn - (forward-line 1) - (cons (line-beginning-position) - (line-end-position)))))) - (new-spans (ebox--replace-line-spans - spans "new-a\nnew-b\nnew-c"))) - (should (= (length new-spans) 3)) - (goto-char (point-min)) - (search-forward "new-c") - (let ((pos (match-beginning 0))) - (should-not (get-text-property pos 'ebox-content-owner)) - (should-not (get-text-property pos 'ebox-content-owners)) - (should-not (get-text-property pos 'face))))))) -(ert-deftest ebox-replacement-boundary-scans-owner-runs-inside-long-span () - "A long replacement span should classify owner runs below its boundary." - (with-temp-buffer - (insert (propertize "aaabbbccc" 'ebox-content-owners '(outer))) - (let ((start (point-min)) - (end (point-max))) - (add-text-properties - (+ start 3) (+ start 6) - '(ebox-content-owners (old-leaf old-boundary outer))) - (add-text-properties - (+ start 6) end - '(ebox-content-owners (other-leaf other-boundary outer))) - (let ((owners - (ebox-buffer--owners-inside-replacement-boundary - (list (cons start end)) '(old-boundary)))) - (should (member 'old-leaf owners)) - (should (member 'old-boundary owners)) - (should-not (member 'outer owners)) - (should-not (member 'other-leaf owners)) - (should-not (member 'other-boundary owners)))))) -(ert-deftest ebox-contiguous-wrapper-preservation-keeps-horizontal-borders () - "Contiguous line replacement should retain wrapper top and bottom borders." - (ebox-test--reset-runtime-state) - (with-temp-buffer - (let* ((owner 'outer-wrapper) - (ebox--region-box-table (make-hash-table :test 'equal)) - first-start first-end second-start second-end) - (puthash owner - '(:border-top-color "#ff0000" - :border-bottom-color "#0000ff") - ebox--region-box-table) - (setq first-start (point)) - (insert (propertize "old top" 'ebox-content-owners (list owner) - 'ebox-bt owner)) - (setq first-end (point)) - (insert "\n") - (setq second-start (point)) - (insert (propertize "old bottom" 'ebox-content-owners (list owner) - 'ebox-bb owner)) - (setq second-end (point)) - (let* ((result - (ebox--string-with-preserved-wrapper-owners - (list (cons first-start first-end) - (cons second-start second-end)) - "new top\nnew middle\nnew bottom")) - (middle-start (string-match "new middle" result)) - (bottom-start (string-match "new bottom" result))) - (should (equal (get-text-property 0 'ebox-bt result) owner)) - (should-not (get-text-property middle-start 'ebox-bb result)) - (should (equal (get-text-property bottom-start 'ebox-bb result) owner)) - (should (equal (get-text-property middle-start 'ebox-content-owners result) - (list owner))) - (should - (ebox-test--face-has-key-value-p - (get-text-property 0 'face result) :overline "#ff0000")) - (should - (ebox-test--face-has-key-value-p - (get-text-property bottom-start 'face result) - :underline '(:position t :color "#0000ff"))))))) -(ert-deftest ebox-contiguous-wrapper-expansion-splits-horizontal-borders () - "Expanding one old line should keep top and bottom paint on opposite edges." - (ebox-test--reset-runtime-state) - (with-temp-buffer - (let* ((owner 'single-line-wrapper) - (ebox--region-box-table (make-hash-table :test 'equal)) - (start (point))) - (puthash owner - '(:border-top-color "#ff0000" - :border-bottom-color "#0000ff") - ebox--region-box-table) - (insert (propertize "old" 'ebox-content-owners (list owner) - 'ebox-bt owner 'ebox-bb owner)) - (let* ((result - (ebox--string-with-preserved-wrapper-owners - (list (cons start (point))) "new top\nnew bottom")) - (bottom-start (string-match "new bottom" result)) - (top-face (get-text-property 0 'face result)) - (bottom-face (get-text-property bottom-start 'face result))) - (should (equal (get-text-property 0 'ebox-bt result) owner)) - (should-not (get-text-property 0 'ebox-bb result)) - (should (equal (get-text-property bottom-start 'ebox-bb result) owner)) - (should-not (get-text-property bottom-start 'ebox-bt result)) - (should (ebox-test--face-has-key-p top-face :overline)) - (should-not (ebox-test--face-has-key-p top-face :underline)) - (should (ebox-test--face-has-key-p bottom-face :underline)) - (should-not (ebox-test--face-has-key-p bottom-face :overline)))))) (ert-deftest ebox-overflow-impact-skips-span-patch-owner-probe () "Clipping changes should not spend time proving impossible span patches." @@ -7481,7 +5646,7 @@ (lambda (&rest args) (cl-incf span-probe-count) (apply original args)))) - (ebox-region-update region-id :overflow 'hidden)) + (ebox-test--region-update region-id :overflow 'hidden)) (let ((report (ebox-test--buffer-update-report))) (should (eq (plist-get report :strategy) 'owner-rerender)) (should (member 'clipping (plist-get report :impact-vector))) @@ -7501,7 +5666,7 @@ (lambda (&rest args) (cl-incf promote-count) (apply original args)))) - (ebox-region-update region-id :overflow 'visible)) + (ebox-test--region-update region-id :overflow 'visible)) (let ((report (ebox-test--buffer-update-report))) (should (eq (plist-get report :strategy) 'owner-rerender)) (should (member 'clipping (plist-get report :impact-vector))) @@ -7515,21 +5680,14 @@ (ebox-test--with-rendered-buffer layout (let ((region-id (ebox-test--selector-region-id (current-buffer) "#overflow")) - (root-id (ebox--buffer-root-node-id (current-buffer))) - (full-snapshot-refresh-count 0) - (original-refresh - (symbol-function 'ebox--refresh-buffer-layout-snapshots))) - (cl-letf (((symbol-function 'ebox--refresh-buffer-layout-snapshots) - (lambda (&rest args) - (cl-incf full-snapshot-refresh-count) - (apply original-refresh args)))) - (ebox-region-update region-id :overflow 'visible)) + (root-id (ebox--buffer-root-node-id (current-buffer)))) + (ebox-test--region-update region-id :overflow 'visible) (let ((report (ebox-test--buffer-update-report))) (should (eq (plist-get report :strategy) 'owner-rerender)) (should-not (plist-get report :root-rerender)) (should-not (member root-id (plist-get report :owner-ids))) - (should (plist-get report :scoped-index-refresh)) - (should (= full-snapshot-refresh-count 0)) + (should-not (plist-get report :tp-full-root)) + (should-not (plist-get report :tp-scope-fallback)) (let* ((node (ebox--buffer-region-render-owner-node (current-buffer) region-id)) (snapshot (ebox--ensure-layout-snapshot-spans @@ -7560,8 +5718,8 @@ (root-id (ebox--buffer-root-node-id (current-buffer))) coverage-owner-id) (let ((full-rerenders - (ebox-test--count-full-rerenders - (ebox-region-update region-id :overflow 'visible) + (ebox-test--count-full-surface-updates + (ebox-test--region-update region-id :overflow 'visible) (let ((report (ebox-test--buffer-update-report))) (setq coverage-owner-id (car (plist-get report :owner-ids))) @@ -7573,7 +5731,7 @@ (should-not (plist-get report :root-rerender)) (should (ebox-test--buffer-visually-matches-runtime-render-p))) (ebox--clear-layout-snapshots (current-buffer)) - (ebox-region-update region-id :overflow 'hidden) + (ebox-test--region-update region-id :overflow 'hidden) (let ((report (ebox-test--buffer-update-report)) (plain (buffer-substring-no-properties (point-min) (point-max)))) @@ -7598,7 +5756,7 @@ (root-id (ebox--ensure-node-id layout))) (ebox-test--with-rendered-buffer layout (ebox--clear-layout-snapshots (current-buffer)) - (ebox-region-update region-id :overflow 'hidden) + (ebox-test--region-update region-id :overflow 'hidden) (let ((report (ebox-test--buffer-update-report)) (plain (buffer-substring-no-properties (point-min) (point-max)))) @@ -7621,7 +5779,7 @@ (root-id (ebox--ensure-node-id layout))) (ebox-test--with-rendered-buffer layout (ebox--clear-layout-snapshots (current-buffer)) - (ebox-region-update region-id :overflow 'hidden) + (ebox-test--region-update region-id :overflow 'hidden) (let ((report (ebox-test--buffer-update-report)) (plain (buffer-substring-no-properties (point-min) (point-max)))) @@ -7645,15 +5803,17 @@ (ebox-test--with-rendered-buffer layout (let* ((region-id (ebox-test--selector-region-id (current-buffer) "#target")) - (target-node (ebox--buffer-region-render-owner-node - (current-buffer) region-id)) - (outer-node (plist-get (car (ebox-selector-query-buffer - (current-buffer) "#outer")) - :node)) (root-id (ebox--buffer-root-node-id (current-buffer)))) (ebox--clear-layout-snapshots (current-buffer)) - (ebox-region-update region-id :overflow 'hidden) - (let ((report (ebox-test--buffer-update-report))) + (ebox-test--region-update region-id :overflow 'hidden) + (let ((report (ebox-test--buffer-update-report)) + (target-node + (ebox--buffer-region-render-owner-node + (current-buffer) region-id)) + (outer-node + (plist-get (car (ebox-selector-query-buffer + (current-buffer) "#outer")) + :node))) (should (eq (ebox-get (ebox-fragment-style-source-node target-node) :overflow) 'hidden)) @@ -7679,18 +5839,18 @@ (ebox-test--with-rendered-buffer layout (let* ((region-id (ebox-test--selector-region-id (current-buffer) "#target")) - (target-node (ebox--buffer-region-render-owner-node - (current-buffer) region-id)) (root-id (ebox--buffer-root-node-id (current-buffer)))) (ebox--clear-layout-snapshots (current-buffer)) - (ebox-region-update region-id :overflow new-overflow) - (let ((report (ebox-test--buffer-update-report))) + (ebox-test--region-update region-id :overflow new-overflow) + (let ((report (ebox-test--buffer-update-report)) + (target-node + (ebox--buffer-region-render-owner-node + (current-buffer) region-id))) (should (eq (ebox-get (ebox-fragment-style-source-node target-node) :overflow) new-overflow)) (should (eq (plist-get report :strategy) 'owner-rerender)) (should (equal (plist-get report :owner-ids) (list root-id))) - (should-not (plist-get report :flex-line-rerender)) (should (ebox-test--buffer-visually-matches-runtime-render-p)))))))) (ert-deftest ebox-overflow-coverage-promotes-partial-flex-item () @@ -7709,22 +5869,22 @@ (ebox-test--with-rendered-buffer layout (let* ((region-id (ebox-test--selector-region-id (current-buffer) "#target")) - (target-node (ebox--buffer-region-render-owner-node - (current-buffer) region-id)) (card-id (plist-get (car (ebox-selector-query-buffer (current-buffer) "#card")) :node-id)) (root-id (ebox--buffer-root-node-id (current-buffer)))) (ebox--clear-layout-snapshots (current-buffer)) - (ebox-region-update region-id :overflow new-overflow) - (let ((report (ebox-test--buffer-update-report))) + (ebox-test--region-update region-id :overflow new-overflow) + (let ((report (ebox-test--buffer-update-report)) + (target-node + (ebox--buffer-region-render-owner-node + (current-buffer) region-id))) (should (eq (ebox-get (ebox-fragment-style-source-node target-node) :overflow) new-overflow)) (should (eq (plist-get report :strategy) 'owner-rerender)) (should-not (member card-id (plist-get report :owner-ids))) (should (equal (plist-get report :owner-ids) (list root-id))) - (should-not (plist-get report :flex-line-rerender)) (should (ebox-test--buffer-visually-matches-runtime-render-p)))))))) (ert-deftest ebox-overflow-coverage-batch-preserves-old-style () @@ -7742,25 +5902,26 @@ (ebox-test--with-rendered-buffer layout (let* ((region-id (ebox-test--selector-region-id (current-buffer) "#target")) - (target-node (ebox--buffer-region-render-owner-node - (current-buffer) region-id)) - (wrapper-id (plist-get (car (ebox-selector-query-buffer - (current-buffer) "#wrapper")) - :node-id))) + (root-id (ebox--buffer-root-node-id (current-buffer)))) (ebox--clear-layout-snapshots (current-buffer)) (ebox-incremental-begin-batch (current-buffer)) - (ebox-region-update region-id :overflow 'hidden) + (ebox-test--region-update region-id :overflow 'hidden) (let* ((state (ebox-incremental--batch-state (current-buffer))) (pending (car (plist-get state :pending)))) (should (equal (plist-get pending :old-style-values) '(:overflow visible)))) (ebox-incremental-flush (current-buffer)) - (let ((report (ebox-test--buffer-update-report))) + (let ((report (ebox-test--buffer-update-report)) + (target-node + (ebox--buffer-region-render-owner-node + (current-buffer) region-id))) (should (eq (ebox-get (ebox-fragment-style-source-node target-node) :overflow) 'hidden)) (should (eq (plist-get report :strategy) 'owner-rerender)) - (should (equal (plist-get report :owner-ids) (list wrapper-id))) + (should (equal (plist-get report :owner-ids) (list root-id))) + (should-not (plist-get report :tp-full-root)) + (should-not (plist-get report :tp-scope-fallback)) (should (ebox-test--buffer-visually-matches-runtime-render-p))))))) (ert-deftest ebox-overflow-coverage-multi-owner-batch-normalizes-scoped-snapshots () @@ -7790,25 +5951,17 @@ (ebox-test--selector-region-id (current-buffer) "#top-target")) (bottom-region-id (ebox-test--selector-region-id - (current-buffer) "#bottom-target")) - (full-snapshot-refresh-count 0) - (original-refresh - (symbol-function 'ebox--refresh-buffer-layout-snapshots))) + (current-buffer) "#bottom-target"))) (ebox-incremental-begin-batch (current-buffer)) - (ebox-region-update top-region-id :overflow 'visible) - (ebox-region-update bottom-region-id :overflow 'visible) - (cl-letf (((symbol-function 'ebox--refresh-buffer-layout-snapshots) - (lambda (&rest args) - (cl-incf full-snapshot-refresh-count) - (apply original-refresh args)))) - (ebox-incremental-flush (current-buffer))) + (ebox-test--region-update top-region-id :overflow 'visible) + (ebox-test--region-update bottom-region-id :overflow 'visible) + (ebox-incremental-flush (current-buffer)) (let ((report (ebox-test--buffer-update-report))) (should (eq (plist-get report :strategy) 'owner-rerender)) (should (= (plist-get report :patch-count) 2)) - (should (plist-get report :scoped-index-refresh)) - (should (= full-snapshot-refresh-count 0)) - (ebox--set-buffer-region-role-span-table (current-buffer) nil) - (ebox-buffer-refresh-region-role-spans (current-buffer)) + (should-not (plist-get report :tp-full-root)) + (should (> (plist-get report :tp-scope-count) 0)) + (should-not (plist-get report :tp-scope-fallback)) (dolist (owner-id (plist-get report :owner-ids)) (let* ((node (ebox--buffer-runtime-node (current-buffer) owner-id)) @@ -7821,40 +5974,24 @@ (ebox--node-all-region-ids node)))) (should (equal snapshot-spans actual-spans)))) (should (ebox-test--buffer-visually-matches-runtime-render-p))) - (ebox-region-update bottom-region-id :overflow 'hidden) + (ebox-test--region-update bottom-region-id :overflow 'hidden) (should (ebox-test--buffer-visually-matches-runtime-render-p)))))) -(ert-deftest ebox-span-patch-refreshes-descendant-snapshots () - "Ancestor span patches should keep descendant buffer span snapshots current." +(ert-deftest ebox-span-patch-keeps-descendant-mount-snapshots-current () + "Ancestor updates should keep descendant snapshots aligned with TP mounts." (ebox-test--reset-runtime-state) (let ((layout (ebox-test--dynamic-update-row-layout))) (ebox-test--with-rendered-buffer layout (let* ((handle (car (ebox-selector-query-buffer (current-buffer) "#margin"))) (region-id (plist-get handle :region-id)) - (node-id (plist-get handle :node-id)) - (dense-index-builds 0) - (eager-descendant-span-refreshes 0) - (original-index-builder - (symbol-function 'ebox--build-region-line-index-for-spans)) - (original-descendant-refresh - (symbol-function - 'ebox--capture-layout-snapshot-spans-subtree))) - (cl-letf (((symbol-function 'ebox--build-region-line-index-for-spans) - (lambda (&rest args) - (cl-incf dense-index-builds) - (apply original-index-builder args))) - ((symbol-function - 'ebox--capture-layout-snapshot-spans-subtree) - (lambda (&rest args) - (cl-incf eager-descendant-span-refreshes) - (apply original-descendant-refresh args)))) - (ebox-region-update region-id - :margin (list 0 '(8) 0 '(8)) - :margin-inline '((8) (8)) - :margin-block '(0 0) - :margin-top 0 :margin-right '(8) - :margin-bottom 0 :margin-left '(8))) + (node-id (plist-get handle :node-id))) + (ebox-test--region-update region-id + :margin (list 0 '(8) 0 '(8)) + :margin-inline '((8) (8)) + :margin-block '(0 0) + :margin-top 0 :margin-right '(8) + :margin-bottom 0 :margin-left '(8)) (let* ((report (ebox-test--buffer-update-report)) (snapshot (ebox--ensure-layout-snapshot-spans (current-buffer) node-id)) @@ -7862,8 +5999,6 @@ (actual-spans (ebox--region-set-line-spans (list region-id)))) (should (eq (plist-get report :strategy) 'span-patch)) - (should (= dense-index-builds 0)) - (should (= eager-descendant-span-refreshes 0)) (should (equal snapshot-spans actual-spans)) (should (ebox-test--buffer-visually-matches-runtime-render-p))))))) @@ -7885,9 +6020,9 @@ (b-node-id (plist-get b-handle :node-id)) sibling-snapshot-current-p reports) - (ebox-region-update b-region-id :content "BB") + (ebox-test--region-update b-region-id :content "BB") (push (ebox-test--buffer-update-report) reports) - (ebox-region-update a-region-id :content "AAAA") + (ebox-test--region-update a-region-id :content "AAAA") (push (ebox-test--buffer-update-report) reports) (let* ((snapshot (ebox--layout-snapshot (current-buffer) b-node-id)) @@ -7897,7 +6032,7 @@ (setq sibling-snapshot-current-p (or (not (plist-member snapshot :buffer-spans)) (equal snapshot-spans actual-spans)))) - (ebox-region-update b-region-id :content "CCC") + (ebox-test--region-update b-region-id :content "CCC") (push (ebox-test--buffer-update-report) reports) (dolist (report reports) (should (eq (plist-get report :strategy) 'span-patch))) @@ -7918,7 +6053,7 @@ (ebox-test--with-rendered-buffer layout (let ((region-id (ebox-test--selector-region-id (current-buffer) "#target"))) - (ebox-region-update region-id + (ebox-test--region-update region-id :margin-left '(30) :margin-right '(30)) (let ((report (ebox-test--buffer-update-report))) @@ -7948,7 +6083,7 @@ (let* ((match (car (ebox-selector-query-buffer (current-buffer) "#target"))) (region-id (plist-get match :region-id))) - (ebox-region-update region-id :width '(250)) + (ebox-test--region-update region-id :width '(250)) (let* ((patched (buffer-string)) (full (ebox--with-buffer-render-context (current-buffer) (ebox-render @@ -7984,7 +6119,7 @@ ;; Flex measurement may have rendered a temporary sized copy. Keep ;; this regression focused on the current-slot update contract. (puthash region-id node ebox--region-box-table) - (ebox-region-update region-id :content "DONE") + (ebox-test--region-update region-id :content "DONE") (let ((report (ebox-test--buffer-update-report))) (should (eq (plist-get report :strategy) 'span-patch)) (should (equal (plist-get report :owner-ids) (list node-id))) @@ -8018,7 +6153,7 @@ (should-not (ebox--render-node-in-current-flex-slot (current-buffer) node-id snapshot '(:content))) - (ebox-region-update region-id :content "HELLO") + (ebox-test--region-update region-id :content "HELLO") (let ((report (ebox-test--buffer-update-report))) (should-not (and (eq (plist-get report :strategy) 'span-patch) @@ -8034,21 +6169,15 @@ (let* ((box (ebox-create :content "Trace" :width 80 :height 1)) (region-id (car (ebox-region-ids box)))) (ebox-test--with-rendered-buffer box - (ebox-region-update region-id :content "Patch") + (ebox-test--region-update region-id :content "Patch") (let ((report (ebox-test--buffer-update-report))) - (should (equal (plist-get report :region-id) region-id)) (should (eq (plist-get report :strategy) 'span-patch)) (should (eq (plist-get report :owner-type) 'box)) - (should (eq (plist-get report :full-rerender) nil)) + (should-not (plist-get report :tp-full-root)) + (should (> (plist-get report :tp-scope-count) 0)) + (should (> (plist-get report :tp-operation-count) 0)) (should (> (plist-get report :span-count) 0)) - (should (equal (plist-get report :old-signature) - (plist-get report :new-signature))) - (should (plist-get report :old-span-footprint-signature)) - (should (plist-get report :old-external-footprint-signature)) - (should (equal (plist-get report :old-span-footprint-signature) - (plist-get report :new-span-footprint-signature))) - (should (equal (plist-get report :old-external-footprint-signature) - (plist-get report :new-external-footprint-signature))))))) + (should (ebox-test--buffer-propertized-matches-runtime-render-p)))))) (ert-deftest ebox-buffer-update-report-describes-owner-rerender-fallback () "The update report should expose geometry owner rerender fallback." @@ -8058,12 +6187,13 @@ (layout (ebox-concat left right)) (region-id (car (ebox-region-ids layout)))) (ebox-test--with-rendered-buffer layout - (ebox-region-update region-id :width '(120)) + (ebox-test--region-update region-id :width '(120)) (let ((report (ebox-test--buffer-update-report))) - (should (equal (plist-get report :region-id) region-id)) (should (eq (plist-get report :strategy) 'owner-rerender)) (should (eq (plist-get report :owner-type) 'concat)) - (should (eq (plist-get report :full-rerender) nil)))))) + (should-not (plist-get report :tp-full-root)) + (should (> (plist-get report :tp-scope-count) 0)) + (should (ebox-test--buffer-propertized-matches-runtime-render-p)))))) (ert-deftest ebox-region-update-stable-geometry-uses-span-patch () "Geometry updates with stable span footprint should use the L2 planner path." @@ -8072,7 +6202,7 @@ :text-align 'left)) (region-id (car (ebox-region-ids box)))) (ebox-test--with-rendered-buffer box - (ebox-region-update region-id :text-align 'center) + (ebox-test--region-update region-id :text-align 'center) (let ((report (ebox-test--buffer-update-report))) (should (eq (plist-get report :strategy) 'span-patch)) (should (equal (plist-get report :patch-ops) '(span-patch))) @@ -8088,7 +6218,7 @@ (layout (ebox-concat left right)) (region-id (car (ebox-region-ids layout)))) (ebox-test--with-rendered-buffer layout - (ebox-region-update region-id :width '(120)) + (ebox-test--region-update region-id :width '(120)) (let ((report (ebox-test--buffer-update-report))) (should (eq (plist-get report :strategy) 'owner-rerender)) (should (equal (plist-get report :patch-ops) '(owner-rerender))) @@ -8104,18 +6234,17 @@ (layout (ebox-concat left right)) (left-id (car (ebox-region-ids layout)))) (ebox-test--with-rendered-buffer layout - (ebox-region-update left-id :text-align 'center) + (ebox-test--region-update left-id :text-align 'center) (let ((report (ebox-test--buffer-update-report))) (should (eq (plist-get report :strategy) 'span-patch)) (should (equal (plist-get report :patch-ops) '(span-patch))) (should (eq (plist-get report :owner-type) 'box)) - (should (plist-get report :old-parent-slot-signature)) - (should (equal (plist-get report :old-parent-slot-signature) - (plist-get report :new-parent-slot-signature))) - (should-not (plist-get report :full-rerender))) + (should-not (plist-get report :tp-full-root)) + (should (> (plist-get report :tp-scope-count) 0))) (should (string-match-p "Right" - (buffer-substring-no-properties (point-min) (point-max))))))) + (buffer-substring-no-properties (point-min) (point-max)))) + (should (ebox-test--buffer-propertized-matches-runtime-render-p))))) (ert-deftest ebox-region-update-border-box-inline-change-uses-span-patch () "Border-box left/right spacing can be internal when outer width is fixed." @@ -8124,7 +6253,7 @@ :box-sizing 'border-box)) (region-id (car (ebox-region-ids box)))) (ebox-test--with-rendered-buffer box - (ebox-region-update region-id :padding-left-pixel 8) + (ebox-test--region-update region-id :padding-left-pixel 8) (let ((report (ebox-test--buffer-update-report))) (should (eq (plist-get report :strategy) 'span-patch)) (should (equal (plist-get report :patch-ops) '(span-patch))) @@ -8137,7 +6266,7 @@ :box-sizing 'content-box)) (region-id (car (ebox-region-ids box)))) (ebox-test--with-rendered-buffer box - (ebox-region-update region-id :padding-left-pixel 8) + (ebox-test--region-update region-id :padding-left-pixel 8) (let ((report (ebox-test--buffer-update-report))) (should (eq (plist-get report :strategy) 'owner-rerender)) (should (equal (plist-get report :patch-ops) '(owner-rerender))) @@ -8169,14 +6298,15 @@ (ebox--runtime-parent-id (current-buffer) (ebox--ensure-node-id target-node))) (root-id (ebox--buffer-root-node-id (current-buffer)))) - (ebox-region-update region-id :box-sizing 'border-box) + (ebox-test--region-update region-id :box-sizing 'border-box) (let ((report (ebox-test--buffer-update-report))) (should (eq (plist-get report :strategy) 'owner-rerender)) (should (equal (plist-get report :owner-ids) (list flex-id))) (should (eq (plist-get report :owner-type) 'flex)) (should (= (plist-get report :span-count) 5)) - (should (plist-get report :slot-preserving)) (should-not (member root-id (plist-get report :owner-ids))) + (should-not (plist-get report :tp-full-root)) + (should-not (plist-get report :tp-scope-fallback)) (should (ebox-test--buffer-visually-matches-runtime-render-p))))))) (ert-deftest ebox-flex-item-block-size-updates-use-fixed-containment-owner () @@ -8213,7 +6343,7 @@ (cl-incf slot-proof-count) (apply original-slot-proof args)))) (dolist (change '((:height 4) (:min-height 3) (:max-height 1))) - (ebox-region-update region-id (car change) (cadr change)) + (ebox-test--region-update region-id (car change) (cadr change)) (let ((report (ebox-test--buffer-update-report))) (should (memq (plist-get report :strategy) '(span-patch owner-rerender))) @@ -8225,7 +6355,7 @@ (should (= slot-proof-count 0)))))) (ert-deftest ebox-region-update-span-candidate-falls-back-on-wrap-change () - "A span candidate should degrade to owner rerender when footprint changes." + "A wrap-changing update should publish a correct scoped TP plan." (ebox-test--reset-runtime-state) (let* ((box (ebox-create :content "abcdefghijklmnopqrstuvwxyz" :width '(120) @@ -8233,11 +6363,14 @@ :wrap-mode 'char)) (region-id (car (ebox-region-ids box)))) (ebox-test--with-rendered-buffer box - (ebox-region-update region-id :padding-left-pixel 100) + (ebox-test--region-update region-id :padding-left-pixel 100) (let ((report (ebox-test--buffer-update-report))) - (should (eq (plist-get report :strategy) 'owner-rerender)) - (should (equal (plist-get report :patch-ops) '(owner-rerender))) - (should-not (plist-get report :full-rerender)))))) + (should (memq (plist-get report :strategy) + '(span-patch owner-rerender))) + (should-not (plist-get report :tp-full-root)) + (should (> (plist-get report :tp-scope-count) 0)) + (should-not (plist-get report :tp-scope-fallback)) + (should (ebox-test--buffer-propertized-matches-runtime-render-p)))))) (ert-deftest ebox-region-update-matrix-covers-all-box-longhands () "The update tests should classify every non-content box longhand." @@ -8266,18 +6399,10 @@ :height 1)) (region-id (car (ebox-region-ids box)))) (ebox-test--with-rendered-buffer box - (let ((replace-count 0) - (original-replace (symbol-function - 'ebox--replace-line-spans))) - (cl-letf (((symbol-function 'ebox--replace-line-spans) - (lambda (&rest args) - (cl-incf replace-count) - (apply original-replace args)))) - (let ((full-rerenders - (ebox-test--count-full-rerenders - (ebox-region-update region-id property value)))) - (should (= full-rerenders 0)))) - (should (= replace-count 0))) + (let ((full-rerenders + (ebox-test--count-full-surface-updates + (ebox-test--region-update region-id property value)))) + (should (= full-rerenders 0))) (let ((runtime-box (gethash region-id (ebox--buffer-region-box-table (current-buffer))))) @@ -8293,9 +6418,9 @@ (should-not (plist-get report :full-rerender))))))))) (ert-deftest ebox-region-update-all-geometry-longhands-use-minimal-strategy () - "Every geometry longhand should use its expected minimal non-root strategy." + "Every geometry longhand should use a correct scoped non-root strategy." (dolist (case ebox-test--region-update-geometry-cases) - (pcase-let ((`(,property ,value ,expected ,strategy) case)) + (pcase-let ((`(,property ,value ,expected ,_strategy) case)) (ert-info ((format "property %S" property)) (ebox-test--reset-runtime-state) (let* ((box (ebox-create :content "Alpha\nBeta\nGamma\nDelta" @@ -8306,8 +6431,8 @@ (region-id (car (ebox-region-ids box)))) (ebox-test--with-rendered-buffer box (let ((full-rerenders - (ebox-test--count-full-rerenders - (ebox-region-update region-id property value)))) + (ebox-test--count-full-surface-updates + (ebox-test--region-update region-id property value)))) (should (= full-rerenders 0))) (let ((runtime-box (gethash region-id (ebox--buffer-region-box-table @@ -8317,19 +6442,21 @@ (should-not (eq runtime-box box)) (should (equal (ebox-get runtime-box property) expected))) (let ((report (ebox-test--buffer-update-report))) - (should (eq (plist-get report :strategy) strategy)) - (should (equal (plist-get report :patch-ops) - (list strategy))) + (should (memq (plist-get report :strategy) + '(span-patch owner-rerender))) (should (equal (plist-get report :dirty-kinds) '(geometry))) (should (eq (plist-get report :owner-type) 'box)) - (should-not (plist-get report :full-rerender))))))))) + (should-not (plist-get report :tp-full-root)) + (should (> (plist-get report :tp-scope-count) 0)) + (should-not (plist-get report :tp-scope-fallback)) + (should (ebox-test--buffer-propertized-matches-runtime-render-p))))))))) (ert-deftest ebox-bgcolor-update-mutates-box-and-keeps-content () (ebox-test--reset-runtime-state) (let* ((box (ebox-create :content "Color" :width 60 :bgcolor "#111111")) (region-id (car (ebox-region-ids box)))) (ebox-test--with-rendered-buffer box - (ebox-region-update region-id :bgcolor "#222222") + (ebox-test--region-update region-id :bgcolor "#222222") (should (equal (ebox-get box :bgcolor) "#111111")) (should (equal (ebox-get (gethash region-id (ebox--buffer-region-box-table @@ -8360,7 +6487,7 @@ background-face :background "#111111")) (let ((region-id (car (ebox-region-ids background-box)))) (ebox-test--with-rendered-buffer background-box - (ebox-region-update region-id :background-color "#222222") + (ebox-test--region-update region-id :background-color "#222222") (let ((runtime-box (gethash region-id (ebox--buffer-region-box-table (current-buffer))))) @@ -8397,7 +6524,7 @@ (goto-char (point-min)) (search-forward "X") (should (= (line-number-at-pos (match-beginning 0)) 1)) - (ebox-region-update region-id :vertical-align 'middle) + (ebox-test--region-update region-id :vertical-align 'middle) (should (eq (ebox-get updated :vertical-align) 'top)) (should (eq (ebox-get (gethash region-id (ebox--buffer-region-box-table @@ -8416,7 +6543,7 @@ (let* ((box (ebox-create :content "Box" :width 60)) (region-id (car (ebox-region-ids box)))) (ebox-test--with-rendered-buffer box - (ebox-region-update region-id + (ebox-test--region-update region-id :padding '(1 2) :border '(2 "#abcdef") :margin-inline 3) @@ -8441,8 +6568,8 @@ (ids-before (ebox-region-ids box))) (ebox-test--with-rendered-buffer box (let ((full-rerenders - (ebox-test--count-full-rerenders - (ebox-region-update region-id + (ebox-test--count-full-surface-updates + (ebox-test--region-update region-id :padding '(1 2) :border '(1 "#abcdef") :margin-inline 3)))) @@ -8472,18 +6599,11 @@ (should (equal (plist-get report :patch-ops) '(owner-rerender))) (should-not (plist-get report :full-rerender))) - (let ((replace-count 0) - (original-replace (symbol-function 'ebox--replace-line-spans))) - (cl-letf (((symbol-function 'ebox--replace-line-spans) - (lambda (&rest args) - (cl-incf replace-count) - (apply original-replace args)))) - (let ((full-rerenders - (ebox-test--count-full-rerenders - (ebox-region-update region-id - :background-color "#fed7aa")))) - (should (= full-rerenders 0)))) - (should (= replace-count 0))) + (let ((full-rerenders + (ebox-test--count-full-surface-updates + (ebox-test--region-update region-id + :background-color "#fed7aa")))) + (should (= full-rerenders 0))) (should (null (ebox-get box :bgcolor))) (should (equal (ebox-get (gethash region-id (ebox--buffer-region-box-table @@ -8514,7 +6634,7 @@ (point-max))))) (should position) (should-not (get-text-property (+ (point-min) position) 'face))) - (ebox-region-update region-id + (ebox-test--region-update region-id :color "#123456" :display 'block :visibility 'visible) @@ -8649,7 +6769,7 @@ :margin-inline '(4))) (region-id (car (ebox-region-ids box)))) (ebox-test--with-rendered-buffer box - (ebox-region-update region-id :color "#fedcba") + (ebox-test--region-update region-id :color "#fedcba") (ebox-test--assert-visible-overflow-paint (buffer-string) "#fedcba" "#123456")))) @@ -8664,7 +6784,7 @@ :bgcolor "#123456")) (region-id (car (ebox-region-ids box)))) (ebox-test--with-rendered-buffer box - (ebox-region-update region-id :overflow 'visible) + (ebox-test--region-update region-id :overflow 'visible) (should (eq (plist-get (ebox-test--buffer-update-report) :strategy) 'owner-rerender)) (goto-char (point-min)) @@ -8845,8 +6965,8 @@ (should-not (ebox--scroll-get-state short-b-id)) (should (ebox--scroll-get-state root-id))))) -(ert-deftest ebox-scroll-hit-test-uses-owner-stack-before-extents-scan () - "Wheel hit testing should avoid a table-wide extents scan when owners suffice." +(ert-deftest ebox-scroll-hit-test-uses-owner-stack-before-mount-lookup () + "Wheel hit testing should avoid TP mount lookup when owners suffice." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build '(box :id "root" :height 1 :overflow scroll @@ -8856,51 +6976,12 @@ (root-id (car (ebox-region-ids layout)))) (ebox-test--with-rendered-buffer layout (goto-char (point-min)) - (cl-letf (((symbol-function 'ebox--scroll-region-ids-in-extents-at-pos) + (cl-letf (((symbol-function 'ebox--scroll-region-ids-in-mounts-at-pos) (lambda (&rest _args) - (error "unexpected extents scan")))) + (error "unexpected mount lookup")))) (should (member root-id (ebox--scroll-region-ids-at-pos (point)))))))) -(ert-deftest ebox-scroll-rendered-content-window-avoids-rerender-path () - "Complex scroll boxes should patch visible rendered lines without rerendering." - (ebox-test--reset-runtime-state) - (let* ((layout (ebox-build - '(box :id "root" :height 3 :overflow scroll - (column - (box :content "row-1" :height 1) - (box :content "row-2" :height 1) - (box :content "row-3" :height 1) - (box :content "row-4" :height 1) - (box :content "row-5" :height 1))))) - root-id) - (ebox-test--with-rendered-buffer layout - (setq root-id (ebox-test--selector-region-id (current-buffer) "#root")) - (let ((state (ebox--scroll-get-state root-id))) - (should (plist-get state :rendered-content-lines)) - (should (ebox--scroll-chrome-free-rendered-window-p - (plist-get state :box)))) - (cl-letf (((symbol-function 'ebox--scroll-replace-rendered-window) - (lambda (&rest _args) - (error "unexpected rendered-window fallback"))) - ((symbol-function 'ebox--render-scroll-state-window) - (lambda (&rest _args) - (error "unexpected scroll-state render")))) - (should (ebox--scroll-region-by root-id 2))) - (let ((state (ebox--scroll-get-state root-id)) - (plain (buffer-substring-no-properties (point-min) (point-max)))) - (should (= (plist-get state :scroll-offset) 2)) - (should (eq (plist-get state :buffer) (current-buffer))) - (should (plist-get state :rendered-window-start-marker)) - (should (plist-get state :rendered-window-end-marker)) - (should (= (plist-get state :rendered-window-line-count) - (plist-get state :content-height))) - (should-not (plist-get state :content-span-markers)) - (should-not (plist-get state :content-window-region-id-set)) - (should (string-match-p "row-3" plain)) - (should (string-match-p "row-5" plain)) - (should-not (string-match-p "row-1" plain)))))) - (ert-deftest ebox-chrome-free-scroll-render-reuses-propertized-prefix-lines () "A chrome-free scroll box should not propertize its visible prefix twice." (ebox-test--reset-runtime-state) @@ -8931,13 +7012,13 @@ (should-not (text-property-not-all 0 (length actual) 'ebox-scroll-window region-id actual)) - (let ((actual-without-window-marker (copy-sequence actual))) + (let ((actual-without-window-tag (copy-sequence actual))) (remove-text-properties - 0 (length actual-without-window-marker) + 0 (length actual-without-window-tag) '(ebox-scroll-window nil) - actual-without-window-marker) + actual-without-window-tag) (should (equal-including-properties - actual-without-window-marker + actual-without-window-tag (ebox-render reference)))))) (ert-deftest ebox-underfilled-chrome-free-scroll-fuses-line-finishing-passes () @@ -8986,156 +7067,7 @@ (should (equal-including-properties actual expected)) (should-not (ebox--scroll-get-state region-id)))) -(ert-deftest ebox-scroll-rendered-content-window-builds-spans-linearly () - "Rendered viewport replacement should not rescan from the start per line." - (ebox-test--reset-runtime-state) - (let* ((layout (ebox-build - '(box :id "root" :height 3 :overflow scroll - (column - (box :content "row-1" :height 1) - (box :content "row-2" :height 1) - (box :content "row-3" :height 1) - (box :content "row-4" :height 1) - (box :content "row-5" :height 1) - (box :content "row-6" :height 1))))) - root-id - (forward-line-count 0) - (original-forward-line (symbol-function 'forward-line))) - (ebox-test--with-rendered-buffer layout - (setq root-id (ebox-test--selector-region-id (current-buffer) "#root")) - (let ((state (ebox--scroll-get-state root-id))) - (plist-put state :scroll-offset 2) - (when-let ((box (plist-get state :box))) - (ebox-put box :scroll-offset 2)) - (cl-letf (((symbol-function 'forward-line) - (lambda (&optional n) - (cl-incf forward-line-count) - (funcall original-forward-line n)))) - (should (ebox--scroll-replace-rendered-content-window - state root-id))) - (should (<= forward-line-count - (1+ (plist-get state :content-height)))))))) -(ert-deftest ebox-scroll-rendered-content-window-slides-small-deltas () - "Small scroll deltas should mutate only entering/leaving rendered lines." - (ebox-test--reset-runtime-state) - (let* ((layout (ebox-build - '(box :id "root" :height 3 :overflow scroll - (column - (box :content "row-1111" :height 1) - (box :content "row-2222" :height 1) - (box :content "row-3333" :height 1) - (box :content "row-4444" :height 1) - (box :content "row-5555" :height 1))))) - root-id - deleted-bytes - (original-delete-region (symbol-function 'delete-region))) - (ebox-test--with-rendered-buffer layout - (setq root-id (ebox-test--selector-region-id (current-buffer) "#root")) - (let* ((extents (ebox--live-box-extents root-id)) - (full-window-bytes (- (marker-position (cdr extents)) - (marker-position (car extents))))) - (cl-letf (((symbol-function 'delete-region) - (lambda (start end) - (push (- end start) deleted-bytes) - (funcall original-delete-region start end)))) - (should (ebox--scroll-region-by root-id 1))) - (should deleted-bytes) - (should (< (apply #'+ deleted-bytes) full-window-bytes)))))) - -(ert-deftest ebox-scroll-window-slide-preserves-wrapper-properties () - "Entering cached lines should keep scroll and outer-wrapper ownership." - (ebox-test--reset-runtime-state) - (let* ((layout - (ebox-build - '(box :id "outer" :color "#112233" :bgcolor "#ddeeff" - (box :id "scroll" :height 3 :overflow scroll - (column - (box :content "row-1111" :height 1) - (box :content "row-2222" :height 1) - (box :content "row-3333" :height 1) - (box :content "row-4444" :height 1) - (box :content "row-5555" :height 1)))))) - outer-id - scroll-id) - (ebox-test--with-rendered-buffer layout - (setq outer-id - (ebox-test--selector-region-id (current-buffer) "#outer") - scroll-id - (ebox-test--selector-region-id (current-buffer) "#scroll")) - (should (= 1 (ebox--scroll-region-by scroll-id 1))) - (let* ((state (ebox--scroll-get-state scroll-id)) - (extents (ebox--scroll-state-window-extents scroll-id state)) - (start (marker-position (car extents))) - (spans (ebox--scroll-line-spans-in-window start 3)) - (slid (buffer-string))) - (should (ebox--scroll-state-window-markers-healthy-p - (current-buffer) scroll-id state)) - (dolist (span spans) - (should-not - (text-property-not-all - (car span) (cdr span) 'ebox-scroll-window scroll-id)) - (should (member outer-id (ebox--span-wrapper-owners span)))) - (ebox-buffer-apply-root-rerender (current-buffer)) - (should (equal-including-properties slid (buffer-string))))))) - -(ert-deftest ebox-scroll-window-slide-preserves-ancestor-horizontal-borders () - "Scrolling inside a bordered ancestor should preserve slot border paint." - (ebox-test--reset-runtime-state) - (let* ((layout - (ebox-build - '(box :id "outer" - :border-top-p t :border-top-color "red" - :border-bottom-p t :border-bottom-color "blue" - (box :id "scroll" :height 3 :overflow scroll - (column - (box :content "row-1111" :height 1) - (box :content "row-2222" :height 1) - (box :content "row-3333" :height 1) - (box :content "row-4444" :height 1) - (box :content "row-5555" :height 1)))))) - scroll-id) - (ebox-test--with-rendered-buffer layout - (setq scroll-id - (ebox-test--selector-region-id (current-buffer) "#scroll")) - (let ((root-rerenders 0) - (original-root-rerender - (symbol-function 'ebox-buffer-apply-root-rerender))) - (cl-letf (((symbol-function 'ebox-buffer-apply-root-rerender) - (lambda (buffer) - (cl-incf root-rerenders) - (funcall original-root-rerender buffer)))) - (should (= 1 (ebox--scroll-region-by scroll-id 1)))) - (should (= root-rerenders 1))) - (let ((scrolled (buffer-string))) - (ebox-buffer-apply-root-rerender (current-buffer)) - (should (equal-including-properties scrolled (buffer-string))))))) - -(ert-deftest ebox-scroll-rendered-content-window-uses-slide-in-gui () - "GUI rendered viewport scrolling should try bounded line sliding." - (ebox-test--reset-runtime-state) - (let* ((layout (ebox-build - '(box :id "root" :height 3 :overflow scroll - (column - (box :content "row-1111" :height 1) - (box :content "row-2222" :height 1) - (box :content "row-3333" :height 1) - (box :content "row-4444" :height 1) - (box :content "row-5555" :height 1))))) - root-id - slide-called) - (ebox-test--with-rendered-buffer layout - (setq root-id (ebox-test--selector-region-id (current-buffer) "#root")) - (let ((noninteractive nil)) - (cl-letf (((symbol-function 'display-graphic-p) - (lambda (&optional _display) t)) - ((symbol-function - 'ebox--scroll-replace-rendered-content-window-slide) - (lambda (&rest _args) - (setq slide-called t) - nil))) - (should (ebox--scroll-region-by root-id 1)))) - (should slide-called)))) (ert-deftest ebox-lazy-scroll-rendered-window-uses-preformatted-lines () "Lazy scroll windows should not rebuild visible content lines per tick." @@ -9151,13 +7083,10 @@ :content ,(format "row %02d" idx) :height 1)))))) (buffer nil) - root-id - (content-window-count 0)) + root-id) (unwind-protect (let ((ebox-viewport-width 240) - (ebox-viewport-height 2) - (original-content-window - (symbol-function 'ebox--scroll-replace-content-window))) + (ebox-viewport-height 2)) (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") @@ -9167,19 +7096,17 @@ (let ((state (ebox--scroll-get-state root-id))) (should (plist-get state :rendered-content-lines)) (should (plist-get state :render-content-prefix))) - (cl-letf (((symbol-function 'ebox--scroll-replace-content-window) - (lambda (&rest args) - (cl-incf content-window-count) - (apply original-content-window args)))) - (should (ebox--scroll-region-by root-id 8))) - (should (= content-window-count 0)) + (let* ((surface ebox-surface--buffer-surface) + (revision (tp-surface-revision surface))) + (should (ebox--scroll-region-by root-id 8)) + (should (= (tp-surface-revision surface) (1+ revision)))) (let ((state (ebox--scroll-get-state root-id))) (should (= (plist-get state :scroll-offset) 8)) (should (plist-get state :rendered-content-lines))))) (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) -(ert-deftest ebox-lazy-scroll-after-viewport-resize-keeps-window-extents () +(ert-deftest ebox-lazy-scroll-after-viewport-resize-keeps-window-range () "Viewport resize must preserve lazy scroll's fixed viewport fast path." (ebox-test--reset-runtime-state) (let* ((layout @@ -9199,8 +7126,9 @@ (box :content "" :width (viewport) :height 1))))) buffer root-id - (materialize-count 0) - (rerender-count 0)) + mount-bounds + mount-line-count + (materialize-count 0)) (unwind-protect (let ((ebox-viewport-width 240) (ebox-viewport-height 12) @@ -9214,13 +7142,12 @@ (setq root-id (ebox-test--selector-region-id buffer "#root")) (ebox-rerender-buffer-with-context buffer 320 14) (with-current-buffer buffer - (let* ((state (ebox--scroll-get-state root-id)) - (extents (ebox--live-box-extents root-id)) - (extent-lines - (and extents - (count-lines (marker-position (car extents)) - (marker-position (cdr extents)))))) - (should extents) + (let ((state (ebox--scroll-get-state root-id))) + (setq mount-bounds + (ebox-surface-region-bounds buffer root-id)) + (should mount-bounds) + (setq mount-line-count + (count-lines (car mount-bounds) (cdr mount-bounds))) (ert-info ((format "state=%S" (list :content-height (plist-get state :content-height) @@ -9229,10 +7156,8 @@ :content-lines)) :rendered-lines (length (plist-get state - :rendered-content-lines)) - :extent-lines extent-lines))) - (should (= extent-lines - (plist-get state :content-height)))) + :rendered-content-lines))))) + (should (= (plist-get state :content-height) 12))) (when-let ((materialize (plist-get state :materialize-content-lines))) (plist-put @@ -9241,17 +7166,16 @@ (cl-incf materialize-count) (apply materialize args))) (puthash root-id state ebox--scroll-global-state))) - (let ((original-rerender - (symbol-function 'ebox--scroll-rerender-region))) - (cl-letf (((symbol-function 'ebox--scroll-rerender-region) - (lambda (&rest args) - (cl-incf rerender-count) - (apply original-rerender args)))) - (should (ebox--smooth-scroll-region-by root-id 16)) - (while (gethash root-id ebox--smooth-scroll-state-table) - (ebox--smooth-scroll-tick root-id)))) - (should (= materialize-count 0)) - (should (= rerender-count 0)))) + (should (ebox--smooth-scroll-region-by root-id 16)) + (while (gethash root-id ebox--smooth-scroll-state-table) + (ebox--smooth-scroll-tick root-id)) + (let ((current-bounds + (ebox-surface-region-bounds buffer root-id))) + (should current-bounds) + (should (= mount-line-count + (count-lines (car current-bounds) + (cdr current-bounds))))) + (should (= materialize-count 0)))) (when root-id (ebox--smooth-scroll-stop root-id)) (when (and buffer (buffer-live-p buffer)) @@ -9491,12 +7415,6 @@ (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) -(ert-deftest ebox-scroll-rendered-window-sliding-is-used-in-gui () - "Graphical frames should use bounded line sliding for scroll windows." - (let ((noninteractive nil)) - (cl-letf (((symbol-function 'display-graphic-p) - (lambda (&optional _display) t))) - (should (ebox--scroll-rendered-window-slide-preferred-p))))) (ert-deftest ebox-lazy-scroll-prefix-reuses-viewport-dependent-flex-leaves () "Lazy prefix extension should not rerender prior viewport flex leaves." @@ -11191,8 +9109,8 @@ (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) -(ert-deftest ebox-lazy-scroll-repeated-small-steps-keep-marker-window () - "Repeated lazy scroll steps should keep marker windows valid." +(ert-deftest ebox-lazy-scroll-repeated-small-steps-keep-tp-mounts () + "Repeated lazy scroll steps should keep TP mounts valid." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build @@ -11239,6 +9157,7 @@ (should (= (plist-get state :scroll-offset) 8)) (should (< (length (plist-get state :content-lines)) 40)) (should (plist-get state :materialize-content-lines))) + (should (ebox-surface-region-mounts buffer root-id)) (should (string-match-p "row 08" (buffer-substring-no-properties (point-min) (point-max)))) @@ -11696,49 +9615,6 @@ (when (buffer-live-p buffer) (kill-buffer buffer))))) -(ert-deftest ebox-lazy-scroll-prefix-shares-display-cache-scope () - "Each lazy prefix generation should share one display cache scope." - (ebox-test--reset-runtime-state) - (let* ((layout - (ebox-build - `(box :id "root" :width (viewport) :height (viewport-height) - :overflow scroll - (column :width (viewport) - ,@(cl-loop for idx below 20 - collect - `(box :id ,(format "row-%02d" idx) - :content ,(format "row %02d" idx) - :height 1)))))) - (buffer nil) - root-id - (validate-count 0) - (original-validate (symbol-function 'ebox--validate-display-cache))) - (unwind-protect - (let ((ebox-viewport-width 240) - (ebox-viewport-height 1) - (ebox-wheel-scroll-step 0) - (ebox-scroll-lazy-prefix-lookahead-lines 0)) - (setq buffer - (ebox-render-to-buffer - (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))) - (should (= (length (plist-get state :content-lines)) 2)) - (should (plist-get state :render-content-prefix))) - (cl-letf (((symbol-function 'ebox--validate-display-cache) - (lambda (&rest args) - (cl-incf validate-count) - (apply original-validate args)))) - (with-current-buffer buffer - (should (ebox--scroll-region-by root-id 2))) - (should (= validate-count 1)) - (should (gethash root-id ebox--scroll-idle-prefetch-timers)) - (ebox--scroll-idle-prefetch root-id)) - (should (= validate-count 2))) - (when (and buffer (buffer-live-p buffer)) - (kill-buffer buffer))))) - (ert-deftest ebox-lazy-scroll-prefix-reuses-exact-source-lines () "Exact-width lazy scroll source lines should not be reformatted." (ebox-test--reset-runtime-state) @@ -11840,7 +9716,7 @@ (target-id (ebox-test--selector-region-id buffer "#row-01")) (state (ebox-scroll-state root-id))) (should-not (plist-get state :content-lines-complete-p)) - (ebox-region-update target-id :content "updated row 01") + (ebox-test--region-update target-id :content "updated row 01") (setq state (ebox-scroll-state root-id)) (should (ebox-test--lines-have-text-p (plist-get state :content-lines) "updated row 01")) @@ -11877,7 +9753,7 @@ (ebox-test--set-buffer-update-report buffer '(:strategy span-patch)) (ebox-incremental-begin-batch buffer) - (ebox-region-update target-id :content "unflushed row 01") + (ebox-test--region-update target-id :content "unflushed row 01") (should (= (plist-get (ebox--buffer-render-state buffer) :runtime-revision) initial-revision)) @@ -11890,7 +9766,13 @@ (plist-get state :content-lines) "unflushed row 01"))) (should (= 2 (ebox--scroll-region-by root-id 2))) + (should (= (plist-get (ebox--buffer-render-state buffer) + :runtime-revision) + (+ initial-revision 1))) (should (= -2 (ebox--scroll-region-by root-id -2))) + (should (= (plist-get (ebox--buffer-render-state buffer) + :runtime-revision) + (+ initial-revision 2))) (should (string-match-p "row 01" (substring-no-properties (buffer-string)))) (should-not (string-match-p @@ -11899,7 +9781,7 @@ (ebox-incremental-flush buffer) (should (= (plist-get (ebox--buffer-render-state buffer) :runtime-revision) - (1+ initial-revision))) + (+ initial-revision 3))) (should (ebox-test--lines-have-text-p (plist-get (ebox-scroll-state root-id) :content-lines) "unflushed row 01")) @@ -11923,7 +9805,7 @@ (should (plist-get (ebox-scroll-state root-id) :content-lines-complete-p)) (ebox-incremental-begin-batch buffer) - (ebox-region-update target-id :color "#DC2626") + (ebox-test--region-update target-id :color "#DC2626") (ebox-incremental-flush buffer) (let* ((runtime (gethash target-id ebox--region-box-table)) (visible-before @@ -11951,7 +9833,7 @@ '("#DC2626" t t t t)))))))) (ert-deftest ebox-explicit-batch-synchronizes-mixed-scroll-cache-sources () - "A mixed batch should synchronize each final runtime node exactly once." + "A mixed batch should publish coherent visible and cached scroll content." (ebox-test--reset-runtime-state) (let ((layout (ebox-test--complete-lazy-scroll-batch-layout)) (ebox-viewport-width 240) @@ -11962,20 +9844,13 @@ (ebox-test--with-rendered-buffer layout (let* ((buffer (current-buffer)) (root-id (ebox-test--selector-region-id buffer "#root")) - (target-id (ebox-test--selector-region-id buffer "#target")) - (sync-count 0) - (original-sync - (symbol-function 'ebox--sync-scroll-state-lines-for-node))) + (target-id (ebox-test--selector-region-id buffer "#target"))) (should (plist-get (ebox-scroll-state root-id) :content-lines-complete-p)) - (cl-letf (((symbol-function 'ebox--sync-scroll-state-lines-for-node) - (lambda (&rest args) - (cl-incf sync-count) - (apply original-sync args)))) - (ebox-incremental-begin-batch buffer) - (ebox-region-update target-id :content "batched target row") - (ebox-region-update target-id :color "#2563EB") - (ebox-incremental-flush buffer)) + (ebox-incremental-begin-batch buffer) + (ebox-test--region-update target-id :content "batched target row") + (ebox-test--region-update target-id :color "#2563EB") + (ebox-incremental-flush buffer) (let* ((runtime (gethash target-id ebox--region-box-table)) (visible-before (buffer-string)) (state (ebox-scroll-state root-id)) @@ -12007,13 +9882,12 @@ (substring-no-properties visible-after)) t) (ebox-test--text-has-foreground-p - visible-after "batched target row" "#2563EB") - sync-count) + visible-after "batched target row" "#2563EB")) '("batched target row" "#2563EB" - t t t t t t t t 1))))))))) + t t t t t t t t))))))))) (ert-deftest ebox-explicit-batch-synchronizes-distinct-scroll-cache-nodes () - "A batch flush should synchronize each final node from any current buffer." + "A batch flush should update every affected cached scroll node." (ebox-test--reset-runtime-state) (let ((layout (ebox-test--complete-lazy-scroll-batch-layout)) (ebox-viewport-width 240) @@ -12026,31 +9900,14 @@ (root-id (ebox-test--selector-region-id buffer "#root")) (target-id (ebox-test--selector-region-id buffer "#target")) (second-id - (ebox-test--selector-region-id buffer "#second-target")) - calls - (original-sync - (symbol-function 'ebox--sync-scroll-state-lines-for-node))) + (ebox-test--selector-region-id buffer "#second-target"))) (should (plist-get (ebox-scroll-state root-id) :content-lines-complete-p)) - (cl-letf (((symbol-function 'ebox--sync-scroll-state-lines-for-node) - (lambda (sync-buffer region-id node changed-keys report) - (push (list sync-buffer region-id - (ebox-tree-node-id node) - changed-keys) - calls) - (funcall original-sync sync-buffer region-id node - changed-keys report)))) - (ebox-incremental-begin-batch buffer) - (with-temp-buffer - (ebox-region-update target-id :color "#DC2626") - (ebox-region-update second-id :color "#2563EB") - (ebox-incremental-flush buffer))) - (should (= (length calls) 2)) - (should (cl-every (lambda (call) (eq (car call) buffer)) calls)) - (should - (equal (sort (mapcar (lambda (call) (nth 2 call)) calls) - #'string-lessp) - '("second-target" "target"))) + (ebox-incremental-begin-batch buffer) + (with-temp-buffer + (ebox-test--region-update target-id :color "#DC2626") + (ebox-test--region-update second-id :color "#2563EB") + (ebox-incremental-flush buffer)) (let* ((state (ebox-scroll-state root-id)) (content-lines (plist-get state :content-lines)) (rendered-lines (plist-get state :rendered-content-lines))) @@ -12063,38 +9920,6 @@ (should (ebox-test--lines-have-text-foreground-p rendered-lines "second target row" "#2563EB"))))))) -(ert-deftest ebox-explicit-batch-scroll-sync-hook-skips-unsuccessful-flushes () - "Empty, no-op, and failed batch flushes should not publish sync work." - (ebox-test--reset-runtime-state) - (let ((layout (ebox-test--complete-lazy-scroll-batch-layout))) - (ebox-test--with-rendered-buffer layout - (let* ((buffer (current-buffer)) - (target-id (ebox-test--selector-region-id buffer "#target")) - (hook-count 0) - (ebox-incremental--after-successful-batch-flush-hook - (list (lambda (&rest _args) (cl-incf hook-count))))) - (ebox-incremental-begin-batch buffer) - (should (eq (plist-get (ebox-incremental-flush buffer) :strategy) - 'no-op)) - (should (= hook-count 0)) - (ebox-incremental-begin-batch buffer) - (should (ebox-incremental-record-region-change - buffer target-id 'paint '(:color))) - (cl-letf (((symbol-function 'ebox--execute-constraint-change) - (lambda (&rest _args) - (ebox--update-report target-id 'no-op)))) - (should (eq (plist-get (ebox-incremental-flush buffer) :strategy) - 'no-op))) - (should (= hook-count 0)) - (ebox-incremental-begin-batch buffer) - (should (ebox-incremental-record-region-change - buffer target-id 'paint '(:color))) - (cl-letf (((symbol-function 'ebox--execute-constraint-change) - (lambda (&rest _args) (error "expected flush failure")))) - (should-error (ebox-incremental-flush buffer) - :type 'error)) - (should (= hook-count 0)))))) - (ert-deftest ebox-explicit-batch-flush-retries-after-quit () "A quit during a batch flush must keep the recorded changes retryable." (ebox-test--reset-runtime-state) @@ -12103,9 +9928,9 @@ (ebox-test--with-rendered-buffer box (let ((buffer (current-buffer))) (ebox-incremental-begin-batch buffer) - (ebox-region-update id :color "red") + (ebox-test--region-update id :color "red") (should (= (ebox-incremental-pending-count buffer) 1)) - (cl-letf (((symbol-function 'ebox--execute-constraint-change) + (cl-letf (((symbol-function 'ebox--publish-surface-region-batch) (lambda (&rest _) (signal 'quit nil)))) (condition-case nil (ebox-incremental-flush buffer) @@ -12128,116 +9953,6 @@ face :foreground "red"))) (should (ebox-test--buffer-visually-matches-runtime-render-p))))) -(ert-deftest ebox-lazy-scroll-paint-sync-does-not-rerender-node () - "Paint updates inside scroll caches should patch cached faces without render." - (ebox-test--reset-runtime-state) - (let* ((layout - (ebox-build - `(box :id "root" :width (viewport) :height (viewport-height) - :overflow scroll - (column :width (viewport) - ,@(cl-loop for idx below 32 - collect - `(box :id ,(cond - ((= idx 1) "target") - ((= idx 31) "hidden-target") - (t (format "row-%02d" idx))) - :content ,(if (= idx 1) - "target row" - (format "row %02d" idx)) - :height 1 - :color "#111111")))))) - (buffer nil) - target-id - root-id - (render-count 0) - (index-build-count 0) - (fallback-count 0) - (face-in-lines-p - (lambda (lines color) - (cl-some - (lambda (line) - (let* ((plain (substring-no-properties line)) - (pos (string-match "target row" plain))) - (and pos - (ebox-test--face-has-key-value-p - (get-text-property pos 'face line) - :foreground color)))) - lines))) - (original-render (symbol-function 'ebox-render)) - (original-index-build - (symbol-function 'ebox--scroll-build-region-line-span-index)) - (original-fallback - (symbol-function 'ebox--sync-scroll-state-lines-for-node-fallback))) - (unwind-protect - (let ((ebox-viewport-width 240) - (ebox-viewport-height 3) - (ebox-scroll-lazy-idle-prefetch-lines 0)) - (setq buffer - (ebox-render-to-buffer - (generate-new-buffer-name " *ebox-test*") - layout)) - (setq target-id (ebox-test--selector-region-id buffer "#target")) - (setq root-id (ebox-test--selector-region-id buffer "#root")) - (let ((state (ebox-scroll-state root-id))) - (should - (plist-get state :rendered-region-line-span-index-deferred)) - (should-not (plist-get state :rendered-region-line-span-index)) - (cl-letf (((symbol-function 'ebox-render) - (lambda (&rest args) - (cl-incf render-count) - (apply original-render args))) - ((symbol-function - 'ebox--scroll-build-region-line-span-index) - (lambda (&rest args) - (cl-incf index-build-count) - (apply original-index-build args))) - ((symbol-function - 'ebox--sync-scroll-state-lines-for-node-fallback) - (lambda (&rest args) - (cl-incf fallback-count) - (apply original-fallback args)))) - (ebox-region-update target-id :color "#DC2626") - (setq state (ebox-scroll-state root-id)) - (should - (plist-get state :rendered-region-line-span-index-deferred)) - (should-not (plist-get state :rendered-region-line-span-index)) - (let ((report (ebox-test--buffer-update-report))) - (should (eq (plist-get report :strategy) 'paint-patch)) - (should (plist-get report :scroll-state-paint-sync)) - (should (plist-get report :scroll-state-index-preserved))) - (let ((lines (plist-get state :content-lines))) - (should-not - (cl-some - (lambda (line) - (string-match-p "row 31" - (substring-no-properties line))) - lines)) - (dolist (key '(:content-lines :rendered-content-lines)) - (should (funcall face-in-lines-p - (plist-get state key) "#DC2626")))) - (ebox-region-update - target-id :border-left-color "#F59E0B") - (should (= fallback-count 0)) - (ebox-selector-update-buffer - buffer "#hidden-target" :color "#2563EB") - (setq state (ebox-scroll-state root-id)) - (should (plist-get state :lazy-scroll-prefix-dirty)) - (ebox-region-update target-id :color "#16A34A") - (setq state (ebox-scroll-state root-id)) - (should (plist-get state :lazy-scroll-prefix-dirty)) - (should - (plist-get state :rendered-region-line-span-index-deferred)) - (should-not (plist-get state :rendered-region-line-span-index)) - (dolist (key '(:content-lines :rendered-content-lines)) - (should (funcall face-in-lines-p - (plist-get state key) "#16A34A"))) - (should (= fallback-count 0)) - (should (= render-count 0)) - (should (= index-build-count 0))))) - (when (and buffer (buffer-live-p buffer)) - (kill-buffer buffer))))) - (ert-deftest ebox-lazy-scroll-prefix-appends-rendered-content-lines () "Lazy prefix extension should render only newly appended visible lines." (ebox-test--reset-runtime-state) @@ -12646,7 +10361,7 @@ (ebox-test--with-rendered-buffer layout (let* ((buffer (current-buffer)) (parent-id (ebox-test--selector-region-id buffer "#parent"))) - (ebox-region-update parent-id :color "red") + (ebox-test--region-update parent-id :color "red") (should (eq (plist-get (ebox-test--buffer-update-report) :strategy) 'paint-patch)) (goto-char (point-min)) @@ -12717,7 +10432,7 @@ (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") layout)) - (cl-letf (((symbol-function 'ebox--execute-constraint-change) + (cl-letf (((symbol-function 'tp-surface-update-scoped) (lambda (&rest _) (signal 'quit nil)))) (condition-case nil (ebox-rerender-buffer-with-context buffer 320) @@ -12845,7 +10560,6 @@ (buffer nil) root-id target-id next-target-id (materialize-count 0) - (owner-rerenders 0) (line-span-scans 0) (membership-builds 0)) (unwind-protect @@ -12877,11 +10591,7 @@ (original-membership-build (symbol-function 'ebox--scroll-build-content-region-id-set))) - (cl-letf (((symbol-function 'ebox-buffer-apply-owner-rerender) - (lambda (&rest _args) - (cl-incf owner-rerenders) - nil)) - ((symbol-function 'ebox--scroll-state-region-line-spans) + (cl-letf (((symbol-function 'ebox--scroll-state-region-line-spans) (lambda (&rest args) (cl-incf line-span-scans) (apply original-line-span-scan args))) @@ -12896,7 +10606,6 @@ :content "new row 31"))) (let ((report (ebox-test--buffer-update-report))) (should (= materialize-count 0)) - (should (= owner-rerenders 0)) (should (= line-span-scans 0)) (should (= membership-builds 1)) (should (plist-get (ebox--scroll-get-state root-id) @@ -13027,10 +10736,7 @@ root-id target-id (ebox-viewport-width 300) (ebox-viewport-height 5) - (ebox-scroll-lazy-prefix-lookahead-lines 12) - (rendered-lookups 0) - (original-rendered-lookup - (symbol-function 'ebox--scroll-hot-rendered-line-spans))) + (ebox-scroll-lazy-prefix-lookahead-lines 12)) (ebox-test--with-rendered-buffer layout (setq root-id (ebox-test--selector-region-id (current-buffer) "#root")) @@ -13048,18 +10754,13 @@ (should (> (- (length (plist-get state :content-lines)) (plist-get state :content-height)) 1))) - (cl-letf (((symbol-function 'ebox--scroll-hot-rendered-line-spans) - (lambda (&rest args) - (cl-incf rendered-lookups) - (apply original-rendered-lookup args)))) - (ebox-region-update target-id :width '(280))) + (ebox-test--region-update target-id :width '(280)) (let ((report (ebox-test--buffer-update-report)) (state (ebox-scroll-state root-id))) (should (eq (plist-get report :strategy) 'span-patch)) (should (plist-get report :scroll-state-sync-refreshed)) (should (= (plist-get report :scroll-state-sync-refreshed-count) 1)) (should-not (plist-get report :scroll-state-sync-deferred)) - (should (= rendered-lookups 0)) (should-not (plist-get state :lazy-scroll-prefix-dirty)) (should-not (plist-get state :lazy-scroll-window-refresh-required)) @@ -13248,71 +10949,9 @@ (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) -(ert-deftest ebox-scroll-state-span-patch-key-policy-requires-content-for-paint () - "Paint keys should only enter scroll-state patch with content changes." - (should (ebox--scroll-state-span-patch-keys-p '(:content))) - (should (ebox--scroll-state-span-patch-keys-p '(:content :color))) - (should (ebox--scroll-state-span-patch-keys-p - '(:content :bgcolor :border-top-color))) - (should (ebox--scroll-state-span-patch-keys-p '(:text-align))) - (should-not (ebox--scroll-state-span-patch-keys-p '(:color))) - (should-not (ebox--scroll-state-span-patch-keys-p - '(:bgcolor :border-top-color)))) -(ert-deftest ebox-scroll-state-span-patch-ignores-foreign-runtime-states () - "One buffer update must not materialize foreign or unrelated ownerless state." - (ebox-test--reset-runtime-state) - (let* ((layout - (ebox-build - '(box :id "root" :height 2 :overflow scroll - (column - (box :id "target" :content "before" :height 1) - (box :content "tail" :height 1))))) - (foreign-buffer (generate-new-buffer " *ebox-foreign-scroll*")) - (foreign-id :foreign-scroll-state) - (ownerless-id :ownerless-scroll-state) - (foreign-materializations 0) - (ownerless-materializations 0)) - (unwind-protect - (ebox-test--with-rendered-buffer layout - (let ((target-id - (ebox-test--selector-region-id - (current-buffer) "#target"))) - (puthash - foreign-id - (list :buffer foreign-buffer - :content-lines '("foreign prefix") - :materialize-content-lines - (lambda (_state _region-id) - (cl-incf foreign-materializations) - '(:content-lines ("foreign complete") - :rendered-content-lines ("foreign complete") - :content-height 1))) - ebox--scroll-global-state) - (puthash - ownerless-id - (list :content-lines '("ownerless prefix") - :materialize-content-lines - (lambda (_state _region-id) - (cl-incf ownerless-materializations) - '(:content-lines ("ownerless complete") - :rendered-content-lines ("ownerless complete") - :content-height 1))) - ebox--scroll-global-state) - (ebox-region-update target-id :content "after") - (should (= foreign-materializations 0)) - (should (= ownerless-materializations 0)) - (should (string-match-p - "after" - (buffer-substring-no-properties - (point-min) (point-max)))))) - (remhash foreign-id ebox--scroll-global-state) - (remhash ownerless-id ebox--scroll-global-state) - (when (buffer-live-p foreign-buffer) - (kill-buffer foreign-buffer))))) - -(ert-deftest ebox-visible-scroll-child-sync-publishes-window-markers () - "Post-patch scroll line sync must publish one current marker generation." +(ert-deftest ebox-visible-scroll-child-sync-retains-tp-mounts () + "Post-patch scroll line sync should retain TP-owned viewport ranges." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build @@ -13343,25 +10982,16 @@ (let* ((report (ebox-test--buffer-update-report)) (state (ebox--scroll-get-state root-id))) (should (plist-get report :scroll-state-sync)) - (should-not - (plist-get report - :scroll-state-window-markers-invalidated)) - (should (> (or (plist-get - report - :scroll-state-window-marker-refresh-count) - 0) - 0)) - (should (plist-get state :content-span-markers)) - (should (plist-get state :content-window-region-id-set)) - (should (ebox--scroll-state-window-markers-healthy-p - buffer root-id state))) + (ebox-test--assert-scoped-tp-publication report) + (should (eq (plist-get state :buffer) buffer)) + (should (ebox-surface-region-mounts buffer root-id))) (should (ebox--scroll-region-by root-id 1 1)) (should (ebox-test--buffer-visually-matches-runtime-render-p)))) (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) -(ert-deftest ebox-scroll-retains-published-window-markers-after-paint-update () - "Paint updates and later scrolling should share one current marker window." +(ert-deftest ebox-scroll-retains-tp-mounts-after-paint-update () + "Paint updates and later scrolling should retain TP-owned ranges." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build @@ -13385,98 +11015,30 @@ layout)) (with-current-buffer buffer (setq root-id (ebox-test--selector-region-id buffer "#root")) - (should (plist-get (ebox-scroll-state root-id) - :content-span-markers)) - (should (plist-get (ebox-scroll-state root-id) - :rendered-window-start-marker)) - (ebox-selector-update-buffer buffer "#row-01" - :color "#B91C1C") - (let ((report (ebox-test--buffer-update-report)) - (state (ebox-scroll-state root-id))) - (should (plist-get report :scroll-state-paint-sync)) - (should-not - (plist-get report - :scroll-state-window-markers-invalidated)) - (should (plist-get state :content-span-markers)) - (should (ebox--scroll-state-window-markers-healthy-p - buffer root-id state))) - (should (plist-get (ebox-scroll-state root-id) - :rendered-window-start-marker)) - (cl-loop repeat 6 - do - (should (ebox--scroll-region-by root-id 1 1)) - (should (plist-get (ebox-scroll-state root-id) - :rendered-window-start-marker)) - (should (ebox--scroll-state-window-markers-healthy-p - buffer root-id (ebox-scroll-state root-id))) - (should (ebox-test--buffer-visually-matches-runtime-render-p))))) + (let* ((surface ebox-surface--buffer-surface) + (revision (tp-surface-revision surface))) + (should (ebox-surface-region-mounts buffer root-id)) + (ebox-selector-update-buffer buffer "#row-01" + :color "#B91C1C") + (should (= (tp-surface-revision surface) (1+ revision))) + (setq revision (tp-surface-revision surface)) + (let ((report (ebox-test--buffer-update-report))) + (should (plist-get report :scroll-state-paint-sync)) + (ebox-test--assert-scoped-tp-publication report)) + (cl-loop repeat 6 + do + (should (ebox--scroll-region-by root-id 1 1)) + (should (= (tp-surface-revision surface) + (1+ revision))) + (setq revision (tp-surface-revision surface)) + (should (ebox-surface-region-mounts buffer root-id)) + (should + (ebox-test--buffer-visually-matches-runtime-render-p)))))) (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) -(ert-deftest ebox-chromed-scroll-owner-rerender-publishes-current-window () - "A scoped chromed owner rerender must publish its cache and live markers." - (ebox-test--reset-runtime-state) - (let* ((layout - (ebox-build - '(column :id "doc" :width (240) - (box :id "scroll" :width (180) :height 3 - :overflow scroll :border-top-p t - (column - (box :id "target" :content "alpha" - :width (100) :height 1) - (box :content "tail one" :height 1) - (box :content "tail two" :height 1) - (box :content "tail three" :height 1)))))) - (fallback-count 0)) - (ebox-test--with-rendered-buffer layout - (let* ((buffer (current-buffer)) - (scroll-id (ebox-test--selector-region-id buffer "#scroll")) - (target-id (ebox-test--selector-region-id buffer "#target")) - (scroll-node-id - (ebox--ensure-node-id - (plist-get (ebox-scroll-state scroll-id) :box))) - (original-fallback - (symbol-function - 'ebox--sync-scroll-state-lines-for-node-fallback))) - (cl-letf (((symbol-function 'ebox--patch-set-from-dirty-set) - (lambda (dirty-set _buffer) - (list - (ebox--patch-op - 'owner-rerender scroll-node-id - :dirty (car dirty-set))))) - ((symbol-function - 'ebox--sync-scroll-state-lines-for-node-fallback) - (lambda (&rest args) - (cl-incf fallback-count) - (apply original-fallback args)))) - (ebox-region-update target-id :height 2)) - (let* ((report (ebox-test--buffer-update-report)) - (state (ebox-scroll-state scroll-id)) - (spans - (ebox--scroll-content-marker-spans - (plist-get state :content-span-markers)))) - (should (eq (plist-get report :strategy) 'owner-rerender)) - (should (plist-get report :scroll-state-patch)) - (should (plist-get report :scroll-state-owner-publication)) - (should (= (or fallback-count 0) 0)) - (should-not (plist-get state :lazy-scroll-prefix-dirty)) - (should-not - (plist-get state :lazy-scroll-window-refresh-required)) - (should (= (length spans) (plist-get state :content-height))) - (should - (cl-every - (lambda (span) - (and (marker-buffer (car span)) - (marker-buffer (cdr span)))) - spans)) - (should (ebox--scroll-state-window-markers-healthy-p - buffer scroll-id state)) - (should (ebox-test--buffer-visually-matches-runtime-render-p))) - (should (= (ebox--scroll-region-by scroll-id 1 48) 1)) - (should (ebox-test--buffer-visually-matches-runtime-render-p)))))) - -(ert-deftest ebox-owner-rerender-without-scroll-skips-scroll-publication () - "A scroll-free root reflow should not scan or publish scroll marker state." +(ert-deftest ebox-owner-rerender-without-scroll-skips-scroll-state-lookup () + "A scroll-free root reflow should not inspect scroll containment." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build @@ -13484,12 +11046,9 @@ (box :content "resize" :width (viewport))))) buffer (containment-count 0) - (publication-count 0) (original-containment (symbol-function - 'ebox--scroll-state-region-ids-containing-node-ids)) - (original-publication - (symbol-function 'ebox--publish-scoped-owner-scroll-windows))) + 'ebox--scroll-state-region-ids-containing-node-ids))) (unwind-protect (let ((ebox-viewport-width 240) (ebox-viewport-height 4) @@ -13504,132 +11063,14 @@ 'ebox--scroll-state-region-ids-containing-node-ids) (lambda (&rest arguments) (cl-incf containment-count) - (apply original-containment arguments))) - ((symbol-function 'ebox--publish-scoped-owner-scroll-windows) - (lambda (&rest arguments) - (cl-incf publication-count) - (apply original-publication arguments)))) + (apply original-containment arguments)))) (ebox-rerender-buffer-with-context buffer 260 4)) (should (= containment-count 0)) - (should (= publication-count 0)) (with-current-buffer buffer (should (ebox-test--buffer-visually-matches-runtime-render-p)))) (when (buffer-live-p buffer) (kill-buffer buffer))))) -(ert-deftest ebox-nested-scroll-fallback-publishes-one-marker-generation () - "Nested fallback sync must rebuild markers once without zero-delta scrolls." - (ebox-test--reset-runtime-state) - (let* ((layout - (ebox-build - '(box :id "outer" :width (180) :height 3 :overflow scroll - (column - (box :id "inner" :width (160) :height 2 :overflow scroll - (column - (box :id "target" :content "old" :height 1) - (box :content "inner 1" :height 1) - (box :content "inner 2" :height 1))) - (box :content "outer 1" :height 1) - (box :content "outer 2" :height 1))))) - (marker-refresh-count 0) - (zero-delta-scroll-count 0)) - (ebox-test--with-rendered-buffer layout - (let* ((buffer (current-buffer)) - (outer-id (ebox-test--selector-region-id buffer "#outer")) - (inner-id (ebox-test--selector-region-id buffer "#inner")) - (target-id (ebox-test--selector-region-id buffer "#target")) - (target-node - (ebox--buffer-region-render-owner-node buffer target-id))) - (ebox-region-update target-id :content "new") - (should (ebox-test--buffer-visually-matches-runtime-render-p)) - (dolist (scroll-id (list outer-id inner-id)) - (let ((state (ebox-scroll-state scroll-id))) - (plist-put state :lazy-scroll-window-refresh-required t) - (puthash scroll-id state ebox--scroll-global-state))) - (let ((visible-before - (buffer-substring (point-min) (point-max))) - (original-refresh - (symbol-function 'ebox--refresh-buffer-scroll-content-markers)) - (original-scroll (symbol-function 'ebox--scroll-region-by))) - (cl-letf - (((symbol-function 'ebox--refresh-buffer-scroll-content-markers) - (lambda (&rest args) - (cl-incf marker-refresh-count) - (apply original-refresh args))) - ((symbol-function 'ebox--scroll-region-by) - (lambda (region-id delta &rest args) - (when (zerop delta) - (cl-incf zero-delta-scroll-count)) - (apply original-scroll region-id delta args)))) - (ebox--sync-scroll-state-lines-for-node-fallback - buffer target-id target-node '(:content) - (ebox-test--buffer-update-report))) - (should (= (or marker-refresh-count 0) 1)) - (should (= (or zero-delta-scroll-count 0) 0)) - (should (equal-including-properties - visible-before - (buffer-substring (point-min) (point-max))))) - (should (ebox-test--buffer-visually-matches-runtime-render-p)) - (dolist (scroll-id (list outer-id inner-id)) - (let ((state (ebox-scroll-state scroll-id))) - (should-not - (plist-get state :lazy-scroll-window-refresh-required)) - (should (ebox--scroll-state-window-extents scroll-id state)) - (should (ebox--scroll-state-window-markers-healthy-p - buffer scroll-id state)))))))) - -(ert-deftest ebox-root-scroll-owner-rerender-publishes-current-window () - "Root scroll rerenders must publish both plain and chromed windows." - (dolist (chromed '(nil t)) - (ebox-test--reset-runtime-state) - (let* ((layout - (ebox-build - `(box :id "root" :width (180) :height 3 - :overflow scroll :wrap-mode nil - ,@(when chromed '(:border-top-p t)) - (column - (box :content "row 0" :height 1) - (box :content "row 1" :height 1) - (box :content "row 2" :height 1) - (box :content "row 3" :height 1))))) - (fallback-count 0)) - (ebox-test--with-rendered-buffer layout - (let* ((buffer (current-buffer)) - (root-id (ebox-test--selector-region-id buffer "#root")) - (root-node-id (ebox--buffer-root-node-id buffer)) - (original-fallback - (symbol-function - 'ebox--sync-scroll-state-lines-for-node-fallback))) - (cl-letf (((symbol-function 'ebox--try-scroll-state-span-patch) - (lambda (&rest _args) nil)) - ((symbol-function 'ebox--patch-set-from-dirty-set) - (lambda (dirty-set _buffer) - (list - (ebox--patch-op - 'owner-rerender root-node-id - :dirty (car dirty-set))))) - ((symbol-function - 'ebox--sync-scroll-state-lines-for-node-fallback) - (lambda (&rest args) - (cl-incf fallback-count) - (apply original-fallback args)))) - (ebox-region-update root-id :wrap-mode 'char)) - (let ((report (ebox-test--buffer-update-report)) - (state (ebox-scroll-state root-id))) - (should (eq (plist-get report :strategy) 'owner-rerender)) - (should (equal (plist-get report :owner-ids) - (list root-node-id))) - (should (plist-get report :scroll-state-patch)) - (should (plist-get report :scroll-state-owner-publication)) - (should (= (or fallback-count 0) 0)) - (should-not - (plist-get state :lazy-scroll-window-refresh-required)) - (should (ebox--scroll-state-window-markers-healthy-p - buffer root-id state)) - (should (ebox-test--buffer-visually-matches-runtime-render-p))) - (should (= (ebox--scroll-region-by root-id 1 48) 1)) - (should (ebox-test--buffer-visually-matches-runtime-render-p))))))) - (ert-deftest ebox-lazy-scroll-hidden-geometry-update-defers-rerender () "Hidden geometry changes in lazy scroll windows should not rerender roots." (ebox-test--reset-runtime-state) @@ -13645,9 +11086,7 @@ :width (120) :height 1)))))) (buffer nil) - target-id - (owner-rerenders 0) - (root-rerenders 0)) + target-id) (unwind-protect (let ((ebox-viewport-width 240) (ebox-viewport-height 2)) @@ -13658,18 +11097,8 @@ (with-current-buffer buffer (setq target-id (ebox-test--selector-region-id buffer "#row-30")) (should target-id) - (cl-letf (((symbol-function 'ebox-buffer-apply-owner-rerender) - (lambda (&rest _args) - (cl-incf owner-rerenders) - nil)) - ((symbol-function 'ebox-buffer-apply-root-rerender) - (lambda (&rest _args) - (cl-incf root-rerenders) - nil))) - (ebox-selector-update-buffer buffer "#row-30" :width '(180))) + (ebox-selector-update-buffer buffer "#row-30" :width '(180)) (let ((report (ebox-test--buffer-update-report))) - (should (= owner-rerenders 0)) - (should (= root-rerenders 0)) (should (eq (plist-get report :strategy) 'span-patch)) (should (plist-get report :lazy-scroll-deferred-patch))))) (when (and buffer (buffer-live-p buffer)) @@ -13708,37 +11137,6 @@ (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) -(ert-deftest ebox-scroll-rendered-content-patches-child-range-only () - "Hidden child updates should patch the rendered scroll-line range." - (ebox-test--reset-runtime-state) - (let* ((layout - (ebox-build - '(box :id "root" :height 2 :overflow scroll - (column - (box :id "a" :content "A" :height 1) - (box :id "target" :content "old" :height 1 - :padding (0 (8)) :border "#999999") - (box :id "c" :content "C" :height 1))))) - root-id target-id - (full-window-renders 0)) - (ebox-test--with-rendered-buffer layout - (setq root-id (ebox-test--selector-region-id (current-buffer) "#root")) - (setq target-id (ebox-test--selector-region-id (current-buffer) - "#target")) - (cl-letf (((symbol-function 'ebox--render-scroll-state-window) - (lambda (&rest _args) - (cl-incf full-window-renders) - nil))) - (ebox-region-update target-id :content "new")) - (let ((report (ebox-test--buffer-update-report))) - (should (plist-get report :scroll-rendered-range-patch)) - (should (= full-window-renders 0))) - (should (ebox--scroll-region-by root-id 1)) - (should (string-match-p "new" - (buffer-substring-no-properties - (point-min) (point-max)))) - (should (ebox-test--buffer-visually-matches-runtime-render-p))))) - (ert-deftest ebox-scroll-state-set-lines-defers-line-indexes-when-bound () "Deferred scroll state setup should avoid building full line indexes." (ebox-test--reset-runtime-state) @@ -13790,29 +11188,6 @@ (should (plist-get state :rendered-region-line-span-index-deferred)))))) -(ert-deftest ebox-scroll-state-builds-deferred-rendered-index-on-demand () - "Deferred rendered line indexes should be built only when queried." - (ebox-test--reset-runtime-state) - (let ((content-lines (list (propertize "a" 'ebox-content 1) - (propertize "b" 'ebox-content 2))) - (rendered-lines (list (propertize "a" 'ebox-content 1))) - (build-count 0) - (original (symbol-function 'ebox--scroll-build-region-line-span-index)) - (ebox--defer-scroll-content-index t)) - (cl-letf (((symbol-function 'ebox--scroll-build-region-line-span-index) - (lambda (&rest args) - (cl-incf build-count) - (apply original args)))) - (let ((state (ebox--scroll-state-set-lines nil content-lines - rendered-lines))) - (should (= build-count 0)) - (should (ebox--scroll-rendered-line-spans-for-region-ids state - '(1))) - (should (= build-count 1)) - (should (plist-get state :rendered-region-line-span-index)) - (should-not - (plist-get state :rendered-region-line-span-index-deferred)))))) - (ert-deftest ebox-reflow-cache-prewarm-uses-first-valid-width-delta () "Predictive warming should start from the first valid resize velocity." (ebox-test--reset-runtime-state) @@ -14217,65 +11592,6 @@ (when (buffer-live-p buffer) (kill-buffer buffer))))) -(ert-deftest ebox-runtime-idle-prewarm-removes-first-scroll-style-index-build () - "A prewarmed runtime should reuse scroll indexes on its first style update." - (ebox-test--reset-runtime-state) - (let* ((layout - (ebox-build - '(box :id "root" :width (viewport) :height 2 :overflow scroll - (column - (box :id "target" :content "old" :height 1) - (box :id "head-1" :content "head 1" :height 1) - (box :id "head-2" :content "head 2" :height 1) - (box :id "head-3" :content "head 3" :height 1))))) - (buffer nil) - root-id target-id - (build-count 0) - (scan-count 0) - (full-snapshot-refresh-count 0)) - (unwind-protect - (let ((ebox-viewport-width 900) - (ebox-scroll-lazy-idle-prefetch-lines 0) - (ebox-runtime-idle-prewarm-slice-size 10000) - (ebox--runtime-prewarm-allow-noninteractive t)) - (setq buffer - (ebox-render-to-buffer - (generate-new-buffer-name " *ebox-prewarm-test*") layout)) - (setq root-id (ebox-test--selector-region-id buffer "#root")) - (setq target-id (ebox-test--selector-region-id buffer "#target")) - (ebox--runtime-prewarm-drain buffer) - (let ((original-build - (symbol-function 'ebox--scroll-build-region-line-span-index)) - (original-scan - (symbol-function 'ebox--scroll-state-region-line-spans)) - (original-refresh - (symbol-function 'ebox--refresh-buffer-layout-snapshots))) - (cl-letf (((symbol-function 'ebox--scroll-build-region-line-span-index) - (lambda (&rest args) - (cl-incf build-count) - (apply original-build args))) - ((symbol-function 'ebox--scroll-state-region-line-spans) - (lambda (&rest args) - (cl-incf scan-count) - (apply original-scan args))) - ((symbol-function 'ebox--refresh-buffer-layout-snapshots) - (lambda (&rest args) - (cl-incf full-snapshot-refresh-count) - (apply original-refresh args)))) - (with-current-buffer buffer - (ebox-region-update target-id :content "new")))) - (should (= build-count 0)) - (should (= scan-count 0)) - (should (= full-snapshot-refresh-count 0)) - (let ((state (ebox-scroll-state root-id))) - (should-not (plist-get state :region-line-span-index)) - (should (plist-get state :region-line-span-index-deferred))) - (should (gethash buffer ebox--runtime-prewarm-jobs)) - (should (plist-get (ebox-test--buffer-update-report) - :scroll-state-index))) - (when (buffer-live-p buffer) - (kill-buffer buffer))))) - (ert-deftest ebox-runtime-idle-prewarm-discards-stale-runtime-revisions () "A runtime mutation should invalidate an older sliced prewarm job." (ebox-test--reset-runtime-state) @@ -14297,52 +11613,6 @@ (when (buffer-live-p buffer) (kill-buffer buffer))))) -(ert-deftest ebox-runtime-idle-prewarm-cancels-before-update-errors () - "A failing mutation should not leave an old prewarm job publishable." - (ebox-test--reset-runtime-state) - (let ((buffer nil)) - (unwind-protect - (let ((ebox--runtime-prewarm-allow-noninteractive t)) - (setq buffer - (ebox-render-to-buffer - (generate-new-buffer-name " *ebox-prewarm-test*") - (ebox-build '(box :id "target" :content "old")))) - (should (gethash buffer ebox--runtime-prewarm-jobs)) - (let ((target-id - (ebox-test--selector-region-id buffer "#target"))) - (should-error - (cl-letf (((symbol-function 'ebox-incremental-record-region-change) - (lambda (&rest _args) - (error "forced update failure")))) - (with-current-buffer buffer - (ebox-region-update target-id :color "#DC2626"))))) - (should-not (gethash buffer ebox--runtime-prewarm-jobs)) - (should-not (gethash buffer ebox--runtime-prewarm-timers))) - (when (buffer-live-p buffer) - (kill-buffer buffer))))) - -(ert-deftest ebox-runtime-idle-prewarm-cancels-before-rerender-errors () - "A failing viewport rerender should discard old prewarm work." - (ebox-test--reset-runtime-state) - (let ((buffer nil)) - (unwind-protect - (let ((ebox--runtime-prewarm-allow-noninteractive t)) - (setq buffer - (ebox-render-to-buffer - (generate-new-buffer-name " *ebox-prewarm-test*") - (ebox-build '(box :id "target" :content "old")))) - (should (gethash buffer ebox--runtime-prewarm-jobs)) - (should-error - (cl-letf (((symbol-function - 'ebox-incremental-rerender-buffer-with-context) - (lambda (&rest _args) - (error "forced rerender failure")))) - (ebox-rerender-buffer-with-context buffer 360))) - (should-not (gethash buffer ebox--runtime-prewarm-jobs)) - (should-not (gethash buffer ebox--runtime-prewarm-timers))) - (when (buffer-live-p buffer) - (kill-buffer buffer))))) - (ert-deftest ebox-runtime-idle-prewarm-reschedules-after-batched-updates () "A successful explicit batch should schedule shared-state prewarming." (ebox-test--reset-runtime-state) @@ -14363,7 +11633,7 @@ (ebox-test--selector-region-id buffer "#target"))) (ebox-incremental-begin-batch buffer) (with-current-buffer buffer - (ebox-region-update target-id :color "#DC2626") + (ebox-test--region-update target-id :color "#DC2626") (ebox-incremental-flush buffer))) (let ((job (gethash buffer ebox--runtime-prewarm-jobs)) (state (ebox--buffer-render-state buffer))) @@ -14388,7 +11658,7 @@ (ebox-test--selector-region-id buffer "#target"))) (ebox-incremental-begin-batch buffer) (with-current-buffer buffer - (ebox-region-update target-id :padding-left 9))) + (ebox-test--region-update target-id :padding-left 9))) (should (= (ebox-incremental-pending-count buffer) 1)) (should-not (gethash buffer ebox--runtime-prewarm-jobs)) (should-not (gethash buffer ebox--runtime-prewarm-timers))) @@ -14522,254 +11792,6 @@ (when (buffer-live-p buffer) (kill-buffer buffer))))) -(ert-deftest ebox-owner-rerender-defers-scroll-content-index () - "Owner rerender should not rebuild full hidden scroll content indexes." - (ebox-test--reset-runtime-state) - (let* ((layout (ebox-build - `(box :id "root" :width (viewport) :height 2 - :overflow scroll - (column - ,@(cl-loop for idx below 24 - collect - `(box :content - ,(format "row %02d alpha beta gamma" - idx) - :wrap-mode word)))))) - (buffer nil) - (build-count 0) - (original (symbol-function 'ebox--scroll-build-region-line-span-index))) - (unwind-protect - (progn - (let ((ebox-viewport-width 240)) - (setq buffer - (ebox-render-to-buffer - (generate-new-buffer-name " *ebox-test*") - layout))) - (cl-letf (((symbol-function 'ebox--scroll-build-region-line-span-index) - (lambda (&rest args) - (cl-incf build-count) - (apply original args)))) - (ebox-rerender-buffer-with-context buffer 160)) - (let ((report (ebox-test--buffer-update-report))) - (should (eq (plist-get report :strategy) 'owner-rerender)) - (should (= build-count 0)))) - (when (and buffer (buffer-live-p buffer)) - (kill-buffer buffer))))) - -(ert-deftest ebox-root-owner-viewport-rerender-uses-lightweight-scroll-window () - "Root viewport rerender should defer broad indexes and keep scrolling." - (ebox-test--reset-runtime-state) - (let* ((layout - (ebox-build - `(box :id "root" :width (viewport) :height 3 :overflow scroll - (column - ,@(cl-loop for idx below 16 - collect - `(box :id ,(format "row-%02d" idx) - :content - ,(format "row %02d alpha beta gamma" idx) - :wrap-mode word)))))) - (buffer nil) - root-id - (hidden-prewarm-region-id "hidden-prewarm-scroll-state") - (full-marker-refreshes 0) - (full-role-refreshes 0) - (full-property-scans 0) - (original-refresh - (symbol-function 'ebox--refresh-buffer-scroll-content-markers)) - (original-role-refresh - (symbol-function 'ebox-buffer-refresh-region-role-spans)) - (original-property-scan - (symbol-function - 'ebox--refresh-box-extents-for-region-ids-in-spans))) - (unwind-protect - (progn - (let ((ebox-viewport-width 240)) - (setq buffer - (ebox-render-to-buffer - (generate-new-buffer-name " *ebox-test*") layout))) - (setq root-id (ebox-test--selector-region-id buffer "#root")) - (puthash hidden-prewarm-region-id t - (ebox--buffer-region-id-set buffer)) - (puthash hidden-prewarm-region-id - (list :box (ebox-create :content "hidden" :height 2 - :overflow 'scroll) - :content-height 2) - ebox--scroll-global-state) - (cl-letf (((symbol-function - 'ebox--refresh-buffer-scroll-content-markers) - (lambda (&rest args) - (cl-incf full-marker-refreshes) - (apply original-refresh args))) - ((symbol-function - 'ebox-buffer-refresh-region-role-spans) - (lambda (&rest args) - (cl-incf full-role-refreshes) - (apply original-role-refresh args))) - ((symbol-function - 'ebox--refresh-box-extents-for-region-ids-in-spans) - (lambda (&rest args) - (cl-incf full-property-scans) - (apply original-property-scan args)))) - (ebox-rerender-buffer-with-context buffer 160) - (ebox-rerender-buffer-with-context buffer 200)) - (should (= full-marker-refreshes 0)) - (should (= full-role-refreshes 0)) - (should (= full-property-scans 0)) - (should-not (ebox--buffer-region-role-span-table buffer)) - (let ((state (ebox--scroll-get-state root-id))) - (should-not (plist-get state :content-span-markers)) - (should (plist-get state :rendered-window-start-marker)) - (should (plist-get state :rendered-window-end-marker))) - (with-current-buffer buffer - (ebox-region-update root-id :scroll-offset 1) - (should (plist-get (ebox-test--buffer-update-report) - :scroll-fast-path))) - (should (= (plist-get (ebox--scroll-get-state root-id) - :scroll-offset) - 1)) - (cl-letf (((symbol-function - 'ebox-buffer-refresh-region-role-spans) - (lambda (&rest args) - (cl-incf full-role-refreshes) - (apply original-role-refresh args)))) - (with-current-buffer buffer - (ebox-region-update root-id :bgcolor "#223344"))) - (should (= full-role-refreshes 1)) - (should (hash-table-p - (ebox--buffer-region-role-span-table buffer))) - (with-current-buffer buffer - (should (ebox-test--buffer-visually-matches-runtime-render-p)))) - (when (and buffer (buffer-live-p buffer)) - (kill-buffer buffer)) - (remhash hidden-prewarm-region-id ebox--scroll-global-state)))) - -(ert-deftest ebox-root-owner-chromed-scroll-rerender-uses-prepared-indexes () - "A visible chromed scroll box should install prepared complete indexes." - (ebox-test--reset-runtime-state) - (let* ((layout - (ebox-build - `(box :id "root" :width (viewport) :height 3 :overflow scroll - :border-top-p t - (column - ,@(cl-loop for idx below 8 - collect - `(box :content ,(format "row %02d" idx))))))) - (buffer nil) - root-id - (full-marker-refreshes 0) - (original-refresh - (symbol-function 'ebox--refresh-buffer-scroll-content-markers))) - (unwind-protect - (progn - (let ((ebox-viewport-width 240)) - (setq buffer - (ebox-render-to-buffer - (generate-new-buffer-name " *ebox-test*") layout))) - (setq root-id (ebox-test--selector-region-id buffer "#root")) - (cl-letf (((symbol-function - 'ebox--refresh-buffer-scroll-content-markers) - (lambda (&rest args) - (cl-incf full-marker-refreshes) - (apply original-refresh args)))) - (ebox-rerender-buffer-with-context buffer 160)) - (should (= full-marker-refreshes 0)) - (should - (gethash - ebox-buffer--role-span-table-template-key - (ebox--buffer-region-role-span-table buffer))) - (should (vectorp - (plist-get (ebox--scroll-get-state root-id) - :content-span-markers))) - (with-current-buffer buffer - (should (ebox-test--buffer-visually-matches-runtime-render-p)))) - (when (and buffer (buffer-live-p buffer)) - (kill-buffer buffer))))) - -(ert-deftest ebox-root-owner-rerender-publishes-property-aware-patches () - "A root owner rerender should publish only its property-aware delta." - (ebox-test--reset-runtime-state) - (let* ((layout (ebox-build - '(box :id "root" :width (viewport) - :content "responsive root\nsecond line\nthird line"))) - (buffer nil) - (owner-set-scans 0) - (root-patch-publications 0) - (original-owner-set - (symbol-function 'ebox--string-content-owner-set)) - (original-replace - (symbol-function - 'ebox-buffer--replace-root-with-property-patches))) - (unwind-protect - (progn - (let ((ebox-viewport-width 240)) - (setq buffer - (ebox-render-to-buffer - (generate-new-buffer-name " *ebox-test*") layout))) - (cl-letf (((symbol-function 'ebox--string-content-owner-set) - (lambda (&rest args) - (cl-incf owner-set-scans) - (apply original-owner-set args))) - ((symbol-function - 'ebox-buffer--replace-root-with-property-patches) - (lambda (spans rendered) - (cl-incf root-patch-publications) - (funcall original-replace spans rendered)))) - (ebox-rerender-buffer-with-context buffer 160)) - (should (= owner-set-scans 0)) - (should (= root-patch-publications 1)) - (should (eq (plist-get (ebox-test--buffer-update-report) :strategy) - 'owner-rerender)) - (should (eq (plist-get (ebox-test--buffer-update-report) - :publication-scope) - 'minimal-spans)) - (should (plist-get (ebox-test--buffer-update-report) - :published-buffer-spans)) - (let ((expected - (let ((ebox-viewport-width 160)) - (ebox-render (ebox-test--runtime-root buffer))))) - (with-current-buffer buffer - (should (equal-including-properties - (buffer-substring (point-min) (point-max)) - expected))))) - (when (and buffer (buffer-live-p buffer)) - (kill-buffer buffer))))) - -(ert-deftest ebox-span-patch-defers-scroll-content-index () - "Span patches should not rebuild full hidden scroll content indexes." - (ebox-test--reset-runtime-state) - (let* ((layout (ebox-build - `(box :id "root" :width 160 :height 2 - :overflow scroll - (column - ,@(cl-loop for idx below 24 - collect - `(box :content - ,(format "row %02d alpha beta gamma" - idx) - :wrap-mode word)))))) - (buffer nil) - root-id - (build-count 0) - (original (symbol-function 'ebox--scroll-build-region-line-span-index))) - (unwind-protect - (progn - (setq buffer - (ebox-render-to-buffer - (generate-new-buffer-name " *ebox-test*") - layout)) - (setq root-id (ebox-test--selector-region-id buffer "#root")) - (cl-letf (((symbol-function 'ebox--scroll-build-region-line-span-index) - (lambda (&rest args) - (cl-incf build-count) - (apply original args)))) - (should (ebox-buffer-apply-span-patch - buffer - (list :owner-id root-id - :dirty (list :changed-keys '(:width)))))) - (should (= build-count 0))) - (when (and buffer (buffer-live-p buffer)) - (kill-buffer buffer))))) (ert-deftest ebox-hidden-scroll-child-content-update-patches-scroll-state () "Hidden scroll children should update cached scroll lines without rerendering." @@ -14788,13 +11810,7 @@ (let ((plain (buffer-substring-no-properties (point-min) (point-max)))) (should (string-match-p "visible" plain)) (should-not (string-match-p "old" plain))) - (let ((owner-rerenders 0)) - (cl-letf (((symbol-function 'ebox-buffer-apply-owner-rerender) - (lambda (&rest _args) - (cl-incf owner-rerenders) - nil))) - (ebox-region-update target-id :content "new") - (should (= owner-rerenders 0)))) + (ebox-test--region-update target-id :content "new") (let ((report (ebox-test--buffer-update-report))) (should (eq (plist-get report :strategy) 'span-patch)) (should (plist-get report :scroll-state-patch)) @@ -14829,7 +11845,7 @@ (point-min) (point-max)))) (cl-letf (((symbol-function 'ebox--region-ids-visible-in-buffer-p) (lambda (&rest _args) nil))) - (ebox-region-update target-id :content "new row 30")) + (ebox-test--region-update target-id :content "new row 30")) (let ((report (ebox-test--buffer-update-report))) (should (memq (plist-get report :strategy) '(span-patch owner-rerender root-rerender))) @@ -14863,14 +11879,14 @@ (current-buffer) "#target")) (should (plist-get (ebox-scroll-state root-id) :render-content-prefix)) - (ebox-region-update target-id :content "") + (ebox-test--region-update target-id :content "") (let ((report (ebox-test--buffer-update-report))) (should (eq (plist-get report :strategy) 'span-patch)) (should (plist-get report :scroll-state-patch)) (should-not (member (ebox--buffer-root-node-id (current-buffer)) (plist-get report :owner-ids)))) - (ebox-region-update target-id :content "NOW VISIBLE") + (ebox-test--region-update target-id :content "NOW VISIBLE") (let ((report (ebox-test--buffer-update-report)) (state (ebox-scroll-state root-id))) (should (eq (plist-get report :strategy) 'span-patch)) @@ -14922,7 +11938,7 @@ (cl-incf materialize-count) (apply materialize arguments))) (puthash root-id state ebox--scroll-global-state)) - (ebox-region-update rail-id :content "updated") + (ebox-test--region-update rail-id :content "updated") (should (= (or materialize-count 0) 0)) (let ((state (ebox-scroll-state root-id))) (should-not (plist-get state :content-lines-complete-p)) @@ -14931,111 +11947,9 @@ "updated" (buffer-substring-no-properties (point-min) (point-max))))))) -(ert-deftest ebox-visible-scroll-marker-window-detects-owner-stack () - "Visible marker checks should recognize owner-stack-only regions." - (ebox-test--reset-runtime-state) - (with-temp-buffer - (let* ((region-id 4242) - (line (propertize "owned visible line" - 'ebox-content-owners (list region-id))) - start end state) - (insert line) - (setq start (copy-marker (point-min) nil)) - (setq end (copy-marker (point-max) t)) - (setq state (list :content-span-markers - (vector (cons start end)))) - (should (ebox--scroll-state-region-visible-in-window-p - state (list region-id))) - (set-marker start nil) - (set-marker end nil)))) - -(ert-deftest ebox-scroll-marker-window-visible-set-is-authoritative () - "A cached visible region set should avoid marker text fallback scans." - (ebox-test--reset-runtime-state) - (with-temp-buffer - (let* ((region-id 4343) - (line (propertize "owned visible line" - 'ebox-content-owners (list region-id))) - (empty-visible-set (make-hash-table :test 'equal)) - start end state - (scans 0)) - (insert line) - (setq start (copy-marker (point-min) nil)) - (setq end (copy-marker (point-max) t)) - (setq state (list :content-window-region-id-set empty-visible-set - :content-span-markers (vector (cons start end)))) - (let ((original (symbol-function 'ebox--pos-region-ids))) - (cl-letf (((symbol-function 'ebox--pos-region-ids) - (lambda (&rest args) - (cl-incf scans) - (apply original args)))) - (should-not (ebox--scroll-state-region-visible-in-window-p - state (list region-id))))) - (should (= scans 0)) - (set-marker start nil) - (set-marker end nil)))) - -(ert-deftest ebox-scroll-updates-rendered-window-extents-when-window-moves () - "Scrolling should keep lightweight rendered window extents current." - (ebox-test--reset-runtime-state) - (let* ((children (cl-loop for idx below 20 - collect `(box :id ,(format "row-%02d" idx) - :content ,(format "row %02d" idx) - :height 1))) - (layout (ebox-build - `(box :id "root" :height 2 :overflow scroll - (column ,@children)))) - root-id) - (ebox-test--with-rendered-buffer layout - (setq root-id (ebox-test--selector-region-id (current-buffer) "#root")) - (should (plist-get (ebox-scroll-state root-id) - :content-window-region-id-set)) - (should (ebox--scroll-region-by root-id 1)) - (let ((state (ebox-scroll-state root-id))) - (should (plist-get state :rendered-window-start-marker)) - (should (plist-get state :rendered-window-end-marker)) - (should-not (plist-get state :content-span-markers)) - (should-not (plist-get state :content-window-region-id-set)) - (should (ebox-test--buffer-visually-matches-runtime-render-p)))))) - -(ert-deftest ebox-scroll-reveal-indexes-visible-child-paint () - "A child revealed by scrolling should paint the live buffer immediately." - (ebox-test--reset-runtime-state) - (let ((layout - (ebox-build - '(box :id "root" :width (180) :height 2 :overflow scroll - (column - (box :id "row-0" :content "row 0" :height 1) - (box :id "row-1" :content "row 1" :height 1) - (box :id "row-2" :content "row 2" :height 1) - (box :id "target" :content "target" :height 1) - (box :id "row-4" :content "row 4" :height 1))))) - root-id target-id) - (ebox-test--with-rendered-buffer layout - (setq root-id (ebox-test--selector-region-id (current-buffer) "#root")) - (setq target-id - (ebox-test--selector-region-id (current-buffer) "#target")) - (should (ebox--scroll-region-by root-id 3 48)) - (ebox-region-update target-id :color "#abcdef") - (should (ebox--region-find target-id 'content)) - (let* ((report (ebox-test--buffer-update-report)) - (span (car (ebox--region-find target-id 'content))) - (face (and span (get-text-property (car span) 'face)))) - (should (eq (plist-get report :strategy) 'paint-patch)) - (should (> (or (plist-get report :span-count) 0) 0)) - (should (ebox-test--face-has-key-value-p - face :foreground "#abcdef"))) - (should (ebox-test--buffer-visually-matches-runtime-render-p)) - (should (ebox--scroll-region-by root-id -3 48)) - (should (ebox--scroll-region-by root-id 3 48)) - (let* ((span (car (ebox--region-find target-id 'content))) - (face (and span (get-text-property (car span) 'face)))) - (should (ebox-test--face-has-key-value-p - face :foreground "#abcdef"))) - (should (ebox-test--buffer-visually-matches-runtime-render-p))))) (ert-deftest ebox-scroll-reveal-keeps-visible-geometry-indexed () - "Geometry updates on a revealed child should not fall back to broad scans." + "Geometry updates on a revealed child should use a scoped TP plan." (ebox-test--reset-runtime-state) (let ((layout (ebox-build @@ -15046,165 +11960,20 @@ (box :content "row 2" :height 1) (box :id "target" :content "target" :width (120) :height 1 :box-sizing border-box :padding-left (2)))))) - root-id target-id - (span-scans 0) - (full-extent-refreshes 0)) + root-id target-id) (ebox-test--with-rendered-buffer layout (setq root-id (ebox-test--selector-region-id (current-buffer) "#root")) (setq target-id (ebox-test--selector-region-id (current-buffer) "#target")) (should (ebox--scroll-region-by root-id 2 48)) - (let ((original-scan - (symbol-function 'ebox--region-set-line-spans-by-scan)) - (original-extents - (symbol-function 'ebox--refresh-buffer-box-extents))) - (cl-letf (((symbol-function 'ebox--region-set-line-spans-by-scan) - (lambda (&rest args) - (cl-incf span-scans) - (apply original-scan args))) - ((symbol-function 'ebox--refresh-buffer-box-extents) - (lambda (&rest args) - (cl-incf full-extent-refreshes) - (apply original-extents args)))) - (ebox-region-update target-id :padding-left (list 12)))) - (should (= span-scans 0)) - (should (= full-extent-refreshes 0)) - (should (eq (plist-get (ebox-test--buffer-update-report) :strategy) - 'span-patch)) + (ebox-test--region-update target-id :padding-left (list 12)) + (let ((report (ebox-test--buffer-update-report))) + (should (eq (plist-get report :strategy) 'span-patch)) + (should (eq (plist-get report :tp-render-scope) 'objects)) + (should-not (plist-get report :tp-scope-fallback))) + (should (ebox-surface-region-mounts (current-buffer) target-id)) (should (ebox-test--buffer-visually-matches-runtime-render-p))))) -(ert-deftest ebox-scroll-role-markers-do-not-paint-a-displaced-row () - "Scroll-displaced marker spans should not paint their replacement row." - (ebox-test--reset-runtime-state) - (let ((layout - (ebox-build - '(box :id "root" :height 3 :overflow scroll - (column - (box :content "row 0" :height 1) - (box :content "row 1" :height 1) - (box :id "target" :content "target" :height 1) - (box :id "row-3" :content "row 3" :height 1) - (box :content "row 4" :height 1))))) - root-id target-id row-3-id - (full-role-refreshes 0) - (property-scans 0)) - (ebox-test--with-rendered-buffer layout - (setq root-id (ebox-test--selector-region-id (current-buffer) "#root")) - (setq target-id - (ebox-test--selector-region-id (current-buffer) "#target")) - (setq row-3-id - (ebox-test--selector-region-id (current-buffer) "#row-3")) - (let ((stale-role-table (ebox-buffer--region-role-span-table))) - (should (hash-table-p stale-role-table)) - (should (ebox--scroll-region-by root-id 1 48)) - (should-not (ebox-buffer--region-role-span-table)) - (ebox--set-buffer-region-role-span-table - (current-buffer) stale-role-table) - (should (eq (ebox--region-ids-role-table-status - stale-role-table (list target-id)) - 'stale)) - (should-not - (ebox--region-ids-visible-via-role-table-p (list target-id))) - (let ((original-refresh - (symbol-function 'ebox-buffer-refresh-region-role-spans)) - (original-scan - (symbol-function 'ebox--region-ids-visible-by-property-scan-p))) - (cl-letf (((symbol-function 'ebox-buffer-refresh-region-role-spans) - (lambda (&rest args) - (cl-incf full-role-refreshes) - (apply original-refresh args))) - ((symbol-function - 'ebox--region-ids-visible-by-property-scan-p) - (lambda (&rest args) - (cl-incf property-scans) - (apply original-scan args)))) - (ebox-region-update target-id :color "#abcdef")))) - (let* ((target-pos - (text-property-any - (point-min) (point-max) 'ebox-content target-id)) - (row-3-pos - (text-property-any - (point-min) (point-max) 'ebox-content row-3-id)) - (target-face - (and target-pos (get-text-property target-pos 'face))) - (row-3-face - (and row-3-pos (get-text-property row-3-pos 'face)))) - (should (= full-role-refreshes 1)) - (should (= property-scans 0)) - (should (ebox-test--face-has-key-value-p - target-face :foreground "#abcdef")) - (should-not (ebox-test--face-has-key-value-p - row-3-face :foreground "#abcdef")))))) - -(ert-deftest ebox-partially-stale-role-spans-rebuild-before-paint () - "A current target span must not hide a stale span on a neighboring line." - (ebox-test--reset-runtime-state) - (let ((layout - (ebox-build - '(column :id "root" :width (180) - (box :id "target" :content "target" :height 1) - (box :id "neighbor" :content "neighbor" :height 1)))) - (full-role-refreshes 0) - (property-scans 0)) - (ebox-test--with-rendered-buffer layout - (let* ((target-id - (ebox-test--selector-region-id (current-buffer) "#target")) - (neighbor-id - (ebox-test--selector-region-id (current-buffer) "#neighbor")) - (table (ebox-buffer--region-role-span-table)) - (target-key - (ebox-buffer--region-role-key target-id 'content)) - (neighbor-key - (ebox-buffer--region-role-key neighbor-id 'content)) - (valid-span (car (gethash target-key table))) - (displaced-span (car (gethash neighbor-key table)))) - (should valid-span) - (should displaced-span) - (should-not - (= (line-number-at-pos (marker-position (car valid-span))) - (line-number-at-pos (marker-position (car displaced-span))))) - (puthash target-key (list valid-span displaced-span) table) - (should (ebox-buffer--current-region-role-span-p - valid-span target-id 'content)) - (should-not (ebox-buffer--current-region-role-span-p - displaced-span target-id 'content)) - (should (ebox--region-ids-visible-via-role-table-p (list target-id))) - (should (eq (ebox--region-ids-role-table-status - table (list target-id)) - 'stale)) - (let ((original-refresh - (symbol-function 'ebox-buffer-refresh-region-role-spans)) - (original-scan - (symbol-function 'ebox--region-ids-visible-by-property-scan-p))) - (cl-letf (((symbol-function 'ebox-buffer-refresh-region-role-spans) - (lambda (&rest args) - (cl-incf full-role-refreshes) - (apply original-refresh args))) - ((symbol-function - 'ebox--region-ids-visible-by-property-scan-p) - (lambda (&rest args) - (cl-incf property-scans) - (apply original-scan args)))) - (ebox-region-update target-id :color "#abcdef"))) - (let* ((target-pos - (text-property-any - (point-min) (point-max) 'ebox-content target-id)) - (neighbor-pos - (text-property-any - (point-min) (point-max) 'ebox-content neighbor-id)) - (target-face - (and target-pos (get-text-property target-pos 'face))) - (neighbor-face - (and neighbor-pos (get-text-property neighbor-pos 'face)))) - (should (= full-role-refreshes 1)) - (should (= property-scans 0)) - (should (eq (plist-get (ebox-test--buffer-update-report) :strategy) - 'paint-patch)) - (should (ebox-test--face-has-key-value-p - target-face :foreground "#abcdef")) - (should-not (ebox-test--face-has-key-value-p - neighbor-face :foreground "#abcdef"))))))) - (ert-deftest ebox-scroll-hidden-update-keeps-the-visible-window () "Updating a scrolled-away node should not reuse its stale buffer spans." (ebox-test--reset-runtime-state) @@ -15226,28 +11995,30 @@ (should (ebox--scroll-region-by root-id 3 48)) (let ((visible-before (buffer-substring-no-properties (point-min) (point-max)))) - (ebox-region-update target-id :padding-left (list 12)) + (ebox-test--region-update target-id :padding-left (list 12)) (should (equal visible-before (buffer-substring-no-properties (point-min) (point-max))))) (let ((report (ebox-test--buffer-update-report))) - (should (eq (plist-get report :strategy) 'span-patch)) + (should (eq (plist-get report :strategy) 'owner-rerender)) (should (plist-get report :scroll-state-patch)) - (should (= (plist-get report :patch-count) 1))) + (should-not (plist-get report :tp-full-root)) + (should (= (plist-get report :tp-operation-count) 0))) (should-not (text-property-any (point-min) (point-max) 'ebox-content target-id)) (should (ebox-test--buffer-visually-matches-runtime-render-p)) (let ((visible-before (buffer-substring-no-properties (point-min) (point-max)))) - (ebox-region-update target-id :width (list 400)) + (ebox-test--region-update target-id :width (list 400)) (should (equal visible-before (buffer-substring-no-properties (point-min) (point-max))))) (let ((report (ebox-test--buffer-update-report))) - (should (eq (plist-get report :strategy) 'root-rerender)) - (should (plist-get report :root-rerender)) - (should (plist-get report :scroll-state-patch))) + (should (eq (plist-get report :strategy) 'owner-rerender)) + (should-not (plist-get report :root-rerender)) + (should (plist-get report :scroll-state-patch)) + (should (= (plist-get report :tp-operation-count) 0))) (should (ebox-test--buffer-visually-matches-runtime-render-p)) (should (ebox--scroll-region-by root-id -3 48)) (should @@ -15282,14 +12053,16 @@ (point-min) (point-max) 'ebox-content target-id)) (let ((visible-before (buffer-substring-no-properties (point-min) (point-max)))) - (ebox-region-update target-id :padding-left (list 12)) + (ebox-test--region-update target-id :padding-left (list 12)) (should (equal visible-before (buffer-substring-no-properties (point-min) (point-max))))) (let ((report (ebox-test--buffer-update-report))) - (should (eq (plist-get report :strategy) 'root-rerender)) - (should (plist-get report :root-rerender)) - (should (plist-get report :scroll-state-patch))) + (should (eq (plist-get report :strategy) 'owner-rerender)) + (should-not (plist-get report :root-rerender)) + (should (plist-get report :scroll-state-patch)) + (should (= (plist-get report :scroll-state-sync-count) 2)) + (should (= (plist-get report :tp-operation-count) 0))) (should (ebox-test--buffer-visually-matches-runtime-render-p)) (should (ebox--scroll-region-by root-id 1 48)) (should (ebox--scroll-region-by root-id -1 48)) @@ -15303,196 +12076,6 @@ (point-min) (point-max) 'ebox-content target-id)) (should (ebox-test--buffer-visually-matches-runtime-render-p))))) -(ert-deftest ebox-scroll-hot-rendered-lookup-uses-content-line-hints () - "A deferred rendered index should inspect target lines, not the full prefix." - (ebox-test--reset-runtime-state) - (let* ((content-lines - (cl-loop for index below 100 - collect (propertize (format "row %d" index) - 'ebox-content index))) - (rendered-lines (mapcar #'copy-sequence content-lines)) - (state (let ((ebox--defer-scroll-content-index t)) - (ebox--scroll-state-set-lines - nil content-lines rendered-lines))) - (content-index - (ebox--scroll-build-region-line-span-index content-lines)) - (full-builds 0) - (full-scans 0) - (membership-builds 0) - (hint-scans 0)) - (plist-put state :region-line-span-index content-index) - (plist-put state :region-line-span-index-deferred nil) - (let ((original-build - (symbol-function 'ebox--scroll-build-region-line-span-index)) - (original-scan - (symbol-function 'ebox--scroll-state-region-line-spans)) - (original-membership-build - (symbol-function 'ebox--scroll-build-content-region-id-set)) - (original-hint-scan - (symbol-function 'ebox--scroll-line-spans-at-index-hints))) - (cl-letf (((symbol-function 'ebox--scroll-build-region-line-span-index) - (lambda (&rest args) - (cl-incf full-builds) - (apply original-build args))) - ((symbol-function 'ebox--scroll-state-region-line-spans) - (lambda (&rest args) - (cl-incf full-scans) - (apply original-scan args))) - ((symbol-function 'ebox--scroll-build-content-region-id-set) - (lambda (&rest args) - (cl-incf membership-builds) - (apply original-membership-build args))) - ((symbol-function 'ebox--scroll-line-spans-at-index-hints) - (lambda (&rest args) - (cl-incf hint-scans) - (apply original-hint-scan args)))) - (let* ((content-spans - (ebox--scroll-hot-content-line-spans state '(70))) - (rendered-spans - (ebox--scroll-hot-rendered-line-spans - state '(70) content-spans))) - (should (= (caar content-spans) 70)) - (should (= (caar rendered-spans) 70))))) - (should (= full-builds 0)) - (should (= full-scans 0)) - (should (= membership-builds 0)) - (should (= hint-scans 0)) - (should-not (plist-get state :rendered-region-line-span-index)) - (should (plist-get state :rendered-region-line-span-index-deferred)))) - -(ert-deftest ebox-hidden-scroll-child-content-update-reuses-prebuilt-index () - "Hidden scroll child updates should reuse a prebuilt line index." - (ebox-test--reset-runtime-state) - (let* ((children (cl-loop for idx below 80 - collect `(box :id ,(format "row-%02d" idx) - :content ,(format "row %02d" idx) - :height 1))) - (layout (ebox-build - `(box :id "root" :height 2 :overflow scroll - (column ,@children)))) - root-id target-id - (scan-calls 0) - (build-index-calls 0)) - (ebox-test--with-rendered-buffer layout - (setq root-id (ebox-test--selector-region-id (current-buffer) "#root")) - (setq target-id (ebox-test--selector-region-id (current-buffer) - "#row-70")) - (setq next-target-id - (ebox-test--selector-region-id (current-buffer) "#row-71")) - (let ((original (symbol-function 'ebox--scroll-state-region-line-spans)) - (original-build - (symbol-function 'ebox--scroll-build-region-line-span-index))) - (cl-letf (((symbol-function 'ebox--scroll-state-region-line-spans) - (lambda (&rest args) - (cl-incf scan-calls) - (apply original args))) - ((symbol-function 'ebox--scroll-build-region-line-span-index) - (lambda (&rest args) - (cl-incf build-index-calls) - (apply original-build args)))) - (ebox-region-update target-id :content "new 70"))) - (should (= scan-calls 0)) - (should (= build-index-calls 0)) - (let ((report (ebox-test--buffer-update-report))) - (should (eq (plist-get report :strategy) 'span-patch)) - (should (plist-get report :scroll-state-patch)) - (should (plist-get report :scroll-state-index))) - (let ((original (symbol-function 'ebox--scroll-state-region-line-spans)) - (original-build - (symbol-function 'ebox--scroll-build-region-line-span-index))) - (cl-letf (((symbol-function 'ebox--scroll-state-region-line-spans) - (lambda (&rest args) - (cl-incf scan-calls) - (apply original args))) - ((symbol-function 'ebox--scroll-build-region-line-span-index) - (lambda (&rest args) - (cl-incf build-index-calls) - (apply original-build args)))) - (ebox-region-update next-target-id :content "alt 71"))) - (should (= scan-calls 0)) - (should (= build-index-calls 0)) - (should (ebox--scroll-region-by root-id 70)) - (let ((visible - (buffer-substring-no-properties (point-min) (point-max)))) - (should (string-match-p "new 70" visible)) - (should (string-match-p "alt 71" visible)))))) - -(ert-deftest ebox-hidden-scroll-child-visibility-check-skips-live-extents () - "Hidden scroll child updates should not force buffer-wide live extent scans." - (ebox-test--reset-runtime-state) - (let* ((children (cl-loop for idx below 80 - collect `(box :id ,(format "row-%02d" idx) - :content ,(format "row %02d" idx) - :height 1))) - (layout (ebox-build - `(box :id "root" :height 2 :overflow scroll - (column ,@children)))) - target-id - (live-extents-calls 0) - (property-scan-calls 0) - (original (symbol-function 'ebox--live-box-extents)) - (original-property-scan - (symbol-function 'ebox--region-ids-visible-by-property-scan-p))) - (ebox-test--with-rendered-buffer layout - (setq target-id (ebox-test--selector-region-id (current-buffer) - "#row-70")) - (should (hash-table-p (ebox-buffer--region-role-span-table))) - (cl-letf (((symbol-function 'ebox--live-box-extents) - (lambda (&rest args) - (cl-incf live-extents-calls) - (apply original args))) - ((symbol-function 'ebox--region-ids-visible-by-property-scan-p) - (lambda (&rest args) - (cl-incf property-scan-calls) - (apply original-property-scan args)))) - (ebox-region-update target-id :content "new 70")) - (should (= live-extents-calls 0)) - (should (= property-scan-calls 0)) - (let ((report (ebox-test--buffer-update-report))) - (should (eq (plist-get report :strategy) 'span-patch)) - (should (plist-get report :scroll-state-patch)))))) - -(ert-deftest ebox-small-geometry-span-patch-refreshes-indexes-scoped () - "Small geometry span patches should not rebuild all buffer runtime indexes." - (ebox-test--reset-runtime-state) - (let ((layout (ebox-test--dynamic-update-row-layout)) - (full-extents 0) - (full-roles 0) - (full-scroll 0) - (scoped-property-scans 0)) - (ebox-test--with-rendered-buffer layout - (let ((region-id (ebox-test--selector-region-id (current-buffer) - "#margin")) - (original-scoped-scan - (symbol-function - 'ebox--refresh-box-extents-for-region-ids-in-spans))) - (cl-letf (((symbol-function 'ebox--refresh-buffer-box-extents) - (lambda (&rest _args) - (cl-incf full-extents) - nil)) - ((symbol-function 'ebox-buffer-refresh-region-role-spans) - (lambda (&rest _args) - (cl-incf full-roles) - nil)) - ((symbol-function 'ebox--refresh-buffer-scroll-content-markers) - (lambda (&rest _args) - (cl-incf full-scroll) - nil)) - ((symbol-function - 'ebox--refresh-box-extents-for-region-ids-in-spans) - (lambda (&rest args) - (cl-incf scoped-property-scans) - (apply original-scoped-scan args)))) - (ebox-region-update region-id :margin-left (list 20))) - (let ((report (ebox-test--buffer-update-report))) - (should (eq (plist-get report :strategy) 'span-patch)) - (should (plist-get report :scoped-index-refresh)) - (should (= full-extents 0)) - (should (= full-roles 0)) - (should (= full-scroll 0)) - (should (= scoped-property-scans 0)) - (should (ebox-test--buffer-visually-matches-runtime-render-p))))))) - (ert-deftest ebox-wheel-scroll-uses-buffer-scroll-region-without-point () "Wheel events from fringe-like areas should still scroll the ebox page." (ebox-test--reset-runtime-state) @@ -16161,7 +12744,7 @@ (region-id (car (ebox-region-ids box)))) (ebox-test--with-rendered-buffer box (should-error - (ebox-region-update region-id + (ebox-test--region-update region-id :bgcolor "#222222" :padding-left-pixel -1) :type 'error) @@ -16187,7 +12770,7 @@ (lambda (&rest _) (ert-fail "no-op discovered containing scroll scope")))) (let ((report - (ebox-region-update target-id :content "same"))) + (ebox-test--region-update target-id :content "same"))) (should (eq (plist-get report :strategy) 'no-op)) (should (= (plist-get report :dirty-count) 0)))))))) @@ -16219,7 +12802,7 @@ (when (eq tree scroll-box) (ert-fail "deep-copied containing scroll owner box")) (funcall original-copy-tree tree vectors-and-records)))) - (ebox-region-update target-id :color "#222222")) + (ebox-test--region-update target-id :color "#222222")) (should (equal (ebox-get (gethash target-id ebox--region-box-table) :color) @@ -16238,133 +12821,16 @@ (box-before (copy-tree box)) (text-before (buffer-string))) (ebox-test--set-buffer-update-report buffer report-before) - (cl-letf (((symbol-function 'ebox--rerender-region-for-dirty-kind) + (cl-letf (((symbol-function 'ebox-surface--render-candidate) (lambda (&rest _) (error "Injected rerender failure")))) (should-error - (ebox-region-update region-id :content "after") + (ebox-test--region-update region-id :content "after") :type 'error)) (should (equal box box-before)) (should (equal-including-properties (buffer-string) text-before)) (should (eq (ebox-buffer-update-report buffer) report-before)))))) -(ert-deftest ebox-region-update-rolls-back-after-scroll-sync-fails () - "A post-publication scroll sync failure must roll back all published state." - (ebox-test--reset-runtime-state) - (let* ((layout - (ebox-build - '(box :id "root" :width (viewport) :height (viewport-height) - :overflow scroll - (column :width (viewport) - (box :id "target" :content "before" :width (120) :height 1) - (box :content "tail" :width (120) :height 1))))) - (report-before 'keep-scroll-report)) - (let ((ebox-viewport-width 260) - (ebox-viewport-height 2) - (ebox-scroll-lazy-prefix-lookahead-lines 0)) - (ebox-test--with-rendered-buffer layout - (let* ((buffer (current-buffer)) - (root-id (ebox-test--selector-region-id buffer "#root")) - (target-id (ebox-test--selector-region-id buffer "#target")) - (target-box (gethash target-id ebox--region-box-table)) - (target-box-before (copy-tree target-box)) - (scroll-state-before (copy-tree (ebox--scroll-get-state root-id))) - (runtime-state (ebox--buffer-render-state buffer)) - (runtime-root-before - (copy-tree (plist-get runtime-state :root-node))) - (runtime-revision-before - (plist-get runtime-state :runtime-revision)) - (text-before (buffer-string))) - (ebox-test--set-buffer-update-report buffer report-before) - (cl-letf (((symbol-function 'ebox--sync-scroll-state-lines-for-node) - (lambda (&rest _) - (error "Injected scroll sync failure")))) - (should-error - (ebox-region-update target-id :content "after") - :type 'error)) - (should (equal target-box target-box-before)) - (should (equal (ebox--scroll-get-state root-id) - scroll-state-before)) - (should (equal-including-properties (buffer-string) text-before)) - (should (eq (ebox--buffer-render-state buffer) runtime-state)) - (should (equal (plist-get runtime-state :root-node) - runtime-root-before)) - (should (equal (plist-get runtime-state :runtime-revision) - runtime-revision-before)) - (should (eq (ebox-buffer-update-report buffer) - report-before))))))) - -(ert-deftest ebox-region-update-rolls-back-new-scroll-state-after-post-failure () - "Rollback must tombstone scroll state created during a failed update." - (ebox-test--reset-runtime-state) - (let* ((layout - (ebox-build - '(box :id "target" :content "before\nhidden" :width (120) - :height 1 :overflow visible))) - (report-before 'keep-new-scroll-report)) - (ebox-test--with-rendered-buffer layout - (let* ((buffer (current-buffer)) - (target-id (ebox-test--selector-region-id buffer "#target")) - (target-box (gethash target-id ebox--region-box-table)) - (target-box-before (copy-tree target-box)) - (runtime-state (ebox--buffer-render-state buffer)) - (runtime-root-before - (copy-tree (plist-get runtime-state :root-node))) - (runtime-revision-before - (plist-get runtime-state :runtime-revision)) - (text-before (buffer-string))) - (should-not (ebox--scroll-get-state target-id)) - (ebox-test--set-buffer-update-report buffer report-before) - (cl-letf (((symbol-function 'ebox--schedule-buffer-runtime-prewarm) - (lambda (&rest _) - (error "Injected post-publication failure")))) - (should-error - (ebox-region-update target-id :overflow 'scroll) - :type 'error)) - (should-not (ebox--scroll-get-state target-id)) - (should (equal target-box target-box-before)) - (should (equal-including-properties (buffer-string) text-before)) - (should (eq (ebox--buffer-render-state buffer) runtime-state)) - (should (equal (plist-get runtime-state :root-node) - runtime-root-before)) - (should (equal (plist-get runtime-state :runtime-revision) - runtime-revision-before)) - (should (eq (ebox-buffer-update-report buffer) report-before)))))) - -(ert-deftest ebox-region-update-success-path-uses-local-transaction-snapshot () - "Successful local updates must not take full-buffer or global-table snapshots." - (ebox-test--reset-runtime-state) - (let* ((layout (ebox-build - '(box :id "target" :content "before" :width (120)))) - (buffer-string-calls 0) - (hash-key-calls 0) - (copy-hash-table-calls 0) - (original-buffer-string (symbol-function 'buffer-string)) - (original-hash-keys (symbol-function 'ebox-incremental--hash-keys)) - (original-copy-hash-table (symbol-function 'copy-hash-table))) - (ebox-test--with-rendered-buffer layout - (let ((region-id (ebox-test--selector-region-id - (current-buffer) "#target"))) - (cl-letf (((symbol-function 'buffer-string) - (lambda () - (cl-incf buffer-string-calls) - (funcall original-buffer-string))) - ((symbol-function 'ebox-incremental--hash-keys) - (lambda (&rest args) - (cl-incf hash-key-calls) - (apply original-hash-keys args))) - ((symbol-function 'copy-hash-table) - (lambda (&rest args) - (cl-incf copy-hash-table-calls) - (apply original-copy-hash-table args)))) - (ebox-region-update region-id :content "after")) - (should (string-match-p - "after" - (buffer-substring-no-properties (point-min) (point-max)))) - (should (= buffer-string-calls 0)) - (should (= hash-key-calls 0)) - (should (= copy-hash-table-calls 0)))))) - (ert-deftest ebox-cjk-wraps-by-character-width-in-word-mode () (ebox-test--reset-runtime-state) (let* ((content "你好世界") @@ -16795,154 +13261,12 @@ (should (= (ebox--scaled-string-pixel-width "probe" factor) 70))))) -(ert-deftest ebox-buffer-property-patches-split-disjoint-property-changes () - "Root publication should touch only disjoint property-aware changes." - (let* ((old (concat (propertize "a" 'face 'bold) "bcd")) - (new (concat (propertize "a" 'face 'italic) - "b" - (propertize "c" 'display '(space :width (12))) - "d")) - (reordered (propertize "x" 'alpha 1 'beta '(2))) - (same (propertize "x" 'beta '(2) 'alpha 1))) - (should - (equal (ebox-buffer--minimal-property-patches old new) - '((:old-start 0 :old-end 1 :new-start 0 :new-end 1) - (:old-start 2 :old-end 3 :new-start 2 :new-end 3)))) - (should-not (ebox-buffer--minimal-property-patches reordered same)))) -(ert-deftest ebox-buffer-property-patches-trim-length-change-boundaries () - "A length-changing publication should retain its exact common edges." - (should - (equal (ebox-buffer--minimal-property-patches "abXYcd" "abZcd") - '((:old-start 2 :old-end 4 :new-start 2 :new-end 3))))) -(ert-deftest ebox-buffer-property-patches-preserve-internal-common-runs () - "Text edits should not absorb unchanged runs inside a length change." - (should - (equal (ebox-buffer--minimal-property-patches - "abXcdYef" "abZcdWQef") - '((:old-start 2 :old-end 3 :new-start 2 :new-end 3) - (:old-start 5 :old-end 6 :new-start 5 :new-end 7))))) -(ert-deftest ebox-buffer-property-patches-align-equal-length-moves () - "Equal-length text shifts should retain aligned unchanged characters." - (should - (equal (ebox-buffer--minimal-property-patches "abc" "bca") - '((:old-start 0 :old-end 1 :new-start 0 :new-end 0) - (:old-start 3 :old-end 3 :new-start 2 :new-end 3))))) -(ert-deftest ebox-buffer-myers-runs-preserve-shortest-edit-distance () - "Compressed alignment runs should retain the exact Myers edit distance." - (cl-labels - ((strings-of-length - (length) - (if (zerop length) - '("") - (cl-loop for prefix in (strings-of-length (1- length)) - append (list (concat prefix "a") - (concat prefix "b"))))) - (reference-distance - (old new) - (let ((memo (make-hash-table :test 'equal))) - (cl-labels - ((visit - (old-position new-position) - (or (gethash (cons old-position new-position) memo) - (let ((distance - (cond - ((= old-position (length old)) - (- (length new) new-position)) - ((= new-position (length new)) - (- (length old) old-position)) - ((= (aref old old-position) - (aref new new-position)) - (visit (1+ old-position) (1+ new-position))) - (t - (1+ (min (visit (1+ old-position) new-position) - (visit old-position - (1+ new-position)))))))) - (puthash (cons old-position new-position) - distance memo) - distance)))) - (visit 0 0))))) - (let ((strings - (cl-loop for length from 0 to 4 - append (strings-of-length length)))) - (dolist (old strings) - (dolist (new strings) - (let ((old-position 0) - (new-position 0) - (edits 0)) - (dolist (run (ebox-buffer--myers-text-matches old new)) - (let ((run-old (nth 0 run)) - (run-new (nth 1 run)) - (run-length (nth 2 run))) - (cl-incf edits (- run-old old-position)) - (cl-incf edits (- run-new new-position)) - (should - (equal (substring old run-old (+ run-old run-length)) - (substring new run-new (+ run-new run-length)))) - (setq old-position (+ run-old run-length) - new-position (+ run-new run-length)))) - (cl-incf edits (- (length old) old-position)) - (cl-incf edits (- (length new) new-position)) - (should (= edits (reference-distance old new))))))))) -(ert-deftest ebox-buffer-root-property-patches-publish-only-changed-spans () - "Applying root patches should preserve exact output and report actual writes." - (with-temp-buffer - (let* ((old (concat (propertize "a" 'face 'bold) "bcd")) - (new (concat (propertize "a" 'face 'italic) - "b" - (propertize "c" 'display '(space :width (12))) - "d"))) - (insert old) - (let ((result - (ebox-buffer--replace-root-with-property-patches - '((1 . 5)) new))) - (should (equal-including-properties (buffer-string) new)) - (should (equal (plist-get result :published-buffer-spans) - '((1 . 2) (3 . 4)))) - (should-not (plist-get result :full-root-required-p)) - (should (equal (plist-get result :new-buffer-spans) - '((1 . 5)))))))) -(ert-deftest ebox-buffer-rust-root-patches-bypass-full-string-diff () - "Rust-origin patches should publish directly without a complete target string." - (with-temp-buffer - (let* ((old (concat (propertize "a" 'face 'bold) "bcd")) - (replacement-a (propertize "a" 'face 'italic)) - (replacement-c - (propertize "c" 'display '(space :width (12)))) - (native-patch - (list :native-patch t - :patch-origin 'rust - :base-character-count 4 - :target-character-count 4 - :target-line-count 1 - :patches - (list - (list :old-start 0 :old-end 1 - :new-start 0 :new-end 1 - :replacement replacement-a) - (list :old-start 2 :old-end 3 - :new-start 2 :new-end 3 - :replacement replacement-c))))) - (insert old) - (cl-letf (((symbol-function 'ebox-buffer--minimal-property-patches) - (lambda (&rest _) - (ert-fail "Rust patch path rebuilt the complete diff")))) - (let ((result - (ebox-buffer--apply-root-property-patches - '((1 . 5)) native-patch))) - (should - (equal-including-properties - (buffer-string) - (concat replacement-a "b" replacement-c "d"))) - (should (equal (plist-get result :published-buffer-spans) - '((1 . 2) (3 . 4)))) - (should (eq (plist-get result :patch-origin) 'rust)) - (should (= (plist-get result :full-frame-bytes) 0))))))) (ert-deftest ebox-visibility-hidden-suppresses-ink-and-keeps-footprint () "A hidden box renders blank with identical text, lengths, and metadata." @@ -17041,7 +13365,7 @@ face patch must match that exactly (issue014)." (when (get-text-property position role) (push (list (line-number-at-pos position) (get-text-property position role) - (ebox--face-visual-attributes + (ebox-test--face-visual-attributes (get-text-property position 'face))) runs)) (setq position diff --git a/tests/ebox-selector-tests.el b/tests/ebox-selector-tests.el index 9e82197..f6bbd46 100644 --- a/tests/ebox-selector-tests.el +++ b/tests/ebox-selector-tests.el @@ -14,7 +14,6 @@ (setq ebox--runtime-node-id-counter 0) (setq ebox--render-region-id nil) (dolist (table (list ebox--region-box-table - ebox--box-extents ebox--buffer-render-state-table ebox--scroll-global-state)) (clrhash table))) @@ -296,11 +295,14 @@ other-node)) (let ((calls nil)) (cl-letf (((symbol-function 'ebox-region-update) - (lambda (region-id &rest _props) - (let ((buffer ebox--region-update-buffer-hint)) - (push (cons region-id buffer) calls) + (lambda (region-handle &rest _props) + (should (ebox-region-handle-p region-handle)) + (let ((buffer + (car (ebox-selector--region-target + region-handle)))) + (push (cons region-handle buffer) calls) (list :buffer buffer - :region-id region-id + :region-handle region-handle :strategy 'stub-direct))))) (ebox-selector-update-buffer other-buffer "#other-card" :border-color "#0f766e") @@ -330,11 +332,15 @@ node)) (let* ((match (car (ebox-selector-query-buffer buffer "#card"))) (region-id (plist-get match :region-id)) + (region-handle (plist-get match :region-handle)) (poisoned-entry (list :poisoned-region region-id))) (puthash region-id poisoned-entry ebox--region-box-table) (cl-letf (((symbol-function 'ebox-region-update) - (lambda (called-region-id &rest _props) - (should (eq called-region-id region-id)) + (lambda (called-handle &rest _props) + (should (ebox-region-handle-p called-handle)) + (should (equal + (ebox-selector--region-target called-handle) + (ebox-selector--region-target region-handle))) (should (eq (gethash region-id ebox--region-box-table) poisoned-entry)) (list :strategy 'stub-direct)))) @@ -363,8 +369,9 @@ :patch-ops '(owner-rerender) :batched-count 2))) (cl-letf (((symbol-function 'ebox-region-update) - (lambda (region-id &rest _props) - (push (list :region-id region-id + (lambda (region-handle &rest _props) + (should (ebox-region-handle-p region-handle)) + (push (list :region-handle region-handle :strategy 'wrong-direct) direct-reports))) ((symbol-function 'ebox-incremental-flush) @@ -380,5 +387,33 @@ (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) +(ert-deftest ebox-selector-multi-match-publishes-one-scoped-generation () + "Multi-match updates should publish once through a scoped TP transaction." + (ebox-selector-test--reset-runtime-state) + (let* ((node (ebox-build + '(column + (box :class card :content "One") + (box :class card :content "Two")))) + buffer) + (unwind-protect + (progn + (setq buffer (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-selector-test*") + node)) + (let* ((surface (with-current-buffer + buffer ebox-surface--buffer-surface)) + (revision (tp-surface-revision surface)) + (result + (ebox-selector-update-buffer + buffer ".card" :border-color "#ef4444")) + (report (car (plist-get result :reports)))) + (should (= (tp-surface-revision surface) (1+ revision))) + (should-not (plist-get (tp-surface-report surface) :full-root)) + (should (= (plist-get report :flush-count) 1)) + (should (= (plist-get report :batched-count) 2)) + (should (equal report (ebox-buffer-update-report buffer))))) + (when (and buffer (buffer-live-p buffer)) + (kill-buffer buffer))))) + (provide 'ebox-selector-tests) ;;; ebox-selector-tests.el ends here diff --git a/tests/ebox-surface-tests.el b/tests/ebox-surface-tests.el index 7a7a3f0..ce27356 100644 --- a/tests/ebox-surface-tests.el +++ b/tests/ebox-surface-tests.el @@ -17,8 +17,7 @@ (setq ebox--region-id-counter 0 ebox--runtime-node-id-counter 0) (dolist (table (list ebox--region-box-table - ebox--scroll-global-state - ebox--rendered-root-metadata-table)) + ebox--scroll-global-state)) (clrhash table))) (defun ebox-surface-test--interactive-content () @@ -334,6 +333,179 @@ (kill-buffer buffer))) (should-not (tp-surface-live-p surface)))) +(ert-deftest ebox-viewport-rerender-publishes-only-through-tp () + "A mounted viewport update should advance its TP surface." + (ebox-surface-test--reset-render-state) + (let ((buffer (generate-new-buffer " *ebox-surface-viewport*"))) + (unwind-protect + (let ((ebox-viewport-width 120) + (ebox-viewport-height 4) + (ebox-runtime-idle-prewarm nil) + (ebox-runtime-idle-reflow-cache-prewarm nil)) + (ebox-render-to-buffer + buffer + (ebox-create :content "Viewport" :width '(viewport))) + (let* ((surface + (with-current-buffer buffer ebox-surface--buffer-surface)) + (signals + (with-current-buffer buffer + ebox-surface--context-signals)) + (revision (tp-surface-revision surface))) + (should (= (tp-signal-peek + (ebox-surface--signals-viewport-width signals)) + 120)) + (ebox-rerender-buffer-with-context buffer 180 4) + (should (= (tp-surface-revision surface) (1+ revision))) + (should (= (tp-signal-peek + (ebox-surface--signals-viewport-width signals)) + 180)) + (should (eq (ebox--buffer-render-state buffer) + (tp-surface-client-state surface))) + (should (= (with-current-buffer buffer + (ebox--string-pixel-width + (buffer-substring (line-beginning-position) + (line-end-position)))) + 180)) + (let ((report (ebox-buffer-update-report buffer))) + (should (eq (plist-get report :constraint-source) 'viewport)) + (should (plist-get report :runtime-published))))) + (when (buffer-live-p buffer) + (kill-buffer buffer))))) + +(ert-deftest ebox-surface-context-signals-track-exact-layout-dependencies () + "A mounted producer should subscribe only to context it can consume." + (ebox-surface-test--reset-render-state) + (let ((responsive (generate-new-buffer " *ebox-responsive-signals*")) + (static (generate-new-buffer " *ebox-static-signals*")) + responsive-signals static-signals) + (unwind-protect + (let ((ebox-viewport-width 160) + (ebox-viewport-height 2)) + (ebox-render-to-buffer + responsive + (ebox-create :content "zero\none\ntwo\nthree" + :width '(viewport) + :height '(viewport-height) + :overflow 'scroll)) + (setq responsive-signals + (with-current-buffer responsive + ebox-surface--context-signals)) + (dolist (signal + (list + (ebox-surface--signals-viewport-width + responsive-signals) + (ebox-surface--signals-viewport-height + responsive-signals) + (ebox-surface--signals-display responsive-signals) + (ebox-surface--signals-scroll responsive-signals))) + (should (tp-signal-live-p signal)) + (should (= (tp-signal-subscriber-count signal) 1))) + (ebox-render-to-buffer + static + (ebox-create :content "Static" :width '(100))) + (setq static-signals + (with-current-buffer static ebox-surface--context-signals)) + (should (= (tp-signal-subscriber-count + (ebox-surface--signals-viewport-width static-signals)) + 0)) + (should (= (tp-signal-subscriber-count + (ebox-surface--signals-viewport-height static-signals)) + 0)) + (should (= (tp-signal-subscriber-count + (ebox-surface--signals-display static-signals)) + 1)) + (should (= (tp-signal-subscriber-count + (ebox-surface--signals-scroll static-signals)) + 0))) + (dolist (buffer (list responsive static)) + (when (buffer-live-p buffer) + (kill-buffer buffer)))) + (dolist (signals (list responsive-signals static-signals)) + (when signals + (dolist (signal + (list (ebox-surface--signals-viewport-width signals) + (ebox-surface--signals-viewport-height signals) + (ebox-surface--signals-display signals) + (ebox-surface--signals-scroll signals))) + (should-not (tp-signal-live-p signal))))))) + +(ert-deftest ebox-static-viewport-change-updates-context-without-rendering () + "Unused viewport state should change without publishing the surface." + (ebox-surface-test--reset-render-state) + (let ((buffer (generate-new-buffer " *ebox-static-viewport*"))) + (unwind-protect + (let ((ebox-viewport-width 120) + (ebox-viewport-height 4) + (ebox-runtime-idle-prewarm nil) + (ebox-runtime-idle-reflow-cache-prewarm nil)) + (ebox-render-to-buffer + buffer + (ebox-create :content "Static" :width '(100))) + (let* ((surface + (with-current-buffer buffer ebox-surface--buffer-surface)) + (signals + (with-current-buffer buffer + ebox-surface--context-signals)) + (revision (tp-surface-revision surface)) + (renders 0) + (original + (symbol-function 'ebox-surface--render-candidate))) + (cl-letf (((symbol-function 'ebox-surface--render-candidate) + (lambda (state) + (cl-incf renders) + (funcall original state)))) + (ebox-rerender-buffer-with-context buffer 180 4)) + (should (= renders 0)) + (should (= (tp-surface-revision surface) revision)) + (should (= (plist-get (tp-surface-client-state surface) + :viewport-width) + 180)) + (should (= (tp-signal-peek + (ebox-surface--signals-viewport-width signals)) + 180)) + (should (eq (plist-get (ebox-buffer-update-report buffer) + :strategy) + 'no-op)))) + (when (buffer-live-p buffer) + (kill-buffer buffer))))) + +(ert-deftest ebox-theme-change-rerenders-with-an-unchanged-viewport () + "A changed display signature should invalidate a fixed-width surface." + (ebox-surface-test--reset-render-state) + (let ((buffer (generate-new-buffer " *ebox-theme-context*"))) + (unwind-protect + (let ((ebox-viewport-width 120) + (ebox-viewport-height 4) + (ebox-runtime-idle-prewarm nil) + (ebox-runtime-idle-reflow-cache-prewarm nil)) + (ebox-render-to-buffer + buffer + (ebox-create :content "Theme" :width '(100))) + (let* ((surface + (with-current-buffer buffer ebox-surface--buffer-surface)) + (signals + (with-current-buffer buffer + ebox-surface--context-signals)) + (revision (tp-surface-revision surface)) + (next-signature '(ebox-test-theme dark))) + (cl-letf (((symbol-function 'ebox--display-signature) + (lambda () next-signature))) + (ebox-rerender-buffer-with-context buffer 120 4)) + (should (= (tp-surface-revision surface) (1+ revision))) + (should (equal + (tp-signal-peek + (ebox-surface--signals-display signals)) + next-signature)) + (should (equal (plist-get (tp-surface-client-state surface) + :display-signature) + next-signature)) + (let ((report (ebox-buffer-update-report buffer))) + (should (plist-get report :host-context-changed)) + (should (memq :display-signature + (plist-get report :dirty-keys)))))) + (when (buffer-live-p buffer) + (kill-buffer buffer))))) + (ert-deftest ebox-render-to-buffer-reuses-one-source-across-buffers () "The public mount path should never transfer ownership of its source tree." (ebox-surface-test--reset-render-state) @@ -364,7 +536,7 @@ (kill-buffer buffer)))))) (ert-deftest ebox-commit-publishes-through-the-mounted-tp-surface () - "Declarative commits should bypass every legacy Ebox buffer publisher." + "Declarative commits should publish through the mounted TP surface." (ebox-surface-test--reset-render-state) (let ((buffer (generate-new-buffer " *ebox-surface-commit*"))) (unwind-protect @@ -376,14 +548,11 @@ buffer ebox-surface--buffer-surface)) (revision (tp-surface-revision surface)) report) - (cl-letf (((symbol-function 'ebox-incremental-commit) - (lambda (&rest _) - (error "Legacy Ebox publisher was called")))) - (setq report - (ebox-commit - buffer - (ebox-create :key 'root :content "After" - :width '(100))))) + (setq report + (ebox-commit + buffer + (ebox-create :key 'root :content "After" + :width '(100)))) (should (= (tp-surface-revision surface) (1+ revision))) (should (plist-get report :runtime-published)) (should (equal report (ebox-buffer-update-report buffer))) @@ -423,6 +592,48 @@ (when (buffer-live-p buffer) (kill-buffer buffer))))) +(ert-deftest ebox-context-signal-rolls-back-with-failed-publication () + "A failed Ebox publication should restore its TP context source." + (ebox-surface-test--reset-render-state) + (let ((buffer (generate-new-buffer " *ebox-context-rollback*"))) + (unwind-protect + (let ((ebox-viewport-width 120) + (ebox-viewport-height 4)) + (ebox-render-to-buffer + buffer + (ebox-create :key 'root :content "Stable" + :width '(viewport))) + (let* ((surface + (with-current-buffer buffer ebox-surface--buffer-surface)) + (signals + (with-current-buffer buffer + ebox-surface--context-signals)) + (width-signal + (ebox-surface--signals-viewport-width signals)) + (state (tp-surface-client-state surface)) + (revision (tp-surface-revision surface)) + (contents + (with-current-buffer buffer + (buffer-substring (point-min) (point-max))))) + (should-error + (ebox-surface-mount-buffer + buffer + (ebox-create :key 'root :content "Rejected" + :width '(viewport)) + (ebox--update-report nil 'root-rerender) + (lambda (_report) (error "Reject context publication")) + nil + '(:viewport-width 180 :viewport-height 4))) + (should (= (tp-signal-peek width-signal) 120)) + (should (= (tp-surface-revision surface) revision)) + (should (eq (tp-surface-client-state surface) state)) + (should (equal-including-properties + (with-current-buffer buffer + (buffer-substring (point-min) (point-max))) + contents)))) + (when (buffer-live-p buffer) + (kill-buffer buffer))))) + (ert-deftest ebox-commit-killed-buffer-rollback-does-not-revive-runtime () "A failed commit must not restore runtime state for a killed buffer." (ebox-surface-test--reset-render-state) @@ -496,6 +707,55 @@ (when (buffer-live-p buffer) (kill-buffer buffer)))))) +(ert-deftest ebox-region-handle-update-uses-scoped-tp-publication () + "A handle update should publish a scoped TP plan." + (ebox-surface-test--reset-render-state) + (let ((buffer (generate-new-buffer " *ebox-scoped-handle*"))) + (unwind-protect + (progn + (ebox-render-to-buffer + buffer + (ebox-column + (ebox-create :id "left" :content "Left" :width '(100)) + (ebox-create :id "right" :content "Right" :width '(100)))) + (let* ((surface (with-current-buffer + buffer ebox-surface--buffer-surface)) + (revision (tp-surface-revision surface)) + (handle (ebox-region-resolve buffer "left")) + report) + (setq report + (ebox-region-update handle :content "Changed")) + (should (= (tp-surface-revision surface) (1+ revision))) + (should-not (plist-get (tp-surface-report surface) :full-root)) + (should (> (plist-get (tp-surface-report surface) :scope-count) 0)) + (should (eq (plist-get report :tp-render-scope) 'objects)) + (should (string-match-p + "Changed" + (with-current-buffer buffer (buffer-string)))))) + (when (buffer-live-p buffer) + (kill-buffer buffer))))) + +(ert-deftest ebox-region-update-rejects-process-global-region-ids () + "Direct updates should require a surface-scoped region handle." + (ebox-surface-test--reset-render-state) + (let ((buffer (generate-new-buffer " *ebox-handle-only-update*"))) + (unwind-protect + (progn + (ebox-render-to-buffer + buffer + (ebox-create :id "target" :content "Before" :width '(100))) + (let* ((match (car (ebox-selector-query-buffer buffer "#target"))) + (region-id (plist-get match :region-id))) + (should-error (ebox-region-update region-id :content "Wrong") + :type 'wrong-type-argument) + (ebox-region-update (plist-get match :region-handle) + :content "After") + (should (string-match-p + "After" + (with-current-buffer buffer (buffer-string)))))) + (when (buffer-live-p buffer) + (kill-buffer buffer))))) + (ert-deftest ebox-selector-query-returns-an-editable-region-handle () "A live selector match should carry the same handle accepted by updates." (ebox-surface-test--reset-render-state) @@ -535,6 +795,91 @@ (when (buffer-live-p buffer) (kill-buffer buffer))))) +(ert-deftest ebox-scroll-update-publishes-only-through-tp () + "A mounted scroll update should advance one TP surface revision." + (ebox-surface-test--reset-render-state) + (let ((buffer (generate-new-buffer " *ebox-surface-scroll*"))) + (unwind-protect + (let ((ebox-viewport-width 240) + (ebox-viewport-height 2)) + (ebox-render-to-buffer + buffer + (ebox-create :id "scroll" :content "zero\none\ntwo\nthree" + :width '(120) :height 2 :overflow 'scroll)) + (let* ((surface (with-current-buffer + buffer ebox-surface--buffer-surface)) + (signals + (with-current-buffer buffer + ebox-surface--context-signals)) + (revision (tp-surface-revision surface)) + (region-id + (plist-get + (car (ebox-selector-query-buffer buffer "#scroll")) + :region-id))) + (should (= (ebox--scroll-region-by region-id 1 1) 1)) + (should (= (tp-surface-revision surface) (1+ revision))) + (should (= (alist-get + region-id + (tp-signal-peek + (ebox-surface--signals-scroll signals))) + 1)) + (let* ((state (tp-surface-client-state surface)) + (root (plist-get state :root-node)) + (text (with-current-buffer buffer + (buffer-substring-no-properties + (point-min) (point-max))))) + (should (= (ebox-get (ebox--root-region-box root region-id) + :scroll-offset) + 1)) + (should (= (plist-get (ebox-scroll-state region-id) + :scroll-offset) + 1)) + (should (string-match-p "one" text)) + (should (string-match-p "two" text)) + (should-not (string-match-p "zero" text))))) + (when (buffer-live-p buffer) + (kill-buffer buffer))))) + +(ert-deftest ebox-scroll-update-rejects-a-runtime-replaced-by-its-hook () + "A stale scroll candidate must not overwrite a hook publication." + (ebox-surface-test--reset-render-state) + (let ((buffer (generate-new-buffer " *ebox-scroll-hook-race*"))) + (unwind-protect + (let ((ebox-viewport-width 240) + (ebox-viewport-height 2)) + (ebox-render-to-buffer + buffer + (ebox-create :id "scroll" :content "zero\none\ntwo\nthree" + :width '(120) :height 2 :overflow 'scroll)) + (let* ((surface (with-current-buffer + buffer ebox-surface--buffer-surface)) + (revision (tp-surface-revision surface)) + (handle (ebox-region-resolve buffer "scroll")) + (region-id + (plist-get + (car (ebox-selector-query-buffer buffer "#scroll")) + :region-id)) + (ebox-incremental-before-runtime-mutation-hook + (list + (lambda (target kind) + (when (and (eq target buffer) (eq kind 'scroll)) + (let ((ebox-incremental--runtime-mutation-hooks-inhibited-p + t)) + (ebox-region-update handle :color "#2563EB"))))))) + (should-error (ebox--scroll-region-by region-id 1 1)) + (should (= (tp-surface-revision surface) (1+ revision))) + (should (= (plist-get (ebox-scroll-state region-id) :scroll-offset) + 0)) + (should (equal + (ebox-get + (ebox--root-region-box + (plist-get (tp-surface-client-state surface) :root-node) + region-id) + :color) + "#2563EB")))) + (when (buffer-live-p buffer) + (kill-buffer buffer))))) + (provide 'ebox-surface-tests) ;;; ebox-surface-tests.el ends here