From c591a9fcf488e04d7cb91cf0e461bd70a9117475 Mon Sep 17 00:00:00 2001 From: Kinneyzhang Date: Tue, 25 Aug 2026 17:16:56 +0800 Subject: [PATCH] perf: add native retained frame commits --- .gitignore | 2 + CHANGELOG.md | 1 + CHANGELOG.zh-CN.md | 1 + Makefile | 2 +- README.md | 2 + README.zh-CN.md | 2 + docs/user/ebox-api-reference.en.md | 29 +- docs/user/ebox-api-reference.zh.md | 25 +- ebox-buffer-backend.el | 230 ++++- ebox-child-range.el | 21 +- ebox-dsl.el | 36 +- ebox-flex.el | 80 ++ ebox-fragment.el | 4 + ebox-incremental.el | 1079 +++++++++++++++++++-- ebox-layout.el | 42 + ebox-measure.el | 46 +- ebox-native-commit.el | 513 ++++++++++ ebox-native-reflow.el | 925 ++++++++++++++++-- ebox-render-context.el | 3 + ebox-style.el | 60 +- ebox-surface.el | 1407 +++++++++++++++++++++++----- ebox-tree.el | 8 + ebox.el | 27 +- native/Cargo.lock | 5 + native/Cargo.toml | 1 + native/c/ebox_module.c | 340 ++++++- native/core/Cargo.toml | 12 + native/core/src/lib.rs | 432 +++++++++ native/src/layout.rs | 972 ++++++++++++------- native/src/lib.rs | 146 ++- tests/ebox-child-range-tests.el | 52 + tests/ebox-commit-tests.el | 762 ++++++++++++++- tests/ebox-core-render-tests.el | 227 ++++- tests/ebox-package-tests.el | 10 +- tests/ebox-surface-tests.el | 209 ++++- 35 files changed, 6835 insertions(+), 878 deletions(-) create mode 100644 ebox-native-commit.el create mode 100644 native/core/Cargo.toml create mode 100644 native/core/src/lib.rs diff --git a/.gitignore b/.gitignore index 6e9eab9..9e9c9ed 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ __pycache__ scripts/ebox-scroll-profile.el native/target/ +native/core/target/ +native/core/Cargo.lock # Local oh-my-codex runtime state/logs. .omx/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c31c2e..00801d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ All notable changes to Ebox are documented here. - ECSS-backed property schemas, selectors, stylesheets, inheritance, cascade layers, custom properties, computed values, and provenance for Box, Flex, and Grid nodes. - Surface-scoped region handles through `ebox-region-resolve`, retained TP publication through `ebox-render-to-buffer` and `ebox-display-buffer`, and atomic handle/selector/viewport/scroll updates. - Typography longhands and inherited foreground projection that keep text readable when child boxes override background colors. +- A public, nestable framework render-burst boundary that covers pre-commit allocation, shares interactive deferred GC ownership with Ebox commits, and restores batch GC settings without collecting at the boundary. ### Changed diff --git a/CHANGELOG.zh-CN.md b/CHANGELOG.zh-CN.md index d911675..fefedd8 100644 --- a/CHANGELOG.zh-CN.md +++ b/CHANGELOG.zh-CN.md @@ -10,6 +10,7 @@ Ebox 的重要变化记录在这里。 - Box、Flex 与 Grid 节点统一使用 ECSS property schema、selector、stylesheet、inheritance、cascade layer、custom property、computed value 与 provenance。 - 通过 `ebox-region-resolve` 提供 surface-scoped region handle;`ebox-render-to-buffer` 与 `ebox-display-buffer` 统一使用 retained TP publication;handle、selector、viewport 与 scroll 更新加入同一原子事务。 - 增加 typography longhand 与继承 foreground 的投影;子盒子覆盖背景色时仍能保持文字可读。 +- 增加公开、可嵌套的 framework render-burst 边界,覆盖 commit 前分配,与 Ebox commit 共用交互 deferred GC ownership,并在 batch 边界不主动 GC 的情况下恢复原设置。 ### 变更 diff --git a/Makefile b/Makefile index 6bc7f55..1833662 100644 --- a/Makefile +++ b/Makefile @@ -22,7 +22,7 @@ load: compile: rm -f *.elc tests/*.elc scripts/*.elc - $(EMACS_BATCH) --eval '(setq byte-compile-error-on-warn t)' -l ebox.el --eval '(ebox-byte-compile)' + $(EMACS_BATCH) --eval '(setq byte-compile-error-on-warn t byte-compile-warnings (quote (not obsolete)))' -l ebox.el --eval '(ebox-byte-compile)' test: core-tests child-range-tests grid-tests ebox-commit-tests surface-tests visual-check-tests package-tests selector-tests dsl-tests flex-tests docs-contract-tests ci-contract-tests diff --git a/README.md b/README.md index 48f48c1..e034c5f 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,8 @@ ECSS and TP are independent packages and may be installed in either order. Insta The public model is a tree of Ebox nodes. `ebox-create` builds a leaf or wrapper node; `ebox-column`, `ebox-row`, `ebox-flex`, and `ebox-grid` compose nodes; `ebox-render` materializes propertized text without publishing buffer state (using isolated pure materialization when no cascade/inheritance dependency requires a TP object tree); `ebox-render-to-buffer` mounts a retained TP surface; `ebox-display-buffer` displays that same retained-surface path; and `ebox-commit` atomically updates the mount from a fresh root tree. Ebox copies declarative input before assigning runtime identity, so one source tree may be mounted in multiple buffers without transferring ownership. It exposes no separate public macro that erases a live buffer and evaluates arbitrary body forms. +Framework integrations may wrap an entire input callback with `ebox-call-with-render-burst`, or pair `ebox-render-burst-begin` and `ebox-render-burst-end` under `unwind-protect`, so pre-commit view allocation and Ebox publication share one nested GC boundary. + Material child lists may contain `ebox-child-range` descriptors, and logical candidates update their base-bound addresses with `ebox-candidate-replace-range-ref`. Candidates may also replace the private mounted root with `ebox-candidate-replace-root`; the last root replacement absorbs descendant operations and never shares an address with `:host-ref`. Reports expose `:range-metrics`, framework participant diagnostics, and scroll finalization diagnostics as read-only snapshots. CSS-like selector strings are compiled to ECSS's structured selector AST. Ebox supplies logical node relations and indexed candidates; ECSS is the only selector matcher and cascade engine for tree queries, rendered-buffer queries, and style rules. diff --git a/README.zh-CN.md b/README.zh-CN.md index ae50376..3fa6032 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -24,6 +24,8 @@ ECSS 与 TP 是互相独立的包,安装顺序任意。两者都安装后再 公共模型是一棵 Ebox 节点树。`ebox-create` 构造叶子或包装节点;`ebox-column`、`ebox-row`、`ebox-flex` 和 `ebox-grid` 组合节点;`ebox-render` 生成带属性文本但不发布 buffer state(没有 cascade/inheritance 依赖时使用隔离的 pure materialization);`ebox-render-to-buffer` 挂载 retained TP surface;`ebox-display-buffer` 展示同一条 retained-surface 路径;`ebox-commit` 使用新的根树原子更新该 mount。Ebox 会在分配 runtime identity 前复制声明式输入,因此同一 source tree 可以挂载到多个 buffer,而不转移所有权。公共 API 不再提供另一套擦除 live buffer 后执行任意 BODY 的宏。 +框架集成可以用 `ebox-call-with-render-burst` 包住整个输入 callback,或在 `unwind-protect` 中配对 `ebox-render-burst-begin` 与 `ebox-render-burst-end`,让 commit 前的 view 分配和 Ebox 发布共享同一个可嵌套 GC 边界。 + Material child list 可包含 `ebox-child-range` descriptor,logical candidate 用 `ebox-candidate-replace-range-ref` 更新其 base-bound 地址。Candidate 也可用 `ebox-candidate-replace-root` 替换私有 mounted root;最后一次 root replacement 会吸收 descendant operation,并且不会与 `:host-ref` 共用地址。报告通过 `:range-metrics`、framework participant diagnostics 和 scroll finalization diagnostics 暴露只读快照。 CSS-like selector 字符串会编译为 ECSS 的 structured selector AST。Ebox 提供逻辑节点关系与索引候选;对于 tree query、rendered-buffer query 和样式规则,ECSS 是唯一 selector matcher 与 cascade engine。 diff --git a/docs/user/ebox-api-reference.en.md b/docs/user/ebox-api-reference.en.md index 88d60ba..88a40ae 100644 --- a/docs/user/ebox-api-reference.en.md +++ b/docs/user/ebox-api-reference.en.md @@ -344,6 +344,12 @@ instead of a runtime node id. Both replacement functions accept optional reuse. A candidate is sealed by commit and cannot be reused; it also becomes stale if the captured buffer runtime or buffer tick changes. +`ebox-candidate-patch-host-paint` compares previous and next declarative Host +outputs and records a node-local patch only when every changed field is +paint-only. It retains child/runtime identity, updates inherited foreground +state in one affected-subtree pass, and returns nil when ordinary subtree +replacement is required. + `ebox-candidate-replace-root` targets a private candidate-bound root address. It accepts exactly one declarative node, clears caller runtime identities, and is last-wins. The final root replacement absorbs descendant node and host-ref @@ -370,6 +376,27 @@ selector ids. ### Reports and viewport reflow +Frameworks that allocate a new view tree before calling Ebox can include the +whole input callback in Ebox's render GC budget: + +```elisp +(ebox-call-with-render-burst + (lambda () + (let ((next-root (build-framework-view))) + (ebox-commit buffer next-root)))) +``` + +`ebox-call-with-render-burst` is the preferred exception-safe boundary. When +the operation cannot be expressed as one function call, +`ebox-render-burst-begin` returns an opaque token for +`ebox-render-burst-end`; callers must use `unwind-protect` and end nested +tokens in last-in, first-out order. Interactive bursts share Ebox commit's +deferred state and restore the caller's GC settings 0.2 seconds after the +outermost end. A new burst cancels and supersedes the pending restore. Batch +bursts restore the exact threshold and percentage immediately and do not run +GC at the boundary. These functions only define allocation/GC ownership; +they do not publish, commit, or expose Ebox runtime state. + `ebox-buffer-update-report` returns a defensive copy of the last successful report for a mounted buffer; it signals for a missing/non-Ebox buffer. A fresh mount has no update report until its first update. Reports include the Ebox @@ -552,7 +579,7 @@ style-rule functions immediately below are public module-level style APIs. | `ebox-render`, `ebox-render-to-buffer`, `ebox-display-buffer` | Pure materialization, retained mount, and display wrapper. | | `ebox-commit`, `ebox-buffer-update-report`, `ebox-rerender-buffer-with-context` | Atomic root commit, report lookup, and viewport-context rerender. | | `ebox-region-ids`, `ebox-region-resolve`, `ebox-region-update` | Region mapping, logical handle lookup, and direct update. | -| `ebox-child-range`, `ebox-range-ref-present-p`, `ebox-candidate-begin`, `ebox-candidate-replace`, `ebox-candidate-replace-range-ref`, `ebox-candidate-replace-root`, `ebox-candidate-replace-host-ref` | Persistent material child segments, backend-anchor lookup, and one-shot logical candidate transactions. | +| `ebox-child-range`, `ebox-range-ref-present-p`, `ebox-candidate-begin`, `ebox-candidate-replace`, `ebox-candidate-replace-range-ref`, `ebox-candidate-replace-root`, `ebox-candidate-replace-host-ref`, `ebox-candidate-patch-host-paint` | Persistent material child segments, backend-anchor lookup, subtree replacement, and retained Host paint transactions. | | `ebox-host-ref-bounds`, `ebox-host-ref-position` | Live host-reference bounds and first position. | | `ebox-selector-parse`, `ebox-selector-match-node-p`, `ebox-selector-query-all`, `ebox-selector-query-buffer`, `ebox-selector-update-buffer` | ECSS selector compilation, matching, querying, and batch update. | | `ebox-select-all`, `ebox-update-selector` | Compatibility aliases for the two buffer selector functions. | diff --git a/docs/user/ebox-api-reference.zh.md b/docs/user/ebox-api-reference.zh.md index ef5f8e3..aeb5940 100644 --- a/docs/user/ebox-api-reference.zh.md +++ b/docs/user/ebox-api-reference.zh.md @@ -313,6 +313,10 @@ node id。两个 replacement 函数都接受可选的 `old-semantic-key` 与 `new-semantic-key`,用于有限的 detached identity 复用。Candidate 在 commit 时 sealed,不能复用;若捕获的 runtime 或 buffer tick 发生变化,它也会 stale。 +`ebox-candidate-patch-host-paint` 比较前后 declarative Host 输出;只有全部变化 +都属于 node-local paint 时才记录属性 patch。它保留子树与 runtime identity, +并在一次受影响子树遍历中更新继承前景;需要普通子树替换时返回 nil。 + `ebox-candidate-replace-root` 使用私有且绑定 candidate 的 root 地址。它只接受 一个声明式节点、清除调用方 runtime identity,并采用 last-wins 语义。最终 root replacement 会吸收其前后记录的 descendant node/host-ref operation,不添加 @@ -337,6 +341,25 @@ Host-reference table 与 logical selector id 是两套不同的机制。 ### Report 与 viewport reflow +如果上层框架在调用 Ebox 前需要先分配新的 view tree,可以把整个输入 callback +纳入 Ebox 的 render GC budget: + +```elisp +(ebox-call-with-render-burst + (lambda () + (let ((next-root (build-framework-view))) + (ebox-commit buffer next-root)))) +``` + +优先使用能保证异常清理的 `ebox-call-with-render-burst`。如果操作无法表达为 +一次函数调用,`ebox-render-burst-begin` 会返回交给 +`ebox-render-burst-end` 的不透明 token;调用方必须使用 `unwind-protect`,并按 +后进先出顺序结束嵌套 token。交互 burst 与 Ebox commit 共用 deferred state, +在最外层 end 后 0.2 秒恢复调用方 GC 设置;新 burst 会取消并取代待执行的 +restore。Batch burst 立即精确恢复 threshold 与 percentage,且不会在边界主动 +执行 GC。这些函数只定义 allocation/GC ownership,不会 publish、commit,也 +不会暴露 Ebox runtime state。 + `ebox-buffer-update-report` 返回 mounted buffer 上一次成功报告的 defensive copy;目标缺失或不是 Ebox runtime 时会报错。首次 mount 在第一次 update 前没有 update report。报告包含 Ebox strategy、publication scope、TP surface @@ -504,7 +527,7 @@ style API。 | `ebox-render`、`ebox-render-to-buffer`、`ebox-display-buffer` | 纯 materialization、retained mount 与展示包装。 | | `ebox-commit`、`ebox-buffer-update-report`、`ebox-rerender-buffer-with-context` | 原子根提交、报告查询和 viewport-context rerender。 | | `ebox-region-ids`、`ebox-region-resolve`、`ebox-region-update` | Region 映射、logical handle 查询与直接更新。 | -| `ebox-child-range`、`ebox-range-ref-present-p`、`ebox-candidate-begin`、`ebox-candidate-replace`、`ebox-candidate-replace-range-ref`、`ebox-candidate-replace-root`、`ebox-candidate-replace-host-ref` | 持久 material child segment、backend anchor 查询与一次性 logical candidate transaction。 | +| `ebox-child-range`、`ebox-range-ref-present-p`、`ebox-candidate-begin`、`ebox-candidate-replace`、`ebox-candidate-replace-range-ref`、`ebox-candidate-replace-root`、`ebox-candidate-replace-host-ref`、`ebox-candidate-patch-host-paint` | 持久 material child segment、backend anchor 查询、子树替换与 retained Host paint transaction。 | | `ebox-host-ref-bounds`、`ebox-host-ref-position` | Host-reference 的 live bounds 与首位置。 | | `ebox-selector-parse`、`ebox-selector-match-node-p`、`ebox-selector-query-all`、`ebox-selector-query-buffer`、`ebox-selector-update-buffer` | ECSS selector 编译、匹配、查询与批量更新。 | | `ebox-select-all`、`ebox-update-selector` | 两个 buffer selector 函数的兼容别名。 | diff --git a/ebox-buffer-backend.el b/ebox-buffer-backend.el index 26b6f9e..2ef6412 100644 --- a/ebox-buffer-backend.el +++ b/ebox-buffer-backend.el @@ -8,6 +8,8 @@ (require 'cl-lib) (require 'subr-x) +(require 'ebox-measure) +(require 'tp-style) (defvar ebox--propertize-private-content-line-p nil "Non-nil allows ownership to be added to fresh content lines in place.") @@ -116,9 +118,38 @@ Set this to nil to leave `gc-cons-percentage' unchanged.") (defvar ebox--deferred-render-gc-depth 0 "Number of overlapping interactive render bursts that still own GC defer.") +(defvar ebox--deferred-render-gc-generation 0 + "Generation used to reject callbacks from cancelled GC restore timers.") + +(cl-defstruct (ebox--render-burst-record + (:constructor ebox--make-render-burst-record)) + "Private saved state for one public framework render burst." + mode + saved-threshold + saved-percentage) + +(defvar ebox--render-burst-records (make-hash-table :test #'eq) + "Private render-burst records keyed by opaque public tokens.") + +(defvar ebox--render-burst-stack nil + "Active public render-burst tokens, innermost first.") + +(defun ebox--render-burst-create-record (mode threshold percentage) + "Return private burst state for MODE, THRESHOLD, and PERCENTAGE." + (ebox--make-render-burst-record + :mode mode + :saved-threshold threshold + :saved-percentage percentage)) + +(defun ebox--make-deferred-render-gc-state (threshold percentage) + "Return private deferred GC state for THRESHOLD and PERCENTAGE." + (list :gc-cons-threshold threshold + :gc-cons-percentage percentage)) + (defun ebox--deferred-render-gc-restore () "Restore GC settings saved by `ebox--with-deferred-render-gc'." (let ((state ebox--deferred-render-gc-state)) + (cl-incf ebox--deferred-render-gc-generation) (setq ebox--deferred-render-gc-state nil) (setq ebox--deferred-render-gc-timer nil) (setq ebox--deferred-render-gc-depth 0) @@ -131,20 +162,29 @@ Set this to nil to leave `gc-cons-percentage' unchanged.") (defun ebox--deferred-render-gc-enter () "Raise GC settings for an interactive render transaction." - (cl-incf ebox--deferred-render-gc-depth) - (unless ebox--deferred-render-gc-state - (setq ebox--deferred-render-gc-state - (list :gc-cons-threshold gc-cons-threshold - :gc-cons-percentage gc-cons-percentage))) - (when-let ((threshold - (ebox--effective-deferred-render-gc-cons-threshold))) - (setq gc-cons-threshold threshold)) - (when (numberp ebox-render-gc-cons-percentage) - (setq gc-cons-percentage - (max gc-cons-percentage ebox-render-gc-cons-percentage))) - (when (timerp ebox--deferred-render-gc-timer) - (cancel-timer ebox--deferred-render-gc-timer) - (setq ebox--deferred-render-gc-timer nil))) + (let ((saved-threshold gc-cons-threshold) + (saved-percentage gc-cons-percentage)) + ;; Cross a safe allocation boundary before automatic tuning or saved-state + ;; construction can allocate. The original scalar values above require no + ;; heap record and remain authoritative for the eventual restore. + (when (numberp ebox-render-gc-cons-threshold) + (setq gc-cons-threshold + (max gc-cons-threshold ebox-render-gc-cons-threshold))) + (when (numberp ebox-render-gc-cons-percentage) + (setq gc-cons-percentage + (max gc-cons-percentage ebox-render-gc-cons-percentage))) + (cl-incf ebox--deferred-render-gc-generation) + (cl-incf ebox--deferred-render-gc-depth) + (when-let ((threshold + (ebox--effective-deferred-render-gc-cons-threshold))) + (setq gc-cons-threshold threshold)) + (unless ebox--deferred-render-gc-state + (setq ebox--deferred-render-gc-state + (ebox--make-deferred-render-gc-state + saved-threshold saved-percentage))) + (when (timerp ebox--deferred-render-gc-timer) + (cancel-timer ebox--deferred-render-gc-timer) + (setq ebox--deferred-render-gc-timer nil)))) (defun ebox--deferred-render-gc-raise-threshold (threshold) "Raise an active interactive burst allocation budget to THRESHOLD." @@ -168,8 +208,18 @@ Set this to nil to leave `gc-cons-percentage' unchanged.") ebox--deferred-render-gc-state) (when (timerp ebox--deferred-render-gc-timer) (cancel-timer ebox--deferred-render-gc-timer)) - (setq ebox--deferred-render-gc-timer - (run-at-time 0.2 nil #'ebox--deferred-render-gc-restore)))) + (let ((generation (cl-incf ebox--deferred-render-gc-generation))) + (setq ebox--deferred-render-gc-timer + (run-at-time + 0.2 nil #'ebox--deferred-render-gc-restore-if-current + generation))))) + +(defun ebox--deferred-render-gc-restore-if-current (generation) + "Restore deferred GC state when GENERATION still owns the timer boundary." + (when (and (= generation ebox--deferred-render-gc-generation) + (zerop ebox--deferred-render-gc-depth) + ebox--deferred-render-gc-state) + (ebox--deferred-render-gc-restore))) (defmacro ebox--with-deferred-render-gc (&rest body) "Evaluate BODY with render GC settings restored after idle redisplay. @@ -182,6 +232,92 @@ immediately after BODY can trigger GC before the updated buffer is visible." (progn ,@body) (ebox--deferred-render-gc-schedule-restore)))) +;;;###autoload +(defun ebox-render-burst-begin () + "Begin a framework render burst and return an opaque ownership token. + +Frameworks may enter this boundary at the start of an input callback so +allocation performed before `ebox-commit' shares the same GC budget as Ebox +rendering and publication. Interactive callers reuse Ebox's settling burst: +the outermost matching end restores settings after 0.2 seconds. Batch callers +restore the exact settings captured by this call immediately, without running +GC at the boundary. + +Pass the returned token to `ebox-render-burst-end'. Bursts may be nested, but +must be ended in last-in, first-out order. Prefer +`ebox-call-with-render-burst' when the complete operation is available as a +function, because it guarantees cleanup across errors and quits." + (let ((saved-threshold gc-cons-threshold) + (saved-percentage gc-cons-percentage) + token + completed) + (if noninteractive + ;; Do not allocate the opaque token, its private record, or a stack + ;; cons until the old threshold can no longer collect at begin itself. + (progn + (when (numberp ebox-render-gc-cons-threshold) + (setq gc-cons-threshold + (max gc-cons-threshold ebox-render-gc-cons-threshold))) + (when (numberp ebox-render-gc-cons-percentage) + (setq gc-cons-percentage + (max gc-cons-percentage ebox-render-gc-cons-percentage)))) + (ebox--deferred-render-gc-enter)) + (unwind-protect + (progn + (setq token (make-symbol "ebox-render-burst-token")) + (puthash + token + (ebox--render-burst-create-record + (if noninteractive 'batch 'interactive) + (and noninteractive saved-threshold) + (and noninteractive saved-percentage)) + ebox--render-burst-records) + (push token ebox--render-burst-stack) + (setq completed t) + token) + (unless completed + (when token + (remhash token ebox--render-burst-records)) + (if noninteractive + (setq gc-cons-threshold saved-threshold + gc-cons-percentage saved-percentage) + (ebox--deferred-render-gc-schedule-restore)))))) + +;;;###autoload +(defun ebox-render-burst-end (token) + "End the render burst owned by opaque TOKEN. + +TOKEN must be the most recently returned active token from +`ebox-render-burst-begin'. Invalid, repeated, or out-of-order ends signal an +error without changing GC ownership. Return nil after a successful end." + (let ((record (gethash token ebox--render-burst-records))) + (unless record + (error "Invalid or inactive Ebox render-burst token")) + (unless (eq token (car ebox--render-burst-stack)) + (error "Ebox render bursts must end in last-in, first-out order")) + (pop ebox--render-burst-stack) + (remhash token ebox--render-burst-records) + (if (eq (ebox--render-burst-record-mode record) 'interactive) + (ebox--deferred-render-gc-schedule-restore) + (setq gc-cons-threshold + (ebox--render-burst-record-saved-threshold record) + gc-cons-percentage + (ebox--render-burst-record-saved-percentage record)))) + nil) + +;;;###autoload +(defun ebox-call-with-render-burst (function &rest arguments) + "Call FUNCTION with ARGUMENTS inside one framework render burst. + +The burst is always ended when FUNCTION returns, signals an error, or quits. +Return FUNCTION's value." + (unless (functionp function) + (signal 'wrong-type-argument (list 'functionp function))) + (let ((token (ebox-render-burst-begin))) + (unwind-protect + (apply function arguments) + (ebox-render-burst-end token)))) + ;;; Paint Mapping (defun ebox-buffer--font-attributes (style) @@ -243,11 +379,19 @@ buffer-facing paint properties. Layout-only properties never pass through." (let ((face (ebox-buffer--font-face style))) (when-let ((foreground (ebox-buffer--paint-color style role))) (setq face - (if (eq foreground 'ebox/default-foreground) - (append (ebox-buffer--default-foreground-face) face) - (plist-put face :foreground foreground)))) + (cond + ((tp-paint-slot-p foreground) + (let ((slot-face (tp-paint-slot-face foreground))) + (if face (list slot-face face) slot-face))) + ((eq foreground 'ebox/default-foreground) + (append (ebox-buffer--default-foreground-face) face)) + (t (plist-put face :foreground foreground))))) (when-let ((background (ebox-buffer--paint-background-color style role))) - (setq face (plist-put face :background background))) + (setq face + (if (tp-paint-slot-p background) + (let ((slot-face (tp-paint-slot-face background))) + (if face (list slot-face face) slot-face)) + (plist-put face :background background)))) (when face (list 'face face)))) @@ -263,9 +407,10 @@ buffer-facing paint properties. Layout-only properties never pass through." (defun ebox-buffer-side-border-face (color) "Return the canonical Emacs face for a side border with COLOR." - (if color - `(:background ,color) - '(:inverse-video t))) + (cond + ((tp-paint-slot-p color) (tp-paint-slot-face color)) + (color `(:background ,color)) + (t '(:inverse-video t)))) (defun ebox--pixel-border (pixel-width height &optional color) "Generate a border line of PIXEL-WIDTH and HEIGHT using optional COLOR." @@ -284,7 +429,12 @@ buffer-facing paint properties. Layout-only properties never pass through." (let* ((source string) (string (copy-sequence source)) (length (length string))) - (ebox--add-render-face! string 0 length `(:background ,bgcolor) t) + (ebox--add-render-face! + string 0 length + (if (tp-paint-slot-p bgcolor) + (tp-paint-slot-face bgcolor) + `(:background ,bgcolor)) + t) (ebox--register-render-owned-face-values source string))) (defun ebox--propertize-color (string color) @@ -294,9 +444,11 @@ buffer-facing paint properties. Layout-only properties never pass through." (length (length string))) (ebox--add-render-face! string 0 length - (if (eq color 'ebox/default-foreground) - (ebox-buffer--default-foreground-face) - `(:foreground ,color)) + (cond + ((tp-paint-slot-p color) (tp-paint-slot-face color)) + ((eq color 'ebox/default-foreground) + (ebox-buffer--default-foreground-face)) + (t `(:foreground ,color))) t) (ebox--register-render-owned-face-values source string))) @@ -308,13 +460,19 @@ buffer-facing paint properties. Layout-only properties never pass through." (when color (ebox--add-render-face! string 0 length - (if (eq color 'ebox/default-foreground) - (ebox-buffer--default-foreground-face) - `(:foreground ,color)) + (cond + ((tp-paint-slot-p color) (tp-paint-slot-face color)) + ((eq color 'ebox/default-foreground) + (ebox-buffer--default-foreground-face)) + (t `(:foreground ,color))) t)) (when bgcolor (ebox--add-render-face! - string 0 length `(:background ,bgcolor) t)) + string 0 length + (if (tp-paint-slot-p bgcolor) + (tp-paint-slot-face bgcolor) + `(:background ,bgcolor)) + t)) (ebox--register-render-owned-face-values source string))) (defun ebox--propertize-overline (string &optional color) @@ -322,7 +480,11 @@ buffer-facing paint properties. Layout-only properties never pass through." (let* ((source string) (string (copy-sequence source))) (ebox--add-render-face! - string 0 (length string) `(:overline ,(or color t)) t) + string 0 (length string) + (if (tp-paint-slot-p color) + (tp-paint-slot-face color) + `(:overline ,(or color t))) + t) (ebox--register-render-owned-face-values source string))) (defun ebox--propertize-underline (string &optional color) @@ -331,7 +493,9 @@ buffer-facing paint properties. Layout-only properties never pass through." (string (copy-sequence source))) (ebox--add-render-face! string 0 (length string) - `(:underline (:position t ,@(when color `(:color ,color)))) + (if (tp-paint-slot-p color) + (tp-paint-slot-face color) + `(:underline (:position t ,@(when color `(:color ,color))))) t) (ebox--register-render-owned-face-values source string))) diff --git a/ebox-child-range.el b/ebox-child-range.el index a352195..c793d52 100644 --- a/ebox-child-range.el +++ b/ebox-child-range.el @@ -248,8 +248,10 @@ HASH-FUNCTION optionally supplies the stable key hash." "Return `(NEW-SEQUENCE . METRICS)' for SEQUENCE after replacing REF ITEMS." (ebox-child-range--replace-many sequence (list (cons ref items)))) -(defun ebox-child-range--replace-many (sequence replacements) - "Replace RANGE REPLACEMENTS in SEQUENCE with one two-phase transaction." +(defun ebox-child-range--replace-many (sequence replacements &optional reuse-maps) + "Replace RANGE REPLACEMENTS in SEQUENCE with one two-phase transaction. +REUSE-MAPS is an alist from Range ref to proven `(NEW . OLD)' item indexes; +those NEW items already name canonical published objects and are not copied." (let ((metrics (ebox-child-range--make-metrics)) (missing (make-symbol "missing-range-ref")) (seen (make-hash-table :test #'equal)) records) @@ -267,12 +269,15 @@ HASH-FUNCTION optionally supplies the stable key hash." missing))) (when (eq index missing) (error "Ebox child Range ref does not exist: %S" (car entry))) - (push (list (car entry) index - (vconcat (mapcar - (lambda (item) - (ebox-child-range--item-copy item metrics)) - (cdr entry)))) - records))) + (let ((reuse-map (cdr (assoc (car entry) reuse-maps #'equal)))) + (push (list (car entry) index + (vconcat + (cl-loop for item in (cdr entry) for offset from 0 + collect + (if (assq offset reuse-map) + item + (ebox-child-range--item-copy item metrics))))) + records)))) (setq records (nreverse records)) (let ((key-root (ebox-child-range--sequence-key-root sequence)) (hash-function (ebox-child-range--sequence-hash-function sequence)) diff --git a/ebox-dsl.el b/ebox-dsl.el index 9b7435b..39f3be9 100644 --- a/ebox-dsl.el +++ b/ebox-dsl.el @@ -12,6 +12,8 @@ (require 'ebox-flex) (require 'ebox-grid) +(declare-function ebox-create "ebox" (&rest plist)) + (defun ebox--dsl-unquote-value (value) "Unquote one static DSL property VALUE when it is quoted data." (if (and (consp value) @@ -36,40 +38,6 @@ keyword properties are normalized." (push item result)))) (nreverse result))) -(defun ebox--build-split-attrs (items) - "Split DSL ITEMS into (PROPS . CHILDREN)." - (let (props children) - (while items - (let ((item (pop items))) - (if (keywordp item) - (if items - (let ((value (pop items))) - (push item props) - (push value props)) - (error "ebox-build: missing value for %S" item)) - (push item children)))) - (cons (nreverse props) (nreverse children)))) - -(defun ebox--plist-keep-keys (plist keys) - "Return a copy of PLIST containing only entries whose key is in KEYS." - (let (result) - (while plist - (let ((key (pop plist)) - (value (pop plist))) - (when (memq key keys) - (setq result (append result (list key value)))))) - result)) - -(defun ebox--plist-remove-keys (plist keys) - "Return a copy of PLIST without entries whose key is in KEYS." - (let (result) - (while plist - (let ((key (pop plist)) - (value (pop plist))) - (unless (memq key keys) - (setq result (append result (list key value)))))) - result)) - (defun ebox--build-children-layout (children) "Build a vertical default layout from CHILDREN." (setq children (delq nil children)) diff --git a/ebox-flex.el b/ebox-flex.el index e42bef5..58151e5 100644 --- a/ebox-flex.el +++ b/ebox-flex.el @@ -22,9 +22,55 @@ "ebox-incremental" (node)) (declare-function ebox--viewport-height-dependent-subtree-p "ebox-incremental" (node)) +(declare-function ebox--viewport-dependent-subtree-p + "ebox-incremental" (node)) +(declare-function ebox--viewport-dependent-size-value-p + "ebox-incremental" (value &optional nil-dependent)) +(declare-function ebox--render-cacheable-node-p + "ebox-incremental" (node)) +(declare-function ebox--render-cache-node-body-signature + "ebox-incremental" (node)) +(declare-function ebox--render-cache-node-signature + "ebox-incremental" (node)) +(declare-function ebox--render-cache-lookup + "ebox-incremental" (cache-key signature)) +(declare-function ebox--render-cache-store + "ebox-incremental" + (cache-key signature rendered &optional side-effects)) +(declare-function ebox--render-with-cache + "ebox-incremental" (node &optional force cache-probe)) +(declare-function ebox-create "ebox" (&rest plist)) +(declare-function ebox-get "ebox" (box property)) +(declare-function ebox--ensure-node-id "ebox" (node)) +(declare-function ebox--ensure-region-id "ebox" (box)) +(declare-function ebox--runtime-node-ids "ebox" (node)) +(declare-function ebox-region-ids "ebox" (node)) +(declare-function ebox-string-height "ebox" (string)) +(declare-function ebox-string-lines "ebox" (string)) +(declare-function ebox-lines-join "ebox" (lines)) +(declare-function ebox--maplines "ebox" (function string)) +(declare-function ebox--line-has-non-content-properties-p + "ebox" (line)) +(declare-function ebox--lines-concat-horizontal "ebox" (&rest strings)) +(declare-function ebox--lines-stack-vertical "ebox" (&rest strings)) +(declare-function ebox--pixel-border + "ebox-buffer-backend" + (pixel-width height &optional color)) +(declare-function ebox--propertize-colors + "ebox-buffer-backend" (string color bgcolor)) +(declare-function ebox--propertize-overline + "ebox-buffer-backend" (string &optional color)) +(declare-function ebox--propertize-underline + "ebox-buffer-backend" (string &optional color)) +(declare-function ebox--propertize-region + "ebox-buffer-backend" (string property region-id)) +(declare-function ebox--propertize-content-line + "ebox-buffer-backend" (line region-id idx fallback)) (defvar ebox--propertize-private-content-line-p) (defvar ebox--flex-content-min-width-table) +(defvar ebox--render-region-id) +(defvar ebox--region-box-table) (defvar ebox-fragment-flex-retention-rerender-count) (defvar ebox--flex-native-size-lines-backend :unknown "Cached optional pure geometry backend for batched Flex line sizing.") @@ -43,6 +89,40 @@ '(:width :height :box-sizing) "Flex box properties that do not create visible wrapping by themselves.") +(defun ebox--build-split-attrs (items) + "Split constructor ITEMS into (PROPS . CHILDREN)." + (let (props children) + (while items + (let ((item (pop items))) + (if (keywordp item) + (if items + (let ((value (pop items))) + (push item props) + (push value props)) + (error "ebox-build: missing value for %S" item)) + (push item children)))) + (cons (nreverse props) (nreverse children)))) + +(defun ebox--plist-keep-keys (plist keys) + "Return a copy of PLIST containing only entries whose key is in KEYS." + (let (result) + (while plist + (let ((key (pop plist)) + (value (pop plist))) + (when (memq key keys) + (setq result (append result (list key value)))))) + result)) + +(defun ebox--plist-remove-keys (plist keys) + "Return a copy of PLIST without entries whose key is in KEYS." + (let (result) + (while plist + (let ((key (pop plist)) + (value (pop plist))) + (unless (memq key keys) + (setq result (append result (list key value)))))) + result)) + (defun ebox--flex-visual-box-props-p (props) "Return non-nil when PROPS contains visible box behavior." (cl-loop for (key _value) on props by #'cddr diff --git a/ebox-fragment.el b/ebox-fragment.el index 00f05d6..f64ccd7 100644 --- a/ebox-fragment.el +++ b/ebox-fragment.el @@ -12,6 +12,10 @@ (require 'ebox-measure) (declare-function ebox--current-display-signature "ebox-measure" ()) +(declare-function ebox--ensure-node-id "ebox" (node)) +(declare-function ebox--complete-layout-snapshot + "ebox-incremental" + (buffer node snapshot &optional copy)) (defvar ebox--layout-fragments-table nil "Dynamic published-generation layout fragment retention table.") diff --git a/ebox-incremental.el b/ebox-incremental.el index e16063f..54bae76 100644 --- a/ebox-incremental.el +++ b/ebox-incremental.el @@ -49,6 +49,7 @@ (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--runtime-node-ids "ebox" (node)) (declare-function ebox--schedule-buffer-runtime-prewarm "ebox" (buffer)) (declare-function ebox--cancel-buffer-runtime-prewarm @@ -82,6 +83,11 @@ "ebox-cache" (buffer snapshot)) (declare-function ebox-style-cascade-active-p "ebox-style" ()) +(declare-function ebox-native-commit-plan-eligible-p + "ebox-native-commit" + (old-state candidate-state prepared)) +(declare-function ebox-native-commit-topology-stable-p + "ebox-native-commit" (old-state candidate-state prepared)) (defvar ebox-viewport-width) (defvar ebox-viewport-height) @@ -145,6 +151,7 @@ arguments.") base-revision base-buffer-tick replacements + paint-patches range-replacements range-metrics sealed-p) @@ -164,7 +171,14 @@ arguments.") (:constructor ebox-incremental--make-candidate-range-replacement) (:conc-name ebox-incremental--candidate-range-replacement-)) "One base-addressed child Range payload replacement." - ref items) + ref items reuse-map) + +(cl-defstruct + (ebox-incremental--candidate-paint-patch + (:constructor ebox-incremental--make-candidate-paint-patch) + (:conc-name ebox-incremental--candidate-paint-patch-)) + "One base-addressed Host paint update without subtree replacement." + anchor-id node changed-keys) (cl-defstruct (ebox-incremental--detached-history @@ -349,6 +363,10 @@ Kept as a narrow predicate for callers that need box-only sizing semantics." (defun ebox--render-cache-value-signature (value) "Return VALUE normalized for render cache signatures." (cond + ((ebox-child-range--sequence-p value) + (cons 'ebox-child-sequence + (mapcar #'ebox--render-cache-value-signature + (ebox-child-range--flatten value)))) ((and (listp value) (not (stringp value)) (plist-get value :ebox-type)) @@ -2267,6 +2285,7 @@ checks pays one walk per distinct node instead of one walk per pair." (defun ebox--patch-operation-strength (strategy) "Return structural dominance strength for patch STRATEGY." (pcase strategy + ('native-frame 4) ('owner-rerender 3) ('span-patch 2) ('paint-patch 1) @@ -3470,6 +3489,10 @@ cannot cover an old unowned visible-overflow suffix." (defun ebox--patch-results-strategy (results) "Return aggregate update strategy for executed patch RESULTS." (cond + ((cl-some (lambda (result) + (eq (plist-get result :op) 'native-frame)) + results) + 'native-frame) ((cl-some (lambda (result) (eq (plist-get result :op) 'owner-rerender)) results) @@ -3911,46 +3934,88 @@ during `ebox-incremental-flush'." :base-buffer-tick (with-current-buffer buffer (buffer-modified-tick)) :replacements nil + :paint-patches nil :range-replacements nil :range-metrics nil :sealed-p nil))) -(defun ebox-incremental-candidate-replace-range-ref (candidate ref next-items) - "Record a last-wins base Range REF replacement with NEXT-ITEMS." +(defun ebox-incremental-candidate-replace-range-ref + (candidate ref next-items &optional reuse-map) + "Record a Range REF replacement using proven slot REUSE-MAP." (unless (ebox-candidate-p candidate) (error "Ebox Range replacement requires an Ebox candidate")) (when (ebox-candidate--sealed-p candidate) (error "Ebox candidate is already sealed")) (unless (proper-list-p next-items) (signal 'wrong-type-argument (list 'proper-list-p next-items))) - (unless (gethash ref (plist-get (ebox-candidate--base-state candidate) - :range-ref-table)) - (error "Ebox candidate Range ref does not exist in its base: %S" ref)) - (dolist (item next-items) - (unless (and (listp item) (not (stringp item))) - (error "Ebox child Range item must be a declarative node")) - (cl-labels ((reject-range - (node) - (when (ebox-child-range--descriptor-p node) - (error "Range replacement payload cannot introduce a Range")) - (when (and (listp node) (not (stringp node))) - (dolist (child (ebox-tree--children-raw node)) - (reject-range child))))) - (reject-range item))) - (ebox-tree-validate-declarative-root - (list :ebox-type 'stack :display '(block column) :children next-items)) - (let ((owned - (mapcar (lambda (item) - (ebox-tree-clear-runtime-identities - (ebox-tree-copy-node-structure item))) - next-items))) + (unless (proper-list-p reuse-map) + (signal 'wrong-type-argument (list 'proper-list-p reuse-map))) + (let* ((base-state (ebox-candidate--base-state candidate)) + (record (gethash ref (plist-get base-state :range-ref-table)))) + (unless record + (error "Ebox candidate Range ref does not exist in its base: %S" ref)) + (let* ((parent (gethash (plist-get record :parent-node-id) + (plist-get base-state :node-table))) + (sequence (and parent (plist-get parent :ebox-child-sequence))) + (segment (and sequence (ebox-child-range--lookup-ref sequence ref))) + (old-items (and segment + (append (ebox-child-range--segment-payload segment) + nil))) + (aligned-p (= (length old-items) (length next-items))) + (reuse-by-new (make-hash-table :test #'eql)) + (seen-old (make-hash-table :test #'eql)) + (missing (make-symbol "ebox-range-reuse-missing")) + owned) + (dolist (entry reuse-map) + (let ((new-index (car-safe entry)) + (old-index (cdr-safe entry))) + (unless (and (integerp new-index) (>= new-index 0) + (< new-index (length next-items)) + (integerp old-index) (>= old-index 0) + (< old-index (length old-items)) + (eq (gethash new-index reuse-by-new missing) missing) + (not (gethash old-index seen-old))) + (error "Ebox Range reuse map is invalid: %S" entry)) + (puthash new-index (nth old-index old-items) reuse-by-new) + (puthash old-index t seen-old))) + (cl-labels + ((validate-new + (item) + (unless (and (listp item) (not (stringp item))) + (error "Ebox child Range item must be a declarative node")) + (cl-labels + ((reject-range + (node) + (when (ebox-child-range--descriptor-p node) + (error "Range replacement payload cannot introduce a Range")) + (when (and (listp node) (not (stringp node))) + (dolist (child (ebox-tree--children-raw node)) + (reject-range child))))) + (reject-range item)) + (ebox-tree-validate-declarative-root item) + (ebox-tree-clear-runtime-identities + (ebox-tree-copy-node-structure item)))) + (cl-loop + for item in next-items + for index from 0 + for reused = (gethash index reuse-by-new missing) + for old = (if (eq reused missing) + (and aligned-p (nth index old-items)) + reused) + do (push (if (or (not (eq reused missing)) + (and aligned-p (eq item old))) + old + (validate-new item)) + owned))) + (setq owned (nreverse owned)) (setf (ebox-candidate--range-replacements candidate) (append (cl-remove ref (ebox-candidate--range-replacements candidate) :key #'ebox-incremental--candidate-range-replacement-ref :test #'equal) (list (ebox-incremental--make-candidate-range-replacement - :ref ref :items owned))))) + :ref ref :items owned :reuse-map (copy-tree reuse-map)))))) + ) candidate) (defun ebox-incremental--candidate-ancestor-p @@ -4060,6 +4125,96 @@ have the same optional detached-identity meaning as in candidate node-id next-subtree old-semantic-key new-semantic-key))) +(defun ebox-incremental--find-host-ref-node (node host-ref) + "Return the node below NODE that owns HOST-REF, or nil." + (catch 'found + (cl-labels + ((visit + (current) + (when (and (listp current) (not (stringp current))) + (when (equal (plist-get current :host-ref) host-ref) + (throw 'found current)) + (dolist (child (ebox-tree--children-raw current)) + (visit child))))) + (visit node)) + nil)) + +(defun ebox-incremental-candidate-patch-host-paint + (candidate host-ref previous-subtree next-subtree) + "Patch HOST-REF paint from declarative OLD/NEW subtrees. + +PREVIOUS-SUBTREE and NEXT-SUBTREE may be complete Host outputs; only their +local nodes owning HOST-REF are compared. Comparing declarative peers avoids +mistaking inherited/cascaded runtime values for explicit deltas. The update +is accepted only when every changed field is paint-only. Children and runtime +identities remain owned by the candidate base and are path-copied only along +the patched Host's ancestor chain." + (unless (ebox-candidate-p candidate) + (error "Ebox Host paint patch requires an Ebox candidate")) + (when (ebox-candidate--sealed-p candidate) + (error "Ebox candidate is already sealed")) + (let* ((state (ebox-candidate--base-state candidate)) + (missing (make-symbol "ebox-missing-host-ref")) + (node-id (gethash host-ref (plist-get state :host-ref-table) missing))) + (when (eq node-id missing) + (error "Ebox candidate host ref does not exist: %S" host-ref)) + (catch 'unsupported + (let* ((base-node (gethash node-id (plist-get state :node-table))) + (previous-node + (ebox-incremental--find-host-ref-node + previous-subtree host-ref)) + (next-node + (ebox-incremental--find-host-ref-node next-subtree host-ref))) + (when + (cl-some + (lambda (replacement) + (let ((anchor + (ebox-incremental--candidate-replacement-anchor-id + replacement))) + (or (equal anchor node-id) + (ebox-incremental--candidate-ancestor-p + candidate anchor node-id)))) + (ebox-candidate--replacements candidate)) + (throw 'unsupported nil)) + (unless (and previous-node next-node) + (error "Ebox paint patch output lacks Host ref: %S" host-ref)) + (unless (and (eq (plist-get base-node :ebox-type) + (plist-get previous-node :ebox-type)) + (eq (plist-get previous-node :ebox-type) + (plist-get next-node :ebox-type))) + (throw 'unsupported nil)) + (let ((changed-keys + (ebox-tree-node-local-changed-keys + previous-node next-node))) + (unless (cl-every + (lambda (key) + (memq key ebox--paint-style-signature-keys)) + changed-keys) + (throw 'unsupported nil)) + (let ((patch (list :ebox-type (plist-get next-node :ebox-type) + :host-ref host-ref))) + (dolist (key changed-keys) + (setq patch + (plist-put patch key + (copy-tree (plist-get next-node key))))) + (dolist (key '(:ebox-style-declarations :ebox-style-overrides)) + (when (plist-member next-node key) + (setq patch + (plist-put patch key + (copy-tree (plist-get next-node key)))))) + (setf (ebox-candidate--paint-patches candidate) + (append + (cl-remove + node-id (ebox-candidate--paint-patches candidate) + :key + #'ebox-incremental--candidate-paint-patch-anchor-id + :test #'equal) + (list + (ebox-incremental--make-candidate-paint-patch + :anchor-id node-id :node patch + :changed-keys changed-keys)))))) + candidate)))) + (defun ebox-incremental--candidate-assert-current (candidate buffer old-state) "Reject CANDIDATE unless BUFFER still publishes its exact captured base." @@ -4704,6 +4859,44 @@ DETACHED-HISTORY receives identity snapshots for semantic replacements." (ebox-tree-reconcile-runtime old item))) items)) +(defun ebox-incremental--candidate-canonical-range-reuse + (sequence ref items reuse-map parent-id segment-index) + "Return canonical ITEMS and effective REUSE-MAP for published SEQUENCE. +Only an item that keeps its exact sequence location may reuse the published +object. A moved item retains semantic identity through ordinary reconcile, +but must re-enter the candidate indexes as a new object." + (let* ((segment (ebox-child-range--lookup-ref sequence ref)) + (old-payload + (and segment (ebox-child-range--segment-payload segment))) + (result (vconcat items)) + effective) + (dolist (pair reuse-map) + (let ((new-index (car-safe pair)) + (old-index (cdr-safe pair))) + (unless (and (integerp new-index) (>= new-index 0) + (< new-index (length result)) + (integerp old-index) (>= old-index 0) + old-payload (< old-index (length old-payload))) + (error "Ebox Range reuse map is out of bounds: %S" pair)) + (let* ((published (aref old-payload old-index)) + (target-location + (list :parent-node-id parent-id + :segment-index segment-index :offset new-index))) + (if (equal (plist-get published :ebox-sequence-location) + target-location) + (progn + (aset result new-index published) + (push pair effective)) + ;; The producer may use the published object itself to describe + ;; an unchanged keyed item. Once that item moves, exact-slot + ;; reuse is no longer authorized: leave a detached candidate for + ;; ordinary runtime reconciliation, never the published object. + (when (eq (aref result new-index) published) + (aset result new-index + (ebox-tree-clear-runtime-identities + (ebox-tree-copy-node-structure published)))))))) + (list (append result nil) (nreverse effective)))) + (defun ebox-incremental--candidate-current-node (state node-id) "Return current candidate NODE-ID from path trace or base STATE." (or (plist-get (gethash node-id ebox-incremental--candidate-path-copy-trace) @@ -4788,7 +4981,9 @@ DETACHED-HISTORY receives identity snapshots for semantic replacements." old-parent (list (cons old-child new-child))))) (grandparent-id (gethash current-parent-id parent-table))) (ebox-incremental--candidate-record-path-copy - current-parent-id new-parent grandparent-id nil structure-p) + current-parent-id new-parent grandparent-id nil + (not (equal (plist-get old-child :node-id) + (plist-get new-child :node-id)))) (setq old-child old-parent new-child new-parent parent-id grandparent-id))) (if (equal node-id (plist-get root :node-id)) replacement new-child))) @@ -4842,39 +5037,84 @@ DETACHED-HISTORY receives identity snapshots for semantic replacements." (when (ebox-child-range--lookup-ref sequence ref) (let ((items (ebox-incremental--candidate-range-replacement-items + entry)) + (reuse-map + (ebox-incremental--candidate-range-replacement-reuse-map entry))) - (ebox-incremental--candidate-reconcile-range-items - sequence ref items) (let ((segment-index (plist-get (gethash ref base-ranges) :segment-index))) - (cl-loop for item in items for offset from 0 do - (plist-put - item :ebox-sequence-location - (list :parent-node-id parent-id - :segment-index segment-index - :offset offset)))) - (push (cons ref items) replacements))))) + (pcase-let + ((`(,canonical-items ,effective-reuse-map) + (ebox-incremental--candidate-canonical-range-reuse + sequence ref items reuse-map parent-id + segment-index))) + (setq items canonical-items + reuse-map effective-reuse-map))) + (ebox-incremental--candidate-reconcile-range-items + sequence ref items) + (cl-loop for item in items for offset from 0 + for location = + (list :parent-node-id parent-id + :segment-index + (plist-get (gethash ref base-ranges) + :segment-index) + :offset offset) + unless (equal + (plist-get item + :ebox-sequence-location) + location) + do (plist-put item :ebox-sequence-location + location)) + (push (list ref items reuse-map) replacements))))) (when replacements (let* ((ordered-replacements (nreverse replacements)) + (range-pairs + (mapcar (lambda (replacement) + (cons (nth 0 replacement) + (nth 1 replacement))) + ordered-replacements)) (old-payloads (mapcar (lambda (replacement) (append (ebox-child-range--segment-payload (ebox-child-range--lookup-ref - base-sequence (car replacement))) nil)) + base-sequence (nth 0 replacement))) nil)) ordered-replacements)) (result (ebox-child-range--replace-many - sequence ordered-replacements)) + sequence range-pairs + (mapcar + (lambda (replacement) + (cons (nth 0 replacement) + (nth 2 replacement))) + ordered-replacements))) (new-payloads (mapcar (lambda (replacement) (append (ebox-child-range--segment-payload (ebox-child-range--lookup-ref - (car result) (car replacement))) nil)) + (car result) (nth 0 replacement))) nil)) ordered-replacements)) + (old-affected-payloads + (cl-mapcar + (lambda (replacement payload) + (let ((reused (mapcar #'cdr + (nth 2 replacement)))) + (cl-loop for item in payload for index from 0 + unless (memq index reused) + collect item))) + ordered-replacements old-payloads)) + (new-affected-payloads + (cl-mapcar + (lambda (replacement payload) + (let ((reused (mapcar #'car + (nth 2 replacement)))) + (cl-loop for item in payload for index from 0 + unless (memq index reused) + collect item))) + ordered-replacements new-payloads)) (children-changed (not (cl-every @@ -4899,12 +5139,136 @@ DETACHED-HISTORY receives identity snapshots for semantic replacements." (push (list :old-parent base-parent :new-parent new-parent :parent-id parent-id :children-changed children-changed - :old-payloads old-payloads - :new-payloads new-payloads) + :old-payloads old-affected-payloads + :new-payloads new-affected-payloads) ebox-incremental--candidate-range-index-deltas)))))))) (setf (ebox-candidate--range-metrics candidate) (nreverse metrics)) root)) +(defun ebox-incremental--node-explicit-color-p (node) + "Return non-nil when NODE owns a declaration winner for foreground." + (let ((style (plist-get node :ebox-computed-style))) + (or (and style (ebox-style--specified-property-p style :color)) + (plist-member (ebox-style-node-declarations node) 'ebox/color)))) + +(defun ebox-incremental--candidate-paint-top-anchors + (state patch-table patch-ids) + "Return PATCH-IDS without a patched ancestor in STATE." + (let ((parents (plist-get state :parent-table)) result) + (dolist (node-id patch-ids (nreverse result)) + (let ((parent-id (gethash node-id parents)) found) + (while (and parent-id (not found)) + (setq found (gethash parent-id patch-table) + parent-id (and (not found) (gethash parent-id parents)))) + (unless found (push node-id result)))))) + +(defun ebox-incremental--candidate-apply-paint-patches (candidate root) + "Apply CANDIDATE paint patches and inherited color in one subtree pass." + (let* ((state (ebox-candidate--base-state candidate)) + (parents (plist-get state :parent-table)) + (patch-table (make-hash-table :test #'equal)) + patch-ids) + (dolist (entry (ebox-candidate--paint-patches candidate)) + (let ((node-id + (ebox-incremental--candidate-paint-patch-anchor-id entry))) + (puthash node-id entry patch-table) + (push node-id patch-ids))) + (cl-labels + ((nearest-parent-color + (node-id) + (let ((parent-id (gethash node-id parents)) color found) + (while (and parent-id (not found)) + (let ((parent + (ebox-incremental--candidate-current-node + state parent-id))) + (when (and parent (plist-member parent :color)) + (setq color (plist-get parent :color) found t))) + (setq parent-id + (and (not found) (gethash parent-id parents)))) + color)) + (rewrite + (node inherited-color inherited-changed-p) + (if (or (not (listp node)) (stringp node)) + node + (let* ((node-id (plist-get node :node-id)) + (entry (and node-id (gethash node-id patch-table))) + (patch (and entry + (ebox-incremental--candidate-paint-patch-node + entry))) + (changed-keys + (and entry + (ebox-incremental--candidate-paint-patch-changed-keys + entry))) + (direct-color-p (memq :color changed-keys)) + (style-node-p (plist-member node :color)) + (next (if entry (copy-sequence node) node))) + (when entry + (dolist (key changed-keys) + (plist-put next key (copy-tree (plist-get patch key)))) + (dolist (key '(:ebox-style-declarations + :ebox-style-overrides)) + (when (plist-member patch key) + (plist-put + next key + (ebox-style-merge-declarations + (copy-tree (plist-get node key)) + (copy-tree (plist-get patch key))))))) + (let* ((explicit-color-p + (and style-node-p + (ebox-incremental--node-explicit-color-p next))) + (declarations + (and style-node-p + (ebox-style-node-declarations next))) + (declared-color + (and declarations + (plist-member declarations 'ebox/color))) + (old-color (and style-node-p (plist-get node :color))) + (desired-color + (cond (direct-color-p (plist-get next :color)) + ((and style-node-p inherited-changed-p + declared-color) + (cadr declared-color)) + ((and style-node-p inherited-changed-p + (not explicit-color-p)) + inherited-color) + (style-node-p (plist-get next :color)) + (t inherited-color))) + (color-changed-p + (and style-node-p + (not (equal old-color desired-color))))) + (when color-changed-p + (when (eq next node) (setq next (copy-sequence node))) + (plist-put next :color (copy-tree desired-color))) + (let (child-replacements) + (dolist (child (ebox-tree--children-raw next)) + (let ((replacement + (rewrite + child desired-color + (if style-node-p + (or direct-color-p color-changed-p) + inherited-changed-p)))) + (unless (eq replacement child) + (push (cons child replacement) child-replacements)))) + (when child-replacements + (setq next + (ebox-tree-copy-with-direct-child-replacements + next (nreverse child-replacements))))) + next))))) + (dolist + (node-id + (ebox-incremental--candidate-paint-top-anchors + state patch-table patch-ids) + root) + (let ((current + (ebox-incremental--candidate-current-node state node-id))) + (unless current + (error "Ebox paint patch anchor is absent: %S" node-id)) + (setq root + (ebox-incremental--candidate-path-copy-overlay + root node-id + (rewrite current (nearest-parent-color node-id) nil) + state nil))))))) + (defun ebox-incremental--candidate-logical-root (candidate &optional detached-history) "Return CANDIDATE root after node/Host and child Range coalescing." @@ -4957,10 +5321,12 @@ DETACHED-HISTORY receives identity snapshots for semantic replacements." original-nodes) (ebox-candidate--range-replacements candidate) surviving-ranges) (unwind-protect - (ebox-incremental--candidate-apply-range-replacements + (ebox-incremental--candidate-apply-paint-patches candidate - (ebox-incremental--candidate-node-logical-root - candidate detached-history)) + (ebox-incremental--candidate-apply-range-replacements + candidate + (ebox-incremental--candidate-node-logical-root + candidate detached-history))) (setf (ebox-candidate--replacements candidate) original-nodes (ebox-candidate--range-replacements candidate) original-ranges)))) @@ -5808,17 +6174,30 @@ recognized from its first untouched sibling without walking the whole tree." Published nodes carry computed ECSS longhands while fresh candidate nodes are still declaration-stage values. When canonical declarations are unchanged, -discard style longhand and container-prop differences caused solely by that -stage mismatch; selector metadata and non-style source fields remain visible." - (let ((changed (ebox-tree-node-local-changed-keys old new))) - (if (equal (ebox-style-node-declarations old) - (ebox-style-node-declarations new)) - (cl-remove-if - (lambda (key) - (or (eq key :props) - (ebox-style-property key))) - changed) - changed))) +compare only non-style source fields directly instead of allocating two full +filtered signatures and then deleting every derived style difference." + (if (not (equal (ebox-style-node-declarations old) + (ebox-style-node-declarations new))) + (ebox-tree-node-local-changed-keys old new) + (let (seen changed) + (dolist (source (list old new)) + (let ((plist source)) + (while plist + (let ((key (pop plist))) + (pop plist) + (unless (or (memq key seen) + (memq key ebox-tree--excluded-source-keys) + (memq key '(:props :raw-props)) + (ebox-style-property key)) + (push key seen) + (let ((old-entry (plist-member old key)) + (new-entry (plist-member new key))) + (unless (and (eq (not (null old-entry)) + (not (null new-entry))) + (equal-including-properties + (cadr old-entry) (cadr new-entry))) + (push key changed)))))))) + (nreverse changed)))) (defun ebox-incremental--declarative-dirty-set (old-state candidate-root candidate-index) @@ -5884,6 +6263,7 @@ candidate therefore owns its cache tables until TP commits the generation." (dolist (entry '((:render-cache . equal) (:layout-fragments . equal) (:render-signature-cache . eq) + (:native-fragment-owner-cache . equal) (:flex-content-min-widths . eq) (:viewport-height-dependent-subtree-cache . eq))) (let ((key (car entry)) @@ -5895,8 +6275,10 @@ candidate therefore owns its cache tables until TP commits the generation." (defun ebox-incremental--candidate-state (old-state candidate-root candidate-index prepared) "Return unpublished runtime state for a prepared declarative root." - (let ((state (ebox-incremental--isolate-candidate-caches - (copy-sequence old-state)))) + ;; Every mutable cache is replaced below and isolated once after the + ;; candidate fields are installed. Copying OLD-STATE's cache tables here as + ;; well only copied values that were immediately discarded. + (let ((state (copy-sequence old-state))) (dolist (entry `((:root-node ,candidate-root) @@ -5908,6 +6290,10 @@ candidate therefore owns its cache tables until TP commits the generation." ,(or (plist-get prepared :layout-snapshot-detail-generation) (plist-get old-state :layout-snapshot-detail-generation) 0)) + ;; A candidate starts without publication authority. A successful + ;; span projection installs fresh allocation certificates after its + ;; output footprint has been validated. + (:retained-allocation-certificates nil) (:runtime-revision ,(1+ (or (plist-get old-state :runtime-revision) 0))) (:display-signature ,(plist-get prepared :display-signature)) @@ -6113,6 +6499,10 @@ must be recomputed in the next publication." old-state candidate-root candidate-index)))) (list :root candidate-root :logical-candidate-p logical-candidate-p + :touched-node-ids + (plist-get local-preparation :touched-node-ids) + :removed-node-ids + (plist-get local-preparation :removed-node-ids) :range-metrics (plist-get local-preparation :range-metrics) :range-replacement-count (plist-get local-preparation :range-replacement-count) @@ -6336,8 +6726,11 @@ the expensive parent-context mount and role scan first." (ebox-incremental--formatting-context-reflow-node-p node))) (snapshot (and formatting-p - (ebox-incremental--cached-layout-snapshot-details - buffer node-id))) + ;; Eligibility only needs already-published text spans. + ;; Completing footprint/role details for every ancestor + ;; made a failed proof scan the buffer repeatedly before + ;; the real owner proof even started. + (ebox--ensure-layout-snapshot-spans buffer node-id))) (spans (and snapshot (plist-get snapshot :buffer-spans)))) (if (not formatting-p) ;; A Grid/Flex/row allocator between the dirty LCA and a whole @@ -6455,7 +6848,7 @@ line count; its outside siblings are shifted by the one scoped replacement." (cl-every (lambda (node-id) (or (equal node-id candidate-id) - (ebox--runtime-ancestor-id-p + (ebox--cached-runtime-ancestor-id-p buffer candidate-id node-id))) node-ids)) (setq proof @@ -6474,6 +6867,120 @@ line count; its outside siblings are shifted by the one scoped replacement." (setq candidate-id (gethash candidate-id parents)))))) proof)) +(defun ebox-incremental--two-owner-allocation-proof + (buffer old-state candidate-state geometry-prepared) + "Return two disjoint geometry proofs containing one allocation closure. +This narrow fallback runs only after the ordinary combined span proof misses. +It never partitions larger dirty sets, preserving their existing coalescing." + (let ((dirty-set (plist-get geometry-prepared :dirty-set)) + (root-id (ebox--buffer-root-node-id buffer)) + proofs owner-proofs valid allocation-p) + (setq valid (= (length dirty-set) 2)) + ;; Prove definite-width local spans first. Their snapshot proof warms the + ;; shared ancestor facts subsequently consumed by an auto-width allocation + ;; closure, avoiding the same cold ancestor scan in the wider proof. + (dolist (dirty + (sort + (copy-sequence dirty-set) + (lambda (left right) + (let ((left-width + (plist-get (plist-get left :old-signature) :width)) + (right-width + (plist-get (plist-get right :old-signature) :width))) + (and (numberp left-width) (not (numberp right-width))))))) + (when valid + (let* ((local-prepared (plist-put (copy-sequence geometry-prepared) + :dirty-set (list dirty))) + (plan + ;; One dirty entry needs no dominance/coalescing pass. Build + ;; its tentative operation directly; the exact span or + ;; allocation proof below still owns authorization. + (when-let ((op + (ebox-incremental--declarative-tentative-op + buffer dirty))) + (list op))) + (allocation-first-p + (let ((old-width + (plist-get (plist-get dirty :old-signature) :width)) + (new-width + (plist-get (plist-get dirty :new-signature) :width))) + ;; A stable definite-width content slot is the cheap local + ;; span case. Auto-width content must prove its allocator + ;; closure anyway, so do that before constructing the more + ;; expensive span-footprint proof that usually rejects it. + (or (eq (plist-get (car plan) :op) 'owner-rerender) + (not (and (numberp old-width) + (numberp new-width) + (= old-width new-width)))))) + (early-allocation-proof + (and allocation-first-p + (ebox-incremental--allocation-closure-proof + buffer old-state candidate-state local-prepared))) + (span-proof + (and (not early-allocation-proof) plan + (ebox-incremental--span-patch-projection-proof + buffer old-state local-prepared candidate-state plan))) + (allocation-proof + (or early-allocation-proof + (and (not span-proof) + (ebox-incremental--allocation-closure-proof + buffer old-state candidate-state local-prepared)))) + (proof (or span-proof allocation-proof)) + (ids (and proof + (or (plist-get proof :owner-ids) + (and (plist-get proof :owner-id) + (list (plist-get proof :owner-id)))))) + (local-owner-proofs + (and proof + (or (plist-get proof :owner-proofs) + (and (plist-get proof :owner-id) (list proof)))))) + (when (and span-proof + (not (plist-get span-proof :owner-proofs))) + (setq proof + (plist-put (copy-sequence proof) + :retain-external-owner-suffix-p t) + local-owner-proofs (list proof))) + (if (and proof ids + (not (member root-id ids)) + local-owner-proofs) + (progn + (push proof proofs) + (setq owner-proofs + (append owner-proofs + (copy-sequence local-owner-proofs))) + (setq allocation-p (or allocation-p allocation-proof))) + (setq valid nil))))) + (when (and valid allocation-p + (= (length proofs) 2) + (ebox-incremental--owner-proofs-disjoint-p + buffer owner-proofs)) + (setq proofs (nreverse proofs)) + (let ((owner-ids + (delete-dups + (mapcar (lambda (proof) (plist-get proof :owner-id)) + owner-proofs)))) + (list :owner-id (car owner-ids) + :owner-ids owner-ids + :owner-proofs owner-proofs + :dirty-set (copy-sequence dirty-set) + :variable-content-p + (cl-some (lambda (proof) + (plist-get proof :variable-content-p)) + proofs) + :ancestor-slot-signature + (cl-mapcan + (lambda (proof) + (copy-sequence + (plist-get proof :ancestor-slot-signature))) + proofs) + :layout-snapshot-detail-generation + (plist-get old-state :layout-snapshot-detail-generation) + :static-cascade-p + (cl-every (lambda (proof) + (plist-get proof :static-cascade-p)) + proofs) + :contains-allocation-closure-p t))))) + (defun ebox-incremental--mixed-owner-proof (buffer old-state candidate-state prepared owner-plan) "Return a strict mixed geometry/paint proof, or nil. @@ -6504,13 +7011,24 @@ suppresses the ordinary fallback when any fact is missing." (and geometry-dirty (plist-put (copy-sequence prepared) :dirty-set geometry-dirty))) + ;; Two independent content changes commonly arise from one semantic + ;; action (for example a control label plus status text). Try the + ;; narrow per-owner allocation union before walking and completing a + ;; broader common formatting context that will be discarded when the + ;; two local proofs already close exactly. + (geometry-two-owner-proof + (and (= (length geometry-dirty) 2) + (ebox-incremental--two-owner-allocation-proof + buffer old-state candidate-state geometry-prepared))) (geometry-context-proof - (and (>= (length geometry-dirty) 2) + (and (not geometry-two-owner-proof) + (>= (length geometry-dirty) 2) (ebox-incremental--formatting-context-reflow-proof buffer old-state candidate-state geometry-prepared))) (geometry-plan - (and (not geometry-context-proof) - (>= (length geometry-dirty) 2) + (and (not geometry-two-owner-proof) + (not geometry-context-proof) + geometry-dirty (let ((ebox-incremental--candidate-state-for-slot-proof candidate-state)) (ebox-incremental--layout-owner-plan @@ -6520,7 +7038,15 @@ suppresses the ordinary fallback when any fact is missing." (ebox-incremental--span-patch-projection-proof buffer old-state geometry-prepared candidate-state geometry-plan))) - (geometry-proof (or geometry-context-proof geometry-span-proof)) + (geometry-allocation-proof + (and (= (length geometry-dirty) 1) + (not geometry-context-proof) + (not geometry-span-proof) + (ebox-incremental--allocation-closure-proof + buffer old-state candidate-state geometry-prepared))) + (geometry-proof + (or geometry-context-proof geometry-span-proof + geometry-allocation-proof geometry-two-owner-proof)) (geometry-kind (and geometry-proof (if geometry-context-proof 'formatting-context-reflow @@ -6530,7 +7056,13 @@ suppresses the ordinary fallback when any fact is missing." (geometry-context-proof (list (plist-get geometry-context-proof :owner-id))) (geometry-span-proof - (copy-sequence (plist-get geometry-span-proof :owner-ids))))) + (copy-sequence (plist-get geometry-span-proof :owner-ids))) + (geometry-allocation-proof + (copy-sequence + (plist-get geometry-allocation-proof :owner-ids))) + (geometry-two-owner-proof + (copy-sequence + (plist-get geometry-two-owner-proof :owner-ids))))) (geometry-owner-id (car geometry-owner-ids)) (external-paint-dirty (and geometry-owner-ids @@ -6540,7 +7072,7 @@ suppresses the ordinary fallback when any fact is missing." (lambda (geometry-id) (let ((node-id (plist-get entry :node-id))) (or (equal geometry-id node-id) - (ebox--runtime-ancestor-id-p + (ebox--cached-runtime-ancestor-id-p buffer geometry-id node-id)))) geometry-owner-ids)) paint-dirty))) @@ -6595,13 +7127,19 @@ suppresses the ordinary fallback when any fact is missing." (not (cl-some (lambda (geometry-id) (member geometry-id paint-owner-ids)) geometry-owner-ids)))) + (allocation-disjoint-p + (or (not (or geometry-allocation-proof + geometry-two-owner-proof)) + (ebox-incremental--allocation-closure-paint-disjoint-p + old-state geometry-owner-ids paint-owner-ids))) (cascade-safe-p (or (and (not (plist-get old-state :cascade-active-p)) (not (plist-get old-state :cascade-required-p)) (not (ebox-style-cascade-active-p))) (ebox-surface--cascade-local-owner-proof-p old-state candidate-state dirty-set)))) - (when (and geometry-proof paint-valid-p disjoint-p cascade-safe-p + (when (and geometry-proof paint-valid-p disjoint-p + allocation-disjoint-p cascade-safe-p (not (plist-get prepared :scroll-state-transaction)) (not (plist-get prepared :scroll-patch-fast-p)) root-id @@ -6622,6 +7160,177 @@ suppresses the ordinary fallback when any fact is missing." (length (delete-dups (append geometry-owner-ids paint-owner-ids)))))))) +(defun ebox-incremental--allocation-closure-paint-disjoint-p + (state geometry-owner-ids paint-owner-ids) + "Return non-nil when paint cannot overwrite geometry closure output. +Paint owned by an ancestor of a geometry closure is safe: mixed projection +recomposes that semantic contribution from the fragment baseline after the +closure is rendered. Paint at the geometry owner or below it is rejected, +because it could address a run whose topology the closure just replaced." + (let ((parents (plist-get state :parent-table)) + (geometry-set (make-hash-table :test 'equal)) + (valid t)) + (dolist (node-id geometry-owner-ids) (puthash node-id t geometry-set)) + (dolist (node-id paint-owner-ids) + (let ((walk node-id)) + (while (and valid walk) + (when (gethash walk geometry-set) (setq valid nil)) + (setq walk (and valid (gethash walk parents)))))) + valid)) + +(defun ebox-incremental--allocation-closure-ancestor-stable-p + (old-state candidate-state owner-id) + "Return non-nil when OWNER-ID's retained path through the root is stable." + (let ((old-nodes (plist-get old-state :node-table)) + (new-nodes (plist-get candidate-state :node-table)) + (old-parents (plist-get old-state :parent-table)) + (new-parents (plist-get candidate-state :parent-table)) + (node-id owner-id) + (valid t)) + (while (and valid node-id) + (let* ((old-node (gethash node-id old-nodes)) + (new-node (gethash node-id new-nodes)) + (old-parent (gethash node-id old-parents))) + (setq valid + (and old-node new-node + (equal old-parent (gethash node-id new-parents)) + (ebox-incremental--node-child-ids-equal-p + old-node new-node) + (equal + (ebox-incremental--layout-slot-style-signature old-node) + (ebox-incremental--layout-slot-style-signature new-node)) + (not (eq (ebox-get + (ebox-fragment-style-source-node old-node) + :overflow) + 'visible)) + (not (eq (ebox-get + (ebox-fragment-style-source-node new-node) + :overflow) + 'visible)) + (not (ebox-tree-node-visible-overflow-p old-node)) + (not (ebox-tree-node-visible-overflow-p new-node)))) + (setq node-id old-parent))) + valid)) + +(defun ebox-incremental--allocation-closure-proof + (buffer old-state candidate-state prepared) + "Return a strict retained Flex allocation closure proof, or nil." + (let* ((dirty-set (plist-get prepared :dirty-set)) + (dirty (and (= (length dirty-set) 1) (car dirty-set))) + (dirty-id (and dirty (plist-get dirty :node-id))) + (changed-keys (and dirty (plist-get dirty :changed-keys))) + (old-nodes (plist-get old-state :node-table)) + (new-nodes (plist-get candidate-state :node-table)) + (parents (plist-get old-state :parent-table)) + (candidate-parents (plist-get candidate-state :parent-table)) + (objects (plist-get old-state :surface-node-object-table)) + (root-id (ebox--buffer-root-node-id buffer)) + (candidate-id dirty-id) + (saw-flex-p nil) + (path-valid-p t) + proof) + (when (and dirty dirty-id root-id + (eq (plist-get dirty :dirty-kind) 'geometry) + (memq :content changed-keys) + (not (plist-get dirty :children)) + (null (plist-get dirty :node-ids)) + (cl-every + (lambda (key) + (or (eq key :content) + (eq key :surface-properties) + (memq key ebox--paint-style-signature-keys))) + changed-keys) + (ebox-incremental--same-root-surface-identity-p + (plist-get old-state :root-node) + (plist-get candidate-state :root-node)) + (ebox-incremental--span-patch-identity-stable-p + buffer old-state candidate-state prepared dirty-id) + (null (plist-get old-state :scroll-region-ids)) + (null (plist-get candidate-state :scroll-region-ids)) + (or (and (not (plist-get old-state :cascade-active-p)) + (not (plist-get old-state :cascade-required-p)) + (not (ebox-style-cascade-active-p))) + (ebox-surface--cascade-local-owner-proof-p + old-state candidate-state dirty-set))) + (while (and candidate-id path-valid-p (not proof) + (not (equal candidate-id root-id))) + (let* ((old-node (gethash candidate-id old-nodes)) + (new-node (gethash candidate-id new-nodes)) + (parent-id (gethash candidate-id parents)) + (display (and old-node (ebox-tree-display-inner old-node)))) + (setq saw-flex-p (or saw-flex-p (eq display 'flex))) + (setq path-valid-p + (and old-node new-node + (equal parent-id + (gethash candidate-id candidate-parents)) + (ebox-incremental--node-child-ids-equal-p + old-node new-node) + (equal + (ebox-incremental--layout-slot-style-signature old-node) + (ebox-incremental--layout-slot-style-signature new-node)) + (not (eq (ebox-get + (ebox-fragment-style-source-node old-node) + :overflow) + 'visible)) + (not (eq (ebox-get + (ebox-fragment-style-source-node new-node) + :overflow) + 'visible)) + (not (ebox-tree-node-visible-overflow-p old-node)) + (not (ebox-tree-node-visible-overflow-p new-node)))) + (when (and path-valid-p saw-flex-p + (hash-table-p objects) (gethash candidate-id objects)) + (let* ((snapshot + (ebox-incremental--cached-layout-snapshot-details + buffer candidate-id)) + (spans (and snapshot (plist-get snapshot :buffer-spans))) + (ancestor-signature + (and spans + (ebox-incremental--ancestor-slot-signature + buffer old-state candidate-state candidate-id))) + (allocated-width + (and snapshot + (plist-get + (plist-get snapshot + :external-footprint-signature) + :max-line-pixel-width))) + (content + (plist-get (plist-get dirty :new-signature) :content))) + (when (and spans ancestor-signature allocated-width + (> allocated-width 0) + (stringp content) + (not (string-match-p "\n" content)) + (<= (ebox--string-pixel-width content) + allocated-width) + (ebox--spans-contiguous-lines-p spans) + (with-current-buffer buffer + (ebox--line-spans-cover-whole-lines-p spans)) + (ebox-incremental--allocation-closure-ancestor-stable-p + old-state candidate-state candidate-id)) + (let ((owner-proof + (list :owner-id candidate-id + :dirty-set (copy-sequence dirty-set) + :changed-keys (copy-sequence changed-keys) + :snapshot snapshot + :allocated-width allocated-width + :allocation-closure-p t + :ancestor-slot-signature ancestor-signature + :layout-snapshot-detail-generation + (plist-get old-state + :layout-snapshot-detail-generation) + :static-cascade-p t))) + (setq proof + (list :owner-ids (list candidate-id) + :owner-proofs (list owner-proof) + :dirty-set (copy-sequence dirty-set) + :ancestor-slot-signature ancestor-signature + :layout-snapshot-detail-generation + (plist-get old-state + :layout-snapshot-detail-generation) + :static-cascade-p t)))))) + (setq candidate-id parent-id)))) + proof)) + (defun ebox-incremental--broad-dirty-owner-id (buffer node-ids) "Return one structurally safe broad owner for NODE-IDS in BUFFER." (let* ((root-id (ebox--buffer-root-node-id buffer)) @@ -6782,7 +7491,9 @@ rendered into its retained slot and must pass the complete footprint proof." "Return merged smallest-owner candidates for RENDER-DIRTY-SET." (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)) + (ebox--flex-slot-safety-cache + (or ebox--flex-slot-safety-cache + (make-hash-table :test 'equal))) (index (ebox--make-patch-op-merge-index)) ops) (ebox--with-layout-snapshot-detail-context buffer @@ -6870,15 +7581,28 @@ rendered into its retained slot and must pass the complete footprint proof." OLD-STATE and CANDIDATE-STATE bound the style evidence for DIRTY-SET. Dynamic or changed cascade dependencies cannot use a local owner; a static binding closure may still plan one for a later owner-scoped surface projection." - (and - (or (plist-get old-state :cascade-active-p) - (plist-get old-state :cascade-required-p) - (ebox-style-cascade-active-p)) - (cl-some (lambda (entry) - (not (eq (plist-get entry :dirty-kind) 'paint))) - dirty-set) - (not + (let* ((objects (plist-get old-state :surface-node-object-table)) + (style-states (plist-get old-state :style-binding-states)) + (missing-structure-style-p + (and (hash-table-p objects) (hash-table-p style-states) + (cl-some + (lambda (entry) + (and (eq (plist-get entry :dirty-kind) 'structure) + (when-let ((object + (gethash (plist-get entry :node-id) + objects))) + (null (gethash object style-states))))) + dirty-set)))) (and + (or (plist-get old-state :cascade-active-p) + (plist-get old-state :cascade-required-p) + (ebox-style-cascade-active-p)) + (cl-some (lambda (entry) + (not (eq (plist-get entry :dirty-kind) 'paint))) + dirty-set) + (or missing-structure-style-p + (not + (and (cl-every (lambda (entry) (or @@ -6903,9 +7627,9 @@ closure may still plan one for a later owner-scoped surface projection." buffer entry candidate-state) (ebox-incremental--content-slot-stable-p buffer entry candidate-state))))) - dirty-set) - (ebox-surface--cascade-local-owner-proof-p - old-state candidate-state dirty-set))))) + dirty-set) + (ebox-surface--cascade-local-owner-proof-p + old-state candidate-state dirty-set))))))) (defun ebox-incremental--root-owner-plan (buffer dirty-set) "Return one conservative root owner operation for DIRTY-SET." @@ -6916,6 +7640,15 @@ closure may still plan one for a later owner-scoped surface projection." :dirty (ebox--merge-dirty-provenance-list dirty-set) :proof-skipped-p t)))) +(defun ebox-incremental--native-frame-plan (buffer dirty-set) + "Return the bounded publication plan for a complete native frame." + (when-let ((root-id (ebox--buffer-root-node-id buffer))) + (list + (ebox--patch-op + 'native-frame root-id + :dirty (ebox--merge-dirty-provenance-list dirty-set) + :proof-skipped-p t)))) + (defun ebox-incremental--layout-owner-report (prepared owner-plan candidate-state) "Return Ebox report for PREPARED, OWNER-PLAN, and CANDIDATE-STATE." @@ -7125,6 +7858,69 @@ the complete proof." ebox-incremental--allocated-slot-proof-cache)) allocation))))) +(defun ebox-incremental--retained-ancestor-slot-signature + (old-state candidate-state owner-id) + "Return a still-valid published allocation certificate for OWNER-ID." + (let* ((certificates + (plist-get old-state :retained-allocation-certificates)) + (certificate + (and (hash-table-p certificates) + (gethash owner-id certificates))) + (signature (and certificate (plist-get certificate :signature))) + (old-nodes (plist-get old-state :node-table)) + (new-nodes (plist-get candidate-state :node-table)) + (old-parents (plist-get old-state :parent-table)) + (new-parents (plist-get candidate-state :parent-table)) + (entries signature) + (valid (and signature + (equal (plist-get certificate :root-id) + (plist-get (plist-get old-state :root-node) + :node-id)) + (equal (plist-get certificate :root-id) + (plist-get (plist-get candidate-state :root-node) + :node-id)) + (equal (plist-get certificate :viewport-width) + (plist-get old-state :viewport-width)) + (equal (plist-get certificate :viewport-height) + (plist-get old-state :viewport-height)) + (equal (plist-get certificate :display-signature) + (plist-get old-state :display-signature)) + (equal (plist-get old-state :display-signature) + (plist-get candidate-state :display-signature))))) + (while (and valid entries) + (let* ((entry (car entries)) + (node-id (plist-get entry :node-id)) + (next-id (and (cdr entries) + (plist-get (cadr entries) :node-id))) + (old-node (and node-id (gethash node-id old-nodes))) + (new-node (and node-id (gethash node-id new-nodes))) + (old-parent (and node-id (gethash node-id old-parents))) + (new-parent (and node-id (gethash node-id new-parents))) + (allocated-style (plist-get entry :allocated-style))) + (setq valid + (and old-node new-node allocated-style + (equal old-parent new-parent) + (or (null next-id) (equal old-parent next-id)) + (eq (plist-get old-node :ebox-type) + (plist-get new-node :ebox-type)) + (equal (ebox-tree-node-key old-node) + (ebox-tree-node-key new-node)) + (ebox-incremental--node-child-ids-equal-p + old-node new-node) + (equal allocated-style + (ebox-incremental--layout-slot-style-signature + old-node)) + (equal allocated-style + (ebox-incremental--layout-slot-style-signature + new-node)) + (or (not (equal node-id owner-id)) + (and (equal (ebox-get old-node :width) + (ebox-get new-node :width)) + (equal (ebox-get old-node :height) + (ebox-get new-node :height))))))) + (setq entries (cdr entries))) + (and valid (copy-tree signature)))) + (defun ebox-incremental--compute-ancestor-slot-signature (buffer old-state candidate-state owner-id) "Return stable allocated-slot evidence from OWNER-ID through the root. @@ -7180,7 +7976,7 @@ candidate's unrendered caches." (and valid (nreverse signature)))) (defun ebox-incremental--ancestor-slot-signature - (buffer old-state candidate-state owner-id) + (buffer old-state candidate-state owner-id) "Return candidate-local cached ancestor allocation evidence for OWNER-ID." (let* ((key (cons 'ancestor owner-id)) (missing (make-symbol "ebox-ancestor-proof-missing")) @@ -7190,8 +7986,10 @@ candidate's unrendered caches." (if (and cached (not (eq cached missing))) (unless (eq cached 'no-signature) cached) (let ((signature - (ebox-incremental--compute-ancestor-slot-signature - buffer old-state candidate-state owner-id))) + (or (ebox-incremental--retained-ancestor-slot-signature + old-state candidate-state owner-id) + (ebox-incremental--compute-ancestor-slot-signature + buffer old-state candidate-state owner-id)))) (when ebox-incremental--allocated-slot-proof-cache (puthash key (or signature 'no-signature) ebox-incremental--allocated-slot-proof-cache)) @@ -7973,29 +8771,71 @@ result in the same owner-proof shape consumed by span publication." (old-state prepared candidate-state projection-kind &optional span-proof source-path-copied-p formatting-proof) "Return surface overrides for PREPARED CANDIDATE-STATE." - (let ((scroll-fast-p + (let* ((scroll-fast-p (and (eq projection-kind 'scroll-patch) (plist-get prepared :scroll-patch-fast-p))) + (certificate-proof + (cond + ((memq projection-kind '(span-patch owner-scoped)) span-proof) + ((and (eq projection-kind 'mixed-owner-reflow) + (eq (plist-get (plist-get prepared :mixed-owner-proof) + :geometry-kind) + 'span-patch)) + (plist-get (plist-get prepared :mixed-owner-proof) + :geometry-proof)))) + (certificate-proofs + (and certificate-proof + (or (plist-get certificate-proof :owner-proofs) + (and (plist-get certificate-proof :owner-id) + (list certificate-proof))))) (overrides (list :viewport-width (plist-get old-state :viewport-width) :viewport-height (plist-get old-state :viewport-height) + :runtime-revision (plist-get candidate-state :runtime-revision) :display-signature (plist-get prepared :display-signature) :scroll-state-table (plist-get prepared :scroll-state-table) :native-buffer-scroll-p (plist-get candidate-state :native-buffer-scroll-p) + :native-sync-session + (plist-get candidate-state :native-sync-session) + :native-sync-pending + (plist-get candidate-state :native-sync-pending) + :native-sync-confirmed-p + (plist-get candidate-state :native-sync-confirmed-p) + :native-committed-rendered + (plist-get candidate-state :native-committed-rendered) + :native-committed-fragment-template + (plist-get candidate-state :native-committed-fragment-template) + :native-committed-owned-ranges + (plist-get candidate-state :native-committed-owned-ranges) + :native-fragment-owner-cache + (plist-get candidate-state :native-fragment-owner-cache) :source-path-copied-p source-path-copied-p :runtime-index-prepared-p (or (plist-get prepared :runtime-index-complete-p) (memq projection-kind '(span-patch owner-scoped scroll-patch formatting-context-reflow mixed-owner-reflow - viewport-reflow))) + viewport-reflow native-frame))) :logical-candidate-p (plist-get prepared :logical-candidate-p) :detached-identity-history (plist-get prepared :detached-identity-history)))) + (setq overrides + (plist-put overrides :native-touched-node-ids + (or (plist-get candidate-state + :native-touched-node-ids) + (plist-get prepared :touched-node-ids)))) + (setq overrides + (plist-put overrides :native-removed-node-ids + (plist-get prepared :removed-node-ids))) + (setq overrides + (plist-put + overrides :native-topology-stable-p + (plist-get candidate-state :native-topology-stable-p))) (if (and (not (memq projection-kind '(paint span-patch owner-scoped scroll-patch - formatting-context-reflow mixed-owner-reflow))) + formatting-context-reflow mixed-owner-reflow + native-frame))) (not (plist-get prepared :runtime-index-complete-p))) overrides (append @@ -8115,6 +8955,7 @@ result in the same owner-proof shape consumed by span publication." :mixed-owner-proof (and (eq projection-kind 'mixed-owner-reflow) (plist-get prepared :mixed-owner-proof)) + :allocation-certificate-proofs certificate-proofs :scroll-patch-region-id nil :scroll-state-transaction nil))))) @@ -8130,25 +8971,76 @@ result in the same owner-proof shape consumed by span publication." "Return TP surface input for BUFFER from OLD-STATE and PREPARED runtime." (let* ((ebox-incremental--allocated-slot-proof-cache (make-hash-table :test 'equal)) + (ebox--flex-slot-safety-cache (make-hash-table :test 'equal)) + (ebox--runtime-ancestor-cache (make-hash-table :test 'equal)) (candidate-state (ebox-incremental--candidate-state old-state (plist-get prepared :root) (plist-get prepared :index) prepared)) + (structural-dirty-p + (cl-some (lambda (entry) + (eq (plist-get entry :dirty-kind) 'structure)) + (plist-get prepared :dirty-set))) + (native-program-p + (and (require 'ebox-native-commit nil t) + (ebox-native-commit-plan-eligible-p + old-state candidate-state prepared))) (formatting-proof - (and (not (cl-some + (and (not native-program-p) + (not (cl-some (lambda (entry) (eq (plist-get entry :dirty-kind) 'paint)) (plist-get prepared :dirty-set))) (ebox-incremental--formatting-context-reflow-proof buffer old-state candidate-state prepared))) + (mixed-shape-p + (and (not native-program-p) + (not structural-dirty-p) + (cl-some + (lambda (entry) + (eq (plist-get entry :dirty-kind) 'paint)) + (plist-get prepared :dirty-set)) + (>= (cl-count-if + (lambda (entry) + (eq (plist-get entry :dirty-kind) 'geometry)) + (plist-get prepared :dirty-set)) + 1))) + (mixed-proof + (and mixed-shape-p + (ebox-incremental--mixed-owner-proof + buffer old-state candidate-state prepared nil))) + (mixed-owner-plan + (and mixed-proof + ;; The proof already owns the exact geometry/paint union. + ;; These lightweight ops feed the generic report and scope + ;; builders; they never re-run layout ownership planning. + (append + (mapcar + (lambda (owner-id) + (ebox--patch-op + (if (eq (plist-get mixed-proof :geometry-kind) + 'formatting-context-reflow) + 'formatting-context-reflow + 'span-patch) + owner-id)) + (plist-get mixed-proof :geometry-owner-ids)) + (mapcar (lambda (owner-id) + (ebox--patch-op 'paint-patch owner-id)) + (plist-get mixed-proof :paint-owner-ids))))) (owner-plan - (if formatting-proof + (cond + (native-program-p + (ebox-incremental--native-frame-plan + buffer (plist-get prepared :dirty-set))) + (mixed-owner-plan mixed-owner-plan) + (formatting-proof (list (ebox--patch-op 'formatting-context-reflow (plist-get formatting-proof :owner-id) :dirty (plist-get formatting-proof :dirty-set) - :proof formatting-proof)) + :proof formatting-proof))) + (t (let ((ebox-incremental--candidate-state-for-slot-proof candidate-state)) (if (ebox-incremental--cascade-root-owner-plan-p @@ -8158,10 +9050,7 @@ result in the same owner-proof shape consumed by span publication." buffer (plist-get prepared :dirty-set)) (ebox-incremental--layout-owner-plan buffer old-state candidate-state - (plist-get prepared :dirty-set)))))) - (mixed-proof - (ebox-incremental--mixed-owner-proof - buffer old-state candidate-state prepared owner-plan)) + (plist-get prepared :dirty-set))))))) (same-root-identity-p (ebox-incremental--same-root-surface-identity-p (plist-get old-state :root-node) @@ -8176,14 +9065,16 @@ result in the same owner-proof shape consumed by span publication." (ebox-incremental--layout-owner-report prepared owner-plan candidate-state)) (paint-p - (ebox-incremental--paint-projection-p - old-state owner-plan (plist-get prepared :dirty-set))) + (and (not native-program-p) + (ebox-incremental--paint-projection-p + old-state owner-plan (plist-get prepared :dirty-set)))) (span-proof - (unless (or paint-p mixed-proof) + (unless (or native-program-p structural-dirty-p paint-p mixed-proof) (ebox-incremental--span-patch-projection-proof buffer old-state prepared candidate-state owner-plan))) (projection-kind (cond + (native-program-p 'native-frame) (mixed-proof 'mixed-owner-reflow) (formatting-proof 'formatting-context-reflow) (paint-p 'paint) diff --git a/ebox-layout.el b/ebox-layout.el index 0582d85..1dcd0a7 100644 --- a/ebox-layout.el +++ b/ebox-layout.el @@ -13,14 +13,56 @@ (require 'ebox-measure) (require 'ebox-fragment) (require 'ebox-render-context) +(require 'ebox-buffer-backend) (declare-function ebox--render-grid "ebox-grid" (node)) +(declare-function ebox--render-flex "ebox-flex" (node)) +(declare-function ebox--render-with-cache + "ebox-incremental" (node &optional force cache-probe)) +(declare-function ebox-create "ebox" (&rest plist)) +(declare-function ebox-get "ebox" (box property)) +(declare-function ebox--ensure-node-id "ebox" (node)) +(declare-function ebox--ensure-region-id "ebox" (box)) +(declare-function ebox--scroll-clear-state "ebox" (region-id)) +(declare-function ebox--scroll-set-state "ebox" (region-id state)) +(declare-function ebox--apply-hidden-visibility "ebox" (string)) +(declare-function ebox-string-lines "ebox" (string)) +(declare-function ebox-string-height "ebox" (string)) +(declare-function ebox-lines-join "ebox" (lines)) +(declare-function ebox--maplines "ebox" (function string)) +(declare-function ebox--line-has-non-content-properties-p "ebox" (line)) +(declare-function ebox--lines-align-vertical + "ebox" (string height align)) +(declare-function ebox--lines-concat-horizontal "ebox" (&rest strings)) +(declare-function ebox--lines-stack-vertical "ebox" (&rest strings)) +(declare-function ebox--add-content-owner + "ebox-buffer-backend" (string region-id)) +(declare-function ebox--pixel-border + "ebox-buffer-backend" + (pixel-width height &optional color)) +(declare-function ebox--propertize-color + "ebox-buffer-backend" (string color)) +(declare-function ebox--propertize-colors + "ebox-buffer-backend" (string color bgcolor)) +(declare-function ebox--propertize-content-line + "ebox-buffer-backend" (line region-id idx fallback)) +(declare-function ebox--propertize-overline + "ebox-buffer-backend" (string &optional color)) +(declare-function ebox--propertize-region + "ebox-buffer-backend" (string property region-id)) +(declare-function ebox--propertize-typography + "ebox-buffer-backend" (string style)) +(declare-function ebox--propertize-underline + "ebox-buffer-backend" (string &optional color)) (defvar ebox--render-runtime-revision nil "Current buffer runtime revision for render-local cache validation.") (defvar ebox--region-id-counter) (defvar ebox--runtime-node-id-counter) +(defvar ebox--region-box-table) +(defvar ebox--render-region-id) +(defvar ebox--defer-scroll-content-index) (defvar ebox--intrinsic-layout-measurement nil "Non-nil while measuring intrinsic layout contributions. diff --git a/ebox-measure.el b/ebox-measure.el index ffae5d1..6b476d3 100644 --- a/ebox-measure.el +++ b/ebox-measure.el @@ -11,6 +11,8 @@ (declare-function ebox--register-render-owned-text-value "ebox-render-context" (property value)) +(declare-function ebox-string-lines "ebox" (string)) +(declare-function ebox--string-repeat-lines "ebox" (string count)) ;; Measurement Cache Model: ;; These caches only accelerate display-signature-sensitive pixel measurement. @@ -115,14 +117,18 @@ of pixel measurements it triggers do not each rebuild the display signature.") (fillarray ebox--string-pixel-width-cache-ring nil) (setq ebox--string-pixel-width-cache-ring-index 0)) -(defun ebox--string-pixel-width-visual-key (line factor) - "Return a width cache key for LINE at FACTOR when visual properties are constant." - (let ((end (length line))) +(defun ebox--string-pixel-width-visual-key + (line factor &optional start end) + "Return a width cache key for LINE slice at FACTOR. +START and END default to the complete string; visual properties must be +constant across the selected slice." + (let ((start (or start 0)) + (end (or end (length line)))) (list factor - (substring-no-properties line) + (substring-no-properties line start end) (cl-loop for property in '(face display) - for value = (and (> end 0) - (get-text-property 0 property line)) + for value = (and (> end start) + (get-text-property start property line)) when value append (list property value))))) (defun ebox--string-pixel-width-cache-key (line factor) @@ -140,8 +146,9 @@ of pixel measurements it triggers do not each rebuild the display signature.") (and constant-p (ebox--string-pixel-width-visual-key line factor)))) -(defun ebox--cached-string-pixel-width (line factor key) - "Return LINE width at FACTOR through the render and display caches at KEY." +(defun ebox--cached-string-pixel-width + (line factor key &optional start end) + "Return LINE slice width at FACTOR through both caches at KEY." (let ((cached (or (and ebox--render-string-pixel-width-cache (gethash key ebox--render-string-pixel-width-cache)) @@ -151,7 +158,10 @@ of pixel measurements it triggers do not each rebuild the display signature.") (when ebox--render-string-pixel-width-cache (puthash key cached ebox--render-string-pixel-width-cache)) cached) - (let* ((width (ebox--scaled-string-pixel-width line factor)) + (let* ((sample (if (or start end) + (substring line (or start 0) (or end (length line))) + line)) + (width (ebox--scaled-string-pixel-width sample factor)) (index ebox--string-pixel-width-cache-ring-index) (evicted (aref ebox--string-pixel-width-cache-ring index))) (when ebox--render-string-pixel-width-cache @@ -188,21 +198,19 @@ keep their absolute pixel width." minimize (or (next-single-property-change pos property line end) end))) - (segment (substring line pos next)) - ;; The segment ends at every visual property change, so its - ;; width key can skip the second constant-property scan. - (segment-key - (ebox--string-pixel-width-visual-key segment factor)) (space-width (ebox--display-space-width display))) (setq total (+ total (if space-width (* space-width (- next pos)) - (if segment-key - (ebox--cached-string-pixel-width - segment factor segment-key) - (ebox--scaled-string-pixel-width - segment factor))))) + ;; The segment ends at every visual property change. + ;; Build only its property-free cache key on hits; a + ;; propertized substring is materialized solely on miss. + (let ((segment-key + (ebox--string-pixel-width-visual-key + line factor pos next))) + (ebox--cached-string-pixel-width + line factor segment-key pos next))))) (setq pos next))) total))) diff --git a/ebox-native-commit.el b/ebox-native-commit.el new file mode 100644 index 0000000..4b00a49 --- /dev/null +++ b/ebox-native-commit.el @@ -0,0 +1,513 @@ +;;; ebox-native-commit.el --- Thin native frame commit for Ebox -*- lexical-binding: t; -*- + +;; SPDX-License-Identifier: GPL-3.0-or-later + +;;; Commentary: + +;; Converts one pure native frame into candidate-local Ebox state. Rust owns +;; layout, paint-property composition, and the fragment effect tape; this file +;; only validates the side-effect boundary and installs already computed facts. + +;;; Code: + +(require 'cl-lib) + +(declare-function ebox-native-reflow-layout-ready-p "ebox-native-reflow" ()) +(declare-function ebox-native-reflow-execute-sync + "ebox-native-reflow" (node frame &optional layout-package)) +(declare-function ebox-native-reflow-execute-session-sync + "ebox-native-reflow" + (session node frame &optional layout-package state)) +(declare-function ebox-native-reflow-create-session + "ebox-native-reflow" (&rest arguments)) +(declare-function ebox-native-reflow-confirm-native-frame + "ebox-native-reflow" + (session generation key confirmed-revision)) +(declare-function ebox-native-reflow-session-generation + "ebox-native-reflow" (session)) +(declare-function ebox-native-reflow-frame-fragments + "ebox-native-reflow" (frame)) +(declare-function ebox--literal-root-pixel-width "ebox" (box)) +(declare-function ebox-get "ebox" (box key)) +(declare-function ebox-style-cascade-active-p "ebox-style" ()) +(declare-function ebox-tree-node-key "ebox-tree" (node)) +(declare-function ebox-tree--children-raw "ebox-tree" (node)) +(declare-function ebox--node-direct-viewport-width-dependent-p + "ebox-incremental" (node)) +(declare-function ebox--node-direct-viewport-height-dependent-p + "ebox-incremental" (node)) +(declare-function ebox--render-cache-contained-viewport-node-p + "ebox-incremental" (node)) + +(defun ebox-native-commit-topology-stable-p + (old-state candidate-state prepared) + "Return non-nil when PREPARED preserves OLD-STATE's object topology." + (let ((old-nodes (plist-get old-state :node-table)) + (new-nodes (plist-get candidate-state :node-table)) + (old-parents (plist-get old-state :parent-table)) + (new-parents (plist-get candidate-state :parent-table)) + (old-regions (plist-get old-state :region-node-table)) + (new-regions (plist-get candidate-state :region-node-table)) + (old-objects (plist-get old-state :surface-node-object-table)) + (stable t) + (missing (make-symbol "native-topology-missing"))) + (setq stable + (and (null (plist-get prepared :removed-node-ids)) + (not (cl-some + (lambda (entry) + (eq (plist-get entry :dirty-kind) 'structure)) + (plist-get prepared :dirty-set))) + (hash-table-p old-nodes) (hash-table-p new-nodes) + (hash-table-p old-parents) (hash-table-p new-parents) + (hash-table-p old-regions) (hash-table-p new-regions) + (hash-table-p old-objects) + (= (hash-table-count old-nodes) + (hash-table-count new-nodes)) + (= (hash-table-count old-regions) + (hash-table-count new-regions)))) + (when stable + (maphash + (lambda (node-id node) + (let ((old-node (gethash node-id old-nodes missing))) + (unless (and (not (eq old-node missing)) + (gethash node-id old-objects) + (equal (gethash node-id old-parents missing) + (gethash node-id new-parents missing)) + (eq (plist-get old-node :ebox-type) + (plist-get node :ebox-type)) + (equal (ebox-tree-node-key old-node) + (ebox-tree-node-key node))) + (setq stable nil)))) + new-nodes)) + (when stable + (maphash + (lambda (region-id node-id) + (unless (equal (gethash region-id old-regions missing) node-id) + (setq stable nil))) + new-regions)) + stable)) + +(defun ebox-native-commit-layout-touched-node-ids (candidate-state prepared) + "Return PREPARED's exact dirty-to-root closure in CANDIDATE-STATE." + (let ((parents (plist-get candidate-state :parent-table)) + (seen (make-hash-table :test 'equal)) + result) + (dolist (entry (plist-get prepared :dirty-set)) + (let ((node-id (plist-get entry :node-id))) + (while (and node-id (not (gethash node-id seen))) + (puthash node-id t seen) + (push node-id result) + (setq node-id (and parents (gethash node-id parents)))))) + (nreverse result))) + +(defun ebox-native-commit--node-position-table (state) + "Return NODE-ID to `(PARENT-ID . POSITION)' table for STATE." + (let ((nodes (plist-get state :node-table)) + (table (make-hash-table :test 'equal))) + (when (hash-table-p nodes) + (maphash + (lambda (parent-id node) + (cl-loop for child in (ebox-tree--children-raw node) + for position from 0 + do (puthash (plist-get child :node-id) + (cons parent-id position) table))) + nodes) + (when-let ((root (plist-get state :root-node))) + (puthash (plist-get root :node-id) (cons nil 0) table))) + table)) + +(defun ebox-native-commit-object-delta-node-ids + (old-state candidate-state prepared) + "Return the exact preorder TP object delta, or nil. +Compare OLD-STATE with CANDIDATE-STATE under PREPARED's local index proof. +Every old node absent from CANDIDATE-STATE must be named by PREPARED's removed +set. Every new, replaced, moved, reparented, or explicitly touched node enters +the result. Untouched nodes are authorized only when their old TP object, +type, key, parent, and sibling position are identical." + (let* ((old-root (plist-get old-state :root-node)) + (new-root (plist-get candidate-state :root-node)) + (old-nodes (plist-get old-state :node-table)) + (new-nodes (plist-get candidate-state :node-table)) + (old-objects (plist-get old-state :surface-node-object-table)) + (old-positions (ebox-native-commit--node-position-table old-state)) + (new-positions + (ebox-native-commit--node-position-table candidate-state)) + (requested (make-hash-table :test 'equal)) + (removed (make-hash-table :test 'equal)) + (replaced (make-hash-table :test 'equal)) + (missing (make-symbol "native-object-delta-missing")) + (valid + (and old-root new-root + (equal (plist-get old-root :node-id) + (plist-get new-root :node-id)) + (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)) + (hash-table-p old-nodes) (hash-table-p new-nodes) + (hash-table-p old-objects))) + result) + (dolist (node-id (plist-get prepared :touched-node-ids)) + (if (gethash node-id new-nodes) + (puthash node-id t requested) + (setq valid nil))) + (dolist (node-id (plist-get prepared :removed-node-ids)) + (if (and (gethash node-id old-nodes) + (not (gethash node-id new-nodes))) + (puthash node-id t removed) + (setq valid nil))) + (when valid + (maphash + (lambda (node-id _node) + (when (and (not (gethash node-id new-nodes)) + (not (gethash node-id removed))) + (setq valid nil))) + old-nodes)) + (when valid + (cl-labels + ((visit (node) + (let* ((node-id (plist-get node :node-id)) + (old-node (gethash node-id old-nodes missing)) + (old-object (gethash node-id old-objects)) + (position (gethash node-id new-positions)) + (parent-id (car-safe position)) + (replacement-p + (or (eq old-node missing) + (null old-object) + (not (eq (plist-get old-node :ebox-type) + (plist-get node :ebox-type))) + (not (equal (ebox-tree-node-key old-node) + (ebox-tree-node-key node))))) + (touched-p + (or replacement-p + (gethash node-id requested) + (gethash parent-id replaced) + (not (equal (gethash node-id old-positions missing) + position))))) + (unless (and node-id position) + (setq valid nil)) + (when replacement-p (puthash node-id t replaced)) + (when touched-p (push node-id result)) + (dolist (child (ebox-tree--children-raw node)) + (visit child))))) + (visit new-root))) + (and valid result (nreverse result)))) + +(defun ebox-native-commit-context-axes-stable-p + (old-state candidate-state prepared) + "Return non-nil when PREPARED preserves viewport-axis membership." + (let ((old-nodes (plist-get old-state :node-table)) + (new-nodes (plist-get candidate-state :node-table)) + stable) + (setq stable + (and (plist-get old-state :viewport-dependent-node-ids-ready) + (plist-member old-state :viewport-dependent-node-id-axes) + (hash-table-p old-nodes) + (hash-table-p new-nodes))) + (dolist (entry (plist-get prepared :dirty-set) stable) + (let* ((node-id (plist-get entry :node-id)) + (old (and stable (gethash node-id old-nodes))) + (new (and stable (gethash node-id new-nodes)))) + (unless (and old new + (eq (ebox--node-direct-viewport-width-dependent-p old) + (ebox--node-direct-viewport-width-dependent-p new)) + (eq (ebox--node-direct-viewport-height-dependent-p old) + (ebox--node-direct-viewport-height-dependent-p new)) + (eq (ebox--render-cache-contained-viewport-node-p old) + (ebox--render-cache-contained-viewport-node-p new))) + (setq stable nil)))))) + +(defun ebox-native-commit-projection-eligible-p + (previous-state state-overrides stylesheet-active-p) + "Return non-nil when a candidate may use changed-only TP projection." + (let ((state (append state-overrides nil))) + (and previous-state + (not stylesheet-active-p) + (require 'ebox-native-reflow nil t) + (ebox-native-reflow-layout-ready-p) + (hash-table-p (plist-get previous-state + :surface-node-object-table)) + (or (plist-get state :native-topology-stable-p) + (consp (plist-get state :native-touched-node-ids))) + (ebox-native-commit--runtime-types-supported-p state) + (ebox-native-commit--region-index-safe-p state)))) + +(defun ebox-native-commit-plan-eligible-p + (old-state candidate-state prepared) + "Return non-nil when PREPARED can bypass superseded local layout proofs." + (let* ((stable + (ebox-native-commit-topology-stable-p + old-state candidate-state prepared)) + (context-axes-stable + (and stable + (ebox-native-commit-context-axes-stable-p + old-state candidate-state prepared))) + (layout-touched + (if stable + (ebox-native-commit-layout-touched-node-ids + candidate-state prepared) + (ebox-native-commit-object-delta-node-ids + old-state candidate-state prepared))) + (state (copy-sequence candidate-state))) + (plist-put candidate-state :native-topology-stable-p stable) + (plist-put candidate-state :native-touched-node-ids layout-touched) + (when context-axes-stable + (plist-put candidate-state :viewport-dependent-node-ids-ready t) + (plist-put candidate-state :viewport-dependent-node-ids + (plist-get old-state :viewport-dependent-node-ids)) + (plist-put candidate-state :viewport-dependent-node-id-axes + (plist-get old-state :viewport-dependent-node-id-axes))) + (unless stable + (cl-remf candidate-state :native-fragment-owner-cache)) + (plist-put state :native-touched-node-ids layout-touched) + (plist-put state :native-topology-stable-p stable) + (and (or stable layout-touched) + (not (ebox-style-cascade-active-p)) + (ebox-native-commit-projection-eligible-p + old-state state nil)))) + +(defun ebox-native-commit--root-box (node) + "Return NODE's outer box, or nil." + (pcase (plist-get node :ebox-type) + ('box node) + ('flex (plist-get node :box)))) + +(defun ebox-native-commit--region-box (state region-id) + "Resolve REGION-ID's candidate box directly from STATE indexes." + (when-let* ((node-id (gethash region-id + (plist-get state :region-node-table))) + (node (gethash node-id (plist-get state :node-table)))) + (pcase (plist-get node :ebox-type) + ('box node) + ('flex (plist-get node :box))))) + +(defun ebox-native-commit--region-index-safe-p (state) + "Return non-nil when STATE has an unambiguous, non-scrolling region index." + (let ((counts (plist-get state :region-box-count-table)) + (scroll (plist-get state :scroll-state-table)) + safe) + (setq safe + (and (hash-table-p counts) + (or (null scroll) + (and (hash-table-p scroll) + (zerop (hash-table-count scroll)))))) + (when safe + (maphash + (lambda (region-id count) + (let ((box (ebox-native-commit--region-box state region-id))) + (unless (and (= count 1) box + (zerop (or (ebox-get box :scroll-offset) 0))) + (setq safe nil)))) + counts)) + safe)) + +(defun ebox-native-commit--runtime-types-supported-p (state) + "Return non-nil when STATE contains only executable native node types." + (let ((nodes (plist-get state :node-table)) + (supported t)) + (when (hash-table-p nodes) + (maphash + (lambda (_node-id node) + (unless (memq (plist-get node :ebox-type) + '(box concat stack flex flex-item)) + (setq supported nil))) + nodes)) + supported)) + +(defun ebox-native-commit--install-region-boxes (state) + "Install STATE's already indexed region boxes without walking its tree." + (let ((table (plist-get state :region-box-table))) + (unless (hash-table-p table) + (setq table (make-hash-table :test 'equal)) + (plist-put state :region-box-table table)) + (when (zerop (hash-table-count table)) + (maphash + (lambda (region-id _count) + (puthash region-id + (ebox-native-commit--region-box state region-id) + table)) + (plist-get state :region-box-count-table))) + table)) + +(defun ebox-native-commit--frame-spec (state node) + "Return the exact native execution frame for STATE and NODE." + (let* ((viewport-width (plist-get state :viewport-width)) + (viewport-height (plist-get state :viewport-height)) + (root-box (ebox-native-commit--root-box node)) + (root-width + (or (and root-box (ebox--literal-root-pixel-width root-box)) + viewport-width))) + (when (and (integerp viewport-width) (> viewport-width 0) + (integerp viewport-height) (> viewport-height 0) + (integerp root-width) (> root-width 0)) + (append + (list :key 1 + :viewport-width viewport-width + :viewport-height viewport-height + :root-width root-width + :runtime-revision + (or (plist-get state :runtime-revision) 0) + :context-hash + (sxhash-equal + (list viewport-width viewport-height root-width + (plist-get state :display-signature))) + :complete t + :root-metadata nil) + (when (plist-get state :native-sync-confirmed-p) + (list :base-viewport-width viewport-width + :base-viewport-height viewport-height + :base-root-width root-width)))))) + +(defun ebox-native-commit--apply-patches (source patches) + "Return SOURCE with ordered native PATCHES applied." + (let ((cursor 0) pieces) + (dolist (patch patches) + (let ((start (plist-get patch :old-start)) + (end (plist-get patch :old-end)) + (replacement (plist-get patch :replacement))) + (unless (and (integerp start) (integerp end) + (<= cursor start end (length source)) + (stringp replacement)) + (error "Native retained frame has an invalid patch")) + (push (substring source cursor start) pieces) + (push replacement pieces) + (setq cursor end))) + (push (substring source cursor) pieces) + (apply #'concat (nreverse pieces)))) + +(defun ebox-native-commit--apply-fragment-style-delta (template delta) + "Return TEMPLATE with Rust-computed fragment style DELTA applied." + (unless (vectorp template) + (error "Native fragment style delta has no retained template")) + (let ((target (copy-sequence template))) + (dolist (entry delta) + (let ((index (car entry))) + (unless (and (integerp index) (<= 0 index) (< index (length target))) + (error "Native fragment style delta index is out of bounds")) + (let ((record (copy-sequence (aref target index)))) + (aset record 7 (copy-sequence (cdr entry))) + (aset target index record)))) + target)) + +(defun ebox-native-commit--promote-pending-frame (state session) + "Promote STATE's previously published pending frame in SESSION." + (when-let ((pending (plist-get state :native-sync-pending))) + (ebox-native-reflow-confirm-native-frame + session (plist-get pending :generation) (plist-get pending :key) + (plist-get pending :confirmed-revision)) + (plist-put state :native-sync-confirmed-p t) + (cl-remf state :native-sync-pending))) + +(defun ebox-native-commit--retained-frame (state node frame-spec) + "Return NODE's full or patch-derived retained native frame for STATE." + (let ((session + (or (plist-get state :native-sync-session) + (let ((created + (ebox-native-reflow-create-session + :workers 1 :max-jobs 4 :max-results 4))) + (plist-put state :native-sync-session created) + created)))) + (ebox-native-commit--promote-pending-frame state session) + (let* ((result + (ebox-native-reflow-execute-session-sync + session node frame-spec nil state)) + (generation (ebox-native-reflow-session-generation session)) + (frame + (if (plist-get result :native-patch) + (let* ((source (plist-get state :native-committed-rendered)) + (metadata (plist-get result :root-render-metadata)) + (reuse-fragments + (plist-get result :reuse-fragment-template)) + (fragment-style-delta + (plist-get result :fragment-style-delta)) + (rendered nil)) + (unless (stringp source) + (error "Native retained patch has no committed base")) + (when (or reuse-fragments fragment-style-delta) + (let ((template + (plist-get state + :native-committed-fragment-template))) + (unless (vectorp template) + (error "Native retained patch has no fragment base")) + (when fragment-style-delta + (setq template + (ebox-native-commit--apply-fragment-style-delta + template fragment-style-delta))) + (setq metadata + (plist-put metadata :fragment-span-template + template)))) + (when (and (plist-get result :reuse-ownership-template) + (plist-get state :native-topology-stable-p)) + (plist-put state :native-reuse-ownership-p t)) + (when (and (plist-get result :reuse-mount-projection) + (plist-get state :native-topology-stable-p)) + (plist-put state :native-reuse-mount-projection-p t)) + (setq rendered + (ebox-native-commit--apply-patches + source (plist-get result :patches))) + (plist-put state :native-coordinate-patches + (plist-get result :coordinate-patches)) + (list :native-frame t :rendered rendered + :root-render-metadata metadata + :effect-tape metadata + :line-widths [] + :patches (plist-get result :patches) + :coordinate-patches + (plist-get result :coordinate-patches) + :base-character-count + (plist-get result :base-character-count) + :target-character-count + (plist-get result :target-character-count) + :styles (plist-get result :styles) + :property-templates + (plist-get result :property-templates) + :native-patch t + :reuse-fragment-template reuse-fragments + :reuse-ownership-template + (plist-get result :reuse-ownership-template) + :reuse-mount-projection + (plist-get result :reuse-mount-projection) + :fragment-style-delta fragment-style-delta)) + result))) + (plist-put state :native-sync-pending + (list :generation generation :key 1 + :confirmed-revision + (1+ (or (plist-get state :runtime-revision) 0)))) + (plist-put state :native-frame-kind + (if (plist-get frame :native-patch) 'patch 'full)) + frame))) + +(defun ebox-native-commit-render (state node) + "Return NODE's native rendered string after committing effects into STATE. +Return nil without mutating STATE when the native capability proof is absent." + (when (and (require 'ebox-native-reflow nil t) + (ebox-native-reflow-layout-ready-p) + (memq (plist-get state :projection-kind) '(nil native-frame)) + (ebox-native-commit--runtime-types-supported-p state) + (ebox-native-commit--region-index-safe-p state)) + (when-let ((frame-spec (ebox-native-commit--frame-spec state node))) + (condition-case err + (let* ((frame (ebox-native-commit--retained-frame + state node frame-spec)) + (effects (plist-get frame :effect-tape)) + (rendered (plist-get frame :rendered)) + (fragment-template + (and (not (plist-get effects :scroll-window-p)) + (plist-get effects :fragment-span-template)))) + (when (and (stringp rendered) (vectorp fragment-template)) + (ebox-native-commit--install-region-boxes state) + (plist-put state :render-owned-text-values + (make-hash-table :test #'eq)) + (plist-put state :native-render-output rendered) + (plist-put state :native-render-frame frame) + (plist-put state :native-render-fragment-template + fragment-template) + (plist-put state :native-render-p t) + rendered)) + (error + (plist-put state :native-render-fallback + (error-message-string err)) + nil))))) + +(provide 'ebox-native-commit) +;;; ebox-native-commit.el ends here diff --git a/ebox-native-reflow.el b/ebox-native-reflow.el index 080f4ee..5796979 100644 --- a/ebox-native-reflow.el +++ b/ebox-native-reflow.el @@ -28,6 +28,10 @@ (session generation key)) (declare-function ebox-native--module-render-proof "ebox_native_reflow" (payload)) +(declare-function ebox-native--module-render-frame "ebox_native_reflow" + (payload)) +(declare-function ebox-native--module-render-session-frame "ebox_native_reflow" + (session generation payload)) (declare-function ebox-native--module-flex-size-lines "ebox_native_reflow" (payload main-limit main-gap)) (declare-function ebox-native--module-confirm-frame "ebox_native_reflow" @@ -57,6 +61,8 @@ (declare-function ebox--horizontal-size-pixels "ebox-layout" (value &optional default)) (declare-function ebox--layout-children "ebox-tree" (node)) +(declare-function ebox-tree-layout-children "ebox-tree" (node)) +(declare-function ebox-tree-node-local-source-signature "ebox-tree" (node)) (declare-function ebox-buffer-side-border-face "ebox-buffer-backend" (color)) (declare-function ebox--space-pixel-width "ebox-measure" ()) @@ -66,10 +72,13 @@ (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 tp-property-value-copy "tp-core" (value)) +(declare-function tp-paint-slot-p "tp-style" (value)) +(declare-function tp-paint-slot-face "tp-style" (slot)) (defvar ebox--flex-content-min-width-table) -(defconst ebox-native-reflow-abi-version "8:3:9" +(defconst ebox-native-reflow-abi-version "9:7:12" "Version tuple shared by the native module, layout IR, and render tape.") (defconst ebox-native-reflow--minimum-rust-version "1.82.0" @@ -195,6 +204,8 @@ module while loading the package." generation styles layout-package + layout-fragment-cache + layout-fragment-revision readiness-process readiness-preparation released-p) @@ -264,6 +275,15 @@ module while loading the package." (defvar ebox-native-reflow--compile-property-template-ids nil "Dynamically bound opaque property-template lookup table.") +(defvar ebox-native-reflow--source-cluster-cache + (make-hash-table :test #'equal) + "Bounded cache of display-specific measured source clusters.") + +(defconst ebox-native-reflow--source-cluster-cache-limit 4096 + "Maximum measured source runs retained by the native compiler.") + +(defvar ebox-native-reflow--compile-display-signature nil) + (defconst ebox-native-reflow--unsafe-opaque-properties '(category composition @@ -286,13 +306,13 @@ validation.") (defconst ebox-native-reflow--tape-magic (unibyte-string ?E ?B ?X ?T)) -(defconst ebox-native-reflow--tape-version 9) +(defconst ebox-native-reflow--tape-version 12) (defconst ebox-native-reflow--tape-header-length 112) (defconst ebox-native-reflow--tape-flag-ok 1) (defconst ebox-native-reflow--tape-flag-complete 2) (defconst ebox-native-reflow--tape-flag-patch 4) -(defconst ebox-native-reflow--tape-body-preamble-length 24) -(defconst ebox-native-reflow--tape-patch-body-preamble-length 32) +(defconst ebox-native-reflow--tape-body-preamble-length 40) +(defconst ebox-native-reflow--tape-patch-body-preamble-length 56) (defconst ebox-native-reflow--tape-patch-descriptor-length 32) (defconst ebox-native-reflow--tape-metadata-role-symbols '(content content-owner pt pb pl pr mt mb ml mr bt bb bl br) @@ -1284,7 +1304,9 @@ Ebox nodes, identities, text, properties, or publication state." (ebox-native-reflow--make-session :handle (ebox-native--module-create-session workers max-jobs max-results max-result-bytes) - :generation 0))) + :generation 0 + :layout-fragment-cache (make-hash-table :test 'equal) + :layout-fragment-revision 0))) (defun ebox-native-reflow--live-handle (session) "Return SESSION's live module handle or signal an error." @@ -1336,8 +1358,12 @@ Ebox nodes, identities, text, properties, or publication state." "Register opaque text PROPERTIES for LABEL and return its native id." (when properties (ebox-native-reflow--validate-property-template properties label) - (let* ((entry (list :kind (intern label) - :properties (copy-sequence properties))) + (let* ((owned-properties + (cl-loop for (property value) on properties by #'cddr + append (list property + (tp-property-value-copy value)))) + (entry (list :kind (intern label) + :properties owned-properties)) (missing (make-symbol "missing-property-template")) (existing (if (hash-table-p @@ -1417,15 +1443,39 @@ Ebox nodes, identities, text, properties, or publication state." (next (or (next-property-change position line) length)) (template-id (ebox-native-reflow--register-property-template - properties "source"))) - (dolist (cluster (append (ebox--grapheme-clusters - (substring line position next)) - nil)) - (setq cluster - (plist-put cluster :text - (substring-no-properties - (plist-get cluster :text)))) - (push (ebox-native-reflow--compile-cluster cluster template-id) + properties "source")) + (plain (substring-no-properties line position next)) + (cache-key + (list ebox-native-reflow--compile-display-signature + properties plain)) + (missing (make-symbol "native-source-clusters-missing")) + (measured + (gethash cache-key ebox-native-reflow--source-cluster-cache + missing))) + (when (eq measured missing) + (setq measured + (vconcat + (mapcar + (lambda (cluster) + (setq cluster (copy-sequence cluster)) + (plist-put cluster :text + (substring-no-properties + (plist-get cluster :text))) + (ebox-native-reflow--compile-cluster cluster)) + (append + (ebox--grapheme-clusters + (substring line position next)) + nil)))) + (when (>= (hash-table-count + ebox-native-reflow--source-cluster-cache) + ebox-native-reflow--source-cluster-cache-limit) + (clrhash ebox-native-reflow--source-cluster-cache)) + (puthash cache-key measured + ebox-native-reflow--source-cluster-cache)) + (dolist (cluster (append measured nil)) + (push (append (copy-sequence cluster) + (when template-id + (list :source-template-id template-id))) clusters)) (setq position next))) (vconcat (nreverse clusters)))) @@ -1511,6 +1561,14 @@ When FORMATTED-P is non-nil, preserve exact display spaces produced by KP." "Register additive FACE and return its id, or nil." (and face (ebox-native-reflow--register-style 'add face))) +(defun ebox-native-reflow--compile-foreground-face (color) + "Return the serializable native face for Ebox foreground COLOR." + (when color + (cond + ((tp-paint-slot-p color) (tp-paint-slot-face color)) + ((eq color 'ebox/default-foreground) '(:inherit default)) + (t (list :foreground color))))) + (defun ebox-native-reflow--compile-border-face-style (width color) "Register the set-face operation for a border of WIDTH and COLOR." (and (> width 0) @@ -1586,12 +1644,15 @@ Rust still verifies the dynamic equal-width and nonempty-line proof per frame." (border-right (floor (or (ebox-get box :border-right-pixel) 0))) (foreground-style (ebox-native-reflow--compile-add-face-style - (and (ebox-get box :color) - (list :foreground (ebox-get box :color))))) + (ebox-native-reflow--compile-foreground-face + (ebox-get box :color)))) (background-style (ebox-native-reflow--compile-add-face-style - (and (ebox-get box :bgcolor) - (list :background (ebox-get box :bgcolor))))) + (let ((background (ebox-get box :bgcolor))) + (cond + ((tp-paint-slot-p background) + (tp-paint-slot-face background)) + (background (list :background background)))))) (border-left-style (ebox-native-reflow--compile-border-face-style border-left (ebox-get box :border-left-color))) @@ -1601,15 +1662,19 @@ Rust still verifies the dynamic equal-width and nonempty-line proof per frame." (border-top-style (and (ebox-get box :border-top-p) (ebox-native-reflow--compile-add-face-style - (list :overline (or (ebox-get box :border-top-color) t))))) + (let ((color (ebox-get box :border-top-color))) + (if (tp-paint-slot-p color) + (tp-paint-slot-face color) + (list :overline (or color t))))))) (border-bottom-style (and (ebox-get box :border-bottom-p) (ebox-native-reflow--compile-add-face-style - (list :underline - (append (list :position t) - (when (ebox-get box :border-bottom-color) - (list :color - (ebox-get box :border-bottom-color)))))))) + (let ((color (ebox-get box :border-bottom-color))) + (if (tp-paint-slot-p color) + (tp-paint-slot-face color) + (list :underline + (append (list :position t) + (when color (list :color color))))))))) (surface-template-id (ebox-native-reflow--register-property-template (ebox-get box :surface-properties) "surface"))) @@ -1918,7 +1983,14 @@ PROPERTY-TEMPLATES are Emacs-owned plists addressed by opaque native ids." (lambda (descriptor) (list :mode (symbol-name (plist-get descriptor :mode)) - :face (plist-get descriptor :face))) + ;; The Rust IR treats this compiler-produced literal + ;; as opaque style data and returns it by numeric id. + ;; `prin1-to-string' preserves every valid Emacs face + ;; form without leaking Lisp symbols into JSON. + :face + (list :lisp + (prin1-to-string + (plist-get descriptor :face))))) styles)) :root root) :styles (vconcat styles) @@ -2050,21 +2122,192 @@ per call, and no call recursively visits the captured Ebox tree." ebox-native-reflow--compile-property-templates))))) (ebox-native-reflow-layout-builder-package builder))))) -(defun ebox-native-reflow--compile-layout-package (node) - "Compile NODE to a native document plus its Emacs-owned style registry." +(defun ebox-native-reflow--compile-layout-package + (node &optional retained-styles retained-property-templates) + "Compile NODE, extending optional retained native registries." (unless (fboundp 'ebox--grapheme-clusters) (error "Native reflow layout compiler requires Ebox")) (ebox--with-validated-display-cache - (let ((ebox-native-reflow--compile-styles nil) - (ebox-native-reflow--compile-property-templates nil) - (ebox-native-reflow--compile-property-template-ids - (make-hash-table :test 'equal)) + (let* ((ebox-native-reflow--compile-styles + (append retained-styles nil)) + (ebox-native-reflow--compile-property-templates + (append retained-property-templates nil)) + (ebox-native-reflow--compile-property-template-ids + (make-hash-table :test 'equal)) + (ebox-native-reflow--compile-display-signature + (ebox-display-signature)) (ebox-native-reflow--compile-root-node node)) + (cl-loop + for template in ebox-native-reflow--compile-property-templates + for index from 0 + do (puthash template index + ebox-native-reflow--compile-property-template-ids)) (let ((root (ebox-native-reflow--compile-node node))) (ebox-native-reflow--layout-package-from-root root ebox-native-reflow--compile-styles ebox-native-reflow--compile-property-templates))))) +(defun ebox-native-reflow--retained-layout-children (node) + "Return NODE children referenced by one compiled native fragment." + (pcase (plist-get node :ebox-type) + ('box (when-let ((child (plist-get node :ebox-content-node))) + (list child))) + ((or 'concat 'stack) (ebox--layout-children node)) + ('flex + (mapcar #'ebox--flex-item-source-node + (ebox-tree-layout-children node))) + (_ nil))) + +(defun ebox-native-reflow--retained-layout-postorder (root) + "Return compiler-owned postorder for retained ROOT." + (let (nodes) + (cl-labels + ((visit (node) + (dolist (child + (ebox-native-reflow--retained-layout-children node)) + (visit child)) + (unless (eq (plist-get node :ebox-type) 'flex-item) + (push node nodes)))) + (visit root)) + (vconcat (nreverse nodes)))) + +(defun ebox-native-reflow--retained-layout-signature + (node child-revisions flex-content-min-widths) + "Return exact retained compiler signature for NODE and CHILD-REVISIONS." + (let ((wrapper (and (eq (plist-get node :ebox-type) 'flex) + (plist-get node :box)))) + (list + (plist-get node :ebox-type) + (plist-get node :node-id) + (or (plist-get node :region-id) + (and wrapper (plist-get wrapper :region-id))) + (ebox-tree-node-local-source-signature node) + (and wrapper (ebox-tree-node-local-source-signature wrapper)) + child-revisions + (and (eq (plist-get node :ebox-type) 'flex) + (mapcar + (lambda (child) + (and (hash-table-p flex-content-min-widths) + (gethash child flex-content-min-widths))) + (ebox-native-reflow--retained-layout-children node)))))) + +(defun ebox-native-reflow--compile-retained-layout-package + (session state node) + "Compile NODE incrementally into retained native SESSION from STATE." + (let ((postorder (plist-get state :native-node-postorder))) + (unless (and (vectorp postorder) (> (length postorder) 0)) + (setq postorder + (ebox-native-reflow--retained-layout-postorder node))) + (ebox--with-validated-display-cache + (let* ((old-cache + (or (ebox-native-reflow-session-layout-fragment-cache session) + (make-hash-table :test 'equal))) + (fragments (make-hash-table :test 'eq)) + (revision + (or (ebox-native-reflow-session-layout-fragment-revision session) + 0)) + (flex-content-min-widths + (plist-get state :flex-content-min-widths)) + (touched-ids (plist-get state :native-touched-node-ids)) + (touched-set + (and (plist-get state :native-topology-stable-p) + (consp touched-ids) + (make-hash-table :test 'equal))) + (retained-fast-p + (and touched-set + (= (hash-table-count old-cache) (length postorder)) + (gethash (plist-get node :node-id) old-cache))) + (new-cache + (if retained-fast-p + (copy-hash-table old-cache) + (make-hash-table :test 'equal))) + (ebox-native-reflow--compile-styles + (append (ebox-native-reflow-session-styles session) nil)) + (ebox-native-reflow--compile-property-templates + (append + (plist-get + (ebox-native-reflow-session-layout-package session) + :property-templates) + nil)) + (ebox-native-reflow--compile-property-template-ids + (make-hash-table :test 'equal)) + (ebox-native-reflow--compile-display-signature + (ebox-display-signature)) + (ebox-native-reflow--compile-root-node node) + (ebox--render-string-pixel-width-cache + (make-hash-table :test 'equal)) + (ebox--render-string-max-pixel-width-cache + (make-hash-table :test 'eq))) + (dolist (node-id touched-ids) + (when touched-set (puthash node-id t touched-set))) + (cl-loop + for template in ebox-native-reflow--compile-property-templates + for index from 0 + do (puthash template index + ebox-native-reflow--compile-property-template-ids)) + (cl-labels + ((seed-fragment + (current) + (let ((entry (gethash (plist-get current :node-id) + new-cache))) + (unless entry + (error "Native retained layout cache lost a stable node")) + (puthash current (plist-get entry :fragment) fragments) + entry)) + (compile-current + (current) + (let* ((node-id (plist-get current :node-id)) + (cached (gethash node-id old-cache)) + (children + (ebox-native-reflow--retained-layout-children current)) + (_seeded + (when retained-fast-p + (dolist (child children) + (seed-fragment child)))) + (child-revisions + (mapcar + (lambda (child) + (plist-get + (gethash (plist-get child :node-id) new-cache) + :revision)) + children)) + (signature + (ebox-native-reflow--retained-layout-signature + current child-revisions flex-content-min-widths)) + fragment entry) + (if (and cached + (equal signature (plist-get cached :signature))) + (setq fragment (plist-get cached :fragment) + entry cached) + (setq fragment + (ebox-native-reflow--compile-scene-node + current fragments flex-content-min-widths) + revision (1+ revision) + entry (list :signature (copy-tree signature) + :fragment fragment + :revision revision))) + (puthash current fragment fragments) + (puthash node-id entry new-cache)))) + (dotimes (index (length postorder)) + (let* ((current (aref postorder index)) + (node-id (plist-get current :node-id))) + (if (and retained-fast-p + (not (gethash node-id touched-set))) + nil + (compile-current current)))) + (when retained-fast-p + (seed-fragment node))) + (let ((package + (ebox-native-reflow--layout-package-from-root + (ebox-native-reflow--compiled-scene-child fragments node) + ebox-native-reflow--compile-styles + ebox-native-reflow--compile-property-templates))) + (setf (ebox-native-reflow-session-layout-fragment-cache session) + new-cache + (ebox-native-reflow-session-layout-fragment-revision session) + revision) + package))))) + (defun ebox-native-reflow-compile-layout-ir (node) "Compile NODE to one complete versioned native layout document." (plist-get (ebox-native-reflow--compile-layout-package node) :document)) @@ -2193,7 +2436,12 @@ LAYOUT-PACKAGE may reuse a caller-validated document for the same NODE." (unless (and (integerp generation) (>= generation 0)) (error "Native reflow generation must be a nonnegative integer")) (let* ((package (or layout-package - (ebox-native-reflow--compile-layout-package node))) + (ebox-native-reflow--compile-layout-package + node + (ebox-native-reflow-session-styles session) + (plist-get + (ebox-native-reflow-session-layout-package session) + :property-templates)))) (register-layout-p (not (eq package (ebox-native-reflow-session-layout-package session)))) @@ -2543,7 +2791,8 @@ plist supplied by the caller." (setq position (next-property-change position rendered limit)))))) (defun ebox-native-reflow--patch-payload-metadata - (metadata metadata-record-count target-character-count complete-p) + (metadata metadata-record-count target-character-count complete-p + property-template-count style-count) "Validate patch METADATA against its declared record count." (cond ((null metadata) @@ -2556,7 +2805,8 @@ plist supplied by the caller." (error "Native reflow tape has too many metadata records")) (t (ebox-native-reflow--validate-root-metadata - metadata metadata-record-count target-character-count)))) + metadata metadata-record-count target-character-count + property-template-count style-count)))) (defun ebox-native-reflow--patch-payload-replacements (text descriptors) "Split aggregate patch TEXT into replacement strings for DESCRIPTORS." @@ -2589,7 +2839,7 @@ plist supplied by the caller." (payload (car parsed))) (unless (= (cdr parsed) (length literal)) (error "Native reflow patch payload literal has trailing data")) - (unless (and (vectorp payload) (= (length payload) 2) + (unless (and (vectorp payload) (memq (length payload) '(2 3)) (stringp (aref payload 0))) (error "Native reflow patch payload shape mismatch")) (let* ((text (aref payload 0)) @@ -2608,7 +2858,23 @@ plist supplied by the caller." :metadata (ebox-native-reflow--patch-payload-metadata (aref payload 1) metadata-record-count target-character-count - complete-p))))) + complete-p (length property-templates) (length styles)) + :fragment-style-delta + (when (= (length payload) 3) + (let ((delta (aref payload 2)) (previous -1)) + (unless (proper-list-p delta) + (error "Native fragment style delta is not a list")) + (dolist (entry delta) + (unless (and (proper-list-p entry) entry + (integerp (car entry)) (> (car entry) previous) + (cl-every + (lambda (style-id) + (and (integerp style-id) (>= style-id 0) + (< style-id (length styles)))) + (cdr entry))) + (error "Native fragment style delta is invalid")) + (setq previous (car entry))) + delta)))))) (defun ebox-native-reflow--root-metadata-point-span-p (span character-count) @@ -2684,8 +2950,69 @@ plist supplied by the caller." table) count)) +(defun ebox-native-reflow--validate-root-fragment-template + (template character-count property-template-count style-count) + "Validate compact root fragment TEMPLATE and return its record count." + (unless (vectorp template) + (error "Native reflow tape has an invalid fragment template")) + (let ((cursor 0) + (previous-line 0) + (allowed-roles ebox-native-reflow--tape-metadata-role-symbols)) + (dotimes (index (length template)) + (let ((record (aref template index))) + (unless (and (vectorp record) (= (length record) 8)) + (error "Native reflow tape has an invalid fragment record")) + (let ((start (aref record 0)) + (end (aref record 1)) + (line (aref record 2)) + (roles (aref record 3)) + (content-owner (aref record 4)) + (content-index (aref record 5)) + (template-ids (aref record 6)) + (style-ids (aref record 7)) + (seen (make-hash-table :test #'equal))) + (unless (and (integerp start) (integerp end) + (= start cursor) (< start end) + (<= end character-count) + (integerp line) (<= previous-line line)) + (error "Native reflow tape fragment coordinates are invalid")) + (unless (proper-list-p roles) + (error "Native reflow tape fragment roles are invalid")) + (dolist (role roles) + (unless (and (consp role) (memq (car role) allowed-roles) + (integerp (cdr role)) (> (cdr role) 0) + (not (gethash role seen))) + (error "Native reflow tape fragment role is invalid")) + (puthash role t seen)) + (unless (or (null content-owner) + (and (integerp content-owner) (> content-owner 0))) + (error "Native reflow tape fragment owner is invalid")) + (unless (or (null content-index) + (and (integerp content-index) (>= content-index 0))) + (error "Native reflow tape fragment content index is invalid")) + (unless (and (proper-list-p template-ids) + (cl-every + (lambda (template-id) + (and (integerp template-id) (>= template-id 0) + (< template-id property-template-count))) + template-ids)) + (error "Native reflow tape fragment property template is invalid")) + (unless (and (proper-list-p style-ids) + (cl-every + (lambda (style-id) + (and (integerp style-id) (>= style-id 0) + (< style-id style-count))) + style-ids)) + (error "Native reflow tape fragment style is invalid")) + (setq cursor end + previous-line line)))) + (unless (= cursor character-count) + (error "Native reflow tape fragment template is incomplete")) + (length template))) + (defun ebox-native-reflow--validate-root-metadata - (metadata record-count character-count) + (metadata record-count character-count _property-template-count + &optional _style-count) "Validate precompiled METADATA against its declared dimensions." (pcase metadata (`(:prepared-p t @@ -2709,6 +3036,7 @@ plist supplied by the caller." (defun ebox-native-reflow--tape-read-root-metadata (cursor metadata-byte-count record-count character-count complete-p + property-template-count style-count &optional _trusted-native-encoder-p) "Read and validate one precompiled root metadata literal from CURSOR." (cond @@ -2737,10 +3065,174 @@ plist supplied by the caller." (unless (= (cdr parsed) (length literal)) (error "Native reflow root metadata has trailing data")) (ebox-native-reflow--validate-root-metadata - metadata record-count character-count))))) + metadata record-count character-count property-template-count + style-count))))) + +(defun ebox-native-reflow--fragment-role-symbol (kind) + "Return the semantic role symbol encoded by fragment KIND." + (and (integerp kind) (<= 1 kind 14) + (nth (1- kind) ebox-native-reflow--tape-metadata-role-symbols))) + +(defun ebox-native-reflow--fragment-template-from-flat + (flat record-count character-count property-template-count style-count) + "Validate native-decoded FLAT fragment effects and return compact records." + (unless (and (vectorp flat) (integerp record-count) + (>= record-count 0)) + (error "Native reflow decoded fragment tape is invalid")) + (let ((cursor 0) + (length (length flat)) + (template (make-vector record-count nil)) + (none-value (- (ash 1 63)))) + (dotimes (index record-count) + (when (> (+ cursor 8) length) + (error "Native reflow decoded fragment record is truncated")) + (let* ((start (aref flat cursor)) + (finish (aref flat (+ cursor 1))) + (line (aref flat (+ cursor 2))) + (owner-value (aref flat (+ cursor 3))) + (content-index-value (aref flat (+ cursor 4))) + (role-count (aref flat (+ cursor 5))) + (template-count (aref flat (+ cursor 6))) + (fragment-style-count (aref flat (+ cursor 7))) + roles template-ids style-ids) + (setq cursor (+ cursor 8)) + (unless (and (integerp role-count) (>= role-count 0) + (integerp template-count) (>= template-count 0) + (integerp fragment-style-count) + (>= fragment-style-count 0) + (<= (+ cursor (* role-count 2) + template-count fragment-style-count) + length)) + (error "Native reflow decoded fragment dimensions are invalid")) + (dotimes (_ role-count) + (let ((role + (ebox-native-reflow--fragment-role-symbol + (aref flat cursor))) + (region-id (aref flat (1+ cursor)))) + (unless (and role (integerp region-id) (> region-id 0) + (not (member (cons role region-id) roles))) + (error "Native reflow decoded fragment role is invalid")) + (push (cons role region-id) roles) + (setq cursor (+ cursor 2)))) + (dotimes (_ template-count) + (let ((template-id (aref flat cursor))) + (unless (and (integerp template-id) (>= template-id 0) + (< template-id property-template-count)) + (error "Native reflow decoded property template is invalid")) + (push template-id template-ids) + (setq cursor (1+ cursor)))) + (dotimes (_ fragment-style-count) + (let ((style-id (aref flat cursor))) + (unless (and (integerp style-id) (>= style-id 0) + (< style-id style-count)) + (error "Native reflow decoded fragment style is invalid")) + (push style-id style-ids) + (setq cursor (1+ cursor)))) + (aset template index + (vector start finish line (nreverse roles) + (unless (= owner-value none-value) owner-value) + (unless (= content-index-value none-value) + content-index-value) + (nreverse template-ids) (nreverse style-ids))))) + (unless (= cursor length) + (error "Native reflow decoded fragment tape has trailing values")) + (ebox-native-reflow--validate-root-fragment-template + template character-count property-template-count style-count) + template)) + +(defun ebox-native-reflow--tape-read-fragment-template + (cursor byte-count record-count character-count + property-template-count style-count + &optional flat flat-record-count) + "Read and validate one compact binary fragment effect tape from CURSOR." + (cond + ((and (zerop byte-count) (zerop record-count)) []) + ((or (zerop byte-count) (zerop record-count)) + (error "Native reflow fragment tape has inconsistent dimensions")) + ((or (> record-count most-positive-fixnum) + (> record-count character-count)) + (error "Native reflow fragment tape has too many records")) + (t + (let* ((end (+ (aref cursor 1) byte-count)) + (fragment-cursor (vector (aref cursor 0) (aref cursor 1) end)) + (template (make-vector record-count nil)) + (previous-end 0) + (previous-line 0) + (none-value (- (ash 1 63)))) + (when (> end (aref cursor 2)) + (error "Native reflow fragment tape exceeds its frame")) + (if flat + (progn + (unless (= flat-record-count record-count) + (error "Native reflow decoded fragment count mismatch")) + (aset cursor 1 end) + (ebox-native-reflow--fragment-template-from-flat + flat record-count character-count property-template-count + style-count)) + (progn + (dotimes (index record-count) + (ebox-native-reflow--tape-require fragment-cursor 56) + (let* ((start (ebox-native-reflow--tape-read-u64 fragment-cursor)) + (finish (ebox-native-reflow--tape-read-u64 fragment-cursor)) + (line (ebox-native-reflow--tape-read-u64 fragment-cursor)) + (owner-value + (ebox-native-reflow--tape-read-i64 fragment-cursor)) + (content-index-value + (ebox-native-reflow--tape-read-i64 fragment-cursor)) + (role-count + (ebox-native-reflow--tape-read-u32 fragment-cursor)) + (template-count + (ebox-native-reflow--tape-read-u32 fragment-cursor)) + (fragment-style-count + (ebox-native-reflow--tape-read-u32 fragment-cursor)) + (reserved + (ebox-native-reflow--tape-read-u32 fragment-cursor)) + roles template-ids style-ids) + (unless (and (= start previous-end) (< start finish) + (<= finish character-count) + (<= previous-line line) (zerop reserved)) + (error "Native reflow fragment tape has invalid coordinates")) + (dotimes (_ role-count) + (let ((role + (ebox-native-reflow--fragment-role-symbol + (ebox-native-reflow--tape-read-u8 fragment-cursor))) + (region-id + (ebox-native-reflow--tape-read-i64 fragment-cursor))) + (unless (and role (> region-id 0) + (not (member (cons role region-id) roles))) + (error "Native reflow fragment tape has an invalid role")) + (push (cons role region-id) roles))) + (dotimes (_ template-count) + (let ((template-id + (ebox-native-reflow--tape-read-u32 fragment-cursor))) + (unless (< template-id property-template-count) + (error "Native reflow fragment tape has an invalid property template")) + (push template-id template-ids))) + (dotimes (_ fragment-style-count) + (let ((style-id + (ebox-native-reflow--tape-read-u32 fragment-cursor))) + (unless (< style-id style-count) + (error "Native reflow fragment tape has an invalid style")) + (push style-id style-ids))) + (aset template index + (vector start finish line (nreverse roles) + (unless (= owner-value none-value) owner-value) + (unless (= content-index-value none-value) + content-index-value) + (nreverse template-ids) (nreverse style-ids))) + (setq previous-end finish + previous-line line))) + (unless (and (= previous-end character-count) + (= (aref fragment-cursor 1) end)) + (error "Native reflow fragment tape is incomplete")) + (aset cursor 1 end) + (ebox-native-reflow--validate-root-fragment-template + template character-count property-template-count style-count) + template)))))) (defun ebox-native-reflow--preflight-layout-patch-tape - (cursor header styles trusted-native-encoder-p property-templates) + (cursor header styles trusted-native-encoder-p property-templates + &optional fragment-flat fragment-flat-count) "Decode one Rust-origin minimal patch body from CURSOR. HEADER supplies the already validated target identity. STYLES owns the face registry. TRUSTED-NATIVE-ENCODER-P is retained for full-tape @@ -2753,15 +3245,34 @@ HEADER supplies the already validated target identity. STYLES owns the face (reserved (ebox-native-reflow--tape-read-u32 cursor)) (metadata-record-count (ebox-native-reflow--tape-read-u64 cursor)) (payload-byte-length (ebox-native-reflow--tape-read-u64 cursor)) + (fragment-byte-length (ebox-native-reflow--tape-read-u64 cursor)) + (fragment-record-count (ebox-native-reflow--tape-read-u64 cursor)) + (coordinate-patch-count + (ebox-native-reflow--tape-read-u32 cursor)) + (coordinate-reserved + (ebox-native-reflow--tape-read-u32 cursor)) (target-character-count (plist-get header :character-count)) (descriptor-bytes - (* patch-count ebox-native-reflow--tape-patch-descriptor-length)) - (required-bytes (+ descriptor-bytes payload-byte-length))) - (unless (zerop reserved) - (error "Native reflow patch reserved field is nonzero")) + (* (+ patch-count coordinate-patch-count) + ebox-native-reflow--tape-patch-descriptor-length)) + (required-bytes + (+ descriptor-bytes payload-byte-length fragment-byte-length))) + (unless (and (integerp reserved) (<= 0 reserved 15)) + (error "Native reflow patch flags are invalid")) + (unless (zerop coordinate-reserved) + (error "Native reflow coordinate patch reserved field is nonzero")) + (when (and (/= (logand reserved 1) 0) + (not (and (zerop fragment-byte-length) + (zerop fragment-record-count)))) + (error "Native reflow reused fragment tape has a payload")) + (when (and (/= (logand reserved 8) 0) + (not (and (zerop fragment-byte-length) + (zerop fragment-record-count)))) + (error "Native fragment style delta has a full fragment payload")) (unless (= required-bytes (- (aref cursor 2) (aref cursor 1))) (error "Native reflow patch body length mismatch")) (let ((descriptors nil) + (coordinate-descriptors nil) (previous-old-end 0) (previous-new-end 0)) (dotimes (_ patch-count) @@ -2786,6 +3297,29 @@ HEADER supplies the already validated target identity. STYLES owns the face (- target-character-count previous-new-end)) (error "Native reflow patch target length is inconsistent")) (setq descriptors (nreverse descriptors)) + (setq previous-old-end 0 previous-new-end 0) + (dotimes (_ coordinate-patch-count) + (let ((old-start (ebox-native-reflow--tape-read-u64 cursor)) + (old-end (ebox-native-reflow--tape-read-u64 cursor)) + (new-start (ebox-native-reflow--tape-read-u64 cursor)) + (new-end (ebox-native-reflow--tape-read-u64 cursor))) + (unless (and (<= 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)) + (or (< old-start old-end) (< new-start new-end))) + (error "Native reflow coordinate patch is invalid")) + (push (list :old-start old-start :old-end old-end + :new-start new-start :new-end new-end) + coordinate-descriptors) + (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 reflow coordinate patch length is inconsistent")) + (setq coordinate-descriptors (nreverse coordinate-descriptors)) (let* ((payload-end (+ (aref cursor 1) payload-byte-length)) (payload-cursor (vector (aref cursor 0) (aref cursor 1) payload-end)) @@ -2798,24 +3332,44 @@ HEADER supplies the already validated target identity. STYLES owns the face (unless (= (aref payload-cursor 1) payload-end) (error "Native reflow patch body has trailing bytes")) (aset cursor 1 payload-end) + (let* ((style-delta (plist-get payload :fragment-style-delta)) + (_style-delta-contract + (unless (eq (/= (logand reserved 8) 0) + (and style-delta t)) + (error "Native fragment style delta flag mismatch"))) + (fragments + (unless style-delta + (ebox-native-reflow--tape-read-fragment-template + cursor fragment-byte-length fragment-record-count + target-character-count (length property-templates) + (length styles) fragment-flat fragment-flat-count)))) + (when style-delta + (aset cursor 1 (+ (aref cursor 1) fragment-byte-length))) (unless (= (aref cursor 1) (aref cursor 2)) (error "Native reflow patch tape has trailing bytes")) (list :native-patch t + :reuse-fragment-template (/= (logand reserved 1) 0) + :reuse-ownership-template (/= (logand reserved 2) 0) + :reuse-mount-projection (/= (logand reserved 4) 0) + :fragment-style-delta style-delta :patch-origin 'rust :base-character-count base-character-count :target-character-count target-character-count :target-line-count (plist-get header :line-count) :patches descriptors - :root-render-metadata (plist-get payload :metadata) + :coordinate-patches coordinate-descriptors + :root-render-metadata + (append (plist-get payload :metadata) + (list :fragment-span-template fragments)) :metadata-record-count metadata-record-count :replacement-bytes (cl-loop for replacement in (plist-get payload :replacements) sum (string-bytes replacement)) - :full-frame-bytes 0))))) + :full-frame-bytes 0)))))) (defun ebox-native-reflow--preflight-layout-tape (payload styles expected &optional trusted-native-encoder-p - property-templates) + property-templates fragment-flat fragment-flat-count) "Validate and decode one versioned Rust materialization tape PAYLOAD." (unless (vectorp styles) (error "Native reflow styles must be a vector")) @@ -2834,18 +3388,22 @@ HEADER supplies the already validated target identity. STYLES owns the face message-bytes "error message")))) (if (plist-get header :patch) (ebox-native-reflow--preflight-layout-patch-tape - cursor header styles trusted-native-encoder-p property-templates) + cursor header styles trusted-native-encoder-p property-templates + fragment-flat fragment-flat-count) (ebox-native-reflow--tape-require cursor ebox-native-reflow--tape-body-preamble-length) (let* ((literal-byte-length (ebox-native-reflow--tape-read-u64 cursor)) (line-width-count (ebox-native-reflow--tape-read-u32 cursor)) (metadata-byte-length (ebox-native-reflow--tape-read-u32 cursor)) (metadata-record-count (ebox-native-reflow--tape-read-u64 cursor)) + (fragment-byte-length (ebox-native-reflow--tape-read-u64 cursor)) + (fragment-record-count (ebox-native-reflow--tape-read-u64 cursor)) (line-count (plist-get header :line-count)) (declared-characters (plist-get header :character-count)) (required-bytes (+ (* line-width-count 8) literal-byte-length - metadata-byte-length))) + metadata-byte-length + fragment-byte-length))) (unless (= line-width-count line-count) (error "Native reflow tape body metadata mismatch")) (unless (= required-bytes (- (aref cursor 2) (aref cursor 1))) @@ -2883,14 +3441,22 @@ HEADER supplies the already validated target identity. STYLES owns the face (ebox-native-reflow--tape-read-root-metadata cursor metadata-byte-length metadata-record-count declared-characters (plist-get header :complete) + (length property-templates) + (length styles) trusted-native-encoder-p))) + (let ((fragments + (ebox-native-reflow--tape-read-fragment-template + cursor fragment-byte-length fragment-record-count + declared-characters (length property-templates) + (length styles) fragment-flat fragment-flat-count))) (unless (= (aref cursor 1) (aref cursor 2)) (error "Native reflow tape has trailing bytes")) - (vector header rendered line-widths metadata)))))))) + (vector header rendered line-widths metadata fragments))))))))) (defun ebox-native-reflow--materialize-layout-tape (payload &optional styles expected trusted-native-encoder-p - property-templates) + property-templates return-frame-p fragment-flat + fragment-flat-count) "Validate and materialize binary layout tape PAYLOAD. STYLES supplies Emacs-owned face templates. EXPECTED optionally constrains the identity header before any result string is allocated. When @@ -2899,7 +3465,7 @@ typed line-width and property invariants instead of repeating them in Emacs." (let* ((styles (or styles [])) (decoded (ebox-native-reflow--preflight-layout-tape payload styles expected trusted-native-encoder-p - property-templates))) + property-templates fragment-flat fragment-flat-count))) (if (plist-get decoded :native-patch) (let* ((patch-count (length (plist-get decoded :patches))) (metadata-count @@ -2914,7 +3480,9 @@ 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))) + (line-widths (aref decoded 2)) + (metadata (aref decoded 3)) + (fragments (aref decoded 4))) (unless trusted-native-encoder-p (ebox-native-reflow--validate-literal-properties rendered styles (plist-get header :complete) @@ -2927,7 +3495,15 @@ 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")))) - rendered)))) + (if return-frame-p + (list :native-frame t :rendered rendered + :root-render-metadata metadata + :effect-tape + (append metadata (list :fragment-span-template fragments)) + :line-widths line-widths + :styles styles + :property-templates property-templates) + rendered))))) (defun ebox-native-reflow-take-layout (session generation key &optional expected) @@ -2948,44 +3524,223 @@ EXPECTED may add exact tape identity fields beyond GENERATION and KEY." (plist-get (ebox-native-reflow-session-layout-package session) :property-templates))))) -(defun ebox-native-reflow-render-proof-sync +(defun ebox-native-reflow-execute-sync (node frame &optional layout-package) - "Render NODE once through the native synchronous proof path. + "Execute NODE once through the native synchronous frame path. FRAME supplies positive viewport geometry and optional `:root-width'. The -result is a fully materialized propertized string; native-owned property -template ids are expanded before returning." +result contains the fully materialized propertized string plus the validated +root effect metadata required by a thin host commit." (when (and (ebox-native-reflow-layout-ready-p) - (fboundp 'ebox-native--module-render-proof)) + (fboundp 'ebox-native--module-render-frame)) (let* ((package (or layout-package (ebox-native-reflow--compile-layout-package node))) (native-key (or (plist-get frame :key) 1)) (control-frame (copy-sequence frame))) (setq control-frame (plist-put control-frame :key native-key)) - (let* ((payload - (ebox-native--module-render-proof - (ebox-native-reflow--layout-control-json - (plist-get package :document) - (list control-frame)))) - (expected - (list :generation 0 - :key native-key - :runtime-revision - (or (plist-get control-frame :runtime-revision) 0) - :context-hash - (or (plist-get control-frame :context-hash) 0) - :viewport-width (plist-get control-frame :viewport-width) - :viewport-height - (plist-get control-frame :viewport-height) - :root-width - (or (plist-get control-frame :root-width) - (plist-get control-frame :viewport-width)) - :complete - (if (plist-member control-frame :complete) - (plist-get control-frame :complete) - t)))) - (ebox-native-reflow--materialize-layout-tape - payload (plist-get package :styles) expected t - (plist-get package :property-templates)))))) + (ebox-native-reflow--materialize-module-frame + (ebox-native--module-render-frame + (ebox-native-reflow--layout-control-json + (plist-get package :document) + (list control-frame))) + package control-frame 0)))) + +(defun ebox-native-reflow--materialize-module-frame + (native-frame package control-frame generation) + "Materialize NATIVE-FRAME for PACKAGE and CONTROL-FRAME identity." + (let* ((payload (aref native-frame 0)) + (fragment-flat (aref native-frame 1)) + (fragment-flat-count (aref native-frame 2)) + (expected + (list :generation generation + :key (or (plist-get control-frame :key) 1) + :runtime-revision + (or (plist-get control-frame :runtime-revision) 0) + :context-hash + (or (plist-get control-frame :context-hash) 0) + :viewport-width (plist-get control-frame :viewport-width) + :viewport-height (plist-get control-frame :viewport-height) + :root-width + (or (plist-get control-frame :root-width) + (plist-get control-frame :viewport-width)) + :complete + (if (plist-member control-frame :complete) + (plist-get control-frame :complete) + t))) + (result + (ebox-native-reflow--materialize-layout-tape + payload (plist-get package :styles) expected t + (plist-get package :property-templates) t + fragment-flat fragment-flat-count))) + (when (plist-get result :native-patch) + (plist-put result :styles (plist-get package :styles)) + (plist-put result :property-templates + (plist-get package :property-templates))) + result)) + +(defun ebox-native-reflow-execute-session-sync + (session node frame &optional layout-package state) + "Execute NODE synchronously through retained native SESSION." + (unless (and (ebox-native-reflow-session-p session) + (fboundp 'ebox-native--module-render-session-frame)) + (error "Native retained frame execution is unavailable")) + (let* ((package + (or layout-package + (if state + (ebox-native-reflow--compile-retained-layout-package + session state node) + (ebox-native-reflow--compile-layout-package + node + (ebox-native-reflow-session-styles session) + (plist-get + (ebox-native-reflow-session-layout-package session) + :property-templates))))) + (generation (1+ (ebox-native-reflow-session-generation session))) + (control-frame (copy-sequence frame)) + (native-key (or (plist-get control-frame :key) 1))) + (setq control-frame (plist-put control-frame :key native-key)) + (let ((result + (ebox-native-reflow--materialize-module-frame + (ebox-native--module-render-session-frame + (ebox-native-reflow--live-handle session) + generation + (ebox-native-reflow--layout-control-json + (plist-get package :document) (list control-frame))) + package control-frame generation))) + (setf (ebox-native-reflow-session-generation session) generation + (ebox-native-reflow-session-layout-package session) package + (ebox-native-reflow-session-styles session) + (plist-get package :styles)) + result))) + +(defun ebox-native-reflow-render-proof-sync + (node frame &optional layout-package) + "Render NODE once through the native synchronous proof path. +This compatibility entry returns only the rendered string. New runtime +callers should consume `ebox-native-reflow-execute-sync' and commit its effect +metadata together with the text." + (when-let ((result + (ebox-native-reflow-execute-sync node frame layout-package))) + (plist-get result :rendered))) + +(defun ebox-native-reflow--face-entries (face) + "Return FACE as an ordered list of face entries." + (cond ((null face) nil) + ((and (listp face) (keywordp (car-safe face))) (list face)) + ((listp face) (copy-sequence face)) + (t (list face)))) + +(defun ebox-native-reflow--face-value (entries) + "Return canonical face value represented by ENTRIES." + (pcase entries + ('() nil) + (`(,entry) entry) + (_ entries))) + +(defun ebox-native-reflow--fragment-style-faces (style-ids styles) + "Return effective native face contributions for STYLE-IDS." + (let (faces) + (dolist (style-id style-ids faces) + (let ((style (aref styles style-id))) + (pcase (plist-get style :mode) + ('set (setq faces (list (plist-get style :face)))) + ('add (setq faces + (append faces (list (plist-get style :face))))) + (_ (error "Native reflow frame has an invalid style mode"))))))) + +(defun ebox-native-reflow--fragment-face-baseline + (rendered start style-ids styles) + "Remove native STYLE-IDS from RENDERED's face at START." + (let* ((face (get-text-property start 'face rendered)) + (entries (ebox-native-reflow--face-entries face)) + (contributions + (ebox-native-reflow--fragment-style-faces style-ids styles)) + (count (length contributions))) + (when (and (> count 0) + (or (< (length entries) count) + (not (equal (last entries count) contributions)))) + (error "Native reflow fragment face does not match its style tape")) + (ebox-native-reflow--face-value + (if (> count 0) + (seq-take entries (- (length entries) count)) + entries)))) + +(defun ebox-native-reflow-frame-fragments (frame) + "Decode FRAME's fragment effect template without scanning rendered text." + (unless (plist-get frame :native-frame) + (error "Native reflow fragment decode requires a native frame")) + (let* ((rendered (plist-get frame :rendered)) + (metadata (plist-get frame :effect-tape)) + (template (and metadata + (plist-get metadata :fragment-span-template))) + (styles (or (plist-get frame :styles) [])) + (baseline-cache (make-hash-table :test #'equal)) + (ordinal-table (make-hash-table :test #'equal)) + fragments previous-role-ids next-role-ids) + (unless (and (stringp rendered) (vectorp template)) + (error "Native reflow frame has no fragment effect template")) + (dotimes (index (length template)) + (let* ((record (aref template index)) + (start (aref record 0)) + (end (aref record 1)) + (line (aref record 2)) + (roles (copy-tree (aref record 3))) + (content-owner (aref record 4)) + (content-index (aref record 5)) + (style-ids (aref record 7)) + (baseline-key + (list (get-text-property start 'face rendered) style-ids)) + (missing (make-symbol "native-fragment-baseline-missing")) + (baseline + (gethash baseline-key baseline-cache missing)) + (prefix + (and (or roles content-owner (integerp content-index)) + (list :roles (copy-tree roles) + :content-owner content-owner + :content-index content-index))) + (ordinal (and prefix (gethash prefix ordinal-table 0))) + (address + (and prefix + (list :roles (copy-tree roles) + :content-owner content-owner + :content-index content-index + :ordinal ordinal)))) + (when (eq baseline missing) + (setq baseline + (ebox-native-reflow--fragment-face-baseline + rendered start style-ids styles)) + (puthash baseline-key baseline baseline-cache)) + (when prefix + (puthash prefix (1+ ordinal) ordinal-table)) + (push (list :text rendered :text-source-p t + :start start :end end :line line + :paint-role-ids roles :role-ids roles + :paint-address address + :paint-token + (and address + (list :baseline (copy-tree baseline) + :roles (copy-tree roles) + :content-owner content-owner + :content-index content-index)) + :face-baseline (copy-tree baseline) + :face-baseline-known-p t) + fragments))) + (setq fragments (nreverse fragments)) + (dolist (fragment fragments) + (if-let ((roles (plist-get fragment :role-ids))) + (setq previous-role-ids roles) + (plist-put fragment :previous-role-ids previous-role-ids))) + (dolist (fragment (reverse (copy-sequence fragments))) + (if-let ((roles (plist-get fragment :role-ids))) + (setq next-role-ids roles) + (plist-put fragment :role-ids + (delete-dups + (append (copy-sequence + (plist-get fragment :previous-role-ids)) + (copy-sequence next-role-ids)))))) + (cl-loop for fragment in fragments for index from 0 + do (plist-put fragment :key (cons 'ebox/fragment index)) + do (cl-remf fragment :previous-role-ids) + collect fragment))) (defun ebox-native-reflow-submit (session generation frames) "Submit FRAMES for SESSION GENERATION without waiting for results." diff --git a/ebox-render-context.el b/ebox-render-context.el index 684d712..63c4f16 100644 --- a/ebox-render-context.el +++ b/ebox-render-context.el @@ -74,6 +74,9 @@ the render boundary." (let* ((next (min end (or (next-single-property-change position ebox--paint-origin-property string end) + end) + (or (next-single-property-change + position 'face string end) end))) (origin (get-text-property position ebox--paint-origin-property string))) diff --git a/ebox-style.el b/ebox-style.el index 9ae6d79..ff04e0d 100644 --- a/ebox-style.el +++ b/ebox-style.el @@ -16,6 +16,24 @@ (declare-function ebox-create "ebox" (&rest plist)) (declare-function ebox--flex-normalize-container-props "ebox-flex" (props)) +(declare-function ebox--normalize-horizontal-size-value + "ebox-flex" (value allowed-keywords)) +(declare-function ebox--nonnegative-horizontal-size-pixels + "ebox-layout" (value &optional default)) +(declare-function ecss-schema-set-create "ecss" ()) +(declare-function ecss-stylesheet-create "ecss" ()) + +(defvar ebox--preferred-size-keywords) +(defvar ebox--min-size-keywords) +(defvar ebox--max-size-keywords) + +(defun ebox-style--create-schema-set () + "Return one private ECSS schema set for Ebox style state." + (ecss-schema-set-create)) + +(defun ebox-style--create-stylesheet () + "Return one private ECSS stylesheet for Ebox style state." + (ecss-stylesheet-create)) (defconst ebox-style--border-styles '(none hidden dotted dashed solid double groove ridge inset outset) @@ -224,7 +242,7 @@ (defvar ebox-style--property-table nil "Hash table from canonical property names and aliases to metadata.") -(defvar ebox-style-schemas (ecss-schema-set-create) +(defvar ebox-style-schemas (ebox-style--create-schema-set) "ECSS property schemas for the isolated Ebox style domain.") (defvar ebox-style--registered-schemas nil @@ -597,12 +615,26 @@ every `ebox-create' needlessly copies the entire property metadata domain.") (setq options (plist-put options :shorthand shorthand))) options)) +(defvar ebox-style--closed-computed-cache (make-hash-table :test #'equal) + "Bounded cache of selector-free, dependency-free computed styles.") + +(defconst ebox-style--closed-computed-cache-limit 512 + "Maximum selector-free computed styles retained across projections.") + +(declare-function tp-computed-p "tp-style" (value)) + +(defun ebox-style--closed-declarations-p (declarations) + "Return non-nil when DECLARATIONS contain no computed value source." + (cl-loop for (_property value) on declarations by #'cddr + never (tp-computed-p value))) + (defun ebox-style-register-properties () "Register the complete namespaced Ebox property domain with ECSS." (dolist (property ebox-style--property-definitions) (apply #'ecss-schema-set-define ebox-style-schemas (plist-get property :id) (ebox-style--schema-options property))) + (clrhash ebox-style--closed-computed-cache) (setq ebox-style--registered-schemas ebox-style-schemas)) (defun ebox-style--ensure-properties () @@ -615,7 +647,7 @@ every `ebox-create' needlessly copies the entire property metadata domain.") (setq ebox-style--registered-schemas ebox-style-schemas) (ebox-style-register-properties)))) -(defvar ebox-style-stylesheet (ecss-stylesheet-create) +(defvar ebox-style-stylesheet (ebox-style--create-stylesheet) "Isolated ECSS stylesheet containing Ebox layout and paint rules.") (defun ebox-style-cascade-active-p () @@ -733,10 +765,26 @@ ORIGIN, LAYER, and SCOPE use ECSS cascade semantics." (defun ebox-style-compute-subject (subject declarations &optional parent-style) "Compute SUBJECT style from DECLARATIONS and optional PARENT-STYLE via ECSS." (ebox-style--ensure-properties) - (ecss-compute-style - ebox-style-schemas subject :declarations declarations - :stylesheet ebox-style-stylesheet :parent-style parent-style - :value-resolver #'tp-resolve-value)) + (let* ((closed-p + (and (not (ebox-style-cascade-active-p)) + (ebox-style--closed-declarations-p declarations))) + (key (and closed-p (list declarations parent-style))) + (missing (make-symbol "ebox-closed-style-cache-missing")) + (cached (and key (gethash key ebox-style--closed-computed-cache + missing)))) + (if (and key (not (eq cached missing))) + cached + (let ((style + (ecss-compute-style + ebox-style-schemas subject :declarations declarations + :stylesheet ebox-style-stylesheet :parent-style parent-style + :value-resolver #'tp-resolve-value))) + (when key + (when (>= (hash-table-count ebox-style--closed-computed-cache) + ebox-style--closed-computed-cache-limit) + (clrhash ebox-style--closed-computed-cache)) + (puthash key style ebox-style--closed-computed-cache)) + style)))) (defconst ebox-style--theme-delta-properties '(ebox/color ebox/background-color) diff --git a/ebox-surface.el b/ebox-surface.el index 2eca76b..2ac3394 100644 --- a/ebox-surface.el +++ b/ebox-surface.el @@ -45,7 +45,23 @@ (defvar ebox-incremental--allocated-slot-proof-cache) (defvar ebox-incremental--buffer-render-state-override) +(defvar ebox-surface--inline-style-value-cache nil + "Per-projection cache for selector-free computed inline styles.") + (declare-function ebox--render-layout "ebox-layout" (node)) +(declare-function ebox-native-commit-render + "ebox-native-commit" (state node)) +(declare-function ebox-native-reflow-frame-fragments + "ebox-native-reflow" (frame)) +(declare-function ebox-native-commit-projection-eligible-p + "ebox-native-commit" + (previous-state state-overrides stylesheet-active-p)) +(declare-function tp-object-reuse "tp-surface" (context object)) +(declare-function tp-object-ensure-at + "tp-surface" (context parent key kind position)) +(declare-function tp-bind-precomputed + "tp-reactive" + (owner key compute value dependencies &rest options)) (declare-function ebox--record-render-output-provenance "ebox-render-context" (rendered)) (declare-function ebox--render-owned-text-value-p @@ -76,6 +92,7 @@ (declare-function ebox--scroll-clear-state "ebox" (region-id)) (declare-function ebox--smooth-scroll-stop "ebox" (region-id)) (declare-function ebox-put "ebox" (box property value)) +(declare-function ebox-get "ebox" (box property)) (declare-function ebox-string-lines "ebox" (string)) (declare-function ebox-lines-join "ebox" (lines)) (declare-function ebox--scroll-state-rendered-visible-window @@ -101,7 +118,7 @@ (declare-function tp-surface-retained-content-result-create "tp-surface" (context plan rendered ranges &optional client-state - full-surface-p)) + full-surface-p property-contributions)) (declare-function tp-surface-report-summary "tp-surface" (surface)) (defvar-local ebox-surface--buffer-surface nil @@ -144,23 +161,24 @@ left on the conservative full fragment scanner path." Each cached line template is copied only at the plist spine and points at the single joined RENDERED string. The text and role values remain candidate owned; no per-fragment substring is allocated." - (let ((offset 0) - (line-index 0) - result) - (dolist (line lines) - (let ((template (ebox-surface--scroll-line-fragment-template line))) - (unless template - (cl-return-from ebox-surface--scroll-fragment-data nil)) - (dolist (fragment template) - (let ((copy (copy-sequence fragment))) - (plist-put copy :text rendered) - (plist-put copy :start (+ offset (plist-get fragment :start))) - (plist-put copy :end (+ offset (plist-get fragment :end))) - (plist-put copy :line line-index) - (push copy result)))) - (setq offset (+ offset (length line) 1)) - (setq line-index (1+ line-index))) - (nreverse result))) + (cl-block ebox-surface--scroll-fragment-data + (let ((offset 0) + (line-index 0) + result) + (dolist (line lines) + (let ((template (ebox-surface--scroll-line-fragment-template line))) + (unless template + (cl-return-from ebox-surface--scroll-fragment-data nil)) + (dolist (fragment template) + (let ((copy (copy-sequence fragment))) + (plist-put copy :text rendered) + (plist-put copy :start (+ offset (plist-get fragment :start))) + (plist-put copy :end (+ offset (plist-get fragment :end))) + (plist-put copy :line line-index) + (push copy result)))) + (setq offset (+ offset (length line) 1)) + (setq line-index (1+ line-index))) + (nreverse result)))) (defun ebox-surface--style-state-table (previous-state) "Copy retained style state from PREVIOUS-STATE into a weak table." @@ -185,6 +203,25 @@ owned; no per-fragment substring is allocated." (mapcar #'subtree (ecss-subject-children node))))) (subtree subject))) +(defun ebox-surface--subject-local-equal-p (left right) + "Return non-nil when LEFT and RIGHT expose equal local selector fields." + (and left right + (equal (ecss-subject-type left) (ecss-subject-type right)) + (equal (ecss-subject-id left) (ecss-subject-id right)) + (equal (ecss-subject-classes left) (ecss-subject-classes right)) + (equal (ecss-subject-attributes left) + (ecss-subject-attributes right)) + (equal (ecss-subject-states left) (ecss-subject-states right)))) + +(defun ebox-surface--stylesheet-subject-local-p (stylesheet-signature) + "Return non-nil when STYLESHEET-SIGNATURE has only local selector rules. +Scoped rules remain tree-dependent because scope distance reads ancestors." + (cl-every + (lambda (rule) + (and (null (ecss-rule-scope rule)) + (ecss-selector-subject-local-p (ecss-rule-selector rule)))) + (car stylesheet-signature))) + (defconst ebox-surface--root-key 'ebox/surface "Stable TP key for an Ebox surface projection root.") @@ -202,6 +239,12 @@ owned; no per-fragment substring is allocated." ebox/font-weight ebox/font-slant) "ECSS properties whose inline values can flow to descendants.") +(defun ebox-surface--style-consuming-node-p (node) + "Return non-nil when NODE owns a paintable style surface." + (and (eq (plist-get node :ebox-type) 'box) + (null (plist-get node :ebox-content-node)) + (> (length (or (plist-get node :content) "")) 0))) + (defun ebox-surface--inline-inheritance-required-p (root) "Return non-nil when inline styles in ROOT require an ECSS cascade. An empty stylesheet still needs a cascade when an inherited declaration can @@ -216,7 +259,8 @@ construction time and can use the static projection path." (inherited (cdr entry)) (declarations (ebox-style-node-declarations node))) (when (cl-some (lambda (property) - (and (memq property inherited) + (and (ebox-surface--style-consuming-node-p node) + (memq property inherited) (not (plist-member declarations property)))) ebox-surface--inherited-style-properties) (setq required t)) @@ -435,6 +479,7 @@ ROLES has the same filtering meaning as in `ebox-surface-region-mounts'." (dolist (entry `((:runtime-published . t) (:runtime-revision . ,(plist-get state :runtime-revision)) + (:native-frame-kind . ,(plist-get state :native-frame-kind)) (:surface-revision . ,(tp-surface-revision surface)) (:publication-scope . tp-surface) (:planned-publication-scope . ,planned-publication-scope) @@ -822,7 +867,8 @@ FRAMEWORK-PARTICIPANT retains post-TP publication facts when non-nil." :last-update-report))))))) (defun ebox-surface--projection-start - (context root &optional style-required-p projection-kind previous-state) + (context root &optional style-required-p projection-kind previous-state + state-overrides) "Create retained TP identities and optional computed styles for ROOT. STYLE-REQUIRED-P is non-nil when the candidate must refresh cascade results. When it is nil, inline declarations already projected by node construction @@ -838,12 +884,19 @@ PREVIOUS-STATE's node-object table." (if (memq projection-kind '(span-patch owner-scoped scroll-patch formatting-context-reflow mixed-owner-reflow - viewport-reflow viewport-reflow-mixed-scroll)) + viewport-reflow viewport-reflow-mixed-scroll + native-frame)) + (if (eq projection-kind 'native-frame) + (make-hash-table :test 'eq) (or (plist-get previous-state :surface-node-object-table) - (error "Ebox span projection has no retained node table")) - (make-hash-table :test 'eq))) + (error "Ebox span projection has no retained node table"))) + (make-hash-table :test 'eq))) (style-state-table (ebox-surface--style-state-table previous-state)) + (ebox-surface--inline-style-value-cache + (and style-required-p + (not (ebox-style-cascade-active-p)) + (make-hash-table :test 'equal))) (stylesheet-signature (and style-required-p (if (memq projection-kind @@ -851,9 +904,16 @@ PREVIOUS-STATE's node-object table." formatting-context-reflow mixed-owner-reflow)) nil (ebox-surface--stylesheet-signature)))) + (stylesheet-subject-local-p + (and stylesheet-signature + (ebox-surface--stylesheet-subject-local-p + stylesheet-signature))) (bindings-by-subject (make-hash-table :test 'eq)) (states-by-subject (make-hash-table :test 'eq)) (subjects (when (and style-required-p + (not (and (eq projection-kind 'native-frame) + (plist-get state-overrides + :native-topology-stable-p))) (not (memq projection-kind '(owner-scoped scroll-patch formatting-context-reflow @@ -862,14 +922,15 @@ PREVIOUS-STATE's node-object table." (selector-tree-snapshot (if (memq projection-kind '(owner-scoped scroll-patch - formatting-context-reflow mixed-owner-reflow)) + formatting-context-reflow mixed-owner-reflow + native-frame)) (plist-get previous-state :selector-tree-snapshot) (and subjects (ebox-surface--subject-signature (plist-get subjects :root-subject))))) (selector-tree-token (if (memq projection-kind - '(owner-scoped scroll-patch)) + '(owner-scoped scroll-patch native-frame)) (plist-get previous-state :selector-tree-token) (and selector-tree-snapshot (if (and (equal selector-tree-snapshot @@ -878,7 +939,14 @@ PREVIOUS-STATE's node-object table." (plist-get previous-state :selector-tree-token)) (plist-get previous-state :selector-tree-token) (cons 'ebox/selector-tree nil)))))) - (if (memq projection-kind + (if (eq projection-kind 'native-frame) + (ebox-surface--project-native-node-delta + context root previous-state state-overrides node-root objects-by-node + (and subjects (plist-get subjects :node-subject-table)) + bindings-by-subject states-by-subject style-state-table + stylesheet-signature selector-tree-token + stylesheet-subject-local-p) + (if (memq projection-kind '(span-patch owner-scoped scroll-patch formatting-context-reflow mixed-owner-reflow viewport-reflow viewport-reflow-mixed-scroll)) @@ -890,7 +958,7 @@ PREVIOUS-STATE's node-object table." (and subjects (plist-get subjects :node-subject-table)) bindings-by-subject states-by-subject style-state-table stylesheet-signature - selector-tree-token))) + selector-tree-token stylesheet-subject-local-p)))) (list :surface-root surface-root :node-root node-root :objects-by-node objects-by-node @@ -899,6 +967,96 @@ PREVIOUS-STATE's node-object table." :selector-tree-snapshot selector-tree-snapshot :selector-tree-token selector-tree-token))) +(defun ebox-surface--project-native-stable-node-table + (context nodes node-root old-objects objects-by-node) + "Reuse native NODES whose object topology is proven unchanged." + (tp-object-reuse-subtree context node-root) + (maphash + (lambda (node-id node) + (let ((object (gethash node-id old-objects))) + (unless object + (error "Ebox native projection lost retained node %S" node-id)) + (plist-put node :surface-object object) + (puthash node object objects-by-node))) + nodes) + objects-by-node) + +(defun ebox-surface--project-native-node-delta + (context root previous-state state-overrides node-root objects-by-node + subject-table bindings-by-subject states-by-subject + style-state-table stylesheet-signature selector-tree-token + stylesheet-subject-local-p) + "Project ROOT objects from its exact native touched-node delta." + (let* ((nodes (plist-get state-overrides :node-table)) + (parents (plist-get state-overrides :parent-table)) + (touched-ids (plist-get state-overrides :native-touched-node-ids)) + (old-objects (plist-get previous-state :surface-node-object-table)) + (touched (make-hash-table :test 'equal)) + (objects-by-id (make-hash-table :test 'equal))) + (unless (and (hash-table-p nodes) (hash-table-p parents) + (hash-table-p old-objects) + (or (plist-get state-overrides :native-topology-stable-p) + touched-ids)) + (error "Ebox native projection has no candidate object delta")) + (if (plist-get state-overrides :native-topology-stable-p) + (ebox-surface--project-native-stable-node-table + context nodes node-root old-objects objects-by-node) + (tp-object-retain context node-root) + (dolist (node-id touched-ids) (puthash node-id t touched)) + (maphash + (lambda (node-id node) + (when subject-table + (when-let* ((subject (gethash node subject-table)) + (object (gethash node-id old-objects)) + (style-state (gethash object style-state-table)) + (binding (plist-get style-state :binding))) + (puthash subject binding bindings-by-subject) + (puthash subject style-state states-by-subject))) + (unless (gethash node-id touched) + (let ((object (gethash node-id old-objects))) + (unless object + (error "Ebox native projection lost retained node %S" node-id)) + (tp-object-reuse context object) + (puthash node object objects-by-node) + (puthash node-id object objects-by-id)))) + nodes) + (dolist (node-id touched-ids) + (let* ((node (gethash node-id nodes)) + (parent-id (and node (gethash node-id parents))) + (old-object (and node (gethash node-id old-objects))) + (parent-node (and parent-id (gethash parent-id nodes))) + (position + (if parent-node + (or (cl-position + node (ebox-tree--children-raw parent-node) :test #'eq) + (error "Ebox native projection lost child position")) + 0)) + (parent (if parent-id + (gethash parent-id objects-by-id) + node-root))) + (unless (and node parent) + (error "Ebox native projection has an unordered node delta")) + (let ((object + (tp-object-ensure-at context parent + (ebox-surface--node-key node) + (ebox-surface--node-kind node) + position))) + (tp-object-retain context object) + (if (eq object old-object) + (tp-object-reuse context object) + (when subject-table + (ebox-surface--apply-node-style + object node subject-table bindings-by-subject + states-by-subject style-state-table stylesheet-signature + selector-tree-token stylesheet-subject-local-p) + (ebox-style-sync-flex-item node))) + (plist-put node :surface-object object) + (puthash node object objects-by-node) + (puthash node-id object objects-by-id)))) + (unless (gethash (plist-get root :node-id) objects-by-id) + (error "Ebox native projection lost its root object"))) + objects-by-node)) + (defun ebox-surface--complete-projection (context root style-required-p projection) "Complete a retained PROJECTION for ROOT after a local proof miss. @@ -913,6 +1071,10 @@ the projection roots a second time." (or (plist-get projection :stylesheet-signature) (and style-required-p (ebox-surface--stylesheet-signature)))) + (stylesheet-subject-local-p + (and stylesheet-signature + (ebox-surface--stylesheet-subject-local-p + stylesheet-signature))) (selector-tree-token (plist-get projection :selector-tree-token)) (bindings-by-subject (make-hash-table :test 'eq)) @@ -925,7 +1087,7 @@ the projection roots a second time." (and subjects (plist-get subjects :node-subject-table)) bindings-by-subject states-by-subject style-state-table stylesheet-signature - selector-tree-token) + selector-tree-token stylesheet-subject-local-p) projection)) (defun ebox-surface--projection-result @@ -942,6 +1104,9 @@ the projection roots a second time." (and scroll-fast-p (plist-get state :retained-scroll-content-p) root-id owner-id (= root-id owner-id))) + (native-retained-p + (and (eq projection-kind 'native-frame) + (plist-get state :native-topology-stable-p))) (node-objects (or (and scroll-fast-p (plist-get state :surface-node-object-table)) @@ -960,18 +1125,128 @@ the projection roots a second time." (plist-get state :logical-id-region-table)) (plist-put state :logical-id-region-table (ebox-surface--logical-id-region-table state region-objects))) - (let* ((prepared - (ebox-surface--surface-plan - context surface-root output state node-objects region-objects - scroll-fast-p)) - (plan (car prepared)) - (rendered (cadr prepared)) - (owned-ranges (nth 2 prepared))) - (if full-surface-p - (tp-surface-retained-content-result-create - context plan rendered owned-ranges state - t) - (tp-surface-result-create-owned context plan state))))) + (or (and native-retained-p + (ebox-surface--native-patch-result + context projection state output node-objects region-objects)) + (let* ((prepared + (ebox-surface--surface-plan + context surface-root output state node-objects region-objects + scroll-fast-p)) + (plan (car prepared)) + (rendered (cadr prepared)) + (owned-ranges (nth 2 prepared)) + (property-contributions + (plist-get state :paint-property-contributions))) + (if (or full-surface-p native-retained-p property-contributions) + (tp-surface-retained-content-result-create + context plan rendered owned-ranges state + full-surface-p property-contributions) + (tp-surface-result-create-owned context plan state)))))) + +(defun ebox-surface--native-patch-result + (context projection state rendered node-objects region-objects) + "Return a TP batch directly from STATE's exact native patch, or nil." + (let ((frame (plist-get state :native-render-frame))) + (when (and (plist-get frame :native-patch) + (plist-get state :native-topology-stable-p) + (stringp rendered)) + (let* ((surface-root (plist-get projection :surface-root)) + (fragment-root + (tp-object-ensure context surface-root + ebox-surface--fragments-key 'ebox/fragments)) + (text-leaf + (tp-object-ensure context fragment-root + ebox-surface--text-key 'ebox/text)) + (fragment-data + (plist-get state :native-render-fragment-template)) + (reuse-mount-projection-p + (and (plist-get state :native-reuse-mount-projection-p) t)) + (owned-ranges + (if (or reuse-mount-projection-p + (plist-get state :native-reuse-ownership-p)) + (let ((ranges + (plist-get state :native-committed-owned-ranges))) + (unless ranges + (error "Native retained frame has no ownership base")) + ranges) + (unless (vectorp fragment-data) + (error "Native retained patch has no target fragments")) + (ebox-surface--native-owned-ranges + context text-leaf fragment-data state node-objects + region-objects nil))) + (rendered-length (length rendered)) + (mount-records + (list (list :object text-leaf :start 0 :end rendered-length + :props nil :tags nil :leaf t) + (list :object fragment-root :start 0 :end rendered-length + :props nil :tags nil :leaf nil) + (list :object surface-root :start 0 :end rendered-length + :props nil :tags nil :leaf nil)))) + (plist-put state :surface-owned-ranges owned-ranges) + (plist-put state :native-committed-rendered rendered) + (plist-put state :native-committed-owned-ranges owned-ranges) + (plist-put state :native-committed-fragment-template fragment-data) + (plist-put state :surface-fragments (list :native-frame frame)) + (cl-remf state :native-reuse-ownership-p) + (cl-remf state :native-reuse-mount-projection-p) + (cl-remf state :native-coordinate-patches) + (cl-remf state :native-render-output) + (cl-remf state :native-render-fragments) + (cl-remf state :native-render-fragment-template) + (cl-remf state :native-render-frame) + (cl-remf state :previous-surface-owned-ranges) + (ebox-surface--native-commit-batch + context state mount-records owned-ranges + reuse-mount-projection-p))))) + +(defun ebox-surface--coordinate-patches-identity-p (frame) + "Return non-nil when FRAME preserves every character coordinate." + (and (= (plist-get frame :base-character-count) + (plist-get frame :target-character-count)) + (cl-every + (lambda (patch) + (and (= (plist-get patch :old-start) + (plist-get patch :new-start)) + (= (plist-get patch :old-end) + (plist-get patch :new-end)))) + (plist-get frame :coordinate-patches)))) + +(defun ebox-surface--commit-mount-specs (records owned-ranges) + "Return exact target mount specs from RECORDS and OWNED-RANGES. +The text record precedes logical ownership ranges, matching TP's normal +content projection order; fragment and surface records follow unchanged." + (unless records + (error "Ebox native commit has no target mount records")) + (append (list (car records)) owned-ranges (cdr records))) + +(defun ebox-surface--native-commit-batch + (context state mount-records owned-ranges &optional retain-mount-state-p) + "Return a strict TP batch result for STATE's stable-topology native patch." + (let* ((frame (or (plist-get state :native-render-frame) + (plist-get (plist-get state :surface-fragments) + :native-frame))) + (patches (and frame (plist-get frame :patches))) + (base (and frame (plist-get frame :base-character-count))) + (target (and frame (plist-get frame :target-character-count)))) + (when (and (plist-get frame :native-patch) + (plist-get state :native-topology-stable-p) + (integerp base) (integerp target) + (proper-list-p patches)) + (let ((batch + (tp-commit-batch-create + :base-revision (plist-get state :runtime-revision) + :target-revision (1+ (plist-get state :runtime-revision)) + :base-extent base :target-extent target + :patches patches + :coordinate-patches (plist-get frame :coordinate-patches)))) + (if retain-mount-state-p + (tp-commit-batch-result-create + context batch :client-state state :reuse-mount-projection t) + (tp-commit-batch-result-create + context batch + :mount-specs + (ebox-surface--commit-mount-specs mount-records owned-ranges) + :client-state state)))))) (defun ebox-surface--mounted-object-for-node (state node-id) "Return NODE-ID's nearest live retained object with mounts in STATE." @@ -1150,7 +1425,8 @@ published generation so a failed transaction has no cache state to restore." (defun ebox-surface--node-style-binding (object node subject bindings-by-subject states-by-subject style-state-table - stylesheet-signature selector-tree-token) + stylesheet-signature selector-tree-token + stylesheet-subject-local-p) "Install and return NODE style binding on OBJECT for SUBJECT." (let* ((parent (ecss-subject-parent subject)) (parent-binding (and parent (gethash parent bindings-by-subject))) @@ -1158,6 +1434,13 @@ published generation so a failed transaction has no cache state to restore." (declarations (ebox-style-node-declarations node)) (old-state (gethash object style-state-table)) (old-binding (and old-state (plist-get old-state :binding))) + (selector-context-stable-p + (and old-state + (or (eq selector-tree-token + (plist-get old-state :selector-tree-token)) + (and stylesheet-subject-local-p + (ebox-surface--subject-local-equal-p + subject (plist-get old-state :subject)))))) (old-parent-style (and old-state (plist-get old-state :parent-binding) @@ -1167,8 +1450,7 @@ published generation so a failed transaction has no cache state to restore." (and parent-binding (tp-binding-last-value parent-binding))) (theme-explicit-delta-style (and old-state old-binding - (eq selector-tree-token - (plist-get old-state :selector-tree-token)) + selector-context-stable-p (equal stylesheet-signature (plist-get old-state :stylesheet-signature)) (ebox-style--theme-delta-computed @@ -1181,8 +1463,7 @@ published generation so a failed transaction has no cache state to restore." (and old-state old-binding (equal (plist-get old-state :declarations) declarations) - (eq selector-tree-token - (plist-get old-state :selector-tree-token)) + selector-context-stable-p (equal stylesheet-signature (plist-get old-state :stylesheet-signature)) (ebox-style--theme-parent-delta-computed @@ -1194,10 +1475,9 @@ published generation so a failed transaction has no cache state to restore." (and (null theme-explicit-delta-style) theme-parent-delta-style)) (reused-p - (and old-state + (and old-state (null theme-delta-style) - (eq selector-tree-token - (plist-get old-state :selector-tree-token)) + selector-context-stable-p (equal declarations (plist-get old-state :declarations)) (eq parent-binding (plist-get old-state :parent-binding)) (eq parent-style-state @@ -1244,7 +1524,7 @@ published generation so a failed transaction has no cache state to restore." last-style (plist-get captured :declarations) last-parent-style parent-style) - (ebox-style-compute-subject + (ebox-surface--compute-node-style (plist-get captured :subject) (plist-get captured :declarations) parent-style))) @@ -1252,17 +1532,27 @@ published generation so a failed transaction has no cache state to restore." last-style)) (lambda () (or delta-style - (ebox-style-compute-subject + (ebox-surface--compute-node-style (plist-get captured :subject) (plist-get captured :declarations) (and (plist-get captured :parent-binding) (tp-binding-read (plist-get captured :parent-binding))))))))) new-state))) + (precomputed-style + (and (not reused-p) + (null old-state) + (hash-table-p ebox-surface--inline-style-value-cache) + (ebox-surface--compute-node-style + subject declarations new-parent-style))) (binding - (tp-bind - object 'ebox/computed-style - (plist-get state :compute)))) + (if precomputed-style + (tp-bind-precomputed + object 'ebox/computed-style (plist-get state :compute) + precomputed-style (and parent-binding (list parent-binding))) + (tp-bind + object 'ebox/computed-style + (plist-get state :compute))))) (unless reused-p (plist-put state :binding binding) (puthash object state style-state-table)) @@ -1270,19 +1560,55 @@ published generation so a failed transaction has no cache state to restore." (puthash subject state states-by-subject) binding)) +(defun ebox-surface--compute-node-style (subject declarations parent-style) + "Compute one node style, reusing selector-free immutable results." + (if (not (hash-table-p ebox-surface--inline-style-value-cache)) + (ebox-style-compute-subject subject declarations parent-style) + (let* ((key (list declarations parent-style)) + (missing (make-symbol "ebox-inline-style-cache-missing")) + (cached (gethash key ebox-surface--inline-style-value-cache + missing))) + (if (not (eq cached missing)) + cached + (let ((style + (ebox-style-compute-subject + subject declarations parent-style))) + (puthash key style ebox-surface--inline-style-value-cache) + style))))) + +(defun ebox-surface--node-style-binding-required-p (node) + "Return non-nil when selector-free NODE needs its own style binding." + (let ((declarations (ebox-style-node-declarations node))) + (or (ebox-surface--style-consuming-node-p node) + (cl-some (lambda (property) + (plist-member declarations property)) + ebox-surface--inherited-style-properties) + (cl-loop for (property _value) on declarations by #'cddr + thereis (and (symbolp property) + (string-prefix-p "--" (symbol-name property))))))) + (defun ebox-surface--apply-node-style (object node subject-table bindings-by-subject states-by-subject style-state-table - stylesheet-signature selector-tree-token) + stylesheet-signature selector-tree-token + stylesheet-subject-local-p) "Compute and apply NODE style using retained OBJECT and SUBJECT-TABLE." (when-let ((subject (gethash node subject-table))) - (ebox-style-apply-computed - node - (tp-binding-read - (ebox-surface--node-style-binding - object node subject bindings-by-subject states-by-subject - style-state-table - stylesheet-signature selector-tree-token))))) + (if (and (hash-table-p ebox-surface--inline-style-value-cache) + (not (ebox-surface--node-style-binding-required-p node))) + (when-let* ((parent (ecss-subject-parent subject)) + (binding (gethash parent bindings-by-subject))) + (puthash subject binding bindings-by-subject) + (when-let ((state (gethash parent states-by-subject))) + (puthash subject state states-by-subject))) + (ebox-style-apply-computed + node + (tp-binding-read + (ebox-surface--node-style-binding + object node subject bindings-by-subject states-by-subject + style-state-table + stylesheet-signature selector-tree-token + stylesheet-subject-local-p)))))) (defun ebox-surface--static-style-state-p (state &optional seen) "Return non-nil when STATE's inherited binding closure is static. @@ -1369,7 +1695,7 @@ dependencies, or any malformed index conservatively preserves root planning." (defun ebox-surface--ensure-node-tree (context parent node table subject-table bindings-by-subject states-by-subject style-state-table stylesheet-signature - selector-tree-token) + selector-tree-token stylesheet-subject-local-p) "Ensure styled NODE descendants below PARENT in CONTEXT and fill TABLE." (let ((object (tp-object-ensure context parent @@ -1382,12 +1708,13 @@ dependencies, or any malformed index conservatively preserves root planning." (ebox-surface--apply-node-style object node subject-table bindings-by-subject states-by-subject style-state-table - stylesheet-signature selector-tree-token)) + stylesheet-signature selector-tree-token + stylesheet-subject-local-p)) (dolist (child (ebox-tree--children-raw node)) (ebox-surface--ensure-node-tree context object child table subject-table bindings-by-subject states-by-subject style-state-table stylesheet-signature - selector-tree-token)) + selector-tree-token stylesheet-subject-local-p)) (when subject-table (ebox-style-sync-flex-item node)) object)) @@ -1535,6 +1862,12 @@ valid because their declarative layout and viewport did not change." (timer-table (make-hash-table :test 'equal)) (smooth-table (make-hash-table :test 'equal))) (cl-remf state :preserve-scroll-producer-region-ids) + (cl-remf state :native-render-p) + (cl-remf state :native-render-output) + (cl-remf state :native-render-fragments) + (cl-remf state :native-render-frame) + (cl-remf state :native-render-fragment-template) + (cl-remf state :native-render-fallback) (let ((ebox-viewport-width (plist-get state :viewport-width)) (ebox-viewport-height (plist-get state :viewport-height)) (ebox--render-display-signature @@ -1567,10 +1900,16 @@ valid because their declarative layout and viewport did not change." (make-hash-table :test #'eq))) (cl-letf (((symbol-function 'ebox--scroll-schedule-idle-prefetch) (lambda (&rest _) nil))) + (when (and (require 'ebox-native-commit nil t) + (not (ebox-surface--runtime-index-ready-p state))) + (setq state (ebox-surface--finish-runtime-state state))) (let ((rendered - (let ((ebox--surface-materialization-active t)) - (ebox--render-layout node)))) - (ebox--record-render-output-provenance rendered) + (or (and (require 'ebox-native-commit nil t) + (ebox-native-commit-render state node)) + (let ((ebox--surface-materialization-active t)) + (ebox--render-layout node))))) + (unless (plist-get state :native-render-p) + (ebox--record-render-output-provenance rendered)) (ebox-surface--restore-scroll-metadata scroll-table scroll-metadata) (plist-put state :scroll-state-table scroll-table) @@ -1774,12 +2113,89 @@ old allocated width while the owner changes its natural content length." (puthash owner-id updated (plist-get state :layout-snapshots)) (plist-put state :layout-snapshot-detail-generation generation) (plist-put state :layout-snapshots-complete-p nil) + (when-let* ((proof + (cl-find owner-id + (plist-get state :allocation-certificate-proofs) + :key (lambda (candidate) + (plist-get candidate :owner-id)) + :test #'equal)) + (signature (plist-get proof :ancestor-slot-signature))) + (let ((certificates + (or (plist-get state :retained-allocation-certificates) + (make-hash-table :test 'equal)))) + (puthash + owner-id + (list :root-id + (plist-get (plist-get state :root-node) :node-id) + :viewport-width (plist-get state :viewport-width) + :viewport-height (plist-get state :viewport-height) + :display-signature + (copy-tree (plist-get state :display-signature)) + :signature (copy-tree signature)) + certificates) + (plist-put state :retained-allocation-certificates certificates))) state)) +(defun ebox-surface--allocation-owner-line-signature (lines region-ids) + "Return local content-owner run signatures for propertized LINES." + (let ((local (ebox--region-id-set region-ids))) + (mapcar + (lambda (line) + (let ((position 0) (limit (length line)) previous runs) + (while (< position limit) + (let* ((owners + (copy-sequence + (or (get-text-property position + 'ebox-content-owners line) + nil))) + (owner (get-text-property position + 'ebox-content-owner line)) + (stack (if (and owner (not (member owner owners))) + (append owners (list owner)) + owners)) + (local-stack + (cl-remove-if-not (lambda (id) (gethash id local)) stack)) + (next (or (next-property-change position line limit) + limit))) + (unless (equal local-stack previous) + (push local-stack runs) + (setq previous local-stack)) + (setq position (max (1+ position) next)))) + (nreverse runs))) + lines))) + +(defun ebox-surface--retained-external-owner-suffix + (buffer spans region-ids) + "Return one common retained content-owner suffix outside REGION-IDS." + (let ((local (ebox--region-id-set region-ids)) common seen valid) + (setq valid t) + (with-current-buffer buffer + (dolist (span spans) + (let ((position (car span)) (limit (cdr span))) + (while (and valid (< position limit)) + (let* ((owners + (copy-sequence + (or (get-text-property position 'ebox-content-owners) + nil))) + (owner (get-text-property position 'ebox-content-owner)) + (stack (if (and owner (not (member owner owners))) + (append owners (list owner)) owners)) + (external + (cl-remove-if (lambda (id) (gethash id local)) stack)) + (next (or (next-property-change position nil limit) + limit))) + (when stack + (if seen + (unless (equal common external) (setq valid nil)) + (setq common external seen t))) + (setq position (max (1+ position) next))))))) + (and valid seen common))) + (defun ebox-surface--owner-patch-candidate (buffer state owner-id allocated-width &optional variable-content-p variable-content-max-width - role-owned-lines-p changed-keys) + role-owned-lines-p changed-keys allocation-closure-p + retain-external-owner-suffix-p) "Return validated patch details for OWNER-ID, or nil." (let* ( (old-snapshot @@ -1802,13 +2218,44 @@ old allocated width while the owner changes its natural content length." node)))) (when (and old-snapshot spans node render-node) (let* ((rendered - (ebox-surface--render-candidate-node state render-node)) + (prog1 + (ebox-surface--render-candidate-node state render-node) + (when (and allocation-closure-p (not (eq render-node node))) + (ebox--flex-recache-source-boxes node)))) + (region-ids (plist-get old-snapshot :region-ids)) + (old-allocation-signature + (and allocation-closure-p + (with-current-buffer buffer + (ebox-surface--allocation-owner-line-signature + (mapcar (lambda (span) + (buffer-substring (car span) (cdr span))) + spans) + region-ids)))) + (new-allocation-signature + (and allocation-closure-p + (ebox-surface--allocation-owner-line-signature + (ebox-string-lines rendered) region-ids))) (owned-rendered (if role-owned-lines-p (ebox-buffer--rendered-owned-lines - rendered (plist-get old-snapshot :region-ids) + rendered region-ids (length spans)) rendered)) + (owned-rendered + (if allocation-closure-p + (and (equal old-allocation-signature + new-allocation-signature) + owned-rendered) + owned-rendered)) + (owned-rendered + (if (or allocation-closure-p + retain-external-owner-suffix-p) + (and owned-rendered + (when-let ((owners + (ebox-surface--retained-external-owner-suffix + buffer spans region-ids))) + (ebox--add-content-owners owned-rendered owners))) + owned-rendered)) (replacement (and owned-rendered (ebox-surface--span-patch-lines @@ -1839,7 +2286,9 @@ old allocated width while the owner changes its natural content length." (plist-get proof :variable-content-p) (plist-get proof :variable-content-max-width) (plist-get proof :role-owned-lines-p) - (plist-get proof :changed-keys))) + (plist-get proof :changed-keys) + (plist-get proof :allocation-closure-p) + (plist-get proof :retain-external-owner-suffix-p))) proofs) (list (ebox-surface--owner-patch-candidate @@ -1921,6 +2370,22 @@ old allocated width while the owner changes its natural content length." (ebox-surface--replace-buffer-spans source origin (mapcar #'car pairs) (mapcar #'cdr pairs)))) + (plist-put + state :content-coordinate-patches + (sort + (cl-loop + for (candidate . details) in rebased append + (cl-mapcar + (lambda (old-span new-span) + (list :old-start (- (car old-span) origin) + :old-end (- (cdr old-span) origin) + :new-start (- (car new-span) origin) + :new-end (- (cdr new-span) origin))) + (plist-get candidate :old-spans) + (plist-get details :spans))) + (lambda (left right) + (< (plist-get left :old-start) + (plist-get right :old-start))))) (dolist (candidate candidates) (ebox-surface--install-span-patch-snapshot state (plist-get candidate :owner-id) @@ -2053,8 +2518,64 @@ untouched." (plist-put state :formatting-context-reflow-new-spans new-spans) (plist-put state :formatting-context-reflow-new-length (length context-rendered)) + (plist-put + state :content-coordinate-patches + (list (list :old-start block-start :old-end block-end + :new-start block-start + :new-end (+ block-start + (length context-rendered))))) output)))))))) +(defvar ebox-surface--paint-node-depth-cache nil + "Dynamically bound node-id depth cache for one mixed paint projection.") + +(defvar ebox-surface--paint-node-chain-cache nil + "Dynamically bound role-chain cache for one mixed paint projection.") + +(defconst ebox-surface--paint-node-depth-missing + (make-symbol "ebox-paint-node-depth-missing") + "Sentinel distinguishing an uncached paint node depth.") + +(defconst ebox-surface--paint-node-chain-missing + (make-symbol "ebox-paint-node-chain-missing") + "Sentinel distinguishing an uncached paint node chain.") + +(defun ebox-surface--repaint-output-fragment! + (output fragment old-state new-state old-face-cache new-face-cache + old-node-face-cache new-node-face-cache) + "Recompose FRAGMENT's face directly in mutable OUTPUT and return metadata. +FRAGMENT retains absolute offsets into OUTPUT. Avoiding one substring, one +fragment-local copy, and a final full concat per run keeps mixed projection +work proportional to semantic face composition instead of allocation churn." + (let* ((start (plist-get fragment :start)) + (end (plist-get fragment :end)) + (old-roles (plist-get fragment :old-paint-role-ids)) + (new-roles (plist-get fragment :paint-role-ids)) + (old-faces + (ebox-surface--cached-face-contributions + old-face-cache old-state old-roles old-node-face-cache)) + (new-faces + (ebox-surface--cached-face-contributions + new-face-cache new-state new-roles new-node-face-cache)) + (baseline + (and (< start end) + (if (plist-get fragment :face-baseline-known-p) + (copy-tree (plist-get fragment :face-baseline)) + (ebox-surface--face-baseline + (get-text-property start 'face output) old-faces)))) + (unchanged (and (equal old-roles new-roles) + (equal old-faces new-faces))) + (face (and (not unchanged) + (ebox-surface--compose-face baseline new-faces)))) + (when (and (not unchanged) (< start end)) + (if face + (put-text-property start end 'face face output) + (remove-text-properties start end '(face nil) output))) + (cl-remf fragment :text) + (plist-put fragment :text-source-p t) + (cl-remf fragment :old-paint-role-ids) + fragment)) + (defun ebox-surface--mixed-owner-output (buffer previous-state state) "Return one geometry-plus-paint candidate output, or nil on proof miss. The geometry context is rendered by the existing whole-line proof. Paint @@ -2074,17 +2595,16 @@ causes the caller to use the ordinary full projection." (make-hash-table :test 'eq)) (let ((ebox--box-content-render-cache (make-hash-table :test 'eq))) - (ebox-surface--formatting-context-reflow-output + (ebox-surface--formatting-context-reflow-output buffer state))))) - (old-fragments (plist-get previous-state :surface-fragments)) - (old-text - (and (buffer-live-p buffer) - (with-current-buffer buffer - (save-restriction - (widen) - (buffer-substring (point-min) (point-max)))))) + (old-fragments + (ebox-surface--materialized-fragment-ledger previous-state)) (old-by-address (make-hash-table :test #'equal)) - (old-by-local-address (make-hash-table :test #'equal))) + (old-by-local-address (make-hash-table :test #'equal)) + (ebox-surface--paint-node-depth-cache + (make-hash-table :test #'eql)) + (ebox-surface--paint-node-chain-cache + (make-hash-table :test #'equal))) (when (and output old-fragments paint-owner-ids) (dolist (fragment old-fragments) (when-let ((address (plist-get fragment :paint-address))) @@ -2099,16 +2619,18 @@ causes the caller to use the ordinary full projection." (puthash key (cons fragment (gethash key old-by-local-address)) old-by-local-address))))) - (let ((new-fragments (ebox-surface--rendered-fragments output))) + (let ((new-fragments + (or (and (eq geometry-kind 'span-patch) + (ebox-surface--incremental-patched-fragments + output old-fragments + (plist-get state :content-coordinate-patches))) + (ebox-surface--rendered-fragments output)))) (catch 'mixed-owner-proof-miss - (let ((old-face-cache (make-hash-table :test #'equal)) - (new-face-cache (make-hash-table :test #'equal)) - pieces) + (let (pieces) (dolist (fragment new-fragments) - (let* ((start (plist-get fragment :start)) - (end (plist-get fragment :end)) - (text (substring output start end)) - (copy (copy-tree fragment)) + (let* (;; Fragment metadata is immutable below its plist spine; + ;; repaint replaces every mutable field it owns. + (copy (copy-sequence fragment)) (address (plist-get fragment :paint-address)) (roles (plist-get fragment :paint-role-ids)) (owner (cdr (assq 'content roles))) @@ -2122,92 +2644,51 @@ causes the caller to use the ordinary full projection." (old-fragment (or (and address (gethash address old-by-address)) (and (= (length matches) 1) (car matches)))) - (old-fragment-text - (and old-text old-fragment - (integerp (plist-get old-fragment :start)) - (integerp (plist-get old-fragment :end)) - (substring old-text - (plist-get old-fragment :start) - (plist-get old-fragment :end)))) - (affected-p + (old-roles (and old-fragment + (plist-get old-fragment + :paint-role-ids))) + (paint-chain + (if (and old-fragment (equal old-roles roles) + (plist-get old-fragment :paint-node-chain)) + (copy-sequence + (plist-get old-fragment :paint-node-chain)) + (ebox-surface--paint-node-chain state roles))) + (direct-affected-p (cl-some (lambda (entry) - (memq (cdr entry) paint-owner-ids)) - roles))) - (plist-put copy :text text) - (plist-put copy :start 0) - (plist-put copy :end (length text)) - (cl-remf copy :text-source-p) - (when affected-p - (unless (and address old-fragment - (or (plist-get old-fragment - :face-baseline-known-p) - (plist-get copy - :face-baseline-known-p))) - (throw 'mixed-owner-proof-miss nil)) + (memq (cdr entry) paint-owner-ids)) + roles)) + (affected-p + (cl-some (lambda (node-id) + (memq node-id paint-owner-ids)) + paint-chain))) + (plist-put copy :paint-node-chain paint-chain) + (if (not affected-p) + (progn + (cl-remf copy :text) + (plist-put copy :text-source-p t) + (push copy pieces)) + (when direct-affected-p + (unless (and address old-fragment + (or (plist-get old-fragment + :face-baseline-known-p) + (plist-get copy + :face-baseline-known-p))) + (throw 'mixed-owner-proof-miss nil))) (when (plist-get old-fragment :face-baseline-known-p) (plist-put copy :face-baseline (copy-tree (plist-get old-fragment :face-baseline))) - (plist-put copy :face-baseline-known-p t))) - (plist-put copy :old-paint-role-ids - (copy-tree (or (and old-fragment - (plist-get old-fragment - :paint-role-ids)) - roles))) - ;; Geometry reflow still produces a complete candidate text, - ;; but an unaffected fragment whose text/properties are - ;; byte-for-byte identical and whose semantic face - ;; contributions are unchanged already has the correct - ;; composed face. Reuse its candidate metadata without - ;; rebuilding it; changed/paint-owned fragments retain the - ;; existing baseline-safe repaint path. - (let* ((old-roles (plist-get copy :old-paint-role-ids)) - (new-roles (plist-get copy :paint-role-ids)) - (reusable-p - (and old-fragment - (not affected-p) - old-fragment-text - (equal-including-properties - old-fragment-text text) - (equal old-roles new-roles) - (let ((old-faces - (or (gethash old-roles old-face-cache) - (let ((value - (ebox-surface--face-contributions - previous-state old-roles))) - (puthash old-roles value old-face-cache) - value))) - (new-faces - (or (gethash new-roles new-face-cache) - (let ((value - (ebox-surface--face-contributions - state new-roles))) - (puthash new-roles value new-face-cache) - value)))) - (equal old-faces new-faces))))) - (push (if reusable-p - (progn - (cl-remf copy :old-paint-role-ids) - copy) - (ebox-surface--repaint-fragment - copy previous-state state - old-face-cache new-face-cache)) - pieces)))) - (let* ((fragments (nreverse pieces)) - (rendered - (apply #'concat - (mapcar (lambda (fragment) - (plist-get fragment :text)) - fragments))) - (offset 0)) - (dolist (fragment fragments) - (let ((length (length (plist-get fragment :text)))) - (plist-put fragment :start offset) - (plist-put fragment :end (+ offset length)) - (setq offset (+ offset length)))) + (plist-put copy :face-baseline-known-p t)) + (plist-put copy :old-paint-role-ids + (copy-tree (or old-roles roles))) + (push copy pieces)))) + (let ((fragments (nreverse pieces))) + (setq fragments + (ebox-surface--layer-paint-contributions + output fragments state)) (plist-put state :mixed-owner-fragment-data fragments) (plist-put state :mixed-owner-content-p t) - rendered))))))) + output))))))) (defun ebox-surface--scroll-patch-output (_buffer state) "Return a staged visible scroll window without rerunning layout when safe. @@ -2301,7 +2782,8 @@ box with wrapper chrome falls back to the retained wrapper renderer." "Install or retain the prepared runtime indexes in candidate STATE." (unless (ebox-surface--runtime-index-ready-p state) (ebox--render-state-install-index - state (ebox--runtime-index (plist-get state :root-node) t))) + state (ebox--runtime-index (plist-get state :root-node) t)) + (plist-put state :runtime-index-prepared-p t)) state) (defun ebox-surface--role-ids-from-properties (properties) @@ -2468,12 +2950,194 @@ when it needs to mutate them." do (cl-remf fragment :previous-role-ids) collect fragment))) -(defun ebox-surface--fragment-metadata (fragments) +(defun ebox-surface--incremental-patched-fragments + (output old-fragments patches) + "Rebase OLD-FRAGMENTS through exact text PATCHES into OUTPUT. +Return nil unless every patch is aligned to retained fragment boundaries and +preserves the ordered role topology inside its replaced range. On success, +only replacement slices are scanned; unchanged fragment metadata is copied +and shifted without inspecting its text properties." + (when (and (stringp output) old-fragments patches) + (catch 'miss + (let ((remaining old-fragments) + (old-cursor 0) + (new-cursor 0) + (line-shift 0) + result new-origin-ranges) + (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))) + (unless (and (integerp old-start) (integerp old-end) + (integerp new-start) (integerp new-end) + (<= old-cursor old-start old-end) + (<= new-cursor new-start new-end) + (= (- new-start new-cursor) + (- old-start old-cursor)) + (<= new-end (length output))) + (throw 'miss nil)) + (while (and remaining + (<= (plist-get (car remaining) :end) old-start)) + (let ((copy (copy-sequence (pop remaining)))) + (unless (= (plist-get copy :start) old-cursor) + (throw 'miss nil)) + (let ((length (- (plist-get copy :end) + (plist-get copy :start)))) + (plist-put copy :start new-cursor) + (plist-put copy :end (+ new-cursor length)) + (plist-put copy :line + (+ line-shift (plist-get copy :line))) + (plist-put copy :text output) + (plist-put copy :text-source-p t) + (push copy result) + (setq old-cursor (+ old-cursor length) + new-cursor (+ new-cursor length))))) + (unless (= old-cursor old-start) (throw 'miss nil)) + (let (old-window) + (while (and remaining + (< (plist-get (car remaining) :start) old-end)) + (let ((fragment (pop remaining))) + (unless (and (= (plist-get fragment :start) old-cursor) + (<= (plist-get fragment :end) old-end)) + (throw 'miss nil)) + (push fragment old-window) + (setq old-cursor (plist-get fragment :end)))) + (unless (= old-cursor old-end) (throw 'miss nil)) + (setq old-window (nreverse old-window)) + (let* ((slice (substring output new-start new-end)) + (new-window (ebox-surface--rendered-fragments slice))) + (unless (and (= (length old-window) (length new-window)) + (cl-every + (lambda (pair) + (and (equal + (plist-get (car pair) :paint-role-ids) + (plist-get (cdr pair) :paint-role-ids)) + (equal + (plist-get (car pair) :role-ids) + (plist-get (cdr pair) :role-ids)))) + (cl-mapcar #'cons old-window new-window))) + (throw 'miss nil)) + (let ((old-first-line + (or (plist-get (car old-window) :line) 0)) + (new-first-line + (or (plist-get (car new-window) :line) 0)) + (old-last-line + (or (plist-get (car (last old-window)) :line) 0)) + (new-last-line + (or (plist-get (car (last new-window)) :line) 0))) + ;; Span-patch proofs retain the same line topology. A + ;; variable-line context uses the full fragment scanner. + (unless (= (- old-last-line old-first-line) + (- new-last-line new-first-line)) + (throw 'miss nil)) + (cl-mapc + (lambda (old new) + (let ((copy (copy-sequence new))) + (plist-put copy :start + (+ new-start (plist-get new :start))) + (plist-put copy :end + (+ new-start (plist-get new :end))) + (plist-put copy :line + (+ line-shift old-first-line + (- (plist-get new :line) + new-first-line))) + (plist-put copy :key (plist-get old :key)) + (plist-put copy :paint-address + (copy-tree + (plist-get old :paint-address))) + (when-let ((chain (plist-get old :paint-node-chain))) + (plist-put copy :paint-node-chain + (copy-sequence chain))) + (plist-put copy :text output) + (plist-put copy :text-source-p t) + (push copy result))) + old-window new-window) + (push (cons new-start new-end) new-origin-ranges))) + (setq old-cursor old-end + new-cursor new-end)))) + (while remaining + (let ((copy (copy-sequence (pop remaining)))) + (unless (= (plist-get copy :start) old-cursor) + (throw 'miss nil)) + (let ((length (- (plist-get copy :end) + (plist-get copy :start)))) + (plist-put copy :start new-cursor) + (plist-put copy :end (+ new-cursor length)) + (plist-put copy :line (+ line-shift (plist-get copy :line))) + (plist-put copy :text output) + (plist-put copy :text-source-p t) + (push copy result) + (setq old-cursor (+ old-cursor length) + new-cursor (+ new-cursor length))))) + (unless (= new-cursor (length output)) (throw 'miss nil)) + (dolist (range new-origin-ranges) + (remove-text-properties + (car range) (cdr range) + (list ebox--paint-origin-property nil) output)) + (nreverse result))))) + +(defun ebox-surface--rebase-coordinate (position patches) + "Return POSITION rebased through PATCHES, or nil inside a replacement." + (let ((delta 0)) + (catch 'done + (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))) + (unless (= new-start (+ old-start delta)) + (throw 'done nil)) + (cond + ((< position old-start) (throw 'done (list (+ position delta)))) + ((= position old-start) (throw 'done (list new-start))) + ((< position old-end) (throw 'done nil)) + ((= position old-end) (throw 'done (list new-end)))) + (setq delta (+ delta (- new-end new-start) + (- old-start old-end))))) + (list (+ position delta))))) + +(defun ebox-surface--rebase-owned-ranges (ranges patches output-length) + "Return fresh candidate RANGES rebased through PATCHES. +Any range boundary inside replaced text rejects reuse so ownership never +widens heuristically. The returned cons distinguishes an exact empty result +from a failed proof." + (catch 'miss + (let (result) + (dolist (range ranges) + (let ((start (ebox-surface--rebase-coordinate + (plist-get range :start) patches)) + (end (ebox-surface--rebase-coordinate + (plist-get range :end) patches))) + (unless (and start end (< (car start) (car end)) + (<= (car end) output-length)) + (throw 'miss nil)) + (push (list :object (plist-get range :object) + :start (car start) :end (car end) + :tags (copy-tree (plist-get range :tags))) + result))) + (cons t (nreverse result))))) + +(defun ebox-surface--snapshot-owned-ranges (ranges) + "Return an immutable client-state snapshot of candidate RANGES." + (mapcar (lambda (range) + (list :object (plist-get range :object) + :start (plist-get range :start) + :end (plist-get range :end) + :tags (copy-tree (plist-get range :tags)))) + ranges)) + +(defun ebox-surface--fragment-metadata (fragments &optional state) "Return FRAGMENTS as offsets into the TP-owned rendered text. The mutable propertized text is intentionally not retained in Ebox client state; paint projection reads a defensive snapshot from the published -buffer only when it needs the old text." +buffer only when it needs the old text. STATE additionally retains each +fragment's complete paint-owner ancestor chain." (let ((offset 0) + (ebox-surface--paint-node-depth-cache + (and state (make-hash-table :test #'eql))) + (ebox-surface--paint-node-chain-cache + (and state (make-hash-table :test #'equal))) metadata) (dolist (fragment fragments (nreverse metadata)) (let* ((source-p (plist-get fragment :text-source-p)) @@ -2489,6 +3153,12 @@ buffer only when it needs the old text." (unless source-p (plist-put copy :start offset) (plist-put copy :end (+ offset length))) + (when (and state (plist-get copy :paint-role-ids) + (not (plist-get copy :paint-node-chain))) + (plist-put copy :paint-node-chain + (copy-sequence + (ebox-surface--paint-node-chain + state (plist-get copy :paint-role-ids))))) (plist-put copy :text-source-p t) (push copy metadata) (setq offset (+ offset length)))))) @@ -2532,10 +3202,60 @@ Adjacent ranges merge only when both their owner and opaque tags match." (push range ranges) (puthash tags range tag-ranges))))) (setq offset (if source-p end (+ offset (length (plist-get fragment :text))))))) + (setq ranges (nreverse ranges)) (when attach-p - (tp-object-attach-content-ranges-owned context leaf (nreverse ranges))) + (tp-object-attach-content-ranges-owned context leaf ranges)) ranges)) +(defun ebox-surface--native-owned-ranges + (context leaf template state node-objects region-objects attach-p) + "Attach owner ranges from compact native fragment TEMPLATE." + (let ((active (make-hash-table :test #'eq)) + (owner-cache + (or (plist-get state :native-fragment-owner-cache) + (let ((cache (make-hash-table :test #'equal))) + (plist-put state :native-fragment-owner-cache cache) + cache))) + ranges) + (dotimes (index (length template)) + (let* ((record (aref template index)) + (roles (aref record 3)) + (owners (gethash roles owner-cache 'ebox/no-owners)) + (start (aref record 0)) + (end (aref record 1))) + (when (eq owners 'ebox/no-owners) + (setq owners + (ebox-surface--fragment-owners + roles state node-objects region-objects)) + (puthash roles owners owner-cache)) + (dolist (owner owners) + (let* ((object (nth 0 owner)) + (tags (nth 1 owner)) + (tag-ranges + (or (gethash object active) + (let ((table (make-hash-table :test #'equal))) + (puthash object table active) + table))) + (current (gethash tags tag-ranges))) + (if (and current (= (plist-get current :end) start)) + (plist-put current :end end) + (let ((range (list :object object :start start :end end + :tags tags))) + (push range ranges) + (puthash tags range tag-ranges))))))) + (setq ranges (nreverse ranges)) + (when attach-p + (tp-object-attach-content-ranges-owned context leaf ranges)) + ranges)) + +(defun ebox-surface--materialized-fragment-ledger (state) + "Return STATE's paint ledger, expanding a native frame only on demand." + (let ((ledger (plist-get state :surface-fragments))) + (if-let ((frame (and (listp ledger) + (plist-get ledger :native-frame)))) + (ebox-native-reflow-frame-fragments frame) + ledger))) + (defun ebox-surface--formatting-context-reflow-owned-ranges (context leaf ranges state node-objects) "Replace inferred owner ranges for the reflow context with one block range. @@ -2580,6 +3300,8 @@ NODE-OBJECTS and REGION-OBJECTS supply retained ownership ranges." (or (and transfer-text-p (plist-get state :retained-scroll-content-p) (plist-get state :scroll-patch-fragment-data)) + (and (eq output (plist-get state :native-render-output)) + (plist-get state :native-render-fragment-template)) (and (eq (plist-get state :projection-kind) 'mixed-owner-reflow) (plist-get state :mixed-owner-fragment-data)) @@ -2602,30 +3324,79 @@ NODE-OBJECTS and REGION-OBJECTS supply retained ownership ranges." (let* ((attach-p (not (and transfer-text-p (plist-get state :retained-scroll-content-p)))) - (owned-ranges - (ebox-surface--owned-ranges - context text-leaf fragment-data state node-objects region-objects - (and attach-p - (not (eq (plist-get state :projection-kind) - 'formatting-context-reflow)) - (not (and (eq (plist-get state :projection-kind) - 'mixed-owner-reflow) - (eq (plist-get - (plist-get state :mixed-owner-proof) - :geometry-kind) - 'formatting-context-reflow))))))) - (when (or (eq (plist-get state :projection-kind) - 'formatting-context-reflow) + (formatting-reflow-p + (or (eq (plist-get state :projection-kind) + 'formatting-context-reflow) (and (eq (plist-get state :projection-kind) 'mixed-owner-reflow) (eq (plist-get (plist-get state :mixed-owner-proof) :geometry-kind) - 'formatting-context-reflow))) + 'formatting-context-reflow)))) + (native-template-p + (and (vectorp fragment-data) + (eq output (plist-get state :native-render-output)))) + (owned-ranges + (cond + ((and native-template-p + (plist-get state :native-reuse-ownership-p)) + (let ((ranges + (plist-get state :native-committed-owned-ranges))) + (unless ranges + (error "Native retained frame has no ownership base")) + (when attach-p + (tp-object-attach-content-ranges-owned + context text-leaf ranges)) + ranges)) + (native-template-p + ;; Rust already supplied the exact target role template. Map + ;; it once to TP objects instead of guessing target ownership + ;; by rebasing every old range through every text edit. + (ebox-surface--native-owned-ranges + context text-leaf fragment-data state node-objects + region-objects (and attach-p (not formatting-reflow-p)))) + (t + (let ((rebased + (and (eq (plist-get state :projection-kind) + 'mixed-owner-reflow) + (not formatting-reflow-p) + (plist-get state :previous-surface-owned-ranges) + (plist-get state :content-coordinate-patches) + (ebox-surface--rebase-owned-ranges + (plist-get state :previous-surface-owned-ranges) + (plist-get state :content-coordinate-patches) + (length rendered))))) + (if rebased + (let ((ranges (cdr rebased))) + (when attach-p + (tp-object-attach-content-ranges-owned + context text-leaf ranges)) + ranges) + (ebox-surface--owned-ranges + context text-leaf fragment-data state node-objects + region-objects + (and attach-p (not formatting-reflow-p))))))))) + (when formatting-reflow-p (setq owned-ranges (ebox-surface--formatting-context-reflow-owned-ranges context text-leaf owned-ranges state node-objects))) + (plist-put state :surface-owned-ranges + (if native-template-p + owned-ranges + (ebox-surface--snapshot-owned-ranges owned-ranges))) + (when (plist-get state :native-render-p) + (plist-put state :native-committed-rendered rendered) + (plist-put state :native-committed-owned-ranges owned-ranges) + (when native-template-p + (plist-put state :native-committed-fragment-template fragment-data))) + (cl-remf state :native-reuse-ownership-p) + (cl-remf state :native-coordinate-patches) + (cl-remf state :native-render-output) + (cl-remf state :native-render-fragments) + (cl-remf state :native-render-fragment-template) + (cl-remf state :previous-surface-owned-ranges) (let* ((plan-text (if (or transfer-text-p + (plist-get state :native-render-p) (plist-get state :mixed-owner-content-p)) ;; Scroll output is a fresh renderer-owned string. The TP ;; owned-result path copies the string spine once while @@ -2648,7 +3419,15 @@ NODE-OBJECTS and REGION-OBJECTS supply retained ownership ranges." :capability 'content)) :capability 'content))) (plist-put state :surface-fragments - (ebox-surface--fragment-metadata fragment-data)) + (cond + ((plist-get state :native-render-frame) + (list :native-frame (plist-get state :native-render-frame))) + ((plist-get state :mixed-owner-content-p) + ;; Mixed projection already produced fresh offset-only + ;; fragment plists with retained paint chains. + fragment-data) + (t (ebox-surface--fragment-metadata fragment-data state)))) + (cl-remf state :native-render-frame) (cl-remf state :mixed-owner-fragment-data) (cl-remf state :mixed-owner-content-p) (let ((rendered-length (length rendered))) @@ -2666,25 +3445,45 @@ NODE-OBJECTS and REGION-OBJECTS supply retained ownership ranges." (defun ebox-surface--node-depth (state node-id) "Return NODE-ID depth in Ebox runtime STATE." - (let ((parents (plist-get state :parent-table)) (depth 0)) - (while (setq node-id (and node-id (gethash node-id parents))) - (cl-incf depth)) - depth)) + (let ((cached + (and ebox-surface--paint-node-depth-cache + (gethash node-id ebox-surface--paint-node-depth-cache + ebox-surface--paint-node-depth-missing)))) + (if (and cached + (not (eq cached ebox-surface--paint-node-depth-missing))) + cached + (let ((key node-id) + (parents (plist-get state :parent-table)) + (depth 0)) + (while (setq node-id (and node-id (gethash node-id parents))) + (cl-incf depth)) + (when ebox-surface--paint-node-depth-cache + (puthash key depth ebox-surface--paint-node-depth-cache)) + depth)))) (defun ebox-surface--paint-node-chain (state role-ids) "Return the deepest rendered node and its ancestors for ROLE-IDS." - (let ((region-nodes (plist-get state :region-node-table)) - (parents (plist-get state :parent-table)) deepest deepest-depth) - (dolist (entry role-ids) - (when-let ((node-id (gethash (cdr entry) region-nodes))) - (let ((depth (ebox-surface--node-depth state node-id))) - (when (or (null deepest-depth) (> depth deepest-depth)) - (setq deepest node-id deepest-depth depth))))) - (let (chain) - (while deepest - (push deepest chain) - (setq deepest (gethash deepest parents))) - (nreverse chain)))) + (let ((cached + (and ebox-surface--paint-node-chain-cache + (gethash role-ids ebox-surface--paint-node-chain-cache + ebox-surface--paint-node-chain-missing)))) + (if (and cached (not (eq cached ebox-surface--paint-node-chain-missing))) + cached + (let ((region-nodes (plist-get state :region-node-table)) + (parents (plist-get state :parent-table)) deepest deepest-depth) + (dolist (entry role-ids) + (when-let ((node-id (gethash (cdr entry) region-nodes))) + (let ((depth (ebox-surface--node-depth state node-id))) + (when (or (null deepest-depth) (> depth deepest-depth)) + (setq deepest node-id deepest-depth depth))))) + (let (chain) + (while deepest + (push deepest chain) + (setq deepest (gethash deepest parents))) + (setq chain (nreverse chain)) + (when ebox-surface--paint-node-chain-cache + (puthash role-ids chain ebox-surface--paint-node-chain-cache)) + chain))))) (defun ebox-surface--foreground-face (color) "Return the Ebox foreground face for COLOR." @@ -2737,24 +3536,57 @@ ANCESTOR-P means BOX wraps a more specific rendered surface." (list :color color)))))))) faces)) -(defun ebox-surface--face-contributions (state role-ids) +(defconst ebox-surface--node-face-cache-missing + (make-symbol "ebox-node-face-cache-missing") + "Sentinel distinguishing a cached nil node face contribution from a miss.") + +(defun ebox-surface--face-contributions (state role-ids &optional node-cache) "Return semantic Ebox face contributions for ROLE-IDS in STATE." (let ((nodes (plist-get state :node-table)) result first) (setq first t) (dolist (node-id (ebox-surface--paint-node-chain state role-ids)) (when-let* ((node (gethash node-id nodes)) (box (ebox-fragment-style-source-node node))) - (let ((region-id (plist-get box :region-id))) - (setq result - (append - result - (ebox-surface--region-face-contributions - box - (cl-loop for (role . owner) in role-ids - when (equal owner region-id) collect role) - (not first)))) + (let* ((region-id (plist-get box :region-id)) + (roles + (cl-loop for (role . owner) in role-ids + when (equal owner region-id) collect role)) + (ancestor-p (not first)) + (key (and node-cache (list node-id ancestor-p roles))) + (cached + (and node-cache + (gethash key node-cache + ebox-surface--node-face-cache-missing))) + (faces + (if (and node-cache + (not (eq cached + ebox-surface--node-face-cache-missing))) + cached + (let ((computed + (ebox-surface--region-face-contributions + box roles ancestor-p))) + (when node-cache + (puthash key computed node-cache)) + computed)))) + (dolist (face faces) + (push face result)) (setq first nil)))) - result)) + (nreverse result))) + +(defconst ebox-surface--face-cache-missing + (make-symbol "ebox-face-cache-missing") + "Sentinel distinguishing a cached nil face contribution from a miss.") + +(defun ebox-surface--cached-face-contributions + (cache state role-ids &optional node-cache) + "Return CACHE's semantic face contribution for STATE and ROLE-IDS." + (let ((value (gethash role-ids cache ebox-surface--face-cache-missing))) + (if (eq value ebox-surface--face-cache-missing) + (let ((computed + (ebox-surface--face-contributions state role-ids node-cache))) + (puthash role-ids computed cache) + computed) + value))) (defun ebox-surface--face-list (face) "Return FACE as an ordered list of face entries." @@ -2794,6 +3626,64 @@ ANCESTOR-P means BOX wraps a more specific rendered surface." (add-face-text-property 0 1 face t sample)) (get-text-property 0 'face sample))) +(defun ebox-surface--layer-paint-contributions (output fragments state) + "Return baseline FRAGMENTS and retain ordered paint layers in STATE. +OUTPUT supplies source-backed fragment text. TP receives the resulting +relative face contributions and performs the final merge during prepare." + (let ((face-cache (make-hash-table :test #'equal)) + (node-cache (make-hash-table :test #'equal)) + (offset 0) + contributions result) + (dolist (fragment fragments) + (let* ((copy (copy-sequence fragment)) + (source-p (plist-get fragment :text-source-p)) + (text + (if source-p + (substring output + (plist-get fragment :start) + (plist-get fragment :end)) + (copy-sequence (plist-get fragment :text)))) + (length (length text)) + (roles (plist-get fragment :paint-role-ids)) + (affected-p (plist-member fragment :old-paint-role-ids)) + (faces + (and affected-p + (ebox-surface--cached-face-contributions + face-cache state roles node-cache))) + (baseline + (and affected-p + (if (plist-get fragment :face-baseline-known-p) + (copy-tree (plist-get fragment :face-baseline)) + (and (> length 0) + (ebox-surface--face-baseline + (get-text-property 0 'face text) faces)))))) + (when (and affected-p (> length 0)) + (if baseline + (put-text-property 0 length 'face baseline text) + (remove-text-properties 0 length '(face nil) text)) + (when-let ((face (ebox-surface--face-value faces))) + (let ((props (list 'face (copy-tree face)))) + (if (and contributions + (= (plist-get (car contributions) :end) offset) + (equal-including-properties + (plist-get (car contributions) :props) props)) + (plist-put (car contributions) :end (+ offset length)) + (push (list :start offset :end (+ offset length) + :props props) + contributions))))) + (plist-put copy :text text) + (plist-put copy :text-source-p nil) + (plist-put copy :start offset) + (plist-put copy :end (+ offset length)) + (when affected-p + (plist-put copy :face-baseline (copy-tree baseline)) + (plist-put copy :face-baseline-known-p t)) + (push copy result) + (setq offset (+ offset length)))) + (plist-put state :paint-property-contributions + (nreverse contributions)) + (nreverse result))) + (defun ebox-surface--fragment-has-region-role-p (fragment region-id roles) "Return non-nil when FRAGMENT carries REGION-ID under any of ROLES." @@ -2857,19 +3747,15 @@ ANCESTOR-P means BOX wraps a more specific rendered surface." (old-roles (plist-get fragment :old-paint-role-ids)) (new-roles (plist-get fragment :paint-role-ids)) (old-faces - (or (and old-face-cache (gethash old-roles old-face-cache)) - (let ((value (ebox-surface--face-contributions - old-state old-roles))) - (when old-face-cache - (puthash old-roles value old-face-cache)) - value))) + (if old-face-cache + (ebox-surface--cached-face-contributions + old-face-cache old-state old-roles) + (ebox-surface--face-contributions old-state old-roles))) (new-faces - (or (and new-face-cache (gethash new-roles new-face-cache)) - (let ((value (ebox-surface--face-contributions - new-state new-roles))) - (when new-face-cache - (puthash new-roles value new-face-cache)) - value))) + (if new-face-cache + (ebox-surface--cached-face-contributions + new-face-cache new-state new-roles) + (ebox-surface--face-contributions new-state new-roles))) (baseline (and (> (length text) 0) (if (plist-get fragment :face-baseline-known-p) (copy-tree (plist-get fragment :face-baseline)) @@ -2907,7 +3793,7 @@ OLD-TEXT is a defensive snapshot of the published TP content." (plist-put copy :old-paint-role-ids (copy-tree (plist-get fragment :paint-role-ids))) copy)) - (plist-get old-state :surface-fragments)))) + (ebox-surface--materialized-fragment-ledger old-state)))) (unless fragments (error "Ebox paint projection has no published fragment topology")) (ebox-surface--paint-border-topology fragments new-state dirty-set) @@ -2958,6 +3844,26 @@ OLD-TEXT is a defensive snapshot of the published TP content." (make-hash-table :test 'eq))) (ebox--viewport-dependent-node-id-axes root))) +(defun ebox-surface--mixed-projection-preserves-context-axes-p + (previous-state state-overrides projection-kind) + "Return non-nil when a mixed projection preserves viewport dependency axes. +PREVIOUS-STATE must own a completed axis index. STATE-OVERRIDES must carry a +strict mixed-owner proof whose changes are limited to content, TP surface +properties, or paint. Those values can change measurement or publication, +but cannot introduce or remove a viewport expression or node identity." + (and (eq projection-kind 'mixed-owner-reflow) + (plist-get previous-state :viewport-dependent-node-ids-ready) + (plist-member previous-state :viewport-dependent-node-id-axes) + (when-let ((proof (plist-get state-overrides :mixed-owner-proof))) + (cl-every + (lambda (entry) + (cl-every + (lambda (key) + (or (memq key '(:content :surface-properties)) + (memq key ebox--paint-style-signature-keys))) + (plist-get entry :changed-keys))) + (plist-get proof :dirty-set))))) + (defun ebox-surface--projection-state (root previous-state state-overrides projection-kind stylesheet-active-p cascade-required-p axes viewport-width @@ -2973,6 +3879,10 @@ OLD-TEXT is a defensive snapshot of the published TP content." (plist-put state :projection-kind projection-kind) (plist-put state :cascade-active-p stylesheet-active-p) (plist-put state :cascade-required-p cascade-required-p) + (when (and (eq projection-kind 'mixed-owner-reflow) + (plist-get previous-state :surface-owned-ranges)) + (plist-put state :previous-surface-owned-ranges + (plist-get previous-state :surface-owned-ranges))) (when axes (plist-put state :viewport-width viewport-width) (plist-put state :viewport-height viewport-height) @@ -3028,7 +3938,11 @@ published runtime and must be consumed without clearing those shared nodes." (root (cond (source-path-copied-p source) (source-isolated-p - (ebox-surface--clear-runtime-attachments source)) + (if (and (eq projection-kind 'native-frame) + (plist-get state-overrides + :native-topology-stable-p)) + source + (ebox-surface--clear-runtime-attachments source))) (t (ebox-surface--candidate-root source preserve-identities-p)))) @@ -3053,17 +3967,35 @@ published runtime and must be consumed without clearing those shared nodes." (or cascade-required-p (plist-get previous-state :cascade-active-p) (plist-get previous-state :cascade-required-p)))) + (projection-kind + (if (and (null projection-kind) + (require 'ebox-native-commit nil t) + (ebox-native-commit-projection-eligible-p + previous-state state-overrides stylesheet-active-p)) + 'native-frame + projection-kind)) (projection (ebox-surface--projection-start - context root style-required-p projection-kind previous-state))) + context root style-required-p projection-kind previous-state + state-overrides))) (unless (or source-isolated-p source-path-copied-p) (ebox-surface--reconcile-candidate root previous-state)) - (let* ((axes + (let* ((reuse-context-axes-p (and signals - (if scroll-fast-p - (plist-get previous-state - :viewport-dependent-node-id-axes) - (ebox-surface--context-axes root)))) + (or scroll-fast-p + (and (eq projection-kind 'native-frame) + (plist-get state-overrides + :native-topology-stable-p) + (plist-get state-overrides + :viewport-dependent-node-ids-ready)) + (ebox-surface--mixed-projection-preserves-context-axes-p + previous-state state-overrides projection-kind)))) + (axes + (and signals + (if reuse-context-axes-p + (plist-get previous-state + :viewport-dependent-node-id-axes) + (ebox-surface--context-axes root)))) (viewport-width (and signals (funcall @@ -3087,6 +4019,11 @@ published runtime and must be consumed without clearing those shared nodes." root previous-state state-overrides projection-kind stylesheet-active-p cascade-required-p axes viewport-width viewport-height display-signature scroll-offsets))) + (when reuse-context-axes-p + (plist-put state :viewport-dependent-node-ids + (plist-get previous-state + :viewport-dependent-node-ids)) + (plist-put state :viewport-dependent-node-ids-ready t)) (plist-put state :style-binding-states (plist-get projection :style-binding-states)) (plist-put state :selector-tree-snapshot @@ -3191,6 +4128,8 @@ published runtime and must be consumed without clearing those shared nodes." ;; bypass its scope validation. (plist-put state :retained-scroll-content-p nil) (plist-put state :native-scroll-materialize-p nil) + (cl-remf state :native-touched-node-ids) + (cl-remf state :native-removed-node-ids) result))))))) (defun ebox-surface-producer diff --git a/ebox-tree.el b/ebox-tree.el index e72c1ea..4cd4b27 100644 --- a/ebox-tree.el +++ b/ebox-tree.el @@ -14,12 +14,20 @@ (require 'ebox-style) (require 'ebox-child-range) +(declare-function ebox-get "ebox" (box property)) +(declare-function ebox--ensure-node-id "ebox" (node)) +(declare-function ebox--ensure-region-id "ebox" (box)) +(declare-function ebox--next-runtime-node-id "ebox" ()) +(declare-function ebox--string-region-ids "ebox" (string)) +(declare-function ebox--box-visible-overflow-p "ebox" (box)) + (defconst ebox--default-display '(block flow) "Default CSS-like display for a plain Ebox box.") (defvar ebox--node-region-ids-cache nil "Dynamic snapshot-detail-local node -> region ids cache. This is an optimization for snapshot capture, not buffer runtime state.") +(defvar ebox--flex-item-prop-keys) (defun ebox-tree-computed-display (node) "Return NODE's canonical CSS-like display pair." diff --git a/ebox.el b/ebox.el index 22468ee..85e1cbf 100644 --- a/ebox.el +++ b/ebox.el @@ -26,7 +26,7 @@ '("ebox-cache.el" "ebox-style.el" "ebox-child-range.el" "ebox-tree.el" "ebox-measure.el" "ebox-fragment.el" "ebox-render-context.el" "ebox-layout.el" "ebox-flex.el" "ebox-grid.el" "ebox-buffer-backend.el" "ebox-incremental.el" - "ebox-surface.el" "ebox-dsl.el" "ebox-selector.el" "ebox.el" + "ebox-native-commit.el" "ebox-surface.el" "ebox-dsl.el" "ebox-selector.el" "ebox.el" "ebox-native-reflow.el") "Active Ebox Lisp sources compiled by `ebox-byte-compile'.") @@ -4520,10 +4520,22 @@ Return CANDIDATE." candidate host-ref next-subtree old-semantic-key new-semantic-key)) ;;;###autoload -(defun ebox-candidate-replace-range-ref (candidate range-ref next-items) - "Replace base child RANGE-REF in CANDIDATE with proper node NEXT-ITEMS." +(defun ebox-candidate-patch-host-paint + (candidate host-ref previous-subtree next-subtree) + "Patch HOST-REF paint from declarative OLD/NEW subtrees in CANDIDATE. +Only node-local paint fields may differ. The Host's children and runtime +identities are retained instead of being copied as a replacement subtree. +Return CANDIDATE when accepted, or nil when the delta needs ordinary subtree +replacement." + (ebox-incremental-candidate-patch-host-paint + candidate host-ref previous-subtree next-subtree)) + +;;;###autoload +(defun ebox-candidate-replace-range-ref + (candidate range-ref next-items &optional reuse-map) + "Replace RANGE-REF, retaining slots named by proven REUSE-MAP." (ebox-incremental-candidate-replace-range-ref - candidate range-ref next-items)) + candidate range-ref next-items reuse-map)) ;;;###autoload (defun ebox-candidate-replace-root (candidate next-root) @@ -4648,12 +4660,14 @@ through dirty-set and patch-set execution before falling back to root rerender." ebox-buffer-update-report ebox-build ebox-byte-compile + ebox-call-with-render-burst ebox-candidate-begin ebox-child-range ebox-candidate-replace ebox-candidate-replace-range-ref ebox-candidate-replace-root ebox-candidate-replace-host-ref + ebox-candidate-patch-host-paint ebox-clear-cache ebox-column ebox-commit @@ -4674,6 +4688,8 @@ through dirty-set and patch-set execution before falling back to root rerender." ebox-region-resolve ebox-region-update ebox-render + ebox-render-burst-begin + ebox-render-burst-end ebox-render-to-buffer ebox-rerender-buffer-with-context ebox-row @@ -4707,6 +4723,7 @@ and are documented separately in the public API reference.") direct-region-update dsl failure-atomic-rollback + framework-render-burst flex-layout host-reference intrinsic-and-viewport-sizing @@ -4763,6 +4780,8 @@ and are documented separately in the public API reference.") ;; → replace one runtime node logically ;; ebox-candidate-replace-host-ref cand ref root &optional old-key new-key ;; → replace one semantic host boundary +;; ebox-candidate-patch-host-paint cand ref old-root new-root +;; → patch paint without replacing its subtree ;; ebox-display-buffer buf box → render through TP and display the buffer ;; ebox-host-ref-position buf ref → first live position for :host-ref ;; ebox-host-ref-bounds buf ref → live (START . END) bounds for :host-ref diff --git a/native/Cargo.lock b/native/Cargo.lock index 54ca302..88231b4 100644 --- a/native/Cargo.lock +++ b/native/Cargo.lock @@ -17,10 +17,15 @@ name = "ebox-native-reflow" version = "0.1.0" dependencies = [ "cc", + "etaf-core", "serde", "serde_json", ] +[[package]] +name = "etaf-core" +version = "0.1.0" + [[package]] name = "find-msvc-tools" version = "0.1.9" diff --git a/native/Cargo.toml b/native/Cargo.toml index bd5d416..58740bf 100644 --- a/native/Cargo.toml +++ b/native/Cargo.toml @@ -10,6 +10,7 @@ name = "ebox_native_reflow" crate-type = ["cdylib"] [dependencies] +etaf-core = { path = "core" } serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/native/c/ebox_module.c b/native/c/ebox_module.c index 7779a70..b9c3cf9 100644 --- a/native/c/ebox_module.c +++ b/native/c/ebox_module.c @@ -67,6 +67,11 @@ extern bool ebox_native_layout_ready(void); extern bool ebox_native_render_proof(const unsigned char *payload, size_t payload_len, ebox_native_bytes *output); +extern bool ebox_native_session_render_sync(void *session, + uint64_t generation, + const unsigned char *payload, + size_t payload_len, + ebox_native_bytes *output); extern bool ebox_native_flex_size_lines(const ebox_native_flex_item *items, size_t item_count, const size_t *line_offsets, @@ -157,7 +162,7 @@ ebox_module_version(emacs_env *env, ptrdiff_t nargs, emacs_value *args, (void) nargs; (void) args; (void) data; - static const char version[] = "8:3:9"; + static const char version[] = "9:7:12"; return env->make_string(env, version, (ptrdiff_t) (sizeof version - 1)); } @@ -320,6 +325,333 @@ ebox_module_render_proof(emacs_env *env, ptrdiff_t nargs, emacs_value *args, return result; } +static bool +ebox_read_u16(const unsigned char *bytes, size_t length, size_t offset, + uint16_t *value) +{ + if (offset > length || length - offset < 2) + return false; + *value = (uint16_t) bytes[offset] | ((uint16_t) bytes[offset + 1] << 8); + return true; +} + +static bool +ebox_read_u32(const unsigned char *bytes, size_t length, size_t offset, + uint32_t *value) +{ + if (offset > length || length - offset < 4) + return false; + *value = (uint32_t) bytes[offset] + | ((uint32_t) bytes[offset + 1] << 8) + | ((uint32_t) bytes[offset + 2] << 16) + | ((uint32_t) bytes[offset + 3] << 24); + return true; +} + +static bool +ebox_read_u64(const unsigned char *bytes, size_t length, size_t offset, + uint64_t *value) +{ + if (offset > length || length - offset < 8) + return false; + uint64_t result = 0; + for (size_t index = 0; index < 8; index++) + result |= (uint64_t) bytes[offset + index] << (index * 8); + *value = result; + return true; +} + +static bool +ebox_checked_add(size_t left, size_t right, size_t *result) +{ + if (right > SIZE_MAX - left) + return false; + *result = left + right; + return true; +} + +static bool +ebox_fragment_section(const unsigned char *bytes, size_t length, + size_t *start, size_t *fragment_length, + size_t *record_count) +{ + static const size_t header_length = 112; + uint16_t version = 0; + uint16_t flags = 0; + uint32_t encoded_header_length = 0; + uint64_t encoded_total_length = 0; + uint64_t fragment_bytes = 0; + uint64_t fragment_records = 0; + if (length < header_length + || memcmp(bytes, "EBXT", 4) != 0 + || !ebox_read_u16(bytes, length, 4, &version) + || version != 12 + || !ebox_read_u16(bytes, length, 6, &flags) + || !ebox_read_u32(bytes, length, 8, &encoded_header_length) + || encoded_header_length != header_length + || !ebox_read_u64(bytes, length, 12, &encoded_total_length) + || encoded_total_length != length) + return false; + + size_t offset = header_length; + if ((flags & 4) != 0) { + uint32_t patch_count = 0; + uint32_t coordinate_patch_count = 0; + uint64_t payload_length = 0; + if (!ebox_read_u32(bytes, length, 120, &patch_count) + || !ebox_read_u64(bytes, length, 136, &payload_length) + || !ebox_read_u64(bytes, length, 144, &fragment_bytes) + || !ebox_read_u64(bytes, length, 152, &fragment_records) + || !ebox_read_u32(bytes, length, 160, &coordinate_patch_count)) + return false; + size_t descriptor_bytes = 0; + if ((size_t) patch_count > SIZE_MAX - (size_t) coordinate_patch_count + || (size_t) patch_count + (size_t) coordinate_patch_count + > SIZE_MAX / 32) + return false; + descriptor_bytes = ((size_t) patch_count + (size_t) coordinate_patch_count) + * 32; + if (!ebox_checked_add(offset, 56, &offset) + || !ebox_checked_add(offset, descriptor_bytes, &offset) + || payload_length > SIZE_MAX + || !ebox_checked_add(offset, (size_t) payload_length, &offset)) + return false; + } else { + uint64_t literal_length = 0; + uint32_t line_count = 0; + uint32_t metadata_length = 0; + if (!ebox_read_u64(bytes, length, 112, &literal_length) + || !ebox_read_u32(bytes, length, 120, &line_count) + || !ebox_read_u32(bytes, length, 124, &metadata_length) + || !ebox_read_u64(bytes, length, 136, &fragment_bytes) + || !ebox_read_u64(bytes, length, 144, &fragment_records)) + return false; + size_t width_bytes = 0; + if ((size_t) line_count > SIZE_MAX / 8) + return false; + width_bytes = (size_t) line_count * 8; + if (!ebox_checked_add(offset, 40, &offset) + || !ebox_checked_add(offset, width_bytes, &offset) + || literal_length > SIZE_MAX + || !ebox_checked_add(offset, (size_t) literal_length, &offset) + || !ebox_checked_add(offset, (size_t) metadata_length, &offset)) + return false; + } + if (fragment_bytes > SIZE_MAX || fragment_records > SIZE_MAX + || offset > length || (size_t) fragment_bytes != length - offset) + return false; + *start = offset; + *fragment_length = (size_t) fragment_bytes; + *record_count = (size_t) fragment_records; + return true; +} + +static bool +ebox_fragment_flat_length(const unsigned char *bytes, size_t length, + size_t record_count, size_t *scalar_count) +{ + size_t cursor = 0; + size_t scalars = 0; + for (size_t record = 0; record < record_count; record++) { + uint32_t roles = 0; + uint32_t templates = 0; + uint32_t styles = 0; + uint32_t reserved = 0; + if (cursor > length || length - cursor < 56 + || !ebox_read_u32(bytes, length, cursor + 40, &roles) + || !ebox_read_u32(bytes, length, cursor + 44, &templates) + || !ebox_read_u32(bytes, length, cursor + 48, &styles) + || !ebox_read_u32(bytes, length, cursor + 52, &reserved) + || reserved != 0) + return false; + size_t variable = 0; + if ((size_t) roles > SIZE_MAX / 9 + || (size_t) templates > SIZE_MAX / 4 + || (size_t) styles > SIZE_MAX / 4 + || !ebox_checked_add((size_t) roles * 9, + (size_t) templates * 4, &variable) + || !ebox_checked_add(variable, (size_t) styles * 4, &variable) + || !ebox_checked_add(cursor, 56, &cursor) + || !ebox_checked_add(cursor, variable, &cursor)) + return false; + size_t record_scalars = 8; + if ((size_t) roles > (SIZE_MAX - record_scalars) / 2) + return false; + record_scalars += (size_t) roles * 2; + if (!ebox_checked_add(record_scalars, (size_t) templates, + &record_scalars) + || !ebox_checked_add(record_scalars, (size_t) styles, + &record_scalars) + || !ebox_checked_add(scalars, record_scalars, &scalars)) + return false; + } + if (cursor != length || scalars > PTRDIFF_MAX) + return false; + *scalar_count = scalars; + return true; +} + +static bool +ebox_flat_set(emacs_env *env, emacs_value vector, size_t *index, + int64_t value) +{ + env->vec_set(env, vector, (ptrdiff_t) *index, + env->make_integer(env, (intmax_t) value)); + (*index)++; + return env->non_local_exit_check(env) == emacs_funcall_exit_return; +} + +static emacs_value +ebox_fragment_flat_vector(emacs_env *env, const unsigned char *bytes, + size_t length, size_t record_count) +{ + size_t scalar_count = 0; + if (!ebox_fragment_flat_length(bytes, length, record_count, &scalar_count)) { + static const char message[] = "Native reflow fragment tape is malformed"; + return ebox_signal_error(env, message, sizeof message - 1); + } + emacs_value make_args[] = { + env->make_integer(env, (intmax_t) scalar_count), ebox_nil(env) + }; + emacs_value vector = env->funcall(env, env->intern(env, "make-vector"), + 2, make_args); + if (env->non_local_exit_check(env) != emacs_funcall_exit_return) + return ebox_nil(env); + + size_t cursor = 0; + size_t output = 0; + for (size_t record = 0; record < record_count; record++) { + uint64_t start = 0; + uint64_t end = 0; + uint64_t line = 0; + uint64_t owner = 0; + uint64_t content_index = 0; + uint32_t roles = 0; + uint32_t templates = 0; + uint32_t styles = 0; + ebox_read_u64(bytes, length, cursor, &start); + ebox_read_u64(bytes, length, cursor + 8, &end); + ebox_read_u64(bytes, length, cursor + 16, &line); + ebox_read_u64(bytes, length, cursor + 24, &owner); + ebox_read_u64(bytes, length, cursor + 32, &content_index); + ebox_read_u32(bytes, length, cursor + 40, &roles); + ebox_read_u32(bytes, length, cursor + 44, &templates); + ebox_read_u32(bytes, length, cursor + 48, &styles); + if (start > INT64_MAX || end > INT64_MAX || line > INT64_MAX + || !ebox_flat_set(env, vector, &output, (int64_t) start) + || !ebox_flat_set(env, vector, &output, (int64_t) end) + || !ebox_flat_set(env, vector, &output, (int64_t) line) + || !ebox_flat_set(env, vector, &output, (int64_t) owner) + || !ebox_flat_set(env, vector, &output, (int64_t) content_index) + || !ebox_flat_set(env, vector, &output, (int64_t) roles) + || !ebox_flat_set(env, vector, &output, (int64_t) templates) + || !ebox_flat_set(env, vector, &output, (int64_t) styles)) + return ebox_nil(env); + cursor += 56; + for (uint32_t role = 0; role < roles; role++) { + uint8_t kind = bytes[cursor]; + uint64_t region = 0; + ebox_read_u64(bytes, length, cursor + 1, ®ion); + if (region > INT64_MAX + || !ebox_flat_set(env, vector, &output, kind) + || !ebox_flat_set(env, vector, &output, (int64_t) region)) + return ebox_nil(env); + cursor += 9; + } + for (uint32_t index = 0; index < templates + styles; index++) { + uint32_t value = 0; + ebox_read_u32(bytes, length, cursor, &value); + if (!ebox_flat_set(env, vector, &output, value)) + return ebox_nil(env); + cursor += 4; + } + } + return vector; +} + +static emacs_value +ebox_module_render_frame(emacs_env *env, ptrdiff_t nargs, emacs_value *args, + void *data) +{ + (void) nargs; + (void) data; + unsigned char *payload = NULL; + size_t payload_len = 0; + if (!ebox_copy_string(env, args[0], &payload, &payload_len)) + return ebox_nil(env); + ebox_native_bytes output = {0}; + bool ok = ebox_native_render_proof(payload, payload_len, &output); + free(payload); + if (!ok) + return ebox_signal_native_error(env, output, + "Native frame render failed"); + size_t fragment_start = 0; + size_t fragment_length = 0; + size_t record_count = 0; + if (!ebox_fragment_section(output.data, output.len, &fragment_start, + &fragment_length, &record_count)) { + ebox_native_bytes_free(output); + static const char message[] = "Native frame has an invalid effect tape"; + return ebox_signal_error(env, message, sizeof message - 1); + } + emacs_value tape = env->make_unibyte_string( + env, (const char *) output.data, (ptrdiff_t) output.len); + emacs_value flat = ebox_fragment_flat_vector( + env, output.data + fragment_start, fragment_length, record_count); + ebox_native_bytes_free(output); + if (env->non_local_exit_check(env) != emacs_funcall_exit_return) + return ebox_nil(env); + emacs_value values[] = { + tape, flat, env->make_integer(env, (intmax_t) record_count) + }; + return env->funcall(env, env->intern(env, "vector"), 3, values); +} + +static emacs_value +ebox_module_render_session_frame(emacs_env *env, ptrdiff_t nargs, + emacs_value *args, void *data) +{ + (void) nargs; + (void) data; + void *session = ebox_session_pointer(env, args[0]); + uint64_t generation = (uint64_t) env->extract_integer(env, args[1]); + if (session == NULL || + env->non_local_exit_check(env) != emacs_funcall_exit_return) + return ebox_nil(env); + unsigned char *payload = NULL; + size_t payload_len = 0; + if (!ebox_copy_string(env, args[2], &payload, &payload_len)) + return ebox_nil(env); + ebox_native_bytes output = {0}; + bool ok = ebox_native_session_render_sync( + session, generation, payload, payload_len, &output); + free(payload); + if (!ok) + return ebox_signal_native_error(env, output, + "Native retained frame render failed"); + size_t fragment_start = 0; + size_t fragment_length = 0; + size_t record_count = 0; + if (!ebox_fragment_section(output.data, output.len, &fragment_start, + &fragment_length, &record_count)) { + ebox_native_bytes_free(output); + static const char message[] = + "Native retained frame has an invalid effect tape"; + return ebox_signal_error(env, message, sizeof message - 1); + } + emacs_value tape = env->make_unibyte_string( + env, (const char *) output.data, (ptrdiff_t) output.len); + emacs_value flat = ebox_fragment_flat_vector( + env, output.data + fragment_start, fragment_length, record_count); + ebox_native_bytes_free(output); + if (env->non_local_exit_check(env) != emacs_funcall_exit_return) + return ebox_nil(env); + emacs_value values[] = { + tape, flat, env->make_integer(env, (intmax_t) record_count) + }; + return env->funcall(env, env->intern(env, "vector"), 3, values); +} + static bool ebox_extract_flex_integer(emacs_env *env, emacs_value value, int64_t *output) { @@ -602,6 +934,12 @@ ebox_module_init_impl(struct emacs_runtime *runtime) ebox_bind_function(env, "ebox-native--module-render-proof", 1, 1, ebox_module_render_proof, "Render one private native proof synchronously.") || + ebox_bind_function(env, "ebox-native--module-render-frame", 1, 1, + ebox_module_render_frame, + "Render one native frame with decoded effects.") || + ebox_bind_function(env, "ebox-native--module-render-session-frame", 3, 3, + ebox_module_render_session_frame, + "Render one retained native frame synchronously.") || ebox_bind_function(env, "ebox-native--module-flex-size-lines", 3, 3, ebox_module_flex_size_lines, "Calculate pure native Flex geometry lines.") || diff --git a/native/core/Cargo.toml b/native/core/Cargo.toml new file mode 100644 index 0000000..60e81ad --- /dev/null +++ b/native/core/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "etaf-core" +version = "0.1.0" +edition = "2021" +rust-version = "1.82" +publish = false + +[lib] +name = "etaf_core" +crate-type = ["rlib"] + +[dependencies] diff --git a/native/core/src/lib.rs b/native/core/src/lib.rs new file mode 100644 index 0000000..c3045c4 --- /dev/null +++ b/native/core/src/lib.rs @@ -0,0 +1,432 @@ +//! Editor-independent retained surface computation. +//! +//! This crate owns only deterministic data transformations. It deliberately +//! has no editor, FFI, serialization, marker, face, or buffer dependency. + +/// Monotonic retained surface revision. +pub type Revision = u64; + +/// Half-open coordinate range in one retained surface revision. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct TextRange { + pub start: usize, + pub end: usize, +} + +impl TextRange { + pub fn new(start: usize, end: usize) -> Result { + if start > end { + return Err(CommitError::InvalidRange { start, end }); + } + Ok(Self { start, end }) + } +} + +/// One aligned replacement between base and target revisions. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct SpanEdit { + pub old_start: usize, + pub old_end: usize, + pub new_start: usize, + pub new_end: usize, +} + +/// Pure computation result consumed by an editor-specific commit adapter. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CommitBatch { + pub base_revision: Revision, + pub target_revision: Revision, + pub base_extent: usize, + pub target_extent: usize, + /// Text or semantic state changed in these aligned spans. + pub semantic_edits: Vec, + /// Only text coordinates changed in these aligned spans. + pub coordinate_edits: Vec, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CommitError { + NonConsecutiveRevision { + base: Revision, + target: Revision, + }, + InvalidRange { + start: usize, + end: usize, + }, +} + +const MYERS_MAX_SEARCH_DISTANCE: usize = 1024; + +fn trace_value(trace: &[isize], distance: usize, diagonal: isize) -> isize { + let distance = distance as isize; + if diagonal < -distance || diagonal > distance { + -1 + } else { + trace[(diagonal + distance) as usize] + } +} + +fn core_matches( + old: &[T], + new: &[T], + equal: &F, + max_distance: usize, +) -> Option> +where + F: Fn(&T, &T) -> bool, +{ + let old_length = old.len(); + let new_length = new.len(); + let maximum = old_length.saturating_add(new_length); + let bounded = maximum.min(max_distance); + let mut traces: Vec> = Vec::with_capacity(bounded.saturating_add(1)); + let mut final_distance = maximum; + let mut found = false; + + 'search: for distance in 0..=bounded { + let previous = distance.checked_sub(1).and_then(|index| traces.get(index)); + let mut current = vec![-1; 1 + 2 * distance]; + let mut diagonal = -(distance as isize); + while diagonal <= distance as isize { + let down = previous.map(|trace| trace_value(trace, distance - 1, diagonal + 1)); + let right = previous.map(|trace| trace_value(trace, distance - 1, diagonal - 1)); + let mut old_position = if distance == 0 { + 0 + } else if diagonal == -(distance as isize) { + down.unwrap_or(-1) + } else if diagonal == distance as isize { + right.unwrap_or(-1) + 1 + } else if right.unwrap_or(-1) < down.unwrap_or(-1) { + down.unwrap_or(-1) + } else { + right.unwrap_or(-1) + 1 + }; + let mut new_position = old_position - diagonal; + while old_position >= 0 + && new_position >= 0 + && (old_position as usize) < old_length + && (new_position as usize) < new_length + && equal(&old[old_position as usize], &new[new_position as usize]) + { + old_position += 1; + new_position += 1; + } + current[(diagonal + distance as isize) as usize] = old_position; + if old_position >= old_length as isize && new_position >= new_length as isize { + traces.push(current); + final_distance = distance; + found = true; + break 'search; + } + diagonal += 2; + } + traces.push(current); + } + if !found { + return None; + } + + let mut old_position = old_length as isize; + let mut new_position = new_length as isize; + let mut matches = Vec::new(); + for distance in (1..=final_distance).rev() { + let previous_distance = distance - 1; + let previous = &traces[previous_distance]; + let diagonal = old_position - new_position; + let previous_diagonal = if diagonal == -(distance as isize) + || (diagonal != distance as isize + && trace_value(previous, previous_distance, diagonal - 1) + < trace_value(previous, previous_distance, diagonal + 1)) + { + diagonal + 1 + } else { + diagonal - 1 + }; + let previous_old = trace_value(previous, previous_distance, previous_diagonal); + let previous_new = previous_old - previous_diagonal; + if old_position > previous_old && new_position > previous_new { + let length = (old_position - previous_old).min(new_position - previous_new) as usize; + matches.push(( + old_position as usize - length, + new_position as usize - length, + length, + )); + } + old_position = previous_old; + new_position = previous_new; + } + if old_position > 0 && new_position > 0 { + let length = old_position.min(new_position) as usize; + matches.push(( + old_position as usize - length, + new_position as usize - length, + length, + )); + } + matches.reverse(); + Some(matches) +} + +fn matches(old: &[T], new: &[T], equal: &F) -> Vec<(usize, usize, usize)> +where + F: Fn(&T, &T) -> bool, +{ + let prefix = old + .iter() + .zip(new) + .take_while(|(left, right)| equal(left, right)) + .count(); + let old_rest = &old[prefix..]; + let new_rest = &new[prefix..]; + let suffix = old_rest + .iter() + .rev() + .zip(new_rest.iter().rev()) + .take_while(|(left, right)| equal(left, right)) + .count(); + let old_core = &old_rest[..old_rest.len() - suffix]; + let new_core = &new_rest[..new_rest.len() - suffix]; + let mut result = Vec::new(); + if prefix > 0 { + result.push((0, 0, prefix)); + } + if let Some(core) = core_matches(old_core, new_core, equal, MYERS_MAX_SEARCH_DISTANCE) { + result.extend( + core.into_iter() + .map(|(old_start, new_start, length)| { + (old_start + prefix, new_start + prefix, length) + }), + ); + } + if suffix > 0 { + result.push((old.len() - suffix, new.len() - suffix, suffix)); + } + result +} + +fn push_edit( + edits: &mut Vec, + old_start: usize, + old_end: usize, + new_start: usize, + new_end: usize, +) { + if old_start == old_end && new_start == new_end { + return; + } + if let Some(previous) = edits.last_mut() { + if previous.old_end == old_start && previous.new_end == new_start { + previous.old_end = old_end; + previous.new_end = new_end; + return; + } + } + edits.push(SpanEdit { + old_start, + old_end, + new_start, + new_end, + }); +} + +fn coordinate_edits(old: &[T], new: &[T], text_equal: &F) -> Vec +where + F: Fn(&T, &T) -> bool, +{ + let mut old_position = 0; + let mut new_position = 0; + let mut edits = Vec::new(); + for (matched_old, matched_new, length) in matches(old, new, text_equal) { + if old_position < matched_old || new_position < matched_new { + push_edit( + &mut edits, + old_position, + matched_old, + new_position, + matched_new, + ); + } + old_position = matched_old + length; + new_position = matched_new + length; + } + push_edit( + &mut edits, + old_position, + old.len(), + new_position, + new.len(), + ); + edits +} + +fn semantic_edits( + old: &[T], + new: &[T], + text_equal: &FText, + semantic_equal: &FSemantic, +) -> Vec +where + FText: Fn(&T, &T) -> bool, + FSemantic: Fn(&T, &T) -> bool, +{ + let text_matches = matches(old, new, text_equal); + let mut old_position = 0; + let mut new_position = 0; + let mut edits = Vec::new(); + for (matched_old, matched_new, length) in text_matches { + if old_position < matched_old || new_position < matched_new { + push_edit( + &mut edits, + old_position, + matched_old, + new_position, + matched_new, + ); + } + let mut run_start = None; + for offset in 0..length { + let equal = semantic_equal(&old[matched_old + offset], &new[matched_new + offset]); + match (run_start, equal) { + (None, false) => run_start = Some(offset), + (Some(start), true) => { + push_edit( + &mut edits, + matched_old + start, + matched_old + offset, + matched_new + start, + matched_new + offset, + ); + run_start = None; + } + _ => {} + } + } + if let Some(start) = run_start { + push_edit( + &mut edits, + matched_old + start, + matched_old + length, + matched_new + start, + matched_new + length, + ); + } + old_position = matched_old + length; + new_position = matched_new + length; + } + push_edit( + &mut edits, + old_position, + old.len(), + new_position, + new.len(), + ); + edits +} + +/// Diff two retained frames without knowing their editor-specific cell type. +pub fn diff_commit_batch( + base_revision: Revision, + target_revision: Revision, + old: &[T], + new: &[T], + text_equal: FText, + semantic_equal: FSemantic, +) -> Result +where + FText: Fn(&T, &T) -> bool, + FSemantic: Fn(&T, &T) -> bool, +{ + if target_revision != base_revision.saturating_add(1) { + return Err(CommitError::NonConsecutiveRevision { + base: base_revision, + target: target_revision, + }); + } + Ok(CommitBatch { + base_revision, + target_revision, + base_extent: old.len(), + target_extent: new.len(), + semantic_edits: semantic_edits(old, new, &text_equal, &semantic_equal), + coordinate_edits: coordinate_edits(old, new, &text_equal), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[derive(Clone, Debug, PartialEq, Eq)] + struct Cell { + text: char, + style: u32, + } + + #[test] + fn commit_batch_separates_text_and_style_changes() { + let old = [ + Cell { text: 'a', style: 1 }, + Cell { text: 'b', style: 1 }, + Cell { text: 'c', style: 1 }, + ]; + let new = [ + Cell { text: 'a', style: 1 }, + Cell { text: 'B', style: 2 }, + Cell { text: 'c', style: 3 }, + ]; + let batch = diff_commit_batch( + 7, + 8, + &old, + &new, + |left, right| left.text == right.text, + |left, right| left.style == right.style, + ) + .unwrap(); + assert_eq!( + batch.coordinate_edits, + vec![SpanEdit { + old_start: 1, + old_end: 2, + new_start: 1, + new_end: 2, + }] + ); + assert_eq!( + batch.semantic_edits, + vec![SpanEdit { + old_start: 1, + old_end: 3, + new_start: 1, + new_end: 3, + }] + ); + } + + #[test] + fn commit_batch_rejects_revision_gaps() { + let result = diff_commit_batch::(1, 3, &[], &[], |a, b| a == b, |a, b| a == b); + assert_eq!( + result, + Err(CommitError::NonConsecutiveRevision { base: 1, target: 3 }) + ); + } + + #[test] + fn pathological_diff_degrades_to_one_bounded_edit() { + let old = vec![b'a'; 30_000]; + let new = vec![b'b'; 30_000]; + let batch = diff_commit_batch(0, 1, &old, &new, |a, b| a == b, |a, b| a == b) + .unwrap(); + assert_eq!( + batch.semantic_edits, + vec![SpanEdit { + old_start: 0, + old_end: 30_000, + new_start: 0, + new_end: 30_000, + }] + ); + } +} diff --git a/native/src/layout.rs b/native/src/layout.rs index 27566ea..2834d13 100644 --- a/native/src/layout.rs +++ b/native/src/layout.rs @@ -1,3 +1,4 @@ +use etaf_core::{diff_commit_batch, CommitBatch, SpanEdit}; use serde::Deserialize; use std::collections::BTreeMap; @@ -11,7 +12,7 @@ thread_local! { } const LAYOUT_VERSION: u32 = 2; -pub const TAPE_VERSION: u16 = 9; +pub const TAPE_VERSION: u16 = 12; pub const TAPE_HEADER_LEN: usize = 112; pub const MIN_TAPE_BYTES: usize = TAPE_HEADER_LEN + 5; const TAPE_MAGIC: &[u8; 4] = b"EBXT"; @@ -76,6 +77,10 @@ enum StyleMode { #[derive(Debug, Clone, Deserialize, PartialEq, Eq)] #[serde(rename_all = "kebab-case", deny_unknown_fields)] struct FaceTemplate { + #[serde(default)] + lisp: Option, + #[serde(default)] + inherit: Option, #[serde(default)] inverse_video: Option, #[serde(default)] @@ -366,13 +371,7 @@ struct FlatLayoutTape { characters: Vec, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -struct TapePatch { - old_start: usize, - old_end: usize, - new_start: usize, - new_end: usize, -} +type TapePatch = SpanEdit; #[derive(Debug, Clone, PartialEq, Eq)] struct TapeLine { @@ -961,6 +960,114 @@ struct TapeMetadataRecord { end: u64, } +#[derive(Clone, Debug, PartialEq, Eq)] +struct FragmentTemplate { + start: u64, + end: u64, + line: u64, + roles: Vec<(&'static str, i64)>, + content_owner: Option, + content_index: Option, + property_template_ids: Vec, + style_ids: Vec, +} + +type FragmentRoles = Vec<(&'static str, i64)>; +type FragmentOwnershipSpan = (u64, u64, FragmentRoles); +type RegionMountKey = (i64, Vec<&'static str>); +type RegionMountSpan = (RegionMountKey, u64, u64); +type FragmentStyleDelta = Vec<(usize, Vec)>; + +fn propagated_fragment_roles(fragments: &[FragmentTemplate]) -> Vec> { + let mut propagated = vec![Vec::new(); fragments.len()]; + let mut previous = Vec::new(); + for (index, fragment) in fragments.iter().enumerate() { + if fragment.roles.is_empty() { + propagated[index] = previous.clone(); + } else { + previous = fragment.roles.clone(); + propagated[index] = previous.clone(); + } + } + let mut next = Vec::new(); + for index in (0..fragments.len()).rev() { + if fragments[index].roles.is_empty() { + for role in &next { + if !propagated[index].contains(role) { + propagated[index].push(*role); + } + } + } else { + next = fragments[index].roles.clone(); + } + } + propagated +} + +fn fragment_ownership_spans(fragments: &[FragmentTemplate]) -> Vec { + let mut spans: Vec = Vec::new(); + for (fragment, roles) in fragments.iter().zip(propagated_fragment_roles(fragments)) { + if let Some(previous) = spans.last_mut() { + if previous.1 == fragment.start && previous.2 == roles { + previous.1 = fragment.end; + continue; + } + } + spans.push((fragment.start, fragment.end, roles)); + } + spans +} + +fn fragment_region_mount_projection(fragments: &[FragmentTemplate]) -> Vec { + let mut spans: Vec = Vec::new(); + let mut active: BTreeMap = BTreeMap::new(); + for fragment in fragments { + let mut roles_by_region: BTreeMap> = BTreeMap::new(); + for (role, region_id) in &fragment.roles { + roles_by_region.entry(*region_id).or_default().push(*role); + } + for (region_id, roles) in roles_by_region { + let key = (region_id, roles); + if let Some(index) = active.get(&key).copied() { + if spans[index].2 == fragment.start { + spans[index].2 = fragment.end; + continue; + } + } + let index = spans.len(); + spans.push((key.clone(), fragment.start, fragment.end)); + active.insert(key, index); + } + } + spans +} + +fn fragment_style_delta( + old: &[FragmentTemplate], + target: &[FragmentTemplate], +) -> Option { + if old.len() != target.len() { + return None; + } + let mut delta = Vec::new(); + for (index, (old, target)) in old.iter().zip(target).enumerate() { + if old.start != target.start + || old.end != target.end + || old.line != target.line + || old.roles != target.roles + || old.content_owner != target.content_owner + || old.content_index != target.content_index + || old.property_template_ids != target.property_template_ids + { + return None; + } + if old.style_ids != target.style_ids { + delta.push((index, target.style_ids.clone())); + } + } + Some(delta) +} + #[derive(Debug)] struct CompiledStyle { mode: StyleMode, @@ -1004,7 +1111,21 @@ fn push_property(output: &mut String, first: &mut bool, name: &str, value: &str) impl FaceTemplate { fn validate(&self) -> Result<(), String> { - if self.inverse_video.is_none() + if let Some(lisp) = &self.lisp { + validate_style_string(lisp, "face literal")?; + if self.inherit.is_some() + || self.inverse_video.is_some() + || self.foreground.is_some() + || self.background.is_some() + || self.overline.is_some() + || self.underline.is_some() + { + return Err("Native layout literal face cannot mix typed fields".to_owned()); + } + return Ok(()); + } + if self.inherit.is_none() + && self.inverse_video.is_none() && self.foreground.is_none() && self.background.is_none() && self.overline.is_none() @@ -1012,6 +1133,11 @@ impl FaceTemplate { { return Err("Native layout face template is empty".to_owned()); } + if let Some(inherit) = &self.inherit { + if inherit != "default" { + return Err("Native layout inherit face must be default".to_owned()); + } + } if self.inverse_video == Some(false) { return Err("Native layout inverse-video face must be true".to_owned()); } @@ -1044,8 +1170,14 @@ impl FaceTemplate { } fn to_lisp(&self) -> String { + if let Some(lisp) = &self.lisp { + return lisp.clone(); + } let mut output = String::from("("); let mut first = true; + if self.inherit.as_deref() == Some("default") { + push_property(&mut output, &mut first, ":inherit", "default"); + } if self.inverse_video == Some(true) { push_property(&mut output, &mut first, ":inverse-video", "t"); } @@ -1777,9 +1909,121 @@ fn build_root_metadata_records( Ok(records) } +fn push_fragment_role( + roles: &mut Vec<(&'static str, i64)>, + role: &'static str, + region_id: i64, +) -> Result<(), String> { + if region_id <= 0 { + return Err("Native layout fragment has an invalid region id".to_owned()); + } + if !roles + .iter() + .any(|(existing_role, existing_id)| *existing_role == role && *existing_id == region_id) + { + roles.push((role, region_id)); + } + Ok(()) +} + +fn fragment_roles(properties: &AtomProperties) -> Result, String> { + let mut roles = Vec::new(); + for region_id in &properties.owners { + push_fragment_role(&mut roles, "content-owner", *region_id)?; + } + if let Some(region_id) = properties.content { + push_fragment_role(&mut roles, "content", region_id)?; + } + if let Some(region_id) = properties.owner { + push_fragment_role(&mut roles, "content-owner", region_id)?; + } + for role in REGION_ROLES { + if let Some(entry) = properties + .roles + .iter() + .rev() + .find(|entry| entry.role == role) + { + let symbol = metadata_role_symbol(role.metadata_kind()) + .ok_or_else(|| "Native layout fragment has an unknown role".to_owned())?; + push_fragment_role(&mut roles, symbol, entry.region_id)?; + } + } + Ok(roles) +} + +fn build_fragment_templates( + text: &str, + spaces: &[TapeSpaceSpan], + property_spans: &[TapePropertySpan], +) -> Result, String> { + let character_count = u64::try_from(text.chars().count()) + .map_err(|_| "Native layout fragment character count overflow".to_owned())?; + let mut boundaries = Vec::with_capacity(2 + spaces.len() * 2 + property_spans.len() * 2); + boundaries.push(0); + boundaries.push(character_count); + for space in spaces { + boundaries.push(space.start); + boundaries.push(space.start + 1); + } + for span in property_spans { + boundaries.push(span.start); + boundaries.push(span.end); + } + boundaries.sort_unstable(); + boundaries.dedup(); + + let mut line_prefix = Vec::with_capacity(text.chars().count() + 1); + line_prefix.push(0_u64); + for character in text.chars() { + let previous = *line_prefix.last().unwrap_or(&0); + line_prefix.push(previous + u64::from(character == '\n')); + } + + let empty = AtomProperties::default(); + let mut property_index = 0; + let mut fragments = Vec::with_capacity(boundaries.len().saturating_sub(1)); + for boundary in boundaries.windows(2) { + let start = boundary[0]; + let end = boundary[1]; + if start == end { + continue; + } + while property_index < property_spans.len() && property_spans[property_index].end <= start { + property_index += 1; + } + let properties = property_spans + .get(property_index) + .filter(|span| span.start <= start && end <= span.end) + .map_or(&empty, |span| &span.properties); + let line_index = usize::try_from(start) + .map_err(|_| "Native layout fragment offset is too large".to_owned())?; + let line = *line_prefix + .get(line_index) + .ok_or_else(|| "Native layout fragment line offset is invalid".to_owned())?; + if properties.content_idx.is_some_and(|index| index < 0) { + return Err("Native layout fragment has a negative content index".to_owned()); + } + fragments.push(FragmentTemplate { + start, + end, + line, + roles: fragment_roles(properties)?, + content_owner: properties.owner, + content_index: properties.content_idx, + property_template_ids: properties.property_template_ids.clone(), + style_ids: properties.style_ids.clone(), + }); + } + Ok(fragments) +} + struct RootMetadataPayload { literal: String, record_count: usize, + fragments: Vec, + fragment_bytes: Vec, + fragment_count: usize, } fn metadata_role_symbol(kind: u8) -> Option<&'static str> { @@ -1889,27 +2133,115 @@ fn metadata_literal_from_records(records: &[TapeMetadataRecord]) -> Result, value: usize, label: &str) -> Result<(), String> { + output.extend_from_slice( + &u32::try_from(value) + .map_err(|_| format!("Native layout fragment has too many {label}"))? + .to_le_bytes(), + ); + Ok(()) +} + +fn encode_fragment_templates(fragments: &[FragmentTemplate]) -> Result, String> { + let mut output = Vec::new(); + for fragment in fragments { + output.extend_from_slice(&fragment.start.to_le_bytes()); + output.extend_from_slice(&fragment.end.to_le_bytes()); + output.extend_from_slice(&fragment.line.to_le_bytes()); + output.extend_from_slice(&fragment.content_owner.unwrap_or(i64::MIN).to_le_bytes()); + output.extend_from_slice(&fragment.content_index.unwrap_or(i64::MIN).to_le_bytes()); + push_fragment_u32(&mut output, fragment.roles.len(), "roles")?; + push_fragment_u32( + &mut output, + fragment.property_template_ids.len(), + "property templates", + )?; + push_fragment_u32(&mut output, fragment.style_ids.len(), "styles")?; + output.extend_from_slice(&0_u32.to_le_bytes()); + for (role, region_id) in &fragment.roles { + let kind = match *role { + "content" => METADATA_ROLE_CONTENT, + "content-owner" => METADATA_ROLE_CONTENT_OWNER, + "pt" => METADATA_ROLE_PADDING_TOP, + "pb" => METADATA_ROLE_PADDING_BOTTOM, + "pl" => METADATA_ROLE_PADDING_LEFT, + "pr" => METADATA_ROLE_PADDING_RIGHT, + "mt" => METADATA_ROLE_MARGIN_TOP, + "mb" => METADATA_ROLE_MARGIN_BOTTOM, + "ml" => METADATA_ROLE_MARGIN_LEFT, + "mr" => METADATA_ROLE_MARGIN_RIGHT, + "bt" => METADATA_ROLE_BORDER_TOP, + "bb" => METADATA_ROLE_BORDER_BOTTOM, + "bl" => METADATA_ROLE_BORDER_LEFT, + "br" => METADATA_ROLE_BORDER_RIGHT, + _ => return Err("Native layout fragment has an unknown role".to_owned()), + }; + output.push(kind); + output.extend_from_slice(®ion_id.to_le_bytes()); + } + for template_id in &fragment.property_template_ids { + output.extend_from_slice(&template_id.to_le_bytes()); + } + for style_id in &fragment.style_ids { + output.extend_from_slice(&style_id.to_le_bytes()); + } + } + Ok(output) +} + fn root_metadata_payload( text: &str, + spaces: &[TapeSpaceSpan], property_spans: &[TapePropertySpan], complete: bool, root_metadata: bool, max_bytes: usize, ) -> Result, String> { - if !complete || !root_metadata { + if !complete { return Ok(None); } - let records = build_root_metadata_records(text, property_spans)?; - if records.is_empty() { + let records = if root_metadata { + build_root_metadata_records(text, property_spans)? + } else { + property_spans + .iter() + .find_map(|span| { + span.properties.scroll_window.map(|region_id| { + vec![TapeMetadataRecord { + kind: METADATA_SCROLL_WINDOW, + region_id, + index: 0, + start: span.start, + end: span.end, + }] + }) + }) + .unwrap_or_default() + }; + let mut fragments = build_fragment_templates(text, spaces, property_spans)?; + let propagated_roles = propagated_fragment_roles(&fragments); + for (fragment, roles) in fragments.iter_mut().zip(propagated_roles) { + fragment.roles = roles; + } + if records.is_empty() && fragments.is_empty() { return Ok(None); } let literal = metadata_literal_from_records(&records)?; - if literal.len() > max_bytes { + let fragment_bytes = encode_fragment_templates(&fragments)?; + let fragment_count = fragments.len(); + if literal + .len() + .checked_add(fragment_bytes.len()) + .is_none_or(|length| length > max_bytes) + { return Err("Native layout tape metadata exceeds its byte limit".to_owned()); } Ok(Some(RootMetadataPayload { literal, record_count: records.len(), + fragments, + fragment_bytes, + fragment_count, })) } @@ -1981,249 +2313,21 @@ fn flatten_layout_tape(tape: LayoutTape, complete: bool) -> Result isize { - let distance = distance as isize; - if diagonal < -distance || diagonal > distance { - -1 - } else { - trace[(diagonal + distance) as usize] - } -} - -const MYERS_MAX_SEARCH_DISTANCE: usize = 1024; - -fn myers_text_matches(old: &[TapeCharacter], new: &[TapeCharacter]) -> Vec<(usize, usize, usize)> { - let prefix = old - .iter() - .zip(new.iter()) - .take_while(|(left, right)| left.value == right.value) - .count(); - let old_rest = &old[prefix..]; - let new_rest = &new[prefix..]; - let suffix = old_rest - .iter() - .rev() - .zip(new_rest.iter().rev()) - .take_while(|(left, right)| left.value == right.value) - .count(); - let old_core = &old_rest[..old_rest.len() - suffix]; - let new_core = &new_rest[..new_rest.len() - suffix]; - let mut matches = Vec::new(); - if prefix > 0 { - matches.push((0, 0, prefix)); - } - // The trace history retains O(distance^2) memory, so an unbounded - // search over two dissimilar full frames can exhaust the process. - // A capped core simply degrades into one replacement patch. - if let Some(core) = myers_core_matches(old_core, new_core, MYERS_MAX_SEARCH_DISTANCE) { - for (old_start, new_start, length) in core { - matches.push((old_start + prefix, new_start + prefix, length)); - } - } - if suffix > 0 { - matches.push((old.len() - suffix, new.len() - suffix, suffix)); - } - matches -} - -fn myers_core_matches( - old: &[TapeCharacter], - new: &[TapeCharacter], - max_distance: usize, -) -> Option> { - let old_length = old.len(); - let new_length = new.len(); - let maximum = old_length.saturating_add(new_length); - let bounded = maximum.min(max_distance); - let mut traces: Vec> = Vec::with_capacity(bounded.saturating_add(1)); - let mut final_distance = maximum; - let mut found = false; - - 'search: for distance in 0..=bounded { - let previous = distance.checked_sub(1).and_then(|index| traces.get(index)); - let mut current = vec![-1; 1 + 2 * distance]; - let mut diagonal = -(distance as isize); - while diagonal <= distance as isize { - let down = previous.map(|trace| myers_trace_value(trace, distance - 1, diagonal + 1)); - let right = previous.map(|trace| myers_trace_value(trace, distance - 1, diagonal - 1)); - let mut old_position = if distance == 0 { - 0 - } else if diagonal == -(distance as isize) { - down.unwrap_or(-1) - } else if diagonal == distance as isize { - right.unwrap_or(-1) + 1 - } else if right.unwrap_or(-1) < down.unwrap_or(-1) { - down.unwrap_or(-1) - } else { - right.unwrap_or(-1) + 1 - }; - let mut new_position = old_position - diagonal; - while old_position >= 0 - && new_position >= 0 - && (old_position as usize) < old_length - && (new_position as usize) < new_length - && old[old_position as usize].value == new[new_position as usize].value - { - old_position += 1; - new_position += 1; - } - current[(diagonal + distance as isize) as usize] = old_position; - if old_position >= old_length as isize && new_position >= new_length as isize { - traces.push(current); - final_distance = distance; - found = true; - break 'search; - } - diagonal += 2; - } - traces.push(current); - } - if !found { - return None; - } - - let mut old_position = old_length as isize; - let mut new_position = new_length as isize; - let mut matches = Vec::new(); - for distance in (1..=final_distance).rev() { - let previous_distance = distance - 1; - let previous = &traces[previous_distance]; - let diagonal = old_position - new_position; - let previous_diagonal = if diagonal == -(distance as isize) - || (diagonal != distance as isize - && myers_trace_value(previous, previous_distance, diagonal - 1) - < myers_trace_value(previous, previous_distance, diagonal + 1)) - { - diagonal + 1 - } else { - diagonal - 1 - }; - let previous_old = myers_trace_value(previous, previous_distance, previous_diagonal); - let previous_new = previous_old - previous_diagonal; - if old_position > previous_old && new_position > previous_new { - let length = (old_position - previous_old).min(new_position - previous_new) as usize; - matches.push(( - (old_position as usize) - length, - (new_position as usize) - length, - length, - )); - } - old_position = previous_old; - new_position = previous_new; - } - if old_position > 0 && new_position > 0 { - let length = old_position.min(new_position) as usize; - matches.push(( - old_position as usize - length, - new_position as usize - length, - length, - )); - } - matches.reverse(); - Some(matches) -} - -fn push_tape_patch( - patches: &mut Vec, - old_start: usize, - old_end: usize, - new_start: usize, - new_end: usize, -) { - if old_start == old_end && new_start == new_end { - return; - } - if let Some(previous) = patches.last_mut() { - if previous.old_end == old_start && previous.new_end == new_start { - previous.old_end = old_end; - previous.new_end = new_end; - return; - } - } - patches.push(TapePatch { - old_start, - old_end, - new_start, - new_end, - }); -} - -fn push_semantic_run_patches( - old: &[TapeCharacter], - new: &[TapeCharacter], - old_start: usize, - new_start: usize, - length: usize, - patches: &mut Vec, -) { - let mut run_start = None; - for offset in 0..length { - let old_character = &old[old_start + offset]; - let new_character = &new[new_start + offset]; - let equal = old_character.pixel_width == new_character.pixel_width - && old_character.properties == new_character.properties; - match (run_start, equal) { - (None, false) => run_start = Some(offset), - (Some(start), true) => { - push_tape_patch( - patches, - old_start + start, - old_start + offset, - new_start + start, - new_start + offset, - ); - run_start = None; - } - _ => {} - } - } - if let Some(start) = run_start { - push_tape_patch( - patches, - old_start + start, - old_start + length, - new_start + start, - new_start + length, - ); - } +fn tape_commit_batch(old: &[TapeCharacter], new: &[TapeCharacter]) -> CommitBatch { + diff_commit_batch( + 0, + 1, + old, + new, + |left, right| left.value == right.value, + |left, right| left.pixel_width == right.pixel_width && left.properties == right.properties, + ) + .expect("fixed consecutive adapter revisions") } +#[cfg(test)] fn minimal_tape_patches(old: &[TapeCharacter], new: &[TapeCharacter]) -> Vec { - let text_equal = old.len() == new.len() - && old - .iter() - .zip(new) - .all(|(left, right)| left.value == right.value); - let matches = if text_equal { - vec![(0, 0, old.len())] - } else { - myers_text_matches(old, new) - }; - let mut old_position = 0; - let mut new_position = 0; - let mut patches = Vec::new(); - for (matched_old, matched_new, length) in matches { - if old_position < matched_old || new_position < matched_new { - push_tape_patch( - &mut patches, - old_position, - matched_old, - new_position, - matched_new, - ); - } - push_semantic_run_patches(old, new, matched_old, matched_new, length, &mut patches); - old_position = matched_old + length; - new_position = matched_new + length; - } - push_tape_patch( - &mut patches, - old_position, - old.len(), - new_position, - new.len(), - ); - patches + tape_commit_batch(old, new).semantic_edits } fn tape_character_encoding_parts( @@ -2283,13 +2387,38 @@ fn encode_patch_replacement_body( fn encode_patch_combined_payload( replacement: &[u8], metadata: Option<&RootMetadataPayload>, + fragment_style_delta: Option<&FragmentStyleDelta>, max_bytes: usize, ) -> Result, String> { let metadata_literal = metadata.map_or("nil", |payload| payload.literal.as_str()); + let style_delta_literal = fragment_style_delta.map(|delta| { + let entries = delta + .iter() + .map(|(index, style_ids)| { + let ids = style_ids + .iter() + .map(u32::to_string) + .collect::>() + .join(" "); + if ids.is_empty() { + format!("({index})") + } else { + format!("({index} {ids})") + } + }) + .collect::>() + .join(" "); + format!("({entries})") + }); let payload_len = 1_usize .checked_add(replacement.len()) .and_then(|size| size.checked_add(1)) .and_then(|size| size.checked_add(metadata_literal.len())) + .and_then(|size| { + style_delta_literal + .as_ref() + .map_or(Some(size), |literal| size.checked_add(1 + literal.len())) + }) .and_then(|size| size.checked_add(1)) .ok_or_else(|| "Native layout patch payload size overflow".to_owned())?; if payload_len > max_bytes { @@ -2300,10 +2429,139 @@ fn encode_patch_combined_payload( payload.extend_from_slice(replacement); payload.push(b' '); payload.extend_from_slice(metadata_literal.as_bytes()); + if let Some(literal) = style_delta_literal { + payload.push(b' '); + payload.extend_from_slice(literal.as_bytes()); + } payload.push(b']'); Ok(payload) } +/// Emacs adapter payload built on the editor-independent core batch. +struct EmacsCommitBatch { + core: CommitBatch, + combined_payload: Vec, + metadata_records: usize, + fragment_bytes: Vec, + fragment_records: usize, + reuse_fragment_template: bool, + reuse_ownership_template: bool, + reuse_mount_projection: bool, + fragment_style_delta: Option, +} + +fn build_emacs_commit_batch( + old: &FlatLayoutTape, + target: &FlatLayoutTape, + compiled_styles: &[CompiledStyle], + complete: bool, + root_metadata: bool, + max_bytes: usize, +) -> Result { + let core = tape_commit_batch(&old.characters, &target.characters); + let descriptor_bytes = core + .semantic_edits + .len() + .checked_add(core.coordinate_edits.len()) + .ok_or_else(|| "Native layout patch descriptor count overflow".to_owned())? + .checked_mul(32) + .ok_or_else(|| "Native layout patch size overflow".to_owned())?; + let fixed_body_bytes = 56_usize + .checked_add(descriptor_bytes) + .ok_or_else(|| "Native layout patch size overflow".to_owned())?; + let payload_limit = max_bytes + .checked_sub(TAPE_HEADER_LEN) + .and_then(|remaining| remaining.checked_sub(fixed_body_bytes)) + .ok_or_else(|| "Native layout patch exceeds its byte limit".to_owned())?; + let (target_text, target_spaces, target_property_spans) = + tape_character_encoding_parts(&target.characters)?; + let mut metadata_payload = root_metadata_payload( + &target_text, + &target_spaces, + &target_property_spans, + complete, + root_metadata, + payload_limit, + )?; + let old_fragments = if metadata_payload.is_some() { + let (old_text, old_spaces, old_property_spans) = + tape_character_encoding_parts(&old.characters)?; + let mut fragments = build_fragment_templates(&old_text, &old_spaces, &old_property_spans)?; + let roles = propagated_fragment_roles(&fragments); + for (fragment, roles) in fragments.iter_mut().zip(roles) { + fragment.roles = roles; + } + Some(fragments) + } else { + None + }; + let reuse_fragment_template = old_fragments + .as_ref() + .zip(metadata_payload.as_ref()) + .is_some_and(|(old, target)| *old == target.fragments); + let reuse_ownership_template = old_fragments + .as_ref() + .zip(metadata_payload.as_ref()) + .is_some_and(|(old, target)| { + fragment_ownership_spans(old) == fragment_ownership_spans(&target.fragments) + }); + let reuse_mount_projection = old_fragments + .as_ref() + .zip(metadata_payload.as_ref()) + .is_some_and(|(old, target)| { + fragment_region_mount_projection(old) + == fragment_region_mount_projection(&target.fragments) + }); + let fragment_style_delta = if reuse_fragment_template { + None + } else { + old_fragments + .as_ref() + .zip(metadata_payload.as_ref()) + .and_then(|(old, target)| fragment_style_delta(old, &target.fragments)) + }; + let replacements = encode_patch_replacement_body( + &target.characters, + &core.semantic_edits, + compiled_styles, + payload_limit, + )?; + let combined_payload = encode_patch_combined_payload( + &replacements, + metadata_payload.as_ref(), + fragment_style_delta.as_ref(), + payload_limit, + )?; + let metadata_records = metadata_payload + .as_ref() + .map_or(0, |payload| payload.record_count); + let fragment_records = if reuse_fragment_template || fragment_style_delta.is_some() { + 0 + } else { + metadata_payload + .as_ref() + .map_or(0, |payload| payload.fragment_count) + }; + let fragment_bytes = if reuse_fragment_template || fragment_style_delta.is_some() { + Vec::new() + } else { + metadata_payload.as_mut().map_or_else(Vec::new, |payload| { + std::mem::take(&mut payload.fragment_bytes) + }) + }; + Ok(EmacsCommitBatch { + core, + combined_payload, + metadata_records, + fragment_bytes, + fragment_records, + reuse_fragment_template, + reuse_ownership_template, + reuse_mount_projection, + fragment_style_delta, + }) +} + pub(crate) fn encode_layout_patch_tape( old_tape: LayoutTape, target_tape: LayoutTape, @@ -2321,66 +2579,65 @@ pub(crate) fn encode_layout_patch_tape( .iter() .map(StyleTemplate::compile) .collect::, _>>()?; - let patches = minimal_tape_patches(&old.characters, &target.characters); - let patch_count = count_u32(patches.len(), "patches")?; + let batch = build_emacs_commit_batch( + &old, + &target, + &compiled_styles, + identity.complete, + root_metadata, + max_bytes, + )?; + let patch_count = count_u32(batch.core.semantic_edits.len(), "patches")?; + let coordinate_patch_count = + count_u32(batch.core.coordinate_edits.len(), "coordinate patches")?; let target_character_count = u64::try_from(target.characters.len()) .map_err(|_| "Native layout patch character count overflow".to_owned())?; let base_character_count = u64::try_from(old.characters.len()) .map_err(|_| "Native layout patch base character count overflow".to_owned())?; - let descriptor_bytes = patches - .len() - .checked_mul(32) - .ok_or_else(|| "Native layout patch size overflow".to_owned())?; - let fixed_body_bytes = 32_usize - .checked_add(descriptor_bytes) - .ok_or_else(|| "Native layout patch size overflow".to_owned())?; - let payload_limit = max_bytes - .checked_sub(TAPE_HEADER_LEN) - .and_then(|remaining| remaining.checked_sub(fixed_body_bytes)) - .ok_or_else(|| "Native layout patch exceeds its byte limit".to_owned())?; - let (target_text, _, target_property_spans) = - tape_character_encoding_parts(&target.characters)?; - let metadata_payload = root_metadata_payload( - &target_text, - &target_property_spans, - identity.complete, - root_metadata, - payload_limit, - )?; - let metadata_records = metadata_payload - .as_ref() - .map_or(0, |payload| payload.record_count); - let replacements = encode_patch_replacement_body( - &target.characters, - &patches, - &compiled_styles, - payload_limit, - )?; - let combined_payload = - encode_patch_combined_payload(&replacements, metadata_payload.as_ref(), payload_limit)?; - let mut writer = TapeWriter::new(max_bytes)?; writer.push_u64(base_character_count)?; writer.push_u32(patch_count)?; // Publication remains a minimal text/property patch. The compact target // role sidecar lets Emacs swap exact runtime indexes without synchronously // rescanning the page or rebuilding thousands of markers per frame. - writer.push_u32(0)?; + writer.push_u32( + u32::from(batch.reuse_fragment_template) + | (u32::from(batch.reuse_ownership_template) << 1) + | (u32::from(batch.reuse_mount_projection) << 2) + | (u32::from(batch.fragment_style_delta.is_some()) << 3), + )?; writer.push_u64( - u64::try_from(metadata_records) + u64::try_from(batch.metadata_records) .map_err(|_| "Native layout patch has too many metadata records".to_owned())?, )?; writer.push_u64( - u64::try_from(combined_payload.len()) + u64::try_from(batch.combined_payload.len()) .map_err(|_| "Native layout patch body is too large".to_owned())?, )?; - for patch in &patches { + writer.push_u64( + u64::try_from(batch.fragment_bytes.len()) + .map_err(|_| "Native layout fragment tape is too large".to_owned())?, + )?; + writer.push_u64( + u64::try_from(batch.fragment_records) + .map_err(|_| "Native layout fragment tape has too many records".to_owned())?, + )?; + writer.push_u32(coordinate_patch_count)?; + writer.push_u32(0)?; + for patch in &batch.core.semantic_edits { writer.push_u64(patch.old_start as u64)?; writer.push_u64(patch.old_end as u64)?; writer.push_u64(patch.new_start as u64)?; writer.push_u64(patch.new_end as u64)?; } - writer.push_bytes(&combined_payload)?; + for patch in &batch.core.coordinate_edits { + writer.push_u64(patch.old_start as u64)?; + writer.push_u64(patch.old_end as u64)?; + writer.push_u64(patch.new_start as u64)?; + writer.push_u64(patch.new_end as u64)?; + } + writer.push_bytes(&batch.combined_payload)?; + writer.push_bytes(&batch.fragment_bytes)?; Ok(writer.finish_header( identity, true, @@ -2495,7 +2752,7 @@ pub(crate) fn encode_layout_tape( } } - let fixed_body_bytes = 24_usize + let fixed_body_bytes = 40_usize .checked_add( (line_count as usize) .checked_mul(8) @@ -2508,6 +2765,7 @@ pub(crate) fn encode_layout_tape( .ok_or_else(|| "Native layout tape exceeds its byte limit".to_owned())?; let metadata_payload = root_metadata_payload( &text, + &spaces, &property_spans, identity.complete, root_metadata, @@ -2519,10 +2777,17 @@ pub(crate) fn encode_layout_tape( let metadata_records = metadata_payload .as_ref() .map_or(0, |payload| payload.record_count); + let fragment_bytes = metadata_payload + .as_ref() + .map_or(0, |payload| payload.fragment_bytes.len()); + let fragment_records = metadata_payload + .as_ref() + .map_or(0, |payload| payload.fragment_count); let literal_limit = max_bytes .checked_sub(TAPE_HEADER_LEN) .and_then(|remaining| remaining.checked_sub(fixed_body_bytes)) .and_then(|remaining| remaining.checked_sub(metadata_bytes)) + .and_then(|remaining| remaining.checked_sub(fragment_bytes)) .ok_or_else(|| "Native layout tape exceeds its byte limit".to_owned())?; let literal = encode_lisp_literal( &text, @@ -2547,12 +2812,21 @@ pub(crate) fn encode_layout_tape( u64::try_from(metadata_records) .map_err(|_| "Native layout tape has too many metadata records".to_owned())?, )?; + writer.push_u64( + u64::try_from(fragment_bytes) + .map_err(|_| "Native layout fragment tape is too large".to_owned())?, + )?; + writer.push_u64( + u64::try_from(fragment_records) + .map_err(|_| "Native layout fragment tape has too many records".to_owned())?, + )?; for width in line_widths { writer.push_u64(width)?; } writer.push_bytes(literal.as_bytes())?; if let Some(payload) = metadata_payload { writer.push_bytes(payload.literal.as_bytes())?; + writer.push_bytes(&payload.fragment_bytes)?; } Ok(writer.finish_header( @@ -5645,7 +5919,7 @@ mod tests { fn literal_from_full_tape(tape: &[u8]) -> &str { let literal_length = u64::from_le_bytes(tape[112..120].try_into().unwrap()) as usize; let line_count = u32::from_le_bytes(tape[120..124].try_into().unwrap()) as usize; - let literal_start = 136 + line_count * 8; + let literal_start = 152 + line_count * 8; std::str::from_utf8(&tape[literal_start..literal_start + literal_length]).unwrap() } @@ -5653,7 +5927,7 @@ mod tests { let literal_length = u64::from_le_bytes(tape[112..120].try_into().unwrap()) as usize; let line_count = u32::from_le_bytes(tape[120..124].try_into().unwrap()) as usize; let metadata_length = u32::from_le_bytes(tape[124..128].try_into().unwrap()) as usize; - let literal_start = 136 + line_count * 8; + let literal_start = 152 + line_count * 8; let metadata_start = literal_start + literal_length; std::str::from_utf8(&tape[metadata_start..metadata_start + metadata_length]).unwrap() } @@ -6176,6 +6450,8 @@ mod tests { StyleTemplate { mode: StyleMode::Add, face: FaceTemplate { + lisp: None, + inherit: None, inverse_video: None, foreground: Some("red".to_owned()), background: None, @@ -6186,6 +6462,8 @@ mod tests { StyleTemplate { mode: StyleMode::Add, face: FaceTemplate { + lisp: None, + inherit: None, inverse_video: None, foreground: None, background: Some("blue".to_owned()), @@ -6196,6 +6474,8 @@ mod tests { StyleTemplate { mode: StyleMode::Set, face: FaceTemplate { + lisp: None, + inherit: None, inverse_video: Some(true), foreground: Some("white".to_owned()), background: None, @@ -6235,12 +6515,17 @@ mod tests { assert_eq!(u32::from_le_bytes(full[120..124].try_into().unwrap()), 2); let metadata_length = u32::from_le_bytes(full[124..128].try_into().unwrap()) as usize; let metadata_records = u64::from_le_bytes(full[128..136].try_into().unwrap()) as usize; + let fragment_length = u64::from_le_bytes(full[136..144].try_into().unwrap()) as usize; + let fragment_records = u64::from_le_bytes(full[144..152].try_into().unwrap()) as usize; assert!(metadata_length > 0); assert!(metadata_records > 0); - assert_eq!(u64::from_le_bytes(full[136..144].try_into().unwrap()), 8); - assert_eq!(u64::from_le_bytes(full[144..152].try_into().unwrap()), 8); - let literal_end = 152 + literal_length; - let literal = std::str::from_utf8(&full[152..literal_end]).unwrap(); + assert!(fragment_length > 0); + assert!(fragment_records > 0); + assert_eq!(u64::from_le_bytes(full[152..160].try_into().unwrap()), 8); + assert_eq!(u64::from_le_bytes(full[160..168].try_into().unwrap()), 8); + let literal_start = 168; + let literal_end = literal_start + literal_length; + let literal = std::str::from_utf8(&full[literal_start..literal_end]).unwrap(); assert_eq!(literal.len(), literal_length); assert!(literal.starts_with("#(\" \\n \"")); assert!(literal.contains("face #3=(:inverse-video t :foreground \"white\")")); @@ -6248,7 +6533,7 @@ mod tests { assert!(literal.contains("ebox-content 11")); assert!(literal.contains("ebox-native-property-template-ids (4)")); assert!(literal.contains("display (space :width (8))")); - let metadata = &full[literal_end..]; + let metadata = &full[literal_end..literal_end + metadata_length]; assert_eq!(metadata.len(), metadata_length); let metadata_literal = metadata_from_full_tape(&full); assert!(metadata_literal.starts_with("(:prepared-p t")); @@ -6257,6 +6542,7 @@ mod tests { assert!(metadata_literal.contains("(11 pl) ((1 . 4))")); assert!(metadata_literal.contains("data (11 (1 . 4))")); assert!(metadata_literal.contains("data (11 ((0 0 . 3)")); + assert!(metadata_literal.contains(":scroll-window-p t")); assert!(metadata_literal.ends_with(":scroll-window-p t)")); let full_without_root_metadata = @@ -6265,14 +6551,13 @@ mod tests { u16::from_le_bytes(full_without_root_metadata[6..8].try_into().unwrap()), 3 ); - assert_eq!( - u32::from_le_bytes(full_without_root_metadata[124..128].try_into().unwrap()), - 0 - ); + assert!(u32::from_le_bytes(full_without_root_metadata[124..128].try_into().unwrap()) > 0); assert_eq!( u64::from_le_bytes(full_without_root_metadata[128..136].try_into().unwrap()), - 0 + 1 ); + assert!(u64::from_le_bytes(full_without_root_metadata[136..144].try_into().unwrap()) > 0); + assert!(u64::from_le_bytes(full_without_root_metadata[144..152].try_into().unwrap()) > 0); let full_without_root_metadata_literal = literal_from_full_tape(&full_without_root_metadata); assert!(full_without_root_metadata_literal.contains("ebox-content 11")); @@ -6294,6 +6579,8 @@ mod tests { assert_eq!(u16::from_le_bytes(light[6..8].try_into().unwrap()), 1); assert_eq!(u32::from_le_bytes(light[124..128].try_into().unwrap()), 0); assert_eq!(u64::from_le_bytes(light[128..136].try_into().unwrap()), 0); + assert_eq!(u64::from_le_bytes(light[136..144].try_into().unwrap()), 0); + assert_eq!(u64::from_le_bytes(light[144..152].try_into().unwrap()), 0); assert!(light.len() < full.len()); assert!(encode_layout_tape(tape, &styles, identity, true, TAPE_HEADER_LEN).is_err()); @@ -6366,8 +6653,12 @@ mod tests { #[test] fn root_metadata_literal_preserves_zero_metadata_and_byte_limit() { - let empty_payload = root_metadata_payload("x", &[], true, true, 4096).unwrap(); - assert!(empty_payload.is_none()); + let fragment_only_payload = root_metadata_payload("x", &[], &[], true, true, 4096) + .unwrap() + .unwrap(); + assert_eq!(fragment_only_payload.record_count, 0); + assert_eq!(fragment_only_payload.fragment_count, 1); + assert!(!fragment_only_payload.fragment_bytes.is_empty()); let property_spans = vec![TapePropertySpan { start: 0, @@ -6378,17 +6669,23 @@ mod tests { ..AtomProperties::default() }, }]; - let payload = root_metadata_payload("x", &property_spans, true, true, 4096) + let payload = root_metadata_payload("x", &[], &property_spans, true, true, 4096) .unwrap() .unwrap(); assert_eq!(payload.record_count, 4); + assert_eq!(payload.fragment_count, 1); assert!(payload.literal.contains("(42 content) ((1 . 2))")); assert!(payload.literal.contains("data (42 (1 . 2))")); assert!(payload.literal.contains("data (42 ((0 0 . 1)))")); - assert!( - root_metadata_payload("x", &property_spans, true, true, payload.literal.len() - 1) - .is_err() - ); + assert!(root_metadata_payload( + "x", + &[], + &property_spans, + true, + true, + payload.literal.len() - 1, + ) + .is_err()); } #[test] @@ -6757,7 +7054,10 @@ mod tests { ) .unwrap(); assert_eq!(u16::from_le_bytes(encoded[6..8].try_into().unwrap()), 7); - assert_eq!(u16::from_le_bytes(encoded[4..6].try_into().unwrap()), 9); + assert_eq!( + u16::from_le_bytes(encoded[4..6].try_into().unwrap()), + TAPE_VERSION + ); assert_eq!(u64::from_le_bytes(encoded[92..100].try_into().unwrap()), 7); assert_eq!(u64::from_le_bytes(encoded[112..120].try_into().unwrap()), 6); let patch_count = u32::from_le_bytes(encoded[120..124].try_into().unwrap()) as usize; @@ -6765,18 +7065,60 @@ mod tests { let reserved = u32::from_le_bytes(encoded[124..128].try_into().unwrap()); let metadata_records = u64::from_le_bytes(encoded[128..136].try_into().unwrap()); let payload_length = u64::from_le_bytes(encoded[136..144].try_into().unwrap()) as usize; - let payload_start = 144 + patch_count * 32; + let fragment_length = u64::from_le_bytes(encoded[144..152].try_into().unwrap()) as usize; + let fragment_records = u64::from_le_bytes(encoded[152..160].try_into().unwrap()) as usize; + let coordinate_patch_count = + u32::from_le_bytes(encoded[160..164].try_into().unwrap()) as usize; + assert!(coordinate_patch_count > 0); + let payload_start = 168 + (patch_count + coordinate_patch_count) * 32; let payload_end = payload_start + payload_length; let payload = std::str::from_utf8(&encoded[payload_start..payload_end]).unwrap(); assert_eq!(reserved, 0); assert!(metadata_records > 0); - assert_eq!(encoded.len(), payload_end); + assert!(fragment_length > 0); + assert!(fragment_records > 0); + assert_eq!(encoded.len(), payload_end + fragment_length); assert!(payload.starts_with("[#(\"Xe \"")); assert!(payload.contains("1 2 (ebox-content 9 ebox-native-property-template-ids (4))")); assert!(payload.contains("2 3 (display (space :width (7)))")); assert!(payload.contains("(:prepared-p t")); assert!(payload.contains("(9 content) ((5 . 6))")); assert!(payload.contains("data (9 (5 . 6))")); + assert!(payload.contains(":scroll-window-p nil")); assert!(payload.ends_with(":scroll-window-p nil)]")); } + + #[test] + fn mount_projection_reuse_ignores_fragment_splits_but_not_roles() { + fn fragment(start: u64, end: u64, region_id: i64) -> FragmentTemplate { + FragmentTemplate { + start, + end, + line: 0, + roles: vec![("content", region_id), ("content-owner", region_id)], + content_owner: Some(region_id), + content_index: None, + property_template_ids: Vec::new(), + style_ids: Vec::new(), + } + } + let old = vec![fragment(0, 2, 7)]; + let target = vec![fragment(0, 1, 7), fragment(1, 2, 7)]; + assert_eq!( + fragment_region_mount_projection(&old), + fragment_region_mount_projection(&target) + ); + let mut styled = target.clone(); + styled[1].style_ids = vec![3, 5]; + assert_eq!( + fragment_style_delta(&target, &styled), + Some(vec![(1, vec![3, 5])]) + ); + let changed = vec![fragment(0, 2, 8)]; + assert_ne!( + fragment_region_mount_projection(&old), + fragment_region_mount_projection(&changed) + ); + assert!(fragment_style_delta(&old, &changed).is_none()); + } } diff --git a/native/src/lib.rs b/native/src/lib.rs index 37bc982..952b17f 100644 --- a/native/src/lib.rs +++ b/native/src/lib.rs @@ -262,6 +262,11 @@ fn flex_size_line(items: &[NativeFlexItem], main_limit: i64, main_gap: i64) -> V /// /// `line_offsets` contains `line_count + 1` offsets into ITEMS. The output /// is a little-endian i64 stream whose values follow the input line order. +/// +/// # Safety +/// +/// Nonempty ITEMS and LINE_OFFSETS must reference their declared readable +/// lengths, and OUTPUT must point to writable `NativeBytes` storage. #[no_mangle] pub unsafe extern "C" fn ebox_native_flex_size_lines( items: *const NativeFlexItem, @@ -277,10 +282,8 @@ pub unsafe extern "C" fn ebox_native_flex_size_lines( || (line_count > 0 && line_offsets.is_null()) || item_count > MAX_FLEX_ITEMS || line_count > MAX_FLEX_LINES - || main_limit < -MAX_LAYOUT_DIMENSION - || main_limit > MAX_LAYOUT_DIMENSION - || main_gap < 0 - || main_gap > MAX_LAYOUT_DIMENSION + || !(-MAX_LAYOUT_DIMENSION..=MAX_LAYOUT_DIMENSION).contains(&main_limit) + || !(0..=MAX_LAYOUT_DIMENSION).contains(&main_gap) { set_error( output, @@ -396,12 +399,14 @@ struct BaselineIdentity { struct ConfirmedBaseline { identity: BaselineIdentity, tape: LayoutTape, + styles: Vec, } #[derive(Debug)] struct PendingBaseline { confirmed_identity: BaselineIdentity, tape: LayoutTape, + styles: Vec, } #[derive(Debug)] @@ -739,6 +744,69 @@ impl Session { result.map(|entry| entry.bytes) } + fn render_sync(&self, generation: u64, payload: &[u8]) -> Result, String> { + if !self.shared.alive.load(Ordering::Acquire) { + return Err("Native reflow session is closed".to_owned()); + } + let batch = parse_control_batch(payload)?; + let document = batch + .document + .ok_or_else(|| "Native retained render requires an inline document".to_owned())?; + document.validate()?; + if batch.frames.len() != 1 { + return Err("Native retained render requires exactly one frame".to_owned()); + } + let frame = batch + .frames + .into_iter() + .next() + .expect("checked retained frame"); + if !frame.complete || frame.delay_ms != 0 { + return Err("Native retained render requires one immediate complete frame".to_owned()); + } + let document = Arc::new(document); + let prepared = prepare_layout_job(&document, frame)?; + let confirmed = self + .shared + .state + .lock() + .unwrap_or_else(|poison| poison.into_inner()) + .confirmed_baseline + .clone(); + let output = render_layout_payload( + prepared.payload, + self.shared.id, + generation, + prepared.key, + self.shared.max_result_bytes, + confirmed, + true, + ); + if output.bytes.len() > self.shared.max_result_bytes { + return Err("Native retained render exceeds the result byte limit".to_owned()); + } + let mut state = self + .shared + .state + .lock() + .unwrap_or_else(|poison| poison.into_inner()); + if output.base_renders > 0 { + state.baseline_misses += 1; + } else if output.baseline_hit { + state.baseline_hits += 1; + } + state.base_renders += output.base_renders; + state.target_renders += output.target_renders; + state.pending_baselines.clear(); + if let Some(pending) = output.pending { + state + .pending_baselines + .insert((generation, prepared.key), pending); + } + state.completed_jobs += 1; + Ok(output.bytes) + } + fn confirm(&self, generation: u64, key: i64, confirmed_revision: u64) -> Result { let mut state = self .shared @@ -746,7 +814,10 @@ impl Session { .lock() .unwrap_or_else(|poison| poison.into_inner()); let Some(pending) = state.pending_baselines.remove(&(generation, key)) else { - return Ok(false); + return Ok(state + .confirmed_baseline + .as_ref() + .is_some_and(|baseline| baseline.identity.runtime_revision == confirmed_revision)); }; if confirmed_revision <= pending.confirmed_identity.runtime_revision { return Err("Native confirmed frame revision mismatch".to_owned()); @@ -756,6 +827,7 @@ impl Session { state.confirmed_baseline = Some(ConfirmedBaseline { identity, tape: pending.tape, + styles: pending.styles, }); Ok(true) } @@ -1050,6 +1122,7 @@ fn render_layout_payload( key: i64, max_result_bytes: usize, confirmed_baseline: Option, + require_confirmed_patch_base: bool, ) -> RenderedJob { match payload { JobPayload::Echo(bytes) => RenderedJob { @@ -1114,9 +1187,21 @@ fn render_layout_payload( runtime_revision, context_hash, complete, - ) + ) && baseline.styles == document.styles }) .cloned(); + if require_confirmed_patch_base && base_hit.is_none() { + let target = document + .layout_tape(context, root_width_override.then_some(root_width))?; + let bytes = layout::encode_layout_tape( + target.clone(), + &document.styles, + identity, + output.root_metadata, + output.max_bytes, + )?; + return Ok((bytes, target, false, 0, 1)); + } let (old, baseline_hit, base_renders) = if let Some(baseline) = base_hit { (baseline.tape, true, 0) } else { @@ -1159,6 +1244,7 @@ fn render_layout_payload( pending: Some(PendingBaseline { confirmed_identity: pending_identity, tape, + styles: document.styles.clone(), }), baseline_hit, base_renders, @@ -1219,6 +1305,7 @@ fn render_proof(payload: &[u8]) -> Result, String> { prepared.key, SYNC_RENDER_MAX_RESULT_BYTES, None, + false, ); if output.bytes.len() > SYNC_RENDER_MAX_RESULT_BYTES { return Err("Native proof render exceeds the result byte limit".to_owned()); @@ -1284,6 +1371,7 @@ fn worker_loop(shared: Arc) { job.key, shared.max_result_bytes, confirmed_baseline, + false, ); if output.bytes.len() > shared.max_result_bytes { let mut state = shared @@ -1451,6 +1539,50 @@ pub unsafe extern "C" fn ebox_native_session_submit( } } +#[no_mangle] +/// Render one retained frame synchronously through a live native session. +/// +/// # Safety +/// +/// `session` must come from `ebox_native_session_create`. PAYLOAD and OUTPUT +/// follow the same validity rules as `ebox_native_render_proof`. +pub unsafe extern "C" fn ebox_native_session_render_sync( + session: *mut c_void, + generation: u64, + payload: *const u8, + payload_len: usize, + output: *mut NativeBytes, +) -> bool { + if output.is_null() { + return false; + } + let outcome = catch_unwind(AssertUnwindSafe(|| { + let session = + session_ref(session).ok_or_else(|| "Native reflow session is null".to_owned())?; + if payload.is_null() && payload_len != 0 { + return Err("Native retained render payload pointer is null".to_owned()); + } + let bytes = if payload_len == 0 { + &[][..] + } else { + unsafe { slice::from_raw_parts(payload, payload_len) } + }; + session.render_sync(generation, bytes) + })); + let result = match outcome { + Ok(result) => result, + Err(_) => Err("Native retained render panicked".to_owned()), + }; + let success = result.is_ok(); + unsafe { + *output = match result { + Ok(bytes) => NativeBytes::from_vec(bytes), + Err(message) => NativeBytes::from_error(message), + }; + } + success +} + #[no_mangle] /// Render one complete layout proof synchronously from a versioned payload. /// @@ -1684,7 +1816,7 @@ mod tests { fn full_tape_line_widths(bytes: &[u8]) -> Vec { let line_count = u32::from_le_bytes(bytes[120..124].try_into().unwrap()) as usize; (0..line_count) - .map(|index| read_le_u64(bytes, 136 + index * 8)) + .map(|index| read_le_u64(bytes, 152 + index * 8)) .collect() } diff --git a/tests/ebox-child-range-tests.el b/tests/ebox-child-range-tests.el index 26d2d69..1134325 100644 --- a/tests/ebox-child-range-tests.el +++ b/tests/ebox-child-range-tests.el @@ -23,6 +23,58 @@ (mapcar #'ebox-child-range-test--item range-items)))) hash)) +(ert-deftest ebox-child-range-reuse-map-rebinds-published-objects () + "Stable slots use published objects; moved slots re-enter normal indexing." + (let* ((sequence (ebox-child-range-test--fixture 0 '(one two))) + (segment (ebox-child-range--lookup-ref sequence 'range)) + (payload (ebox-child-range--segment-payload segment)) + (first (aref payload 0)) + (second (aref payload 1))) + (plist-put first :node-id 1) + (plist-put second :node-id 2) + (plist-put first :ebox-sequence-location + '(:parent-node-id 99 :segment-index 0 :offset 0)) + (plist-put second :ebox-sequence-location + '(:parent-node-id 99 :segment-index 0 :offset 1)) + (pcase-let + ((`(,items ,reuse-map) + (ebox-incremental--candidate-canonical-range-reuse + sequence 'range (list (copy-tree first) (copy-tree second)) + '((0 . 0) (1 . 1)) 99 0))) + (should (eq (nth 0 items) first)) + (should (eq (nth 1 items) second)) + (should (equal reuse-map '((0 . 0) (1 . 1))))) + (pcase-let + ((`(,items ,reuse-map) + (ebox-incremental--candidate-canonical-range-reuse + sequence 'range (list second first) + '((0 . 1) (1 . 0)) 99 0))) + (should-not (eq (nth 0 items) second)) + (should-not (eq (nth 1 items) first)) + (should-not (plist-get (nth 0 items) :node-id)) + (should-not (plist-get (nth 1 items) :node-id)) + (should-not reuse-map)) + (should-error + (ebox-incremental--candidate-canonical-range-reuse + sequence 'range (list (copy-tree first)) '((1 . 0)) 99 0)))) + +(ert-deftest ebox-child-range-render-signature-snapshots-item-identities () + "A cached Range signature must not follow later runtime identity mutation." + (let* ((item (ebox-create :content "item" :key 'item)) + (sequence (ebox-child-range--build (list (cons 'range (list item))))) + (published + (aref (ebox-child-range--segment-payload + (ebox-child-range--lookup-ref sequence 'range)) + 0)) + (signature (ebox--render-cache-value-signature sequence))) + (plist-put published :node-id 1) + (plist-put published :region-id 10) + (setq signature (ebox--render-cache-value-signature sequence)) + (plist-put published :region-id 20) + (should-not + (equal signature (ebox--render-cache-value-signature sequence))) + (should (equal 10 (plist-get (cadr signature) :region-id))))) + (ert-deftest ebox-child-range-gate-a-exact-segment-and-payload-costs () "Point replacement cost follows trie height, never parent child count." (dolist (case '((0 0 1) (10 1 2) (100 2 3) (500 2 3))) diff --git a/tests/ebox-commit-tests.el b/tests/ebox-commit-tests.el index b502bef..ad02cb8 100644 --- a/tests/ebox-commit-tests.el +++ b/tests/ebox-commit-tests.el @@ -3,6 +3,7 @@ (require 'cl-lib) (require 'ert) (require 'ebox) +(require 'ebox-native-commit) (defun ebox-commit-test--buffer-string (buffer) "Return BUFFER's complete propertized contents." @@ -11,6 +12,90 @@ (widen) (buffer-substring (point-min) (point-max))))) +(ert-deftest ebox-native-fragment-style-delta-copies-only-changed-records () + "A native style delta keeps the retained template immutable." + (let* ((first [0 1 0 nil nil nil nil (1)]) + (second [1 2 0 nil nil nil nil (2)]) + (template (vector first second)) + (target + (ebox-native-commit--apply-fragment-style-delta + template '((1 3 5))))) + (should (eq (aref target 0) first)) + (should-not (eq (aref target 1) second)) + (should (equal (aref (aref target 1) 7) '(3 5))) + (should (equal (aref second 7) '(2))))) + +(ert-deftest ebox-native-object-delta-orders-moved-and-new-nodes () + "A topology delta names parents before moved and introduced children." + (let* ((old-root + (ebox-column (ebox-create :key 'a :content "A") + (ebox-create :key 'b :content "B"))) + (_old-ids (ebox--runtime-node-ids old-root)) + (old-index (ebox--runtime-index old-root t)) + (old-objects (make-hash-table :test 'equal)) + (new-root + (ebox-column (ebox-create :key 'b :content "B") + (ebox-create :key 'a :content "A") + (ebox-create :key 'c :content "C"))) + (_reconciled (ebox-tree-reconcile-runtime old-root new-root)) + (new-index (ebox--runtime-index new-root t)) + (root-id (plist-get old-root :node-id))) + (maphash (lambda (node-id _node) + (puthash node-id (list 'object node-id) old-objects)) + (plist-get old-index :node-table)) + (let* ((old-state (append (list :root-node old-root + :surface-node-object-table old-objects) + old-index)) + (new-state (append (list :root-node new-root) new-index)) + (delta + (ebox-native-commit-object-delta-node-ids + old-state new-state + (list :touched-node-ids (list root-id) + :removed-node-ids nil))) + (children (ebox-tree--children-raw new-root))) + (should + (equal delta + (cons root-id (mapcar (lambda (node) + (plist-get node :node-id)) + children))))))) + +(ert-deftest ebox-native-object-delta-requires-complete-removal-proof () + "An unreported disappeared object rejects the native topology delta." + (let* ((old-root + (ebox-column (ebox-create :key 'a :content "A") + (ebox-create :key 'tail :content "T") + (ebox-create :key 'b :content "B"))) + (_old-ids (ebox--runtime-node-ids old-root)) + (old-index (ebox--runtime-index old-root t)) + (old-objects (make-hash-table :test 'equal)) + (removed-node (car (last (ebox-tree--children-raw old-root)))) + (new-root + (ebox-column (ebox-create :key 'a :content "A") + (ebox-create :key 'tail :content "T"))) + (_reconciled (ebox-tree-reconcile-runtime old-root new-root)) + (new-index (ebox--runtime-index new-root t)) + (root-id (plist-get old-root :node-id))) + (maphash (lambda (node-id _node) + (puthash node-id (list 'object node-id) old-objects)) + (plist-get old-index :node-table)) + (let ((old-state (append (list :root-node old-root + :surface-node-object-table old-objects) + old-index)) + (new-state (append (list :root-node new-root) new-index))) + (should-not + (ebox-native-commit-object-delta-node-ids + old-state new-state + (list :touched-node-ids (list root-id) + :removed-node-ids nil))) + (should + (equal + (list root-id) + (ebox-native-commit-object-delta-node-ids + old-state new-state + (list :touched-node-ids (list root-id) + :removed-node-ids + (list (plist-get removed-node :node-id))))))))) + (ert-deftest ebox-style-schema-registration-is-not-per-node-copy () "Repeated node construction must not copy the whole ECSS schema domain." (let ((calls 0) @@ -330,6 +415,104 @@ (when (buffer-live-p buffer) (kill-buffer buffer))))) +(ert-deftest ebox-commit-reuses-local-selector-styles-across-tree-change () + "A subject-local stylesheet should compute only newly inserted node styles." + (let* ((ebox-style-stylesheet (ecss-stylesheet-create)) + (calls 0) + (original (symbol-function 'ecss-compute-style)) + (buffer nil)) + (ebox-style-add-rule ".card" '(:color "#2255AA") :layer 'components) + (unwind-protect + (progn + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-local-selector-reuse*") + (ebox-column + (ebox-create :key 'first :class "card" :content "First" + :width '(40)) + (ebox-create :key 'second :class "card" :content "Second" + :width '(40))))) + (cl-letf (((symbol-function 'ecss-compute-style) + (lambda (&rest arguments) + (cl-incf calls) + (apply original arguments)))) + (ebox-commit + buffer + (ebox-column + (ebox-create :key 'first :class "card" :content "First" + :width '(40)) + (ebox-create :key 'second :class "card" :content "Second" + :width '(40)) + (ebox-create :key 'third :class "card" :content "Third" + :width '(40))))) + (should (= calls 1)) + (let* ((text (ebox-commit-test--buffer-string buffer)) + (position (string-match "Third" text))) + (should position) + (should (equal (plist-get (get-text-property position 'face text) + :foreground) + "#2255AA")))) + (when (buffer-live-p buffer) + (kill-buffer buffer))))) + +(ert-deftest ebox-commit-reuses-successful-allocation-certificate () + "A repeated fixed-slot update must not rescan unchanged ancestor spans." + (let* ((ebox-style-stylesheet (ecss-stylesheet-create)) + (buffer nil) + (ancestor-scans 0)) + (ebox-style-add-rule ".card" '(:color "#111111") :layer 'components) + (unwind-protect + (progn + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-allocation-certificate*") + (ebox-build + '(grid :width (100) :height 2 + :grid-template-columns (1fr 1fr) + (column :key card-shell :width stretch :height 2 + (box :key card :class "card" :host-ref target + :content "Before" :width (40))) + (box :key stable :content "Stable" + :width (40) :height 1))))) + (let ((first (ebox-candidate-begin buffer))) + (ebox-candidate-replace-host-ref + first 'target + (ebox-create :key 'card :class "card" :host-ref 'target + :content "Middle" :width '(40))) + (should (eq (plist-get (ebox-commit buffer first) + :projection-kind) + 'owner-scoped))) + (let* ((state (ebox--buffer-render-state buffer)) + (certificates + (plist-get state :retained-allocation-certificates))) + (should (hash-table-p certificates)) + (should (= 1 (hash-table-count certificates)))) + (let ((second (ebox-candidate-begin buffer)) + (original + (symbol-function + 'ebox-incremental--compute-ancestor-slot-signature))) + (ebox-candidate-replace-host-ref + second 'target + (ebox-create :key 'card :class "card" :host-ref 'target + :content "After" :width '(40))) + (cl-letf + (((symbol-function + 'ebox-incremental--compute-ancestor-slot-signature) + (lambda (&rest arguments) + (cl-incf ancestor-scans) + (apply original arguments)))) + (should (eq (plist-get (ebox-commit buffer second) + :projection-kind) + 'owner-scoped)))) + (should (= ancestor-scans 0)) + (should + (string-match-p + "After" + (substring-no-properties + (ebox-commit-test--buffer-string buffer))))) + (when (buffer-live-p buffer) + (kill-buffer buffer))))) + (ert-deftest ebox-commit-invalidates-selector-tree-token-for-sibling-change () "A sibling metadata change must invalidate retained selector computations." (let* ((ebox-style-stylesheet (ecss-stylesheet-create)) @@ -1254,6 +1437,74 @@ remain retained identities." (ebox-create :key 'untouched :host-ref 'untouched :content "untouched"))) +(ert-deftest ebox-candidate-host-paint-patch-preserves-subtree-identity () + "Patch one Host's paint without copying or reconciling its descendants." + (let* ((buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-host-paint-patch* ") + (ebox-create + :key 'target :host-ref 'target :bgcolor "#111111" + :ebox-content-node + (ebox-column + (ebox-create :key 'child :host-ref 'child :content "child"))))) + (child-id (plist-get (ebox--host-ref-node buffer 'child) :node-id))) + (unwind-protect + (progn + (let ((candidate (ebox-candidate-begin buffer))) + (ebox-candidate-patch-host-paint + candidate 'target + (ebox-create + :key 'target :host-ref 'target :bgcolor "#111111" + :ebox-content-node + (ebox-column + (ebox-create :key 'child :host-ref 'child :content "child"))) + (ebox-create + :key 'target :host-ref 'target :bgcolor "#EEEEEE" + :ebox-content-node + (ebox-column + (ebox-create :key 'child :host-ref 'child :content "child")))) + (let ((report (ebox-commit buffer candidate))) + (should (eq (plist-get report :projection-kind) 'paint)) + (should (= child-id + (plist-get (ebox--host-ref-node buffer 'child) + :node-id))) + (should (equal "#EEEEEE" + (plist-get (ebox--host-ref-node buffer 'target) + :bgcolor))))) + (let ((candidate (ebox-candidate-begin buffer))) + (should-not + (ebox-candidate-patch-host-paint + candidate 'target + (ebox-create + :key 'target :host-ref 'target :bgcolor "#EEEEEE" + :content "child") + (ebox-create + :key 'target :host-ref 'target :bgcolor "#EEEEEE" + :width '(40) :content "child")))) + (let ((candidate (ebox-candidate-begin buffer))) + (should + (ebox-candidate-patch-host-paint + candidate 'target + (ebox-create + :key 'target :host-ref 'target :bgcolor "#EEEEEE" + :ebox-content-node + (ebox-column + (ebox-create :key 'child :host-ref 'child :content "child"))) + (ebox-create + :key 'target :host-ref 'target :bgcolor "#EEEEEE" + :color "#FFFFFF" + :ebox-content-node + (ebox-column + (ebox-create :key 'child :host-ref 'child :content "child"))))) + (ebox-commit buffer candidate) + (should + (equal "#FFFFFF" + (plist-get (ebox--host-ref-node buffer 'target) :color))) + (should + (equal "#FFFFFF" + (plist-get (ebox--host-ref-node buffer 'child) :color))))) + (when (buffer-live-p buffer) (kill-buffer buffer))))) + (defun ebox-commit-test--fixed-basis-selection-root (row-1 row-2) "Return a stretched fixed-basis panel containing two selectable rows." (let ((panel @@ -1287,7 +1538,10 @@ remain retained identities." (ert-deftest ebox-commit-fixed-basis-selection-round-trip-stays-local () "Continuous row-1 -> row-2 -> row-1 publication keeps local TP scope." - (let ((buffer + (let ((slot-proof-count 0) + (original-slot-proof + (symbol-function 'ebox--flex-item-slot-footprint-safe-p)) + (buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-fixed-basis-round-trip* ") (ebox-commit-test--fixed-basis-selection-root @@ -1300,13 +1554,17 @@ remain retained identities." (setq panel-id (ebox-incremental--nearest-fixed-basis-flex-item-owner-id buffer panel-id)) - (dolist (contents '(("[ ] row 1" "[x] row 2") - ("[x] row 1" "[ ] row 2"))) - (let* ((report - (ebox-commit - buffer - (ebox-commit-test--fixed-basis-selection-candidate - buffer (car contents) (cadr contents)))) + (cl-letf (((symbol-function 'ebox--flex-item-slot-footprint-safe-p) + (lambda (&rest arguments) + (cl-incf slot-proof-count) + (apply original-slot-proof arguments)))) + (dolist (contents '(("[ ] row 1" "[x] row 2") + ("[x] row 1" "[ ] row 2"))) + (let* ((report + (ebox-commit + buffer + (ebox-commit-test--fixed-basis-selection-candidate + buffer (car contents) (cadr contents)))) (surface (with-current-buffer buffer ebox-surface--buffer-surface)) (tp-report (tp-surface-report surface)) @@ -1330,13 +1588,14 @@ remain retained identities." ;; to recapture geometry from the committed TP mounts. (should panel-snapshot) (should-not (plist-member panel-snapshot :buffer-spans)) - (with-current-buffer buffer - (should - (equal - (buffer-substring-no-properties (point-min) (point-max)) - (substring-no-properties - (ebox-render (ebox--buffer-root-node buffer)))))))) - (should (= (length reports) 2))) + (with-current-buffer buffer + (should + (equal + (buffer-substring-no-properties (point-min) (point-max)) + (substring-no-properties + (ebox-render (ebox--buffer-root-node buffer))))))))) + (should (= (length reports) 2)) + (should (= slot-proof-count 2))) (when (buffer-live-p buffer) (kill-buffer buffer))))) (defun ebox-commit-test--mixed-owner-candidate @@ -1474,7 +1733,7 @@ remain retained identities." (when (buffer-live-p buffer) (kill-buffer buffer))))) (ert-deftest ebox-commit-mixed-owner-reflow-reuses-unchanged-fragments () - "Skip face recomposition for unchanged fragments in a mixed projection." + "Delegate affected face composition to TP and retain fragment topology." (let* ((buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-mixed-owner-reuse* ") @@ -1484,13 +1743,28 @@ remain retained identities." (old-state (tp-surface-client-state surface)) (fragment-count (length (plist-get old-state :surface-fragments))) (repaints 0) - (original (symbol-function 'ebox-surface--repaint-fragment))) + (metadata-calls 0) + (original + (symbol-function 'ebox-surface--repaint-output-fragment!)) + (original-metadata + (symbol-function 'ebox-surface--fragment-metadata))) (unwind-protect (progn - (cl-letf (((symbol-function 'ebox-surface--repaint-fragment) + (should + (cl-every + (lambda (fragment) + (or (null (plist-get fragment :paint-role-ids)) + (plist-get fragment :paint-node-chain))) + (plist-get old-state :surface-fragments))) + (should (plist-get old-state :surface-owned-ranges)) + (cl-letf (((symbol-function 'ebox-surface--repaint-output-fragment!) (lambda (&rest args) (cl-incf repaints) - (apply original args)))) + (apply original args))) + ((symbol-function 'ebox-surface--fragment-metadata) + (lambda (&rest args) + (cl-incf metadata-calls) + (apply original-metadata args)))) (should (eq (plist-get (ebox-commit buffer @@ -1500,7 +1774,455 @@ remain retained identities." :projection-kind) 'mixed-owner-reflow))) (should (> fragment-count 0)) - (should (< repaints fragment-count))) + (should (zerop repaints)) + (should + (plist-get (tp-surface-client-state surface) + :paint-property-contributions)) + (let ((fragments + (plist-get (tp-surface-client-state surface) + :surface-fragments)) + (offset 0)) + (should fragments) + (dolist (fragment fragments) + (should (= offset (plist-get fragment :start))) + (setq offset (plist-get fragment :end))) + (should (= offset + (with-current-buffer buffer (buffer-size))))) + (should (= metadata-calls 0))) + (when (buffer-live-p buffer) (kill-buffer buffer))))) + +(ert-deftest ebox-surface-owned-range-index-rebases-exact-boundaries () + "Rebase retained ownership exactly and reject ambiguous inner boundaries." + (let* ((patches '((:old-start 5 :old-end 10 + :new-start 5 :new-end 12))) + (ranges '((:object before :start 0 :end 5 :tags (:before t)) + (:object changed :start 5 :end 10 :tags (:changed t)) + (:object parent :start 0 :end 20 :tags (:parent t)) + (:object after :start 10 :end 20 :tags (:after t)))) + (rebased (cdr (ebox-surface--rebase-owned-ranges + ranges patches 22)))) + (should + (equal (mapcar (lambda (range) + (list (plist-get range :object) + (plist-get range :start) + (plist-get range :end))) + rebased) + '((before 0 5) (changed 5 12) (parent 0 22) (after 12 22)))) + (should-not + (ebox-surface--rebase-owned-ranges + '((:object ambiguous :start 6 :end 9)) patches 22)))) + +(ert-deftest ebox-commit-structure-skips-inapplicable-paint-span-proofs () + "A structural transaction must not run proofs whose domain excludes it." + (let* ((buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-structure-proof-domain* ") + (ebox-column + (ebox-create + :key 'target :host-ref 'target + :ebox-content-node + (ebox-column (ebox-create :key 'first :content "first")))))) + (span-calls 0) + (mixed-calls 0) + (old-span + (symbol-function 'ebox-incremental--span-patch-projection-proof)) + (old-mixed + (symbol-function 'ebox-incremental--mixed-owner-proof))) + (unwind-protect + (let ((candidate (ebox-candidate-begin buffer))) + (ebox-candidate-replace-host-ref + candidate 'target + (ebox-create + :key 'target :host-ref 'target + :ebox-content-node + (ebox-column (ebox-create :key 'first :content "first") + (ebox-create :key 'second :content "second")))) + (cl-letf + (((symbol-function 'ebox-incremental--span-patch-projection-proof) + (lambda (&rest arguments) + (cl-incf span-calls) + (apply old-span arguments))) + ((symbol-function 'ebox-incremental--mixed-owner-proof) + (lambda (&rest arguments) + (cl-incf mixed-calls) + (apply old-mixed arguments)))) + (ebox-commit buffer candidate)) + (should (zerop span-calls)) + (should (zerop mixed-calls)) + (should (string-match-p "second" + (ebox-commit-test--buffer-string buffer)))) + (when (buffer-live-p buffer) (kill-buffer buffer))))) + +(ert-deftest ebox-candidate-range-structure-stops-at-range-parent () + "A Range child identity change must not mark copied ancestors structural." + (let* ((buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-range-dirty-boundary* ") + (ebox-column + (ebox-child-range + 'rows (ebox-create :key 'old :content "old"))))) + (state (ebox--buffer-render-state buffer)) + (parent-id + (plist-get (gethash 'rows (plist-get state :range-ref-table)) + :parent-node-id)) + captured) + (unwind-protect + (let ((candidate (ebox-candidate-begin buffer)) + (original + (symbol-function 'ebox-incremental--surface-commit-input))) + (ebox-candidate-replace-range-ref + candidate 'rows (list (ebox-create :key 'new :content "new"))) + (cl-letf + (((symbol-function 'ebox-incremental--surface-commit-input) + (lambda (target old-state prepared) + (setq captured (copy-tree (plist-get prepared :dirty-set))) + (funcall original target old-state prepared)))) + (ebox-commit buffer candidate)) + (should + (equal + (mapcar (lambda (entry) + (list (plist-get entry :node-id) + (plist-get entry :dirty-kind) + (plist-get entry :changed-keys))) + captured) + (list (list parent-id 'structure '(:children))))) + (should (string-match-p "new" + (ebox-commit-test--buffer-string buffer)))) + (when (buffer-live-p buffer) (kill-buffer buffer))))) + +(ert-deftest ebox-commit-mixed-owner-reflow-skips-full-owner-planner () + "A proven mixed union should not also plan the complete dirty set." + (let* ((buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-mixed-owner-plan* ") + (ebox-commit-test--mixed-owner-root + "left-old" "right-old" "#111111" "#222222" "#333333"))) + (calls 0) + (axis-scans 0) + (original + (symbol-function 'ebox-incremental--layout-owner-plan)) + (original-context-axes + (symbol-function 'ebox-surface--context-axes))) + (unwind-protect + (cl-letf + (((symbol-function 'ebox-incremental--layout-owner-plan) + (lambda (&rest arguments) + (cl-incf calls) + (apply original arguments))) + ((symbol-function 'ebox-surface--context-axes) + (lambda (&rest arguments) + (cl-incf axis-scans) + (apply original-context-axes arguments)))) + (let* ((report + (ebox-commit + buffer + (ebox-commit-test--mixed-owner-candidate + buffer "left-new" "right-new" + "#AAAAAA" "#BBBBBB" "#CCCCCC"))) + (owner-ids (plist-get report :owner-ids))) + (should (eq (plist-get report :projection-kind) + 'mixed-owner-reflow)) + (should (= calls 0)) + (should (= axis-scans 0)) + (should (= (plist-get report :tp-scope-count) + (length owner-ids))) + (should-not (plist-get report :tp-scope-fallback)) + (with-current-buffer buffer + (should + (equal-including-properties + (buffer-substring (point-min) (point-max)) + (ebox-render (ebox--buffer-root-node buffer))))))) + (when (buffer-live-p buffer) (kill-buffer buffer))))) + +(defun ebox-commit-test--allocation-closure-root + (toast paint-a paint-b &optional width footer-overflow root-overflow) + "Return a generic whole-line Flex allocation-closure fixture." + (ebox-create + :key 'root :width (list (or width 800)) + :height (and root-overflow 1) :overflow root-overflow + :ebox-content-node + (ebox-column + (ebox-create :key 'status :host-ref 'status + :content "Theme: Light" :width '(200)) + (ebox-create :key 'paint-a :host-ref 'paint-a + :content "paint-a" :color paint-a) + (ebox-create :key 'paint-b :host-ref 'paint-b + :content "paint-b" :bgcolor paint-b) + (ebox-create + :key 'footer-owner :width (list (or width 800)) + :ebox-content-node + (ebox-column + (ebox-flex + :key 'footer :width (list (or width 800)) :flex-wrap 'wrap + :overflow footer-overflow :gap '(1 (4)) + (ebox-create + :key 'toast-slot :width 'stretch :min-width 0 + :flex-grow 1 :flex-shrink 1 :flex-basis '(0) + :ebox-content-node + (ebox-column + (ebox-create :key 'toast :host-ref 'toast :content toast))) + (ebox-create :key 'peer :content "database.sqlite"))))))) + +(defun ebox-commit-test--allocation-closure-candidate + (buffer toast paint-a paint-b) + "Return BUFFER candidate changing one Flex content and two paints." + (let ((candidate (ebox-candidate-begin buffer))) + (ebox-candidate-replace-host-ref + candidate 'toast + (ebox-create :key 'toast :host-ref 'toast :content toast)) + (ebox-candidate-replace-host-ref + candidate 'paint-a + (ebox-create :key 'paint-a :host-ref 'paint-a + :content "paint-a" :color paint-a)) + (ebox-candidate-replace-host-ref + candidate 'paint-b + (ebox-create :key 'paint-b :host-ref 'paint-b + :content "paint-b" :bgcolor paint-b)) + candidate)) + +(defun ebox-commit-test--two-geometry-allocation-candidate + (buffer status toast paint-a paint-b) + "Return BUFFER candidate with one span and one allocation geometry owner." + (let ((candidate + (ebox-commit-test--allocation-closure-candidate + buffer toast paint-a paint-b))) + (ebox-candidate-replace-host-ref + candidate 'status + (ebox-create :key 'status :host-ref 'status + :content status :width '(200))) + candidate)) + +(ert-deftest ebox-commit-allocation-closure-round-trips-exactly () + "Stable Flex allocation closes one content damage plus external paint." + (let* ((buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-allocation-closure* ") + (ebox-commit-test--allocation-closure-root + "Light" "#111111" "#222222"))) + (surface (with-current-buffer buffer ebox-surface--buffer-surface)) + (full-renders 0) + (original-full-render + (symbol-function 'ebox-surface--render-candidate))) + (unwind-protect + (cl-letf (((symbol-function 'ebox-surface--render-candidate) + (lambda (&rest arguments) + (cl-incf full-renders) + (apply original-full-render arguments)))) + (dolist (values '(("A longer dark notification" + "#AAAAAA" "#BBBBBB") + ("Light" "#111111" "#222222"))) + (let* ((before (tp-surface-revision surface)) + (candidate + (apply #'ebox-commit-test--allocation-closure-candidate + buffer values)) + (report (ebox-commit buffer candidate)) + (state (tp-surface-client-state surface)) + (owner-proof + (car (plist-get + (plist-get (plist-get state :mixed-owner-proof) + :geometry-proof) + :owner-proofs)))) + (should (eq (plist-get report :projection-kind) + 'mixed-owner-reflow)) + (should (plist-get owner-proof :allocation-closure-p)) + (should (= (tp-surface-revision surface) (1+ before))) + (should-not (plist-get report :tp-full-root)) + (should-not (plist-get report :tp-scope-fallback)) + (should (= (plist-get report :created-objects) 0)) + (should (= (plist-get report :removed-objects) 0)) + (should (= (or (plist-get report :moved-objects) 0) 0)) + (with-current-buffer buffer + (should + (equal-including-properties + (buffer-substring (point-min) (point-max)) + (ebox-render (ebox--buffer-root-node buffer))))))) + (should (= full-renders 0))) + (when (buffer-live-p buffer) (kill-buffer buffer))))) + +(ert-deftest ebox-commit-allocation-closure-rolls-back-and-retries () + "Failed allocation-closure publication restores state before retry." + (let* ((buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-allocation-rollback* ") + (ebox-commit-test--allocation-closure-root + "Light" "#111111" "#222222"))) + (surface (with-current-buffer buffer ebox-surface--buffer-surface)) + (state (tp-surface-client-state surface)) + (revision (tp-surface-revision surface)) + (text (ebox-commit-test--buffer-string buffer))) + (unwind-protect + (progn + (cl-letf (((symbol-function 'accept-change-group) + (lambda (_group) (error "allocation final accept")))) + (should-error + (ebox-commit + buffer + (ebox-commit-test--allocation-closure-candidate + buffer "A longer dark notification" "#AAAAAA" "#BBBBBB")))) + (should (= (tp-surface-revision surface) revision)) + (should (eq (tp-surface-client-state surface) state)) + (should (equal-including-properties + (ebox-commit-test--buffer-string buffer) text)) + (should (eq (plist-get + (ebox-commit + buffer + (ebox-commit-test--allocation-closure-candidate + buffer "A longer dark notification" + "#AAAAAA" "#BBBBBB")) + :projection-kind) + 'mixed-owner-reflow))) + (when (buffer-live-p buffer) (kill-buffer buffer))))) + +(ert-deftest ebox-commit-two-geometry-allocation-closure-round-trips () + "Combine one ordinary span with one allocation closure after global miss." + (let* ((buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-two-geometry-closure* ") + (ebox-commit-test--allocation-closure-root + "Light" "#111111" "#222222"))) + (surface (with-current-buffer buffer ebox-surface--buffer-surface)) + (full-renders 0) + (original-full-render + (symbol-function 'ebox-surface--render-candidate))) + (unwind-protect + (cl-letf (((symbol-function 'ebox-surface--render-candidate) + (lambda (&rest arguments) + (cl-incf full-renders) + (apply original-full-render arguments)))) + (dolist (values '(("Theme: Dark" "A longer dark notification" + "#AAAAAA" "#BBBBBB") + ("Theme: Light" "Light" + "#111111" "#222222"))) + (let* ((before (tp-surface-revision surface)) + (report + (ebox-commit + buffer + (apply + #'ebox-commit-test--two-geometry-allocation-candidate + buffer values))) + (state (tp-surface-client-state surface)) + (proofs + (plist-get + (plist-get (plist-get state :mixed-owner-proof) + :geometry-proof) + :owner-proofs))) + (should (eq (plist-get report :projection-kind) + 'mixed-owner-reflow)) + (should (= 2 (length proofs))) + (should (cl-some (lambda (proof) + (plist-get proof :allocation-closure-p)) + proofs)) + (should (= (tp-surface-revision surface) (1+ before))) + (should-not (plist-get report :tp-full-root)) + (should-not (plist-get report :tp-scope-fallback)) + (with-current-buffer buffer + (should + (equal-including-properties + (buffer-substring (point-min) (point-max)) + (ebox-render (ebox--buffer-root-node buffer))))))) + (should (= full-renders 0))) + (when (buffer-live-p buffer) (kill-buffer buffer))))) + +(ert-deftest ebox-allocation-closure-allows-recomposable-ancestor-paint () + "Allow ancestor paint but reject paint at/below an allocation owner." + (let ((parents (make-hash-table :test #'eql))) + ;; 1(root) -> 2(paint ancestor) -> 3(geometry) -> 4(paint descendant) + (puthash 2 1 parents) + (puthash 3 2 parents) + (puthash 4 3 parents) + (let ((state (list :parent-table parents))) + (should + (ebox-incremental--allocation-closure-paint-disjoint-p + state '(3) '(2))) + (should-not + (ebox-incremental--allocation-closure-paint-disjoint-p + state '(3) '(3))) + (should-not + (ebox-incremental--allocation-closure-paint-disjoint-p + state '(3) '(4)))))) + +(ert-deftest ebox-commit-allocation-closure-unsafe-shapes-fallback () + "Wrap/width, overflow, and scroll changes cannot use allocation closure." + (dolist (case '((120 nil nil) (800 visible nil) (800 nil scroll))) + (pcase-let ((`(,width ,footer-overflow ,root-overflow) case)) + (let ((buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-allocation-fallback* ") + (ebox-commit-test--allocation-closure-root + "Light" "#111111" "#222222" + width footer-overflow root-overflow)))) + (unwind-protect + (let ((report + (ebox-commit + buffer + (ebox-commit-test--allocation-closure-candidate + buffer (make-string 200 ?X) "#AAAAAA" "#BBBBBB")))) + (should-not (eq (plist-get report :projection-kind) + 'mixed-owner-reflow))) + (when (buffer-live-p buffer) (kill-buffer buffer))))))) + +(ert-deftest ebox-commit-allocation-closure-proof-misses-fallback () + "Topology, selector, cascade, and role misses reject allocation closure." + (dolist (kind '(topology selector cascade)) + (let ((buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-allocation-proof-miss* ") + (ebox-commit-test--allocation-closure-root + "Light" "#111111" "#222222")))) + (unwind-protect + (let ((candidate (ebox-candidate-begin buffer))) + (ebox-candidate-replace-host-ref + candidate 'toast + (if (eq kind 'topology) + (ebox-create + :key 'toast :host-ref 'toast + :ebox-content-node + (ebox-column + (ebox-create :key 'nested-toast + :content "A longer notification"))) + (ebox-create :key 'toast :host-ref 'toast + :class (and (eq kind 'selector) "changed") + :content "A longer notification"))) + (ebox-candidate-replace-host-ref + candidate 'paint-a + (ebox-create :key 'paint-a :host-ref 'paint-a + :content "paint-a" :color "#AAAAAA")) + (let ((report + (if (eq kind 'cascade) + (cl-letf + (((symbol-function 'ebox-style-cascade-active-p) + (lambda () t)) + ((symbol-function + 'ebox-surface--cascade-local-owner-proof-p) + (lambda (&rest _) nil))) + (ebox-commit buffer candidate)) + (ebox-commit buffer candidate)))) + (should-not (eq (plist-get report :projection-kind) + 'mixed-owner-reflow)))) + (when (buffer-live-p buffer) (kill-buffer buffer))))) + (let* ((buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-allocation-role-miss* ") + (ebox-commit-test--allocation-closure-root + "Light" "#111111" "#222222"))) + (original-output + (symbol-function 'ebox-surface--mixed-owner-output)) + mixed-output) + (unwind-protect + (progn + (ebox--refresh-buffer-layout-snapshots buffer t) + (cl-letf + (((symbol-function + 'ebox-surface--rendered-role-topology-signature) + (lambda (&rest _) '(:roles (mismatched)))) + ((symbol-function 'ebox-surface--mixed-owner-output) + (lambda (&rest arguments) + (setq mixed-output (apply original-output arguments))))) + (ebox-commit + buffer + (ebox-commit-test--allocation-closure-candidate + buffer "A longer notification" "#AAAAAA" "#BBBBBB")) + (should-not mixed-output))) (when (buffer-live-p buffer) (kill-buffer buffer))))) (provide 'ebox-commit-tests) diff --git a/tests/ebox-core-render-tests.el b/tests/ebox-core-render-tests.el index b087afb..b551077 100644 --- a/tests/ebox-core-render-tests.el +++ b/tests/ebox-core-render-tests.el @@ -46,7 +46,10 @@ (when (timerp ebox--deferred-render-gc-timer) (cancel-timer ebox--deferred-render-gc-timer)) (setq ebox--deferred-render-gc-timer nil) - (setq ebox--deferred-render-gc-state nil)) + (setq ebox--deferred-render-gc-state nil) + (setq ebox--deferred-render-gc-depth 0) + (setq ebox--render-burst-stack nil) + (clrhash ebox--render-burst-records)) (defmacro ebox-test--with-rendered-buffer (layout &rest body) "Render LAYOUT into a temporary ebox buffer and run BODY there." @@ -1884,6 +1887,17 @@ (should props) (should (plist-member props 'face)))) +(ert-deftest ebox-render-keeps-tp-paint-slot-address-stable () + "A TP paint slot should recolor retained output without property rewrites." + (let* ((slot (tp-paint-slot-create '(:foreground "red"))) + (face (tp-paint-slot-face slot)) + (rendered (ebox-render (ebox-create :content "paint" :color slot))) + (property (get-text-property 0 'face rendered))) + (should (eq face property)) + (tp-paint-slot-update slot '(:foreground "blue")) + (should (eq property (get-text-property 0 'face rendered))) + (should (equal "blue" (face-attribute face :foreground nil nil))))) + (ert-deftest ebox-buffer-backend-does-not-map-layout-only-properties () "Layout-only properties should not produce text properties directly." (let* ((style (ebox-style-compute @@ -2974,6 +2988,217 @@ (when (timerp ebox--deferred-render-gc-timer) (cancel-timer ebox--deferred-render-gc-timer))))) +(ert-deftest ebox-public-render-burst-batch-restores-nested-settings-lifo () + "Batch bursts should nest strictly and restore exact caller GC settings." + (let ((noninteractive t) + (gc-cons-threshold 1000) + (gc-cons-percentage 0.1) + (ebox-render-gc-cons-threshold 4000) + (ebox-render-gc-cons-percentage 0.8) + (ebox--render-burst-stack nil) + (gc-count 0)) + (cl-letf (((symbol-function 'garbage-collect) + (lambda () (cl-incf gc-count)))) + (let ((outer (ebox-render-burst-begin))) + (should (= gc-cons-threshold 4000)) + (should (= gc-cons-percentage 0.8)) + (let ((inner (ebox-render-burst-begin))) + (should-error (ebox-render-burst-end outer)) + (should (= gc-cons-threshold 4000)) + (ebox-render-burst-end inner) + (should (= gc-cons-threshold 4000))) + (ebox-render-burst-end outer) + (should (= gc-cons-threshold 1000)) + (should (= gc-cons-percentage 0.1)) + (should-error (ebox-render-burst-end outer))) + (should (= gc-count 0))))) + +(ert-deftest ebox-public-render-burst-raises-gc-before-private-allocation () + "Begin should cross the GC boundary before creating any ownership record." + (let ((noninteractive t) + (gc-cons-threshold 1000) + (gc-cons-percentage 0.1) + (ebox-render-gc-cons-threshold 4000) + (ebox-render-gc-cons-percentage 0.8) + (ebox--render-burst-stack nil) + (ebox--render-burst-records (make-hash-table :test #'eq)) + (original-make-symbol (symbol-function 'make-symbol)) + (original-make-record + (symbol-function 'ebox--render-burst-create-record)) + token-threshold + record-threshold) + (cl-letf (((symbol-function 'make-symbol) + (lambda (name) + (setq token-threshold gc-cons-threshold) + (funcall original-make-symbol name))) + ((symbol-function 'ebox--render-burst-create-record) + (lambda (&rest args) + (setq record-threshold gc-cons-threshold) + (apply original-make-record args)))) + (let ((token (ebox-render-burst-begin))) + (should (= token-threshold 4000)) + (should (= record-threshold 4000)) + (ebox-render-burst-end token))) + (should (= gc-cons-threshold 1000)) + (should (= gc-cons-percentage 0.1)))) + +(ert-deftest ebox-public-render-burst-restores-batch-gc-when-begin-fails () + "A batch begin allocation failure should restore its caller's GC settings." + (let ((noninteractive t) + (gc-cons-threshold 1000) + (gc-cons-percentage 0.1) + (ebox-render-gc-cons-threshold 4000) + (ebox-render-gc-cons-percentage 0.8) + (ebox--render-burst-stack nil) + (ebox--render-burst-records (make-hash-table :test #'eq))) + (cl-letf (((symbol-function 'make-symbol) + (lambda (_name) + (should (= gc-cons-threshold 4000)) + (error "token allocation failed")))) + (should-error (ebox-render-burst-begin))) + (should (= gc-cons-threshold 1000)) + (should (= gc-cons-percentage 0.1)) + (should-not ebox--render-burst-stack) + (should (zerop (hash-table-count ebox--render-burst-records))))) + +(ert-deftest ebox-interactive-render-burst-raises-gc-before-state-allocation () + "Interactive entry should save scalars and raise GC before state allocation." + (let ((noninteractive nil) + (gc-cons-threshold 1000) + (gc-cons-percentage 0.1) + (ebox-render-gc-cons-threshold 4000) + (ebox-deferred-render-gc-cons-threshold 8000) + (ebox-render-gc-cons-percentage 0.8) + (ebox--deferred-render-gc-state nil) + (ebox--deferred-render-gc-timer nil) + (ebox--deferred-render-gc-depth 0) + (original-make-state + (symbol-function 'ebox--make-deferred-render-gc-state)) + observed-threshold) + (unwind-protect + (cl-letf (((symbol-function 'ebox--make-deferred-render-gc-state) + (lambda (threshold percentage) + (setq observed-threshold gc-cons-threshold) + (funcall original-make-state threshold percentage)))) + (ebox--deferred-render-gc-enter) + (should (= observed-threshold 8000)) + (should (= (plist-get ebox--deferred-render-gc-state + :gc-cons-threshold) + 1000)) + (should (= (plist-get ebox--deferred-render-gc-state + :gc-cons-percentage) + 0.1))) + (ebox--deferred-render-gc-restore)))) + +(ert-deftest ebox-public-render-burst-wrapper-cleans-up-errors-and-quits () + "The call wrapper should restore batch settings across nonlocal exits." + (let ((noninteractive t) + (gc-cons-threshold 1000) + (gc-cons-percentage 0.1) + (ebox-render-gc-cons-threshold 4000) + (ebox-render-gc-cons-percentage 0.8) + (ebox--render-burst-stack nil) + quit-seen) + (should-error + (ebox-call-with-render-burst + (lambda () + (should (= gc-cons-threshold 4000)) + (error "render failed")))) + (should (= gc-cons-threshold 1000)) + (should (= gc-cons-percentage 0.1)) + (condition-case nil + (ebox-call-with-render-burst (lambda () (signal 'quit nil))) + (quit (setq quit-seen t))) + (should quit-seen) + (should (= gc-cons-threshold 1000)) + (should (= gc-cons-percentage 0.1)) + (should-not ebox--render-burst-stack))) + +(ert-deftest ebox-public-render-burst-interactive-reuses-safe-restore-timer () + "Interactive bursts should defer once and ignore a cancelled timer callback." + (let ((noninteractive nil) + (gc-cons-threshold 1000) + (gc-cons-percentage 0.1) + (ebox-deferred-render-gc-cons-threshold 4000) + (ebox-render-gc-cons-percentage 0.8) + (ebox--deferred-render-gc-state nil) + (ebox--deferred-render-gc-timer nil) + (ebox--deferred-render-gc-depth 0) + (ebox--deferred-render-gc-generation 0) + (ebox--render-burst-stack nil) + callbacks + cancelled + (timer-index 0) + (gc-count 0)) + (cl-letf (((symbol-function 'run-at-time) + (lambda (delay repeat function &rest args) + (let ((timer (list 'ebox-test-timer (cl-incf timer-index)))) + (push (list delay repeat function args timer) callbacks) + timer))) + ((symbol-function 'timerp) + (lambda (value) (eq (car-safe value) 'ebox-test-timer))) + ((symbol-function 'cancel-timer) + (lambda (timer) (push timer cancelled))) + ((symbol-function 'garbage-collect) + (lambda () (cl-incf gc-count)))) + (should (eq (ebox-call-with-render-burst #'identity 'done) 'done)) + (should (= gc-cons-threshold 4000)) + (should (= (length callbacks) 1)) + (let ((stale (car callbacks)) + (token (ebox-render-burst-begin))) + (should (= (length cancelled) 1)) + (apply (nth 2 stale) (nth 3 stale)) + (should (= gc-cons-threshold 4000)) + (should (= gc-count 0)) + (ebox-render-burst-end token)) + (should (= (length callbacks) 2)) + (let ((current (car callbacks))) + (should (= (nth 0 current) 0.2)) + (apply (nth 2 current) (nth 3 current))) + (should (= gc-cons-threshold 1000)) + (should (= gc-cons-percentage 0.1)) + (should (= gc-count 1))))) + +(ert-deftest ebox-public-render-burst-nests-around-interactive-commit () + "A framework burst should own GC before and throughout Ebox commit." + (ebox-test--reset-runtime-state) + (let ((buffer (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-test*") + (ebox-create :content "before")))) + (unwind-protect + (let ((noninteractive nil) + (gc-cons-threshold 1000) + (gc-cons-percentage 0.1) + (ebox-deferred-render-gc-cons-threshold 4000) + (ebox--deferred-render-gc-state nil) + (ebox--deferred-render-gc-timer nil) + (ebox--deferred-render-gc-depth 0) + (ebox--render-burst-stack nil) + scheduled + observed-depth) + (cl-letf (((symbol-function 'run-at-time) + (lambda (delay _repeat function &rest args) + (setq scheduled (list delay function args)) + 'ebox-test-timer)) + ((symbol-function 'timerp) + (lambda (value) (eq value 'ebox-test-timer))) + ((symbol-function 'cancel-timer) #'ignore)) + (let ((token (ebox-render-burst-begin))) + (ebox-commit + buffer (ebox-create :content "after") + (lambda (_report) + (setq observed-depth ebox--deferred-render-gc-depth))) + (should (= observed-depth 2)) + (should (= ebox--deferred-render-gc-depth 1)) + (should-not scheduled) + (ebox-render-burst-end token)) + (should (= ebox--deferred-render-gc-depth 0)) + (should (= (car scheduled) 0.2)) + (apply (nth 1 scheduled) (nth 2 scheduled)) + (should (= gc-cons-threshold 1000)))) + (when (buffer-live-p buffer) + (kill-buffer buffer))))) + (ert-deftest ebox-deferred-render-gc-keeps-overlapping-burst-owned () "A nested idle slice must not restore GC during an active animation." (let ((gc-cons-threshold 1000) diff --git a/tests/ebox-package-tests.el b/tests/ebox-package-tests.el index be0b503..4643d8a 100644 --- a/tests/ebox-package-tests.el +++ b/tests/ebox-package-tests.el @@ -15,6 +15,7 @@ "ebox-layout.el" "ebox-flex.el" "ebox-grid.el" + "ebox-buffer-backend.el" "ebox-selector.el" "ebox-native-reflow.el") "Source files that currently own public autoload entry points.") @@ -69,7 +70,8 @@ "ebox-tree.el" "ebox-measure.el" "ebox-fragment.el" "ebox-render-context.el" "ebox-layout.el" "ebox-flex.el" "ebox-grid.el" "ebox-buffer-backend.el" "ebox-incremental.el" - "ebox-surface.el" "ebox-dsl.el" "ebox-selector.el" "ebox.el" + "ebox-native-commit.el" "ebox-surface.el" "ebox-dsl.el" + "ebox-selector.el" "ebox.el" "ebox-native-reflow.el")))) (ert-deftest ebox-surface-depends-only-on-public-tp-api () @@ -125,6 +127,9 @@ "Stable public entry points should be discoverable by package autoloads." (let ((source (ebox-test--source-text))) (dolist (entry '(("ebox-clear-cache" . "defun") + ("ebox-render-burst-begin" . "defun") + ("ebox-render-burst-end" . "defun") + ("ebox-call-with-render-burst" . "defun") ("ebox-render" . "defun") ("ebox-region-ids" . "defun") ("ebox-concat" . "defun") @@ -163,6 +168,7 @@ ebox-buffer-update-report ebox-build ebox-buffer-mode + ebox-call-with-render-burst ebox-candidate-begin ebox-child-range ebox-candidate-replace @@ -189,6 +195,8 @@ ebox-region-resolve ebox-region-update ebox-render + ebox-render-burst-begin + ebox-render-burst-end ebox-render-to-buffer ebox-rerender-buffer-with-context ebox-row diff --git a/tests/ebox-surface-tests.el b/tests/ebox-surface-tests.el index 6231964..4674abc 100644 --- a/tests/ebox-surface-tests.el +++ b/tests/ebox-surface-tests.el @@ -11,6 +11,7 @@ (load-file (expand-file-name "../ebox.el" (file-name-directory load-file-name))) (require 'tp-surface) +(require 'ebox-native-reflow) (defun ebox-surface-test--reset-render-state () "Reset render identities and side tables used by projection tests." @@ -270,6 +271,42 @@ candidate cannot hide mutations by restoring the old hash-table pointer." (ebox-surface-producer node)) (ebox-render node)))) +(defun ebox-surface-test--project-row-column () + "Project the row/column fixture through the pure TP materializer." + (ebox-surface-test--render-fresh + (cdr (assq 'row-column (ebox-surface-test--fixtures))) t)) + +(defun ebox-surface-test--canonical-region-properties (rendered) + "Return RENDERED with opaque region ids renamed by semantic occurrence." + (let ((copy (copy-sequence rendered)) + (ids (make-hash-table :test #'eql)) + (next-id 0) + (properties + (delete-dups + (append (mapcar #'cdr ebox-region-types) + '(ebox-content ebox-content-owner ebox-scroll-window + ebox-overflow-foreground-source))))) + (cl-labels ((canonical + (id) + (or (gethash id ids) + (let ((canonical (cl-incf next-id))) + (puthash id canonical ids) + canonical)))) + (let ((position 0)) + (while (< position (length copy)) + (let* ((end (or (next-property-change position copy) (length copy))) + (props (text-properties-at position copy))) + (dolist (property properties) + (when-let* ((id (plist-get props property))) + (setq props (plist-put props property (canonical id))))) + (when-let* ((owners (plist-get props 'ebox-content-owners))) + (setq props + (plist-put props 'ebox-content-owners + (mapcar #'canonical owners)))) + (set-text-properties position end props copy) + (setq position end))))) + copy)) + (defun ebox-surface-test--walk-runtime (node function) "Call FUNCTION for every runtime NODE in preorder." (funcall function node) @@ -439,6 +476,119 @@ candidate cannot hide mutations by restoring the old hash-table pointer." (should (plist-get first :face-baseline-known-p)) (should-not (get-text-property 0 ebox--paint-origin-property rendered))))) +(ert-deftest ebox-native-layout-ir-preserves-default-foreground-face () + "Serialize and optionally render the Ebox default foreground reset." + (let* ((ebox-viewport-width 80) + (ebox-viewport-height 20) + (node (ebox-create :content "native" :width 20 + :color 'ebox/default-foreground)) + (package (ebox-native-reflow--compile-layout-package node)) + (document (plist-get package :document)) + (styles (plist-get document :styles)) + (control + (ebox-native-reflow--layout-control-json + document + '((:key 1 :viewport-width 80 :viewport-height 20 + :root-width 20 :runtime-revision 0 + :context-hash 0 :complete t))))) + (should (stringp control)) + (should + (cl-find "(:inherit default)" styles + :key (lambda (style) + (plist-get (plist-get style :face) :lisp)) + :test #'equal)) + (when (ebox-native-reflow-layout-ready-p) + (let ((frame + (ebox-native-reflow-execute-sync + node + '(:key 1 :viewport-width 80 :viewport-height 20 + :root-width 20 :runtime-revision 0 + :context-hash 0 :complete t) + package))) + (should (plist-get frame :native-frame)) + (should (vectorp + (plist-get (plist-get frame :effect-tape) + :fragment-span-template))) + (should (equal-including-properties + (plist-get frame :rendered) + (ebox-render node))))))) + +(ert-deftest ebox-native-layout-effect-fragments-match-render-scan () + "Decode native paint fragments without scanning rendered properties." + (skip-unless (ebox-native-reflow-layout-ready-p)) + (let* ((ebox-viewport-width 80) + (ebox-viewport-height 20) + (content (propertize "native" 'face 'italic 'help-echo "source")) + (node (ebox-create :content content :width 20 + :color "#f0f0f0" :bgcolor "#101010")) + (package (ebox-native-reflow--compile-layout-package node)) + (normal + (let ((ebox--surface-materialization-active t) + (ebox--paint-origin-capture-p t)) + (ebox--render-layout node))) + (normal-fragments (ebox-surface--rendered-fragments normal)) + (frame + (ebox-native-reflow-execute-sync + node + '(:key 1 :viewport-width 80 :viewport-height 20 + :root-width 20 :runtime-revision 0 + :context-hash 0 :complete t) + package)) + (native-fragments (ebox-native-reflow-frame-fragments frame))) + (should (equal-including-properties normal (plist-get frame :rendered))) + (should (= (length normal-fragments) (length native-fragments))) + (cl-mapc + (lambda (normal-fragment native-fragment) + (dolist (key '(:start :end :line :paint-role-ids :role-ids + :paint-address :paint-token :face-baseline + :face-baseline-known-p :key)) + (should (equal (plist-get normal-fragment key) + (plist-get native-fragment key))))) + normal-fragments native-fragments))) + +(ert-deftest ebox-native-layout-effect-fragments-reject-coordinate-gaps () + "Effect templates must cover the rendered frame exactly once." + (should-error + (ebox-native-reflow--validate-root-fragment-template + [[0 1 0 nil nil nil nil nil] + [2 3 0 nil nil nil nil nil]] + 3 0 0))) + +(ert-deftest ebox-surface-fragment-index-rebases-aligned-text-patch () + "Scan only an aligned replacement and retain surrounding paint addresses." + (let ((old (copy-sequence "abcXYZdef")) + (output (copy-sequence "abcQdef"))) + (cl-mapc + (lambda (range owner text) + (put-text-property (car range) (cdr range) + 'ebox-content-owner owner text)) + '((0 . 3) (3 . 6) (6 . 9)) '(1 2 3) (make-list 3 old)) + (cl-mapc + (lambda (range owner text) + (put-text-property (car range) (cdr range) + 'ebox-content-owner owner text)) + '((0 . 3) (3 . 4) (4 . 7)) '(1 2 3) (make-list 3 output)) + (let* ((old-fragments (ebox-surface--rendered-fragments old)) + (rebased + (ebox-surface--incremental-patched-fragments + output old-fragments + '((:old-start 3 :old-end 6 :new-start 3 :new-end 4))))) + (should rebased) + (should (equal (mapcar (lambda (fragment) + (cons (plist-get fragment :start) + (plist-get fragment :end))) + rebased) + '((0 . 3) (3 . 4) (4 . 7)))) + (should (equal (mapcar (lambda (fragment) + (plist-get + (plist-get fragment :paint-address) + :content-owner)) + rebased) + '(1 2 3))) + (should (cl-every (lambda (fragment) + (eq (plist-get fragment :text) output)) + rebased))))) + (ert-deftest ebox-surface-candidate-plan-copies-face-property-values () "Candidate plans isolate mutable face values despite provenance hints." (let* ((color (copy-sequence "#192233")) @@ -469,7 +619,10 @@ candidate cannot hide mutations by restoring the old hash-table pointer." (let* ((builder (cdr fixture)) (expected (ebox-surface-test--render-fresh builder nil)) (actual (ebox-surface-test--render-fresh builder t))) - (should (equal-including-properties actual expected)) + (should + (equal-including-properties + (ebox-surface-test--canonical-region-properties actual) + (ebox-surface-test--canonical-region-properties expected))) (should (equal (mapcar #'ebox--string-pixel-width (ebox-string-lines actual)) (mapcar #'ebox--string-pixel-width @@ -530,6 +683,21 @@ candidate cannot hide mutations by restoring the old hash-table pointer." t)) (should (> calls 0)))) +(ert-deftest ebox-surface-inline-inheritance-crosses-transparent-layout () + "Transparent layout nodes do not consume inherited paint themselves." + (let ((closed + (ebox-create + :color "red" + :ebox-content-node + (ebox-column (ebox-create :content "child" :color "blue")))) + (open + (ebox-create + :color "red" + :ebox-content-node + (ebox-column (ebox-create :content "child"))))) + (should-not (ebox-surface--inline-inheritance-required-p closed)) + (should (ebox-surface--inline-inheritance-required-p open)))) + (ert-deftest ebox-box-content-cache-reuses-fixed-viewport-subtree () "A fixed box content viewport should reuse its exact composite layout." (let* ((ebox--render-cache-table (make-hash-table :test 'equal)) @@ -640,18 +808,33 @@ candidate cannot hide mutations by restoring the old hash-table pointer." (ert-deftest ebox-surface-projection-does-not-mutate-a-buffer () "Pure projection should not call any final buffer mutation primitive." - (cl-letf (((symbol-function 'insert) - (lambda (&rest _) (error "Unexpected buffer insertion"))) - ((symbol-function 'erase-buffer) - (lambda (&rest _) (error "Unexpected buffer erase"))) - ((symbol-function 'delete-region) - (lambda (&rest _) (error "Unexpected buffer deletion"))) - ((symbol-function 'replace-region-contents) - (lambda (&rest _) (error "Unexpected buffer replacement")))) - (should - (stringp - (ebox-surface-test--render-fresh - (cdr (assq 'row-column (ebox-surface-test--fixtures))) t))))) + (let ((probe (generate-new-buffer " *ebox-projection-probe*")) + (original-insert (symbol-function 'insert)) + (original-erase (symbol-function 'erase-buffer)) + (original-delete (symbol-function 'delete-region)) + (original-replace (symbol-function 'replace-region-contents))) + (unwind-protect + (with-current-buffer probe + (cl-labels ((guarded + (label original arguments) + (when (eq (current-buffer) probe) + (error "Unexpected probe buffer %s" label)) + (apply original arguments))) + (cl-letf (((symbol-function 'insert) + (lambda (&rest arguments) + (guarded 'insert original-insert arguments))) + ((symbol-function 'erase-buffer) + (lambda (&rest arguments) + (guarded 'erase original-erase arguments))) + ((symbol-function 'delete-region) + (lambda (&rest arguments) + (guarded 'delete original-delete arguments))) + ((symbol-function 'replace-region-contents) + (lambda (&rest arguments) + (guarded 'replace original-replace arguments)))) + (should (stringp (ebox-surface-test--project-row-column))) + (should (equal (buffer-string) ""))))) + (when (buffer-live-p probe) (kill-buffer probe))))) (ert-deftest ebox-surface-logical-box-owns-disjoint-render-fragments () "One logical Ebox box should resolve all of its separated painted regions."