diff --git a/.runtime-index-plan.md b/.runtime-index-plan.md new file mode 100644 index 0000000..7308694 --- /dev/null +++ b/.runtime-index-plan.md @@ -0,0 +1,51 @@ +# Runtime persistent index cleanup plan + +1. Lock the persistent index contract: exact `equal` keys, nil values, + collision buckets, immutable put/delete, count/map/keys, and legacy hash + adapters. +2. Add one fixed-depth radix index module and load it before tree, patch-plan, + incremental, surface, and native commit consumers. +3. Bootstrap node, parent, and region-owner indexes directly into immutable + indexes; store native postorder as node IDs. Bind the generated ID format + to the exact read-only vector; convert unmarked legacy entries with complete + candidate membership validation before retaining that format. +4. Change local candidate preparation to path-copy only removed/touched node and + affected-region keys. Keep all other state tables on their current path. +5. Migrate readers and compatibility entry points to the common adapters. + Preserve hash-table inputs at package boundaries. +6. Keep the complete topology comparison, including untouched surface objects + and source keys. Key-set identity tokens prove only persistent index + membership: value updates retain the token, membership changes replace it, + and unrelated index lineages use the exact key comparison. +7. Verify focused index and candidate regressions, then compile/checkdoc/full + ERT serially after coordinating CPU use with the root agent. + +Stop when all production consumers of the three runtime indexes use adapters, +local candidates perform no whole-table copy/remap, and the focused plus full +validation gates pass. Report remaining full work in region-box/count/source +and other state tables. + +## Compressed radix follow-up + +The compiled fixed-depth version passed behavior checks but regressed measured +row/theme/filter/pagination latency. It is not accepted for integration. + +1. Preserve the measured fixed-depth module, compiled bytecode, tests, and + reviewed patch outside the checkout before editing. +2. Add regressions for compressed branching depth and exact key semantics: + prefix divergence above an existing branch; colliding 64-bit integer codes; + colliding noninteger keys; nil values; deletion collapse and fork isolation; + and deletion of the key that supplied a branch's representative code. +3. Replace mandatory-depth roots with nil, exact collision leaves, and + 16-way branch vectors carrying their actual nibble shift and representative + code. Branches have at least two children and increasing child shifts. + Put returns its membership-change flag without a separate lookup. Delete + replaces unary branches with their surviving child. +4. Keep key-set tokens, hash-table adapters, native postorder representation, + full topology validation, and all publication/rollback paths unchanged. +5. After the CPU owner releases its slot, run focused regressions, compiled + old/new index cost comparisons, then required package checks serially. + Report actual costs to the root agent for full workload measurement. + +Accept this follow-up only after semantic checks pass and measured costs +improve; compressed structure alone does not establish end-to-end latency. diff --git a/Makefile b/Makefile index dd09c00..f832e91 100644 --- a/Makefile +++ b/Makefile @@ -10,7 +10,7 @@ NATIVE_MANIFEST = native/Cargo.toml NATIVE_TARGET ?= $(shell $(EMACS_BATCH) -l ebox-native-reflow.el --eval '(princ (ebox-native-reflow--rust-target))') NATIVE_RELEASE_DIR = native/target/$(NATIVE_TARGET)/release -.PHONY: all check ci load compile test checkdoc source-tests font-tests core-tests child-range-tests grid-tests ebox-commit-tests surface-tests visual-check-tests package-tests selector-tests dsl-tests flex-tests viewport-topology-tests vertical-materialization-tests box-line-flow-tests m0a-tests state-contract-tests layout-boundary-tests layout-boundary-performance patch-plan-tests patch-plan-performance style-schema-tests style-schema-performance spi-tests spi-performance c1b-contract-tests docs-contract-tests ci-contract-tests performance-evaluator visual-check native-rust-tests native-build diff-check clean package-lint package-lint-install +.PHONY: all check ci load compile test checkdoc runtime-index-tests source-tests font-tests core-tests child-range-tests grid-tests ebox-commit-tests surface-tests visual-check-tests package-tests selector-tests dsl-tests flex-tests viewport-topology-tests vertical-materialization-tests box-line-flow-tests m0a-tests state-contract-tests layout-boundary-tests layout-boundary-performance patch-plan-tests patch-plan-performance style-schema-tests style-schema-performance spi-tests spi-performance c1b-contract-tests docs-contract-tests ci-contract-tests performance-evaluator visual-check native-rust-tests native-build diff-check clean package-lint package-lint-install .PHONY: node-memo-generation-tests @@ -27,7 +27,10 @@ compile: rm -f *.elc tests/*.elc scripts/*.elc $(EMACS_BATCH) --eval '(setq byte-compile-error-on-warn t byte-compile-warnings (quote (not obsolete)))' -l ebox.el --eval '(ebox-byte-compile)' -test: source-tests font-tests core-tests child-range-tests grid-tests ebox-commit-tests surface-tests visual-check-tests package-tests selector-tests dsl-tests flex-tests viewport-topology-tests vertical-materialization-tests box-line-flow-tests node-memo-generation-tests m0a-tests state-contract-tests layout-boundary-tests patch-plan-tests style-schema-tests spi-tests docs-contract-tests ci-contract-tests +test: runtime-index-tests source-tests font-tests core-tests child-range-tests grid-tests ebox-commit-tests surface-tests visual-check-tests package-tests selector-tests dsl-tests flex-tests viewport-topology-tests vertical-materialization-tests box-line-flow-tests node-memo-generation-tests m0a-tests state-contract-tests layout-boundary-tests patch-plan-tests style-schema-tests spi-tests docs-contract-tests ci-contract-tests + +runtime-index-tests: + $(EMACS_BATCH) -l tests/ebox-runtime-index-tests.el -f ert-run-tests-batch-and-exit source-tests: $(EMACS_BATCH) -l tests/ebox-source-tests.el -f ert-run-tests-batch-and-exit diff --git a/docs/maintainer/ebox-current-implementation-reference.en.md b/docs/maintainer/ebox-current-implementation-reference.en.md index 9f7b909..7f8b89a 100644 --- a/docs/maintainer/ebox-current-implementation-reference.en.md +++ b/docs/maintainer/ebox-current-implementation-reference.en.md @@ -19,6 +19,7 @@ This is the maintainer entry point for the standalone Ebox repository. It descri | `ebox.el` | Public facade, construction helpers, rendering, TP-backed buffer entry points, scrolling, commit, and byte compilation. | | `ebox-cache.el` | Measurement/render cache records, invalidation, and cache reports. | | `ebox-source.el` | Opaque source handles, immutable author source records, and candidate source indexes. | +| `ebox-runtime-index.el` | Compressed persistent radix maps and legacy hash-table adapters. | | `ebox-state-contract.el` | Closed retained-state ownership inventory and read-only compatibility-mirror rebuild probes. | | `ebox-style.el` | ECSS property schemas, declarations and cascade, shorthand expansion, computed style, colors, borders, and dirty effects. | | `ebox-tree.el` | Node traversal, logical child access, ECSS subject adaptation, identity, parent paths, keys, and tree snapshots. | @@ -42,6 +43,8 @@ The package intentionally does not include application Components, UI controls, The active contract also covers `Makefile`, `.github/workflows/ci.yml`, `tests/ebox-core-render-tests.el`, `tests/ebox-state-contract-tests.el`, `tests/ebox-layout-boundary-tests.el`, `tests/ebox-patch-plan-tests.el`, `tests/ebox-style-schema-tests.el`, `tests/ebox-spi-tests.el`, `tests/ebox-child-range-tests.el`, `tests/ebox-grid-tests.el`, `tests/ebox-commit-tests.el`, `tests/ebox-surface-tests.el`, `tests/ebox-dsl-tests.el`, `tests/ebox-flex-tests.el`, `tests/ebox-selector-tests.el`, `tests/ebox-package-tests.el`, `tests/ebox-visual-check-tests.el`, `tests/ebox-docs-contract-tests.el`, `tests/ebox-ci-contract-tests.el`, `native/Cargo.toml`, `native/Cargo.lock`, `native/build.rs`, `native/vendor/emacs-30/emacs-module.h`, `native/src/lib.rs`, `native/src/layout.rs`, `native/c/ebox_module.c`, `scripts/ebox-package-lint.el`, `scripts/ebox-visual-check.el`, and `scripts/ebox-performance-evaluator.el`. +Runtime index behavior is covered by `tests/ebox-runtime-index-tests.el`. + ## Runtime model The normal data flow is: @@ -137,6 +140,7 @@ make load make compile make check make core-tests +make runtime-index-tests make child-range-tests make grid-tests make ebox-commit-tests diff --git a/docs/maintainer/ebox-current-implementation-reference.zh.md b/docs/maintainer/ebox-current-implementation-reference.zh.md index 5e1a47b..35e89f3 100644 --- a/docs/maintainer/ebox-current-implementation-reference.zh.md +++ b/docs/maintainer/ebox-current-implementation-reference.zh.md @@ -19,6 +19,7 @@ | `ebox.el` | 公共门面、构造辅助函数、渲染、基于 TP 的 buffer 入口、滚动、commit 与 byte compile。 | | `ebox-cache.el` | 测量/渲染缓存记录、失效和缓存报告。 | | `ebox-source.el` | opaque source handle、不可变 author source record 与 candidate source index。 | +| `ebox-runtime-index.el` | 压缩持久 radix map 与旧 hash-table adapter。 | | `ebox-state-contract.el` | 封闭的 retained-state 所有权清单与只读 compatibility-mirror 重建探针。 | | `ebox-style.el` | ECSS property schema、declaration 与 cascade、shorthand 展开、computed style、颜色、border 和 dirty effect。 | | `ebox-tree.el` | 节点遍历、逻辑子节点访问、ECSS subject 适配、identity、父路径、key 和树 snapshot。 | @@ -42,6 +43,8 @@ active 合同还覆盖 `Makefile`、`.github/workflows/ci.yml`、`tests/ebox-core-render-tests.el`、`tests/ebox-state-contract-tests.el`、`tests/ebox-layout-boundary-tests.el`、`tests/ebox-patch-plan-tests.el`、`tests/ebox-style-schema-tests.el`、`tests/ebox-spi-tests.el`、`tests/ebox-child-range-tests.el`、`tests/ebox-grid-tests.el`、`tests/ebox-commit-tests.el`、`tests/ebox-surface-tests.el`、`tests/ebox-dsl-tests.el`、`tests/ebox-flex-tests.el`、`tests/ebox-selector-tests.el`、`tests/ebox-package-tests.el`、`tests/ebox-visual-check-tests.el`、`tests/ebox-docs-contract-tests.el`、`tests/ebox-ci-contract-tests.el`、`native/Cargo.toml`、`native/Cargo.lock`、`native/build.rs`、`native/vendor/emacs-30/emacs-module.h`、`native/src/lib.rs`、`native/src/layout.rs`、`native/c/ebox_module.c`、`scripts/ebox-package-lint.el`、`scripts/ebox-visual-check.el` 和 `scripts/ebox-performance-evaluator.el`。 +Runtime index 行为由 `tests/ebox-runtime-index-tests.el` 覆盖。 + ## 运行时模型 正常数据流是: @@ -130,6 +133,7 @@ active 合同还覆盖 `Makefile`、`.github/workflows/ci.yml`、`tests/ebox-cor ```sh make check make core-tests +make runtime-index-tests make child-range-tests make grid-tests make ebox-commit-tests diff --git a/ebox-incremental.el b/ebox-incremental.el index f31e49b..6ecaf55 100644 --- a/ebox-incremental.el +++ b/ebox-incremental.el @@ -14,6 +14,7 @@ (require 'ebox-buffer-backend) (require 'ebox-render-context) (require 'ebox-patch-plan) +(require 'ebox-runtime-index) (defvar ebox-region-types) (declare-function ebox--scroll-state-region-ids-containing-node-ids @@ -87,6 +88,10 @@ (declare-function ebox-native-commit-plan-eligible-p "ebox-native-commit" (old-state candidate-state prepared)) +(declare-function ebox-native-commit--root-box + "ebox-native-commit" (node)) +(declare-function ebox-native-commit-root-scroll-continuation-p + "ebox-native-commit" (state)) (declare-function ebox-native-commit-prepare-viewport-continuity "ebox-native-commit" (previous-state candidate-state)) @@ -440,8 +445,8 @@ unless root reflow and idle prefetch also avoid crossing several such rows.") (or (plist-get state :selector-index-stale-p) (and (hash-table-p type-counts) (> (or (gethash 'flex type-counts) 0) 0)) - (and (hash-table-p node-table) - (>= (hash-table-count node-table) + (and (ebox-runtime-index-like-p node-table) + (>= (ebox-runtime-index-size node-table) ebox--latency-sensitive-scroll-node-limit))))) (defun ebox--root-reflow-scroll-lookahead-lines-for-buffer (buffer) @@ -1148,10 +1153,10 @@ Keys are region-id, values are plists with :scroll-offset, :content-lines, etc." When COLLECT-REGION-BOXES is non-nil, collect `:region-box-table' during the same traversal in complete-render overwrite order. SOURCE-INDEX, when supplied, is the candidate-owned source snapshot for ROOT-NODE." - (let ((node-table (make-hash-table :test 'equal)) - (parent-table (make-hash-table :test 'equal)) + (let ((node-table (ebox-runtime-index-empty)) + (parent-table (ebox-runtime-index-empty)) (region-id-set (make-hash-table :test 'equal)) - (region-node-table (make-hash-table :test 'equal)) + (region-node-table (ebox-runtime-index-empty)) (region-box-table (and collect-region-boxes (make-hash-table :test 'equal))) (region-box-count-table (make-hash-table :test 'equal)) @@ -1181,17 +1186,21 @@ supplied, is the candidate-owned source snapshot for ROOT-NODE." ;; A flex wrapper is visited after its flex owner. Keep ;; that earlier render-owner mapping instead of ;; replacing it with the non-renderable source box. - (unless (gethash region-id region-node-table) - (puthash region-id node-id region-node-table)))) + (unless (ebox-runtime-index-get region-id region-node-table) + (setq region-node-table + (ebox-runtime-index-put + region-id node-id region-node-table))))) ('flex (when-let* ((box (plist-get node :box))) (let ((region-id (ebox--ensure-region-id box))) (puthash region-id t region-id-set) ;; Preserve the recursive lookup's first-match ;; semantics when malformed trees reuse a region id. - (unless (gethash region-id region-node-table) - (puthash region-id node-id - region-node-table))))))) + (unless (ebox-runtime-index-get region-id region-node-table) + (setq region-node-table + (ebox-runtime-index-put + region-id node-id + region-node-table)))))))) (index-region-box (node) ;; Box rendering records region ownership after rendering ;; child content. Preserve that postorder overwrite order @@ -1211,11 +1220,14 @@ supplied, is the candidate-owned source snapshot for ROOT-NODE." (not (stringp node))) (let ((node-id (ebox--ensure-node-id node)) (type (plist-get node :ebox-type))) - (puthash node-id node node-table) + (setq node-table + (ebox-runtime-index-put node-id node node-table)) (ebox-incremental--adjust-runtime-type-count runtime-type-count-table node 1) (when parent-id - (puthash node-id parent-id parent-table)) + (setq parent-table + (ebox-runtime-index-put + node-id parent-id parent-table))) (index-region-id node node-id) (index-host-ref node node-id) (when-let* ((sequence (plist-get node :ebox-child-sequence))) @@ -1257,12 +1269,12 @@ supplied, is the candidate-owned source snapshot for ROOT-NODE." ;; source boxes are indexed for ownership without adding ;; duplicate compiler work. (when native-layout-p - (push node native-node-postorder)))))) + (push node-id native-node-postorder)))))) (visit root-node nil t)) (setq source-index (or source-index (ebox-tree-source-index root-node))) (setq source-index (ebox-source--with-runtime-hosts source-index host-ref-table)) - (let () + (let ((postorder (vconcat (nreverse native-node-postorder)))) (list :node-table node-table :parent-table parent-table :region-id-set region-id-set @@ -1278,8 +1290,8 @@ supplied, is the candidate-owned source snapshot for ROOT-NODE." :selector-type-table (ebox-source--index-selector-table source-index 'type) :runtime-type-count-table runtime-type-count-table - :native-node-postorder - (vconcat (nreverse native-node-postorder)))))) + :native-node-postorder postorder + :native-node-postorder-ids postorder)))) (defun ebox-incremental--ensure-buffer-selector-indexes (buffer) "Return BUFFER's state after materializing deferred selector indexes." @@ -1337,7 +1349,7 @@ supplied, is the candidate-owned source snapshot for ROOT-NODE." :range-ref-table :source-index :selector-id-table :selector-class-table :selector-type-table :runtime-type-count-table - :native-node-postorder)) + :native-node-postorder :native-node-postorder-ids)) (setq state (plist-put state key (plist-get index key)))) state) @@ -1386,7 +1398,7 @@ supplied, is the candidate-owned source snapshot for ROOT-NODE." (defun ebox-incremental--range-ref-resolve (state ref) "Resolve REF in STATE to its parent, sequence, segment index, and rank." (when-let* ((record (gethash ref (plist-get state :range-ref-table))) - (parent (gethash (plist-get record :parent-node-id) + (parent (ebox-runtime-index-get (plist-get record :parent-node-id) (plist-get state :node-table))) (sequence (plist-get parent :ebox-child-sequence))) (list :parent parent :sequence sequence @@ -1399,7 +1411,7 @@ supplied, is the candidate-owned source snapshot for ROOT-NODE." Use the persistent runtime index when available. Legacy runtime states that lack the index, or an indexed entry, fall back to a recursive tree lookup." (or (when-let* ((table (ebox--buffer-region-node-table buffer))) - (gethash region-id table)) + (ebox-runtime-index-get region-id table)) (when-let* ((root (ebox--buffer-root-node buffer)) (path (ebox--node-path-to-region root region-id))) (ebox--ensure-node-id (car path))))) @@ -2187,7 +2199,7 @@ the planning pass has one for this buffer." (or (when-let* ((base ebox-incremental--candidate-base-state) ((eq buffer (car base))) (node-table (plist-get (cdr base) :node-table))) - (gethash node-id node-table)) + (ebox-runtime-index-get node-id node-table)) (ebox--buffer-runtime-node buffer node-id))) (defun ebox--ensure-layout-snapshot-spans (buffer node-id) @@ -2328,17 +2340,17 @@ When DETAILS is non-nil, include expensive per-line detail fields." (or (and ebox-incremental--candidate-proof-node-table (eq buffer (car ebox-incremental--candidate-proof-node-table)) - (gethash node-id + (ebox-runtime-index-get node-id (cdr ebox-incremental--candidate-proof-node-table))) (when-let* ((node-table (ebox--buffer-node-table buffer))) - (gethash node-id node-table)) + (ebox-runtime-index-get node-id node-table)) (when-let* ((root (ebox--buffer-root-node buffer))) (ebox--runtime-node-by-id root node-id)))) (defun ebox--runtime-parent-id (buffer node-id) "Return NODE-ID's parent id in BUFFER runtime state." (when-let* ((parent-table (ebox--buffer-parent-table buffer))) - (gethash node-id parent-table))) + (ebox-runtime-index-get node-id parent-table))) (defun ebox--invalidate-runtime-render-signature-path (node-id node-table parent-table signature-cache height-cache) @@ -2348,10 +2360,10 @@ When DETAILS is non-nil, include expensive per-line detail fields." (when signature-cache (clrhash signature-cache)) (when height-cache (clrhash height-cache))) (while node-id - (when-let* ((node (gethash node-id node-table))) + (when-let* ((node (ebox-runtime-index-get node-id node-table))) (when signature-cache (remhash node signature-cache)) (when height-cache (remhash node height-cache))) - (setq node-id (gethash node-id parent-table))))) + (setq node-id (ebox-runtime-index-get node-id parent-table))))) (defun ebox--runtime-ancestor-id-p (buffer ancestor-id node-id) "Return non-nil when ANCESTOR-ID is NODE-ID's ancestor in BUFFER." @@ -4184,7 +4196,7 @@ during `ebox-incremental-flush'." (ebox-incremental--candidate-range-items-retainable-p candidate next-items ebox-incremental--source-base-index reuse-map))) - (parent (gethash (plist-get record :parent-node-id) + (parent (ebox-runtime-index-get (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))) @@ -4285,12 +4297,12 @@ during `ebox-incremental-flush'." "Return non-nil when ANCESTOR-ID contains DESCENDANT-ID in CANDIDATE's base." (let* ((state (ebox-candidate--base-state candidate)) (parent-table (plist-get state :parent-table)) - (current (gethash descendant-id parent-table)) + (current (ebox-runtime-index-get descendant-id parent-table)) found) (while (and current (not found)) (if (equal current ancestor-id) (setq found t) - (setq current (gethash current parent-table)))) + (setq current (ebox-runtime-index-get current parent-table)))) found)) (defun ebox-incremental-candidate-replace @@ -4311,7 +4323,7 @@ detached semantic variants of this stable anchor for bounded identity reuse." (let* ((state (ebox-candidate--base-state candidate)) (node-table (plist-get state :node-table)) retained) - (unless (gethash node-id node-table) + (unless (ebox-runtime-index-get node-id node-table) (error "Ebox candidate anchor does not exist: %S" node-id)) (unless (and (listp next-subtree) (not (stringp next-subtree))) (error "Ebox candidate replacement must be an Ebox node")) @@ -4428,7 +4440,7 @@ the patched Host's ancestor chain." (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))) + (let* ((base-node (ebox-runtime-index-get node-id (plist-get state :node-table))) (previous-node (ebox-incremental--find-host-ref-node previous-subtree host-ref)) @@ -4565,9 +4577,9 @@ logical tree crosses that boundary." "Return ROOT path-copied with NODE-ID replaced by REPLACEMENT." (let* ((node-table (plist-get index :node-table)) (parent-table (plist-get index :parent-table)) - (old-child (gethash node-id node-table)) + (old-child (ebox-runtime-index-get node-id node-table)) (new-child replacement) - (parent-id (gethash node-id parent-table))) + (parent-id (ebox-runtime-index-get node-id parent-table))) (unless old-child (error "Ebox candidate anchor is absent after an ancestor replacement: %S" node-id)) @@ -4577,12 +4589,12 @@ logical tree crosses that boundary." (plist-get replacement :node-id)))) (while parent-id (let* ((current-parent-id parent-id) - (old-parent (gethash current-parent-id node-table)) + (old-parent (ebox-runtime-index-get current-parent-id node-table)) (new-parent (ebox-tree-copy-with-direct-child-replacements old-parent (list (cons old-child new-child)))) (grandparent-id - (gethash current-parent-id parent-table))) + (ebox-runtime-index-get current-parent-id parent-table))) (ebox-incremental--candidate-record-path-copy current-parent-id new-parent grandparent-id nil (not (equal (plist-get old-child :node-id) @@ -4607,16 +4619,16 @@ copies the owner's descendants before mutation while retaining their ids." (node-table (plist-get state :node-table)) (parent-table (plist-get state :parent-table)) (region-node-table (plist-get state :region-node-table)) - (owner-id (and (hash-table-p region-node-table) - (gethash region-id region-node-table))) - (owner (and (hash-table-p node-table) - (gethash owner-id node-table))) + (owner-id (and (ebox-runtime-index-like-p region-node-table) + (ebox-runtime-index-get region-id region-node-table))) + (owner (and (ebox-runtime-index-like-p node-table) + (ebox-runtime-index-get owner-id node-table))) (box (pcase (and owner (plist-get owner :ebox-type)) ('box owner) ('flex (plist-get owner :box))))) (when (and root owner box - (hash-table-p parent-table) + (ebox-runtime-index-like-p parent-table) (equal (plist-get box :region-id) region-id)) (let* ((candidate-box (if isolate-owner-subtree-p @@ -4647,14 +4659,14 @@ shared with the published generation." affected-ids) (maphash (lambda (node-id _replacement) - (unless (gethash node-id node-table) + (unless (ebox-runtime-index-get node-id node-table) (error "Ebox candidate path-copy node is absent: %S" node-id)) (let ((current node-id)) (while current (unless (gethash current affected) (puthash current t affected) (push current affected-ids)) - (setq current (gethash current parent-table))))) + (setq current (ebox-runtime-index-get current parent-table))))) replacements) (let ((depths (make-hash-table :test 'equal))) (cl-labels @@ -4664,7 +4676,7 @@ shared with the published generation." (let ((current node-id) path (result -1)) (while (and current (not (gethash current depths))) (push current path) - (setq current (gethash current parent-table))) + (setq current (ebox-runtime-index-get current parent-table))) (when current (setq result (gethash current depths))) (dolist (path-id path) @@ -4676,7 +4688,7 @@ shared with the published generation." (> (depth left) (depth right))))))) (let (result) (dolist (node-id affected-ids) - (let* ((old-node (gethash node-id node-table)) + (let* ((old-node (ebox-runtime-index-get node-id node-table)) (local (or (gethash node-id replacements) old-node)) (child-replacements (gethash node-id children)) (effective-child-replacements @@ -4704,7 +4716,7 @@ shared with the published generation." (ebox-tree-copy-with-direct-child-replacements local effective-child-replacements) local)) - (parent-id (gethash node-id parent-table))) + (parent-id (ebox-runtime-index-get node-id parent-table))) (ebox-incremental--candidate-record-path-copy node-id candidate parent-id (gethash node-id replacements) nil) (if parent-id @@ -4729,15 +4741,15 @@ be resolved through the retained runtime indexes." (region-node-table (plist-get state :region-node-table)) (replacements (make-hash-table :test 'equal)) valid) - (setq valid (and root (hash-table-p node-table) - (hash-table-p parent-table) - (hash-table-p region-node-table))) + (setq valid (and root (ebox-runtime-index-like-p node-table) + (ebox-runtime-index-like-p parent-table) + (ebox-runtime-index-like-p region-node-table))) (dolist (entry region-offsets) (when valid (let* ((region-id (car entry)) (offset (cdr entry)) - (owner-id (gethash region-id region-node-table)) - (owner (and owner-id (gethash owner-id node-table))) + (owner-id (ebox-runtime-index-get region-id region-node-table)) + (owner (and owner-id (ebox-runtime-index-get owner-id node-table))) (box (pcase (and owner (plist-get owner :ebox-type)) ('box owner) @@ -4772,8 +4784,8 @@ be resolved through the retained runtime indexes." affected-ids) (maphash (lambda (node-id replacement) - (let ((old-node (gethash node-id node-table)) - (parent-id (gethash node-id parent-table))) + (let ((old-node (ebox-runtime-index-get node-id node-table)) + (parent-id (ebox-runtime-index-get node-id parent-table))) (unless old-node (error "Ebox candidate path-copy node is absent: %S" node-id)) (ebox-incremental--candidate-record-path-copy @@ -4790,7 +4802,7 @@ be resolved through the retained runtime indexes." (unless (gethash parent-id affected-table) (puthash parent-id t affected-table) (push parent-id affected-ids)) - (setq parent-id (gethash parent-id parent-table))))) + (setq parent-id (ebox-runtime-index-get parent-id parent-table))))) replacements) (let ((depth-table (make-hash-table :test 'equal))) (cl-labels @@ -4803,7 +4815,7 @@ be resolved through the retained runtime indexes." (while (and current (not (gethash current depth-table))) (push current path) - (setq current (gethash current parent-table))) + (setq current (ebox-runtime-index-get current parent-table))) (when current (setq result (gethash current depth-table))) (dolist (path-id path) @@ -4816,11 +4828,11 @@ be resolved through the retained runtime indexes." (let ((result (gethash (plist-get root :node-id) replacements))) (dolist (parent-id affected-ids) (let* ((child-replacements (gethash parent-id direct)) - (old-parent (gethash parent-id node-table)) + (old-parent (ebox-runtime-index-get parent-id node-table)) (copy (ebox-tree-copy-with-direct-child-replacements old-parent child-replacements)) - (grandparent-id (gethash parent-id parent-table))) + (grandparent-id (ebox-runtime-index-get parent-id parent-table))) (ebox-incremental--candidate-record-path-copy parent-id copy grandparent-id nil (cl-some @@ -4846,12 +4858,12 @@ building an index for an intermediate path-copied root." (cl-labels ((ancestor-p (ancestor-id descendant-id) - (let ((current (gethash descendant-id parent-table)) + (let ((current (ebox-runtime-index-get descendant-id parent-table)) found) (while (and current (not found)) (if (equal current ancestor-id) (setq found t) - (setq current (gethash current parent-table)))) + (setq current (ebox-runtime-index-get current parent-table)))) found))) (cl-loop for tail on replacements @@ -4900,12 +4912,12 @@ Cost is proportional to the replacements, never to the page." (let ((walk descendant) found) (while (and walk (not found)) (setq found (equal walk ancestor) - walk (and (not found) (gethash walk parent-table)))) + walk (and (not found) (ebox-runtime-index-get walk parent-table)))) found)) (inside-replaced-anchor-p (node-id) (let ((walk node-id)) (while (and walk (not (member walk anchor-ids))) - (setq walk (gethash walk parent-table))) + (setq walk (ebox-runtime-index-get walk parent-table))) walk)) (collect-host-refs (node acc) (if (or (not (listp node)) (stringp node)) @@ -4929,8 +4941,8 @@ Cost is proportional to the replacements, never to the page." (replacement-key (ebox-tree-node-author-key replacement-source-index replacement)) - (parent-id (gethash node-id parent-table)) - (parent (and parent-id (gethash parent-id node-table)))) + (parent-id (ebox-runtime-index-get node-id parent-table)) + (parent (and parent-id (ebox-runtime-index-get parent-id node-table)))) ;; Sibling keys stay unique under the anchor's parent. (when (and replacement-key parent) (dolist (sibling (ebox-tree--children-raw parent)) @@ -5033,7 +5045,7 @@ CURRENT-SNAPSHOT identifies the subtree identities being replaced." conflict) (maphash (lambda (node-id _present) - (when (and (gethash node-id node-table) + (when (and (ebox-runtime-index-get node-id node-table) (not (gethash node-id current-node-ids))) (setq conflict t))) (plist-get historical-snapshot :node-id-set)) @@ -5118,7 +5130,7 @@ DETACHED-HISTORY receives identity snapshots for semantic replacements." (let* ((node-id (ebox-incremental--candidate-replacement-anchor-id entry)) (old-anchor - (gethash node-id (plist-get base-index :node-table)))) + (ebox-runtime-index-get node-id (plist-get base-index :node-table)))) (unless old-anchor (error "Ebox candidate anchor is absent: %S" node-id)) ;; Replacements were validated when they were recorded @@ -5147,7 +5159,7 @@ DETACHED-HISTORY receives identity snapshots for semantic replacements." (let* ((node-id (ebox-incremental--candidate-replacement-anchor-id entry)) (old-anchor - (gethash node-id (plist-get base-index :node-table)))) + (ebox-runtime-index-get node-id (plist-get base-index :node-table)))) (unless old-anchor (error "Ebox candidate anchor is absent: %S" @@ -5251,7 +5263,7 @@ but must re-enter the candidate indexes as a new object." "Return current candidate NODE-ID from path trace or base STATE." (or (plist-get (gethash node-id ebox-incremental--candidate-path-copy-trace) :node) - (gethash node-id (plist-get state :node-table)))) + (ebox-runtime-index-get node-id (plist-get state :node-table)))) (defun ebox-incremental--candidate-range-ref-overlay (old-state path-trace) "Return Range ref table updated only for node anchors in PATH-TRACE." @@ -5268,10 +5280,10 @@ but must re-enter the candidate indexes as a new object." (setq anchors (cl-delete-if (lambda (anchor) - (let ((walk (gethash (car anchor) parent-table)) found) + (let ((walk (ebox-runtime-index-get (car anchor) parent-table)) found) (while (and walk (not found)) (setq found (gethash walk anchor-set) - walk (and (not found) (gethash walk parent-table)))) + walk (and (not found) (ebox-runtime-index-get walk parent-table)))) found)) anchors))) (if (null anchors) @@ -5285,7 +5297,7 @@ but must re-enter the candidate indexes as a new object." (dolist (child (ebox-tree--children-raw node)) (walk child function)))) (dolist (anchor anchors) - (when-let* ((old (gethash (car anchor) + (when-let* ((old (ebox-runtime-index-get (car anchor) (plist-get old-state :node-table)))) (walk old (lambda (_parent record) @@ -5309,7 +5321,7 @@ but must re-enter the candidate indexes as a new object." (let* ((parent-table (plist-get state :parent-table)) (old-child (ebox-incremental--candidate-current-node state node-id)) (new-child replacement) - (parent-id (gethash node-id parent-table))) + (parent-id (ebox-runtime-index-get node-id parent-table))) (ebox-incremental--candidate-record-path-copy node-id replacement parent-id t structure-p) (while parent-id @@ -5348,7 +5360,7 @@ but must re-enter the candidate indexes as a new object." copy) (ebox-tree-copy-with-direct-child-replacements old-parent (list (cons old-child new-child))))) - (grandparent-id (gethash current-parent-id parent-table))) + (grandparent-id (ebox-runtime-index-get current-parent-id parent-table))) (ebox-incremental--candidate-record-path-copy current-parent-id new-parent grandparent-id nil (not (equal (plist-get old-child :node-id) @@ -5391,7 +5403,7 @@ but must re-enter the candidate indexes as a new object." (dolist (parent-id (nreverse order)) (let* ((parent (ebox-incremental--candidate-current-node state parent-id)) - (base-parent (gethash parent-id (plist-get state :node-table))) + (base-parent (ebox-runtime-index-get parent-id (plist-get state :node-table))) (base-sequence (and base-parent (plist-get base-parent :ebox-child-sequence))) @@ -5561,10 +5573,10 @@ but must re-enter the candidate indexes as a new object." "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) + (let ((parent-id (ebox-runtime-index-get 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)))) + parent-id (and (not found) (ebox-runtime-index-get parent-id parents)))) (unless found (push node-id result)))))) (defun ebox-incremental--candidate-apply-paint-patches (candidate root) @@ -5587,7 +5599,7 @@ style consumers; the Style Adapter computes their values exactly once later." (let ((current node-id)) (while current (puthash current t patch-paths) - (setq current (gethash current parents))))) + (setq current (ebox-runtime-index-get current parents))))) (cl-labels ((rewrite (node inherited-input-changed-p) @@ -5691,7 +5703,7 @@ style consumers; the Style Adapter computes their values exactly once later." (gethash (ebox-incremental--candidate-range-replacement-ref range) (plist-get (ebox-candidate--base-state candidate) :range-ref-table))) - (parent (gethash (plist-get record :parent-node-id) + (parent (ebox-runtime-index-get (plist-get record :parent-node-id) (plist-get (ebox-candidate--base-state candidate) :node-table))) (segment (and parent @@ -5762,10 +5774,11 @@ Return the same pure surface-input shape as buffer old-state next-root)))) (defun ebox-incremental--hash-keys (table) - "Return the keys currently present in hash TABLE." + "Return the keys currently present in supported TABLE." (let (keys) - (when (hash-table-p table) - (maphash (lambda (key _value) (push key keys)) table)) + (when (ebox-runtime-index-like-p table) + (ebox-runtime-index-map + (lambda (key _value) (push key keys)) table)) (nreverse keys))) (defun ebox-incremental--hash-snapshot (table keys) @@ -5812,7 +5825,7 @@ Return the same pure surface-input shape as (candidate-node-table (plist-get candidate-index :node-table)) (snapshots (make-hash-table :test 'equal))) (when (hash-table-p old-snapshots) - (maphash + (ebox-runtime-index-map (lambda (node-id _node) (when-let* ((snapshot (gethash node-id old-snapshots))) (puthash node-id (copy-sequence snapshot) snapshots))) @@ -6009,14 +6022,17 @@ Return the same pure surface-input shape as (plist-get state :runtime-type-count-table) :selector-index-stale-p (plist-get state :selector-index-stale-p) - :native-node-postorder (plist-get state :native-node-postorder))) + :native-node-postorder (plist-get state :native-node-postorder) + :native-node-postorder-ids + (plist-get state :native-node-postorder-ids))) (defun ebox-incremental--candidate-copy-index-table (state key test) - "Return a materialized copy of STATE's hash table under KEY." + "Return STATE's persistent index under KEY or a fresh legacy table." (let ((table (plist-get state key))) - (if (hash-table-p table) - (copy-hash-table table) - (make-hash-table :test test)))) + (cond + ((ebox-runtime-index-p table) table) + ((hash-table-p table) (copy-hash-table table)) + (t (make-hash-table :test test))))) (defun ebox-incremental--candidate-node-region-record (node) "Return NODE's `(REGION-ID OWNER-ID BOX)' record, or nil." @@ -6064,7 +6080,8 @@ complete preorder first-owner and postorder last-box semantics." (maphash (lambda (region-id _present) (remhash region-id region-id-set) - (remhash region-id region-node-table) + (setq region-node-table + (ebox-runtime-index-delete region-id region-node-table)) (remhash region-id region-box-table)) region-ids) (cl-labels @@ -6075,8 +6092,10 @@ complete preorder first-owner and postorder last-box semantics." (pcase-let ((`(,region-id ,owner-id ,_box) record)) (when (gethash region-id region-ids) (puthash region-id t region-id-set) - (unless (gethash region-id region-node-table) - (puthash region-id owner-id region-node-table))))) + (unless (ebox-runtime-index-get region-id region-node-table) + (setq region-node-table + (ebox-runtime-index-put + region-id owner-id region-node-table)))))) (dolist (child (ebox--node-children node)) (visit child)) (when-let* ((record @@ -6084,7 +6103,8 @@ complete preorder first-owner and postorder last-box semantics." (pcase-let ((`(,region-id ,_owner-id ,box) record)) (when (gethash region-id region-ids) (puthash region-id box region-box-table))))))) - (visit root))) + (visit root)) + (list region-id-set region-node-table region-box-table)) (defun ebox-incremental--candidate-native-postorder (root) "Return ROOT's native compiler postorder without building other indexes." @@ -6092,7 +6112,8 @@ complete preorder first-owner and postorder last-box semantics." (cl-labels ((visit (node native-layout-p) (when (and (listp node) (not (stringp node))) - (let ((type (plist-get node :ebox-type))) + (let ((type (plist-get node :ebox-type)) + (node-id (plist-get node :node-id))) (ebox-tree-for-each-direct-child node (lambda (child) @@ -6102,23 +6123,33 @@ complete preorder first-owner and postorder last-box semantics." (eq child (plist-get node :box)))))))) (when native-layout-p - (push node postorder)))))) + (push node-id postorder)))))) (visit root t)) (vconcat (nreverse postorder)))) -(defun ebox-incremental--candidate-map-native-postorder +(defun ebox-incremental--candidate-retain-native-postorder (old-state candidate-node-table) - "Re-key OLD-STATE's structurally stable native postorder to candidate nodes." + "Return OLD-STATE's stable node-id postorder for CANDIDATE-NODE-TABLE. +The `:native-node-postorder-ids' slot binds the exact read-only vector built +from indexed node IDs. Reuse that vector without enumeration. Unmarked +vectors may contain legacy node plists or IDs; resolve every entry against +the candidate index before converting them. This format marker supplies no +topology proof, and neither producers nor consumers may mutate its vector." (let ((old-postorder (plist-get old-state :native-node-postorder))) - (if (vectorp old-postorder) + (when (vectorp old-postorder) + (if (eq old-postorder (plist-get old-state :native-node-postorder-ids)) + old-postorder (vconcat (mapcar - (lambda (old-node) - (or (gethash (plist-get old-node :node-id) - candidate-node-table) - (error "Ebox candidate lost a structurally retained native node"))) - (append old-postorder nil))) - nil))) + (lambda (entry) + (let ((node-id (if (and (listp entry) + (plist-member entry :node-id)) + (plist-get entry :node-id) + entry))) + (unless (ebox-runtime-index-get node-id candidate-node-table) + (error "Ebox candidate lost a structurally retained native node")) + node-id)) + old-postorder)))))) (defun ebox-incremental--candidate-range-created-node-ids (range-delta-table) "Return new node ids covered by structural RANGE-DELTA-TABLE entries." @@ -6236,7 +6267,7 @@ complete preorder first-owner and postorder last-box semantics." (defun ebox-incremental--candidate-full-index-p (old-state touched) "Return non-nil when TOUCHED justifies rebuilding OLD-STATE's index." (let ((touched-count (length touched)) - (old-count (hash-table-count (plist-get old-state :node-table)))) + (old-count (ebox-runtime-index-size (plist-get old-state :node-table)))) (and (>= touched-count ebox-incremental--candidate-full-index-min-touched) (>= (* 2 touched-count) old-count)))) @@ -6286,9 +6317,9 @@ complete preorder first-owner and postorder last-box semantics." Traversal stops at every node that is `eq' to OLD-STATE's indexed node. The work is therefore bounded by copied ancestor paths plus added, removed, and -replacement subtrees. Core tables are materialized in this stage so existing -scalar `gethash' consumers remain unchanged; publication journaling can later -replace those O(n) table copies without changing this delta contract." +replacement subtrees. Node, parent, and region-owner changes path-copy their +immutable radix indexes; unrelated runtime side tables retain their existing +candidate isolation contracts." (let ((old-root (plist-get old-state :root-node))) (if (eq candidate-root old-root) (list :index (ebox-incremental--candidate-base-index old-state) @@ -6317,7 +6348,7 @@ replace those O(n) table copies without changing this delta contract." touched))) (visit-new (node parent-id) (let* ((node-id (ebox--ensure-node-id node)) - (old-node (gethash node-id old-node-table missing))) + (old-node (ebox-runtime-index-get node-id old-node-table missing))) (unless (or (gethash node-id visited-new) (eq node old-node)) (puthash node-id t visited-new) @@ -6347,7 +6378,7 @@ replace those O(n) table copies without changing this delta contract." (parent-id (plist-get delta :parent-id))) (puthash parent-id t visited-new) (record-touched old-parent new-parent - (gethash parent-id + (ebox-runtime-index-get parent-id (plist-get old-state :parent-table)) (plist-get delta :children-changed)) (dolist (payload (plist-get delta :old-payloads)) @@ -6362,7 +6393,7 @@ replace those O(n) table copies without changing this delta contract." (maphash (lambda (base-node-id trace) (when (plist-get trace :anchor-p) - (let* ((old-node (gethash base-node-id old-node-table)) + (let* ((old-node (ebox-runtime-index-get base-node-id old-node-table)) (new-node (plist-get trace :node)) (parent-id (plist-get trace :parent-id))) (when (and old-node @@ -6381,7 +6412,7 @@ replace those O(n) table copies without changing this delta contract." (unless (gethash node-id visited-new) (puthash node-id t visited-new) (record-touched - (gethash base-node-id old-node-table) + (ebox-runtime-index-get base-node-id old-node-table) new-node (plist-get trace :parent-id) (plist-get trace :structure-p)))))) @@ -6418,8 +6449,8 @@ replace those O(n) table copies without changing this delta contract." ((parent-id (node-id) (if (gethash node-id visited-new) - (gethash node-id touched-parent-table) - (gethash node-id old-parent-table))) + (ebox-runtime-index-get node-id touched-parent-table) + (ebox-runtime-index-get node-id old-parent-table))) (depth (node-id) (or (gethash node-id depth-table) @@ -6475,8 +6506,10 @@ replace those O(n) table copies without changing this delta contract." (let ((node-id (plist-get node :node-id))) (ebox-incremental--adjust-runtime-type-count runtime-type-count-table node -1) - (remhash node-id node-table) - (remhash node-id parent-table) + (setq node-table + (ebox-runtime-index-delete node-id node-table)) + (setq parent-table + (ebox-runtime-index-delete node-id parent-table)) (when-let* ((host-ref (ebox-tree-node-source-identity node))) (when (equal (gethash host-ref host-ref-table) node-id) @@ -6531,13 +6564,17 @@ replace those O(n) table copies without changing this delta contract." (when (hash-table-p old-region-box-count-table) (ebox-incremental--candidate-adjust-region-box-count region-box-count-table old-node -1))) - (puthash node-id new-node node-table) + (setq node-table + (ebox-runtime-index-put node-id new-node node-table)) (when (or (null old-node) (not (eq old-type new-type))) (ebox-incremental--adjust-runtime-type-count runtime-type-count-table new-node 1)) (if parent-id - (puthash node-id parent-id parent-table) - (remhash node-id parent-table)) + (setq parent-table + (ebox-runtime-index-put + node-id parent-id parent-table)) + (setq parent-table + (ebox-runtime-index-delete node-id parent-table))) ;; Replacement subtrees were validated before recording and ;; checked against untouched Host refs before path copying. (when-let* ((new-host-ref @@ -6568,7 +6605,9 @@ replace those O(n) table copies without changing this delta contract." (maphash (lambda (region-id _present) (remhash region-id region-id-set) - (remhash region-id region-node-table) + (setq region-node-table + (ebox-runtime-index-delete + region-id region-node-table)) (remhash region-id region-box-table)) affected-region-ids) ;; TOUCHED is preorder, matching `ebox--runtime-index' ownership: @@ -6579,13 +6618,21 @@ replace those O(n) table copies without changing this delta contract." (nth 1 entry)))) (pcase-let ((`(,region-id ,owner-id ,box) record)) (puthash region-id t region-id-set) - (unless (gethash region-id region-node-table) - (puthash region-id owner-id region-node-table)) + (unless (ebox-runtime-index-get region-id region-node-table) + (setq region-node-table + (ebox-runtime-index-put + region-id owner-id region-node-table))) (puthash region-id box region-box-table)))) (when (> (hash-table-count ambiguous-region-ids) 0) - (ebox-incremental--candidate-recompute-regions - candidate-root ambiguous-region-ids - region-id-set region-node-table region-box-table)) + (pcase-let + ((`(,new-region-id-set ,new-region-node-table + ,new-region-box-table) + (ebox-incremental--candidate-recompute-regions + candidate-root ambiguous-region-ids + region-id-set region-node-table region-box-table))) + (setq region-id-set new-region-id-set + region-node-table new-region-node-table + region-box-table new-region-box-table))) (let* ((range-delta-table (make-hash-table :test #'equal)) (_range-deltas (dolist (delta range-index-deltas) @@ -6613,7 +6660,7 @@ replace those O(n) table copies without changing this delta contract." (if (or range-index-deltas structure-p) (ebox-incremental--candidate-native-postorder candidate-root) - (ebox-incremental--candidate-map-native-postorder + (ebox-incremental--candidate-retain-native-postorder old-state node-table))) (index (list :node-table node-table @@ -6637,7 +6684,8 @@ replace those O(n) table copies without changing this delta contract." candidate-source-index 'type) :runtime-type-count-table runtime-type-count-table :selector-index-stale-p t - :native-node-postorder native-node-postorder))) + :native-node-postorder native-node-postorder + :native-node-postorder-ids native-node-postorder))) (list :index index :dirty-set dirty-set :participation-node-ids @@ -6651,6 +6699,8 @@ replace those O(n) table copies without changing this delta contract." (mapcar (lambda (node) (plist-get node :node-id)) removed) + :touched-region-ids + (ebox-incremental--hash-keys affected-region-ids) :touched-count (length touched) :removed-count (length removed)))))))))) @@ -6661,7 +6711,7 @@ The search stops at the first shared node, so a copy-on-write candidate is recognized from its first untouched sibling without walking the whole tree." (let ((old-root (plist-get old-state :root-node)) (old-node-table (plist-get old-state :node-table))) - (and (hash-table-p old-node-table) + (and (ebox-runtime-index-like-p old-node-table) (catch 'shared (cl-labels ((visit (node) @@ -6669,7 +6719,7 @@ recognized from its first untouched sibling without walking the whole tree." (let ((node-id (plist-get node :node-id))) (when (and node-id (not (eq node old-root)) - (eq node (gethash node-id old-node-table))) + (eq node (ebox-runtime-index-get node-id old-node-table))) (throw 'shared t))) (dolist (child (ebox-tree--children-raw node)) (visit child))))) @@ -6795,9 +6845,10 @@ CANDIDATE-INDEX is the prepared runtime index for CANDIDATE-ROOT." :new-region-ids (ebox--node-all-region-ids candidate-root) :old old-root :new candidate-root) dirty) - (maphash - (lambda (node-id new-node) - (when-let* ((old-node (gethash node-id old-table))) + (dolist (node-id (ebox--runtime-node-ids candidate-root)) + (let ((new-node (ebox-runtime-index-get node-id new-table))) + (when-let* ((old-node + (ebox-runtime-index-get node-id old-table))) (let* ((changed-keys (ebox-incremental--candidate-local-changed-keys old-node (plist-get old-state :source-index) @@ -6832,8 +6883,7 @@ CANDIDATE-INDEX is the prepared runtime index for CANDIDATE-ROOT." (ebox-tree-node-local-source-signature new-node)) (ebox-incremental--child-change-props old-node new-node children-changed)) - dirty))))) - new-table)) + dirty))))))) (nreverse dirty))) (defun ebox-incremental--isolate-candidate-caches (state) @@ -6933,7 +6983,9 @@ candidate therefore owns its cache tables until TP commits the generation." (:selector-index-stale-p ,(plist-get candidate-index :selector-index-stale-p)) (:native-node-postorder - ,(plist-get candidate-index :native-node-postorder)))) + ,(plist-get candidate-index :native-node-postorder)) + (:native-node-postorder-ids + ,(plist-get candidate-index :native-node-postorder-ids)))) (setq state (plist-put state (car entry) (cadr entry)))) (ebox-incremental--isolate-candidate-caches state))) @@ -6944,11 +6996,11 @@ candidate therefore owns its cache tables until TP commits the generation." (let ((candidate-cache (make-hash-table :test 'eq)) (missing (make-symbol "ebox-candidate-cache-missing"))) (when (and (hash-table-p old-cache) - (hash-table-p old-node-table) - (hash-table-p candidate-node-table)) - (maphash + (ebox-runtime-index-like-p old-node-table) + (ebox-runtime-index-like-p candidate-node-table)) + (ebox-runtime-index-map (lambda (node-id candidate-node) - (when-let* ((old-node (gethash node-id old-node-table))) + (when-let* ((old-node (ebox-runtime-index-get node-id old-node-table))) (let ((cached (gethash old-node old-cache missing))) (unless (eq cached missing) (puthash candidate-node cached candidate-cache))))) @@ -6972,7 +7024,7 @@ must be recomputed in the next publication." old-node-table old-parent-table candidate-cache nil)) (dolist (node-id (append touched-node-ids removed-node-ids)) - (when-let* ((old-node (gethash node-id old-node-table))) + (when-let* ((old-node (ebox-runtime-index-get node-id old-node-table))) (remhash old-node candidate-cache))) candidate-cache)) @@ -7026,8 +7078,8 @@ derive render changes from the computed fragment style signatures." (mapcar (lambda (entry) (let* ((node-id (plist-get entry :node-id)) - (old-node (and node-id (gethash node-id old-nodes))) - (new-node (and node-id (gethash node-id new-nodes)))) + (old-node (and node-id (ebox-runtime-index-get node-id old-nodes))) + (new-node (and node-id (ebox-runtime-index-get node-id new-nodes)))) (if (or (eq (plist-get entry :dirty-kind) 'structure) (not old-node) (not new-node) @@ -7156,7 +7208,7 @@ certificate." (with-current-buffer buffer (dolist (entry dirty-set) (when (memq (plist-get entry :dirty-kind) '(geometry structure)) - (when (gethash (plist-get entry :node-id) + (when (ebox-runtime-index-get (plist-get entry :node-id) (plist-get old-state :node-table)) (ebox--ensure-layout-snapshot-spans buffer (plist-get entry :node-id))))) @@ -7454,7 +7506,7 @@ the expensive parent-context mount and role scan first." (node-id candidate-id) eligible) (while (and node-id (not (equal node-id root-id)) (not eligible)) - (let* ((node (gethash node-id (plist-get old-state :node-table))) + (let* ((node (ebox-runtime-index-get node-id (plist-get old-state :node-table))) (formatting-p (and node (ebox-incremental--formatting-context-reflow-node-p node))) @@ -7475,7 +7527,7 @@ the expensive parent-context mount and role scan first." (ebox--line-spans-cover-whole-lines-p spans))) (setq eligible t)) (unless eligible - (setq node-id (gethash node-id parents))))) + (setq node-id (ebox-runtime-index-get node-id parents))))) ) eligible)) @@ -7521,11 +7573,11 @@ line count; its outside siblings are shifted by the one scoped replacement." dirty-set)) (while (and candidate-id (not proof) (not (equal candidate-id root-id))) - (let* ((old-node (gethash candidate-id + (let* ((old-node (ebox-runtime-index-get candidate-id (plist-get old-state :node-table))) - (new-node (gethash candidate-id + (new-node (ebox-runtime-index-get candidate-id (plist-get candidate-state :node-table))) - (parent-id (gethash candidate-id parents)) + (parent-id (ebox-runtime-index-get candidate-id parents)) (snapshot (and old-node (ebox--ensure-layout-snapshot-details @@ -7544,25 +7596,25 @@ line count; its outside siblings are shifted by the one scoped replacement." (plist-get mount :end)) mounts))))) (parent (and parent-id - (gethash parent-id + (ebox-runtime-index-get parent-id (plist-get old-state :node-table)))) (parent-new (and parent-id - (gethash parent-id + (ebox-runtime-index-get parent-id (plist-get candidate-state :node-table)))) (ancestors-stable-p t) (walk parent-id)) (while (and walk ancestors-stable-p (not (equal walk root-id))) - (let ((ancestor (gethash walk (plist-get old-state :node-table)))) + (let ((ancestor (ebox-runtime-index-get walk (plist-get old-state :node-table)))) (when (or (not ancestor) (memq (ebox-tree-display-inner ancestor) '(row flex grid)) (ebox-tree-node-visible-overflow-p ancestor)) (setq ancestors-stable-p nil))) - (setq walk (gethash walk parents))) + (setq walk (ebox-runtime-index-get walk parents))) (when (and old-node new-node parent parent-new (ebox-incremental--formatting-context-reflow-node-p old-node) - (equal (gethash candidate-id parents) - (gethash candidate-id candidate-parents)) + (equal (ebox-runtime-index-get candidate-id parents) + (ebox-runtime-index-get candidate-id candidate-parents)) (ebox-incremental--node-child-ids-equal-p old-node new-node) (equal (ebox-incremental--layout-slot-style-signature @@ -7598,7 +7650,7 @@ line count; its outside siblings are shifted by the one scoped replacement." (plist-get old-state :layout-snapshot-detail-generation)))) (unless proof - (setq candidate-id (gethash candidate-id parents)))))) + (setq candidate-id (ebox-runtime-index-get candidate-id parents)))))) proof)) (defun ebox-incremental--two-owner-geometry-proof @@ -7858,14 +7910,14 @@ suppresses the ordinary fallback when any fact is missing." :border-bottom-color))) (plist-get entry :changed-keys)) (let* ((node-id (plist-get entry :node-id)) - (old-node (gethash node-id old-nodes)) - (new-node (gethash node-id new-nodes))) + (old-node (ebox-runtime-index-get node-id old-nodes)) + (new-node (ebox-runtime-index-get node-id new-nodes))) (and old-node new-node (ebox-incremental--node-child-ids-equal-p old-node new-node) - (equal (gethash node-id + (equal (ebox-runtime-index-get node-id (plist-get old-state :parent-table)) - (gethash node-id + (ebox-runtime-index-get node-id (plist-get candidate-state :parent-table))) (equal (ebox-incremental--layout-slot-style-signature @@ -7920,7 +7972,7 @@ suppresses the ordinary fallback when any fact is missing." (not (plist-get prepared :scroll-patch-fast-p)) root-id (not (equal geometry-owner-id root-id)) - (> (hash-table-count old-nodes) + (> (ebox-runtime-index-size old-nodes) (length (delete-dups (append geometry-owner-ids paint-owner-ids))))) (list :geometry-proof geometry-proof @@ -7932,7 +7984,7 @@ suppresses the ordinary fallback when any fact is missing." :dirty-set (copy-sequence dirty-set) :owner-plan (copy-tree owner-plan) :outside-complement-count - (- (hash-table-count old-nodes) + (- (ebox-runtime-index-size old-nodes) (length (delete-dups (append geometry-owner-ids paint-owner-ids)))))))) @@ -7951,7 +8003,7 @@ because it could address a run whose topology the closure just replaced." (let ((walk node-id)) (while (and valid walk) (when (gethash walk geometry-set) (setq valid nil)) - (setq walk (and valid (gethash walk parents)))))) + (setq walk (and valid (ebox-runtime-index-get walk parents)))))) valid)) (defun ebox-incremental--allocation-closure-ancestor-stable-p @@ -7964,12 +8016,12 @@ because it could address a run whose topology the closure just replaced." (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))) + (let* ((old-node (ebox-runtime-index-get node-id old-nodes)) + (new-node (ebox-runtime-index-get node-id new-nodes)) + (old-parent (ebox-runtime-index-get node-id old-parents))) (setq valid (and old-node new-node - (equal old-parent (gethash node-id new-parents)) + (equal old-parent (ebox-runtime-index-get node-id new-parents)) (ebox-incremental--node-child-ids-equal-p old-node new-node) (equal @@ -8029,15 +8081,15 @@ because it could address a run whose topology the closure just replaced." 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)) + (let* ((old-node (ebox-runtime-index-get candidate-id old-nodes)) + (new-node (ebox-runtime-index-get candidate-id new-nodes)) + (parent-id (ebox-runtime-index-get 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-runtime-index-get candidate-id candidate-parents)) (ebox-incremental--node-child-ids-equal-p old-node new-node) (equal @@ -8303,7 +8355,7 @@ an owner for one descendant after its narrower projection proof has failed." (parent-table (plist-get (ebox--buffer-render-state buffer) :parent-table)) ops) - (unless (hash-table-p parent-table) + (unless (ebox-runtime-index-like-p parent-table) (signal 'ebox-patch-plan-error (list :missing-parent-table buffer))) (ebox--with-layout-snapshot-detail-context buffer @@ -8483,7 +8535,7 @@ closure may still plan one for a later owner-scoped surface projection." (mapcar (lambda (op) (plist-get op :op)) owner-plan) :owner-ids owner-ids :owner-type - (plist-get (gethash single-owner node-table) :ebox-type) + (plist-get (ebox-runtime-index-get single-owner node-table) :ebox-type) :span-count (cl-loop for owner-id in owner-ids for snapshot = (and snapshots @@ -8514,18 +8566,8 @@ BUFFER, PREPARED and CANDIDATE-STATE prove independence from retained scroll." (plist-get prepared :dirty-set)))) (defun ebox-incremental--hash-key-set-equal-p (left right) - "Return non-nil when hash tables LEFT and RIGHT have the same keys." - (and (hash-table-p left) - (hash-table-p right) - (= (hash-table-count left) (hash-table-count right)) - (let ((equal-p t) - (missing (make-symbol "ebox-missing-key"))) - (maphash - (lambda (key _value) - (when (eq (gethash key right missing) missing) - (setq equal-p nil))) - left) - equal-p))) + "Return non-nil when supported indexes LEFT and RIGHT have the same keys." + (ebox-runtime-index-key-set-equal-p left right)) (defun ebox-incremental--span-patch-identity-stable-p (buffer old-state candidate-state prepared owner-id) @@ -8546,7 +8588,7 @@ the surface is allowed to reuse retained TP objects." (plist-get prepared :dirty-set)))) (while walk (push walk ids) - (setq walk (gethash walk old-parent-table))) + (setq walk (ebox-runtime-index-get walk old-parent-table))) (delete-dups ids)) (delete-dups (append (plist-get prepared :touched-node-ids) @@ -8560,8 +8602,8 @@ the surface is allowed to reuse retained TP objects." (plist-get candidate-state :region-id-set))) (cl-every (lambda (node-id) - (let ((old-node (gethash node-id old-node-table)) - (new-node (gethash node-id new-node-table))) + (let ((old-node (ebox-runtime-index-get node-id old-node-table)) + (new-node (ebox-runtime-index-get node-id new-node-table))) (and old-node new-node (eq (plist-get old-node :ebox-type) (plist-get new-node :ebox-type)) @@ -8570,8 +8612,8 @@ the surface is allowed to reuse retained TP objects." (plist-get old-state :source-index) old-node) (ebox-tree-node-author-key (plist-get candidate-state :source-index) new-node)) - (equal (gethash node-id old-parent-table) - (gethash node-id new-parent-table))))) + (equal (ebox-runtime-index-get node-id old-parent-table) + (ebox-runtime-index-get node-id new-parent-table))))) (delq nil touched))))) (defun ebox-incremental--path-span-line-count-stable-p @@ -8732,10 +8774,10 @@ the complete proof." (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))) + (old-node (and node-id (ebox-runtime-index-get node-id old-nodes))) + (new-node (and node-id (ebox-runtime-index-get node-id new-nodes))) + (old-parent (and node-id (ebox-runtime-index-get node-id old-parents))) + (new-parent (and node-id (ebox-runtime-index-get node-id new-parents))) (allocated-style (plist-get entry :allocated-style))) (setq valid (and old-node new-node allocated-style @@ -8784,10 +8826,10 @@ candidate's unrendered caches." signature valid) (setq 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)) - (new-parent (gethash node-id new-parents)) + (let* ((old-node (ebox-runtime-index-get node-id old-nodes)) + (new-node (ebox-runtime-index-get node-id new-nodes)) + (old-parent (ebox-runtime-index-get node-id old-parents)) + (new-parent (ebox-runtime-index-get node-id new-parents)) (snapshot (and old-node (ebox-incremental--cached-allocation-snapshot buffer node-id))) @@ -8881,28 +8923,28 @@ complete published ancestor slot chain remains identical." (let* ((old-state (ebox-incremental--proof-base-state buffer)) (node-id (plist-get entry :node-id)) (old-node (and node-id - (gethash node-id (plist-get old-state :node-table)))) + (ebox-runtime-index-get node-id (plist-get old-state :node-table)))) (new-node (and node-id - (gethash node-id + (ebox-runtime-index-get node-id (plist-get candidate-state :node-table)))) (parent-table (plist-get old-state :parent-table)) (candidate-parents (plist-get candidate-state :parent-table)) - (ancestor-id (and node-id (gethash node-id parent-table))) + (ancestor-id (and node-id (ebox-runtime-index-get node-id parent-table))) grid-id old-grid new-grid) (while (and ancestor-id (not grid-id)) (let ((old-ancestor - (gethash ancestor-id (plist-get old-state :node-table))) + (ebox-runtime-index-get ancestor-id (plist-get old-state :node-table))) (new-ancestor - (gethash ancestor-id (plist-get candidate-state :node-table)))) + (ebox-runtime-index-get ancestor-id (plist-get candidate-state :node-table)))) (if (and old-ancestor new-ancestor - (equal (gethash ancestor-id parent-table) - (gethash ancestor-id candidate-parents)) + (equal (ebox-runtime-index-get ancestor-id parent-table) + (ebox-runtime-index-get ancestor-id candidate-parents)) (eq (ebox-tree-display-inner old-ancestor) 'grid) (eq (ebox-tree-display-inner new-ancestor) 'grid)) (setq grid-id ancestor-id old-grid old-ancestor new-grid new-ancestor) - (setq ancestor-id (gethash ancestor-id parent-table))))) + (setq ancestor-id (ebox-runtime-index-get ancestor-id parent-table))))) (let* ((old-props (and old-grid (ebox-tree-layout-props old-grid))) (new-props (and new-grid (ebox-tree-layout-props new-grid))) (tracks (and old-props @@ -8944,16 +8986,16 @@ following sibling. The surface footprint validator remains the final gate." (let* ((old-state (ebox-incremental--proof-base-state buffer)) (node-id (plist-get entry :node-id)) (old-node (and node-id - (gethash node-id (plist-get old-state :node-table)))) + (ebox-runtime-index-get node-id (plist-get old-state :node-table)))) (new-node (and node-id - (gethash node-id (plist-get candidate-state :node-table)))) + (ebox-runtime-index-get node-id (plist-get candidate-state :node-table)))) (parents (plist-get old-state :parent-table)) (candidate-parents (plist-get candidate-state :parent-table)) - (parent-id (and node-id (gethash node-id parents))) + (parent-id (and node-id (ebox-runtime-index-get node-id parents))) (old-parent (and parent-id - (gethash parent-id (plist-get old-state :node-table)))) + (ebox-runtime-index-get parent-id (plist-get old-state :node-table)))) (new-parent (and parent-id - (gethash parent-id (plist-get candidate-state :node-table)))) + (ebox-runtime-index-get parent-id (plist-get candidate-state :node-table)))) (old-children (and old-parent (ebox-tree--children-raw old-parent))) (position (and old-children (cl-position-if @@ -8971,8 +9013,8 @@ following sibling. The surface footprint validator remains the final gate." (ebox-incremental--ancestor-slot-signature buffer old-state candidate-state node-id)))) (and old-state old-node new-node old-parent new-parent - (equal (gethash parent-id parents) - (gethash parent-id candidate-parents)) + (equal (ebox-runtime-index-get parent-id parents) + (ebox-runtime-index-get parent-id candidate-parents)) (ebox-incremental--node-child-ids-equal-p old-parent new-parent) (equal (ebox-incremental--layout-slot-style-signature old-parent) (ebox-incremental--layout-slot-style-signature new-parent)) @@ -9029,17 +9071,17 @@ proving that changed descendants still fit this slot exactly." (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-node (and owner-id (gethash owner-id old-nodes))) - (new-node (and owner-id (gethash owner-id new-nodes))) - (parent-id (and owner-id (gethash owner-id old-parents))) - (old-parent (and parent-id (gethash parent-id old-nodes))) - (new-parent (and parent-id (gethash parent-id new-nodes))) + (old-node (and owner-id (ebox-runtime-index-get owner-id old-nodes))) + (new-node (and owner-id (ebox-runtime-index-get owner-id new-nodes))) + (parent-id (and owner-id (ebox-runtime-index-get owner-id old-parents))) + (old-parent (and parent-id (ebox-runtime-index-get parent-id old-nodes))) + (new-parent (and parent-id (ebox-runtime-index-get parent-id new-nodes))) (snapshot (and old-node (ebox-incremental--cached-allocation-snapshot buffer owner-id)))) (and old-node new-node old-parent new-parent snapshot - (equal parent-id (gethash owner-id new-parents)) + (equal parent-id (ebox-runtime-index-get owner-id new-parents)) (eq (ebox-tree-display-inner old-parent) 'column) (eq (ebox-tree-display-inner new-parent) 'column) (ebox-incremental--node-child-ids-equal-p old-node new-node) @@ -9080,17 +9122,17 @@ style, and the published ancestor slot chain remain mandatory." (let* ((old-state (ebox-incremental--proof-base-state buffer)) (node-id (plist-get entry :node-id)) (old-node (and node-id - (gethash node-id (plist-get old-state :node-table)))) + (ebox-runtime-index-get node-id (plist-get old-state :node-table)))) (new-node (and node-id - (gethash node-id (plist-get candidate-state + (ebox-runtime-index-get node-id (plist-get candidate-state :node-table)))) (parents (plist-get old-state :parent-table)) (candidate-parents (plist-get candidate-state :parent-table)) - (parent-id (and node-id (gethash node-id parents))) + (parent-id (and node-id (ebox-runtime-index-get node-id parents))) (old-parent (and parent-id - (gethash parent-id (plist-get old-state :node-table)))) + (ebox-runtime-index-get parent-id (plist-get old-state :node-table)))) (new-parent (and parent-id - (gethash parent-id + (ebox-runtime-index-get parent-id (plist-get candidate-state :node-table)))) (snapshot (and old-node (ebox-incremental--cached-allocation-snapshot @@ -9101,8 +9143,8 @@ style, and the published ancestor slot chain remain mandatory." (eq (ebox-tree-display-inner old-parent) (ebox-tree-display-inner new-parent)) (memq (ebox-tree-display-inner old-parent) '(row flex)) - (equal (gethash parent-id parents) - (gethash parent-id candidate-parents)) + (equal (ebox-runtime-index-get parent-id parents) + (ebox-runtime-index-get parent-id candidate-parents)) (ebox-incremental--node-child-ids-equal-p old-parent new-parent) (equal (ebox-incremental--layout-slot-style-signature old-parent) (ebox-incremental--layout-slot-style-signature new-parent)) @@ -9135,17 +9177,17 @@ the complete snapshot proof." (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 (gethash grid-id old-parents)) + (node-id (ebox-runtime-index-get grid-id old-parents)) (seen nil) (valid (and node-id t))) (while (and valid node-id) (if (memq node-id seen) (setq valid nil) (push node-id seen) - (let* ((old-node (gethash node-id old-nodes)) - (new-node (gethash node-id new-nodes)) - (old-parent (gethash node-id old-parents)) - (new-parent (gethash node-id new-parents)) + (let* ((old-node (ebox-runtime-index-get node-id old-nodes)) + (new-node (ebox-runtime-index-get node-id new-nodes)) + (old-parent (ebox-runtime-index-get node-id old-parents)) + (new-parent (ebox-runtime-index-get node-id new-parents)) (display (and old-node (ebox-tree-display-inner old-node))) (old-width (and old-node (ebox-get old-node :width))) @@ -9178,22 +9220,22 @@ projection kinds and the surface output validator remains authoritative." (let* ((old-state (ebox-incremental--proof-base-state buffer)) (node-id (plist-get entry :node-id)) (old-node (and node-id - (gethash node-id (plist-get old-state :node-table)))) + (ebox-runtime-index-get node-id (plist-get old-state :node-table)))) (new-node (and node-id - (gethash node-id + (ebox-runtime-index-get node-id (plist-get candidate-state :node-table)))) (parents (plist-get old-state :parent-table)) (candidate-parents (plist-get candidate-state :parent-table)) - (ancestor-id (and node-id (gethash node-id parents))) + (ancestor-id (and node-id (ebox-runtime-index-get node-id parents))) (child-id node-id) grid-id old-grid new-grid) (while (and ancestor-id (not grid-id)) - (let* ((old-ancestor (gethash ancestor-id + (let* ((old-ancestor (ebox-runtime-index-get ancestor-id (plist-get old-state :node-table))) - (new-ancestor (gethash ancestor-id + (new-ancestor (ebox-runtime-index-get ancestor-id (plist-get candidate-state :node-table))) - (parent-id (gethash ancestor-id parents)) - (new-parent-id (gethash ancestor-id candidate-parents))) + (parent-id (ebox-runtime-index-get ancestor-id parents)) + (new-parent-id (ebox-runtime-index-get ancestor-id candidate-parents))) (if (and old-ancestor new-ancestor (equal parent-id new-parent-id) (not (ebox-tree-node-visible-overflow-p old-ancestor))) @@ -9324,9 +9366,9 @@ regenerate its cached lines; only separate branches may keep those lines." (and scroll-id next-box (eq (plist-get old-scroll :buffer) buffer) (equal scroll-id (plist-get next-box :node-id)) - (eq old-box (gethash scroll-id (plist-get old-state :node-table))) + (eq old-box (ebox-runtime-index-get scroll-id (plist-get old-state :node-table))) (eq old-box (gethash region (plist-get old-state :region-box-table))) - (eq next-box (gethash scroll-id (plist-get candidate-state :node-table))) + (eq next-box (ebox-runtime-index-get scroll-id (plist-get candidate-state :node-table))) (eq next-box (gethash region (plist-get candidate-state :region-box-table))) (not (equal scroll-id owner-id)) (let ((ebox-incremental--buffer-render-state-override @@ -9352,8 +9394,8 @@ metadata; this predicate only authorizes the local attempt." (dirty (car (plist-get prepared :dirty-set))) (owner-id (plist-get operation :owner-id)) (changed-keys (plist-get dirty :changed-keys)) - (old-node (gethash owner-id (plist-get old-state :node-table))) - (new-node (gethash owner-id (plist-get candidate-state :node-table))) + (old-node (ebox-runtime-index-get owner-id (plist-get old-state :node-table))) + (new-node (ebox-runtime-index-get owner-id (plist-get candidate-state :node-table))) (slot-dirty (plist-put (copy-sequence dirty) :node-id owner-id)) (snapshot @@ -9632,8 +9674,8 @@ role, and outside-complement compatibility before publication." (owner-id (plist-get operation :owner-id)) (dirty-set (plist-get prepared :dirty-set)) (root-id (ebox--buffer-root-node-id buffer)) - (old-node (gethash owner-id (plist-get old-state :node-table))) - (new-node (gethash owner-id + (old-node (ebox-runtime-index-get owner-id (plist-get old-state :node-table))) + (new-node (ebox-runtime-index-get owner-id (plist-get candidate-state :node-table))) (fixed-flex-slot-p (ebox--flex-item-fixed-basis-content-allocation-stable-p @@ -9745,9 +9787,9 @@ role, and outside-complement compatibility before publication." ;; a second proof render here would only duplicate work. (or owner-slot-proof fixed-flex-ancestor-proof content-slot-proof) - (equal (gethash owner-id + (equal (ebox-runtime-index-get owner-id (plist-get old-state :parent-table)) - (gethash owner-id + (ebox-runtime-index-get owner-id (plist-get candidate-state :parent-table))) (ebox-incremental--node-child-ids-equal-p old-node new-node) @@ -9805,8 +9847,8 @@ role, and outside-complement compatibility before publication." (dirty (car (plist-get prepared :dirty-set))) (delta (plist-get dirty :range-delta)) (owner-id (plist-get operation :owner-id)) - (old-node (gethash owner-id (plist-get old-state :node-table))) - (new-node (gethash owner-id + (old-node (ebox-runtime-index-get owner-id (plist-get old-state :node-table))) + (new-node (ebox-runtime-index-get owner-id (plist-get candidate-state :node-table))) (old-items (and delta (apply #'append (plist-get delta :old-payloads)))) @@ -9837,8 +9879,8 @@ role, and outside-complement compatibility before publication." (cl-every (lambda (item) (eq (plist-get item :ebox-kind) 'box)) new-items) - (equal (gethash owner-id (plist-get old-state :parent-table)) - (gethash owner-id + (equal (ebox-runtime-index-get owner-id (plist-get old-state :parent-table)) + (ebox-runtime-index-get owner-id (plist-get candidate-state :parent-table))) (equal (ebox-incremental--layout-slot-style-signature old-node) (ebox-incremental--layout-slot-style-signature new-node)) @@ -10000,6 +10042,8 @@ role, and outside-complement compatibility before publication." "Return the retained native runtime facts owned by STATE." (list :native-buffer-scroll-p (plist-get state :native-buffer-scroll-p) + :native-root-scroll-producer (plist-get state :native-root-scroll-producer) + :native-root-scroll-offset-dirty (plist-get state :native-root-scroll-offset-dirty) :native-sync-session (plist-get state :native-sync-session) :native-sync-pending @@ -10141,6 +10185,8 @@ role, and outside-complement compatibility before publication." (plist-get candidate-state :selector-index-stale-p) :native-node-postorder (plist-get candidate-state :native-node-postorder) + :native-node-postorder-ids + (plist-get candidate-state :native-node-postorder-ids) :surface-node-object-table (and (memq projection-kind '(span-patch owner-scoped scroll-patch @@ -10641,10 +10687,20 @@ runtime candidate whose identities already match the published root." prepared owner-plan candidate-state) report-overrides))) (when scroll-transaction-p - ;; Scroll changes already carry a bounded rendered window in the - ;; staged scroll state. Publish that window through the retained - ;; object scope instead of re-running the full root layout. - (setq projection-kind 'scroll-patch) + ;; A complete native producer must advance its confirmed visible + ;; frame on scroll as well. Ordinary scroll states publish their + ;; already staged window through the retained object scope. + (setq projection-kind + (if (and (require 'ebox-native-commit nil t) + (ebox-native-commit-root-scroll-continuation-p old-state) + (equal (plist-get report-overrides :region-id) + (plist-get (ebox-native-commit--root-box + (plist-get old-state :root-node)) + :region-id)) + (ebox-native-commit-plan-eligible-p + old-state candidate-state prepared)) + 'native-frame + 'scroll-patch)) (setq report (plist-put report :projection-kind projection-kind))) (when projection-kind (setq report (plist-put report :projection-kind projection-kind))) @@ -10673,7 +10729,8 @@ runtime candidate whose identities already match the published root." (plist-get report-overrides :owner-id)))) (setq state-overrides (plist-put state-overrides :scroll-fast-window-p - scroll-patch-fast-p))) + (and (eq projection-kind 'scroll-patch) + scroll-patch-fast-p)))) (list :root (plist-get prepared :root) :scope-node-ids scope-node-ids :report-base report @@ -10722,6 +10779,7 @@ When RETAIN-NATIVE-P is non-nil, preserve its committed native frame bases." :selector-type-table (plist-get state :selector-type-table) :runtime-type-count-table (plist-get state :runtime-type-count-table) :native-node-postorder (plist-get state :native-node-postorder) + :native-node-postorder-ids (plist-get state :native-node-postorder-ids) :runtime-index-prepared-p t :scroll-state-table (plist-get state :scroll-state-table) :detached-identity-history @@ -10786,7 +10844,7 @@ changes, or topology changes." (region-id (and (= (length scroll-ids) 1) (car scroll-ids))) (region-node-id (and region-id region-node-table - (gethash region-id region-node-table))) + (ebox-runtime-index-get region-id region-node-table))) (candidate-scroll-table (plist-get candidate-state :scroll-state-table))) (and old-root candidate-root @@ -10824,8 +10882,8 @@ fresh regardless of whether any stable producers exist." (scroll-state-table (plist-get state :scroll-state-table)) (stable nil) (affected nil)) - (if (not (and (hash-table-p region-node-table) - (hash-table-p node-table) + (if (not (and (ebox-runtime-index-like-p region-node-table) + (ebox-runtime-index-like-p node-table) (hash-table-p scroll-state-table))) (setq affected (reverse (plist-get state :scroll-region-ids))) (let ((ebox--viewport-dependent-node-ids-cache @@ -10835,8 +10893,8 @@ fresh regardless of whether any stable producers exist." (ebox--viewport-height-dependent-subtree-cache (make-hash-table :test #'eq))) (dolist (region-id (plist-get state :scroll-region-ids)) - (let* ((node-id (gethash region-id region-node-table)) - (node (and node-id (gethash node-id node-table))) + (let* ((node-id (ebox-runtime-index-get region-id region-node-table)) + (node (and node-id (ebox-runtime-index-get node-id node-table))) (scroll-state (gethash region-id scroll-state-table)) (stable-p (and node scroll-state @@ -10877,6 +10935,8 @@ fresh regardless of whether any stable producers exist." (plist-get candidate-state :selector-index-stale-p) :native-node-postorder (plist-get candidate-state :native-node-postorder) + :native-node-postorder-ids + (plist-get candidate-state :native-node-postorder-ids) :runtime-index-prepared-p t :surface-node-object-table (plist-get old-state :surface-node-object-table)))) diff --git a/ebox-layout.el b/ebox-layout.el index 949487b..642f510 100644 --- a/ebox-layout.el +++ b/ebox-layout.el @@ -15,6 +15,7 @@ (require 'ebox-fragment) (require 'ebox-render-context) (require 'ebox-buffer-backend) +(require 'ebox-runtime-index) (declare-function ebox--render-grid "ebox-grid" (node)) (declare-function ebox--render-grid-box-children @@ -1925,8 +1926,8 @@ also pass a pre-rendered string while an active surface owns runtime facts." (cl-find-if (lambda (state) (let ((table (plist-get state :node-table))) - (and (hash-table-p table) - (eq node (gethash node-id table))))) + (and (ebox-runtime-index-like-p table) + (eq node (ebox-runtime-index-get node-id table))))) ebox--render-source-states))) (base-source-index (or base-source-index diff --git a/ebox-native-commit.el b/ebox-native-commit.el index 92dd302..a97950e 100644 --- a/ebox-native-commit.el +++ b/ebox-native-commit.el @@ -11,6 +11,7 @@ ;;; Code: (require 'cl-lib) +(require 'ebox-runtime-index) (declare-function ebox-native-reflow-layout-ready-p "ebox-native-reflow" ()) (declare-function ebox-native-reflow-execute-sync @@ -33,6 +34,10 @@ "ebox-native-reflow" (frame)) (declare-function ebox--literal-root-pixel-width "ebox" (box)) (declare-function ebox-get "ebox" (box key)) +(declare-function ebox-put "ebox" (box key value)) +(declare-function ebox--scroll-set-state "ebox" (region-id state)) +(declare-function ebox--scroll-clear-state "ebox" (region-id)) +(defvar ebox--scroll-global-state) (declare-function ebox-style-cascade-active-p "ebox-style" ()) (declare-function ebox-style-property "ebox-style" (name)) (declare-function ebox-tree-node-key "ebox-tree" (source-index node)) @@ -45,7 +50,7 @@ "ebox-incremental" (node)) (defun ebox-native-commit-topology-stable-p - (old-state candidate-state prepared) + (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)) @@ -62,22 +67,22 @@ (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) + (ebox-runtime-index-like-p old-nodes) (ebox-runtime-index-like-p new-nodes) + (ebox-runtime-index-like-p old-parents) (ebox-runtime-index-like-p new-parents) + (ebox-runtime-index-like-p old-regions) (ebox-runtime-index-like-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)))) + (= (ebox-runtime-index-size old-nodes) + (ebox-runtime-index-size new-nodes)) + (= (ebox-runtime-index-size old-regions) + (ebox-runtime-index-size new-regions)))) (when stable - (maphash + (ebox-runtime-index-map (lambda (node-id node) - (let ((old-node (gethash node-id old-nodes missing))) + (let ((old-node (ebox-runtime-index-get 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)) + (equal (ebox-runtime-index-get node-id old-parents missing) + (ebox-runtime-index-get node-id new-parents missing)) (eq (plist-get old-node :ebox-type) (plist-get node :ebox-type)) (equal @@ -88,9 +93,9 @@ (setq stable nil)))) new-nodes)) (when stable - (maphash + (ebox-runtime-index-map (lambda (region-id node-id) - (unless (equal (gethash region-id old-regions missing) node-id) + (unless (equal (ebox-runtime-index-get region-id old-regions missing) node-id) (setq stable nil))) new-regions)) stable)) @@ -105,7 +110,7 @@ (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)))))) + (setq node-id (and parents (ebox-runtime-index-get node-id parents)))))) (nreverse result))) (defun ebox-native-commit-inherited-dirty-node-ids (prepared) @@ -123,8 +128,8 @@ "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 + (when (ebox-runtime-index-like-p nodes) + (ebox-runtime-index-map (lambda (parent-id node) (cl-loop for child in (ebox-tree--children-raw node) for position from 0 @@ -166,22 +171,22 @@ type, key, parent, and sibling position are identical." (plist-get old-state :source-index) old-root) (ebox-tree-node-key (plist-get candidate-state :source-index) new-root)) - (hash-table-p old-nodes) (hash-table-p new-nodes) + (ebox-runtime-index-like-p old-nodes) (ebox-runtime-index-like-p new-nodes) (hash-table-p old-objects))) result) (dolist (node-id (plist-get prepared :touched-node-ids)) - (if (gethash node-id new-nodes) + (if (ebox-runtime-index-get 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))) + (if (and (ebox-runtime-index-get node-id old-nodes) + (not (ebox-runtime-index-get node-id new-nodes))) (puthash node-id t removed) (setq valid nil))) (when valid - (maphash + (ebox-runtime-index-map (lambda (node-id _node) - (when (and (not (gethash node-id new-nodes)) + (when (and (not (ebox-runtime-index-get node-id new-nodes)) (not (gethash node-id removed))) (setq valid nil))) old-nodes)) @@ -189,7 +194,7 @@ type, key, parent, and sibling position are identical." (cl-labels ((visit (node) (let* ((node-id (plist-get node :node-id)) - (old-node (gethash node-id old-nodes missing)) + (old-node (ebox-runtime-index-get node-id old-nodes missing)) (old-object (gethash node-id old-objects)) (position (gethash node-id new-positions)) (parent-id (car-safe position)) @@ -229,12 +234,12 @@ type, key, parent, and sibling position are identical." (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))) + (ebox-runtime-index-like-p old-nodes) + (ebox-runtime-index-like-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)))) + (old (and stable (ebox-runtime-index-get node-id old-nodes))) + (new (and stable (ebox-runtime-index-get 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)) @@ -398,39 +403,111 @@ new session with the same bounded configuration used by retained frames." (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 + (when-let* ((node-id (ebox-runtime-index-get region-id (plist-get state :region-node-table))) - (node (gethash node-id (plist-get state :node-table)))) + (node (ebox-runtime-index-get node-id (plist-get state :node-table)))) (pcase (plist-get node :ebox-type) ('box node) ('flex (plist-get node :box))))) +(defun ebox-native-commit-root-scroll-continuation-p (state) + "Return non-nil for STATE's confirmed complete sole-root scroll producer." + (let ((producer (plist-get state :native-root-scroll-producer)) + (box (ebox-native-commit--root-box (plist-get state :root-node)))) + (and (vectorp producer) (= (length producer) 6) box + (equal (aref producer 0) (plist-get box :region-id)) + (plist-get state :native-sync-session) + (plist-get state :native-sync-confirmed-p)))) + (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)) + (root-scroll (ebox-native-commit-root-scroll-continuation-p state)) + (root-region (plist-get (ebox-native-commit--root-box + (plist-get state :root-node)) :region-id)) safe) (setq safe (and (hash-table-p counts) (or (null scroll) (and (hash-table-p scroll) - (zerop (hash-table-count scroll)))))) + (or (zerop (hash-table-count scroll)) + (and root-scroll (= (hash-table-count scroll) 1) + (gethash root-region 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))) + (or (zerop (or (ebox-get box :scroll-offset) 0)) + (and root-scroll (equal region-id root-region)))) (setq safe nil)))) counts)) safe)) +(defun ebox-native-commit--install-root-scroll-producer (state producer) + "Install complete PRODUCER in STATE's isolated candidate scroll table." + (let* ((box (ebox-native-commit--root-box (plist-get state :root-node))) + (region (plist-get box :region-id)) + (previous (plist-get state :native-root-scroll-producer)) + (previous-scroll (gethash region ebox--scroll-global-state))) + (unless (and box (vectorp producer) (= (length producer) 6) + (equal region (aref producer 0))) + (error "Native scroll producer does not own the actual root")) + (when (or (eq (aref producer 4) :reuse) (eq (aref producer 5) :reuse)) + (unless (and (eq (aref producer 4) :reuse) (eq (aref producer 5) :reuse) + (eq (plist-get state :native-frame-kind) 'patch) + (ebox-native-commit-root-scroll-continuation-p state) + (= (aref producer 1) (aref previous 1)) + (> (aref previous 1) (aref previous 2)) + (listp (aref previous 4)) (listp (aref previous 5))) + (error "Native scroll producer reuse has no confirmed complete base")) + (setq producer (copy-sequence producer)) + (aset producer 4 (aref previous 4)) + (aset producer 5 (aref previous 5))) + (unless (= (or (ebox-get box :scroll-offset) 0) (aref producer 3)) + ;; Viewport candidates may share their unchanged root. Clamp only a new + ;; root spine, and compile that normalized offset on the next native job. + (let* ((root (copy-sequence (plist-get state :root-node))) + (root-id (plist-get root :node-id)) + (nodes (plist-get state :node-table))) + (if (eq (plist-get root :ebox-type) 'flex) + (progn (setq box (copy-sequence (plist-get root :box))) + (plist-put root :box box)) + (setq box root)) + (ebox-put box :scroll-offset (aref producer 3)) + (plist-put state :root-node root) + (plist-put state :node-table + (ebox-runtime-index-put root-id root + (if (hash-table-p nodes) (copy-hash-table nodes) nodes))) + (let ((regions (copy-hash-table (plist-get state :region-box-table)))) + (puthash region box regions) + (plist-put state :region-box-table regions)) + (plist-put state :native-root-scroll-offset-dirty t))) + (plist-put state :native-root-scroll-producer producer) + (if (> (aref producer 1) (aref producer 2)) + (if (and previous-scroll + (eq (plist-get previous-scroll :content-lines) (aref producer 4)) + (eq (plist-get previous-scroll :rendered-content-lines) (aref producer 5))) + (let ((scroll-state (copy-sequence previous-scroll))) + (plist-put scroll-state :scroll-offset (aref producer 3)) + (plist-put scroll-state :content-height (aref producer 2)) + (plist-put scroll-state :box box) + (puthash region scroll-state ebox--scroll-global-state)) + (ebox--scroll-set-state + region (list :scroll-offset (aref producer 3) + :content-height (aref producer 2) + :content-lines (aref producer 4) + :rendered-content-lines (aref producer 5) + :content-lines-complete-p t :box box))) + (ebox--scroll-clear-state region)))) + (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 + (when (ebox-runtime-index-like-p nodes) + (ebox-runtime-index-map (lambda (_node-id node) (unless (memq (plist-get node :ebox-type) '(box concat stack flex)) @@ -481,6 +558,7 @@ new session with the same bounded configuration used by retained frames." ;; base while font/display changes invalidate it. (sxhash-equal (plist-get state :display-signature)) :complete t + :root-scroll-producer t :root-metadata nil) (when (and (plist-get state :native-sync-confirmed-p) (plist-get state :native-topology-stable-p)) @@ -620,6 +698,8 @@ publication transaction can still roll back." (defconst ebox-native-commit--failed-render-state-keys '(:native-sync-session + :native-root-scroll-producer + :native-root-scroll-offset-dirty :native-sync-pending :native-sync-confirmed-p :native-render-p @@ -672,16 +752,29 @@ projection so the caller may publish its existing Elisp fallback truthfully." (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))) + (when (plist-get state :native-root-scroll-offset-dirty) + (let ((root-id (plist-get node :node-id))) + (plist-put state :native-touched-node-ids + (cons root-id (remove root-id (plist-get state :native-touched-node-ids)))) + (plist-put state :native-local-dirty-entries + (cons (list :node-id root-id :dirty-kind 'geometry + :changed-keys '(:scroll-offset)) + (plist-get state :native-local-dirty-entries)))) + (cl-remf state :native-root-scroll-offset-dirty)) (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)) + (effects (plist-get frame :effect-tape)) + (rendered (plist-get frame :rendered)) + (producer (plist-get effects :root-scroll-producer)) + (fragment-template + (and (or (not (plist-get effects :scroll-window-p)) producer) (plist-get effects :fragment-span-template)))) (if (and (stringp rendered) (vectorp fragment-template)) (progn + (if producer + (ebox-native-commit--install-root-scroll-producer state producer) + (cl-remf state :native-root-scroll-producer)) (ebox-native-commit--install-region-boxes state) (plist-put state :render-owned-text-values (make-hash-table :test #'eq)) diff --git a/ebox-native-reflow.el b/ebox-native-reflow.el index 9b3478c..84c150f 100644 --- a/ebox-native-reflow.el +++ b/ebox-native-reflow.el @@ -15,6 +15,7 @@ (require 'subr-x) (require 'ebox-measure) (require 'ebox-render-context) +(require 'ebox-runtime-index) (declare-function ebox-native--module-version "ebox_native_reflow" ()) (declare-function ebox-native--module-layout-ready-p "ebox_native_reflow" ()) @@ -2411,7 +2412,13 @@ Otherwise return CANDIDATE with the next document revision." (root (and state (plist-get state :root-node))) (postorder (and state (plist-get state :native-node-postorder)))) (unless (and root (vectorp postorder) (> (length postorder) 0) - (eq root (aref postorder (1- (length postorder))))) + (equal (plist-get root :node-id) + (let ((entry (aref postorder + (1- (length postorder))))) + (if (and (listp entry) + (plist-member entry :node-id)) + (plist-get entry :node-id) + entry)))) (error "Native layout scene is absent from the render transaction")) (with-current-buffer buffer (ebox-native-reflow--make-layout-scene @@ -2496,7 +2503,12 @@ per call, and no call recursively visits the captured Ebox tree." (< (- (float-time) started) budget-seconds))) (let* ((index (ebox-native-reflow-layout-builder-index builder)) - (node (aref nodes index)) + (node + (ebox-native-reflow--postorder-node + (aref nodes index) + (plist-get + (ebox-native-reflow-layout-scene-render-state scene) + :node-table))) (fragment (ebox-native-reflow--compile-scene-node node fragments @@ -2645,10 +2657,21 @@ per call, and no call recursively visits the captured Ebox tree." (plist-get node :content-min-width))) local))) -(defun ebox-native-reflow--retained-edge-index (postorder cache) - "Build exact child-edge facts from full POSTORDER fragments in CACHE." +(defun ebox-native-reflow--postorder-node (entry node-table) + "Resolve POSTORDER ENTRY through NODE-TABLE when it is a node id." + (if (and (listp entry) (plist-member entry :ebox-type)) + entry + (or (ebox-runtime-index-get entry node-table) + (error "Native postorder references an absent node: %S" entry)))) + +(defun ebox-native-reflow--retained-edge-index + (postorder cache &optional node-table) + "Build exact child-edge facts from full POSTORDER fragments in CACHE. +Resolve node-ID entries through NODE-TABLE; legacy node plists need no index." (let (index) - (cl-loop for parent across postorder + (cl-loop for entry across postorder + for parent = (ebox-native-reflow--postorder-node + entry node-table) for parent-id = (plist-get parent :node-id) for rule = (ebox-native-reflow--retained-edge-rule parent) when rule do @@ -2685,7 +2708,8 @@ per call, and no call recursively visits the captured Ebox tree." (defun ebox-native-reflow--compile-retained-layout-package-full (session state node) "Compile a complete retained package for NODE in SESSION from STATE." - (let ((postorder (plist-get state :native-node-postorder))) + (let ((postorder (plist-get state :native-node-postorder)) + (node-table (plist-get state :node-table))) (unless (and (vectorp postorder) (> (length postorder) 0)) (setq postorder (ebox-native-reflow--retained-layout-postorder node))) @@ -2728,7 +2752,11 @@ per call, and no call recursively visits the captured Ebox tree." (postorder-id-set (let ((set (make-hash-table :test 'equal))) (dotimes (index (length postorder)) - (puthash (plist-get (aref postorder index) :node-id) + (puthash + (plist-get + (ebox-native-reflow--postorder-node + (aref postorder index) node-table) + :node-id) t set)) set)) (ebox-native-reflow--compile-styles @@ -2832,7 +2860,9 @@ per call, and no call recursively visits the captured Ebox tree." (puthash current fragment fragments) (puthash node-id entry new-cache)))) (dotimes (index (length postorder)) - (let* ((current (aref postorder index)) + (let* ((current + (ebox-native-reflow--postorder-node + (aref postorder index) node-table)) (node-id (plist-get current :node-id))) (if (and retained-fast-p (not (gethash node-id touched-set))) @@ -2871,7 +2901,7 @@ per call, and no call recursively visits the captured Ebox tree." (plist-get package :property-templates)) (ebox-native-reflow-session-layout-edge-index session) (ebox-native-reflow--retained-edge-index - postorder new-cache)) + postorder new-cache node-table)) package))))) (defconst ebox-native-reflow--delta-edge-fields @@ -2924,9 +2954,9 @@ RULE identifies the compiler lowering, and RETAINED supplies its stable slot." (owner-id (and nodes parents (ebox-native-reflow--delta-owner-id state dirty-id index))) - (owner (and owner-id (gethash owner-id nodes))) - (parent-id (and owner-id (gethash owner-id parents))) - (parent (and parent-id (gethash parent-id nodes))) + (owner (and owner-id (ebox-runtime-index-get owner-id nodes))) + (parent-id (and owner-id (ebox-runtime-index-get owner-id parents))) + (parent (and parent-id (ebox-runtime-index-get parent-id nodes))) (current-parent-rule (and parent (ebox-native-reflow--retained-edge-rule parent))) (retained-parent-rule @@ -2994,9 +3024,9 @@ RULE identifies the compiler lowering, and RETAINED supplies its stable slot." (let ((parents (plist-get state :parent-table)) (nodes (plist-get state :node-table)) (current node-id)) - (when-let* ((source (and nodes (gethash current nodes))) - (parent-id (and parents (gethash current parents))) - (parent (and nodes (gethash parent-id nodes)))) + (when-let* ((source (and nodes (ebox-runtime-index-get current nodes))) + (parent-id (and parents (ebox-runtime-index-get current parents))) + (parent (and nodes (ebox-runtime-index-get parent-id nodes)))) (when (and (eq (plist-get source :ebox-kind) 'text) (eq (plist-get parent :ebox-kind) 'box) (eq (ebox-layout-config-kind @@ -3006,7 +3036,7 @@ RULE identifies the compiler lowering, and RETAINED supplies its stable slot." (while (and current (null (ebox-native-reflow--persistent-index-get index current))) - (setq current (and parents (gethash current parents)))) + (setq current (and parents (ebox-runtime-index-get current parents)))) current)) (defun ebox-native-reflow--compile-delta-slots (node old-fragment) @@ -3073,7 +3103,7 @@ RULE identifies the compiler lowering, and RETAINED supplies its stable slot." (old-index (ebox-native-reflow-session-layout-fragment-index session)) (node-table (plist-get state :node-table)) (touched (plist-get state :native-touched-node-ids))) - (when (and old-package old-index (hash-table-p node-table) + (when (and old-package old-index (ebox-runtime-index-like-p node-table) (plist-get state :native-topology-stable-p) (plist-member state :native-local-dirty-entries) (not (ebox-native-reflow--delta-edge-change-p @@ -3126,7 +3156,7 @@ RULE identifies the compiler lowering, and RETAINED supplies its stable slot." (let* ((cached (ebox-native-reflow--persistent-index-get old-index owner-id)) (old-fragment (plist-get cached :fragment)) - (source (gethash owner-id node-table)) + (source (ebox-runtime-index-get owner-id node-table)) (expected (plist-get cached :revision)) patches new-fragment) (unless (and cached source (integerp expected)) @@ -3303,6 +3333,7 @@ DOCUMENT-DELTA is the stable-topology local replacement batch." (if (plist-member frame :root-metadata) (plist-get frame :root-metadata) t)) + (root-scroll-producer (plist-get frame :root-scroll-producer)) (delay (or (plist-get frame :delay-ms) 0))) (unless (integerp key) (error "Native reflow layout key must be an integer: %S" key)) @@ -3332,6 +3363,10 @@ DOCUMENT-DELTA is the stable-topology local replacement batch." (error "Native reflow complete flag must be boolean")) (unless (memq root-metadata '(t nil :false)) (error "Native reflow root-metadata flag must be boolean")) + (unless (memq root-scroll-producer '(t nil :false)) + (error "Native reflow root-scroll-producer flag must be boolean")) + (when (and (eq root-scroll-producer t) (not (eq complete t))) + (error "Native root scroll producer requires complete properties")) (append (list :key key :viewport-width viewport-width @@ -3348,6 +3383,8 @@ DOCUMENT-DELTA is the stable-topology local replacement batch." :root-metadata (if (eq root-metadata t) t :false) :delay-ms delay) + (when (eq root-scroll-producer t) + (list :root-scroll-producer t)) (when patch-p (list :base-viewport-width base-viewport-width @@ -3974,7 +4011,12 @@ plist supplied by the caller." :role-span-template ,roles :box-extent-template ,extents :scroll-content-span-template ,scroll - :scroll-window-p ,(and window-p (or 'nil 't))) + :scroll-window-p ,(and window-p (or 'nil 't)) . ,extra) + (unless (or (null extra) + (and (proper-list-p extra) (= (length extra) 2) + (eq (car extra) :root-scroll-producer) + (vectorp (cadr extra)) (= (length (cadr extra)) 6))) + (error "Native reflow tape has invalid root scroll metadata")) (let ((actual-count (+ (ebox-native-reflow--validate-root-role-template roles character-count) @@ -3982,13 +4024,50 @@ plist supplied by the caller." extents character-count) (ebox-native-reflow--validate-root-scroll-template scroll character-count) - (if window-p 1 0)))) + (if window-p 1 0) + (if extra 1 0)))) (unless (= actual-count record-count) (error "Native reflow tape metadata record count mismatch"))) metadata) (_ (error "Native reflow tape has invalid prepared root metadata")))) +(defun ebox-native-reflow--decode-root-scroll-producer + (metadata styles property-templates &optional allow-reuse-p) + "Validate and decode METADATA's complete retained root scroll producer." + (when-let* ((encoded (plist-get metadata :root-scroll-producer))) + (unless (and (vectorp encoded) (= (length encoded) 6) + (integerp (aref encoded 0)) (> (aref encoded 0) 0) + (integerp (aref encoded 1)) (> (aref encoded 1) 0) + (integerp (aref encoded 2)) (> (aref encoded 2) 0) + (integerp (aref encoded 3)) (>= (aref encoded 3) 0) + (<= (aref encoded 3) + (max 0 (- (aref encoded 1) (aref encoded 2))))) + (error "Native root scroll producer has invalid geometry")) + (let ((producer (copy-sequence encoded))) + (if (or (and (<= (aref encoded 1) (aref encoded 2)) + (eq (aref encoded 4) :inactive) + (eq (aref encoded 5) :inactive)) + (and allow-reuse-p (> (aref encoded 1) (aref encoded 2)) + (eq (aref encoded 4) :reuse) + (eq (aref encoded 5) :reuse))) + nil + (dolist (index '(4 5)) + (let* ((literal (aref encoded index)) + (read-circle t) + (read-symbol-shorthands nil) + (parsed (and (stringp literal) (read-from-string literal))) + (rendered (car parsed))) + (unless (and (stringp rendered) (= (cdr parsed) (length literal)) + (= (1+ (cl-count ?\n rendered)) (aref encoded 1))) + (error "Native root scroll producer has invalid complete content")) + (ebox-native-reflow--expand-property-templates rendered property-templates) + (ebox-native-reflow--validate-literal-properties + rendered styles t property-templates) + (aset producer index (ebox-string-lines rendered))))) + (plist-put metadata :root-scroll-producer producer))) + metadata) + (defun ebox-native-reflow--tape-read-root-metadata (cursor metadata-byte-count record-count character-count complete-p property-template-count style-count @@ -4429,6 +4508,8 @@ typed line-width and property invariants instead of repeating them in Emacs." (+ (or (plist-get decoded :replacement-bytes) 0) (* patch-count 128) (* metadata-count 64)))) + (ebox-native-reflow--decode-root-scroll-producer + (plist-get decoded :root-render-metadata) styles property-templates t) (plist-put decoded :tape-bytes (string-bytes payload)) (plist-put decoded :artifact-byte-estimate (max (string-bytes payload) retained-estimate)) @@ -4438,6 +4519,8 @@ typed line-width and property invariants instead of repeating them in Emacs." (line-widths (aref decoded 2)) (metadata (aref decoded 3)) (fragments (aref decoded 4))) + (ebox-native-reflow--decode-root-scroll-producer + metadata styles property-templates) (unless trusted-native-encoder-p (ebox-native-reflow--validate-literal-properties rendered styles (plist-get header :complete) diff --git a/ebox-patch-plan.el b/ebox-patch-plan.el index a5df05a..fe00411 100644 --- a/ebox-patch-plan.el +++ b/ebox-patch-plan.el @@ -12,6 +12,7 @@ (require 'cl-lib) (require 'subr-x) +(require 'ebox-runtime-index) (define-error 'ebox-patch-plan-error "Invalid Ebox patch plan input") @@ -115,14 +116,14 @@ "Return OWNER-ID's strict ancestors from immutable PARENT-TABLE." (let ((ancestors (make-hash-table :test #'equal)) (seen (make-hash-table :test #'equal)) - (walk (gethash owner-id parent-table))) + (walk (ebox-runtime-index-get owner-id parent-table))) (while walk (when (gethash walk seen) (signal 'ebox-patch-plan-error (list :cyclic-parent-table :node-id walk))) (puthash walk t seen) (puthash walk t ancestors) - (setq walk (gethash walk parent-table))) + (setq walk (ebox-runtime-index-get walk parent-table))) ancestors)) (defun ebox-patch-plan--make-index () @@ -216,7 +217,7 @@ PARENT-TABLE maps a node id to its parent id and is treated as immutable. OPERATIONS are patch artifact plists. This function mutates neither input and has no publication capability." - (unless (hash-table-p parent-table) + (unless (ebox-runtime-index-like-p parent-table) (signal 'ebox-patch-plan-error (list :malformed-parent-table parent-table))) (unless (proper-list-p operations) diff --git a/ebox-runtime-index.el b/ebox-runtime-index.el new file mode 100644 index 0000000..b09302c --- /dev/null +++ b/ebox-runtime-index.el @@ -0,0 +1,237 @@ +;;; ebox-runtime-index.el --- Immutable runtime indexes for Ebox -*- lexical-binding: t; -*- + +;; SPDX-License-Identifier: GPL-3.0-or-later + +;;; Commentary: + +;; Compressed persistent radix maps used by runtime state. Branches retain +;; only nibble positions where codes diverge; leaves hold exact equal-key +;; collision buckets. Adapters continue to accept legacy hash tables. + +;;; Code: + +(require 'cl-lib) + +(cl-defstruct (ebox-runtime-index + (:constructor ebox-runtime-index--create + (root count key-set-token))) + "Immutable compressed radix index." + root + count + key-set-token) + +(defun ebox-runtime-index-empty () + "Return an empty immutable runtime index." + (ebox-runtime-index--create nil 0 nil)) + +(defun ebox-runtime-index-like-p (object) + "Return non-nil when OBJECT is a supported runtime index." + (or (ebox-runtime-index-p object) (hash-table-p object))) + +(defun ebox-runtime-index--code (key) + "Return a stable 64-bit radix code for KEY." + (logand (if (and (integerp key) (>= key 0)) key (sxhash-equal key)) + #xffffffffffffffff)) + +(defun ebox-runtime-index--branch (shift code &optional original) + "Return a fresh branch for nibble SHIFT and representative CODE. +Copy ORIGINAL when supplied; its metadata already carries SHIFT and CODE. +All branch allocations, including path copies, pass through this function." + (if original + (copy-sequence original) + (let ((branch (make-vector 18 nil))) + (aset branch 0 shift) + (aset branch 1 code) + branch))) + +(defun ebox-runtime-index--join (left left-code right right-code) + "Join LEFT and RIGHT above the first differing nibble of their codes. +LEFT-CODE and RIGHT-CODE are distinct 64-bit representative codes." + (let* ((difference (logxor left-code right-code)) + (shift (logand (logcount (1- (logand difference (- difference)))) + -4)) + (branch (ebox-runtime-index--branch shift left-code))) + (aset branch (+ 2 (logand (ash left-code (- shift)) 15)) left) + (aset branch (+ 2 (logand (ash right-code (- shift)) 15)) right) + branch)) + +(defun ebox-runtime-index--root-get (root key missing) + "Return KEY from persistent ROOT, or MISSING." + (let ((node root) + (code (ebox-runtime-index--code key))) + (while (vectorp node) + (setq node (aref node (+ 2 (logand (ash code (- (aref node 0))) 15))))) + (if (and node (= code (car node))) + (let ((pair (assoc key (cdr node)))) + (if pair (cdr pair) missing)) + missing))) + +(defun ebox-runtime-index-get (key index &optional missing) + "Return KEY's value from INDEX, or MISSING. +INDEX may be an immutable runtime index or a legacy hash table." + (cond + ((ebox-runtime-index-p index) + (ebox-runtime-index--root-get (ebox-runtime-index-root index) key missing)) + ((hash-table-p index) (gethash key index missing)) + (t missing))) + +(defun ebox-runtime-index-contains-p (key index) + "Return non-nil when INDEX contains KEY, including a nil value." + (let ((missing (make-symbol "ebox-runtime-index-missing"))) + (not (eq (ebox-runtime-index-get key index missing) missing)))) + +(defun ebox-runtime-index--root-put (root key value code) + "Return `(ROOT . ADDED)' after mapping KEY to VALUE using CODE." + (cond + ((null root) (cons (cons code (list (cons key value))) t)) + ((vectorp root) + (let ((shift (aref root 0)) + (representative (aref root 1))) + (if (/= (logand (logxor code representative) (1- (ash 1 shift))) 0) + (cons (ebox-runtime-index--join + root representative (cons code (list (cons key value))) code) + t) + (let* ((slot (+ 2 (logand (ash code (- shift)) 15))) + (result (ebox-runtime-index--root-put + (aref root slot) key value code)) + (copy (ebox-runtime-index--branch shift representative root))) + (aset copy slot (car result)) + (cons copy (cdr result)))))) + ((= code (car root)) + (let* ((bucket (cdr root)) + (present (assoc key bucket))) + (cons (cons code + (cons (cons key value) + (if present + (cl-remove key bucket :key #'car :test #'equal) + bucket))) + (not present)))) + (t (cons (ebox-runtime-index--join + root (car root) (cons code (list (cons key value))) code) + t)))) + +(defun ebox-runtime-index-put (key value index) + "Return INDEX with KEY mapped to VALUE. +Immutable indexes return a new value. Legacy hash tables are mutated and +returned for compatibility." + (cond + ((ebox-runtime-index-p index) + (let ((result + (ebox-runtime-index--root-put + (ebox-runtime-index-root index) key value + (ebox-runtime-index--code key)))) + (ebox-runtime-index--create + (car result) + (+ (ebox-runtime-index-count index) (if (cdr result) 1 0)) + (if (cdr result) + (make-symbol "ebox-runtime-index-key-set") + (ebox-runtime-index-key-set-token index))))) + ((hash-table-p index) (puthash key value index) index) + (t (signal 'wrong-type-argument (list 'ebox-runtime-index-like-p index))))) + +(defun ebox-runtime-index--root-delete (root key code) + "Return `(ROOT . REMOVED)' after deleting KEY using CODE." + (cond + ((null root) (cons nil nil)) + ((vectorp root) + (let* ((shift (aref root 0)) + (slot (+ 2 (logand (ash code (- shift)) 15))) + (result (ebox-runtime-index--root-delete (aref root slot) key code))) + (if (not (cdr result)) + (cons root nil) + (let ((children 0) survivor) + (unless (car result) + (dotimes (offset 16) + (let ((position (+ offset 2))) + (when (and (/= position slot) (aref root position)) + (cl-incf children) + (setq survivor (aref root position)))))) + (if (= children 1) + (cons survivor t) + (let ((copy (ebox-runtime-index--branch shift (aref root 1) root))) + (aset copy slot (car result)) + (cons copy t))))))) + ((and (= code (car root)) (assoc key (cdr root))) + (let ((bucket (cl-remove key (cdr root) :key #'car :test #'equal))) + (cons (and bucket (cons code bucket)) t))) + (t (cons root nil)))) + +(defun ebox-runtime-index-delete (key index) + "Return INDEX without KEY. +Immutable indexes return a new value. Legacy hash tables are mutated and +returned for compatibility." + (cond + ((ebox-runtime-index-p index) + (let ((result + (ebox-runtime-index--root-delete + (ebox-runtime-index-root index) key + (ebox-runtime-index--code key)))) + (if (cdr result) + (let ((count (1- (ebox-runtime-index-count index)))) + (ebox-runtime-index--create + (car result) count + (and (> count 0) + (make-symbol "ebox-runtime-index-key-set")))) + index))) + ((hash-table-p index) (remhash key index) index) + (t (signal 'wrong-type-argument (list 'ebox-runtime-index-like-p index))))) + +(defun ebox-runtime-index-size (index) + "Return the number of entries in INDEX." + (cond + ((ebox-runtime-index-p index) (ebox-runtime-index-count index)) + ((hash-table-p index) (hash-table-count index)) + (t 0))) + +(defun ebox-runtime-index-key-set-equal-p (left right) + "Return non-nil when supported indexes LEFT and RIGHT have equal keys." + (let ((persistent-shared-keys-p + (and (ebox-runtime-index-p left) + (ebox-runtime-index-p right) + (= (ebox-runtime-index-count left) + (ebox-runtime-index-count right)) + (eq (ebox-runtime-index-key-set-token left) + (ebox-runtime-index-key-set-token right))))) + (cond + (persistent-shared-keys-p t) + ((and (ebox-runtime-index-like-p left) + (ebox-runtime-index-like-p right) + (= (ebox-runtime-index-size left) (ebox-runtime-index-size right))) + (let ((equal-p t) + (missing (make-symbol "ebox-runtime-index-missing"))) + (ebox-runtime-index-map + (lambda (key _value) + (when (eq (ebox-runtime-index-get key right missing) missing) + (setq equal-p nil))) + left) + equal-p)) + (t nil)))) + +(defun ebox-runtime-index--map-root (function root) + "Call FUNCTION for entries below ROOT, visiting only live trie nodes." + (when root + (if (vectorp root) + (dotimes (branch 16) + (let ((child (aref root (+ 2 branch)))) + (when child (ebox-runtime-index--map-root function child)))) + (dolist (pair (cdr root)) (funcall function (car pair) (cdr pair)))))) + +(defun ebox-runtime-index-map (function index) + "Call FUNCTION with every key and value in INDEX." + (cond + ((ebox-runtime-index-p index) + (ebox-runtime-index--map-root + function (ebox-runtime-index-root index))) + ((hash-table-p index) (maphash function index)) + (t (signal 'wrong-type-argument (list 'ebox-runtime-index-like-p index)))) + nil) + +(defun ebox-runtime-index-keys (index) + "Return a list of keys in INDEX." + (let (keys) + (ebox-runtime-index-map (lambda (key _value) (push key keys)) index) + keys)) + +(provide 'ebox-runtime-index) + +;;; ebox-runtime-index.el ends here diff --git a/ebox-surface.el b/ebox-surface.el index 31e48a5..36e32be 100644 --- a/ebox-surface.el +++ b/ebox-surface.el @@ -18,6 +18,7 @@ (require 'ebox-tree) (require 'ebox-layout) (require 'ebox-incremental) +(require 'ebox-runtime-index) (require 'tp-reactive) (require 'tp-surface) (require 'tp) @@ -1644,7 +1645,7 @@ PREVIOUS-STATE's node-object 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 + (ebox-runtime-index-map (lambda (node-id node) (let ((object (gethash node-id old-objects))) (unless object @@ -1667,7 +1668,7 @@ PREVIOUS-STATE's node-object table." (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) + (unless (and (ebox-runtime-index-like-p nodes) (ebox-runtime-index-like-p parents) (hash-table-p old-objects) (or (plist-get state-overrides :native-topology-stable-p) touched-ids)) @@ -1677,7 +1678,7 @@ PREVIOUS-STATE's node-object 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 + (ebox-runtime-index-map (lambda (node-id node) (when subject-table (when-let* ((subject @@ -1697,10 +1698,10 @@ PREVIOUS-STATE's node-object table." (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))) + (let* ((node (ebox-runtime-index-get node-id nodes)) + (parent-id (and node (ebox-runtime-index-get node-id parents))) (old-object (and node (gethash node-id old-objects))) - (parent-node (and parent-id (gethash parent-id nodes))) + (parent-node (and parent-id (ebox-runtime-index-get parent-id nodes))) (position (if parent-node (or (cl-position @@ -2283,7 +2284,7 @@ RETAINED-OWNED-RANGES witnesses unchanged ownership when it is that snapshot." (progn (setq object (and objects (gethash node-id objects))) (or (not object) (not (tp-object-mounted-p object))))) - (setq node-id (and parents (gethash node-id parents)))) + (setq node-id (and parents (ebox-runtime-index-get node-id parents)))) (unless (and object (tp-object-live-p object) (tp-object-mounted-p object)) (error "Ebox scoped owner has no mounted TP object")) @@ -2728,19 +2729,19 @@ source-handle-to-subject table when the requested local preparation completed." (when (gethash node-id visiting) (error "Ebox candidate style parent cycle at %S" node-id)) (puthash node-id t visiting) - (let* ((node (gethash node-id new-nodes)) - (old-node (gethash node-id old-nodes)) - (parent-id (gethash node-id parents)) + (let* ((node (ebox-runtime-index-get node-id new-nodes)) + (old-node (ebox-runtime-index-get node-id old-nodes)) + (parent-id (ebox-runtime-index-get node-id parents)) (parent-style (and parent-id (resolve parent-id))) (parent-subject (and parent-id (gethash parent-id subjects-by-id))) (old-parent-id (and old-node - (gethash node-id + (ebox-runtime-index-get node-id (plist-get old-state :parent-table)))) (old-parent (and old-parent-id - (gethash old-parent-id old-nodes))) + (ebox-runtime-index-get old-parent-id old-nodes))) (old-parent-style (and old-parent (plist-get old-parent :ebox-computed-style))) @@ -2805,7 +2806,7 @@ source-handle-to-subject table when the requested local preparation completed." (ebox-surface--project-font node style (and parent-id - (plist-get (gethash parent-id new-nodes) + (plist-get (ebox-runtime-index-get parent-id new-nodes) :ebox-font-fact)) snapshot) (cond @@ -2827,7 +2828,7 @@ source-handle-to-subject table when the requested local preparation completed." (puthash node-id style resolved) style)))))) (dolist (node-id node-ids) - (when (gethash node-id new-nodes) + (when (ebox-runtime-index-get node-id new-nodes) (resolve node-id)))) subjects-by-handle)))) @@ -2942,7 +2943,7 @@ dependencies, or any malformed index conservatively preserves root planning." signature))))) (and (hash-table-p objects) (hash-table-p style-states) - (hash-table-p candidate-nodes) + (ebox-runtime-index-like-p candidate-nodes) dirty-set (cl-every (lambda (entry) @@ -2952,7 +2953,8 @@ dependencies, or any malformed index conservatively preserves root planning." (object (and node-id (gethash node-id objects))) (style-state (and object (gethash object style-states))) (candidate-node (and node-id - (gethash node-id candidate-nodes))) + (ebox-runtime-index-get + node-id candidate-nodes))) (subject (and style-state (plist-get style-state :subject))) (candidate-declarations @@ -3061,7 +3063,7 @@ dependencies, or any malformed index conservatively preserves root planning." (defun ebox-surface--region-object-table (state node-objects) "Return region-to-object table from candidate STATE and NODE-OBJECTS." (let ((table (make-hash-table :test 'equal))) - (maphash + (ebox-runtime-index-map (lambda (region-id node-id) (let ((object (gethash node-id node-objects))) (unless object @@ -3179,7 +3181,7 @@ available during planning; replacements here belong only to the candidate." (when (hash-table-p snapshots) (maphash (lambda (node-id snapshot) - (let* ((node (gethash node-id nodes)) + (let* ((node (ebox-runtime-index-get node-id nodes)) (fresh (and node (ebox--node-layout-snapshot nil node nil)))) (unless (and fresh (cl-every @@ -3268,8 +3270,9 @@ available during planning; replacements here belong only to the candidate." (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) + (unless (plist-get state :native-render-p) + (ebox-surface--restore-scroll-metadata + scroll-table scroll-metadata)) (plist-put state :scroll-state-table scroll-table) (plist-put state :scroll-region-ids (ebox-surface--hash-keys scroll-table)) @@ -3281,10 +3284,10 @@ available during planning; replacements here belong only to the candidate." "Attach retained TP objects to candidate nodes in STATE." (let ((nodes (plist-get state :node-table)) (objects (plist-get state :surface-node-object-table))) - (when (and (hash-table-p nodes) (hash-table-p objects)) + (when (and (ebox-runtime-index-like-p nodes) (hash-table-p objects)) (maphash (lambda (node-id object) - (when-let* ((node (gethash node-id nodes))) + (when-let* ((node (ebox-runtime-index-get node-id nodes))) (plist-put node :surface-object object))) objects)) state)) @@ -3300,7 +3303,7 @@ available during planning; replacements here belong only to the candidate." (objects (plist-get state :surface-node-object-table)) (style-states (plist-get state :style-binding-states))) (dolist (owner-id owner-ids) - (when-let* ((node (and nodes (gethash owner-id nodes))) + (when-let* ((node (and nodes (ebox-runtime-index-get owner-id nodes))) (object (and objects (gethash owner-id objects))) (style-state (and style-states (gethash object style-states))) @@ -3559,8 +3562,8 @@ properties. Its local renderer cannot prove equivalence for such an owner." (nodes (plist-get state :node-table)) (node-id owner-id) needed) - (while (and (not needed) (setq node-id (gethash node-id parents))) - (when-let* ((box (ebox-fragment-style-source-node (gethash node-id nodes)))) + (while (and (not needed) (setq node-id (ebox-runtime-index-get node-id parents))) + (when-let* ((box (ebox-fragment-style-source-node (ebox-runtime-index-get node-id nodes)))) (setq needed (or (ebox-get box :surface-properties) (ebox-surface--region-face-contributions box '(bt bb) t))))) @@ -3606,14 +3609,14 @@ Render-created paint origins compare their captured baselines instead." Compare resolved paint and surface properties, preserving opaque identities." (let ((old-id owner-id) (new-id owner-id) (same-p t)) (while (and same-p old-id new-id) - (setq old-id (gethash old-id (plist-get previous-state :parent-table)) - new-id (gethash new-id (plist-get state :parent-table)) + (setq old-id (ebox-runtime-index-get old-id (plist-get previous-state :parent-table)) + new-id (ebox-runtime-index-get new-id (plist-get state :parent-table)) same-p (equal old-id new-id)) (when (and same-p old-id) (let ((old (ebox-fragment-style-source-node - (gethash old-id (plist-get previous-state :node-table)))) + (ebox-runtime-index-get old-id (plist-get previous-state :node-table)))) (new (ebox-fragment-style-source-node - (gethash new-id (plist-get state :node-table))))) + (ebox-runtime-index-get new-id (plist-get state :node-table))))) (setq same-p (and (eq (null old) (null new)) (or (null old) @@ -3634,9 +3637,9 @@ Compare resolved paint and surface properties, preserving opaque identities." (buffer state owner-id snapshot allocated-width variable-content-p role-owned-lines-p changed-keys) "Return STATE's OWNER-ID rendered in its proven slot, or at natural size." - (let* ((node (gethash owner-id (plist-get state :node-table))) - (parent-id (gethash owner-id (plist-get state :parent-table))) - (parent (and parent-id (gethash parent-id (plist-get state :node-table))))) + (let* ((node (ebox-runtime-index-get owner-id (plist-get state :node-table))) + (parent-id (ebox-runtime-index-get owner-id (plist-get state :parent-table))) + (parent (and parent-id (ebox-runtime-index-get parent-id (plist-get state :node-table))))) (cond ((and role-owned-lines-p parent (eq (ebox-tree-display-inner parent) 'flex)) @@ -3781,9 +3784,9 @@ Only direct content characters may change. An isolated old owner render must match the published text and the new render's complete property topology. Existing strict geometry validation still applies to the returned string. Proof mismatches return nil; unexpected rendering errors propagate." - (let* ((old-node (gethash owner-id + (let* ((old-node (ebox-runtime-index-get owner-id (plist-get previous-state :node-table))) - (new-node (gethash owner-id (plist-get state :node-table))) + (new-node (ebox-runtime-index-get owner-id (plist-get state :node-table))) (regions (ebox--node-all-region-ids old-node)) (render-node (ebox-surface--owner-slot-render-node @@ -3797,9 +3800,9 @@ Proof mismatches return nil; unexpected rendering errors propagate." root t (ebox-tree-source-index root t - (when-let* ((parent-id (gethash owner-id (plist-get previous-state + (when-let* ((parent-id (ebox-runtime-index-get owner-id (plist-get previous-state :parent-table))) - (parent (gethash parent-id (plist-get previous-state + (parent (ebox-runtime-index-get parent-id (plist-get previous-state :node-table)))) (ebox-tree-display-inner parent)) (plist-get previous-state :source-index) t)))))) @@ -3807,11 +3810,11 @@ Proof mismatches return nil; unexpected rendering errors propagate." (equal regions (ebox--node-all-region-ids new-node)) (cl-every (lambda (region) - (let* ((old-id (gethash region (plist-get previous-state + (let* ((old-id (ebox-runtime-index-get region (plist-get previous-state :region-node-table))) - (new-id (gethash region (plist-get state :region-node-table))) - (old (gethash old-id (plist-get previous-state :node-table))) - (new (gethash new-id (plist-get state :node-table)))) + (new-id (ebox-runtime-index-get region (plist-get state :region-node-table))) + (old (ebox-runtime-index-get old-id (plist-get previous-state :node-table))) + (new (ebox-runtime-index-get new-id (plist-get state :node-table)))) (and (equal old-id new-id) (if role-owned-lines-p ;; The canonical candidate may replace an @@ -3821,8 +3824,8 @@ Proof mismatches return nil; unexpected rendering errors propagate." ;; topology instead of retaining the old handle. (and (eq (plist-get old :ebox-type) (plist-get new :ebox-type)) - (equal (gethash old-id (plist-get previous-state :parent-table)) - (gethash new-id (plist-get state :parent-table))) + (equal (ebox-runtime-index-get old-id (plist-get previous-state :parent-table)) + (ebox-runtime-index-get new-id (plist-get state :parent-table))) (equal (ebox-tree-node-author-key (plist-get previous-state :source-index) old) (ebox-tree-node-author-key @@ -3915,7 +3918,7 @@ Proof mismatches return nil; unexpected rendering errors propagate." (old-snapshot (and owner-id (ebox--ensure-layout-snapshot-details buffer owner-id))) (spans (and old-snapshot (plist-get old-snapshot :buffer-spans))) - (node (and owner-id (gethash owner-id (plist-get state :node-table)))) + (node (and owner-id (ebox-runtime-index-get owner-id (plist-get state :node-table)))) (render-node (and owner-id (ebox-surface--owner-slot-render-node @@ -4010,7 +4013,7 @@ Proof mismatches return nil; unexpected rendering errors propagate." (let* ((owner-id (plist-get proof :owner-id)) (old-snapshot (plist-get proof :snapshot)) (spans (plist-get old-snapshot :buffer-spans)) - (node (gethash owner-id (plist-get state :node-table))) + (node (ebox-runtime-index-get owner-id (plist-get state :node-table))) (render-node (if (and node (numberp (plist-get proof :allocated-width))) (plist-put (copy-sequence node) @@ -4251,7 +4254,7 @@ untouched." (snapshot (and proof (plist-get proof :snapshot))) (old-spans (and proof (plist-get proof :old-spans))) (node (and owner-id - (gethash owner-id (plist-get state :node-table)))) + (ebox-runtime-index-get owner-id (plist-get state :node-table)))) (allocated-width (and proof (plist-get proof :allocated-width)))) (when (and proof snapshot old-spans node @@ -4262,16 +4265,16 @@ untouched." (parent-table (plist-get state :parent-table)) (ancestor-owner-ids (let ((walk (and parent-table - (gethash owner-id parent-table))) + (ebox-runtime-index-get owner-id parent-table))) owners) (while walk (when-let* ((ancestor - (gethash walk (plist-get state :node-table))) + (ebox-runtime-index-get walk (plist-get state :node-table))) (region-id (ebox-surface--node-editable-region-id ancestor))) (setq owners (append owners (list region-id)))) - (setq walk (gethash walk parent-table))) + (setq walk (ebox-runtime-index-get walk parent-table))) owners)) (rendered (ebox--maplines @@ -4657,11 +4660,11 @@ box with wrapper chrome falls back to the retained wrapper renderer." (nodes (plist-get state :node-table))) (and (plist-get state :runtime-index-prepared-p) root-id - (hash-table-p nodes) - (eq root (gethash root-id nodes)) - (hash-table-p (plist-get state :parent-table)) + (ebox-runtime-index-like-p nodes) + (eq root (ebox-runtime-index-get root-id nodes)) + (ebox-runtime-index-like-p (plist-get state :parent-table)) (hash-table-p (plist-get state :region-id-set)) - (hash-table-p (plist-get state :region-node-table)) + (ebox-runtime-index-like-p (plist-get state :region-node-table)) (hash-table-p (plist-get state :region-box-count-table)) (hash-table-p (plist-get state :region-box-table)) (hash-table-p @@ -4767,7 +4770,7 @@ Each owner appears at most once for one rendered fragment." owners) (dolist (region-id (delete-dups (mapcar #'cdr role-ids))) (let ((object (gethash region-id region-objects)) - (node-id (gethash region-id region-node-table))) + (node-id (ebox-runtime-index-get region-id region-node-table))) (unless (and object node-id) (error "Ebox output references unknown region %S" region-id)) (unless (gethash object attached) @@ -4775,7 +4778,7 @@ Each owner appears at most once for one rendered fragment." (ebox-surface--region-role-tags region-id role-ids)) owners) (puthash object t attached)) - (setq node-id (gethash node-id parent-table)) + (setq node-id (ebox-runtime-index-get node-id parent-table)) (while node-id (let ((ancestor (gethash node-id node-objects))) (unless ancestor @@ -4783,7 +4786,7 @@ Each owner appears at most once for one rendered fragment." (unless (gethash ancestor attached) (push (list ancestor (list :ebox/descendant-output t)) owners) (puthash ancestor t attached))) - (setq node-id (gethash node-id parent-table))))) + (setq node-id (ebox-runtime-index-get node-id parent-table))))) (nreverse owners))) (defun ebox-surface--line-layout-dependencies (fragments rendered) @@ -5148,7 +5151,7 @@ Adjacent ranges merge only when both their owner and opaque tags match." (let ((attached (make-hash-table :test #'eq))) (dolist (owner owners) (puthash (car owner) t attached)) (dolist (region-id layout-region-ids) - (let ((node-id (gethash region-id + (let ((node-id (ebox-runtime-index-get region-id (plist-get state :region-node-table)))) (while node-id (let ((object (gethash node-id node-objects))) @@ -5159,7 +5162,7 @@ Adjacent ranges merge only when both their owner and opaque tags match." (push (list object '(:ebox/layout-output t)) owners) (puthash object t attached))) (setq node-id - (gethash node-id (plist-get state :parent-table)))))))) + (ebox-runtime-index-get node-id (plist-get state :parent-table)))))))) (puthash cache-key owners owner-cache)) (dolist (owner owners) (let* ((object (nth 0 owner)) @@ -5288,7 +5291,7 @@ all descendant ranges remain unchanged." (let* ((proof (plist-get state :formatting-context-reflow-proof)) (owner-id (plist-get proof :owner-id)) (owner (and owner-id (gethash owner-id node-objects))) - (node (and owner-id (gethash owner-id (plist-get state :node-table)))) + (node (and owner-id (ebox-runtime-index-get owner-id (plist-get state :node-table)))) (region-id (and node (plist-get node :region-id))) (block-start (max 0 @@ -5494,7 +5497,7 @@ NODE-OBJECTS and REGION-OBJECTS supply retained ownership ranges." (let ((key node-id) (parents (plist-get state :parent-table)) (depth 0)) - (while (setq node-id (and node-id (gethash node-id parents))) + (while (setq node-id (and node-id (ebox-runtime-index-get node-id parents))) (cl-incf depth)) (when ebox-surface--paint-node-depth-cache (puthash key depth ebox-surface--paint-node-depth-cache)) @@ -5511,14 +5514,16 @@ NODE-OBJECTS and REGION-OBJECTS supply retained ownership ranges." (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))) + (when-let* ((node-id + (ebox-runtime-index-get + (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 deepest (ebox-runtime-index-get deepest parents))) (setq chain (nreverse chain)) (when ebox-surface--paint-node-chain-cache (puthash role-ids chain ebox-surface--paint-node-chain-cache)) @@ -5580,7 +5585,7 @@ ANCESTOR-P means BOX wraps a more specific rendered surface." (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)) + (when-let* ((node (ebox-runtime-index-get node-id nodes)) (box (ebox-fragment-style-source-node node))) (let* ((region-id (plist-get box :region-id)) (roles @@ -5768,7 +5773,7 @@ relative face contributions and performs the final merge during prepare." "Return FRAGMENTS with horizontal border roles updated from STATE." (let ((nodes (plist-get state :node-table))) (dolist (dirty dirty-set fragments) - (when-let* ((node (gethash (plist-get dirty :node-id) nodes)) + (when-let* ((node (ebox-runtime-index-get (plist-get dirty :node-id) nodes)) (box (ebox-fragment-style-source-node node)) (region-id (plist-get box :region-id))) (dolist (entry '(((:border-top-width :border-top-style) @@ -5849,7 +5854,7 @@ OLD-TEXT is a defensive snapshot of the published TP content." (when (hash-table-p snapshots) (dolist (dirty dirty-set) (when-let* ((node-id (plist-get dirty :node-id)) - (node (gethash node-id nodes)) + (node (ebox-runtime-index-get node-id nodes)) (snapshot (gethash node-id snapshots))) (plist-put snapshot :style-signature (ebox-fragment-node-style-signature node))))) diff --git a/ebox-tree.el b/ebox-tree.el index 28bd633..684b695 100644 --- a/ebox-tree.el +++ b/ebox-tree.el @@ -15,6 +15,7 @@ (require 'ebox-style) (require 'ebox-node-factory) (require 'ebox-child-range) +(require 'ebox-runtime-index) (defvar ebox-tree--incoming-source-indexes nil "Transaction-local handle to incoming source-index map for one delta.") @@ -739,8 +740,8 @@ handles, reused node objects, and cycles without consulting node metadata." (defun ebox-tree--indexed-parent-context (node-id node-table parent-table) "Return the direct parent layout kind for NODE-ID in indexed tree." - (let* ((parent-id (gethash node-id parent-table)) - (parent (and parent-id (gethash parent-id node-table)))) + (let* ((parent-id (ebox-runtime-index-get node-id parent-table)) + (parent (and parent-id (ebox-runtime-index-get parent-id node-table)))) (and parent (ebox-tree--child-layout-kind parent nil)))) (defun ebox-tree-validate-indexed-participation @@ -748,7 +749,7 @@ handles, reused node objects, and cycles without consulting node metadata." "Validate NODE-IDS in indexed final tree using AUTHOR or COMPUTED SOURCE." (setq source (or source 'author)) (dolist (node-id (delete-dups (copy-sequence node-ids))) - (when-let* ((node (gethash node-id node-table))) + (when-let* ((node (ebox-runtime-index-get node-id node-table))) (ebox-tree--validate-node-parent-participation node (ebox-tree--indexed-parent-context node-id node-table parent-table) @@ -1379,12 +1380,12 @@ stale until a query or full projection materializes them." (parent-id (nth 2 entry)) parent-subject) (while (and parent-id (null parent-subject)) - (when-let* ((parent (gethash parent-id node-table))) + (when-let* ((parent (ebox-runtime-index-get parent-id node-table))) (when-let* ((parent-handle (ebox-tree-node-source-handle parent))) (setq parent-subject (node-subject parent-handle)))) (unless parent-subject - (setq parent-id (gethash parent-id parent-table)))) + (setq parent-id (ebox-runtime-index-get parent-id parent-table)))) (setf (ecss-subject-parent subject) parent-subject) (puthash (ebox-source--handle-id-view handle) subject added-subjects) diff --git a/ebox.el b/ebox.el index 16eaab0..b8d9d1f 100644 --- a/ebox.el +++ b/ebox.el @@ -23,7 +23,8 @@ "Directory containing the active Ebox Lisp sources.") (defconst ebox--compile-sources - '("ebox-cache.el" "ebox-source.el" "ebox-state-contract.el" + '("ebox-cache.el" "ebox-source.el" "ebox-runtime-index.el" + "ebox-state-contract.el" "ebox-font.el" "ebox-style.el" "ebox-layout-config.el" "ebox-node-factory.el" @@ -44,6 +45,7 @@ (require 'subr-x) (require 'ebox-cache) (require 'ebox-source) +(require 'ebox-runtime-index) (require 'ebox-state-contract) (require 'ebox-font) (require 'ebox-style) @@ -1158,7 +1160,7 @@ remain transaction-local." (let* ((template-box (plist-get template :box)) (node-id (and template-box (ebox--ensure-node-id template-box))) (node-table (ebox--buffer-node-table (current-buffer)))) - (or (and node-table node-id (gethash node-id node-table)) + (or (and node-table node-id (ebox-runtime-index-get node-id node-table)) (plist-get (ebox--scroll-get-state region-id) :box) (gethash region-id ebox--region-box-table)))) @@ -1181,7 +1183,7 @@ remain transaction-local." (or (ebox-get effective-box :scroll-offset) 0)) (or portable (null node-table) - (eq box (gethash node-id node-table))) + (eq box (ebox-runtime-index-get node-id node-table))) (or (null live-state) (eq effective-box (plist-get live-state :box))) (not (plist-get live-state :lazy-scroll-prefix-dirty)) @@ -1916,14 +1918,34 @@ uses their rendered descendants." (cl-loop for line in lines for line-index from 0 do - (dolist (region-id (ebox--scroll-line-region-ids line)) - (when-let* ((span (ebox--scroll-line-region-span - line (ebox--region-id-set - (list region-id))))) - (puthash region-id - (cons (cons line-index span) - (gethash region-id index)) - index)))) + (let ((spans (make-hash-table :test 'equal)) + (pos 0) + (limit (length line))) + ;; Aggregate all owners during the same property-run scan. + ;; A foreign direct content owner invalidates that region's + ;; entire hull, exactly as the single-region query does. + (while (< pos limit) + (let ((next (ebox--string-next-region-property-change + line pos limit)) + (roles (ebox--string-region-role-ids-at line pos))) + (dolist (id (ebox--string-region-ids-at line pos)) + (let ((span (gethash id spans))) + (unless (eq span 'incompatible) + (cond + ((cl-some (lambda (role) + (and (eq (car role) 'content) + (not (equal (cdr role) id)))) + roles) + (puthash id 'incompatible spans)) + (span (setcdr span next)) + (t (puthash id (cons pos next) spans)))))) + (setq pos (max next (1+ pos))))) + (maphash + (lambda (id span) + (unless (eq span 'incompatible) + (puthash id (cons (cons line-index span) + (gethash id index)) index))) + spans))) (maphash (lambda (region-id spans) (puthash region-id (nreverse spans) index)) index) @@ -2485,7 +2507,8 @@ publishes the prepared rendered-body entry plus its derived root templates." (puthash buffer (run-with-idle-timer - (max 0 ebox-runtime-idle-reflow-cache-prewarm-delay) + (ebox--idle-continuation-delay + ebox-runtime-idle-reflow-cache-prewarm-delay) nil #'ebox--run-root-width-cache-prewarm buffer state revision region-id source-width target-width) ebox--reflow-cache-prewarm-timers))))) @@ -2527,7 +2550,8 @@ predictions are skipped." (puthash buffer (run-with-idle-timer - (max 0 ebox-runtime-idle-reflow-cache-prewarm-delay) + (ebox--idle-continuation-delay + ebox-runtime-idle-reflow-cache-prewarm-delay) nil #'ebox--run-reflow-cache-prewarm buffer state revision viewport-width target-width viewport-height) ebox--reflow-cache-prewarm-timers)))))) @@ -2958,7 +2982,9 @@ Scroll routing uses semantic ancestry rather than mount span length because nested owners may render equal-sized clipped spans." (let* ((state (ebox--buffer-render-state (current-buffer))) (region-nodes (plist-get state :region-node-table)) - (node-id (and region-nodes (gethash region-id region-nodes)))) + (node-id + (and region-nodes + (ebox-runtime-index-get region-id region-nodes)))) (if node-id (ebox-surface--node-depth state node-id) -1))) (defun ebox--scroll-region-ids-at-pos (pos) @@ -3190,7 +3216,9 @@ inside the old prefix, so a line-slide never mixes two layout versions." (setq value (plist-put value :retained-scroll-content-p - (and scroll-patch-fast-p root-id owner-id + (and (eq (plist-get commit-input :projection-kind) + 'scroll-patch) + scroll-patch-fast-p root-id owner-id (= root-id owner-id)))) (if (plist-get transition :native-materialize-p) (plist-put value :native-scroll-materialize-p t) @@ -4113,7 +4141,7 @@ cannot be changed through the unpublished batch root." entries) (let ((parents (ebox--buffer-parent-table buffer)) ancestors) (dolist (node-id node-ids) - (while (setq node-id (gethash node-id parents)) + (while (setq node-id (ebox-runtime-index-get node-id parents)) (push node-id ancestors))) (delete-dups ancestors)))) @@ -4125,7 +4153,7 @@ cannot be changed through the unpublished batch root." (let ((parents (ebox--buffer-parent-table buffer))) (delete-dups (delq nil - (mapcar (lambda (node-id) (gethash node-id parents)) + (mapcar (lambda (node-id) (ebox-runtime-index-get node-id parents)) node-ids)))))) (defun ebox--publish-scoped-region-candidate diff --git a/native/src/atom_plan.rs b/native/src/atom_plan.rs new file mode 100644 index 0000000..9f0553d --- /dev/null +++ b/native/src/atom_plan.rs @@ -0,0 +1,786 @@ +use std::cell::Cell; +use std::sync::Arc; + +use crate::sequence::{Entry, Measure, Node, NodeView, Sequence, Work}; + +use super::{Atom, AtomProperties, RegionRole, RegionRoleEntry}; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "kebab-case")] +pub(crate) struct AtomPlanWork { + pub(crate) sequence_nodes_created: u64, + pub(crate) sequence_entries_written: u64, + pub(crate) materialized_atoms: u64, + pub(crate) property_applications: u64, + pub(crate) atom_clones: u64, + pub(crate) operation_block_writes: u64, +} + +thread_local! { + static WORK: Cell = Cell::new(AtomPlanWork::default()); +} + +pub(crate) fn reset_work() { + WORK.set(AtomPlanWork::default()); +} + +pub(crate) fn work() -> AtomPlanWork { + WORK.get() +} + +fn record_sequence(work: Work) { + WORK.set(WORK.get().checked_add(AtomPlanWork { + sequence_nodes_created: work.nodes_created, + sequence_entries_written: work.entries_written, + materialized_atoms: 0, + property_applications: 0, + atom_clones: 0, + operation_block_writes: 0, + })); +} + +fn record_materialized(count: usize) { + let count = u64::try_from(count).expect("validated native atom count fits u64"); + WORK.set(WORK.get().checked_add(AtomPlanWork { + materialized_atoms: count, + atom_clones: count, + ..AtomPlanWork::default() + })); +} + +fn record_atom_clones(count: usize) { + let count = u64::try_from(count).expect("validated native atom count fits u64"); + WORK.set(WORK.get().checked_add(AtomPlanWork { + atom_clones: count, + ..AtomPlanWork::default() + })); +} + +fn record_operation_block_writes(count: usize) { + let count = u64::try_from(count).expect("native atom decoration block fits u64"); + WORK.set(WORK.get().checked_add(AtomPlanWork { + operation_block_writes: count, + ..AtomPlanWork::default() + })); +} + +fn record_property_application() { + WORK.set(WORK.get().checked_add(AtomPlanWork { + property_applications: 1, + ..AtomPlanWork::default() + })); +} + +impl AtomPlanWork { + pub(crate) fn accumulate(&mut self, other: Self) { + let combined = Self { + sequence_nodes_created: self + .sequence_nodes_created + .checked_add(other.sequence_nodes_created) + .expect("native atom plan node work counter overflow"), + sequence_entries_written: self + .sequence_entries_written + .checked_add(other.sequence_entries_written) + .expect("native atom plan entry work counter overflow"), + materialized_atoms: self + .materialized_atoms + .checked_add(other.materialized_atoms) + .expect("native atom plan materialization counter overflow"), + property_applications: self + .property_applications + .checked_add(other.property_applications) + .expect("native atom plan property work counter overflow"), + atom_clones: self + .atom_clones + .checked_add(other.atom_clones) + .expect("native atom plan clone work counter overflow"), + operation_block_writes: self + .operation_block_writes + .checked_add(other.operation_block_writes) + .expect("native atom plan operation work counter overflow"), + }; + *self = combined; + } + + fn checked_add(mut self, other: Self) -> Self { + self.accumulate(other); + self + } +} + +#[derive(Clone, Copy, Debug, Default)] +struct RunSummary { + prefix: i64, + suffix: i64, + maximum: i64, + all_run: bool, +} + +impl RunSummary { + fn atom(atom: &Atom) -> Self { + if atom.wrap_space() { + Self::default() + } else if atom.wrap_cjk() { + Self { + maximum: atom.width(), + ..Self::default() + } + } else { + Self { + prefix: atom.width(), + suffix: atom.width(), + maximum: atom.width(), + all_run: true, + } + } + } + + fn combine(self, other: Self) -> Self { + let cross = self + .suffix + .checked_add(other.prefix) + .expect("validated native atom widths fit i64"); + Self { + prefix: if self.all_run { + self.prefix + .checked_add(other.prefix) + .expect("validated native atom widths fit i64") + } else { + self.prefix + }, + suffix: if other.all_run { + self.suffix + .checked_add(other.suffix) + .expect("validated native atom widths fit i64") + } else { + other.suffix + }, + maximum: self.maximum.max(other.maximum).max(cross), + all_run: self.all_run && other.all_run, + } + } +} + +#[derive(Clone, Debug, Default)] +struct Summary { + atom_count: usize, + any_owner: bool, + any_content: bool, + noncontent: bool, + whitespace: bool, + first_properties: Option, + runs: RunSummary, +} + +impl Summary { + fn atom(atom: &Atom) -> Self { + let properties = atom.properties(); + Self { + atom_count: 1, + any_owner: properties.owner.is_some() || !properties.owners.is_empty(), + any_content: properties.content.is_some(), + noncontent: !properties.style_ids.is_empty() + || !properties.roles.is_empty() + || !properties.property_template_ids.is_empty(), + whitespace: match atom { + Atom::Text { text, .. } => text.trim().is_empty(), + Atom::Space { .. } => true, + }, + first_properties: Some(properties.clone()), + runs: RunSummary::atom(atom), + } + } + + fn combine(self, other: Self) -> Self { + Self { + atom_count: self + .atom_count + .checked_add(other.atom_count) + .expect("validated native atom count fits usize"), + any_owner: self.any_owner || other.any_owner, + any_content: self.any_content || other.any_content, + noncontent: self.noncontent || other.noncontent, + whitespace: self.whitespace && other.whitespace, + first_properties: self.first_properties.or(other.first_properties), + runs: self.runs.combine(other.runs), + } + } + + fn atoms(atoms: &[Atom]) -> Self { + let first = atoms.first().expect("nonempty native atom block"); + let mut summary = Self { + atom_count: 0, + any_owner: false, + any_content: false, + noncontent: false, + whitespace: true, + first_properties: Some(first.properties().clone()), + runs: RunSummary { + all_run: true, + ..RunSummary::default() + }, + }; + for atom in atoms { + let properties = atom.properties(); + summary.atom_count = summary + .atom_count + .checked_add(1) + .expect("validated native atom count fits usize"); + summary.any_owner |= properties.owner.is_some() || !properties.owners.is_empty(); + summary.any_content |= properties.content.is_some(); + summary.noncontent |= !properties.style_ids.is_empty() + || !properties.roles.is_empty() + || !properties.property_template_ids.is_empty(); + summary.whitespace &= match atom { + Atom::Text { text, .. } => text.trim().is_empty(), + Atom::Space { .. } => true, + }; + summary.runs = summary.runs.combine(RunSummary::atom(atom)); + } + summary + } +} + +#[derive(Clone, Debug)] +enum Decoration { + OwnContent { + region: i64, + index: i64, + had_owner: bool, + had_content: bool, + }, + Style(u32), + Template(u32), + Role(RegionRole, i64), + ScrollWindow(i64), +} + +#[derive(Clone, Debug)] +enum Part { + Atoms(Box<[Atom]>), + Decorated { + input: Arc, + decorations: Box<[Decoration]>, + }, +} + +#[must_use] +#[derive(Debug, Clone)] +pub(super) struct AtomPlan { + parts: Sequence, + summary: Summary, +} + +impl Default for AtomPlan { + fn default() -> Self { + Self { + parts: Sequence::empty(), + summary: Summary { + whitespace: true, + ..Summary::default() + }, + } + } +} + +impl AtomPlan { + pub(super) fn from_atoms(atoms: &[Atom]) -> Self { + record_atom_clones(atoms.len()); + Self::from_owned_atoms(atoms.to_vec()) + } + + pub(super) fn from_owned_atoms(atoms: Vec) -> Self { + if atoms.is_empty() { + return Self::default(); + } + let summary = Summary::atoms(&atoms); + let mut sequence_work = Work::default(); + let mut entries = Vec::with_capacity(atoms.len().div_ceil(16)); + let mut atoms = atoms.into_iter(); + loop { + let chunk = atoms.by_ref().take(16).collect::>(); + if chunk.is_empty() { + break; + } + let measure = atoms_measure(&chunk); + entries.push( + Entry::new(Arc::new(Part::Atoms(chunk.into_boxed_slice())), measure) + .expect("validated native atom measure is nonnegative"), + ); + } + let parts = Sequence::from_entries(entries, &mut sequence_work) + .expect("validated native atom sequence aggregates fit limits"); + record_sequence(sequence_work); + Self { parts, summary } + } + + pub(super) fn is_empty(&self) -> bool { + self.parts.is_empty() + } + + pub(super) fn char_count(&self) -> u64 { + self.parts.measure().chars + } + + pub(super) fn append(&self, other: &Self) -> Self { + if self.is_empty() { + return other.clone(); + } + if other.is_empty() { + return self.clone(); + } + let mut sequence_work = Work::default(); + let parts = self + .parts + .concat(&other.parts, &mut sequence_work) + .expect("validated native atom sequence aggregates fit limits"); + record_sequence(sequence_work); + Self { + parts, + summary: self.summary.clone().combine(other.summary.clone()), + } + } + + pub(super) fn push(&self, atom: Atom) -> Self { + self.append(&Self::from_single(atom)) + } + + pub(super) fn prepend(&self, atom: Atom) -> Self { + Self::from_single(atom).append(self) + } + + pub(super) fn to_vec(&self) -> Vec { + let mut output = Vec::with_capacity(self.summary.atom_count); + if let Some(root) = self.parts.root() { + materialize_node(root, &mut output); + } + record_materialized(output.len()); + output + } + + pub(super) fn first(&self) -> Option { + first_node(self.parts.root()?) + } + + pub(super) fn whitespace_only(&self) -> bool { + self.summary.whitespace + } + + pub(super) fn has_noncontent_properties(&self) -> bool { + self.summary.noncontent + } + + pub(super) fn own_content(&self, region: i64, index: i64) -> Self { + if self.is_empty() { + return self.clone(); + } + self.decorate(Decoration::OwnContent { + region, + index, + had_owner: self.summary.any_owner, + had_content: self.summary.any_content, + }) + } + + pub(super) fn apply_style(&self, style: u32) -> Self { + self.decorate(Decoration::Style(style)) + } + + pub(super) fn apply_template(&self, template: u32) -> Self { + self.decorate(Decoration::Template(template)) + } + + pub(super) fn apply_role(&self, role: RegionRole, region: i64) -> Self { + self.decorate(Decoration::Role(role, region)) + } + + pub(super) fn apply_scroll_window(&self, region: i64) -> Self { + self.decorate(Decoration::ScrollWindow(region)) + } + + pub(super) fn min_content_width(&self) -> i64 { + self.summary.runs.maximum + } + + pub(super) fn width(&self) -> i64 { + self.parts.measure().width_sum + } + + fn from_single(atom: Atom) -> Self { + let summary = Summary::atom(&atom); + let measure = atom_measure(&atom); + let mut sequence_work = Work::default(); + let parts = Sequence::from_entries( + [Entry::new( + Arc::new(Part::Atoms(vec![atom].into_boxed_slice())), + measure, + ) + .expect("validated native atom measure is nonnegative")], + &mut sequence_work, + ) + .expect("single native atom sequence fits limits"); + record_sequence(sequence_work); + Self { parts, summary } + } + + fn decorate(&self, decoration: Decoration) -> Self { + if self.is_empty() { + return self.clone(); + } + let mut summary = self.summary.clone(); + if let Some(properties) = &mut summary.first_properties { + apply_decoration(properties, &decoration); + } + match &decoration { + Decoration::OwnContent { .. } => { + summary.any_owner = true; + summary.any_content = true; + } + Decoration::Style(_) | Decoration::Template(_) | Decoration::Role(_, _) => { + summary.noncontent = true; + } + Decoration::ScrollWindow(_) => {} + } + let (input, decorations) = self + .parts + .get(0) + .filter(|_| self.parts.len() == 1) + .and_then(|entry| match entry.value().as_ref() { + Part::Decorated { input, decorations } if decorations.len() < 16 => { + let mut combined = decorations.to_vec(); + combined.push(decoration.clone()); + Some((input.clone(), combined.into_boxed_slice())) + } + _ => None, + }) + .unwrap_or_else(|| (Arc::new(self.clone()), vec![decoration].into_boxed_slice())); + record_operation_block_writes(decorations.len()); + let measure = self.parts.measure(); + let mut sequence_work = Work::default(); + let parts = Sequence::from_entries( + [ + Entry::new(Arc::new(Part::Decorated { input, decorations }), measure) + .expect("validated native atom plan measure is nonnegative"), + ], + &mut sequence_work, + ) + .expect("decorated native atom sequence fits limits"); + record_sequence(sequence_work); + Self { parts, summary } + } +} + +fn atom_measure(atom: &Atom) -> Measure { + let chars = match atom { + Atom::Text { text, .. } => text.chars().count() as u64, + Atom::Space { .. } => 1, + }; + Measure::new(chars, 0, atom.width(), atom.width()) + .expect("validated native atom width is nonnegative") +} + +fn atoms_measure(atoms: &[Atom]) -> Measure { + atoms.iter().fold(Measure::default(), |measure, atom| { + measure + .checked_combine(atom_measure(atom)) + .expect("validated native atom measures fit limits") + }) +} + +fn apply_decoration(properties: &mut AtomProperties, decoration: &Decoration) { + record_property_application(); + match decoration { + Decoration::OwnContent { + region, + index, + had_owner, + had_content, + } => { + if *had_owner { + let mut owners = properties.owners.clone(); + if let Some(owner) = properties.owner { + if !owners.contains(&owner) { + owners.push(owner); + } + } + if !owners.contains(region) { + owners.push(*region); + } + properties.owner = Some(*region); + properties.owners = owners; + } else { + properties.owner = Some(*region); + properties.owners = vec![*region]; + } + if !had_content { + properties.content = Some(*region); + properties.content_idx = Some(*index); + } + } + Decoration::Style(style) => properties.style_ids.push(*style), + Decoration::Template(template) => properties.property_template_ids.push(*template), + Decoration::Role(role, region) => properties.roles.push(RegionRoleEntry { + role: *role, + region_id: *region, + }), + Decoration::ScrollWindow(region) => properties.scroll_window = Some(*region), + } +} + +fn decorate_atom(mut atom: Atom, decoration: &Decoration) -> Atom { + apply_decoration(atom.properties_mut(), decoration); + atom +} + +fn materialize_node(node: &Arc>, output: &mut Vec) { + match node.view() { + NodeView::Leaf { entries, .. } => { + for entry in entries { + match entry.value().as_ref() { + Part::Atoms(atoms) => output.extend(atoms.iter().cloned()), + Part::Decorated { input, decorations } => { + let start = output.len(); + if let Some(root) = input.parts.root() { + materialize_node(root, output); + } + for atom in &mut output[start..] { + for decoration in decorations { + apply_decoration(atom.properties_mut(), decoration); + } + } + } + } + } + } + NodeView::Branch { left, right, .. } => { + materialize_node(left, output); + materialize_node(right, output); + } + } +} + +fn first_node(node: &Arc>) -> Option { + match node.view() { + NodeView::Leaf { entries, .. } => match entries.first()?.value().as_ref() { + Part::Atoms(atoms) => { + record_materialized(1); + Some(atoms.first()?.clone()) + } + Part::Decorated { input, decorations } => input + .first() + .map(|atom| decorations.iter().fold(atom, decorate_atom)), + }, + NodeView::Branch { left, .. } => first_node(left), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn text(value: &str, width: i64, cjk: bool, space: bool) -> Atom { + Atom::Text { + text: value.to_owned(), + width, + cjk, + space, + properties: AtomProperties::default(), + } + } + + #[test] + fn decoration_is_lazy_ordered_and_new_padding_is_not_retroactive() { + reset_work(); + let content = AtomPlan::from_atoms(&[text("x", 1, false, false)]) + .apply_style(3) + .own_content(7, 2); + let plan = content.prepend(Atom::Space { + width: 4, + properties: AtomProperties::default(), + }); + assert_eq!(work().materialized_atoms, 0); + let atoms = plan.to_vec(); + assert!(atoms[0].properties().style_ids.is_empty()); + assert_eq!(atoms[1].properties().style_ids, vec![3]); + assert_eq!(atoms[1].properties().content, Some(7)); + assert_eq!(atoms[1].properties().content_idx, Some(2)); + assert_eq!(work().materialized_atoms, 2); + } + + #[test] + fn own_content_uses_whole_line_owner_and_content_branches() { + let mut owned = text("a", 1, false, false); + owned.properties_mut().owner = Some(5); + owned.properties_mut().owners = vec![5]; + owned.properties_mut().content = Some(4); + let plan = AtomPlan::from_atoms(&[owned, text("b", 1, false, false)]).own_content(9, 3); + let atoms = plan.to_vec(); + assert_eq!(atoms[0].properties().content, Some(4)); + assert_eq!(atoms[1].properties().content, None); + assert_eq!(atoms[0].properties().owner, Some(9)); + assert_eq!(atoms[0].properties().owners, vec![5, 9]); + assert_eq!(atoms[1].properties().owner, Some(9)); + assert_eq!(atoms[1].properties().owners, vec![9]); + } + + #[test] + fn min_content_summary_matches_space_and_cjk_breaks() { + let plan = AtomPlan::from_atoms(&[ + text("ab", 2, false, false), + text(" ", 1, false, true), + text("界", 4, true, false), + text("cd", 3, false, false), + ]); + assert_eq!(plan.min_content_width(), 4); + assert!(!plan.whitespace_only()); + assert!(!plan.has_noncontent_properties()); + assert!(!plan.is_empty()); + assert!(AtomPlan::default().first().is_none()); + } + + #[test] + fn append_shares_parts_and_materializes_once() { + reset_work(); + let left = AtomPlan::from_atoms(&[text("a", 1, false, false)]); + let right = AtomPlan::from_owned_atoms(vec![text("b", 2, false, false)]); + let plan = left.append(&right).apply_template(2).apply_scroll_window(8); + assert_eq!(plan.width(), 3); + assert_eq!(work().materialized_atoms, 0); + let atoms = plan.to_vec(); + assert_eq!(atoms.len(), 2); + assert_eq!(atoms[0].properties().property_template_ids, vec![2]); + assert_eq!(atoms[1].properties().scroll_window, Some(8)); + } + + #[test] + fn wide_append_and_decorations_do_no_atom_work_until_materialized() { + for size in [32, 128, 512, 8192] { + let atoms = (0..size) + .map(|_| text("x", 1, false, false)) + .collect::>(); + let plan = AtomPlan::from_atoms(&atoms); + reset_work(); + let combined = plan + .append(&plan) + .own_content(7, 0) + .apply_style(1) + .apply_template(2) + .apply_role(RegionRole::PaddingLeft, 7) + .apply_scroll_window(7); + let lazy = work(); + assert_eq!(lazy.materialized_atoms, 0); + assert_eq!(lazy.sequence_nodes_created, 6); + assert!(lazy.sequence_entries_written <= 21, "{lazy:?}"); + assert_eq!(lazy.property_applications, 5); + assert_eq!(lazy.atom_clones, 0); + assert_eq!(lazy.operation_block_writes, 15); + + let materialized = combined.to_vec(); + assert_eq!(materialized.len(), size * 2); + let eager = work(); + assert_eq!(eager.materialized_atoms, (size * 2) as u64); + assert_eq!(eager.property_applications, 5 + (size * 2 * 5) as u64); + } + } + + #[test] + fn min_content_randomized_summary_matches_vec_oracle() { + fn oracle(atoms: &[Atom]) -> i64 { + let mut maximum = 0; + let mut run = 0_i64; + for atom in atoms { + if atom.wrap_space() { + maximum = maximum.max(run); + run = 0; + } else if atom.wrap_cjk() { + maximum = maximum.max(run).max(atom.width()); + run = 0; + } else { + run += atom.width(); + } + } + maximum.max(run) + } + + let mut seed = 0x5e91_7ac3_u64; + for size in 0..257 { + let mut atoms = Vec::with_capacity(size); + for _ in 0..size { + seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1); + let width = ((seed >> 32) % 8 + 1) as i64; + atoms.push(match seed % 5 { + 0 => text(" ", width, false, true), + 1 => text("界", width, true, false), + _ => text("x", width, false, false), + }); + } + let split = size / 3; + let plan = AtomPlan::default() + .append(&AtomPlan::from_atoms(&atoms[..split])) + .append(&AtomPlan::default()) + .append(&AtomPlan::from_atoms(&atoms[split..])); + assert_eq!(plan.min_content_width(), oracle(&atoms)); + assert_eq!(plan.is_empty(), atoms.is_empty()); + } + } + + #[test] + fn first_counts_one_atom_and_each_lazy_property_application() { + let plan = AtomPlan::from_atoms(&[text("a", 1, false, false), text("b", 1, false, false)]) + .apply_style(3) + .apply_template(4); + reset_work(); + let first = plan.first().unwrap(); + assert_eq!(first.properties().style_ids, vec![3]); + assert_eq!(first.properties().property_template_ids, vec![4]); + assert_eq!(work().materialized_atoms, 1); + assert_eq!(work().property_applications, 2); + assert_eq!(work().atom_clones, 1); + } + + #[test] + fn decoration_blocks_are_bounded_ordered_and_keep_old_fork() { + let base = AtomPlan::from_owned_atoms(vec![text("x", 1, false, false)]); + let fork = base.clone(); + let old_root = Arc::clone(base.parts.root().unwrap()); + reset_work(); + let decorated = (0..33).fold(base, |plan, style| plan.apply_style(style)); + assert!(Arc::ptr_eq(fork.parts.root().unwrap(), &old_root)); + assert_eq!(work().materialized_atoms, 0); + assert_eq!(work().operation_block_writes, 273); + + let mut block_lengths = Vec::new(); + let mut current = &decorated; + loop { + let part = current.parts.get(0).unwrap().value().as_ref(); + match part { + Part::Decorated { input, decorations } => { + block_lengths.push(decorations.len()); + current = input; + } + Part::Atoms(_) => break, + } + } + assert_eq!(block_lengths, vec![1, 16, 16]); + assert_eq!( + decorated.to_vec()[0].properties().style_ids, + (0..33).collect::>() + ); + assert!(fork.to_vec()[0].properties().style_ids.is_empty()); + } + + #[test] + fn wide_owned_bootstrap_writes_one_sequence_entry_per_atom_block() { + let atoms = (0..8192) + .map(|_| text("x", 1, false, false)) + .collect::>(); + reset_work(); + let plan = AtomPlan::from_owned_atoms(atoms); + let bootstrap = work(); + assert_eq!(bootstrap.atom_clones, 0); + assert_eq!(bootstrap.sequence_entries_written, 512); + assert_eq!(bootstrap.sequence_nodes_created, 63); + assert_eq!(plan.width(), 8192); + assert_eq!(plan.summary.atom_count, 8192); + } +} diff --git a/native/src/composition.rs b/native/src/composition.rs new file mode 100644 index 0000000..c44c1c8 --- /dev/null +++ b/native/src/composition.rs @@ -0,0 +1,606 @@ +//! Current Column slots and their persistent composition. Shape contains only +//! original structural paths; target nodes are read from the current document. + +use std::sync::Arc; + +use super::evaluation::{record, EvalRecord, EvalWork, RenderScope, RenderedChange}; +use super::line_plan::{ChangeKind, LineOp, LinePlan, LineSplice, PlanChange, ProjectionState}; +use super::{LayoutContext, LayoutNode, Line, LocalStep, Rendered}; + +#[derive(Debug, Default)] +struct ShapeRoute { + slot: Option, + next: Box<[(LocalStep, ShapeRoute)]>, +} + +#[derive(Debug)] +struct ColumnShape { + paths: Box<[Arc<[LocalStep]>]>, + routes: ShapeRoute, +} + +impl ColumnShape { + fn new(paths: Vec>) -> Arc { + // column_leaves supplies paths in original lexicographic order. Freeze + // each prefix group once; updates only binary-search these shared arrays. + fn build(paths: &[Arc<[LocalStep]>], first: usize, depth: usize) -> ShapeRoute { + if paths.first().is_some_and(|path| path.len() == depth) { + assert_eq!(paths.len(), 1, "one leaf at each original Column path"); + return ShapeRoute { + slot: Some(first), + next: Box::new([]), + }; + } + let mut next = Vec::new(); + let mut start = 0; + while start < paths.len() { + let step = paths[start][depth]; + let mut end = start + 1; + while end < paths.len() && paths[end][depth] == step { + end += 1; + } + record(EvalWork { + column_shape_steps_visited: (end - start) as u64, + ..EvalWork::default() + }); + next.push((step, build(&paths[start..end], first + start, depth + 1))); + start = end; + } + ShapeRoute { + slot: None, + next: next.into_boxed_slice(), + } + } + let routes = build(&paths, 0, 0); + Arc::new(Self { + paths: paths.into_boxed_slice(), + routes, + }) + } + + fn dirty_slots(&self, scope: &RenderScope<'_>) -> Vec { + fn collect( + route: &ShapeRoute, + scope: &RenderScope<'_>, + path: &mut Vec, + all: bool, + slots: &mut Vec, + ) { + record(EvalWork { + column_shape_steps_visited: 1, + ..EvalWork::default() + }); + if let Some(slot) = route.slot { + slots.push(slot); + return; + } + let dirty = scope.dirty_route(path); + if all || dirty.local { + for (step, child) in &route.next { + path.push(*step); + collect(child, scope, path, true, slots); + path.pop(); + } + } else { + for step in dirty.next { + if let Ok(index) = route.next.binary_search_by_key(&step, |(key, _)| *key) { + path.push(step); + collect(&route.next[index].1, scope, path, false, slots); + path.pop(); + } + } + } + } + let mut slots = Vec::new(); + collect(&self.routes, scope, &mut Vec::new(), false, &mut slots); + slots + } + + fn scope<'a>(&self, scope: &RenderScope<'a>, index: usize) -> Result, String> { + let mut result = scope.clone(); + for step in self.paths[index].iter() { + record(EvalWork { + column_shape_steps_visited: 1, + ..EvalWork::default() + }); + let (LayoutNode::Column { children, .. }, LocalStep::ColumnChild(index)) = + (result.view.node, step) + else { + return Err("Native retained Column shape lost its original path".to_owned()); + }; + let child = children + .get(*index) + .ok_or_else(|| "Native retained Column shape lost its child slot".to_owned())?; + result = result.child(*step, child); + } + Ok(result) + } +} + +#[derive(Debug)] +struct ColumnSlot { + record: Option>, + raw: LinePlan, + normalized: LinePlan, + projection: Option>, +} + +impl ColumnSlot { + fn normalize( + raw: LinePlan, + child: Option>, + proof: Option<&PlanChange>, + target: i64, + previous: Option<&Self>, + ) -> (Arc, Option) { + let extra = (target - raw.first_width()).max(0); + let (normalized, projection, change) = if extra == 0 { + let change = previous.map(|old| { + proof + .filter(|proof| proof.applies_to(&old.normalized, &raw)) + .cloned() + .unwrap_or_else(|| PlanChange::replace_all(&old.normalized, &raw)) + }); + (raw.clone(), None, change) + } else { + let ops = Arc::from([LineOp::AppendSpace(extra), LineOp::ClearBreaks]); + if let Some(old) = previous.and_then(|old| old.projection.as_ref()) { + let update = old.update(raw.clone(), proof, ops); + ( + update.state.plan().clone(), + Some(update.state), + Some(update.change), + ) + } else { + let projection = ProjectionState::new(raw.clone(), ops); + let normalized = projection.plan().clone(); + let change = + previous.map(|old| PlanChange::replace_all(&old.normalized, &normalized)); + (normalized, Some(projection), change) + } + }; + ( + Arc::new(Self { + record: child, + raw, + normalized, + projection, + }), + change, + ) + } +} + +#[derive(Clone, Copy, Debug, Default)] +struct ColumnMeasure { + slots: usize, + lines: usize, + max_first: i64, + max_width: i64, + max_width_minus_first: Option, + first_nonempty: Option, +} + +impl ColumnMeasure { + fn leaf(slot: &ColumnSlot) -> Self { + let first = slot.raw.first_width(); + let width = slot.raw.max_width(); + Self { + slots: 1, + lines: slot.raw.len(), + max_first: first, + max_width: width, + max_width_minus_first: (!slot.raw.is_empty()).then_some(width - first), + first_nonempty: (!slot.raw.is_empty()).then_some(first), + } + } + + fn combine(self, other: Self) -> Self { + Self { + slots: self.slots + other.slots, + lines: self.lines + other.lines, + max_first: self.max_first.max(other.max_first), + max_width: self.max_width.max(other.max_width), + max_width_minus_first: self.max_width_minus_first.max(other.max_width_minus_first), + first_nonempty: self.first_nonempty.or(other.first_nonempty), + } + } + + fn normalized_max(self, target: i64) -> i64 { + self.max_width_minus_first + .map_or(0, |delta| self.max_width.max(target + delta)) + } +} + +#[derive(Debug)] +enum ColumnNode { + Leaf { + slot: Arc, + measure: ColumnMeasure, + }, + Branch { + left: Arc, + right: Arc, + measure: ColumnMeasure, + lines: LinePlan, + }, +} + +impl ColumnNode { + fn leaf(slot: Arc) -> Arc { + record(EvalWork { + column_tree_nodes_created: 1, + ..EvalWork::default() + }); + Arc::new(Self::Leaf { + measure: ColumnMeasure::leaf(&slot), + slot, + }) + } + + fn branch(left: Arc, right: Arc, previous: Option<&Self>) -> Arc { + record(EvalWork { + column_tree_nodes_created: 1, + ..EvalWork::default() + }); + let lines = match previous { + Some(Self::Branch { + left: old_left, + right: old_right, + lines, + .. + }) if left.lines().ptr_eq(old_left.lines()) + && right.lines().ptr_eq(old_right.lines()) => + { + lines.clone() + } + _ => left.lines().concat(right.lines()), + }; + Arc::new(Self::Branch { + measure: left.measure().combine(right.measure()), + left, + right, + lines, + }) + } + + fn build(slots: &[Arc]) -> Option> { + match slots.len() { + 0 => None, + 1 => Some(Self::leaf(Arc::clone(&slots[0]))), + count => { + let mid = count / 2; + Some(Self::branch( + Self::build(&slots[..mid]).unwrap(), + Self::build(&slots[mid..]).unwrap(), + None, + )) + } + } + } + + fn measure(&self) -> ColumnMeasure { + match self { + Self::Leaf { measure, .. } | Self::Branch { measure, .. } => *measure, + } + } + + fn lines(&self) -> &LinePlan { + match self { + Self::Leaf { slot, .. } => &slot.normalized, + Self::Branch { lines, .. } => lines, + } + } + + fn slot(&self, index: usize) -> &Arc { + record(EvalWork { + column_tree_nodes_visited: 1, + ..EvalWork::default() + }); + match self { + Self::Leaf { slot, .. } => { + assert_eq!(index, 0); + slot + } + Self::Branch { left, right, .. } => { + let left_len = left.measure().slots; + if index < left_len { + left.slot(index) + } else { + right.slot(index - left_len) + } + } + } + } + + fn prefix_lines(&self, end: usize) -> usize { + record(EvalWork { + column_tree_nodes_visited: 1, + ..EvalWork::default() + }); + if end == 0 { + return 0; + } + if end == self.measure().slots { + return self.measure().lines; + } + match self { + Self::Leaf { .. } => unreachable!("validated Column prefix"), + Self::Branch { left, right, .. } => { + let left_len = left.measure().slots; + if end <= left_len { + left.prefix_lines(end) + } else { + left.measure().lines + right.prefix_lines(end - left_len) + } + } + } + } + + fn replace(&self, index: usize, slot: Arc) -> Arc { + record(EvalWork { + column_tree_nodes_visited: 1, + ..EvalWork::default() + }); + match self { + Self::Leaf { .. } => { + assert_eq!(index, 0); + Self::leaf(slot) + } + Self::Branch { left, right, .. } => { + let left_len = left.measure().slots; + if index < left_len { + Self::branch(left.replace(index, slot), Arc::clone(right), Some(self)) + } else { + Self::branch( + Arc::clone(left), + right.replace(index - left_len, slot), + Some(self), + ) + } + } + } + } + + fn retarget(&self, target: i64) -> Arc { + record(EvalWork { + column_tree_nodes_visited: 1, + ..EvalWork::default() + }); + match self { + Self::Leaf { slot, .. } => { + record(EvalWork { + column_slots_updated: 1, + ..EvalWork::default() + }); + let same = PlanChange::same(&slot.raw); + Self::leaf( + ColumnSlot::normalize( + slot.raw.clone(), + slot.record.clone(), + Some(&same), + target, + Some(slot), + ) + .0, + ) + } + Self::Branch { left, right, .. } => { + Self::branch(left.retarget(target), right.retarget(target), Some(self)) + } + } + } +} + +#[derive(Debug)] +pub(super) struct ColumnState { + shape: Arc, + root: Option>, + target: i64, + lines: LinePlan, +} + +#[cfg(test)] +impl ColumnState { + pub(super) fn shares_slot(&self, other: &Self, index: usize) -> bool { + Arc::ptr_eq( + self.root.as_ref().unwrap().slot(index), + other.root.as_ref().unwrap().slot(index), + ) + } + + pub(super) fn shares_shape(&self, other: &Self) -> bool { + Arc::ptr_eq(&self.shape, &other.shape) + } +} + +fn target_width(context: LayoutContext, intrinsic: bool, maximum: i64) -> i64 { + if intrinsic || context.inline_auto_width_intrinsic || !context.viewport_width_known { + maximum + } else { + context.viewport_width.max(0) + } +} + +fn output(root: Option<&Arc>, previous: Option<&ColumnState>) -> LinePlan { + if let Some(root) = root.filter(|root| root.measure().lines > 0) { + return root.lines().clone(); + } + if let Some(previous) = previous.filter(|previous| { + previous + .root + .as_ref() + .is_none_or(|root| root.measure().lines == 0) + }) { + return previous.lines.clone(); + } + LinePlan::from_lines([Line::default()]) +} + +// A concatenation owns its joining break, including the former last line when +// an empty boundary moves. Include that predecessor in each changed span. +fn include_join_boundaries(splices: Vec) -> Vec { + let mut result: Vec = Vec::with_capacity(splices.len()); + for mut splice in splices { + if splice.old.start == 0 || splice.new.start == 0 { + splice.old.start = 0; + splice.new.start = 0; + } else { + splice.old.start -= 1; + splice.new.start -= 1; + } + if let Some(previous) = result.last_mut().filter(|previous| { + splice.old.start <= previous.old.end || splice.new.start <= previous.new.end + }) { + previous.old.end = previous.old.end.max(splice.old.end); + previous.new.end = previous.new.end.max(splice.new.end); + } else { + result.push(splice); + } + } + result +} + +pub(super) fn render_column<'a>( + scope: &RenderScope<'a>, + children: &'a [LayoutNode], + context: LayoutContext, + intrinsic: bool, +) -> Result { + let state = if let Some(previous) = scope.previous_column() { + let dirty = previous.shape.dirty_slots(scope); + scope.inherit_column_uses(); + let mut root = previous.root.clone(); + let mut changes = Vec::with_capacity(dirty.len()); + for index in dirty { + let child_scope = previous.shape.scope(scope, index)?; + let result = child_scope.render_with_change(context, intrinsic, None)?; + let old = previous + .root + .as_ref() + .expect("dirty Column has slots") + .slot(index); + let (slot, change) = ColumnSlot::normalize( + result.rendered.lines, + result.record, + result.change.as_ref(), + previous.target, + Some(old), + ); + record(EvalWork { + column_slots_updated: 1, + ..EvalWork::default() + }); + root = Some( + root.as_ref() + .expect("dirty Column root") + .replace(index, slot), + ); + changes.push((index, change.expect("previous normalized child"))); + } + let target = target_width( + context, + intrinsic, + root.as_ref().map_or(0, |root| root.measure().max_first), + ); + if target != previous.target { + record(EvalWork { + column_full_rebuilds: 1, + ..EvalWork::default() + }); + root = root.map(|root| root.retarget(target)); + } + let lines = output(root.as_ref(), Some(&previous)); + let change = if lines.ptr_eq(&previous.lines) { + PlanChange::same(&lines) + } else if target != previous.target + || previous + .root + .as_ref() + .is_none_or(|root| root.measure().lines == 0) + || root.as_ref().is_none_or(|root| root.measure().lines == 0) + { + PlanChange::replace_all(&previous.lines, &lines) + } else { + let old_root = previous.root.as_ref().unwrap(); + let new_root = root.as_ref().unwrap(); + let mut splices = Vec::new(); + for (index, change) in changes { + let old_offset = old_root.prefix_lines(index); + let new_offset = new_root.prefix_lines(index); + match change.kind() { + ChangeKind::Same => {} + ChangeKind::Splices(parts) => { + splices.extend(parts.iter().map(|part| LineSplice { + old: old_offset + part.old.start..old_offset + part.old.end, + new: new_offset + part.new.start..new_offset + part.new.end, + })); + } + ChangeKind::ReplaceAll => splices.push(LineSplice { + old: old_offset..old_offset + old_root.slot(index).raw.len(), + new: new_offset..new_offset + new_root.slot(index).raw.len(), + }), + } + } + PlanChange::splices(&previous.lines, &lines, include_join_boundaries(splices)) + }; + scope.publish_change(change); + Arc::new(ColumnState { + shape: Arc::clone(&previous.shape), + root, + target, + lines, + }) + } else { + record(EvalWork { + column_full_rebuilds: 1, + ..EvalWork::default() + }); + let mut paths = Vec::new(); + let mut results = Vec::new(); + for child in super::column_leaves(scope, children) { + let path = &child.view.address.path[scope.view.address.path.len()..]; + record(EvalWork { + source_path_steps_copied: path.len() as u64, + column_slots_built: 1, + ..EvalWork::default() + }); + paths.push(Arc::from(path)); + results.push(child.render_with_change(context, intrinsic, None)?); + } + let maximum = results + .iter() + .map(|result| result.rendered.first_width()) + .max() + .unwrap_or(0); + let target = target_width(context, intrinsic, maximum); + let slots = results + .into_iter() + .map( + |RenderedChange { + rendered, record, .. + }| { + ColumnSlot::normalize(rendered.lines, record, None, target, None).0 + }, + ) + .collect::>(); + let root = ColumnNode::build(&slots); + let lines = output(root.as_ref(), None); + Arc::new(ColumnState { + shape: ColumnShape::new(paths), + root, + target, + lines, + }) + }; + debug_assert_eq!( + state + .root + .as_ref() + .map_or(0, |root| root.measure().normalized_max(state.target)), + state.lines.max_width() + ); + let rendered = Rendered::from_line_plan(state.lines.clone()); + scope.store_column(state); + Ok(rendered) +} diff --git a/native/src/composition_tests.rs b/native/src/composition_tests.rs new file mode 100644 index 0000000..a47fca8 --- /dev/null +++ b/native/src/composition_tests.rs @@ -0,0 +1,206 @@ +mod composition_regressions { + use super::*; + + fn expected_text_line( + text: &str, + properties: &AtomProperties, + break_after: Option, + ) -> TapeLine { + TapeLine { + width: text.chars().count() as i64, + atoms: text + .chars() + .map(|character| TapeAtom::Text { + text: character.to_string(), + width: 1, + properties: properties.clone(), + }) + .collect(), + break_after, + } + } + + #[test] + fn ownership_before_wrap_and_slice_keeps_its_source_index_anchor() { + let mut line = Line::from_clusters(&[ + cluster("a", 1, Some(3)), + cluster("b", 1, Some(3)), + cluster("c", 1, Some(3)), + cluster("d", 1, Some(3)), + ]); + line.own_content(11, 7); + line.apply_style(Some(2)); + line.apply_property_template(Some(4)); + let source = Rendered::from_lines(vec![line]); + let anchored = AtomProperties { + style_ids: vec![2], + content: Some(11), + content_idx: Some(7), + owner: Some(11), + owners: vec![11], + property_template_ids: vec![3, 4], + ..AtomProperties::default() + }; + assert_eq!( + source.clone().into_tape(6), + LayoutTape { + style_count: 6, + lines: vec![expected_text_line("abcd", &anchored, None)], + } + ); + + let wrapped = wrap_rendered(source, 2, WrapMode::Char); + let expected_wrapped = LayoutTape { + style_count: 6, + lines: vec![ + expected_text_line("ab", &anchored, Some(AtomProperties::default())), + expected_text_line("cd", &anchored, None), + ], + }; + assert_eq!(wrapped.clone().into_tape(6), expected_wrapped); + + let mut selected = slice_rendered(wrapped.clone(), 1, 1); + assert_eq!( + selected.clone().into_tape(6), + LayoutTape { + style_count: 6, + lines: vec![expected_text_line("cd", &anchored, None)], + } + ); + selected.lines = selected.lines.map_lines(|index, mut line| { + line.own_content(22, 99 + index as i64); + line.apply_style(Some(5)); + line.apply_property_template(Some(8)); + line + }); + let outer = AtomProperties { + style_ids: vec![2, 5], + content: Some(11), + content_idx: Some(7), + owner: Some(22), + owners: vec![11, 22], + property_template_ids: vec![3, 4, 8], + ..AtomProperties::default() + }; + assert_eq!( + selected.into_tape(6), + LayoutTape { + style_count: 6, + lines: vec![expected_text_line("cd", &outer, None)], + } + ); + assert_eq!(wrapped.into_tape(6), expected_wrapped); + } + + fn expected_column_with_prefix(prefix_lines: usize) -> LayoutTape { + let parent_only = AtomProperties { + owner: Some(1), + owners: vec![1], + ..AtomProperties::default() + }; + let owned_line = |region_id, content_index| TapeLine { + width: 4, + atoms: vec![ + TapeAtom::Text { + text: "x".to_owned(), + width: 1, + properties: AtomProperties { + content: Some(region_id), + content_idx: Some(content_index), + owner: Some(1), + owners: vec![region_id, 1], + ..AtomProperties::default() + }, + }, + TapeAtom::Space { + width: 3, + properties: parent_only.clone(), + }, + ], + break_after: Some(AtomProperties::default()), + }; + let mut lines = (0..prefix_lines) + .map(|index| owned_line(10, index as i64)) + .collect::>(); + lines.push(TapeLine { + width: 4, + atoms: vec![TapeAtom::Space { + width: 4, + properties: AtomProperties { + content: Some(1), + content_idx: Some(prefix_lines as i64), + owner: Some(1), + owners: vec![1], + ..AtomProperties::default() + }, + }], + break_after: Some(AtomProperties::default()), + }); + let mut suffix = owned_line(30, 0); + suffix.break_after = None; + lines.push(suffix); + LayoutTape { + style_count: 0, + lines, + } + } + + #[test] + fn prefix_line_growth_and_shrink_update_only_the_inherited_suffix_content_index() { + let mut empty_suffix = nonuniform_text(20, &[0]); + let LayoutNode::Text { content, .. } = &mut empty_suffix else { + unreachable!() + }; + *content = Arc::new(measured_text(vec![Vec::new()])); + let root = identified( + child_box( + 1, + LayoutNode::Column { + node_id: None, + node_revision: None, + children: Arc::new(vec![ + identified(nonuniform_text(10, &[1]), 2, 1), + identified(empty_suffix, 3, 1), + identified(nonuniform_text(30, &[1]), 4, 1), + ]), + }, + None, + ), + 1, + 1, + ); + let mut document = RetainedDocument::bootstrap(retained_document(root)) + .unwrap() + .0; + let context = LayoutContext { + viewport_width: 4, + ..test_context() + }; + let mut frame = document.render_frame(None, None, context, None).unwrap(); + assert_eq!(frame.materialize_tape(), expected_column_with_prefix(1)); + + for (revision, prefix_lines) in [(1, 3), (2, 1)] { + let lines = vec![ + serde_json::json!({"clusters":[{ + "text":"x","width":1,"cjk":false,"space":false + }]}); + prefix_lines + ]; + let applied = document + .apply_delta(context_test_delta(vec![serde_json::json!({ + "node-id":2,"expected-revision":revision,"target-revision":revision+1, + "slot-patches":[{"slot":0,"local":{"content":{"lines":lines}}}] + })])) + .unwrap(); + frame = applied + .document + .render_frame(Some(&frame), Some(&applied.changes), context, None) + .unwrap(); + assert_eq!( + frame.materialize_tape(), + expected_column_with_prefix(prefix_lines) + ); + document = applied.document; + } + } +} diff --git a/native/src/evaluation.rs b/native/src/evaluation.rs new file mode 100644 index 0000000..145ff0c --- /dev/null +++ b/native/src/evaluation.rs @@ -0,0 +1,1075 @@ +//! One immutable tree of current evaluation uses, scoped to an exact document pair. + +use std::cell::{Cell, RefCell}; +use std::collections::{BTreeMap, BTreeSet}; +use std::sync::Arc; + +use super::composition::ColumnState; +use super::line_plan::{BoxProjectionSlot, PlanChange, ProjectionState}; +use super::source_topology::SourceAddress; +use super::{ + BoxOverride, LayoutContext, LayoutNode, LayoutTape, LocalStep, Rendered, RetainedDocument, + SourceChanges, +}; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "kebab-case")] +pub(crate) struct EvalWork { + pub(crate) lookups: u64, + pub(crate) hits: u64, + pub(crate) body_runs: u64, + pub(crate) records_created: u64, + pub(crate) caller_edges_written: u64, + pub(crate) occurrence_slots_written: u64, + pub(crate) child_slots_visited: u64, + pub(crate) source_path_steps_copied: u64, + pub(crate) use_path_steps_copied: u64, + pub(crate) dirty_seeds: u64, + pub(crate) dirty_prefixes_visited: u64, + pub(crate) dirty_addresses_inserted: u64, + pub(crate) topology_lookups: u64, + pub(crate) incoming_uses_visited: u64, + pub(crate) height_queries: u64, + pub(crate) child_use_nodes_created: u64, + pub(crate) child_use_nodes_visited: u64, + pub(crate) dirty_routes_written: u64, + pub(crate) dirty_routes_visited: u64, + pub(crate) column_shape_steps_visited: u64, + pub(crate) column_slots_built: u64, + pub(crate) column_slots_updated: u64, + pub(crate) column_tree_nodes_created: u64, + pub(crate) column_tree_nodes_visited: u64, + pub(crate) column_full_rebuilds: u64, + pub(crate) scroll_producer_lines_encoded: u64, + pub(crate) scroll_producer_chars_encoded: u64, + pub(crate) scroll_producer_reuses: u64, +} + +thread_local! { + static WORK: Cell = Cell::new(EvalWork::default()); +} + +impl EvalWork { + pub(crate) fn accumulate(&mut self, other: Self) { + macro_rules! add { + ($($field:ident),+ $(,)?) => { $(self.$field = self.$field.checked_add(other.$field) + .expect("native evaluation work counter overflow");)+ }; + } + add!( + lookups, + hits, + body_runs, + records_created, + caller_edges_written, + occurrence_slots_written, + child_slots_visited, + source_path_steps_copied, + use_path_steps_copied, + dirty_seeds, + dirty_prefixes_visited, + dirty_addresses_inserted, + topology_lookups, + incoming_uses_visited, + height_queries, + child_use_nodes_created, + child_use_nodes_visited, + dirty_routes_written, + dirty_routes_visited, + column_shape_steps_visited, + column_slots_built, + column_slots_updated, + column_tree_nodes_created, + column_tree_nodes_visited, + column_full_rebuilds, + scroll_producer_lines_encoded, + scroll_producer_chars_encoded, + scroll_producer_reuses + ); + } +} + +pub(crate) fn reset_work() { + WORK.set(EvalWork::default()); +} +pub(crate) fn work() -> EvalWork { + WORK.get() +} + +pub(super) fn record(delta: EvalWork) { + let mut work = WORK.get(); + work.accumulate(delta); + WORK.set(work); +} + +pub(super) fn record_height_query() { + record(EvalWork { + height_queries: 1, + ..EvalWork::default() + }); +} + +#[derive(Clone, Debug)] +pub(super) struct SourceView<'a> { + pub(super) node: &'a LayoutNode, + pub(super) address: SourceAddress, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub(super) enum Phase { + FlexMeasure, + FlexMinWidth, + FlexAutoMinContent, + FlexMaxWidth, + FlexBasisContent, + FlexBasisWidth, + ContentIntrinsic, + FlexCrossProbe, + FlexFinal, + WindowFull, + WindowFallback, + WindowChildFull, + WindowChildPartial, +} + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +enum UseStep { + Child(LocalStep), + Phase(Phase), +} + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +struct UseSlot { + path: Vec, + occurrence: usize, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct EvalRequest { + context: LayoutContext, + intrinsic: bool, + size_override: Option, + root_scroll_request: bool, + complete_scroll_effects: bool, +} + +#[derive(Debug)] +pub(super) struct EvalRecord { + source: SourceAddress, + request: EvalRequest, + rendered: Rendered, + children: ChildUses, + column: Option>, + projections: BTreeMap>, +} + +/// Persistent caller-local map. Path copying writes only the search path; keys +/// and current child records remain shared. There is no historical use table. +#[derive(Clone, Debug, Default)] +struct ChildUses(Option>); + +#[derive(Debug)] +struct UseNode { + slot: Arc, + value: Arc, + left: ChildUses, + right: ChildUses, + height: u32, + len: usize, + scroll_owners: u8, +} + +impl ChildUses { + fn from_fresh(entries: BTreeMap>) -> Self { + fn build( + entries: &mut impl Iterator)>, + count: usize, + ) -> ChildUses { + if count == 0 { + return ChildUses::default(); + } + let middle = count / 2; + let left = build(entries, middle); + let (slot, value) = entries.next().expect("fresh caller-use count"); + let right = build(entries, count - middle - 1); + ChildUses::node(Arc::new(slot), value, left, right) + } + let count = entries.len(); + build(&mut entries.into_iter(), count) + } + + fn height(&self) -> u32 { + self.0.as_ref().map_or(0, |node| node.height) + } + + fn len(&self) -> usize { + self.0.as_ref().map_or(0, |node| node.len) + } + + fn scroll_owners(&self) -> u8 { + self.0.as_ref().map_or(0, |node| node.scroll_owners) + } + + fn node(slot: Arc, value: Arc, left: Self, right: Self) -> Self { + record(EvalWork { + child_use_nodes_created: 1, + ..EvalWork::default() + }); + let final_use = !slot.path.iter().any(|step| { + matches!( + step, + UseStep::Phase( + Phase::FlexMeasure + | Phase::FlexMinWidth + | Phase::FlexAutoMinContent + | Phase::FlexMaxWidth + | Phase::FlexBasisContent + | Phase::FlexBasisWidth + | Phase::ContentIntrinsic + | Phase::FlexCrossProbe + ) + ) + }); + let incomplete_window = slot + .path + .iter() + .any(|step| matches!(step, UseStep::Phase(Phase::WindowChildPartial))); + let scroll_owners = (left.scroll_owners() + + right.scroll_owners() + + if final_use { + if incomplete_window { + 2 + } else { + value.rendered.scroll_owners + } + } else { + 0 + }) + .min(2); + Self(Some(Arc::new(UseNode { + slot, + value, + height: 1 + left.height().max(right.height()), + len: 1 + left.len() + right.len(), + scroll_owners, + left, + right, + }))) + } + + fn balanced(slot: Arc, value: Arc, left: Self, right: Self) -> Self { + if left.height() > right.height() + 1 { + let root = left.0.as_ref().expect("left-heavy caller tree"); + if root.left.height() >= root.right.height() { + return Self::node( + Arc::clone(&root.slot), + Arc::clone(&root.value), + root.left.clone(), + Self::node(slot, value, root.right.clone(), right), + ); + } + let middle = root.right.0.as_ref().expect("left-right caller tree"); + return Self::node( + Arc::clone(&middle.slot), + Arc::clone(&middle.value), + Self::node( + Arc::clone(&root.slot), + Arc::clone(&root.value), + root.left.clone(), + middle.left.clone(), + ), + Self::node(slot, value, middle.right.clone(), right), + ); + } + if right.height() > left.height() + 1 { + let root = right.0.as_ref().expect("right-heavy caller tree"); + if root.right.height() >= root.left.height() { + return Self::node( + Arc::clone(&root.slot), + Arc::clone(&root.value), + Self::node(slot, value, left, root.left.clone()), + root.right.clone(), + ); + } + let middle = root.left.0.as_ref().expect("right-left caller tree"); + return Self::node( + Arc::clone(&middle.slot), + Arc::clone(&middle.value), + Self::node(slot, value, left, middle.left.clone()), + Self::node( + Arc::clone(&root.slot), + Arc::clone(&root.value), + middle.right.clone(), + root.right.clone(), + ), + ); + } + Self::node(slot, value, left, right) + } + + fn get(&self, slot: &UseSlot) -> Option<&Arc> { + let mut current = self.0.as_deref(); + while let Some(node) = current { + record(EvalWork { + child_use_nodes_visited: 1, + ..EvalWork::default() + }); + match slot.cmp(&node.slot) { + std::cmp::Ordering::Less => current = node.left.0.as_deref(), + std::cmp::Ordering::Greater => current = node.right.0.as_deref(), + std::cmp::Ordering::Equal => return Some(&node.value), + } + } + None + } + + fn inserted(&self, slot: Arc, value: Arc) -> Self { + let Some(node) = &self.0 else { + return Self::node(slot, value, Self::default(), Self::default()); + }; + record(EvalWork { + child_use_nodes_visited: 1, + ..EvalWork::default() + }); + match slot.cmp(&node.slot) { + std::cmp::Ordering::Less => Self::balanced( + Arc::clone(&node.slot), + Arc::clone(&node.value), + node.left.inserted(slot, value), + node.right.clone(), + ), + std::cmp::Ordering::Greater => Self::balanced( + Arc::clone(&node.slot), + Arc::clone(&node.value), + node.left.clone(), + node.right.inserted(slot, value), + ), + std::cmp::Ordering::Equal => Self::node( + Arc::clone(&node.slot), + value, + node.left.clone(), + node.right.clone(), + ), + } + } + + #[cfg(test)] + fn iter(&self) -> impl Iterator)> { + let mut stack = Vec::new(); + let mut next = self.0.as_deref(); + std::iter::from_fn(move || { + while let Some(node) = next { + stack.push(node); + next = node.left.0.as_deref(); + } + let node = stack.pop()?; + next = node.right.0.as_deref(); + Some((node.slot.as_ref(), &node.value)) + }) + } + + #[cfg(test)] + fn values(&self) -> impl Iterator> { + self.iter().map(|(_, value)| value) + } + + #[cfg(test)] + fn keys(&self) -> impl Iterator { + self.iter().map(|(slot, _)| slot) + } +} + +/// Full evaluation uses a mutable fresh collector, then freezes exactly N nodes. +/// A proven sparse Column starts from an immutable current root and path-copies. +#[derive(Debug)] +enum BuildingUses { + Fresh(BTreeMap>), + Sparse(ChildUses), +} + +impl Default for BuildingUses { + fn default() -> Self { + Self::Fresh(BTreeMap::new()) + } +} + +impl BuildingUses { + fn len(&self) -> usize { + match self { + Self::Fresh(entries) => entries.len(), + Self::Sparse(entries) => entries.len(), + } + } + + fn insert(&mut self, slot: UseSlot, value: Arc) { + match self { + Self::Fresh(entries) => assert!( + entries.insert(slot, value).is_none(), + "unique caller use slot" + ), + Self::Sparse(entries) => *entries = entries.inserted(Arc::new(slot), value), + } + } + + fn finish(self) -> ChildUses { + match self { + Self::Fresh(entries) => ChildUses::from_fresh(entries), + Self::Sparse(entries) => entries, + } + } + + #[cfg(test)] + fn values(&self) -> Box> + '_> { + match self { + Self::Fresh(entries) => Box::new(entries.values()), + Self::Sparse(entries) => Box::new(entries.values()), + } + } +} + +#[derive(Debug)] +pub(super) struct RenderedChange { + pub(super) rendered: Rendered, + pub(super) change: Option, + pub(super) record: Option>, +} + +/// Owns the current document and current evaluation tree, never a previous frame. +#[derive(Debug)] +pub(crate) struct RetainedFrame { + document: Arc, + root: Arc, +} + +impl RetainedFrame { + pub(crate) fn root_scroll(&self) -> Option<&super::RootScrollPlan> { + self.root.rendered.root_scroll.as_deref() + } + pub(crate) fn materialize_tape(&self) -> LayoutTape { + self.root + .rendered + .clone() + .into_tape(self.document.style_count) + } +} + +#[derive(Debug)] +struct BuildingRecord { + slot: UseSlot, + source: SourceAddress, + request: EvalRequest, + previous: Option>, + children: BuildingUses, + occurrences: BTreeMap, usize>, + column: Option>, + projections: BTreeMap>, + pending_change: Option, +} + +#[derive(Debug)] +struct RenderTxn { + previous: Option>, + dirty: DirtySources, + same_shape: bool, + stack: Vec, + root: Option>, + complete_scroll_effects: bool, +} + +impl RenderTxn { + fn begin( + &mut self, + source: &SourceAddress, + path: &[UseStep], + request: EvalRequest, + ) -> Option { + record(EvalWork { + lookups: 1, + ..EvalWork::default() + }); + let (slot, previous) = if let Some(parent) = self.stack.last_mut() { + let occurrence = parent.occurrences.entry(path.to_vec()).or_default(); + let slot = UseSlot { + path: path.to_vec(), + occurrence: *occurrence, + }; + *occurrence += 1; + record(EvalWork { + occurrence_slots_written: 1, + use_path_steps_copied: (path.len() * 2) as u64, + ..EvalWork::default() + }); + let previous = parent + .previous + .as_ref() + .and_then(|old| old.children.get(&slot)) + .cloned(); + (slot, previous) + } else { + ( + UseSlot { + path: Vec::new(), + occurrence: 0, + }, + self.previous.clone(), + ) + }; + if let Some(old) = previous.as_ref().filter(|old| { + old.source == *source && old.request == request && !self.dirty.contains_key(source) + }) { + record(EvalWork { + hits: 1, + ..EvalWork::default() + }); + let result = RenderedChange { + rendered: old.rendered.clone(), + change: Some(PlanChange::same(&old.rendered.lines)), + record: Some(Arc::clone(old)), + }; + self.attach(slot, Arc::clone(old)); + return Some(result); + } + record(EvalWork { + body_runs: 1, + ..EvalWork::default() + }); + self.stack.push(BuildingRecord { + slot, + source: source.clone(), + request, + previous, + children: BuildingUses::default(), + occurrences: BTreeMap::new(), + column: None, + projections: BTreeMap::new(), + pending_change: None, + }); + None + } + + fn finish(&mut self, rendered: Result) -> Result { + let current = self.stack.pop().expect("native evaluation builder stack"); + let mut rendered = rendered?; + let children = current.children.finish(); + let child_owners = children.scroll_owners(); + rendered.scroll_owners = (child_owners + u8::from(rendered.own_scroll_owner)).min(2); + if child_owners != 0 { + rendered.root_scroll = None; + } + let change = current.previous.as_ref().map(|previous| { + current + .pending_change + .filter(|change| change.applies_to(&previous.rendered.lines, &rendered.lines)) + .unwrap_or_else(|| { + PlanChange::replace_all(&previous.rendered.lines, &rendered.lines) + }) + }); + record(EvalWork { + records_created: 1, + ..EvalWork::default() + }); + let retained = Arc::new(EvalRecord { + source: current.source, + request: current.request, + rendered: rendered.clone(), + children, + column: current.column, + projections: current.projections, + }); + self.attach(current.slot, Arc::clone(&retained)); + Ok(RenderedChange { + rendered, + change, + record: Some(retained), + }) + } + + fn attach(&mut self, slot: UseSlot, record: Arc) { + if let Some(parent) = self.stack.last_mut() { + record_work_edge(); + parent.children.insert(slot, record); + } else { + self.root = Some(record); + } + } +} + +fn record_work_edge() { + record(EvalWork { + caller_edges_written: 1, + ..EvalWork::default() + }); +} + +/// Explicit source/callsite context. Interior mutability is transaction-local; +/// no borrow of the builder stack spans a recursive renderer call. +#[derive(Debug)] +pub(super) struct RenderScope<'a> { + pub(super) view: SourceView<'a>, + pub(super) resolver: Option<&'a RetainedDocument>, + transaction: Option<&'a RefCell>, + path: Vec, +} + +impl Clone for RenderScope<'_> { + fn clone(&self) -> Self { + if self.transaction.is_some() { + record(EvalWork { + use_path_steps_copied: self.path.len() as u64, + ..EvalWork::default() + }); + } + Self { + view: self.view.clone(), + resolver: self.resolver, + transaction: self.transaction, + path: self.path.clone(), + } + } +} + +impl<'a> RenderScope<'a> { + pub(super) fn uncached(node: &'a LayoutNode, resolver: Option<&'a RetainedDocument>) -> Self { + Self { + view: SourceView { + node, + address: SourceAddress { + owner_id: node.node_id().unwrap_or(0), + path: Arc::from([]), + }, + }, + resolver, + transaction: None, + path: Vec::new(), + } + } + + pub(super) fn resolve(&self) -> Result { + let mut resolved = self.clone(); + if let LayoutNode::NodeRef { node_id } = self.view.node { + if let Some(resolver) = self.resolver { + resolved.view = SourceView { + node: resolver.resolve(self.view.node)?, + address: SourceAddress { + owner_id: *node_id, + path: Arc::from([]), + }, + }; + } + } + Ok(resolved) + } + + pub(super) fn child(&self, step: LocalStep, node: &'a LayoutNode) -> Self { + if self.transaction.is_none() { + return Self::uncached(node, self.resolver); + } + let mut address = self.view.address.path.to_vec(); + address.push(step); + let mut path = self.path.clone(); + path.push(UseStep::Child(step)); + if self.transaction.is_some() { + record(EvalWork { + child_slots_visited: 1, + source_path_steps_copied: address.len() as u64, + use_path_steps_copied: path.len() as u64, + ..EvalWork::default() + }); + } + Self { + view: SourceView { + node, + address: SourceAddress { + owner_id: self.view.address.owner_id, + path: address.into(), + }, + }, + resolver: self.resolver, + transaction: self.transaction, + path, + } + } + + pub(super) fn phase(&self, phase: Phase) -> Self { + if self.transaction.is_none() { + return self.clone(); + } + let mut scoped = self.clone(); + scoped.path.push(UseStep::Phase(phase)); + if self.transaction.is_some() { + record(EvalWork { + use_path_steps_copied: 1, + ..EvalWork::default() + }); + } + scoped + } + + pub(super) fn render( + &self, + context: LayoutContext, + intrinsic: bool, + size_override: Option, + ) -> Result { + self.render_with_change(context, intrinsic, size_override) + .map(|result| result.rendered) + } + + pub(super) fn render_with_change( + &self, + context: LayoutContext, + intrinsic: bool, + size_override: Option, + ) -> Result { + let source = self.resolve()?; + if let Some(transaction) = source.transaction { + let request = EvalRequest { + context, + intrinsic, + size_override, + root_scroll_request: transaction.borrow().complete_scroll_effects + && transaction.borrow().stack.is_empty(), + complete_scroll_effects: transaction.borrow().complete_scroll_effects, + }; + if let Some(rendered) = + transaction + .borrow_mut() + .begin(&source.view.address, &source.path, request) + { + return Ok(rendered); + } + } + // Descendant slots are local to this caller, even on a parent request miss. + let body_scope = Self { + path: Vec::new(), + ..source + }; + let rendered = super::render_node_body(&body_scope, context, intrinsic, size_override); + if let Some(transaction) = body_scope.transaction { + return transaction.borrow_mut().finish(rendered); + } + rendered.map(|rendered| RenderedChange { + rendered, + change: None, + record: None, + }) + } + + pub(super) fn is_retained(&self) -> bool { + self.transaction.is_some() + } + + pub(super) fn root_scroll_requested(&self) -> bool { + self.transaction.is_some_and(|transaction| { + transaction + .borrow() + .stack + .last() + .is_some_and(|record| record.request.root_scroll_request) + }) + } + + pub(super) fn complete_scroll_effects_requested(&self) -> bool { + self.transaction + .is_some_and(|transaction| transaction.borrow().complete_scroll_effects) + } + + pub(super) fn previous_root_scroll(&self) -> Option> { + let transaction = self.transaction?.borrow(); + let current = transaction.stack.last()?; + current + .previous + .as_ref() + .filter(|old| old.source == current.source)? + .rendered + .root_scroll + .clone() + } + + pub(super) fn publish_change(&self, change: PlanChange) { + if let Some(transaction) = self.transaction { + transaction + .borrow_mut() + .stack + .last_mut() + .expect("current evaluation") + .pending_change = Some(change); + } + } + + pub(super) fn previous_projection( + &self, + slot: BoxProjectionSlot, + ) -> Option> { + let transaction = self.transaction?.borrow(); + let current = transaction.stack.last()?; + let previous = current + .previous + .as_ref() + .filter(|old| old.source == current.source)?; + previous.projections.get(&slot).cloned() + } + + pub(super) fn store_projection(&self, slot: BoxProjectionSlot, state: Arc) { + if let Some(transaction) = self.transaction { + transaction + .borrow_mut() + .stack + .last_mut() + .expect("current evaluation") + .projections + .insert(slot, state); + } + } + + pub(super) fn previous_column(&self) -> Option> { + let transaction = self.transaction?.borrow(); + let current = transaction.stack.last()?; + let previous = current.previous.as_ref().filter(|old| { + transaction.same_shape + && old.source == current.source + && old.request == current.request + && !transaction + .dirty + .get(¤t.source) + .is_some_and(|route| route.local) + })?; + previous.column.clone() + } + + pub(super) fn inherit_column_uses(&self) { + let mut transaction = self.transaction.expect("retained Column").borrow_mut(); + let current = transaction.stack.last_mut().expect("current evaluation"); + assert_eq!( + current.children.len(), + 0, + "inherit before evaluating dirty Column slots" + ); + current.children = BuildingUses::Sparse( + current + .previous + .as_ref() + .expect("previous Column") + .children + .clone(), + ); + } + + pub(super) fn store_column(&self, state: Arc) { + self.transaction + .expect("retained Column") + .borrow_mut() + .stack + .last_mut() + .expect("current evaluation") + .column = Some(state); + } + + pub(super) fn dirty_route(&self, relative: &[LocalStep]) -> DirtyRoute { + let Some(transaction) = self.transaction else { + return DirtyRoute::default(); + }; + let mut path = self.view.address.path.to_vec(); + path.extend_from_slice(relative); + record(EvalWork { + source_path_steps_copied: path.len() as u64, + dirty_routes_visited: 1, + ..EvalWork::default() + }); + transaction + .borrow() + .dirty + .get(&SourceAddress { + owner_id: self.view.address.owner_id, + path: path.into(), + }) + .cloned() + .unwrap_or_default() + } +} + +#[derive(Clone, Debug, Default)] +pub(super) struct DirtyRoute { + pub(super) local: bool, + pub(super) next: BTreeSet, +} + +type DirtySources = BTreeMap; + +fn dirty_sources(document: &RetainedDocument, changes: &SourceChanges) -> DirtySources { + fn mark(address: SourceAddress, dirty: &mut DirtySources, owners: &mut Vec) { + for length in (0..=address.path.len()).rev() { + record(EvalWork { + dirty_prefixes_visited: 1, + source_path_steps_copied: length as u64, + ..EvalWork::default() + }); + let prefix = SourceAddress { + owner_id: address.owner_id, + path: Arc::from(&address.path[..length]), + }; + let is_new = !dirty.contains_key(&prefix); + let route = dirty.entry(prefix).or_default(); + if length == address.path.len() { + route.local = true; + } else if route.next.insert(address.path[length]) { + record(EvalWork { + dirty_routes_written: 1, + ..EvalWork::default() + }); + } + if is_new { + record(EvalWork { + dirty_addresses_inserted: 1, + ..EvalWork::default() + }); + if length == 0 { + owners.push(address.owner_id); + } + } + } + } + let mut dirty = BTreeMap::new(); + let mut owners = Vec::new(); + changes.visit_changed_slots(|owner_id, _, _, slot, _| { + record(EvalWork { + dirty_seeds: 1, + ..EvalWork::default() + }); + let path: Arc<[LocalStep]> = match slot { + 0 => Arc::from([]), + 1 => Arc::from([LocalStep::BoxChild]), + _ => unreachable!("validated native source slot"), + }; + mark(SourceAddress { owner_id, path }, &mut dirty, &mut owners); + }); + while let Some(owner) = owners.pop() { + record(EvalWork { + topology_lookups: 1, + ..EvalWork::default() + }); + for incoming in document.topology.incoming_uses(owner) { + record(EvalWork { + incoming_uses_visited: 1, + ..EvalWork::default() + }); + let mut path = incoming.parent.path.to_vec(); + path.push(incoming.child_slot); + record(EvalWork { + source_path_steps_copied: path.len() as u64, + ..EvalWork::default() + }); + mark( + SourceAddress { + owner_id: incoming.parent.owner_id, + path: path.into(), + }, + &mut dirty, + &mut owners, + ); + } + } + dirty +} + +impl RetainedDocument { + pub(crate) fn render_frame( + self: &Arc, + previous: Option<&RetainedFrame>, + changes: Option<&SourceChanges>, + context: LayoutContext, + root_width_override: Option, + ) -> Result, String> { + self.render_frame_mode(previous, changes, context, root_width_override, false) + } + + pub(crate) fn render_frame_with_root_scroll( + self: &Arc, + previous: Option<&RetainedFrame>, + changes: Option<&SourceChanges>, + context: LayoutContext, + root_width_override: Option, + ) -> Result, String> { + self.render_frame_mode(previous, changes, context, root_width_override, true) + } + + fn render_frame_mode( + self: &Arc, + previous: Option<&RetainedFrame>, + changes: Option<&SourceChanges>, + context: LayoutContext, + root_width_override: Option, + complete_scroll_effects: bool, + ) -> Result, String> { + let reusable = match (previous, changes) { + (Some(previous), Some(changes)) => { + if !changes.applies_to(&previous.document, self) { + return Err( + "Native retained frame source changes do not match the document pair" + .to_owned(), + ); + } + Some(previous) + } + (Some(previous), None) if Arc::ptr_eq(&previous.document, self) => Some(previous), + (None, Some(changes)) if !Arc::ptr_eq(&changes.target, self) => { + return Err( + "Native retained frame source changes do not match the target document" + .to_owned(), + ); + } + _ => None, + }; + let dirty = match (reusable, changes) { + (Some(_), Some(changes)) => dirty_sources(self, changes), + _ => BTreeMap::new(), + }; + let transaction = RefCell::new(RenderTxn { + complete_scroll_effects, + previous: reusable.map(|previous| Arc::clone(&previous.root)), + dirty, + same_shape: reusable + .is_some_and(|previous| Arc::ptr_eq(&previous.document.topology, &self.topology)), + stack: Vec::new(), + root: None, + }); + let root = self + .effective_node(self.root_id) + .ok_or_else(|| "Native retained document lost its root".to_owned())?; + super::RESOLVER_LOOKUP_COUNT.with(|count| count.set(count.get().saturating_add(1))); + if root_width_override.is_some() && !matches!(root, LayoutNode::Box { .. }) { + return Err("Native root width override requires a box root".to_owned()); + } + let scope = RenderScope { + view: SourceView { + node: root, + address: SourceAddress { + owner_id: self.root_id, + path: Arc::from([]), + }, + }, + resolver: Some(self), + transaction: Some(&transaction), + path: Vec::new(), + }; + scope.render( + context, + false, + root_width_override.map(|declared_width| BoxOverride { + declared_width: Some(declared_width), + ..BoxOverride::default() + }), + )?; + let root = transaction + .into_inner() + .root + .expect("native retained root evaluation"); + Ok(Arc::new(RetainedFrame { + document: Arc::clone(self), + root, + })) + } +} + +#[cfg(test)] +#[path = "evaluation_tests.rs"] +mod tests; diff --git a/native/src/evaluation_tests.rs b/native/src/evaluation_tests.rs new file mode 100644 index 0000000..bdb0f46 --- /dev/null +++ b/native/src/evaluation_tests.rs @@ -0,0 +1,1724 @@ +use super::super::tests::{ + child_box, context_test_delta, identified, nonuniform_text, retained_document, test_context, +}; +use super::super::{FlexAlign, FlexDirection, FlexItem, FlexWrap, Size, WrapMode}; +use super::*; + +fn flex(nodes: Vec, direction: FlexDirection, wrap: FlexWrap) -> LayoutNode { + LayoutNode::Flex { + node_id: None, + node_revision: None, + direction, + wrap, + justify: FlexAlign::FlexStart, + align_items: FlexAlign::FlexStart, + align_content: FlexAlign::FlexStart, + width: Size::Pixels { value: 12 }, + height: Size::Auto, + row_gap: 1, + column_gap: 1, + items: Arc::new( + nodes + .into_iter() + .enumerate() + .map(|(index, node)| FlexItem { + node, + order: (index % 2) as i64, + grow: 0.0, + shrink: 1.0, + basis: Size::Pixels { value: 3 }, + align_self: FlexAlign::Auto, + }) + .collect(), + ), + } +} + +fn row_id(index: usize) -> u64 { + index as u64 * 100 + 10 +} + +fn mixed(size: usize) -> Arc { + let rows = (0..size) + .map(|index| { + let id = row_id(index); + let nodes = (0..3) + .map(|item| { + let item_id = id + 20 + item; + let mut node = child_box( + item_id as i64, + nonuniform_text(item_id as i64 + 100_000, &[1]), + None, + ); + let LayoutNode::Box { wrap_mode, .. } = &mut node else { + unreachable!() + }; + *wrap_mode = WrapMode::Word; + identified(node, item_id, 1) + }) + .collect(); + identified( + LayoutNode::Row { + node_id: None, + node_revision: None, + children: Arc::new(vec![ + identified( + child_box( + id as i64 + 1, + nonuniform_text(id as i64 + 100_001, &[1]), + None, + ), + id + 1, + 1, + ), + identified( + flex(nodes, FlexDirection::RowReverse, FlexWrap::Wrap), + id + 2, + 1, + ), + ]), + }, + id, + 1, + ) + }) + .collect(); + let root = identified( + child_box( + 1, + LayoutNode::Column { + node_id: None, + node_revision: None, + children: Arc::new(rows), + }, + None, + ), + 1, + 1, + ); + RetainedDocument::bootstrap(retained_document(root)) + .unwrap() + .0 +} + +fn text_update(owner: u64, before: u64, slot: u8, text: &str, width: i64) -> serde_json::Value { + serde_json::json!({"node-id": owner, "expected-revision": before, "target-revision": before + 1, + "slot-patches": [{"slot": slot, "local": {"content": {"lines": [{"clusters": [{ + "text": text, "width": width, "cjk": false, "space": false, + }]}]}}}]}) +} + +fn records(root: &Arc) -> Vec<&Arc> { + let mut output = vec![root]; + for child in root.children.values() { + output.extend(records(child)); + } + output +} + +fn owner_record(root: &Arc, owner: u64) -> &Arc { + records(root) + .into_iter() + .find(|record| record.source.owner_id == owner && record.source.path.is_empty()) + .expect("fixture owner has a current evaluation") +} + +fn assert_oracle(frame: &RetainedFrame, context: LayoutContext, root_width: Option) { + assert_eq!( + frame.materialize_tape(), + frame.document.layout_tape(context, root_width).unwrap() + ); +} + +fn sole_root_scroll_document(child: LayoutNode) -> Arc { + let mut root = child_box(1, child, None); + let LayoutNode::Box { height, .. } = &mut root else { + unreachable!() + }; + *height = Size::ViewportHeight; + RetainedDocument::bootstrap(retained_document(identified(root, 1, 1))) + .unwrap() + .0 +} + +#[test] +fn root_scroll_producer_preserves_mixed_children_and_cached_effects() { + let document = sole_root_scroll_document(LayoutNode::Column { + node_id: None, + node_revision: None, + children: Arc::new(vec![ + LayoutNode::Row { + node_id: None, + node_revision: None, + children: Arc::new(vec![ + nonuniform_text(2, &[1, 1]), + child_box(3, nonuniform_text(4, &[1]), None), + ]), + }, + flex( + vec![nonuniform_text(5, &[1]), nonuniform_text(6, &[1])], + FlexDirection::Column, + FlexWrap::Nowrap, + ), + ]), + }); + let context = LayoutContext { + viewport_height: 1, + ..test_context() + }; + let frame = document + .render_frame_with_root_scroll(None, None, context, None) + .unwrap(); + assert_oracle(&frame, context, None); + let producer = frame.root_scroll().expect("sole actual root owner"); + assert_eq!(producer.region_id, 1); + assert!(producer.full_content.len() > 1); + assert_eq!(producer.full_content.len(), producer.rendered_content.len()); + assert_eq!(producer.visible_height, 1); + assert_eq!(frame.root.rendered.scroll_owners, 1); + assert!(records(&frame.root) + .into_iter() + .skip(1) + .all(|record| record.rendered.root_scroll.is_none())); + reset_work(); + let reused = document + .render_frame_with_root_scroll(Some(&frame), None, context, None) + .unwrap(); + assert_eq!(work().hits, 1); + assert_eq!(work().body_runs, 0); + assert!(std::ptr::eq(producer, reused.root_scroll().unwrap())); +} + +#[test] +fn root_scroll_producer_rejects_offscreen_nested_owners() { + let mut nested = child_box(4, nonuniform_text(5, &[1, 1]), None); + let LayoutNode::Box { height, .. } = &mut nested else { + unreachable!() + }; + *height = Size::Lines { value: 1 }; + let document = sole_root_scroll_document(LayoutNode::Column { + node_id: None, + node_revision: None, + children: Arc::new(vec![nonuniform_text(2, &[1, 1, 1]), nested]), + }); + for height in [1, 20] { + let context = LayoutContext { + viewport_height: height, + ..test_context() + }; + let frame = document + .render_frame_with_root_scroll(None, None, context, None) + .unwrap(); + assert_oracle(&frame, context, None); + assert!( + frame.root_scroll().is_none(), + "nested owner at height {height}" + ); + assert_eq!( + frame.root.rendered.scroll_owners, + if height == 1 { 2 } else { 1 } + ); + assert!(records(&frame.root) + .into_iter() + .any(|record| record.rendered.own_scroll_owner && record.source.path.len() > 1)); + let reused = document + .render_frame_with_root_scroll(Some(&frame), None, context, None) + .unwrap(); + assert!(reused.root_scroll().is_none()); + } +} + +#[test] +fn root_scroll_owner_summary_distinguishes_measurements_and_final_windows() { + let document = sole_root_scroll_document(nonuniform_text(2, &[1, 1])); + let frame = document + .render_frame_with_root_scroll( + None, + None, + LayoutContext { + viewport_height: 1, + ..test_context() + }, + None, + ) + .unwrap(); + assert_eq!(frame.root.rendered.scroll_owners, 1); + for (phase, expected) in [ + (Phase::FlexMeasure, 0), + (Phase::FlexMinWidth, 0), + (Phase::FlexAutoMinContent, 0), + (Phase::FlexMaxWidth, 0), + (Phase::FlexBasisContent, 0), + (Phase::FlexBasisWidth, 0), + (Phase::ContentIntrinsic, 0), + (Phase::FlexCrossProbe, 0), + (Phase::FlexFinal, 1), + (Phase::WindowFull, 1), + (Phase::WindowFallback, 1), + (Phase::WindowChildFull, 1), + (Phase::WindowChildPartial, 2), + ] { + let uses = ChildUses::from_fresh(BTreeMap::from([( + UseSlot { + path: vec![UseStep::Phase(phase)], + occurrence: 0, + }, + Arc::clone(&frame.root), + )])); + assert_eq!(uses.scroll_owners(), expected, "{phase:?}"); + } +} + +#[test] +fn instrumentation_records_common_entries_without_changing_full_semantics() { + let document = mixed(3); + reset_work(); + let frame = document + .render_frame(None, None, test_context(), None) + .unwrap(); + let measured = work(); + assert_eq!(measured.hits, 0); + assert_eq!(measured.lookups, measured.body_runs); + assert_eq!( + measured.records_created as usize, + records(&frame.root).len() + ); + assert_eq!(measured.caller_edges_written + 1, measured.records_created); + assert_oracle(&frame, test_context(), None); + let all = records(&frame.root); + assert!(all + .iter() + .any(|record| matches!(record.source.path.as_ref(), [LocalStep::BoxChild]))); + let phases = all + .iter() + .flat_map(|record| record.children.keys()) + .flat_map(|slot| slot.path.iter()) + .filter_map(|step| match step { + UseStep::Phase(phase) => Some(*phase), + _ => None, + }) + .collect::>(); + for phase in [ + Phase::FlexMeasure, + Phase::FlexMinWidth, + Phase::FlexAutoMinContent, + Phase::FlexMaxWidth, + Phase::FlexBasisContent, + Phase::FlexBasisWidth, + Phase::ContentIntrinsic, + Phase::FlexCrossProbe, + Phase::FlexFinal, + ] { + assert!(phases.contains(&phase), "missing phase {phase:?}"); + } + let flex_record = owner_record(&frame.root, row_id(0) + 2); + let original_positions = flex_record + .children + .keys() + .filter_map(|slot| { + slot.path.iter().find_map(|step| match step { + UseStep::Child(LocalStep::FlexItem(index)) => Some(*index), + _ => None, + }) + }) + .collect::>(); + assert_eq!(original_positions, BTreeSet::from([0, 1, 2])); + // The cache-disabled oracle builds neither records nor caller/source paths. + reset_work(); + document.layout_tape(test_context(), None).unwrap(); + assert_eq!(work().records_created, 0); + assert_eq!(work().child_slots_visited, 0); + assert_eq!(work().source_path_steps_copied, 0); + assert_eq!(work().use_path_steps_copied, 0); +} + +#[test] +fn instrumentation_preserves_literal_column_paths_and_node_ref_boundaries() { + let nested = LayoutNode::Column { + node_id: None, + node_revision: None, + children: Arc::new(vec![ + nonuniform_text(2, &[2, 9]), + identified( + LayoutNode::Column { + node_id: None, + node_revision: None, + children: Arc::new(vec![nonuniform_text(3, &[5]), nonuniform_text(4, &[1])]), + }, + 2, + 1, + ), + ]), + }; + let root = identified( + child_box( + 1, + LayoutNode::Column { + node_id: None, + node_revision: None, + children: Arc::new(vec![nested, nonuniform_text(5, &[8])]), + }, + None, + ), + 1, + 1, + ); + let document = RetainedDocument::bootstrap(retained_document(root)) + .unwrap() + .0; + let frame = document + .render_frame(None, None, test_context(), None) + .unwrap(); + assert_oracle(&frame, test_context(), None); + let anonymous = records(&frame.root) + .into_iter() + .find(|record| { + record.source.owner_id == 1 + && record.source.path.as_ref() + == [ + LocalStep::BoxChild, + LocalStep::ColumnChild(0), + LocalStep::ColumnChild(0), + ] + }) + .unwrap(); + assert_eq!(anonymous.rendered.height(), 2); + let identified = owner_record(&frame.root, 2); + assert_eq!(identified.children.len(), 2); + assert!(identified + .children + .values() + .all(|record| record.source.owner_id == 2)); +} + +#[test] +fn nonzero_mixed_leaf_delta_reuses_unchanged_subtree_bodies() { + let document = mixed(8); + let baseline = document + .render_frame(None, None, test_context(), None) + .unwrap(); + let update = document + .apply_delta(context_test_delta(vec![text_update( + row_id(3) + 1, + 1, + 1, + "y", + 1, + )])) + .unwrap(); + reset_work(); + super::super::TEST_RENDER_NODE_COUNT.with(|count| count.set(Some(0))); + let target = update + .document + .render_frame(Some(&baseline), Some(&update.changes), test_context(), None) + .unwrap(); + let measured = work(); + let actual_bodies = + super::super::TEST_RENDER_NODE_COUNT.with(|count| count.replace(None).unwrap()); + assert_eq!(actual_bodies as u64, measured.body_runs); + assert_oracle(&target, test_context(), None); + assert!( + Arc::ptr_eq( + owner_record(&baseline.root, row_id(7)), + owner_record(&target.root, row_id(7)) + ), + "unchanged row record must be shared across the nonzero delta: {measured:?}" + ); + assert!(!Arc::ptr_eq( + owner_record(&baseline.root, row_id(3)), + owner_record(&target.root, row_id(3)) + )); + assert_eq!( + measured.body_runs, 5, + "only root Box/Column, changed Row/Box/Text bodies run" + ); + assert_eq!( + measured.hits, 1, + "the changed row's unchanged Flex is looked up; other rows stay in shared slots" + ); + assert_eq!(measured.records_created, 5); +} + +#[test] +fn fixed_changed_subtree_reuses_bodies_across_sibling_sizes() { + for size in [32, 128, 512] { + let document = mixed(size); + let baseline = document + .render_frame(None, None, test_context(), None) + .unwrap(); + for (offset, text) in [(1, "y"), (20, "longer")] { + let update = document + .apply_delta(context_test_delta(vec![text_update( + row_id(size / 2) + offset, + 1, + 1, + text, + 1, + )])) + .unwrap(); + reset_work(); + super::super::reset_line_plan_work(); + super::super::TEST_RENDER_NODE_COUNT.with(|count| count.set(Some(0))); + let target = update + .document + .render_frame(Some(&baseline), Some(&update.changes), test_context(), None) + .unwrap(); + let actual = + super::super::TEST_RENDER_NODE_COUNT.with(|count| count.replace(None).unwrap()); + let measured = work(); + let line_work = super::super::line_plan_work(); + assert_eq!(measured.body_runs as usize, actual); + assert!( + measured.body_runs <= 20, + "N={size}, offset={offset}: {measured:?}" + ); + assert!( + measured.caller_edges_written <= 64, + "only changed-row calls write caller edges: {measured:?}" + ); + assert_eq!(measured.dirty_seeds, 1); + assert!(measured.dirty_addresses_inserted <= 9); + assert!(Arc::ptr_eq( + owner_record(&baseline.root, row_id(size - 1)), + owner_record(&target.root, row_id(size - 1)) + )); + assert_oracle(&target, test_context(), None); + eprintln!( + "N={size}, changed_offset={offset}, eval={measured:?}, lines_mapped={}", + line_work.lines_mapped + ); + } + } +} + +#[test] +fn sparse_column_update_does_not_visit_resolve_or_rewrite_unchanged_siblings() { + for size in [32, 128, 512] { + let document = mixed(size); + let baseline = document + .render_frame(None, None, test_context(), None) + .unwrap(); + let update = document + .apply_delta(context_test_delta(vec![text_update( + row_id(size / 2) + 1, + 1, + 1, + "changed", + 1, + )])) + .unwrap(); + reset_work(); + super::super::reset_resolver_lookups(); + let target = update + .document + .render_frame(Some(&baseline), Some(&update.changes), test_context(), None) + .unwrap(); + let measured = work(); + let resolvers = super::super::resolver_lookups(); + eprintln!("sparse Column N={size}, eval={measured:?}, resolvers={resolvers}"); + assert_oracle(&target, test_context(), None); + assert_eq!(measured.body_runs, 5); + assert!(measured.child_slots_visited <= 12, "N={size}: {measured:?}"); + assert!( + measured.caller_edges_written <= 12, + "N={size}: {measured:?}" + ); + assert!(resolvers <= 12, "N={size}: resolvers={resolvers}"); + assert!(Arc::ptr_eq( + owner_record(&baseline.root, row_id(size - 1)), + owner_record(&target.root, row_id(size - 1)) + )); + } +} + +fn root_column_record(frame: &RetainedFrame) -> &Arc { + frame + .root + .children + .values() + .find(|child| child.column.is_some()) + .expect("fixture root has a complete retained Column") +} + +fn root_column(frame: &RetainedFrame) -> &Arc { + root_column_record(frame).column.as_ref().unwrap() +} + +#[test] +fn front_line_growth_and_shrink_share_column_suffix_slots_and_release_old_state() { + for size in [32, 128, 512] { + let mut document = mixed(size); + let mut frame = document + .render_frame(None, None, test_context(), None) + .unwrap(); + let original_height = owner_record(&frame.root, row_id(0)).rendered.height(); + for (revision, line_count) in [(1, original_height + 3), (2, 1)] { + let lines = (0..line_count) + .map(|_| { + serde_json::json!({"clusters":[{ + "text":"x", "width":1, "cjk":false, "space":false + }]}) + }) + .collect::>(); + let update = document.apply_delta(context_test_delta(vec![serde_json::json!({ + "node-id":row_id(0)+1, "expected-revision":revision, "target-revision":revision+1, + "slot-patches":[{"slot":1,"local":{"content":{"lines":lines}}}] + })])).unwrap(); + let old_document = Arc::downgrade(&document); + let old_column = Arc::downgrade(root_column(&frame)); + reset_work(); + super::super::reset_resolver_lookups(); + super::super::reset_line_plan_work(); + let target = update + .document + .render_frame(Some(&frame), Some(&update.changes), test_context(), None) + .unwrap(); + let measured = work(); + let resolvers = super::super::resolver_lookups(); + let line_work = super::super::line_plan_work(); + assert!(measured.child_slots_visited <= 12, "N={size}: {measured:?}"); + assert!( + measured.caller_edges_written <= 12, + "N={size}: {measured:?}" + ); + assert!(resolvers <= 12, "N={size}: {resolvers}"); + assert_eq!(measured.column_slots_updated, 1); + assert_eq!(measured.column_slots_built, 0); + assert_eq!(measured.column_full_rebuilds, 0); + assert!( + measured.child_use_nodes_created <= 32, + "N={size}: {measured:?}" + ); + assert!( + measured.column_tree_nodes_created <= 12, + "N={size}: {measured:?}" + ); + assert!(root_column(&frame).shares_shape(root_column(&target))); + assert!(root_column(&frame).shares_slot(root_column(&target), size - 1)); + assert!(!root_column(&frame).shares_slot(root_column(&target), 0)); + assert!( + Arc::ptr_eq( + root_column_record(&frame) + .children + .0 + .as_ref() + .unwrap() + .right + .0 + .as_ref() + .unwrap(), + root_column_record(&target) + .children + .0 + .as_ref() + .unwrap() + .right + .0 + .as_ref() + .unwrap(), + ), + "the unchanged right half of the caller-use map must be shared" + ); + assert_oracle(&target, test_context(), None); + assert_ne!(frame.materialize_tape(), target.materialize_tape()); + eprintln!("front Column N={size}, lines={line_count}, eval={measured:?}, line_work={line_work:?}"); + document = Arc::clone(&update.document); + drop(update); + frame = target; + assert!( + old_document.upgrade().is_none(), + "current slots must not retain the previous source document" + ); + assert!( + old_column.upgrade().is_none(), + "current slots/projections must not retain old parent state" + ); + } + } +} + +#[test] +fn padded_box_same_parameter_updates_bound_metric_work_and_release_previous_frames() { + use super::super::VerticalAlign; + + let mut excessive_work = Vec::new(); + for size in [32, 128, 512] { + for align in [ + VerticalAlign::Top, + VerticalAlign::Center, + VerticalAlign::Bottom, + ] { + let children = (0..size) + .map(|index| { + let id = index as u64 + 2; + identified(nonuniform_text(id as i64, &[1]), id, 1) + }) + .collect(); + let mut root = child_box( + 1, + LayoutNode::Column { + node_id: None, + node_revision: None, + children: Arc::new(children), + }, + None, + ); + let LayoutNode::Box { + width, + height, + padding_top, + padding_bottom, + padding_left, + padding_right, + margin_top, + margin_bottom, + margin_left, + margin_right, + foreground_style, + vertical_align, + .. + } = &mut root + else { + unreachable!() + }; + *width = Size::Pixels { value: 80 }; + *height = if align == VerticalAlign::Top { + Size::Auto + } else { + Size::Lines { + value: size as i64 + 12, + } + }; + *padding_top = 2; + *padding_bottom = 3; + *padding_left = 1; + *padding_right = 2; + *margin_top = 1; + *margin_bottom = 2; + *margin_left = 1; + *margin_right = 1; + *foreground_style = Some(0); + *vertical_align = align; + let mut input = retained_document(identified(root, 1, 1)); + input.style_count = 1; + input.styles.push( + serde_json::from_value(serde_json::json!({ + "mode":"set", "face":{"foreground":"red"} + })) + .unwrap(), + ); + let mut document = RetainedDocument::bootstrap(input).unwrap().0; + let mut frame = document + .render_frame(None, None, test_context(), None) + .unwrap(); + assert_oracle(&frame, test_context(), None); + for (revision, line_count, text) in + [(1, 1, "same-height"), (2, 4, "grow"), (3, 1, "shrink")] + { + let lines = (0..line_count) + .map(|_| { + serde_json::json!({"clusters":[{ + "text":text, "width":1, "cjk":false, "space":false + }]}) + }) + .collect::>(); + let mut delta = context_test_delta(vec![serde_json::json!({ + "node-id":2,"expected-revision":revision,"target-revision":revision+1, + "slot-patches":[{"slot":0,"local":{"content":{"lines":lines}}}] + })]); + delta.style_base_count = 1; + let update = document.apply_delta(delta).unwrap(); + let old_document = Arc::downgrade(&document); + let old_frame = Arc::downgrade(&frame); + let old_column = Arc::downgrade(root_column(&frame)); + let old_projections = frame + .root + .projections + .values() + .map(Arc::downgrade) + .collect::>(); + super::super::reset_line_plan_work(); + reset_work(); + let target = update + .document + .render_frame(Some(&frame), Some(&update.changes), test_context(), None) + .unwrap(); + let line_work = super::super::line_plan_work(); + let evaluated = work(); + assert_eq!(evaluated.column_slots_updated, 1); + assert_eq!(evaluated.column_full_rebuilds, 0); + assert!(root_column(&frame).shares_slot(root_column(&target), size - 1)); + assert_oracle(&target, test_context(), None); + assert_ne!(frame.materialize_tape(), target.materialize_tape()); + eprintln!("padded Box N={size}, align={align:?}, update={text}, eval={evaluated:?}, line_work={line_work:?}"); + if line_work.projection_lines_measured > 48 + || line_work.projection_fallback_lines_measured > 16 + { + excessive_work.push(format!("N={size}, align={align:?}, update={text}: projection_lines_measured={}, fallback_lines_measured={}", + line_work.projection_lines_measured, line_work.projection_fallback_lines_measured)); + } + document = Arc::clone(&update.document); + drop(update); + frame = target; + assert!( + old_document.upgrade().is_none(), + "framing must not retain the old document" + ); + assert!( + old_frame.upgrade().is_none(), + "framing must not retain the old frame" + ); + assert!( + old_column.upgrade().is_none(), + "framing must not retain the old Column state" + ); + assert!( + old_projections + .iter() + .all(|state| state.upgrade().is_none()), + "framing must not retain old projection states" + ); + } + } + } + assert!( + excessive_work.is_empty(), + "same-parameter padded Box work must remain bounded:\n{}", + excessive_work.join("\n") + ); +} + +#[test] +fn cold_column_and_child_uses_freeze_linear_nodes_and_request_changes_rebuild() { + for size in [32, 128, 512] { + let document = mixed(size); + reset_work(); + let baseline = document + .render_frame(None, None, test_context(), None) + .unwrap(); + let cold = work(); + assert_eq!( + cold.child_use_nodes_created + 1, + cold.records_created, + "fresh maps freeze once per current caller edge: {cold:?}" + ); + assert_eq!(cold.column_slots_built, size as u64); + assert_eq!(cold.column_tree_nodes_created, 2 * size as u64 - 1); + let changed_context = LayoutContext { + viewport_width: 79, + ..test_context() + }; + reset_work(); + let target = document + .render_frame(Some(&baseline), None, changed_context, None) + .unwrap(); + let changed = work(); + assert_eq!(changed.column_slots_built, size as u64); + assert_eq!(changed.column_slots_updated, 0); + assert!(!root_column(&baseline).shares_shape(root_column(&target))); + assert_oracle(&target, changed_context, None); + } +} + +#[test] +fn intrinsic_column_target_changes_retarget_constant_child_extra_and_count_full_work() { + let document = RetainedDocument::bootstrap(retained_document(identified( + LayoutNode::Column { + node_id: None, + node_revision: None, + children: Arc::new(vec![ + identified(nonuniform_text(2, &[1, 9]), 2, 1), + identified(nonuniform_text(3, &[5, 1]), 3, 1), + ]), + }, + 1, + 1, + ))) + .unwrap() + .0; + let context = LayoutContext { + viewport_width_known: false, + ..test_context() + }; + let baseline = document.render_frame(None, None, context, None).unwrap(); + assert_eq!( + baseline + .materialize_tape() + .lines + .iter() + .map(|line| line.width) + .collect::>(), + [5, 13, 5, 1] + ); + let lines = [7, 9] + .into_iter() + .map(|width| { + serde_json::json!({"clusters":[{ + "text":"x", "width":width, "cjk":false, "space":false + }]}) + }) + .collect::>(); + let update = document + .apply_delta(context_test_delta(vec![serde_json::json!({ + "node-id":2,"expected-revision":1,"target-revision":2, + "slot-patches":[{"slot":0,"local":{"content":{"lines":lines}}}] + })])) + .unwrap(); + reset_work(); + let target = update + .document + .render_frame(Some(&baseline), Some(&update.changes), context, None) + .unwrap(); + let measured = work(); + assert_eq!( + target + .materialize_tape() + .lines + .iter() + .map(|line| line.width) + .collect::>(), + [7, 9, 7, 3] + ); + assert_eq!(measured.column_full_rebuilds, 1); + assert_eq!( + measured.column_slots_updated, 3, + "one raw replacement and both normalized slots" + ); + assert_eq!(measured.column_slots_built, 0); + assert!(Arc::ptr_eq( + owner_record(&baseline.root, 3), + owner_record(&target.root, 3) + )); + assert_oracle(&target, context, None); +} + +#[test] +fn sparse_routes_keep_nested_literal_paths_zero_width_lines_and_node_ref_boundaries() { + let column = |children| LayoutNode::Column { + node_id: None, + node_revision: None, + children: Arc::new(children), + }; + let document = RetainedDocument::bootstrap(retained_document(identified( + child_box( + 1, + column(vec![ + column(vec![ + identified(child_box(2, nonuniform_text(12, &[1]), None), 2, 1), + column(vec![nonuniform_text(99, &[0])]), + identified(nonuniform_text(3, &[1]), 3, 1), + ]), + identified( + column(vec![identified( + child_box(5, nonuniform_text(15, &[1]), None), + 5, + 1, + )]), + 4, + 1, + ), + ]), + None, + ), + 1, + 1, + ))) + .unwrap() + .0; + let baseline = document + .render_frame(None, None, test_context(), None) + .unwrap(); + assert_oracle(&baseline, test_context(), None); + let update = document + .apply_delta(context_test_delta(vec![ + text_update(2, 1, 1, "left", 2), + text_update(5, 1, 1, "right", 3), + ])) + .unwrap(); + reset_work(); + let target = update + .document + .render_frame(Some(&baseline), Some(&update.changes), test_context(), None) + .unwrap(); + let measured = work(); + assert_eq!( + measured.column_slots_updated, 3, + "two outer slots and one NodeRef Column slot" + ); + assert_eq!(measured.column_slots_built, 0); + assert_eq!(measured.column_full_rebuilds, 0); + assert!(root_column(&baseline).shares_shape(root_column(&target))); + assert!(root_column(&baseline).shares_slot(root_column(&target), 1)); + assert!(root_column(&baseline).shares_slot(root_column(&target), 2)); + let paths = root_column_record(&target) + .children + .keys() + .map(|slot| slot.path.clone()) + .collect::>(); + assert_eq!( + paths, + vec![ + vec![ + UseStep::Child(LocalStep::ColumnChild(0)), + UseStep::Child(LocalStep::ColumnChild(0)) + ], + vec![ + UseStep::Child(LocalStep::ColumnChild(0)), + UseStep::Child(LocalStep::ColumnChild(1)), + UseStep::Child(LocalStep::ColumnChild(0)) + ], + vec![ + UseStep::Child(LocalStep::ColumnChild(0)), + UseStep::Child(LocalStep::ColumnChild(2)) + ], + vec![UseStep::Child(LocalStep::ColumnChild(1))], + ] + ); + assert!( + owner_record(&target.root, 4).column.is_some(), + "NodeRef Column remains its own caller" + ); + assert_oracle(&target, test_context(), None); +} + +#[test] +fn parent_and_child_source_updates_share_only_clean_descendants() { + let document = mixed(4); + let baseline = document + .render_frame(None, None, test_context(), None) + .unwrap(); + let update = document + .apply_delta(context_test_delta(vec![ + serde_json::json!({"node-id":1,"expected-revision":1,"target-revision":2, + "slot-patches":[{"slot":0,"local":{"width":{"kind":"pixels","value":80}}}]}), + text_update(row_id(1) + 20, 1, 1, "changed", 2), + ])) + .unwrap(); + let target = update + .document + .render_frame(Some(&baseline), Some(&update.changes), test_context(), None) + .unwrap(); + assert_oracle(&target, test_context(), None); + assert!(Arc::ptr_eq( + owner_record(&baseline.root, row_id(3)), + owner_record(&target.root, row_id(3)) + )); + assert!(!Arc::ptr_eq( + owner_record(&baseline.root, row_id(1) + 20), + owner_record(&target.root, row_id(1) + 20) + )); +} + +#[test] +fn exact_document_pair_authority_rejects_mismatch_and_cold_renders_without_proof() { + let document = mixed(2); + let baseline = document + .render_frame(None, None, test_context(), None) + .unwrap(); + let update = document + .apply_delta(context_test_delta(vec![text_update( + row_id(0) + 1, + 1, + 1, + "y", + 1, + )])) + .unwrap(); + reset_work(); + let cold = update + .document + .render_frame(Some(&baseline), None, test_context(), None) + .unwrap(); + assert_eq!(work().hits, 0); + assert!(!Arc::ptr_eq( + owner_record(&baseline.root, row_id(1)), + owner_record(&cold.root, row_id(1)) + )); + assert_oracle(&cold, test_context(), None); + let foreign = mixed(2); + let foreign_frame = foreign + .render_frame(None, None, test_context(), None) + .unwrap(); + for (target, previous) in [(&document, &baseline), (&update.document, &foreign_frame)] { + assert!(target + .render_frame(Some(previous), Some(&update.changes), test_context(), None) + .is_err()); + } + assert!(foreign + .render_frame(None, Some(&update.changes), test_context(), None) + .is_err()); + assert!(update + .document + .render_frame(None, Some(&update.changes), test_context(), None) + .is_ok()); +} + +#[test] +fn revision_and_registry_only_changes_reuse_output_and_release_old_document() { + let document = mixed(1); + let old_document = Arc::downgrade(&document); + let baseline = document + .render_frame(None, None, test_context(), None) + .unwrap(); + let delta = serde_json::from_value(serde_json::json!({ + "style-base-count":0,"styles-append":[{"mode":"set","face":{"foreground":"red"}}], + "property-template-base-count":0,"property-template-target-count":1, + "entries":[{"node-id":1,"expected-revision":1,"target-revision":2}], + })) + .unwrap(); + let update = document.apply_delta(delta).unwrap(); + reset_work(); + let target = update + .document + .render_frame(Some(&baseline), Some(&update.changes), test_context(), None) + .unwrap(); + assert_eq!( + (work().hits, work().body_runs, work().records_created), + (1, 0, 0) + ); + assert!(Arc::ptr_eq(&baseline.root, &target.root)); + assert_eq!(target.materialize_tape().style_count, 1); + assert_oracle(&target, test_context(), None); + drop(update); + drop(document); + assert!( + old_document.upgrade().is_some(), + "the old frame still owns its document" + ); + drop(baseline); + assert!( + old_document.upgrade().is_none(), + "shared evaluation records must not retain old documents" + ); + assert_eq!(target.document.style_count, 1); +} + +fn render_request( + document: &Arc, + previous: Option<&RetainedFrame>, + context: LayoutContext, + intrinsic: bool, + size_override: Option, +) -> Arc { + let transaction = RefCell::new(RenderTxn { + complete_scroll_effects: false, + previous: previous.map(|frame| Arc::clone(&frame.root)), + dirty: BTreeMap::new(), + same_shape: true, + stack: Vec::new(), + root: None, + }); + let scope = RenderScope { + view: SourceView { + node: document.effective_node(document.root_id).unwrap(), + address: SourceAddress { + owner_id: document.root_id, + path: Arc::from([]), + }, + }, + resolver: Some(document), + transaction: Some(&transaction), + path: Vec::new(), + }; + let rendered = scope.render(context, intrinsic, size_override).unwrap(); + let oracle = super::super::render_node_with_override( + scope.view.node, + Some(document), + context, + intrinsic, + size_override, + ) + .unwrap(); + assert_eq!( + rendered.into_tape(document.style_count), + oracle.into_tape(document.style_count) + ); + Arc::new(RetainedFrame { + document: Arc::clone(document), + root: transaction.into_inner().root.unwrap(), + }) +} + +#[test] +fn requests_preserve_all_context_intrinsic_and_override_fields_exactly() { + let document = mixed(1); + let context = test_context(); + let baseline = render_request(&document, None, context, false, None); + let requests = [ + ( + LayoutContext { + viewport_width: 79, + ..context + }, + false, + None, + ), + ( + LayoutContext { + viewport_width_known: false, + ..context + }, + false, + None, + ), + ( + LayoutContext { + viewport_height: 23, + ..context + }, + false, + None, + ), + ( + LayoutContext { + inline_auto_width_intrinsic: true, + ..context + }, + false, + None, + ), + (context, true, None), + (context, false, Some(BoxOverride::default())), + ( + context, + false, + Some(BoxOverride { + content_width: Some(80), + ..BoxOverride::default() + }), + ), + ( + context, + false, + Some(BoxOverride { + content_height: Some(2), + ..BoxOverride::default() + }), + ), + ( + context, + false, + Some(BoxOverride { + declared_width: Some(80), + ..BoxOverride::default() + }), + ), + ]; + for (context, intrinsic, size_override) in requests { + let changed = render_request( + &document, + Some(&baseline), + context, + intrinsic, + size_override, + ); + assert!(!Arc::ptr_eq(&baseline.root, &changed.root)); + let repeated = render_request(&document, Some(&changed), context, intrinsic, size_override); + assert!(Arc::ptr_eq(&changed.root, &repeated.root)); + } +} + +#[test] +fn viewport_a_b_a_prunes_old_records_and_has_no_history_chain() { + let document = mixed(2); + let context_a = test_context(); + let context_b = LayoutContext { + viewport_height: 42, + ..context_a + }; + let a = document.render_frame(None, None, context_a, None).unwrap(); + let old_root = Arc::downgrade(&a.root); + let old_leaf = Arc::downgrade(owner_record(&a.root, row_id(0) + 20)); + let b = document + .render_frame(Some(&a), None, context_b, None) + .unwrap(); + let count = records(&b.root).len(); + drop(a); + assert!(old_root.upgrade().is_none()); + assert!(old_leaf.upgrade().is_none()); + let weak_b = Arc::downgrade(&b.root); + let current = document + .render_frame(Some(&b), None, context_a, None) + .unwrap(); + drop(b); + assert!(weak_b.upgrade().is_none()); + assert_eq!(records(¤t.root).len(), count); + assert_oracle(¤t, context_a, None); +} + +fn scroll_document() -> Arc { + let children = (2..6) + .map(|id| { + let mut node = child_box(id, nonuniform_text(id + 100, &[1, 1, 1]), None); + let LayoutNode::Box { height, .. } = &mut node else { + unreachable!() + }; + *height = Size::Lines { value: 3 }; + identified(node, id as u64, 1) + }) + .collect(); + let mut root = child_box( + 1, + LayoutNode::Column { + node_id: None, + node_revision: None, + children: Arc::new(children), + }, + None, + ); + let LayoutNode::Box { + width, + height, + content_width_exact, + scroll_offset, + .. + } = &mut root + else { + unreachable!() + }; + *width = Size::Viewport; + *height = Size::Lines { value: 2 }; + *content_width_exact = false; + *scroll_offset = 4; + RetainedDocument::bootstrap(retained_document(identified(root, 1, 1))) + .unwrap() + .0 +} + +#[test] +fn root_scroll_mode_is_explicit_and_cannot_reuse_lazy_caller_records() { + let document = scroll_document(); + let lazy = document + .render_frame(None, None, test_context(), None) + .unwrap(); + assert!(lazy.root_scroll().is_none()); + assert!(!records(&lazy.root) + .iter() + .any(|record| record.source.owner_id == 2)); + assert!(records(&lazy.root) + .iter() + .all(|record| !record.request.complete_scroll_effects)); + let complete = document + .render_frame_with_root_scroll(Some(&lazy), None, test_context(), None) + .unwrap(); + assert_eq!(complete.root_scroll().unwrap().full_content.len(), 12); + assert!(records(&complete.root) + .iter() + .all(|record| record.request.complete_scroll_effects)); + assert!(!Arc::ptr_eq( + owner_record(&lazy.root, 3), + owner_record(&complete.root, 3) + )); + assert_oracle(&complete, test_context(), None); + let update = document + .apply_delta(context_test_delta(vec![serde_json::json!({ + "node-id":2,"expected-revision":1,"target-revision":2, + "slot-patches":[{"slot":0,"local":{"height":{"kind":"lines","value":5}}}] + })])) + .unwrap(); + let target = update + .document + .render_frame_with_root_scroll(Some(&complete), Some(&update.changes), test_context(), None) + .unwrap(); + assert_eq!(target.root_scroll().unwrap().full_content.len(), 14); + assert_ne!(complete.materialize_tape(), target.materialize_tape()); + assert_oracle(&target, test_context(), None); + reset_work(); + let lazy_again = update + .document + .render_frame(Some(&target), None, test_context(), None) + .unwrap(); + assert!(work().height_queries > 0); + assert!(lazy_again.root_scroll().is_none()); + assert!(records(&lazy_again.root) + .iter() + .all(|record| !record.request.complete_scroll_effects)); + assert_oracle(&lazy_again, test_context(), None); +} + +#[test] +fn unrendered_height_dependencies_invalidate_scroll_and_windows_cache_only_complete_values() { + let document = scroll_document(); + let baseline = document + .render_frame(None, None, test_context(), None) + .unwrap(); + assert_oracle(&baseline, test_context(), None); + assert!(!records(&baseline.root) + .iter() + .any(|record| record.source.owner_id == 2)); + assert_eq!( + owner_record(&baseline.root, 3).rendered.height(), + 3, + "the partially visible child is retained as a complete render" + ); + let update = document + .apply_delta(context_test_delta(vec![serde_json::json!({ + "node-id":2,"expected-revision":1,"target-revision":2, + "slot-patches":[{"slot":0,"local":{"height":{"kind":"lines","value":5}}}] + })])) + .unwrap(); + reset_work(); + let target = update + .document + .render_frame(Some(&baseline), Some(&update.changes), test_context(), None) + .unwrap(); + assert!(work().body_runs > 0); + assert!(work().incoming_uses_visited > 0); + assert!(work().height_queries > 0); + assert_oracle(&target, test_context(), None); + assert_ne!(baseline.materialize_tape(), target.materialize_tape()); + assert!( + Arc::ptr_eq( + owner_record(&baseline.root, 3), + owner_record(&target.root, 3) + ), + "reuse a complete child while selecting a different visible slice" + ); + let expanded = update.document.apply_delta(context_test_delta(vec![serde_json::json!({ + "node-id":1,"expected-revision":1,"target-revision":2, + "slot-patches":[{"slot":0,"local":{"height":{"kind":"lines","value":20},"scroll-offset":0}}] + })])).unwrap(); + let full = expanded + .document + .render_frame(Some(&target), Some(&expanded.changes), test_context(), None) + .unwrap(); + assert_oracle(&full, test_context(), None); + let column = full + .root + .children + .values() + .find(|record| record.source.path.as_ref() == [LocalStep::BoxChild]) + .unwrap(); + assert_eq!(column.rendered.height(), 14); + let old_column = Arc::downgrade(column); + let collapsed = expanded.document.apply_delta(context_test_delta(vec![serde_json::json!({ + "node-id":1,"expected-revision":2,"target-revision":3, + "slot-patches":[{"slot":0,"local":{"height":{"kind":"lines","value":2},"scroll-offset":4}}] + })])).unwrap(); + let window = collapsed + .document + .render_frame(Some(&full), Some(&collapsed.changes), test_context(), None) + .unwrap(); + assert_oracle(&window, test_context(), None); + assert!(!window + .root + .children + .values() + .any(|record| record.source.path.as_ref() == [LocalStep::BoxChild])); + drop(full); + assert!( + old_column.upgrade().is_none(), + "the old full-column use must be pruned" + ); +} + +#[test] +fn nested_reversed_wrapped_flex_preserves_distinct_uses_on_geometry_changes() { + let inner = flex( + (11..14) + .map(|id| { + identified( + child_box(id, nonuniform_text(id + 100, &[1]), None), + id as u64, + 1, + ) + }) + .collect(), + FlexDirection::RowReverse, + FlexWrap::WrapReverse, + ); + let outer = flex( + vec![ + identified(child_box(10, inner, None), 10, 1), + identified(child_box(20, nonuniform_text(120, &[1]), None), 20, 1), + ], + FlexDirection::ColumnReverse, + FlexWrap::WrapReverse, + ); + let document = RetainedDocument::bootstrap(retained_document(identified( + child_box(1, outer, None), + 1, + 1, + ))) + .unwrap() + .0; + let baseline = document + .render_frame(None, None, test_context(), None) + .unwrap(); + let update = document + .apply_delta(context_test_delta(vec![text_update( + 12, 1, 1, "expanded", 7, + )])) + .unwrap(); + reset_work(); + let target = update + .document + .render_frame(Some(&baseline), Some(&update.changes), test_context(), None) + .unwrap(); + assert!(work().hits > 0); + assert_oracle(&target, test_context(), None); + assert!(Arc::ptr_eq( + owner_record(&baseline.root, 20), + owner_record(&target.root, 20) + )); + assert!( + records(&target.root) + .iter() + .filter(|record| record.source.owner_id == 12 && record.source.path.is_empty()) + .count() + > 1, + "a Flex owner has multiple current evaluation uses" + ); +} + +fn repeated_calls( + document: &Arc, + previous: Option<&RetainedFrame>, + widths: &[i64], +) -> Arc { + let source = SourceAddress { + owner_id: document.root_id, + path: Arc::from([]), + }; + let transaction = RefCell::new(RenderTxn { + complete_scroll_effects: false, + previous: previous.map(|frame| Arc::clone(&frame.root)), + dirty: BTreeMap::from([( + source.clone(), + DirtyRoute { + local: true, + next: BTreeSet::new(), + }, + )]), + same_shape: true, + stack: Vec::new(), + root: None, + }); + let node = document.effective_node(document.root_id).unwrap(); + let scope = RenderScope { + view: SourceView { + node, + address: source.clone(), + }, + resolver: Some(document), + transaction: Some(&transaction), + path: Vec::new(), + }; + let request = EvalRequest { + root_scroll_request: false, + complete_scroll_effects: false, + context: test_context(), + intrinsic: false, + size_override: None, + }; + assert!(transaction + .borrow_mut() + .begin(&source, &[], request) + .is_none()); + let LayoutNode::Box { + child: Some(child), .. + } = node + else { + unreachable!() + }; + let child_scope = scope + .child(LocalStep::BoxChild, child) + .phase(Phase::ContentIntrinsic); + let parts = widths + .iter() + .map(|width| { + child_scope + .render( + LayoutContext { + viewport_width: *width, + ..test_context() + }, + true, + None, + ) + .unwrap() + }) + .collect(); + transaction + .borrow_mut() + .finish(Ok(super::super::stack_vertical(parts))) + .unwrap(); + Arc::new(RetainedFrame { + document: Arc::clone(document), + root: transaction.into_inner().root.unwrap(), + }) +} + +#[test] +fn repeated_callsite_uses_have_local_occurrences_and_prune_unused_contexts() { + let document = RetainedDocument::bootstrap(retained_document(identified( + child_box(1, nonuniform_text(2, &[1]), None), + 1, + 1, + ))) + .unwrap() + .0; + let baseline = repeated_calls(&document, None, &[10, 20]); + let keys = baseline.root.children.keys().collect::>(); + assert_eq!(keys[0].path, keys[1].path); + assert_eq!((keys[0].occurrence, keys[1].occurrence), (0, 1)); + let removed = Arc::downgrade(baseline.root.children.get(keys[1]).unwrap()); + reset_work(); + let one = repeated_calls(&document, Some(&baseline), &[10]); + assert_eq!(work().hits, 1); + assert_eq!(one.root.children.len(), 1); + drop(baseline); + assert!(removed.upgrade().is_none()); + reset_work(); + let changed = repeated_calls(&document, Some(&one), &[20]); + assert_eq!( + work().hits, + 0, + "a discarded second occurrence is not a historical context cache" + ); + assert_eq!(changed.root.children.len(), 1); +} + +#[test] +fn independent_nonzero_forks_share_clean_records_without_retaining_old_documents() { + let document = mixed(3); + let old_document = Arc::downgrade(&document); + let baseline = document + .render_frame(None, None, test_context(), None) + .unwrap(); + let left_update = document + .apply_delta(context_test_delta(vec![text_update( + row_id(0) + 1, + 1, + 1, + "left", + 1, + )])) + .unwrap(); + let right_update = document + .apply_delta(context_test_delta(vec![text_update( + row_id(1) + 1, + 1, + 1, + "right", + 1, + )])) + .unwrap(); + let left = left_update + .document + .render_frame( + Some(&baseline), + Some(&left_update.changes), + test_context(), + None, + ) + .unwrap(); + let right = right_update + .document + .render_frame( + Some(&baseline), + Some(&right_update.changes), + test_context(), + None, + ) + .unwrap(); + assert_oracle(&left, test_context(), None); + assert_oracle(&right, test_context(), None); + assert!(Arc::ptr_eq( + owner_record(&left.root, row_id(2)), + owner_record(&right.root, row_id(2)) + )); + assert!(!Arc::ptr_eq( + owner_record(&left.root, row_id(0)), + owner_record(&right.root, row_id(0)) + )); + let old_changed = Arc::downgrade(owner_record(&baseline.root, row_id(0))); + drop(left_update); + drop(right_update); + drop(baseline); + drop(document); + assert!(old_document.upgrade().is_none()); + assert!( + old_changed.upgrade().is_some(), + "the independent right fork still shares this row" + ); + drop(right); + assert!(old_changed.upgrade().is_none()); + assert_oracle(&left, test_context(), None); +} + +#[test] +fn unwinding_discards_current_builder_records_without_mutating_previous_frame() { + let document = mixed(1); + let baseline = document + .render_frame(None, None, test_context(), None) + .unwrap(); + let baseline_tape = baseline.materialize_tape(); + let created = RefCell::new(None); + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let source = baseline.root.source.clone(); + let transaction = RefCell::new(RenderTxn { + complete_scroll_effects: false, + previous: Some(Arc::clone(&baseline.root)), + dirty: BTreeMap::from([( + source.clone(), + DirtyRoute { + local: true, + next: BTreeSet::new(), + }, + )]), + same_shape: true, + stack: Vec::new(), + root: None, + }); + let node = document.effective_node(document.root_id).unwrap(); + let scope = RenderScope { + view: SourceView { + node, + address: source.clone(), + }, + resolver: Some(&document), + transaction: Some(&transaction), + path: Vec::new(), + }; + transaction + .borrow_mut() + .begin(&source, &[], baseline.root.request); + let LayoutNode::Box { + child: Some(child), .. + } = node + else { + unreachable!() + }; + scope + .child(LocalStep::BoxChild, child) + .render( + LayoutContext { + viewport_height: 99, + ..test_context() + }, + false, + None, + ) + .unwrap(); + *created.borrow_mut() = Some(Arc::downgrade( + transaction + .borrow() + .stack + .last() + .unwrap() + .children + .values() + .next() + .unwrap(), + )); + panic!("injected caller failure after child evaluation"); + })); + assert!(result.is_err()); + assert!(created.into_inner().unwrap().upgrade().is_none()); + assert_eq!(baseline.materialize_tape(), baseline_tape); + let retry = document + .render_frame(Some(&baseline), None, test_context(), None) + .unwrap(); + assert!(Arc::ptr_eq(&retry.root, &baseline.root)); +} diff --git a/native/src/layout.rs b/native/src/layout.rs index 2f40df7..4b1671a 100644 --- a/native/src/layout.rs +++ b/native/src/layout.rs @@ -6,6 +6,50 @@ use std::collections::BTreeMap; use std::collections::HashSet; use std::sync::Arc; +#[path = "source_topology.rs"] +mod source_topology; +use source_topology::{LocalStep, SourceTopology, TopologyBuilder}; + +#[path = "atom_plan.rs"] +mod atom_plan; +use atom_plan::AtomPlan; +pub(crate) use atom_plan::AtomPlanWork; + +pub(crate) fn reset_atom_plan_work() { + atom_plan::reset_work(); +} + +pub(crate) fn atom_plan_work() -> AtomPlanWork { + atom_plan::work() +} + +#[path = "line_plan.rs"] +mod line_plan; +pub(crate) use line_plan::LinePlanWork; +use line_plan::{BoxProjectionSlot, FrameSpec, LineOp, LinePlan, PlanChange, ProjectionState}; + +pub(crate) fn reset_line_plan_work() { + line_plan::reset_work(); +} + +pub(crate) fn line_plan_work() -> LinePlanWork { + line_plan::work() +} + +#[path = "composition.rs"] +mod composition; +#[path = "evaluation.rs"] +mod evaluation; +pub(crate) use evaluation::{EvalWork, RetainedFrame}; +use evaluation::{Phase, RenderScope}; + +pub(crate) fn reset_eval_work() { + evaluation::reset_work(); +} +pub(crate) fn eval_work() -> EvalWork { + evaluation::work() +} + fn deserialize_arc_vec<'de, D, T>(deserializer: D) -> Result>, D::Error> where D: Deserializer<'de>, @@ -123,6 +167,173 @@ struct LocalSlotPatch { local: JsonMap, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum LocalField { + RegionId, + Content, + ContentRegionId, + ContentTypographyStyle, + ContentForegroundStyle, + ContentSurfaceTemplateId, + ContentWidthExact, + ContentMinWidth, + Width, + MinWidth, + MaxWidth, + Height, + MinHeight, + MaxHeight, + BoxSizing, + PaddingLeft, + PaddingRight, + PaddingTop, + PaddingBottom, + MarginLeft, + MarginRight, + MarginTop, + MarginBottom, + BorderLeft, + BorderRight, + TypographyStyle, + ForegroundStyle, + BackgroundStyle, + BorderLeftStyle, + BorderRightStyle, + BorderTopStyle, + BorderBottomStyle, + SurfaceTemplateId, + TextAlign, + VerticalAlign, + Overflow, + WrapMode, + ScrollOffset, + Direction, + Wrap, + Justify, + AlignItems, + AlignContent, + RowGap, + ColumnGap, +} + +#[derive(Debug)] +struct SlotChange { + slot: u8, + fields: Vec, +} + +#[derive(Debug)] +struct OwnerChange { + node_id: u64, + expected_revision: u64, + target_revision: u64, + slots: Vec, +} + +/// Transient source facts for one exact pair of documents, never retained in a +/// document or confirmed baseline. The current renderer does not cache by these facts. +#[derive(Debug)] +pub(crate) struct SourceChanges { + base: Arc, + target: Arc, + owners: Vec, + styles: std::ops::Range, + property_templates: std::ops::Range, +} + +impl SourceChanges { + pub(crate) fn applies_to( + &self, + base: &Arc, + target: &Arc, + ) -> bool { + Arc::ptr_eq(&self.base, base) && Arc::ptr_eq(&self.target, target) + } + + pub(crate) fn visit_changed_slots( + &self, + mut visit: impl FnMut(u64, u64, u64, u8, &[LocalField]), + ) { + for owner in &self.owners { + for slot in &owner.slots { + visit( + owner.node_id, + owner.expected_revision, + owner.target_revision, + slot.slot, + &slot.fields, + ); + } + } + } + + pub(crate) fn registry_ranges(&self) -> (std::ops::Range, std::ops::Range) { + (self.styles.clone(), self.property_templates.clone()) + } +} + +/// Counts concrete comparisons/copies at delta preparation and emitted source +/// changes at input integration. These are not layout-cache or topology counters. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "kebab-case")] +pub(crate) struct SourceChangeWork { + pub(crate) fields_compared: u64, + pub(crate) size_nodes_compared: u64, + pub(crate) text_lines_compared: u64, + pub(crate) text_clusters_compared: u64, + pub(crate) text_bytes_compared: u64, + pub(crate) local_nodes_copied: u64, + pub(crate) owners_changed: u64, + pub(crate) slots_changed: u64, + pub(crate) fields_changed: u64, + pub(crate) styles_appended: u64, + pub(crate) property_templates_added: u64, +} + +impl SourceChangeWork { + pub(crate) fn accumulate(&mut self, other: Self) { + self.fields_compared += other.fields_compared; + self.size_nodes_compared += other.size_nodes_compared; + self.text_lines_compared += other.text_lines_compared; + self.text_clusters_compared += other.text_clusters_compared; + self.text_bytes_compared += other.text_bytes_compared; + self.local_nodes_copied += other.local_nodes_copied; + self.owners_changed += other.owners_changed; + self.slots_changed += other.slots_changed; + self.fields_changed += other.fields_changed; + self.styles_appended += other.styles_appended; + self.property_templates_added += other.property_templates_added; + } +} + +#[derive(Debug, Default)] +pub(crate) struct DeltaStats { + pub(crate) entries_parsed: u64, + pub(crate) trie_path_nodes_copied: u64, + pub(crate) source_work: SourceChangeWork, +} + +#[derive(Debug)] +pub(crate) struct AppliedDelta { + pub(crate) document: Arc, + pub(crate) changes: SourceChanges, + pub(crate) stats: DeltaStats, +} + +struct PatchedOwner { + node: Arc, + slot_work: [Arc; 2], + work_delta: LocalWorkDelta, + slots: Vec, +} + +struct PatchedLocal<'a> { + node: Option, + // Names come from the same dispatch as the typed identifiers, for the + // existing local-work summary; no second field classification is needed. + fields: Vec<(LocalField, &'a str)>, +} + #[derive(Debug)] struct RetainedEntry { revision: u64, @@ -131,7 +342,18 @@ struct RetainedEntry { } #[derive(Debug, Clone, Default)] -struct LocalWorkSummary(BTreeMap<&'static str, usize>); +struct LocalWorkSummary(BTreeMap<&'static str, LocalFieldWork>); + +#[derive(Debug, Clone, PartialEq, Eq)] +struct LocalFieldWork { + units: usize, + viewport_heights: usize, +} + +struct LocalWorkDelta { + units: isize, + viewport_heights: i128, +} #[derive(Debug)] struct RadixNode { @@ -196,10 +418,12 @@ fn radix_insert( pub(crate) struct RetainedDocument { root_id: u64, entries: Arc, + topology: Arc, styles: Arc, style_count: u32, property_template_count: u32, work_units: usize, + context_viewport_heights: usize, } #[derive(Debug)] @@ -314,83 +538,112 @@ fn visit_layout_children(node: &LayoutNode, mut visit: impl FnMut(&LayoutNode)) impl RetainedDocument { pub(crate) fn bootstrap(document: LayoutDocument) -> Result<(Arc, u64), String> { let (node_count, work_units) = document.validate_metrics()?; - fn install_owner( - mut node: LayoutNode, - entries: &mut Arc, - seen: &mut HashSet, - count: &mut u64, - ) -> Result { - fn localize( - node: &mut LayoutNode, - owner_id: u64, - entries: &mut Arc, - seen: &mut HashSet, - count: &mut u64, - ) -> Result<(), String> { - fn localize_child( - child: LayoutNode, - owner_id: u64, - entries: &mut Arc, - seen: &mut HashSet, - count: &mut u64, - ) -> Result { - if child.node_id().is_some() { - let installed = install_owner(child, entries, seen, count)?; - return Ok(LayoutNode::NodeRef { node_id: installed }); - } - let mut child = child; - localize(&mut child, owner_id, entries, seen, count)?; - Ok(child) - } - match node { - LayoutNode::Box { child, .. } => { - if let Some(old) = child.take() { - let old = Arc::try_unwrap(old).unwrap_or_else(|value| (*value).clone()); - *child = Some(Arc::new(localize_child( - old, owner_id, entries, seen, count, - )?)); - } - } - LayoutNode::Row { children, .. } | LayoutNode::Column { children, .. } => { - let old = std::mem::take(children); - let old = Arc::try_unwrap(old).unwrap_or_else(|value| (*value).clone()); - let mut localized = Vec::with_capacity(old.len()); - for child in old { - localized.push(localize_child(child, owner_id, entries, seen, count)?); - } - *children = Arc::new(localized); - } - LayoutNode::Flex { items, .. } => { - let old = std::mem::take(items); - let old = Arc::try_unwrap(old).unwrap_or_else(|value| (*value).clone()); - let mut localized = Vec::with_capacity(old.len()); - for mut item in old { - item.node = localize_child(item.node, owner_id, entries, seen, count)?; - localized.push(item); - } - *items = Arc::new(localized); - } - LayoutNode::Text { .. } => {} - LayoutNode::NodeRef { .. } => { - return Err( - "Native retained bootstrap received a node reference".to_owned() - ); - } - } - Ok(()) + struct BootstrapState { + entries: Arc, + seen: HashSet, + owner_count: u64, + context_viewport_heights: usize, + topology: TopologyBuilder, + } + + fn localize_child( + mut child: LayoutNode, + owner_id: u64, + path: &mut Vec, + slot: LocalStep, + state: &mut BootstrapState, + ) -> Result { + if child.node_id().is_some() { + let installed = install_owner(child, state)?; + state.topology.add_use(owner_id, path, slot, installed)?; + return Ok(LayoutNode::NodeRef { node_id: installed }); } + path.push(slot); + let result = localize(&mut child, owner_id, path, state); + path.pop(); + result?; + Ok(child) + } + fn localize( + node: &mut LayoutNode, + owner_id: u64, + path: &mut Vec, + state: &mut BootstrapState, + ) -> Result<(), String> { + // This is the existing one-time traversal, including anonymous nodes. + visit_context_height_fields(node, |_, size| { + state.context_viewport_heights = state + .context_viewport_heights + .checked_add(context_size_occurrences(size)?) + .ok_or_else(|| "Native retained context summary invariant failed".to_owned())?; + Ok(()) + })?; + let column = matches!(node, LayoutNode::Column { .. }); + match node { + LayoutNode::Box { child, .. } => { + if let Some(old) = child.take() { + let old = Arc::try_unwrap(old).unwrap_or_else(|value| (*value).clone()); + *child = Some(Arc::new(localize_child( + old, + owner_id, + path, + LocalStep::BoxChild, + state, + )?)); + } + } + LayoutNode::Row { children, .. } | LayoutNode::Column { children, .. } => { + let old = std::mem::take(children); + let old = Arc::try_unwrap(old).unwrap_or_else(|value| (*value).clone()); + let mut localized = Vec::with_capacity(old.len()); + for (index, child) in old.into_iter().enumerate() { + let slot = if column { + LocalStep::ColumnChild(index) + } else { + LocalStep::RowChild(index) + }; + localized.push(localize_child(child, owner_id, path, slot, state)?); + } + *children = Arc::new(localized); + } + LayoutNode::Flex { items, .. } => { + let old = std::mem::take(items); + let old = Arc::try_unwrap(old).unwrap_or_else(|value| (*value).clone()); + let mut localized = Vec::with_capacity(old.len()); + for (index, mut item) in old.into_iter().enumerate() { + item.node = localize_child( + item.node, + owner_id, + path, + LocalStep::FlexItem(index), + state, + )?; + localized.push(item); + } + *items = Arc::new(localized); + } + LayoutNode::Text { .. } => {} + LayoutNode::NodeRef { .. } => { + return Err("Native retained bootstrap received a node reference".to_owned()); + } + } + Ok(()) + } + + fn install_owner(mut node: LayoutNode, state: &mut BootstrapState) -> Result { let node_id = node.node_id().ok_or_else(|| { "Native retained document owner is missing its node id".to_owned() })?; let revision = node.node_revision().ok_or_else(|| { "Native retained document node is missing its revision".to_owned() })?; - if node_id == 0 || !seen.insert(node_id) { + if node_id == 0 || !state.seen.insert(node_id) { return Err("Native retained document has invalid duplicate node id".to_owned()); } - localize(&mut node, node_id, entries, seen, count)?; + // Each owner starts a fresh local address; paths never cross NodeRefs. + localize(&mut node, node_id, &mut Vec::new(), state)?; let slot_one = match &node { LayoutNode::Box { child: Some(child), .. @@ -402,17 +655,21 @@ impl RetainedDocument { slot_work: [Arc::new(local_work_summary(&node)?), Arc::new(slot_one)], node: Arc::new(node), }); - let (updated, _) = radix_insert(entries, node_id, entry); - *entries = updated; - *count += 1; + let (updated, _) = radix_insert(&state.entries, node_id, entry); + state.entries = updated; + state.owner_count += 1; Ok(node_id) } - let mut entries = Arc::new(RadixNode::default()); - let mut seen = HashSet::new(); - let mut owner_count = 0; - let root_id = install_owner(document.root, &mut entries, &mut seen, &mut owner_count)?; - debug_assert!(usize::try_from(owner_count).is_ok_and(|count| count <= node_count)); + let mut state = BootstrapState { + entries: Arc::new(RadixNode::default()), + seen: HashSet::new(), + owner_count: 0, + context_viewport_heights: 0, + topology: TopologyBuilder::default(), + }; + let root_id = install_owner(document.root, &mut state)?; + debug_assert!(usize::try_from(state.owner_count).is_ok_and(|count| count <= node_count)); let mut styles = Arc::new(StyleRadixNode::default()); if document.styles.len() != document.style_count as usize { return Err("Native retained style table count mismatch".to_owned()); @@ -421,20 +678,29 @@ impl RetainedDocument { let (updated, _) = style_insert(&styles, index as u64, Arc::new(style)); styles = updated; } + let topology = state.topology.finish(root_id); + debug_assert!(topology.incoming_uses(root_id).is_empty()); + debug_assert_eq!( + topology.build_work().occurrences_written, + state.owner_count - 1 + ); Ok(( Arc::new(Self { root_id, - entries, + entries: state.entries, + topology, styles, style_count: document.style_count, property_template_count: document.property_template_count, work_units, + context_viewport_heights: state.context_viewport_heights, }), node_count as u64, )) } fn effective_node(&self, node_id: u64) -> Option<&LayoutNode> { + debug_assert_eq!(self.root_id, self.topology.root_owner()); radix_lookup(&self.entries, node_id).map(|entry| entry.node.as_ref()) } @@ -461,9 +727,9 @@ impl RetainedDocument { } pub(crate) fn apply_delta( - &self, + self: &Arc, delta: DocumentDelta, - ) -> Result<(Arc, u64, u64), String> { + ) -> Result { if delta.style_base_count != self.style_count || delta.property_template_base_count != self.property_template_count || delta.property_template_target_count < delta.property_template_base_count @@ -483,7 +749,10 @@ impl RetainedDocument { } let mut seen = HashSet::with_capacity(delta.entries.len()); let mut prepared = Vec::with_capacity(delta.entries.len()); + let mut owners = Vec::new(); + let mut stats = DeltaStats::default(); let mut work_delta_total = 0_i128; + let mut context_delta_total = 0_i128; for update in delta.entries { if !seen.insert(update.node_id) { return Err("Native retained delta contains a duplicate node id".to_owned()); @@ -495,35 +764,34 @@ impl RetainedDocument { { return Err("Native retained delta node revision mismatch".to_owned()); } - let node = if update.slot_patches.is_empty() { - Arc::clone(&old.node) - } else { - let (patched, slot_work, work_delta) = patch_owner_slots( - &old.node, - &old.slot_work, - &update.slot_patches, - target_style_count, - delta.property_template_target_count, - )?; - work_delta_total = work_delta_total - .checked_add(work_delta as i128) - .ok_or_else(|| "Native layout work estimate overflowed".to_owned())?; - prepared.push(( - update.node_id, - Arc::new(RetainedEntry { - revision: update.target_revision, - node: Arc::new(patched), - slot_work, - }), - )); - continue; - }; + let patched = patch_owner_slots( + &old.node, + &old.slot_work, + &update.slot_patches, + target_style_count, + delta.property_template_target_count, + &mut stats.source_work, + )?; + work_delta_total = work_delta_total + .checked_add(patched.work_delta.units as i128) + .ok_or_else(|| "Native layout work estimate overflowed".to_owned())?; + context_delta_total = context_delta_total + .checked_add(patched.work_delta.viewport_heights) + .ok_or_else(|| "Native retained context summary invariant failed".to_owned())?; + if !patched.slots.is_empty() { + owners.push(OwnerChange { + node_id: update.node_id, + expected_revision: update.expected_revision, + target_revision: update.target_revision, + slots: patched.slots, + }); + } prepared.push(( update.node_id, Arc::new(RetainedEntry { revision: update.target_revision, - node, - slot_work: old.slot_work.clone(), + node: patched.node, + slot_work: patched.slot_work, }), )); } @@ -545,6 +813,10 @@ impl RetainedDocument { if work_units > MAX_LAYOUT_WORK_UNITS { return Err("Native layout exceeds the work-unit limit".to_owned()); } + let context_viewport_heights = (self.context_viewport_heights as i128) + .checked_add(context_delta_total) + .and_then(|count| usize::try_from(count).ok()) + .ok_or_else(|| "Native retained context summary invariant failed".to_owned())?; let parsed = prepared.len() as u64; let mut entries = Arc::clone(&self.entries); let mut copied = 0; @@ -560,47 +832,75 @@ impl RetainedDocument { styles = updated; copied += count; } - Ok(( - Arc::new(Self { - root_id: self.root_id, - entries, - styles, - style_count: target_style_count, - property_template_count: delta.property_template_target_count, - work_units, - }), - parsed, - copied, - )) + let document = Arc::new(Self { + root_id: self.root_id, + topology: Arc::clone(&self.topology), + entries, + styles, + style_count: target_style_count, + property_template_count: delta.property_template_target_count, + work_units, + context_viewport_heights, + }); + stats.entries_parsed = parsed; + stats.trie_path_nodes_copied = copied; + let changes = SourceChanges { + base: Arc::clone(self), + target: Arc::clone(&document), + owners, + styles: self.style_count..target_style_count, + property_templates: self.property_template_count..document.property_template_count, + }; + Ok(AppliedDelta { + document, + changes, + stats, + }) } } fn patch_owner_slots( - base: &LayoutNode, + base: &Arc, base_work: &[Arc; 2], patches: &[LocalSlotPatch], style_count: u32, property_template_count: u32, -) -> Result<(LayoutNode, [Arc; 2], isize), String> { - let mut result = base.clone(); + source_work: &mut SourceChangeWork, +) -> Result { + let mut result = Arc::clone(base); let mut slot_work = base_work.clone(); let mut work_delta = 0_isize; + let mut context_delta = 0_i128; let mut seen = [false; 2]; + let mut slots = Vec::new(); for patch in patches { let index = patch.slot as usize; if index >= seen.len() || std::mem::replace(&mut seen[index], true) { return Err("Native retained delta has an invalid duplicate slot".to_owned()); } if patch.slot == 0 { - let old_work = slot_work[0].changed_work(&patch.local); - result = patch_local_node(&result, &patch.local)?; - validate_changed_fields(&result, &patch.local, style_count, property_template_count)?; - let updated = slot_work[0].updated(&result, &patch.local)?; - let new_work = updated.changed_work(&patch.local); + let patched = patch_local_node(&result, &patch.local, source_work)?; + validate_changed_fields( + patched.node.as_ref().unwrap_or(&result), + &patch.local, + style_count, + property_template_count, + )?; + let Some(node) = patched.node else { continue }; + let old_work = slot_work[0].changed_work(&patched.fields); + let updated = slot_work[0].updated(&node, &patched.fields)?; + context_delta += updated.changed_context_occurrences(&patched.fields) + - slot_work[0].changed_context_occurrences(&patched.fields); + let new_work = updated.changed_work(&patched.fields); work_delta += new_work as isize - old_work as isize; - slot_work[0] = Arc::new(updated); + slot_work[0] = updated; + result = Arc::new(node); + slots.push(SlotChange { + slot: 0, + fields: patched.fields.into_iter().map(|(field, _)| field).collect(), + }); } else { - let LayoutNode::Box { child, .. } = &mut result else { + let LayoutNode::Box { child, .. } = result.as_ref() else { return Err("Native retained delta slot one requires a box owner".to_owned()); }; let old_child = child @@ -609,35 +909,83 @@ fn patch_owner_slots( if old_child.node_id().is_some() { return Err("Native retained delta slot one must be anonymous".to_owned()); } - let old_work = slot_work[1].changed_work(&patch.local); - let new_child = patch_local_node(old_child, &patch.local)?; + let patched = patch_local_node(old_child, &patch.local, source_work)?; validate_changed_fields( - &new_child, + patched.node.as_ref().unwrap_or(old_child), &patch.local, style_count, property_template_count, )?; - let updated = slot_work[1].updated(&new_child, &patch.local)?; - let new_work = updated.changed_work(&patch.local); + let Some(new_child) = patched.node else { + continue; + }; + let old_work = slot_work[1].changed_work(&patched.fields); + let updated = slot_work[1].updated(&new_child, &patched.fields)?; + context_delta += updated.changed_context_occurrences(&patched.fields) + - slot_work[1].changed_context_occurrences(&patched.fields); + let new_work = updated.changed_work(&patched.fields); work_delta += new_work as isize - old_work as isize; - slot_work[1] = Arc::new(updated); + slot_work[1] = updated; + if Arc::strong_count(&result) > 1 { + source_work.local_nodes_copied += 1; + } + let LayoutNode::Box { child, .. } = Arc::make_mut(&mut result) else { + unreachable!() + }; *child = Some(Arc::new(new_child)); + slots.push(SlotChange { + slot: 1, + fields: patched.fields.into_iter().map(|(field, _)| field).collect(), + }); } } - Ok((result, slot_work, work_delta)) + Ok(PatchedOwner { + node: result, + slot_work, + work_delta: LocalWorkDelta { + units: work_delta, + viewport_heights: context_delta, + }, + slots, + }) } -fn patch_local_node( +fn patch_local_node<'a>( base: &LayoutNode, - fields: &JsonMap, -) -> Result { + fields: &'a JsonMap, + source_work: &mut SourceChangeWork, +) -> Result, String> { fn parsed(value: &JsonValue) -> Result { serde_json::from_value(value.clone()) .map_err(|error| format!("Invalid native retained local field: {error}")) } - let mut output = base.clone(); + let mut output = None; + let mut changed_fields = Vec::new(); for (name, value) in fields { - match &mut output { + macro_rules! assign { + ($variant:ident, $field:ident, $id:ident, $same:ident, $parsed:expr) => {{ + let next = $parsed; + source_work.fields_compared += 1; + if !$same($field, &next, source_work) { + let node = output.get_or_insert_with(|| { + source_work.local_nodes_copied += 1; + base.clone() + }); + let LayoutNode::$variant { $field, .. } = node else { + unreachable!() + }; + *$field = next; + changed_fields.push((LocalField::$id, name.as_str())); + } + }}; + ($variant:ident, $field:ident, $id:ident) => { + assign!($variant, $field, $id, same_scalar_source, parsed(value)?) + }; + ($variant:ident, $field:ident, $id:ident, $same:ident) => { + assign!($variant, $field, $id, $same, parsed(value)?) + }; + } + match output.as_ref().unwrap_or(base) { LayoutNode::Box { region_id, content, @@ -679,44 +1027,56 @@ fn patch_local_node( scroll_offset, .. } => match name.as_str() { - "region-id" => *region_id = parsed(value)?, - "content" => *content = parsed::>(value)?.map(Arc::new), - "content-region-id" => *content_region_id = parsed(value)?, - "content-typography-style" => *content_typography_style = parsed(value)?, - "content-foreground-style" => *content_foreground_style = parsed(value)?, - "content-surface-template-id" => *content_surface_template_id = parsed(value)?, - "content-width-exact" => *content_width_exact = parsed(value)?, - "content-min-width" => *content_min_width = parsed(value)?, - "width" => *width = parsed(value)?, - "min-width" => *min_width = parsed(value)?, - "max-width" => *max_width = parsed(value)?, - "height" => *height = parsed(value)?, - "min-height" => *min_height = parsed(value)?, - "max-height" => *max_height = parsed(value)?, - "box-sizing" => *box_sizing = parsed(value)?, - "padding-left" => *padding_left = parsed(value)?, - "padding-right" => *padding_right = parsed(value)?, - "padding-top" => *padding_top = parsed(value)?, - "padding-bottom" => *padding_bottom = parsed(value)?, - "margin-left" => *margin_left = parsed(value)?, - "margin-right" => *margin_right = parsed(value)?, - "margin-top" => *margin_top = parsed(value)?, - "margin-bottom" => *margin_bottom = parsed(value)?, - "border-left" => *border_left = parsed(value)?, - "border-right" => *border_right = parsed(value)?, - "typography-style" => *typography_style = parsed(value)?, - "foreground-style" => *foreground_style = parsed(value)?, - "background-style" => *background_style = parsed(value)?, - "border-left-style" => *border_left_style = parsed(value)?, - "border-right-style" => *border_right_style = parsed(value)?, - "border-top-style" => *border_top_style = parsed(value)?, - "border-bottom-style" => *border_bottom_style = parsed(value)?, - "surface-template-id" => *surface_template_id = parsed(value)?, - "text-align" => *text_align = parsed(value)?, - "vertical-align" => *vertical_align = parsed(value)?, - "overflow" => *overflow = parsed(value)?, - "wrap-mode" => *wrap_mode = parsed(value)?, - "scroll-offset" => *scroll_offset = parsed(value)?, + "region-id" => assign!(Box, region_id, RegionId), + "content" => assign!( + Box, + content, + Content, + same_optional_content_source, + parsed::>(value)?.map(Arc::new) + ), + "content-region-id" => assign!(Box, content_region_id, ContentRegionId), + "content-typography-style" => { + assign!(Box, content_typography_style, ContentTypographyStyle) + } + "content-foreground-style" => { + assign!(Box, content_foreground_style, ContentForegroundStyle) + } + "content-surface-template-id" => { + assign!(Box, content_surface_template_id, ContentSurfaceTemplateId) + } + "content-width-exact" => assign!(Box, content_width_exact, ContentWidthExact), + "content-min-width" => assign!(Box, content_min_width, ContentMinWidth), + "width" => assign!(Box, width, Width, same_size_source), + "min-width" => assign!(Box, min_width, MinWidth, same_size_source), + "max-width" => assign!(Box, max_width, MaxWidth, same_size_source), + "height" => assign!(Box, height, Height, same_size_source), + "min-height" => assign!(Box, min_height, MinHeight, same_size_source), + "max-height" => assign!(Box, max_height, MaxHeight, same_size_source), + "box-sizing" => assign!(Box, box_sizing, BoxSizing), + "padding-left" => assign!(Box, padding_left, PaddingLeft), + "padding-right" => assign!(Box, padding_right, PaddingRight), + "padding-top" => assign!(Box, padding_top, PaddingTop), + "padding-bottom" => assign!(Box, padding_bottom, PaddingBottom), + "margin-left" => assign!(Box, margin_left, MarginLeft), + "margin-right" => assign!(Box, margin_right, MarginRight), + "margin-top" => assign!(Box, margin_top, MarginTop), + "margin-bottom" => assign!(Box, margin_bottom, MarginBottom), + "border-left" => assign!(Box, border_left, BorderLeft), + "border-right" => assign!(Box, border_right, BorderRight), + "typography-style" => assign!(Box, typography_style, TypographyStyle), + "foreground-style" => assign!(Box, foreground_style, ForegroundStyle), + "background-style" => assign!(Box, background_style, BackgroundStyle), + "border-left-style" => assign!(Box, border_left_style, BorderLeftStyle), + "border-right-style" => assign!(Box, border_right_style, BorderRightStyle), + "border-top-style" => assign!(Box, border_top_style, BorderTopStyle), + "border-bottom-style" => assign!(Box, border_bottom_style, BorderBottomStyle), + "surface-template-id" => assign!(Box, surface_template_id, SurfaceTemplateId), + "text-align" => assign!(Box, text_align, TextAlign), + "vertical-align" => assign!(Box, vertical_align, VerticalAlign), + "overflow" => assign!(Box, overflow, Overflow), + "wrap-mode" => assign!(Box, wrap_mode, WrapMode), + "scroll-offset" => assign!(Box, scroll_offset, ScrollOffset), _ => return Err(format!("Unsupported native retained box field {name}")), }, LayoutNode::Text { @@ -728,12 +1088,18 @@ fn patch_local_node( wrap_mode, .. } => match name.as_str() { - "region-id" => *region_id = parsed(value)?, - "content" => *content = Arc::new(parsed(value)?), - "typography-style" => *typography_style = parsed(value)?, - "foreground-style" => *foreground_style = parsed(value)?, - "surface-template-id" => *surface_template_id = parsed(value)?, - "wrap-mode" => *wrap_mode = parsed(value)?, + "region-id" => assign!(Text, region_id, RegionId), + "content" => assign!( + Text, + content, + Content, + same_text_source, + Arc::new(parsed(value)?) + ), + "typography-style" => assign!(Text, typography_style, TypographyStyle), + "foreground-style" => assign!(Text, foreground_style, ForegroundStyle), + "surface-template-id" => assign!(Text, surface_template_id, SurfaceTemplateId), + "wrap-mode" => assign!(Text, wrap_mode, WrapMode), _ => return Err(format!("Unsupported native retained text field {name}")), }, LayoutNode::Flex { @@ -748,15 +1114,15 @@ fn patch_local_node( column_gap, .. } => match name.as_str() { - "direction" => *direction = parsed(value)?, - "wrap" => *wrap = parsed(value)?, - "justify" => *justify = parsed(value)?, - "align-items" => *align_items = parsed(value)?, - "align-content" => *align_content = parsed(value)?, - "width" => *width = parsed(value)?, - "height" => *height = parsed(value)?, - "row-gap" => *row_gap = parsed(value)?, - "column-gap" => *column_gap = parsed(value)?, + "direction" => assign!(Flex, direction, Direction), + "wrap" => assign!(Flex, wrap, Wrap), + "justify" => assign!(Flex, justify, Justify), + "align-items" => assign!(Flex, align_items, AlignItems), + "align-content" => assign!(Flex, align_content, AlignContent), + "width" => assign!(Flex, width, Width, same_size_source), + "height" => assign!(Flex, height, Height, same_size_source), + "row-gap" => assign!(Flex, row_gap, RowGap), + "column-gap" => assign!(Flex, column_gap, ColumnGap), _ => return Err(format!("Unsupported native retained flex field {name}")), }, LayoutNode::Row { .. } | LayoutNode::Column { .. } => { @@ -767,7 +1133,93 @@ fn patch_local_node( } } } - Ok(output) + Ok(PatchedLocal { + node: output, + fields: changed_fields, + }) +} + +fn same_scalar_source(base: &T, target: &T, _: &mut SourceChangeWork) -> bool { + base == target +} + +fn same_size_source(base: &Size, target: &Size, work: &mut SourceChangeWork) -> bool { + work.size_nodes_compared += 1; + match (base, target) { + (Size::Pixels { value: left }, Size::Pixels { value: right }) + | (Size::Lines { value: left }, Size::Lines { value: right }) => left == right, + (Size::FitContent { limit: left }, Size::FitContent { limit: right }) => { + match (left, right) { + (None, None) => true, + (Some(left), Some(right)) => same_size_source(left, right, work), + _ => false, + } + } + (Size::Add { values: left }, Size::Add { values: right }) + | (Size::Subtract { values: left }, Size::Subtract { values: right }) => { + left.0.len() == right.0.len() + && left + .0 + .iter() + .zip(right.0.iter()) + .all(|(left, right)| same_size_source(left, right, work)) + } + (Size::Auto, Size::Auto) + | (Size::Content, Size::Content) + | (Size::None, Size::None) + | (Size::Viewport, Size::Viewport) + | (Size::ViewportHeight, Size::ViewportHeight) + | (Size::MinContent, Size::MinContent) + | (Size::MaxContent, Size::MaxContent) + | (Size::Stretch, Size::Stretch) + | (Size::Contain, Size::Contain) => true, + _ => false, + } +} + +fn same_optional_content_source( + base: &Option>, + target: &Option>, + work: &mut SourceChangeWork, +) -> bool { + match (base, target) { + (None, None) => true, + (Some(base), Some(target)) => same_text_source(base, target, work), + _ => false, + } +} + +fn same_text_source( + base: &MeasuredText, + target: &MeasuredText, + work: &mut SourceChangeWork, +) -> bool { + base.lines.len() == target.lines.len() + && base.lines.iter().zip(&target.lines).all(|(left, right)| { + work.text_lines_compared += 1; + left.clusters.len() == right.clusters.len() + && left + .clusters + .iter() + .zip(&right.clusters) + .all(|(left, right)| { + work.text_clusters_compared += 1; + left.width == right.width + && left.cjk == right.cjk + && left.space == right.space + && left.pixel_space == right.pixel_space + && left.source_template_id == right.source_template_id + && left.text.len() == right.text.len() + && left + .text + .bytes() + .zip(right.text.bytes()) + .all(|(left, right)| { + work.text_bytes_compared += 1; + left == right + }) + }) + }) } fn measured_text_work(text: &MeasuredText, property_template_count: u32) -> Result { @@ -804,6 +1256,28 @@ fn size_work(size: &Size) -> Result { Ok(work) } +fn context_size_occurrences(size: &Size) -> Result { + let mut count = 0_usize; + visit_context_size_occurrences(size, &mut || { + count = count + .checked_add(1) + .ok_or_else(|| "Native retained context summary invariant failed".to_owned())?; + Ok(()) + })?; + Ok(count) +} + +fn context_field_occurrences(node: &LayoutNode, name: &str) -> Result { + let mut count = 0; + visit_context_height_fields(node, |field, size| { + if field == name { + count = context_size_occurrences(size)?; + } + Ok(()) + })?; + Ok(count) +} + fn field_work(node: &LayoutNode, name: &str) -> Result, String> { Ok(match (node, name) { (LayoutNode::Box { content, .. }, "content") => { @@ -854,31 +1328,52 @@ fn local_work_summary(node: &LayoutNode) -> Result { let mut values = BTreeMap::new(); for name in FIELDS { if let Some(value) = field_work(node, name)? { - values.insert(name, value); + values.insert( + name, + LocalFieldWork { + units: value, + viewport_heights: context_field_occurrences(node, name)?, + }, + ); } } Ok(LocalWorkSummary(values)) } impl LocalWorkSummary { - fn changed_work(&self, fields: &JsonMap) -> usize { + fn changed_work(&self, fields: &[(LocalField, &str)]) -> usize { fields - .keys() - .filter_map(|name| self.0.get(name.as_str())) - .copied() + .iter() + .filter_map(|(_, name)| self.0.get(name)) + .map(|work| work.units) + .sum() + } + + fn changed_context_occurrences(&self, fields: &[(LocalField, &str)]) -> i128 { + fields + .iter() + .filter_map(|(_, name)| self.0.get(name)) + .map(|work| work.viewport_heights as i128) .sum() } fn updated( - &self, + self: &Arc, node: &LayoutNode, - fields: &JsonMap, - ) -> Result { - let mut output = self.clone(); - for name in fields.keys() { + fields: &[(LocalField, &str)], + ) -> Result, String> { + let mut output = Arc::clone(self); + for (_, name) in fields { if let Some(value) = field_work(node, name)? { - output.0.insert( - match name.as_str() { + let value = LocalFieldWork { + units: value, + viewport_heights: context_field_occurrences(node, name)?, + }; + if output.0.get(name) == Some(&value) { + continue; + } + Arc::make_mut(&mut output).0.insert( + match *name { "content" => "content", "height" => "height", "min-height" => "min-height", @@ -1504,23 +1999,27 @@ enum Atom { #[derive(Debug, Clone, Default)] struct Line { - atoms: Vec, + atoms: AtomPlan, width: i64, } impl Line { fn from_clusters(clusters: &[MeasuredCluster]) -> Self { - let mut line = Self::default(); + let mut atoms = Vec::with_capacity(clusters.len()); for cluster in clusters { let properties = AtomProperties { property_template_ids: cluster.source_template_id.into_iter().collect(), ..AtomProperties::default() }; if cluster.pixel_space { - line.push_space_with_properties(cluster.width, properties); + if cluster.width > 0 { + atoms.push(Atom::Space { + width: cluster.width, + properties, + }); + } } else { - line.width += cluster.width; - line.atoms.push(Atom::Text { + atoms.push(Atom::Text { text: cluster.text.clone(), width: cluster.width, cjk: cluster.cjk, @@ -1529,13 +2028,18 @@ impl Line { }); } } - line + let atoms = AtomPlan::from_owned_atoms(atoms); + Self { + width: atoms.width(), + atoms, + } } fn from_atoms(atoms: &[Atom]) -> Self { + let atoms = AtomPlan::from_atoms(atoms); Self { - atoms: atoms.to_vec(), - width: atoms.iter().map(Atom::width).sum(), + width: atoms.width(), + atoms, } } @@ -1548,7 +2052,7 @@ impl Line { return; } self.width += width; - self.atoms.push(Atom::Space { width, properties }); + self.atoms = self.atoms.push(Atom::Space { width, properties }); } fn prepend_space(&mut self, width: i64) { @@ -1560,12 +2064,12 @@ impl Line { return; } self.width += width; - self.atoms.insert(0, Atom::Space { width, properties }); + self.atoms = self.atoms.prepend(Atom::Space { width, properties }); } fn append(&mut self, other: &Self) { self.width += other.width; - self.atoms.extend(other.atoms.iter().cloned()); + self.atoms = self.atoms.append(&other.atoms); } fn padded(mut self, target: i64, align: HorizontalAlign) -> Self { @@ -1593,63 +2097,23 @@ impl Line { } fn whitespace_only(&self) -> bool { - self.atoms.iter().all(|atom| match atom { - Atom::Space { .. } => true, - Atom::Text { text, .. } => text.trim().is_empty(), - }) + self.atoms.whitespace_only() } fn has_noncontent_properties(&self) -> bool { - self.atoms.iter().any(|atom| { - let properties = atom.properties(); - !properties.style_ids.is_empty() - || !properties.roles.is_empty() - || !properties.property_template_ids.is_empty() - }) + self.atoms.has_noncontent_properties() } fn own_content(&mut self, region_id: i64, content_idx: i64) { - let has_owner = self - .atoms - .iter() - .any(|atom| atom.properties().owner.is_some() || !atom.properties().owners.is_empty()); - for atom in &mut self.atoms { - let properties = atom.properties_mut(); - if has_owner { - let mut owners = properties.owners.clone(); - if let Some(owner) = properties.owner { - if !owners.contains(&owner) { - owners.push(owner); - } - } - if !owners.contains(®ion_id) { - owners.push(region_id); - } - properties.owner = Some(region_id); - properties.owners = owners; - } else { - properties.owner = Some(region_id); - properties.owners = vec![region_id]; - } - } - let has_content = self - .atoms - .iter() - .any(|atom| atom.properties().content.is_some()); - if !has_content { - for atom in &mut self.atoms { - let properties = atom.properties_mut(); - properties.content = Some(region_id); - properties.content_idx = Some(content_idx); - } - } + self.atoms = self.atoms.own_content(region_id, content_idx); } fn collapse_whitespace_content(self, width: i64, region_id: i64) -> Self { if !self.whitespace_only() || self.has_noncontent_properties() { return self; } - let first = self.atoms.first().map(Atom::properties); + let first_atom = self.atoms.first(); + let first = first_atom.as_ref().map(Atom::properties); let mut properties = AtomProperties { content: first .and_then(|properties| properties.content) @@ -1670,28 +2134,18 @@ impl Line { fn apply_style(&mut self, style_id: Option) { if let Some(style_id) = style_id { - for atom in &mut self.atoms { - atom.properties_mut().style_ids.push(style_id); - } + self.atoms = self.atoms.apply_style(style_id); } } fn apply_property_template(&mut self, template_id: Option) { if let Some(template_id) = template_id { - for atom in &mut self.atoms { - atom.properties_mut() - .property_template_ids - .push(template_id); - } + self.atoms = self.atoms.apply_template(template_id); } } fn apply_role(&mut self, role: RegionRole, region_id: i64) { - for atom in &mut self.atoms { - atom.properties_mut() - .roles - .push(RegionRoleEntry { role, region_id }); - } + self.atoms = self.atoms.apply_role(role, region_id); } } @@ -1736,97 +2190,113 @@ impl Atom { #[derive(Debug, Clone)] struct Rendered { - lines: Vec, - breaks: Vec, + lines: LinePlan, + root_scroll: Option>, + own_scroll_owner: bool, + scroll_owners: u8, +} + +#[derive(Debug)] +pub(crate) struct RootScrollPlan { + region_id: i64, + full_content: LinePlan, + rendered_content: LinePlan, + visible_height: i64, + effective_offset: i64, + text_input: Option, +} + +#[derive(Debug)] +struct RootTextInput { + source: Arc, + width: i64, + wrap: WrapMode, + region: Option, + styles: [Option; 3], + lines: LinePlan, } impl Rendered { fn from_lines(lines: Vec) -> Self { - let breaks = vec![AtomProperties::default(); lines.len().saturating_sub(1)]; - Self { lines, breaks } + Self { + lines: LinePlan::from_lines(lines), + root_scroll: None, + own_scroll_owner: false, + scroll_owners: 0, + } + } + + fn from_line_plan(lines: LinePlan) -> Self { + Self { + lines, + root_scroll: None, + own_scroll_owner: false, + scroll_owners: 0, + } } fn first_width(&self) -> i64 { - self.lines.first().map_or(0, |line| line.width) + self.lines.first_width() } fn max_width(&self) -> i64 { - self.lines.iter().map(|line| line.width).max().unwrap_or(0) + self.lines.max_width() } fn min_content_width(&self, wrap_mode: WrapMode) -> i64 { if wrap_mode == WrapMode::None { return self.max_width(); } - self.lines - .iter() - .map(|line| { - let mut maximum = 0; - let mut run = 0; - for atom in &line.atoms { - if atom.wrap_space() { - maximum = maximum.max(run); - run = 0; - } else if atom.wrap_cjk() { - maximum = maximum.max(run).max(atom.width()); - run = 0; - } else { - run += atom.width(); - } - } - maximum.max(run) - }) - .max() - .unwrap_or(0) + self.lines.min_content_width() } fn height(&self) -> i64 { - self.lines.len() as i64 + self.lines.height() } + #[cfg(test)] fn apply_scroll_window(&mut self, region_id: i64) { - for line in &mut self.lines { - for atom in &mut line.atoms { - atom.properties_mut().scroll_window = Some(region_id); - } - } - for properties in &mut self.breaks { - properties.scroll_window = Some(region_id); - } + self.lines = ProjectionState::new( + self.lines.clone(), + Arc::from([LineOp::ScrollWindow(region_id)]), + ) + .plan() + .clone(); } fn into_tape(self, style_count: u32) -> LayoutTape { - let last_line = self.lines.len().saturating_sub(1); - let mut breaks = self.breaks.into_iter(); + line_plan::record_materialized(self.lines.len(), self.lines.prefix_chars(self.lines.len())); LayoutTape { style_count, lines: self .lines - .into_iter() - .enumerate() - .map(|(index, line)| TapeLine { - width: line.width, - atoms: line - .atoms - .into_iter() - .map(|atom| match atom { - Atom::Text { - text, - width, - properties, - .. - } => TapeAtom::Text { - text, - width, - properties, - }, - Atom::Space { width, properties } => { - TapeAtom::Space { width, properties } - } - }) - .collect(), - break_after: (index < last_line) - .then(|| breaks.next().expect("rendered break invariant")), + .iter_with_breaks() + .map(|(view, break_after)| { + let line = view.materialize(); + TapeLine { + width: view.width(), + atoms: line + .atoms + .to_vec() + .into_iter() + .map(|atom| match atom { + Atom::Text { + text, + width, + properties, + .. + } => TapeAtom::Text { + text, + width, + properties, + }, + Atom::Space { width, properties } => { + TapeAtom::Space { width, properties } + } + }) + .collect(), + break_after: break_after.map(|view| view.materialize().into_owned()), + } }) .collect(), } @@ -3081,6 +3551,79 @@ struct RootMetadataPayload { fragment_count: usize, } +fn append_scroll_metadata( + payload: &mut Option, + scroll: Option<(&RootScrollPlan, Option<&RootScrollPlan>)>, + styles: &[CompiledStyle], + style_count: u32, + max_bytes: usize, +) -> Result<(), String> { + let (Some(payload), Some((scroll, base))) = (payload.as_mut(), scroll) else { + return Ok(()); + }; + let mut effect = format!( + " :root-scroll-producer [{} {} {} {} ", + scroll.region_id, + scroll.full_content.len(), + scroll.visible_height, + scroll.effective_offset + ); + let inactive = scroll.full_content.len() as i64 <= scroll.visible_height; + let reused = !inactive + && base.is_some_and(|base| { + base.region_id == scroll.region_id + && base.full_content.len() as i64 > base.visible_height + && base.full_content.ptr_eq(&scroll.full_content) + && base.rendered_content.ptr_eq(&scroll.rendered_content) + }); + if inactive { + // The receiver needs only geometry to retire its active scroll state. + // Keep the complete plans in this frame for a later clipped request. + effect.push_str(":inactive :inactive "); + } else if reused { + effect.push_str(":reuse :reuse "); + evaluation::record(EvalWork { + scroll_producer_reuses: 1, + ..EvalWork::default() + }); + } else { + for plan in [&scroll.full_content, &scroll.rendered_content] { + evaluation::record(EvalWork { + scroll_producer_lines_encoded: plan.len() as u64, + scroll_producer_chars_encoded: plan.prefix_chars(plan.len()), + ..EvalWork::default() + }); + let tape = Rendered::from_line_plan(plan.clone()).into_tape(style_count); + let flat = flatten_layout_tape(tape, true)?; + let (text, spaces, properties) = tape_character_encoding_parts(&flat.characters)?; + let literal = encode_lisp_literal( + &text, + &spaces, + &properties, + styles, + flat.characters.len() as u64, + max_bytes, + )?; + // Each nested literal has its own read-circle label namespace. + push_lisp_string(&mut effect, &literal); + effect.push(' '); + } + } + effect.push_str("])"); + payload.literal.pop(); + payload.literal.push_str(&effect); + payload.record_count += 1; + if payload + .literal + .len() + .saturating_add(payload.fragment_bytes.len()) + > max_bytes + { + return Err("Native scroll producer exceeds its byte limit".to_owned()); + } + Ok(()) +} + fn metadata_role_symbol(kind: u8) -> Option<&'static str> { match kind { METADATA_ROLE_CONTENT => Some("content"), @@ -3545,6 +4088,7 @@ fn build_emacs_commit_batch( complete: bool, root_metadata: bool, max_bytes: usize, + scroll: Option<(&RootScrollPlan, Option<&RootScrollPlan>)>, ) -> Result { let core = tape_commit_batch(&old.characters, &target.characters); let publication_edits = coalesce_publication_edits(&core.semantic_edits); @@ -3571,6 +4115,13 @@ fn build_emacs_commit_batch( root_metadata, payload_limit, )?; + append_scroll_metadata( + &mut metadata_payload, + scroll, + compiled_styles, + target.style_count, + payload_limit, + )?; let old_fragments = if metadata_payload.is_some() { let (old_text, old_spaces, old_property_spans) = tape_character_encoding_parts(&old.characters)?; @@ -3651,6 +4202,7 @@ fn build_emacs_commit_batch( }) } +#[cfg(test)] pub(crate) fn encode_layout_patch_tape( old_tape: LayoutTape, target_tape: LayoutTape, @@ -3658,6 +4210,26 @@ pub(crate) fn encode_layout_patch_tape( identity: TapeIdentity, root_metadata: bool, max_bytes: usize, +) -> Result, String> { + encode_layout_patch_tape_with_scroll( + old_tape, + target_tape, + styles, + identity, + root_metadata, + max_bytes, + None, + ) +} + +pub(crate) fn encode_layout_patch_tape_with_scroll( + old_tape: LayoutTape, + target_tape: LayoutTape, + styles: &[StyleTemplate], + identity: TapeIdentity, + root_metadata: bool, + max_bytes: usize, + scroll: Option<(&RootScrollPlan, Option<&RootScrollPlan>)>, ) -> Result, String> { let old = flatten_layout_tape(old_tape, identity.complete)?; let target = flatten_layout_tape(target_tape, identity.complete)?; @@ -3675,6 +4247,7 @@ pub(crate) fn encode_layout_patch_tape( identity.complete, root_metadata, max_bytes, + scroll.filter(|_| identity.complete), )?; let patch_count = count_u32(batch.publication_edits.len(), "patches")?; let coordinate_patch_count = @@ -3738,12 +4311,24 @@ pub(crate) fn encode_layout_patch_tape( )) } +#[cfg(test)] pub(crate) fn encode_layout_tape( tape: LayoutTape, styles: &[StyleTemplate], identity: TapeIdentity, root_metadata: bool, max_bytes: usize, +) -> Result, String> { + encode_layout_tape_with_scroll(tape, styles, identity, root_metadata, max_bytes, None) +} + +pub(crate) fn encode_layout_tape_with_scroll( + tape: LayoutTape, + styles: &[StyleTemplate], + identity: TapeIdentity, + root_metadata: bool, + max_bytes: usize, + scroll: Option<&RootScrollPlan>, ) -> Result, String> { if tape.lines.is_empty() { return Err("Native layout tape has no lines".to_owned()); @@ -3853,7 +4438,7 @@ pub(crate) fn encode_layout_tape( .checked_sub(TAPE_HEADER_LEN) .and_then(|remaining| remaining.checked_sub(fixed_body_bytes)) .ok_or_else(|| "Native layout tape exceeds its byte limit".to_owned())?; - let metadata_payload = root_metadata_payload( + let mut metadata_payload = root_metadata_payload( &text, &spaces, &property_spans, @@ -3861,6 +4446,15 @@ pub(crate) fn encode_layout_tape( root_metadata, metadata_limit, )?; + append_scroll_metadata( + &mut metadata_payload, + scroll + .filter(|_| identity.complete) + .map(|scroll| (scroll, None)), + &compiled_styles, + tape.style_count, + metadata_limit, + )?; let metadata_bytes = metadata_payload .as_ref() .map_or(0, |payload| payload.literal.len()); @@ -4027,51 +4621,19 @@ impl LayoutDocument { impl RetainedDocument { pub(crate) fn validate_context(&self, context: LayoutContext) -> Result<(), String> { - fn add_retained_context_work( - node: &LayoutNode, - document: &RetainedDocument, - context: LayoutContext, - work_units: &mut usize, - ) -> Result<(), String> { - let node = document.resolve(node)?; - match node { - LayoutNode::NodeRef { .. } => unreachable!("resolver returned a node reference"), - LayoutNode::Text { .. } => {} - LayoutNode::Box { - child, - height, - min_height, - max_height, - .. - } => { - for size in [height, min_height, max_height] { - add_context_size_work(size, context, work_units)?; - } - if let Some(child) = child { - add_retained_context_work(child, document, context, work_units)?; - } - } - LayoutNode::Row { children, .. } | LayoutNode::Column { children, .. } => { - for child in children.iter() { - add_retained_context_work(child, document, context, work_units)?; - } - } - LayoutNode::Flex { height, items, .. } => { - add_context_size_work(height, context, work_units)?; - for item in items.iter() { - add_retained_context_work(&item.node, document, context, work_units)?; - } - } - } + // Bootstrap establishes the root/reference invariant and deltas cannot + // change topology. The full traversal adds the same nonnegative amount + // for every occurrence, stopping at the work limit before a later add + // could overflow. Division preserves that error without multiplying. + if self.context_viewport_heights == 0 { + return Ok(()); + } + let height = usize::try_from(context.viewport_height).unwrap_or(usize::MAX); + if height > MAX_LAYOUT_WORK_UNITS / self.context_viewport_heights { + Err("Native layout exceeds the work-unit limit".to_owned()) + } else { Ok(()) } - - let root = self - .effective_node(self.root_id) - .ok_or_else(|| "Native retained document lost its root".to_owned())?; - RESOLVER_LOOKUP_COUNT.with(|count| count.set(count.get().saturating_add(1))); - let mut work_units = 0; - add_retained_context_work(root, self, context, &mut work_units) } pub(crate) fn layout_tape( @@ -4145,22 +4707,17 @@ fn add_vertical_size_work(size: &Size, work_units: &mut usize) -> Result<(), Str } } -fn add_context_size_work( +fn visit_context_size_occurrences( size: &Size, - context: LayoutContext, - work_units: &mut usize, + visit: &mut impl FnMut() -> Result<(), String>, ) -> Result<(), String> { + // Validation work adds every operand, including subtraction operands. match size { - Size::ViewportHeight => add_work_units( - work_units, - usize::try_from(context.viewport_height).unwrap_or(usize::MAX), - ), - Size::FitContent { limit: Some(limit) } => { - add_context_size_work(limit, context, work_units) - } + Size::ViewportHeight => visit(), + Size::FitContent { limit: Some(limit) } => visit_context_size_occurrences(limit, visit), Size::Add { values } | Size::Subtract { values } => { for value in &values.0 { - add_context_size_work(value, context, work_units)?; + visit_context_size_occurrences(value, visit)?; } Ok(()) } @@ -4168,26 +4725,58 @@ fn add_context_size_work( } } -fn add_context_work( - node: &LayoutNode, +fn add_context_size_work( + size: &Size, context: LayoutContext, work_units: &mut usize, +) -> Result<(), String> { + visit_context_size_occurrences(size, &mut || { + add_work_units( + work_units, + usize::try_from(context.viewport_height).unwrap_or(usize::MAX), + ) + }) +} + +fn visit_context_height_fields( + node: &LayoutNode, + mut visit: impl FnMut(&'static str, &Size) -> Result<(), String>, ) -> Result<(), String> { match node { - LayoutNode::NodeRef { .. } => { - return Err("Native full layout cannot contain a node reference".to_owned()); - } - LayoutNode::Text { .. } => {} LayoutNode::Box { - child, height, min_height, max_height, .. } => { - for size in [height, min_height, max_height] { - add_context_size_work(size, context, work_units)?; + for (name, size) in [ + ("height", height), + ("min-height", min_height), + ("max-height", max_height), + ] { + visit(name, size)?; } + } + LayoutNode::Flex { height, .. } => visit("height", height)?, + _ => {} + } + Ok(()) +} + +fn add_context_work( + node: &LayoutNode, + context: LayoutContext, + work_units: &mut usize, +) -> Result<(), String> { + visit_context_height_fields(node, |_, size| { + add_context_size_work(size, context, work_units) + })?; + match node { + LayoutNode::NodeRef { .. } => { + return Err("Native full layout cannot contain a node reference".to_owned()); + } + LayoutNode::Text { .. } => {} + LayoutNode::Box { child, .. } => { if let Some(child) = child { add_context_work(child, context, work_units)?; } @@ -4197,8 +4786,7 @@ fn add_context_work( add_context_work(child, context, work_units)?; } } - LayoutNode::Flex { height, items, .. } => { - add_context_size_work(height, context, work_units)?; + LayoutNode::Flex { items, .. } => { for item in items.iter() { add_context_work(&item.node, context, work_units)?; } @@ -4949,10 +5537,12 @@ fn wrap_rendered_line(line: &Line, max_width: i64, mode: WrapMode) -> Vec return vec![Line::default()]; } if mode == WrapMode::None || line.width <= max_width { - return vec![line.clone()]; + return vec![line_plan::clone_line(line)]; } - let atoms = &line.atoms; + // Wrapping inspects the actual atom boundaries. Composition and decoration + // preserve the plan; only this geometry-dependent operation materializes it. + let atoms = line.atoms.to_vec(); let count = atoms.len(); let mut ranges = Vec::new(); let mut line_start = 0; @@ -5033,34 +5623,21 @@ fn wrap_rendered(rendered: Rendered, max_width: i64, mode: WrapMode) -> Rendered if mode == WrapMode::None { return rendered; } - let original_breaks = rendered.breaks; - let mut lines = Vec::new(); - let mut breaks = Vec::new(); - for (line_index, line) in rendered.lines.into_iter().enumerate() { - for (piece_index, piece) in wrap_rendered_line(&line, max_width, mode) - .into_iter() - .enumerate() - { - if !lines.is_empty() { - breaks.push(if piece_index == 0 { - original_breaks[line_index - 1].clone() - } else { - AtomProperties::default() - }); - } - lines.push(piece); - } + let mut lines = LinePlan::default(); + let mut joining_break = AtomProperties::default(); + for (line, break_after) in rendered.lines.iter_with_breaks() { + let pieces = LinePlan::from_lines(wrap_rendered_line(&line.materialize(), max_width, mode)); + lines = lines.concat_with_break(&pieces, joining_break); + joining_break = break_after + .map(|view| view.materialize().into_owned()) + .unwrap_or_default(); } - Rendered { lines, breaks } + Rendered::from_line_plan(lines) } -fn vertical_align( - mut lines: Vec, - height: usize, - align: VerticalAlign, - width: i64, -) -> Vec { - lines.truncate(height); +#[cfg(test)] +fn vertical_align(lines: LinePlan, height: usize, align: VerticalAlign, width: i64) -> LinePlan { + let lines = lines.slice(0..lines.len().min(height)); if lines.len() >= height { return lines; } @@ -5070,11 +5647,11 @@ fn vertical_align( VerticalAlign::Bottom => rest, VerticalAlign::Center => rest / 2, }; - let mut output = Vec::with_capacity(height); - output.extend((0..top).map(|_| Line::blank(width))); - output.append(&mut lines); - output.extend((0..(rest - top)).map(|_| Line::blank(width))); - output + LinePlan::from_lines((0..top).map(|_| Line::blank(width))) + .concat(&lines) + .concat(&LinePlan::from_lines( + (0..(rest - top)).map(|_| Line::blank(width)), + )) } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -5092,6 +5669,7 @@ enum FlexMode { #[derive(Debug, Clone)] struct FlexRuntimeItem<'a> { source: &'a LayoutNode, + scope: RenderScope<'a>, grow: f64, shrink: f64, align_self: FlexAlign, @@ -5202,7 +5780,7 @@ fn box_vertical_side(node: &LayoutNode) -> Option { fn box_content_intrinsics( node: &LayoutNode, - resolver: Option<&RetainedDocument>, + scope: &RenderScope<'_>, context: LayoutContext, ) -> Result, String> { let LayoutNode::Box { @@ -5225,7 +5803,10 @@ fn box_content_intrinsics( // current inline viewport. Treating that width as unknown makes // responsive descendants collapse to their narrow intrinsic form and // produces a different automatic minimum from the visible renderer. - let rendered = render_node(child, resolver, context, true)?; + let rendered = scope + .child(LocalStep::BoxChild, child) + .phase(Phase::ContentIntrinsic) + .render(context, true, None)?; Ok(Some(( content_min_width.unwrap_or_else(|| rendered.min_content_width(*wrap_mode)), rendered.max_width(), @@ -5239,7 +5820,7 @@ fn flex_box_resolve_width( node: &LayoutNode, size: &Size, fallback: Option, - resolver: Option<&RetainedDocument>, + scope: &RenderScope<'_>, context: LayoutContext, ) -> Result, String> { let LayoutNode::Box { @@ -5254,7 +5835,7 @@ fn flex_box_resolve_width( return Ok(fallback); }; let (min_content, max_content) = - box_content_intrinsics(node, resolver, context)?.unwrap_or((0, 0)); + box_content_intrinsics(node, scope, context)?.unwrap_or((0, 0)); let side = box_horizontal_side(node).unwrap_or(0); let stretch = context .viewport_width_known @@ -5303,7 +5884,7 @@ fn flex_min_main( source: &LayoutNode, rendered: &Rendered, axis: FlexAxis, - resolver: Option<&RetainedDocument>, + scope: &RenderScope<'_>, context: LayoutContext, ) -> Result { let LayoutNode::Box { @@ -5322,17 +5903,27 @@ fn flex_min_main( Ok(match axis { FlexAxis::Row => { let side = box_horizontal_side(source).unwrap_or(0); - let declared = - flex_box_resolve_width(source, min_width, Some(0), resolver, context)?.unwrap_or(0); + let declared = flex_box_resolve_width( + source, + min_width, + Some(0), + &scope.phase(Phase::FlexMinWidth), + context, + )? + .unwrap_or(0); if *wrap_mode == WrapMode::None { rendered.max_width().max(side + declared) } else { let content_min = match content_min_width { Some(content_min_width) => *content_min_width, None => { - box_content_intrinsics(source, resolver, context)? - .unwrap_or((0, 0)) - .0 + box_content_intrinsics( + source, + &scope.phase(Phase::FlexAutoMinContent), + context, + )? + .unwrap_or((0, 0)) + .0 } }; side + declared.max(content_min) @@ -5350,7 +5941,7 @@ fn flex_min_main( fn flex_max_main( source: &LayoutNode, axis: FlexAxis, - resolver: Option<&RetainedDocument>, + scope: &RenderScope<'_>, context: LayoutContext, ) -> Result, String> { let LayoutNode::Box { @@ -5362,8 +5953,14 @@ fn flex_max_main( return Ok(None); }; Ok(match axis { - FlexAxis::Row => flex_box_resolve_width(source, max_width, None, resolver, context)? - .map(|value| value + box_horizontal_side(source).unwrap_or(0)), + FlexAxis::Row => flex_box_resolve_width( + source, + max_width, + None, + &scope.phase(Phase::FlexMaxWidth), + context, + )? + .map(|value| value + box_horizontal_side(source).unwrap_or(0)), FlexAxis::Column => flex_box_resolve_height(source, max_height, None, context) .map(|value| value + box_vertical_side(source).unwrap_or(0)), }) @@ -5390,7 +5987,7 @@ fn flex_basis_main( rendered: &Rendered, axis: FlexAxis, basis: &Size, - resolver: Option<&RetainedDocument>, + scope: &RenderScope<'_>, context: LayoutContext, ) -> Result { let rendered_main = match axis { @@ -5403,15 +6000,24 @@ fn flex_basis_main( if matches!(basis, Size::Content) { if axis == FlexAxis::Row && matches!(source, LayoutNode::Box { .. }) { let (_, content_max) = - box_content_intrinsics(source, resolver, context)?.unwrap_or((0, 0)); + box_content_intrinsics(source, &scope.phase(Phase::FlexBasisContent), context)? + .unwrap_or((0, 0)); return Ok(box_horizontal_side(source).unwrap_or(0) + content_max); } return Ok(rendered_main); } if axis == FlexAxis::Row && matches!(source, LayoutNode::Box { .. }) { - let (_, content_max) = box_content_intrinsics(source, resolver, context)?.unwrap_or((0, 0)); - let content = flex_box_resolve_width(source, basis, Some(content_max), resolver, context)? - .unwrap_or(content_max); + let (_, content_max) = + box_content_intrinsics(source, &scope.phase(Phase::FlexBasisContent), context)? + .unwrap_or((0, 0)); + let content = flex_box_resolve_width( + source, + basis, + Some(content_max), + &scope.phase(Phase::FlexBasisWidth), + context, + )? + .unwrap_or(content_max); return Ok(box_horizontal_side(source).unwrap_or(0) + content); } Ok(match axis { @@ -5444,13 +6050,11 @@ fn measure_flex_item<'a>( item: &'a FlexItem, axis: FlexAxis, inline_viewport: Option, - resolver: Option<&'a RetainedDocument>, + scope: RenderScope<'a>, context: LayoutContext, ) -> Result, String> { - let source = match resolver { - Some(resolver) => resolver.resolve(&item.node)?, - None => &item.node, - }; + let scope = scope.resolve()?; + let source = scope.view.node; let uses_inline_viewport = flex_item_uses_inline_viewport(source); let measurement_context = LayoutContext { viewport_width: if uses_inline_viewport { @@ -5462,13 +6066,17 @@ fn measure_flex_item<'a>( viewport_height: context.viewport_height, inline_auto_width_intrinsic: context.inline_auto_width_intrinsic, }; - let rendered = render_node(source, resolver, measurement_context, uses_inline_viewport)?; - let min_main = flex_min_main(source, &rendered, axis, resolver, context)?; - let max_main = flex_max_main(source, axis, resolver, context)?; - let base = flex_basis_main(source, &rendered, axis, &item.basis, resolver, context)?.max(0); + let rendered = + scope + .phase(Phase::FlexMeasure) + .render(measurement_context, uses_inline_viewport, None)?; + let min_main = flex_min_main(source, &rendered, axis, &scope, context)?; + let max_main = flex_max_main(source, axis, &scope, context)?; + let base = flex_basis_main(source, &rendered, axis, &item.basis, &scope, context)?.max(0); let hypothetical = flex_clamp_main(base, min_main, max_main); Ok(FlexRuntimeItem { source, + scope, grow: item.grow, shrink: item.shrink, align_self: item.align_self, @@ -5745,7 +6353,7 @@ fn flex_resolved_align(item: &FlexRuntimeItem<'_>, container: FlexAlign) -> Flex } fn pad_rendered_width(mut rendered: Rendered, width: i64, align: FlexAlign) -> Rendered { - for line in &mut rendered.lines { + rendered.lines = rendered.lines.map_lines(|_, mut line| { let extra = (width - line.width).max(0); let left = match align { FlexAlign::FlexEnd | FlexAlign::End | FlexAlign::Right | FlexAlign::SelfEnd => extra, @@ -5754,7 +6362,8 @@ fn pad_rendered_width(mut rendered: Rendered, width: i64, align: FlexAlign) -> R }; line.prepend_space(left); line.push_space(extra - left); - } + line + }); rendered } @@ -5812,7 +6421,7 @@ fn render_flex_sized_entry( main: i64, cross: Option, container_align: FlexAlign, - resolver: Option<&RetainedDocument>, + phase: Phase, context: LayoutContext, intrinsic: bool, ) -> Result { @@ -5835,13 +6444,10 @@ fn render_flex_sized_entry( inline_auto_width_intrinsic: context.inline_auto_width_intrinsic, }; let override_size = box_override_for_flex(item.source, axis, main, cross, stretch); - let mut rendered = render_node_with_override( - item.source, - resolver, - render_context, - intrinsic, - override_size, - )?; + let mut rendered = item + .scope + .phase(phase) + .render(render_context, intrinsic, override_size)?; match axis { FlexAxis::Row => { rendered = pad_rendered_width(rendered, main, FlexAlign::FlexStart); @@ -5881,38 +6487,40 @@ fn concat_horizontal_sized(parts: Vec<(Rendered, i64)>, target_height: i64) -> R .max() .unwrap_or(1) .max(target_height); - let mut lines = Vec::with_capacity(height as usize); - for index in 0..height as usize { + let mut parts = parts + .iter() + .map(|(rendered, width)| (rendered.lines.iter(), *width)) + .collect::>(); + let lines = LinePlan::from_lines((0..height as usize).map(|_| { let mut line = Line::default(); - for (rendered, width) in &parts { - if let Some(part) = rendered.lines.get(index) { - line.append(part); + for (lines, width) in &mut parts { + if let Some(part) = lines.next() { + line.append(&part.materialize()); } else { line.push_space(*width); } } - lines.push(line); - } - Rendered::from_lines(lines) + line + })); + Rendered::from_line_plan(lines) } fn stack_vertical(parts: Vec) -> Rendered { - let mut lines = Vec::new(); - let mut breaks = Vec::new(); - for mut part in parts { - if part.lines.is_empty() { - continue; + fn join_parts(parts: &[Rendered]) -> LinePlan { + match parts { + [] => LinePlan::default(), + [part] => part.lines.clone(), + _ => { + let middle = parts.len() / 2; + join_parts(&parts[..middle]).concat(&join_parts(&parts[middle..])) + } } - if !lines.is_empty() { - breaks.push(AtomProperties::default()); - } - lines.append(&mut part.lines); - breaks.append(&mut part.breaks); } + let mut lines = join_parts(&parts); if lines.is_empty() { - lines.push(Line::default()); + lines = LinePlan::from_lines([Line::default()]); } - Rendered { lines, breaks } + Rendered::from_line_plan(lines) } fn slice_rendered(rendered: Rendered, start: i64, height: i64) -> Rendered { @@ -5920,25 +6528,9 @@ fn slice_rendered(rendered: Rendered, start: i64, height: i64) -> Rendered { let height = usize::try_from(height.max(0)).unwrap_or(0); let end = start.saturating_add(height).min(rendered.lines.len()); if start >= end { - return Rendered { - lines: Vec::new(), - breaks: Vec::new(), - }; + return Rendered::from_line_plan(LinePlan::default()); } - let line_count = end - start; - let lines = rendered - .lines - .into_iter() - .skip(start) - .take(line_count) - .collect::>(); - let breaks = rendered - .breaks - .into_iter() - .skip(start) - .take(line_count.saturating_sub(1)) - .collect::>(); - Rendered { lines, breaks } + Rendered::from_line_plan(rendered.lines.slice(start..end)) } fn exact_rendered_height( @@ -5946,6 +6538,7 @@ fn exact_rendered_height( resolver: Option<&RetainedDocument>, context: LayoutContext, ) -> Option { + evaluation::record_height_query(); let node = resolver .and_then(|value| value.resolve(node).ok()) .unwrap_or(node); @@ -5996,19 +6589,25 @@ fn exact_rendered_height( } fn render_node_window( - node: &LayoutNode, - resolver: Option<&RetainedDocument>, + scope: &RenderScope<'_>, context: LayoutContext, intrinsic: bool, start: i64, height: i64, ) -> Option> { - let node = resolver - .and_then(|value| value.resolve(node).ok()) - .unwrap_or(node); + let scope = match scope.resolve() { + Ok(scope) => scope, + Err(error) => return Some(Err(error)), + }; + let node = scope.view.node; + let resolver = scope.resolver; let total_height = exact_rendered_height(node, resolver, context)?; if start <= 0 && height >= total_height { - return Some(render_node(node, resolver, context, intrinsic)); + return Some( + scope + .phase(Phase::WindowFull) + .render(context, intrinsic, None), + ); } match node { LayoutNode::Column { children, .. } @@ -6017,32 +6616,32 @@ fn render_node_window( && context.viewport_width_known => { Some(render_column_window( - children, resolver, context, start, height, + children, &scope, context, start, height, )) } _ => Some( - render_node(node, resolver, context, intrinsic) + scope + .phase(Phase::WindowFallback) + .render(context, intrinsic, None) .map(|rendered| slice_rendered(rendered, start, height)), ), } } -fn render_column_window( - children: &[LayoutNode], - resolver: Option<&RetainedDocument>, +fn render_column_window<'a>( + children: &'a [LayoutNode], + scope: &RenderScope<'a>, context: LayoutContext, start: i64, height: i64, ) -> Result { - let mut leaves = Vec::new(); - collect_column_leaves(children, &mut leaves); let end = start.saturating_add(height).max(start); let target = context.viewport_width.max(0); let mut offset = 0_i64; let mut parts = Vec::new(); - for child in leaves { - let child_height = exact_rendered_height(child, resolver, context) + for child in column_leaves(scope, children) { + let child_height = exact_rendered_height(child.view.node, child.resolver, context) .ok_or_else(|| "Native layout column window has an unbounded child".to_owned())?; let child_end = offset.saturating_add(child_height); if child_end <= start { @@ -6056,29 +6655,34 @@ fn render_column_window( let child_start = start.saturating_sub(offset); let child_window_height = (child_end.min(end) - (offset + child_start)).max(0); let mut rendered = if child_start == 0 && child_window_height >= child_height { - render_node(child, resolver, context, false)? + child + .phase(Phase::WindowChildFull) + .render(context, false, None)? } else { render_node_window( - child, - resolver, + &child.phase(Phase::WindowChildPartial), context, false, child_start, child_window_height, ) .unwrap_or_else(|| { - render_node(child, resolver, context, false) + child + .phase(Phase::WindowFallback) + .render(context, false, None) .map(|rendered| slice_rendered(rendered, child_start, child_window_height)) })? }; let extra = (target - rendered.first_width()).max(0); - for line in &mut rendered.lines { - line.push_space(extra); - } if extra > 0 { - rendered.breaks = - vec![AtomProperties::default(); rendered.lines.len().saturating_sub(1)]; + rendered.lines = rendered + .lines + .map_lines(|_, mut line| { + line.push_space(extra); + line + }) + .clear_breaks(); } if !rendered.lines.is_empty() { parts.push(rendered); @@ -6093,7 +6697,6 @@ fn flex_line_cross( line: &[FlexRuntimeItem<'_>], axis: FlexAxis, container_align: FlexAlign, - resolver: Option<&RetainedDocument>, context: LayoutContext, intrinsic: bool, ) -> Result { @@ -6106,7 +6709,7 @@ fn flex_line_cross( item.target, None, container_align, - resolver, + Phase::FlexCrossProbe, context, intrinsic, )? @@ -6163,7 +6766,6 @@ fn render_flex_row_line( main_gap: i64, justify: FlexAlign, align: FlexAlign, - resolver: Option<&RetainedDocument>, context: LayoutContext, intrinsic: bool, ) -> Result { @@ -6178,7 +6780,7 @@ fn render_flex_row_line( item.target, Some(line_cross), align, - resolver, + Phase::FlexFinal, context, intrinsic, )?; @@ -6201,7 +6803,6 @@ fn render_flex_column_line( main_gap: i64, justify: FlexAlign, align: FlexAlign, - resolver: Option<&RetainedDocument>, context: LayoutContext, intrinsic: bool, ) -> Result { @@ -6223,7 +6824,7 @@ fn render_flex_column_line( item.target, Some(line_cross), align, - resolver, + Phase::FlexFinal, context, intrinsic, )? @@ -6250,7 +6851,6 @@ fn render_flex_row( align: FlexAlign, align_content: FlexAlign, single_line: bool, - resolver: Option<&RetainedDocument>, context: LayoutContext, intrinsic: bool, ) -> Result { @@ -6269,7 +6869,7 @@ fn render_flex_row( } else { lines .iter() - .map(|line| flex_line_cross(line, FlexAxis::Row, align, resolver, context, intrinsic)) + .map(|line| flex_line_cross(line, FlexAxis::Row, align, context, intrinsic)) .collect::, _>>()? }; let cross_layout = @@ -6295,7 +6895,6 @@ fn render_flex_row( main_gap, justify, align, - resolver, context, intrinsic, )?); @@ -6326,7 +6925,6 @@ fn render_flex_column( align: FlexAlign, align_content: FlexAlign, single_line: bool, - resolver: Option<&RetainedDocument>, context: LayoutContext, intrinsic: bool, ) -> Result { @@ -6338,9 +6936,7 @@ fn render_flex_column( } else { lines .iter() - .map(|line| { - flex_line_cross(line, FlexAxis::Column, align, resolver, context, intrinsic) - }) + .map(|line| flex_line_cross(line, FlexAxis::Column, align, context, intrinsic)) .collect::, _>>()? }; let cross_layout = @@ -6356,7 +6952,7 @@ fn render_flex_column( .enumerate() { let rendered = render_flex_column_line( - line, line_cross, main_size, main_gap, justify, align, resolver, context, intrinsic, + line, line_cross, main_size, main_gap, justify, align, context, intrinsic, )?; parts.push((rendered, line_cross)); if index + 1 < line_count { @@ -6374,7 +6970,7 @@ fn render_flex_column( } #[allow(clippy::too_many_arguments)] -fn render_flex( +fn render_flex<'a>( direction: FlexDirection, wrap: FlexWrap, justify: FlexAlign, @@ -6384,8 +6980,8 @@ fn render_flex( height: &Size, row_gap: i64, column_gap: i64, - source_items: &[FlexItem], - resolver: Option<&RetainedDocument>, + source_items: &'a [FlexItem], + scope: &RenderScope<'a>, context: LayoutContext, intrinsic: bool, ) -> Result { @@ -6406,7 +7002,7 @@ fn render_flex( &source_items[index], axis, inline_viewport, - resolver, + scope.child(LocalStep::FlexItem(index), &source_items[index].node), context, ) }) @@ -6434,7 +7030,6 @@ fn render_flex( align_items, align_content, single_line, - resolver, context, intrinsic, ), @@ -6448,20 +7043,20 @@ fn render_flex( align_items, align_content, single_line, - resolver, context, intrinsic, ), } } -#[derive(Debug, Clone, Copy, Default)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] struct BoxOverride { content_width: Option, content_height: Option, declared_width: Option, } +#[cfg(test)] fn render_node( node: &LayoutNode, resolver: Option<&RetainedDocument>, @@ -6478,10 +7073,58 @@ fn render_node_with_override( intrinsic: bool, size_override: Option, ) -> Result { - let node = match resolver { - Some(resolver) => resolver.resolve(node)?, - None => node, + RenderScope::uncached(node, resolver).render(context, intrinsic, size_override) +} + +fn project_box_lines( + scope: &RenderScope<'_>, + slot: BoxProjectionSlot, + input: LinePlan, + change: &mut Option, + ops: Vec, +) -> LinePlan { + let ops: Arc<[LineOp]> = ops.into(); + let state = if let Some(previous) = scope.previous_projection(slot) { + let update = previous.update(input, change.as_ref(), ops); + *change = Some(update.change); + update.state + } else { + *change = None; + ProjectionState::new(input, ops) }; + let lines = state.plan().clone(); + scope.store_projection(slot, state); + lines +} + +fn frame_box_lines( + scope: &RenderScope<'_>, + slot: BoxProjectionSlot, + input: LinePlan, + change: &mut Option, + frame: FrameSpec, +) -> LinePlan { + let state = if let Some(previous) = scope.previous_projection(slot) { + let update = previous.update_frame(input, change.as_ref(), frame); + *change = Some(update.change); + update.state + } else { + *change = None; + ProjectionState::new_frame(input, frame) + }; + let lines = state.plan().clone(); + scope.store_projection(slot, state); + lines +} + +fn render_node_body( + scope: &RenderScope<'_>, + context: LayoutContext, + intrinsic: bool, + size_override: Option, +) -> Result { + let node = scope.view.node; + let resolver = scope.resolver; #[cfg(test)] TEST_RENDER_NODE_COUNT.with(|count| { if let Some(value) = count.get() { @@ -6609,6 +7252,10 @@ fn render_node_with_override( } }; let simple_scroll_window = *overflow == Overflow::Scroll + // Only a requested complete producer needs effects from every + // child, including offscreen owners. Ordinary retained layout + // keeps the existing window shortcut. + && !scope.complete_scroll_effects_requested() && child.is_some() && definite_content_width.is_some() && definite_content_height.is_some() @@ -6630,7 +7277,9 @@ fn render_node_with_override( && *vertical == VerticalAlign::Top; let mut windowed_child_start = None; let mut windowed_child_height = None; + let mut child_change = None; let mut child_rendered = if let Some(child) = child { + let child_scope = scope.child(LocalStep::BoxChild, child); let child_context = LayoutContext { viewport_width: if intrinsic_child { 0 @@ -6654,8 +7303,7 @@ fn render_node_with_override( windowed_child_height = Some(total_height); Some( render_node_window( - child, - resolver, + &child_scope, child_context, intrinsic || intrinsic_child, start, @@ -6664,20 +7312,22 @@ fn render_node_with_override( .expect("exact height checked above")?, ) } else { - Some(render_node( - child, - resolver, + let evaluated = child_scope.render_with_change( child_context, intrinsic || intrinsic_child, - )?) + None, + )?; + child_change = evaluated.change; + Some(evaluated.rendered) } } else { - Some(render_node( - child, - resolver, + let evaluated = child_scope.render_with_change( child_context, intrinsic || intrinsic_child, - )?) + None, + )?; + child_change = evaluated.change; + Some(evaluated.rendered) } } else { None @@ -6770,21 +7420,52 @@ fn render_node_with_override( && border_top_style.is_none() && border_bottom_style.is_none() && child_rendered.as_ref().is_some_and(|rendered| { - rendered - .lines - .iter() - .all(|line| !line.atoms.is_empty() && line.width == content_width) + rendered.lines.all_nonempty() && rendered.lines.uniform_width(content_width) }); if transparent_preformatted { let mut rendered = child_rendered.take().expect("validated child"); - for (index, line) in rendered.lines.iter_mut().enumerate() { - line.own_content(*region_id, index as i64); - line.apply_property_template(*surface_template_id); + rendered.root_scroll = None; + rendered.own_scroll_owner = false; + let mut ops = vec![LineOp::OwnContent { + region: *region_id, + start: 0, + }]; + ops.extend(surface_template_id.map(LineOp::Template)); + rendered.lines = project_box_lines( + scope, + BoxProjectionSlot::TransparentContent, + rendered.lines, + &mut child_change, + ops, + ); + if let Some(change) = child_change { + scope.publish_change(change); } return Ok(rendered); } - let mut formatted = if let Some(text) = content { + let previous_scroll = scope.previous_root_scroll(); + let text_styles = [ + *content_typography_style, + *content_foreground_style, + *content_surface_template_id, + ]; + let reusable_text = previous_scroll + .as_ref() + .and_then(|scroll| scroll.text_input.as_ref()) + .filter(|input| { + content + .as_ref() + .is_some_and(|text| Arc::ptr_eq(text, &input.source)) + && input.width == content_width + && input.wrap == *wrap_mode + && input.region == *content_region_id + && input.styles == text_styles + }); + let mut formatted = if let Some(input) = reusable_text { + child_change = Some(PlanChange::same(&input.lines)); + input.lines.clone() + } else if let Some(text) = content { let mut content_lines = measured_lines(text, content_width, *wrap_mode); if let Some(content_region_id) = content_region_id { for (index, line) in content_lines.iter_mut().enumerate() { @@ -6794,34 +7475,48 @@ fn render_node_with_override( line.apply_property_template(*content_surface_template_id); } } - content_lines - .into_iter() - .map(|line| line.padded(content_width, *text_align)) - .collect::>() + LinePlan::from_lines(content_lines) } else { let rendered = child_rendered.expect("validated child"); - let uniform_width = rendered - .lines - .iter() - .all(|line| line.width == content_width); - let preserve_exact_width = *content_width_exact && *wrap_mode == WrapMode::None; + let uniform_width = rendered.lines.uniform_width(content_width); let rendered = if *wrap_mode != WrapMode::None && !uniform_width { + child_change = None; wrap_rendered(rendered, content_width, *wrap_mode) } else { rendered }; - rendered - .lines - .into_iter() - .map(|line| { - if preserve_exact_width { - line - } else { - line.padded(content_width, *text_align) - } - }) - .collect::>() + rendered.lines }; + let text_input = if scope.root_scroll_requested() { + content.as_ref().map(|text| RootTextInput { + source: Arc::clone(text), + width: content_width, + wrap: *wrap_mode, + region: *content_region_id, + styles: text_styles, + lines: formatted.clone(), + }) + } else { + None + }; + // Ordinary Box formatting historically rebuilds default breaks; + // the transparent preformatted branch above preserves child breaks. + let mut change = child_change; + let mut pad_ops = Vec::new(); + if content.is_some() || !(*content_width_exact && *wrap_mode == WrapMode::None) { + pad_ops.push(LineOp::PadTo { + width: content_width, + align: *text_align, + }); + } + pad_ops.push(LineOp::ClearBreaks); + formatted = project_box_lines( + scope, + BoxProjectionSlot::ContentPad, + formatted, + &mut change, + pad_ops, + ); let text_height = windowed_child_height.unwrap_or(formatted.len() as i64); let minimum_height = resolve_height( min_height, @@ -6873,11 +7568,63 @@ fn render_node_with_override( && border_bottom_style.is_none() && *vertical == VerticalAlign::Top; + let root_scroll = if scope.root_scroll_requested() + && *overflow == Overflow::Scroll + && *padding_left == 0 + && *padding_right == 0 + && *padding_top == 0 + && *padding_bottom == 0 + && *margin_left == 0 + && *margin_right == 0 + && *margin_top == 0 + && *margin_bottom == 0 + && *border_left == 0 + && *border_right == 0 + && border_top_style.is_none() + && border_bottom_style.is_none() + && surface_template_id.is_none() + && *vertical == VerticalAlign::Top + { + let full_content = formatted.clone(); + let mut ops = vec![LineOp::OwnContent { + region: *region_id, + start: 0, + }]; + ops.extend( + [*typography_style, *foreground_style, *background_style] + .into_iter() + .flatten() + .map(LineOp::Style), + ); + let rendered_content = project_box_lines( + scope, + BoxProjectionSlot::ScrollContent, + full_content.clone(), + &mut change.clone(), + ops, + ); + Some(Arc::new(RootScrollPlan { + region_id: *region_id, + full_content, + rendered_content, + visible_height: content_height, + effective_offset: (*scroll_offset) + .max(0) + .min((text_height - content_height).max(0)), + text_input, + })) + } else { + None + }; + let mut content_index_start = 0_i64; - let mut overflow_lines = Vec::new(); + let mut overflow_lines = LinePlan::default(); if let Some(start) = windowed_child_start { content_index_start = start; } else if formatted.len() > content_height as usize { + // Clipping changes operation origins. Until the exact old/new window + // anchors are available, the next stage takes its counted fallback. + change = None; match overflow { Overflow::Scroll => { let max_offset = formatted.len() - content_height as usize; @@ -6885,122 +7632,219 @@ fn render_node_with_override( if simple_scroll_rendered { content_index_start = start as i64; } - formatted = formatted[start..start + content_height as usize].to_vec(); + formatted = formatted.slice(start..start + content_height as usize); } Overflow::Hidden => { - formatted.truncate(content_height as usize); + formatted = formatted.slice(0..content_height as usize); } Overflow::Visible => { - overflow_lines = formatted.split_off(content_height as usize); + overflow_lines = formatted.slice(content_height as usize..formatted.len()); + formatted = formatted.slice(0..content_height as usize); } } } - for (index, line) in formatted.iter_mut().enumerate() { - line.own_content(*region_id, content_index_start + index as i64); + formatted = project_box_lines( + scope, + BoxProjectionSlot::ContentOwn, + formatted, + &mut change, + vec![LineOp::OwnContent { + region: *region_id, + start: content_index_start, + }], + ); + if formatted.len() > content_height as usize { + change = None; + formatted = formatted.slice(0..content_height as usize); } - let mut lines = - vertical_align(formatted, content_height as usize, *vertical, content_width) + let remaining = content_height as usize - formatted.len(); + let top = match vertical { + VerticalAlign::Top => 0, + VerticalAlign::Bottom => remaining, + VerticalAlign::Center => remaining / 2, + }; + let lines = frame_box_lines( + scope, + BoxProjectionSlot::VerticalFrame, + formatted, + &mut change, + FrameSpec { + prefix: top, + suffix: remaining - top, + width: content_width, + prefix_properties: Arc::new(AtomProperties::default()), + suffix_properties: Arc::new(AtomProperties::default()), + }, + ); + let collapse_ops = if simple_scroll_rendered { + Vec::new() + } else { + vec![LineOp::CollapseWhitespace { + width: content_width, + region: *region_id, + }] + }; + let lines = project_box_lines( + scope, + BoxProjectionSlot::ContentCollapse, + lines, + &mut change, + collapse_ops, + ); + + let mut padded = frame_box_lines( + scope, + BoxProjectionSlot::PaddingFrame, + lines, + &mut change, + FrameSpec { + prefix: *padding_top as usize, + suffix: *padding_bottom as usize, + width: content_width, + prefix_properties: region_properties(RegionRole::PaddingTop, *region_id, None) + .into(), + suffix_properties: region_properties( + RegionRole::PaddingBottom, + *region_id, + None, + ) + .into(), + }, + ); + let mut edge_ops = vec![LineOp::EdgeSpaces { + left: *padding_left, + left_properties: region_properties(RegionRole::PaddingLeft, *region_id, None) + .into(), + right: *padding_right, + right_properties: region_properties(RegionRole::PaddingRight, *region_id, None) + .into(), + }]; + edge_ops.extend( + [*typography_style, *foreground_style, *background_style] .into_iter() - .map(|line| { - if simple_scroll_rendered { - line - } else { - line.collapse_whitespace_content(content_width, *region_id) - } - }) - .collect::>(); - - let mut padded = - Vec::with_capacity(lines.len() + *padding_top as usize + *padding_bottom as usize); - padded.extend((0..*padding_top).map(|_| { - Line::blank_with_properties( - content_width, - region_properties(RegionRole::PaddingTop, *region_id, None), + .flatten() + .map(LineOp::Style), + ); + edge_ops.push(LineOp::EdgeSpaces { + left: *border_left, + left_properties: region_properties( + RegionRole::BorderLeft, + *region_id, + *border_left_style, ) - })); - padded.append(&mut lines); - padded.extend((0..*padding_bottom).map(|_| { - Line::blank_with_properties( - content_width, - region_properties(RegionRole::PaddingBottom, *region_id, None), + .into(), + right: *border_right, + right_properties: region_properties( + RegionRole::BorderRight, + *region_id, + *border_right_style, ) + .into(), + }); + padded = project_box_lines( + scope, + BoxProjectionSlot::PaddingEdges, + padded, + &mut change, + edge_ops, + ); + let mut boundary_ops = Vec::new(); + boundary_ops.extend(border_top_style.map(|style| LineOp::FirstStyleRole { + style, + region: *region_id, })); - - for line in &mut padded { - line.prepend_space_with_properties( - *padding_left, - region_properties(RegionRole::PaddingLeft, *region_id, None), - ); - line.push_space_with_properties( - *padding_right, - region_properties(RegionRole::PaddingRight, *region_id, None), - ); - line.apply_style(*typography_style); - line.apply_style(*foreground_style); - line.apply_style(*background_style); - line.prepend_space_with_properties( - *border_left, - region_properties(RegionRole::BorderLeft, *region_id, *border_left_style), - ); - line.push_space_with_properties( - *border_right, - region_properties(RegionRole::BorderRight, *region_id, *border_right_style), - ); - } - if border_top_style.is_some() { - if let Some(first) = padded.first_mut() { - first.apply_style(*border_top_style); - first.apply_role(RegionRole::BorderTop, *region_id); - } - } - if border_bottom_style.is_some() { - if let Some(last) = padded.last_mut() { - last.apply_style(*border_bottom_style); - last.apply_role(RegionRole::BorderBottom, *region_id); - } - } - for line in &mut padded { - line.apply_property_template(*surface_template_id); - } - for line in &mut padded { - line.prepend_space_with_properties( - *margin_left, - region_properties(RegionRole::MarginLeft, *region_id, None), - ); - line.push_space_with_properties( - *margin_right, - region_properties(RegionRole::MarginRight, *region_id, None), - ); - } + boundary_ops.extend(border_bottom_style.map(|style| LineOp::LastStyleRole { + style, + region: *region_id, + })); + padded = project_box_lines( + scope, + BoxProjectionSlot::BoundaryBorders, + padded, + &mut change, + boundary_ops, + ); + padded = project_box_lines( + scope, + BoxProjectionSlot::SurfaceTemplate, + padded, + &mut change, + surface_template_id + .map(LineOp::Template) + .into_iter() + .collect(), + ); + padded = project_box_lines( + scope, + BoxProjectionSlot::MarginEdges, + padded, + &mut change, + vec![LineOp::EdgeSpaces { + left: *margin_left, + left_properties: region_properties(RegionRole::MarginLeft, *region_id, None) + .into(), + right: *margin_right, + right_properties: region_properties(RegionRole::MarginRight, *region_id, None) + .into(), + }], + ); let total_width = content_width + side_width; - let mut output = - Vec::with_capacity(padded.len() + *margin_top as usize + *margin_bottom as usize); - output.extend((0..*margin_top).map(|_| { - Line::blank_with_properties( - total_width, - region_properties(RegionRole::MarginTop, *region_id, None), - ) - })); - output.append(&mut padded); - output.extend((0..*margin_bottom).map(|_| { - Line::blank_with_properties( - total_width, - region_properties(RegionRole::MarginBottom, *region_id, None), - ) - })); - let mut rendered = Rendered::from_lines(output); + let output = frame_box_lines( + scope, + BoxProjectionSlot::MarginFrame, + padded, + &mut change, + FrameSpec { + prefix: *margin_top as usize, + suffix: *margin_bottom as usize, + width: total_width, + prefix_properties: region_properties(RegionRole::MarginTop, *region_id, None) + .into(), + suffix_properties: region_properties( + RegionRole::MarginBottom, + *region_id, + None, + ) + .into(), + }, + ); + let mut rendered = Rendered::from_line_plan(output); if !overflow_lines.is_empty() { let left_space = margin_left + border_left + padding_left; let right_space = padding_right + border_right + margin_right; - for line in &mut overflow_lines { - line.apply_style(*foreground_style); - line.prepend_space(left_space); - line.push_space(right_space); - } - rendered = stack_vertical(vec![rendered, Rendered::from_lines(overflow_lines)]); + let mut ops = foreground_style + .map(LineOp::Style) + .into_iter() + .collect::>(); + ops.push(LineOp::EdgeSpaces { + left: left_space, + left_properties: Arc::new(AtomProperties::default()), + right: right_space, + right_properties: Arc::new(AtomProperties::default()), + }); + overflow_lines = project_box_lines( + scope, + BoxProjectionSlot::OverflowEdges, + overflow_lines, + &mut None, + ops, + ); + rendered = stack_vertical(vec![rendered, Rendered::from_line_plan(overflow_lines)]); + change = None; } if *overflow == Overflow::Scroll && text_height > content_height { - rendered.apply_scroll_window(*region_id); + rendered.own_scroll_owner = true; + rendered.lines = project_box_lines( + scope, + BoxProjectionSlot::ScrollWindow, + rendered.lines, + &mut change, + vec![LineOp::ScrollWindow(*region_id)], + ); + } + rendered.root_scroll = root_scroll; + if let Some(change) = change { + scope.publish_change(change); } Ok(rendered) } @@ -7030,33 +7874,30 @@ fn render_node_with_override( }; let rendered = children .iter() - .map(|child| render_node(child, resolver, child_context, intrinsic)) + .enumerate() + .map(|(index, child)| { + scope.child(LocalStep::RowChild(index), child).render( + child_context, + intrinsic, + None, + ) + }) .collect::, _>>()?; - let height = rendered - .iter() - .map(|item| item.lines.len()) - .max() - .unwrap_or(1); - let mut lines = Vec::with_capacity(height); - for index in 0..height { - let mut line = Line::default(); - for item in &rendered { - if let Some(child_line) = item.lines.get(index) { - line.append(child_line); - } else { - line.push_space(item.first_width()); - } - } - lines.push(line); - } - Ok(Rendered::from_lines(lines)) + let parts = rendered + .into_iter() + .map(|rendered| { + let width = rendered.first_width(); + (rendered, width) + }) + .collect(); + Ok(concat_horizontal_sized(parts, 0)) } LayoutNode::Column { children, .. } => { - let mut leaves = Vec::new(); - collect_column_leaves(children, &mut leaves); - let rendered = leaves - .into_iter() - .map(|child| render_node(child, resolver, context, intrinsic)) + if scope.is_retained() { + return composition::render_column(scope, children, context, intrinsic); + } + let rendered = column_leaves(scope, children) + .map(|child| child.render(context, intrinsic, None)) .collect::, _>>()?; let maximum = rendered .iter() @@ -7074,12 +7915,14 @@ fn render_node_with_override( let mut parts = Vec::with_capacity(rendered.len()); for mut item in rendered { let extra = (target - item.first_width()).max(0); - for line in &mut item.lines { - line.push_space(extra); - } if extra > 0 { - item.breaks = - vec![AtomProperties::default(); item.lines.len().saturating_sub(1)]; + item.lines = item + .lines + .map_lines(|_, mut line| { + line.push_space(extra); + line + }) + .clear_breaks(); } parts.push(item); } @@ -7108,27 +7951,131 @@ fn render_node_with_override( *row_gap, *column_gap, items, - resolver, + scope, context, intrinsic, ), } } -fn collect_column_leaves<'a>(children: &'a [LayoutNode], output: &mut Vec<&'a LayoutNode>) { - for child in children { - if let LayoutNode::Column { children, .. } = child { - collect_column_leaves(children, output); - } else { - output.push(child); +// Flatten only literal Columns. In particular, a retained NodeRef remains a +// child evaluation boundary even when it resolves to an identified Column. +fn column_leaves<'a>( + scope: &RenderScope<'a>, + children: &'a [LayoutNode], +) -> impl Iterator> { + let mut stack = vec![(scope.clone(), children.iter().enumerate())]; + std::iter::from_fn(move || loop { + let (scope, children) = stack.last_mut()?; + match children.next() { + Some((index, child @ LayoutNode::Column { children, .. })) => { + let child_scope = scope.child(LocalStep::ColumnChild(index), child); + stack.push((child_scope, children.iter().enumerate())); + } + Some((index, child)) => return Some(scope.child(LocalStep::ColumnChild(index), child)), + None => { + stack.pop(); + } } - } + }) } #[cfg(test)] mod tests { use super::*; + include!("composition_tests.rs"); + + include!("source_topology_tests.rs"); + + #[test] + fn line_composition_preserves_mixed_ownership_and_decoration_order() { + let mut child = Line::from_clusters(&[cluster("x", 2, Some(7))]); + child.own_content(11, 3); + child.apply_style(Some(2)); + let original = Rendered::from_lines(vec![child.clone()]).into_tape(10); + let mut parent = Line::blank(4); + parent.append(&child); + parent.own_content(22, 9); + parent.apply_style(Some(5)); + parent.apply_property_template(Some(8)); + parent.apply_role(RegionRole::BorderTop, 22); + parent.push_space(6); + let tape = Rendered::from_lines(vec![parent]).into_tape(10); + assert_eq!(tape.lines[0].width, 12); + let TapeAtom::Space { + properties: left, .. + } = &tape.lines[0].atoms[0] + else { + panic!() + }; + assert_eq!(left.owner, Some(22)); + assert_eq!(left.owners, vec![22]); + assert_eq!( + left.content, None, + "a sibling's content prevents assigning padding content" + ); + assert_eq!(left.style_ids, vec![5]); + let TapeAtom::Text { + properties: text, .. + } = &tape.lines[0].atoms[1] + else { + panic!() + }; + assert_eq!(text.owner, Some(22)); + assert_eq!(text.owners, vec![11, 22]); + assert_eq!(text.content, Some(11)); + assert_eq!(text.content_idx, Some(3)); + assert_eq!(text.style_ids, vec![2, 5]); + assert_eq!(text.property_template_ids, vec![7, 8]); + assert_eq!( + text.roles, + vec![RegionRoleEntry { + role: RegionRole::BorderTop, + region_id: 22 + }] + ); + let TapeAtom::Space { + properties: right, .. + } = &tape.lines[0].atoms[2] + else { + panic!() + }; + assert_eq!( + right, + &AtomProperties::default(), + "later padding stays outside prior decorations" + ); + assert_eq!( + Rendered::from_lines(vec![child]).into_tape(10), + original, + "composing a parent must preserve its reusable child" + ); + } + + #[test] + fn line_whitespace_collapse_preserves_property_boundaries() { + let mut plain = Line::blank(5); + plain.own_content(10, 4); + let collapsed = + Rendered::from_lines(vec![plain.collapse_whitespace_content(7, 20)]).into_tape(0); + let TapeAtom::Space { width, properties } = &collapsed.lines[0].atoms[0] else { + panic!() + }; + assert_eq!(*width, 7); + assert_eq!(properties.content, Some(10)); + assert_eq!(properties.content_idx, Some(4)); + assert_eq!(properties.owner, Some(20)); + assert!(properties.owners.is_empty()); + let mut styled = Line::blank(5); + styled.apply_property_template(Some(3)); + let before = Rendered::from_lines(vec![styled.clone()]).into_tape(0); + assert_eq!( + Rendered::from_lines(vec![styled.collapse_whitespace_content(7, 20)]).into_tape(0), + before + ); + } + fn counted_layout_tape( document: &LayoutDocument, context: LayoutContext, @@ -7154,7 +8101,11 @@ mod tests { serde_json::from_str(json).unwrap() } - fn cluster(text: &str, width: i64, source_template_id: Option) -> MeasuredCluster { + pub(super) fn cluster( + text: &str, + width: i64, + source_template_id: Option, + ) -> MeasuredCluster { MeasuredCluster { text: text.to_owned(), width, @@ -7165,7 +8116,7 @@ mod tests { } } - fn measured_text(lines: Vec>) -> MeasuredText { + pub(super) fn measured_text(lines: Vec>) -> MeasuredText { MeasuredText { lines: lines .into_iter() @@ -7237,7 +8188,7 @@ mod tests { node } - fn child_box( + pub(super) fn child_box( region_id: i64, child: LayoutNode, surface_template_id: Option, @@ -7287,7 +8238,7 @@ mod tests { } } - fn identified(mut node: LayoutNode, node_id: u64, revision: u64) -> LayoutNode { + pub(super) fn identified(mut node: LayoutNode, node_id: u64, revision: u64) -> LayoutNode { match &mut node { LayoutNode::Box { node_id: id, @@ -7322,7 +8273,7 @@ mod tests { node } - fn retained_document(root: LayoutNode) -> LayoutDocument { + pub(super) fn retained_document(root: LayoutNode) -> LayoutDocument { LayoutDocument { version: LAYOUT_VERSION, space_width: 1, @@ -7384,7 +8335,7 @@ mod tests { } } - fn test_context() -> LayoutContext { + pub(super) fn test_context() -> LayoutContext { LayoutContext { viewport_width: 80, viewport_width_known: true, @@ -7544,14 +8495,14 @@ mod tests { }), ) .expect("typed column render"); - assert_eq!(rendered.lines[0].width, 100); + assert_eq!(rendered.lines.get(0).unwrap().width(), 100); assert!( matches!( - rendered.lines[0].atoms.first(), + rendered.lines.get(0).unwrap().materialize().atoms.first(), Some(Atom::Space { width: 45, .. }) ), "{:?}", - rendered.lines[0].atoms + rendered.lines.get(0).unwrap().materialize().atoms ); } @@ -7617,6 +8568,7 @@ mod tests { .iter() .map(|line| { line.atoms + .to_vec() .iter() .map(|atom| match atom { Atom::Text { text, .. } => text.as_str(), @@ -8042,21 +8994,39 @@ mod tests { #[test] fn vertical_stack_and_scroll_preserve_break_metadata() { let mut first = Rendered::from_lines(vec![Line::blank(1), Line::blank(1)]); - first.breaks[0].owner = Some(11); + first.lines = first.lines.with_break( + 0, + AtomProperties { + owner: Some(11), + ..AtomProperties::default() + }, + ); let second = Rendered::from_lines(vec![Line::blank(1)]); let mut stacked = stack_vertical(vec![first, second]); - assert_eq!(stacked.breaks.len(), 2); - assert_eq!(stacked.breaks[0].owner, Some(11)); - assert_eq!(stacked.breaks[1], AtomProperties::default()); + assert_eq!(stacked.lines.len() - 1, 2); + assert_eq!( + stacked.lines.break_after(0).unwrap().materialize().owner, + Some(11) + ); + assert_eq!( + stacked + .lines + .break_after(1) + .map(|view| view.materialize().into_owned()), + Some(AtomProperties::default()) + ); stacked.apply_scroll_window(9); assert!(stacked - .breaks - .iter() - .all(|properties| { properties.scroll_window == Some(9) })); + .lines + .iter_with_breaks() + .filter_map(|(_, properties)| properties) + .all(|properties| { properties.materialize().scroll_window == Some(9) })); assert!(stacked.lines.iter().all(|line| { - line.atoms + line.materialize() + .atoms + .to_vec() .iter() .all(|atom| atom.properties().scroll_window == Some(9)) })); @@ -8070,6 +9040,236 @@ mod tests { assert!(tape.lines[2].break_after.is_none()); } + #[test] + fn line_sequence_oracle_wrap_slice_and_join_keep_distinct_breaks() { + let source = Rendered { + root_scroll: None, + own_scroll_owner: false, + scroll_owners: 0, + lines: LinePlan::from_lines([ + Line::from_clusters(&[cluster("a", 2, None), cluster("b", 2, None)]), + Line::from_clusters(&[cluster("c", 2, None), cluster("d", 2, None)]), + ]) + .with_break( + 0, + AtomProperties { + owner: Some(17), + scroll_window: Some(9), + ..AtomProperties::default() + }, + ), + }; + let wrapped = wrap_rendered(source, 2, WrapMode::Char); + let tape = wrapped.clone().into_tape(0); + assert_eq!(tape.lines.len(), 4); + assert_eq!(tape.lines[0].break_after, Some(AtomProperties::default())); + assert_eq!(tape.lines[1].break_after.as_ref().unwrap().owner, Some(17)); + assert_eq!(tape.lines[2].break_after, Some(AtomProperties::default())); + let middle = slice_rendered(wrapped, 1, 2); + assert_eq!( + middle.clone().into_tape(0).lines, + tape.lines[1..3] + .iter() + .cloned() + .enumerate() + .map(|(index, mut line)| { + if index == 1 { + line.break_after = None; + } + line + }) + .collect::>() + ); + let joined = + stack_vertical(vec![middle, Rendered::from_lines(vec![Line::blank(1)])]).into_tape(0); + assert_eq!( + joined.lines[0].break_after.as_ref().unwrap().owner, + Some(17) + ); + assert_eq!(joined.lines[1].break_after, Some(AtomProperties::default())); + } + + pub(super) fn nonuniform_text(region_id: i64, widths: &[i64]) -> LayoutNode { + LayoutNode::Text { + node_id: None, + node_revision: None, + region_id, + content: Arc::new(measured_text( + widths + .iter() + .map(|width| vec![cluster("x", *width, None)]) + .collect(), + )), + typography_style: None, + foreground_style: None, + surface_template_id: None, + wrap_mode: WrapMode::None, + } + } + + #[test] + fn line_sequence_oracle_column_uses_first_width_and_constant_child_extra() { + let column = LayoutNode::Column { + node_id: None, + node_revision: None, + children: Arc::new(vec![ + nonuniform_text(1, &[2, 9]), + nonuniform_text(2, &[5, 1]), + ]), + }; + let tape = render_node(&column, None, test_context(), true) + .unwrap() + .into_tape(0); + assert_eq!( + tape.lines.iter().map(|line| line.width).collect::>(), + [5, 12, 5, 1] + ); + let tape = render_node( + &column, + None, + LayoutContext { + viewport_width: 3, + ..test_context() + }, + false, + ) + .unwrap() + .into_tape(0); + assert_eq!( + tape.lines.iter().map(|line| line.width).collect::>(), + [3, 10, 5, 1] + ); + } + + #[test] + fn line_sequence_oracle_column_flattens_literal_columns_but_not_node_refs() { + let inner = identified( + LayoutNode::Column { + node_id: None, + node_revision: None, + children: Arc::new(vec![nonuniform_text(1, &[2]), nonuniform_text(2, &[5])]), + }, + 2, + 1, + ); + let root = identified( + LayoutNode::Column { + node_id: None, + node_revision: None, + children: Arc::new(vec![inner, nonuniform_text(3, &[8])]), + }, + 1, + 1, + ); + let document = retained_document(root); + let (retained, _) = RetainedDocument::bootstrap(document.clone()).unwrap(); + let full = render_node(&document.root, None, test_context(), true) + .unwrap() + .into_tape(0); + let referenced = render_node( + &LayoutNode::NodeRef { + node_id: retained.root_id, + }, + Some(&retained), + test_context(), + true, + ) + .unwrap() + .into_tape(0); + assert_eq!( + full.lines + .iter() + .map(|line| line.atoms.len()) + .collect::>(), + [2, 2, 1] + ); + assert_eq!( + referenced + .lines + .iter() + .map(|line| line.atoms.len()) + .collect::>(), + [3, 2, 1] + ); + assert_eq!( + full.lines.iter().map(|line| line.width).collect::>(), + [8, 8, 8] + ); + assert_eq!( + referenced + .lines + .iter() + .map(|line| line.width) + .collect::>(), + [8, 8, 8] + ); + } + + #[test] + fn line_sequence_oracle_column_only_positive_extra_clears_scroll_breaks() { + let mut scrolled = child_box(11, nonuniform_text(12, &[2, 2, 2]), None); + let LayoutNode::Box { height, .. } = &mut scrolled else { + unreachable!() + }; + *height = Size::Lines { value: 2 }; + let column = LayoutNode::Column { + node_id: None, + node_revision: None, + children: Arc::new(vec![scrolled]), + }; + for (width, expected_scroll) in [(1, Some(11)), (2, Some(11)), (3, None)] { + let tape = render_node( + &column, + None, + LayoutContext { + viewport_width: width, + ..test_context() + }, + false, + ) + .unwrap() + .into_tape(0); + assert_eq!( + tape.lines[0].break_after.as_ref().unwrap().scroll_window, + expected_scroll + ); + } + } + + #[test] + fn line_sequence_oracle_horizontal_missing_lines_use_first_width() { + let row = LayoutNode::Row { + node_id: None, + node_revision: None, + children: Arc::new(vec![ + nonuniform_text(1, &[2, 9]), + nonuniform_text(2, &[5, 1, 3]), + ]), + }; + let tape = render_node(&row, None, test_context(), true) + .unwrap() + .into_tape(0); + assert_eq!( + tape.lines.iter().map(|line| line.width).collect::>(), + [7, 10, 5] + ); + let parts = vec![ + ( + Rendered::from_lines(vec![Line::blank(2), Line::blank(9)]), + 4, + ), + ( + Rendered::from_lines(vec![Line::blank(5), Line::blank(1), Line::blank(3)]), + 6, + ), + ]; + let tape = concat_horizontal_sized(parts, 4).into_tape(0); + assert_eq!( + tape.lines.iter().map(|line| line.width).collect::>(), + [7, 10, 7, 10] + ); + } + #[test] fn binary_tapes_are_versioned_bounded_and_support_light_frames() { let properties = AtomProperties { @@ -8089,7 +9289,7 @@ mod tests { Line::blank_with_properties(8, properties.clone()), Line::blank_with_properties(8, properties.clone()), ]); - rendered.breaks[0] = properties; + rendered.lines = rendered.lines.with_break(0, properties); let tape = rendered.into_tape(3); let styles = vec![ StyleTemplate { @@ -8825,6 +10025,953 @@ mod tests { assert!(fragment_style_delta(&old, &changed).is_none()); } + // Reconstruct the full document so its original context traversal remains an + // independent oracle for summaries, including after retained local patches. + fn retained_context_full_document(document: &RetainedDocument) -> LayoutDocument { + fn expand(node: &LayoutNode, document: &RetainedDocument) -> LayoutNode { + let mut node = document.resolve(node).unwrap().clone(); + match &mut node { + LayoutNode::Box { child, .. } => { + *child = child + .as_deref() + .map(|child| Arc::new(expand(child, document))); + } + LayoutNode::Row { children, .. } | LayoutNode::Column { children, .. } => { + *children = + Arc::new(children.iter().map(|node| expand(node, document)).collect()); + } + LayoutNode::Flex { items, .. } => { + *items = Arc::new( + items + .iter() + .map(|item| { + let mut item = item.clone(); + item.node = expand(&item.node, document); + item + }) + .collect(), + ); + } + LayoutNode::Text { .. } => {} + LayoutNode::NodeRef { .. } => unreachable!(), + } + node + } + retained_document(expand( + &LayoutNode::NodeRef { + node_id: document.root_id, + }, + document, + )) + } + + fn assert_retained_context_oracle(document: &RetainedDocument, occurrences: usize) { + assert_eq!(document.context_viewport_heights, occurrences); + let full = retained_context_full_document(document); + let boundary = MAX_LAYOUT_WORK_UNITS / occurrences.max(1); + for height in [ + i64::MIN, + -1, + 0, + 1, + boundary as i64, + boundary as i64 + 1, + MAX_LAYOUT_WORK_UNITS as i64, + MAX_LAYOUT_WORK_UNITS as i64 + 1, + MAX_LAYOUT_DIMENSION, + i64::MAX, + ] { + let context = LayoutContext { + viewport_height: height, + ..test_context() + }; + let expected = full.validate_context(context); + assert_eq!( + document.validate_context(context), + expected, + "height {height}" + ); + let accepted = occurrences == 0 || height >= 0 && height <= boundary as i64; + assert_eq!( + expected.is_ok(), + accepted, + "{occurrences} occurrences at height {height}" + ); + if !accepted { + assert_eq!( + expected.unwrap_err(), + "Native layout exceeds the work-unit limit" + ); + } + } + } + + pub(super) fn context_test_delta(entries: Vec) -> DocumentDelta { + serde_json::from_value(serde_json::json!({ + "style-base-count": 0, + "styles-append": [], + "property-template-base-count": 0, + "property-template-target-count": 0, + "entries": entries + })) + .unwrap() + } + + fn context_test_flex(height: Size, nodes: Vec) -> LayoutNode { + LayoutNode::Flex { + node_id: None, + node_revision: None, + direction: FlexDirection::Column, + wrap: FlexWrap::Nowrap, + justify: FlexAlign::FlexStart, + align_items: FlexAlign::Stretch, + align_content: FlexAlign::Stretch, + width: Size::ViewportHeight, + height, + row_gap: 0, + column_gap: 0, + items: Arc::new( + nodes + .into_iter() + .map(|node| FlexItem { + node, + order: 0, + grow: 0.0, + shrink: 0.0, + basis: Size::ViewportHeight, + align_self: FlexAlign::Stretch, + }) + .collect(), + ), + } + } + + #[test] + fn retained_context_bootstrap_counts_deep_anonymous_and_nested_expressions() { + let mut leaf = text_box(2, measured_text(vec![vec![cluster("x", 1, None)]]), None); + let LayoutNode::Box { + height, + min_height, + max_height, + .. + } = &mut leaf + else { + unreachable!(); + }; + *height = serde_json::from_value(serde_json::json!({ + "kind": "subtract", "values": [ + {"kind": "viewport-height"}, + {"kind": "fit-content", "limit": { + "kind": "add", "values": [ + {"kind": "viewport-height"}, {"kind": "viewport-height"}, + {"kind": "viewport"}, {"kind": "lines", "value": 2} + ] + }} + ] + })) + .unwrap(); + *min_height = Size::ViewportHeight; + *max_height = Size::FitContent { + limit: Some(Box::new(Size::ViewportHeight)), + }; + let mut anonymous = context_test_flex(Size::ViewportHeight, vec![identified(leaf, 2, 1)]); + for index in 0..48 { + let mut wrapper = child_box(index + 3, anonymous, None); + let LayoutNode::Box { height, .. } = &mut wrapper else { + unreachable!() + }; + *height = Size::ViewportHeight; + anonymous = LayoutNode::Row { + node_id: None, + node_revision: None, + children: Arc::new(vec![wrapper]), + }; + } + let root = identified(child_box(1, anonymous, None), 1, 1); + let full = retained_document(root); + let (retained, parsed) = RetainedDocument::bootstrap(full.clone()).unwrap(); + assert_eq!(parsed, 99); + assert_retained_context_oracle(&retained, 54); + for height in [0, 1, 4629, 4630, -1, i64::MAX] { + let context = LayoutContext { + viewport_height: height, + ..test_context() + }; + assert_eq!( + retained.validate_context(context), + full.validate_context(context) + ); + } + } + + #[test] + fn retained_context_ignores_widths_and_preserves_zero_occurrence_errors() { + let mut leaf = text_box(2, measured_text(vec![vec![cluster("x", 1, None)]]), None); + let LayoutNode::Box { + width, + min_width, + max_width, + height, + .. + } = &mut leaf + else { + unreachable!(); + }; + *width = Size::ViewportHeight; + *min_width = Size::ViewportHeight; + *max_width = Size::ViewportHeight; + *height = Size::FitContent { limit: None }; + let root = identified( + child_box(1, context_test_flex(Size::Auto, vec![leaf]), None), + 1, + 1, + ); + let (retained, _) = RetainedDocument::bootstrap(retained_document(root)).unwrap(); + assert_retained_context_oracle(&retained, 0); + } + + #[test] + fn retained_context_slot_orders_revision_only_forks_and_invalid_cas_match_oracle() { + let leaf = identified( + text_box(2, measured_text(vec![vec![cluster("x", 1, None)]]), None), + 2, + 1, + ); + let root = identified( + child_box(1, context_test_flex(Size::ViewportHeight, vec![leaf]), None), + 1, + 7, + ); + let (base, _) = RetainedDocument::bootstrap(retained_document(root)).unwrap(); + let slot_zero = serde_json::json!({"slot": 0, "local": { + "height": {"kind": "viewport-height"}, + "min-height": {"kind": "viewport-height"}, + "max-height": {"kind": "fit-content", "limit": {"kind": "viewport-height"}}, + "padding-left": 2 + }}); + let slot_one = serde_json::json!({"slot": 1, "local": { + "height": {"kind": "subtract", "values": [ + {"kind": "viewport-height"}, {"kind": "viewport-height"} + ]}, + "width": {"kind": "viewport-height"} + }}); + let update = |patches: Vec| { + serde_json::json!({ + "node-id": 1, "expected-revision": 7, "target-revision": 8, + "slot-patches": patches + }) + }; + let forward = base + .apply_delta(context_test_delta(vec![update(vec![ + slot_zero.clone(), + slot_one.clone(), + ])])) + .unwrap() + .document; + let reverse = base + .apply_delta(context_test_delta(vec![update(vec![slot_one, slot_zero])])) + .unwrap() + .document; + assert_retained_context_oracle(&base, 1); + assert_retained_context_oracle(&forward, 5); + assert_retained_context_oracle(&reverse, 5); + + let revision = + serde_json::json!({"node-id": 1, "expected-revision": 8, "target-revision": 9}); + let no_op = forward + .apply_delta(context_test_delta(vec![revision])) + .unwrap() + .document; + assert_retained_context_oracle(&no_op, 5); + assert!(Arc::ptr_eq( + &radix_lookup(&forward.entries, 1).unwrap().node, + &radix_lookup(&no_op.entries, 1).unwrap().node + )); + + let remove = update(vec![ + serde_json::json!({"slot": 1, "local": {"height": {"kind": "auto"}}}), + ]); + let other_fork = base + .apply_delta(context_test_delta(vec![remove])) + .unwrap() + .document; + assert_retained_context_oracle(&other_fork, 0); + assert_retained_context_oracle(&base, 1); + assert_retained_context_oracle(&forward, 5); + + for (expected_revision, target_revision) in [(6, 8), (7, 7), (7, 6)] { + let invalid = serde_json::json!({ + "node-id": 1, "expected-revision": expected_revision, "target-revision": target_revision, + "slot-patches": [{"slot": 1, "local": {"height": {"kind": "auto"}}}] + }); + assert_eq!( + base.apply_delta(context_test_delta(vec![invalid])) + .unwrap_err(), + "Native retained delta node revision mismatch" + ); + assert_retained_context_oracle(&base, 1); + } + } + + #[test] + fn retained_context_large_occurrence_count_accepts_zero_height() { + let mut root = identified( + text_box(1, measured_text(vec![vec![cluster("x", 1, None)]]), None), + 1, + 1, + ); + let LayoutNode::Box { height, .. } = &mut root else { + unreachable!() + }; + *height = Size::Add { + values: Box::new(SizeValues( + vec![Size::ViewportHeight; MAX_LAYOUT_WORK_UNITS + 1].into_boxed_slice(), + )), + }; + let (retained, _) = RetainedDocument::bootstrap(retained_document(root)).unwrap(); + assert_retained_context_oracle(&retained, MAX_LAYOUT_WORK_UNITS + 1); + } + + #[test] + fn retained_context_flex_owner_and_anonymous_box_batch_updates_are_atomic() { + let mut anonymous = text_box(3, measured_text(vec![vec![cluster("x", 1, None)]]), None); + let LayoutNode::Box { + height, + min_height, + max_height, + .. + } = &mut anonymous + else { + unreachable!() + }; + *height = Size::ViewportHeight; + *min_height = Size::ViewportHeight; + *max_height = Size::ViewportHeight; + let owner = identified(child_box(2, anonymous, None), 2, 1); + let root = identified(context_test_flex(Size::ViewportHeight, vec![owner]), 1, 1); + let (base, _) = RetainedDocument::bootstrap(retained_document(root)).unwrap(); + let flex_update = serde_json::json!({ + "node-id": 1, "expected-revision": 1, "target-revision": 2, + "slot-patches": [{"slot": 0, "local": {"height": {"kind": "add", "values": [ + {"kind": "viewport-height"}, {"kind": "viewport-height"} + ]}}}] + }); + let box_update = serde_json::json!({ + "node-id": 2, "expected-revision": 1, "target-revision": 2, + "slot-patches": [{"slot": 1, "local": { + "height": {"kind": "auto"}, + "min-height": {"kind": "lines", "value": 2}, + "max-height": {"kind": "subtract", "values": [ + {"kind": "viewport-height"}, {"kind": "viewport-height"} + ]} + }}] + }); + let flex_only = base + .apply_delta(context_test_delta(vec![flex_update.clone()])) + .unwrap() + .document; + let box_only = base + .apply_delta(context_test_delta(vec![box_update.clone()])) + .unwrap() + .document; + let forward = base + .apply_delta(context_test_delta(vec![ + flex_update.clone(), + box_update.clone(), + ])) + .unwrap() + .document; + let reverse = base + .apply_delta(context_test_delta(vec![box_update, flex_update.clone()])) + .unwrap() + .document; + assert_retained_context_oracle(&base, 4); + assert_retained_context_oracle(&flex_only, 5); + assert_retained_context_oracle(&box_only, 3); + assert_retained_context_oracle(&forward, 4); + assert_retained_context_oracle(&reverse, 4); + let removed = forward + .apply_delta(context_test_delta(vec![ + serde_json::json!({"node-id": 1, "expected-revision": 2, "target-revision": 3, + "slot-patches": [{"slot": 0, "local": {"height": {"kind": "auto"}}}]}), + serde_json::json!({"node-id": 2, "expected-revision": 2, "target-revision": 3, + "slot-patches": [{"slot": 1, "local": {"max-height": {"kind": "none"}}}]}), + ])) + .unwrap() + .document; + assert_retained_context_oracle(&removed, 0); + assert_retained_context_oracle(&forward, 4); + + // A later CAS/field failure must discard the first entry's prepared summary. + for invalid in [ + serde_json::json!({"node-id": 2, "expected-revision": 0, "target-revision": 2}), + serde_json::json!({"node-id": 2, "expected-revision": 1, "target-revision": 2, + "slot-patches": [{"slot": 1, "local": {"height": {"kind": "lines", "value": -1}}}]}), + ] { + assert!(base + .apply_delta(context_test_delta(vec![flex_update.clone(), invalid])) + .is_err()); + assert_retained_context_oracle(&base, 4); + assert_eq!(radix_lookup(&base.entries, 1).unwrap().revision, 1); + } + } + + #[test] + fn retained_context_validation_performs_zero_resolver_lookups() { + let nodes = (2..514) + .map(|id| { + identified( + text_box(id, measured_text(vec![vec![cluster("x", 1, None)]]), None), + id as u64, + 1, + ) + }) + .collect(); + let root = identified( + child_box(1, context_test_flex(Size::ViewportHeight, nodes), None), + 1, + 1, + ); + let (retained, _) = RetainedDocument::bootstrap(retained_document(root)).unwrap(); + reset_resolver_lookups(); + retained.validate_context(test_context()).unwrap(); + assert_eq!( + resolver_lookups(), + 0, + "retained context validation traversed the document" + ); + } + + fn source_test_entry( + node_id: u64, + expected: u64, + target: u64, + patches: Vec, + ) -> JsonValue { + serde_json::json!({"node-id": node_id, "expected-revision": expected, + "target-revision": target, "slot-patches": patches}) + } + + fn source_test_content(text: &str) -> JsonValue { + serde_json::json!({"lines": [{"clusters": [{"text": text, "width": text.len(), "cjk": false, "space": false}]}]}) + } + + fn source_test_base() -> Arc { + RetainedDocument::bootstrap(retained_document(identified( + text_box(1, measured_text(vec![vec![cluster("abc", 3, None)]]), None), + 1, + 7, + ))) + .unwrap() + .0 + } + + fn source_test_seeds(changes: &SourceChanges) -> Vec<(u64, u64, u64, u8, Vec)> { + let mut seeds = Vec::new(); + changes.visit_changed_slots(|owner, expected, target, slot, fields| { + seeds.push((owner, expected, target, slot, fields.to_vec())); + }); + seeds + } + + #[test] + fn retained_source_noops_preserve_body_summary_identity_and_advance_cas() { + let base = source_test_base(); + let old = radix_lookup(&base.entries, 1).unwrap(); + for (patches, compared) in [ + (vec![], 0), + (vec![serde_json::json!({"slot": 0, "local": {}})], 0), + ( + vec![serde_json::json!({"slot": 0, "local": {"padding-left": 0}})], + 1, + ), + ( + vec![ + serde_json::json!({"slot": 0, "local": {"content": source_test_content("abc")}}), + ], + 1, + ), + ] { + let applied = base + .apply_delta(context_test_delta(vec![source_test_entry( + 1, 7, 8, patches, + )])) + .unwrap(); + let target = radix_lookup(&applied.document.entries, 1).unwrap(); + assert!(Arc::ptr_eq(&old.node, &target.node)); + for slot in 0..2 { + assert!(Arc::ptr_eq(&old.slot_work[slot], &target.slot_work[slot])); + } + assert_eq!(target.revision, 8); + assert_eq!(old.revision, 7); + assert!(source_test_seeds(&applied.changes).is_empty()); + assert!(applied.changes.applies_to(&base, &applied.document)); + assert_eq!(applied.stats.source_work.fields_compared, compared); + assert_eq!(applied.stats.source_work.local_nodes_copied, 0); + assert!(applied + .document + .apply_delta(context_test_delta(vec![source_test_entry(1, 7, 9, vec![])])) + .is_err()); + } + } + + #[test] + fn retained_source_mixed_content_compares_payload_and_preserves_equal_arc() { + let base = source_test_base(); + let old = radix_lookup(&base.entries, 1).unwrap(); + let applied = base.apply_delta(context_test_delta(vec![source_test_entry(1, 7, 8, vec![ + serde_json::json!({"slot": 0, "local": {"padding-left": 2, "content": source_test_content("abc")}}) + ])])).unwrap(); + let target = radix_lookup(&applied.document.entries, 1).unwrap(); + let LayoutNode::Box { + content: Some(old_content), + .. + } = old.node.as_ref() + else { + unreachable!() + }; + let LayoutNode::Box { + content: Some(target_content), + .. + } = target.node.as_ref() + else { + unreachable!() + }; + assert!(Arc::ptr_eq(old_content, target_content)); + assert!(Arc::ptr_eq(&old.slot_work[0], &target.slot_work[0])); + assert_eq!( + source_test_seeds(&applied.changes), + vec![(1, 7, 8, 0, vec![LocalField::PaddingLeft])] + ); + let work = applied.stats.source_work; + assert_eq!( + ( + work.fields_compared, + work.text_lines_compared, + work.text_clusters_compared, + work.text_bytes_compared, + work.local_nodes_copied + ), + (2, 1, 1, 3, 1) + ); + + let different = base.apply_delta(context_test_delta(vec![source_test_entry(1, 7, 8, vec![ + serde_json::json!({"slot": 0, "local": {"content": source_test_content("axc")}}) + ])])).unwrap(); + assert_eq!( + different.stats.source_work.text_bytes_compared, 2, + "byte comparison must stop at the actual mismatch" + ); + assert_eq!( + source_test_seeds(&different.changes), + vec![(1, 7, 8, 0, vec![LocalField::Content])] + ); + } + + #[test] + fn retained_source_text_payload_compares_every_cluster_field() { + let root = identified(nonuniform_text(1, &[1]), 1, 7); + let mut full = retained_document(root); + full.property_template_count = 1; + let (base, _) = RetainedDocument::bootstrap(full).unwrap(); + let original_content = source_test_content("x"); + for (field, value) in [ + ("text", serde_json::json!("y")), + ("width", serde_json::json!(2)), + ("cjk", serde_json::json!(true)), + ("space", serde_json::json!(true)), + ("pixel-space", serde_json::json!(true)), + ("source-template-id", serde_json::json!(0)), + ] { + let mut content = original_content.clone(); + content["lines"][0]["clusters"][0][field] = value; + let mut delta = context_test_delta(vec![source_test_entry( + 1, + 7, + 8, + vec![serde_json::json!({"slot": 0, "local": {"content": content}})], + )]); + delta.property_template_base_count = 1; + delta.property_template_target_count = 1; + let applied = base.apply_delta(delta).unwrap(); + assert_eq!( + source_test_seeds(&applied.changes), + vec![(1, 7, 8, 0, vec![LocalField::Content])], + "{field}" + ); + } + let mut delta = context_test_delta(vec![source_test_entry( + 1, + 7, + 8, + vec![serde_json::json!({"slot": 0, "local": {"content": original_content}})], + )]); + delta.property_template_base_count = 1; + delta.property_template_target_count = 1; + let equal = base.apply_delta(delta).unwrap(); + assert!(Arc::ptr_eq( + &radix_lookup(&base.entries, 1).unwrap().node, + &radix_lookup(&equal.document.entries, 1).unwrap().node + )); + assert!(equal.changes.owners.is_empty()); + } + + #[test] + fn retained_source_slot_seeds_are_exact_in_both_orders_and_slot_one_only() { + let leaf = identified( + text_box(2, measured_text(vec![vec![cluster("x", 1, None)]]), None), + 2, + 1, + ); + let root = identified( + child_box(1, context_test_flex(Size::Auto, vec![leaf]), None), + 1, + 7, + ); + let (base, _) = RetainedDocument::bootstrap(retained_document(root)).unwrap(); + let old = radix_lookup(&base.entries, 1).unwrap(); + let LayoutNode::Box { + child: Some(old_child), + .. + } = old.node.as_ref() + else { + unreachable!() + }; + let LayoutNode::Flex { + items: old_items, .. + } = old_child.as_ref() + else { + unreachable!() + }; + let slot_zero = serde_json::json!({"slot": 0, "local": {"padding-left": 1, "height": {"kind": "auto"}}}); + let slot_one = serde_json::json!({"slot": 1, "local": {"height": {"kind": "lines", "value": 1}, "width": {"kind": "viewport-height"}}}); + for patches in [ + vec![slot_zero.clone()], + vec![slot_one.clone()], + vec![slot_zero.clone(), slot_one.clone()], + vec![slot_one.clone(), slot_zero.clone()], + ] { + let expected: Vec<_> = patches + .iter() + .map(|patch| { + let slot = patch["slot"].as_u64().unwrap() as u8; + ( + 1, + 7, + 8, + slot, + vec![if slot == 0 { + LocalField::PaddingLeft + } else { + LocalField::Height + }], + ) + }) + .collect(); + let applied = base + .apply_delta(context_test_delta(vec![source_test_entry( + 1, 7, 8, patches, + )])) + .unwrap(); + assert_eq!(source_test_seeds(&applied.changes), expected); + let target = radix_lookup(&applied.document.entries, 1).unwrap(); + let LayoutNode::Box { + child: Some(target_child), + .. + } = target.node.as_ref() + else { + unreachable!() + }; + let LayoutNode::Flex { + items: target_items, + .. + } = target_child.as_ref() + else { + unreachable!() + }; + assert!(Arc::ptr_eq(old_items, target_items)); + if expected.iter().all(|seed| seed.3 == 0) { + assert!(Arc::ptr_eq(old_child, target_child)); + } + if expected.iter().all(|seed| seed.3 == 1) { + assert!(Arc::ptr_eq(&old.slot_work[0], &target.slot_work[0])); + } + assert!(Arc::ptr_eq( + &radix_lookup(&base.entries, 2).unwrap().node, + &radix_lookup(&applied.document.entries, 2).unwrap().node + )); + } + } + + #[test] + fn retained_source_size_expression_identity_is_independent_of_resolved_geometry() { + let mut root = identified( + text_box(1, measured_text(vec![vec![cluster("x", 1, None)]]), None), + 1, + 7, + ); + let LayoutNode::Box { height, .. } = &mut root else { + unreachable!() + }; + *height = Size::Lines { value: 10 }; + let (base, _) = RetainedDocument::bootstrap(retained_document(root)).unwrap(); + let expression = serde_json::json!({"kind": "add", "values": [{"kind": "lines", "value": 4}, {"kind": "lines", "value": 6}]}); + let applied = base + .apply_delta(context_test_delta(vec![source_test_entry( + 1, + 7, + 8, + vec![serde_json::json!({"slot": 0, "local": {"height": expression}})], + )])) + .unwrap(); + assert_eq!( + source_test_seeds(&applied.changes), + vec![(1, 7, 8, 0, vec![LocalField::Height])] + ); + assert_eq!( + base.layout_tape(test_context(), None).unwrap(), + applied.document.layout_tape(test_context(), None).unwrap() + ); + assert_eq!(applied.stats.source_work.size_nodes_compared, 1); + let equal = applied + .document + .apply_delta(context_test_delta(vec![source_test_entry( + 1, + 8, + 9, + vec![serde_json::json!({"slot": 0, "local": {"height": expression}})], + )])) + .unwrap(); + assert_eq!(equal.stats.source_work.size_nodes_compared, 3); + assert_eq!(equal.stats.source_work.local_nodes_copied, 0); + assert!(equal.changes.owners.is_empty()); + } + + #[test] + fn retained_source_registry_only_updates_and_forks_bind_exact_document_identity() { + let base = source_test_base(); + let mut delta = context_test_delta(vec![]); + delta.styles_append = vec![serde_json::from_value( + serde_json::json!({"mode": "add", "face": {"foreground": "red"}}), + ) + .unwrap()]; + delta.property_template_target_count = 2; + let registry = base.apply_delta(delta).unwrap(); + assert_eq!(registry.changes.registry_ranges(), (0..1, 0..2)); + assert!(registry.changes.owners.is_empty()); + assert_eq!(registry.stats.entries_parsed, 0); + assert!(Arc::ptr_eq(&base.entries, ®istry.document.entries)); + assert_eq!(base.style_count, 0); + + let entry = source_test_entry( + 1, + 7, + 8, + vec![serde_json::json!({"slot": 0, "local": {"padding-left": 2}})], + ); + let left = base + .apply_delta(context_test_delta(vec![entry.clone()])) + .unwrap(); + let right = base.apply_delta(context_test_delta(vec![entry])).unwrap(); + assert!(left.changes.applies_to(&base, &left.document)); + assert!(!left.changes.applies_to(&base, &right.document)); + assert!(!left.changes.applies_to(&right.document, &left.document)); + assert_eq!( + source_test_seeds(&left.changes), + source_test_seeds(&right.changes) + ); + } + + #[test] + fn retained_source_transient_changes_do_not_retain_document_history() { + let base = source_test_base(); + let weak_base = Arc::downgrade(&base); + let applied = base + .apply_delta(context_test_delta(vec![source_test_entry(1, 7, 8, vec![])])) + .unwrap(); + let AppliedDelta { + document: target, + changes, + .. + } = applied; + drop(base); + assert!( + weak_base.upgrade().is_some(), + "the transient descriptor owns its exact base" + ); + drop(changes); + assert!( + weak_base.upgrade().is_none(), + "a target document must not retain its historical base" + ); + let weak_target = Arc::downgrade(&target); + let next = target + .apply_delta(context_test_delta(vec![source_test_entry(1, 8, 9, vec![])])) + .unwrap(); + drop(target); + let AppliedDelta { + document: next, + changes, + .. + } = next; + drop(changes); + assert!(weak_target.upgrade().is_none()); + assert_eq!(radix_lookup(&next.entries, 1).unwrap().revision, 9); + } + + #[test] + fn retained_source_local_comparison_work_does_not_grow_with_owner_count() { + for count in [32, 128, 512] { + let children = (2..count + 2) + .map(|id| { + identified( + text_box(id, measured_text(vec![vec![cluster("abc", 3, None)]]), None), + id as u64, + 7, + ) + }) + .collect(); + let root = identified( + child_box(1, context_test_flex(Size::Auto, children), None), + 1, + 7, + ); + let (base, _) = RetainedDocument::bootstrap(retained_document(root)).unwrap(); + let applied = base.apply_delta(context_test_delta(vec![source_test_entry(2, 7, 8, vec![serde_json::json!({"slot": 0, "local": {"content": source_test_content("abc"), "padding-left": 2}})])])).unwrap(); + let work = applied.stats.source_work; + assert_eq!( + ( + work.fields_compared, + work.text_lines_compared, + work.text_clusters_compared, + work.text_bytes_compared, + work.local_nodes_copied + ), + (2, 1, 1, 3, 1) + ); + assert_eq!(applied.stats.trie_path_nodes_copied, 17); + assert!(Arc::ptr_eq(&base.topology, &applied.document.topology)); + } + } + + #[test] + fn retained_source_rejection_contract_preserves_parent_and_render() { + let root = identified( + text_box(1, measured_text(vec![vec![cluster("x", 1, None)]]), None), + 1, + 7, + ); + let (base, _) = RetainedDocument::bootstrap(retained_document(root)).unwrap(); + let before = base.layout_tape(test_context(), None).unwrap(); + let entry = |id, expected, target, patches: Vec| { + serde_json::json!({ + "node-id": id, "expected-revision": expected, "target-revision": target, + "slot-patches": patches + }) + }; + let same = serde_json::json!({"slot": 0, "local": {"padding-left": 0}}); + for (entries, message) in [ + ( + vec![entry(1, 6, 8, vec![same.clone()])], + "node revision mismatch", + ), + ( + vec![entry(1, 7, 7, vec![same.clone()])], + "node revision mismatch", + ), + (vec![entry(99, 7, 8, vec![same.clone()])], "unknown node id"), + ( + vec![entry(1, 7, 8, vec![same.clone()]), entry(1, 7, 9, vec![])], + "duplicate node id", + ), + ( + vec![entry(1, 7, 8, vec![same.clone(), same.clone()])], + "duplicate slot", + ), + ( + vec![entry( + 1, + 7, + 8, + vec![ + serde_json::json!({"slot": 0, "local": {"padding-left": 0, "unknown-field": 0}}), + ], + )], + "Unsupported native retained box field", + ), + ( + vec![entry( + 1, + 7, + 8, + vec![ + serde_json::json!({"slot": 0, "local": {"padding-left": 0, "height": {"kind": "lines", "value": -1}}}), + ], + )], + "cannot be negative", + ), + ( + vec![entry( + 1, + 7, + 8, + vec![ + serde_json::json!({"slot": 0, "local": {"padding-left": 0, "content": null}}), + ], + )], + "exactly one text or child", + ), + ( + vec![entry(1, 7, 8, vec![same.clone()]), entry(99, 7, 8, vec![])], + "unknown node id", + ), + ] { + let error = base.apply_delta(context_test_delta(entries)).unwrap_err(); + assert!(error.contains(message), "{error}"); + assert_eq!(radix_lookup(&base.entries, 1).unwrap().revision, 7); + assert_eq!(base.layout_tape(test_context(), None).unwrap(), before); + } + } + + #[test] + fn retained_source_equal_and_mixed_fields_preserve_render_contract() { + let root = identified( + text_box(1, measured_text(vec![vec![cluster("x", 1, None)]]), None), + 1, + 7, + ); + let (base, _) = RetainedDocument::bootstrap(retained_document(root.clone())).unwrap(); + let content = serde_json::json!({"lines": [{"clusters": [{"text": "x", "width": 1, "cjk": false, "space": false}]}]}); + for local in [ + serde_json::json!({}), + serde_json::json!({"padding-left": 0, "content": content.clone()}), + serde_json::json!({"padding-left": 2, "content": content}), + ] { + let target = base + .apply_delta(context_test_delta(vec![serde_json::json!({ + "node-id": 1, "expected-revision": 7, "target-revision": 8, + "slot-patches": [{"slot": 0, "local": local}] + })])) + .unwrap() + .document; + let mut expected = root.clone(); + if let LayoutNode::Box { padding_left, .. } = &mut expected { + *padding_left = local + .get("padding-left") + .and_then(JsonValue::as_i64) + .unwrap_or(0); + } + assert_eq!( + target.layout_tape(test_context(), None).unwrap(), + retained_document(expected) + .layout_tape(test_context(), None) + .unwrap() + ); + assert_eq!(radix_lookup(&target.entries, 1).unwrap().revision, 8); + } + assert_eq!(radix_lookup(&base.entries, 1).unwrap().revision, 7); + } + #[test] fn retained_scalar_patch_path_copies_only_radix_and_shares_wide_edges() { let children = Arc::new( @@ -8878,7 +11025,10 @@ mod tests { }] })) .unwrap(); - let (target, validated, copied) = base.apply_delta(delta).unwrap(); + let applied = base.apply_delta(delta).unwrap(); + let target = applied.document; + let validated = applied.stats.entries_parsed; + let copied = applied.stats.trie_path_nodes_copied; assert_eq!(validated, 1); assert_eq!(copied, 17); let target_root = radix_lookup(&target.entries, 1).unwrap(); @@ -8920,8 +11070,10 @@ mod tests { })) .unwrap() }; - let (left, _, copied) = base.apply_delta(revision_only(3, 20)).unwrap(); - let (right, _, _) = base.apply_delta(revision_only(3, 30)).unwrap(); + let applied = base.apply_delta(revision_only(3, 20)).unwrap(); + let left = applied.document; + let copied = applied.stats.trie_path_nodes_copied; + let right = base.apply_delta(revision_only(3, 30)).unwrap().document; assert_eq!(copied, 17); assert!(Arc::ptr_eq( &radix_lookup(&base.entries, 1).unwrap().node, @@ -9029,11 +11181,11 @@ mod tests { let forward = base .apply_delta(delta(vec![entry(2, 10), entry(3, high)])) .unwrap() - .0; + .document; let reverse = base .apply_delta(delta(vec![entry(3, high), entry(2, 10)])) .unwrap() - .0; + .document; assert_eq!(forward.work_units, MAX_LAYOUT_WORK_UNITS); assert_eq!(reverse.work_units, MAX_LAYOUT_WORK_UNITS); } diff --git a/native/src/lib.rs b/native/src/lib.rs index f32f597..0d67247 100644 --- a/native/src/lib.rs +++ b/native/src/lib.rs @@ -1,8 +1,10 @@ mod layout; +pub mod sequence; use layout::{ encode_error_tape, DocumentDelta, LayoutContext, LayoutDocument, LayoutTape, RetainedDocument, - TapeIdentity, TapeOutputOptions, MAX_LAYOUT_DIMENSION, MIN_TAPE_BYTES, + RetainedFrame, SourceChanges, TapeIdentity, TapeOutputOptions, MAX_LAYOUT_DIMENSION, + MIN_TAPE_BYTES, }; use serde::{Deserialize, Serialize}; use std::collections::{HashMap, HashSet, VecDeque}; @@ -370,6 +372,8 @@ struct ControlFrame { #[serde(default = "default_true")] root_metadata: bool, #[serde(default)] + root_scroll_producer: bool, + #[serde(default)] delay_ms: u64, } @@ -378,6 +382,7 @@ enum JobPayload { Echo(Vec), Layout { document: LayoutSource, + source_changes: Option, context: LayoutContext, root_width: i64, root_width_override: bool, @@ -388,6 +393,7 @@ enum JobPayload { context_hash: i64, complete: bool, root_metadata: bool, + root_scroll_producer: bool, document_base_revision: u64, document_target_revision: u64, validation_resolver_lookups: u64, @@ -425,6 +431,33 @@ impl LayoutSource { Self::Retained(document) => document.styles(), } } + + fn render_target( + &self, + context: LayoutContext, + root_width: Option, + previous: Option<&RetainedFrame>, + changes: Option<&SourceChanges>, + root_scroll_producer: bool, + ) -> Result<(LayoutTape, Option>), String> { + match self { + Self::Full(document) => { + if changes.is_some() { + return Err("Native source changes require a retained document".to_owned()); + } + Ok((document.layout_tape(context, root_width)?, None)) + } + Self::Retained(document) => { + let frame = if root_scroll_producer { + document + .render_frame_with_root_scroll(previous, changes, context, root_width)? + } else { + document.render_frame(previous, changes, context, root_width)? + }; + Ok((frame.materialize_tape(), Some(frame))) + } + } + } } #[derive(Clone, Debug)] @@ -443,6 +476,7 @@ struct ConfirmedBaseline { document: LayoutSource, document_revision: u64, tape: LayoutTape, + retained_frame: Option>, styles: Vec, } @@ -452,6 +486,7 @@ struct PendingBaseline { document: LayoutSource, document_revision: u64, tape: LayoutTape, + retained_frame: Option>, styles: Vec, } @@ -468,6 +503,9 @@ struct RenderedJob { base_renders: u64, target_renders: u64, resolver_lookups: u64, + atom_plan_work: layout::AtomPlanWork, + line_plan_work: layout::LinePlanWork, + eval_work: layout::EvalWork, } #[derive(Default)] @@ -481,6 +519,7 @@ struct DocumentInputStats { delta_entries_parsed: u64, delta_entries_validated: u64, trie_path_nodes_copied: u64, + source_change_work: layout::SourceChangeWork, } #[derive(Debug)] @@ -520,6 +559,10 @@ struct RuntimeState { document_delta_entries_validated: u64, document_trie_path_nodes_copied: u64, document_resolver_lookups: u64, + source_change_work: layout::SourceChangeWork, + atom_plan_work: layout::AtomPlanWork, + line_plan_work: layout::LinePlanWork, + eval_work: layout::EvalWork, confirmed_baseline: Option>, } @@ -544,6 +587,23 @@ struct Shared { result_available: Condvar, } +impl Shared { + fn request_stop(&self) { + // Both worker condition variables inspect `alive` under this mutex. + // Publish shutdown under the same lock so notification cannot fall + // between a worker's predicate check and its atomic unlock-and-wait. + { + let _state = self + .state + .lock() + .unwrap_or_else(|poison| poison.into_inner()); + self.alive.store(false, Ordering::Release); + } + self.job_available.notify_all(); + self.result_available.notify_all(); + } +} + #[derive(Debug)] struct Session { shared: Arc, @@ -580,6 +640,10 @@ struct SessionStats { document_delta_entries_validated: u64, document_trie_path_nodes_copied: u64, document_resolver_lookups: u64, + source_change_work: layout::SourceChangeWork, + atom_plan_work: layout::AtomPlanWork, + line_plan_work: layout::LinePlanWork, + eval_work: layout::EvalWork, pending_baselines: usize, confirmed_baseline: bool, confirmed_baseline_bytes: usize, @@ -652,6 +716,10 @@ impl Session { document_delta_entries_validated: 0, document_trie_path_nodes_copied: 0, document_resolver_lookups: 0, + source_change_work: layout::SourceChangeWork::default(), + atom_plan_work: layout::AtomPlanWork::default(), + line_plan_work: layout::LinePlanWork::default(), + eval_work: layout::EvalWork::default(), confirmed_baseline, }), readiness_channel: Mutex::new(None), @@ -667,8 +735,7 @@ impl Session { match spawn { Ok(handle) => handles.push(handle), Err(error) => { - shared.alive.store(false, Ordering::Release); - shared.job_available.notify_all(); + shared.request_stop(); for handle in handles { let _ = handle.join(); } @@ -778,6 +845,7 @@ impl Session { frame, document_base_revision, document_target_revision, + None, )? } else { let payload = frame @@ -926,6 +994,7 @@ impl Session { .unwrap_or_else(|poison| poison.into_inner()) .confirmed_baseline .clone(); + let mut source_changes = None; let (document, input_stats) = match (batch.document, batch.document_delta) { (Some(_), Some(_)) => { return Err( @@ -994,14 +1063,39 @@ impl Session { .to_owned(), ); }; - let (document, parsed, copied) = document.apply_delta(delta)?; + let applied = document.apply_delta(delta)?; + if !applied.changes.applies_to(document, &applied.document) { + return Err( + "Native retained source changes document identity mismatch".to_owned() + ); + } + // Account for exact input work, then carry these facts only for + // this render job. Documents and baselines never store the + // descriptor's references to both versions. + let mut source_change_work = applied.stats.source_work; + let mut previous_owner = None; + applied + .changes + .visit_changed_slots(|owner, _, _, _, fields| { + if previous_owner != Some(owner) { + source_change_work.owners_changed += 1; + previous_owner = Some(owner); + } + source_change_work.slots_changed += 1; + source_change_work.fields_changed += fields.len() as u64; + }); + let (styles, property_templates) = applied.changes.registry_ranges(); + source_change_work.styles_appended = styles.len() as u64; + source_change_work.property_templates_added = property_templates.len() as u64; + source_changes = Some(applied.changes); ( - LayoutSource::Retained(document), + LayoutSource::Retained(applied.document), DocumentInputStats { delta_input_bytes: payload.len() as u64, - delta_entries_parsed: parsed, - delta_entries_validated: parsed, - trie_path_nodes_copied: copied, + delta_entries_parsed: applied.stats.entries_parsed, + delta_entries_validated: applied.stats.entries_parsed, + trie_path_nodes_copied: applied.stats.trie_path_nodes_copied, + source_change_work, ..DocumentInputStats::default() }, ) @@ -1051,6 +1145,7 @@ impl Session { frame, document_base_revision, document_target_revision, + source_changes, )?; let output = render_layout_payload( prepared.payload, @@ -1077,6 +1172,9 @@ impl Session { state.base_renders += output.base_renders; state.target_renders += output.target_renders; state.document_resolver_lookups += output.resolver_lookups; + state.atom_plan_work.accumulate(output.atom_plan_work); + state.line_plan_work.accumulate(output.line_plan_work); + state.eval_work.accumulate(output.eval_work); state.document_parses += input_stats.parses; state.document_validations += input_stats.validations; state.document_reuses += input_stats.reuses; @@ -1086,6 +1184,9 @@ impl Session { state.document_delta_entries_parsed += input_stats.delta_entries_parsed; state.document_delta_entries_validated += input_stats.delta_entries_validated; state.document_trie_path_nodes_copied += input_stats.trie_path_nodes_copied; + state + .source_change_work + .accumulate(input_stats.source_change_work); state.pending_baselines.clear(); if let Some(pending) = output.pending { state @@ -1118,6 +1219,7 @@ impl Session { document: pending.document, document_revision: pending.document_revision, tape: pending.tape, + retained_frame: pending.retained_frame, styles: pending.styles, })); Ok(true) @@ -1195,6 +1297,8 @@ impl Session { .lock() .unwrap_or_else(|poison| poison.into_inner()); let pending_baselines = state.pending_baselines.len(); + // Compatibility field: the shallow tape container only, not retained + // plan allocations, shared heap memory or a cache memory bound. let confirmed_baseline_bytes = state .confirmed_baseline .as_ref() @@ -1225,6 +1329,10 @@ impl Session { document_delta_entries_validated: state.document_delta_entries_validated, document_trie_path_nodes_copied: state.document_trie_path_nodes_copied, document_resolver_lookups: state.document_resolver_lookups, + source_change_work: state.source_change_work, + atom_plan_work: state.atom_plan_work, + line_plan_work: state.line_plan_work, + eval_work: state.eval_work, pending_baselines, confirmed_baseline: state.confirmed_baseline.is_some(), confirmed_baseline_bytes, @@ -1239,9 +1347,7 @@ impl Session { fn stop(&self, join: bool) { self.detach_readiness_channel(); - self.shared.alive.store(false, Ordering::Release); - self.shared.job_available.notify_all(); - self.shared.result_available.notify_all(); + self.shared.request_stop(); if let Some(handles) = self .workers .lock() @@ -1330,8 +1436,12 @@ fn prepare_layout_job( frame: ControlFrame, document_base_revision: u64, document_target_revision: u64, + source_changes: Option, ) -> Result { layout::reset_resolver_lookups(); + if frame.root_scroll_producer && !frame.complete { + return Err("Native root scroll producer requires complete properties".to_owned()); + } if frame.payload.is_some() { return Err(format!( "Native layout frame {} cannot contain an echo payload", @@ -1403,6 +1513,7 @@ fn prepare_layout_job( delay_ms: frame.delay_ms, payload: JobPayload::Layout { document: document.clone(), + source_changes, context, root_width, root_width_override: frame.root_width_override, @@ -1413,6 +1524,7 @@ fn prepare_layout_job( context_hash: frame.context_hash, complete: frame.complete, root_metadata: frame.root_metadata, + root_scroll_producer: frame.root_scroll_producer, document_base_revision, document_target_revision, validation_resolver_lookups: layout::resolver_lookups(), @@ -1437,9 +1549,13 @@ fn render_layout_payload( base_renders: 0, target_renders: 0, resolver_lookups: 0, + atom_plan_work: layout::AtomPlanWork::default(), + line_plan_work: layout::LinePlanWork::default(), + eval_work: layout::EvalWork::default(), }, JobPayload::Layout { document, + source_changes, context, root_width, root_width_override, @@ -1450,6 +1566,7 @@ fn render_layout_payload( context_hash, complete, root_metadata, + root_scroll_producer, document_base_revision, document_target_revision, validation_resolver_lookups, @@ -1487,6 +1604,7 @@ fn render_layout_payload( Vec, LayoutTape, Vec, + Option>, bool, u64, u64, @@ -1494,8 +1612,22 @@ fn render_layout_payload( String, >; layout::reset_resolver_lookups(); + layout::reset_atom_plan_work(); + layout::reset_line_plan_work(); + layout::reset_eval_work(); let result = catch_unwind(AssertUnwindSafe(|| -> LayoutRenderOutcome { let target_styles = document.styles()?; + let render_target = || { + document.render_target( + context, + root_width_override.then_some(root_width), + confirmed_baseline + .as_ref() + .and_then(|baseline| baseline.retained_frame.as_deref()), + source_changes.as_ref(), + root_scroll_producer, + ) + }; if let Some(base_context) = base_context { let base_identity = BaselineIdentity { context: base_context, @@ -1519,16 +1651,18 @@ fn render_layout_payload( }) .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( + let (target, retained_frame) = render_target()?; + let bytes = layout::encode_layout_tape_with_scroll( target.clone(), &target_styles, identity, output.root_metadata, output.max_bytes, + retained_frame + .as_ref() + .and_then(|frame| frame.root_scroll()), )?; - return Ok((bytes, target, target_styles, false, 0, 1)); + return Ok((bytes, target, target_styles, retained_frame, false, 0, 1)); } let (old, baseline_hit, base_renders) = if let Some(baseline) = base_hit { (baseline.tape.clone(), true, 0) @@ -1542,49 +1676,84 @@ fn render_layout_payload( 1, ) }; - let target = - document.layout_tape(context, root_width_override.then_some(root_width))?; - let bytes = layout::encode_layout_patch_tape( + let (target, retained_frame) = render_target()?; + let bytes = layout::encode_layout_patch_tape_with_scroll( old, target.clone(), &target_styles, identity, output.root_metadata, output.max_bytes, + retained_frame + .as_ref() + .and_then(|frame| frame.root_scroll()) + .map(|scroll| { + ( + scroll, + confirmed_baseline + .as_ref() + .filter(|_| baseline_hit) + .and_then(|base| base.retained_frame.as_ref()) + .and_then(|frame| frame.root_scroll()), + ) + }), )?; - Ok((bytes, target, target_styles, baseline_hit, base_renders, 1)) + Ok(( + bytes, + target, + target_styles, + retained_frame, + baseline_hit, + base_renders, + 1, + )) } else { - let target = - document.layout_tape(context, root_width_override.then_some(root_width))?; - let bytes = layout::encode_layout_tape( + let (target, retained_frame) = render_target()?; + let bytes = layout::encode_layout_tape_with_scroll( target.clone(), &target_styles, identity, output.root_metadata, output.max_bytes, + retained_frame + .as_ref() + .and_then(|frame| frame.root_scroll()), )?; - Ok((bytes, target, target_styles, false, 0, 1)) + Ok((bytes, target, target_styles, retained_frame, false, 0, 1)) } })); let resolver_lookups = validation_resolver_lookups.saturating_add(layout::resolver_lookups()); + let atom_plan_work = layout::atom_plan_work(); + let line_plan_work = layout::line_plan_work(); + let eval_work = layout::eval_work(); match result { - Ok(Ok((bytes, tape, styles, baseline_hit, base_renders, target_renders))) => { - RenderedJob { - bytes, - pending: Some(PendingBaseline { - confirmed_identity: pending_identity, - document: document.clone(), - document_revision: document_target_revision, - tape, - styles, - }), - baseline_hit, - base_renders, - target_renders, - resolver_lookups, - } - } + Ok(Ok(( + bytes, + tape, + styles, + retained_frame, + baseline_hit, + base_renders, + target_renders, + ))) => RenderedJob { + bytes, + pending: Some(PendingBaseline { + confirmed_identity: pending_identity, + document: document.clone(), + document_revision: document_target_revision, + tape, + retained_frame, + styles, + }), + baseline_hit, + base_renders, + target_renders, + resolver_lookups, + atom_plan_work, + line_plan_work, + eval_work, + }, Ok(Err(error)) => RenderedJob { bytes: encode_error_tape(identity, &error, max_result_bytes), pending: None, @@ -1592,6 +1761,9 @@ fn render_layout_payload( base_renders: 0, target_renders: 0, resolver_lookups, + atom_plan_work, + line_plan_work, + eval_work, }, Err(_) => RenderedJob { bytes: encode_error_tape(identity, "native layout panicked", max_result_bytes), @@ -1600,6 +1772,9 @@ fn render_layout_payload( base_renders: 0, target_renders: 0, resolver_lookups, + atom_plan_work, + line_plan_work, + eval_work, }, } } @@ -1635,7 +1810,7 @@ fn render_proof(payload: &[u8]) -> Result, String> { return Err("Native proof render cannot contain delay-ms".to_owned()); } let document = LayoutSource::Full(Arc::new(document)); - let prepared = prepare_layout_job(&document, frame, 0, 1)?; + let prepared = prepare_layout_job(&document, frame, 0, 1, None)?; let output = render_layout_payload( prepared.payload, SYNC_RENDER_SESSION_ID, @@ -1749,6 +1924,9 @@ fn worker_loop(shared: Arc) { state.base_renders += output.base_renders; state.target_renders += output.target_renders; state.document_resolver_lookups += output.resolver_lookups; + state.atom_plan_work.accumulate(output.atom_plan_work); + state.line_plan_work.accumulate(output.line_plan_work); + state.eval_work.accumulate(output.eval_work); state.results.insert( (job.generation, job.key), ResultEntry { @@ -2344,6 +2522,69 @@ mod tests { session.stop(true); } + #[test] + fn stopping_serializes_with_a_worker_holding_the_wait_predicate_lock() { + let session = Arc::new(Session::new(1, 1, 1, 4).unwrap()); + let guard = session.shared.state.lock().unwrap(); + let (started_tx, started_rx) = std::sync::mpsc::channel(); + let (completed_tx, completed_rx) = std::sync::mpsc::channel(); + let stopping = Arc::clone(&session); + let stopper = thread::spawn(move || { + started_tx.send(()).unwrap(); + stopping.stop(false); + completed_tx.send(()).unwrap(); + }); + started_rx.recv_timeout(Duration::from_secs(2)).unwrap(); + // A worker holds this mutex between checking the predicate and + // entering Condvar::wait. Shutdown must not publish/notify in that gap. + let completed_while_locked = completed_rx + .recv_timeout(Duration::from_millis(100)) + .is_ok(); + drop(guard); + if !completed_while_locked { + completed_rx.recv_timeout(Duration::from_secs(2)).unwrap(); + } + stopper.join().unwrap(); + assert!( + !completed_while_locked, + "shutdown raced ahead of the worker wait predicate lock" + ); + } + + #[test] + fn stopping_joins_a_worker_blocked_on_result_capacity() { + let session = Arc::new(Session::new(1, 2, 1, 8).unwrap()); + session + .submit( + 1, + &batch(r#"[{"key":1,"payload":"a"},{"key":2,"payload":"b"}]"#), + ) + .unwrap(); + wait_until(|| { + let stats = session.stats(); + stats.completed_jobs == 1 && stats.queued_jobs == 0 + }); + let stopping = Arc::clone(&session); + let (completed_tx, completed_rx) = std::sync::mpsc::channel(); + let stopper = thread::spawn(move || { + stopping.stop(true); + completed_tx.send(()).unwrap(); + }); + let completed_without_rescue = completed_rx.recv_timeout(Duration::from_secs(2)).is_ok(); + if !completed_without_rescue { + // Preserve the failure, but release a stranded waiter before the + // assertion so a broken notification does not hang the test suite. + session.shared.job_available.notify_all(); + session.shared.result_available.notify_all(); + completed_rx.recv_timeout(Duration::from_secs(2)).unwrap(); + } + stopper.join().unwrap(); + assert!( + completed_without_rescue, + "shutdown left a capacity waiter asleep" + ); + } + #[test] fn malformed_and_over_capacity_batches_are_rejected() { let session = Session::new(1, 1, 1, 4).unwrap(); @@ -2433,6 +2674,149 @@ mod tests { session.stop(true); } + #[test] + fn retained_layout_reuse_does_not_bypass_wire_base_proof() { + let session = Session::new(1, 4, 4, 64 * 1024).unwrap(); + let first = identified_proof_layout_payload( + r#"[{"key":1,"viewport-width":80,"viewport-height":10,"root-width":80,"runtime-revision":0,"context-hash":77}]"#, + ); + session.render_sync(1, &first).unwrap(); + assert!(session.confirm(1, 1, 1).unwrap()); + let before = session.stats(); + // The source and layout request still match, but receiver context + // identity does not: a layout hit must still publish a full tape. + let second = retained_layout_payload( + 1, + r#"[{"key":2,"viewport-width":80,"viewport-height":10,"root-width":80,"patch":true,"base-viewport-width":80,"base-viewport-height":10,"base-root-width":80,"runtime-revision":1,"context-hash":78}]"#, + ); + let tape = session.render_sync(2, &second).unwrap(); + let after = session.stats(); + assert_ne!(u16::from_le_bytes(tape[6..8].try_into().unwrap()) & 1, 0); + assert!(!tape_patch_p(&tape)); + assert_eq!(after.baseline_hits, before.baseline_hits); + assert_eq!(after.base_renders, before.base_renders); + assert_eq!(after.eval_work.hits - before.eval_work.hits, 1); + assert_eq!(after.eval_work.body_runs, before.eval_work.body_runs); + assert!(session.confirm(2, 2, 2).unwrap()); + session.stop(true); + } + + #[test] + fn retained_frame_failures_preserve_the_confirmed_fork_and_allow_retry() { + // Failed encoding or confirmation must not promote source/output or + // retain the rejected candidate's evaluation tree. + let parent = Session::new(1, 4, 4, 2048).unwrap(); + let first = identified_proof_layout_payload( + r#"[{"key":1,"viewport-width":80,"viewport-height":10,"root-width":80,"runtime-revision":0,"context-hash":77}]"#, + ); + let first_tape = parent.render_sync(1, &first).unwrap(); + assert_ne!( + u16::from_le_bytes(first_tape[6..8].try_into().unwrap()) & 1, + 0 + ); + assert!(parent.confirm(1, 1, 1).unwrap()); + let child = parent.fork_confirmed().unwrap(); + let original = parent + .shared + .state + .lock() + .unwrap() + .confirmed_baseline + .clone() + .unwrap(); + let original_frame = original.retained_frame.as_ref().unwrap(); + let delta = |key, text: &str| { + serde_json::to_vec(&serde_json::json!({ + "version": 1, "document-base-revision": 1, "document-target-revision": 2, + "document-delta": { + "style-base-count": 0, "styles-append": [], + "property-template-base-count": 0, "property-template-target-count": 0, + "entries": [{"node-id": 1, "expected-revision": 7, "target-revision": 8, + "slot-patches": [{"slot": 0, "local": { + "content": {"lines": [{"clusters": [{"text": text, "width": 8, + "cjk": false, "space": false}]}]} + }}]}] + }, + "frames": [{"key": key, "viewport-width": 80, "viewport-height": 10, + "root-width": 80, "patch": true, "base-viewport-width": 80, + "base-viewport-height": 10, "base-root-width": 80, + "runtime-revision": 1, "context-hash": 77}] + })) + .unwrap() + }; + let oversized = child.render_sync(1, &delta(2, &"x".repeat(4096))).unwrap(); + assert_eq!( + u16::from_le_bytes(oversized[6..8].try_into().unwrap()) & 1, + 0 + ); + assert_eq!(child.stats().pending_baselines, 0); + assert!(!child.confirm(1, 2, 2).unwrap()); + let before = child + .shared + .state + .lock() + .unwrap() + .confirmed_baseline + .clone() + .unwrap(); + assert!(Arc::ptr_eq(&original, &before)); + + let retry = child.render_sync(2, &delta(3, "y")).unwrap(); + assert_ne!(u16::from_le_bytes(retry[6..8].try_into().unwrap()) & 1, 0); + assert_eq!(child.stats().pending_baselines, 1); + let rejected_frame = { + let state = child.shared.state.lock().unwrap(); + let candidate = state.pending_baselines.get(&(2, 3)).unwrap(); + let frame = candidate.retained_frame.as_ref().unwrap(); + assert!(!Arc::ptr_eq(original_frame, frame)); + Arc::downgrade(frame) + }; + assert!(child.confirm(2, 3, 99).is_err()); + assert_eq!(child.stats().pending_baselines, 0); + assert!(rejected_frame.upgrade().is_none()); + let after = child + .shared + .state + .lock() + .unwrap() + .confirmed_baseline + .clone() + .unwrap(); + assert!(Arc::ptr_eq(&original, &after)); + + let retry = child.render_sync(3, &delta(4, "z")).unwrap(); + assert_ne!(u16::from_le_bytes(retry[6..8].try_into().unwrap()) & 1, 0); + assert!(child.confirm(3, 4, 2).unwrap()); + let confirmed = child + .shared + .state + .lock() + .unwrap() + .confirmed_baseline + .clone() + .unwrap(); + assert!(!Arc::ptr_eq(&original, &confirmed)); + assert!(!Arc::ptr_eq( + original_frame, + confirmed.retained_frame.as_ref().unwrap() + )); + assert_eq!(confirmed.document_revision, 2); + assert_eq!(original.document_revision, 1); + assert!(Arc::ptr_eq( + &original, + parent + .shared + .state + .lock() + .unwrap() + .confirmed_baseline + .as_ref() + .unwrap() + )); + parent.stop(true); + child.stop(true); + } + #[test] fn confirmed_fork_shares_only_immutable_baseline() { let parent = Session::new(1, 4, 4, 64 * 1024).unwrap(); @@ -2573,9 +2957,17 @@ mod tests { assert_eq!(stats.document_delta_entries_validated, 1); assert_eq!(stats.document_trie_path_nodes_copied, 17); assert_eq!(stats.document_delta_input_bytes, delta.len() as u64); - assert!( - stats.document_resolver_lookups >= 3, - "target/base validation and target render lookups must all remain visible" + assert_eq!(stats.source_change_work.fields_compared, 1); + assert_eq!(stats.source_change_work.text_lines_compared, 1); + assert_eq!(stats.source_change_work.text_clusters_compared, 1); + assert_eq!(stats.source_change_work.text_bytes_compared, 1); + assert_eq!(stats.source_change_work.local_nodes_copied, 1); + assert_eq!(stats.source_change_work.owners_changed, 1); + assert_eq!(stats.source_change_work.slots_changed, 1); + assert_eq!(stats.source_change_work.fields_changed, 1); + assert_eq!( + stats.document_resolver_lookups, stats.target_renders, + "each single-owner render resolves its root; retained context validation resolves no nodes" ); assert!(session.confirm(2, 2, 2).unwrap()); @@ -2589,6 +2981,60 @@ mod tests { child.stop(true); } + #[test] + fn retained_source_input_counts_noops_and_registries_without_publishing_failed_work() { + let session = Session::new(1, 4, 4, 64 * 1024).unwrap(); + let first = identified_proof_layout_payload( + r#"[{"key":1,"viewport-width":80,"viewport-height":10,"root-width":80,"runtime-revision":0,"context-hash":77}]"#, + ); + session.render_sync(1, &first).unwrap(); + assert!(session.confirm(1, 1, 1).unwrap()); + let document: serde_json::Value = serde_json::from_slice(&first).unwrap(); + let content = document["document"]["root"]["content"].clone(); + let payload = |padding| { + serde_json::to_vec(&serde_json::json!({ + "version": 1, "document-base-revision": 1, "document-target-revision": 2, + "document-delta": { + "style-base-count": 0, "styles-append": [{"mode": "add", "face": {"foreground": "red"}}], + "property-template-base-count": 0, "property-template-target-count": 2, + "entries": [{"node-id": 1, "expected-revision": 7, "target-revision": 8, + "slot-patches": [{"slot": 0, "local": {"content": content, "padding-left": padding}}]}] + }, + "frames": [{"key": 2, "viewport-width": 80, "viewport-height": 10, + "root-width": 80, "runtime-revision": 1, "context-hash": 77}] + })).unwrap() + }; + let before = session.stats().source_change_work; + assert!(session.render_sync(2, &payload(-1)).is_err()); + assert_eq!(session.stats().source_change_work, before); + session.render_sync(3, &payload(0)).unwrap(); + let work = session.stats().source_change_work; + assert_eq!( + ( + work.fields_compared, + work.text_lines_compared, + work.text_clusters_compared, + work.text_bytes_compared + ), + (2, 1, 1, 1) + ); + assert_eq!( + ( + work.local_nodes_copied, + work.owners_changed, + work.slots_changed, + work.fields_changed + ), + (0, 0, 0, 0) + ); + assert_eq!( + (work.styles_appended, work.property_templates_added), + (1, 2) + ); + assert!(session.confirm(3, 2, 2).unwrap()); + session.stop(true); + } + #[test] fn retained_document_reuse_rejects_wrong_or_ambiguous_revision() { let parent = Session::new(1, 4, 4, 64 * 1024).unwrap(); @@ -2659,6 +3105,30 @@ mod tests { retry.stop(true); } + #[test] + fn cancellation_releases_retained_candidate_frame() { + let session = Session::new(1, 4, 4, 64 * 1024).unwrap(); + let payload = identified_proof_layout_payload( + r#"[{"key":1,"viewport-width":80,"viewport-height":10,"runtime-revision":0}]"#, + ); + session.render_sync(1, &payload).unwrap(); + let cancelled_frame = { + let state = session.shared.state.lock().unwrap(); + Arc::downgrade( + state.pending_baselines[&(1, 1)] + .retained_frame + .as_ref() + .unwrap(), + ) + }; + session.cancel(1); + assert!(cancelled_frame.upgrade().is_none()); + assert_eq!(session.stats().pending_baselines, 0); + assert!(!session.stats().confirmed_baseline); + assert!(!session.confirm(1, 1, 1).unwrap()); + session.stop(true); + } + #[test] fn async_omit_uses_the_latest_synchronously_confirmed_document() { let parent = Session::new(1, 4, 4, 64 * 1024).unwrap(); diff --git a/native/src/line_metrics.rs b/native/src/line_metrics.rs new file mode 100644 index 0000000..9f9351b --- /dev/null +++ b/native/src/line_metrics.rs @@ -0,0 +1,295 @@ +//! Persistent numeric summaries. No node retains a line, document, or old projection. +use std::ops::Range; +use std::sync::Arc; + +use super::{record, Line, LinePlanWork}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) struct LineMetric { + pub width: i64, + pub chars: u64, + pub min_content: i64, + pub nonempty: bool, + pub whitespace: bool, + pub noncontent: bool, +} + +impl LineMetric { + pub fn line(line: &Line) -> Self { + Self { + width: line.width, + chars: line.atoms.char_count(), + min_content: line.atoms.min_content_width(), + nonempty: !line.atoms.is_empty(), + whitespace: line.whitespace_only(), + noncontent: line.has_noncontent_properties(), + } + } +} + +#[derive(Clone, Copy, Debug, Default)] +pub(super) struct Summary { + pub count: usize, + pub chars: u64, + pub width_sum: i64, + pub first_width: i64, + pub min_width: i64, + pub max_width: i64, + pub min_content: i64, + pub all_nonempty: bool, +} + +impl Summary { + fn line(line: LineMetric) -> Self { + Self { + count: 1, + chars: line.chars, + width_sum: line.width, + first_width: line.width, + min_width: line.width, + max_width: line.width, + min_content: line.min_content, + all_nonempty: line.nonempty, + } + } + + fn combine(self, other: Self) -> Self { + if self.count == 0 { + return other; + } + if other.count == 0 { + return self; + } + Self { + count: self + .count + .checked_add(other.count) + .expect("validated line count"), + chars: self + .chars + .checked_add(other.chars) + .expect("validated character count"), + width_sum: self + .width_sum + .checked_add(other.width_sum) + .expect("validated line widths"), + first_width: self.first_width, + min_width: self.min_width.min(other.min_width), + max_width: self.max_width.max(other.max_width), + min_content: self.min_content.max(other.min_content), + all_nonempty: self.all_nonempty && other.all_nonempty, + } + } +} + +#[derive(Clone, Debug, Default)] +pub(super) struct Metrics(Option>); + +#[derive(Debug)] +struct Node { + summary: Summary, + height: u32, + kind: Kind, +} + +#[derive(Debug)] +enum Kind { + Leaf(Box<[LineMetric]>), + Branch(Metrics, Metrics), +} + +impl Metrics { + pub fn from_iter(lines: impl IntoIterator) -> Self { + let mut lines = lines.into_iter(); + let mut blocks = Vec::new(); + loop { + let block = lines.by_ref().take(16).collect::>(); + if block.is_empty() { + break; + } + blocks.push(Self::leaf(block.into_boxed_slice())); + } + fn build(lines: &[Metrics]) -> Metrics { + match lines { + [] => Metrics::default(), + [line] => line.clone(), + _ => Metrics::branch( + build(&lines[..lines.len() / 2]), + build(&lines[lines.len() / 2..]), + ), + } + } + build(&blocks) + } + + fn line(line: LineMetric) -> Self { + Self::leaf(Box::new([line])) + } + + fn leaf(lines: Box<[LineMetric]>) -> Self { + record(LinePlanWork { + metric_nodes_created: 1, + metric_entries_written: lines.len() as u64, + ..LinePlanWork::default() + }); + let summary = lines + .iter() + .copied() + .fold(Summary::default(), |summary, line| { + summary.combine(Summary::line(line)) + }); + Self(Some(Arc::new(Node { + summary, + height: 1, + kind: Kind::Leaf(lines), + }))) + } + + fn height(&self) -> u32 { + self.0.as_ref().map_or(0, |node| node.height) + } + pub fn summary(&self) -> Summary { + self.0 + .as_ref() + .map_or(Summary::default(), |node| node.summary) + } + + fn branch(left: Self, right: Self) -> Self { + if left.0.is_none() { + return right; + } + if right.0.is_none() { + return left; + } + record(LinePlanWork { + metric_nodes_created: 1, + ..LinePlanWork::default() + }); + Self(Some(Arc::new(Node { + summary: left.summary().combine(right.summary()), + height: left.height().max(right.height()) + 1, + kind: Kind::Branch(left, right), + }))) + } + + fn children(&self) -> (&Self, &Self) { + let Kind::Branch(left, right) = &self.0.as_ref().expect("nonempty metrics").kind else { + unreachable!("metric balance branch") + }; + (left, right) + } + + fn balanced(left: Self, right: Self) -> Self { + if left.height() > right.height() + 1 { + let (ll, lr) = left.children(); + if ll.height() >= lr.height() { + Self::branch(ll.clone(), Self::branch(lr.clone(), right)) + } else { + let (lrl, lrr) = lr.children(); + Self::branch( + Self::branch(ll.clone(), lrl.clone()), + Self::branch(lrr.clone(), right), + ) + } + } else if right.height() > left.height() + 1 { + let (rl, rr) = right.children(); + if rr.height() >= rl.height() { + Self::branch(Self::branch(left, rl.clone()), rr.clone()) + } else { + let (rll, rlr) = rl.children(); + Self::branch( + Self::branch(left, rll.clone()), + Self::branch(rlr.clone(), rr.clone()), + ) + } + } else { + Self::branch(left, right) + } + } + + pub fn concat(&self, other: &Self) -> Self { + if self.height() > other.height() + 1 { + let (left, right) = self.children(); + Self::balanced(left.clone(), right.concat(other)) + } else if other.height() > self.height() + 1 { + let (left, right) = other.children(); + Self::balanced(self.concat(left), right.clone()) + } else { + Self::branch(self.clone(), other.clone()) + } + } + + #[cfg(test)] + pub fn get(&self, index: usize) -> Option { + let node = self.0.as_ref()?; + record(LinePlanWork { + metric_nodes_visited: 1, + ..LinePlanWork::default() + }); + match &node.kind { + Kind::Leaf(lines) => lines.get(index).copied(), + Kind::Branch(left, right) => { + if index < left.summary().count { + left.get(index) + } else { + right.get(index - left.summary().count) + } + } + } + } + + pub fn slice(&self, range: Range) -> Self { + assert!(range.start <= range.end && range.end <= self.summary().count); + if range.is_empty() { + return Self::default(); + } + if range.start == 0 && range.end == self.summary().count { + return self.clone(); + } + record(LinePlanWork { + metric_nodes_visited: 1, + ..LinePlanWork::default() + }); + if let Kind::Leaf(lines) = &self.0.as_ref().expect("nonempty metrics").kind { + return Self::leaf(lines[range].into()); + } + let (left, right) = self.children(); + let middle = left.summary().count; + if range.end <= middle { + left.slice(range) + } else if range.start >= middle { + right.slice(range.start - middle..range.end - middle) + } else { + left.slice(range.start..middle) + .concat(&right.slice(0..range.end - middle)) + } + } + + pub fn replace(&self, index: usize, line: LineMetric) -> Self { + self.slice(0..index) + .concat(&Self::line(line)) + .concat(&self.slice(index + 1..self.summary().count)) + } + + pub fn iter(&self) -> impl Iterator + '_ { + let mut stack = self.0.as_deref().into_iter().collect::>(); + let mut leaf = [].iter(); + std::iter::from_fn(move || loop { + if let Some(line) = leaf.next() { + return Some(*line); + } + let node = stack.pop()?; + record(LinePlanWork { + metric_nodes_visited: 1, + ..LinePlanWork::default() + }); + match &node.kind { + Kind::Leaf(lines) => leaf = lines.iter(), + Kind::Branch(left, right) => { + stack.push(right.0.as_deref().expect("nonempty metric branch")); + stack.push(left.0.as_deref().expect("nonempty metric branch")); + } + } + }) + } +} diff --git a/native/src/line_ops.rs b/native/src/line_ops.rs new file mode 100644 index 0000000..c3c5b40 --- /dev/null +++ b/native/src/line_ops.rs @@ -0,0 +1,170 @@ +use super::super::{AtomProperties, HorizontalAlign, Line, RegionRole}; +use super::line_metrics::LineMetric; +use std::sync::Arc; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub(in crate::layout) enum BoxProjectionSlot { + ContentPad, + ContentOwn, + ContentCollapse, + VerticalFrame, + PaddingFrame, + MarginFrame, + PaddingEdges, + BoundaryBorders, + SurfaceTemplate, + MarginEdges, + OverflowEdges, + ScrollWindow, + ScrollContent, + TransparentContent, +} + +/// Ordered, cardinality-preserving operations. Each index is in this stage's input. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(in crate::layout) enum LineOp { + PadTo { + width: i64, + align: HorizontalAlign, + }, + AppendSpace(i64), + OwnContent { + region: i64, + start: i64, + }, + CollapseWhitespace { + width: i64, + region: i64, + }, + EdgeSpaces { + left: i64, + left_properties: Arc, + right: i64, + right_properties: Arc, + }, + Style(u32), + Template(u32), + ClearBreaks, + ScrollWindow(i64), + FirstStyleRole { + style: u32, + region: i64, + }, + LastStyleRole { + style: u32, + region: i64, + }, +} + +impl LineOp { + pub fn apply(&self, mut line: Line, index: usize, length: usize) -> Line { + match self { + Self::PadTo { width, align } => line = line.padded(*width, *align), + Self::AppendSpace(width) => line.push_space(*width), + Self::OwnContent { region, start } => line.own_content(*region, *start + index as i64), + Self::CollapseWhitespace { width, region } => { + line = line.collapse_whitespace_content(*width, *region) + } + Self::EdgeSpaces { + left, + left_properties, + right, + right_properties, + } => { + line.prepend_space_with_properties(*left, left_properties.as_ref().clone()); + line.push_space_with_properties(*right, right_properties.as_ref().clone()); + } + Self::Style(style) => line.apply_style(Some(*style)), + Self::Template(template) => line.apply_property_template(Some(*template)), + Self::ScrollWindow(region) => line.atoms = line.atoms.apply_scroll_window(*region), + Self::FirstStyleRole { style, region } if index == 0 => { + line.apply_style(Some(*style)); + line.apply_role(RegionRole::BorderTop, *region); + } + Self::LastStyleRole { style, region } if index + 1 == length => { + line.apply_style(Some(*style)); + line.apply_role(RegionRole::BorderBottom, *region); + } + Self::FirstStyleRole { .. } | Self::LastStyleRole { .. } | Self::ClearBreaks => {} + } + line + } + + pub(super) fn metric(&self, mut line: LineMetric, index: usize, length: usize) -> LineMetric { + fn space(line: &mut LineMetric, width: i64, properties: Option<&AtomProperties>) { + if width <= 0 { + return; + } + line.width += width; + line.chars += 1; + if !line.nonempty { + line.whitespace = true; + } + line.nonempty = true; + line.noncontent |= properties.is_some_and(|p| { + !p.style_ids.is_empty() + || !p.roles.is_empty() + || !p.property_template_ids.is_empty() + }); + } + match self { + Self::PadTo { width, align } => { + let remaining = (*width - line.width).max(0); + let left = match align { + HorizontalAlign::Left => 0, + HorizontalAlign::Right => remaining, + HorizontalAlign::Center => remaining / 2, + }; + space(&mut line, left, None); + space(&mut line, remaining - left, None); + } + Self::AppendSpace(width) => space(&mut line, *width, None), + Self::EdgeSpaces { + left, + left_properties, + right, + right_properties, + } => { + space(&mut line, *left, Some(left_properties)); + space(&mut line, *right, Some(right_properties)); + } + Self::CollapseWhitespace { width, .. } if line.whitespace && !line.noncontent => { + line = LineMetric { + width: (*width).max(0), + chars: u64::from(*width > 0), + min_content: 0, + nonempty: *width > 0, + whitespace: *width > 0, + noncontent: false, + }; + } + Self::Style(_) | Self::Template(_) => line.noncontent |= line.nonempty, + Self::FirstStyleRole { .. } if index == 0 => line.noncontent |= line.nonempty, + Self::LastStyleRole { .. } if index + 1 == length => line.noncontent |= line.nonempty, + _ => {} + } + line + } + + pub fn apply_break(&self, properties: &mut AtomProperties) { + match self { + Self::ClearBreaks => *properties = AtomProperties::default(), + Self::ScrollWindow(region) => properties.scroll_window = Some(*region), + _ => {} + } + } + + pub fn boundary_sensitive(&self) -> bool { + matches!( + self, + Self::FirstStyleRole { .. } | Self::LastStyleRole { .. } + ) + } + + pub fn preserves_metrics(&self) -> bool { + matches!( + self, + Self::OwnContent { .. } | Self::ClearBreaks | Self::ScrollWindow(_) + ) + } +} diff --git a/native/src/line_plan.rs b/native/src/line_plan.rs new file mode 100644 index 0000000..d1a4b26 --- /dev/null +++ b/native/src/line_plan.rs @@ -0,0 +1,1990 @@ +use std::borrow::Cow; +use std::cell::Cell; +use std::ops::Range; +use std::sync::Arc; + +use crate::sequence::{Entry, Measure, Sequence, Work}; + +use super::{AtomProperties, Line}; + +#[path = "line_metrics.rs"] +mod line_metrics; +#[path = "line_ops.rs"] +mod line_ops; +use line_metrics::{LineMetric, Metrics}; +pub(super) use line_ops::{BoxProjectionSlot, LineOp}; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "kebab-case")] +pub(crate) struct LinePlanWork { + pub(crate) sequence_nodes_created: u64, + pub(crate) sequence_entries_written: u64, + pub(crate) iterator_nodes_visited: u64, + pub(crate) lines_visited: u64, + pub(crate) line_lookups: u64, + pub(crate) range_queries: u64, + pub(crate) lines_mapped: u64, + pub(crate) breaks_mapped: u64, + pub(crate) line_clones: u64, + pub(crate) materialized_lines: u64, + pub(crate) materialized_breaks: u64, + pub(crate) materialized_chars: u64, + pub(crate) metric_nodes_created: u64, + pub(crate) metric_entries_written: u64, + pub(crate) metric_nodes_visited: u64, + pub(crate) projection_lines_measured: u64, + pub(crate) projection_nodes_created: u64, + pub(crate) projection_fallbacks: u64, + pub(crate) projection_initial_lines_measured: u64, + pub(crate) projection_fallback_lines_measured: u64, + pub(crate) projection_splice_lines_measured: u64, + pub(crate) frame_blank_lines_created: u64, +} + +thread_local! { + static WORK: Cell = Cell::new(LinePlanWork::default()); +} + +pub(crate) fn reset_work() { + WORK.set(LinePlanWork::default()); +} + +pub(crate) fn work() -> LinePlanWork { + WORK.get() +} + +impl LinePlanWork { + pub(crate) fn accumulate(&mut self, other: Self) { + macro_rules! add { + ($($field:ident),+ $(,)?) => { + $(self.$field = self.$field.checked_add(other.$field) + .expect("native line plan work counter overflow");)+ + }; + } + add!( + sequence_nodes_created, + sequence_entries_written, + iterator_nodes_visited, + lines_visited, + line_lookups, + range_queries, + lines_mapped, + breaks_mapped, + line_clones, + materialized_lines, + materialized_breaks, + materialized_chars, + metric_nodes_created, + metric_entries_written, + metric_nodes_visited, + projection_lines_measured, + projection_nodes_created, + projection_fallbacks, + projection_initial_lines_measured, + projection_fallback_lines_measured, + projection_splice_lines_measured, + frame_blank_lines_created + ); + } +} + +fn record(delta: LinePlanWork) { + let mut work = WORK.get(); + work.accumulate(delta); + WORK.set(work); +} + +fn record_sequence(work: Work) { + record(LinePlanWork { + sequence_nodes_created: work.nodes_created, + sequence_entries_written: work.entries_written, + ..LinePlanWork::default() + }); +} + +pub(super) fn record_materialized(lines: usize, chars: u64) { + record(LinePlanWork { + materialized_lines: lines as u64, + materialized_breaks: lines.saturating_sub(1) as u64, + materialized_chars: chars, + ..LinePlanWork::default() + }); +} + +pub(super) fn clone_line(line: &Line) -> Line { + record(LinePlanWork { + line_clones: 1, + ..LinePlanWork::default() + }); + line.clone() +} + +#[derive(Debug)] +struct LineEntry { + line: Line, + // Stored even at the terminal entry, where it is not part of the output. + // Concatenation replaces that boundary explicitly before it becomes visible. + break_after: Arc, +} + +fn entry(line: Line, break_after: Arc) -> Entry { + // One virtual newline per entry makes prefixes additive. The final newline + // is removed by each output-range summary, including empty/zero-width lines. + let chars = line + .atoms + .char_count() + .checked_add(1) + .expect("validated native line character count fits u64"); + let measure = Measure::new(chars, 1, line.width, line.width) + .expect("validated native line width is nonnegative"); + Entry::new(Arc::new(LineEntry { line, break_after }), measure) + .expect("validated native line measure") +} + +#[derive(Clone, Debug, Default)] +pub(super) struct LinePlan { + // Raw plans retain the existing packed sequence. Extended plans keep this empty. + entries: Sequence, + extension: Option>, + metrics: Metrics, + identity: Arc<()>, +} + +#[derive(Debug)] +enum Extension { + Projection { + input: LinePlan, + ops: Arc<[LineOp]>, + origin: usize, + length: usize, + }, + Concat { + left: LinePlan, + right: LinePlan, + height: u32, + }, + Break { + input: LinePlan, + properties: Arc, + }, +} + +#[derive(Clone, Copy)] +struct Stage<'a> { + ops: &'a [LineOp], + index: usize, + length: usize, +} + +pub(super) struct LineView<'a> { + entry: &'a Arc, + stages: Vec>, + break_override: Option<&'a AtomProperties>, + break_stage_start: usize, +} + +impl<'a> LineView<'a> { + fn metric(&self) -> LineMetric { + let mut metric = LineMetric::line(&self.entry.line); + for stage in &self.stages { + for op in stage.ops { + metric = op.metric(metric, stage.index, stage.length); + } + } + metric + } + + pub(super) fn width(&self) -> i64 { + self.metric().width + } + + /// Raw lines remain borrowed. Typed programs are realized only at an explicit boundary. + pub(super) fn materialize(&self) -> Cow<'a, Line> { + if self.stages.is_empty() { + return Cow::Borrowed(&self.entry.line); + } + let mut line = clone_line(&self.entry.line); + for stage in &self.stages { + for op in stage.ops { + line = op.apply(line, stage.index, stage.length); + } + } + Cow::Owned(line) + } + + fn break_view(&self) -> BreakView<'a> { + BreakView { + original: self.break_override.unwrap_or(&self.entry.break_after), + stages: self.stages[self.break_stage_start..].to_vec(), + } + } +} + +pub(super) struct BreakView<'a> { + original: &'a AtomProperties, + stages: Vec>, +} + +impl<'a> BreakView<'a> { + pub(super) fn materialize(&self) -> Cow<'a, AtomProperties> { + if self.stages.is_empty() { + return Cow::Borrowed(self.original); + } + let mut properties = self.original.clone(); + for stage in &self.stages { + if stage.index + 1 < stage.length { + for op in stage.ops { + op.apply_break(&mut properties); + } + } + } + Cow::Owned(properties) + } +} + +pub(super) struct Lines<'a> { + inner: Box> + 'a>, + remaining: usize, +} + +impl<'a> Iterator for Lines<'a> { + type Item = LineView<'a>; + fn next(&mut self) -> Option { + let value = self.inner.next(); + if value.is_some() { + self.remaining -= 1; + } + value + } + fn size_hint(&self) -> (usize, Option) { + (self.remaining, Some(self.remaining)) + } +} +impl ExactSizeIterator for Lines<'_> {} + +pub(super) struct LinesAndBreaks<'a> { + lines: Lines<'a>, +} +impl<'a> Iterator for LinesAndBreaks<'a> { + type Item = (LineView<'a>, Option>); + fn next(&mut self) -> Option { + let line = self.lines.next()?; + let properties = (self.lines.len() > 0).then(|| line.break_view()); + Some((line, properties)) + } + fn size_hint(&self) -> (usize, Option) { + self.lines.size_hint() + } +} +impl ExactSizeIterator for LinesAndBreaks<'_> {} + +impl LinePlan { + pub(super) fn ptr_eq(&self, other: &Self) -> bool { + Arc::ptr_eq(&self.identity, &other.identity) + } + + pub(super) fn from_lines(lines: impl IntoIterator) -> Self { + let default_break = Arc::new(AtomProperties::default()); + Self::from_entries( + lines + .into_iter() + .map(|line| entry(line, Arc::clone(&default_break))), + ) + } + + fn from_entries(entries: impl IntoIterator>) -> Self { + let mut work = Work::default(); + let entries = Sequence::from_entries(entries, &mut work) + .expect("validated native line sequence aggregates fit limits"); + record_sequence(work); + let metrics = Metrics::from_iter( + entries + .iter() + .map(|entry| LineMetric::line(&entry.value().line)), + ); + Self { + entries, + metrics, + ..Self::default() + } + } + + fn extended(extension: Extension, metrics: Metrics) -> Self { + record(LinePlanWork { + projection_nodes_created: 1, + ..LinePlanWork::default() + }); + Self { + extension: Some(Arc::new(extension)), + metrics, + ..Self::default() + } + } + + pub(super) fn len(&self) -> usize { + self.metrics.summary().count + } + pub(super) fn is_empty(&self) -> bool { + self.len() == 0 + } + pub(super) fn first_width(&self) -> i64 { + self.metrics.summary().first_width + } + pub(super) fn max_width(&self) -> i64 { + self.metrics.summary().max_width + } + pub(super) fn min_content_width(&self) -> i64 { + self.metrics.summary().min_content + } + pub(super) fn uniform_width(&self, width: i64) -> bool { + self.is_empty() || (self.metrics.summary().min_width == width && self.max_width() == width) + } + pub(super) fn all_nonempty(&self) -> bool { + self.is_empty() || self.metrics.summary().all_nonempty + } + pub(super) fn height(&self) -> i64 { + i64::try_from(self.len()).expect("validated line height") + } + + pub(super) fn iter(&self) -> Lines<'_> { + let inner: Box>> = match self.extension.as_deref() { + None => { + let mut iter = self.entries.iter(); + Box::new(std::iter::from_fn(move || { + let before = iter.nodes_visited(); + let value = iter.next(); + record(LinePlanWork { + iterator_nodes_visited: iter.nodes_visited() - before, + lines_visited: u64::from(value.is_some()), + ..LinePlanWork::default() + }); + value.map(|entry| LineView { + entry: entry.value(), + stages: Vec::new(), + break_override: None, + break_stage_start: 0, + }) + })) + } + Some(Extension::Projection { + input, + ops, + origin, + length, + }) => { + record(LinePlanWork { + iterator_nodes_visited: 1, + ..LinePlanWork::default() + }); + Box::new(input.iter().enumerate().map(move |(index, mut line)| { + line.stages.push(Stage { + ops, + index: origin + index, + length: *length, + }); + line + })) + } + Some(Extension::Concat { left, right, .. }) => { + record(LinePlanWork { + iterator_nodes_visited: 1, + ..LinePlanWork::default() + }); + Box::new(left.iter().chain(right.iter())) + } + Some(Extension::Break { input, properties }) => { + record(LinePlanWork { + iterator_nodes_visited: 1, + ..LinePlanWork::default() + }); + Box::new(input.iter().map(move |mut line| { + line.break_override = Some(properties); + line.break_stage_start = line.stages.len(); + line + })) + } + }; + Lines { + inner, + remaining: self.len(), + } + } + + pub(super) fn iter_with_breaks(&self) -> LinesAndBreaks<'_> { + LinesAndBreaks { lines: self.iter() } + } + + pub(super) fn get(&self, index: usize) -> Option> { + record(LinePlanWork { + line_lookups: 1, + ..LinePlanWork::default() + }); + if index >= self.len() { + return None; + } + match self.extension.as_deref() { + None => self.entries.get(index).map(|entry| LineView { + entry: entry.value(), + stages: Vec::new(), + break_override: None, + break_stage_start: 0, + }), + Some(Extension::Projection { + input, + ops, + origin, + length, + }) => { + let mut line = input.get(index)?; + line.stages.push(Stage { + ops, + index: origin + index, + length: *length, + }); + Some(line) + } + Some(Extension::Concat { left, right, .. }) => { + if index < left.len() { + left.get(index) + } else { + right.get(index - left.len()) + } + } + Some(Extension::Break { input, properties }) => { + let mut line = input.get(index)?; + line.break_override = Some(properties); + line.break_stage_start = line.stages.len(); + Some(line) + } + } + } + + #[cfg(test)] + pub(super) fn first(&self) -> Option> { + self.get(0) + } + + pub(super) fn break_after(&self, index: usize) -> Option> { + (index < self.len().saturating_sub(1)) + .then(|| self.get(index).expect("checked break index").break_view()) + } + + pub(super) fn prefix_chars(&self, end: usize) -> u64 { + let chars = self.range_measure(0..end).chars; + chars + .checked_add(u64::from(end > 0 && end < self.len())) + .expect("validated line prefix") + } + + pub(super) fn range_measure(&self, range: Range) -> Measure { + record(LinePlanWork { + range_queries: 1, + ..LinePlanWork::default() + }); + let summary = self.metrics.slice(range).summary(); + Measure::new( + summary.chars + summary.count.saturating_sub(1) as u64, + summary.count as u64, + summary.width_sum, + summary.max_width, + ) + .expect("validated line range") + } + + pub(super) fn slice(&self, range: Range) -> Self { + assert!(range.start <= range.end && range.end <= self.len()); + if range.start == 0 && range.end == self.len() { + return self.clone(); + } + if range.is_empty() { + return Self::default(); + } + let metrics = self.metrics.slice(range.clone()); + match self.extension.as_deref() { + None => { + let mut work = Work::default(); + let entries = self + .entries + .slice(range, &mut work) + .expect("validated line slice"); + record_sequence(work); + Self { + entries, + metrics, + ..Self::default() + } + } + Some(Extension::Projection { + input, + ops, + origin, + length, + }) => Self::extended( + Extension::Projection { + input: input.slice(range.clone()), + ops: Arc::clone(ops), + origin: origin + range.start, + length: *length, + }, + metrics, + ), + Some(Extension::Concat { left, right, .. }) => { + if range.end <= left.len() { + left.slice(range) + } else if range.start >= left.len() { + right.slice(range.start - left.len()..range.end - left.len()) + } else { + left.slice(range.start..left.len()) + .join_stored(&right.slice(0..range.end - left.len())) + } + } + Some(Extension::Break { input, properties }) => Self::extended( + Extension::Break { + input: input.slice(range), + properties: Arc::clone(properties), + }, + metrics, + ), + } + } + + #[cfg(test)] + pub(super) fn replace_line(&self, index: usize, line: Line) -> Self { + let old = self + .get(index) + .expect("validated line replacement") + .break_view() + .materialize() + .into_owned(); + if self.extension.is_none() { + self.replace_entry(index, entry(line, Arc::new(old))) + } else { + let replacement = Self::from_entries([entry(line, Arc::new(old))]); + self.slice(0..index) + .join_stored(&replacement) + .join_stored(&self.slice(index + 1..self.len())) + } + } + + pub(super) fn with_break(&self, index: usize, properties: AtomProperties) -> Self { + let old = self.break_after(index).expect("validated break index"); + if *old.materialize() == properties { + return self.clone(); + } + self.set_stored_break(index, Arc::new(properties)) + } + + fn set_stored_break(&self, index: usize, properties: Arc) -> Self { + if self.extension.is_none() { + let old = self + .entries + .get(index) + .expect("validated break replacement"); + self.replace_entry(index, entry(clone_line(&old.value().line), properties)) + } else { + let selected = self.slice(index..index + 1); + // The new override replaces every property of the old one. Keep + // its current line program, without retaining the shadowed root. + let input = match selected.extension.as_deref() { + Some(Extension::Break { input, .. }) => input.clone(), + _ => selected.clone(), + }; + let replacement = + Self::extended(Extension::Break { input, properties }, selected.metrics); + self.slice(0..index) + .join_stored(&replacement) + .join_stored(&self.slice(index + 1..self.len())) + } + } + + fn replace_entry(&self, index: usize, entry: Entry) -> Self { + let metric = LineMetric::line(&entry.value().line); + let mut work = Work::default(); + let entries = self + .entries + .replace(index, entry, &mut work) + .expect("validated line replacement"); + record_sequence(work); + Self { + entries, + metrics: self.metrics.replace(index, metric), + ..Self::default() + } + } + + fn tree_height(&self) -> u32 { + if self.is_empty() { + return 0; + } + match self.extension.as_deref() { + Some(Extension::Concat { height, .. }) => *height, + _ => 1, + } + } + + fn branch(left: Self, right: Self) -> Self { + if left.is_empty() { + return right; + } + if right.is_empty() { + return left; + } + let metrics = left.metrics.concat(&right.metrics); + let height = left.tree_height().max(right.tree_height()) + 1; + Self::extended( + Extension::Concat { + left, + right, + height, + }, + metrics, + ) + } + + fn children(&self) -> (&Self, &Self) { + let Some(Extension::Concat { left, right, .. }) = self.extension.as_deref() else { + unreachable!("balanced line branch") + }; + (left, right) + } + + fn balanced(left: Self, right: Self) -> Self { + if left.tree_height() > right.tree_height() + 1 { + let (ll, lr) = left.children(); + if ll.tree_height() >= lr.tree_height() { + Self::branch(ll.clone(), Self::branch(lr.clone(), right)) + } else { + let (lrl, lrr) = lr.children(); + Self::branch( + Self::branch(ll.clone(), lrl.clone()), + Self::branch(lrr.clone(), right), + ) + } + } else if right.tree_height() > left.tree_height() + 1 { + let (rl, rr) = right.children(); + if rr.tree_height() >= rl.tree_height() { + Self::branch(Self::branch(left, rl.clone()), rr.clone()) + } else { + let (rll, rlr) = rl.children(); + Self::branch( + Self::branch(left, rll.clone()), + Self::branch(rlr.clone(), rr.clone()), + ) + } + } else { + Self::branch(left, right) + } + } + + /// Concatenate already-authorized stored breaks without introducing a new seam. + fn join_stored(&self, other: &Self) -> Self { + if self.is_empty() { + return other.clone(); + } + if other.is_empty() { + return self.clone(); + } + if self.extension.is_none() && other.extension.is_none() { + let mut work = Work::default(); + let entries = self + .entries + .concat(&other.entries, &mut work) + .expect("validated line concatenation"); + record_sequence(work); + return Self { + entries, + metrics: self.metrics.concat(&other.metrics), + ..Self::default() + }; + } + if self.tree_height() > other.tree_height() + 1 { + let (left, right) = self.children(); + Self::balanced(left.clone(), right.join_stored(other)) + } else if other.tree_height() > self.tree_height() + 1 { + let (left, right) = other.children(); + Self::balanced(self.join_stored(left), right.clone()) + } else { + Self::branch(self.clone(), other.clone()) + } + } + + pub(super) fn concat(&self, other: &Self) -> Self { + self.concat_with_break(other, AtomProperties::default()) + } + pub(super) fn concat_with_break(&self, other: &Self, properties: AtomProperties) -> Self { + if self.is_empty() { + return other.clone(); + } + if other.is_empty() { + return self.clone(); + } + self.join_stored(other) + .with_break(self.len() - 1, properties) + } + + /// Full fallback/oracle transform; every visited line and copy is counted. + pub(super) fn map_lines(&self, mut map: impl FnMut(usize, Line) -> Line) -> Self { + Self::from_entries(self.iter().enumerate().map(|(index, old)| { + record(LinePlanWork { + lines_mapped: 1, + ..LinePlanWork::default() + }); + entry( + map(index, clone_line(&old.materialize())), + Arc::new(old.break_view().materialize().into_owned()), + ) + })) + } + + pub(super) fn map_breaks(&self, mut map: impl FnMut(&mut AtomProperties)) -> Self { + let last = self.len().saturating_sub(1); + Self::from_entries(self.iter().enumerate().map(|(index, old)| { + if index == last && old.stages.is_empty() && old.break_override.is_none() { + // Keep the raw terminal entry exactly as before. + return Entry::new( + Arc::clone(old.entry), + Measure::new( + old.entry.line.atoms.char_count() + 1, + 1, + old.entry.line.width, + old.entry.line.width, + ) + .expect("line measure"), + ) + .expect("line entry"); + } + let mut properties = old.break_view().materialize().into_owned(); + if index != last { + map(&mut properties); + record(LinePlanWork { + breaks_mapped: 1, + ..LinePlanWork::default() + }); + } + entry(clone_line(&old.materialize()), Arc::new(properties)) + })) + } + pub(super) fn clear_breaks(&self) -> Self { + self.map_breaks(|properties| *properties = AtomProperties::default()) + } +} + +#[derive(Clone, Debug)] +pub(super) struct LineSplice { + pub(super) old: Range, + pub(super) new: Range, +} +#[derive(Clone, Debug)] +pub(super) enum ChangeKind { + Same, + Splices(Box<[LineSplice]>), + ReplaceAll, +} + +/// Transaction-local authorization for this exact pair. Splices describe line programs, +/// not final properties: relative coordinates may move within otherwise shared suffixes. +#[derive(Clone, Debug)] +pub(super) struct PlanChange { + base: LinePlan, + target: LinePlan, + kind: ChangeKind, +} +impl PlanChange { + pub(super) fn same(plan: &LinePlan) -> Self { + Self { + base: plan.clone(), + target: plan.clone(), + kind: ChangeKind::Same, + } + } + pub(super) fn replace_all(base: &LinePlan, target: &LinePlan) -> Self { + if base.ptr_eq(target) { + return Self::same(base); + } + Self { + base: base.clone(), + target: target.clone(), + kind: ChangeKind::ReplaceAll, + } + } + pub(super) fn splices(base: &LinePlan, target: &LinePlan, splices: Vec) -> Self { + let (mut old_end, mut new_end) = (0, 0); + for splice in &splices { + if splice.old.start < old_end + || splice.new.start < new_end + || splice.old.start > splice.old.end + || splice.new.start > splice.new.end + || splice.old.end > base.len() + || splice.new.end > target.len() + || splice.old.start - old_end != splice.new.start - new_end + { + return Self::replace_all(base, target); + } + old_end = splice.old.end; + new_end = splice.new.end; + } + if base.len() - old_end != target.len() - new_end { + return Self::replace_all(base, target); + } + if base.ptr_eq(target) { + return Self::same(base); + } + Self { + base: base.clone(), + target: target.clone(), + kind: ChangeKind::Splices(splices.into_boxed_slice()), + } + } + pub(super) fn applies_to(&self, base: &LinePlan, target: &LinePlan) -> bool { + self.base.ptr_eq(base) && self.target.ptr_eq(target) + } + pub(super) fn kind(&self) -> &ChangeKind { + &self.kind + } + + fn boundary_splices(&self) -> Vec { + let ChangeKind::Splices(splices) = &self.kind else { + return Vec::new(); + }; + let mut result = splices.to_vec(); + let (mut old_start, mut new_start) = (0, 0); + let terminal = LineSplice { + old: self.base.len()..self.base.len(), + new: self.target.len()..self.target.len(), + }; + for splice in splices.iter().chain(std::iter::once(&terminal)) { + // Only unchanged-gap endpoints need adding; changed ranges already cover the rest. + for old in [0, self.base.len().saturating_sub(1)] { + if old >= old_start && old < splice.old.start { + let new = new_start + old - old_start; + result.push(LineSplice { + old: old..old + 1, + new: new..new + 1, + }); + } + } + for new in [0, self.target.len().saturating_sub(1)] { + if new >= new_start && new < splice.new.start { + let old = old_start + new - new_start; + result.push(LineSplice { + old: old..old + 1, + new: new..new + 1, + }); + } + } + old_start = splice.old.end; + new_start = splice.new.end; + } + result.sort_by_key(|splice| (splice.old.start, splice.new.start)); + let mut merged: Vec = Vec::new(); + for splice in result { + if let Some(previous) = merged.last_mut() { + if splice.old.start <= previous.old.end && splice.new.start <= previous.new.end { + previous.old.end = previous.old.end.max(splice.old.end); + previous.new.end = previous.new.end.max(splice.new.end); + continue; + } + } + merged.push(splice); + } + merged + } +} + +#[derive(Debug)] +pub(super) struct ProjectionState { + input: LinePlan, + ops: Arc<[LineOp]>, + plan: LinePlan, + frame: Option, +} + +/// Current blank-row framing parameters; counts can change with child height. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) struct FrameSpec { + pub(super) prefix: usize, + pub(super) suffix: usize, + pub(super) width: i64, + pub(super) prefix_properties: Arc, + pub(super) suffix_properties: Arc, +} + +#[derive(Debug)] +struct FrameState { + spec: FrameSpec, + prefix: LinePlan, + suffix: LinePlan, +} + +impl FrameSpec { + fn same_parameters(&self, other: &Self) -> bool { + self.width == other.width + && self.prefix_properties == other.prefix_properties + && self.suffix_properties == other.suffix_properties + } +} +pub(super) struct ProjectionUpdate { + pub(super) state: Arc, + pub(super) change: PlanChange, +} +impl ProjectionState { + fn measure(input: &LinePlan, ops: &[LineOp], range: Range) -> Metrics { + if ops.iter().all(LineOp::preserves_metrics) { + return input.metrics.slice(range); + } + let selected = input.metrics.slice(range.clone()); + Metrics::from_iter(selected.iter().enumerate().map(|(offset, mut metric)| { + let index = range.start + offset; + record(LinePlanWork { + projection_lines_measured: 1, + ..LinePlanWork::default() + }); + for op in ops { + metric = op.metric(metric, index, input.len()); + } + metric + })) + } + + fn build(input: LinePlan, ops: Arc<[LineOp]>, metrics: Metrics) -> Arc { + let plan = if ops.is_empty() { + input.clone() + } else { + LinePlan::extended( + Extension::Projection { + input: input.clone(), + ops: Arc::clone(&ops), + origin: 0, + length: input.len(), + }, + metrics, + ) + }; + Arc::new(Self { + input, + ops, + plan, + frame: None, + }) + } + + pub(super) fn new(input: LinePlan, ops: Arc<[LineOp]>) -> Arc { + let before = work().projection_lines_measured; + let metrics = Self::measure(&input, &ops, 0..input.len()); + record(LinePlanWork { + projection_initial_lines_measured: work().projection_lines_measured - before, + ..LinePlanWork::default() + }); + Self::build(input, ops, metrics) + } + + pub(super) fn plan(&self) -> &LinePlan { + &self.plan + } + + pub(super) fn update( + self: &Arc, + input: LinePlan, + proof: Option<&PlanChange>, + ops: Arc<[LineOp]>, + ) -> ProjectionUpdate { + let valid = proof.filter(|proof| proof.applies_to(&self.input, &input)); + if self.frame.is_none() && ops == self.ops { + if input.ptr_eq(&self.input) { + return ProjectionUpdate { + state: Arc::clone(self), + change: PlanChange::same(&self.plan), + }; + } + if let Some(PlanChange { + kind: ChangeKind::Splices(splices), + .. + }) = valid + { + let before = work().projection_lines_measured; + // New and former endpoints are explicit changed ranges; all other + // numeric subtrees remain shared even when their coordinates shift. + let output_splices = if ops.iter().any(LineOp::boundary_sensitive) { + valid.expect("validated splice proof").boundary_splices() + } else { + splices.to_vec() + }; + let metrics = if ops.iter().all(LineOp::preserves_metrics) { + input.metrics.clone() + } else { + let mut metrics = Metrics::default(); + let mut old_end = 0; + for splice in &output_splices { + metrics = + metrics.concat(&self.plan.metrics.slice(old_end..splice.old.start)); + metrics = metrics.concat(&Self::measure(&input, &ops, splice.new.clone())); + old_end = splice.old.end; + } + metrics.concat(&self.plan.metrics.slice(old_end..self.input.len())) + }; + debug_assert_eq!(metrics.summary().count, input.len()); + record(LinePlanWork { + projection_splice_lines_measured: work().projection_lines_measured - before, + ..LinePlanWork::default() + }); + let state = Self::build(input, ops, metrics); + let change = PlanChange::splices(&self.plan, &state.plan, output_splices); + return ProjectionUpdate { state, change }; + } + } + record(LinePlanWork { + projection_fallbacks: 1, + ..LinePlanWork::default() + }); + let before = work().projection_lines_measured; + let metrics = Self::measure(&input, &ops, 0..input.len()); + record(LinePlanWork { + projection_fallback_lines_measured: work().projection_lines_measured - before, + ..LinePlanWork::default() + }); + let state = Self::build(input, ops, metrics); + let change = PlanChange::replace_all(&self.plan, &state.plan); + ProjectionUpdate { state, change } + } + + fn blanks(count: usize, width: i64, properties: &Arc) -> LinePlan { + record(LinePlanWork { + frame_blank_lines_created: count as u64, + ..LinePlanWork::default() + }); + LinePlan::from_lines( + (0..count).map(|_| Line::blank_with_properties(width, properties.as_ref().clone())), + ) + } + + fn build_frame( + input: LinePlan, + spec: FrameSpec, + prefix: LinePlan, + suffix: LinePlan, + ) -> Arc { + let plan = if spec.prefix == 0 && spec.suffix == 0 { + // Zero framing forwards input-bound proofs, including empty roots. + input.clone() + } else { + prefix.concat(&input).concat(&suffix) + }; + Arc::new(Self { + input, + ops: Arc::from([]), + plan, + frame: Some(FrameState { + spec, + prefix, + suffix, + }), + }) + } + + pub(super) fn new_frame(input: LinePlan, spec: FrameSpec) -> Arc { + let prefix = Self::blanks(spec.prefix, spec.width, &spec.prefix_properties); + let suffix = Self::blanks(spec.suffix, spec.width, &spec.suffix_properties); + Self::build_frame(input, spec, prefix, suffix) + } + + pub(super) fn update_frame( + self: &Arc, + input: LinePlan, + proof: Option<&PlanChange>, + spec: FrameSpec, + ) -> ProjectionUpdate { + let Some(previous) = self + .frame + .as_ref() + .filter(|previous| previous.spec.same_parameters(&spec)) + else { + record(LinePlanWork { + projection_fallbacks: 1, + ..LinePlanWork::default() + }); + let state = Self::new_frame(input, spec); + let change = PlanChange::replace_all(&self.plan, &state.plan); + return ProjectionUpdate { state, change }; + }; + if input.ptr_eq(&self.input) && previous.spec == spec { + return ProjectionUpdate { + state: Arc::clone(self), + change: PlanChange::same(&self.plan), + }; + } + let resize = |old: &LinePlan, count: usize, properties: &Arc| { + if count <= old.len() { + old.slice(0..count) + } else { + old.concat(&Self::blanks(count - old.len(), spec.width, properties)) + } + }; + let prefix = resize(&previous.prefix, spec.prefix, &spec.prefix_properties); + let suffix = resize(&previous.suffix, spec.suffix, &spec.suffix_properties); + let valid = proof.filter(|proof| proof.applies_to(&self.input, &input)); + let child_splices = if input.ptr_eq(&self.input) { + Some(Vec::new()) + } else { + valid.map(|proof| match proof.kind() { + ChangeKind::Splices(splices) => splices.to_vec(), + ChangeKind::ReplaceAll => vec![LineSplice { + old: 0..self.input.len(), + new: 0..input.len(), + }], + ChangeKind::Same => Vec::new(), + }) + }; + let state = Self::build_frame(input, spec.clone(), prefix, suffix); + let Some(child_splices) = child_splices else { + record(LinePlanWork { + projection_fallbacks: 1, + ..LinePlanWork::default() + }); + let change = PlanChange::replace_all(&self.plan, &state.plan); + return ProjectionUpdate { state, change }; + }; + if previous.spec.prefix == 0 + && previous.spec.suffix == 0 + && spec.prefix == 0 + && spec.suffix == 0 + { + let change = valid + .cloned() + .unwrap_or_else(|| PlanChange::replace_all(&self.plan, &state.plan)); + return ProjectionUpdate { state, change }; + } + let old_prefix = previous.spec.prefix; + let new_prefix = spec.prefix; + let mut splices = Vec::new(); + if old_prefix != new_prefix { + let shared = old_prefix.min(new_prefix); + splices.push(LineSplice { + old: shared..old_prefix, + new: shared..new_prefix, + }); + } + splices.extend(child_splices.into_iter().map(|splice| LineSplice { + old: old_prefix + splice.old.start..old_prefix + splice.old.end, + new: new_prefix + splice.new.start..new_prefix + splice.new.end, + })); + if previous.spec.suffix != spec.suffix { + let shared = previous.spec.suffix.min(spec.suffix); + splices.push(LineSplice { + old: old_prefix + self.input.len() + shared..self.plan.len(), + new: new_prefix + state.input.len() + shared..state.plan.len(), + }); + } + // Default joins replace the stored predecessor break. Include that row, + // including the terminal-row case when a child or blank tail disappears. + for splice in &mut splices { + if splice.old.start > 0 && splice.new.start > 0 { + splice.old.start -= 1; + splice.new.start -= 1; + } else { + splice.old.start = 0; + splice.new.start = 0; + } + } + splices.sort_by_key(|splice| (splice.old.start, splice.new.start)); + let mut merged: Vec = Vec::new(); + for splice in splices { + if let Some(previous) = merged.last_mut() { + if splice.old.start <= previous.old.end && splice.new.start <= previous.new.end { + previous.old.end = previous.old.end.max(splice.old.end); + previous.new.end = previous.new.end.max(splice.new.end); + continue; + } + } + merged.push(splice); + } + let change = PlanChange::splices(&self.plan, &state.plan, merged); + ProjectionUpdate { state, change } + } +} + +#[cfg(test)] +mod tests { + use super::super::{MeasuredCluster, Rendered}; + use super::*; + + fn text(value: &str, width: i64) -> Line { + Line::from_clusters(&[MeasuredCluster { + text: value.to_owned(), + width, + cjk: false, + space: false, + pixel_space: false, + source_template_id: None, + }]) + } + + #[test] + fn typed_projection_keeps_exact_spaces_and_relative_content_without_line_maps() { + let input = LinePlan::from_lines([ + text("界a", 3), + Line::default(), + Line::blank(2), + text("z", 4), + ]); + reset_work(); + let state = ProjectionState::new( + input, + Arc::from([ + LineOp::PadTo { + width: 5, + align: super::super::HorizontalAlign::Center, + }, + LineOp::OwnContent { + region: 42, + start: 7, + }, + ]), + ); + let projected = state.plan().clone(); + let projection_work = work(); + let tape = Rendered::from_line_plan(projected.clone()).into_tape(0); + assert_eq!(projected.prefix_chars(4), 14); + assert_eq!( + tape.lines.iter().map(|line| line.width).collect::>(), + [5; 4] + ); + for (index, line) in tape.lines.iter().enumerate() { + for atom in &line.atoms { + let properties = match atom { + super::super::TapeAtom::Text { properties, .. } + | super::super::TapeAtom::Space { properties, .. } => properties, + }; + assert_eq!(properties.content_idx, Some(7 + index as i64)); + } + } + assert_eq!(projection_work.lines_mapped, 0); + } + + fn assert_exact_metrics(plan: &LinePlan) { + let mut chars = plan.len().saturating_sub(1) as u64; + let mut max_width = 0; + let mut min_content = 0; + for (index, view) in plan.iter().enumerate() { + let line = view.materialize(); + let expected = LineMetric::line(&line); + assert_eq!(plan.metrics.get(index), Some(expected), "line {index}"); + chars += expected.chars; + max_width = max_width.max(expected.width); + min_content = min_content.max(expected.min_content); + } + assert_eq!(plan.prefix_chars(plan.len()), chars); + assert_eq!(plan.max_width(), max_width); + assert_eq!(plan.min_content_width(), min_content); + assert_eq!( + plan.first_width(), + plan.first().map_or(0, |line| line.width()) + ); + } + + #[test] + fn projection_cold_build_is_packed_and_same_parameter_splices_are_bounded() { + for size in [32_usize, 128, 512, 8192] { + let input = LinePlan::from_lines((0..size).map(|_| text("界", 2))); + let ops: Arc<[LineOp]> = Arc::from([ + LineOp::PadTo { + width: 7, + align: super::super::HorizontalAlign::Center, + }, + LineOp::OwnContent { + region: 9, + start: 0, + }, + ]); + reset_work(); + let original = ProjectionState::new(input.clone(), Arc::clone(&ops)); + let cold = work(); + assert_eq!(cold.projection_initial_lines_measured, size as u64); + assert_eq!(cold.metric_entries_written, size as u64); + assert!(cold.metric_nodes_created <= size as u64 / 8); + assert!(cold.metric_nodes_visited <= size as u64 / 8); + assert_eq!((cold.lines_mapped, cold.lines_visited), (0, 0)); + + let replacement = input.replace_line(1, text("wide", 8)); + let proof = PlanChange::splices( + &input, + &replacement, + vec![LineSplice { + old: 1..2, + new: 1..2, + }], + ); + reset_work(); + let changed = original.update(replacement.clone(), Some(&proof), Arc::clone(&ops)); + let steady = work(); + assert_eq!(steady.projection_splice_lines_measured, 1); + assert_eq!(steady.projection_fallbacks, 0); + assert_eq!( + ( + steady.lines_mapped, + steady.lines_visited, + steady.materialized_lines + ), + (0, 0, 0) + ); + assert!(steady.metric_nodes_created <= 12 * u64::from(size.ilog2()) + 8); + assert!(changed + .change + .applies_to(original.plan(), changed.state.plan())); + assert_exact_metrics(changed.state.plan()); + let oracle = replacement.map_lines(|index, line| { + let mut line = line.padded(7, super::super::HorizontalAlign::Center); + line.own_content(9, index as i64); + line + }); + assert_eq!( + Rendered::from_line_plan(changed.state.plan().clone()).into_tape(0), + Rendered::from_line_plan(oracle).into_tape(0) + ); + + let changed_ops: Arc<[LineOp]> = Arc::from([LineOp::AppendSpace(2)]); + reset_work(); + let fallback = changed.state.update(replacement, None, changed_ops); + assert_eq!(work().projection_fallback_lines_measured, size as u64); + assert_eq!(work().projection_initial_lines_measured, 0); + assert_eq!(work().projection_fallbacks, 1); + assert!(matches!(fallback.change.kind(), ChangeKind::ReplaceAll)); + assert_exact_metrics(fallback.state.plan()); + } + } + + #[test] + fn projection_coordinates_shift_shared_suffixes_and_slices_keep_stage_origins() { + let mut owned = text("owned", 5); + owned.own_content(20, 0); + let input = LinePlan::from_lines([Line::blank(2), Line::blank(2), owned]); + let ops: Arc<[LineOp]> = Arc::from([LineOp::OwnContent { + region: 9, + start: 10, + }]); + let original = ProjectionState::new(input.clone(), Arc::clone(&ops)); + let grown = LinePlan::from_lines([Line::blank(2), Line::blank(2), Line::blank(2)]) + .concat(&input.slice(1..3)); + let proof = PlanChange::splices( + &input, + &grown, + vec![LineSplice { + old: 0..1, + new: 0..3, + }], + ); + reset_work(); + let changed = original.update(grown, Some(&proof), Arc::clone(&ops)); + assert_eq!(work().projection_lines_measured, 0); + let tape = Rendered::from_line_plan(changed.state.plan().clone()).into_tape(0); + let properties = |atom: &super::super::TapeAtom| match atom { + super::super::TapeAtom::Text { properties, .. } + | super::super::TapeAtom::Space { properties, .. } => properties.content_idx, + }; + assert_eq!(properties(&tape.lines[3].atoms[0]), Some(13)); + assert_eq!(properties(&tape.lines[4].atoms[0]), Some(0)); + let selected = changed.state.plan().slice(2..4); + assert_eq!( + selected + .first() + .unwrap() + .materialize() + .atoms + .first() + .unwrap() + .properties() + .content_idx, + Some(12) + ); + let sliced_first = ProjectionState::new(input.slice(1..2), ops); + assert_eq!( + sliced_first + .plan() + .first() + .unwrap() + .materialize() + .atoms + .first() + .unwrap() + .properties() + .content_idx, + Some(10) + ); + assert_exact_metrics(&selected); + } + + #[test] + fn projection_empty_zero_width_and_ordered_decorations_match_eager_oracle() { + let mut mixed = Line::blank(2); + mixed.append(&text("a b", 3)); + let input = LinePlan::from_lines([ + Line::default(), + text("", 0), + text(" \t", 2), + Line::blank(0), + mixed, + ]); + for width in [0, 1, 6] { + let left_properties = + super::super::region_properties(super::super::RegionRole::PaddingLeft, 44, None); + let ops = Arc::from([ + LineOp::OwnContent { + region: 12, + start: 4, + }, + LineOp::CollapseWhitespace { width, region: 12 }, + LineOp::Style(3), + LineOp::EdgeSpaces { + left: 2, + left_properties: Arc::new(left_properties.clone()), + right: 0, + right_properties: Arc::new(AtomProperties::default()), + }, + LineOp::Template(7), + LineOp::ClearBreaks, + LineOp::ScrollWindow(88), + ]); + let state = ProjectionState::new(input.clone(), ops); + let oracle = input + .map_lines(|index, mut line| { + line.own_content(12, 4 + index as i64); + line = line.collapse_whitespace_content(width, 12); + line.apply_style(Some(3)); + line.prepend_space_with_properties(2, left_properties.clone()); + line.push_space(0); + line.apply_property_template(Some(7)); + line.atoms = line.atoms.apply_scroll_window(88); + line + }) + .map_breaks(|properties| { + *properties = AtomProperties { + scroll_window: Some(88), + ..AtomProperties::default() + } + }); + assert_exact_metrics(state.plan()); + assert_eq!( + Rendered::from_line_plan(state.plan().clone()).into_tape(0), + Rendered::from_line_plan(oracle).into_tape(0) + ); + } + } + + #[test] + fn projection_endpoints_repair_metrics_and_propagate_precise_change() { + let input = LinePlan::from_lines([Line::blank(2), Line::blank(2), Line::blank(2)]); + let ops: Arc<[LineOp]> = Arc::from([ + LineOp::FirstStyleRole { + style: 3, + region: 9, + }, + LineOp::LastStyleRole { + style: 4, + region: 9, + }, + ]); + let original = ProjectionState::new(input.clone(), Arc::clone(&ops)); + let grown = LinePlan::from_lines([Line::blank(2)]).concat(&input); + let proof = PlanChange::splices( + &input, + &grown, + vec![LineSplice { + old: 0..0, + new: 0..1, + }], + ); + let changed = original.update(grown.clone(), Some(&proof), ops); + assert!(matches!(changed.change.kind(), ChangeKind::Splices(_))); + assert_exact_metrics(changed.state.plan()); + let oracle = grown.map_lines(|index, mut line| { + if index == 0 { + line.apply_style(Some(3)); + line.apply_role(super::super::RegionRole::BorderTop, 9); + } + if index + 1 == grown.len() { + line.apply_style(Some(4)); + line.apply_role(super::super::RegionRole::BorderBottom, 9); + } + line + }); + assert_eq!( + Rendered::from_line_plan(changed.state.plan().clone()).into_tape(0), + Rendered::from_line_plan(oracle).into_tape(0) + ); + } + + #[test] + fn projection_hints_require_exact_roots_and_replaced_history_is_released() { + let input = LinePlan::from_lines([text("old", 3), text("kept", 4)]); + let removed = Arc::downgrade(input.entries.get(0).unwrap().value()); + let ops: Arc<[LineOp]> = Arc::from([LineOp::AppendSpace(1)]); + let original = ProjectionState::new(input.clone(), Arc::clone(&ops)); + let old_state = Arc::downgrade(&original); + let old_root = Arc::downgrade(&original.plan.identity); + let replacement = input.replace_line(0, text("new", 3)); + let unrelated = input.replace_line(0, text("new", 3)); + let wrong = PlanChange::splices( + &input, + &unrelated, + vec![LineSplice { + old: 0..1, + new: 0..1, + }], + ); + reset_work(); + let fallback = original.update(replacement.clone(), Some(&wrong), Arc::clone(&ops)); + assert_eq!(work().projection_fallback_lines_measured, 2); + assert!(matches!(fallback.change.kind(), ChangeKind::ReplaceAll)); + let proof = PlanChange::splices( + &input, + &replacement, + vec![LineSplice { + old: 0..1, + new: 0..1, + }], + ); + let updated = original.update(replacement.clone(), Some(&proof), Arc::clone(&ops)); + let same = updated.state.update( + replacement, + Some(&PlanChange::same(&updated.state.input)), + ops, + ); + assert!(Arc::ptr_eq(&same.state, &updated.state)); + assert!(matches!(same.change.kind(), ChangeKind::Same)); + let retained = updated.state.plan().clone(); + drop(( + input, original, proof, wrong, unrelated, fallback, updated, same, + )); + assert!(old_state.upgrade().is_none()); + assert!(old_root.upgrade().is_none()); + assert!(removed.upgrade().is_none()); + assert_exact_metrics(&retained); + assert_eq!( + retained + .first() + .unwrap() + .materialize() + .atoms + .first() + .unwrap() + .width(), + 3 + ); + } + + #[test] + fn projection_slice_prunes_old_inputs_and_breaks_follow_their_stage() { + let input = LinePlan::from_lines((0..4).map(|_| text("x", 1))); + let removed = Arc::downgrade(input.entries.get(3).unwrap().value()); + let state = ProjectionState::new(input.clone(), Arc::from([LineOp::ScrollWindow(8)])); + let old_root = Arc::downgrade(&state.plan.identity); + let selected = state.plan().slice(0..2); + let joined = selected.concat(&LinePlan::from_lines([text("y", 1)])); + assert_eq!( + joined.break_after(0).unwrap().materialize().scroll_window, + Some(8) + ); + assert_eq!( + joined.break_after(1).unwrap().materialize().scroll_window, + None + ); + let outer = ProjectionState::new(joined, Arc::from([LineOp::ScrollWindow(9)])); + assert_eq!( + outer + .plan() + .break_after(1) + .unwrap() + .materialize() + .scroll_window, + Some(9) + ); + assert!(outer.plan().break_after(2).is_none()); + drop((input, state, selected)); + assert!(old_root.upgrade().is_none()); + assert!(removed.upgrade().is_none()); + assert_exact_metrics(outer.plan()); + } + + #[test] + fn repeated_projected_break_override_releases_shadowed_roots_and_properties() { + let mut plan = ProjectionState::new( + LinePlan::from_lines([text("a", 1), text("b", 1)]), + Arc::from([LineOp::Style(3)]), + ) + .plan() + .clone() + .with_break( + 0, + AtomProperties { + owner: Some(10), + ..AtomProperties::default() + }, + ); + for owner in 11..75 { + let old_line = plan.slice(0..1); + let old_root = + Arc::downgrade(old_line.extension.as_ref().expect("projected break root")); + let Some(Extension::Break { properties, .. }) = old_line.extension.as_deref() else { + panic!("break override") + }; + let old_properties = Arc::downgrade(properties); + plan = plan.with_break( + 0, + AtomProperties { + owner: Some(owner), + ..AtomProperties::default() + }, + ); + drop(old_line); + assert!( + old_root.upgrade().is_none(), + "old override root retained at {owner}" + ); + assert!( + old_properties.upgrade().is_none(), + "old override properties retained at {owner}" + ); + let line = plan.slice(0..1); + let Some(Extension::Break { input, .. }) = line.extension.as_deref() else { + panic!("current break override") + }; + assert!(!matches!( + input.extension.as_deref(), + Some(Extension::Break { .. }) + )); + assert_eq!( + plan.break_after(0).unwrap().materialize().owner, + Some(owner) + ); + } + } + + #[test] + fn zero_blank_frame_binds_forwarded_changes_across_empty_transitions() { + let mut bindings = Vec::new(); + for starts_empty in [true, false] { + let empty = LinePlan::default(); + let nonempty = LinePlan::from_lines([text("x", 1)]); + let (input, target) = if starts_empty { + (empty, nonempty) + } else { + (nonempty, empty) + }; + let spec = FrameSpec { + prefix: 0, + suffix: 0, + width: 3, + prefix_properties: Arc::new(AtomProperties::default()), + suffix_properties: Arc::new(AtomProperties::default()), + }; + let state = ProjectionState::new_frame(input.clone(), spec.clone()); + let proof = PlanChange::splices( + &input, + &target, + vec![LineSplice { + old: 0..input.len(), + new: 0..target.len(), + }], + ); + let updated = state.update_frame(target.clone(), Some(&proof), spec); + bindings.push(( + updated + .change + .applies_to(state.plan(), updated.state.plan()), + updated.state.plan().ptr_eq(&target), + )); + } + assert_eq!(bindings, [(true, true), (true, true)]); + } + + #[test] + fn retained_blank_frame_matches_vertical_oracle_and_keeps_only_current_state() { + let input = LinePlan::from_lines((0..5).map(|_| text("x", 2))); + let spec = FrameSpec { + prefix: 2, + suffix: 3, + width: 3, + prefix_properties: Arc::new(AtomProperties::default()), + suffix_properties: Arc::new(AtomProperties::default()), + }; + let state = ProjectionState::new_frame(input.clone(), spec.clone()); + let old_state = Arc::downgrade(&state); + let old_root = Arc::downgrade(&state.plan.identity); + let same = state.update_frame(input.clone(), Some(&PlanChange::same(&input)), spec.clone()); + assert!(Arc::ptr_eq(&state, &same.state)); + assert!(matches!(same.change.kind(), ChangeKind::Same)); + let target = LinePlan::from_lines((0..4).map(|_| text("y", 2))).concat(&input.slice(1..5)); + let proof = PlanChange::splices( + &input, + &target, + vec![LineSplice { + old: 0..1, + new: 0..4, + }], + ); + reset_work(); + let updated = state.update_frame( + target.clone(), + Some(&proof), + FrameSpec { + prefix: 1, + suffix: 1, + ..spec.clone() + }, + ); + assert_eq!(work().frame_blank_lines_created, 0); + assert!(matches!(updated.change.kind(), ChangeKind::Splices(_))); + let oracle = super::super::vertical_align( + target.clone(), + 10, + super::super::VerticalAlign::Center, + 3, + ); + assert_eq!( + Rendered::from_line_plan(updated.state.plan().clone()).into_tape(0), + Rendered::from_line_plan(oracle).into_tape(0) + ); + assert_exact_metrics(updated.state.plan()); + // The transaction-local result deliberately owns its exact old root; + // only the retained state must release history after that proof drops. + let ProjectionUpdate { + state: updated, + change, + } = updated; + drop((same, state, input, proof)); + assert!(old_state.upgrade().is_none()); + assert_eq!( + old_root.strong_count(), + 1, + "only the transient change owns the old root" + ); + assert!(Arc::ptr_eq( + &change.base.identity, + &old_root.upgrade().unwrap() + )); + drop(change); + assert!(old_root.upgrade().is_none()); + reset_work(); + let changed_parameters = updated.update_frame( + target, + None, + FrameSpec { + prefix: 1, + suffix: 1, + width: 7, + ..spec + }, + ); + assert_eq!(work().projection_fallbacks, 1); + assert_eq!(work().frame_blank_lines_created, 2); + assert!(matches!( + changed_parameters.change.kind(), + ChangeKind::ReplaceAll + )); + assert_exact_metrics(changed_parameters.state.plan()); + } + + #[test] + fn prefix_and_range_summaries_exclude_exactly_one_terminal_newline() { + let empty = LinePlan::default(); + assert_eq!(empty.prefix_chars(0), 0); + assert_eq!(empty.range_measure(0..0), Measure::default()); + let lines = LinePlan::from_lines([text("界a", 3), Line::default(), text("z", 0)]); + assert_eq!((lines.height(), lines.max_width()), (3, 3)); + assert_eq!( + (0..=3) + .map(|end| lines.prefix_chars(end)) + .collect::>(), + [0, 3, 4, 5] + ); + let middle = lines.range_measure(1..3); + assert_eq!((middle.chars, middle.lines, middle.width_max), (2, 2, 0)); + let single_empty = lines.slice(1..2); + assert_eq!(single_empty.prefix_chars(1), 0); + assert_eq!(single_empty.height(), 1); + let two_empty = LinePlan::from_lines([Line::default(), Line::default()]); + assert_eq!(two_empty.prefix_chars(1), 1); + assert_eq!(two_empty.prefix_chars(2), 1); + assert_eq!(two_empty.range_measure(0..2).chars, 1); + } + + #[test] + fn sliced_terminal_break_never_reappears_as_a_default_join() { + let original = LinePlan::from_lines([text("a", 1), text("b", 1), text("c", 1)]) + .with_break( + 0, + AtomProperties { + owner: Some(11), + ..AtomProperties::default() + }, + ) + .with_break( + 1, + AtomProperties { + scroll_window: Some(12), + ..AtomProperties::default() + }, + ); + let prefix = original.slice(0..1); + assert!(prefix.break_after(0).is_none()); + let joined = prefix.concat(&original.slice(1..3)); + assert_eq!( + joined + .break_after(0) + .map(|view| view.materialize().into_owned()), + Some(AtomProperties::default()) + ); + assert_eq!( + joined.break_after(1).unwrap().materialize().scroll_window, + Some(12) + ); + assert_eq!( + original.break_after(0).unwrap().materialize().owner, + Some(11) + ); + let explicit = prefix.concat_with_break( + &original.slice(1..3), + AtomProperties { + owner: Some(13), + ..AtomProperties::default() + }, + ); + assert_eq!( + explicit.break_after(0).unwrap().materialize().owner, + Some(13) + ); + assert_eq!(prefix.concat(&LinePlan::default()).prefix_chars(1), 1); + } + + #[test] + fn replacement_slice_and_concat_share_paths_across_sizes() { + for size in [32_usize, 128, 512, 8192] { + let original = LinePlan::from_lines((0..size).map(|_| text("a", 1))); + let suffix_entry = Arc::clone(original.entries.get(size - 1).unwrap().value()); + let before = Rendered::from_line_plan(original.clone()).into_tape(0); + reset_work(); + let fork = original.clone(); + assert_eq!(work(), LinePlanWork::default()); + let updated = original.replace_line(1, text("hello", 5)); + let replaced = work(); + assert!(replaced.sequence_nodes_created <= u64::from(size.ilog2()) + 1); + assert!(replaced.sequence_entries_written <= 16); + assert_eq!((replaced.lines_mapped, replaced.materialized_lines), (0, 0)); + assert!(Arc::ptr_eq( + updated.entries.get(size - 1).unwrap().value(), + &suffix_entry + )); + assert_eq!( + updated.prefix_chars(size - 1), + original.prefix_chars(size - 1) + 4 + ); + assert_eq!((updated.height(), updated.max_width()), (size as i64, 5)); + reset_work(); + let selected = updated.slice(size / 4..size * 3 / 4); + let rejoined = updated + .slice(0..size / 4) + .concat(&selected) + .concat(&updated.slice(size * 3 / 4..size)); + let maintained = work(); + assert!(maintained.sequence_nodes_created <= 12 * u64::from(size.ilog2()) + 16); + assert!(maintained.sequence_entries_written <= 128); + assert_eq!( + ( + maintained.lines_visited, + maintained.lines_mapped, + maintained.materialized_lines + ), + (0, 0, 0) + ); + assert!(Arc::ptr_eq( + rejoined.entries.get(size - 1).unwrap().value(), + &suffix_entry + )); + assert_eq!(Rendered::from_line_plan(fork).into_tape(0), before); + let mut oracle = before; + oracle.lines[1] = Rendered::from_lines(vec![text("hello", 5)]) + .into_tape(0) + .lines + .remove(0); + oracle.lines[1].break_after = Some(AtomProperties::default()); + assert_eq!(Rendered::from_line_plan(rejoined).into_tape(0), oracle); + } + } + + #[test] + fn full_maps_iteration_and_tape_materialization_report_real_work() { + let size = 128; + let lines = LinePlan::from_lines((0..size).map(|_| text("a", 1))); + reset_work(); + let mapped = lines.map_lines(|_, mut line| { + line.push_space(1); + line + }); + let line_work = work(); + assert_eq!( + ( + line_work.lines_visited, + line_work.lines_mapped, + line_work.line_clones + ), + (size, size, size) + ); + assert_eq!(line_work.sequence_entries_written, size); + assert_eq!(line_work.iterator_nodes_visited, 15); + assert_eq!(line_work.materialized_lines, 0); + reset_work(); + let decorated = mapped.map_breaks(|properties| properties.owner = Some(9)); + let break_work = work(); + assert_eq!(break_work.lines_visited, size); + assert_eq!(break_work.breaks_mapped, size - 1); + assert_eq!(break_work.sequence_entries_written, size); + assert_eq!(break_work.iterator_nodes_visited, 15); + reset_work(); + let borrowed = decorated.iter().count(); + assert_eq!(borrowed, size as usize); + assert_eq!(work().line_clones, 0); + assert_eq!(work().iterator_nodes_visited, 15); + reset_work(); + let tape = Rendered::from_line_plan(decorated).into_tape(0); + assert_eq!(work().materialized_lines, size); + assert_eq!(work().materialized_breaks, size - 1); + assert_eq!(work().materialized_chars, size * 3 - 1); + assert_eq!(work().lines_visited, size); + assert_eq!(work().iterator_nodes_visited, 15); + assert_eq!(work().line_clones, 0); + assert!(tape.lines.last().unwrap().break_after.is_none()); + } + + #[test] + fn bulk_vertical_stack_shares_children_with_linear_sequence_work() { + for size in [32_usize, 128, 512, 8192] { + let children = (0..size) + .map(|_| LinePlan::from_lines([text("a", 1)])) + .collect::>(); + let parts = children + .iter() + .cloned() + .map(Rendered::from_line_plan) + .collect(); + reset_work(); + let stacked = super::super::stack_vertical(parts); + let joined = work(); + assert_eq!(joined.sequence_nodes_created, size as u64 - 1); + assert_eq!(joined.sequence_entries_written, size as u64 * 4); + assert_eq!( + ( + joined.lines_visited, + joined.line_clones, + joined.materialized_lines + ), + (0, 0, 0) + ); + for (index, child) in children.iter().enumerate() { + assert!(Arc::ptr_eq( + stacked.lines.entries.get(index).unwrap().value(), + child.entries.get(0).unwrap().value() + )); + } + assert_eq!(stacked.lines.prefix_chars(size), size as u64 * 2 - 1); + } + } +} diff --git a/native/src/sequence.rs b/native/src/sequence.rs new file mode 100644 index 0000000..4938502 --- /dev/null +++ b/native/src/sequence.rs @@ -0,0 +1,896 @@ +use std::ops::Range; +use std::sync::Arc; + +const LEAF_CAPACITY: usize = 16; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Measure { + pub chars: u64, + pub lines: u64, + pub width_sum: i64, + pub width_max: i64, +} + +impl Measure { + pub fn new(chars: u64, lines: u64, width_sum: i64, width_max: i64) -> Result { + if width_sum < 0 || width_max < 0 { + return Err(Error::NegativeWidth); + } + Ok(Self { + chars, + lines, + width_sum, + width_max, + }) + } + + pub fn checked_combine(self, other: Self) -> Result { + Ok(Self { + chars: self.chars.checked_add(other.chars).ok_or(Error::Overflow)?, + lines: self.lines.checked_add(other.lines).ok_or(Error::Overflow)?, + width_sum: self + .width_sum + .checked_add(other.width_sum) + .ok_or(Error::Overflow)?, + width_max: self.width_max.max(other.width_max), + }) + } +} + +#[derive(Debug)] +pub struct Entry { + value: Arc, + measure: Measure, +} + +impl Clone for Entry { + fn clone(&self) -> Self { + Self { + value: Arc::clone(&self.value), + measure: self.measure, + } + } +} + +impl Entry { + pub fn new(value: Arc, measure: Measure) -> Result { + if measure.width_sum < 0 || measure.width_max < 0 { + return Err(Error::NegativeWidth); + } + Ok(Self { value, measure }) + } + + pub fn value(&self) -> &Arc { + &self.value + } + + pub fn measure(&self) -> Measure { + self.measure + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Work { + pub nodes_created: u64, + pub entries_written: u64, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Error { + IndexOutOfBounds, + NegativeWidth, + Overflow, +} + +#[derive(Debug)] +pub enum Node { + Leaf { + entries: Box<[Entry]>, + measure: Measure, + }, + Branch { + left: Arc>, + right: Arc>, + len: usize, + height: u32, + measure: Measure, + }, +} + +pub enum NodeView<'a, T> { + Leaf { + entries: &'a [Entry], + measure: Measure, + }, + Branch { + left: &'a Arc>, + right: &'a Arc>, + len: usize, + height: u32, + measure: Measure, + }, +} + +type OptionalRoot = Option>>; +type SplitRoots = (OptionalRoot, OptionalRoot); + +impl Node { + pub fn view(&self) -> NodeView<'_, T> { + match self { + Self::Leaf { entries, measure } => NodeView::Leaf { + entries, + measure: *measure, + }, + Self::Branch { + left, + right, + len, + height, + measure, + } => NodeView::Branch { + left, + right, + len: *len, + height: *height, + measure: *measure, + }, + } + } + + fn len(&self) -> usize { + match self { + Self::Leaf { entries, .. } => entries.len(), + Self::Branch { len, .. } => *len, + } + } + + fn height(&self) -> u32 { + match self { + Self::Leaf { .. } => 1, + Self::Branch { height, .. } => *height, + } + } + + fn measure(&self) -> Measure { + match self { + Self::Leaf { measure, .. } | Self::Branch { measure, .. } => *measure, + } + } +} + +#[derive(Debug)] +pub struct Sequence { + root: Option>>, +} + +/// Borrowed in-order traversal with an O(log N) stack and no entry clones. +pub struct Iter<'a, T> { + pending: Vec<&'a Node>, + leaf: std::slice::Iter<'a, Entry>, + remaining: usize, + nodes_visited: u64, +} + +impl Iter<'_, T> { + pub fn nodes_visited(&self) -> u64 { + self.nodes_visited + } +} + +impl<'a, T> Iterator for Iter<'a, T> { + type Item = &'a Entry; + + fn next(&mut self) -> Option { + loop { + if let Some(entry) = self.leaf.next() { + self.remaining -= 1; + return Some(entry); + } + let node = self.pending.pop()?; + self.nodes_visited = self + .nodes_visited + .checked_add(1) + .expect("sequence traversal count fits u64"); + match node { + Node::Leaf { entries, .. } => self.leaf = entries.iter(), + Node::Branch { left, right, .. } => { + self.pending.push(right); + self.pending.push(left); + } + } + } + } + + fn size_hint(&self) -> (usize, Option) { + (self.remaining, Some(self.remaining)) + } +} + +impl ExactSizeIterator for Iter<'_, T> {} + +impl Clone for Sequence { + fn clone(&self) -> Self { + Self { + root: self.root.as_ref().map(Arc::clone), + } + } +} + +impl Default for Sequence { + fn default() -> Self { + Self::empty() + } +} + +impl Sequence { + pub fn empty() -> Self { + Self { root: None } + } + + pub fn from_entries(entries: I, work: &mut Work) -> Result + where + I: IntoIterator>, + { + let entries = entries.into_iter().collect::>(); + let mut leaves = Vec::with_capacity(entries.len().div_ceil(LEAF_CAPACITY)); + for chunk in entries.chunks(LEAF_CAPACITY) { + leaves.push(make_leaf(chunk.to_vec(), work)?); + } + fn build(leaves: &[Arc>], work: &mut Work) -> Result>, Error> { + if leaves.len() == 1 { + return Ok(Arc::clone(&leaves[0])); + } + let middle = leaves.len() / 2; + make_branch( + build(&leaves[..middle], work)?, + build(&leaves[middle..], work)?, + work, + ) + } + Ok(Self { + root: if leaves.is_empty() { + None + } else { + Some(build(&leaves, work)?) + }, + }) + } + + pub fn len(&self) -> usize { + self.root.as_ref().map_or(0, |root| root.len()) + } + + pub fn is_empty(&self) -> bool { + self.root.is_none() + } + + pub fn measure(&self) -> Measure { + self.root + .as_ref() + .map_or(Measure::default(), |root| root.measure()) + } + + pub fn root(&self) -> Option<&Arc>> { + self.root.as_ref() + } + + pub fn iter(&self) -> Iter<'_, T> { + Iter { + pending: self.root.as_deref().into_iter().collect(), + leaf: [].iter(), + remaining: self.len(), + nodes_visited: 0, + } + } + + pub fn get(&self, index: usize) -> Option<&Entry> { + fn get_at(node: &Node, index: usize) -> Option<&Entry> { + match node { + Node::Leaf { entries, .. } => entries.get(index), + Node::Branch { left, right, .. } => { + if index < left.len() { + get_at(left, index) + } else { + get_at(right, index - left.len()) + } + } + } + } + (index < self.len()).then(|| get_at(self.root.as_deref()?, index))? + } + + pub fn prefix_measure(&self, end: usize) -> Result { + fn prefix(node: &Node, end: usize) -> Result { + match node { + Node::Leaf { entries, .. } => entries[..end] + .iter() + .try_fold(Measure::default(), |sum, entry| { + sum.checked_combine(entry.measure) + }), + Node::Branch { left, right, .. } => { + if end <= left.len() { + prefix(left, end) + } else { + left.measure() + .checked_combine(prefix(right, end - left.len())?) + } + } + } + } + if end > self.len() { + return Err(Error::IndexOutOfBounds); + } + self.root + .as_deref() + .map_or(Ok(Measure::default()), |root| prefix(root, end)) + } + + /// Aggregate a range by visiting its boundary paths and cached interior nodes. + pub fn range_measure(&self, range: Range) -> Result { + fn measure(node: &Node, start: usize, end: usize) -> Result { + if start == end { + return Ok(Measure::default()); + } + if start == 0 && end == node.len() { + return Ok(node.measure()); + } + match node { + Node::Leaf { entries, .. } => entries[start..end] + .iter() + .try_fold(Measure::default(), |sum, entry| { + sum.checked_combine(entry.measure) + }), + Node::Branch { left, right, .. } => { + let middle = left.len(); + if end <= middle { + measure(left, start, end) + } else if start >= middle { + measure(right, start - middle, end - middle) + } else { + measure(left, start, middle)?.checked_combine(measure( + right, + 0, + end - middle, + )?) + } + } + } + } + if range.start > range.end || range.end > self.len() { + return Err(Error::IndexOutOfBounds); + } + self.root.as_deref().map_or(Ok(Measure::default()), |root| { + measure(root, range.start, range.end) + }) + } + + /// Share the interior of a range, copying only its two boundary paths. + pub fn slice(&self, range: Range, work: &mut Work) -> Result { + if range.start > range.end || range.end > self.len() { + return Err(Error::IndexOutOfBounds); + } + let (before, _) = self.split_at(range.end, work)?; + let (_, selected) = before.split_at(range.start, work)?; + Ok(selected) + } + + pub fn split_at(&self, index: usize, work: &mut Work) -> Result<(Self, Self), Error> { + if index > self.len() { + return Err(Error::IndexOutOfBounds); + } + let (left, right) = split_node(self.root.as_ref(), index, work)?; + Ok((Self { root: left }, Self { root: right })) + } + + pub fn concat(&self, other: &Self, work: &mut Work) -> Result { + Ok(Self { + root: join(self.root.clone(), other.root.clone(), work)?, + }) + } + + pub fn replace(&self, index: usize, entry: Entry, work: &mut Work) -> Result { + if index >= self.len() { + return Err(Error::IndexOutOfBounds); + } + Ok(Self { + root: Some(replace_node( + self.root.as_ref().expect("nonempty checked"), + index, + entry, + work, + )?), + }) + } +} + +fn count_node(work: &mut Work) -> Result<(), Error> { + work.nodes_created = work.nodes_created.checked_add(1).ok_or(Error::Overflow)?; + Ok(()) +} + +fn make_leaf(entries: Vec>, work: &mut Work) -> Result>, Error> { + debug_assert!(!entries.is_empty() && entries.len() <= LEAF_CAPACITY); + let measure = entries.iter().try_fold(Measure::default(), |sum, entry| { + sum.checked_combine(entry.measure) + })?; + let written = u64::try_from(entries.len()).map_err(|_| Error::Overflow)?; + let next_written = work + .entries_written + .checked_add(written) + .ok_or(Error::Overflow)?; + count_node(work)?; + work.entries_written = next_written; + Ok(Arc::new(Node::Leaf { + entries: entries.into_boxed_slice(), + measure, + })) +} + +fn make_branch( + left: Arc>, + right: Arc>, + work: &mut Work, +) -> Result>, Error> { + let len = left.len().checked_add(right.len()).ok_or(Error::Overflow)?; + let height = left + .height() + .max(right.height()) + .checked_add(1) + .ok_or(Error::Overflow)?; + let measure = left.measure().checked_combine(right.measure())?; + count_node(work)?; + Ok(Arc::new(Node::Branch { + left, + right, + len, + height, + measure, + })) +} + +fn join( + left: Option>>, + right: Option>>, + work: &mut Work, +) -> Result>>, Error> { + let (left, right) = match (left, right) { + (None, right) => return Ok(right), + (left, None) => return Ok(left), + (Some(left), Some(right)) => (left, right), + }; + if let (Node::Leaf { entries: a, .. }, Node::Leaf { entries: b, .. }) = + (left.as_ref(), right.as_ref()) + { + if a.len() + b.len() <= LEAF_CAPACITY { + let mut entries = Vec::with_capacity(a.len() + b.len()); + entries.extend_from_slice(a); + entries.extend_from_slice(b); + return make_leaf(entries, work).map(Some); + } + } + if left.height() > right.height().saturating_add(1) { + let Node::Branch { + left: outer, + right: inner, + .. + } = left.as_ref() + else { + unreachable!() + }; + let joined = join(Some(Arc::clone(inner)), Some(right), work)?.expect("nonempty join"); + return rebalance(Arc::clone(outer), joined, work).map(Some); + } + if right.height() > left.height().saturating_add(1) { + let Node::Branch { + left: inner, + right: outer, + .. + } = right.as_ref() + else { + unreachable!() + }; + let joined = join(Some(left), Some(Arc::clone(inner)), work)?.expect("nonempty join"); + return rebalance(joined, Arc::clone(outer), work).map(Some); + } + make_branch(left, right, work).map(Some) +} + +fn rebalance( + left: Arc>, + right: Arc>, + work: &mut Work, +) -> Result>, Error> { + if left.height() > right.height().saturating_add(1) { + let Node::Branch { + left: ll, + right: lr, + .. + } = left.as_ref() + else { + unreachable!() + }; + if ll.height() >= lr.height() { + return make_branch( + Arc::clone(ll), + make_branch(Arc::clone(lr), right, work)?, + work, + ); + } + let Node::Branch { + left: lrl, + right: lrr, + .. + } = lr.as_ref() + else { + unreachable!() + }; + return make_branch( + make_branch(Arc::clone(ll), Arc::clone(lrl), work)?, + make_branch(Arc::clone(lrr), right, work)?, + work, + ); + } + if right.height() > left.height().saturating_add(1) { + let Node::Branch { + left: rl, + right: rr, + .. + } = right.as_ref() + else { + unreachable!() + }; + if rr.height() >= rl.height() { + return make_branch( + make_branch(left, Arc::clone(rl), work)?, + Arc::clone(rr), + work, + ); + } + let Node::Branch { + left: rll, + right: rlr, + .. + } = rl.as_ref() + else { + unreachable!() + }; + return make_branch( + make_branch(left, Arc::clone(rll), work)?, + make_branch(Arc::clone(rlr), Arc::clone(rr), work)?, + work, + ); + } + make_branch(left, right, work) +} + +fn split_node( + node: Option<&Arc>>, + index: usize, + work: &mut Work, +) -> Result, Error> { + let Some(node) = node else { + return Ok((None, None)); + }; + if index == 0 { + return Ok((None, Some(Arc::clone(node)))); + } + if index == node.len() { + return Ok((Some(Arc::clone(node)), None)); + } + match node.as_ref() { + Node::Leaf { entries, .. } => Ok(( + Some(make_leaf(entries[..index].to_vec(), work)?), + Some(make_leaf(entries[index..].to_vec(), work)?), + )), + Node::Branch { left, right, .. } => { + if index < left.len() { + let (before, after) = split_node(Some(left), index, work)?; + Ok((before, join(after, Some(Arc::clone(right)), work)?)) + } else if index == left.len() { + Ok((Some(Arc::clone(left)), Some(Arc::clone(right)))) + } else { + let (before, after) = split_node(Some(right), index - left.len(), work)?; + Ok((join(Some(Arc::clone(left)), before, work)?, after)) + } + } + } +} + +fn replace_node( + node: &Arc>, + index: usize, + entry: Entry, + work: &mut Work, +) -> Result>, Error> { + match node.as_ref() { + Node::Leaf { entries, .. } => { + let mut entries = entries.to_vec(); + entries[index] = entry; + make_leaf(entries, work) + } + Node::Branch { left, right, .. } => { + if index < left.len() { + make_branch( + replace_node(left, index, entry, work)?, + Arc::clone(right), + work, + ) + } else { + make_branch( + Arc::clone(left), + replace_node(right, index - left.len(), entry, work)?, + work, + ) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn entry(value: usize) -> Entry { + let text = if value % 5 == 0 { + format!("界{value}") + } else { + value.to_string() + }; + Entry::new( + Arc::new(text.clone()), + Measure::new(text.chars().count() as u64, 1, value as i64, value as i64).unwrap(), + ) + .unwrap() + } + + fn values(sequence: &Sequence) -> Vec { + (0..sequence.len()) + .map(|index| sequence.get(index).unwrap().value().as_ref().clone()) + .collect() + } + + fn height(sequence: &Sequence) -> u32 { + sequence.root().map_or(0, |root| root.height()) + } + + fn shared_nodes(left: &Arc>, right: &Arc>) -> usize { + if Arc::ptr_eq(left, right) { + return 1; + } + match (left.as_ref(), right.as_ref()) { + ( + Node::Branch { + left: ll, + right: lr, + .. + }, + Node::Branch { + left: rl, + right: rr, + .. + }, + ) => shared_nodes(ll, rl) + shared_nodes(lr, rr), + _ => 0, + } + } + + fn assert_valid(node: &Arc>) -> (usize, u32, Measure) { + match node.as_ref() { + Node::Leaf { entries, measure } => { + assert!(!entries.is_empty() && entries.len() <= LEAF_CAPACITY); + let calculated = entries + .iter() + .try_fold(Measure::default(), |sum, entry| { + sum.checked_combine(entry.measure()) + }) + .unwrap(); + assert_eq!(*measure, calculated); + (entries.len(), 1, calculated) + } + Node::Branch { + left, + right, + len, + height, + measure, + } => { + let (left_len, left_height, left_measure) = assert_valid(left); + let (right_len, right_height, right_measure) = assert_valid(right); + assert!(left_height.abs_diff(right_height) <= 1); + assert_eq!(*len, left_len + right_len); + assert_eq!(*height, left_height.max(right_height) + 1); + assert_eq!( + *measure, + left_measure.checked_combine(right_measure).unwrap() + ); + (*len, *height, *measure) + } + } + } + + #[test] + fn empty_and_unicode_measures_are_exact() { + let empty = Sequence::::empty(); + assert!(empty.is_empty()); + assert_eq!(empty.measure(), Measure::default()); + let mut work = Work::default(); + let sequence = Sequence::from_entries([entry(0), entry(12)], &mut work).unwrap(); + assert_eq!(sequence.len(), 2); + assert_eq!(sequence.measure().chars, 4); + assert_eq!(sequence.measure().lines, 2); + assert_eq!(sequence.measure().width_sum, 12); + assert_eq!(sequence.measure().width_max, 12); + assert_eq!(sequence.prefix_measure(1).unwrap().chars, 2); + for size in 1..=257 { + let mut work = Work::default(); + let varied = Sequence::from_entries((0..size).map(entry), &mut work).unwrap(); + assert_eq!(varied.len(), size); + assert_valid(varied.root().unwrap()); + } + } + + #[test] + fn every_split_point_rejoins_exactly() { + let mut setup = Work::default(); + let original = Sequence::from_entries((0..97).map(entry), &mut setup).unwrap(); + let oracle = values(&original); + for index in 0..=original.len() { + let mut work = Work::default(); + let (left, right) = original.split_at(index, &mut work).unwrap(); + assert_eq!(values(&left), oracle[..index]); + assert_eq!(values(&right), oracle[index..]); + let joined = left.concat(&right, &mut work).unwrap(); + assert_eq!(values(&joined), oracle); + assert!(height(&joined) <= 8); + assert_valid(joined.root().unwrap()); + } + } + + #[test] + fn borrowed_iteration_and_range_operations_match_exact_measures() { + let mut setup = Work::default(); + let original = Sequence::from_entries((0..97).map(entry), &mut setup).unwrap(); + let mut iter = original.iter(); + for index in 0..original.len() { + assert_eq!(iter.len(), original.len() - index); + assert!(std::ptr::eq( + iter.next().unwrap(), + original.get(index).unwrap() + )); + } + assert!(iter.next().is_none()); + assert_eq!(iter.nodes_visited(), setup.nodes_created); + for start in 0..=97 { + for end in start..=97 { + let expected = original + .iter() + .skip(start) + .take(end - start) + .try_fold(Measure::default(), |sum, entry| { + sum.checked_combine(entry.measure()) + }) + .unwrap(); + assert_eq!(original.range_measure(start..end).unwrap(), expected); + let mut work = Work::default(); + let selected = original.slice(start..end, &mut work).unwrap(); + assert_eq!(selected.measure(), expected); + assert!(selected + .iter() + .zip(original.iter().skip(start)) + .all(|(a, b)| Arc::ptr_eq(a.value(), b.value()))); + } + } + assert_eq!( + original.slice(98..98, &mut Work::default()).unwrap_err(), + Error::IndexOutOfBounds + ); + assert_eq!(original.range_measure(98..98), Err(Error::IndexOutOfBounds)); + } + + #[test] + fn repeated_left_and_right_concat_remain_balanced() { + let mut left = Sequence::empty(); + let mut right = Sequence::empty(); + let mut work = Work::default(); + for index in 0..2048 { + let one = Sequence::from_entries([entry(index)], &mut work).unwrap(); + left = left.concat(&one, &mut work).unwrap(); + right = one.concat(&right, &mut work).unwrap(); + } + assert_eq!(left.len(), 2048); + assert_eq!(right.len(), 2048); + assert!(height(&left) <= 16, "left height={}", height(&left)); + assert!(height(&right) <= 16, "right height={}", height(&right)); + assert_valid(left.root().unwrap()); + assert_valid(right.root().unwrap()); + } + + #[test] + fn deterministic_random_operations_match_vec_oracle() { + let mut seed = 0x91e1_0da5_u64; + let mut oracle = (0..64).collect::>(); + let mut setup = Work::default(); + let mut sequence = + Sequence::from_entries(oracle.iter().copied().map(entry), &mut setup).unwrap(); + for _ in 0..1000 { + seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1); + let index = (seed as usize) % oracle.len(); + seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1); + let value = (seed >> 16) as usize % 10_000; + oracle[index] = value; + let mut work = Work::default(); + sequence = sequence.replace(index, entry(value), &mut work).unwrap(); + assert!(work.entries_written <= LEAF_CAPACITY as u64); + } + assert_eq!( + values(&sequence), + oracle + .iter() + .map(|value| entry(*value).value().as_ref().clone()) + .collect::>() + ); + assert_valid(sequence.root().unwrap()); + } + + #[test] + fn single_replace_is_logarithmic_and_preserves_old_fork() { + for size in [32, 128, 512, 8192] { + let mut setup = Work::default(); + let original = Sequence::from_entries((0..size).map(entry), &mut setup).unwrap(); + let fork = original.clone(); + let old_value = original.get(1).unwrap().value().clone(); + let mut work = Work::default(); + let updated = original.replace(1, entry(99_999), &mut work).unwrap(); + assert!(work.nodes_created <= u64::from(height(&original)) + 1); + assert!(work.entries_written <= LEAF_CAPACITY as u64); + assert!(shared_nodes(original.root().unwrap(), updated.root().unwrap()) > 0); + assert!(Arc::ptr_eq(fork.get(1).unwrap().value(), &old_value)); + assert_eq!(fork.get(1).unwrap().value().as_str(), "1"); + assert_eq!(updated.get(1).unwrap().value().as_str(), "99999"); + assert_valid(updated.root().unwrap()); + } + } + + #[test] + fn large_split_and_rejoin_touch_only_logarithmic_paths() { + let mut setup = Work::default(); + let original = Sequence::from_entries((0..8192).map(entry), &mut setup).unwrap(); + let mut split_work = Work::default(); + let (left, right) = original.split_at(4097, &mut split_work).unwrap(); + assert!(split_work.nodes_created <= 32, "{split_work:?}"); + assert!(split_work.entries_written <= 32, "{split_work:?}"); + let mut concat_work = Work::default(); + let rejoined = left.concat(&right, &mut concat_work).unwrap(); + assert!(concat_work.nodes_created <= 32, "{concat_work:?}"); + assert_eq!(values(&rejoined), values(&original)); + assert_valid(rejoined.root().unwrap()); + } + + #[test] + fn overflow_and_invalid_updates_leave_the_old_root_unchanged() { + let huge = Entry::new( + Arc::new("x".to_owned()), + Measure::new(u64::MAX, 0, i64::MAX, i64::MAX).unwrap(), + ) + .unwrap(); + let one = Entry::new(Arc::new("y".to_owned()), Measure::new(1, 0, 1, 1).unwrap()).unwrap(); + let mut work = Work::default(); + let base = Sequence::from_entries([huge], &mut work).unwrap(); + let old_root = Arc::clone(base.root().unwrap()); + let mut failed_work = Work::default(); + assert!(matches!( + base.concat( + &Sequence::from_entries([one], &mut Work::default()).unwrap(), + &mut failed_work + ), + Err(Error::Overflow) + )); + assert!(Arc::ptr_eq(base.root().unwrap(), &old_root)); + assert!(matches!( + base.replace(2, entry(2), &mut failed_work), + Err(Error::IndexOutOfBounds) + )); + assert!(Measure::new(0, 0, -1, 0).is_err()); + assert!(Arc::ptr_eq(base.root().unwrap(), &old_root)); + } +} diff --git a/native/src/source_topology.rs b/native/src/source_topology.rs new file mode 100644 index 0000000..8b22954 --- /dev/null +++ b/native/src/source_topology.rs @@ -0,0 +1,214 @@ +//! Immutable source-reference occurrences, independent of layout evaluation phases. + +use std::collections::BTreeMap; +use std::sync::Arc; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub(super) enum LocalStep { + BoxChild, + RowChild(usize), + ColumnChild(usize), + FlexItem(usize), +} + +/// A local structural address stops before crossing an identified node reference. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub(super) struct SourceAddress { + pub(super) owner_id: u64, + pub(super) path: Arc<[LocalStep]>, +} + +/// One structural occurrence can have several distinct layout evaluation uses. +#[derive(Debug, PartialEq, Eq)] +pub(super) struct ParentUse { + pub(super) use_id: u64, + pub(super) parent: SourceAddress, + pub(super) child_slot: LocalStep, + pub(super) target_owner: u64, +} + +/// Explicit bootstrap work; excludes allocator/map internals and layout work/K. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(super) struct TopologyBuildWork { + pub(super) occurrences_written: u64, + pub(super) path_steps_copied: u64, + pub(super) incoming_lists_frozen: u64, +} + +#[derive(Debug)] +pub(super) struct SourceTopology { + root_owner: u64, + incoming: BTreeMap>, + build_work: TopologyBuildWork, +} + +impl SourceTopology { + pub(super) fn root_owner(&self) -> u64 { + self.root_owner + } + + /// Borrows the complete occurrence list; no owner-pair deduplication. + pub(super) fn incoming_uses(&self, owner_id: u64) -> &[ParentUse] { + self.incoming.get(&owner_id).map_or(&[], Box::as_ref) + } + + pub(super) fn build_work(&self) -> TopologyBuildWork { + self.build_work + } +} + +/// Only the bootstrap traversal can create a topology. Scalar deltas share it. +#[derive(Default)] +pub(super) struct TopologyBuilder { + incoming: BTreeMap>, + work: TopologyBuildWork, +} + +impl TopologyBuilder { + pub(super) fn add_use( + &mut self, + parent_owner: u64, + parent_path: &[LocalStep], + child_slot: LocalStep, + target_owner: u64, + ) -> Result<(), String> { + let use_id = self.work.occurrences_written; + let occurrences_written = use_id + .checked_add(1) + .ok_or_else(|| "Native source topology occurrence count overflow".to_owned())?; + let path_steps_copied = self + .work + .path_steps_copied + .checked_add(parent_path.len() as u64) + .ok_or_else(|| "Native source topology path count overflow".to_owned())?; + self.incoming + .entry(target_owner) + .or_default() + .push(ParentUse { + use_id, + parent: SourceAddress { + owner_id: parent_owner, + path: Arc::from(parent_path), + }, + child_slot, + target_owner, + }); + self.work.occurrences_written = occurrences_written; + self.work.path_steps_copied = path_steps_copied; + Ok(()) + } + + /// The caller has already validated owner uniqueness and rejected NodeRef input. + pub(super) fn finish(self, root_owner: u64) -> Arc { + let mut work = self.work; + let incoming = self + .incoming + .into_iter() + .map(|(owner, uses)| { + work.incoming_lists_frozen += 1; + (owner, uses.into_boxed_slice()) + }) + .collect(); + Arc::new(SourceTopology { + root_owner, + incoming, + build_work: work, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn anonymous_paths_and_structural_slots_remain_distinct() { + let mut builder = TopologyBuilder::default(); + builder + .add_use(11, &[LocalStep::BoxChild], LocalStep::FlexItem(3), 29) + .unwrap(); + builder + .add_use( + 29, + &[LocalStep::BoxChild, LocalStep::RowChild(2)], + LocalStep::ColumnChild(1), + 41, + ) + .unwrap(); + let topology = builder.finish(11); + assert_eq!(topology.root_owner(), 11); + assert!(topology.incoming_uses(11).is_empty()); + assert!(topology.incoming_uses(999).is_empty()); + let first = &topology.incoming_uses(29)[0]; + assert_eq!(first.use_id, 0); + assert_eq!(first.parent.owner_id, 11); + assert_eq!(first.parent.path.as_ref(), &[LocalStep::BoxChild]); + assert_eq!(first.child_slot, LocalStep::FlexItem(3)); + assert_eq!(first.target_owner, 29); + let second = &topology.incoming_uses(41)[0]; + assert_eq!(second.parent.owner_id, 29); + assert_eq!(second.use_id, 1); + assert_eq!( + second.parent.path.as_ref(), + &[LocalStep::BoxChild, LocalStep::RowChild(2)] + ); + assert_eq!(second.child_slot, LocalStep::ColumnChild(1)); + assert_eq!( + topology.build_work(), + TopologyBuildWork { + occurrences_written: 2, + path_steps_copied: 3, + incoming_lists_frozen: 2, + } + ); + } + + #[test] + fn every_occurrence_is_retained_even_for_the_same_owner_pair() { + // The storage must not silently deduplicate occurrences. This does not + // authorize duplicate identified owners in the public bootstrap input. + let mut builder = TopologyBuilder::default(); + for slot in [2, 7, 2] { + builder + .add_use(1, &[], LocalStep::RowChild(slot), 9) + .unwrap(); + } + let topology = builder.finish(1); + let uses = topology.incoming_uses(9); + assert_eq!(uses.len(), 3); + assert_eq!( + uses.iter().map(|usage| usage.use_id).collect::>(), + [0, 1, 2] + ); + assert_eq!(uses[0].child_slot, uses[2].child_slot); + assert_ne!(uses[0].use_id, uses[2].use_id); + assert_eq!(topology.build_work().incoming_lists_frozen, 1); + } + + #[test] + fn forks_borrow_the_same_lists_without_changing_build_work() { + for n in [32, 128, 512] { + let mut builder = TopologyBuilder::default(); + for index in 0..n { + builder + .add_use(1, &[], LocalStep::ColumnChild(index), index as u64 + 2) + .unwrap(); + } + let base = builder.finish(1); + let original_work = base.build_work(); + let left = Arc::clone(&base); + let right = Arc::clone(&base); + let target = (n / 2 + 2) as u64; + assert!(Arc::ptr_eq(&base, &left)); + assert!(Arc::ptr_eq(&left, &right)); + assert!(std::ptr::eq( + base.incoming_uses(target), + right.incoming_uses(target) + )); + assert_eq!(base.build_work(), original_work); + assert_eq!(original_work.occurrences_written, n as u64); + assert_eq!(original_work.path_steps_copied, 0); + assert_eq!(original_work.incoming_lists_frozen, n as u64); + } + } +} diff --git a/native/src/source_topology_tests.rs b/native/src/source_topology_tests.rs new file mode 100644 index 0000000..a251422 --- /dev/null +++ b/native/src/source_topology_tests.rs @@ -0,0 +1,162 @@ +// Included in layout::tests after the production bootstrap topology is attached. + +#[test] +fn retained_topology_bootstrap_records_exact_anonymous_use_paths() { + use source_topology::LocalStep; + let leaf = |id| { + identified( + text_box( + id as i64, + measured_text(vec![vec![cluster("x", 1, None)]]), + None, + ), + id, + 1, + ) + }; + let nested_owner = identified(child_box(3, child_box(30, leaf(4), None), None), 3, 1); + let root = identified( + child_box( + 1, + LayoutNode::Column { + node_id: None, + node_revision: None, + children: Arc::new(vec![ + LayoutNode::Row { + node_id: None, + node_revision: None, + children: Arc::new(vec![ + text_box(20, measured_text(vec![vec![cluster("a", 1, None)]]), None), + leaf(2), + ]), + }, + context_test_flex(Size::Auto, vec![nested_owner]), + ]), + }, + None, + ), + 1, + 1, + ); + let full = retained_document(root); + let (retained, _) = RetainedDocument::bootstrap(full.clone()).unwrap(); + let topology = &retained.topology; + assert_eq!(topology.root_owner(), 1); + assert!(topology.incoming_uses(1).is_empty()); + let cases = [ + ( + 2, + 1, + vec![LocalStep::BoxChild, LocalStep::ColumnChild(0)], + LocalStep::RowChild(1), + ), + ( + 3, + 1, + vec![LocalStep::BoxChild, LocalStep::ColumnChild(1)], + LocalStep::FlexItem(0), + ), + (4, 3, vec![LocalStep::BoxChild], LocalStep::BoxChild), + ]; + let mut use_ids = HashSet::new(); + for (target, parent, path, slot) in cases { + let uses = topology.incoming_uses(target); + assert_eq!(uses.len(), 1); + assert_eq!(uses[0].target_owner, target); + assert_eq!(uses[0].parent.owner_id, parent); + assert_eq!(uses[0].parent.path.as_ref(), path.as_slice()); + assert_eq!(uses[0].child_slot, slot); + assert!(use_ids.insert(uses[0].use_id)); + } + assert_eq!(topology.build_work().occurrences_written, 3); + assert_eq!(topology.build_work().path_steps_copied, 5); + assert_eq!( + retained.layout_tape(test_context(), None).unwrap(), + full.layout_tape(test_context(), None).unwrap() + ); +} + +#[test] +fn retained_topology_real_deltas_share_lists_across_forks_and_failures() { + for n in [32, 128, 512] { + let children = (0..n) + .map(|i| { + identified( + text_box( + i + 2, + measured_text(vec![vec![cluster("x", 1, None)]]), + None, + ), + (i + 2) as u64, + 1, + ) + }) + .collect(); + let root = identified( + LayoutNode::Column { + node_id: None, + node_revision: None, + children: Arc::new(children), + }, + 1, + 1, + ); + let (base, _) = RetainedDocument::bootstrap(retained_document(root)).unwrap(); + let target_id = (n / 2 + 2) as u64; + let original = Arc::clone(&base.topology); + let original_list = original.incoming_uses(target_id); + let patch = |region| { + context_test_delta(vec![serde_json::json!({ + "node-id": target_id, "expected-revision": 1, "target-revision": 2, + "slot-patches": [{"slot": 0, "local": {"region-id": region}}] + })]) + }; + let left = base.apply_delta(patch(100_000)).unwrap().document; + let right = base.apply_delta(patch(100_001)).unwrap().document; + for target in [&base, &left, &right] { + assert!(Arc::ptr_eq(&original, &target.topology)); + assert!(std::ptr::eq( + original_list, + target.topology.incoming_uses(target_id) + )); + assert_eq!(target.topology.root_owner(), 1); + } + assert_eq!(original.incoming_uses(target_id)[0].target_owner, target_id); + assert_eq!(original.build_work().occurrences_written, n as u64); + // Later-entry rejection cannot install a partially updated topology. + let failed = base.apply_delta(context_test_delta(vec![ + serde_json::json!({"node-id": target_id, "expected-revision": 1, "target-revision": 2}), + serde_json::json!({"node-id": 999_999, "expected-revision": 1, "target-revision": 2}), + ])); + assert!(failed.is_err()); + assert!(Arc::ptr_eq(&base.topology, &original)); + assert_eq!(radix_lookup(&base.entries, target_id).unwrap().revision, 1); + } +} + +#[test] +fn retained_topology_identity_is_local_to_one_validated_bootstrap() { + let leaf = identified( + text_box(1, measured_text(vec![vec![cluster("x", 1, None)]]), None), + 1, + 1, + ); + let full = retained_document(leaf.clone()); + let (first, _) = RetainedDocument::bootstrap(full.clone()).unwrap(); + let (second, _) = RetainedDocument::bootstrap(full).unwrap(); + assert!(!Arc::ptr_eq(&first.topology, &second.topology)); + assert!(first.topology.incoming_uses(1).is_empty()); + assert_eq!(first.topology.build_work().occurrences_written, 0); + let duplicate = identified( + LayoutNode::Row { + node_id: None, + node_revision: None, + children: Arc::new(vec![leaf.clone(), leaf]), + }, + 2, + 1, + ); + assert!(RetainedDocument::bootstrap(retained_document(duplicate)).is_err()); + let external = identified(child_box(2, LayoutNode::NodeRef { node_id: 1 }, None), 2, 1); + assert!(RetainedDocument::bootstrap(retained_document(external)).is_err()); +} diff --git a/tests/ebox-child-range-tests.el b/tests/ebox-child-range-tests.el index 289a510..f885bf4 100644 --- a/tests/ebox-child-range-tests.el +++ b/tests/ebox-child-range-tests.el @@ -355,8 +355,8 @@ (should-not (plist-member empty-record :sequence)) (should (= (plist-get empty-resolved :rank) 1)) (should (= (plist-get items-resolved :rank) 1)) - (should (= (hash-table-count (plist-get state :node-table)) - (hash-table-count (plist-get direct-state :node-table)))) + (should (= (ebox-runtime-index-size (plist-get state :node-table)) + (ebox-runtime-index-size (plist-get direct-state :node-table)))) (should (= (hash-table-count (plist-get state :region-id-set)) (hash-table-count (plist-get direct-state :region-id-set)))) @@ -482,7 +482,9 @@ (node-id (gethash ref (ebox-child-range-test--host-ref-table state))) - (node (gethash node-id (plist-get state :node-table)))) + (node + (ebox-runtime-index-get + node-id (plist-get state :node-table)))) (should node-id) (should (equal (ebox-child-range-test--node-key @@ -668,7 +670,7 @@ (ebox-commit buffer candidate)) (let* ((state (ebox--buffer-render-state buffer)) (record (gethash 'items (plist-get state :range-ref-table))) - (parent (gethash (plist-get record :parent-node-id) + (parent (ebox-runtime-index-get (plist-get record :parent-node-id) (plist-get state :node-table))) (segment (ebox-child-range--lookup-ref @@ -742,7 +744,7 @@ (ebox-commit buffer candidate)) (let* ((state (ebox--buffer-render-state buffer)) (record (gethash 'items (plist-get state :range-ref-table))) - (parent (gethash (plist-get record :parent-node-id) + (parent (ebox-runtime-index-get (plist-get record :parent-node-id) (plist-get state :node-table))) (segment (ebox-child-range--lookup-ref @@ -879,7 +881,7 @@ (parents (plist-get (ebox--buffer-render-state (current-buffer)) :parent-table)) result) - (while (setq node-id (gethash node-id parents)) + (while (setq node-id (ebox-runtime-index-get node-id parents)) (push node-id result)) result))) (dolist (expanded '(t nil t)) @@ -2073,7 +2075,7 @@ node-id (plist-get state :surface-node-object-table)))) - (should (eq node (gethash node-id + (should (eq node (ebox-runtime-index-get node-id (plist-get state :node-table)))) (should object) (should (eq object (plist-get node :surface-object)))))))) @@ -2166,7 +2168,9 @@ (current-id (gethash 'child (ebox-child-range-test--host-ref-table state))) - (node (gethash current-id (plist-get state :node-table)))) + (node + (ebox-runtime-index-get + current-id (plist-get state :node-table)))) (when node-id (should (equal node-id current-id))) (setq node-id current-id) (should (plist-get node :ebox-sequence-location)))) diff --git a/tests/ebox-commit-tests.el b/tests/ebox-commit-tests.el index d6e9727..ef96f97 100644 --- a/tests/ebox-commit-tests.el +++ b/tests/ebox-commit-tests.el @@ -189,19 +189,21 @@ (should-not (ebox-incremental--owner-disjoint-from-scroll-p (current-buffer) state prepared candidate owner))) (plist-put candidate :region-box-table (plist-get state :region-box-table)) - (let ((parents (copy-hash-table (plist-get state :parent-table)))) - (puthash scroll-id owner parents) + (let ((parents (ebox-incremental--candidate-copy-index-table + state :parent-table 'equal))) + (setq parents (ebox-runtime-index-put scroll-id owner parents)) (plist-put candidate :parent-table parents) (should-not (ebox-incremental--owner-disjoint-from-scroll-p (current-buffer) state prepared candidate owner))) (plist-put candidate :parent-table (plist-get state :parent-table)) - (let* ((parents (copy-hash-table (plist-get state :parent-table))) + (let* ((parents (ebox-incremental--candidate-copy-index-table + state :parent-table 'equal)) (overlapping-old-state (copy-sequence state)) (ebox-incremental--buffer-render-state-override (cons (current-buffer) candidate))) ;; The caller may already expose candidate ancestry. Old-state ;; ancestry must still reject an owner that used to be inside scroll. - (puthash owner scroll-id parents) + (setq parents (ebox-runtime-index-put owner scroll-id parents)) (plist-put overlapping-old-state :parent-table parents) (should-not (ebox-incremental--owner-disjoint-from-scroll-p (current-buffer) overlapping-old-state prepared @@ -838,9 +840,10 @@ (defun ebox-commit-test--hash-facts (table &optional values) "Return sorted TABLE keys, or key/value pairs when VALUES is non-nil." (let (facts) - (maphash (lambda (key value) - (push (if values (cons key value) key) facts)) - table) + (ebox-runtime-index-map + (lambda (key value) + (push (if values (cons key value) key) facts)) + table) (sort facts (lambda (left right) (string< (prin1-to-string left) @@ -1448,7 +1451,7 @@ new-root new-source-index)) (new-index (ebox--runtime-index new-root t new-source-index)) (root-id (plist-get old-root :node-id))) - (maphash (lambda (node-id _node) + (ebox-runtime-index-map (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 @@ -1495,7 +1498,7 @@ new-root new-source-index)) (new-index (ebox--runtime-index new-root t new-source-index)) (root-id (plist-get old-root :node-id))) - (maphash (lambda (node-id _node) + (ebox-runtime-index-map (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 @@ -2137,20 +2140,24 @@ remain retained identities." (node-table (plist-get state :node-table)) (parent-table (plist-get state :parent-table)) (node-id (and region-node-table - (gethash value region-node-table))) + (ebox-runtime-index-get + value region-node-table))) (source-node-id node-id) (root-node-id (plist-get (plist-get state :root-node) :node-id)) key) (while (and node-id (not key)) - (when-let* ((node (gethash node-id node-table))) + (when-let* ((node + (ebox-runtime-index-get + node-id node-table))) (setq key (ebox-tree-node-key (plist-get state :source-index) node))) (setq node-id (and (not key) - (gethash node-id parent-table)))) + (ebox-runtime-index-get + node-id parent-table)))) (or key (and (equal source-node-id root-node-id) 'root) value)) @@ -2828,7 +2835,7 @@ Normalize only buffer-local region identifiers; retain every text property." (or (gethash id regions) (ert-fail (list :unknown-region id))))) (walk (plist-get state :root-node) '(root)) - (maphash (lambda (region node-id) + (ebox-runtime-index-map (lambda (region node-id) (puthash region (gethash node-id paths) regions)) (plist-get state :region-node-table)) (let ((position 0)) @@ -2887,7 +2894,8 @@ Normalize only buffer-local region identifiers; retain every text property." (ebox-commit-test--allocated-single-root "short" "#123456")) (let* ((state (ebox--buffer-render-state (current-buffer))) (owner-id - (cl-loop for node being the hash-values of (plist-get state :node-table) + (cl-loop for id in (ebox-runtime-index-keys (plist-get state :node-table)) + for node = (ebox-runtime-index-get id (plist-get state :node-table)) when (eq (ebox-tree-node-author-key (plist-get state :source-index) node) 'selection-panel) @@ -3728,7 +3736,7 @@ When INHERITED-P is non-nil, keep ancestor color across an untouched sibling." (regions (make-hash-table :test #'eql))) (dolist (entry (nodes state)) (puthash (plist-get (cdr entry) :node-id) (car entry) paths)) - (maphash (lambda (region node-id) + (ebox-runtime-index-map (lambda (region node-id) (puthash region (gethash node-id paths) regions)) (plist-get state :region-node-table)) regions)) @@ -3862,10 +3870,10 @@ When INHERITED-P is non-nil, keep ancestor color across an untouched sibling." (ebox-source--subject-table-get subjects (ebox-tree-node-source-handle peer))) (parent-id - (gethash (plist-get peer :node-id) + (ebox-runtime-index-get (plist-get peer :node-id) (plist-get state :parent-table))) (parent - (gethash parent-id (plist-get state :node-table))) + (ebox-runtime-index-get parent-id (plist-get state :node-table))) (parent-subject (ebox-source--subject-table-get subjects (ebox-tree-node-source-handle parent)))) @@ -4652,6 +4660,187 @@ When INHERITED-P is non-nil, keep ancestor color across an untouched sibling." (ebox-native-reflow--persistent-index-get next 17))) (should-not (eq base next)))) +(ert-deftest ebox-logical-candidate-path-copies-runtime-indexes-only () + "A local logical commit shares its base and never copies a complete core map." + (let* ((input + (ebox-test-column + (ebox-test-box :key 'message :source-identity 'message + (ebox-test-text "old")) + (ebox-test-box :key 'tail (ebox-test-text "tail")))) + (buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-runtime-index-local*") input)) + (old-state (ebox--buffer-render-state buffer)) + (old-nodes (plist-get old-state :node-table)) + (old-parents (plist-get old-state :parent-table)) + (old-regions (plist-get old-state :region-node-table)) + (old-postorder (plist-get old-state :native-node-postorder)) + (old-message (ebox--host-ref-node buffer 'message)) + (original-copy (symbol-function 'copy-hash-table)) + copied-core) + (unwind-protect + (let ((candidate (ebox-candidate-begin buffer))) + (ebox-candidate-replace-host-ref + candidate 'message + (ebox-test-box :key 'message :source-identity 'message + (ebox-test-text "new"))) + (cl-letf (((symbol-function 'copy-hash-table) + (lambda (table) + (when (memq table + (list old-nodes old-parents old-regions)) + (setq copied-core t)) + (funcall original-copy table)))) + (ebox-commit buffer candidate)) + (let* ((state (ebox--buffer-render-state buffer)) + (nodes (plist-get state :node-table)) + (parents (plist-get state :parent-table)) + (regions (plist-get state :region-node-table))) + (should-not copied-core) + (should (ebox-runtime-index-p nodes)) + (should (ebox-runtime-index-p parents)) + (should (ebox-runtime-index-p regions)) + (should-not (eq nodes old-nodes)) + (should (eq regions old-regions)) + (should (eq (plist-get state :native-node-postorder) + old-postorder)) + (should (eq (plist-get state :native-node-postorder-ids) + old-postorder)) + (should (eq (ebox-runtime-index-get + (plist-get old-message :node-id) old-nodes) + old-message)) + (should + (cl-every + (lambda (node-id) + (ebox-runtime-index-get node-id nodes)) + old-postorder)))) + (when (buffer-live-p buffer) (kill-buffer buffer))))) + +(ert-deftest ebox-logical-candidate-converts-legacy-native-postorder () + "Legacy and mixed vectors resolve every entry against candidate nodes." + (let* ((old-leaf '(:ebox-type text :node-id 1 :content "old")) + (old-root (list :ebox-type 'column :node-id 2 + :children (list old-leaf))) + (new-leaf '(:ebox-type text :node-id 1 :content "new")) + (new-root (list :ebox-type 'column :node-id 2 + :children (list new-leaf)))) + (dolist (table (list (ebox-runtime-index-empty) + (make-hash-table :test 'equal))) + (setq table (ebox-runtime-index-put 1 new-leaf table) + table (ebox-runtime-index-put 2 new-root table)) + (dolist (legacy (list (vector old-leaf old-root) + (vector 1 old-root) + (vector old-leaf 2))) + (let* ((before (copy-sequence legacy)) + (state (list :native-node-postorder legacy + :native-node-postorder-ids (vector 1 2))) + (postorder + (ebox-incremental--candidate-retain-native-postorder + state table))) + (should (equal postorder [1 2])) + (should-not (eq postorder legacy)) + (should (equal legacy before)) + (should (eq (ebox-native-reflow--postorder-node + (aref postorder 0) table) + new-leaf)) + (should (eq (ebox-native-reflow--postorder-node + (aref postorder 1) table) + new-root))))))) + +(ert-deftest ebox-logical-candidate-native-postorder-validates-legacy-membership () + "Unmarked retained entries reject missing candidate nodes; empty is valid." + (let* ((node '(:ebox-type text :node-id 1 :content "old")) + (table (ebox-runtime-index-empty))) + (should-error + (ebox-incremental--candidate-retain-native-postorder + (list :native-node-postorder (vector node)) table) + :type 'error) + (should (equal + (ebox-incremental--candidate-retain-native-postorder + (list :native-node-postorder []) table) + [])) + (should-not + (ebox-incremental--candidate-retain-native-postorder nil table)))) + +(ert-deftest ebox-logical-candidate-retains-native-id-postorder-without-lookups () + "Generated ID vectors retain their exact identity without walking nodes." + (let* ((input (ebox-test-column (ebox-test-text "retained"))) + (root (ebox-test-root input)) + (index (ebox--runtime-index root nil (ebox-test-source-index input))) + (postorder (plist-get index :native-node-postorder))) + (should (eq postorder (plist-get index :native-node-postorder-ids))) + (cl-letf (((symbol-function 'ebox-runtime-index-get) + (lambda (&rest _arguments) + (ert-fail "Generated native ID postorder was remapped")))) + (should (eq postorder + (ebox-incremental--candidate-retain-native-postorder + index (plist-get index :node-table))))))) + +(defun ebox-test--native-topology-full-gate-fixture () + "Return `(BUFFER OLD-STATE CANDIDATE-STATE PREPARED TAIL)' for a paint edit." + (let* ((previous-message + (ebox-test-box :key 'message :source-identity 'message + :background-color "#111111" + (ebox-test-text "message"))) + (previous + (ebox-test-column + previous-message + (ebox-test-box :key 'tail :source-identity 'tail + (ebox-test-text "tail")))) + (next + (ebox-test-box :key 'message :source-identity 'message + :background-color "#222222" + (ebox-test-text "message"))) + (buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-topology-authority*") previous)) + (old-state (ebox--buffer-render-state buffer)) + (candidate (ebox-candidate-begin buffer))) + (unless (ebox-candidate-patch-host-paint + candidate 'message + previous-message next) + (error "Topology authority fixture paint patch was rejected")) + (let* ((prepared + (ebox-incremental--prepare-logical-candidate + buffer old-state candidate)) + (candidate-state + (ebox-incremental--candidate-state + old-state (plist-get prepared :root) + (plist-get prepared :index) prepared))) + (list buffer old-state candidate-state prepared + (ebox--host-ref-node buffer 'tail))))) + +(ert-deftest ebox-native-topology-full-gate-rejects-untouched-object-loss () + "The full topology gate rejects a missing untouched surface object." + (pcase-let ((`(,buffer ,old-state ,candidate-state ,prepared ,tail) + (ebox-test--native-topology-full-gate-fixture))) + (unwind-protect + (progn + (should (ebox-native-commit-topology-stable-p + old-state candidate-state prepared)) + (remhash (plist-get tail :node-id) + (plist-get old-state :surface-node-object-table)) + (should-not (ebox-native-commit-topology-stable-p + old-state candidate-state prepared))) + (when (buffer-live-p buffer) (kill-buffer buffer))))) + +(ert-deftest ebox-native-topology-full-gate-rejects-untouched-source-key-change () + "The full topology gate rejects an untouched source key change." + (pcase-let ((`(,buffer ,old-state ,candidate-state ,prepared ,tail) + (ebox-test--native-topology-full-gate-fixture))) + (unwind-protect + (progn + (should (ebox-native-commit-topology-stable-p + old-state candidate-state prepared)) + (let* ((source-index (plist-get candidate-state :source-index)) + (binding + (ebox-source-index-rebind + source-index (ebox-tree-node-source-handle tail) + :key 'tampered-tail))) + (plist-put candidate-state :source-index (car binding))) + (should-not (ebox-native-commit-topology-stable-p + old-state candidate-state prepared))) + (when (buffer-live-p buffer) (kill-buffer buffer))))) + (ert-deftest ebox-native-session-fork-shares-immutable-compiler-roots () "Forking does not clone the retained fragment map or persistent indexes." (require 'ebox-native-reflow) diff --git a/tests/ebox-core-render-tests.el b/tests/ebox-core-render-tests.el index 1227535..1e1d2f2 100644 --- a/tests/ebox-core-render-tests.el +++ b/tests/ebox-core-render-tests.el @@ -1012,6 +1012,31 @@ (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) +(ert-deftest ebox-runtime-bootstrap-uses-persistent-core-indexes-and-id-postorder () + "Runtime bootstrap stores persistent core indexes and stable node ids." + (ebox-test--reset-runtime-state) + (let* ((input + (ebox-test-column + (ebox-test-box :key 'left (ebox-test-text "Left")) + (ebox-test-box :key 'right (ebox-test-text "Right")))) + (root (ebox-test-root input)) + (index (ebox--runtime-index root t (ebox-test-source-index input))) + (nodes (plist-get index :node-table)) + (postorder (plist-get index :native-node-postorder))) + (should (ebox-runtime-index-p nodes)) + (should (ebox-runtime-index-p (plist-get index :parent-table))) + (should (ebox-runtime-index-p (plist-get index :region-node-table))) + (should (vectorp postorder)) + (should (> (length postorder) 0)) + (should + (cl-every + (lambda (node-id) + (and (not (listp node-id)) + (ebox-runtime-index-get node-id nodes))) + postorder)) + (should (equal (aref postorder (1- (length postorder))) + (plist-get root :node-id))))) + (ert-deftest ebox-region-render-owner-index-keeps-earlier-duplicate-box () "A later flex wrapper should not replace an earlier duplicate region owner." (ebox-test--reset-runtime-state) @@ -1031,7 +1056,7 @@ (expected (car (ebox--node-path-to-region layout-node region-id)))) (should (eq expected plain-node)) - (should (equal (gethash region-id table) + (should (equal (ebox-runtime-index-get region-id table) (ebox--ensure-node-id expected)))))) (ert-deftest ebox-region-render-owner-index-falls-back-for-legacy-state () @@ -1764,7 +1789,7 @@ (kill-buffer buffer))))) (ert-deftest ebox-root-width-cache-prewarm-yields-to-user-input () - "Ordinary root prediction should run only after an idle interval." + "Ordinary root prediction should wait a fresh interval even while idle." (ebox-test--reset-runtime-state) (let* ((layout (ebox-test-box :id "root" :width '(240) @@ -1786,7 +1811,9 @@ (generate-new-buffer-name " *ebox-root-schedule*") layout)) (let ((root-id (ebox-test--selector-region-id buffer "#root"))) - (cl-letf (((symbol-function 'run-at-time) + (cl-letf (((symbol-function 'current-idle-time) + (lambda () (seconds-to-time 0.4))) + ((symbol-function 'run-at-time) (lambda (&rest _) (ert-fail "Resize prediction must yield to input"))) ((symbol-function 'run-with-idle-timer) @@ -1799,7 +1826,7 @@ (ebox--schedule-buffer-root-width-cache-prewarm buffer root-id 240 260))) (should (= scheduled-delay - ebox-runtime-idle-reflow-cache-prewarm-delay)) + (+ 0.4 ebox-runtime-idle-reflow-cache-prewarm-delay))) (should-not scheduled-repeat) (should (eq scheduled-function #'ebox--run-root-width-cache-prewarm)) @@ -1809,6 +1836,72 @@ (when (buffer-live-p buffer) (kill-buffer buffer))))) +(ert-deftest ebox-reflow-prewarm-replacement-and-one-shot-completion () + "Fresh and zero-delay predictions replace old jobs and complete once." + (ebox-test--reset-runtime-state) + (let ((ebox-viewport-width 900) + (ebox-viewport-height 4) + (ebox-runtime-idle-prewarm nil) + (ebox-runtime-idle-reflow-cache-prewarm t) + (ebox--runtime-prewarm-allow-noninteractive t) + buffer) + (unwind-protect + (progn + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-prewarm-one-shot*") + (ebox-test-box :id "root" :width '(240) :height 1 + (ebox-test-text "prediction")))) + (let ((state (ebox--buffer-render-state buffer)) + (root-id (ebox-test--selector-region-id buffer "#root"))) + (dolist (kind '(viewport root-width)) + (dolist (case '((nil 0.15 0.15) (0.4 0 0.4))) + (pcase-let* ((`(,idle-age ,delay ,threshold) case) + (ebox-runtime-idle-reflow-cache-prewarm-delay delay) + (scratch (plist-get state :reflow-prewarm-scratch)) + (cancel (symbol-function 'cancel-timer)) + (registrations 0) + (scheduled nil) + (cancelled nil)) + (cl-letf + (((symbol-function 'current-idle-time) + (lambda () (and idle-age (seconds-to-time idle-age)))) + ((symbol-function 'cancel-timer) + (lambda (timer) + (push timer cancelled) + (funcall cancel timer))) + ((symbol-function 'run-with-idle-timer) + (lambda (seconds repeat function &rest arguments) + (cl-incf registrations) + (setq scheduled (list seconds repeat function arguments)) + (timer-create)))) + (dotimes (attempt 2) + (let ((old (gethash buffer ebox--reflow-cache-prewarm-timers))) + (pcase kind + ('viewport + (ebox--schedule-buffer-reflow-cache-prewarm + buffer 880 900)) + ('root-width + (ebox--schedule-buffer-root-width-cache-prewarm + buffer root-id 240 260))) + (when (= attempt 1) + (should (memq old cancelled)) + (should-not + (eq old (gethash buffer ebox--reflow-cache-prewarm-timers)))))) + (should (= registrations 2)) + (should (= (car scheduled) threshold)) + (should-not (cadr scheduled)) + (should (eq scratch (plist-get state :reflow-prewarm-scratch))) + ;; Advance the same idle period beyond the fixed threshold. + ;; Run the real callback, including its state/revision guards. + (setq idle-age (+ threshold 1)) + (should (apply (nth 2 scheduled) (nth 3 scheduled))) + (should (plist-get state :reflow-prewarm-scratch)) + (should-not (gethash buffer ebox--reflow-cache-prewarm-timers)) + (should (= registrations 2)))))))) + (when (buffer-live-p buffer) + (kill-buffer buffer))))) + (ert-deftest ebox-scheduled-reflow-prewarms-use-offscreen-gc-pressure () "Both predicted reflow paths should move GC pressure off visible frames." (let ((prewarm-count 0) @@ -2622,7 +2715,7 @@ (should (eq (plist-get report :constraint-owner-type) 'box)) (should (equal (plist-get report :constraint-owner-id) - (gethash left-id + (ebox-runtime-index-get left-id (ebox--buffer-region-node-table (current-buffer))))) (should (equal (plist-get report :dirty-kinds) @@ -11090,7 +11183,7 @@ line compositor's complete property output, including empty lines and chrome." :rendered-region-line-span-index-deferred)))))) (ert-deftest ebox-reflow-cache-prewarm-uses-first-valid-width-delta () - "Predictive warming should start from the first valid resize velocity." + "First resize prediction should wait a fresh interval even while idle." (ebox-test--reset-runtime-state) (let* ((layout (ebox-test-box :id "root" :width '(viewport) :height 2 @@ -11110,7 +11203,9 @@ line compositor's complete property output, including empty lines and chrome." (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") layout)) - (cl-letf (((symbol-function 'run-at-time) + (cl-letf (((symbol-function 'current-idle-time) + (lambda () (seconds-to-time 0.4))) + ((symbol-function 'run-at-time) (lambda (&rest _) (ert-fail "Resize prediction must yield to input"))) ((symbol-function 'run-with-idle-timer) @@ -11122,7 +11217,7 @@ line compositor's complete property output, including empty lines and chrome." 'scheduled-reflow-timer))) (ebox-rerender-buffer-with-context buffer 260 4)) (should (= scheduled-delay - ebox-runtime-idle-reflow-cache-prewarm-delay)) + (+ 0.4 ebox-runtime-idle-reflow-cache-prewarm-delay))) (should-not scheduled-repeat) (should (eq scheduled-function #'ebox--run-reflow-cache-prewarm)) (should (equal (car scheduled-arguments) buffer)) @@ -13913,4 +14008,35 @@ face patch must match that exactly (issue014)." (should (= (plist-get peer-after :node-id) peer-node-id))))) (when (buffer-live-p buffer) (kill-buffer buffer)))))) +(ert-deftest ebox-scroll-span-index-scans-property-runs-once () + "Index all regions in one pass while preserving single-region semantics." + (let* ((line (apply #'concat + (cl-loop for id from 1 to 32 + collect (propertize "x" 'ebox-content id + 'ebox-content-owners '(999))))) + (lines (list line + (concat (propertize "a" 'ebox-content 7) + "gap" (propertize "b" 'ebox-content 7)) + "plain")) + (expected (make-hash-table :test 'equal)) + (next (symbol-function 'ebox--string-next-region-property-change)) + (calls 0) actual) + ;; The established query is an independent oracle for compatibility and + ;; disconnected hulls, including rejection of a foreign ancestor owner. + (cl-loop for text in lines for number from 0 do + (dolist (id (ebox--scroll-line-region-ids text)) + (when-let* ((span (ebox--scroll-line-region-span + text (ebox--region-id-set (list id))))) + (puthash id (append (gethash id expected) + (list (cons number span))) expected)))) + (cl-letf (((symbol-function 'ebox--string-next-region-property-change) + (lambda (&rest args) + (cl-incf calls) + (apply next args)))) + (setq actual (ebox--scroll-build-region-line-span-index lines))) + (should (= (hash-table-count actual) (hash-table-count expected))) + (maphash (lambda (id spans) (should (equal (gethash id actual) spans))) expected) + (should-not (gethash 999 actual)) + (should (= calls 36)))) + ;;; ebox-core-render-tests.el ends here diff --git a/tests/ebox-docs-contract-tests.el b/tests/ebox-docs-contract-tests.el index 1b90c38..d0d0cf3 100644 --- a/tests/ebox-docs-contract-tests.el +++ b/tests/ebox-docs-contract-tests.el @@ -24,14 +24,16 @@ (defconst ebox-docs-test--active-files '("Makefile" ".github/workflows/ci.yml" - "ebox.el" "ebox-cache.el" "ebox-state-contract.el" "ebox-style.el" + "ebox.el" "ebox-cache.el" "ebox-runtime-index.el" + "ebox-state-contract.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-patch-plan.el" "ebox-incremental.el" "ebox-surface.el" "ebox-dsl.el" "ebox-spi.el" "ebox-selector.el" "ebox-native-reflow.el" - "tests/ebox-core-render-tests.el" "tests/ebox-state-contract-tests.el" + "tests/ebox-core-render-tests.el" "tests/ebox-runtime-index-tests.el" + "tests/ebox-state-contract-tests.el" "tests/ebox-layout-boundary-tests.el" "tests/ebox-patch-plan-tests.el" "tests/ebox-style-schema-tests.el" @@ -50,7 +52,8 @@ "Active Ebox files that the maintainer map must cover.") (defconst ebox-docs-test--targets - '("check" "ci" "load" "compile" "core-tests" "child-range-tests" "grid-tests" + '("check" "ci" "load" "compile" "core-tests" "runtime-index-tests" + "child-range-tests" "grid-tests" "ebox-commit-tests" "surface-tests" "visual-check-tests" "package-tests" "selector-tests" "dsl-tests" "flex-tests" "state-contract-tests" "layout-boundary-tests" "layout-boundary-performance" diff --git a/tests/ebox-node-memo-generation-tests.el b/tests/ebox-node-memo-generation-tests.el index 2933584..027aa14 100644 --- a/tests/ebox-node-memo-generation-tests.el +++ b/tests/ebox-node-memo-generation-tests.el @@ -32,7 +32,7 @@ (defun ebox-node-memo-test--counts (state) "Return total/current/stale key counts for the node memos in STATE." (let ((current (make-hash-table :test 'eq))) - (maphash (lambda (_ node) (puthash node t current)) + (ebox-runtime-index-map (lambda (_ node) (puthash node t current)) (plist-get state :node-table)) (mapcar (lambda (key) @@ -82,7 +82,7 @@ (initial-text (with-current-buffer buffer (buffer-string))) (objects (copy-hash-table (plist-get initial-state :surface-node-object-table))) - (node-count (hash-table-count (plist-get initial-state :node-table)))) + (node-count (ebox-runtime-index-size (plist-get initial-state :node-table)))) (should (ebox-host-ref-position buffer host)) (push (cons 0 (ebox-node-memo-test--counts initial-state)) history) ;; A later cache hit may legitimately leave a node memo empty; @@ -97,7 +97,7 @@ (let ((state (ebox--buffer-render-state buffer))) (should (eq (plist-get (ebox-buffer-update-report buffer) :viewport-axes) axes)) - (should (= node-count (hash-table-count (plist-get state :node-table)))) + (should (= node-count (ebox-runtime-index-size (plist-get state :node-table)))) (should (ebox-host-ref-position buffer host)) (maphash (lambda (id object) @@ -210,7 +210,7 @@ (lambda (id _) (let ((box (gethash id regions))) (should box) - (should (eq box (gethash (plist-get box :node-id) nodes))))) + (should (eq box (ebox-runtime-index-get (plist-get box :node-id) nodes))))) ids)))) (should cache-hits)) (when (buffer-live-p buffer) (kill-buffer buffer))))) diff --git a/tests/ebox-package-tests.el b/tests/ebox-package-tests.el index 016c30b..e4dca9b 100644 --- a/tests/ebox-package-tests.el +++ b/tests/ebox-package-tests.el @@ -156,7 +156,8 @@ (should (commandp 'ebox-byte-compile)) (should (equal ebox--compile-sources - '("ebox-cache.el" "ebox-source.el" "ebox-state-contract.el" + '("ebox-cache.el" "ebox-source.el" "ebox-runtime-index.el" + "ebox-state-contract.el" "ebox-font.el" "ebox-style.el" "ebox-layout-config.el" "ebox-node-factory.el" diff --git a/tests/ebox-runtime-index-tests.el b/tests/ebox-runtime-index-tests.el new file mode 100644 index 0000000..7753e8a --- /dev/null +++ b/tests/ebox-runtime-index-tests.el @@ -0,0 +1,295 @@ +;;; ebox-runtime-index-tests.el --- Runtime index tests -*- lexical-binding: t; -*- + +;; SPDX-License-Identifier: GPL-3.0-or-later + +;;; Code: + +(require 'ert) +(require 'cl-lib) +(require 'ebox-runtime-index) + +(defun ebox-runtime-index-test--shape (index) + "Validate compressed INDEX and return `(BRANCHES LEAVES MAX-DEPTH)'." + (let ((branches 0) (leaves 0) (max-depth 0)) + (cl-labels + ((visit (node depth parent-shift mask prefix) + (setq max-depth (max max-depth depth)) + (cond + ((vectorp node) + (should (= (length node) 18)) + (let ((shift (aref node 0)) + (code (aref node 1)) + (children 0)) + (should (and (integerp shift) (<= 0 shift 60) + (= (% shift 4) 0) (> shift parent-shift))) + (should (= (logand code mask) prefix)) + (cl-incf branches) + (dotimes (slot 16) + (when-let* ((child (aref node (+ slot 2)))) + (cl-incf children) + (visit child (1+ depth) shift + (1- (ash 1 (+ shift 4))) + (logior (logand code (1- (ash 1 shift))) + (ash slot shift))))) + (should (>= children 2)))) + (node + (should (and (consp node) (integerp (car node)) (cdr node))) + (should (= (logand (car node) mask) prefix)) + (dolist (entry (cdr node)) + (should (= (ebox-runtime-index--code (car entry)) + (car node)))) + (cl-incf leaves))))) + (visit (ebox-runtime-index-root index) 0 -1 0 0)) + (list branches leaves max-depth))) + +(ert-deftest ebox-runtime-index-persistent-update-isolates-base () + (let* ((base (ebox-runtime-index-put 1 'old (ebox-runtime-index-empty))) + (changed (ebox-runtime-index-put 1 'new base)) + (added (ebox-runtime-index-put 17 nil changed)) + (deleted (ebox-runtime-index-delete 1 added))) + (should (eq (ebox-runtime-index-get 1 base) 'old)) + (should (eq (ebox-runtime-index-get 1 changed) 'new)) + (should (ebox-runtime-index-contains-p 17 added)) + (should-not (ebox-runtime-index-get 17 added 'missing)) + (should-not (ebox-runtime-index-contains-p 1 deleted)) + (should (ebox-runtime-index-key-set-equal-p base changed)) + (should-not (ebox-runtime-index-key-set-equal-p base added)) + (should-not (ebox-runtime-index-key-set-equal-p base deleted)) + (should (= (ebox-runtime-index-size base) 1)) + (should (= (ebox-runtime-index-size added) 2)) + (should (= (ebox-runtime-index-size deleted) 1)))) + +(ert-deftest ebox-runtime-index-key-set-proof-does-not-enumerate-persistent-indexes () + (let* ((base (ebox-runtime-index-put 1 'old (ebox-runtime-index-empty))) + (changed (ebox-runtime-index-put 1 'new base))) + (cl-letf (((symbol-function 'ebox-runtime-index-map) + (lambda (&rest _args) + (ert-fail "persistent key-set proof enumerated its input")))) + (should (ebox-runtime-index-key-set-equal-p base changed))))) + +(ert-deftest ebox-runtime-index-key-set-equality-supports-independent-indexes () + (let ((left (ebox-runtime-index-empty)) + (right (ebox-runtime-index-empty))) + (dolist (key '(1 17 33)) + (setq left (ebox-runtime-index-put key key left))) + (dolist (key '(33 17 1)) + (setq right (ebox-runtime-index-put key nil right))) + (should (ebox-runtime-index-key-set-equal-p left right)))) + +(ert-deftest ebox-runtime-index-preserves-equal-key-collisions () + (cl-letf (((symbol-function 'ebox-runtime-index--code) + (lambda (_key) 0))) + (let* ((base (ebox-runtime-index-empty)) + (one (ebox-runtime-index-put '(same code one) 1 base)) + (two (ebox-runtime-index-put '(same code two) 2 one))) + (should (= (ebox-runtime-index-get '(same code one) two) 1)) + (should (= (ebox-runtime-index-get '(same code two) two) 2)) + (should (= (ebox-runtime-index-size two) 2))))) + +(ert-deftest ebox-runtime-index-map-and-keys-cover-exact-entries () + (let ((index (ebox-runtime-index-empty)) + entries) + (dolist (pair '((5 . five) (1 . one) (9 . nine))) + (setq index (ebox-runtime-index-put (car pair) (cdr pair) index))) + (ebox-runtime-index-map + (lambda (key value) (push (cons key value) entries)) index) + (should (equal (sort entries (lambda (a b) (< (car a) (car b)))) + '((1 . one) (5 . five) (9 . nine)))) + (should (equal (sort (ebox-runtime-index-keys index) #'<) '(1 5 9))))) + +(ert-deftest ebox-runtime-index-adapters-accept-legacy-hash-tables () + (let ((table (make-hash-table :test #'equal))) + (should (eq (ebox-runtime-index-put 'key nil table) table)) + (should (ebox-runtime-index-contains-p 'key table)) + (should (= (ebox-runtime-index-size table) 1)) + (should (equal (ebox-runtime-index-keys table) '(key))) + (should (eq (ebox-runtime-index-delete 'key table) table)) + (should (= (ebox-runtime-index-size table) 0)))) + +(ert-deftest ebox-runtime-index-update-work-is-bounded-by-changed-keys () + "Path-copy work grows with changed keys rather than retained index size." + (dolist (case '((32 . 2) (128 . 2) (512 . 3))) + (let ((size (car case)) + (max-depth (cdr case)) + (base (ebox-runtime-index-empty))) + (dotimes (id size) + (setq base (ebox-runtime-index-put id id base))) + (dolist (changed-count '(1 4 16)) + (let ((next base) + (vector-copies 0) + (original-copy (symbol-function 'copy-sequence))) + (cl-letf (((symbol-function 'copy-sequence) + (lambda (sequence) + (when (vectorp sequence) + (cl-incf vector-copies)) + (funcall original-copy sequence)))) + (dotimes (id changed-count) + (setq next (ebox-runtime-index-put id (- id) next)))) + (should (<= vector-copies (* changed-count max-depth))) + (should (= (ebox-runtime-index-size next) size)) + (should (= (ebox-runtime-index-get (1- size) base) (1- size))) + (should (= (ebox-runtime-index-get 0 base) 0))))))) + +(ert-deftest ebox-runtime-index-compresses-prefixes-for-generic-keys () + "Noninteger keys with long shared codes keep only real branching levels." + (cl-letf (((symbol-function 'ebox-runtime-index--code) + (lambda (key) (logior #x123456789a (ash (cadr key) 40))))) + (let ((index (ebox-runtime-index-empty))) + (dotimes (id 128) + (setq index (ebox-runtime-index-put (list 'key id) id index))) + (pcase-let ((`(,branches ,leaves ,depth) + (ebox-runtime-index-test--shape index))) + (should (= branches 17)) + (should (= leaves 128)) + (should (= depth 2))) + (dotimes (id 128) + (should (= (ebox-runtime-index-get (list 'key id) index) id)))))) + +(ert-deftest ebox-runtime-index-splits-prefixes-and-collapses-deleted-branches () + "Splitting above retained branches and deleting representatives isolate forks." + (let ((index (ebox-runtime-index-empty)) snapshots) + (dolist (key '(#x1100 #x2100 #x0101 #x1110 #x3110)) + (push index snapshots) + (setq index (ebox-runtime-index-put key key index)) + (ebox-runtime-index-test--shape index)) + (let ((base index)) + ;; The first key supplied representative codes to several branch nodes. + (setq index (ebox-runtime-index-delete #x1100 index)) + (ebox-runtime-index-test--shape index) + (setq index (ebox-runtime-index-put #x4110 'later index)) + (ebox-runtime-index-test--shape index) + (should (= (ebox-runtime-index-get #x1100 base) #x1100)) + (should-not (ebox-runtime-index-contains-p #x4110 base)) + (should (eq (ebox-runtime-index-get #x4110 index) 'later)) + (dolist (key '(#x2100 #x0101 #x1110 #x3110)) + (setq index (ebox-runtime-index-delete key index)) + (ebox-runtime-index-test--shape index)) + (should (equal (ebox-runtime-index-test--shape index) '(0 1 0))) + (should (eq index (ebox-runtime-index-delete 'absent index))) + (setq index (ebox-runtime-index-delete #x4110 index)) + (should (= (ebox-runtime-index-size index) 0)) + (should-not (ebox-runtime-index-root index))) + (cl-loop for snapshot in snapshots + for count downfrom 4 + do (should (= (ebox-runtime-index-size snapshot) count)) + do (ebox-runtime-index-test--shape snapshot)))) + +(ert-deftest ebox-runtime-index-preserves-truncated-large-integer-collisions () + "Keys differing above bit 63 retain independent exact values and membership." + (let* ((small 7) + (large (+ 7 (ash 1 80))) + (larger (+ 7 (ash 1 100))) + (base (ebox-runtime-index-put small 'small (ebox-runtime-index-empty))) + (base (ebox-runtime-index-put large nil base)) + (base (ebox-runtime-index-put larger 'larger base)) + (changed (ebox-runtime-index-put large 'large base)) + (deleted (ebox-runtime-index-delete small changed))) + (should (= (ebox-runtime-index--code small) + (ebox-runtime-index--code larger))) + (should (= (ebox-runtime-index-size base) 3)) + (should (ebox-runtime-index-contains-p large base)) + (should-not (ebox-runtime-index-get large base 'missing)) + (should (eq (ebox-runtime-index-get large deleted) 'large)) + (should (eq (ebox-runtime-index-get larger deleted) 'larger)) + (should-not (ebox-runtime-index-contains-p small deleted)) + (should (ebox-runtime-index-key-set-equal-p base changed)) + (should (equal (ebox-runtime-index-test--shape deleted) '(0 1 0))))) + +(ert-deftest ebox-runtime-index-branches-at-the-highest-code-nibble () + "The compressed prefix arithmetic covers all 64 code bits." + (let ((index (ebox-runtime-index-empty))) + (dolist (key (list 0 (ash 1 60) (ash 1 63))) + (setq index (ebox-runtime-index-put key key index))) + (should (equal (ebox-runtime-index-test--shape index) '(1 3 1))) + (should (= (aref (ebox-runtime-index-root index) 0) 60)) + (setq index (ebox-runtime-index-delete 0 index)) + (setq index (ebox-runtime-index-put (ash 1 62) 'later index)) + (ebox-runtime-index-test--shape index) + (should (= (ebox-runtime-index-get (ash 1 63) index) (ash 1 63))) + (should (eq (ebox-runtime-index-get (ash 1 62) index) 'later)))) + +(ert-deftest ebox-runtime-index-put-discovers-membership-in-one-traversal () + "Insertion and replacement do not perform a separate membership lookup." + (let ((base (ebox-runtime-index-put 'one nil (ebox-runtime-index-empty)))) + (cl-letf (((symbol-function 'ebox-runtime-index--root-get) + (lambda (&rest _arguments) + (ert-fail "Put performed a preliminary membership lookup")))) + (should (= (ebox-runtime-index-size + (ebox-runtime-index-put 'one 'changed base)) + 1)) + (should (= (ebox-runtime-index-size + (ebox-runtime-index-put 'two nil base)) + 2))))) + +(ert-deftest ebox-runtime-index-collision-updates-and-deletes-preserve-equal-keys () + "Colliding noninteger keys compare with equal across immutable forks." + (cl-letf (((symbol-function 'ebox-runtime-index--code) + (lambda (_key) 23))) + (let* ((base (ebox-runtime-index-put + (list "key" 1) nil (ebox-runtime-index-empty))) + (base (ebox-runtime-index-put ["key" 2] 'two base)) + (changed (ebox-runtime-index-put (list "key" 1) 'one base)) + (deleted (ebox-runtime-index-delete (vector "key" 2) changed))) + (should (= (ebox-runtime-index-size changed) 2)) + (should (ebox-runtime-index-contains-p (list "key" 1) base)) + (should-not (ebox-runtime-index-get (list "key" 1) base 'missing)) + (should (eq (ebox-runtime-index-get (list "key" 1) deleted) 'one)) + (should-not (ebox-runtime-index-contains-p ["key" 2] deleted)) + (should (eq (ebox-runtime-index-get ["key" 2] base) 'two)) + (ebox-runtime-index-test--shape deleted)))) + +(ert-deftest ebox-runtime-index-map-visits-only-live-compressed-nodes () + "Enumeration visits at most one leaf and one branch per distinct code." + (let ((index (ebox-runtime-index-empty)) + (visits 0) + (entries 0) + (original (symbol-function 'ebox-runtime-index--map-root))) + (dotimes (id 512) + (setq index (ebox-runtime-index-put id id index))) + (cl-letf (((symbol-function 'ebox-runtime-index--map-root) + (lambda (&rest arguments) + (cl-incf visits) + (apply original arguments)))) + (ebox-runtime-index-map + (lambda (_key _value) (cl-incf entries)) index)) + (should (= entries 512)) + (should (<= visits (1- (* entries 2)))))) + +(ert-deftest ebox-runtime-index-mixed-key-updates-match-hash-table () + "Generic insertion/deletion sequences preserve exact facts and old snapshots." + (let ((keys (vector nil t -1 0 1 17 256 (ash 1 80) (1+ (ash 1 80)) + "key" '(key . 1) '[key 2])) + (index (ebox-runtime-index-empty)) + (expected (make-hash-table :test 'equal)) + (random-state 1729) + snapshots) + (dotimes (step 256) + (setq random-state (mod (+ (* random-state 1103515245) 12345) + 2147483648)) + (let ((key (aref keys (% (/ random-state 16) (length keys)))) + (value (and (/= (% step 7) 0) step))) + (if (= (% random-state 3) 0) + (progn + (setq index (ebox-runtime-index-delete key index)) + (remhash key expected)) + (setq index (ebox-runtime-index-put key value index)) + (puthash key value expected))) + (should (= (ebox-runtime-index-size index) (hash-table-count expected))) + (ebox-runtime-index-test--shape index) + (cl-loop for key across keys do + (should (equal (ebox-runtime-index-get key index 'missing) + (gethash key expected 'missing)))) + (when (= (% step 16) 0) + (push (cons index (copy-hash-table expected)) snapshots))) + (dolist (snapshot snapshots) + (ebox-runtime-index-test--shape (car snapshot)) + (should (= (ebox-runtime-index-size (car snapshot)) + (hash-table-count (cdr snapshot)))) + (cl-loop for key across keys do + (should (equal + (ebox-runtime-index-get key (car snapshot) 'missing) + (gethash key (cdr snapshot) 'missing))))))) + +(provide 'ebox-runtime-index-tests) + +;;; ebox-runtime-index-tests.el ends here diff --git a/tests/ebox-source-tests.el b/tests/ebox-source-tests.el index 0a3e146..7cfe989 100644 --- a/tests/ebox-source-tests.el +++ b/tests/ebox-source-tests.el @@ -455,7 +455,7 @@ (ebox-source-record-classes (ebox-source-index-record old-index old-handle)))) - (maphash + (ebox-runtime-index-map (lambda (_node-id node) (when (ebox-node-kind node) (should (ebox-source-index-record @@ -504,7 +504,7 @@ (eq node-subjects (ebox-source-table-base next-node-subjects))) (let* ((next-node - (gethash old-id + (ebox-runtime-index-get old-id (plist-get (ebox--buffer-render-state buffer) :node-table))) (next-handle (ebox-node-source-handle next-node))) diff --git a/tests/ebox-surface-tests.el b/tests/ebox-surface-tests.el index 42bf301..ec6fe89 100644 --- a/tests/ebox-surface-tests.el +++ b/tests/ebox-surface-tests.el @@ -967,7 +967,7 @@ candidate cannot hide mutations by restoring the old hash-table pointer." (defun ebox-surface-test--object-by-key (state key) "Return the candidate surface object for Ebox node KEY in STATE." (let (object) - (maphash + (ebox-runtime-index-map (lambda (_node-id node) (when (equal (plist-get node :key) key) (setq object (plist-get node :surface-object)))) @@ -977,7 +977,7 @@ candidate cannot hide mutations by restoring the old hash-table pointer." (defun ebox-surface-test--node-by-key (state key) "Return the runtime Ebox node for KEY in STATE." (let (match) - (maphash + (ebox-runtime-index-map (lambda (_node-id node) (when (equal (plist-get node :key) key) (setq match node))) @@ -1383,7 +1383,7 @@ candidate cannot hide mutations by restoring the old hash-table pointer." (nodes (plist-get state :node-table)) (parents (plist-get state :parent-table)) (checked 0)) - (maphash + (ebox-runtime-index-map (lambda (id node) (when (member (plist-get node :ebox-text-value) '("Header" "Footer")) (cl-incf checked) @@ -1394,7 +1394,8 @@ candidate cannot hide mutations by restoring the old hash-table pointer." (should (eq (ecss-subject-parent (ebox-tree-node-subject source-index node)) (ebox-tree-node-subject - source-index (gethash (gethash id parents) nodes)))))) + source-index (ebox-runtime-index-get + (ebox-runtime-index-get id parents) nodes)))))) nodes) (should (= checked 2)))) @@ -2072,6 +2073,230 @@ candidate cannot hide mutations by restoring the old hash-table pointer." (should (ebox-native-reflow-session-released-p committed-session)))))) +(ert-deftest ebox-native-scroll-window-retains-viewport-continuity () + "Clipping and scrolling preserve native frames, including failed commits." + (skip-unless (ebox-native-reflow-layout-ready-p)) + (require 'ebox-native-commit) + (ebox-surface-test--reset-render-state) + (let ((buffer (generate-new-buffer " *ebox-native-scroll-continuity*")) + (oracle (generate-new-buffer " *ebox-scroll-continuity-oracle*")) + (ebox-viewport-width 120) + (ebox-viewport-height 3) + (ebox-native-buffer-scroll nil) + (ebox-runtime-idle-prewarm nil) + (ebox-runtime-idle-reflow-cache-prewarm nil) + (keymap (make-sparse-keymap)) + (original-frame (symbol-function 'ebox-native-commit--retained-frame)) + (original-send + (symbol-function 'ebox-native--module-render-session-frame)) + (original-layout (symbol-function 'ebox--render-layout)) + (original-fork (symbol-function 'ebox-native-reflow-fork-session)) + (original-confirm (symbol-function 'ebox-native-reflow-confirm-native-frame)) + frames controls ordinary-renders identities previous-session + committed-session) + (define-key keymap [mouse-1] #'ignore) + (unwind-protect + (cl-labels + ((fixture + () + (ebox-test-box + :key 'scroll-root :source-identity 'scroll-root :id "scroll" + :width '(viewport) :height '(viewport-height) :overflow 'scroll + (ebox-test-text + (mapconcat + (lambda (label) + (propertize label 'keymap keymap 'mouse-face 'highlight + 'help-echo (concat "line-" label) + 'face '(:weight bold))) + '("A" "B" "C") "\n") + :key 'scroll-text :source-identity 'scroll-text))) + (contents + (target) + (with-current-buffer target + (buffer-substring (point-min) (point-max)))) + (advance + (target step) + (pcase step + ('mount (ebox-render-to-buffer target (fixture))) + ((or 'shrink 'reshrink) + (ebox-rerender-buffer-with-context target 120 1)) + ('scroll + (let* ((state (ebox--buffer-render-state target)) + (region (plist-get (plist-get state :root-node) + :region-id))) + (with-current-buffer target + (should (= (ebox--scroll-region-by region 1 1) 1))))) + ('grow (ebox-rerender-buffer-with-context target 120 3)))) + (reject-advance + (step failure) + (let* ((surface (with-current-buffer buffer ebox-surface--buffer-surface)) + (state (tp-surface-client-state surface)) + (root (plist-get state :root-node)) + (region (plist-get root :region-id)) + (offset (or (ebox-get root :scroll-offset) 0)) + (producer (plist-get state :native-root-scroll-producer)) + (scroll (gethash region (plist-get state :scroll-state-table))) + (scroll-offset (plist-get scroll :scroll-offset)) + (scroll-height (plist-get scroll :content-height)) + (session (plist-get state :native-sync-session)) + (generation (ebox-native-reflow-session-generation session)) + (revision (tp-surface-revision surface)) + (before (contents buffer)) + candidate) + (cl-letf + (((symbol-function 'ebox-native-reflow-fork-session) + (lambda (&rest args) + (setq candidate (apply original-fork args)))) + ((symbol-function 'ebox-native-reflow-confirm-native-frame) + (lambda (&rest args) + (if (eq failure 'confirm) + (error "Reject clipped native confirmation") + (apply original-confirm args)))) + (tp--surface-publication-step-function + (lambda (part _surface) + (when (and (eq failure 'publication) (eq part 'client-state)) + (error "Reject clipped native publication"))))) + (should-error (advance buffer step))) + (should candidate) + (should (ebox-native-reflow-session-released-p candidate)) + (should-not (ebox-native-reflow-session-released-p session)) + (should (= (ebox-native-reflow-session-generation session) generation)) + (should (= (tp-surface-revision surface) revision)) + (should (eq (tp-surface-client-state surface) state)) + (should (eq (plist-get state :root-node) root)) + (should (= (or (ebox-get root :scroll-offset) 0) offset)) + (should (eq (plist-get state :native-root-scroll-producer) producer)) + (should (eq (gethash region (plist-get state :scroll-state-table)) scroll)) + (should (equal (plist-get scroll :scroll-offset) scroll-offset)) + (should (equal (plist-get scroll :content-height) scroll-height)) + (should (equal-including-properties (contents buffer) before))))) + (dolist (case '((mount 3 0 "A\nB\nC" nil) + (shrink 1 0 "A" t) + (scroll 1 1 "B" t) + (grow 3 0 "A\nB\nC" nil) + (reshrink 1 0 "A" t))) + (pcase-let ((`(,step ,height ,offset ,labels ,window-p) case)) + ;; Use an independent ordinary surface, including its real scroll + ;; transition, so oracle rendering cannot mutate the native state. + (ebox-surface-test--with-elisp-backend (advance oracle step)) + (when (memq step '(shrink grow)) + (reject-advance step 'publication) + (reject-advance step 'confirm)) + (setq frames nil controls nil ordinary-renders 0) + (cl-letf + (((symbol-function 'ebox-native-commit--retained-frame) + (lambda (&rest args) + (let ((frame (apply original-frame args))) + (push frame frames) + frame))) + ((symbol-function 'ebox-native--module-render-session-frame) + (lambda (handle generation control) + (push (json-parse-string control :object-type 'plist + :array-type 'array) + controls) + (funcall original-send handle generation control))) + ((symbol-function 'ebox--render-layout) + (lambda (&rest args) + (cl-incf ordinary-renders) + (apply original-layout args)))) + (advance buffer step)) + (let* ((state (ebox--buffer-render-state buffer)) + (root (plist-get state :root-node)) + (region (plist-get root :region-id)) + (session (plist-get state :native-sync-session)) + (effects (plist-get (car frames) :effect-tape)) + (scroll-state (gethash region + (plist-get state :scroll-state-table))) + (actual (contents buffer)) + current-identities) + (ert-info ((format "Native scroll continuity step %S" step)) + (message "Native scroll %S: calls=%S frames=%S window=%S projection=%S fallback=%S" + step (length controls) (length frames) + (plist-get effects :scroll-window-p) + (plist-get state :projection-kind) + (plist-get state :native-render-fallback)) + ;; Observe the actual module result before asserting native + ;; publication: the H1 regression must reach the marked frame. + (should (= (length controls) 1)) + (should (eq (plist-get (aref (plist-get (car controls) :frames) 0) + :root-scroll-producer) t)) + (should (= (length frames) 1)) + (should (stringp (plist-get (car frames) :rendered))) + (should (vectorp (plist-get effects :fragment-span-template))) + (should (eq (plist-get effects :scroll-window-p) window-p)) + (should (eq (plist-get state :projection-kind) 'native-frame)) + (should (= ordinary-renders 0)) + (should-not (plist-get state :native-render-fallback)) + (should session) + (setq committed-session session) + (should-not (ebox-native-reflow-session-released-p session)) + (should (plist-get state :native-sync-confirmed-p)) + (should-not (plist-get state :native-sync-pending)) + (let ((work (plist-get (ebox-native-reflow-stats session) :eval-work)) + (producer (plist-get state :native-root-scroll-producer))) + (should (= (plist-get work :scroll-producer-reuses) + (if (eq step 'scroll) 1 0))) + (should (= (plist-get work :scroll-producer-lines-encoded) + (if (memq step '(shrink reshrink)) 6 0))) + (unless window-p + (should (eq (aref producer 4) :inactive)) + (should (eq (aref producer 5) :inactive)))) + (when previous-session + (should-not (eq session previous-session)) + (should (ebox-native-reflow-session-released-p previous-session)) + (should-not (plist-get (car controls) :document))) + (setq previous-session session) + (ebox-surface-test--walk-runtime + root + (lambda (node) + (push (list (plist-get node :node-id) + (plist-get node :region-id) + (plist-get node :surface-object)) + current-identities))) + (if identities + (cl-mapc + (lambda (old new) + (should (equal (seq-take old 2) (seq-take new 2))) + (should (eq (nth 2 old) (nth 2 new)))) + identities current-identities) + (setq identities current-identities)) + (should (= (length current-identities) 2)) + (should (= (plist-get state :viewport-height) height)) + (should (equal (replace-regexp-in-string + "[[:blank:]]" "" actual) labels)) + (should + (equal-including-properties + (ebox-surface-test--canonical-region-properties actual) + (ebox-surface-test--canonical-region-properties + (contents oracle)))) + (with-current-buffer buffer + (goto-char (point-min)) + (while (re-search-forward "[ABC]" nil t) + (let* ((position (match-beginning 0)) + (label (match-string-no-properties 0)) + (index (- (aref label 0) ?A))) + (should (equal (get-text-property position 'keymap) keymap)) + (should (equal (get-text-property position 'help-echo) + (concat "line-" label))) + (should (= (get-text-property position 'ebox-content-idx) + index)) + (should (equal (get-text-property position 'ebox-scroll-window) + (and window-p region)))))) + (if window-p + (progn + (should scroll-state) + (should (= (plist-get scroll-state :scroll-offset) offset)) + (should (= (plist-get scroll-state :content-height) height)) + (should (plist-get scroll-state :content-lines-complete-p)) + (should (= (length (plist-get scroll-state + :rendered-content-lines)) 3))) + (should-not scroll-state) + (should (= (or (plist-get root :scroll-offset) 0) 0)))))))) + (when (buffer-live-p buffer) (kill-buffer buffer)) + (when (buffer-live-p oracle) (kill-buffer oracle)) + (when committed-session + (should (ebox-native-reflow-session-released-p committed-session)))))) + (ert-deftest ebox-native-full-frame-bootstraps-from-ordinary-surface () "Native bootstrap falls back truthfully and remains retryable." (skip-unless (ebox-native-reflow-layout-ready-p)) @@ -2651,7 +2876,7 @@ candidate cannot hide mutations by restoring the old hash-table pointer." (style-states (plist-get state :style-binding-states)) (source-index (plist-get state :source-index)) text-id text-node) - (maphash + (ebox-runtime-index-map (lambda (node-id node) (when (and (eq (plist-get node :ebox-kind) 'text) (equal (plist-get node :content) "Inherited")) @@ -2659,13 +2884,14 @@ candidate cannot hide mutations by restoring the old hash-table pointer." nodes) (should text-id) (let* ((parent-id - (gethash text-id (plist-get state :parent-table))) + (ebox-runtime-index-get + text-id (plist-get state :parent-table))) (object (gethash text-id objects)) (parent-object (gethash parent-id objects)) (style-state (gethash object style-states)) (parent-state (gethash parent-object style-states)) (candidate-node (copy-sequence text-node)) - (candidate-nodes (copy-hash-table nodes)) + (candidate-nodes nodes) (candidate-state (copy-sequence state))) (should style-state) (should parent-state) @@ -2678,7 +2904,9 @@ candidate cannot hide mutations by restoring the old hash-table pointer." (ecss-subject-id (plist-get style-state :subject)))) (should (ebox-surface--static-style-state-p style-state)) (plist-put candidate-node :ebox-candidate-computed-style-p t) - (puthash text-id candidate-node candidate-nodes) + (setq candidate-nodes + (ebox-runtime-index-put + text-id candidate-node candidate-nodes)) (plist-put candidate-state :node-table candidate-nodes) (let ((ebox-incremental--allocated-slot-proof-cache (make-hash-table :test 'equal))) @@ -3833,7 +4061,7 @@ changes. Geometry and non-inherited computed values must remain identical." (maphash (lambda (id node) (should (eq node - (gethash (gethash id + (ebox-runtime-index-get (ebox-runtime-index-get id (plist-get state :region-node-table)) (plist-get state :node-table))))) diff --git a/tests/ebox-viewport-topology-tests.el b/tests/ebox-viewport-topology-tests.el index f980aeb..fd264bc 100644 --- a/tests/ebox-viewport-topology-tests.el +++ b/tests/ebox-viewport-topology-tests.el @@ -35,7 +35,7 @@ (ebox-viewport-height height) (regions-by-source (make-hash-table :test 'eq)) (region-map (make-hash-table :test 'equal))) - (maphash (lambda (_ node) + (ebox-runtime-index-map (lambda (_ node) (puthash (ebox-node-source-handle node) (plist-get node :region-id) regions-by-source)) (plist-get state :node-table)) @@ -47,7 +47,7 @@ :scroll-region-ids)))) (dotimes (_ offset) (ebox--surface-scroll-region-by buffer region-id 1 nil)))) - (maphash (lambda (_ node) + (ebox-runtime-index-map (lambda (_ node) (let ((region (gethash (ebox-node-source-handle node) regions-by-source))) (should region) @@ -273,11 +273,13 @@ :surface-node-object-table))) (index-contents (mapcar (lambda (table) - (let ((copy (copy-hash-table table))) - (maphash (lambda (key value) - (when (listp value) - (puthash key (copy-sequence value) copy))) - table) + (let ((copy (make-hash-table :test 'equal))) + (ebox-runtime-index-map + (lambda (key value) + (puthash key (if (listp value) + (copy-sequence value) + value) copy)) + table) copy)) indexes))) (should host-position) @@ -305,10 +307,10 @@ indexes '(:node-table :parent-table :region-node-table :surface-node-object-table)) (cl-mapc (lambda (before after) - (should (= (hash-table-count before) (hash-table-count after))) + (should (= (hash-table-count before) (ebox-runtime-index-size after))) (maphash (lambda (key value) (should (equal-including-properties - value (gethash key after)))) + value (ebox-runtime-index-get key after)))) before)) index-contents indexes) (ebox-rerender-buffer-with-context buffer 200 7)