merge: integrate retained native input with current main fixes
Reconcile duplicated native ancestry against the verified equivalent base. Preserve current source ownership, scroll partition, allocated owner rendering, and later retained-state repairs while adapting their consumers and existing fixtures to persistent runtime indexes. Validation: strict byte compilation of 28 source files, static syntax across 56 Lisp files, cargo check, and native release build passed. Regression suites and benchmarks were not run at the user's direction.
This commit is contained in:
commit
7c21185215
51
.runtime-index-plan.md
Normal file
51
.runtime-index-plan.md
Normal file
@ -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.
|
||||
7
Makefile
7
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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -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
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
237
ebox-runtime-index.el
Normal file
237
ebox-runtime-index.el
Normal file
@ -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
|
||||
133
ebox-surface.el
133
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)))))
|
||||
|
||||
11
ebox-tree.el
11
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)
|
||||
|
||||
62
ebox.el
62
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
|
||||
|
||||
786
native/src/atom_plan.rs
Normal file
786
native/src/atom_plan.rs
Normal file
@ -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<AtomPlanWork> = 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<AtomProperties>,
|
||||
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<AtomPlan>,
|
||||
decorations: Box<[Decoration]>,
|
||||
},
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
#[derive(Debug, Clone)]
|
||||
pub(super) struct AtomPlan {
|
||||
parts: Sequence<Part>,
|
||||
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<Atom>) -> 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::<Vec<_>>();
|
||||
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<Atom> {
|
||||
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<Atom> {
|
||||
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<Node<Part>>, output: &mut Vec<Atom>) {
|
||||
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<Node<Part>>) -> Option<Atom> {
|
||||
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::<Vec<_>>();
|
||||
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::<Vec<_>>()
|
||||
);
|
||||
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::<Vec<_>>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
606
native/src/composition.rs
Normal file
606
native/src/composition.rs
Normal file
@ -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<usize>,
|
||||
next: Box<[(LocalStep, ShapeRoute)]>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ColumnShape {
|
||||
paths: Box<[Arc<[LocalStep]>]>,
|
||||
routes: ShapeRoute,
|
||||
}
|
||||
|
||||
impl ColumnShape {
|
||||
fn new(paths: Vec<Arc<[LocalStep]>>) -> Arc<Self> {
|
||||
// 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<usize> {
|
||||
fn collect(
|
||||
route: &ShapeRoute,
|
||||
scope: &RenderScope<'_>,
|
||||
path: &mut Vec<LocalStep>,
|
||||
all: bool,
|
||||
slots: &mut Vec<usize>,
|
||||
) {
|
||||
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<RenderScope<'a>, 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<Arc<EvalRecord>>,
|
||||
raw: LinePlan,
|
||||
normalized: LinePlan,
|
||||
projection: Option<Arc<ProjectionState>>,
|
||||
}
|
||||
|
||||
impl ColumnSlot {
|
||||
fn normalize(
|
||||
raw: LinePlan,
|
||||
child: Option<Arc<EvalRecord>>,
|
||||
proof: Option<&PlanChange>,
|
||||
target: i64,
|
||||
previous: Option<&Self>,
|
||||
) -> (Arc<Self>, Option<PlanChange>) {
|
||||
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<i64>,
|
||||
first_nonempty: Option<i64>,
|
||||
}
|
||||
|
||||
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<ColumnSlot>,
|
||||
measure: ColumnMeasure,
|
||||
},
|
||||
Branch {
|
||||
left: Arc<Self>,
|
||||
right: Arc<Self>,
|
||||
measure: ColumnMeasure,
|
||||
lines: LinePlan,
|
||||
},
|
||||
}
|
||||
|
||||
impl ColumnNode {
|
||||
fn leaf(slot: Arc<ColumnSlot>) -> Arc<Self> {
|
||||
record(EvalWork {
|
||||
column_tree_nodes_created: 1,
|
||||
..EvalWork::default()
|
||||
});
|
||||
Arc::new(Self::Leaf {
|
||||
measure: ColumnMeasure::leaf(&slot),
|
||||
slot,
|
||||
})
|
||||
}
|
||||
|
||||
fn branch(left: Arc<Self>, right: Arc<Self>, previous: Option<&Self>) -> Arc<Self> {
|
||||
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<ColumnSlot>]) -> Option<Arc<Self>> {
|
||||
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<ColumnSlot> {
|
||||
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<ColumnSlot>) -> Arc<Self> {
|
||||
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<Self> {
|
||||
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<ColumnShape>,
|
||||
root: Option<Arc<ColumnNode>>,
|
||||
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<ColumnNode>>, 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<LineSplice>) -> Vec<LineSplice> {
|
||||
let mut result: Vec<LineSplice> = 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<Rendered, String> {
|
||||
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::<Vec<_>>();
|
||||
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)
|
||||
}
|
||||
206
native/src/composition_tests.rs
Normal file
206
native/src/composition_tests.rs
Normal file
@ -0,0 +1,206 @@
|
||||
mod composition_regressions {
|
||||
use super::*;
|
||||
|
||||
fn expected_text_line(
|
||||
text: &str,
|
||||
properties: &AtomProperties,
|
||||
break_after: Option<AtomProperties>,
|
||||
) -> 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::<Vec<_>>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
1075
native/src/evaluation.rs
Normal file
1075
native/src/evaluation.rs
Normal file
File diff suppressed because it is too large
Load Diff
1724
native/src/evaluation_tests.rs
Normal file
1724
native/src/evaluation_tests.rs
Normal file
File diff suppressed because it is too large
Load Diff
3656
native/src/layout.rs
3656
native/src/layout.rs
File diff suppressed because it is too large
Load Diff
@ -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<u8>),
|
||||
Layout {
|
||||
document: LayoutSource,
|
||||
source_changes: Option<SourceChanges>,
|
||||
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<i64>,
|
||||
previous: Option<&RetainedFrame>,
|
||||
changes: Option<&SourceChanges>,
|
||||
root_scroll_producer: bool,
|
||||
) -> Result<(LayoutTape, Option<Arc<RetainedFrame>>), 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<Arc<RetainedFrame>>,
|
||||
styles: Vec<layout::StyleTemplate>,
|
||||
}
|
||||
|
||||
@ -452,6 +486,7 @@ struct PendingBaseline {
|
||||
document: LayoutSource,
|
||||
document_revision: u64,
|
||||
tape: LayoutTape,
|
||||
retained_frame: Option<Arc<RetainedFrame>>,
|
||||
styles: Vec<layout::StyleTemplate>,
|
||||
}
|
||||
|
||||
@ -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<Arc<ConfirmedBaseline>>,
|
||||
}
|
||||
|
||||
@ -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<Shared>,
|
||||
@ -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<SourceChanges>,
|
||||
) -> Result<PreparedJob, String> {
|
||||
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<u8>,
|
||||
LayoutTape,
|
||||
Vec<layout::StyleTemplate>,
|
||||
Option<Arc<RetainedFrame>>,
|
||||
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<Vec<u8>, 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<Shared>) {
|
||||
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();
|
||||
|
||||
295
native/src/line_metrics.rs
Normal file
295
native/src/line_metrics.rs
Normal file
@ -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<Arc<Node>>);
|
||||
|
||||
#[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<Item = LineMetric>) -> Self {
|
||||
let mut lines = lines.into_iter();
|
||||
let mut blocks = Vec::new();
|
||||
loop {
|
||||
let block = lines.by_ref().take(16).collect::<Vec<_>>();
|
||||
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<LineMetric> {
|
||||
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<usize>) -> 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<Item = LineMetric> + '_ {
|
||||
let mut stack = self.0.as_deref().into_iter().collect::<Vec<_>>();
|
||||
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"));
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
170
native/src/line_ops.rs
Normal file
170
native/src/line_ops.rs
Normal file
@ -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<AtomProperties>,
|
||||
right: i64,
|
||||
right_properties: Arc<AtomProperties>,
|
||||
},
|
||||
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(_)
|
||||
)
|
||||
}
|
||||
}
|
||||
1990
native/src/line_plan.rs
Normal file
1990
native/src/line_plan.rs
Normal file
File diff suppressed because it is too large
Load Diff
896
native/src/sequence.rs
Normal file
896
native/src/sequence.rs
Normal file
@ -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<Self, Error> {
|
||||
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<Self, Error> {
|
||||
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<T> {
|
||||
value: Arc<T>,
|
||||
measure: Measure,
|
||||
}
|
||||
|
||||
impl<T> Clone for Entry<T> {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
value: Arc::clone(&self.value),
|
||||
measure: self.measure,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Entry<T> {
|
||||
pub fn new(value: Arc<T>, measure: Measure) -> Result<Self, Error> {
|
||||
if measure.width_sum < 0 || measure.width_max < 0 {
|
||||
return Err(Error::NegativeWidth);
|
||||
}
|
||||
Ok(Self { value, measure })
|
||||
}
|
||||
|
||||
pub fn value(&self) -> &Arc<T> {
|
||||
&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<T> {
|
||||
Leaf {
|
||||
entries: Box<[Entry<T>]>,
|
||||
measure: Measure,
|
||||
},
|
||||
Branch {
|
||||
left: Arc<Node<T>>,
|
||||
right: Arc<Node<T>>,
|
||||
len: usize,
|
||||
height: u32,
|
||||
measure: Measure,
|
||||
},
|
||||
}
|
||||
|
||||
pub enum NodeView<'a, T> {
|
||||
Leaf {
|
||||
entries: &'a [Entry<T>],
|
||||
measure: Measure,
|
||||
},
|
||||
Branch {
|
||||
left: &'a Arc<Node<T>>,
|
||||
right: &'a Arc<Node<T>>,
|
||||
len: usize,
|
||||
height: u32,
|
||||
measure: Measure,
|
||||
},
|
||||
}
|
||||
|
||||
type OptionalRoot<T> = Option<Arc<Node<T>>>;
|
||||
type SplitRoots<T> = (OptionalRoot<T>, OptionalRoot<T>);
|
||||
|
||||
impl<T> Node<T> {
|
||||
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<T> {
|
||||
root: Option<Arc<Node<T>>>,
|
||||
}
|
||||
|
||||
/// Borrowed in-order traversal with an O(log N) stack and no entry clones.
|
||||
pub struct Iter<'a, T> {
|
||||
pending: Vec<&'a Node<T>>,
|
||||
leaf: std::slice::Iter<'a, Entry<T>>,
|
||||
remaining: usize,
|
||||
nodes_visited: u64,
|
||||
}
|
||||
|
||||
impl<T> Iter<'_, T> {
|
||||
pub fn nodes_visited(&self) -> u64 {
|
||||
self.nodes_visited
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, T> Iterator for Iter<'a, T> {
|
||||
type Item = &'a Entry<T>;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
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<usize>) {
|
||||
(self.remaining, Some(self.remaining))
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> ExactSizeIterator for Iter<'_, T> {}
|
||||
|
||||
impl<T> Clone for Sequence<T> {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
root: self.root.as_ref().map(Arc::clone),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Default for Sequence<T> {
|
||||
fn default() -> Self {
|
||||
Self::empty()
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Sequence<T> {
|
||||
pub fn empty() -> Self {
|
||||
Self { root: None }
|
||||
}
|
||||
|
||||
pub fn from_entries<I>(entries: I, work: &mut Work) -> Result<Self, Error>
|
||||
where
|
||||
I: IntoIterator<Item = Entry<T>>,
|
||||
{
|
||||
let entries = entries.into_iter().collect::<Vec<_>>();
|
||||
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<T>(leaves: &[Arc<Node<T>>], work: &mut Work) -> Result<Arc<Node<T>>, 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<Node<T>>> {
|
||||
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<T>> {
|
||||
fn get_at<T>(node: &Node<T>, index: usize) -> Option<&Entry<T>> {
|
||||
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<Measure, Error> {
|
||||
fn prefix<T>(node: &Node<T>, end: usize) -> Result<Measure, Error> {
|
||||
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<usize>) -> Result<Measure, Error> {
|
||||
fn measure<T>(node: &Node<T>, start: usize, end: usize) -> Result<Measure, Error> {
|
||||
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<usize>, work: &mut Work) -> Result<Self, Error> {
|
||||
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<Self, Error> {
|
||||
Ok(Self {
|
||||
root: join(self.root.clone(), other.root.clone(), work)?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn replace(&self, index: usize, entry: Entry<T>, work: &mut Work) -> Result<Self, Error> {
|
||||
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<T>(entries: Vec<Entry<T>>, work: &mut Work) -> Result<Arc<Node<T>>, 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<T>(
|
||||
left: Arc<Node<T>>,
|
||||
right: Arc<Node<T>>,
|
||||
work: &mut Work,
|
||||
) -> Result<Arc<Node<T>>, 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<T>(
|
||||
left: Option<Arc<Node<T>>>,
|
||||
right: Option<Arc<Node<T>>>,
|
||||
work: &mut Work,
|
||||
) -> Result<Option<Arc<Node<T>>>, 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<T>(
|
||||
left: Arc<Node<T>>,
|
||||
right: Arc<Node<T>>,
|
||||
work: &mut Work,
|
||||
) -> Result<Arc<Node<T>>, 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<T>(
|
||||
node: Option<&Arc<Node<T>>>,
|
||||
index: usize,
|
||||
work: &mut Work,
|
||||
) -> Result<SplitRoots<T>, 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<T>(
|
||||
node: &Arc<Node<T>>,
|
||||
index: usize,
|
||||
entry: Entry<T>,
|
||||
work: &mut Work,
|
||||
) -> Result<Arc<Node<T>>, 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<String> {
|
||||
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<String>) -> Vec<String> {
|
||||
(0..sequence.len())
|
||||
.map(|index| sequence.get(index).unwrap().value().as_ref().clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn height<T>(sequence: &Sequence<T>) -> u32 {
|
||||
sequence.root().map_or(0, |root| root.height())
|
||||
}
|
||||
|
||||
fn shared_nodes<T>(left: &Arc<Node<T>>, right: &Arc<Node<T>>) -> 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<T>(node: &Arc<Node<T>>) -> (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::<String>::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::<Vec<_>>();
|
||||
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::<Vec<_>>()
|
||||
);
|
||||
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));
|
||||
}
|
||||
}
|
||||
214
native/src/source_topology.rs
Normal file
214
native/src/source_topology.rs
Normal file
@ -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<u64, Box<[ParentUse]>>,
|
||||
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<u64, Vec<ParentUse>>,
|
||||
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<SourceTopology> {
|
||||
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::<Vec<_>>(),
|
||||
[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);
|
||||
}
|
||||
}
|
||||
}
|
||||
162
native/src/source_topology_tests.rs
Normal file
162
native/src/source_topology_tests.rs
Normal file
@ -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());
|
||||
}
|
||||
@ -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))))
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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)))))
|
||||
|
||||
@ -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"
|
||||
|
||||
295
tests/ebox-runtime-index-tests.el
Normal file
295
tests/ebox-runtime-index-tests.el
Normal file
@ -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
|
||||
@ -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)))
|
||||
|
||||
@ -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)))))
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user