From cb0e1823d3786fa8e31844827d63c4610cbcdd83 Mon Sep 17 00:00:00 2001 From: Kinneyzhang Date: Fri, 28 Aug 2026 16:48:09 +0800 Subject: [PATCH] refactor: own author facts behind opaque source handles --- Makefile | 7 +- README.md | 11 +- README.zh-CN.md | 12 +- ...box-current-implementation-reference.en.md | 1 + ...box-current-implementation-reference.zh.md | 1 + docs/user/ebox-api-reference.en.md | 115 +- docs/user/ebox-api-reference.zh.md | 110 +- docs/user/ebox-user-guide.en.md | 77 +- docs/user/ebox-user-guide.zh.md | 75 +- ebox-canonical.el | 329 +- ebox-child-range.el | 106 +- ebox-dsl.el | 91 +- ebox-flex.el | 22 +- ebox-grid.el | 2 +- ebox-incremental.el | 717 +++- ebox-layout.el | 74 +- ebox-native-commit.el | 25 +- ebox-node-factory.el | 28 +- ebox-selector.el | 72 +- ebox-source.el | 769 ++++ ebox-style.el | 76 +- ebox-surface.el | 238 +- ebox-tree.el | 778 ++-- ebox.el | 296 +- scripts/ebox-performance-evaluator.el | 23 +- scripts/ebox-visual-check.el | 4 +- tests/ebox-child-range-tests.el | 618 +-- tests/ebox-commit-tests.el | 540 +-- tests/ebox-core-render-tests.el | 3646 +++++++++-------- tests/ebox-docs-contract-tests.el | 9 +- tests/ebox-dsl-tests.el | 477 ++- tests/ebox-fixtures.el | 282 +- tests/ebox-flex-tests.el | 325 +- tests/ebox-grid-tests.el | 115 +- tests/ebox-package-tests.el | 17 +- tests/ebox-selector-tests.el | 39 +- tests/ebox-source-tests.el | 403 ++ tests/ebox-surface-tests.el | 370 +- 38 files changed, 7100 insertions(+), 3800 deletions(-) create mode 100644 ebox-source.el create mode 100644 tests/ebox-source-tests.el diff --git a/Makefile b/Makefile index e225d56..3210e45 100644 --- a/Makefile +++ b/Makefile @@ -10,7 +10,7 @@ NATIVE_MANIFEST = native/Cargo.toml NATIVE_TARGET ?= $(shell $(EMACS_BATCH) -l ebox-native-reflow.el --eval '(princ (ebox-native-reflow--rust-target))') NATIVE_RELEASE_DIR = native/target/$(NATIVE_TARGET)/release -.PHONY: all check ci load compile test checkdoc core-tests child-range-tests grid-tests ebox-commit-tests surface-tests visual-check-tests package-tests selector-tests dsl-tests flex-tests docs-contract-tests ci-contract-tests 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 source-tests core-tests child-range-tests grid-tests ebox-commit-tests surface-tests visual-check-tests package-tests selector-tests dsl-tests flex-tests docs-contract-tests ci-contract-tests performance-evaluator visual-check native-rust-tests native-build diff-check clean package-lint package-lint-install all: check @@ -25,7 +25,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: core-tests child-range-tests grid-tests ebox-commit-tests surface-tests visual-check-tests package-tests selector-tests dsl-tests flex-tests docs-contract-tests ci-contract-tests +test: source-tests core-tests child-range-tests grid-tests ebox-commit-tests surface-tests visual-check-tests package-tests selector-tests dsl-tests flex-tests docs-contract-tests ci-contract-tests + +source-tests: + $(EMACS_BATCH) -l tests/ebox-source-tests.el -f ert-run-tests-batch-and-exit core-tests: $(EMACS_TEST) -l tests/ebox-core-render-tests.el -f ert-run-tests-batch-and-exit diff --git a/README.md b/README.md index 2a7b37c..1e23902 100644 --- a/README.md +++ b/README.md @@ -41,8 +41,9 @@ field-based child syntax. ``` Use `ebox-build` for the public author DSL. Framework integrations may instead -construct typed nodes with `ebox-text-create`, a typed LayoutConfig constructor, -and `ebox-box-create`; that evaluated API is not a second author grammar. +assemble typed nodes with one `ebox-source-builder`, then seal the forest and +its source generation as one `CanonicalEboxInput`; that evaluated API is not a +second author grammar. ## Layout choices @@ -59,12 +60,12 @@ do not require a wrapper node. - `ebox-render` returns propertized text without publishing a live buffer. - `ebox-render-to-buffer` mounts a retained surface. -- `ebox-commit` atomically publishes a newly built root. +- `ebox-commit` atomically publishes a newly built canonical input. - `ebox-buffer-update-report` returns the last successful update report. - `ebox-rerender-buffer-with-context` applies an explicit viewport change. -Ebox copies author input before assigning runtime identity, so one built tree -may be mounted in multiple buffers without sharing live ownership. +Ebox copies canonical input before assigning runtime identity, so one built +value may be mounted in multiple buffers without sharing live ownership. ## Optional native module diff --git a/README.zh-CN.md b/README.zh-CN.md index 12abdb2..3986fb2 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -37,9 +37,9 @@ Ebox 需要 Emacs 29.1 或更高版本,并依赖 ECSS 与 TP。包管理器应 (box "Right"))))) ``` -普通 author DSL 统一通过 `ebox-build`。框架集成可以直接组合 -`ebox-text-create`、typed LayoutConfig constructor 和 `ebox-box-create`;这个 -evaluated API 不是第二套 author 语法。 +普通 author DSL 统一通过 `ebox-build`。框架集成可以用一个 +`ebox-source-builder` 组合 typed node,再把 forest 与同一代 source facts 封装为一个 +`CanonicalEboxInput`;这个 evaluated API 不是第二套 author 语法。 ## 如何选择布局 @@ -55,12 +55,12 @@ Flex/Grid participation property 直接属于子 `box`,不需要额外 wrapper - `ebox-render` 返回带属性文本,不发布 live buffer; - `ebox-render-to-buffer` 挂载 retained surface; -- `ebox-commit` 原子发布重新构建的 root; +- `ebox-commit` 原子发布重新构建的 canonical input; - `ebox-buffer-update-report` 返回最近一次成功更新报告; - `ebox-rerender-buffer-with-context` 应用显式 viewport 变化。 -Ebox 会在分配 runtime identity 前复制 author 输入,因此同一棵已构建树可以挂载到 -多个 buffer,而不会共享 live ownership。 +Ebox 会在分配 runtime identity 前复制 canonical input,因此同一个 built value 可以 +挂载到多个 buffer,而不会共享 live ownership。 ## 可选 native 模块 diff --git a/docs/maintainer/ebox-current-implementation-reference.en.md b/docs/maintainer/ebox-current-implementation-reference.en.md index 174762c..c76cedd 100644 --- a/docs/maintainer/ebox-current-implementation-reference.en.md +++ b/docs/maintainer/ebox-current-implementation-reference.en.md @@ -18,6 +18,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-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. | | `ebox-child-range.el` | Immutable weighted segment trie, sparse persistent key trie, Range replacement, and Gate A metrics. | diff --git a/docs/maintainer/ebox-current-implementation-reference.zh.md b/docs/maintainer/ebox-current-implementation-reference.zh.md index 1db37a9..98dc0c6 100644 --- a/docs/maintainer/ebox-current-implementation-reference.zh.md +++ b/docs/maintainer/ebox-current-implementation-reference.zh.md @@ -18,6 +18,7 @@ | --- | --- | | `ebox.el` | 公共门面、构造辅助函数、渲染、基于 TP 的 buffer 入口、滚动、commit 与 byte compile。 | | `ebox-cache.el` | 测量/渲染缓存记录、失效和缓存报告。 | +| `ebox-source.el` | opaque source handle、不可变 author source record 与 candidate source index。 | | `ebox-style.el` | ECSS property schema、declaration 与 cascade、shorthand 展开、computed style、颜色、border 和 dirty effect。 | | `ebox-tree.el` | 节点遍历、逻辑子节点访问、ECSS subject 适配、identity、父路径、key 和树 snapshot。 | | `ebox-child-range.el` | 不可变 weighted segment trie、稀疏持久 key trie、Range replacement 与 Gate A metrics。 | diff --git a/docs/user/ebox-api-reference.en.md b/docs/user/ebox-api-reference.en.md index cb45731..f3c1f49 100644 --- a/docs/user/ebox-api-reference.en.md +++ b/docs/user/ebox-api-reference.en.md @@ -31,6 +31,11 @@ one string payload. Every Box form accepts zero or more directly nested Text or Box children. The form name selects Normal, Row, Column, Flex, or Grid child layout; authors do not pass a layout selector as a property. +`ebox-build` returns one opaque `CanonicalEboxInput`. It atomically carries the +ordered canonical forest and the source generation that owns its author facts. +Ordinary author code passes that value unchanged to Ebox render/publication +functions; it does not extract a node or construct a source index. + ```elisp (ebox-build '(column :padding (1 2) @@ -73,28 +78,87 @@ to Ebox and invalid context/value combinations signal an error. ## 2. Evaluated typed construction -Frameworks that already own author parsing can construct canonical nodes -directly: +Frameworks that already own author parsing can use the evaluated integration +API. This API is not a second author grammar. One `ebox-source-builder` owns +the complete source generation; every typed node carries a handle from that +builder and node-owned facts projected from the same normalized declarations. +The final `CanonicalEboxInput` transports the forest and sealed source index as +one value: ```elisp -(let ((layout (ebox-column-layout-create - :item-gap 1 :cross-align 'stretch))) - (ebox-box-create - :layout layout - :children (list (ebox-text-create :value "One") - (ebox-text-create :value "Two")))) +(let* ((builder (ebox-source-builder-create)) + (root-declarations nil) + (left-declarations nil) + (right-declarations nil) + (root-handle + (ebox-source-builder-bind + builder :declarations root-declarations)) + (left-handle + (ebox-source-builder-bind + builder :declarations left-declarations)) + (right-handle + (ebox-source-builder-bind + builder :declarations right-declarations)) + (left + (ebox-text-create + :value "Left" + :source-handle left-handle + :owned-facts + (ebox-canonical-facts-from-declarations + 'text left-declarations))) + (right + (ebox-text-create + :value "Right" + :source-handle right-handle + :owned-facts + (ebox-canonical-facts-from-declarations + 'text right-declarations))) + (root + (ebox-box-create + :layout (ebox-row-layout-create + :item-gap 1 :cross-align 'center) + :children (list left right) + :source-handle root-handle + :owned-facts + (ebox-canonical-facts-from-declarations + 'row root-declarations)))) + (ebox-canonical-input-create + (list root) + (ebox-source-builder-finish builder))) ``` -- `ebox-text-create` requires `:value STRING`. +- `ebox-source-builder-create` begins exactly one mutable source assembly. +- `ebox-source-builder-bind` stores normalized source facts and returns an + identity-only opaque handle. +- `ebox-source-builder-finish` detaches and seals the builder's immutable fact + index. A sealed builder rejects further mutation. +- `ebox-canonical-facts-from-declarations` projects the node-owned facts from + the same normalized declarations passed to the source builder. +- `ebox-text-create` requires `:value`, `:source-handle`, and `:owned-facts`. - `ebox-normal-layout-create` creates Normal LayoutConfig. - `ebox-row-layout-create` and `ebox-column-layout-create` accept `:item-gap` and `:cross-align`. - `ebox-flex-layout-create` creates a validated Flex LayoutConfig. - `ebox-grid-layout-create` creates a validated Grid LayoutConfig. -- `ebox-box-create` requires one typed `:layout` value and accepts a - `:children` list of typed nodes. +- `ebox-box-create` requires `:layout`, `:source-handle`, and `:owned-facts`; + it accepts a `:children` list of typed nodes. +- `ebox-canonical-input-create` combines an ordered node forest with the one + source index that owns every referenced handle; this boundary validates the + exact set and seals source order from the forest's final preorder, independent + of bottom-up construction order. +- `ebox-canonical-input-root-host-ref` returns the detached publication address + of a validated single-root input without exposing its source handle. +- `ebox-canonical-input-roots` returns a shallow, read-only copy of the exact + top-level roots. It does not import source facts into another candidate. +- `ebox-canonical-input-import-roots` accepts only exact, unique top-level roots + from that input and an open source builder. It imports facts only for the + selected subtrees, preserves their node, source-handle, and immutable-record + identities, and never adopts facts from unselected roots. +- `ebox-canonical-input-equal-p` compares the complete canonical forest, Range + anchors, source identities, and immutable source facts for no-op proofs. -The last two fields belong only to this evaluated integration contract. They +These source builders, fact/input constructors, and typed node/layout +constructors belong only to the evaluated integration contract. Their fields are not author properties or alternate DSL syntax. Node inspection functions are `ebox-node-kind`, `ebox-text-node-p`, @@ -107,10 +171,10 @@ though the facade inventory below focuses on application entry points. | Function | Contract | | --- | --- | -| `ebox-render` | Return a propertized string without publishing a live buffer. | -| `ebox-render-to-buffer` | Copy the built root, mount a retained TP surface, enable `ebox-buffer-mode`, and return the buffer. Its optional plist accepts only `:observer FUNCTION`. | -| `ebox-display-buffer` | Render through the retained path, delete other windows, and switch to the result. | -| `ebox-commit` | Atomically publish a newly built root or one-shot logical candidate. | +| `ebox-render` | Accept a single-root `CanonicalEboxInput` and return a propertized string without publishing a live buffer. | +| `ebox-render-to-buffer` | Accept a single-root `CanonicalEboxInput`, mount a retained TP surface, enable `ebox-buffer-mode`, and return the buffer. Its optional plist accepts only `:observer FUNCTION`. | +| `ebox-display-buffer` | Accept a single-root `CanonicalEboxInput`, render through the retained path, delete other windows, and switch to the result. | +| `ebox-commit` | Atomically publish a single-root `CanonicalEboxInput` or a one-shot logical candidate returned by `ebox-candidate-begin`. | | `ebox-buffer-set-observer` | Set or remove one function-valued observer on an already mounted buffer. | | `ebox-buffer-update-report` | Return a defensive copy of the last successful update report. | | `ebox-rerender-buffer-with-context` | Apply explicit viewport width and optional height while retaining identity. | @@ -154,13 +218,13 @@ They do not publish by themselves. | `ebox-region-ids` | Return current low-level region ids in document order. | | `ebox-region-resolve` | Resolve one logical `:id` in a mounted buffer to an opaque surface-scoped handle. | | `ebox-region-update` | Apply supported mutable style or scroll properties through one retained transaction. | -| `ebox-child-range` | Build a transparent, addressable child Range descriptor from typed nodes. | +| `ebox-child-range` | Framework integration only: build a nonvisual, addressable child Range descriptor while assembling a typed canonical input. | | `ebox-candidate-begin` | Capture the exact current mounted generation in a one-shot candidate. | -| `ebox-candidate-replace` | Replace one candidate node address. | -| `ebox-candidate-replace-range-ref` | Replace one transparent child Range payload. | -| `ebox-candidate-replace-root` | Replace the candidate's private root address. | -| `ebox-candidate-replace-host-ref` | Replace one framework-owned opaque host reference. | -| `ebox-candidate-patch-host-paint` | Record a proven paint-only host patch or return nil when replacement is required. | +| `ebox-candidate-replace` | Replace one candidate node address from a single-root `CanonicalEboxInput`. | +| `ebox-candidate-replace-range-ref` | Replace one transparent child Range payload from a forest-valued `CanonicalEboxInput`. | +| `ebox-candidate-replace-root` | Replace the candidate's private root address from a single-root `CanonicalEboxInput`. | +| `ebox-candidate-replace-host-ref` | Replace one framework-owned opaque host reference from a single-root `CanonicalEboxInput`. | +| `ebox-candidate-patch-host-paint` | Compare previous and next single-root canonical inputs; record a proven paint-only host patch or return nil when replacement is required. | | `ebox-host-ref-bounds` | Return current margin-free bounds for an opaque host reference. | | `ebox-host-ref-position` | Return its first current position. | @@ -168,6 +232,10 @@ Handles and reported positions belong to one live publication generation. Resolve them again after their object is removed. A candidate is sealed by commit and cannot be reused. +The author DSL has no Range form. Ordinary authors compose direct Text/Box +children and never call `ebox-child-range`; Range addresses exist only for +frameworks that already own semantic child ranges and candidate updates. + ## 5. Selectors ECSS is the sole selector parser and matcher. Ebox supplies logical tree @@ -216,7 +284,7 @@ names every entry: - Construction: `ebox-build`, `ebox-text-create`, `ebox-normal-layout-create`, `ebox-row-layout-create`, `ebox-column-layout-create`, `ebox-flex-layout-create`, - `ebox-grid-layout-create`, `ebox-box-create`, `ebox-child-range`. + `ebox-grid-layout-create`, `ebox-box-create`. - Render/publication: `ebox-render`, `ebox-render-to-buffer`, `ebox-display-buffer`, `ebox-commit`, `ebox-buffer-set-observer`, `ebox-buffer-update-report`, @@ -226,6 +294,7 @@ names every entry: - Candidate/identity: `ebox-candidate-begin`, `ebox-candidate-replace`, `ebox-candidate-replace-range-ref`, `ebox-candidate-replace-root`, `ebox-candidate-replace-host-ref`, `ebox-candidate-patch-host-paint`, + `ebox-child-range`, `ebox-region-ids`, `ebox-region-resolve`, `ebox-region-update`, `ebox-host-ref-bounds`, `ebox-host-ref-position`. - Selectors: `ebox-selector-parse`, `ebox-selector-match-node-p`, diff --git a/docs/user/ebox-api-reference.zh.md b/docs/user/ebox-api-reference.zh.md index 7d016fb..cf5799d 100644 --- a/docs/user/ebox-api-reference.zh.md +++ b/docs/user/ebox-api-reference.zh.md @@ -29,6 +29,10 @@ STRING 每种 Box form 都接受零个或多个直接嵌套的 Text/Box 子节点。form 名称选择 Normal、 Row、Column、Flex 或 Grid 子布局;author 不通过 property 传入布局选择器。 +`ebox-build` 返回一个不透明的 `CanonicalEboxInput`,以原子整体携带有序 canonical +forest 和拥有其 author facts 的 source generation。普通 author 代码只把这个值原样 +传给 Ebox 渲染/发布函数,不取出 node,也不构造 source index。 + ```elisp (ebox-build '(column :padding (1 2) @@ -69,27 +73,83 @@ Ebox;context/value 组合无效时会直接报错。 ## 2. Evaluated typed construction -已经拥有 author parsing 的框架可以直接构造 canonical node: +已经拥有 author parsing 的框架可以使用 evaluated 集成 API;它不是第二套 author +语法。一个 `ebox-source-builder` 拥有完整 source generation;每个 typed node 都携带 +该 builder 返回的 handle,以及由同一份 normalized declarations 投影出的 node-owned +facts。最后用一个 `CanonicalEboxInput` 同时传递 forest 与封存的 source index: ```elisp -(let ((layout (ebox-column-layout-create - :item-gap 1 :cross-align 'stretch))) - (ebox-box-create - :layout layout - :children (list (ebox-text-create :value "One") - (ebox-text-create :value "Two")))) +(let* ((builder (ebox-source-builder-create)) + (root-declarations nil) + (left-declarations nil) + (right-declarations nil) + (root-handle + (ebox-source-builder-bind + builder :declarations root-declarations)) + (left-handle + (ebox-source-builder-bind + builder :declarations left-declarations)) + (right-handle + (ebox-source-builder-bind + builder :declarations right-declarations)) + (left + (ebox-text-create + :value "Left" + :source-handle left-handle + :owned-facts + (ebox-canonical-facts-from-declarations + 'text left-declarations))) + (right + (ebox-text-create + :value "Right" + :source-handle right-handle + :owned-facts + (ebox-canonical-facts-from-declarations + 'text right-declarations))) + (root + (ebox-box-create + :layout (ebox-row-layout-create + :item-gap 1 :cross-align 'center) + :children (list left right) + :source-handle root-handle + :owned-facts + (ebox-canonical-facts-from-declarations + 'row root-declarations)))) + (ebox-canonical-input-create + (list root) + (ebox-source-builder-finish builder))) ``` -- `ebox-text-create` 要求 `:value STRING`; +- `ebox-source-builder-create` 开始且只开始一次 mutable source assembly; +- `ebox-source-builder-bind` 保存 normalized source facts,并返回只含 identity 的 + opaque handle; +- `ebox-source-builder-finish` 分离并封存 builder 的 immutable fact index;封存后 + builder 拒绝继续修改; +- `ebox-canonical-facts-from-declarations` 从传给 source builder 的同一份 normalized + declarations 投影 node-owned facts; +- `ebox-text-create` 要求 `:value`、`:source-handle` 与 `:owned-facts`; - `ebox-normal-layout-create` 构造 Normal LayoutConfig; - `ebox-row-layout-create` 与 `ebox-column-layout-create` 接受 `:item-gap` 和 `:cross-align`; - `ebox-flex-layout-create` 构造经过验证的 Flex LayoutConfig; - `ebox-grid-layout-create` 构造经过验证的 Grid LayoutConfig; -- `ebox-box-create` 要求一个 typed `:layout` 值,并接受 typed node 的 - `:children` list。 +- `ebox-box-create` 要求 `:layout`、`:source-handle` 与 `:owned-facts`,并接受 typed + node 的 `:children` list; +- `ebox-canonical-input-create` 把有序 node forest 与拥有其中全部 handle 的唯一 + source index 组合为一个值;这个边界验证精确 handle 集合,并从 forest 的最终 + preorder 封存 source order,不依赖 bottom-up 构造顺序。 +- `ebox-canonical-input-root-host-ref` 返回经过验证的 single-root input 的分离发布 + 地址,不暴露其中的 source handle; +- `ebox-canonical-input-roots` 返回 exact top-level roots 的 shallow、只读副本, + 不会把 source facts 导入另一个 candidate; +- `ebox-canonical-input-import-roots` 只接受该 input 中 exact、唯一的 top-level + roots 与一个 open source builder;它只导入选中 subtree 的 facts,保留 node、 + source-handle 与 immutable record identity,绝不接管未选 root 的 facts; +- `ebox-canonical-input-equal-p` 比较完整 canonical forest、Range anchor、source + identity 和 immutable source facts,用于 no-op 证明。 -最后两个 field 只属于 evaluated 集成契约,不是 author property,也不是另一套 DSL。 +这些 source builder、fact/input constructor 与 typed node/layout constructor 只属于 +evaluated 集成契约;其中的 field 不是 author property,也不是另一套 DSL。 typed 模块还公开 `ebox-node-kind`、`ebox-text-node-p`、`ebox-box-node-p`、 `ebox-node-source-handle`、`ebox-text-node-value`、`ebox-box-node-layout`、 @@ -100,10 +160,10 @@ typed 模块还公开 `ebox-node-kind`、`ebox-text-node-p`、`ebox-box-node-p` | 函数 | 契约 | | --- | --- | -| `ebox-render` | 返回带属性字符串,不发布 live buffer。 | -| `ebox-render-to-buffer` | 复制 built root,挂载 retained TP surface,启用 `ebox-buffer-mode` 并返回 buffer;可选 plist 只接受 `:observer FUNCTION`。 | -| `ebox-display-buffer` | 走 retained 路径渲染,删除其他 window 后切换到结果。 | -| `ebox-commit` | 原子发布重新构建的 root 或 one-shot logical candidate。 | +| `ebox-render` | 接收 single-root `CanonicalEboxInput`,返回带属性字符串,不发布 live buffer。 | +| `ebox-render-to-buffer` | 接收 single-root `CanonicalEboxInput`,挂载 retained TP surface,启用 `ebox-buffer-mode` 并返回 buffer;可选 plist 只接受 `:observer FUNCTION`。 | +| `ebox-display-buffer` | 接收 single-root `CanonicalEboxInput`,走 retained 路径渲染,删除其他 window 后切换到结果。 | +| `ebox-commit` | 原子发布 single-root `CanonicalEboxInput` 或 `ebox-candidate-begin` 返回的 one-shot logical candidate。 | | `ebox-buffer-set-observer` | 给已经 mounted 的 buffer 设置或移除一个 function observer。 | | `ebox-buffer-update-report` | 返回最近一次成功更新报告的防御性副本。 | | `ebox-rerender-buffer-with-context` | 应用显式 viewport width 与可选 height,同时保留 identity。 | @@ -142,19 +202,23 @@ observer 为 nil 时,Ebox 不创建 observation context,不取 timestamp/GC | `ebox-region-ids` | 按文档顺序返回当前底层 region id。 | | `ebox-region-resolve` | 把 mounted buffer 中一个逻辑 `:id` 解析为不透明、surface-scoped 的 handle。 | | `ebox-region-update` | 通过一次 retained transaction 应用支持的可变样式或滚动 property。 | -| `ebox-child-range` | 从 typed node 构造透明、可寻址的 child Range descriptor。 | +| `ebox-child-range` | 仅供框架集成:在组装 typed canonical input 时构造非视觉、可寻址的 child Range descriptor。 | | `ebox-candidate-begin` | 在 one-shot candidate 中捕获当前准确 mounted generation。 | -| `ebox-candidate-replace` | 替换一个 candidate node address。 | -| `ebox-candidate-replace-range-ref` | 替换一个透明 child Range payload。 | -| `ebox-candidate-replace-root` | 替换 candidate 的私有 root address。 | -| `ebox-candidate-replace-host-ref` | 替换一个 framework-owned opaque host reference。 | -| `ebox-candidate-patch-host-paint` | 记录经过证明的 paint-only host patch;需要 replacement 时返回 nil。 | +| `ebox-candidate-replace` | 用 single-root `CanonicalEboxInput` 替换一个 candidate node address。 | +| `ebox-candidate-replace-range-ref` | 用 forest-valued `CanonicalEboxInput` 替换一个透明 child Range payload。 | +| `ebox-candidate-replace-root` | 用 single-root `CanonicalEboxInput` 替换 candidate 的私有 root address。 | +| `ebox-candidate-replace-host-ref` | 用 single-root `CanonicalEboxInput` 替换一个 framework-owned opaque host reference。 | +| `ebox-candidate-patch-host-paint` | 比较 previous/next single-root canonical input;记录经过证明的 paint-only host patch,需要 replacement 时返回 nil。 | | `ebox-host-ref-bounds` | 返回 opaque host reference 当前不含 margin 的 bounds。 | | `ebox-host-ref-position` | 返回其当前第一个位置。 | handle 和位置只属于一个 live publication generation;对象被删除后要重新解析。 candidate 被 commit 封存后不能复用。 +author DSL 没有 Range form。普通 author 只组合直接嵌套的 Text/Box child,不调用 +`ebox-child-range`;Range address 只服务已经拥有 semantic child range 与 candidate +update 的框架。 + ## 5. Selector ECSS 是唯一 selector parser 与 matcher;Ebox 只提供 logical tree relation 和 @@ -198,8 +262,7 @@ native 模块只是加速器,不是正确性依赖。加载包不会构建它 - 构造:`ebox-build`、`ebox-text-create`、`ebox-normal-layout-create`、 `ebox-row-layout-create`、`ebox-column-layout-create`、 - `ebox-flex-layout-create`、`ebox-grid-layout-create`、`ebox-box-create`、 - `ebox-child-range`; + `ebox-flex-layout-create`、`ebox-grid-layout-create`、`ebox-box-create`; - 渲染/发布:`ebox-render`、`ebox-render-to-buffer`、`ebox-display-buffer`、 `ebox-commit`、`ebox-buffer-set-observer`、`ebox-buffer-update-report`、 `ebox-rerender-buffer-with-context`、`ebox-viewport-window-width`、 @@ -208,6 +271,7 @@ native 模块只是加速器,不是正确性依赖。加载包不会构建它 - Candidate/identity:`ebox-candidate-begin`、`ebox-candidate-replace`、 `ebox-candidate-replace-range-ref`、`ebox-candidate-replace-root`、 `ebox-candidate-replace-host-ref`、`ebox-candidate-patch-host-paint`、 + `ebox-child-range`、 `ebox-region-ids`、`ebox-region-resolve`、`ebox-region-update`、 `ebox-host-ref-bounds`、`ebox-host-ref-position`; - Selector:`ebox-selector-parse`、`ebox-selector-match-node-p`、 diff --git a/docs/user/ebox-user-guide.en.md b/docs/user/ebox-user-guide.en.md index 0e7d4fb..1d36d1e 100644 --- a/docs/user/ebox-user-guide.en.md +++ b/docs/user/ebox-user-guide.en.md @@ -35,7 +35,7 @@ Children are always nested directly. A layout form is a Box with a selected child-layout algorithm, not a different kind of visual object. ```elisp -(defvar ebox-guide-root +(defvar ebox-guide-input (ebox-build '(column :padding (1 2) :border ((1) solid "#8A93A6") @@ -45,6 +45,11 @@ child-layout algorithm, not a different kind of visual object. (box :background-color "#EEF2FF" "Archive"))))) ``` +`ebox-build` returns an opaque `CanonicalEboxInput`. It keeps the canonical +forest and its source generation together. Pass that value unchanged to the +render and publication functions; ordinary author code does not extract or +reassemble its internal nodes. + Use `box` with geometry but no child when an empty rectangular area is needed. No extra node type is necessary. @@ -125,13 +130,15 @@ parent. The child-layout algorithm still comes from the form name. `ebox-render` returns propertized text without changing a live buffer. `ebox-render-to-buffer` mounts a retained TP surface and returns its buffer. +Both functions, and `ebox-display-buffer`, accept the opaque value returned by +`ebox-build`. ```elisp -(ebox-render ebox-guide-root) -(ebox-render-to-buffer "*Ebox Guide*" ebox-guide-root) +(ebox-render ebox-guide-input) +(ebox-render-to-buffer "*Ebox Guide*" ebox-guide-input) ``` -Build a fresh root and use `ebox-commit` for an atomic update: +Build a fresh canonical input and use `ebox-commit` for an atomic update: ```elisp (ebox-commit @@ -139,7 +146,7 @@ Build a fresh root and use `ebox-commit` for an atomic update: (ebox-build '(column :padding (1 2) (text "Updated notes") - (box :key body "The new root is caller-owned.")))) + (box :key body "The new input is caller-owned.")))) ``` Validation, rendering, or publication failure leaves the previous buffer and @@ -198,19 +205,61 @@ examples. ## 9. Typed integration API -Frameworks that already normalize author input may bypass the list DSL. They -construct one TextNode with `ebox-text-create`, one typed LayoutConfig with the -matching layout constructor, then one BoxNode with `ebox-box-create`. +Frameworks that already normalize author input may bypass the list DSL. This +is an integration API, not a second author grammar. One source builder owns +the complete source generation. Each TextNode or BoxNode receives an opaque +handle from that builder and node-owned facts projected from the same +normalized declarations. The framework then seals the builder and transports +the forest and source index together as one `CanonicalEboxInput`. ```elisp -(ebox-box-create - :layout (ebox-row-layout-create :item-gap 1 :cross-align 'center) - :children (list (ebox-text-create :value "Left") - (ebox-text-create :value "Right"))) +(let* ((builder (ebox-source-builder-create)) + (root-declarations nil) + (left-declarations nil) + (right-declarations nil) + (root-handle + (ebox-source-builder-bind + builder :declarations root-declarations)) + (left-handle + (ebox-source-builder-bind + builder :declarations left-declarations)) + (right-handle + (ebox-source-builder-bind + builder :declarations right-declarations)) + (left + (ebox-text-create + :value "Left" + :source-handle left-handle + :owned-facts + (ebox-canonical-facts-from-declarations + 'text left-declarations))) + (right + (ebox-text-create + :value "Right" + :source-handle right-handle + :owned-facts + (ebox-canonical-facts-from-declarations + 'text right-declarations))) + (root + (ebox-box-create + :layout (ebox-row-layout-create + :item-gap 1 :cross-align 'center) + :children (list left right) + :source-handle root-handle + :owned-facts + (ebox-canonical-facts-from-declarations + 'row root-declarations)))) + (ebox-canonical-input-create + (list root) + (ebox-source-builder-finish builder))) ``` -Here `:layout` and `:children` are fields of the evaluated typed constructor. -They are not author properties and do not extend the seven-entry grammar. +`ebox-source-builder-create`, `ebox-source-builder-bind`, +`ebox-source-builder-finish`, `ebox-canonical-facts-from-declarations`, the +typed node/layout constructors, and `ebox-canonical-input-create` belong to +this integration boundary. Here `:layout`, `:children`, `:source-handle`, and +`:owned-facts` are evaluated constructor fields. They are not author +properties and do not extend the seven-entry grammar. ## 10. Optional native reflow and verification diff --git a/docs/user/ebox-user-guide.zh.md b/docs/user/ebox-user-guide.zh.md index 2cb5072..47f9919 100644 --- a/docs/user/ebox-user-guide.zh.md +++ b/docs/user/ebox-user-guide.zh.md @@ -31,7 +31,7 @@ Ebox 文档由 Text 和 Box 节点组成。author 语法只有七个入口: 子节点始终直接嵌套。布局 form 只是选择了子布局算法的 Box,不是另一种视觉对象。 ```elisp -(defvar ebox-guide-root +(defvar ebox-guide-input (ebox-build '(column :padding (1 2) :border ((1) solid "#8A93A6") @@ -41,6 +41,10 @@ Ebox 文档由 Text 和 Box 节点组成。author 语法只有七个入口: (box :background-color "#EEF2FF" "Archive"))))) ``` +`ebox-build` 返回不透明的 `CanonicalEboxInput`,把 canonical forest 与对应的 source +generation 保持为一个整体。普通 author 代码只需把这个值原样传给渲染和发布函数, +不要取出或重新组装其中的内部 node。 + 需要空矩形区域时,使用只有几何 property、没有子节点的 `box`,不需要额外节点类型。 ## 3. 用布局名称直接表达意图 @@ -115,14 +119,15 @@ placement property 同样直接属于子 `box`。 ## 5. 渲染与发布 `ebox-render` 返回带属性文本,不修改 live buffer;`ebox-render-to-buffer` 挂载 -retained TP surface 并返回 buffer。 +retained TP surface 并返回 buffer。它们和 `ebox-display-buffer` 都接收 +`ebox-build` 返回的不透明值。 ```elisp -(ebox-render ebox-guide-root) -(ebox-render-to-buffer "*Ebox Guide*" ebox-guide-root) +(ebox-render ebox-guide-input) +(ebox-render-to-buffer "*Ebox Guide*" ebox-guide-input) ``` -重新构建 root 后,用 `ebox-commit` 原子更新: +重新构建 canonical input 后,用 `ebox-commit` 原子更新: ```elisp (ebox-commit @@ -130,7 +135,7 @@ retained TP surface 并返回 buffer。 (ebox-build '(column :padding (1 2) (text "Updated notes") - (box :key body "The new root is caller-owned.")))) + (box :key body "The new input is caller-owned.")))) ``` 验证、渲染或发布失败时,旧 buffer 与 runtime 状态保持不变。 @@ -184,19 +189,59 @@ retained TP surface 并返回 buffer。 ## 9. Typed 集成 API -已经完成 author normalization 的框架可以绕过 list DSL:用 `ebox-text-create` -构造 TextNode,用对应 layout constructor 构造 typed LayoutConfig,再用 -`ebox-box-create` 构造 BoxNode。 +已经完成 author normalization 的框架可以绕过 list DSL;这是集成 API,不是第二套 +author 语法。一个 source builder 拥有完整 source generation。每个 TextNode 或 +BoxNode 都取得该 builder 返回的不透明 handle,并使用由同一份 normalized +declarations 投影出的 node-owned facts。最后封存 builder,把 forest 与 source index +作为一个 `CanonicalEboxInput` 传递。 ```elisp -(ebox-box-create - :layout (ebox-row-layout-create :item-gap 1 :cross-align 'center) - :children (list (ebox-text-create :value "Left") - (ebox-text-create :value "Right"))) +(let* ((builder (ebox-source-builder-create)) + (root-declarations nil) + (left-declarations nil) + (right-declarations nil) + (root-handle + (ebox-source-builder-bind + builder :declarations root-declarations)) + (left-handle + (ebox-source-builder-bind + builder :declarations left-declarations)) + (right-handle + (ebox-source-builder-bind + builder :declarations right-declarations)) + (left + (ebox-text-create + :value "Left" + :source-handle left-handle + :owned-facts + (ebox-canonical-facts-from-declarations + 'text left-declarations))) + (right + (ebox-text-create + :value "Right" + :source-handle right-handle + :owned-facts + (ebox-canonical-facts-from-declarations + 'text right-declarations))) + (root + (ebox-box-create + :layout (ebox-row-layout-create + :item-gap 1 :cross-align 'center) + :children (list left right) + :source-handle root-handle + :owned-facts + (ebox-canonical-facts-from-declarations + 'row root-declarations)))) + (ebox-canonical-input-create + (list root) + (ebox-source-builder-finish builder))) ``` -这里的 `:layout` 和 `:children` 是 evaluated typed constructor 的字段,不是 author -property,也没有扩展七入口语法。 +`ebox-source-builder-create`、`ebox-source-builder-bind`、 +`ebox-source-builder-finish`、`ebox-canonical-facts-from-declarations`、typed +node/layout constructor 与 `ebox-canonical-input-create` 只属于集成边界。这里的 +`:layout`、`:children`、`:source-handle` 与 `:owned-facts` 是 evaluated constructor +字段,不是 author property,也没有扩展七入口语法。 ## 10. 可选 native reflow 与验证 diff --git a/ebox-canonical.el b/ebox-canonical.el index a16b170..664e01b 100644 --- a/ebox-canonical.el +++ b/ebox-canonical.el @@ -12,9 +12,11 @@ (require 'cl-lib) (require 'subr-x) (require 'ebox-layout-config) +(require 'ebox-source) (require 'ebox-style) (require 'ebox-node-factory) (require 'ebox-child-range) +(require 'ebox-tree) (defun ebox-canonical--validate-plist (plist context) "Validate PLIST shape for error CONTEXT and return PLIST." @@ -53,39 +55,191 @@ (error "%s accepts %S at most once" context field)) (and values (cons t (car values))))) -(defconst ebox-canonical--source-metadata-fields - '(:source-handle :key :class :id) - "Source facts accepted by both canonical Text and Box constructors.") +(defconst ebox-canonical--source-fields + '(:source-handle) + "Opaque source input accepted by canonical Text and Box constructors.") (defvar ebox-canonical--declaration-fact-cache (make-hash-table :test #'eq :weakness 'key) "Declaration-identity keyed validated canonical node facts.") -(defun ebox-canonical--source-metadata (plist context) - "Return detached source metadata from PLIST for CONTEXT. -Every metadata field is optional but may occur at most once." - (let (metadata) - (dolist (field ebox-canonical--source-metadata-fields) - (when-let* ((entry (ebox-canonical--optional-field - plist field context))) - (setq metadata - (append metadata (list field (cdr entry)))))) - metadata)) +(defvar ebox-canonical--source-builder nil + "Transaction-local source builder available during typed tree assembly.") -(defun ebox-canonical--apply-source-metadata (node metadata) - "Attach canonical source METADATA to runtime NODE and return NODE." - (while metadata - (let ((field (pop metadata)) - (value (pop metadata))) - (plist-put node - (pcase field - (:source-handle :ebox-source-handle) - (_ field)) - value) - ;; `:host-ref' is the private retained-geometry projection of the - ;; source-owned handle. It is not a second public identity field. - (when (and (eq field :source-handle) value) - (plist-put node :host-ref value)))) +(cl-defstruct (ebox-canonical-facts + (:constructor ebox-canonical--make-facts)) + "Already computed node-owned projection of one source declaration set." + engine-properties style-required-p specified-property-ids) + +(cl-defstruct (ebox-canonical-input + (:constructor ebox-canonical--make-input) + (:conc-name ebox-canonical-input--)) + "Atomic transport of a canonical forest and its source generation." + nodes source-index range-anchors) + +(defun ebox-canonical--validate-input-source-ownership (nodes source-index) + "Validate that NODES reference exactly SOURCE-INDEX's source generation." + (let ((seen-nodes (make-hash-table :test #'eq)) + (seen-handles (make-hash-table :test #'eq)) + preorder-handles) + (cl-labels + ((visit + (node) + (unless (or (ebox-text-node-p node) (ebox-box-node-p node)) + (error "Ebox canonical input contains a noncanonical node: %S" node)) + (when (gethash node seen-nodes) + (error "Ebox canonical input reuses one node object")) + (puthash node t seen-nodes) + (let ((handle (ebox-node-source-handle node))) + (unless (and (ebox-source-handle-p handle) + (ebox-source-index-handle-member-p + source-index handle)) + (error + "Ebox canonical source is absent from its input generation")) + (when (gethash handle seen-handles) + (error "Ebox canonical input reuses one source handle")) + (puthash handle t seen-handles) + (push handle preorder-handles)) + (when (ebox-box-node-p node) + (mapc #'visit (ebox-box-node-children node))))) + (mapc #'visit nodes)) + (setq preorder-handles (nreverse preorder-handles)) + (unless (= (hash-table-count seen-handles) + (ebox-source--table-size + (ebox-source--index-handle-records source-index))) + (error "Ebox canonical input contains unreferenced source facts")) + (ebox-source--with-order source-index preorder-handles))) + +(defun ebox-canonical-input-create (nodes source-index &optional range-anchors) + "Return one opaque canonical input for ordered forest NODES. +SOURCE-INDEX owns every source fact referenced by NODES. RANGE-ANCHORS are +nonvisual structural addresses transported with the same generation." + (unless (and (proper-list-p nodes) + (cl-every (lambda (node) + (and (listp node) (not (stringp node)))) + nodes)) + (error "Ebox canonical input nodes must be a proper node forest: %S" + nodes)) + (unless (ebox-source-index-p source-index) + (signal 'wrong-type-argument (list 'ebox-source-index-p source-index))) + (setq source-index + (ebox-canonical--validate-input-source-ownership nodes source-index)) + (ebox-canonical--make-input + :nodes (copy-sequence nodes) + :source-index source-index + :range-anchors (copy-tree range-anchors))) + +(defun ebox-canonical-input-roots (input) + "Return a shallow copy of canonical INPUT's ordered forest roots. +This is a read-only structural view; it does not adopt INPUT's source facts +into another candidate." + (unless (ebox-canonical-input-p input) + (signal 'wrong-type-argument (list 'ebox-canonical-input-p input))) + (copy-sequence (ebox-canonical-input--nodes input))) + +(defun ebox-canonical-input-import-roots (input roots builder) + "Import INPUT facts owned by selected top-level ROOTS into BUILDER. +Each ROOT must be an exact, unique member of INPUT's forest. The selected +subtrees retain their existing nodes, handles, and immutable records without +adopting facts from unselected roots. Return a shallow copy of ROOTS." + (unless (ebox-canonical-input-p input) + (signal 'wrong-type-argument (list 'ebox-canonical-input-p input))) + (ebox-source--builder-assert-open builder) + (unless (proper-list-p roots) + (error "Ebox canonical selected roots must be a proper list: %S" roots)) + (let ((available (make-hash-table :test #'eq)) + (selected (make-hash-table :test #'eq)) + (source-index (ebox-canonical-input--source-index input))) + (dolist (root (ebox-canonical-input--nodes input)) + (puthash root t available)) + (cl-labels + ((import-node + (node) + (let* ((handle (ebox-node-source-handle node)) + (record (ebox-source--index-record source-index handle))) + (unless record + (error "Ebox canonical selected root has no source facts")) + (ebox-source--builder-add-record builder handle record)) + (when (ebox-box-node-p node) + (mapc #'import-node (ebox-box-node-children node))))) + (dolist (root roots) + (unless (gethash root available) + (error "Ebox canonical selected root is outside its input")) + (when (gethash root selected) + (error "Ebox canonical selected root occurs more than once")) + (puthash root t selected) + (import-node root)))) + (copy-sequence roots)) + +(defun ebox-canonical-input--single-root (input context) + "Return INPUT's only root node, or signal for error CONTEXT." + (unless (ebox-canonical-input-p input) + (signal 'wrong-type-argument (list 'ebox-canonical-input-p input))) + (let ((nodes (ebox-canonical-input--nodes input))) + (unless (= (length nodes) 1) + (error "%s requires exactly one canonical root" context)) + (car nodes))) + +(defun ebox-canonical-input-root-host-ref (input) + "Return INPUT's detached single-root publication reference. +This framework-integration port keeps `ebox-source-handle' opaque: Ebox first +validates that the canonical root's exact handle belongs to INPUT's immutable +source generation, then returns only the author-owned publication reference." + (let* ((root (ebox-canonical-input--single-root + input "ebox-canonical-input-root-host-ref")) + (handle (plist-get root :ebox-source-handle)) + (source-index (ebox-canonical-input--source-index input))) + (unless (and (ebox-source-handle-p handle) + (ebox-source-index-handle-member-p source-index handle)) + (error "Ebox canonical root source is absent from its input generation")) + (ebox-source-handle-id handle))) + +(defun ebox-canonical-input-equal-p (left right) + "Return non-nil when canonical inputs LEFT and RIGHT are exactly equivalent. +The comparison includes the ordered forest, transparent Range anchors, source +identity order, and every immutable author fact. Derived selector/runtime +indexes are deliberately excluded because they are projections of these +canonical inputs, not additional source authority." + (and (ebox-canonical-input-p left) + (ebox-canonical-input-p right) + (equal-including-properties + (ebox-canonical-input--nodes left) + (ebox-canonical-input--nodes right)) + (equal-including-properties + (ebox-canonical-input--range-anchors left) + (ebox-canonical-input--range-anchors right)) + (let ((left-handles + (ebox-source-index-handles + (ebox-canonical-input--source-index left))) + (right-handles + (ebox-source-index-handles + (ebox-canonical-input--source-index right))) + (left-index (ebox-canonical-input--source-index left)) + (right-index (ebox-canonical-input--source-index right))) + (and (= (length left-handles) (length right-handles)) + (cl-every + (lambda (left-handle right-handle) + (and + (equal-including-properties + (ebox-source--handle-id-view left-handle) + (ebox-source--handle-id-view right-handle)) + (equal-including-properties + (ebox-source--index-record left-index left-handle) + (ebox-source--index-record right-index right-handle)))) + left-handles right-handles))))) + +(defun ebox-canonical--source-handle (plist context) + "Return the one opaque source handle from PLIST for CONTEXT." + (let ((handle (ebox-canonical--required-field + plist :source-handle context))) + (unless (ebox-source-handle-p handle) + (error "%s :source-handle must be opaque Ebox source: %S" + context handle)) + handle)) + +(defun ebox-canonical--apply-source-handle (node handle) + "Attach opaque source HANDLE to runtime NODE and return NODE." + (plist-put node :ebox-source-handle handle) node) (defun ebox-canonical--text-measurement-property-p (property) @@ -171,6 +325,25 @@ and author TAG." '(paint typography)))))) nil)))) +(defun ebox-canonical-facts-from-declarations (tag declarations) + "Return immutable node-owned facts for TAG from DECLARATIONS. +Source declarations stay behind the source handle; the returned value contains +only the current canonical-node projection needed before G4c separates paint." + (let ((predicate + (if (eq tag 'text) + #'ebox-canonical--text-measurement-property-p + #'ebox-canonical--box-frame-property-p))) + (pcase-let ((`(,canonical ,engine ,style-required-p) + (ebox-canonical--node-properties + nil (and declarations (cons t declarations)) + predicate + (if (eq tag 'text) "Ebox Text" "Ebox Box") tag))) + (ebox-canonical--make-facts + :engine-properties engine + :style-required-p style-required-p + :specified-property-ids + (cl-loop for (id _value) on canonical by #'cddr collect id))))) + (defun ebox-node-kind (node) "Return canonical NODE kind, or nil for a legacy runtime node." (and (listp node) (plist-get node :ebox-kind))) @@ -247,41 +420,57 @@ and author TAG." children)) (push (cons nil (list entry)) segments) (push entry material))) - (list (nreverse material) - (and range-p - (ebox-child-range--build (nreverse segments) nil t))))) + (setq material (nreverse material) + segments (nreverse segments)) + (when (and range-p (not (ebox-source-builder-p + ebox-canonical--source-builder))) + (error "Ebox Range children require one canonical source builder")) + (let ((source-builder (and range-p ebox-canonical--source-builder))) + (list material + (and range-p + (ebox-child-range--build + segments + (lambda (node) + (when-let* ((record + (ebox-source--builder-record + source-builder + (ebox-node-source-handle node)))) + (ebox-source-record-key record))) + nil t)))))) ;;;###autoload (defun ebox-text-create (&rest plist) "Create a canonical TextNode from evaluated PLIST. -`:value' is required and must be one string. `:source-handle', `:key', -`:class', and `:id' are optional source facts. Direct properties are limited -to text measurement. `:declarations' may carry one already-normalized Text -author fact set so paint is projected without reparsing." +`:value' is required and must be one string. `:source-handle' is required and +is the only author source input. Direct properties are limited to already +computed Text measurement facts." (ebox-canonical--validate-plist plist "ebox-text-create") (let* ((value (ebox-canonical--required-field plist :value "Ebox Text")) - (declarations-field (ebox-canonical--optional-field - plist :declarations "Ebox Text")) - (source-metadata (ebox-canonical--source-metadata plist "Ebox Text")) + (source-handle (ebox-canonical--source-handle plist "Ebox Text")) + (facts (ebox-canonical--required-field + plist :owned-facts "Ebox Text")) (props (ebox-canonical--without-keys - plist (append '(:value :declarations) - ebox-canonical--source-metadata-fields)))) + plist + (append '(:value :owned-facts) + ebox-canonical--source-fields)))) (unless (stringp value) (error "Ebox Text :value must be a string: %S" value)) - (pcase-let* ((`(,declarations ,engine-props ,style-required-p) - (ebox-canonical--node-properties - props declarations-field - #'ebox-canonical--text-measurement-property-p - "Ebox Text" 'text)) - (node - (ebox-node-factory--create-text-expanded - (append engine-props (list :content value)) - declarations))) + (when props + (error "Ebox Text accepts only normalized owned facts")) + (when (not (ebox-canonical-facts-p facts)) + (error "Ebox Text :owned-facts must be canonical facts: %S" facts)) + (pcase-let* ((engine-props + (ebox-canonical-facts-engine-properties facts)) + (style-required-p + (ebox-canonical-facts-style-required-p facts)) + (node + (ebox-node-factory--create-text-expanded + (append engine-props (list :content value))))) (plist-put node :ebox-kind 'text) (plist-put node :ebox-text-value value) - (ebox-canonical--apply-source-metadata node source-metadata) + (ebox-canonical--apply-source-handle node source-handle) (plist-put node :display '(inline flow)) (plist-put node :ebox-author-style-count (if style-required-p 1 0)) @@ -295,24 +484,24 @@ author fact set so paint is projected without reparsing." `:layout' must occur exactly once as an `ebox-layout-config'. `:children' is a list of canonical nodes. Every Layout accepts zero or more -children according to its own formatting algorithm. `:declarations' may -carry one already-normalized Box author fact set." +children according to its own formatting algorithm. `:source-handle' is the +only author source input." (ebox-canonical--validate-plist plist "ebox-box-create") (let* ((layout (ebox-canonical--required-field plist :layout "Ebox Box")) (children-field (ebox-canonical--optional-field plist :children "Ebox Box")) - (declarations-field (ebox-canonical--optional-field - plist :declarations "Ebox Box")) (outer-field (ebox-canonical--optional-field plist :outer "Ebox Box")) - (source-metadata (ebox-canonical--source-metadata plist "Ebox Box")) + (source-handle (ebox-canonical--source-handle plist "Ebox Box")) + (facts (ebox-canonical--required-field + plist :owned-facts "Ebox Box")) (children (if children-field (cdr children-field) nil)) child-sequence (outer (if outer-field (cdr outer-field) 'block)) (props (ebox-canonical--without-keys - plist (append '(:layout :children :outer :declarations) - ebox-canonical--source-metadata-fields)))) + plist (append '(:layout :children :outer :owned-facts) + ebox-canonical--source-fields)))) (unless (ebox-layout-config-p layout) (error "Ebox Box :layout must be a typed layout config: %S" layout)) @@ -329,22 +518,28 @@ carry one already-normalized Box author fact set." '(normal row column flex grid)) (error "Ebox Box Layout is not implemented: %S" (ebox-layout-config-kind layout))) - (pcase-let* ((tag (if (eq (ebox-layout-config-kind layout) 'normal) - 'box - (ebox-layout-config-kind layout))) - (`(,declarations ,engine-props ,style-required-p) - (ebox-canonical--node-properties - props declarations-field - #'ebox-canonical--box-frame-property-p - "Ebox Box" tag)) - (node (ebox-node-factory--create-expanded - engine-props declarations))) + (when props + (error "Ebox Box accepts only normalized owned facts")) + (when (not (ebox-canonical-facts-p facts)) + (error "Ebox Box :owned-facts must be canonical facts: %S" facts)) + (pcase-let* ((`(,canonical-declarations ,engine-props ,style-required-p) + (list + (ebox-canonical-facts-specified-property-ids facts) + (ebox-canonical-facts-engine-properties facts) + (ebox-canonical-facts-style-required-p facts))) + (node (ebox-node-factory--create-expanded engine-props))) (plist-put node :ebox-kind 'box) (plist-put node :ebox-layout-config layout) (plist-put node :children children) + (when (plist-member canonical-declarations + (ebox-style-schema-id :min-width)) + (plist-put node :ebox-explicit-min-width-p t)) + (when (plist-member canonical-declarations + (ebox-style-schema-id :min-height)) + (plist-put node :ebox-explicit-min-height-p t)) (when child-sequence (plist-put node :ebox-child-sequence child-sequence)) - (ebox-canonical--apply-source-metadata node source-metadata) + (ebox-canonical--apply-source-handle node source-handle) (plist-put node :display (list outer (pcase (ebox-layout-config-kind layout) diff --git a/ebox-child-range.el b/ebox-child-range.el index a6c5c29..a328936 100644 --- a/ebox-child-range.el +++ b/ebox-child-range.el @@ -23,7 +23,7 @@ (cl-defstruct (ebox-child-range--segment (:constructor ebox-child-range--make-segment)) - ref payload) + ref payload keys) (cl-defstruct (ebox-child-range--segment-node (:constructor ebox-child-range--make-segment-node)) @@ -208,12 +208,17 @@ shallow copy owned by the persistent sequence." (ebox-child-range--make-descriptor :ref ref :items items)) (defun ebox-child-range--build - (segments &optional hash-function retain-item-identities-p) + (segments key-function &optional hash-function retain-item-identities-p + segment-key-vectors) "Build an immutable child sequence from SEGMENTS. Each entry is `(REF . ITEMS)'; nil REF denotes one static child item. -HASH-FUNCTION optionally supplies the stable key hash. When +KEY-FUNCTION returns each item's source-owned author key, or nil when this +sequence is deliberately unkeyed. HASH-FUNCTION optionally supplies the +stable key hash. When RETAIN-ITEM-IDENTITIES-P is non-nil, ITEMS are already-owned canonical nodes -and the sequence retains those exact objects instead of copying them." +and the sequence retains those exact objects instead of copying them. +SEGMENT-KEY-VECTORS, when non-nil, supplies the already-derived key vector for +each segment so structural copies never recover keys from source metadata." (unless (proper-list-p segments) (signal 'wrong-type-argument (list 'proper-list-p segments))) (let* ((count (length segments)) @@ -222,7 +227,13 @@ and the sequence retains those exact objects instead of copying them." (metrics (ebox-child-range--make-metrics)) root key-root (refs (make-hash-table :test #'equal)) (index 0)) (when (zerop count) (error "Ebox child sequence requires a segment")) - (dolist (entry segments) + (when (and segment-key-vectors + (/= (length segment-key-vectors) (length segments))) + (error "Ebox child sequence key vectors are misaligned")) + (cl-loop for entry in segments + for explicit-keys in (or segment-key-vectors + (make-list (length segments) nil)) + do (let* ((ref (car entry)) (items (cdr entry))) (unless (proper-list-p items) @@ -232,16 +243,27 @@ and the sequence retains those exact objects instead of copying them." (when (and ref (gethash ref refs)) (error "Ebox child Range ref is not unique: %S" ref)) (when ref (puthash ref index refs)) - (let ((payload (vconcat - (mapcar (lambda (item) - (ebox-child-range--item-copy - item metrics retain-item-identities-p)) - items)))) + (let* ((payload (vconcat + (mapcar (lambda (item) + (ebox-child-range--item-copy + item metrics retain-item-identities-p)) + items))) + (keys + (if explicit-keys + (copy-sequence explicit-keys) + (vconcat + (mapcar (lambda (item) + (and key-function + (funcall key-function item))) + items))))) + (unless (= (length keys) (length payload)) + (error "Ebox child sequence keys do not match payload")) (setq root (ebox-child-range--segment-insert root height index - (ebox-child-range--make-segment :ref ref :payload payload))) + (ebox-child-range--make-segment + :ref ref :payload payload :keys keys))) (dotimes (offset (length payload)) - (let* ((item (aref payload offset)) (key (plist-get item :key))) + (let ((key (aref keys offset))) (when key (setq key-root (ebox-child-range--hash-change @@ -252,14 +274,17 @@ and the sequence retains those exact objects instead of copying them." :root root :height height :count count :ref-index refs :key-root key-root :hash-function hash-function))) -(defun ebox-child-range--replace (sequence ref items) +(defun ebox-child-range--replace (sequence ref items key-function) "Return `(NEW-SEQUENCE . METRICS)' for SEQUENCE after replacing REF ITEMS." - (ebox-child-range--replace-many sequence (list (cons ref items)))) + (ebox-child-range--replace-many + sequence (list (cons ref items)) key-function)) -(defun ebox-child-range--replace-many (sequence replacements &optional reuse-maps) +(defun ebox-child-range--replace-many + (sequence replacements key-function &optional reuse-maps) "Replace RANGE REPLACEMENTS in SEQUENCE with one two-phase transaction. REUSE-MAPS is an alist from Range ref to proven `(NEW . OLD)' item indexes; -those NEW items already name canonical published objects and are not copied." +those NEW items already name canonical published objects and are not copied. +KEY-FUNCTION returns the source-owned key for every replacement item." (let ((metrics (ebox-child-range--make-metrics)) (missing (make-symbol "missing-range-ref")) (seen (make-hash-table :test #'equal)) records) @@ -278,13 +303,18 @@ those NEW items already name canonical published objects and are not copied." (when (eq index missing) (error "Ebox child Range ref does not exist: %S" (car entry))) (let ((reuse-map (cdr (assoc (car entry) reuse-maps #'equal)))) - (push (list (car entry) index - (vconcat - (cl-loop for item in (cdr entry) for offset from 0 - collect - (if (assq offset reuse-map) - item - (ebox-child-range--item-copy item metrics))))) + (push (list + (car entry) index + (vconcat + (cl-loop for item in (cdr entry) for offset from 0 + collect + (if (assq offset reuse-map) + item + (ebox-child-range--item-copy item metrics)))) + (vconcat + (mapcar (lambda (item) + (and key-function (funcall key-function item))) + (cdr entry)))) records)))) (setq records (nreverse records)) (let ((key-root (ebox-child-range--sequence-key-root sequence)) @@ -300,24 +330,29 @@ those NEW items already name canonical published objects and are not copied." root (ebox-child-range--sequence-height sequence) (nth 1 record) (ebox-child-range--make-segment - :ref (car record) :payload (nth 2 record)) + :ref (car record) :payload (nth 2 record) + :keys (nth 3 record)) metrics))) (setq root (car result)) (list (car record) (nth 1 record) (cdr result) - (nth 2 record)))) + (nth 2 record) (nth 3 record)))) records)) (dolist (record records) - (let ((payload (ebox-child-range--segment-payload (nth 2 record)))) + (let* ((segment (nth 2 record)) + (payload (ebox-child-range--segment-payload segment)) + (keys (ebox-child-range--segment-keys segment))) (dotimes (offset (length payload)) (ebox-child-range--metric-add metrics 'old-affected-payload-visits) - (when-let* ((key (plist-get (aref payload offset) :key))) + (when-let* ((key (aref keys offset))) (setq key-root (ebox-child-range--hash-change key-root key nil (funcall hash-function key) 0 t metrics)))))) (dolist (record records) - (let ((index (nth 1 record)) (payload (nth 3 record))) + (let ((index (nth 1 record)) + (payload (nth 3 record)) + (keys (nth 4 record))) (dotimes (offset (length payload)) - (when-let* ((key (plist-get (aref payload offset) :key))) + (when-let* ((key (aref keys offset))) (setq key-root (ebox-child-range--hash-change key-root key (cons index offset) @@ -330,16 +365,17 @@ those NEW items already name canonical published objects and are not copied." metrics)))) (defun ebox-child-range--replace-item-at - (sequence segment-index offset old-item new-item) + (sequence segment-index offset old-item new-item new-key) "Persistently replace OLD-ITEM by NEW-ITEM in SEQUENCE. -SEGMENT-INDEX and OFFSET identify its exact material-child location." +SEGMENT-INDEX and OFFSET identify its exact material-child location. NEW-KEY +is the replacement's source-owned author key." (let* ((segment (ebox-child-range--segment-at sequence segment-index)) (payload (copy-sequence (ebox-child-range--segment-payload segment))) + (keys (copy-sequence (ebox-child-range--segment-keys segment))) (key-root (ebox-child-range--sequence-key-root sequence)) (hash-function (ebox-child-range--sequence-hash-function sequence)) (metrics (ebox-child-range--make-metrics)) - (old-key (plist-get old-item :key)) - (new-key (plist-get new-item :key))) + (old-key (aref keys offset))) (unless (eq (aref payload offset) old-item) (error "Ebox child sequence location is stale")) (when old-key @@ -351,12 +387,14 @@ SEGMENT-INDEX and OFFSET identify its exact material-child location." key-root new-key (cons segment-index offset) (funcall hash-function new-key) 0 nil metrics))) (aset payload offset new-item) + (aset keys offset new-key) (let ((result (ebox-child-range--segment-replace (ebox-child-range--sequence-root sequence) (ebox-child-range--sequence-height sequence) segment-index (ebox-child-range--make-segment - :ref (ebox-child-range--segment-ref segment) :payload payload) + :ref (ebox-child-range--segment-ref segment) + :payload payload :keys keys) metrics))) (ebox-child-range--make-sequence :root (car result) :height (ebox-child-range--sequence-height sequence) diff --git a/ebox-dsl.el b/ebox-dsl.el index 7c699b2..22def1c 100644 --- a/ebox-dsl.el +++ b/ebox-dsl.el @@ -9,6 +9,7 @@ ;;; Code: (require 'cl-lib) +(require 'ebox-source) (require 'ebox-style) (require 'ebox-canonical) (require 'ebox-flex) @@ -19,10 +20,11 @@ (:constructor ebox-dsl--make-properties)) "Normalized properties for one canonical Text or Box author source." tag - source-metadata + source-handle outer layout - declarations) + declarations + owned-facts) (defconst ebox-dsl--box-tags '(box row column flex grid) @@ -32,10 +34,6 @@ '(:key :class :id) "Shared source metadata accepted by Ebox author surfaces.") -(defun ebox-dsl--source-handle () - "Return one opaque identity for a normalized author node." - (make-symbol "ebox-source")) - (defun ebox-dsl--validate-plist (plist context) "Validate PLIST shape for CONTEXT and return PLIST." (unless (and (proper-list-p plist) (zerop (% (length plist) 2))) @@ -70,16 +68,10 @@ when (memq key keys) append (list key value))) -(defun ebox-dsl--source-metadata - (properties source-handle context) - "Return source metadata from PROPERTIES and SOURCE-HANDLE for CONTEXT." - (let ((metadata (list :source-handle source-handle))) - (dolist (field ebox-dsl--source-fields) - (when-let* ((entry (ebox-dsl--optional-field - properties field context))) - (setq metadata - (append metadata (list field (cdr entry)))))) - metadata)) +(defun ebox-dsl--source-field (properties field context) + "Return optional source FIELD value from PROPERTIES for CONTEXT." + (when-let* ((entry (ebox-dsl--optional-field properties field context))) + (cdr entry))) (defun ebox-dsl--compile-style (tag properties context) "Compile and validate TAG author PROPERTIES for CONTEXT." @@ -110,8 +102,8 @@ (error "%s rejected layout config: %s" context (error-message-string err)))))) -(defun ebox-dsl--normalize-properties (tag properties source-handle) - "Project evaluated TAG PROPERTIES for opaque SOURCE-HANDLE. +(defun ebox-dsl--normalize-properties (tag properties source source-builder) + "Project evaluated TAG PROPERTIES for author SOURCE. TAG is `text', `box', `row', `column', `flex', or `grid'. The returned `ebox-dsl--properties' contains typed constructor inputs, source metadata, @@ -148,14 +140,22 @@ function." tag style-properties context))) (ebox-dsl--make-properties :tag tag - :source-metadata - (ebox-dsl--source-metadata - properties source-handle context) + :source-handle + (ebox-source-builder-bind + source-builder + :key (ebox-dsl--source-field properties :key context) + :id (ebox-dsl--source-field properties :id context) + :class (ebox-dsl--source-field properties :class context) + :declarations declarations + :provenance (list :adapter 'ebox-dsl :tag tag + :source-present-p (not (null source)))) :outer outer :layout (ebox-dsl--layout-config tag declarations extensions context) - :declarations declarations)))) + :declarations declarations + :owned-facts + (ebox-canonical-facts-from-declarations tag declarations))))) (defconst ebox-dsl--tags '(text box row column flex grid) "The complete Ebox author tag set.") @@ -196,7 +196,7 @@ function." (setq children (append children (list item)))))) (cons properties children))) -(defun ebox-dsl--build-text (_source items) +(defun ebox-dsl--build-text (source items source-builder) "Build one canonical TextNode from author ITEMS." (let* ((split (ebox-dsl--split items)) (properties (car split)) @@ -206,53 +206,66 @@ function." (unless (stringp (car payloads)) (error "Ebox text payload must be a string: %S" (car payloads))) (let* ((plan (ebox-dsl--normalize-properties - 'text properties (ebox-dsl--source-handle))) + 'text properties source source-builder)) (node - (apply #'ebox-text-create - (append (list :value (car payloads)) - (ebox-dsl--properties-source-metadata plan) - (list :declarations - (ebox-dsl--properties-declarations plan)))))) + (ebox-text-create + :value (car payloads) + :owned-facts (ebox-dsl--properties-owned-facts plan) + :source-handle (ebox-dsl--properties-source-handle plan)))) node))) -(defun ebox-dsl--build-box (tag _source items) +(defun ebox-dsl--build-box (tag source items source-builder) "Build canonical BoxNode TAG from author ITEMS." (let* ((split (ebox-dsl--split items)) (properties (car split)) (child-forms (cdr split)) (plan (ebox-dsl--normalize-properties - tag properties (ebox-dsl--source-handle))) - (children (mapcar #'ebox-dsl-build child-forms)) + tag properties source source-builder)) + (children + (mapcar (lambda (child) + (ebox-dsl-build child source-builder)) + child-forms)) (node (apply #'ebox-box-create (append (list :layout (ebox-dsl--properties-layout plan) :outer (ebox-dsl--properties-outer plan) :children children - :declarations - (ebox-dsl--properties-declarations plan)) - (ebox-dsl--properties-source-metadata plan))))) + :owned-facts (ebox-dsl--properties-owned-facts plan) + :source-handle + (ebox-dsl--properties-source-handle plan)))))) node)) -(defun ebox-dsl-build (dsl) +(defun ebox-dsl-build (dsl source-builder) "Build one canonical Ebox node from inert author DSL. The complete grammar is String plus `text', `box', `row', `column', `flex', and `grid'. Strings normalize to Text. The five Box forms select a typed LayoutConfig; no form accepts `:content', `:layout', or a raw runtime node." + (unless (ebox-source-builder-p source-builder) + (signal 'wrong-type-argument + (list 'ebox-source-builder-p source-builder))) (cond ((stringp dsl) - (ebox-dsl--build-text dsl (list dsl))) + (ebox-dsl--build-text dsl (list dsl) source-builder)) ((not (and (consp dsl) (symbolp (car dsl)))) (error "Ebox build: invalid author node %S" dsl)) ((not (memq (car dsl) ebox-dsl--tags)) (error "Ebox build: unknown author tag %S" (car dsl))) ((eq (car dsl) 'text) (ebox-dsl--build-text - dsl (ebox-dsl--unquote-properties (cdr dsl)))) + dsl (ebox-dsl--unquote-properties (cdr dsl)) source-builder)) (t (ebox-dsl--build-box - (car dsl) dsl (ebox-dsl--unquote-properties (cdr dsl)))))) + (car dsl) dsl (ebox-dsl--unquote-properties (cdr dsl)) + source-builder)))) + +(defun ebox-dsl-build-input (dsl) + "Compile inert DSL into one atomic canonical Ebox input." + (let* ((builder (ebox-source-builder-create)) + (root (ebox-dsl-build dsl builder)) + (source-index (ebox-source-builder-finish builder))) + (ebox-canonical-input-create (list root) source-index))) (provide 'ebox-dsl) diff --git a/ebox-flex.el b/ebox-flex.el index bf0a4be..0e5d012 100644 --- a/ebox-flex.el +++ b/ebox-flex.el @@ -639,8 +639,10 @@ grapheme." (defun ebox--flex-explicit-min-main-p (box axis) "Return non-nil when BOX declares a main-axis minimum explicitly." - (let ((property (if (eq axis 'row) 'ebox/min-width 'ebox/min-height))) - (plist-member (plist-get box :ebox-style-declarations) property))) + (plist-get box + (if (eq axis 'row) + :ebox-explicit-min-width-p + :ebox-explicit-min-height-p))) (defun ebox--flex-box-main-constraint (box axis property) "Return BOX's total main-axis size constraint from PROPERTY." @@ -746,14 +748,8 @@ grapheme." (- target outer-size)))) (defun ebox--flex-copy-put-size (copy property value) - "Set COPY size PROPERTY to VALUE in engine fields and ECSS declarations." - (plist-put copy property value) - (plist-put - copy :ebox-style-declarations - (ebox-style-merge-declarations - (plist-get copy :ebox-style-declarations) - (ebox-style-compile-declarations - (list property (if (eq property :width) (list value) value)))))) + "Set COPY's layout-computed size PROPERTY to VALUE." + (plist-put copy property value)) (defun ebox--flex-copy-node-for-size (node axis main cross stretch) "Return NODE or a copy with flex-computed MAIN/CROSS size." @@ -1142,7 +1138,7 @@ Return a sized render entry containing the rendered string and dimensions." (t (ebox--flex-bind-source-viewport source item-viewport - (lambda () (ebox-render (or node source)))))))) + (lambda () (ebox--render-node (or node source)))))))) (ebox--flex-recache-source-boxes source) (when (and (not fragment-entry) main @@ -1646,7 +1642,7 @@ metadata and rendering decisions." (plist-put copy :content rendered) (plist-put copy :ebox-content-node nil) (plist-put copy :ebox-content-width-exact-p t) - (prog1 (ebox-render copy) + (prog1 (ebox--render-node copy) (puthash region-id box ebox--region-box-table))) rendered)) @@ -1891,7 +1887,7 @@ a prefix of the eventual full wrapper render." (plist-put copy :content content) (plist-put copy :ebox-content-node nil) (plist-put copy :ebox-content-width-exact-p t) - (prog1 (ebox-render copy) + (prog1 (ebox--render-node copy) (puthash region-id box ebox--region-box-table))) content)) (lines (ebox-string-lines rendered))) diff --git a/ebox-grid.el b/ebox-grid.el index 56b7a6e..5ee42d6 100644 --- a/ebox-grid.el +++ b/ebox-grid.el @@ -773,7 +773,7 @@ size for start/center/end alignment unless it would overflow its track." (plist-put copy :content rendered) (plist-put copy :ebox-content-node nil) (plist-put copy :ebox-content-width-exact-p t) - (prog1 (ebox-render copy) + (prog1 (ebox--render-node copy) (puthash region-id box ebox--region-box-table))) rendered)) diff --git a/ebox-incremental.el b/ebox-incremental.el index 70061f3..5d98e5f 100644 --- a/ebox-incremental.el +++ b/ebox-incremental.el @@ -30,13 +30,15 @@ (root &optional validate-participation-p root-parent-kind)) (declare-function ebox-tree-validate-indexed-participation "ebox-tree" - (node-table parent-table node-ids &optional source)) + (node-table parent-table node-ids + &optional source source-index)) (declare-function ebox-tree-clear-runtime-identities "ebox-tree" (root)) (declare-function ebox-tree-copy-node-structure "ebox-tree" (root)) (declare-function ebox-tree-reconcile-runtime - "ebox-tree" (old new)) + "ebox-tree" + (old old-source-index new new-source-index)) (declare-function ebox-tree-transfer-runtime-identity "ebox-tree" (old new)) (declare-function ebox-tree-runtime-identity-snapshot @@ -153,6 +155,18 @@ arguments.") (defvar ebox-incremental--declarative-commit-buffer nil "Buffer whose declarative root commit currently owns publication.") +(defvar ebox-incremental--source-base-index nil + "Transaction-local source facts transported by one canonical input.") + +(defvar ebox-incremental--previous-source-base-index nil + "Transaction-local previous source facts for one paired paint proof.") + +(defvar ebox-incremental--candidate-incoming-source-indexes nil + "Transaction-local handle to input-index map for one logical candidate.") + +(defvar ebox-incremental--candidate-full-source-base-index nil + "Transaction-local fact index for one broad candidate rebuild.") + (cl-defstruct (ebox-candidate (:constructor ebox-incremental--make-candidate) @@ -176,6 +190,7 @@ arguments.") "One logical anchor replacement and its optional semantic transition." anchor-id subtree + source-index old-semantic-key new-semantic-key) @@ -184,14 +199,14 @@ arguments.") (:constructor ebox-incremental--make-candidate-range-replacement) (:conc-name ebox-incremental--candidate-range-replacement-)) "One base-addressed child Range payload replacement." - ref items reuse-map) + ref items reuse-map source-indexes) (cl-defstruct (ebox-incremental--candidate-paint-patch (:constructor ebox-incremental--make-candidate-paint-patch) (:conc-name ebox-incremental--candidate-paint-patch-)) "One base-addressed Host paint update without subtree replacement." - anchor-id node changed-keys) + anchor-id node source-index changed-keys) (cl-defstruct (ebox-incremental--detached-history @@ -781,7 +796,7 @@ This normally does no work. It rebuilds metadata only after a defensive (make-hash-table :test 'eq))) rendered) (let ((ebox--render-cache-scroll-state-region-ids region-ids)) - (setq rendered (ebox-render node))) + (setq rendered (ebox--render-node node))) (ebox--render-cache-merge-scroll-region-ids region-ids parent-region-ids) (list rendered @@ -921,7 +936,7 @@ CACHE-PROBE may supply a prior exact miss from an accelerator boundary." (ebox--render-cache-probe node force)))) (cond ((null probe) - (ebox-render node)) + (ebox--render-node node)) ((plist-member probe :rendered) (ebox--replay-render-output-provenance (plist-get probe :rendered))) @@ -1041,10 +1056,12 @@ Keys are region-id, values are plists with :scroll-offset, :content-lines, etc." ((= count 0) (remhash type table)) (t (puthash type count table)))))) -(defun ebox--runtime-index (root-node &optional collect-region-boxes) +(defun ebox--runtime-index + (root-node &optional collect-region-boxes source-index) "Return persistent runtime indexes for ROOT-NODE. When COLLECT-REGION-BOXES is non-nil, collect `:region-box-table' during the -same traversal in complete-render overwrite order." +same traversal in complete-render overwrite order. SOURCE-INDEX, when +supplied, is the candidate-owned source snapshot for ROOT-NODE." (let ((node-table (make-hash-table :test 'equal)) (parent-table (make-hash-table :test 'equal)) (region-id-set (make-hash-table :test 'equal)) @@ -1057,7 +1074,8 @@ same traversal in complete-render overwrite order." (runtime-type-count-table (make-hash-table :test 'eq)) native-node-postorder) (cl-labels ((index-host-ref (node node-id) - (when-let* ((host-ref (plist-get node :host-ref))) + (when-let* ((handle (plist-get node :ebox-source-handle)) + (host-ref (ebox-source--handle-id-view handle))) (let ((count (hash-table-count host-ref-table))) (puthash host-ref node-id host-ref-table) (when (= count (hash-table-count host-ref-table)) @@ -1155,20 +1173,24 @@ same traversal in complete-render overwrite order." (when native-layout-p (push node native-node-postorder)))))) (visit root-node nil t)) - (let ((selector-index (ebox-tree-selector-index root-node))) + (setq source-index (or source-index (ebox-tree-source-index root-node))) + (setq source-index + (ebox-source--with-runtime-hosts source-index host-ref-table)) + (let () (list :node-table node-table :parent-table parent-table :region-id-set region-id-set :region-node-table region-node-table :region-box-table region-box-table :region-box-count-table region-box-count-table - :host-ref-table host-ref-table :range-ref-table range-ref-table - :selector-id-table (plist-get selector-index :selector-id-table) + :source-index source-index + :selector-id-table + (ebox-source--index-selector-table source-index 'id) :selector-class-table - (plist-get selector-index :selector-class-table) + (ebox-source--index-selector-table source-index 'class) :selector-type-table - (plist-get selector-index :selector-type-table) + (ebox-source--index-selector-table source-index 'type) :runtime-type-count-table runtime-type-count-table :native-node-postorder (vconcat (nreverse native-node-postorder)))))) @@ -1177,13 +1199,27 @@ same traversal in complete-render overwrite order." "Return BUFFER's state after materializing deferred selector indexes." (let ((state (ebox--buffer-render-state buffer))) (when (and state (plist-get state :selector-index-stale-p)) - (let ((index (ebox-tree-selector-index - (plist-get state :root-node)))) + (let* ((source-index + (ebox-source--with-runtime-hosts + (ebox-tree-source-index + (plist-get state :root-node) nil nil + (plist-get state :source-index)) + (ebox-source--index-host-ref-table-view + (plist-get state :source-index)))) + (index + (list :selector-id-table + (ebox-source--index-selector-table source-index 'id) + :selector-class-table + (ebox-source--index-selector-table source-index 'class) + :selector-type-table + (ebox-source--index-selector-table source-index 'type)))) + (setq state (plist-put state :source-index source-index)) (dolist (key '(:selector-id-table :selector-class-table :selector-type-table)) - (plist-put state key (plist-get index key))) - (plist-put state :selector-index-stale-p nil))) + (setq state (plist-put state key (plist-get index key)))) + (setq state (plist-put state :selector-index-stale-p nil)) + (puthash buffer state ebox--buffer-render-state-table))) state)) (defun ebox--new-buffer-render-state (root-node) @@ -1211,8 +1247,8 @@ same traversal in complete-render overwrite order." (defun ebox--render-state-install-index (state index) "Install runtime INDEX tables into candidate STATE and return STATE." (dolist (key '(:node-table :parent-table :region-id-set :region-node-table - :region-box-count-table :region-box-table :host-ref-table - :range-ref-table + :region-box-count-table :region-box-table + :range-ref-table :source-index :selector-id-table :selector-class-table :selector-type-table :runtime-type-count-table :native-node-postorder)) @@ -1239,6 +1275,10 @@ same traversal in complete-render overwrite order." "Return BUFFER's runtime parent table." (plist-get (ebox--buffer-render-state buffer) :parent-table)) +(defun ebox--buffer-source-index (buffer) + "Return BUFFER's retained immutable source index." + (plist-get (ebox--buffer-render-state buffer) :source-index)) + (defun ebox--buffer-region-id-set (buffer) "Return BUFFER's runtime region id set." (plist-get (ebox--buffer-render-state buffer) :region-id-set)) @@ -1253,7 +1293,9 @@ same traversal in complete-render overwrite order." (defun ebox--buffer-host-ref-table (buffer) "Return BUFFER's opaque host reference to runtime node id table." - (plist-get (ebox--buffer-render-state buffer) :host-ref-table)) + (when-let* ((index + (plist-get (ebox--buffer-render-state buffer) :source-index))) + (ebox-source--index-host-ref-table-view index))) (defun ebox-incremental--range-ref-resolve (state ref) "Resolve REF in STATE to its parent, sequence, segment index, and rank." @@ -2985,7 +3027,7 @@ its new minimum must still fit CURRENT-MAIN." (ebox-style-no-soft-wrap-p (ebox-get style-node :wrap-mode))) (ebox--with-buffer-render-context buffer - (ebox-render style-node)) + (ebox--render-node style-node)) ""))) (<= (ebox--flex-min-main style-node rendered axis) current-main)))) @@ -3101,7 +3143,7 @@ CHANGED-KEYS must prove that reusing the allocation is safe." (ebox--flex-item-slot-sized-node buffer node-id snapshot changed-keys))) (prog1 - (ebox-render sized-node) + (ebox--render-node sized-node) ;; Rendering the sized copy reuses the stable region id. Restore the ;; authoritative source object so later public updates never target the ;; temporary used-size copy. @@ -3124,7 +3166,7 @@ CHANGED-KEYS must prove that reusing the allocation is safe." (let* ((rendered (or (ebox--render-node-in-current-flex-slot buffer node-id snapshot changed-keys) - (ebox-render node))) + (ebox--render-node node))) (role-owned-lines-p (ebox--flex-item-fixed-basis-content-allocation-stable-p buffer node-id)) @@ -3258,7 +3300,9 @@ running expensive span and snapshot checks." (snapshot (ebox--ensure-layout-snapshot-spans buffer node-id)) (spans (plist-get snapshot :buffer-spans))) (with-current-buffer buffer - (let* ((partial-slot (ebox--partial-slot-owner-p buffer node-id)) + (let* ((ebox--render-source-index + (plist-get (ebox--buffer-render-state buffer) :source-index)) + (partial-slot (ebox--partial-slot-owner-p buffer node-id)) (coverage-flex (and allow-owned-overflow-coverage (eq (ebox--display-inner node) 'flex))) @@ -3842,6 +3886,7 @@ legacy callers." :pending nil :flush-count 0 :surface-candidate-root nil + :surface-source-base-index nil :surface-publisher nil))) (puthash buffer state ebox-incremental--batch-table) state)) @@ -3856,6 +3901,12 @@ legacy callers." (and (plist-get state :active) (plist-get state :surface-candidate-root)))) +(defun ebox-incremental-surface-batch-source-index (buffer) + "Return BUFFER's unpublished batch source index, or nil." + (let ((state (ebox-incremental--batch-state buffer))) + (and (plist-get state :active) + (plist-get state :surface-source-base-index)))) + (defun ebox-incremental-record-surface-region-change (buffer candidate-root region-id handle dirty-kind changed-keys old-style-values publisher) @@ -3864,9 +3915,19 @@ CANDIDATE-ROOT is the accumulated unpublished tree. REGION-ID and HANDLE identify the logical target, DIRTY-KIND, CHANGED-KEYS, and OLD-STYLE-VALUES describe the semantic change, and PUBLISHER commits the complete batch once during `ebox-incremental-flush'." + (ebox-incremental-record-surface-region-input-change + buffer candidate-root nil region-id handle dirty-kind changed-keys + old-style-values publisher)) + +(defun ebox-incremental-record-surface-region-input-change + (buffer candidate-root source-base-index region-id handle dirty-kind + changed-keys old-style-values publisher) + "Record one batched region change and its candidate SOURCE-BASE-INDEX." (when (ebox-incremental-batching-p buffer) (let ((state (ebox-incremental--batch-state buffer))) (plist-put state :surface-candidate-root candidate-root) + (when source-base-index + (plist-put state :surface-source-base-index source-base-index)) (plist-put state :surface-publisher publisher) (plist-put state :pending (append @@ -3965,10 +4026,12 @@ during `ebox-incremental-flush'." (error "Ebox batch has no TP surface publisher")) (let (report) (unwind-protect - (setq report - (funcall - surface-publisher buffer - (plist-get state :surface-candidate-root) pending)) + (let ((ebox-incremental--source-base-index + (plist-get state :surface-source-base-index))) + (setq report + (funcall + surface-publisher buffer + (plist-get state :surface-candidate-root) pending))) ;; A quit or render error during execution must leave the ;; batch active with its recorded changes still pending so a ;; later retry cannot silently publish a no-op. If execution @@ -4081,9 +4144,12 @@ during `ebox-incremental-flush'." (dolist (child (ebox-tree--children-raw node)) (reject-range child))))) (reject-range item)) - (ebox-tree-validate-declarative-root item) - (ebox-tree-clear-runtime-identities - (ebox-tree-copy-node-structure item)))) + (let ((copy + (ebox-tree-clear-runtime-identities + (ebox-tree-copy-node-structure item)))) + (ebox-tree-source-index + copy nil nil ebox-incremental--source-base-index t) + copy))) (cl-loop for item in next-items for index from 0 @@ -4097,13 +4163,32 @@ during `ebox-incremental-flush'." (validate-new item)) owned))) (setq owned (nreverse owned)) - (setf (ebox-candidate--range-replacements candidate) - (append - (cl-remove ref (ebox-candidate--range-replacements candidate) - :key #'ebox-incremental--candidate-range-replacement-ref - :test #'equal) - (list (ebox-incremental--make-candidate-range-replacement - :ref ref :items owned :reuse-map (copy-tree reuse-map)))))) + (let ((source-indexes (make-hash-table :test #'eq)) + (base-source-index (plist-get base-state :source-index))) + (dolist (item owned) + (let ((handle (ebox-tree-node-source-handle item))) + (puthash + handle + (if (and handle + (ebox-source-index-handle-member-p + base-source-index handle)) + base-source-index + (if (and ebox-incremental--source-base-index + (ebox-source-index-handle-member-p + ebox-incremental--source-base-index handle)) + ebox-incremental--source-base-index + (ebox-tree-source-index item))) + source-indexes))) + (setf (ebox-candidate--range-replacements candidate) + (append + (cl-remove + ref (ebox-candidate--range-replacements candidate) + :key #'ebox-incremental--candidate-range-replacement-ref + :test #'equal) + (list + (ebox-incremental--make-candidate-range-replacement + :ref ref :items owned :reuse-map (copy-tree reuse-map) + :source-indexes source-indexes)))))) ) candidate) @@ -4142,11 +4227,12 @@ detached semantic variants of this stable anchor for bounded identity reuse." (error "Ebox candidate anchor does not exist: %S" node-id)) (unless (and (listp next-subtree) (not (stringp next-subtree))) (error "Ebox candidate replacement must be an Ebox node")) - ;; Validate before copying so reused objects and cycles remain observable. - (ebox-tree-validate-declarative-root next-subtree) - (let ((replacement - (ebox-tree-clear-runtime-identities - (ebox-tree-copy-node-structure next-subtree)))) + (let* ((replacement + (ebox-tree-clear-runtime-identities + (ebox-tree-copy-node-structure next-subtree))) + (source-index + (ebox-tree-source-index + replacement nil nil ebox-incremental--source-base-index))) ;; The new operation supersedes an earlier operation at the same anchor ;; and every earlier descendant operation. An earlier ancestor stays ;; before a new descendant so the descendant is applied inside its @@ -4173,6 +4259,7 @@ detached semantic variants of this stable anchor for bounded identity reuse." (ebox-incremental--make-candidate-replacement :anchor-id node-id :subtree replacement + :source-index source-index :old-semantic-key old-semantic-key :new-semantic-key new-semantic-key))))))) candidate)) @@ -4205,8 +4292,9 @@ have the same optional detached-identity meaning as in (let* ((missing (make-symbol "ebox-missing-host-ref")) (node-id (gethash host-ref - (plist-get (ebox-candidate--base-state candidate) - :host-ref-table) + (ebox-source--index-host-ref-table-view + (plist-get (ebox-candidate--base-state candidate) + :source-index)) missing))) (when (eq node-id missing) (error "Ebox candidate host ref does not exist: %S" host-ref)) @@ -4221,7 +4309,7 @@ have the same optional detached-identity meaning as in ((visit (current) (when (and (listp current) (not (stringp current))) - (when (equal (plist-get current :host-ref) host-ref) + (when (equal (ebox-tree-node-source-identity current) host-ref) (throw 'found current)) (dolist (child (ebox-tree--children-raw current)) (visit child))))) @@ -4244,7 +4332,11 @@ the patched Host's ancestor chain." (error "Ebox candidate is already sealed")) (let* ((state (ebox-candidate--base-state candidate)) (missing (make-symbol "ebox-missing-host-ref")) - (node-id (gethash host-ref (plist-get state :host-ref-table) missing))) + (node-id + (gethash host-ref + (ebox-source--index-host-ref-table-view + (plist-get state :source-index)) + missing))) (when (eq node-id missing) (error "Ebox candidate host ref does not exist: %S" host-ref)) (catch 'unsupported @@ -4272,21 +4364,31 @@ the patched Host's ancestor chain." (eq (plist-get previous-node :ebox-type) (plist-get next-node :ebox-type))) (throw 'unsupported nil)) - (let ((changed-keys - (ebox-incremental--candidate-local-changed-keys - previous-node next-node))) + (let* ((previous-source-index + (ebox-tree-source-index + previous-subtree nil nil + ebox-incremental--previous-source-base-index)) + (next-source-index + (ebox-tree-source-index + next-subtree nil nil ebox-incremental--source-base-index)) + (changed-keys + (ebox-incremental--candidate-local-changed-keys + previous-node previous-source-index + next-node next-source-index))) (unless (cl-every (lambda (key) (memq key ebox--paint-style-signature-keys)) changed-keys) (throw 'unsupported nil)) - (let ((patch (list :ebox-type (plist-get next-node :ebox-type) - :host-ref host-ref))) + (let ((patch + (list :ebox-type (plist-get next-node :ebox-type) + :ebox-source-handle + (ebox-tree-node-source-handle next-node)))) (dolist (key changed-keys) (setq patch (plist-put patch key (copy-tree (plist-get next-node key))))) - (dolist (key '(:ebox-style-declarations :ebox-style-overrides)) + (dolist (key '(:ebox-style-overrides)) (when (plist-member next-node key) (setq patch (plist-put patch key @@ -4301,6 +4403,7 @@ the patched Host's ancestor chain." (list (ebox-incremental--make-candidate-paint-patch :anchor-id node-id :node patch + :source-index next-source-index :changed-keys changed-keys)))))) candidate)))) @@ -4695,8 +4798,11 @@ either fresh or owned by a node that some replaced anchor is removing. Cost is proportional to the replacements, never to the page." (let* ((node-table (plist-get base-index :node-table)) (parent-table (plist-get base-index :parent-table)) - (host-ref-table (plist-get base-index :host-ref-table)) + (base-source-index (plist-get base-index :source-index)) + (host-ref-table + (ebox-source--index-host-ref-table-view base-source-index)) (replacement-host-refs (make-hash-table :test 'equal)) + (replacement-source-indexes (make-hash-table :test #'eq)) (anchor-ids (mapcar #'ebox-incremental--candidate-replacement-anchor-id @@ -4716,7 +4822,7 @@ Cost is proportional to the replacements, never to the page." (collect-host-refs (node acc) (if (or (not (listp node)) (stringp node)) acc - (let ((ref (plist-get node :host-ref))) + (let ((ref (ebox-tree-node-source-identity node))) (when ref (push ref acc))) (dolist (child (ebox-tree--children-raw node) acc) (setq acc (collect-host-refs child acc)))))) @@ -4725,7 +4831,16 @@ Cost is proportional to the replacements, never to the page." (ebox-incremental--candidate-replacement-anchor-id entry)) (replacement (ebox-incremental--candidate-replacement-subtree entry)) - (replacement-key (plist-get replacement :key)) + (replacement-source-index + (or + (ebox-incremental--candidate-replacement-source-index entry) + (gethash replacement replacement-source-indexes) + (puthash replacement + (ebox-tree-source-index replacement) + replacement-source-indexes))) + (replacement-key + (ebox-tree-node-author-key + replacement-source-index replacement)) (parent-id (gethash node-id parent-table)) (parent (and parent-id (gethash parent-id node-table)))) ;; Sibling keys stay unique under the anchor's parent. @@ -4734,15 +4849,27 @@ Cost is proportional to the replacements, never to the page." (let ((sibling-id (plist-get sibling :node-id))) (when (and sibling-id (not (equal sibling-id node-id)) - (equal (plist-get - (if-let* - ((sibling-replacement - (ebox-incremental--candidate-replacement-for-anchor - entries sibling-id))) - (ebox-incremental--candidate-replacement-subtree - sibling-replacement) - sibling) - :key) + (equal + (if-let* + ((sibling-replacement + (ebox-incremental--candidate-replacement-for-anchor + entries sibling-id))) + (let* ((subtree + (ebox-incremental--candidate-replacement-subtree + sibling-replacement)) + (index + (or + (ebox-incremental--candidate-replacement-source-index + sibling-replacement) + (gethash subtree + replacement-source-indexes) + (puthash + subtree + (ebox-tree-source-index subtree) + replacement-source-indexes)))) + (ebox-tree-node-author-key index subtree)) + (ebox-tree-node-author-key + base-source-index sibling)) replacement-key)) (error "Ebox declarative sibling key %S is not unique" @@ -4835,6 +4962,10 @@ CURRENT-SNAPSHOT identifies the subtree identities being replaced." Use candidate-owned HISTORY for detached identity reuse." (let* ((replacement (ebox-incremental--candidate-replacement-subtree entry)) + (old-source-index (plist-get state :source-index)) + (replacement-source-index + (or (ebox-incremental--candidate-replacement-source-index entry) + (ebox-tree-source-index replacement))) (old-key (ebox-incremental--candidate-replacement-old-semantic-key entry)) (new-key @@ -4846,7 +4977,8 @@ Use candidate-owned HISTORY for detached identity reuse." (null new-key) (not (eq (plist-get old-anchor :ebox-type) (plist-get replacement :ebox-type)))) - (ebox-tree-reconcile-runtime old-anchor replacement) + (ebox-tree-reconcile-runtime + old-anchor old-source-index replacement replacement-source-index) (let* ((current (ebox-tree-runtime-identity-snapshot old-anchor)) (old-history-key (list anchor-id old-key)) (new-history-key (list anchor-id new-key))) @@ -4864,10 +4996,14 @@ Use candidate-owned HISTORY for detached identity reuse." state current historical)) (progn (ebox-tree-reconcile-runtime - (plist-get historical :root) replacement) + (plist-get historical :root) + (ebox-tree-source-index (plist-get historical :root)) + replacement replacement-source-index) (ebox-tree-transfer-runtime-identity old-anchor replacement)) - (ebox-tree-reconcile-runtime old-anchor replacement)))))) + (ebox-tree-reconcile-runtime + old-anchor old-source-index + replacement replacement-source-index)))))) (ebox-incremental--candidate-replacement-subtree entry)) (defun ebox-incremental--candidate-node-logical-root @@ -4881,7 +5017,7 @@ DETACHED-HISTORY receives identity snapshots for semantic replacements." (base-index (list :node-table (plist-get state :node-table) :parent-table (plist-get state :parent-table) - :host-ref-table (plist-get state :host-ref-table))) + :source-index (plist-get state :source-index))) (entries (ebox-candidate--replacements candidate))) (if (ebox-incremental--candidate-replacements-disjoint-p entries (plist-get base-index :parent-table)) @@ -4937,14 +5073,23 @@ DETACHED-HISTORY receives identity snapshots for semantic replacements." root base-index replacements)))))) (defun ebox-incremental--candidate-reconcile-range-items - (sequence ref items) - "Reconcile candidate ITEMS for REF against keyed/unkeyed SEQUENCE peers." + (sequence ref items old-source-index item-source-indexes) + "Reconcile ITEMS against REF using explicit generation source indexes." (let* ((segment (ebox-child-range--lookup-ref sequence ref)) (old-payload (and segment (ebox-child-range--segment-payload segment))) (key-root (ebox-child-range--sequence-key-root sequence)) (hash-function (ebox-child-range--sequence-hash-function sequence))) - (cl-loop for item in items for offset from 0 do - (let* ((key (plist-get item :key)) + (cl-labels + ((item-key + (item) + (let ((index + (gethash (ebox-tree-node-source-handle item) + item-source-indexes))) + (unless index + (error "Ebox Range item has no candidate source index")) + (ebox-tree-node-author-key index item)))) + (cl-loop for item in items for offset from 0 do + (let* ((key (item-key item)) (location (and key (ebox-child-range--hash-lookup @@ -4957,17 +5102,23 @@ DETACHED-HISTORY receives identity snapshots for semantic replacements." (cdr location))) (and old-payload (< offset (length old-payload)) (aref old-payload offset))))) - (when (and old key (null (plist-get old :key))) (setq old nil)) - (when (and old (null key) (plist-get old :key)) (setq old nil)) + (let ((old-key + (and old + (ebox-tree-node-author-key + old-source-index old)))) + (when (and old key (null old-key)) (setq old nil)) + (when (and old (null key) old-key) (setq old nil)) ;; Positional fallback is valid only for unkeyed peers. A ;; disappeared keyed item must never donate its runtime ;; identity to a newly introduced key at the same offset; ;; doing so leaves its old region properties in the candidate ;; output while the new region index has already retired them. - (when (and old key (plist-get old :key) - (not (equal (plist-get old :key) key))) - (setq old nil)) - (ebox-tree-reconcile-runtime old item))) + (when (and old key old-key (not (equal old-key key))) + (setq old nil))) + (ebox-tree-reconcile-runtime + old old-source-index item + (gethash (ebox-tree-node-source-handle item) + item-source-indexes))))) items)) (defun ebox-incremental--candidate-canonical-range-reuse @@ -5087,7 +5238,19 @@ but must re-enter the candidate indexes as a new object." copy (ebox-child-range--replace-item-at sequence (plist-get location :segment-index) - (plist-get location :offset) old-child new-child)) + (plist-get location :offset) old-child new-child + (let* ((handle + (ebox-tree-node-source-handle new-child)) + (incoming + (and handle + (hash-table-p + ebox-incremental--candidate-incoming-source-indexes) + (gethash + handle + ebox-incremental--candidate-incoming-source-indexes))) + (index + (or incoming (plist-get state :source-index)))) + (ebox-tree-node-author-key index new-child)))) (when (eq (plist-get old-parent :ebox-kind) 'box) (ebox-tree--set-author-style-count copy @@ -5156,6 +5319,9 @@ but must re-enter the candidate indexes as a new object." (let ((items (ebox-incremental--candidate-range-replacement-items entry)) + (source-indexes + (ebox-incremental--candidate-range-replacement-source-indexes + entry)) (reuse-map (ebox-incremental--candidate-range-replacement-reuse-map entry))) @@ -5170,7 +5336,8 @@ but must re-enter the candidate indexes as a new object." (setq items canonical-items reuse-map effective-reuse-map))) (ebox-incremental--candidate-reconcile-range-items - sequence ref items) + sequence ref items + (plist-get state :source-index) source-indexes) (cl-loop for item in items for offset from 0 for location = (list :parent-node-id parent-id @@ -5184,7 +5351,8 @@ but must re-enter the candidate indexes as a new object." location) do (plist-put item :ebox-sequence-location location)) - (push (list ref items reuse-map) replacements))))) + (push (list ref items reuse-map source-indexes) + replacements))))) (when replacements (let* ((ordered-replacements (nreverse replacements)) (range-pairs @@ -5192,6 +5360,14 @@ but must re-enter the candidate indexes as a new object." (cons (nth 0 replacement) (nth 1 replacement))) ordered-replacements)) + (item-source-indexes + (make-hash-table :test #'eq)) + (_item-source-indexes + (dolist (replacement ordered-replacements) + (maphash + (lambda (handle index) + (puthash handle index item-source-indexes)) + (nth 3 replacement)))) (old-payloads (mapcar (lambda (replacement) @@ -5202,6 +5378,15 @@ but must re-enter the candidate indexes as a new object." ordered-replacements)) (result (ebox-child-range--replace-many sequence range-pairs + (lambda (item) + (let ((index + (gethash + (ebox-tree-node-source-handle item) + item-source-indexes))) + (unless index + (error + "Ebox Range item has no source index")) + (ebox-tree-node-author-key index item))) (mapcar (lambda (replacement) (cons (nth 0 replacement) @@ -5293,6 +5478,7 @@ schema-inherited or custom property, descendants are path-copied as pending style consumers; the Style Adapter computes their values exactly once later." (let* ((state (ebox-candidate--base-state candidate)) (parents (plist-get state :parent-table)) + (source-index (plist-get state :source-index)) (patch-table (make-hash-table :test #'equal)) (patch-paths (make-hash-table :test #'equal)) patch-ids) @@ -5320,22 +5506,30 @@ style consumers; the Style Adapter computes their values exactly once later." (and entry (ebox-incremental--candidate-paint-patch-changed-keys entry))) - (old-declarations (ebox-style-node-declarations node)) + (old-declarations + (ebox-style-node-declarations source-index node)) (next (if (or entry inherited-input-changed-p) (copy-sequence node) node))) (when entry + (plist-put next :ebox-source-handle + (plist-get patch :ebox-source-handle)) (dolist (key changed-keys) (plist-put next key (copy-tree (plist-get patch key)))) - (dolist (key '(:ebox-style-declarations - :ebox-style-overrides)) + (dolist (key '(:ebox-style-overrides)) (when (plist-member patch key) (plist-put next key (ebox-style-merge-declarations (copy-tree (plist-get node key)) (copy-tree (plist-get patch key))))))) - (let* ((new-declarations (ebox-style-node-declarations next)) + (let* ((new-declarations + (ebox-style-node-declarations + (if entry + (ebox-incremental--candidate-paint-patch-source-index + entry) + source-index) + next)) (descendant-input-changed-p (or inherited-input-changed-p (and entry @@ -5710,8 +5904,8 @@ Return the same pure surface-input shape as :region-box-count-table (plist-get state :region-box-count-table) :region-box-table (ebox-incremental--candidate-region-box-table state) - :host-ref-table (plist-get state :host-ref-table) :range-ref-table (plist-get state :range-ref-table) + :source-index (plist-get state :source-index) :selector-id-table (plist-get state :selector-id-table) :selector-class-table (plist-get state :selector-class-table) :selector-type-table (plist-get state :selector-type-table) @@ -5866,7 +6060,8 @@ complete preorder first-owner and postorder last-box semantics." owned)) (defun ebox-incremental--candidate-dirty-set-from-touched - (old-state candidate-root touched &optional range-delta-table) + (old-state candidate-root candidate-source-index touched + &optional range-delta-table) "Return declarative dirty entries for path-local TOUCHED nodes." (let ((old-root (plist-get old-state :root-node)) (range-created-node-ids @@ -5889,7 +6084,8 @@ complete preorder first-owner and postorder last-box semantics." range-created-node-ids))) (let* ((changed-keys (ebox-incremental--candidate-local-changed-keys - old-node new-node)) + old-node (plist-get old-state :source-index) + new-node candidate-source-index)) (range-delta (and range-delta-table (gethash (plist-get new-node :node-id) @@ -5952,12 +6148,16 @@ complete preorder first-owner and postorder last-box semantics." (defun ebox-incremental--candidate-full-index-preparation (old-state candidate-root touched removed) "Return broad candidate preparation from TOUCHED and REMOVED nodes." - (let ((index (ebox--runtime-index candidate-root t))) + (let* ((source-index + (ebox-tree-source-index + candidate-root t nil + ebox-incremental--candidate-full-source-base-index)) + (index (ebox--runtime-index candidate-root t source-index))) (setq index (plist-put index :selector-index-stale-p nil)) (list :index index :dirty-set (ebox-incremental--candidate-dirty-set-from-touched - old-state candidate-root touched) + old-state candidate-root (plist-get index :source-index) touched) :participation-node-ids (ebox-incremental--candidate-participation-node-ids touched) :touched-count (length touched) @@ -6106,8 +6306,13 @@ replace those O(n) table copies without changing this delta contract." (nreverse removed))) (if (and (null range-index-deltas) (ebox-incremental--candidate-full-index-p old-state touched)) - (ebox-incremental--candidate-full-index-preparation - old-state candidate-root touched removed) + (let ((ebox-incremental--candidate-full-source-base-index + (ebox-tree-source-fact-index-delta + (plist-get old-state :source-index) + touched removed + ebox-incremental--candidate-incoming-source-indexes))) + (ebox-incremental--candidate-full-index-preparation + old-state candidate-root touched removed)) (progn ;; Local region owner repair needs ancestors before descendants. ;; A complete index rebuild already establishes that order itself. @@ -6162,8 +6367,9 @@ replace those O(n) table copies without changing this delta contract." (region-box-table (ebox-incremental--candidate-region-box-table old-state)) (host-ref-table - (ebox-incremental--candidate-copy-index-table - old-state :host-ref-table 'equal)) + (copy-hash-table + (ebox-source--index-host-ref-table-view + (plist-get old-state :source-index)))) (runtime-type-count-table (ebox-incremental--candidate-copy-index-table old-state :runtime-type-count-table 'eq)) @@ -6175,7 +6381,8 @@ replace those O(n) table copies without changing this delta contract." runtime-type-count-table node -1) (remhash node-id node-table) (remhash node-id parent-table) - (when-let* ((host-ref (plist-get node :host-ref))) + (when-let* ((host-ref + (ebox-tree-node-source-identity node))) (when (equal (gethash host-ref host-ref-table) node-id) (remhash host-ref host-ref-table))) (when-let* ((record @@ -6190,9 +6397,11 @@ replace those O(n) table copies without changing this delta contract." ;; merely because the other old binding had not been visited yet. (dolist (entry touched) (when-let* ((old-node (car entry)) - (old-host-ref (plist-get old-node :host-ref))) + (old-host-ref + (ebox-tree-node-source-identity old-node))) (let* ((new-node (nth 1 entry)) - (new-host-ref (plist-get new-node :host-ref)) + (new-host-ref + (ebox-tree-node-source-identity new-node)) (node-id (plist-get old-node :node-id))) (unless (equal old-host-ref new-host-ref) (when (equal (gethash old-host-ref host-ref-table) node-id) @@ -6235,10 +6444,12 @@ replace those O(n) table copies without changing this delta contract." (remhash node-id parent-table)) ;; Replacement subtrees were validated before recording and ;; checked against untouched Host refs before path copying. - (when-let* ((new-host-ref (plist-get new-node :host-ref))) + (when-let* ((new-host-ref + (ebox-tree-node-source-identity new-node))) (unless (and old-node (equal new-host-ref - (plist-get old-node :host-ref))) + (ebox-tree-node-source-identity + old-node))) (puthash new-host-ref node-id host-ref-table))) (when (and new-record (not stable-unique-region-p)) (puthash (car new-record) t affected-region-ids)) @@ -6284,9 +6495,19 @@ replace those O(n) table copies without changing this delta contract." (dolist (delta range-index-deltas) (puthash (plist-get delta :parent-id) delta range-delta-table))) + (candidate-source-index + (let ((ebox-tree--incoming-source-indexes + ebox-incremental--candidate-incoming-source-indexes)) + (ebox-source--with-runtime-hosts + (ebox-tree-source-index-delta + (plist-get old-state :source-index) + touched removed node-table parent-table candidate-root) + host-ref-table))) (dirty-set (ebox-incremental--candidate-dirty-set-from-touched - old-state candidate-root touched range-delta-table)) + old-state candidate-root + candidate-source-index + touched range-delta-table)) (structure-p (cl-some (lambda (entry) @@ -6305,19 +6526,19 @@ replace those O(n) table copies without changing this delta contract." :region-node-table region-node-table :region-box-count-table region-box-count-table :region-box-table region-box-table - :host-ref-table host-ref-table :range-ref-table (ebox-incremental--candidate-range-ref-overlay old-state path-copy-trace) - ;; Paths contain ancestor objects, so a path copy makes - ;; selector indexes stale even when selector metadata - ;; itself did not change. Rebuild on first query. + :source-index candidate-source-index :selector-id-table - (plist-get old-state :selector-id-table) + (ebox-source--index-selector-table + candidate-source-index 'id) :selector-class-table - (plist-get old-state :selector-class-table) + (ebox-source--index-selector-table + candidate-source-index 'class) :selector-type-table - (plist-get old-state :selector-type-table) + (ebox-source--index-selector-table + candidate-source-index 'type) :runtime-type-count-table runtime-type-count-table :selector-index-stale-p t :native-node-postorder native-node-postorder))) @@ -6382,16 +6603,30 @@ recognized from its first untouched sibling without walking the whole tree." (ebox-style-expand-ebox-plist (ebox-style-declaration-properties old all-properties)) (ebox-style-expand-ebox-plist - (ebox-style-declaration-properties new all-properties))))) + (ebox-style-declaration-properties new all-properties))))) -(defun ebox-incremental--candidate-local-changed-keys (old new) - "Return real source changes between published OLD and candidate NEW. +(defun ebox-incremental--candidate-source-record (source-index node) + "Return NODE's borrowed immutable record from candidate SOURCE-INDEX. +Private generated layout nodes have no source handle. A canonical node whose +handle is absent from its generation index violates the candidate boundary." + (when-let* ((handle (ebox-tree-node-source-handle node))) + (or (ebox-source--index-record source-index handle) + (error "Ebox candidate source handle is absent from its index")))) + +(defun ebox-incremental--candidate-local-changed-keys + (old old-source-index new new-source-index) + "Return real source changes between OLD and NEW generation facts. Published nodes carry computed ECSS longhands while fresh candidate nodes are still declaration-stage values. Compare non-style facts directly and compare canonical declarations only after expanding them to the engine longhands used -by impact classification; the declaration container itself is not geometry." - (let (seen changed) +by impact classification; the declaration container itself is not geometry. +Each immutable source record is resolved once for this node pair." + (let ((old-record + (ebox-incremental--candidate-source-record old-source-index old)) + (new-record + (ebox-incremental--candidate-source-record new-source-index new)) + seen changed source-changed) (dolist (source (list old new)) (let ((plist source)) (while plist @@ -6399,8 +6634,7 @@ by impact classification; the declaration container itself is not geometry." (pop plist) (unless (or (memq key seen) (memq key ebox-tree--excluded-source-keys) - (memq key '(:props :raw-props - :ebox-style-declarations)) + (memq key '(:props :raw-props)) (ebox-style--property key)) (push key seen) (let ((old-entry (plist-member old key)) @@ -6411,14 +6645,43 @@ by impact classification; the declaration container itself is not geometry." (cadr old-entry) (cadr new-entry))) (push key changed)))))))) (setq changed (nreverse changed)) - (unless (ebox-style-declarations-equal-p - (ebox-style-node-declarations old) - (ebox-style-node-declarations new)) - (dolist (key (ebox-incremental--declaration-changed-keys - (ebox-style-node-declarations old) - (ebox-style-node-declarations new))) + (unless (eq old-record new-record) + (cl-labels + ((record-change + (key old-value new-value) + (unless (equal-including-properties old-value new-value) + (push key source-changed)))) + (record-change + :key (and old-record (ebox-source-record-key old-record)) + (and new-record (ebox-source-record-key new-record))) + (record-change + :id (and old-record (ebox-source-record-id old-record)) + (and new-record (ebox-source-record-id new-record))) + (record-change + :class (and old-record (ebox-source-record-classes old-record)) + (and new-record (ebox-source-record-classes new-record))) + ;; ID and key are independent metadata fields above. SourceRecord + ;; attributes contain only the author's explicit selector attributes, + ;; so their changes are neither recomputed nor reported twice. + (record-change + :selector-attributes + (and old-record (ebox-source-record-attributes old-record)) + (and new-record (ebox-source-record-attributes new-record))) + (record-change + :selector-state + (and old-record (ebox-source-record-states old-record)) + (and new-record (ebox-source-record-states new-record))))) + (setq changed (nconc changed (nreverse source-changed))) + (let ((old-declarations + (ebox-style--node-declarations-from-record old old-record)) + (new-declarations + (ebox-style--node-declarations-from-record new new-record))) + (unless (ebox-style-declarations-equal-p + old-declarations new-declarations) + (dolist (key (ebox-incremental--declaration-changed-keys + old-declarations new-declarations)) (unless (memq key changed) - (setq changed (append changed (list key)))))) + (setq changed (append changed (list key))))))) changed)) (defun ebox-incremental--declarative-dirty-set @@ -6443,7 +6706,8 @@ CANDIDATE-INDEX is the prepared runtime index for CANDIDATE-ROOT." (when-let* ((old-node (gethash node-id old-table))) (let* ((changed-keys (ebox-incremental--candidate-local-changed-keys - old-node new-node)) + old-node (plist-get old-state :source-index) + new-node (plist-get candidate-index :source-index))) (children-changed (not (ebox-incremental--node-child-ids-equal-p @@ -6501,7 +6765,11 @@ candidate therefore owns its cache tables until TP commits the generation." ;; Every mutable cache is replaced below and isolated once after the ;; candidate fields are installed. Copying OLD-STATE's cache tables here as ;; well only copied values that were immediately discarded. - (let ((state (copy-sequence old-state))) + (let* ((source-index + (or (plist-get candidate-index :source-index) + (plist-get prepared :source-index) + (ebox-tree-source-index candidate-root))) + (state (copy-sequence old-state))) (dolist (entry `((:root-node ,candidate-root) @@ -6557,16 +6825,15 @@ candidate therefore owns its cache tables until TP commits the generation." ,(plist-get candidate-index :region-box-count-table)) (:region-box-table ,(plist-get candidate-index :region-box-table)) - (:host-ref-table - ,(plist-get candidate-index :host-ref-table)) (:range-ref-table ,(plist-get candidate-index :range-ref-table)) + (:source-index ,source-index) (:selector-id-table - ,(plist-get candidate-index :selector-id-table)) + ,(ebox-source--index-selector-table source-index 'id)) (:selector-class-table - ,(plist-get candidate-index :selector-class-table)) + ,(ebox-source--index-selector-table source-index 'class)) (:selector-type-table - ,(plist-get candidate-index :selector-type-table)) + ,(ebox-source--index-selector-table source-index 'type)) (:runtime-type-count-table ,(plist-get candidate-index :runtime-type-count-table)) (:selector-index-stale-p @@ -6672,9 +6939,10 @@ derive render changes from the computed fragment style signatures." (not new-node) (not (plist-get new-node :ebox-candidate-computed-style-p))) entry - (let* ((source-keys - (ebox-incremental--candidate-local-changed-keys - old-node new-node)) + (let* (;; Source-stage dirty planning already materialized this + ;; exact node-local delta. Computed projection consumes it; + ;; it must not resolve the same generation facts again. + (source-keys (plist-get entry :changed-keys)) (metadata-keys (cl-remove-if-not (lambda (key) @@ -6709,7 +6977,7 @@ derive render changes from the computed fragment style signatures." (defun ebox-incremental--prepare-declarative-runtime (buffer old-state candidate-root &optional logical-candidate-p - local-preparation scroll-patch-p) + local-preparation scroll-patch-p source-index) "Prepare CANDIDATE-ROOT for BUFFER without mutating its published runtime." (let* ((old-root (plist-get old-state :root-node)) (render-cache @@ -6720,7 +6988,24 @@ derive render changes from the computed fragment style signatures." (make-hash-table :test 'equal)))) (let* ((candidate-index (or (plist-get local-preparation :index) - (ebox--runtime-index candidate-root t))) + (ebox--runtime-index candidate-root t source-index))) + (candidate-source-index + (or (plist-get candidate-index :source-index) + (plist-get local-preparation :source-index) + (ebox-tree-source-index candidate-root))) + (_source-index + (progn + (plist-put candidate-index :source-index + candidate-source-index) + (plist-put candidate-index :selector-id-table + (ebox-source--index-selector-table + candidate-source-index 'id)) + (plist-put candidate-index :selector-class-table + (ebox-source--index-selector-table + candidate-source-index 'class)) + (plist-put candidate-index :selector-type-table + (ebox-source--index-selector-table + candidate-source-index 'type)))) (candidate-region-box-table (plist-get candidate-index :region-box-table)) (candidate-subject-table @@ -6745,7 +7030,8 @@ derive render changes from the computed fragment style signatures." (plist-get candidate-index :node-table) (plist-get candidate-index :parent-table) (plist-get local-preparation :participation-node-ids) - (if styles-prepared-p 'computed 'author))) + (if styles-prepared-p 'computed 'author) + candidate-source-index)) (when styles-prepared-p ;; The local style adapter has consumed every pending author fact on ;; the touched paths. Clear the exact retained subtree counts before @@ -6837,17 +7123,44 @@ derive render changes from the computed fragment style signatures." "Prepare newly built NEXT-ROOT for BUFFER without mutating publication." (unless (and (listp next-root) (not (stringp next-root))) (error "Ebox declarative root must be an Ebox node")) - ;; Validate before copying so shared references and cycles remain visible. - (ebox-tree-validate-declarative-root next-root t) (let* ((candidate-root (ebox-tree-clear-runtime-identities (ebox-tree-copy-node-structure next-root))) + (candidate-source-index + (ebox-tree-source-index + candidate-root t nil ebox-incremental--source-base-index)) (old-root (plist-get old-state :root-node))) ;; Failed preparation may leave harmless allocator gaps, but cannot publish ;; duplicate runtime identities. - (ebox-tree-reconcile-runtime old-root candidate-root) + (ebox-tree-reconcile-runtime + old-root (plist-get old-state :source-index) + candidate-root candidate-source-index) (ebox-incremental--prepare-declarative-runtime - buffer old-state candidate-root nil))) + buffer old-state candidate-root nil nil nil candidate-source-index))) + +(defun ebox-incremental--candidate-source-input-map (candidate) + "Return exact source-handle inputs carried by CANDIDATE operations." + (let ((table (make-hash-table :test #'eq))) + (dolist (replacement (ebox-candidate--replacements candidate)) + (let ((index + (ebox-incremental--candidate-replacement-source-index + replacement))) + (dolist (handle (ebox-source-index-handles index)) + (puthash handle index table)))) + (dolist (replacement (ebox-candidate--range-replacements candidate)) + (maphash + (lambda (_handle index) + (dolist (handle (ebox-source-index-handles index)) + (puthash handle index table))) + (ebox-incremental--candidate-range-replacement-source-indexes + replacement))) + (dolist (patch (ebox-candidate--paint-patches candidate)) + (let ((index + (ebox-incremental--candidate-paint-patch-source-index patch))) + (when index + (dolist (handle (ebox-source-index-handles index)) + (puthash handle index table))))) + table)) (defun ebox-incremental--prepare-logical-candidate (buffer old-state candidate) @@ -6855,18 +7168,24 @@ derive render changes from the computed fragment style signatures." (let* ((path-copy-trace (make-hash-table :test 'equal)) (detached-history (ebox-incremental--copy-detached-history old-state)) + (incoming-source-indexes + (ebox-incremental--candidate-source-input-map candidate)) range-index-deltas (candidate-root (let ((ebox-incremental--candidate-path-copy-trace path-copy-trace) - (ebox-incremental--candidate-range-index-deltas nil)) + (ebox-incremental--candidate-range-index-deltas nil) + (ebox-incremental--candidate-incoming-source-indexes + incoming-source-indexes)) (prog1 (ebox-incremental--candidate-logical-root candidate detached-history) (setq range-index-deltas ebox-incremental--candidate-range-index-deltas)))) (local-preparation - (ebox-incremental--candidate-local-index-delta - old-state candidate-root path-copy-trace range-index-deltas))) + (let ((ebox-incremental--candidate-incoming-source-indexes + incoming-source-indexes)) + (ebox-incremental--candidate-local-index-delta + old-state candidate-root path-copy-trace range-index-deltas)))) ;; Replaced subtrees were validated when they were recorded; the ;; untouched published remainder was validated when it was itself ;; published, and path copying introduces no new sibling or ref @@ -7581,8 +7900,7 @@ because it could address a run whose topology the closure just replaced." (memq key ebox--paint-style-signature-keys))) changed-keys) (ebox-incremental--same-root-surface-identity-p - (plist-get old-state :root-node) - (plist-get candidate-state :root-node)) + old-state candidate-state) (ebox-incremental--span-patch-identity-stable-p buffer old-state candidate-state prepared dirty-id) (null (plist-get old-state :scroll-region-ids)) @@ -8086,8 +8404,11 @@ the surface is allowed to reuse retained TP objects." (and old-node new-node (eq (plist-get old-node :ebox-type) (plist-get new-node :ebox-type)) - (equal (plist-get old-node :key) - (plist-get new-node :key)) + (equal + (ebox-tree-node-author-key + (plist-get old-state :source-index) old-node) + (ebox-tree-node-author-key + (plist-get candidate-state :source-index) new-node)) (equal (gethash node-id old-parent-table) (gethash node-id new-parent-table))))) (delq nil touched))))) @@ -8227,6 +8548,8 @@ the complete proof." (new-nodes (plist-get candidate-state :node-table)) (old-parents (plist-get old-state :parent-table)) (new-parents (plist-get candidate-state :parent-table)) + (old-source-index (plist-get old-state :source-index)) + (new-source-index (plist-get candidate-state :source-index)) (entries signature) (valid (and signature (equal (plist-get certificate :root-id) @@ -8259,8 +8582,8 @@ the complete proof." (or (null next-id) (equal old-parent next-id)) (eq (plist-get old-node :ebox-type) (plist-get new-node :ebox-type)) - (equal (ebox-tree-node-key old-node) - (ebox-tree-node-key new-node)) + (equal (ebox-tree-node-key old-source-index old-node) + (ebox-tree-node-key new-source-index new-node)) ;; This certificate describes OWNER-ID's allocation in its ;; parent, not the contents it allocates internally. A ;; Range owner is expected to replace its children while @@ -8959,8 +9282,11 @@ metadata; this predicate only authorizes the local attempt." old-node new-node (eq (plist-get old-node :ebox-type) (plist-get new-node :ebox-type)) - (equal (plist-get old-node :key) - (plist-get new-node :key)) + (equal + (ebox-tree-node-author-key + (plist-get old-state :source-index) old-node) + (ebox-tree-node-author-key + (plist-get candidate-state :source-index) new-node)) (ebox-incremental--node-child-ids-equal-p old-node new-node) (or local-typography-p @@ -8969,8 +9295,7 @@ metadata; this predicate only authorizes the local attempt." (ebox-incremental--layout-slot-style-signature new-node))) (ebox-incremental--same-root-surface-identity-p - (plist-get old-state :root-node) - (plist-get candidate-state :root-node)) + old-state candidate-state) (ebox-incremental--span-patch-identity-stable-p buffer old-state candidate-state prepared owner-id) (ebox--layout-snapshot-detailed-p snapshot) @@ -9204,8 +9529,7 @@ role, and outside-complement compatibility before publication." (ebox-incremental--layout-slot-style-signature new-node)) (ebox-incremental--same-root-surface-identity-p - (plist-get old-state :root-node) - (plist-get candidate-state :root-node)) + old-state candidate-state) (null (plist-get old-state :scroll-region-ids)) (not (ebox--node-visible-overflow-p old-node)) (not (ebox--node-visible-overflow-p new-node)) @@ -9290,8 +9614,7 @@ role, and outside-complement compatibility before publication." (ebox-incremental--layout-slot-style-signature new-node)) (equal (ebox-tree-display-inner old-node) 'column) (ebox-incremental--same-root-surface-identity-p - (plist-get old-state :root-node) - (plist-get candidate-state :root-node)) + old-state candidate-state) (null (plist-get old-state :scroll-region-ids)) (not (ebox-tree-node-visible-overflow-p old-node)) (not (ebox-tree-node-visible-overflow-p new-node)) @@ -9479,6 +9802,7 @@ role, and outside-complement compatibility before publication." :runtime-revision (plist-get candidate-state :runtime-revision) :display-signature (plist-get prepared :display-signature) + :source-index (plist-get candidate-state :source-index) :scroll-state-table (plist-get prepared :scroll-state-table)) (ebox-incremental--native-runtime-overrides candidate-state) (list :source-path-copied-p source-path-copied-p @@ -9557,8 +9881,8 @@ role, and outside-complement compatibility before publication." :region-box-count-table (plist-get candidate-state :region-box-count-table) :region-box-table (plist-get candidate-state :region-box-table) - :host-ref-table (plist-get candidate-state :host-ref-table) :range-ref-table (plist-get candidate-state :range-ref-table) + :source-index (plist-get candidate-state :source-index) :selector-id-table (plist-get candidate-state :selector-id-table) :selector-class-table (plist-get candidate-state :selector-class-table) @@ -9632,12 +9956,17 @@ role, and outside-complement compatibility before publication." :scroll-patch-region-id nil :scroll-state-transaction nil))))) -(defun ebox-incremental--same-root-surface-identity-p (old-root new-root) - "Return non-nil when OLD-ROOT and NEW-ROOT retain one TP root object." - (and (eq (plist-get old-root :ebox-type) - (plist-get new-root :ebox-type)) - (equal (ebox-tree-node-key old-root) - (ebox-tree-node-key new-root)))) +(defun ebox-incremental--same-root-surface-identity-p + (old-state candidate-state) + "Return non-nil when OLD-STATE and CANDIDATE-STATE retain one TP root." + (let ((old-root (plist-get old-state :root-node)) + (new-root (plist-get candidate-state :root-node))) + (and (eq (plist-get old-root :ebox-type) + (plist-get new-root :ebox-type)) + (equal + (ebox-tree-node-key (plist-get old-state :source-index) old-root) + (ebox-tree-node-key + (plist-get candidate-state :source-index) new-root))))) (defun ebox-incremental--surface-commit-input (buffer old-state prepared) @@ -9650,6 +9979,17 @@ role, and outside-complement compatibility before publication." (ebox-incremental--candidate-state old-state (plist-get prepared :root) (plist-get prepared :index) prepared)) + (ebox--render-source-index + (plist-get candidate-state :source-index)) + (ebox--render-source-generations + (list (plist-get candidate-state :source-index) + (plist-get old-state :source-index))) + (ebox--render-source-states (list candidate-state old-state)) + (_selector-index-stale + (when (and (plist-get candidate-state :source-index) + (ebox-source--index-derived-stale-p + (plist-get candidate-state :source-index))) + (plist-put candidate-state :selector-index-stale-p t))) (structural-dirty-p (cl-some (lambda (entry) (eq (plist-get entry :dirty-kind) 'structure)) @@ -9771,8 +10111,7 @@ role, and outside-complement compatibility before publication." (plist-get prepared :dirty-set))))))) (same-root-identity-p (ebox-incremental--same-root-surface-identity-p - (plist-get old-state :root-node) - (plist-get candidate-state :root-node))) + old-state candidate-state)) (scope-node-ids (and same-root-identity-p (delete-dups @@ -9878,25 +10217,61 @@ Return the intermediate candidate state, owner plan, projection proof, and whether NEXT-ROOT shares untouched published nodes." (let* ((ebox-incremental--allocated-slot-proof-cache (make-hash-table :test 'equal)) + (source-base-index + (or ebox-incremental--source-base-index + (and isolated-candidate-p + (plist-get old-state :source-index)))) (path-copied-p (and isolated-candidate-p (ebox-incremental--candidate-shares-published-node-p old-state next-root))) + (incoming-source-indexes + (when ebox-incremental--source-base-index + (let ((table (make-hash-table :test #'eq))) + (dolist (handle + (ebox-source--index-local-handles + ebox-incremental--source-base-index)) + (puthash handle ebox-incremental--source-base-index table)) + table))) (local-preparation (and path-copied-p - (ebox-incremental--candidate-local-index-delta - old-state next-root))) + (let ((ebox-incremental--candidate-incoming-source-indexes + incoming-source-indexes)) + (ebox-incremental--candidate-local-index-delta + old-state next-root)))) + (source-index + (and source-base-index + (not local-preparation) + (if (ebox-source--index-node-subjects source-base-index) + ;; Region/property candidates preserve validated topology; + ;; their source rebind already patches the one exact subject + ;; handle, so rebuilding every tree-derived subject would + ;; turn an O(changed) update back into O(all). + source-base-index + (ebox-tree-source-index + next-root nil nil source-base-index)))) (prepared (if isolated-candidate-p (ebox-incremental--prepare-declarative-runtime buffer old-state next-root nil local-preparation - skip-span-proof-p) + skip-span-proof-p source-index) (ebox-incremental--prepare-declarative-root buffer old-state next-root))) (candidate-state (ebox-incremental--candidate-state old-state (plist-get prepared :root) (plist-get prepared :index) prepared)) + (ebox--render-source-index + (plist-get candidate-state :source-index)) + (ebox--render-source-generations + (list (plist-get candidate-state :source-index) + (plist-get old-state :source-index))) + (ebox--render-source-states (list candidate-state old-state)) + (_selector-index-stale + (when (and (plist-get candidate-state :source-index) + (ebox-source--index-derived-stale-p + (plist-get candidate-state :source-index))) + (plist-put candidate-state :selector-index-stale-p t))) (formatting-proof (and (not scroll-transaction-p) (not (cl-some @@ -10089,7 +10464,7 @@ When RETAIN-NATIVE-P is non-nil, preserve its committed native frame bases." :region-id-set (plist-get state :region-id-set) :region-node-table (plist-get state :region-node-table) :region-box-count-table (plist-get state :region-box-count-table) - :host-ref-table (plist-get state :host-ref-table) + :source-index (plist-get state :source-index) :range-ref-table (plist-get state :range-ref-table) :selector-id-table (plist-get state :selector-id-table) :selector-class-table (plist-get state :selector-class-table) @@ -10280,7 +10655,6 @@ fresh by the mixed projection." :region-node-table (plist-get candidate-state :region-node-table) :region-box-count-table (plist-get candidate-state :region-box-count-table) :region-box-table (plist-get candidate-state :region-box-table) - :host-ref-table (plist-get candidate-state :host-ref-table) :range-ref-table (plist-get candidate-state :range-ref-table) :selector-id-table (plist-get candidate-state :selector-id-table) :selector-class-table (plist-get candidate-state :selector-class-table) @@ -10300,6 +10674,7 @@ fresh by the mixed projection." style-context-stable-p) "Prepare BUFFER for VIEWPORT-WIDTH, VIEWPORT-HEIGHT, AXES, and DISPLAY-SIGNATURE." (let* ((old-state (ebox--buffer-render-state buffer)) + (ebox--render-source-index (plist-get old-state :source-index)) (change (ebox--viewport-constraint-change buffer axes)) (display-changed (not (equal display-signature diff --git a/ebox-layout.el b/ebox-layout.el index de37c0b..c5b78fe 100644 --- a/ebox-layout.el +++ b/ebox-layout.el @@ -64,6 +64,15 @@ (defvar ebox--render-runtime-revision nil "Current buffer runtime revision for render-local cache validation.") +(defvar ebox--render-source-index nil + "Read-only source generation for the current private render extent.") + +(defvar ebox--render-source-generations nil + "Ordered read-only source generations available to one proof transaction.") + +(defvar ebox--render-source-states nil + "Ordered retained runtime states available to one proof transaction.") + (defvar ebox--region-id-counter) (defvar ebox--runtime-node-id-counter) (defvar ebox--region-box-table) @@ -362,7 +371,7 @@ and its formatted content exactly fills the used height." (declare-function ebox-surface--candidate-root "ebox-surface" (source preserve-identities-p)) (declare-function ebox-surface--inline-inheritance-required-p "ebox-surface" - (root)) + (root source-index)) (declare-function ebox--add-render-face! "ebox-render-context" (string start end face &optional append)) @@ -1387,7 +1396,7 @@ an inherited viewport." :scroll-lookahead ebox--scroll-window-initial-lookahead-lines-override :scroll-disabled ebox--scroll-window-render-disabled))) - (ebox-render node)))))) + (ebox--render-node node)))))) (defun ebox--resolve-size-content-pixel (box value fallback) "Resolve a horizontal size VALUE to BOX content pixels. @@ -1958,12 +1967,40 @@ region/node identities and render side tables for this one materialization." (ebox--render-cache-scroll-state-retained-cost-cache nil)) (ebox--render-layout candidate)))) -;;;###autoload -(defun ebox-render (node) - "Render layout NODE to a multi-line string through a TP materialization. -NODE accepts every layout node supported by `ebox--render-layout'. The -materialized surface is ephemeral and creates no live buffer state." - (let ((rendered +(defun ebox--render-node (node &optional base-source-index) + "Render private layout NODE with optional BASE-SOURCE-INDEX. +NODE is already below the canonical input boundary. Internal layout paths may +also pass a pre-rendered string while an active surface owns runtime facts." + (let* ((explicit-source-index-p (and base-source-index t)) + (handle (and (listp node) (ebox-tree-node-source-handle node))) + (node-id (and (listp node) (plist-get node :node-id))) + (base-source-index + (or base-source-index + (and node-id + (cl-loop + for state in ebox--render-source-states + for table = (plist-get state :node-table) + when (and (hash-table-p table) + (eq node (gethash node-id table))) + return (plist-get state :source-index))) + (and handle + (cl-find-if + (lambda (index) + (and index + (ebox-source-index-handle-member-p index handle))) + ebox--render-source-generations)) + ebox--render-source-index)) + (inherited-source-index-p + (and (not explicit-source-index-p) base-source-index)) + (ebox--render-source-index base-source-index) + (source-index + (and (not (stringp node)) + (not ebox--surface-materialization-active) + (not ebox--render-runtime-revision) + (ebox-tree-source-index + node t ebox--render-root-parent-kind base-source-index + inherited-source-index-p))) + (rendered (if (or ebox--surface-materialization-active ebox--render-runtime-revision) (ebox--render-layout node) @@ -1971,7 +2008,8 @@ materialized surface is ephemeral and creates no live buffer state." (if (and (not (stringp node)) (not (ebox-style-cascade-active-p)) (zerop (ebox-tree-author-style-count node)) - (not (ebox-surface--inline-inheritance-required-p node))) + (not (ebox-surface--inline-inheritance-required-p + node source-index))) (let ((ebox--region-id-counter ebox--region-id-counter) (ebox--runtime-node-id-counter ebox--runtime-node-id-counter)) (ebox--render-ephemeral-static node)) @@ -1979,11 +2017,25 @@ materialized surface is ephemeral and creates no live buffer state." (ebox--region-id-counter ebox--region-id-counter) (ebox--runtime-node-id-counter ebox--runtime-node-id-counter)) (tp-surface-materialize-string - (ebox-surface-producer node nil t))))))) + (ebox-surface-producer + node nil t + (and base-source-index + (list :source-base-index base-source-index))))))))) (unless ebox--paint-origin-capture-p (ebox--strip-paint-origins! rendered)) rendered)) +;;;###autoload +(defun ebox-render (input) + "Render canonical INPUT to a multi-line string through TP. +INPUT atomically carries one typed Text/Box root and its source generation. +The materialized surface is ephemeral and creates no live buffer state." + (unless (ebox-canonical-input-p input) + (signal 'wrong-type-argument (list 'ebox-canonical-input-p input))) + (ebox--render-node + (ebox-canonical-input--single-root input "ebox-render") + (ebox-canonical-input--source-index input))) + (defun ebox--width-pad (string extra-pixels) "Append a display-space of EXTRA-PIXELS to the right of every line in STRING. Used by `ebox--render-stack' to equalise rows to the same total width." @@ -2878,7 +2930,7 @@ are always resolved from BOX so no closure can retain a replaced runtime." (plist-put copy :content content) (plist-put copy :ebox-content-node nil) (plist-put copy :ebox-content-width-exact-p content-width-exact-p) - (prog1 (ebox-render copy) + (prog1 (ebox--render-node copy) (puthash region-id wrapper ebox--region-box-table)))) (defun ebox--window-render-flat-wrapper-chunk-lines diff --git a/ebox-native-commit.el b/ebox-native-commit.el index 1173385..73829db 100644 --- a/ebox-native-commit.el +++ b/ebox-native-commit.el @@ -32,7 +32,7 @@ (declare-function ebox--literal-root-pixel-width "ebox" (box)) (declare-function ebox-get "ebox" (box key)) (declare-function ebox-style-cascade-active-p "ebox-style" ()) -(declare-function ebox-tree-node-key "ebox-tree" (node)) +(declare-function ebox-tree-node-key "ebox-tree" (source-index node)) (declare-function ebox-tree--children-raw "ebox-tree" (node)) (declare-function ebox--node-direct-viewport-width-dependent-p "ebox-incremental" (node)) @@ -77,8 +77,11 @@ (gethash node-id new-parents missing)) (eq (plist-get old-node :ebox-type) (plist-get node :ebox-type)) - (equal (ebox-tree-node-key old-node) - (ebox-tree-node-key node))) + (equal + (ebox-tree-node-key + (plist-get old-state :source-index) old-node) + (ebox-tree-node-key + (plist-get candidate-state :source-index) node))) (setq stable nil)))) new-nodes)) (when stable @@ -144,8 +147,11 @@ type, key, parent, and sibling position are identical." (plist-get new-root :node-id)) (eq (plist-get old-root :ebox-type) (plist-get new-root :ebox-type)) - (equal (ebox-tree-node-key old-root) - (ebox-tree-node-key new-root)) + (equal + (ebox-tree-node-key + (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) (hash-table-p old-objects))) result) @@ -178,8 +184,13 @@ type, key, parent, and sibling position are identical." (null old-object) (not (eq (plist-get old-node :ebox-type) (plist-get node :ebox-type))) - (not (equal (ebox-tree-node-key old-node) - (ebox-tree-node-key node))))) + (not + (equal + (ebox-tree-node-key + (plist-get old-state :source-index) old-node) + (ebox-tree-node-key + (plist-get candidate-state :source-index) + node))))) (touched-p (or replacement-p (gethash node-id requested) diff --git a/ebox-node-factory.el b/ebox-node-factory.el index 300e9f3..b78116b 100644 --- a/ebox-node-factory.el +++ b/ebox-node-factory.el @@ -1,10 +1,9 @@ ;;; ebox-node-factory.el --- Runtime box node factory -*- lexical-binding: t; -*- ;;; Commentary: -;; Owns the legacy runtime box encoding used by render/layout modules during -;; the typed canonical migration. Higher programmatic constructors may lower -;; through this factory, but author DSL parsing and buffer publication do not -;; belong here. +;; Owns private runtime box storage used by render/layout modules. Higher +;; programmatic constructors may lower through this factory, but author source +;; facts, DSL parsing, and buffer publication do not belong here. ;;; Code: @@ -63,8 +62,8 @@ leaving them absent would make the canonical node violate the Box contract.") (plist-put box key value))) box)) -(defun ebox-node-factory--create-expanded (expanded declarations) - "Create one runtime box from EXPANDED engine facts and DECLARATIONS." +(defun ebox-node-factory--create-expanded (expanded) + "Create one runtime box from EXPANDED engine facts." (let ((controlled-scroll-offset-p (plist-member expanded :scroll-offset))) (let ((box (copy-sequence ebox-node-factory--box-longhand))) @@ -72,32 +71,23 @@ leaving them absent would make the canonical node violate the Box contract.") do (plist-put box property value)) (plist-put box :ebox-type 'box) (plist-put box :display '(block flow)) - (plist-put box :ebox-style-declarations (copy-sequence declarations)) (when controlled-scroll-offset-p (plist-put box :ebox-scroll-offset-controlled-p t)) box))) -(defun ebox-node-factory--create-text-expanded (expanded declarations) - "Create canonical Text storage from EXPANDED facts and DECLARATIONS." +(defun ebox-node-factory--create-text-expanded (expanded) + "Create canonical Text storage from EXPANDED facts." (let ((node (copy-sequence ebox-node-factory--text-longhand))) (cl-loop for (property value) on expanded by #'cddr do (plist-put node property value)) (plist-put node :ebox-type 'box) (plist-put node :display '(inline flow)) - (plist-put node :ebox-style-declarations (copy-sequence declarations)) node)) -(defun ebox-node-factory--create (plist declarations) - "Create one runtime box from normalized PLIST and DECLARATIONS. -PLIST contains already-validated public engine inputs; DECLARATIONS are the -single canonical author fact set retained for style projection." - (ebox-node-factory--create-expanded - (ebox-style-expand-ebox-plist plist) declarations)) - (defun ebox-node-factory--create-from-properties (&rest plist) "Create one private runtime box from evaluated engine PLIST." - (ebox-node-factory--create - plist (ebox-style-compile-declarations plist))) + (ebox-node-factory--create-expanded + (ebox-style-expand-ebox-plist plist))) (provide 'ebox-node-factory) diff --git a/ebox-selector.el b/ebox-selector.el index f495db5..4d8b844 100644 --- a/ebox-selector.el +++ b/ebox-selector.el @@ -115,10 +115,23 @@ (ebox--ensure-region-id box))) (_ nil))) +(defun ebox-selector--root-and-index (value context) + "Return `(ROOT . SOURCE-INDEX)' from canonical input VALUE for CONTEXT." + (if (ebox-canonical-input-p value) + (cons (ebox-canonical-input--single-root value context) + (ebox-canonical-input--source-index value)) + (error "%s requires one canonical Ebox input" context))) + ;;;###autoload (defun ebox-selector-match-node-p (node selector) "Return non-nil when NODE matches structured ECSS SELECTOR." - (ecss-selector-match-p selector (ebox-tree-node-subject node))) + (pcase-let* ((`(,root . ,source-index) + (ebox-selector--root-and-index + node "ebox-selector-match-node-p")) + (source-index + (ebox-tree-source-index root nil nil source-index))) + (ecss-selector-match-p + selector (ebox-tree-node-subject source-index root)))) (defun ebox-selector--match-handle (node path selector) "Return a selector match handle for NODE on PATH." @@ -128,12 +141,12 @@ :path path :selector selector)) -(defun ebox-selector--matching-subject-entries (root ast) - "Return semantic ROOT entries whose ECSS subjects match AST." +(defun ebox-selector--matching-subject-entries (root ast &optional source-index) + "Return semantic ROOT entries in SOURCE-INDEX whose subjects match AST." (cl-remove-if-not (lambda (entry) (ecss-selector-match-p ast (plist-get entry :subject))) - (plist-get (ebox-tree-subject-index root) :entries))) + (plist-get (ebox-tree-subject-index root source-index) :entries))) (defun ebox-selector--subject-entry-handle (entry selector) "Return a match handle for semantic subject ENTRY and SELECTOR." @@ -143,10 +156,16 @@ ;;;###autoload (defun ebox-selector-query-all (root selector) "Return document-ordered selector match handles under ROOT." - (let ((ast (ebox-selector-parse selector))) + (pcase-let* ((`(,root . ,source-index) + (ebox-selector--root-and-index + root "ebox-selector-query-all")) + (source-index + (ebox-tree-source-index root nil nil source-index)) + (ast (ebox-selector-parse selector))) (mapcar (lambda (entry) (ebox-selector--subject-entry-handle entry selector)) - (ebox-selector--matching-subject-entries root ast)))) + (ebox-selector--matching-subject-entries + root ast source-index)))) (defun ebox-selector--resolve-buffer (buffer) "Return live buffer object for BUFFER, or signal a user error." @@ -163,11 +182,13 @@ (and region-id (gethash region-id (plist-get state :region-surface-object-table)))) + (source-index (plist-get state :source-index)) (region-handle (and object (ebox-selector--live-region-handle buffer object - (ebox-tree-node-id (plist-get handle :node)))))) + (ebox-tree-node-id + source-index (plist-get handle :node)))))) (append handle (list :buffer buffer :region-handle region-handle)))) @@ -234,12 +255,12 @@ (when (or id classes type) (cons t (ebox-selector--shortest-candidates candidates))))) -(defun ebox-selector--indexed-entry-matches-p (entry ast) - "Return non-nil when indexed ENTRY matches ECSS selector AST." +(defun ebox-selector--indexed-entry-matches-p (source-index entry ast) + "Return non-nil when SOURCE-INDEX ENTRY matches ECSS selector AST." (let ((subject (if (ebox-selector--relational-p ast) - (ebox-tree-subject-for-path (cdr entry)) - (ebox-tree-node-subject (car entry))))) + (ebox-tree-subject-for-path source-index (cdr entry)) + (ebox-tree-node-subject source-index (car entry))))) (ecss-selector-match-p ast subject))) (defun ebox-selector--entries-to-buffer-handles (entries selector buffer) @@ -255,21 +276,25 @@ "Return `(SUPPORTED . HANDLES)' for an indexed AST query in BUFFER." (when (ebox-selector--descendant-indexable-p ast) (when-let* ((indexed (ebox-selector--indexed-candidates buffer ast))) - (cons - t - (ebox-selector--entries-to-buffer-handles - (cl-remove-if-not - (lambda (entry) (ebox-selector--indexed-entry-matches-p entry ast)) - (cdr indexed)) - selector buffer))))) + (let ((source-index (ebox--buffer-source-index buffer))) + (cons + t + (ebox-selector--entries-to-buffer-handles + (cl-remove-if-not + (lambda (entry) + (ebox-selector--indexed-entry-matches-p + source-index entry ast)) + (cdr indexed)) + selector buffer)))))) -(defun ebox-selector--query-buffer-tree (root selector ast buffer) - "Return full semantic tree matches for ROOT, SELECTOR, AST, and BUFFER." +(defun ebox-selector--query-buffer-tree + (root source-index selector ast buffer) + "Return semantic ROOT matches from SOURCE-INDEX for SELECTOR in BUFFER." (mapcar (lambda (entry) (ebox-selector--handle-with-buffer (ebox-selector--subject-entry-handle entry selector) buffer)) - (ebox-selector--matching-subject-entries root ast))) + (ebox-selector--matching-subject-entries root ast source-index))) ;;;###autoload (defun ebox-selector-query-buffer (buffer selector) @@ -278,7 +303,8 @@ Selector lookup reads the stored runtime root, not rendered buffer text properties. Each returned handle includes `:buffer' in addition to the tree-query handle fields." (let* ((resolved-buffer (ebox-selector--resolve-buffer buffer)) - (root (ebox--buffer-root-node resolved-buffer))) + (root (ebox--buffer-root-node resolved-buffer)) + (source-index (ebox--buffer-source-index resolved-buffer))) (unless root (user-error "ebox-selector: buffer has no Ebox runtime state: %S" resolved-buffer)) @@ -288,7 +314,7 @@ tree-query handle fields." (if indexed (cdr indexed) (ebox-selector--query-buffer-tree - root selector ast resolved-buffer))))) + root source-index selector ast resolved-buffer))))) (defun ebox-selector--skip-handle (handle reason) "Return a structured skip entry for HANDLE and REASON." diff --git a/ebox-source.el b/ebox-source.el new file mode 100644 index 0000000..78e5964 --- /dev/null +++ b/ebox-source.el @@ -0,0 +1,769 @@ +;;; ebox-source.el --- Immutable author source facts -*- lexical-binding: t; -*- + +;;; Commentary: +;; Owns opaque author source handles, immutable source records, and candidate +;; source indexes. It does not own Ebox nodes, tree traversal, selector +;; matching, layout, paint, buffers, or publication. + +;;; Code: + +(require 'cl-lib) +(require 'subr-x) + +(cl-defstruct (ebox-source-record + (:constructor ebox-source--make-record)) + "Immutable facts supplied by one author source." + key id classes attributes states declarations provenance) + +(cl-defstruct (ebox-source-handle + (:constructor ebox-source--make-handle) + (:conc-name ebox-source--handle-)) + "Opaque identity for one author source." + identity) + +(cl-defstruct (ebox-source-index + (:constructor ebox-source--make-index) + (:conc-name ebox-source--index-)) + "Immutable candidate-local mapping from source handles to records." + records handle-records order subjects node-subjects entries root-subject + selector-id-table selector-class-table selector-type-table host-ref-table + derived-stale-p) + +(cl-defstruct (ebox-source-builder + (:constructor ebox-source--make-builder) + (:conc-name ebox-source--builder-)) + "Private mutable source assembly sealed into one immutable source index." + records handle-records order sealed-p) + +(cl-defstruct (ebox-source-table + (:constructor ebox-source--make-table)) + "Persistent immutable table overlay with a derived lookup memo." + base added removed count test depth memo) + +(cl-defstruct (ebox-source-order + (:constructor ebox-source--make-order)) + "Bounded persistent document-order handle delta." + base replacements removed appended local-handles handles depth) + +(defconst ebox-source--persistent-max-depth 32 + "Maximum SourceIndex overlay depth before exact compaction.") + +(defconst ebox-source--memo-missing (make-symbol "ebox-source-memo-missing") + "Private marker for a memoized absent persistent-table key.") + +(defconst ebox-source--memo-unset (make-symbol "ebox-source-memo-unset") + "Private marker for a persistent-table key not present in its memo.") + +(defun ebox-source--flat-table (hash test) + "Seal HASH as a flat immutable source table using TEST." + (ebox-source--make-table + :base nil :added hash :removed (make-hash-table :test test) + :count (hash-table-count hash) :test test :depth 0 :memo nil)) + +(defun ebox-source--table-seal (table test) + "Return TABLE as an immutable persistent table using TEST." + (cond + ((null table) nil) + ((ebox-source-table-p table) table) + ((hash-table-p table) (ebox-source--flat-table table test)) + (t (signal 'wrong-type-argument (list 'hash-table-p table))))) + +(defun ebox-source--table-value (table key) + "Return KEY's value from persistent or flat TABLE, or nil." + (cond + ((ebox-source-table-p table) + (let ((missing (make-symbol "ebox-source-table-missing"))) + (let ((value (ebox-source--table-get table key missing))) + (unless (eq value missing) value)))) + ((hash-table-p table) (gethash key table)) + ((null table) nil) + (t (signal 'wrong-type-argument (list 'hash-table-p table))))) + +(defun ebox-source--table-size (table) + "Return the number of bindings retained by TABLE." + (cond + ((ebox-source-table-p table) (ebox-source-table-count table)) + ((hash-table-p table) (hash-table-count table)) + ((null table) 0) + (t (signal 'wrong-type-argument (list 'hash-table-p table))))) + +(defun ebox-source--table-get (table key missing) + "Return KEY from persistent TABLE, or MISSING." + (let* ((memo (ebox-source-table-memo table)) + (memoized + (and memo (gethash key memo ebox-source--memo-unset)))) + (if (and memo (not (eq memoized ebox-source--memo-unset))) + (if (eq memoized ebox-source--memo-missing) missing memoized) + (let* ((local-missing (make-symbol "ebox-source-local-missing")) + (local + (gethash key (ebox-source-table-added table) local-missing)) + (resolved + (cond + ((not (eq local local-missing)) local) + ((gethash key (ebox-source-table-removed table)) + ebox-source--memo-missing) + ((ebox-source-table-base table) + (ebox-source--table-get + (ebox-source-table-base table) + key ebox-source--memo-missing)) + (t ebox-source--memo-missing)))) + ;; Facts remain immutable. The memo only path-compresses a lookup whose + ;; answer is fixed by this immutable overlay and its immutable base. + (when memo (puthash key resolved memo)) + (if (eq resolved ebox-source--memo-missing) missing resolved))))) + +(defun ebox-source--table-materialize (table) + "Return a fresh flat hash table with persistent TABLE's exact bindings." + (let ((result + (if (ebox-source-table-base table) + (ebox-source--table-materialize (ebox-source-table-base table)) + (make-hash-table :test (ebox-source-table-test table))))) + (maphash (lambda (key _value) (remhash key result)) + (ebox-source-table-removed table)) + (maphash (lambda (key value) (puthash key value result)) + (ebox-source-table-added table)) + result)) + +(defun ebox-source--table-member-p (table key) + "Return non-nil when persistent TABLE contains KEY." + (let ((missing (make-symbol "ebox-source-missing"))) + (not (eq (ebox-source--table-get table key missing) missing)))) + +(defun ebox-source--table-overlay (base added removed count) + "Return bounded persistent BASE overlaid by ADDED and REMOVED with COUNT." + (let ((depth (1+ (ebox-source-table-depth base)))) + (if (< depth ebox-source--persistent-max-depth) + (ebox-source--make-table + :base base :added added :removed removed :count count + :test (ebox-source-table-test base) :depth depth + :memo (make-hash-table :test (ebox-source-table-test base))) + (let ((flat (ebox-source--table-materialize base))) + (maphash (lambda (key _value) (remhash key flat)) removed) + (maphash (lambda (key value) (puthash key value flat)) added) + (unless (= (hash-table-count flat) count) + (error "Ebox persistent source table count is inconsistent")) + (ebox-source--flat-table flat (ebox-source-table-test base)))))) + +(defun ebox-source--order-apply (handles replacements removed appended) + "Apply order delta to HANDLES and return a fresh exact order." + (let ((missing (make-symbol "ebox-source-order-missing")) result) + (dolist (handle handles) + (let ((replacement (gethash handle replacements missing))) + (cond + ((not (eq replacement missing)) (push replacement result)) + ((gethash handle removed) nil) + (t (push handle result))))) + (append (nreverse result) (copy-sequence appended)))) + +(defun ebox-source--order-handles (order) + "Materialize persistent source ORDER." + (cond + ((listp order) (copy-sequence order)) + ((ebox-source-order-handles order) + (copy-sequence (ebox-source-order-handles order))) + (t + (ebox-source--order-apply + (ebox-source--order-handles (ebox-source-order-base order)) + (ebox-source-order-replacements order) + (ebox-source-order-removed order) + (ebox-source-order-appended order))))) + +(defun ebox-source--order-overlay + (base replacements removed appended local-handles) + "Return a bounded persistent order delta over BASE." + (let* ((base-depth (if (ebox-source-order-p base) + (or (ebox-source-order-depth base) 0) + 0)) + (depth (1+ base-depth))) + (if (< depth ebox-source--persistent-max-depth) + (ebox-source--make-order + :base base :replacements replacements :removed removed + :appended appended :local-handles (copy-sequence local-handles) + :depth depth) + (ebox-source--make-order + :handles + (ebox-source--order-apply + (ebox-source--order-handles base) replacements removed appended) + :local-handles (copy-sequence local-handles) + :depth 0)))) + +(defun ebox-source--copy-data (value) + "Return a detached copy of mutable source VALUE. +Functions and records are opaque values and retain identity." + (cond + ((functionp value) value) + ((hash-table-p value) + (let ((copy (copy-hash-table value))) + (clrhash copy) + (maphash + (lambda (key item) + (puthash (ebox-source--copy-data key) + (ebox-source--copy-data item) + copy)) + value) + copy)) + ((consp value) + (cons (ebox-source--copy-data (car value)) + (ebox-source--copy-data (cdr value)))) + ((stringp value) (copy-sequence value)) + ((bool-vector-p value) (copy-sequence value)) + ((recordp value) value) + ((vectorp value) + (let ((copy (copy-sequence value))) + (dotimes (index (length copy)) + (aset copy index + (ebox-source--copy-data (aref copy index)))) + copy)) + (t value))) + +(defun ebox-source--metadata-string (value) + "Return VALUE normalized to a selector metadata string." + (cond + ((null value) nil) + ((symbolp value) (symbol-name value)) + ((stringp value) (copy-sequence value)) + (t (format "%s" value)))) + +(defun ebox-source--metadata-tokens (value) + "Return VALUE as unique normalized selector metadata tokens." + (delete-dups + (delq nil + (mapcar #'ebox-source--metadata-string + (if (listp value) value (list value)))))) + +(defun ebox-source--attribute-key (key) + "Return canonical keyword selector attribute KEY." + (cond + ((keywordp key) key) + ((symbolp key) (intern (concat ":" (symbol-name key)))) + ((stringp key) + (intern (if (string-prefix-p ":" key) key (concat ":" key)))) + (t (error "Ebox source attribute name is invalid: %S" key)))) + +(defun ebox-source--attributes (attributes) + "Return detached canonical selector ATTRIBUTES." + (unless (or (null attributes) (proper-list-p attributes)) + (error "Ebox source attributes must be an alist: %S" attributes)) + (mapcar + (lambda (attribute) + (unless (consp attribute) + (error "Ebox source attributes must be an alist: %S" attributes)) + (cons (ebox-source--attribute-key (car attribute)) + (ebox-source--copy-data (cdr attribute)))) + attributes)) + +(defun ebox-source--declarations (declarations) + "Return detached canonical DECLARATIONS after shape validation." + (unless (and (proper-list-p declarations) + (zerop (% (length declarations) 2))) + (error "Ebox source declarations must be an even plist: %S" + declarations)) + (ebox-source--copy-data declarations)) + +(defun ebox-source-builder-create () + "Return a mutable source builder for one canonical input." + (ebox-source--make-builder + :records (make-hash-table :test #'equal) + :handle-records (make-hash-table :test #'eq) + :order nil + :sealed-p nil)) + +(defun ebox-source--builder-assert-open (builder) + "Signal unless BUILDER is a live mutable source builder." + (unless (ebox-source-builder-p builder) + (signal 'wrong-type-argument (list 'ebox-source-builder-p builder))) + (when (ebox-source--builder-sealed-p builder) + (error "Ebox source builder is already sealed"))) + +(defun ebox-source--builder-add-record (builder handle record) + "Add identity-only HANDLE and immutable RECORD to open BUILDER." + (ebox-source--builder-assert-open builder) + (unless (ebox-source-handle-p handle) + (signal 'wrong-type-argument (list 'ebox-source-handle-p handle))) + (unless (ebox-source-record-p record) + (signal 'wrong-type-argument (list 'ebox-source-record-p record))) + (let* ((identity (ebox-source--handle-id-view handle)) + (records (ebox-source--builder-records builder))) + (when (gethash identity records) + (error "Ebox source identity occurs more than once: %S" identity)) + (puthash identity record records) + ;; Both indexes retain the same immutable record object. Identity lookup + ;; owns uniqueness; exact-handle lookup owns hot-path resolution. + (puthash handle record (ebox-source--builder-handle-records builder)) + (push handle (ebox-source--builder-order builder)) + handle)) + +(cl-defun ebox-source-builder-bind + (builder &key identity key id class selector-attributes selector-state + declarations provenance) + "Bind normalized source facts in BUILDER and return an opaque handle. +The returned handle contains identity only; all facts are owned by BUILDER +until `ebox-source-builder-finish' seals them into a source index." + (let ((handle + (ebox-source--make-handle + :identity (ebox-source--copy-data + (or identity (make-symbol "ebox-source"))))) + (record + (ebox-source--make-record + :key (ebox-source--copy-data key) + :id (ebox-source--metadata-string id) + :classes (ebox-source--metadata-tokens class) + :attributes (ebox-source--attributes selector-attributes) + :states (ebox-source--metadata-tokens selector-state) + :declarations (ebox-source--declarations declarations) + :provenance (ebox-source--copy-data provenance)))) + (ebox-source--builder-add-record builder handle record))) + +(defun ebox-source--builder-record (builder handle) + "Return open BUILDER's immutable record for exact HANDLE." + (ebox-source--builder-assert-open builder) + (gethash handle (ebox-source--builder-handle-records builder))) + +(defun ebox-source-builder-import (builder index) + "Import INDEX's immutable source facts into open BUILDER. +The exact handles and records are shared; duplicate source identity is an +error because one canonical forest cannot contain the same source twice." + (ebox-source--builder-assert-open builder) + (unless (ebox-source-index-p index) + (signal 'wrong-type-argument (list 'ebox-source-index-p index))) + (dolist (handle (ebox-source-index-handles index)) + (ebox-source--builder-add-record + builder handle + (or (ebox-source--index-record index handle) + (error "Ebox imported source handle is absent from its index")))) + builder) + +(cl-defun ebox-source-index-rebind + (index handle &key + (key nil key-p) (id nil id-p) (class nil class-p) + (selector-attributes nil attributes-p) + (selector-state nil states-p) + (declarations nil declarations-p) + (provenance nil provenance-p)) + "Return `(NEW-INDEX . NEW-HANDLE)' after rebinding HANDLE in INDEX. +The semantic identity and all omitted immutable facts are retained. A fresh +exact handle makes the candidate generation change explicit without placing +facts on the canonical node." + (let ((record (or (ebox-source--index-record index handle) + (error "Ebox source rebind handle is absent"))) + (builder (ebox-source-builder-create))) + (let* ((new-handle + (ebox-source-builder-bind + builder + :identity (ebox-source--handle-id-view handle) + :key (if key-p key (ebox-source-record-key record)) + :id (if id-p id (ebox-source-record-id record)) + :class (if class-p class (ebox-source-record-classes record)) + :selector-attributes + (if attributes-p + selector-attributes + (ebox-source-record-attributes record)) + :selector-state + (if states-p selector-state (ebox-source-record-states record)) + :declarations (if declarations-p + declarations + (ebox-source-record-declarations record)) + :provenance + (if provenance-p provenance + (ebox-source-record-provenance record)))) + (incoming (ebox-source-builder-finish builder)) + (sources (make-hash-table :test #'eq))) + (puthash new-handle incoming sources) + (let* ((candidate + (ebox-source--index-replace-handles + index (list handle) (list new-handle) sources + (list (cons handle new-handle)))) + (node-subjects (ebox-source--index-node-subjects index)) + (selector-stable-p + (not (or id-p class-p attributes-p states-p))) + (subject (and selector-stable-p node-subjects + (ebox-source--table-value node-subjects handle)))) + (cond + (subject + (let ((added (make-hash-table :test #'eq)) + (removed (make-hash-table :test #'eq))) + (puthash new-handle subject added) + (puthash handle t removed) + (setq candidate + (ebox-source--with-derived + candidate + :subjects (ebox-source--index-subjects index) + :node-subjects + (ebox-source--table-overlay + node-subjects added removed + (ebox-source--table-size node-subjects)) + :entries (ebox-source--index-entries index) + :root-subject (ebox-source--index-root-subject index) + :selector-id-table + (ebox-source--index-selector-id-table index) + :selector-class-table + (ebox-source--index-selector-class-table index) + :selector-type-table + (ebox-source--index-selector-type-table index) + :host-ref-table (ebox-source--index-host-ref-table index) + :derived-stale-p t)))) + (node-subjects + ;; Selector metadata changes invalidate subject identity and ancestry; + ;; the tree owner must derive them again from the candidate root. + (setq candidate + (ebox-source--with-derived + candidate :host-ref-table + (ebox-source--index-host-ref-table index) + :derived-stale-p t)))) + (cons candidate new-handle))))) + +(defun ebox-source-builder-finish (builder) + "Seal BUILDER and return its fact-only immutable source index." + (ebox-source--builder-assert-open builder) + (let ((records (copy-hash-table (ebox-source--builder-records builder))) + (handle-records + (copy-hash-table (ebox-source--builder-handle-records builder))) + (order (nreverse (copy-sequence (ebox-source--builder-order builder))))) + (setf (ebox-source--builder-sealed-p builder) t + (ebox-source--builder-records builder) nil + (ebox-source--builder-handle-records builder) nil + (ebox-source--builder-order builder) nil) + (ebox-source--make-index + :records (ebox-source--flat-table records #'equal) + :handle-records (ebox-source--flat-table handle-records #'eq) + :order order))) + +(defun ebox-source-handle-id (handle) + "Return a detached copy of HANDLE's opaque diagnostic identity." + (ebox-source--copy-data (ebox-source--handle-id-view handle))) + +(defun ebox-source--handle-id-view (handle) + "Return HANDLE's private immutable identity view." + (unless (ebox-source-handle-p handle) + (signal 'wrong-type-argument (list 'ebox-source-handle-p handle))) + (ebox-source--handle-identity handle)) + +(cl-defun ebox-source--with-derived + (index &key subjects node-subjects entries root-subject + selector-id-table selector-class-table selector-type-table + host-ref-table derived-stale-p) + "Return INDEX completed with immutable tree-derived query facts." + (unless (ebox-source-index-p index) + (signal 'wrong-type-argument (list 'ebox-source-index-p index))) + (ebox-source--make-index + :records (ebox-source--index-records index) + :handle-records (ebox-source--index-handle-records index) + :order (ebox-source--index-order index) + :subjects (ebox-source--table-seal subjects #'equal) + :node-subjects (ebox-source--table-seal node-subjects #'eq) + :entries entries :root-subject root-subject + :selector-id-table selector-id-table + :selector-class-table selector-class-table + :selector-type-table selector-type-table + :host-ref-table host-ref-table + :derived-stale-p derived-stale-p)) + +(defun ebox-source--with-order (index handles) + "Return INDEX sharing all facts with exact document-order HANDLES." + (unless (ebox-source-index-p index) + (signal 'wrong-type-argument (list 'ebox-source-index-p index))) + (let* ((order (ebox-source--index-order index)) + (local-handles + (and (ebox-source-order-p order) + (copy-sequence (ebox-source-order-local-handles order))))) + (ebox-source--make-index + :records (ebox-source--index-records index) + :handle-records (ebox-source--index-handle-records index) + :order (if local-handles + (ebox-source--make-order + :handles (copy-sequence handles) + :local-handles local-handles :depth 0) + (copy-sequence handles)) + :subjects (ebox-source--index-subjects index) + :node-subjects (ebox-source--index-node-subjects index) + :entries (ebox-source--index-entries index) + :root-subject (ebox-source--index-root-subject index) + :selector-id-table (ebox-source--index-selector-id-table index) + :selector-class-table (ebox-source--index-selector-class-table index) + :selector-type-table (ebox-source--index-selector-type-table index) + :host-ref-table (ebox-source--index-host-ref-table index) + :derived-stale-p (ebox-source--index-derived-stale-p index)))) + +(defun ebox-source--with-runtime-hosts (index host-ref-table) + "Return INDEX sharing its source facts with runtime HOST-REF-TABLE." + (unless (ebox-source-index-p index) + (signal 'wrong-type-argument (list 'ebox-source-index-p index))) + (ebox-source--make-index + :records (ebox-source--index-records index) + :handle-records (ebox-source--index-handle-records index) + :order (ebox-source--index-order index) + :subjects (ebox-source--index-subjects index) + :node-subjects (ebox-source--index-node-subjects index) + :entries (ebox-source--index-entries index) + :root-subject (ebox-source--index-root-subject index) + :selector-id-table (ebox-source--index-selector-id-table index) + :selector-class-table (ebox-source--index-selector-class-table index) + :selector-type-table (ebox-source--index-selector-type-table index) + :host-ref-table host-ref-table + :derived-stale-p (ebox-source--index-derived-stale-p index))) + +(defun ebox-source--index-replace-handles + (index removed added incoming-source-indexes + &optional replacement-pairs final-order) + "Return candidate INDEX after two-phase REMOVED and ADDED handle changes. +Source records and exact handle membership are updated persistently. Tree and +selector derivations remain shared but are marked stale until explicitly +rebuilt or patched by the tree owner. REPLACEMENT-PAIRS preserve exact order +for nonstructural changes; structural changes supply FINAL-ORDER." + (unless (ebox-source-index-p index) + (signal 'wrong-type-argument (list 'ebox-source-index-p index))) + (let ((removed-identities (make-hash-table :test #'equal)) + (removed-handles (make-hash-table :test #'eq)) + (added-records (make-hash-table :test #'equal)) + (added-handle-records (make-hash-table :test #'eq)) + (replacement-table (make-hash-table :test #'eq)) + (replacement-values (make-hash-table :test #'eq)) + added-order) + (dolist (handle removed) + (when (ebox-source-handle-p handle) + (unless (ebox-source-index-handle-member-p index handle) + (error "Ebox removed source handle is absent")) + (puthash handle t removed-handles) + (puthash (ebox-source--handle-id-view handle) t + removed-identities))) + (dolist (handle added) + (unless (ebox-source-handle-p handle) + (signal 'wrong-type-argument (list 'ebox-source-handle-p handle))) + (let ((identity (ebox-source--handle-id-view handle))) + (when (or (gethash identity added-records) + (and (ebox-source--table-member-p + (ebox-source--index-records index) identity) + (not (gethash identity removed-identities)))) + (error "Ebox source identity occurs more than once: %S" identity)) + (let* ((incoming-index + (and (hash-table-p incoming-source-indexes) + (gethash handle incoming-source-indexes))) + (record + (and incoming-index + (ebox-source--index-record incoming-index handle)))) + (unless record + (error "Ebox candidate source is absent from incoming input")) + (puthash identity record added-records) + (puthash handle record added-handle-records)) + (push handle added-order))) + (setq added-order (nreverse added-order)) + (dolist (pair replacement-pairs) + (unless (and (gethash (car pair) removed-handles) + (gethash (cdr pair) added-handle-records)) + (error "Ebox source order replacement is not part of its fact delta")) + (puthash (car pair) (cdr pair) replacement-table) + (puthash (cdr pair) t replacement-values)) + (unless final-order + (maphash + (lambda (handle _value) + (unless (gethash handle replacement-table) + (error "Structural source removal requires a final document order"))) + removed-handles) + (dolist (handle added-order) + (unless (gethash handle replacement-values) + (error "Structural source insertion requires a final document order")))) + (ebox-source--make-index + :records + (ebox-source--table-overlay + (ebox-source--index-records index) added-records removed-identities + (+ (- (ebox-source-table-count (ebox-source--index-records index)) + (hash-table-count removed-identities)) + (hash-table-count added-records))) + :handle-records + (ebox-source--table-overlay + (ebox-source--index-handle-records index) + added-handle-records removed-handles + (+ (- (ebox-source-table-count + (ebox-source--index-handle-records index)) + (hash-table-count removed-handles)) + (hash-table-count added-handle-records))) + :order + (if final-order + (ebox-source--make-order + :handles (copy-sequence final-order) + :local-handles (copy-sequence added-order) :depth 0) + (ebox-source--order-overlay + (ebox-source--index-order index) + replacement-table removed-handles nil added-order)) + :subjects (ebox-source--index-subjects index) + :node-subjects (ebox-source--index-node-subjects index) + :entries (ebox-source--index-entries index) + :root-subject (ebox-source--index-root-subject index) + :selector-id-table (ebox-source--index-selector-id-table index) + :selector-class-table (ebox-source--index-selector-class-table index) + :selector-type-table (ebox-source--index-selector-type-table index) + :host-ref-table (ebox-source--index-host-ref-table index) + :derived-stale-p t))) + +(defun ebox-source-index-handles (index) + "Return source handles from immutable INDEX in document order." + (unless (ebox-source-index-p index) + (signal 'wrong-type-argument (list 'ebox-source-index-p index))) + (ebox-source--order-handles (ebox-source--index-order index))) + +(defun ebox-source--index-local-handles (index) + "Return handles added by INDEX's newest persistent overlay." + (let ((order (ebox-source--index-order index))) + (and (ebox-source-order-p order) + (copy-sequence (ebox-source-order-local-handles order))))) + +(defun ebox-source-index-handle-member-p (index handle) + "Return non-nil when exact opaque HANDLE belongs to candidate INDEX." + (unless (ebox-source-index-p index) + (signal 'wrong-type-argument (list 'ebox-source-index-p index))) + (and (ebox-source-handle-p handle) + (ebox-source--table-member-p + (ebox-source--index-handle-records index) handle))) + +(defun ebox-source--index-record (index handle) + "Return INDEX-owned record for HANDLE, or nil." + (unless (ebox-source-index-p index) + (signal 'wrong-type-argument (list 'ebox-source-index-p index))) + (unless (ebox-source-handle-p handle) + (signal 'wrong-type-argument (list 'ebox-source-handle-p handle))) + (ebox-source--table-value + (ebox-source--index-handle-records index) handle)) + +(defun ebox-source-index-key (index handle) + "Return HANDLE's detached author key from candidate INDEX." + (ebox-source--copy-data (ebox-source--index-key-view index handle))) + +(defun ebox-source--index-key-view (index handle) + "Return HANDLE's private immutable author key view in INDEX." + (when-let* ((record (ebox-source--index-record index handle))) + (ebox-source-record-key record))) + +(defun ebox-source-index-id (index handle) + "Return HANDLE's detached selector id from candidate INDEX." + (ebox-source--copy-data (ebox-source--index-id-view index handle))) + +(defun ebox-source--index-id-view (index handle) + "Return HANDLE's private immutable selector id view in INDEX." + (when-let* ((record (ebox-source--index-record index handle))) + (ebox-source-record-id record))) + +(defun ebox-source-index-classes (index handle) + "Return HANDLE's detached selector classes in candidate INDEX." + (ebox-source--copy-data (ebox-source--index-classes-view index handle))) + +(defun ebox-source--index-classes-view (index handle) + "Return HANDLE's private immutable selector classes view in INDEX." + (when-let* ((record (ebox-source--index-record index handle))) + (ebox-source-record-classes record))) + +(defun ebox-source-index-attributes (index handle) + "Return HANDLE's detached selector attributes in candidate INDEX." + (ebox-source--copy-data (ebox-source--index-attributes-view index handle))) + +(defun ebox-source--index-attributes-view (index handle) + "Return HANDLE's private immutable selector attributes view in INDEX." + (when-let* ((record (ebox-source--index-record index handle))) + (ebox-source-record-attributes record))) + +(defun ebox-source-index-states (index handle) + "Return HANDLE's detached selector states in candidate INDEX." + (ebox-source--copy-data (ebox-source--index-states-view index handle))) + +(defun ebox-source--index-states-view (index handle) + "Return HANDLE's private immutable selector states view in INDEX." + (when-let* ((record (ebox-source--index-record index handle))) + (ebox-source-record-states record))) + +(defun ebox-source-index-declarations (index handle) + "Return HANDLE's detached canonical declarations from candidate INDEX." + (ebox-source--copy-data + (ebox-source--index-declarations-view index handle))) + +(defun ebox-source--index-declarations-view (index handle) + "Return HANDLE's private immutable declaration view in INDEX." + (when-let* ((record (ebox-source--index-record index handle))) + (ebox-source-record-declarations record))) + +(defun ebox-source-index-provenance (index handle) + "Return HANDLE's detached provenance in candidate INDEX." + (ebox-source--copy-data (ebox-source--index-provenance-view index handle))) + +(defun ebox-source--index-provenance-view (index handle) + "Return HANDLE's private immutable provenance view in INDEX." + (when-let* ((record (ebox-source--index-record index handle))) + (ebox-source-record-provenance record))) + +(defun ebox-source--copy-record (record) + "Return a detached copy of source RECORD." + (and record + (ebox-source--make-record + :key (ebox-source--copy-data (ebox-source-record-key record)) + :id (ebox-source--copy-data (ebox-source-record-id record)) + :classes (ebox-source--copy-data + (ebox-source-record-classes record)) + :attributes (ebox-source--copy-data + (ebox-source-record-attributes record)) + :states (ebox-source--copy-data (ebox-source-record-states record)) + :declarations (ebox-source--copy-data + (ebox-source-record-declarations record)) + :provenance (ebox-source--copy-data + (ebox-source-record-provenance record))))) + +(defun ebox-source-index-record (index handle) + "Return a detached source record for HANDLE in INDEX, or nil." + (ebox-source--copy-record (ebox-source--index-record index handle))) + +(defun ebox-source--index-subject (index handle) + "Return INDEX-owned ECSS subject for HANDLE, or nil." + (unless (ebox-source-index-p index) + (signal 'wrong-type-argument (list 'ebox-source-index-p index))) + (ebox-source--table-value + (ebox-source--index-subjects index) + (ebox-source--handle-id-view handle))) + +(defun ebox-source--index-node-subject (index node) + "Return INDEX-owned ECSS subject for canonical NODE, or nil. +Node copies retain the same opaque source handle, so subject lookup must not +depend on the identity of a particular plist object." + (unless (ebox-source-index-p index) + (signal 'wrong-type-argument (list 'ebox-source-index-p index))) + (when-let* ((handle (and (listp node) + (plist-get node :ebox-source-handle)))) + (ebox-source--table-value + (ebox-source--index-node-subjects index) handle))) + +(defun ebox-source--subject-table-get (table handle) + "Return exact HANDLE's subject from immutable TABLE." + (ebox-source--table-value table handle)) + +(defun ebox-source--index-node-subject-table-view (index) + "Return INDEX's immutable source-handle to ECSS-subject table." + (unless (ebox-source-index-p index) + (signal 'wrong-type-argument (list 'ebox-source-index-p index))) + (ebox-source--index-node-subjects index)) + +(defun ebox-source--index-entries-view (index) + "Return INDEX's immutable document-order subject entries." + (unless (ebox-source-index-p index) + (signal 'wrong-type-argument (list 'ebox-source-index-p index))) + (ebox-source--index-entries index)) + +(defun ebox-source--index-root-subject-view (index) + "Return INDEX's ECSS root subject." + (unless (ebox-source-index-p index) + (signal 'wrong-type-argument (list 'ebox-source-index-p index))) + (ebox-source--index-root-subject index)) + +(defun ebox-source--index-host-ref-table-view (index) + "Return INDEX's immutable host-reference table." + (unless (ebox-source-index-p index) + (signal 'wrong-type-argument (list 'ebox-source-index-p index))) + (ebox-source--index-host-ref-table index)) + +(defun ebox-source--index-selector-table (index kind) + "Return INDEX's derived selector table of KIND." + (unless (ebox-source-index-p index) + (signal 'wrong-type-argument (list 'ebox-source-index-p index))) + (pcase kind + ('id (ebox-source--index-selector-id-table index)) + ('class (ebox-source--index-selector-class-table index)) + ('type (ebox-source--index-selector-type-table index)) + (_ (error "Unknown Ebox source selector index kind: %S" kind)))) + +(provide 'ebox-source) +;;; ebox-source.el ends here diff --git a/ebox-style.el b/ebox-style.el index d1f33a2..6a11c6b 100644 --- a/ebox-style.el +++ b/ebox-style.el @@ -10,6 +10,7 @@ (require 'subr-x) (require 'ecss) (require 'tp-style) +(require 'ebox-source) (require 'ebox-layout-config) (defvar ebox--longhand) @@ -1314,23 +1315,41 @@ SNAPSHOT reuses a previously detached ECSS values snapshot when supplied." "Return non-nil when STYLE PROPERTY has a declaration winner." (ecss-computed-style-specified-p style property)) -(defun ebox-style-node-declarations (node) - "Return NODE's immutable canonical ECSS declarations. -Callers that need to mutate the declaration spine must copy it first." - (let ((declarations (plist-get node :ebox-style-declarations))) +(defun ebox-style--node-declarations-from-record (node record) + "Return NODE's author declarations from borrowed immutable source RECORD." + (let ((declarations + (and record (ebox-source-record-declarations record)))) (if (memq (plist-get node :ebox-type) '(flex grid)) (ebox-style-merge-declarations declarations (plist-get (plist-get node :box) :ebox-style-overrides)) declarations))) -(defun ebox-style-node-specified-value (node name &optional missing) +(defun ebox-style-node-declarations (source-index node) + "Return NODE's immutable author declarations from candidate SOURCE-INDEX. +Private generated layout nodes have no author source and therefore contribute +no declarations of their own." + (ebox-style--node-declarations-from-record + node + (when-let* ((handle (plist-get node :ebox-source-handle))) + (ebox-source--index-record source-index handle)))) + +(defun ebox-style-node-specified-value + (node name &optional missing source-index) "Return NODE's canonical specified value for author property NAME. Return MISSING when NAME is valid but not specified. Aliases resolve to the -same canonical property id, so callers never inspect backend plist fields." +same canonical property id, so callers never inspect backend plist fields. +NODE may be a canonical input with one root. A mounted raw node requires its +explicit generation SOURCE-INDEX; facts are never recovered from its handle." + (when (ebox-canonical-input-p node) + (setq source-index (ebox-canonical-input--source-index node) + node (ebox-canonical-input--single-root + node "ebox-style-node-specified-value"))) + (unless (ebox-source-index-p source-index) + (error "Ebox specified-value lookup requires a source index")) (let ((id (or (ebox-style-schema-id name) (error "Unknown Ebox property: %S" name))) - (declarations (ebox-style-node-declarations node))) + (declarations (ebox-style-node-declarations source-index node))) (if (plist-member declarations id) (plist-get declarations id) missing))) @@ -1583,12 +1602,49 @@ until their private encoding is removed." (setq tail (cddr tail))))) node) +(defconst ebox-style--item-projection-properties + '(:order :flex-grow :flex-shrink :flex-basis :align-self + :grid-column :grid-row :grid-column-span :grid-row-span) + "Canonical node fields owned by computed parent participation style.") + +(defun ebox-style--transfer-node-property (old new property) + "Transfer style-owned PROPERTY presence and value from OLD to NEW." + (if (plist-member old property) + (plist-put new property (plist-get old property)) + (ebox-style--delete-node-property new property))) + +(defun ebox-style--computed-projection-transferable-p (old new style) + "Return non-nil when OLD's STYLE projection is exact for canonical NEW. +Display axes and typed layout config combine style with the fresh node's base +structure. They must already agree; otherwise the ordinary projection path +recomputes that combination from NEW." + (and (memq (plist-get new :ebox-kind) '(text box)) + (eq (plist-get old :ebox-kind) (plist-get new :ebox-kind)) + (eq (plist-get old :ebox-type) (plist-get new :ebox-type)) + (eq style (plist-get old :ebox-computed-style)) + (equal (plist-get old :display) (plist-get new :display)) + (equal (plist-get old :ebox-layout-config) + (plist-get new :ebox-layout-config)))) + +(defun ebox-style--transfer-computed-projection (old new style) + "Transfer OLD's materialized STYLE projection onto fresh canonical NEW. +OLD and NEW must have the same canonical kind and source-stable computed +style. Content, children, source/runtime identity, and scroll state remain +owned by NEW; only fields owned by the unchanged computed style are shared." + (unless (ebox-style--computed-projection-transferable-p old new style) + (error "Ebox computed projection transfer requires source-stable peers")) + (plist-put new :ebox-computed-style style) + (cl-loop for (property _value) on ebox--longhand by #'cddr + unless (memq property '(:content :scroll-offset)) + do (ebox-style--transfer-node-property old new property)) + (dolist (property ebox-style--item-projection-properties) + (ebox-style--transfer-node-property old new property)) + new) + (defun ebox-style--apply-item (node style &optional snapshot) "Apply computed Flex/Grid item fields from STYLE to NODE." (let ((values (ebox-style--context-values style 'item nil snapshot))) - (dolist (property '(:order :flex-grow :flex-shrink :flex-basis :align-self - :grid-column :grid-row - :grid-column-span :grid-row-span)) + (dolist (property ebox-style--item-projection-properties) (ebox-style--delete-node-property node property)) (cl-loop for (property value) on values by #'cddr do (plist-put node property value))) diff --git a/ebox-surface.el b/ebox-surface.el index e51ce61..a83a962 100644 --- a/ebox-surface.el +++ b/ebox-surface.el @@ -527,8 +527,8 @@ continues to own its inline text directly." (or (eq (plist-get node :ebox-kind) 'text) (null (plist-get node :ebox-kind))))) -(defun ebox-surface--inline-inheritance-required-p (root) - "Return non-nil when inline styles in ROOT require an ECSS cascade. +(defun ebox-surface--inline-inheritance-required-p (root source-index) + "Return non-nil when ROOT in SOURCE-INDEX requires an ECSS cascade. An empty stylesheet still needs a cascade when an inherited declaration can reach a descendant that does not declare the same property. Other inline declarations are already projected into the candidate engine fields at node @@ -539,7 +539,8 @@ construction time and can use the static projection path." (let* ((entry (pop pending)) (node (car entry)) (inherited (cdr entry)) - (declarations (ebox-style-node-declarations node))) + (declarations + (ebox-style-node-declarations source-index node))) (when (cl-some (lambda (property) (and (ebox-surface--inherited-style-consumer-p node) (memq property inherited) @@ -1189,6 +1190,11 @@ PREVIOUS-STATE's node-object table." (node-root (tp-object-ensure context surface-root ebox-surface--nodes-key 'ebox/nodes)) + (source-index + (or (plist-get state-overrides :source-index) + (and (eq root (plist-get previous-state :root-node)) + (plist-get previous-state :source-index)) + (ebox-tree-source-index root))) (range-splice-p (and (eq projection-kind 'mixed-owner-reflow) (ebox-surface--mixed-range-splice-p state-overrides))) @@ -1223,16 +1229,29 @@ PREVIOUS-STATE's node-object table." stylesheet-signature))) (bindings-by-subject (make-hash-table :test 'eq)) (states-by-subject (make-hash-table :test 'eq)) - (subjects (when (and style-required-p - (not (and (eq projection-kind 'native-frame) - (plist-get state-overrides - :native-topology-stable-p))) - (not (and (memq projection-kind - '(owner-scoped scroll-patch - formatting-context-reflow - mixed-owner-reflow)) - (not range-splice-p)))) - (ebox-tree-subject-index root))) + (subjects-required-p + (and style-required-p + (not (and (eq projection-kind 'native-frame) + (plist-get state-overrides + :native-topology-stable-p))) + (not (and (memq projection-kind + '(owner-scoped scroll-patch + formatting-context-reflow + mixed-owner-reflow)) + (not range-splice-p))))) + (_materialized-source-index + (when (and subjects-required-p + (ebox-source--index-derived-stale-p source-index)) + ;; A local candidate patches exact handle→subject ancestry in + ;; O(changed) while marking document-order selector views stale. + ;; Style projection consumes that patched table directly; only a + ;; fact-only input with no subjects needs a full tree derivation. + (unless (ebox-source--index-node-subjects source-index) + (setq source-index + (ebox-tree-source-index + root t ebox--render-root-parent-kind source-index))))) + (subjects (when subjects-required-p + (ebox-tree-subject-index root source-index))) (selector-tree-snapshot (if (and (memq projection-kind '(owner-scoped scroll-patch @@ -1258,14 +1277,16 @@ PREVIOUS-STATE's node-object table." (cond ((eq projection-kind 'native-frame) (ebox-surface--project-node-delta - context root previous-state state-overrides node-root objects-by-node + context root source-index previous-state state-overrides + node-root objects-by-node (and subjects (plist-get subjects :node-subject-table)) bindings-by-subject states-by-subject style-state-table stylesheet-signature selector-tree-token stylesheet-subject-local-p)) (range-splice-p (ebox-surface--project-node-delta - context root previous-state state-overrides node-root objects-by-node + context root source-index previous-state state-overrides + node-root objects-by-node (and subjects (plist-get subjects :node-subject-table)) bindings-by-subject states-by-subject style-state-table stylesheet-signature selector-tree-token @@ -1278,7 +1299,7 @@ PREVIOUS-STATE's node-object table." (t (tp-object-retain context node-root) (ebox-surface--ensure-node-tree - context node-root root objects-by-node + context node-root root source-index objects-by-node (and subjects (plist-get subjects :node-subject-table)) bindings-by-subject states-by-subject style-state-table stylesheet-signature @@ -1288,6 +1309,7 @@ PREVIOUS-STATE's node-object table." (ebox-tree-clear-author-style-pending root)) (list :surface-root surface-root :node-root node-root + :source-index source-index :objects-by-node objects-by-node :style-binding-states style-state-table :stylesheet-signature stylesheet-signature @@ -1309,7 +1331,8 @@ PREVIOUS-STATE's node-object table." objects-by-node) (defun ebox-surface--project-node-delta - (context root previous-state state-overrides node-root objects-by-node + (context root source-index previous-state state-overrides + node-root objects-by-node subject-table bindings-by-subject states-by-subject style-state-table stylesheet-signature selector-tree-token stylesheet-subject-local-p) @@ -1333,7 +1356,9 @@ PREVIOUS-STATE's node-object table." (maphash (lambda (node-id node) (when subject-table - (when-let* ((subject (gethash node subject-table)) + (when-let* ((subject + (ebox-source--subject-table-get + subject-table (ebox-tree-node-source-handle node))) (object (gethash node-id old-objects)) (style-state (gethash object style-state-table)) (binding (plist-get style-state :binding))) @@ -1365,7 +1390,8 @@ PREVIOUS-STATE's node-object table." (error "Ebox native projection has an unordered node delta")) (let ((object (tp-object-ensure-at context parent - (ebox-surface--node-key node) + (ebox-surface--node-key + source-index node) (ebox-surface--node-kind node) position))) (tp-object-retain context object) @@ -1373,7 +1399,7 @@ PREVIOUS-STATE's node-object table." (tp-object-reuse context object) (when subject-table (ebox-surface--apply-node-style - object node subject-table bindings-by-subject + source-index object node subject-table bindings-by-subject states-by-subject style-state-table stylesheet-signature selector-tree-token stylesheet-subject-local-p))) (plist-put node :surface-object object) @@ -1389,7 +1415,7 @@ PREVIOUS-STATE's node-object table." objects-by-node)) (defun ebox-surface--complete-projection - (context root style-required-p projection) + (context root source-index style-required-p projection) "Complete a retained PROJECTION for ROOT after a local proof miss. The initial span attempt already owns the surface and node-root identities in CONTEXT, so full fallback must populate those same objects instead of creating @@ -1411,10 +1437,10 @@ the projection roots a second time." (bindings-by-subject (make-hash-table :test 'eq)) (states-by-subject (make-hash-table :test 'eq)) (subjects (when style-required-p - (ebox-tree-subject-index root)))) + (ebox-tree-subject-index root source-index)))) (tp-object-retain context node-root) (ebox-surface--ensure-node-tree - context node-root root objects-by-node + context node-root root source-index objects-by-node (and subjects (plist-get subjects :node-subject-table)) bindings-by-subject states-by-subject style-state-table stylesheet-signature @@ -1884,9 +1910,7 @@ identities while always discarding TP handles and render-cache attachments. VALIDATED-P means the caller owns a current topology certificate for SOURCE." (unless (and (listp source) (not (stringp source))) (error "Ebox surface source must be an Ebox node")) - (unless validated-p - (ebox-tree-validate-declarative-root - source t ebox--render-root-parent-kind)) + (ignore validated-p) (let ((candidate (ebox-tree-copy-node-structure source))) (if preserve-identities-p (ebox-surface--clear-runtime-attachments candidate) @@ -1908,10 +1932,13 @@ published generation so a failed transaction has no cache state to restore." (setq copy (plist-put copy key (copy-hash-table value))))))) copy)) -(defun ebox-surface--reconcile-candidate (root previous-state) - "Reconcile styled ROOT with PREVIOUS-STATE runtime identities." +(defun ebox-surface--reconcile-candidate + (root source-index previous-state) + "Reconcile ROOT identities using SOURCE-INDEX and PREVIOUS-STATE." (when-let* ((previous-root (plist-get previous-state :root-node))) - (ebox-tree-reconcile-runtime previous-root root)) + (ebox-tree-reconcile-runtime + previous-root (plist-get previous-state :source-index) + root source-index)) root) (defun ebox-surface--source-region-id-set (source) @@ -1933,11 +1960,11 @@ published generation so a failed transaction has no cache state to restore." (not (ebox--runtime-region-id-conflict (ebox-surface--source-region-id-set source) buffer)))) -(defun ebox-surface--node-key (node &optional positional-p) - "Return NODE's namespaced sibling key, or nil for positional identity." +(defun ebox-surface--node-key (source-index node &optional positional-p) + "Return NODE's SOURCE-INDEX sibling key, or nil when positional." (when-let* ((key (and (not positional-p) (not (plist-get node :ebox-sequence-location)) - (plist-get node :key)))) + (ebox-tree-node-author-key source-index node)))) (list 'ebox/key key))) (defun ebox-surface--node-kind (node) @@ -1945,14 +1972,16 @@ published generation so a failed transaction has no cache state to restore." (list 'ebox/node (plist-get node :ebox-type))) (defun ebox-surface--node-style-binding - (object node subject bindings-by-subject states-by-subject style-state-table + (source-index object node subject bindings-by-subject states-by-subject + style-state-table stylesheet-signature selector-tree-token stylesheet-subject-local-p) "Install and return NODE style binding on OBJECT for SUBJECT." (let* ((parent (ecss-subject-parent subject)) (parent-binding (and parent (gethash parent bindings-by-subject))) (parent-style-state (and parent (gethash parent states-by-subject))) - (declarations (ebox-style-node-declarations node)) + (declarations + (ebox-tree-node-source-declarations source-index node)) (old-state (gethash object style-state-table)) (old-binding (and old-state (plist-get old-state :binding))) (selector-context-stable-p @@ -2113,9 +2142,10 @@ published generation so a failed transaction has no cache state to restore." (puthash key style ebox-surface--inline-style-value-cache) style))))) -(defun ebox-surface--closed-canonical-node-p (node) - "Return non-nil when NODE needs only closed inherited style projection." - (let ((declarations (ebox-style-node-declarations node))) +(defun ebox-surface--closed-canonical-node-p (source-index node) + "Return non-nil when NODE in SOURCE-INDEX needs closed inheritance." + (let ((declarations + (ebox-tree-node-source-declarations source-index node))) (and (eq (plist-get node :ebox-type) 'box) (memq (plist-get node :ebox-kind) '(box text)) (not (ebox-style-cascade-active-p)) @@ -2132,7 +2162,7 @@ the unpublished node and parent tables. Unchanged declaration/subject chains reuse their exact prior style; changed nodes compute once against the prepared candidate parent style. Stylesheet rules deliberately disable this local path because selector scope may extend outside NODE-IDS. Return the prepared -node-to-subject table when the requested local preparation was completed." +source-handle-to-subject table when the requested local preparation completed." (let ((stylesheet-signature (ebox-surface--stylesheet-signature))) (when (and node-ids (ebox-surface--stylesheet-subject-local-p @@ -2140,10 +2170,12 @@ node-to-subject table when the requested local preparation was completed." (let ((old-nodes (plist-get old-state :node-table)) (new-nodes (plist-get candidate-index :node-table)) (parents (plist-get candidate-index :parent-table)) + (old-source-index (plist-get old-state :source-index)) + (new-source-index (plist-get candidate-index :source-index)) (resolved (make-hash-table :test 'equal)) (visiting (make-hash-table :test 'equal)) (subjects-by-id (make-hash-table :test 'equal)) - (subjects-by-node (make-hash-table :test 'eq)) + (subjects-by-handle (make-hash-table :test 'eq)) (ebox-surface--inline-style-value-cache (make-hash-table :test 'equal))) (cl-labels @@ -2176,19 +2208,25 @@ node-to-subject table when the requested local preparation was completed." (and old-node (plist-get old-node :ebox-computed-style))) (declarations - (and node (ebox-style-node-declarations node))) + (and node + (ebox-tree-node-source-declarations + new-source-index node))) (old-declarations (and old-node - (ebox-style-node-declarations old-node))) + (ebox-tree-node-source-declarations + old-source-index old-node))) (subject (and node (or (gethash node-id subjects-by-id) (let ((created - (ebox-tree-node-subject node))) + (ebox-tree-node-subject + new-source-index node))) (setf (ecss-subject-parent created) parent-subject) (puthash node-id created subjects-by-id) - (puthash node created subjects-by-node) + (puthash + (ebox-tree-node-source-handle node) + created subjects-by-handle) created)))) (old-subject (and old-node @@ -2203,7 +2241,8 @@ node-to-subject table when the requested local preparation was completed." old-state :style-binding-states)))) (plist-get state :subject)) - (ebox-tree-node-subject old-node)))) + (ebox-tree-node-subject + old-source-index old-node)))) (source-stable-p (and old-node (equal parent-id old-parent-id) @@ -2222,9 +2261,14 @@ node-to-subject table when the requested local preparation was completed." parent-style))))) (when (and node style (not (eq node old-node))) (cond - (source-stable-p) + ((and source-stable-p + (ebox-style--computed-projection-transferable-p + old-node node style)) + (ebox-style--transfer-computed-projection + old-node node style)) ((and (null (plist-get node :ebox-computed-style)) - (ebox-surface--closed-canonical-node-p node)) + (ebox-surface--closed-canonical-node-p + new-source-index node)) (ebox-style-apply-closed-inheritance node style)) (t (ebox-style-apply-computed node style))) @@ -2236,11 +2280,12 @@ node-to-subject table when the requested local preparation was completed." (dolist (node-id node-ids) (when (gethash node-id new-nodes) (resolve node-id)))) - subjects-by-node)))) + subjects-by-handle)))) -(defun ebox-surface--node-style-binding-required-p (node) - "Return non-nil when selector-free NODE needs its own style binding." - (let ((declarations (ebox-style-node-declarations node))) +(defun ebox-surface--node-style-binding-required-p (source-index node) + "Return non-nil when NODE in SOURCE-INDEX needs its own style binding." + (let ((declarations + (ebox-tree-node-source-declarations source-index node))) (or (ebox-surface--style-consuming-node-p node) (cl-some (lambda (property) (plist-member declarations property)) @@ -2250,14 +2295,17 @@ node-to-subject table when the requested local preparation was completed." (string-prefix-p "--" (symbol-name property))))))) (defun ebox-surface--apply-node-style - (object node subject-table bindings-by-subject states-by-subject + (source-index object node subject-table bindings-by-subject states-by-subject style-state-table stylesheet-signature selector-tree-token stylesheet-subject-local-p) "Compute and apply NODE style using retained OBJECT and SUBJECT-TABLE." - (when-let* ((subject (gethash node subject-table))) + (when-let* ((subject + (ebox-source--subject-table-get + subject-table (ebox-tree-node-source-handle node)))) (if (and (hash-table-p ebox-surface--inline-style-value-cache) - (not (ebox-surface--node-style-binding-required-p node))) + (not (ebox-surface--node-style-binding-required-p + source-index node))) (when-let* ((parent (ecss-subject-parent subject)) (binding (gethash parent bindings-by-subject))) (puthash subject binding bindings-by-subject) @@ -2269,13 +2317,15 @@ node-to-subject table when the requested local preparation was completed." (let ((style (tp-binding-read (ebox-surface--node-style-binding - object node subject bindings-by-subject states-by-subject + source-index object node subject + bindings-by-subject states-by-subject style-state-table stylesheet-signature selector-tree-token stylesheet-subject-local-p)))) (unless (equal style (plist-get node :ebox-computed-style)) (if (and (null (plist-get node :ebox-computed-style)) - (ebox-surface--closed-canonical-node-p node)) + (ebox-surface--closed-canonical-node-p + source-index node)) (ebox-style-apply-closed-inheritance node style) (ebox-style-apply-computed node style))))))) @@ -2309,6 +2359,7 @@ dependencies, or any malformed index conservatively preserves root planning." (let ((objects (plist-get state :surface-node-object-table)) (style-states (plist-get state :style-binding-states)) (candidate-nodes (plist-get candidate-state :node-table)) + (candidate-source-index (plist-get candidate-state :source-index)) (stylesheet-signature (let* ((cache ebox-incremental--allocated-slot-proof-cache) (missing (make-symbol "ebox-stylesheet-proof-missing")) @@ -2337,7 +2388,8 @@ dependencies, or any malformed index conservatively preserves root planning." (plist-get style-state :subject))) (candidate-declarations (and candidate-node - (ebox-style-node-declarations candidate-node))) + (ebox-tree-node-source-declarations + candidate-source-index candidate-node))) (locally-computed-p (and candidate-node (plist-get candidate-node @@ -2355,9 +2407,11 @@ dependencies, or any malformed index conservatively preserves root planning." (ebox-style--paint-declarations-equivalent-p (plist-get style-state :declarations) candidate-declarations)) - (equal (ebox-tree-node-id candidate-node) + (equal (ebox-tree-node-id + candidate-source-index candidate-node) (ecss-subject-id subject)) - (equal (ebox-tree-node-classes candidate-node) + (equal (ebox-tree-node-classes + candidate-source-index candidate-node) (ecss-subject-classes subject)) (equal (ebox-tree-metadata-string (ebox-tree-node-selector-type candidate-node)) @@ -2368,9 +2422,11 @@ dependencies, or any malformed index conservatively preserves root planning." (cons (substring (symbol-name (car attribute)) 1) (cdr attribute))) - (ebox-tree-node-attributes candidate-node)) + (ebox-tree-node-attributes + candidate-source-index candidate-node)) (ecss-subject-attributes subject)) - (equal (ebox-tree-node-state candidate-node) + (equal (ebox-tree-node-state + candidate-source-index candidate-node) (ecss-subject-states subject)))) (equal stylesheet-signature (plist-get style-state :stylesheet-signature)) @@ -2378,7 +2434,7 @@ dependencies, or any malformed index conservatively preserves root planning." dirty-set)))) (defun ebox-surface--ensure-node-tree - (context parent node table subject-table bindings-by-subject + (context parent node source-index table subject-table bindings-by-subject states-by-subject style-state-table stylesheet-signature selector-tree-token stylesheet-subject-local-p &optional parent-kind positional-p) @@ -2386,14 +2442,16 @@ dependencies, or any malformed index conservatively preserves root planning." (ebox--ensure-node-id node) (let ((object (tp-object-ensure context parent - (ebox-surface--node-key node positional-p) + (ebox-surface--node-key + source-index node positional-p) (ebox-surface--node-kind node)))) (tp-object-retain context object) (plist-put node :surface-object object) (puthash node object table) (when subject-table (ebox-surface--apply-node-style - object node subject-table bindings-by-subject states-by-subject + source-index object node subject-table + bindings-by-subject states-by-subject style-state-table stylesheet-signature selector-tree-token stylesheet-subject-local-p)) @@ -2402,7 +2460,8 @@ dependencies, or any malformed index conservatively preserves root planning." node (lambda (child) (ebox-surface--ensure-node-tree - context object child table subject-table bindings-by-subject + context object child source-index table subject-table + bindings-by-subject states-by-subject style-state-table stylesheet-signature selector-tree-token stylesheet-subject-local-p child-kind (and (plist-get node :ebox-child-sequence) t))))) @@ -3610,16 +3669,27 @@ box with wrapper chrome falls back to the retained wrapper renderer." (hash-table-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 (plist-get state :host-ref-table)) + (hash-table-p + (and (plist-get state :source-index) + (ebox-source--index-host-ref-table-view + (plist-get state :source-index)))) (hash-table-p (plist-get state :range-ref-table)) (vectorp (plist-get state :native-node-postorder))))) (defun ebox-surface--finish-runtime-state (state) "Install or retain the prepared runtime indexes in candidate STATE." (unless (ebox-surface--runtime-index-ready-p state) - (ebox--render-state-install-index - state (ebox--runtime-index (plist-get state :root-node) t)) - (plist-put state :runtime-index-prepared-p t)) + (setq state + (ebox--render-state-install-index + state + (ebox--runtime-index + (plist-get state :root-node) t (plist-get state :source-index)))) + (setq state (plist-put state :runtime-index-prepared-p t))) + (setq state + (plist-put state :selector-index-stale-p + (and (plist-get state :source-index) + (ebox-source--index-derived-stale-p + (plist-get state :source-index))))) state) (defun ebox-surface--role-ids-from-properties (properties) @@ -4872,9 +4942,28 @@ published runtime and must be consumed without clearing those shared nodes." (not source-path-copied-p)) (ebox-surface--isolated-viewport-overrides state-overrides) state-overrides)) + (source-base-index + (plist-get state-overrides :source-base-index)) + (source-index + (or (plist-get state-overrides :source-index) + (and source-base-index + (ebox-tree-source-index + root t ebox--render-root-parent-kind + source-base-index)) + (and (eq root (plist-get previous-state :root-node)) + (plist-get previous-state :source-index)) + (ebox-tree-source-index + root t ebox--render-root-parent-kind))) + (_source-index + (progn + (setq state-overrides (copy-sequence state-overrides)) + (cl-remf state-overrides :source-base-index) + (setq state-overrides + (plist-put state-overrides :source-index source-index)))) (inline-inheritance-required-p (unless (or scroll-fast-p retained-runtime-source-p) - (ebox-surface--inline-inheritance-required-p root))) + (ebox-surface--inline-inheritance-required-p + root source-index))) (author-style-pending-p (> (ebox-tree-author-style-count root) 0)) (cascade-required-p @@ -4893,9 +4982,13 @@ published runtime and must be consumed without clearing those shared nodes." (ebox-surface--projection-start context root style-required-p projection-kind previous-state state-overrides))) + (setq source-index (plist-get projection :source-index) + state-overrides + (plist-put state-overrides :source-index source-index)) (unless (or source-isolated-p source-path-copied-p retained-runtime-source-p) - (ebox-surface--reconcile-candidate root previous-state)) + (ebox-surface--reconcile-candidate + root source-index previous-state)) (let* ((context-axis-facts (and signals @@ -4963,6 +5056,7 @@ published runtime and must be consumed without clearing those shared nodes." root previous-state state-overrides projection-kind stylesheet-active-p cascade-required-p axes viewport-width viewport-height display-signature scroll-offsets))) + (plist-put state :source-index source-index) (when reuse-context-axes-p (plist-put state :viewport-dependent-node-ids (plist-get context-axis-facts @@ -5040,13 +5134,15 @@ published runtime and must be consumed without clearing those shared nodes." span-output (setq projection (ebox-surface--complete-projection - context root style-required-p projection)) + context root source-index + style-required-p projection)) (setq state (ebox-surface--projection-state root previous-state state-overrides nil stylesheet-active-p cascade-required-p axes viewport-width viewport-height display-signature scroll-offsets)) + (plist-put state :source-index source-index) (plist-put state :style-binding-states (plist-get projection :style-binding-states)) (plist-put state :selector-tree-snapshot diff --git a/ebox-tree.el b/ebox-tree.el index c3295d1..0a4b9b4 100644 --- a/ebox-tree.el +++ b/ebox-tree.el @@ -11,8 +11,12 @@ (require 'subr-x) (require 'ecss-selector) +(require 'ebox-source) (require 'ebox-style) (require 'ebox-child-range) + +(defvar ebox-tree--incoming-source-indexes nil + "Transaction-local handle to incoming source-index map for one delta.") (require 'ebox-layout-config) (declare-function ebox-get "ebox" (box property)) @@ -93,10 +97,24 @@ their private props encoding. Callers receive a detached value." ((stringp value) value) (t (format "%s" value)))) -(defun ebox-tree-node-id (node) - "Return NODE's selector id string from `:id', or nil." - (and (listp node) - (ebox-tree-metadata-string (plist-get node :id)))) +(defun ebox-tree-node-source-handle (node) + "Return canonical NODE's opaque source handle, or nil." + (and (listp node) (plist-get node :ebox-source-handle))) + +(defun ebox-tree-node-source-identity (node) + "Return canonical NODE's opaque source identity, or nil." + (when-let* ((handle (ebox-tree-node-source-handle node))) + (ebox-source--handle-id-view handle))) + +(defun ebox-tree-node-id (source-index node) + "Return NODE's selector id string from SOURCE-INDEX, or nil." + (when-let* ((handle (ebox-tree-node-source-handle node))) + (ebox-source--index-id-view source-index handle))) + +(defun ebox-tree-node-source-declarations (source-index node) + "Return NODE's canonical declarations from SOURCE-INDEX, or nil." + (when-let* ((handle (ebox-tree-node-source-handle node))) + (ebox-source--index-declarations-view source-index handle))) (defun ebox-tree--metadata-tokens (value) "Return VALUE as a list of normalized selector metadata tokens." @@ -106,20 +124,25 @@ their private props encoding. Callers receive a detached value." (delq nil (mapcar #'ebox-tree-metadata-string value))) (t (list (ebox-tree-metadata-string value))))) -(defun ebox-tree-node-classes (node) - "Return NODE's selector class strings from `:class'." - (and (listp node) - (ebox-tree--metadata-tokens (plist-get node :class)))) +(defun ebox-tree-node-classes (source-index node) + "Return NODE's selector class strings from SOURCE-INDEX." + (when-let* ((handle (ebox-tree-node-source-handle node))) + (ebox-source--index-classes-view source-index handle))) -(defun ebox-tree-node-state (node) - "Return NODE's selector state tokens from `:selector-state'." - (and (listp node) - (ebox-tree--metadata-tokens (plist-get node :selector-state)))) +(defun ebox-tree-node-state (source-index node) + "Return NODE's selector state tokens from SOURCE-INDEX." + (when-let* ((handle (ebox-tree-node-source-handle node))) + (ebox-source--index-states-view source-index handle))) -(defun ebox-tree-node-key (node) - "Return NODE's selector key string from `:key', or nil." - (and (listp node) - (ebox-tree-metadata-string (plist-get node :key)))) +(defun ebox-tree-node-author-key (source-index node) + "Return NODE's raw author key from SOURCE-INDEX." + (when-let* ((handle (ebox-tree-node-source-handle node))) + (ebox-source--index-key-view source-index handle))) + +(defun ebox-tree-node-key (source-index node) + "Return NODE's selector key string from SOURCE-INDEX, or nil." + (ebox-tree-metadata-string + (ebox-tree-node-author-key source-index node))) (defun ebox-tree--selector-attribute-key (key) "Return canonical keyword selector attribute KEY." @@ -138,19 +161,18 @@ their private props encoding. Callers receive a detached value." ((numberp value) (number-to-string value)) (t (copy-tree value)))) -(defun ebox-tree-node-attributes (node) - "Return NODE's explicit selector attributes plus built-in id and key." +(defun ebox-tree-node-attributes (source-index node) + "Return NODE's selector attributes from SOURCE-INDEX plus id and key." (when (listp node) (let ((attributes (delq nil - (list (when-let* ((id (ebox-tree-node-id node))) + (list (when-let* ((id (ebox-tree-node-id source-index node))) (cons :id id)) - (when-let* ((key (ebox-tree-node-key node))) + (when-let* ((key (ebox-tree-node-key source-index node))) (cons :key key)))))) - (dolist (attribute (plist-get node :selector-attributes)) - (unless (consp attribute) - (error "Ebox selector attributes must be an alist: %S" - (plist-get node :selector-attributes))) + (dolist (attribute + (when-let* ((handle (ebox-tree-node-source-handle node))) + (ebox-source--index-attributes-view source-index handle))) (push (cons (ebox-tree--selector-attribute-key (car attribute)) (ebox-tree--selector-attribute-value (cdr attribute))) attributes)) @@ -345,7 +367,8 @@ NODE must already be a shallow copy. Return the possibly updated plist." (plist-put node property replacement))) node)) -(defun ebox-tree-copy-with-direct-child-replacements (node replacements) +(defun ebox-tree-copy-with-direct-child-replacements + (node replacements &optional key-function) "Return a shallow copy of NODE with direct child REPLACEMENTS applied. REPLACEMENTS is an alist of (OLD-CHILD . NEW-CHILD) pairs. OLD-CHILD is @@ -391,25 +414,7 @@ still copied while every nested value remains shared." (dolist (replacement replacements) (let* ((old (car replacement)) (new (cdr replacement)) (location (and old (plist-get old :ebox-sequence-location))) - (key (and old (plist-get old :key))) - (key-location - (and (null location) key - (ebox-child-range--hash-lookup - (ebox-child-range--sequence-key-root updated) key - (funcall - (ebox-child-range--sequence-hash-function updated) - key))))) - (when key-location - (let* ((segment (ebox-child-range--segment-at - updated (car key-location))) - (candidate (aref - (ebox-child-range--segment-payload segment) - (cdr key-location)))) - (when (eq candidate old) - (setq location - (list :parent-node-id (plist-get node :node-id) - :segment-index (car key-location) - :offset (cdr key-location)))))) + old-key) (unless location (unless identity-locations (setq identity-locations (make-hash-table :test #'eq)) @@ -429,6 +434,11 @@ still copied while every nested value remains shared." (when (and location (equal (plist-get location :parent-node-id) (plist-get node :node-id))) + (let* ((segment + (ebox-child-range--segment-at + updated (plist-get location :segment-index))) + (keys (ebox-child-range--segment-keys segment))) + (setq old-key (aref keys (plist-get location :offset)))) (cl-incf style-delta (- (ebox-tree-author-style-count new) (ebox-tree-author-style-count old))) @@ -436,22 +446,20 @@ still copied while every nested value remains shared." (setq updated (ebox-child-range--replace-item-at updated (plist-get location :segment-index) - (plist-get location :offset) old new) + (plist-get location :offset) old new + (if key-function + (funcall key-function new) + (if (or (null old-key) + (equal + (ebox-tree-node-source-identity old) + (ebox-tree-node-source-identity new))) + old-key + (error + "Ebox Range replacement requires source key facts")))) changed t) (setq fallback t))))) (when fallback - (let (segments) - (dotimes (index (ebox-child-range--sequence-count sequence)) - (let* ((segment (ebox-child-range--segment-at sequence index)) - (items (append - (ebox-child-range--segment-payload segment) nil))) - (push (cons (ebox-child-range--segment-ref segment) - (ebox-tree--replace-direct-child-list - items replacements)) - segments))) - (setq updated (ebox-child-range--build - (nreverse segments) nil t) - changed t))) + (error "Ebox Range item removal requires a Range transaction")) (when changed (setq copy (if (plist-member copy :children) @@ -469,7 +477,9 @@ still copied while every nested value remains shared." The returned tree owns distinct node plists and child lists. Values such as keymaps, callbacks, Host references, and surface-property payloads retain their original object identity." - (cl-labels + (let ((seen (make-hash-table :test #'eq)) + (active (make-hash-table :test #'eq))) + (cl-labels ((without (plist key) (cl-loop for (property value) on plist by #'cddr unless (eq property key) append (list property value))) @@ -477,6 +487,13 @@ original object identity." (node) (if (or (not (listp node)) (stringp node)) node + (when (gethash node active) + (error "Ebox declarative tree contains a cycle")) + (when (gethash node seen) + (error "Ebox declarative tree reuses one node object")) + (puthash node t seen) + (puthash node t active) + (prog1 (let* ((type (plist-get node :ebox-type)) (material-p (or (memq type '(concat stack flex grid)) (plist-get node :ebox-child-sequence))) @@ -485,12 +502,16 @@ original object identity." (plist-get node :ebox-child-sequence))) (has-range (cl-some #'ebox-child-range--descriptor-p raw))) (if (or has-range sequence) - (let ((copy (copy-sequence node)) segments) + (let ((copy (copy-sequence node)) segments segment-keys) + (when has-range + (error "Ebox declarative Range must be normalized before copy")) (if sequence (dotimes (index (ebox-child-range--sequence-count sequence)) (let ((segment (ebox-child-range--segment-at sequence index))) + (push (ebox-child-range--segment-keys segment) + segment-keys) (push (cons (ebox-child-range--segment-ref segment) (mapcar #'copy-node @@ -507,7 +528,8 @@ original object identity." (ebox-child-range--descriptor-items entry))) segments) (push (cons nil (list (copy-node entry))) segments)))) - (setq segments (nreverse segments)) + (setq segments (nreverse segments) + segment-keys (nreverse segment-keys)) (pcase type ((or 'flex 'grid) (when-let* ((box (plist-get node :box))) @@ -518,7 +540,8 @@ original object identity." (setq copy (without (without copy :top) :bottom)))) (ebox-tree--put-child-sequence copy - (ebox-child-range--build segments nil t) + (ebox-child-range--build + segments nil nil t segment-keys) (cl-mapcan (lambda (segment) (copy-sequence (cdr segment))) segments))) @@ -528,8 +551,9 @@ original object identity." (mapcar (lambda (child) (cons child (copy-node child))) (ebox-tree--children-raw node))))) - copy)))))) - (copy-node root))) + copy))) + (remhash node active))))) + (copy-node root)))) (defun ebox-tree--runtime-identity-node-shell (node node-id-set region-id-set) @@ -542,8 +566,8 @@ Record its identities in NODE-ID-SET and REGION-ID-SET." (error "Ebox runtime identity snapshot found a node without identity")) (puthash node-id t node-id-set) (setq shell (list :ebox-type type :node-id node-id)) - (when (plist-member node :key) - (setq shell (plist-put shell :key (plist-get node :key)))) + (when-let* ((handle (ebox-tree-node-source-handle node))) + (setq shell (plist-put shell :ebox-source-handle handle))) (when (eq type 'box) (let ((region-id (plist-get node :region-id))) (unless region-id @@ -617,33 +641,12 @@ Renderable child nodes are assigned stable runtime ids before returning." (ebox--ensure-node-id child))) children)) -(defun ebox-tree-validate-host-refs (root) - "Reject duplicate non-nil `:host-ref' values below ROOT and return ROOT. -Host references are root-global opaque metadata and are compared with `equal'." - (let ((host-refs (make-hash-table :test 'equal)) - (seen (make-hash-table :test 'eq)) - (active (make-hash-table :test 'eq))) - (cl-labels - ((visit (node) - (when (and (listp node) (not (stringp node))) - ;; Let the complete declarative validator report cycles. This - ;; focused preflight is also used by initial buffer mounting, - ;; where it must remain finite for malformed graphs. - (unless (gethash node active) - (when-let* ((host-ref (plist-get node :host-ref))) - (let ((count (hash-table-count host-refs))) - (puthash host-ref t host-refs) - (when (= count (hash-table-count host-refs)) - (error - "Ebox declarative tree uses duplicate host ref %S" - host-ref)))) - (unless (gethash node seen) - (puthash node t seen) - (puthash node t active) - (ebox-tree-for-each-direct-child node #'visit) - (remhash node active)))))) - (visit root)) - root)) +(defun ebox-tree-validate-host-refs (root &optional source-index) + "Validate ROOT's unique opaque source identities and return ROOT. +SOURCE-INDEX owns Host lookup identity. Building it rejects duplicate source +handles, reused node objects, and cycles without consulting node metadata." + (or source-index (ebox-tree-source-index root)) + root) (defconst ebox-tree--common-participation-keys '(:order :align-self) @@ -668,27 +671,31 @@ Host references are root-global opaque metadata and are compared with `equal'." (and style (ebox-style--specified-property-p style (ebox-style-schema-id key)))) -(defun ebox-tree--declarations-specify-participation-p (node key) - "Return non-nil when NODE author declarations specify participation KEY." +(defun ebox-tree--declarations-specify-participation-p + (source-index node key) + "Return non-nil when NODE in SOURCE-INDEX specifies participation KEY." (and (listp node) - (plist-member (ebox-style-node-declarations node) + (plist-member (ebox-style-node-declarations source-index node) (ebox-style-schema-id key)))) -(defun ebox-tree--author-specifies-participation-p (node key) - "Return non-nil when NODE's author source specifies participation KEY." - (or (ebox-tree--declarations-specify-participation-p node key) +(defun ebox-tree--author-specifies-participation-p + (source-index node key) + "Return non-nil when NODE's SOURCE-INDEX specifies participation KEY." + (or (ebox-tree--declarations-specify-participation-p + source-index node key) (and (null (plist-get node :ebox-computed-style)) (plist-member node key)))) -(defun ebox-tree--node-participation-keys (node source) - "Return NODE participation keys from AUTHOR or COMPUTED SOURCE." +(defun ebox-tree--node-participation-keys (source-index node source) + "Return NODE participation keys from SOURCE-INDEX or COMPUTED SOURCE." (let ((style (plist-get node :ebox-computed-style)) keys) (dolist (key ebox-tree--participation-keys) (when (pcase source ('author - (ebox-tree--author-specifies-participation-p node key)) + (ebox-tree--author-specifies-participation-p + source-index node key)) ('computed (and (plist-member node key) (ebox-tree--style-specifies-participation-p style key))) @@ -705,9 +712,11 @@ Host references are root-global opaque metadata and are compared with `equal'." (kind kind)))) (defun ebox-tree--validate-node-parent-participation - (node parent-kind source) + (node parent-kind source &optional source-index) "Validate NODE participation under PARENT-KIND from AUTHOR or COMPUTED SOURCE." - (let ((keys (ebox-tree--node-participation-keys node source))) + (let ((keys + (ebox-tree--node-participation-keys + source-index node source))) (when keys (when (eq (plist-get node :ebox-kind) 'text) (error "Ebox Text cannot carry participation properties: %S" keys)) @@ -734,7 +743,7 @@ Host references are root-global opaque metadata and are compared with `equal'." (and parent (ebox-tree--child-layout-kind parent nil)))) (defun ebox-tree-validate-indexed-participation - (node-table parent-table node-ids &optional source) + (node-table parent-table node-ids &optional source source-index) "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))) @@ -742,92 +751,20 @@ Host references are root-global opaque metadata and are compared with `equal'." (ebox-tree--validate-node-parent-participation node (ebox-tree--indexed-parent-context node-id node-table parent-table) - source))) + source source-index))) node-table) (defun ebox-tree-validate-declarative-root - (root &optional validate-participation-p root-parent-kind) + (root &optional validate-participation-p root-parent-kind source-index) "Validate ROOT for one declarative runtime commit. The runtime tree must be a proper tree: a node object cannot appear in two locations, cycles are rejected, explicit sibling keys must be unique, and non-nil host references must be unique across the root. Keys are sibling-local and all explicit identities are compared with `equal'. Return ROOT on success." - (when (ebox-child-range--descriptor-p root) - (error "Ebox child Range descriptor cannot be the root")) - (ebox-tree-validate-host-refs root) - (let ((seen (make-hash-table :test 'eq)) - (active (make-hash-table :test 'eq)) - (range-refs (make-hash-table :test 'equal))) - (cl-labels - ((visit (node parent-kind) - (when (and (listp node) (not (stringp node))) - (when (gethash node active) - (error "Ebox declarative tree contains a cycle")) - (when (gethash node seen) - (error "Ebox declarative tree reuses one node object")) - (puthash node t seen) - (puthash node t active) - (when validate-participation-p - (ebox-tree--validate-node-parent-participation - node parent-kind 'author)) - (let* ((keys (make-hash-table :test 'equal)) - (type (plist-get node :ebox-type)) - (raw (and (memq type '(box concat stack flex grid)) - (plist-get node :children))) - children) - (when (and raw (not (proper-list-p raw))) - (error "Ebox material children must be a proper list")) - (when-let* ((sequence (plist-get node :ebox-child-sequence))) - (dotimes (index - (ebox-child-range--sequence-count sequence)) - (when-let* ((ref - (ebox-child-range--segment-ref - (ebox-child-range--segment-at sequence index)))) - (when (gethash ref range-refs) - (error "Ebox child Range ref is not unique: %S" ref)) - (puthash ref t range-refs)))) - (if (plist-get node :ebox-child-sequence) - (ebox-tree-for-each-direct-child - node (lambda (child) (push child children))) - (dolist (entry (ebox-tree--children-raw node)) - (if (ebox-child-range--descriptor-p entry) - (progn - (unless (and (proper-list-p raw) (memq entry raw)) - (error "Ebox child Range descriptor is invalid in a scalar slot")) - (let ((ref (ebox-child-range--descriptor-ref entry)) - (items (ebox-child-range--descriptor-items entry))) - (unless (and ref (proper-list-p items)) - (error "Ebox child Range descriptor is invalid")) - (when (gethash ref range-refs) - (error "Ebox child Range ref is not unique: %S" ref)) - (puthash ref t range-refs) - (dolist (item items) - (when (ebox-child-range--descriptor-p item) - (error "Nested child Range descriptors are reserved")) - (unless (and (listp item) (not (stringp item))) - (error "Ebox child Range item must be a node")) - (push item children)))) - (push entry children)))) - (dolist (child (nreverse children)) - (when (and (eq (plist-get node :ebox-kind) 'box) - (not (memq (plist-get child :ebox-kind) - '(text box)))) - (error - "Canonical Ebox Box child must be Text or Box: %S" - child)) - (when-let* ((key (and (listp child) - (not (stringp child)) - (plist-get child :key)))) - (when (gethash key keys) - (error "Ebox declarative siblings use duplicate key %S" - key)) - (puthash key t keys)) - (visit child - (ebox-tree--child-layout-kind node parent-kind))) - (remhash node active))))) - (visit root root-parent-kind)) - root)) + (ebox-tree-source-index + root validate-participation-p root-parent-kind source-index) + root) (defun ebox-tree-clear-runtime-identities (root) "Clear Ebox-owned runtime identity fields below ROOT and return ROOT." @@ -861,6 +798,7 @@ and all explicit identities are compared with `equal'. Return ROOT on success." (append ebox-tree--runtime-source-keys ebox-tree--child-source-keys '(:ebox-style-overrides + :ebox-source-handle :ebox-grid-config :ebox-computed-style :ebox-style-wrapper :ebox-style-generated-wrapper @@ -873,7 +811,7 @@ Computed styles, projection counters, duplicate Text storage, and legacy wrapper overrides are derived side state.") (defconst ebox-tree-metadata-source-keys - '(:key :id :class :host-ref) + '(:key :id :class :selector-attributes :selector-state) "Declarative metadata keys that do not directly change rendered text.") (defun ebox-tree--plist-keys (plist) @@ -1025,13 +963,14 @@ candidate runtime index agree." (plist-put new :node-id (ebox--next-runtime-node-id))) new) -(defun ebox-tree-reconcile-runtime (old new) - "Transfer stable keyed and positional runtime identity from OLD to NEW." +(defun ebox-tree-reconcile-runtime + (old old-source-index new new-source-index) + "Transfer runtime identity from OLD to NEW using their source indexes." (cl-labels ((node-key - (node) - (when (and (listp node) (plist-member node :key)) - (plist-get node :key))) + (source-index node) + (and node + (ebox-tree-node-author-key source-index node))) (same-type-p (old-node new-node) (and (listp old-node) @@ -1039,18 +978,18 @@ candidate runtime index agree." (eq (plist-get old-node :ebox-type) (plist-get new-node :ebox-type)))) (keyed-children - (children) + (source-index children) (let ((table (make-hash-table :test 'equal))) (dolist (child children table) - (when-let* ((key (node-key child))) + (when-let* ((key (node-key source-index child))) (puthash key child table))))) (match-child (old-children old-keyed new-child index) - (if-let* ((key (node-key new-child))) + (if-let* ((key (node-key new-source-index new-child))) (let ((old-child (gethash key old-keyed))) (and (same-type-p old-child new-child) old-child)) (let ((old-child (nth index old-children))) - (and (not (node-key old-child)) + (and (not (node-key old-source-index old-child)) (same-type-p old-child new-child) old-child)))) (reconcile @@ -1060,7 +999,8 @@ candidate runtime index agree." (let* ((old-children (and old-node (ebox-tree--children-raw old-node))) (new-children (ebox-tree--children-raw new-node)) - (old-keyed (keyed-children old-children))) + (old-keyed + (keyed-children old-source-index old-children))) (cl-loop for new-child in new-children for index from 0 do (reconcile @@ -1104,81 +1044,409 @@ candidate runtime index agree." ('grid (ebox-tree-layout-children node)) (_ (ebox-tree-children node)))) -(defun ebox-tree-selector-index (root) - "Return id, class, and type indexes over ROOT's logical selector tree." - (let ((id-table (make-hash-table :test #'equal)) - (class-table (make-hash-table :test #'equal)) - (type-table (make-hash-table :test #'eq))) - (cl-labels - ((prepend (table key entry) - (puthash key (cons entry (gethash key table)) table)) - (visit (node path) - (when (and (listp node) (not (stringp node))) - (let* ((current-path (append path (list node))) - (entry (cons node current-path))) - (when-let* ((id (ebox-tree-node-id node))) - (prepend id-table id entry)) - (dolist (class (ebox-tree-node-classes node)) - (prepend class-table class entry)) - (when-let* ((type (ebox-tree-node-selector-type node))) - (prepend type-table type entry)) - (dolist (child (ebox-tree-semantic-children node)) - (visit child current-path)))))) - (visit root nil)) - (dolist (table (list id-table class-table type-table)) - (maphash (lambda (key entries) - (puthash key (nreverse entries) table)) - table)) - (list :selector-id-table id-table - :selector-class-table class-table - :selector-type-table type-table))) - -(defun ebox-tree-node-subject (node) - "Return a detached ECSS selector subject representing NODE." +(defun ebox-tree--subject-from-record (node record) + "Return one ECSS selector subject for NODE and source RECORD." (ecss-subject-create :type (ebox-tree-metadata-string (ebox-tree-node-selector-type node)) - :id (ebox-tree-node-id node) - :classes (ebox-tree-node-classes node) + :id (ebox-source-record-id record) + :classes (ebox-source-record-classes record) :attributes (mapcar (lambda (attribute) (cons (substring (symbol-name (car attribute)) 1) (cdr attribute))) - (ebox-tree-node-attributes node)) - :states (ebox-tree-node-state node))) + (let ((attributes + (delq + nil + (list + (when-let* ((id (ebox-source-record-id record))) + (cons :id id)) + (when-let* ((key + (ebox-tree-metadata-string + (ebox-source-record-key record)))) + (cons :key key)))))) + (dolist (attribute (ebox-source-record-attributes record)) + (push (cons + (ebox-tree--selector-attribute-key (car attribute)) + (ebox-tree--selector-attribute-value (cdr attribute))) + attributes)) + (nreverse attributes))) + :states (ebox-source-record-states record))) -(defun ebox-tree-subject-index (root) - "Return ECSS subjects and semantic node paths below ROOT in document order." - (let ((node-subject-table (make-hash-table :test #'eq)) +(defun ebox-tree-node-subject (source-index node) + "Return SOURCE-INDEX's retained ECSS selector subject for NODE." + (or (ebox-source--index-node-subject source-index node) + (error "Ebox selector node is absent from its source index"))) + +(defun ebox-tree-source-builder-snapshot (builder roots) + "Return a sealed source-index slice for canonical ROOTS in open BUILDER." + (unless (proper-list-p roots) + (signal 'wrong-type-argument (list 'proper-list-p roots))) + (let ((slice (ebox-source-builder-create)) + (seen (make-hash-table :test #'eq))) + (cl-labels + ((visit (node) + (when (and (listp node) (not (stringp node))) + (when-let* ((handle (ebox-tree-node-source-handle node))) + (unless (gethash handle seen) + (puthash handle t seen) + (ebox-source--builder-add-record + slice handle + (or (ebox-source--builder-record builder handle) + (error "Ebox source builder does not own canonical node"))))) + (ebox-tree-for-each-direct-child node #'visit)))) + (dolist (root roots) (visit root))) + (ebox-source-builder-finish slice))) + +(defun ebox-tree-source-index + (root &optional validate-participation-p root-parent-kind base-source-index + allow-unreferenced-sources-p) + "Build one immutable source and selector index for canonical ROOT. +When VALIDATE-PARTICIPATION-P is non-nil, validate ROOT-PARENT-KIND during the +same traversal. Topology, Range addresses, source identities, and sibling +keys are always validated. BASE-SOURCE-INDEX, when non-nil, is the fact-only +index transported with the canonical input; records are never recovered from +opaque handles on that path." + (when (ebox-child-range--descriptor-p root) + (error "Ebox child Range descriptor cannot be the root")) + (let ((subjects (make-hash-table :test #'equal)) + (node-subjects (make-hash-table :test #'eq)) + (id-table (make-hash-table :test #'equal)) + (class-table (make-hash-table :test #'equal)) + (type-table (make-hash-table :test #'eq)) + (seen (make-hash-table :test #'eq)) + (active (make-hash-table :test #'eq)) + (range-refs (make-hash-table :test #'equal)) + (used-handles (make-hash-table :test #'eq)) entries) (cl-labels - ((visit (node path) + ((prepend (table key entry) + (puthash key (cons entry (gethash key table)) table)) + (record-for (node) + (when-let* ((handle (ebox-tree-node-source-handle node))) + (or (and base-source-index + (ebox-source--index-record base-source-index handle)) + (error "Ebox canonical source is absent from its input index")))) + (record-key (node) + (when-let* ((record (record-for node))) + (ebox-source-record-key record))) + (record-participation-keys (node record) + (let ((declarations (ebox-source-record-declarations record)) keys) + (dolist (key ebox-tree--participation-keys) + (when (or (plist-member declarations (ebox-style-schema-id key)) + (and (null (plist-get node :ebox-computed-style)) + (plist-member node key))) + (push key keys))) + (nreverse keys))) + (validate-participation (node record parent-kind) + (when validate-participation-p + (let ((keys (record-participation-keys node record))) + (when keys + (when (eq (plist-get node :ebox-kind) 'text) + (error "Ebox Text cannot carry participation properties: %S" + keys)) + (pcase parent-kind + ('flex + (when (cl-intersection + keys ebox-tree--grid-participation-keys :test #'eq) + (error + "Grid participation requires a direct Grid parent: %S" + keys))) + ('grid + (when (cl-intersection + keys ebox-tree--flex-participation-keys :test #'eq) + (error + "Flex participation requires a direct Flex parent: %S" + keys))) + (_ + (error + "Ebox participation requires a Flex or Grid parent: %S" + keys))))))) + (material-children (node) + (let* ((type (plist-get node :ebox-type)) + (raw (and (memq type '(box concat stack flex grid)) + (plist-get node :children))) + children) + (when (and raw (not (proper-list-p raw))) + (error "Ebox material children must be a proper list")) + (if-let* ((sequence (plist-get node :ebox-child-sequence))) + (progn + (dotimes (index (ebox-child-range--sequence-count sequence)) + (when-let* ((ref + (ebox-child-range--segment-ref + (ebox-child-range--segment-at + sequence index)))) + (when (gethash ref range-refs) + (error "Ebox child Range ref is not unique: %S" ref)) + (puthash ref t range-refs))) + (ebox-tree-for-each-direct-child + node (lambda (child) (push child children)))) + (dolist (entry (ebox-tree--children-raw node)) + (if (ebox-child-range--descriptor-p entry) + (progn + (unless (and (proper-list-p raw) (memq entry raw)) + (error + "Ebox child Range descriptor is invalid in a scalar slot")) + (let ((ref (ebox-child-range--descriptor-ref entry)) + (items (ebox-child-range--descriptor-items entry))) + (unless (and ref (proper-list-p items)) + (error "Ebox child Range descriptor is invalid")) + (when (gethash ref range-refs) + (error "Ebox child Range ref is not unique: %S" ref)) + (puthash ref t range-refs) + (dolist (item items) + (when (ebox-child-range--descriptor-p item) + (error "Nested child Range descriptors are reserved")) + (unless (and (listp item) (not (stringp item))) + (error "Ebox child Range item must be a node")) + (push item children)))) + (push entry children)))) + (nreverse children))) + (visit-children (node path parent-kind) + (let ((keys (make-hash-table :test #'equal)) result) + (dolist (child (material-children node)) + (when (and (eq (plist-get node :ebox-kind) 'box) + (not (memq (plist-get child :ebox-kind) '(text box)))) + (error "Canonical Ebox Box child must be Text or Box: %S" + child)) + (when-let* ((key (record-key child))) + (when (gethash key keys) + (error "Ebox declarative siblings use duplicate key %S" key)) + (puthash key t keys)) + (setq result + (nconc + result + (visit child path + (ebox-tree--child-layout-kind node parent-kind))))) + result)) + (visit (node path parent-kind) (when (and (listp node) (not (stringp node))) - (let* ((subject (ebox-tree-node-subject node)) + (when (gethash node active) + (error "Ebox declarative tree contains a cycle")) + (when (gethash node seen) + (error "Ebox declarative tree reuses one node object")) + (puthash node t seen) + (puthash node t active) + (let* ((handle (plist-get node :ebox-source-handle)) (current-path (append path (list node)))) - (puthash node subject node-subject-table) - (push (list :node node :path current-path :subject subject) - entries) - (ecss-subject-set-children - subject - (delq nil - (mapcar (lambda (child) (visit child current-path)) - (ebox-tree-semantic-children node)))) - subject)))) - (let ((root-subject (visit root nil))) - (list :root-subject root-subject - :entries (nreverse entries) - :node-subject-table node-subject-table))))) + (if (null handle) + (prog1 + (visit-children node current-path parent-kind) + (remhash node active)) + (let* ((record (record-for node)) + (identity (ebox-source--handle-id-view handle)) + (subject + (ebox-tree--subject-from-record node record)) + (entry (cons node current-path))) + (puthash handle t used-handles) + (validate-participation node record parent-kind) + (when (gethash identity subjects) + (error "Ebox source identity is not unique: %S" identity)) + (puthash identity subject subjects) + (puthash handle subject node-subjects) + (push (list :node node :path current-path :subject subject + :source-handle handle) + entries) + (when-let* ((id (ebox-source-record-id record))) + (prepend id-table id entry)) + (dolist (class (ebox-source-record-classes record)) + (prepend class-table class entry)) + (when-let* ((type (ebox-tree-node-selector-type node))) + (prepend type-table type entry)) + (ecss-subject-set-children + subject (visit-children node current-path parent-kind)) + (remhash node active) + (list subject))))))) + (let* ((root-subjects (visit root nil root-parent-kind)) + (root-subject (car root-subjects))) + (dolist (table (list id-table class-table type-table)) + (maphash (lambda (key values) + (puthash key (nreverse values) table)) + table)) + (when (and base-source-index + (not allow-unreferenced-sources-p) + (/= (hash-table-count used-handles) + (ebox-source-table-count + (ebox-source--index-handle-records + base-source-index)))) + (error "Ebox canonical input contains unreferenced source facts")) + (ebox-source--with-derived + (or base-source-index + (ebox-source-builder-finish (ebox-source-builder-create))) + :subjects subjects :node-subjects node-subjects + :entries (nreverse entries) + :root-subject root-subject + :selector-id-table id-table + :selector-class-table class-table + :selector-type-table type-table))))) -(defun ebox-tree-subject-for-path (path) - "Return an ECSS subject chain for the root-to-node Ebox PATH." - (let (parent subject) - (dolist (node path) - (when (and (listp node) (not (stringp node))) - (setq subject (ebox-tree-node-subject node)) - (when parent - (ecss-subject-set-children parent (list subject))) - (setq parent subject))) - subject)) +(defun ebox-tree-source-handle-order (root) + "Return canonical source handles below ROOT in document preorder." + (let (handles) + (cl-labels + ((visit + (node) + (when-let* ((handle (ebox-tree-node-source-handle node))) + (push handle handles)) + (ebox-tree-for-each-direct-child node #'visit))) + (visit root)) + (nreverse handles))) + +(defun ebox-tree-source-fact-index-delta + (old-index touched removed incoming-source-indexes &optional final-order) + "Return fact-only OLD-INDEX after local TOUCHED and REMOVED changes." + (let ((removed-handles nil) + (added-handles nil) + (replacement-pairs nil) + (removed-seen (make-hash-table :test #'eq)) + (added-seen (make-hash-table :test #'eq))) + (cl-labels + ((remove-handle + (node) + (when-let* ((handle (ebox-tree-node-source-handle node))) + (unless (gethash handle removed-seen) + (puthash handle t removed-seen) + (push handle removed-handles)))) + (add-handle + (node) + (when-let* ((handle (ebox-tree-node-source-handle node))) + (unless (gethash handle added-seen) + (puthash handle t added-seen) + (push handle added-handles))))) + (dolist (node removed) (remove-handle node)) + (dolist (entry touched) + (let* ((old (car entry)) + (new (nth 1 entry)) + (old-handle (and old (ebox-tree-node-source-handle old))) + (new-handle (ebox-tree-node-source-handle new))) + (unless (eq old-handle new-handle) + (when old (remove-handle old)) + (add-handle new) + (when (and old-handle new-handle) + (push (cons old-handle new-handle) replacement-pairs))))) + (ebox-source--index-replace-handles + old-index (nreverse removed-handles) + (nreverse added-handles) incoming-source-indexes + (nreverse replacement-pairs) final-order)))) + +(defun ebox-tree-source-index-delta + (old-index touched removed node-table parent-table root) + "Return a candidate source index from OLD-INDEX and local tree changes. +TOUCHED entries are `(OLD NEW PARENT-ID CHILDREN-CHANGED)'. REMOVED contains +retired nodes. NODE-TABLE and PARENT-TABLE describe the final candidate. +Records update in O(changed); selector/document-order views remain explicitly +stale until a query or full projection materializes them." + (let* ((structural-p + (or removed + (cl-some (lambda (entry) + (or (null (car entry)) (nth 3 entry))) + touched))) + (final-order + (and structural-p (ebox-tree-source-handle-order root))) + (index + (ebox-tree-source-fact-index-delta + old-index touched removed ebox-tree--incoming-source-indexes + final-order)) + (base-subjects + (or (ebox-source--index-subjects old-index) + (ebox-source--flat-table + (make-hash-table :test #'equal) #'equal))) + (base-node-subjects + (or (ebox-source--index-node-subjects old-index) + (ebox-source--flat-table + (make-hash-table :test #'eq) #'eq))) + (removed-identities (make-hash-table :test #'equal)) + (removed-handles (make-hash-table :test #'eq)) + (added-subjects (make-hash-table :test #'equal)) + (added-node-subjects (make-hash-table :test #'eq))) + (cl-labels + ((remove-node + (node) + (when-let* ((handle (ebox-tree-node-source-handle node))) + (puthash handle t removed-handles) + (puthash (ebox-source--handle-id-view handle) t + removed-identities))) + (node-subject + (handle) + (or (gethash handle added-node-subjects) + (and (not (gethash handle removed-handles)) + (ebox-source--table-value + base-node-subjects handle))))) + (dolist (node removed) (remove-node node)) + (dolist (entry touched) + (when-let* ((old (car entry))) (remove-node old))) + ;; TOUCHED is preorder, so a copied parent subject is installed before + ;; its child and local inheritance can bind without a tree scan. + (dolist (entry touched) + (let* ((node (nth 1 entry)) + (handle (ebox-tree-node-source-handle node))) + (when handle + (let* ((record (or (ebox-source--index-record index handle) + (error "Ebox candidate source is absent"))) + (subject (ebox-tree--subject-from-record node record)) + (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-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)))) + (setf (ecss-subject-parent subject) parent-subject) + (puthash (ebox-source--handle-id-view handle) + subject added-subjects) + (puthash handle subject added-node-subjects))))) + (let* ((subjects + (ebox-source--table-overlay + base-subjects added-subjects removed-identities + (+ (- (ebox-source--table-size base-subjects) + (hash-table-count removed-identities)) + (hash-table-count added-subjects)))) + (node-subjects + (ebox-source--table-overlay + base-node-subjects added-node-subjects removed-handles + (+ (- (ebox-source--table-size base-node-subjects) + (hash-table-count removed-handles)) + (hash-table-count added-node-subjects))))) + (ebox-source--with-derived + index + :subjects subjects :node-subjects node-subjects + :entries (ebox-source--index-entries old-index) + :root-subject + (when-let* ((root-handle (ebox-tree-node-source-handle root))) + (ebox-source--table-value node-subjects root-handle)) + :selector-id-table (ebox-source--index-selector-id-table old-index) + :selector-class-table + (ebox-source--index-selector-class-table old-index) + :selector-type-table + (ebox-source--index-selector-type-table old-index) + :derived-stale-p t))))) + +(defun ebox-tree-selector-index (root) + "Return derived selector views over ROOT's unique source index." + (let ((index (ebox-tree-source-index root))) + (list :source-index index + :selector-id-table + (ebox-source--index-selector-table index 'id) + :selector-class-table + (ebox-source--index-selector-table index 'class) + :selector-type-table + (ebox-source--index-selector-table index 'type)))) + +(defun ebox-tree-subject-index (root &optional source-index) + "Return ECSS subject views over ROOT's SOURCE-INDEX. +Build a temporary index only for an unmounted tree query." + (let ((index (or source-index (ebox-tree-source-index root)))) + (list :source-index index + :root-subject (ebox-source--index-root-subject-view index) + :entries (ebox-source--index-entries-view index) + :node-subject-table + (ebox-source--index-node-subject-table-view index)))) + +(defun ebox-tree-subject-for-path (source-index path) + "Return SOURCE-INDEX's retained subject for the final node on PATH." + (when-let* ((node (car (last path)))) + (ebox-tree-node-subject source-index node))) (defun ebox-tree-flex-participation-props (node) "Return raw flex item participation props attached to NODE." diff --git a/ebox.el b/ebox.el index 64c1514..a83ad78 100644 --- a/ebox.el +++ b/ebox.el @@ -23,7 +23,7 @@ "Directory containing the active Ebox Lisp sources.") (defconst ebox--compile-sources - '("ebox-cache.el" "ebox-style.el" "ebox-layout-config.el" + '("ebox-cache.el" "ebox-source.el" "ebox-style.el" "ebox-layout-config.el" "ebox-node-factory.el" "ebox-child-range.el" "ebox-tree.el" "ebox-measure.el" "ebox-fragment.el" "ebox-render-context.el" "ebox-layout.el" @@ -41,6 +41,7 @@ (require 'seq) (require 'subr-x) (require 'ebox-cache) +(require 'ebox-source) (require 'ebox-style) (require 'ebox-layout-config) (require 'ebox-node-factory) @@ -413,20 +414,9 @@ caller, such as the Playground preview, renders against a concrete window.") (plist-get box property)) (defun ebox-put (box property value) - "Set PROPERTY to VALUE in BOX and return the modified box. -Registered style properties also update BOX's canonical ECSS declarations so a -later cascade computes from the public mutation instead of stale source data." - (when-let* ((declaration - (ebox-style-compile-declarations (list property value)))) - (plist-put - box :ebox-style-declarations - (ebox-style-merge-declarations - (plist-get box :ebox-style-declarations) declaration)) - (when (plist-get box :ebox-style-wrapper) - (plist-put - box :ebox-style-overrides - (ebox-style-merge-declarations - (plist-get box :ebox-style-overrides) declaration)))) + "Set private runtime PROPERTY to VALUE in BOX and return BOX. +Author declarations are immutable source-index facts and cannot be mutated +through this low-level plist helper." (plist-put box property value)) ;;;###autoload @@ -677,10 +667,14 @@ ALIGN can be `top', `center', or `bottom'." ;;;###autoload (defun ebox-region-ids (node) "Return NODE's region ids in document order. -NODE can be an unrendered layout tree or an already-rendered string. +NODE may be a canonical Ebox input, an internal layout tree, or a rendered +string. Canonical inputs must contain exactly one root. For layout trees, missing ids are assigned to box plists and remain stable for later renders and dynamic updates." (cond + ((ebox-canonical-input-p node) + (ebox-region-ids + (ebox-canonical-input--single-root node "ebox-region-ids"))) ((stringp node) (ebox--string-region-ids node)) ((not (listp node)) nil) @@ -730,56 +724,6 @@ for later renders and dynamic updates." (ebox--runtime-node-ids node) node) -(defun ebox--runtime-node-key (node) - "Return NODE's explicit runtime key, or nil." - (when (and (listp node) - (plist-member node :key)) - (plist-get node :key))) - -(defun ebox--runtime-same-type-p (old new) - "Return non-nil when OLD and NEW have the same runtime node type." - (and (listp old) - (listp new) - (eq (plist-get old :ebox-type) - (plist-get new :ebox-type)))) - -(defun ebox--runtime-node-id-by-key (node key) - "Return the first runtime node id under NODE whose :key equals KEY." - (when (and key - (listp node) - (not (stringp node))) - (or (when (equal (ebox--runtime-node-key node) key) - (ebox--ensure-node-id node)) - (cl-some (lambda (child) - (ebox--runtime-node-id-by-key child key)) - (ebox--node-children node))))) - -(defun ebox--keyed-runtime-children (children) - "Return a KEY -> child hash table for explicitly keyed CHILDREN." - (let ((table (make-hash-table :test 'equal))) - (dolist (child children) - (when-let* ((key (ebox--runtime-node-key child))) - (puthash key child table))) - table)) - -(defun ebox--runtime-positional-match (old-child new-child) - "Return OLD-CHILD when NEW-CHILD can reuse its positional identity." - (when (and (not (ebox--runtime-node-key old-child)) - (not (ebox--runtime-node-key new-child)) - (ebox--runtime-same-type-p old-child new-child)) - old-child)) - -(defun ebox--runtime-keyed-match (old-keyed new-child) - "Return the keyed old child that should match NEW-CHILD." - (when-let* ((key (ebox--runtime-node-key new-child))) - (let ((old-child (gethash key old-keyed))) - (when (ebox--runtime-same-type-p old-child new-child) - old-child)))) - -(defun ebox--assign-runtime-node-id-from-match (old new) - "Assign NEW retained runtime identity from OLD when possible." - (ebox-tree-transfer-runtime-identity old new)) - (defun ebox--reconcile-runtime-tree (old new) "Transfer stable runtime identities from OLD to matching nodes in NEW. @@ -788,12 +732,14 @@ matches an old sibling with the same key and node type; unkeyed children may reuse same-position ids when their node type is unchanged. Retained boxes also keep their region ids so buffer properties remain aligned with runtime indexes. This prepares component identity without exposing a component API." - (ebox-tree-reconcile-runtime old new)) + (ebox-tree-reconcile-runtime + old (ebox-tree-source-index old) + new (ebox-tree-source-index new))) ;;;###autoload (defun ebox-build (dsl) - "Build an Ebox node from an ETML-style list DSL." - (ebox-dsl-build dsl)) + "Compile an Ebox author form into one opaque canonical input." + (ebox-dsl-build-input dsl)) ;;; ============================================================ ;;; Property Rules: 配置驱动的 Shorthand/Longhand 映射 @@ -3691,6 +3637,14 @@ FALLBACK is the Emacs scroll command to call when ebox cannot scroll." (max ebox-scroll-step (- (window-body-height nil) next-screen-context-lines)))) +(defun ebox--scroll-page-fallback-argument (requested residual arg) + "Return the native page argument for REQUESTED, RESIDUAL, and prefix ARG. +Nil preserves Emacs' native whole-page boundary behavior when no Ebox owner +consumed an unprefixed request. A prefix or partial residual remains an exact +line count so scroll distance is neither duplicated nor dropped." + (and (or arg (/= (abs residual) (abs requested))) + (abs residual))) + (defun ebox--wheel-region-ids (event) "Return ebox scroll candidate region ids under mouse wheel EVENT." (when-let* ((start (ignore-errors (event-start event))) @@ -3814,7 +3768,8 @@ With prefix ARG, scroll by that many content lines." (ebox--scroll-by delta (lambda (n) - (scroll-up-command n))))) + (scroll-up-command + (ebox--scroll-page-fallback-argument delta n arg)))))) ;;;###autoload (defun ebox-scroll-page-up (&optional arg) @@ -3825,7 +3780,8 @@ With prefix ARG, scroll by that many content lines." (ebox--scroll-by delta (lambda (n) - (scroll-down-command n))))) + (scroll-down-command + (ebox--scroll-page-fallback-argument delta n arg)))))) (defun ebox--region-id-at-pos (pos) "Return the owning ebox region-id at POS, if any." @@ -3911,8 +3867,9 @@ Defaults to the current buffer." (when-let* ((text (ebox--region-update-direct-text-node box))) (ebox-text-node-value text))) -(defun ebox--region-update-analysis (box expanded declarations) - "Return change analysis for BOX, EXPANDED fields, and DECLARATIONS." +(defun ebox--region-update-analysis + (source-index box expanded declarations) + "Return change analysis for BOX in SOURCE-INDEX and new DECLARATIONS." (let (content-seen content-value style-changed-p @@ -3936,7 +3893,7 @@ Defaults to the current buffer." (user-error "Ebox content update requires one direct Text child")) (list :changed-p (or (not (equal declarations - (plist-get box :ebox-style-declarations))) + (ebox-style-node-declarations source-index box))) style-changed-p (and content-seen (not (equal (ebox--region-update-content-value box) @@ -3972,17 +3929,24 @@ cannot be changed through the unpublished batch root." (ebox-tree-copy-node-structure (plist-get state :root-node))))) (defun ebox--surface-region-apply-props - (root region-id expanded declarations) + (root source-index region-id expanded declarations) "Apply DECLARATIONS then EXPANDED fields to REGION-ID in ROOT." - (let ((box (ebox--root-region-box root region-id)) changed-keys) + (let ((box (ebox--root-region-box root region-id)) + (candidate-source-index source-index) + changed-keys) (unless box (user-error "Ebox region handle no longer resolves to a box")) (let ((declarations-changed (not (ebox-style-declarations-equal-p declarations - (plist-get box :ebox-style-declarations))))) + (ebox-style-node-declarations source-index box))))) (when declarations-changed - (plist-put box :ebox-style-declarations declarations)) + (let* ((old-handle (ebox-tree-node-source-handle box)) + (binding + (ebox-source-index-rebind + source-index old-handle :declarations declarations))) + (setq candidate-source-index (car binding)) + (plist-put box :ebox-source-handle (cdr binding)))) (cl-loop for (key value) on expanded by #'cddr for target-key = (ebox--region-update-normalize-property key) when (or (eq target-key :content) @@ -4001,7 +3965,8 @@ cannot be changed through the unpublished batch root." "Ebox content update requires one direct Text child")) (plist-put box target-key value)) and do (push target-key changed-keys))) - (nreverse changed-keys))) + (list :changed-keys (nreverse changed-keys) + :source-index candidate-source-index))) (defun ebox--surface-region-no-op-report (state region-id handle) "Return and store a no-op report for STATE, REGION-ID, and HANDLE." @@ -4223,6 +4188,9 @@ REGION-ID and CHANGED-KEYS describe one update; CHANGES describes a batch." (defun ebox--surface-region-update-target (buffer region-id handle props) "Apply PROPS to BUFFER REGION-ID through TP for optional HANDLE." (let* ((state (ebox--buffer-render-state buffer)) + (source-index + (or (ebox-incremental-surface-batch-source-index buffer) + (plist-get state :source-index))) (published-root (plist-get state :root-node)) (candidate-box (or (ebox--root-region-box published-root region-id) @@ -4230,13 +4198,16 @@ REGION-ID and CHANGED-KEYS describe one update; CHANGES describes a batch." (expanded (ebox--expand-plist props)) (declarations (ebox-style-merge-declarations - (plist-get candidate-box :ebox-style-declarations) + (ebox-style-node-declarations + source-index candidate-box) (ebox-style-compile-declarations props))) (old-style-values (and (plist-member expanded :overflow) (list :overflow (ebox-get candidate-box :overflow)))) (analysis - (ebox--region-update-analysis candidate-box expanded declarations))) + (ebox--region-update-analysis + source-index + candidate-box expanded declarations))) (dolist (key (plist-get analysis :unknown-keys)) (message "ebox-region-update: unknown key %S (ignored)" key)) (if (not (plist-get analysis :changed-p)) @@ -4261,28 +4232,33 @@ REGION-ID and CHANGED-KEYS describe one update; CHANGES describes a batch." (candidate-root (ebox--surface-region-candidate-root buffer state region-id content-only-p)) - (changed-keys + (applied (ebox--surface-region-apply-props - candidate-root region-id expanded declarations)) + candidate-root source-index + region-id expanded declarations)) + (changed-keys (plist-get applied :changed-keys)) + (candidate-source-index (plist-get applied :source-index)) (dirty-kind (ebox--region-update-dirty-kind changed-keys)) (constraint-change (ebox--region-constraint-change buffer region-id dirty-kind changed-keys))) - (if (ebox-incremental-record-surface-region-change - buffer candidate-root region-id handle dirty-kind - changed-keys old-style-values + (if (ebox-incremental-record-surface-region-input-change + buffer candidate-root candidate-source-index + region-id handle dirty-kind changed-keys old-style-values #'ebox--publish-surface-region-batch) (progn (ebox--cancel-buffer-runtime-prewarm buffer) nil) - (ebox--publish-scoped-region-candidate - buffer candidate-root region-id changed-keys - (append (if handle - (list :region-handle handle) - (list :region-id region-id)) - (ebox--constraint-change-report-props - constraint-change))))))))) + (let ((ebox-incremental--source-base-index + candidate-source-index)) + (ebox--publish-scoped-region-candidate + buffer candidate-root region-id changed-keys + (append (if handle + (list :region-handle handle) + (list :region-id region-id)) + (ebox--constraint-change-report-props + constraint-change)))))))))) ;;;###autoload (defun ebox-region-update (handle &rest props) @@ -4394,16 +4370,18 @@ Examples: (cons present observer))) ;;;###autoload -(defun ebox-render-to-buffer (buffer-or-name node &optional options) - "Render layout NODE into BUFFER-OR-NAME and return the buffer. -NODE is a typed TextNode/BoxNode or a pre-rendered string. +(defun ebox-render-to-buffer (buffer-or-name input &optional options) + "Render canonical INPUT into BUFFER-OR-NAME and return the buffer. +INPUT atomically carries one typed Text/Box root and its source generation. OPTIONS accepts only `:observer'. Its function receives the buffer and flat TP/Ebox reports after accepted initial publication and later commits." (declare (indent 1)) - (unless (stringp node) - ;; Validate opaque host identities before preparing a target generation. - (ebox-tree-validate-host-refs node)) - (let* ((observer-option (ebox--render-observer-option options)) + (unless (ebox-canonical-input-p input) + (signal 'wrong-type-argument (list 'ebox-canonical-input-p input))) + (let* ((node (ebox-canonical-input--single-root + input "ebox-render-to-buffer")) + (source-base-index (ebox-canonical-input--source-index input)) + (observer-option (ebox--render-observer-option options)) (buffer (get-buffer-create buffer-or-name)) (old-observer (ebox-surface-buffer-observer buffer)) (old-bridge (with-current-buffer buffer ebox-surface--tp-observer)) @@ -4417,7 +4395,10 @@ TP/Ebox reports after accepted initial publication and later commits." buffer 'mount (lambda () (ebox--with-render-gc - (ebox-surface-mount-buffer buffer node) + (ebox-surface-mount-buffer + buffer node nil nil nil + (and source-base-index + (list :source-base-index source-base-index))) (with-current-buffer buffer (goto-char (point-min)) (add-hook 'kill-buffer-hook @@ -4522,8 +4503,6 @@ ITEMS are declarative nodes. Child Range descriptors cannot be nested." (dolist (child (ebox-tree--children-raw node)) (reject-range child))))) (reject-range item))) - (ebox-tree-validate-declarative-root - (list :ebox-type 'stack :display '(block column) :children items)) (ebox-child-range--descriptor-create range-ref (mapcar (lambda (item) @@ -4533,59 +4512,95 @@ ITEMS are declarative nodes. Child Range descriptors cannot be nested." ;;;###autoload (defun ebox-candidate-replace - (candidate node-id next-subtree + (candidate node-id input &optional old-semantic-key new-semantic-key) - "Replace NODE-ID in CANDIDATE with declarative NEXT-SUBTREE. + "Replace NODE-ID in CANDIDATE with canonical INPUT. OLD-SEMANTIC-KEY and NEW-SEMANTIC-KEY may identify a stable anchor's detached semantic variants so a later return can reuse bounded runtime identity. Return CANDIDATE for convenient transaction construction." - (ebox-incremental-candidate-replace - candidate node-id next-subtree old-semantic-key new-semantic-key)) + (unless (ebox-canonical-input-p input) + (signal 'wrong-type-argument (list 'ebox-canonical-input-p input))) + (let ((ebox-incremental--source-base-index + (ebox-canonical-input--source-index input))) + (ebox-incremental-candidate-replace + candidate node-id + (ebox-canonical-input--single-root input "ebox-candidate-replace") + old-semantic-key new-semantic-key))) ;;;###autoload (defun ebox-candidate-replace-host-ref - (candidate host-ref next-subtree + (candidate host-ref input &optional old-semantic-key new-semantic-key) - "Replace HOST-REF in CANDIDATE with declarative NEXT-SUBTREE. + "Replace HOST-REF in CANDIDATE with canonical INPUT. HOST-REF is resolved against the exact published runtime captured by `ebox-candidate-begin'. OLD-SEMANTIC-KEY and NEW-SEMANTIC-KEY have the same optional detached-identity meaning as in `ebox-candidate-replace'. Return CANDIDATE." - (ebox-incremental-candidate-replace-host-ref - candidate host-ref next-subtree old-semantic-key new-semantic-key)) + (unless (ebox-canonical-input-p input) + (signal 'wrong-type-argument (list 'ebox-canonical-input-p input))) + (let ((ebox-incremental--source-base-index + (ebox-canonical-input--source-index input))) + (ebox-incremental-candidate-replace-host-ref + candidate host-ref + (ebox-canonical-input--single-root + input "ebox-candidate-replace-host-ref") + old-semantic-key new-semantic-key))) ;;;###autoload (defun ebox-candidate-patch-host-paint - (candidate host-ref previous-subtree next-subtree) - "Patch HOST-REF paint from declarative OLD/NEW subtrees in CANDIDATE. + (candidate host-ref previous-input next-input) + "Patch HOST-REF paint from canonical PREVIOUS-INPUT/NEXT-INPUT in CANDIDATE. Only node-local paint fields may differ. The Host's children and runtime identities are retained instead of being copied as a replacement subtree. Return CANDIDATE when accepted, or nil when the delta needs ordinary subtree replacement." - (ebox-incremental-candidate-patch-host-paint - candidate host-ref previous-subtree next-subtree)) + (dolist (input (list previous-input next-input)) + (unless (ebox-canonical-input-p input) + (signal 'wrong-type-argument (list 'ebox-canonical-input-p input)))) + (let ((ebox-incremental--previous-source-base-index + (ebox-canonical-input--source-index previous-input)) + (ebox-incremental--source-base-index + (ebox-canonical-input--source-index next-input))) + (ebox-incremental-candidate-patch-host-paint + candidate host-ref + (ebox-canonical-input--single-root + previous-input "ebox-candidate-patch-host-paint previous input") + (ebox-canonical-input--single-root + next-input "ebox-candidate-patch-host-paint next input")))) ;;;###autoload (defun ebox-candidate-replace-range-ref - (candidate range-ref next-items &optional reuse-map) - "Replace RANGE-REF, retaining slots named by proven REUSE-MAP." - (ebox-incremental-candidate-replace-range-ref - candidate range-ref next-items reuse-map)) + (candidate range-ref input &optional reuse-map) + "Replace RANGE-REF from canonical INPUT, retaining proven REUSE-MAP slots." + (unless (ebox-canonical-input-p input) + (signal 'wrong-type-argument (list 'ebox-canonical-input-p input))) + (let ((ebox-incremental--source-base-index + (ebox-canonical-input--source-index input))) + (ebox-incremental-candidate-replace-range-ref + candidate range-ref + (copy-sequence (ebox-canonical-input--nodes input)) + reuse-map))) ;;;###autoload -(defun ebox-candidate-replace-root (candidate next-root) - "Replace CANDIDATE's exact implicit root with declarative NEXT-ROOT. +(defun ebox-candidate-replace-root (candidate input) + "Replace CANDIDATE's exact implicit root with canonical INPUT. The root address is private, base-bound, and cannot collide with host refs. Repeated calls are last-wins and absorb all descendant candidate operations." - (ebox-incremental-candidate-replace-root candidate next-root)) + (unless (ebox-canonical-input-p input) + (signal 'wrong-type-argument (list 'ebox-canonical-input-p input))) + (let ((ebox-incremental--source-base-index + (ebox-canonical-input--source-index input))) + (ebox-incremental-candidate-replace-root + candidate + (ebox-canonical-input--single-root input "ebox-candidate-replace-root")))) ;;;###autoload (defun ebox-commit (buffer-or-name next-root &optional framework-publish framework-rollback) "Atomically commit declarative NEXT-ROOT into BUFFER-OR-NAME. -NEXT-ROOT may be a newly built Ebox node tree or a one-shot logical candidate -from `ebox-candidate-begin'. A complete root is copied into a buffer-owned +NEXT-ROOT must be a canonical Ebox input or a one-shot logical candidate from +`ebox-candidate-begin'. A complete root is copied into a buffer-owned runtime. A logical candidate path-copies replaced ancestor chains, shares untouched subtrees, and isolates retained scroll nodes before proof rendering. Both forms reconcile Ebox-owned identity, @@ -4617,10 +4632,29 @@ Return the successful publication report stored by `ebox-buffer-update-report'." (error "Ebox declarative commit requires an existing live buffer: %S" buffer-or-name)) - (let* ((commit-input - (if (ebox-candidate-p next-root) - (ebox-incremental-consume-candidate buffer next-root) - (ebox-incremental-prepare-root-commit buffer next-root))) + (unless (or (ebox-canonical-input-p next-root) + (ebox-candidate-p next-root)) + (signal 'wrong-type-argument + (list '(or ebox-canonical-input-p ebox-candidate-p) + next-root))) + (let* ((canonical-input + (and (ebox-canonical-input-p next-root) next-root)) + (source-base-index + (and canonical-input + (ebox-canonical-input--source-index + canonical-input))) + (next-root + (if canonical-input + (ebox-canonical-input--single-root + canonical-input "ebox-commit") + next-root)) + (commit-input + (let ((ebox-incremental--source-base-index + source-base-index)) + (if (ebox-candidate-p next-root) + (ebox-incremental-consume-candidate buffer next-root) + (ebox-incremental-prepare-root-commit + buffer next-root)))) (source (plist-get commit-input :root)) (callback (or framework-publish @@ -4707,11 +4741,11 @@ through dirty-set and patch-set execution before falling back to root rerender." (plist-get state :viewport-height)))) ;;;###autoload -(defun ebox-display-buffer (buffer-or-name node) - "Render NODE to BUFFER-OR-NAME through TP and display the buffer." +(defun ebox-display-buffer (buffer-or-name input) + "Render canonical INPUT to BUFFER-OR-NAME through TP and display it." (declare (indent 1)) (delete-other-windows) - (switch-to-buffer (ebox-render-to-buffer buffer-or-name node))) + (switch-to-buffer (ebox-render-to-buffer buffer-or-name input))) (defconst ebox-public-api '(ebox-buffer-mode diff --git a/scripts/ebox-performance-evaluator.el b/scripts/ebox-performance-evaluator.el index f78ebb5..1cdd650 100644 --- a/scripts/ebox-performance-evaluator.el +++ b/scripts/ebox-performance-evaluator.el @@ -274,10 +274,19 @@ (defun ebox-performance-evaluator--runtime-output (buffer) "Return a fresh propertized render of BUFFER's current runtime root." - (let ((ebox--scroll-global-state (make-hash-table :test #'equal)) - (ebox--region-box-table (make-hash-table :test #'equal))) + (let* ((state (ebox--buffer-render-state buffer)) + (ebox--scroll-global-state (make-hash-table :test #'equal)) + (ebox--region-box-table (make-hash-table :test #'equal))) (ebox--with-buffer-render-context buffer - (ebox-render (ebox--buffer-root-node buffer))))) + (ebox--render-node + (ebox--buffer-root-node buffer) (plist-get state :source-index))))) + +(defun ebox-performance-evaluator--surface-producer (input) + "Return a one-shot TP producer for canonical INPUT." + (ebox-surface-producer + (ebox-canonical-input--single-root input "Ebox performance evaluator") + nil t + (list :source-base-index (ebox-canonical-input--source-index input)))) (defun ebox-performance-evaluator--static-fixture (&optional styled target-content) @@ -334,8 +343,8 @@ (ebox-performance-evaluator--reset-runtime-state) (let ((expected (tp-surface-materialize-string - (ebox-surface-producer - (ebox-performance-evaluator--static-fixture) nil t)))) + (ebox-performance-evaluator--surface-producer + (ebox-performance-evaluator--static-fixture))))) (ebox-performance-evaluator--reset-runtime-state) (let* ((measurement (ebox-performance-evaluator--measure @@ -367,8 +376,8 @@ (let ((ebox-performance-evaluator--active nil) (expected (tp-surface-materialize-string - (ebox-surface-producer - (ebox-performance-evaluator--static-fixture t) nil t)))) + (ebox-performance-evaluator--surface-producer + (ebox-performance-evaluator--static-fixture t))))) (ebox-performance-evaluator--reset-runtime-state) (let* ((measurement (ebox-performance-evaluator--measure diff --git a/scripts/ebox-visual-check.el b/scripts/ebox-visual-check.el index df47887..44d95a4 100644 --- a/scripts/ebox-visual-check.el +++ b/scripts/ebox-visual-check.el @@ -101,7 +101,9 @@ (defun ebox-visual-check-report () "Return visual verification report based on rendered text properties." (let* ((layout (ebox-visual-check-layout)) - (ids-before (ebox-region-ids layout)) + (ids-before + (ebox-region-ids + (ebox-canonical-input--single-root layout "Ebox visual check"))) (buffer (generate-new-buffer " *ebox-visual-check-report*"))) (unwind-protect (progn diff --git a/tests/ebox-child-range-tests.el b/tests/ebox-child-range-tests.el index 8e7ed65..441694a 100644 --- a/tests/ebox-child-range-tests.el +++ b/tests/ebox-child-range-tests.el @@ -7,6 +7,7 @@ ;; This suite locks child-range and Elisp projection semantics. Native frame ;; execution is verified separately and must not replace the named fallback ;; strategies merely because a developer has built the optional module. +(defvar ebox-native-reflow-module-path) (setq ebox-native-reflow-module-path nil) (defconst ebox-child-range-test--root @@ -17,9 +18,40 @@ "Return one declarative test item for KEY." (list :key key :value key)) +(defun ebox-child-range-test--node-key (item &optional source-index) + "Return ITEM's key from SOURCE-INDEX or its low-level fixture field." + (or (and (ebox-canonical-input-p item) + (ebox-tree-node-author-key + (ebox-test-source-index item) (ebox-test-root item))) + (and source-index + (ebox-tree-node-source-handle item) + (ebox-tree-node-author-key source-index item)) + (plist-get item :key))) + +(defalias 'ebox-child-range-test--key + #'ebox-child-range-test--node-key) + +(defun ebox-child-range-test--input (items) + "Combine canonical fixture ITEMS into one Range replacement input." + (apply #'ebox-test-forest-input items)) + +(defun ebox-child-range-test--host-ref-table (state) + "Return STATE's source-owned Host reference index." + (ebox-source--index-host-ref-table-view (plist-get state :source-index))) + +(defun ebox-child-range-test--build (segments &optional hash-function) + "Build low-level SEGMENTS with the fixture key contract." + (ebox-child-range--build + segments #'ebox-child-range-test--key hash-function)) + +(defun ebox-child-range-test--replace (sequence ref items) + "Replace REF ITEMS using the fixture key contract." + (ebox-child-range--replace + sequence ref items #'ebox-child-range-test--key)) + (defun ebox-child-range-test--fixture (static-count range-items &optional hash) "Build STATIC-COUNT static segments followed by RANGE-ITEMS." - (ebox-child-range--build + (ebox-child-range-test--build (append (cl-loop for index below static-count collect (cons nil (list (ebox-child-range-test--item @@ -65,8 +97,14 @@ (ert-deftest ebox-child-range-render-signature-snapshots-item-identities () "A cached Range signature must not follow later runtime identity mutation." - (let* ((item (ebox-test-box :content "item" :key 'item)) - (sequence (ebox-child-range--build (list (cons 'range (list item))))) + (let* ((item-input (ebox-test-box (ebox-test-text "item") :key 'item)) + (item (ebox-test-root item-input)) + (source-index (ebox-test-source-index item-input)) + (sequence + (ebox-child-range--build + (list (cons 'range (list item))) + (lambda (node) + (ebox-tree-node-author-key source-index node)))) (published (aref (ebox-child-range--segment-payload (ebox-child-range--lookup-ref sequence 'range)) @@ -86,7 +124,7 @@ (pcase-let* ((`(,static-count ,height ,bound) case) (old (ebox-child-range-test--fixture static-count '(old))) (old-flat (ebox-child-range--flatten old)) - (result (ebox-child-range--replace + (result (ebox-child-range-test--replace old 'range (mapcar #'ebox-child-range-test--item '(new-a new-b)))) @@ -141,7 +179,7 @@ "Sparse hash work is bounded and unaffected segment branches remain shared." (let* ((old (ebox-child-range-test--fixture 100 '(old-a old-b) (lambda (_key) 0))) - (result (ebox-child-range--replace + (result (ebox-child-range-test--replace old 'range (mapcar #'ebox-child-range-test--item '(new-a new-b new-c)))) @@ -192,12 +230,12 @@ (ert-deftest ebox-child-range-gate-a-empty-order-and-duplicates () "Empty ranges stay addressable and global ref/key uniqueness is exact." (let* ((empty (ebox-child-range-test--fixture 1 nil)) - (one (car (ebox-child-range--replace + (one (car (ebox-child-range-test--replace empty 'range (list (ebox-child-range-test--item 'one))))) - (many (car (ebox-child-range--replace + (many (car (ebox-child-range-test--replace one 'range (mapcar #'ebox-child-range-test--item '(two three))))) - (empty-again (car (ebox-child-range--replace many 'range nil)))) + (empty-again (car (ebox-child-range-test--replace many 'range nil)))) (should (= (length (ebox-child-range--segment-payload (ebox-child-range--segment-at empty 1))) 0)) (should (equal (mapcar (lambda (item) (plist-get item :key)) @@ -205,32 +243,32 @@ '((static 0) two three))) (should-not (cdr (ebox-child-range--flatten empty-again))) (should-error - (ebox-child-range--build + (ebox-child-range-test--build (list (cons 'same nil) (cons 'same nil)))) - (should-error (ebox-child-range--build (list (cons nil nil)))) - (should-error (ebox-child-range--build nil)) + (should-error (ebox-child-range-test--build (list (cons nil nil)))) + (should-error (ebox-child-range-test--build nil)) (should-error - (ebox-child-range--build + (ebox-child-range-test--build (list (cons nil (list (ebox-child-range-test--item 'a) (ebox-child-range-test--item 'b)))))) (should-error - (ebox-child-range--build + (ebox-child-range-test--build (list (cons nil (list (ebox-child-range-test--item 'duplicate))) (cons 'range (list (ebox-child-range-test--item 'duplicate)))))) (should-error - (ebox-child-range--build + (ebox-child-range-test--build (list (cons 'left (list (ebox-child-range-test--item 'duplicate))) (cons 'right (list (ebox-child-range-test--item 'duplicate)))))) (should-error - (ebox-child-range--replace + (ebox-child-range-test--replace empty 'range (list (ebox-child-range--descriptor-create 'nested nil)))) (should-error (ebox-child-range--descriptor-create nil nil)) (should-error (ebox-child-range--descriptor-create 'bad '(one . two))) - (should-error (ebox-child-range--replace empty 'range '(one . two))) + (should-error (ebox-child-range-test--replace empty 'range '(one . two))) (should-error - (ebox-child-range--build (list (cons 'bad '(one . two))))) - (should-error (ebox-child-range--replace empty 'missing nil)))) + (ebox-child-range-test--build (list (cons 'bad '(one . two))))) + (should-error (ebox-child-range-test--replace empty 'missing nil)))) (ert-deftest ebox-child-range-gate-a-adjacent-refs-and-unkeyed-items () "First, middle, and last Range refs stay O(1)-addressed and accept no key." @@ -242,10 +280,10 @@ (cons 'adjacent (list (ebox-child-range-test--item 'adjacent))) (cons nil (list (list :value 'unkeyed-static))) (cons 'last nil))) - (base (ebox-child-range--build segments))) + (base (ebox-child-range-test--build segments))) (dolist (entry '((first first-new) (middle middle-new) (last last-new))) (let* ((result - (ebox-child-range--replace + (ebox-child-range-test--replace base (car entry) (list (ebox-child-range-test--item (cadr entry))))) (new (car result)) @@ -270,17 +308,17 @@ (ert-deftest ebox-child-range-integration-is-transparent-and-indexed () "Compiled Range segments render as direct children and retain empty refs." - (let* ((static (ebox-test-box :key 'a :class "a" :content "A" :width '(40))) - (one (ebox-test-box :key 'b :class "b" :content "B" :width '(40))) - (two (ebox-test-box :key 'c :class "c" :content "C" :width '(40))) + (let* ((static (ebox-test-box :key 'a :class "a" (ebox-test-text "A") :width '(40))) + (one (ebox-test-box :key 'b :class "b" (ebox-test-text "B") :width '(40))) + (two (ebox-test-box :key 'c :class "c" (ebox-test-text "C") :width '(40))) (descriptor (ebox-child-range--descriptor-create 'items (list one two))) (empty (ebox-child-range--descriptor-create 'empty nil)) (raw-items (ebox-child-range--descriptor-items descriptor)) (ranged (ebox-test-column static empty descriptor)) (direct (ebox-test-column - (ebox-test-box :key 'a :class "a" :content "A" :width '(40)) - (ebox-test-box :key 'b :class "b" :content "B" :width '(40)) - (ebox-test-box :key 'c :class "c" :content "C" :width '(40)))) + (ebox-test-box :key 'a :class "a" (ebox-test-text "A") :width '(40)) + (ebox-test-box :key 'b :class "b" (ebox-test-text "B") :width '(40)) + (ebox-test-box :key 'c :class "c" (ebox-test-text "C") :width '(40)))) (left (generate-new-buffer " *ebox-range-direct*")) (right (generate-new-buffer " *ebox-range-compiled*"))) (unwind-protect @@ -300,7 +338,7 @@ (ebox-string-lines (with-current-buffer right (buffer-string)))))) (should (eq raw-items (ebox-child-range--descriptor-items descriptor))) - (should (equal (mapcar (lambda (node) (plist-get node :key)) raw-items) + (should (equal (mapcar #'ebox-child-range-test--node-key raw-items) '(b c))) (let* ((direct-state (ebox--buffer-render-state left)) (state (ebox--buffer-render-state right)) @@ -341,20 +379,20 @@ 'outer (list (ebox-child-range--descriptor-create 'nested nil))))) (should-error (ebox-test-column - (ebox-test-box :key 'duplicate :content "a") + (ebox-test-box :key 'duplicate (ebox-test-text "a")) (ebox-child-range--descriptor-create - 'range (list (ebox-test-box :key 'duplicate :content "b"))))) + 'range (list (ebox-test-box :key 'duplicate (ebox-test-text "b")))))) (let ((buffer (generate-new-buffer " *ebox-range-invalid*"))) (unwind-protect (progn - (ebox-render-to-buffer buffer (ebox-test-box :key 'root :content "old")) + (ebox-render-to-buffer buffer (ebox-test-box :key 'root (ebox-test-text "old"))) (let ((before (with-current-buffer buffer (buffer-string))) (state (ebox--buffer-render-state buffer))) (dolist (root (list (ebox-child-range--descriptor-create - 'root (list (ebox-test-box :key 'x :content "x"))) + 'root (list (ebox-test-box :key 'x (ebox-test-text "x")))) (list :ebox-type 'box :ebox-content-node (ebox-child-range--descriptor-create 'scalar nil)) @@ -384,25 +422,25 @@ "Ranges add no ECSS subject and preserve child/sibling cascade semantics." (let* ((ebox-style-stylesheet (ecss-stylesheet-create)) (direct-left - (ebox-test-box :key 'left :host-ref 'left :class "left" - :content "L" :width '(40))) + (ebox-test-box :key 'left :source-identity 'left :class "left" + (ebox-test-text "L") :width '(40))) (direct-right - (ebox-test-box :key 'right :host-ref 'right :class "right" - :content "R" :width '(40))) + (ebox-test-box :key 'right :source-identity 'right :class "right" + (ebox-test-text "R") :width '(40))) (range-left - (ebox-test-box :key 'left :host-ref 'left :class "left" - :content "L" :width '(40))) + (ebox-test-box :key 'left :source-identity 'left :class "left" + (ebox-test-text "L") :width '(40))) (range-right - (ebox-test-box :key 'right :host-ref 'right :class "right" - :content "R" :width '(40))) + (ebox-test-box :key 'right :source-identity 'right :class "right" + (ebox-test-text "R") :width '(40))) (left-range (ebox-child-range--descriptor-create 'left-range (list range-left))) (right-range (ebox-child-range--descriptor-create 'right-range (list range-right))) - (direct (plist-put (ebox-test-column direct-left direct-right) - :class "parent")) - (ranged (plist-put (ebox-test-column left-range right-range) - :class "parent")) + (direct (ebox-test-column + :class "parent" direct-left direct-right)) + (ranged (ebox-test-column + :class "parent" left-range right-range)) (direct-buffer (generate-new-buffer " *ebox-range-ecss-direct*")) (range-buffer (generate-new-buffer " *ebox-range-ecss-range*")) (original (symbol-function 'ebox-style-compute-subject)) @@ -441,44 +479,68 @@ (should-not (plist-get node :surface-object))) (dolist (ref '(left right)) (let* ((state (ebox--buffer-render-state range-buffer)) - (node-id (gethash ref (plist-get state :host-ref-table))) + (node-id + (gethash ref + (ebox-child-range-test--host-ref-table state))) (node (gethash node-id (plist-get state :node-table)))) (should node-id) - (should (equal (plist-get node :key) ref))))) + (should (equal + (ebox-child-range-test--node-key + node (plist-get state :source-index)) + ref))))) (dolist (buffer (list direct-buffer range-buffer)) (when (buffer-live-p buffer) (kill-buffer buffer)))))) (ert-deftest ebox-child-range-path-copy-replaces-compiled-material-child () "Tree path copying cannot ignore a child stored in a compiled sequence." (let* ((source - (ebox-test-column - (ebox-test-box :key 'static :content "static") - (ebox-child-range--descriptor-create - 'items (list (ebox-test-box :key 'old :content "old"))))) - (compiled (ebox-tree-copy-node-structure source)) + (ebox-test-column + (ebox-test-box :key 'static (ebox-test-text "static")) + (ebox-test-child-range + 'items (ebox-test-box :key 'old (ebox-test-text "old"))))) + (compiled (ebox-tree-copy-node-structure (ebox-test-root source))) (old-child (cadr (ebox-tree-layout-children compiled))) - (new-child (ebox-test-box :key 'new :content "new")) + (new-input (ebox-test-box :key 'new (ebox-test-text "new"))) + (new-child (ebox-test-root new-input)) + (source-index + (ebox-test-source-index + (ebox-test-forest-input source new-input))) (unchanged (ebox-tree-copy-with-direct-child-replacements compiled nil)) (copy (ebox-tree-copy-with-direct-child-replacements - compiled (list (cons old-child new-child))))) + compiled (list (cons old-child new-child)) + (lambda (node) + (ebox-child-range-test--node-key node source-index))))) (should (eq (plist-get unchanged :ebox-child-sequence) (plist-get compiled :ebox-child-sequence))) - (should (equal (mapcar (lambda (node) (plist-get node :key)) + (should (equal (mapcar (lambda (node) + (ebox-child-range-test--node-key + node source-index)) (ebox-tree-layout-children copy)) '(static new))) - (should (equal (mapcar (lambda (node) (plist-get node :key)) + (should (equal (mapcar (lambda (node) + (ebox-child-range-test--node-key + node source-index)) (ebox-tree-layout-children compiled)) '(static old))) - (let* ((original (ebox-text-create :value "old")) - (range (ebox-child-range 'typed original)) - (typed (ebox-box-create - :layout (ebox-column-layout-create) - :children (list range))) + (let* ((original (ebox-test-text "old")) + (range (ebox-test-child-range 'typed original)) + (typed-input (ebox-test-column range)) + (typed (ebox-test-root typed-input)) (owned (car (ebox-box-node-children typed))) - (replacement (ebox-text-create :value "new")) + (replacement-input (ebox-test-text "new")) + (replacement (ebox-test-root replacement-input)) (replaced (ebox-tree-copy-with-direct-child-replacements typed (list (cons owned replacement)))) + (builder (ebox-source-builder-create)) + (_typed-sources + (ebox-source-builder-import + builder (ebox-test-source-index typed-input))) + (_replacement-sources + (ebox-source-builder-import + builder (ebox-test-source-index replacement-input))) + (replaced-index + (ebox-tree-source-builder-snapshot builder (list replaced))) direct) (ebox-tree-for-each-direct-child replaced (lambda (child) (push child direct))) @@ -489,36 +551,42 @@ (ebox-tree-semantic-children replaced))) (should (= (length view) 1)) (should (eq (car view) replacement))) - (should (eq (ebox-tree-validate-declarative-root replaced) replaced)) - (let ((removed - (ebox-tree-copy-with-direct-child-replacements - typed (list (cons owned nil))))) - (should-not (ebox-box-node-children removed)) - (should-not (ebox-tree-layout-children removed)) - (should-not (ebox-tree-semantic-children removed)) - (should (eq (ebox-tree-validate-declarative-root removed) - removed)))))) + (should (eq (ebox-tree-validate-declarative-root + replaced nil nil replaced-index) + replaced)) + (should-error + (ebox-tree-copy-with-direct-child-replacements + typed (list (cons owned nil))) + :type 'error)))) (ert-deftest ebox-child-range-empty-sequence-is-authoritative () "An all-empty sequence cannot fall through to legacy alias children." - (let* ((sequence (ebox-child-range--build (list (cons 'empty nil)))) - (legacy (ebox-test-box :key 'legacy :content "legacy")) + (let* ((sequence (ebox-child-range-test--build (list (cons 'empty nil)))) + (legacy (ebox-test-box :key 'legacy (ebox-test-text "legacy"))) (node (list :ebox-type 'stack :ebox-child-sequence sequence :top legacy :bottom legacy))) (should-not (ebox-tree-layout-children node)))) (ert-deftest ebox-child-range-validator-covers-wrapper-and-scalar-slots () "Flat Range compilation cannot hide wrappers or admit scalar descriptors." - (let* ((wrapper (ebox-test-box :key 'wrapper :host-ref 'wrapper :content "W")) - (item (ebox-test-box :key 'item :host-ref 'item :content "I")) - (descriptor (ebox-child-range--descriptor-create 'items (list item))) + (let* ((wrapper-input + (ebox-test-box :key 'wrapper :source-identity 'wrapper (ebox-test-text "W"))) + (item-input + (ebox-test-box :key 'item :source-identity 'item (ebox-test-text "I"))) + (wrapper (ebox-test-root wrapper-input)) + (item (ebox-test-root item-input)) + (descriptor (ebox-test-child-range 'items item-input)) (source (list :ebox-type 'flex :display '(block flex) - :box wrapper :children (list descriptor)))) - (ebox-tree-validate-declarative-root source) - (let* ((copy (ebox-tree-copy-node-structure source)) - (copy-wrapper (plist-get copy :box)) + :box wrapper :children (list descriptor))) + (typed-input (ebox-test-column descriptor)) + (typed (ebox-test-root typed-input))) + (should-error (ebox-tree-copy-node-structure source) :type 'error) + (ebox-tree-validate-declarative-root + typed nil nil (ebox-test-source-index typed-input)) + (let* ((copy (ebox-tree-copy-node-structure typed)) + (copy-wrapper (car (ebox-tree-layout-children copy))) (copy-item (car (ebox-tree-layout-children copy)))) - (should-not (eq wrapper copy-wrapper)) + (should-not (eq item copy-wrapper)) (should-not (eq item copy-item)) (dolist (node (list wrapper item)) (should-not (plist-get node :node-id)) @@ -528,39 +596,41 @@ (ebox-tree-validate-declarative-root (list :ebox-type 'stack :display '(block column) :top (ebox-child-range--descriptor-create 'bad nil) - :bottom (ebox-test-box :key 'bottom :content "B")))) + :bottom (ebox-test-box :key 'bottom (ebox-test-text "B"))))) (should-error (ebox-tree-validate-declarative-root (list :ebox-type 'flex :display '(block flex) - :box (ebox-test-box :key 'same :host-ref 'same :content "W") + :box (ebox-test-box :key 'same :source-identity 'same (ebox-test-text "W")) :children (list (ebox-child-range--descriptor-create 'items - (list (ebox-test-box :key 'item :host-ref 'same - :content "I"))))))))) + (list (ebox-test-box :key 'item :source-identity 'same + (ebox-test-text "I")))))))))) (ert-deftest ebox-child-range-public-candidate-splices-and-reports () "Public child Ranges support empty and repeated last-wins candidate splices." (let* ((buffer (generate-new-buffer " *ebox-public-range*")) (source (ebox-test-column - (ebox-test-box :key 'static :content "S") - (ebox-child-range 'items))) + (ebox-test-box :key 'static (ebox-test-text "S")) + (ebox-test-child-range 'items))) report) (unwind-protect (progn (ebox-render-to-buffer buffer source) (dolist (items (list - (list (ebox-test-box :key 'one :content "1")) - (list (ebox-test-box :key 'two :content "2") - (ebox-test-box :key 'three :content "3")) - (list (ebox-test-box :key 'final :content "F")) + (list (ebox-test-box :key 'one (ebox-test-text "1"))) + (list (ebox-test-box :key 'two (ebox-test-text "2")) + (ebox-test-box :key 'three (ebox-test-text "3"))) + (list (ebox-test-box :key 'final (ebox-test-text "F"))) nil)) (let ((candidate (ebox-candidate-begin buffer))) - (ebox-candidate-replace-range-ref candidate 'items items) + (ebox-candidate-replace-range-ref + candidate 'items (ebox-child-range-test--input items)) (when items - (ebox-candidate-replace-range-ref candidate 'items items)) + (ebox-candidate-replace-range-ref + candidate 'items (ebox-child-range-test--input items))) (setq report (ebox-commit buffer candidate)) (should (plist-get report :range-metrics)))) (should (gethash 'items @@ -576,41 +646,40 @@ (cl-loop for id from 1 to 12 collect (ebox-test-box - :key id :content (format "row-%02d" id) :width '(420) + :key id (ebox-test-text (format "row-%02d" id)) :width '(420) :color (and (= id selected) "#2F6B43")))) (root () (ebox-test-box - :width '(900) :color "#172033" :wrap-mode nil - :ebox-content-node + :width '(900) :color "#172033" :wrap-mode 'none (ebox-test-flex :width '(900) (ebox-test-box - :key 'list :width 'stretch :min-width 0 :wrap-mode nil + :key 'list :width 'stretch :min-width 0 :wrap-mode 'none :min-height 24 :padding '(2 2) :border "#687386" :flex-grow 2 :flex-shrink 1 :flex-basis '(600) - :ebox-content-node (ebox-test-column - (apply #'ebox-child-range 'items (rows 1)))) - (ebox-test-box :key 'detail :host-ref 'detail + (apply #'ebox-test-child-range 'items (rows 1)))) + (ebox-test-box :key 'detail :source-identity 'detail :width 'stretch :min-width 0 :min-height 24 :padding '(2 2) :border "#687386" :flex-grow 1 :flex-shrink 1 :flex-basis '(300) - :content "detail-1"))))) + (ebox-test-text "detail-1")))))) (unwind-protect (progn (ebox-render-to-buffer buffer (root)) (dolist (selected '(2 1 2)) (let ((candidate (ebox-candidate-begin buffer))) (ebox-candidate-replace-range-ref - candidate 'items (rows selected)) + candidate 'items + (ebox-child-range-test--input (rows selected))) (ebox-candidate-replace-host-ref candidate 'detail - (ebox-test-box :key 'detail :host-ref 'detail + (ebox-test-box :key 'detail :source-identity 'detail :width 'stretch :min-width 0 :min-height 24 :padding '(2 2) :border "#687386" :flex-grow 1 :flex-shrink 1 :flex-basis '(300) - :content (format "detail-%d" selected))) + (ebox-test-text (format "detail-%d" selected)))) (let ((report (ebox-commit buffer candidate))) (ert-info ((format "selected=%S report=%S" selected report)) @@ -638,7 +707,7 @@ ((rows (keys) (mapcar (lambda (key) (ebox-test-box :key key - :content (format "row-%s" key))) + (ebox-test-text (format "row-%s" key)))) keys)) (row-id (key) (let* ((state (ebox--buffer-render-state buffer)) @@ -652,23 +721,28 @@ (ebox-child-range--segment-payload segment) nil) :key (lambda (item) - (plist-get item :key))))) + (ebox-child-range-test--node-key + item + (plist-get state + :source-index)))))) (plist-get node :node-id)))) (unwind-protect (progn (ebox-render-to-buffer buffer (ebox-test-column - (apply #'ebox-child-range 'items (rows '(1 2 3 4))))) + (apply #'ebox-test-child-range 'items (rows '(1 2 3 4))))) (let ((initial-1 (row-id 1)) (initial-4 (row-id 4))) (let ((candidate (ebox-candidate-begin buffer))) (ebox-candidate-replace-range-ref - candidate 'items (rows '(1 4))) + candidate 'items + (ebox-child-range-test--input (rows '(1 4)))) (ebox-commit buffer candidate)) (let ((candidate (ebox-candidate-begin buffer))) (ebox-candidate-replace-range-ref - candidate 'items (rows '(1 2 3 4))) + candidate 'items + (ebox-child-range-test--input (rows '(1 2 3 4)))) (ebox-commit buffer candidate)) (should (= initial-1 (row-id 1))) (should (= initial-4 (row-id 4))) @@ -678,57 +752,59 @@ (ert-deftest ebox-child-range-sole-child-keeps-public-layout-parent () "Keep row/column material parents for one empty or populated Range." (dolist (case '((ebox-test-row row) (ebox-test-column column))) - (dolist (items (list nil (list (ebox-test-box :key 'one :content "1")))) - (let* ((range (apply #'ebox-child-range 'items items)) - (parent (funcall (car case) range))) + (dolist (items (list nil (list (ebox-test-box :key 'one (ebox-test-text "1"))))) + (let* ((range (apply #'ebox-test-child-range 'items items)) + (parent-input (funcall (car case) range)) + (parent (ebox-test-root parent-input))) (should-not (ebox-child-range--descriptor-p parent)) (should (ebox-box-node-p parent)) (should (eq (ebox-layout-config-kind (ebox-box-node-layout parent)) (cadr case))) - (should (equal (ebox-box-node-children parent) items)) + (should (equal (ebox-box-node-children parent) + (mapcar #'ebox-test-root items))) (should (equal (ebox-box-node-range-anchors parent) (list (list :ref 'items :before 0 :after (length items))))) - (should (stringp (ebox-render parent))))))) + (should (stringp (ebox-render parent-input))))))) (ert-deftest ebox-child-range-typed-box-preserves-transparent-range () "Typed Box should retain Range identity outside its visual child model." - (let* ((before (ebox-text-create :value "before" :key 'before)) - (first (ebox-text-create :value "A" :key 'first)) - (second (ebox-text-create :value "B" :key 'second)) - (after (ebox-text-create :value "after" :key 'after)) - (range (ebox-child-range 'items first second)) + (let* ((before (ebox-test-text "before" :key 'before)) + (first (ebox-test-text "A" :key 'first)) + (second (ebox-test-text "B" :key 'second)) + (after (ebox-test-text "after" :key 'after)) + (range (ebox-test-child-range 'items first second)) (range-items (ebox-child-range--descriptor-items range)) - (box (ebox-box-create - :layout (ebox-column-layout-create) - :children (list before range after))) + (box-input (ebox-test-column before range after)) + (box (ebox-test-root box-input)) (buffer (generate-new-buffer " *ebox-typed-range*"))) (unwind-protect (progn (should (equal (ebox-box-node-children box) - (list before first second after))) + (mapcar #'ebox-test-root + (list before first second after)))) (cl-mapc (lambda (actual expected) (should (eq actual expected))) (ebox-box-node-children box) - (list before (car range-items) (cadr range-items) after)) + (mapcar #'ebox-test-root + (list before (car range-items) + (cadr range-items) after))) (should (equal (ebox-box-node-range-anchors box) '((:ref items :before 1 :after 3)))) - (let* ((empty (ebox-child-range 'empty)) - (empty-box - (ebox-box-create - :layout (ebox-column-layout-create) - :children (list before empty after)))) + (let* ((empty (ebox-test-child-range 'empty)) + (empty-input (ebox-test-column before empty after)) + (empty-box (ebox-test-root empty-input))) (should (equal (ebox-box-node-children empty-box) - (list before after))) + (mapcar #'ebox-test-root (list before after)))) (should (equal (ebox-box-node-range-anchors empty-box) '((:ref empty :before 1 :after 1))))) (should (equal (mapcar (lambda (line) (string-trim-right (substring-no-properties line))) - (ebox-string-lines (ebox-render box))) + (ebox-string-lines (ebox-render box-input))) '("before" "A" "B" "after"))) - (ebox-render-to-buffer buffer box) + (ebox-render-to-buffer buffer box-input) (let* ((runtime-root (ebox--buffer-root-node buffer)) (snapshot (ebox-tree-runtime-identity-snapshot runtime-root))) @@ -739,7 +815,8 @@ (let ((candidate (ebox-candidate-begin buffer))) (ebox-candidate-replace-range-ref candidate 'items - (list (ebox-text-create :value "C" :key 'replacement))) + (ebox-child-range-test--input + (list (ebox-test-text "C" :key 'replacement)))) (ebox-commit buffer candidate)) (should (equal @@ -753,9 +830,10 @@ (before-style-text (with-current-buffer buffer (buffer-string))) (candidate (ebox-candidate-begin buffer))) - (ebox-candidate-replace-range-ref - candidate 'items - (list (ebox-test-build '(text :color "red" "B")))) + (ebox-candidate-replace-range-ref + candidate 'items + (ebox-child-range-test--input + (list (ebox-test-text "B" :color "red")))) (cl-letf (((symbol-function 'accept-change-group) (lambda (_group) (error "reject styled Range")))) (should-error (ebox-commit buffer candidate) :type 'error)) @@ -766,7 +844,8 @@ (let ((candidate (ebox-candidate-begin buffer))) (ebox-candidate-replace-range-ref candidate 'items - (list (ebox-test-build '(text :color "red" "B")))) + (ebox-child-range-test--input + (list (ebox-test-text "B" :color "red")))) (ebox-commit buffer candidate)) (let* ((state (ebox--buffer-render-state buffer)) (root (plist-get state :root-node)) @@ -788,7 +867,8 @@ (let ((candidate (ebox-candidate-begin buffer))) (ebox-candidate-replace-range-ref candidate 'items - (list (ebox-text-create :value "D" :key 'unstyled))) + (ebox-child-range-test--input + (list (ebox-test-text "D" :key 'unstyled)))) (ebox-commit buffer candidate)) (let* ((state (ebox--buffer-render-state buffer)) (root (plist-get state :root-node)) @@ -822,25 +902,32 @@ "Typed Range rendering should not repeatedly flatten its child sequence." (let* ((items (cl-loop for index below 100 - collect (ebox-text-create - :value (number-to-string index) :key index))) - (range (apply #'ebox-child-range 'items items)) - (box (ebox-box-create - :layout (ebox-column-layout-create) - :children (list range))) + collect (ebox-test-text + (number-to-string index) :key index))) + (range (apply #'ebox-test-child-range 'items items)) + (box-input (ebox-test-column range)) + (box (ebox-test-root box-input)) (original (symbol-function 'ebox-child-range--flatten)) (calls 0)) (cl-letf (((symbol-function 'ebox-child-range--flatten) (lambda (sequence) (cl-incf calls) (funcall original sequence)))) - (ebox-render box)) + (ebox-render box-input)) (should (zerop calls)) - (let* ((replacement (ebox-text-create :value "replacement" :key 'new)) + (let* ((replacement-input + (ebox-test-text "replacement" :key 'new)) + (replacement (ebox-test-root replacement-input)) + (source-index + (ebox-test-source-index + (ebox-test-forest-input box-input replacement-input))) (updated - (car (ebox-child-range--replace - (plist-get box :ebox-child-sequence) - 'items (list replacement)))) + (car + (ebox-child-range--replace + (plist-get box :ebox-child-sequence) + 'items (list replacement) + (lambda (node) + (ebox-child-range-test--node-key node source-index))))) (candidate (copy-sequence box))) (ebox-tree--put-child-sequence candidate updated) (setq calls 0) @@ -848,7 +935,7 @@ (lambda (sequence) (cl-incf calls) (funcall original sequence)))) - (ebox-tree-subject-index candidate) + (ebox-tree-subject-index candidate source-index) (ebox-tree-layout-children candidate)) (should (= calls 1))))) @@ -858,23 +945,28 @@ (unwind-protect (progn (ebox-render-to-buffer buffer - (ebox-test-column (ebox-child-range 'items))) + (ebox-test-column (ebox-test-child-range 'items))) (let ((candidate (ebox-candidate-begin buffer))) (ebox-candidate-replace-range-ref - candidate 'items (list (ebox-test-box :key 'one :content "1"))) + candidate 'items + (ebox-child-range-test--input + (list (ebox-test-box :key 'one (ebox-test-text "1"))))) (cl-letf (((symbol-function 'accept-change-group) (lambda (&rest _) (error "injected empty accept")))) (should-error (ebox-commit buffer candidate) :type 'error))) (should (equal "" (with-current-buffer buffer (buffer-string)))) (let ((candidate (ebox-candidate-begin buffer))) (ebox-candidate-replace-range-ref - candidate 'items (list (ebox-test-box :key 'one :content "1"))) + candidate 'items + (ebox-child-range-test--input + (list (ebox-test-box :key 'one (ebox-test-text "1"))))) (let ((report (ebox-commit buffer candidate))) (should (plist-get report :empty-range-owner-fallback)))) (should (string-match-p "1" (with-current-buffer buffer (buffer-string)))) (let ((candidate (ebox-candidate-begin buffer))) - (ebox-candidate-replace-range-ref candidate 'items nil) + (ebox-candidate-replace-range-ref + candidate 'items (ebox-test-forest-input)) (ebox-commit buffer candidate)) (should (equal "" (with-current-buffer buffer (buffer-string))))) (when (buffer-live-p buffer) (kill-buffer buffer))))) @@ -885,29 +977,33 @@ (static (cl-loop for index below 100 collect (ebox-test-box :key (list 'static index) - :content (number-to-string index)))) + (ebox-test-text (number-to-string index))))) (root (apply #'ebox-test-column (append static - (list (ebox-child-range + (list (ebox-test-child-range 'items - (ebox-test-box :key 'old :content "old")))))) + (ebox-test-box :key 'old (ebox-test-text "old"))))))) candidate logical trace deltas) (unwind-protect (progn (ebox-render-to-buffer buffer root) (setq candidate (ebox-candidate-begin buffer)) (ebox-candidate-replace-range-ref - candidate 'items (list (ebox-test-box :key 'new :content "new"))) + candidate 'items + (ebox-child-range-test--input + (list (ebox-test-box :key 'new (ebox-test-text "new"))))) (setq trace (make-hash-table :test 'equal)) (cl-letf (((symbol-function 'ebox-child-range--flatten) (lambda (&rest _) (error "candidate flattened Range")))) (let ((ebox-incremental--candidate-path-copy-trace trace) - (ebox-incremental--candidate-range-index-deltas nil)) + (ebox-incremental--candidate-range-index-deltas nil) + (ebox-incremental--candidate-incoming-source-indexes + (ebox-incremental--candidate-source-input-map candidate))) (setq logical (ebox-incremental--candidate-logical-root candidate nil) - deltas ebox-incremental--candidate-range-index-deltas)) - (ebox-incremental--candidate-local-index-delta - (ebox-candidate--base-state candidate) logical trace deltas)) + deltas ebox-incremental--candidate-range-index-deltas) + (ebox-incremental--candidate-local-index-delta + (ebox-candidate--base-state candidate) logical trace deltas))) (let ((metrics (car (ebox-candidate--range-metrics candidate)))) (should (= (plist-get metrics :segment-visits) 3)) (should (= (plist-get metrics :unaffected-payload-visits) 0)))) @@ -921,14 +1017,18 @@ (ebox-render-to-buffer buffer (ebox-test-column - (ebox-child-range 'left (ebox-test-box :key 'move :content "L")) - (ebox-child-range 'right (ebox-test-box :key 'stay :content "R")))) + (ebox-test-child-range + 'left (ebox-test-box :key 'move (ebox-test-text "L"))) + (ebox-test-child-range + 'right (ebox-test-box :key 'stay (ebox-test-text "R"))))) (let ((candidate (ebox-candidate-begin buffer))) - (ebox-candidate-replace-range-ref candidate 'left nil) + (ebox-candidate-replace-range-ref + candidate 'left (ebox-test-forest-input)) (ebox-candidate-replace-range-ref candidate 'right - (list (ebox-test-box :key 'move :content "M") - (ebox-test-box :key 'stay :content "R"))) + (ebox-child-range-test--input + (list (ebox-test-box :key 'move (ebox-test-text "M")) + (ebox-test-box :key 'stay (ebox-test-text "R"))))) (ebox-commit buffer candidate)) (should (string-match-p "M" (with-current-buffer buffer (buffer-string))))) @@ -943,9 +1043,9 @@ (append (cl-loop for index below static-count collect (ebox-test-box :key (list 'static index) - :content "s")) - (list (ebox-child-range - 'items (ebox-test-box :key 'old :content "o"))))))) + (ebox-test-text "s"))) + (list (ebox-test-child-range + 'items (ebox-test-box :key 'old (ebox-test-text "o")))))))) (unwind-protect (progn (ebox-render-to-buffer buffer root) @@ -958,8 +1058,9 @@ report) (ebox-candidate-replace-range-ref candidate 'items - (list (ebox-test-box :key 'new-a :content "a") - (ebox-test-box :key 'new-b :content "b"))) + (ebox-child-range-test--input + (list (ebox-test-box :key 'new-a (ebox-test-text "a")) + (ebox-test-box :key 'new-b (ebox-test-text "b"))))) (cl-letf (((symbol-function 'ebox--runtime-index) (lambda (&rest _) (error "unexpected full index")))) (setq report (ebox-commit buffer candidate))) @@ -1004,21 +1105,23 @@ (progn (ebox-render-to-buffer buffer - (ebox-test-column (ebox-test-box :key 'a :host-ref 'a :content "a") - (ebox-test-box :key 'b :content "b"))) + (ebox-test-column (ebox-test-box :key 'a :source-identity 'a (ebox-test-text "a")) + (ebox-test-box :key 'b (ebox-test-text "b")))) (let ((candidate (ebox-candidate-begin buffer))) (ebox-candidate-replace-host-ref candidate 'a (ebox-test-column - (ebox-test-box :key 'a :host-ref 'a :content "a") - (ebox-child-range 'introduced))) + (ebox-test-box :key 'a :source-identity 'a (ebox-test-text "a")) + (ebox-test-child-range 'introduced))) (should-error - (ebox-candidate-replace-range-ref candidate 'introduced nil)) + (ebox-candidate-replace-range-ref + candidate 'introduced (ebox-test-forest-input))) (ebox-commit buffer candidate)) (let ((candidate (ebox-candidate-begin buffer))) (ebox-candidate-replace-range-ref candidate 'introduced - (list (ebox-test-box :key 'later :content "later"))) + (ebox-child-range-test--input + (list (ebox-test-box :key 'later (ebox-test-text "later"))))) (ebox-commit buffer candidate)) (should (string-match-p "later" (with-current-buffer buffer (buffer-string))))) @@ -1033,14 +1136,13 @@ (ebox-render-to-buffer buffer (ebox-test-column - (plist-put - (ebox-test-column - (ebox-test-box :key 'static :content "s") - (ebox-child-range - 'items (ebox-test-box :key 'child :host-ref 'child - :content "old"))) - :host-ref 'parent) - (ebox-test-box :key 'peer :content "peer"))) + (ebox-test-column + :source-identity 'parent + (ebox-test-box :key 'static (ebox-test-text "s")) + (ebox-test-child-range + 'items (ebox-test-box :key 'child :source-identity 'child + (ebox-test-text "old")))) + (ebox-test-box :key 'peer (ebox-test-text "peer")))) (let ((candidate (ebox-candidate-begin buffer))) (dolist (operation (if (eq order 'host-first) '(host range) '(range host))) @@ -1048,12 +1150,13 @@ ('host (ebox-candidate-replace-host-ref candidate 'parent - (ebox-test-box :key 'parent-new :content "HOST-WINS"))) + (ebox-test-box :key 'parent-new (ebox-test-text "HOST-WINS")))) ('range (ebox-candidate-replace-range-ref candidate 'items - (list (ebox-test-box :key 'range-new - :content "range-loses")))))) + (ebox-child-range-test--input + (list (ebox-test-box :key 'range-new + (ebox-test-text "range-loses")))))))) (ebox-commit buffer candidate)) (should (string-match-p "HOST-WINS" (with-current-buffer buffer @@ -1068,18 +1171,20 @@ (ebox-render-to-buffer buffer (ebox-test-column - (ebox-test-box :key 'static :content "s") - (ebox-child-range - 'items (ebox-test-box :key 'child :host-ref 'child :content "0")))) + (ebox-test-box :key 'static (ebox-test-text "s")) + (ebox-test-child-range + 'items (ebox-test-box :key 'child :source-identity 'child (ebox-test-text "0"))))) (dotimes (turn 2) (let ((candidate (ebox-candidate-begin buffer))) (ebox-candidate-replace-host-ref candidate 'child - (ebox-test-box :key 'child :host-ref 'child - :content (number-to-string (1+ turn)))) + (ebox-test-box :key 'child :source-identity 'child + (ebox-test-text (number-to-string (1+ turn))))) (ebox-commit buffer candidate)) (let* ((state (ebox--buffer-render-state buffer)) - (current-id (gethash 'child (plist-get state :host-ref-table))) + (current-id + (gethash 'child + (ebox-child-range-test--host-ref-table state))) (node (gethash current-id (plist-get state :node-table)))) (when node-id (should (equal node-id current-id))) (setq node-id current-id) @@ -1096,31 +1201,30 @@ (ebox-render-to-buffer buffer (ebox-test-column - (plist-put - (ebox-test-column - (ebox-test-box :key 'static :content "s") - (ebox-child-range - 'items (ebox-test-box :key 'child :host-ref 'child - :content "old"))) - :host-ref 'parent) - (ebox-test-box :key 'peer :content "peer"))) + (ebox-test-column + :source-identity 'parent + (ebox-test-box :key 'static (ebox-test-text "s")) + (ebox-test-child-range + 'items (ebox-test-box :key 'child :source-identity 'child + (ebox-test-text "old")))) + (ebox-test-box :key 'peer (ebox-test-text "peer")))) (let ((candidate (ebox-candidate-begin buffer))) (ebox-candidate-replace-host-ref candidate 'parent - (plist-put - (ebox-test-column - (ebox-test-box :key 'static :content "s2") - (ebox-child-range - 'items (ebox-test-box :key 'child :host-ref 'child - :content "ancestor"))) - :host-ref 'parent)) + (ebox-test-column + :source-identity 'parent + (ebox-test-box :key 'static (ebox-test-text "s2")) + (ebox-test-child-range + 'items (ebox-test-box :key 'child :source-identity 'child + (ebox-test-text "ancestor"))))) (ebox-candidate-replace-range-ref candidate 'items - (list (ebox-test-box :key 'range :content "RANGE-LOSES"))) + (ebox-child-range-test--input + (list (ebox-test-box :key 'range (ebox-test-text "RANGE-LOSES"))))) (ebox-candidate-replace-host-ref candidate 'child - (ebox-test-box :key 'child :host-ref 'child - :content "DESCENDANT-WINS")) + (ebox-test-box :key 'child :source-identity 'child + (ebox-test-text "DESCENDANT-WINS"))) (ebox-commit buffer candidate)) (let ((text (with-current-buffer buffer (buffer-string)))) (should (string-match-p "DESCENDANT-WINS" text)) @@ -1135,8 +1239,8 @@ (append (cl-loop for index below 500 collect (ebox-test-box :key (list 'static index) - :content "s")) - (list (ebox-child-range 'items))))) + (ebox-test-text "s"))) + (list (ebox-test-child-range 'items))))) report) (unwind-protect (progn @@ -1144,9 +1248,10 @@ (let ((candidate (ebox-candidate-begin buffer))) (ebox-candidate-replace-range-ref candidate 'items - (cl-loop for index below 64 - collect (ebox-test-box :key (list 'new index) - :content "n"))) + (ebox-child-range-test--input + (cl-loop for index below 64 + collect (ebox-test-box :key (list 'new index) + (ebox-test-text "n"))))) (cl-letf (((symbol-function 'ebox-incremental--candidate-full-index-preparation) (lambda (&rest _) (error "unexpected full preparation"))) @@ -1168,21 +1273,23 @@ (ebox-render-to-buffer buffer (ebox-test-column - (ebox-test-box :key 'static :content "s") - (ebox-child-range - 'items (ebox-test-box :key 'child :host-ref 'child - :content "old")))) + (ebox-test-box :key 'static (ebox-test-text "s")) + (ebox-test-child-range + 'items (ebox-test-box :key 'child :source-identity 'child + (ebox-test-text "old"))))) (let ((candidate (ebox-candidate-begin buffer))) (dolist (operation (if (eq order 'host-first) '(host range) '(range host))) (if (eq operation 'host) (ebox-candidate-replace-host-ref candidate 'child - (ebox-test-box :key 'child :host-ref 'child - :content "HOST-LOSES")) + (ebox-test-box :key 'child :source-identity 'child + (ebox-test-text "HOST-LOSES"))) (ebox-candidate-replace-range-ref candidate 'items - (list (ebox-test-box :key 'range :content "RANGE-WINS"))))) + (ebox-child-range-test--input + (list (ebox-test-box :key 'range + (ebox-test-text "RANGE-WINS"))))))) (ebox-commit buffer candidate)) (let ((text (with-current-buffer buffer (buffer-string)))) (should (string-match-p "RANGE-WINS" text)) @@ -1197,28 +1304,26 @@ ((child (static-text range-ref range-key range-text) (let ((node (ebox-test-column - (ebox-test-box :key 'child-static :content static-text) + :key 'child :source-identity 'child + (ebox-test-box :key 'child-static (ebox-test-text static-text)) (if range-key - (ebox-child-range + (ebox-test-child-range range-ref - (ebox-test-box :key range-key :content range-text)) - (ebox-child-range range-ref))))) - (plist-put node :key 'child) - (plist-put node :host-ref 'child) + (ebox-test-box :key range-key (ebox-test-text range-text))) + (ebox-test-child-range range-ref))))) node)) (ancestor (child-node peer-text) (let ((node (ebox-test-column + :key 'ancestor :source-identity 'ancestor child-node - (ebox-test-box :key 'ancestor-peer :content peer-text)))) - (plist-put node :key 'ancestor) - (plist-put node :host-ref 'ancestor) + (ebox-test-box :key 'ancestor-peer (ebox-test-text peer-text))))) node))) (ebox-render-to-buffer buffer (ebox-test-column (ancestor (child "s" 'old-ref nil nil) "p") - (ebox-test-box :key 'root-peer :content "r"))) + (ebox-test-box :key 'root-peer (ebox-test-text "r")))) (let ((candidate (ebox-candidate-begin buffer))) (ebox-candidate-replace-host-ref candidate 'ancestor @@ -1230,7 +1335,9 @@ (let* ((state (ebox--buffer-render-state buffer)) (table (plist-get state :range-ref-table)) (record (gethash 'new-ref table)) - (child-id (gethash 'child (plist-get state :host-ref-table)))) + (child-id + (gethash 'child + (ebox-child-range-test--host-ref-table state)))) (should-not (gethash 'old-ref table)) (should record) (should (= (hash-table-count table) 1)) @@ -1245,19 +1352,20 @@ (ebox-render-to-buffer buffer (ebox-test-column - (ebox-test-box :key 'host :host-ref 'host :content "old-host") - (ebox-child-range - 'items (ebox-test-box :key 'old-range :content "old-range")))) + (ebox-test-box :key 'host :source-identity 'host (ebox-test-text "old-host")) + (ebox-test-child-range + 'items (ebox-test-box :key 'old-range (ebox-test-text "old-range"))))) (let* ((surface (with-current-buffer buffer ebox-surface--buffer-surface)) (revision (tp-surface-revision surface)) (candidate (ebox-candidate-begin buffer)) report) (ebox-candidate-replace-host-ref candidate 'host - (ebox-test-box :key 'host :host-ref 'host :content "new-host")) + (ebox-test-box :key 'host :source-identity 'host (ebox-test-text "new-host"))) (ebox-candidate-replace-range-ref candidate 'items - (list (ebox-test-box :key 'new-range :content "new-range"))) + (ebox-child-range-test--input + (list (ebox-test-box :key 'new-range (ebox-test-text "new-range"))))) (setq report (ebox-commit buffer candidate (lambda (_report) (cl-incf callbacks)))) @@ -1279,18 +1387,22 @@ (ebox-render-to-buffer buffer (ebox-test-column - (ebox-test-box :key 'static :content "s") - (ebox-child-range - 'items (ebox-test-box :key 'old :host-ref 'old :content "old")))) + (ebox-test-box :key 'static (ebox-test-text "s")) + (ebox-test-child-range + 'items (ebox-test-box :key 'old :source-identity 'old (ebox-test-text "old"))))) (let* ((old-state (ebox--buffer-render-state buffer)) (old-root (plist-get old-state :root-node)) (old-table (plist-get old-state :range-ref-table)) - (old-id (gethash 'old (plist-get old-state :host-ref-table))) + (old-id + (gethash 'old + (ebox-child-range-test--host-ref-table old-state))) (old-buffer (with-current-buffer buffer (buffer-string))) (candidate (ebox-candidate-begin buffer))) (ebox-candidate-replace-range-ref candidate 'items - (list (ebox-test-box :key 'new :host-ref 'new :content "new"))) + (ebox-child-range-test--input + (list (ebox-test-box :key 'new :source-identity 'new + (ebox-test-text "new"))))) (cl-letf (((symbol-function 'accept-change-group) (lambda (_group) (error "reject final accept")))) (should-error @@ -1302,14 +1414,16 @@ (should (eq (ebox--buffer-render-state buffer) old-state)) (should (eq (plist-get old-state :root-node) old-root)) (should (eq (plist-get old-state :range-ref-table) old-table)) - (should (= (gethash 'old (plist-get old-state :host-ref-table)) + (should (= (gethash + 'old (ebox-child-range-test--host-ref-table old-state)) old-id)) (should (equal (with-current-buffer buffer (buffer-string)) old-buffer))) (let ((candidate (ebox-candidate-begin buffer))) (ebox-candidate-replace-range-ref candidate 'items - (list (ebox-test-box :key 'new :content "new"))) + (ebox-child-range-test--input + (list (ebox-test-box :key 'new (ebox-test-text "new"))))) (ebox-commit buffer candidate))) (when (buffer-live-p buffer) (kill-buffer buffer))))) @@ -1321,9 +1435,9 @@ (ebox-render-to-buffer buffer (ebox-test-column - (ebox-test-box :key 'static :content "s") - (ebox-child-range - 'items (ebox-test-box :key 'old :content "old")))) + (ebox-test-box :key 'static (ebox-test-text "s")) + (ebox-test-child-range + 'items (ebox-test-box :key 'old (ebox-test-text "old"))))) (let* ((old-state (ebox--buffer-render-state buffer)) (old-root (plist-get old-state :root-node)) (old-range-table (plist-get old-state :range-ref-table)) @@ -1332,7 +1446,8 @@ trace) (ebox-candidate-replace-range-ref candidate 'items - (list (ebox-test-box :key 'new :content "new"))) + (ebox-child-range-test--input + (list (ebox-test-box :key 'new (ebox-test-text "new"))))) (should-error (ebox-commit buffer candidate @@ -1347,7 +1462,8 @@ (let ((candidate (ebox-candidate-begin buffer))) (ebox-candidate-replace-range-ref candidate 'items - (list (ebox-test-box :key 'new :content "new"))) + (ebox-child-range-test--input + (list (ebox-test-box :key 'new (ebox-test-text "new"))))) (ebox-commit buffer candidate)) (should (string-match-p "new" (with-current-buffer buffer (buffer-string))))) diff --git a/tests/ebox-commit-tests.el b/tests/ebox-commit-tests.el index e14e024..99b19d1 100644 --- a/tests/ebox-commit-tests.el +++ b/tests/ebox-commit-tests.el @@ -9,6 +9,7 @@ ;; own focused contract tests below; disable runtime module discovery here so ;; a locally built optional module cannot silently replace paint/span/mixed ;; plans with `native-frame' and make this suite environment-dependent. +(defvar ebox-native-reflow-module-path) (setq ebox-native-reflow-module-path nil) (defun ebox-commit-test--buffer-string (buffer) @@ -36,7 +37,7 @@ (defun ebox-commit-test--observed-root (content) "Return one stable declarative root containing CONTENT." - (ebox-test-box :key 'root :content content)) + (ebox-test-box :key 'root (ebox-test-text content))) (defun ebox-commit-test--hash-facts (table &optional values) "Return sorted TABLE keys, or key/value pairs when VALUES is non-nil." @@ -72,6 +73,47 @@ :scroll-region-ids (copy-sequence (plist-get state :scroll-region-ids))))) +(ert-deftest ebox-source-index-promotes-and-rolls-back-atomically () + "Candidate source indexes promote once and never corrupt retained state." + (let ((buffer (generate-new-buffer " *ebox-source-index-lifecycle*"))) + (unwind-protect + (let* ((old-root + (ebox-build + '(column :key root :id "root" :class "old" + (box :key row :class "row" "Old")))) + (candidate + (ebox-build + '(column :key root :id "root" :class "new" + (box :key row :class "row" "New"))))) + (ebox-render-to-buffer buffer old-root) + (let* ((old-state (ebox--buffer-render-state buffer)) + (old-index (plist-get old-state :source-index)) + (old-node-id + (plist-get (plist-get old-state :root-node) :node-id)) + (before (ebox-commit-test--buffer-string buffer))) + (should (ebox-source-index-p old-index)) + (cl-letf (((symbol-function 'accept-change-group) + (lambda (_group) (error "source accept failed")))) + (should-error (ebox-commit buffer candidate) :type 'error)) + (let ((retained (ebox--buffer-render-state buffer))) + (should (eq old-index (plist-get retained :source-index))) + (should (equal-including-properties + before (ebox-commit-test--buffer-string buffer)))) + (ebox-commit buffer candidate) + (let* ((new-state (ebox--buffer-render-state buffer)) + (new-index (plist-get new-state :source-index)) + (new-root (plist-get new-state :root-node)) + (record + (ebox-source-index-record + new-index (ebox-node-source-handle new-root)))) + (should (ebox-source-index-p new-index)) + (should-not (eq old-index new-index)) + (should (= old-node-id (plist-get new-root :node-id))) + (should (equal '("new") + (ebox-source-record-classes record)))))) + (when (buffer-live-p buffer) (kill-buffer buffer))) + (should-not (ebox--buffer-render-state buffer)))) + (defun ebox-commit-test--assert-observer-pair (events stage) "Assert reversed EVENTS contain one TP/Ebox pair for STAGE." (should (= (length events) 2)) @@ -176,7 +218,11 @@ (ebox-build '(box :id scroll-root :height 1 :overflow scroll "A\nB\nC"))) - (scroll-id (car (ebox-region-ids root)))) + (scroll-id + (car + (ebox-region-ids + (ebox-canonical-input--single-root + root "Ebox observer scroll fixture"))))) (ebox-render-to-buffer scroll-buffer root (list :observer @@ -436,18 +482,27 @@ (ert-deftest ebox-native-object-delta-orders-moved-and-new-nodes () "A topology delta names parents before moved and introduced children." - (let* ((old-root - (ebox-test-column (ebox-test-box :key 'a :content "A") - (ebox-test-box :key 'b :content "B"))) + (let* ((old-input + (ebox-test-column (ebox-test-box :key 'a (ebox-test-text "A")) + (ebox-test-box :key 'b (ebox-test-text "B")))) + (old-root (ebox-test-root old-input)) (_old-ids (ebox--runtime-node-ids old-root)) - (old-index (ebox--runtime-index old-root t)) + (old-index + (ebox--runtime-index old-root t (ebox-test-source-index old-input))) (old-objects (make-hash-table :test 'equal)) - (new-root - (ebox-test-column (ebox-test-box :key 'b :content "B") - (ebox-test-box :key 'a :content "A") - (ebox-test-box :key 'c :content "C"))) - (_reconciled (ebox-tree-reconcile-runtime old-root new-root)) - (new-index (ebox--runtime-index new-root t)) + (new-input + (ebox-test-column (ebox-test-box :key 'b (ebox-test-text "B")) + (ebox-test-box :key 'a (ebox-test-text "A")) + (ebox-test-box :key 'c (ebox-test-text "C")))) + (new-root (ebox-test-root new-input)) + (new-source-index + (ebox-tree-source-index + new-root nil nil (ebox-test-source-index new-input))) + (_reconciled + (ebox-tree-reconcile-runtime + old-root (plist-get old-index :source-index) + 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) (puthash node-id (list 'object node-id) old-objects)) @@ -473,19 +528,28 @@ (ert-deftest ebox-native-object-delta-requires-complete-removal-proof () "An unreported disappeared object rejects the native topology delta." - (let* ((old-root - (ebox-test-column (ebox-test-box :key 'a :content "A") - (ebox-test-box :key 'tail :content "T") - (ebox-test-box :key 'b :content "B"))) + (let* ((old-input + (ebox-test-column (ebox-test-box :key 'a (ebox-test-text "A")) + (ebox-test-box :key 'tail (ebox-test-text "T")) + (ebox-test-box :key 'b (ebox-test-text "B")))) + (old-root (ebox-test-root old-input)) (_old-ids (ebox--runtime-node-ids old-root)) - (old-index (ebox--runtime-index old-root t)) + (old-index + (ebox--runtime-index old-root t (ebox-test-source-index old-input))) (old-objects (make-hash-table :test 'equal)) (removed-node (car (last (ebox-tree--children-raw old-root)))) - (new-root - (ebox-test-column (ebox-test-box :key 'a :content "A") - (ebox-test-box :key 'tail :content "T"))) - (_reconciled (ebox-tree-reconcile-runtime old-root new-root)) - (new-index (ebox--runtime-index new-root t)) + (new-input + (ebox-test-column (ebox-test-box :key 'a (ebox-test-text "A")) + (ebox-test-box :key 'tail (ebox-test-text "T")))) + (new-root (ebox-test-root new-input)) + (new-source-index + (ebox-tree-source-index + new-root nil nil (ebox-test-source-index new-input))) + (_reconciled + (ebox-tree-reconcile-runtime + old-root (plist-get old-index :source-index) + 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) (puthash node-id (list 'object node-id) old-objects)) @@ -523,7 +587,7 @@ (cl-incf compose-calls) (apply original-compose arguments)))) (dotimes (_ 24) - (ebox-test-box :content "schema-hot-path" :color "#111111"))) + (ebox-test-box (ebox-test-text "schema-hot-path") :color "#111111"))) (should (= package-calls 0)) (should (= compose-calls 0)))) @@ -545,10 +609,10 @@ "A declarative commit should publish changed content." (let* ((buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-commit-content*") - (ebox-test-box :key 'root :content "Before" :width '(80)))) + (ebox-test-box :key 'root (ebox-test-text "Before") :width '(80)))) (report (ebox-commit buffer - (ebox-test-box :key 'root :content "After" :width '(80))))) + (ebox-test-box :key 'root (ebox-test-text "After") :width '(80))))) (unwind-protect (progn (should (string-prefix-p @@ -566,14 +630,14 @@ (let* ((buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-commit-keyed*") (ebox-test-column - (ebox-test-box :key 'a :host-ref 'a :content "A" :width '(40)) - (ebox-test-box :key 'b :host-ref 'b :content "B" :width '(40))))) + (ebox-test-box :key 'a :source-identity 'a (ebox-test-text "A") :width '(40)) + (ebox-test-box :key 'b :source-identity 'b (ebox-test-text "B") :width '(40))))) (old-b (ebox-host-ref-position buffer 'b)) (report (ebox-commit buffer (ebox-test-column - (ebox-test-box :key 'b :host-ref 'b :content "B2" :width '(40)) - (ebox-test-box :key 'a :host-ref 'a :content "A" :width '(40)))))) + (ebox-test-box :key 'b :source-identity 'b (ebox-test-text "B2") :width '(40)) + (ebox-test-box :key 'a :source-identity 'a (ebox-test-text "A") :width '(40)))))) (unwind-protect (progn (should old-b) @@ -591,24 +655,24 @@ (ebox-render-to-buffer (generate-new-buffer-name " *ebox-root-candidate*") (ebox-test-column - (ebox-test-box :key 'a :host-ref 'a :content "A" :width '(40)) - (ebox-test-box :key 'b :host-ref 'b :content "B" :width '(40))))) + (ebox-test-box :key 'a :source-identity 'a (ebox-test-text "A") :width '(40)) + (ebox-test-box :key 'b :source-identity 'b (ebox-test-text "B") :width '(40))))) (surface (with-current-buffer buffer ebox-surface--buffer-surface)) (revision (tp-surface-revision surface)) (candidate (ebox-candidate-begin buffer))) (unwind-protect (progn (ebox-candidate-replace-host-ref - candidate 'a (ebox-test-box :key 'a :content "ignored-before")) + candidate 'a (ebox-test-box :key 'a (ebox-test-text "ignored-before"))) (ebox-candidate-replace-root candidate - (ebox-test-box :key 'root :host-ref 'a :content "first-root" + (ebox-test-box :key 'root :source-identity 'a (ebox-test-text "first-root") :width '(80))) (ebox-candidate-replace-host-ref - candidate 'b (ebox-test-box :key 'b :content "ignored-after")) + candidate 'b (ebox-test-box :key 'b (ebox-test-text "ignored-after"))) (ebox-candidate-replace-root candidate - (ebox-test-box :key 'root :host-ref 'b :content "final-root" + (ebox-test-box :key 'root :source-identity 'b (ebox-test-text "final-root") :width '(80))) (should (= (length (ebox-candidate--replacements candidate)) 1)) (let ((report (ebox-commit buffer candidate))) @@ -624,7 +688,7 @@ (should (plist-get report :runtime-published))) (should-error (ebox-candidate-replace-root - candidate (ebox-test-box :key 'root :content "sealed")))) + candidate (ebox-test-box :key 'root (ebox-test-text "sealed"))))) (when (buffer-live-p buffer) (kill-buffer buffer))))) (ert-deftest ebox-commit-framework-participant-completes-and-rolls-back () @@ -633,11 +697,11 @@ (unwind-protect (progn (ebox-render-to-buffer - buffer (ebox-test-box :key 'root :content "old" :width '(80))) + buffer (ebox-test-box :key 'root (ebox-test-text "old") :width '(80))) (let (trace) (let ((report (ebox-commit - buffer (ebox-test-box :key 'root :content "new" :width '(80)) + buffer (ebox-test-box :key 'root (ebox-test-text "new") :width '(80)) (lambda (_report) (push 'publish trace)) (lambda (_report) (push 'rollback trace))))) (should (equal trace '(publish))) @@ -658,7 +722,7 @@ (condition-case condition (ebox-commit buffer - (ebox-test-box :key 'root :content "rejected" + (ebox-test-box :key 'root (ebox-test-text "rejected") :width '(80)) (lambda (report) (setq captured report) @@ -688,22 +752,22 @@ (ebox-render-to-buffer buffer (ebox-test-column - (ebox-test-box :key 'a :host-ref 'a :content "old-a" :width '(40)) - (ebox-test-box :key 'b :host-ref 'b :content "old-b" :width '(40)))) + (ebox-test-box :key 'a :source-identity 'a (ebox-test-text "old-a") :width '(40)) + (ebox-test-box :key 'b :source-identity 'b (ebox-test-text "old-b") :width '(40)))) (let ((before (ebox-commit-test--buffer-string buffer)) (candidate (ebox-candidate-begin buffer)) trace captured) (when (eq mode 'scoped) (ebox-candidate-replace-host-ref candidate 'a - (ebox-test-box :key 'a :host-ref 'a :content "new-a" + (ebox-test-box :key 'a :source-identity 'a (ebox-test-text "new-a") :width '(40)))) (should-error (ebox-commit buffer (if (eq mode 'scoped) candidate - (ebox-test-box :key 'root :content "new-root" :width '(80))) + (ebox-test-box :key 'root (ebox-test-text "new-root") :width '(80))) (lambda (report) (setq captured report) (push 'publish trace) @@ -719,7 +783,7 @@ (ert-deftest ebox-commit-framework-argument-validation () "Four-argument framework callbacks have an exact paired contract." (let ((buffer (generate-new-buffer " *ebox-framework-validation*")) - (root (ebox-test-box :key 'root :content "x"))) + (root (ebox-test-box :key 'root (ebox-test-text "x")))) (unwind-protect (progn (ebox-render-to-buffer buffer root) @@ -738,21 +802,21 @@ (ebox-test-column (ebox-test-box :key 'panel :padding '(1 (2)) :border "#687386" :bgcolor "#FFFDF8" - :content "Panel") + (ebox-test-text "Panel")) (ebox-test-box :key 'payload :padding '(0 (1)) - :border "#AAA" :content "No payload yet.") + :border "#AAA" (ebox-test-text "No payload yet.")) (ebox-test-box :key 'later :padding '(0 (1)) - :border "#BBB" :content "Later sibling"))) + :border "#BBB" (ebox-test-text "Later sibling")))) (new-root (ebox-test-column (ebox-test-box :key 'panel :padding '(1 (2)) :border "#687386" :bgcolor "#FFFDF8" - :content "Panel") + (ebox-test-text "Panel")) (ebox-test-box :key 'payload :padding '(0 (1)) :border "#AAA" - :content "Payload received: payload=42") + (ebox-test-text "Payload received: payload=42")) (ebox-test-box :key 'later :padding '(0 (1)) - :border "#BBB" :content "Later sibling"))) + :border "#BBB" (ebox-test-text "Later sibling")))) (report nil)) (unwind-protect (progn @@ -782,9 +846,9 @@ (ebox-render-to-buffer (generate-new-buffer-name " *ebox-commit-style-cache*") (ebox-test-column - (ebox-test-box :key 'first :class "card" :content "Before" + (ebox-test-box :key 'first :class "card" (ebox-test-text "Before") :width '(40)) - (ebox-test-box :key 'second :class "card" :content "Stable" + (ebox-test-box :key 'second :class "card" (ebox-test-text "Stable") :width '(40))))) (setq calls 0) (cl-letf (((symbol-function 'ecss-compute-style) @@ -794,9 +858,9 @@ (ebox-commit buffer (ebox-test-column - (ebox-test-box :key 'first :class "card" :content "After" + (ebox-test-box :key 'first :class "card" (ebox-test-text "After") :width '(40)) - (ebox-test-box :key 'second :class "card" :content "Stable" + (ebox-test-box :key 'second :class "card" (ebox-test-text "Stable") :width '(40)))) (should (= calls 0)))) (when (buffer-live-p buffer) @@ -815,9 +879,9 @@ (ebox-render-to-buffer (generate-new-buffer-name " *ebox-commit-selector-snapshot*") (ebox-test-column - (ebox-test-box :key 'first :class "card" :content "Before" + (ebox-test-box :key 'first :class "card" (ebox-test-text "Before") :width '(40)) - (ebox-test-box :key 'second :class "card" :content "Stable" + (ebox-test-box :key 'second :class "card" (ebox-test-text "Stable") :width '(40))))) (cl-letf (((symbol-function 'ebox-surface--subject-signature) (lambda (&rest arguments) @@ -826,9 +890,9 @@ (ebox-commit buffer (ebox-test-column - (ebox-test-box :key 'first :class "card" :content "After" + (ebox-test-box :key 'first :class "card" (ebox-test-text "After") :width '(40)) - (ebox-test-box :key 'second :class "card" :content "Stable" + (ebox-test-box :key 'second :class "card" (ebox-test-text "Stable") :width '(40)))) (should (= calls 1)))) (when (buffer-live-p buffer) @@ -847,9 +911,9 @@ (ebox-render-to-buffer (generate-new-buffer-name " *ebox-local-selector-reuse*") (ebox-test-column - (ebox-test-box :key 'first :class "card" :content "First" + (ebox-test-box :key 'first :class "card" (ebox-test-text "First") :width '(40)) - (ebox-test-box :key 'second :class "card" :content "Second" + (ebox-test-box :key 'second :class "card" (ebox-test-text "Second") :width '(40))))) (cl-letf (((symbol-function 'ecss-compute-style) (lambda (&rest arguments) @@ -858,11 +922,11 @@ (ebox-commit buffer (ebox-test-column - (ebox-test-box :key 'first :class "card" :content "First" + (ebox-test-box :key 'first :class "card" (ebox-test-text "First") :width '(40)) - (ebox-test-box :key 'second :class "card" :content "Second" + (ebox-test-box :key 'second :class "card" (ebox-test-text "Second") :width '(40)) - (ebox-test-box :key 'third :class "card" :content "Third" + (ebox-test-box :key 'third :class "card" (ebox-test-text "Third") :width '(40))))) (should (= calls 2)) (let* ((text (ebox-commit-test--buffer-string buffer)) @@ -888,9 +952,9 @@ (ebox-render-to-buffer (generate-new-buffer-name " *ebox-commit-selector-change*") (ebox-test-column - (ebox-test-box :key 'state :class "inactive" :content "State" + (ebox-test-box :key 'state :class "inactive" (ebox-test-text "State") :width '(40)) - (ebox-test-box :key 'target :class "target" :content "Target" + (ebox-test-box :key 'target :class "target" (ebox-test-text "Target") :width '(40))))) (let* ((before (ebox-commit-test--buffer-string buffer)) (position (string-match "Target" before))) @@ -899,9 +963,9 @@ (ebox-commit buffer (ebox-test-column - (ebox-test-box :key 'state :class "active" :content "State" + (ebox-test-box :key 'state :class "active" (ebox-test-text "State") :width '(40)) - (ebox-test-box :key 'target :class "target" :content "Target" + (ebox-test-box :key 'target :class "target" (ebox-test-text "Target") :width '(40)))) (let* ((after (ebox-commit-test--buffer-string buffer)) (position (string-match "Target" after))) @@ -923,7 +987,7 @@ (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-commit-style-rule*") - (ebox-test-box :key 'card :class "card" :content "Stable" + (ebox-test-box :key 'card :class "card" (ebox-test-text "Stable") :width '(40)))) (should (equal (ebox-commit-test--face-value (get-text-property (point-min) 'face buffer) @@ -932,7 +996,7 @@ (ebox-style-add-rule ".card" '(:color "#222222") :layer 'components) (ebox-commit buffer - (ebox-test-box :key 'card :class "card" :content "Stable" + (ebox-test-box :key 'card :class "card" (ebox-test-text "Stable") :width '(40))) (should (equal (ebox-commit-test--face-value (get-text-property (point-min) 'face buffer) @@ -950,7 +1014,7 @@ (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-commit-cascade-activation*") - (ebox-test-box :key 'card :class "card" :content "Before" + (ebox-test-box :key 'card :class "card" (ebox-test-text "Before") :width '(40)))) (should-not (get-text-property (point-min) 'face buffer)) (ebox-style-add-rule ".card" '(:color "#2255AA") @@ -958,7 +1022,7 @@ (let ((report (ebox-commit buffer - (ebox-test-box :key 'card :class "card" :content "After" + (ebox-test-box :key 'card :class "card" (ebox-test-text "After") :width '(40))))) (should (string-prefix-p "After" @@ -989,9 +1053,9 @@ (ebox-render-to-buffer (generate-new-buffer-name " *ebox-dynamic-style-proof*") (ebox-test-column - (ebox-test-box :key 'card :class "card" :content "Before" + (ebox-test-box :key 'card :class "card" (ebox-test-text "Before") :width '(40)) - (ebox-test-box :key 'stable :content "Stable" + (ebox-test-box :key 'stable (ebox-test-text "Stable") :width '(40))))) (let* ((state (ebox--buffer-render-state buffer)) (style-states (plist-get state :style-binding-states)) @@ -1015,9 +1079,9 @@ (ebox-commit buffer (ebox-test-column - (ebox-test-box :key 'card :class "card" :content "After" + (ebox-test-box :key 'card :class "card" (ebox-test-text "After") :width '(40)) - (ebox-test-box :key 'stable :content "Stable" + (ebox-test-box :key 'stable (ebox-test-text "Stable") :width '(40)))) (should (= calls 0))))) (when (buffer-live-p buffer) @@ -1031,33 +1095,31 @@ The context owns the complete local block; the root and untouched header/footer remain retained identities." (let* ((old-root (ebox-test-column - (ebox-test-box :key 'header :content "Header" :width '(160)) + (ebox-test-box :key 'header (ebox-test-text "Header") :width '(160)) (ebox-test-box :key 'shell :width '(160) - :ebox-content-node (ebox-test-column - (ebox-test-box :key 'message :host-ref 'message - :content "Callback action pending") + (ebox-test-box :key 'message :source-identity 'message + (ebox-test-text "Callback action pending")) (ebox-test-flex :width '(120) :height 1 - (ebox-test-box :key 'toggle :host-ref 'toggle - :content "Behavior: off")))) - (ebox-test-box :key 'footer :content "Footer" :width '(160)))) + (ebox-test-box :key 'toggle :source-identity 'toggle + (ebox-test-text "Behavior: off"))))) + (ebox-test-box :key 'footer (ebox-test-text "Footer") :width '(160)))) (new-message - (ebox-test-box :key 'message :host-ref 'message - :content "Behavior toggle: on / callback active / a longer status line")) + (ebox-test-box :key 'message :source-identity 'message + (ebox-test-text "Behavior toggle: on / callback active / a longer status line"))) (new-toggle - (ebox-test-box :key 'toggle :host-ref 'toggle - :content "Behavior: on")) + (ebox-test-box :key 'toggle :source-identity 'toggle + (ebox-test-text "Behavior: on"))) (new-root (ebox-test-column - (ebox-test-box :key 'header :content "Header" :width '(160)) + (ebox-test-box :key 'header (ebox-test-text "Header") :width '(160)) (ebox-test-box :key 'shell :width '(160) - :ebox-content-node (ebox-test-column new-message (ebox-test-flex :width '(120) :height 1 new-toggle))) - (ebox-test-box :key 'footer :content "Footer" :width '(160)))) + (ebox-test-box :key 'footer (ebox-test-text "Footer") :width '(160)))) (buffer nil) (fresh nil)) (unwind-protect @@ -1110,7 +1172,9 @@ remain retained identities." key) (while (and node-id (not key)) (when-let* ((node (gethash node-id node-table))) - (setq key (ebox-tree-node-key node))) + (setq key + (ebox-tree-node-key + (plist-get state :source-index) node))) (setq node-id (and (not key) (gethash node-id parent-table)))) @@ -1159,16 +1223,15 @@ remain retained identities." (cl-labels ((root (message toggle) (ebox-test-column - (ebox-test-box :key 'header :content "Header" :width '(160)) + (ebox-test-box :key 'header (ebox-test-text "Header") :width '(160)) (ebox-test-box :key 'shell :width '(160) - :ebox-content-node (ebox-test-column - (ebox-test-box :key 'message :host-ref 'message :content message) + (ebox-test-box :key 'message :source-identity 'message (ebox-test-text message)) (ebox-test-flex :width '(120) :height 1 - (ebox-test-box :key 'toggle :host-ref 'toggle - :content toggle)))) - (ebox-test-box :key 'footer :content "Footer" :width '(160))))) + (ebox-test-box :key 'toggle :source-identity 'toggle + (ebox-test-text toggle))))) + (ebox-test-box :key 'footer (ebox-test-text "Footer") :width '(160))))) (dolist (failure-kind '(client-state final-accept)) (let ((buffer (generate-new-buffer (format " *ebox-formatting-context-%S*" failure-kind)))) @@ -1187,12 +1250,12 @@ remain retained identities." (ebox-candidate-replace-host-ref candidate 'message (ebox-test-box - :key 'message :host-ref 'message - :content "Behavior toggle: on / callback active / a longer status line")) + :key 'message :source-identity 'message + (ebox-test-text "Behavior toggle: on / callback active / a longer status line"))) (ebox-candidate-replace-host-ref candidate 'toggle - (ebox-test-box :key 'toggle :host-ref 'toggle - :content "Behavior: on")) + (ebox-test-box :key 'toggle :source-identity 'toggle + (ebox-test-text "Behavior: on"))) (if (eq failure-kind 'client-state) (let ((tp--surface-publication-step-function (lambda (step _surface) @@ -1213,12 +1276,12 @@ remain retained identities." (ebox-candidate-replace-host-ref retry 'message (ebox-test-box - :key 'message :host-ref 'message - :content "Behavior toggle: on / callback active / a longer status line")) + :key 'message :source-identity 'message + (ebox-test-text "Behavior toggle: on / callback active / a longer status line"))) (ebox-candidate-replace-host-ref retry 'toggle - (ebox-test-box :key 'toggle :host-ref 'toggle - :content "Behavior: on")) + (ebox-test-box :key 'toggle :source-identity 'toggle + (ebox-test-text "Behavior: on"))) (let ((report (ebox-commit buffer retry))) (should (eq (plist-get report :projection-kind) 'formatting-context-reflow)) @@ -1230,17 +1293,15 @@ remain retained identities." (ert-deftest ebox-commit-multi-owner-variable-content-keeps-fixed-slots () "Two fixed Grid slots accept unequal one-line content in one publication." (let* ((new-left - (ebox-test-box :key 'left :host-ref 'left - :content "L")) + (ebox-test-box :key 'left :source-identity 'left + (ebox-test-text "L"))) (new-right - (ebox-test-box :key 'right :host-ref 'right :content "R")) + (ebox-test-box :key 'right :source-identity 'right (ebox-test-text "R"))) (old-root - (ebox-test-build - '(grid :key grid :width (80) - :grid-template-columns ((36) (36)) - :column-gap (0 (8)) - (box :key left :host-ref left :content "left-old") - (box :key right :host-ref right :content "right-old")))) + (ebox-test-grid :key 'grid :width '(80) :grid-template-columns + '((36) (36)) :column-gap '(0 (8)) + (ebox-test-box :key 'left :source-identity 'left (ebox-test-text "left-old")) + (ebox-test-box :key 'right :source-identity 'right (ebox-test-text "right-old")))) (buffer nil) (fresh nil) report) @@ -1266,12 +1327,10 @@ remain retained identities." (ebox-render-to-buffer (generate-new-buffer-name " *ebox-multi-owner-variable-slots-fresh*") - (ebox-test-build - '(grid :key grid :width (80) - :grid-template-columns ((36) (36)) - :column-gap (0 (8)) - (box :key left :host-ref left :content "L") - (box :key right :host-ref right :content "R"))))) + (ebox-test-grid :key 'grid :width '(80) :grid-template-columns + '((36) (36)) :column-gap '(0 (8)) + (ebox-test-box :key 'left :source-identity 'left (ebox-test-text "L")) + (ebox-test-box :key 'right :source-identity 'right (ebox-test-text "R"))))) (should (equal (substring-no-properties (ebox-commit-test--buffer-string buffer)) @@ -1289,16 +1348,16 @@ remain retained identities." (ebox-test-grid :key 'grid :width '(80) :grid-template-columns '((36) (36)) :column-gap '(0 (8)) - (ebox-test-box :key 'left :host-ref 'left :content left) - (ebox-test-box :key 'right :host-ref 'right :content right))) + (ebox-test-box :key 'left :source-identity 'left (ebox-test-text left)) + (ebox-test-box :key 'right :source-identity 'right (ebox-test-text right)))) (candidate (buffer) (let ((candidate (ebox-candidate-begin buffer))) (ebox-candidate-replace-host-ref candidate 'left - (ebox-test-box :key 'left :host-ref 'left :content "L")) + (ebox-test-box :key 'left :source-identity 'left (ebox-test-text "L"))) (ebox-candidate-replace-host-ref candidate 'right - (ebox-test-box :key 'right :host-ref 'right :content "R")) + (ebox-test-box :key 'right :source-identity 'right (ebox-test-text "R"))) candidate))) (dolist (failure-kind '(client-state final-accept)) (let ((buffer (generate-new-buffer @@ -1375,11 +1434,11 @@ remain retained identities." "A failed candidate must leave the previously published buffer intact." (let ((buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-commit-rollback*") - (ebox-test-box :key 'root :content "Stable" :width '(80))))) + (ebox-test-box :key 'root (ebox-test-text "Stable") :width '(80))))) (unwind-protect (progn (should-error - (ebox-commit buffer (ebox-test-box :key 'root :content nil + (ebox-commit buffer (ebox-test-box :key 'root (ebox-test-text nil) :width 'invalid))) (should (string-prefix-p "Stable" @@ -1395,8 +1454,8 @@ remain retained identities." (ebox-render-to-buffer (generate-new-buffer-name " *ebox-participation-rollback*") (ebox-test-column - (ebox-test-box :key 'target :host-ref 'target - :content "Stable" :width '(80))))) + (ebox-test-box :key 'target :source-identity 'target + (ebox-test-text "Stable") :width '(80))))) (surface (with-current-buffer buffer ebox-surface--buffer-surface)) (revision (tp-surface-revision surface)) (before (ebox-commit-test--buffer-string buffer)) @@ -1407,8 +1466,8 @@ remain retained identities." ;; replacement is legal. The final Column graft is authoritative. (ebox-candidate-replace-host-ref candidate 'target - (ebox-test-box :key 'target :host-ref 'target - :content "Invalid" :width '(80) :flex-grow 1)) + (ebox-test-box :key 'target :source-identity 'target + (ebox-test-text "Invalid") :width '(80) :flex-grow 1)) (should-error (ebox-commit buffer candidate) :type 'error) (should (= (tp-surface-revision surface) revision)) (should (equal (ebox-commit-test--buffer-string buffer) before))) @@ -1421,10 +1480,10 @@ remain retained identities." (cl-loop for index below 200 collect (if (= index 99) - (ebox-test-box :key index :host-ref 'target - :content (number-to-string index)) + (ebox-test-box :key index :source-identity 'target + (ebox-test-text (number-to-string index))) (ebox-test-box :key index - :content (number-to-string index))))) + (ebox-test-text (number-to-string index)))))) (buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-participation-local*") @@ -1437,13 +1496,15 @@ remain retained identities." (progn (ebox-candidate-replace-host-ref candidate 'target - (ebox-test-box :key 99 :host-ref 'target :content "changed")) + (ebox-test-box :key 99 :source-identity 'target (ebox-test-text "changed"))) (cl-letf (((symbol-function 'ebox-tree-validate-indexed-participation) - (lambda (node-table parent-table node-ids &optional source) + (lambda (node-table parent-table node-ids + &optional source source-index) (push (cons source (length node-ids)) validation-frontiers) - (funcall original node-table parent-table node-ids source)))) + (funcall original node-table parent-table node-ids + source source-index)))) (ebox-commit buffer candidate)) ;; The changed Box, its Text leaf, and direct parent context are the ;; complete validation frontier; 197 siblings remain untouched. @@ -1457,20 +1518,20 @@ remain retained identities." (second (generate-new-buffer " *ebox-root-valid-second*"))) (unwind-protect (progn - (ebox-render-to-buffer first (ebox-test-box :key 'root :content "one")) - (ebox-render-to-buffer second (ebox-test-box :key 'root :content "two")) + (ebox-render-to-buffer first (ebox-test-box :key 'root (ebox-test-text "one"))) + (ebox-render-to-buffer second (ebox-test-box :key 'root (ebox-test-text "two"))) (let ((candidate (ebox-candidate-begin first))) (should-error (ebox-candidate-replace-root candidate "invalid")) (ebox-candidate-replace-root - candidate (ebox-test-box :key 'root :content "candidate")) + candidate (ebox-test-box :key 'root (ebox-test-text "candidate"))) (should-error (ebox-commit second candidate)) (should-error (ebox-candidate-replace-root - candidate (ebox-test-box :key 'root :content "sealed")))) + candidate (ebox-test-box :key 'root (ebox-test-text "sealed"))))) (let ((candidate (ebox-candidate-begin first))) (ebox-candidate-replace-root - candidate (ebox-test-box :key 'root :content "stale")) - (ebox-commit first (ebox-test-box :key 'root :content "new-base")) + candidate (ebox-test-box :key 'root (ebox-test-text "stale"))) + (ebox-commit first (ebox-test-box :key 'root (ebox-test-text "new-base"))) (should-error (ebox-commit first candidate)))) (dolist (buffer (list first second)) (when (buffer-live-p buffer) (kill-buffer buffer)))))) @@ -1480,15 +1541,15 @@ remain retained identities." (let ((buffer (generate-new-buffer " *ebox-framework-compat*"))) (unwind-protect (progn - (ebox-render-to-buffer buffer (ebox-test-box :key 'root :content "0")) + (ebox-render-to-buffer buffer (ebox-test-box :key 'root (ebox-test-text "0"))) (should (eq (plist-get - (ebox-commit buffer (ebox-test-box :key 'root :content "1")) + (ebox-commit buffer (ebox-test-box :key 'root (ebox-test-text "1"))) :framework-participant-state) 'completed)) (let ((calls 0) seen) (let ((report (ebox-commit - buffer (ebox-test-box :key 'root :content "2") + buffer (ebox-test-box :key 'root (ebox-test-text "2")) (lambda (value) (cl-incf calls) (setq seen value))))) (should (= calls 1)) (should (eq seen report)) @@ -1505,13 +1566,13 @@ remain retained identities." (ebox-render-to-buffer buffer (ebox-test-column - (ebox-test-box :key 'a :host-ref 'a :content "old-a") - (ebox-test-box :key 'b :host-ref 'b :content "old-b"))) + (ebox-test-box :key 'a :source-identity 'a (ebox-test-text "old-a")) + (ebox-test-box :key 'b :source-identity 'b (ebox-test-text "old-b")))) (let* ((before (ebox-commit-test--buffer-string buffer)) (candidate (ebox-candidate-begin buffer)) trace captured failure) (ebox-candidate-replace-host-ref - candidate 'a (ebox-test-box :key 'a :host-ref 'a :content "new-a")) + candidate 'a (ebox-test-box :key 'a :source-identity 'a (ebox-test-text "new-a"))) (cl-letf (((symbol-function 'accept-change-group) (lambda (_group) (error "accept failed")))) (setq failure @@ -1520,7 +1581,7 @@ remain retained identities." buffer (if (eq mode 'scoped) candidate - (ebox-test-box :key 'root :content "new-root")) + (ebox-test-box :key 'root (ebox-test-text "new-root"))) (lambda (report) (setq captured report) (push 'publish trace)) @@ -1540,7 +1601,7 @@ remain retained identities." (ebox-commit-test--buffer-string buffer) before)) (should (eq (plist-get (ebox-commit - buffer (ebox-test-box :key 'root :content "next")) + buffer (ebox-test-box :key 'root (ebox-test-text "next"))) :framework-participant-state) 'completed)))) (when (buffer-live-p buffer) (kill-buffer buffer)))))) @@ -1555,14 +1616,14 @@ remain retained identities." :condition (quit))))) (unwind-protect (progn - (ebox-render-to-buffer buffer (ebox-test-box :key 'root :content "0")) + (ebox-render-to-buffer buffer (ebox-test-box :key 'root (ebox-test-text "0"))) (cl-letf (((symbol-function 'ebox-incremental--finalize-declarative-scroll-publication) (lambda (&rest _arguments) diagnostics))) (let ((report (ebox-commit - buffer (ebox-test-box :key 'root :content "1")))) + buffer (ebox-test-box :key 'root (ebox-test-text "1"))))) (should (eq (plist-get report :framework-participant-state) 'completed)) (should (equal @@ -1570,7 +1631,7 @@ remain retained identities." diagnostics)))) (should (eq (plist-get (ebox-commit buffer - (ebox-test-box :key 'root :content "2")) + (ebox-test-box :key 'root (ebox-test-text "2"))) :framework-participant-state) 'completed))) (when (buffer-live-p buffer) (kill-buffer buffer))))) @@ -1580,7 +1641,7 @@ remain retained identities." (let ((buffer (generate-new-buffer " *ebox-participant-owner-failure*"))) (unwind-protect (progn - (ebox-render-to-buffer buffer (ebox-test-box :key 'root :content "old")) + (ebox-render-to-buffer buffer (ebox-test-box :key 'root (ebox-test-text "old"))) (let* ((surface (with-current-buffer buffer ebox-surface--buffer-surface)) (old-state (tp-surface-client-state surface)) @@ -1599,7 +1660,7 @@ remain retained identities." (setq failure (condition-case condition (ebox-commit - buffer (ebox-test-box :key 'root :content "new") + buffer (ebox-test-box :key 'root (ebox-test-text "new")) #'ignore (lambda (_report) (setq rollback-called t))) (error condition)))) @@ -1619,20 +1680,19 @@ remain retained identities." (ebox-test-column (ebox-test-box :key 'geometry-context :width '(80) - :ebox-content-node (ebox-test-column - (ebox-test-box :key 'left :host-ref 'left - :content left :font 'bold) - (ebox-test-box :key 'right :host-ref 'right - :content right))) - (ebox-test-box :key 'paint-a :host-ref 'paint-a - :content "paint-a" :color paint-a) - (ebox-test-box :key 'paint-b :host-ref 'paint-b - :content "paint-b" :bgcolor paint-b) - (ebox-test-box :key 'paint-c :host-ref 'paint-c - :content "paint-c" :color paint-c) - (ebox-test-box :key 'untouched :host-ref 'untouched - :content "untouched"))) + (ebox-test-box :key 'left :source-identity 'left + (ebox-test-text left) :font 'bold) + (ebox-test-box :key 'right :source-identity 'right + (ebox-test-text right)))) + (ebox-test-box :key 'paint-a :source-identity 'paint-a + (ebox-test-text "paint-a") :color paint-a) + (ebox-test-box :key 'paint-b :source-identity 'paint-b + (ebox-test-text "paint-b") :bgcolor paint-b) + (ebox-test-box :key 'paint-c :source-identity 'paint-c + (ebox-test-text "paint-c") :color paint-c) + (ebox-test-box :key 'untouched :source-identity 'untouched + (ebox-test-text "untouched")))) (ert-deftest ebox-candidate-host-paint-patch-preserves-subtree-identity () "Patch one Host's paint without copying or reconciling its descendants." @@ -1640,10 +1700,9 @@ remain retained identities." (ebox-render-to-buffer (generate-new-buffer-name " *ebox-host-paint-patch* ") (ebox-test-box - :key 'target :host-ref 'target :bgcolor "#111111" - :ebox-content-node + :key 'target :source-identity 'target :bgcolor "#111111" (ebox-test-column - (ebox-test-box :key 'child :host-ref 'child :content "child"))))) + (ebox-test-box :key 'child :source-identity 'child (ebox-test-text "child")))))) (child-id (plist-get (ebox--host-ref-node buffer 'child) :node-id))) (unwind-protect (progn @@ -1651,15 +1710,13 @@ remain retained identities." (ebox-candidate-patch-host-paint candidate 'target (ebox-test-box - :key 'target :host-ref 'target :bgcolor "#111111" - :ebox-content-node + :key 'target :source-identity 'target :bgcolor "#111111" (ebox-test-column - (ebox-test-box :key 'child :host-ref 'child :content "child"))) + (ebox-test-box :key 'child :source-identity 'child (ebox-test-text "child")))) (ebox-test-box - :key 'target :host-ref 'target :bgcolor "#EEEEEE" - :ebox-content-node + :key 'target :source-identity 'target :bgcolor "#EEEEEE" (ebox-test-column - (ebox-test-box :key 'child :host-ref 'child :content "child")))) + (ebox-test-box :key 'child :source-identity 'child (ebox-test-text "child"))))) (let ((report (ebox-commit buffer candidate))) (should (eq (plist-get report :projection-kind) 'paint)) (should (= child-id @@ -1673,31 +1730,31 @@ remain retained identities." (ebox-candidate-patch-host-paint candidate 'target (ebox-test-box - :key 'target :host-ref 'target :bgcolor "#EEEEEE" - :content "child") + :key 'target :source-identity 'target :bgcolor "#EEEEEE" + (ebox-test-text "child")) (ebox-test-box - :key 'target :host-ref 'target :bgcolor "#EEEEEE" - :width '(40) :content "child")))) + :key 'target :source-identity 'target :bgcolor "#EEEEEE" + :width '(40) (ebox-test-text "child"))))) (let ((candidate (ebox-candidate-begin buffer))) (should (ebox-candidate-patch-host-paint candidate 'target (ebox-test-box - :key 'target :host-ref 'target :bgcolor "#EEEEEE" - :ebox-content-node + :key 'target :source-identity 'target :bgcolor "#EEEEEE" (ebox-test-column - (ebox-test-box :key 'child :host-ref 'child :content "child"))) + (ebox-test-box :key 'child :source-identity 'child (ebox-test-text "child")))) (ebox-test-box - :key 'target :host-ref 'target :bgcolor "#EEEEEE" + :key 'target :source-identity 'target :bgcolor "#EEEEEE" :color "#FFFFFF" - :ebox-content-node (ebox-test-column - (ebox-test-box :key 'child :host-ref 'child :content "child"))))) + (ebox-test-box :key 'child :source-identity 'child (ebox-test-text "child")))))) (ebox-commit buffer candidate) (should (equal "#FFFFFF" (ebox-style-node-specified-value - (ebox--host-ref-node buffer 'target) :color))) + (ebox--host-ref-node buffer 'target) :color nil + (plist-get (ebox--buffer-render-state buffer) + :source-index)))) (let* ((rendered (ebox-commit-test--buffer-string buffer)) (position (string-match "child" rendered))) (should position) @@ -1714,15 +1771,14 @@ remain retained identities." (ebox-test-box :key 'selection-panel :width 'stretch :min-width 0 :min-height 24 :flex-grow 2 :flex-shrink 1 :flex-basis '(340) - :ebox-content-node (ebox-test-column - (ebox-test-box :key 'row-1 :host-ref 'row-1 :content row-1) - (ebox-test-box :key 'row-2 :host-ref 'row-2 :content row-2))))) + (ebox-test-box :key 'row-1 :source-identity 'row-1 (ebox-test-text row-1)) + (ebox-test-box :key 'row-2 :source-identity 'row-2 (ebox-test-text row-2)))))) (ebox-test-flex :key 'fixed-basis-selection-root :width '(900) :height 24 :flex-flow '(row nowrap) :align-items 'stretch - (ebox-test-box :key 'peer :content "peer" :width 'stretch + (ebox-test-box :key 'peer (ebox-test-text "peer") :width 'stretch :min-width 0 :min-height 24 :flex-grow 4 :flex-shrink 1 :flex-basis '(620)) panel))) @@ -1733,10 +1789,10 @@ remain retained identities." (let ((candidate (ebox-candidate-begin buffer))) (ebox-candidate-replace-host-ref candidate 'row-1 - (ebox-test-box :key 'row-1 :host-ref 'row-1 :content row-1)) + (ebox-test-box :key 'row-1 :source-identity 'row-1 (ebox-test-text row-1))) (ebox-candidate-replace-host-ref candidate 'row-2 - (ebox-test-box :key 'row-2 :host-ref 'row-2 :content row-2)) + (ebox-test-box :key 'row-2 :source-identity 'row-2 (ebox-test-text row-2))) candidate)) (ert-deftest ebox-commit-fixed-basis-selection-round-trip-stays-local () @@ -1792,11 +1848,14 @@ remain retained identities." (should panel-snapshot) (should-not (plist-member panel-snapshot :buffer-spans)) (with-current-buffer buffer - (should - (equal - (buffer-substring-no-properties (point-min) (point-max)) - (substring-no-properties - (ebox-render (ebox--buffer-root-node buffer))))))))) + (should + (equal + (buffer-substring-no-properties (point-min) (point-max)) + (substring-no-properties + (let ((state (ebox--buffer-render-state buffer))) + (ebox--render-node + (plist-get state :root-node) + (plist-get state :source-index)))))))))) (should (= (length reports) 2)) (should (= slot-proof-count 2))) (when (buffer-live-p buffer) (kill-buffer buffer))))) @@ -1807,24 +1866,24 @@ remain retained identities." (let ((candidate (ebox-candidate-begin buffer))) (ebox-candidate-replace-host-ref candidate 'left - (ebox-test-box :key 'left :host-ref 'left - :content left :font 'bold)) + (ebox-test-box :key 'left :source-identity 'left + (ebox-test-text left) :font 'bold)) (ebox-candidate-replace-host-ref candidate 'right - (ebox-test-box :key 'right :host-ref 'right - :content right)) + (ebox-test-box :key 'right :source-identity 'right + (ebox-test-text right))) (ebox-candidate-replace-host-ref candidate 'paint-a - (ebox-test-box :key 'paint-a :host-ref 'paint-a - :content "paint-a" :color paint-a)) + (ebox-test-box :key 'paint-a :source-identity 'paint-a + (ebox-test-text "paint-a") :color paint-a)) (ebox-candidate-replace-host-ref candidate 'paint-b - (ebox-test-box :key 'paint-b :host-ref 'paint-b - :content "paint-b" :bgcolor paint-b)) + (ebox-test-box :key 'paint-b :source-identity 'paint-b + (ebox-test-text "paint-b") :bgcolor paint-b)) (ebox-candidate-replace-host-ref candidate 'paint-c - (ebox-test-box :key 'paint-c :host-ref 'paint-c - :content "paint-c" :color paint-c)) + (ebox-test-box :key 'paint-c :source-identity 'paint-c + (ebox-test-text "paint-c") :color paint-c)) candidate)) @@ -1858,9 +1917,8 @@ remain retained identities." (generate-new-buffer-name " *ebox-structure-proof-domain* ") (ebox-test-column (ebox-test-box - :key 'target :host-ref 'target - :ebox-content-node - (ebox-test-column (ebox-test-box :key 'first :content "first")))))) + :key 'target :source-identity 'target + (ebox-test-column (ebox-test-box :key 'first (ebox-test-text "first"))))))) (span-calls 0) (mixed-calls 0) (old-span @@ -1872,10 +1930,9 @@ remain retained identities." (ebox-candidate-replace-host-ref candidate 'target (ebox-test-box - :key 'target :host-ref 'target - :ebox-content-node - (ebox-test-column (ebox-test-box :key 'first :content "first") - (ebox-test-box :key 'second :content "second")))) + :key 'target :source-identity 'target + (ebox-test-column (ebox-test-box :key 'first (ebox-test-text "first")) + (ebox-test-box :key 'second (ebox-test-text "second"))))) (cl-letf (((symbol-function 'ebox-incremental--span-patch-projection-proof) (lambda (&rest arguments) @@ -1898,8 +1955,8 @@ remain retained identities." (ebox-render-to-buffer (generate-new-buffer-name " *ebox-range-dirty-boundary* ") (ebox-test-column - (ebox-child-range - 'rows (ebox-test-box :key 'old :content "old"))))) + (ebox-test-child-range + 'rows (ebox-test-box :key 'old (ebox-test-text "old")))))) (state (ebox--buffer-render-state buffer)) (parent-id (plist-get (gethash 'rows (plist-get state :range-ref-table)) @@ -1907,10 +1964,11 @@ remain retained identities." captured) (unwind-protect (let ((candidate (ebox-candidate-begin buffer)) + (next-input (ebox-test-box :key 'new (ebox-test-text "new"))) (original (symbol-function 'ebox-incremental--surface-commit-input))) (ebox-candidate-replace-range-ref - candidate 'rows (list (ebox-test-box :key 'new :content "new"))) + candidate 'rows next-input) (cl-letf (((symbol-function 'ebox-incremental--surface-commit-input) (lambda (target old-state prepared) @@ -1936,17 +1994,15 @@ remain retained identities." (ebox-test-box :key 'root :width (list (or width 800)) :height (and root-overflow 1) :overflow root-overflow - :ebox-content-node (ebox-test-column - (ebox-test-box :key 'status :host-ref 'status - :content "Theme: Light" :width '(200)) - (ebox-test-box :key 'paint-a :host-ref 'paint-a - :content "paint-a" :color paint-a) - (ebox-test-box :key 'paint-b :host-ref 'paint-b - :content "paint-b" :bgcolor paint-b) + (ebox-test-box :key 'status :source-identity 'status + (ebox-test-text "Theme: Light") :width '(200)) + (ebox-test-box :key 'paint-a :source-identity 'paint-a + (ebox-test-text "paint-a") :color paint-a) + (ebox-test-box :key 'paint-b :source-identity 'paint-b + (ebox-test-text "paint-b") :bgcolor paint-b) (ebox-test-box :key 'footer-owner :width (list (or width 800)) - :ebox-content-node (ebox-test-column (ebox-test-flex :key 'footer :width (list (or width 800)) :flex-wrap 'wrap @@ -1954,10 +2010,9 @@ remain retained identities." (ebox-test-box :key 'toast-slot :width 'stretch :min-width 0 :flex-grow 1 :flex-shrink 1 :flex-basis '(0) - :ebox-content-node (ebox-test-column - (ebox-test-box :key 'toast :host-ref 'toast :content toast))) - (ebox-test-box :key 'peer :content "database.sqlite"))))))) + (ebox-test-box :key 'toast :source-identity 'toast (ebox-test-text toast)))) + (ebox-test-box :key 'peer (ebox-test-text "database.sqlite")))))))) (defun ebox-commit-test--allocation-closure-candidate (buffer toast paint-a paint-b) @@ -1965,15 +2020,15 @@ remain retained identities." (let ((candidate (ebox-candidate-begin buffer))) (ebox-candidate-replace-host-ref candidate 'toast - (ebox-test-box :key 'toast :host-ref 'toast :content toast)) + (ebox-test-box :key 'toast :source-identity 'toast (ebox-test-text toast))) (ebox-candidate-replace-host-ref candidate 'paint-a - (ebox-test-box :key 'paint-a :host-ref 'paint-a - :content "paint-a" :color paint-a)) + (ebox-test-box :key 'paint-a :source-identity 'paint-a + (ebox-test-text "paint-a") :color paint-a)) (ebox-candidate-replace-host-ref candidate 'paint-b - (ebox-test-box :key 'paint-b :host-ref 'paint-b - :content "paint-b" :bgcolor paint-b)) + (ebox-test-box :key 'paint-b :source-identity 'paint-b + (ebox-test-text "paint-b") :bgcolor paint-b)) candidate)) (defun ebox-commit-test--two-geometry-allocation-candidate @@ -1984,8 +2039,8 @@ remain retained identities." buffer toast paint-a paint-b))) (ebox-candidate-replace-host-ref candidate 'status - (ebox-test-box :key 'status :host-ref 'status - :content status :width '(200))) + (ebox-test-box :key 'status :source-identity 'status + (ebox-test-text status) :width '(200))) candidate)) @@ -2024,18 +2079,17 @@ remain retained identities." candidate 'toast (if (eq kind 'topology) (ebox-test-box - :key 'toast :host-ref 'toast - :ebox-content-node + :key 'toast :source-identity 'toast (ebox-test-column (ebox-test-box :key 'nested-toast - :content "A longer notification"))) - (ebox-test-box :key 'toast :host-ref 'toast + (ebox-test-text "A longer notification")))) + (ebox-test-box :key 'toast :source-identity 'toast :class (and (eq kind 'selector) "changed") - :content "A longer notification"))) + (ebox-test-text "A longer notification")))) (ebox-candidate-replace-host-ref candidate 'paint-a - (ebox-test-box :key 'paint-a :host-ref 'paint-a - :content "paint-a" :color "#AAAAAA")) + (ebox-test-box :key 'paint-a :source-identity 'paint-a + (ebox-test-text "paint-a") :color "#AAAAAA")) (let ((report (if (eq kind 'cascade) (cl-letf diff --git a/tests/ebox-core-render-tests.el b/tests/ebox-core-render-tests.el index 7b54107..2cef7c8 100644 --- a/tests/ebox-core-render-tests.el +++ b/tests/ebox-core-render-tests.el @@ -13,6 +13,7 @@ ;; Core tests assert named Elisp render/patch strategies. Keep optional local ;; native artifacts from replacing those strategies; native execution has ;; separate module and integration gates. +(defvar ebox-native-reflow-module-path) (setq ebox-native-reflow-module-path nil) (defvar ebox--scroll-window-initial-lookahead-lines-override nil) @@ -92,6 +93,30 @@ "Return BUFFER's surface-owned runtime root." (plist-get (ebox--buffer-render-state buffer) :root-node)) +(defun ebox-test--input-content-pixel (input) + "Return INPUT's used content width at the private layout boundary." + (let ((ebox--render-source-index (ebox-test-source-index input))) + (ebox--content-pixel (ebox-test-root input)))) + +(defun ebox-test--input-total-pixel (input) + "Return INPUT's used outer width at the private layout boundary." + (let ((ebox--render-source-index (ebox-test-source-index input))) + (ebox--total-pixel (ebox-test-root input)))) + +(defun ebox-test--input-content-height (input line-count) + "Return INPUT's used content height for LINE-COUNT layout lines." + (let ((ebox--render-source-index (ebox-test-source-index input))) + (ebox--content-height (ebox-test-root input) line-count))) + +(defun ebox-test--render-input-node (input) + "Render INPUT through the private node boundary with its retained facts." + (ebox--render-node (ebox-test-root input) (ebox-test-source-index input))) + +(defun ebox-test--call-input-node (function input &rest arguments) + "Call private node FUNCTION for INPUT with its retained source generation." + (let ((ebox--render-source-index (ebox-test-source-index input))) + (apply function (ebox-test-root input) arguments))) + (defun ebox-test--selector-node (buffer selector) "Return the first runtime node matching SELECTOR in BUFFER." (plist-get (car (ebox-selector-query-buffer buffer selector)) :node)) @@ -228,7 +253,7 @@ (ert-deftest ebox-scroll-prefix-publishes-private-line-with-suffix-paint () "A cached hidden source line should not rebuild the rendered prefix." (ebox-test--reset-runtime-state) - (let* ((box (ebox-test-box :content "" :width 20 :height 2)) + (let* ((box (ebox-test-box (ebox-test-text "") :width 20 :height 2)) (content-lines '("zero" "one" "two" "three")) (state (list :content-lines (seq-take content-lines 3) :rendered-content-lines @@ -240,7 +265,7 @@ (symbol-function 'ebox--scroll-rendered-content-lines)) (renderer (ebox--scroll-window-prefix-renderer - box '(:stack ignored) 20 2 nil content-lines))) + (ebox-test-root box) '(:stack ignored) 20 2 nil content-lines))) (cl-letf (((symbol-function 'ebox--render-scroll-window-source) (lambda (&rest _) (cl-incf source-renders) @@ -401,15 +426,16 @@ (let* ((ebox-viewport-width 240) (ebox-viewport-height 6) (layout - (ebox-test-build - '(box :width (viewport) :height (viewport-height) - :bgcolor "#151A23" - (column - (box :width (viewport) :height 1 :bgcolor "#202938" - "surface") - (box :width (viewport) :height 1 "transparent") - (box :width (viewport) :height 1 :bgcolor "#29364A" - "surface-two"))))) + (ebox-test-box :width '(viewport) :height '(viewport-height) :bgcolor + "#151A23" + (ebox-test-column + (ebox-test-box :width '(viewport) :height 1 :bgcolor + "#202938" (ebox-test-text "surface")) + (ebox-test-box :width '(viewport) :height 1 + (ebox-test-text "transparent")) + (ebox-test-box :width '(viewport) :height 1 :bgcolor + "#29364A" + (ebox-test-text "surface-two"))))) (lines (ebox-string-lines (ebox-render layout)))) (should (= (length lines) 6)) (dolist (line lines) @@ -430,7 +456,7 @@ (ebox-style-add-rule ".card" '(:color "#F8FAFC" :bgcolor "#1E293B") :layer 'components) - (let* ((node (ebox-test-box :class 'card :content "Cascade" :width '(96))) + (let* ((node (ebox-test-box :class 'card (ebox-test-text "Cascade") :width '(96))) (face (ebox-test--first-content-face (ebox-render node)))) (should (ebox-test--face-has-key-value-p face :foreground "#F8FAFC")) @@ -568,9 +594,9 @@ "A child nil color should block inheritance without inheriting background." (ebox-test--reset-runtime-state) (let* ((ebox-style-stylesheet (ecss-stylesheet-create)) - (root (ebox-test-build - '(box :class panel :wrap-mode none :width (96) - (box :id child :color nil :width (96) "Child"))))) + (root (ebox-test-box :class 'panel :wrap-mode 'none :width '(96) + (ebox-test-box :id 'child :color nil :width '(96) + (ebox-test-text "Child"))))) (ebox-style-add-rule ".panel" '(:color "#E2E8F0" :bgcolor "#0F172A") :layer 'components) @@ -614,8 +640,8 @@ (substring-no-properties (ebox-render (ebox-test-flex - (ebox-test-box :content "A") - (ebox-test-box :content "B")))))) + (ebox-test-box (ebox-test-text "A")) + (ebox-test-box (ebox-test-text "B"))))))) (should (equal (mapcar #'string-trim-right (split-string rendered "\n")) '("A" "B")))))) @@ -626,9 +652,9 @@ (let ((ebox-style-stylesheet (ecss-stylesheet-create))) (ebox-style-add-rule ".card" '(:color "#94A3B8") :layer 'base) (ebox-style-add-rule ".card" '(:color "#38BDF8") :layer 'components) - (let* ((rule-node (ebox-test-box :class 'card :content "Rule")) + (let* ((rule-node (ebox-test-box :class 'card (ebox-test-text "Rule"))) (inline-node - (ebox-test-box :class 'card :content "Inline" :color "#F8FAFC"))) + (ebox-test-box :class 'card (ebox-test-text "Inline") :color "#F8FAFC"))) (should (equal (ebox-test--effective-face-value (ebox-test--first-content-face (ebox-render rule-node)) @@ -655,8 +681,8 @@ (ebox-style-add-rule ".placed" '(:grid-column 2) :layer 'layout) (ebox-test--with-rendered-buffer (ebox-test-grid - (ebox-test-box :class 'placed :content "Placed") - (ebox-test-box :content "Auto")) + (ebox-test-box :class 'placed (ebox-test-text "Placed")) + (ebox-test-box (ebox-test-text "Auto"))) (let* ((root (ebox-test--runtime-root (current-buffer))) (config (plist-get root :ebox-layout-config)) (props (ebox-layout-config-props config)) @@ -673,11 +699,10 @@ "Font computed values should reach child text before pixel measurement." (ebox-test--reset-runtime-state) (let* ((root - (ebox-test-build - '(box :wrap-mode none - :font-family "Parent Family" - :font-height 1.25 :font-weight bold - (box :id child :font-family "Child Family" "MMMM")))) + (ebox-test-box :wrap-mode 'none :font-family "Parent Family" + :font-height 1.25 :font-weight 'bold + (ebox-test-box :id 'child :font-family "Child Family" + (ebox-test-text "MMMM")))) measured-face) (cl-letf (((symbol-function 'ebox--string-pixel-width) (lambda (string) @@ -711,11 +736,11 @@ (child "#1D4ED8") (rendered (ebox-render - (ebox-test-build - `(column :width (120) :height 5 :padding-block 1 - :bgcolor ,parent - (box :width (120) :height 1 :margin-bottom 1 - :bgcolor ,child "Child"))))) + (ebox-test-column :width '(120) :height 5 :padding-block 1 :bgcolor + parent + (ebox-test-box :width '(120) :height 1 + :margin-bottom 1 :bgcolor child + (ebox-test-text "Child"))))) (lines (ebox-string-lines rendered))) (should (= (length lines) 5)) (dolist (line lines) @@ -746,7 +771,7 @@ "A pure paint change should not recompute spatial layout." (ebox-test--reset-runtime-state) (let* ((target - (ebox-test-box :id 'target :content "Paint" :width '(60) + (ebox-test-box :id 'target (ebox-test-text "Paint") :width '(60) :color "#E2E8F0" :bgcolor "#1E293B")) (root (ebox-test-flex @@ -777,9 +802,8 @@ (ert-deftest ebox-live-mount-uses-runtime-copy-and-leaves-source-stable () "Live publication should update a surface copy without mutating SOURCE." (ebox-test--reset-runtime-state) - (let* ((source (ebox-test-build - '(box :id root :width (120) :color "#111111" - :height 1 :overflow scroll "before\nhidden"))) + (let* ((source (ebox-test-box :id 'root :width '(120) :color "#111111" :height 1 + :overflow 'scroll (ebox-test-text "before\nhidden"))) (source-before (copy-tree source)) (buffer (generate-new-buffer " *ebox-live-copy-source*"))) (unwind-protect @@ -787,9 +811,8 @@ (ebox-render-to-buffer buffer source) (ebox-commit buffer - (ebox-test-build - '(box :id root :width (140) :color "#222222" - :height 1 :overflow scroll "after\nhidden"))) + (ebox-test-box :id 'root :width '(140) :color "#222222" :height 1 + :overflow 'scroll (ebox-test-text "after\nhidden"))) (should (equal source source-before)) (with-current-buffer buffer (should (string-match-p "after" (buffer-string))) @@ -800,9 +823,8 @@ (ert-deftest ebox-live-mount-allows-one-source-on-independent-surfaces () "One immutable source should mount and update independently in two buffers." (ebox-test--reset-runtime-state) - (let* ((source (ebox-test-build - '(box :id root :width (120) :height 1 - :overflow scroll "owned\nhidden"))) + (let* ((source (ebox-test-box :id 'root :width '(120) :height 1 :overflow 'scroll + (ebox-test-text "owned\nhidden"))) (source-before (copy-tree source)) first-buffer second-buffer) (unwind-protect @@ -816,13 +838,13 @@ (generate-new-buffer-name " *ebox-live-second*") source)) (ebox-commit first-buffer - (ebox-test-build '(box :id root "first changed"))) + (ebox-test-box :id 'root (ebox-test-text "first changed"))) (with-current-buffer first-buffer (should (string-match-p "first changed" (buffer-string)))) (with-current-buffer second-buffer (should-not (string-match-p "first changed" (buffer-string)))) (ebox-commit second-buffer - (ebox-test-build '(box :id root "second changed"))) + (ebox-test-box :id 'root (ebox-test-text "second changed"))) (with-current-buffer second-buffer (should (string-match-p "second changed" (buffer-string)))) (with-current-buffer first-buffer @@ -835,7 +857,7 @@ (ert-deftest ebox-live-mount-kill-invalidates-handle () "Killing a surface buffer should invalidate its region handles." (ebox-test--reset-runtime-state) - (let* ((old-source (ebox-test-build '(box :id old :width 120 "old"))) + (let* ((old-source (ebox-test-box :id 'old :width 120 (ebox-test-text "old"))) (source-before (copy-tree old-source)) primary-buffer secondary-buffer) (unwind-protect @@ -854,7 +876,7 @@ (generate-new-buffer-name " *ebox-live-secondary*") old-source)) (ebox-commit secondary-buffer - (ebox-test-build '(box :id old :width 120 "reused"))) + (ebox-test-box :id 'old :width 120 (ebox-test-text "reused"))) (with-current-buffer secondary-buffer (should (string-match-p "reused" (buffer-string)))) (should (equal old-source source-before))) @@ -865,10 +887,8 @@ (ert-deftest ebox-wrapper-child-nodes-are-in-runtime-index () "A wrapper box's child layout should remain visible in runtime state." (ebox-test--reset-runtime-state) - (let* ((node (ebox-test-build - '(column :padding 1 - (box :content "One") - (box :content "Two")))) + (let* ((node (ebox-test-column :padding 1 (ebox-test-box (ebox-test-text "One")) + (ebox-test-box (ebox-test-text "Two")))) buffer) (unwind-protect (progn @@ -888,24 +908,28 @@ "A later flex wrapper should not replace an earlier duplicate region owner." (ebox-test--reset-runtime-state) (let* ((region-id 9001) - (plain (ebox-test-box :content "First" :width '(80))) + (plain (ebox-test-box (ebox-test-text "First") :width '(80))) (flex (ebox-test-flex :width '(120) - (ebox-test-box :content "Later" :width '(80)))) - (wrapper (plist-get flex :box)) - (layout (ebox-test-column plain flex))) - (ebox-put plain :region-id region-id) + (ebox-test-box (ebox-test-text "Later") :width '(80)))) + (wrapper (ebox-test-root flex)) + (layout (ebox-test-column plain flex)) + (plain-node (ebox-test-root plain)) + (layout-node (ebox-test-root layout))) + (ebox-put plain-node :region-id region-id) (ebox-put wrapper :region-id region-id) - (let* ((index (ebox--runtime-index layout)) + (let* ((index (ebox--runtime-index + layout-node nil (ebox-test-source-index layout))) (table (plist-get index :region-node-table)) - (expected (car (ebox--node-path-to-region layout region-id)))) - (should (eq expected plain)) + (expected (car (ebox--node-path-to-region + layout-node region-id)))) + (should (eq expected plain-node)) (should (equal (gethash region-id table) (ebox--ensure-node-id expected)))))) (ert-deftest ebox-region-render-owner-index-falls-back-for-legacy-state () "A runtime state without the new table should retain recursive lookup." (ebox-test--reset-runtime-state) - (let* ((child (ebox-test-box :content "Flex child" :width '(80))) + (let* ((child (ebox-test-box (ebox-test-text "Flex child") :width '(80))) (flex (ebox-test-flex :width '(120) :padding 1 child)) (wrapper (plist-get flex :box))) (ebox-test--with-rendered-buffer flex @@ -942,8 +966,8 @@ (ebox-test--reset-runtime-state) (let* ((layout (ebox-test-flex :flex-wrap 'wrap - (ebox-test-box :content "A" :width '(100)) - (ebox-test-box :content "B" :width '(100)))) + (ebox-test-box (ebox-test-text "A") :width '(100)) + (ebox-test-box (ebox-test-text "B") :width '(100)))) (ids-before nil) buffer) (unwind-protect @@ -963,24 +987,37 @@ (ert-deftest ebox-runtime-reconcile-preserves-keyed-child-id () "Keyed children should keep runtime ids across structural rebuilds." (ebox-test--reset-runtime-state) - (let* ((old (ebox--runtime-tree - (ebox-test-column - (ebox-test-box :key 'a :content "A" :width 40) - (ebox-test-box :key 'b :content "B" :width 40)))) - (old-b-id (ebox--runtime-node-id-by-key old 'b)) - (new (ebox--reconcile-runtime-tree - old - (ebox-test-column - (ebox-test-box :key 'x :content "X" :width 40) - (ebox-test-box :key 'b :content "B2" :width 40))))) - (should (equal (ebox--runtime-node-id-by-key new 'b) old-b-id)))) + (let* ((old-input + (ebox-test-column + (ebox-test-box :key 'a (ebox-test-text "A") :width 40) + (ebox-test-box :key 'b (ebox-test-text "B") :width 40))) + (old (ebox--runtime-tree (ebox-test-root old-input))) + (old-b-id + (plist-get + (car (ebox-selector-query-all old-input "[key=b]")) :node-id)) + (new-input + (ebox-test-column + (ebox-test-box :key 'x (ebox-test-text "X") :width 40) + (ebox-test-box :key 'b (ebox-test-text "B2") :width 40))) + (new + (ebox-tree-reconcile-runtime + old (ebox-test-source-index old-input) + (ebox-test-root new-input) (ebox-test-source-index new-input)))) + (should + (equal + (plist-get + (car (ebox-selector-query-all + (ebox-canonical-input-create + (list new) (ebox-test-source-index new-input)) + "[key=b]")) + :node-id) + old-b-id)))) (ert-deftest ebox-tree-helpers-see-wrapper-child-nodes () "Tree helpers should see wrapper children without rendering static content." - (let* ((node (ebox-test-build - '(column :padding 1 - (box :content "Child" :width (120))))) - (children (ebox-tree-children node))) + (let* ((node (ebox-test-column :padding 1 + (ebox-test-box (ebox-test-text "Child") :width '(120)))) + (children (ebox-tree-children (ebox-test-root node)))) (should children) (should (plist-get (car children) :node-id)))) @@ -1295,12 +1332,14 @@ "Portable scroll templates rebind complete state to the candidate box." (ebox-test--reset-runtime-state) (let* ((buffer (generate-new-buffer " *ebox-portable-scroll-cache*")) - (old-box (ebox-test-box :content "old" :scroll-offset 0)) - (node-id (ebox--ensure-node-id old-box)) - (candidate-box (ebox-test-box :content "candidate" :scroll-offset 0)) + (old-box (ebox-test-box (ebox-test-text "old") :scroll-offset 0)) + (old-node (ebox-test-root old-box)) + (node-id (ebox--ensure-node-id old-node)) + (candidate-box (ebox-test-box (ebox-test-text "candidate") :scroll-offset 0)) + (candidate-node (ebox-test-root candidate-box)) (node-table (make-hash-table :test 'eql)) (template - (list :box old-box + (list :box old-node :content-lines '("alpha" "beta") :rendered-content-lines '("alpha") :content-height 1 @@ -1313,8 +1352,8 @@ :portable-p t :restorable-p t)) (ebox--render-cache-scroll-state-restorable-p nil)) - (plist-put candidate-box :node-id node-id) - (puthash node-id candidate-box node-table) + (plist-put candidate-node :node-id node-id) + (puthash node-id candidate-node node-table) (puthash buffer (list :node-table node-table) ebox--buffer-render-state-table) (puthash entry metadata ebox--render-cache-entry-side-effects-table) @@ -1324,7 +1363,7 @@ (should (ebox--render-cache-entry-reusable-p entry)) (ebox--render-cache-replay-entry-side-effects entry) (let ((state (ebox--scroll-get-state 'scroll))) - (should (eq (plist-get state :box) candidate-box)) + (should (eq (plist-get state :box) candidate-node)) (should (equal (plist-get state :content-lines) '("alpha" "beta"))) (should (equal (plist-get state :rendered-content-lines) @@ -1341,7 +1380,7 @@ (actions (list (list 'set 'scroll - (list :box (ebox-test-box :content "old" :scroll-offset 0) + (list :box (ebox-test-box (ebox-test-text "old") :scroll-offset 0) :content-lines '("alpha") :render-content-prefix prefix-renderer :materialize-content-lines materializer)))) @@ -1402,14 +1441,13 @@ (ebox-runtime-idle-prewarm nil) (ebox-runtime-idle-reflow-cache-prewarm nil) (layout - (ebox-test-build - `(box :id "root" :width (viewport) - (flex :width (viewport) :gap 1 - ,@(cl-loop for index below 8 - collect - `(box :id ,(format "card-%d" index) - :content ,(format "card %d" index) - :width 72 :height 1)))))) + (ebox-test-box :id "root" :width '(viewport) + (apply #'ebox-test-flex + (append (list :width '(viewport) :gap 1) + (cl-loop for index below 8 collect + (ebox-test-box :id (format "card-%d" index) + (ebox-test-text (format "card %d" index)) :width + 72 :height 1)))))) buffer) (unwind-protect (let ((ebox-viewport-width 240)) @@ -1430,15 +1468,14 @@ "A cached subtree must restore lazy scroll state for its render context." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-test-build - `(column :id "viewport" :width (viewport) - (box :id "scroll" :width (viewport) :height 4 - :overflow scroll - (column :width (viewport) - ,@(cl-loop for index below 32 - collect - `(box :content ,(format "cached row %02d" index) - :width (viewport) :height 1))))))) + (ebox-test-column :id "viewport" :width '(viewport) + (ebox-test-box :id "scroll" :width '(viewport) + :height 4 :overflow 'scroll + (apply #'ebox-test-column + (append (list :width '(viewport)) + (cl-loop for index below 32 collect + (ebox-test-box (ebox-test-text (format "cached row %02d" index)) + :width '(viewport) :height 1))))))) buffer scroll-id) (unwind-protect @@ -1486,9 +1523,9 @@ "An exact prepared root should bypass layout once and never affect children." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-test-build - '(box :id "root" :width (240) - (box :id "child" :content "prepared" :height 1)))) + (ebox-test-box :id "root" :width '(240) + (ebox-test-box :id "child" (ebox-test-text "prepared") :height + 1))) buffer) (unwind-protect (let ((ebox-viewport-width 900) @@ -1537,7 +1574,7 @@ (ert-deftest ebox-prepared-root-render-rejects-stale-runtime () "A stale prepared root should fall back without entering later updates." (ebox-test--reset-runtime-state) - (let* ((layout (ebox-test-build '(box :id "root" :width (240) :content "old"))) + (let* ((layout (ebox-test-box :id "root" :width '(240) (ebox-test-text "old"))) buffer (fallback-count 0)) (unwind-protect @@ -1570,7 +1607,7 @@ (let ((ebox--prepared-root-render prepared) (ebox--render-cache-table (make-hash-table :test 'equal))) - (cl-letf (((symbol-function 'ebox-render) + (cl-letf (((symbol-function 'ebox--render-node) (lambda (&rest _) (cl-incf fallback-count) "fresh"))) @@ -1585,9 +1622,9 @@ "Viewport predictions should reuse one nonmutating isolated live tree." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-test-build - '(box :id "root" :width (viewport) - (box :content "viewport scratch" :width (viewport) :height 1)))) + (ebox-test-box :id "root" :width '(viewport) + (ebox-test-box (ebox-test-text "viewport scratch") :width + '(viewport) :height 1))) buffer) (unwind-protect (let ((ebox-viewport-width 240) @@ -1622,9 +1659,9 @@ "Ordinary root prediction should run only after an idle interval." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-test-build - '(box :id "root" :width (240) - (box :content "scheduled ordinary root" :height 1)))) + (ebox-test-box :id "root" :width '(240) + (ebox-test-box (ebox-test-text "scheduled ordinary root") + :height 1))) buffer scheduled-delay scheduled-repeat @@ -1748,7 +1785,7 @@ "A TP paint slot should recolor retained output without property rewrites." (let* ((slot (tp-paint-slot-create '(:foreground "red"))) (face (tp-paint-slot-face slot)) - (rendered (ebox-render (ebox-test-box :content "paint" :color slot))) + (rendered (ebox-render (ebox-test-box (ebox-test-text "paint") :color slot))) (property (get-text-property 0 'face rendered))) (should (eq face property)) (tp-paint-slot-update slot '(:foreground "blue")) @@ -1852,18 +1889,20 @@ (ert-deftest ebox-scroll-rendered-content-lines-preserve-text-paint-in-place () "Scroll ownership should preserve Text paint and add Box background." (let* ((box (ebox-test-box :width 120 :bgcolor "#F4F5F6")) + (ebox--render-source-index (ebox-test-source-index box)) (region-id 901) (source (propertize "child" 'ebox-content 77 'ebox-content-owner 77 'ebox-content-owners '(77) 'face '(:foreground "#112233"))) (source-before (copy-sequence source)) - (filler (ebox-pixel-space (ebox--content-pixel box))) + (filler (ebox-pixel-space + (ebox-test--input-content-pixel box))) (expected (ebox--propertize-colors (ebox--propertize-content-line source region-id 4 filler) nil - (ebox-get box :bgcolor))) + (ebox-style-node-specified-value box :bgcolor))) (copying-owner-count 0) (original-copying-owner (symbol-function 'ebox--add-content-owner)) @@ -1874,7 +1913,7 @@ (apply original-copying-owner args)))) (setq actual (car (ebox--scroll-rendered-content-lines - box (list source) region-id 4)))) + (ebox-test-root box) (list source) region-id 4)))) (should (equal-including-properties source source-before)) (should (equal-including-properties actual expected)) (should (= copying-owner-count 0)))) @@ -1882,7 +1921,7 @@ (ert-deftest ebox-box-render-coalesces-foreground-background-pass () "Foreground and background should not each copy the rendered box lines." (ebox-test--reset-runtime-state) - (let* ((box (ebox-test-box :content "paint" + (let* ((box (ebox-test-box (ebox-test-text "paint") :width 12 :height 1 :color "#112233" :bgcolor "#ddeeff")) @@ -1907,7 +1946,7 @@ (ert-deftest ebox-incremental-update-scheduler-batches-multiple-style-changes () "Scheduler seed should coalesce multiple property changes into one flush." - (let* ((node (ebox-test-box :content "Batch" :border-color "#111111")) + (let* ((node (ebox-test-box (ebox-test-text "Batch") :border-color "#111111")) (id (car (ebox-region-ids node)))) (ebox-test--with-rendered-buffer node (ebox-incremental-begin-batch (current-buffer)) @@ -1925,10 +1964,9 @@ (dolist (order '((parent child) (child parent))) (ebox-test--reset-runtime-state) (let ((layout - (ebox-test-build - '(box :id parent :width (120) :color "black" - (box :id child :content "Child" :width (120) - :color "black"))))) + (ebox-test-box :id 'parent :width '(120) :color "black" + (ebox-test-box :id 'child (ebox-test-text "Child") :width + '(120) :color "black")))) (ebox-test--with-rendered-buffer layout (let* ((buffer (current-buffer)) (parent-id (ebox-test--selector-region-id buffer "#parent")) @@ -1950,10 +1988,9 @@ "Ancestor paint must run before a descendant text-changing operation." (ebox-test--reset-runtime-state) (let ((layout - (ebox-test-build - '(box :id parent :width (160) :color "black" - (box :id child :content "Child" :width (120) - :color "blue"))))) + (ebox-test-box :id 'parent :width '(160) :color "black" + (ebox-test-box :id 'child (ebox-test-text "Child") :width + '(120) :color "blue")))) (ebox-test--with-rendered-buffer layout (let* ((buffer (current-buffer)) (parent-id (ebox-test--selector-region-id buffer "#parent")) @@ -1971,30 +2008,51 @@ (defun ebox-test--multi-row-batch-flex-layout () "Return a wrapped flex fixture with targets on separate rows." - (ebox-test-build - '(flex :id matrix :width (500) :flex-flow (row wrap) - :column-gap (12) :row-gap 1 :padding (0 (12)) - :border "#999999" - (column :width (210) :border "#999999" - (box :content "Title A" :width (190)) - (box :id target-a :content "Target A" :width (160) - :height 3 :padding (0 (8)) :border "#999999" - :text-align right) - (box :content "Metric A" :width (190) :height 2)) - (column :width (210) :border "#999999" - (box :content "Peer A" :width (190)) - (box :content "Same first row" :width (190) :height 3) - (box :content "Metric B" :width (190) :height 2)) - (column :width (210) :border "#999999" - (box :content "Title B" :width (190)) - (box :id target-b :content "Target B" :width (160) - :height 3 :padding (0 (8)) :border "#999999" - :text-align right) - (box :content "Metric C" :width (190) :height 2)) - (column :width (210) :border "#999999" - (box :content "Peer B" :width (190)) - (box :content "Same second row" :width (190) :height 3) - (box :content "Metric D" :width (190) :height 2))))) + (ebox-test-flex :id 'matrix :width '(500) :flex-flow '(row wrap) + :column-gap '(12) :row-gap 1 :padding '(0 (12)) + :border "#999999" + (ebox-test-column :width '(210) :border "#999999" + (ebox-test-box (ebox-test-text "Title A") + :width '(190)) + (ebox-test-box :id 'target-a + (ebox-test-text "Target A") + :width '(160) :height + 3 :padding '(0 (8)) + :border "#999999" + :text-align 'right) + (ebox-test-box (ebox-test-text "Metric A") + :width '(190) :height + 2)) + (ebox-test-column :width '(210) :border "#999999" + (ebox-test-box (ebox-test-text "Peer A") + :width '(190)) + (ebox-test-box (ebox-test-text "Same first row") + :width '(190) :height + 3) + (ebox-test-box (ebox-test-text "Metric B") + :width '(190) :height + 2)) + (ebox-test-column :width '(210) :border "#999999" + (ebox-test-box (ebox-test-text "Title B") + :width '(190)) + (ebox-test-box :id 'target-b + (ebox-test-text "Target B") + :width '(160) :height + 3 :padding '(0 (8)) + :border "#999999" + :text-align 'right) + (ebox-test-box (ebox-test-text "Metric C") + :width '(190) :height + 2)) + (ebox-test-column :width '(210) :border "#999999" + (ebox-test-box (ebox-test-text "Peer B") + :width '(190)) + (ebox-test-box (ebox-test-text "Same second row") + :width '(190) :height + 3) + (ebox-test-box (ebox-test-text "Metric D") + :width '(190) :height + 2)))) (ert-deftest ebox-explicit-batch-multiple-flex-rows-skip-line-fast-path () "Dirty descendants on separate flex rows must rerender in either order." @@ -2052,20 +2110,18 @@ (ert-deftest ebox-layout-module-renders-row-and-column-through-facade () "Facade rendering should still use extracted layout owners." (let ((rendered (ebox-render - (ebox-test-build - '(column - (row - (box :content "A" :width (40)) - (box :content "B" :width (40)))))))) + (ebox-test-column + (ebox-test-row (ebox-test-box (ebox-test-text "A") :width '(40)) + (ebox-test-box (ebox-test-text "B") :width '(40))))))) (should (string-match-p "A" rendered)) (should (string-match-p "B" rendered)))) (ert-deftest ebox-layout-snapshot-details-include-footprint-signatures () "Detailed snapshots should expose explicit L2 footprint signatures." (ebox-test--reset-runtime-state) - (let* ((box (ebox-test-box :content "Footprint" :width '(120) + (let* ((box (ebox-test-box (ebox-test-text "Footprint") :width '(120) :height 1 :border t)) - (node-id (ebox--ensure-node-id box))) + (node-id (ebox--ensure-node-id (ebox-test-root box)))) (ebox-test--with-rendered-buffer box (let* ((snapshot (ebox--ensure-layout-snapshot-details (current-buffer) node-id)) @@ -2105,13 +2161,13 @@ :border "#64748B" :bgcolor "#F8FAFC" :flex-flow '(row wrap) (ebox-test-box - :content (format "A%d" index) + (ebox-test-text (format "A%d" index)) :width 60 :bgcolor "#DBEAFE") (ebox-test-box - :content (format "B%d" index) + (ebox-test-text (format "B%d" index)) :width 60 :bgcolor "#DCFCE7") (ebox-test-box - :content (format "C%d" index) + (ebox-test-text (format "C%d" index)) :width 60 :bgcolor "#FEF3C7"))))) (calls 0) (original (symbol-function 'ebox--pos-region-ids)) @@ -2141,7 +2197,7 @@ (cl-loop for index below 12 collect (ebox-test-box - :content (format "Row %02d" index) + (ebox-test-text (format "Row %02d" index)) :width (+ 40 (* index 2)))))) (multiline-width-calls 0) (original (symbol-function 'ebox--string-pixel-width))) @@ -2194,7 +2250,7 @@ (ert-deftest ebox-dirty-set-classifies-paint-only-change () "A color-only change should produce paint dirty, not geometry dirty." (ebox-test--reset-runtime-state) - (let* ((box (ebox-test-box :content "Paint" :width 80 + (let* ((box (ebox-test-box (ebox-test-text "Paint") :width 80 :border '(1 solid "red"))) (region-id (car (ebox-region-ids box)))) (ebox-test--with-rendered-buffer box @@ -2214,8 +2270,8 @@ layout) (setq layout (ebox-test-flex :flex-wrap 'wrap - (ebox-test-box :content "A" :width '(120)) - (ebox-test-box :content "B" :width '(120)))) + (ebox-test-box (ebox-test-text "A") :width '(120)) + (ebox-test-box (ebox-test-text "B") :width '(120)))) (unwind-protect (progn (setq buffer @@ -2238,10 +2294,9 @@ (ert-deftest ebox-dirty-set-classifies-wrapper-child-viewport-geometry () "Viewport reflow under a wrapper should produce geometry dirty, not no-op." (ebox-test--reset-runtime-state) - (let* ((node (ebox-test-build - '(column :padding 1 - (box :content "Text that wraps differently when narrow" - :wrap-mode word)))) + (let* ((node (ebox-test-column :padding 1 + (ebox-test-box (ebox-test-text "Text that wraps differently when narrow") + :wrap-mode 'word))) buffer) (unwind-protect (progn @@ -2261,7 +2316,7 @@ (ert-deftest ebox-patch-set-paints-without-rendered-text-replace () "Paint-only dirty entries should produce paint-patch ops." (ebox-test--reset-runtime-state) - (let* ((box (ebox-test-box :content "Paint" :width 80 + (let* ((box (ebox-test-box (ebox-test-text "Paint") :width 80 :border '(1 solid "red"))) (region-id (car (ebox-region-ids box)))) (ebox-test--with-rendered-buffer box @@ -2278,7 +2333,7 @@ (ebox-test--reset-runtime-state) (let* ((layout (ebox-test-flex :border "#5E7F6A" :bgcolor "#F8FCF8" - (ebox-test-box :content "A" :width 60 + (ebox-test-box (ebox-test-text "A") :width 60 :bgcolor "#DCEBDD"))) (region-id (car (ebox-region-ids layout)))) (ebox-test--with-rendered-buffer layout @@ -2297,8 +2352,8 @@ layout) (setq layout (ebox-test-flex :flex-wrap 'wrap - (ebox-test-box :content "A" :width '(120)) - (ebox-test-box :content "B" :width '(120)))) + (ebox-test-box (ebox-test-text "A") :width '(120)) + (ebox-test-box (ebox-test-text "B") :width '(120)))) (unwind-protect (progn (setq buffer @@ -2324,10 +2379,10 @@ "Viewport resize should publish the correct column scope through TP." (ebox-test--reset-runtime-state) (let* ((top (ebox-test-box - :content "Top text should wrap into more lines when narrow." + (ebox-test-text "Top text should wrap into more lines when narrow.") :wrap-mode 'word)) (bottom (ebox-test-box - :content "Bottom text should also survive after top grows." + (ebox-test-text "Bottom text should also survive after top grows.") :wrap-mode 'word)) (layout (ebox-test-column top bottom)) buffer) @@ -2357,7 +2412,7 @@ (ert-deftest ebox-viewport-rerender-noops-without-viewport-deps () "Viewport resize should not rerender when no node depends on viewport width." (ebox-test--reset-runtime-state) - (let ((layout (ebox-test-box :content "Fixed" :width 80)) + (let ((layout (ebox-test-box (ebox-test-text "Fixed") :width 80)) buffer) (unwind-protect (progn @@ -2378,8 +2433,8 @@ (ert-deftest ebox-region-width-update-report-describes-constraint-change () "Ordinary box geometry updates should use the shared constraint-change model." (ebox-test--reset-runtime-state) - (let* ((left (ebox-test-box :content "Left" :width 60)) - (right (ebox-test-box :content "Right" :width 60)) + (let* ((left (ebox-test-box (ebox-test-text "Left") :width 60)) + (right (ebox-test-box (ebox-test-text "Right") :width 60)) (layout (ebox-test-row left right)) (left-id (car (ebox-region-ids layout)))) (ebox-test--with-rendered-buffer layout @@ -2401,13 +2456,12 @@ (let* ((ebox-viewport-width 240) (ebox-viewport-height 2) (layout - (ebox-test-build - '(box :id "root" :width (240) :height (viewport-height) - :overflow scroll - (column - (box :content "first" :width (viewport)) - (box :content "second" :width (viewport)) - (box :content "third" :width (viewport)))))) + (ebox-test-box :id "root" :width '(240) :height '(viewport-height) + :overflow 'scroll + (ebox-test-column + (ebox-test-box (ebox-test-text "first") :width '(viewport)) + (ebox-test-box (ebox-test-text "second") :width '(viewport)) + (ebox-test-box (ebox-test-text "third") :width '(viewport))))) (root-id (car (ebox-region-ids layout)))) (ebox-test--with-rendered-buffer layout (ebox-test--region-update root-id :width '(160)) @@ -2420,11 +2474,10 @@ (ert-deftest ebox-wrapper-child-width-update-promotes-to-wrapper-context () "Child geometry inside a wrapper should promote to the wrapper formatting context." (ebox-test--reset-runtime-state) - (let* ((node (ebox-test-build - '(column :padding 1 - (row - (box :content "Left" :width (60)) - (box :content "Right" :width (60)))))) + (let* ((node (ebox-test-column :padding 1 + (ebox-test-row + (ebox-test-box (ebox-test-text "Left") :width '(60)) + (ebox-test-box (ebox-test-text "Right") :width '(60))))) (ids (ebox-region-ids node)) (left-id (nth 1 ids))) (ebox-test--with-rendered-buffer node @@ -2446,12 +2499,11 @@ (ert-deftest ebox-patch-set-uses-wrapper-owner-for-contained-layout-change () "Contained layout changes should patch a real owner instead of vanishing." (ebox-test--reset-runtime-state) - (let* ((node (ebox-test-build - '(column - (box :content "Stable header" :width (180)) - (column :padding 1 - (box :content "Child text wraps under the wrapper viewport" - :wrap-mode word))))) + (let* ((node (ebox-test-column + (ebox-test-box (ebox-test-text "Stable header") :width '(180)) + (ebox-test-column :padding 1 + (ebox-test-box (ebox-test-text "Child text wraps under the wrapper viewport") + :wrap-mode 'word)))) buffer) (unwind-protect (progn @@ -2478,7 +2530,7 @@ (cl-loop for index below 24 collect (ebox-test-box - :content (format "Fluid %02d" index) + (ebox-test-text (format "Fluid %02d" index)) :border t)))) buffer) (unwind-protect @@ -2518,7 +2570,7 @@ (cl-loop for index below 12 collect (ebox-test-box - :content (format "Fluid section %02d" index) + (ebox-test-text (format "Fluid section %02d" index)) :border t)))) buffer) (unwind-protect @@ -2553,10 +2605,10 @@ "alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu") (layout (apply #'ebox-test-column - (cl-loop for _index below 12 + (cl-loop repeat 12 collect (ebox-test-box - :content content + (ebox-test-text content) :width 'viewport :wrap-mode 'word :border t)))) @@ -2587,10 +2639,10 @@ "alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu") (layout (apply #'ebox-test-column - (cl-loop for _index below 12 + (cl-loop repeat 12 collect (ebox-test-box - :content content + (ebox-test-text content) :width 'viewport :wrap-mode 'word :border t)))) @@ -2622,7 +2674,7 @@ (ert-deftest ebox-buffer-render-context-selects-target-buffer () "Buffer render contexts should evaluate render-sensitive work in target buffers." (ebox-test--reset-runtime-state) - (let* ((node (ebox-test-box :content "context target")) + (let* ((node (ebox-test-box (ebox-test-text "context target"))) (buffer nil) (other nil) seen-buffer) @@ -2645,7 +2697,7 @@ (ert-deftest ebox-buffer-render-context-preserves-caller-state-binding () "Render context internals should not capture a caller's STATE variable." (ebox-test--reset-runtime-state) - (let* ((node (ebox-test-box :content "context state")) + (let* ((node (ebox-test-box (ebox-test-text "context state"))) (buffer nil) (state 'caller-scroll-state) seen-state) @@ -2860,7 +2912,7 @@ (ebox-test--reset-runtime-state) (let ((buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") - (ebox-test-box :content "before")))) + (ebox-test-box (ebox-test-text "before"))))) (unwind-protect (let ((noninteractive nil) (gc-cons-threshold 1000) @@ -2881,7 +2933,7 @@ ((symbol-function 'cancel-timer) #'ignore)) (let ((token (ebox-render-burst-begin))) (ebox-commit - buffer (ebox-test-box :content "after") + buffer (ebox-test-box (ebox-test-text "after")) (lambda (_report) (setq observed-depth ebox--deferred-render-gc-depth))) (should (= observed-depth 2)) @@ -2944,7 +2996,7 @@ (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") - (ebox-test-box :content "before"))) + (ebox-test-box (ebox-test-text "before")))) (let ((noninteractive nil) (gc-cons-threshold 1000) (gc-cons-percentage 0.1) @@ -2957,7 +3009,7 @@ (unwind-protect (progn (ebox-commit - buffer (ebox-test-box :content "after") + buffer (ebox-test-box (ebox-test-text "after")) (lambda (_report) (setq observed-threshold gc-cons-threshold))) (should (= observed-threshold 4000)) @@ -2984,7 +3036,7 @@ (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") - (ebox-test-box :width '(viewport) :content "resize"))) + (ebox-test-box :width '(viewport) (ebox-test-text "resize")))) (let ((noninteractive nil) (gc-cons-threshold 1000) (gc-cons-percentage 0.1) @@ -3044,7 +3096,7 @@ (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") - (ebox-test-box :width '(viewport) :content "resize"))) + (ebox-test-box :width '(viewport) (ebox-test-text "resize")))) (let ((noninteractive t) (gc-cons-threshold 1000) (gc-cons-percentage 0.1) @@ -3116,7 +3168,7 @@ (cl-loop for index below 4 collect (ebox-test-box - :content (format "S%d-%d" section index) + (ebox-test-text (format "S%d-%d" section index)) :width 80 :height 1)))))) buffer) @@ -3158,7 +3210,7 @@ (cl-loop for index below 4 collect (ebox-test-box - :content (format "S%d-%d" section index) + (ebox-test-text (format "S%d-%d" section index)) :width 80 :height 1)))))) buffer) @@ -3198,13 +3250,13 @@ "Block padding should publish through its parent without rebuilding line indexes." (ebox-test--reset-runtime-state) (let ((layout - (ebox-test-build - '(column :id "root" :width (480) - (box :id "target" :content "mixed padding" - :width (220) :height 3 :box-sizing border-box - :padding (0 (8)) :border "#999999") - (box :content "peer" :width (220) :height 3 - :border "#999999")))) + (ebox-test-column :id "root" :width '(480) + (ebox-test-box :id "target" (ebox-test-text "mixed padding") + :width '(220) :height 3 :box-sizing + 'border-box :padding '(0 (8)) :border + "#999999") + (ebox-test-box (ebox-test-text "peer") :width '(220) :height + 3 :border "#999999"))) (index-build-count 0)) (ebox-test--with-rendered-buffer layout (let ((region-id (ebox-test--selector-region-id (current-buffer) @@ -3228,12 +3280,13 @@ "Published public bounds should agree with TP-owned mount snapshots." (ebox-test--reset-runtime-state) (let ((layout - (ebox-test-build - '(column :id "root" :width (480) - (box :id "target" :width (220) :height 1 - :border "#999999" "target") - (box :id "peer" :width (220) :height 1 - :border "#999999" "peer"))))) + (ebox-test-column :id "root" :width '(480) + (ebox-test-box :id "target" :width '(220) :height 1 + :border "#999999" + (ebox-test-text "target")) + (ebox-test-box :id "peer" :width '(220) :height 1 + :border "#999999" + (ebox-test-text "peer"))))) (ebox-test--with-rendered-buffer layout (let* ((target-id (ebox-test--selector-region-id (current-buffer) "#target")) @@ -3281,7 +3334,7 @@ (cl-loop for index below 4 collect (ebox-test-box - :content (format "S%d-%d" section index) + (ebox-test-text (format "S%d-%d" section index)) :width 80 :height 1)))))) buffer) @@ -3317,7 +3370,7 @@ (cl-loop for index below 12 collect (ebox-test-box - :content (format "Item %02d" index) + (ebox-test-text (format "Item %02d" index)) :width 80 :height 1)))) buffer) @@ -3352,7 +3405,7 @@ (cl-loop for index below 10 collect (ebox-test-box - :content (format "Stretch %02d" index) + (ebox-test-text (format "Stretch %02d" index)) :width 80)))) buffer) (unwind-protect @@ -3384,7 +3437,7 @@ (cl-loop for index below 12 collect (ebox-test-box - :content (format "Measured %02d" index) + (ebox-test-text (format "Measured %02d" index)) :width 80 :height 1)))) buffer) @@ -3425,10 +3478,10 @@ :flex-flow '(row wrap) :column-gap '(8) (ebox-test-box - :content (format "A%d" index) + (ebox-test-text (format "A%d" index)) :width 48 :height 1) (ebox-test-box - :content (format "B%d" index) + (ebox-test-text (format "B%d" index)) :width 48 :height 1)))) (layout (apply @@ -3438,7 +3491,7 @@ :column-gap '(8) (mapcar (lambda (inner) (ebox-test-column - (ebox-test-box :content "Static card" + (ebox-test-box (ebox-test-text "Static card") :width 180 :height 1) inner)) @@ -3475,9 +3528,9 @@ (cl-loop for index below 6 collect (ebox-test-column - (ebox-test-box :content (format "Card %d" index) + (ebox-test-box (ebox-test-text (format "Card %d" index)) :width 96 :height 1) - (ebox-test-box :content "Body" :width 96 :height 2))))) + (ebox-test-box (ebox-test-text "Body") :width 96 :height 2))))) buffer) (unwind-protect (progn @@ -3495,10 +3548,10 @@ (ert-deftest ebox-render-cacheability-rejects-viewport-dependent-wrapper-child () "Wrapper boxes with viewport-dependent child layout should not reuse static render cache." (ebox-test--reset-runtime-state) - (let* ((node (ebox-test-build - '(column :padding 1 - (box :content "Auto child follows viewport" - :wrap-mode word))))) + (let* ((node (ebox-test-root + (ebox-test-column :padding 1 + (ebox-test-box (ebox-test-text "Auto child follows viewport") + :wrap-mode 'word))))) (let ((ebox--render-cache-table (make-hash-table :test 'equal)) (ebox--viewport-dependent-subtree-cache (make-hash-table :test 'eq))) @@ -3507,10 +3560,10 @@ (ert-deftest ebox-render-cacheability-allows-definite-width-wrapper-child () "Definite-width boxes bound descendant viewport-dependent layout." (ebox-test--reset-runtime-state) - (let* ((node (ebox-test-build - '(column :width (210) :padding 1 - (box :content "Child wraps inside the card" - :wrap-mode word))))) + (let* ((node (ebox-test-root + (ebox-test-column :width '(210) :padding 1 + (ebox-test-box (ebox-test-text "Child wraps inside the card") + :wrap-mode 'word))))) (let ((ebox--render-cache-table (make-hash-table :test 'equal)) (ebox--viewport-dependent-subtree-cache (make-hash-table :test 'eq))) @@ -3520,7 +3573,7 @@ (ert-deftest ebox-region-width-update-invalidates-viewport-dependencies () "Width updates can change which boxes participate in viewport resize." (ebox-test--reset-runtime-state) - (let* ((box (ebox-test-box :content "Resizable" :width 80)) + (let* ((box (ebox-test-box (ebox-test-text "Resizable") :width 80)) (box-id (car (ebox-region-ids box)))) (ebox-test--with-rendered-buffer box (ebox-rerender-buffer-with-context (current-buffer) 260) @@ -3533,10 +3586,10 @@ (ert-deftest ebox-viewport-dependencies-include-wrapper-child-layout () "Viewport dependency discovery should see lazy child layout under wrapper boxes." (ebox-test--reset-runtime-state) - (let* ((node (ebox-test-build - '(column :padding 1 - (box :content "Auto child follows the preview width" - :wrap-mode word)))) + (let* ((node (ebox-test-root + (ebox-test-column :padding 1 + (ebox-test-box (ebox-test-text "Auto child follows the preview width") + :wrap-mode 'word)))) (child (car (ebox--node-children node))) (ids (let ((ebox-viewport-width 360)) (ebox--runtime-node-ids node) @@ -3548,10 +3601,10 @@ (ert-deftest ebox-viewport-dependencies-stop-at-definite-width-wrapper () "Definite wrappers should contain descendant viewport dependencies." (ebox-test--reset-runtime-state) - (let* ((node (ebox-test-build - '(column :width (210) :padding 1 - (box :content "Auto child wraps inside the fixed card" - :wrap-mode word)))) + (let* ((node (ebox-test-root + (ebox-test-column :width '(210) :padding 1 + (ebox-test-box (ebox-test-text "Auto child wraps inside the fixed card") + :wrap-mode 'word)))) (child (car (ebox--node-children node))) (ids (let ((ebox-viewport-width 360)) (ebox--runtime-node-ids node) @@ -3565,12 +3618,9 @@ (let* ((viewport-width 120) (node (let ((ebox-viewport-width viewport-width) (ebox-viewport-height 2)) - (ebox-test-build - '(column :width (120) - (box :id child - :content "One\nTwo\nThree\nFour" - :height (viewport-height) - :overflow scroll))))) + (ebox-test-column :width '(120) + (ebox-test-box :id 'child (ebox-test-text "One\nTwo\nThree\nFour") :height + '(viewport-height) :overflow 'scroll)))) buffer) (unwind-protect (progn @@ -3601,14 +3651,22 @@ (ert-deftest ebox-viewport-dependency-axes-classify-both-dimensions () "A node may depend on width, height, or both viewport axes." (ebox-test--reset-runtime-state) - (let* ((width-only (ebox-test-box :content "Width" :width '(viewport))) - (height-only (ebox-test-box :content "Height" - :width '(120) - :height '(viewport-height))) - (both (ebox-test-box :content "Both" - :width '(viewport) - :height '(viewport-height))) - (layout (ebox-test-column width-only height-only both))) + (let* ((width-only-input + (ebox-test-box (ebox-test-text "Width") :width '(viewport))) + (height-only-input + (ebox-test-box (ebox-test-text "Height") + :width '(120) + :height '(viewport-height))) + (both-input + (ebox-test-box (ebox-test-text "Both") + :width '(viewport) + :height '(viewport-height))) + (layout-input + (ebox-test-column width-only-input height-only-input both-input)) + (width-only (ebox-test-root width-only-input)) + (height-only (ebox-test-root height-only-input)) + (both (ebox-test-root both-input)) + (layout (ebox-test-root layout-input))) (let ((ebox-viewport-width 360) (ebox-viewport-height 12) (ebox--viewport-dependent-node-ids-cache @@ -3629,9 +3687,8 @@ "Width-only viewport rerender should not dirty height-only roots." (ebox-test--reset-runtime-state) (let* ((node (let ((ebox-viewport-height 4)) - (ebox-test-build - '(column :width (120) :height (viewport-height) - (box :content "Static" :width (120)))))) + (ebox-test-column :width '(120) :height '(viewport-height) + (ebox-test-box (ebox-test-text "Static") :width '(120))))) buffer) (unwind-protect (progn @@ -3651,7 +3708,7 @@ "Fixed width should retain vertical dependencies without exposing width-only ones." (ebox-test--reset-runtime-state) (let* ((width-only - (ebox-test-box :content "Width" :width '(viewport))) + (ebox-test-box (ebox-test-text "Width") :width '(viewport))) (ordinary-height-nodes (cl-loop for (property value) in '((:height (viewport-height)) @@ -3659,7 +3716,7 @@ (:max-height (+ (viewport-height) 1))) collect (apply #'ebox-test-box - :content "Ordinary" + (ebox-test-text "Ordinary") (list property value)))) (flex-height-nodes (cl-loop for (property value) @@ -3670,29 +3727,34 @@ (apply #'ebox-test-flex (append (list property value) - (list (ebox-test-box :content "Flex")))))) + (list (ebox-test-box (ebox-test-text "Flex"))))))) (layout (apply #'ebox-test-column (append (list width-only) ordinary-height-nodes flex-height-nodes))) - (node (ebox-test-box :width '(210) - :wrap-mode nil - :ebox-content-node layout)) + (node (ebox-test-root + (ebox-test-box :width '(210) + :wrap-mode 'none + layout))) (ids (let ((ebox-viewport-width 360) (ebox-viewport-height 12)) (ebox--runtime-node-ids node) (ebox--viewport-dependent-node-ids node)))) - (should-not (member (plist-get width-only :node-id) ids)) + (should-not (member (plist-get (ebox-test-root width-only) :node-id) + ids)) (dolist (vertical-node (append ordinary-height-nodes flex-height-nodes)) - (should (member (plist-get vertical-node :node-id) ids))))) + (should (member (plist-get (ebox-test-root vertical-node) :node-id) + ids))))) (ert-deftest ebox-viewport-dependencies-ignore-flex-item-wrapper-auto-width () "Flex item wrappers should not be dirty only because their own width is auto." (ebox-test--reset-runtime-state) - (let* ((child (ebox-test-box :content "A" :width 80)) - (item (ebox-test-flex-item child)) - (node (ebox-test-flex :width 240 item)) + (let* ((child-input (ebox-test-box (ebox-test-text "A") :width 80)) + (item-input (ebox-test-flex-item child-input)) + (node (ebox-test-root (ebox-test-flex :width 240 item-input))) + (child (ebox-test-root child-input)) + (item (ebox-test-root item-input)) (ids (let ((ebox-viewport-width 360)) (ebox--runtime-node-ids node) (ebox--viewport-dependent-node-ids node)))) @@ -3702,7 +3764,7 @@ (ert-deftest ebox-buffer-viewport-dependency-cache-recomputes-after-style-change () "Changing responsive style should publish its exact viewport dependency." (ebox-test--reset-runtime-state) - (let* ((box (ebox-test-box :content "Resizable" :width 80)) + (let* ((box (ebox-test-box (ebox-test-text "Resizable") :width 80)) (box-id (car (ebox-region-ids box)))) (ebox-test--with-rendered-buffer box (ebox-rerender-buffer-with-context (current-buffer) 320) @@ -3730,10 +3792,10 @@ :flex-flow '(row wrap) :column-gap '(8) (ebox-test-box - :content (format "A%d" index) + (ebox-test-text (format "A%d" index)) :width 48 :height 1) (ebox-test-box - :content (format "B%d" index) + (ebox-test-text (format "B%d" index)) :width 48 :height 1)))) (layout (apply @@ -3743,7 +3805,7 @@ :column-gap '(8) (mapcar (lambda (inner) (ebox-test-column - (ebox-test-box :content "Static card" + (ebox-test-box (ebox-test-text "Static card") :width 180 :height 1) inner)) @@ -3772,15 +3834,16 @@ "Viewport resize should not recapture every lightweight snapshot eagerly." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-test-build - `(column :width (viewport) - ,@(cl-loop for idx below 24 - collect - `(box :id ,(format "resize-snapshot-%02d" idx) - :content ,(format "row %02d" idx) - :width (viewport) - :height 1))))) - (node-count (length (ebox--runtime-node-ids layout))) + (apply #'ebox-test-column + (append (list :width '(viewport)) + (cl-loop for idx below 24 collect + (ebox-test-box :id + (format "resize-snapshot-%02d" + idx) + (ebox-test-text (format "row %02d" idx)) :width + '(viewport) :height 1))))) + (layout-node (ebox-test-root layout)) + (node-count (length (ebox--runtime-node-ids layout-node))) buffer (snapshot-count 0) (original (symbol-function 'ebox--node-layout-snapshot))) @@ -3803,14 +3866,13 @@ "Viewport resize should republish region ranges through TP mounts." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-test-build - `(column :width (viewport) - ,@(cl-loop for idx below 24 - collect - `(box :id ,(format "resize-mount-%02d" idx) - :content ,(format "row %02d" idx) - :width (viewport) - :height 1))))) + (apply #'ebox-test-column + (append (list :width '(viewport)) + (cl-loop for idx below 24 collect + (ebox-test-box :id + (format "resize-mount-%02d" idx) + (ebox-test-text (format "row %02d" idx)) :width + '(viewport) :height 1))))) buffer target-id) (unwind-protect @@ -3839,10 +3901,11 @@ (let* ((layout (ebox-test-flex :width 240 - (ebox-test-column - (ebox-test-box :content "A" :width 80) - (ebox-test-box :content "B" :width 80)))) - (node-count (length (ebox--runtime-node-ids layout))) + (ebox-test-column + (ebox-test-box (ebox-test-text "A") :width 80) + (ebox-test-box (ebox-test-text "B") :width 80)))) + (layout-node (ebox-test-root layout)) + (node-count (length (ebox--runtime-node-ids layout-node))) (signature-count 0) (original (symbol-function 'ebox--render-cache-plist-signature)) (ebox--render-cache-signature-cache @@ -3851,8 +3914,8 @@ (lambda (&rest args) (cl-incf signature-count) (apply original args)))) - (ebox--render-cache-node-signature layout) - (ebox--render-cache-node-signature layout)) + (ebox--render-cache-node-signature layout-node) + (ebox--render-cache-node-signature layout-node)) (should (= signature-count node-count)))) (ert-deftest ebox-render-cache-value-signature-preserves-dotted-pairs () @@ -3866,7 +3929,8 @@ (ert-deftest ebox-render-cache-signature-includes-inline-auto-width-context () "Row intrinsic sizing must not reuse a cross-axis stretch render." (ebox-test--reset-runtime-state) - (let ((node (ebox-test-box :content "context-sensitive"))) + (let ((node (ebox-test-root + (ebox-test-box (ebox-test-text "context-sensitive"))))) (let ((normal (let ((ebox--inline-auto-width-intrinsic-p nil)) (ebox--render-cache-node-signature node))) (intrinsic (let ((ebox--inline-auto-width-intrinsic-p t)) @@ -3876,7 +3940,8 @@ (ert-deftest ebox-render-cache-signature-includes-intrinsic-layout-context () "Final layout must not reuse an intrinsic measurement render." (ebox-test--reset-runtime-state) - (let ((node (ebox-test-box :content "context-sensitive" :width '(80)))) + (let ((node (ebox-test-root + (ebox-test-box (ebox-test-text "context-sensitive") :width '(80))))) (let ((final (let ((ebox--intrinsic-layout-measurement nil)) (ebox--render-cache-node-signature node))) (intrinsic (let ((ebox--intrinsic-layout-measurement t)) @@ -3886,7 +3951,8 @@ (ert-deftest ebox-render-cache-signature-includes-rendered-region-identity () "Cached render strings should never cross distinct region identities." (ebox-test--reset-runtime-state) - (let* ((left (ebox-test-box :content "Stable" :width 80)) + (let* ((left (ebox-test-root + (ebox-test-box (ebox-test-text "Stable") :width 80))) (right (copy-tree left))) (plist-put left :node-id 1) (plist-put right :node-id 1) @@ -3899,7 +3965,8 @@ (ert-deftest ebox-render-cache-probe-reuses-first-render-output () "The first cache probe should retain the identity used by its output." (ebox-test--reset-runtime-state) - (let* ((node (ebox-test-box :content "Stable" :width 80)) + (let* ((node (ebox-test-root + (ebox-test-box (ebox-test-text "Stable") :width 80))) (ebox--surface-materialization-active t) (ebox--render-cache-table (make-hash-table :test 'equal)) (ebox--render-cache-signature-cache (make-hash-table :test 'eq)) @@ -3918,7 +3985,7 @@ (ert-deftest ebox-render-transaction-snapshots-display-signature () "One render transaction should sample its display signature once." (ebox-test--reset-runtime-state) - (let ((node (ebox-test-box :content "A" :width 80)) + (let ((node (ebox-test-root (ebox-test-box (ebox-test-text "A") :width 80))) (calls 0)) (cl-letf (((symbol-function 'ebox--display-signature) (lambda () @@ -3931,7 +3998,7 @@ (ert-deftest ebox-render-transaction-nesting-reuses-display-signature () "A nested render transaction should reuse the outer display snapshot." (ebox-test--reset-runtime-state) - (let ((node (ebox-test-box :content "A" :width 80)) + (let ((node (ebox-test-root (ebox-test-box (ebox-test-text "A") :width 80))) (calls 0)) (cl-letf (((symbol-function 'ebox--display-signature) (lambda () @@ -3944,7 +4011,7 @@ (ert-deftest ebox-render-transactions-refresh-display-signature () "Separate render transactions should sample display state independently." (ebox-test--reset-runtime-state) - (let ((node (ebox-test-box :content "A" :width 80)) + (let ((node (ebox-test-root (ebox-test-box (ebox-test-text "A") :width 80))) (calls 0) signatures) (cl-letf (((symbol-function 'ebox--display-signature) @@ -4007,9 +4074,10 @@ (ert-deftest ebox-render-cache-signature-keeps-viewport-axes-with-snapshot () "Display snapshots should not collapse width or height cache dimensions." (ebox-test--reset-runtime-state) - (let ((node (ebox-test-box :content "A" - :width '(viewport) - :height '(viewport-height))) + (let ((node (ebox-test-root + (ebox-test-box (ebox-test-text "A") + :width '(viewport) + :height '(viewport-height)))) signatures) (cl-letf (((symbol-function 'ebox--display-signature) (lambda () '(display stable)))) @@ -4028,9 +4096,10 @@ (ert-deftest ebox-render-cache-signature-ignores-unrelated-viewport-height () "Width-only subtree signatures should survive unrelated height changes." (ebox-test--reset-runtime-state) - (let* ((node (ebox-test-column - (ebox-test-box :content "A" :width '(viewport)) - (ebox-test-box :content "B" :width 80))) + (let* ((node (ebox-test-root + (ebox-test-column + (ebox-test-box (ebox-test-text "A") :width '(viewport)) + (ebox-test-box (ebox-test-text "B") :width 80)))) (left (let ((ebox-viewport-width 320) (ebox-viewport-height 10)) (ebox--render-cache-node-signature node))) @@ -4046,9 +4115,10 @@ (ebox-test-flex :width 240 (ebox-test-column - (ebox-test-box :content "A" :width 80) - (ebox-test-box :content "B" :width 80)))) - (node-count (length (ebox--runtime-node-ids layout))) + (ebox-test-box (ebox-test-text "A") :width 80) + (ebox-test-box (ebox-test-text "B") :width 80)))) + (layout-node (ebox-test-root layout)) + (node-count (length (ebox--runtime-node-ids layout-node))) (signature-count 0) (original (symbol-function 'ebox--render-cache-plist-signature)) buffer) @@ -4067,9 +4137,9 @@ (cl-incf signature-count) (apply original args)))) (ebox--with-buffer-render-context buffer - (ebox--render-cache-node-body-signature layout)) + (ebox--render-cache-node-body-signature layout-node)) (ebox--with-buffer-render-context buffer - (ebox--render-cache-node-body-signature layout))) + (ebox--render-cache-node-body-signature layout-node))) (should (= signature-count node-count))) (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) @@ -4077,7 +4147,8 @@ (ert-deftest ebox-render-cache-plist-signature-avoids-append-churn () "Render cache plist signatures should be built linearly." (ebox-test--reset-runtime-state) - (let ((node (ebox-test-box :content "Signature" :width 80 :border t)) + (let ((node (ebox-test-root + (ebox-test-box (ebox-test-text "Signature") :width 80 :border t))) (original-append (symbol-function 'append)) (append-calls 0)) (cl-letf (((symbol-function 'append) @@ -4093,7 +4164,10 @@ (let* ((content (ebox-lines-join (list (ebox-pixel-space 80) (ebox-pixel-space 80)))) - (box (ebox-test-box :content content :width 80 :wrap-mode nil)) + (box-input + (ebox-test-box (ebox-test-text content) :width 80 :wrap-mode 'none)) + (box (ebox-test-root box-input)) + (ebox--render-source-index (ebox-test-source-index box-input)) (measure-count 0) (original (symbol-function 'ebox--string-pixel-width))) (plist-put box :ebox-content-width-exact-p t) @@ -4108,8 +4182,11 @@ "Exact uniform lines should bypass proven no-op word/char formatting." (ebox-test--reset-runtime-state) (dolist (wrap-mode '(word char)) - (let* ((box (ebox-test-box :content "" :width '(120) - :wrap-mode wrap-mode :text-align 'left)) + (let* ((box-input + (ebox-test-box (ebox-test-text "") :width '(120) + :wrap-mode wrap-mode :text-align 'left)) + (box (ebox-test-root box-input)) + (ebox--render-source-index (ebox-test-source-index box-input)) (pixel-width (ebox--content-pixel box)) (line (propertize (ebox-pixel-space pixel-width) 'face '(:foreground "#123456"))) @@ -4137,9 +4214,12 @@ (dolist (case '((kp left 120) (word center 120) (char left 119))) - (let* ((box (ebox-test-box :content "" :width '(120) - :wrap-mode (nth 0 case) - :text-align (nth 1 case))) + (let* ((box-input + (ebox-test-box (ebox-test-text "") :width '(120) + :wrap-mode (nth 0 case) + :text-align (nth 1 case))) + (box (ebox-test-root box-input)) + (ebox--render-source-index (ebox-test-source-index box-input)) (content (ebox-pixel-space 120)) (wrap-count 0) (original-wrap (symbol-function 'ebox--wrap-text))) @@ -4154,9 +4234,9 @@ (ert-deftest ebox-transparent-preformatted-box-fast-path-is-exact () "Transparent exact-width wrappers should match the generic box pipeline." (ebox-test--reset-runtime-state) - (let* ((child-a (ebox-test-box :content "A" :width '(viewport) :border t + (let* ((child-a (ebox-test-box (ebox-test-text "A") :width '(viewport) :border t :region-id 'child-a)) - (child-b (ebox-test-box :content "B" :width '(viewport) :border t + (child-b (ebox-test-box (ebox-test-text "B") :width '(viewport) :border t :region-id 'child-b)) (parent (ebox-test-column :width '(viewport) :region-id 'parent child-a child-b)) @@ -4172,8 +4252,10 @@ (ert-deftest ebox-flat-preformatted-fast-path-keeps-intrinsic-scroll-state () "Intrinsic flat rendering must not clear the published scroll state." (ebox-test--reset-runtime-state) - (let* ((box (ebox-test-box :content "" :width '(40) :wrap-mode nil - :region-id 'intrinsic-wrapper)) + (let* ((box-input + (ebox-test-box (ebox-test-text "") :width '(40) :wrap-mode 'none + :region-id 'intrinsic-wrapper)) + (box (ebox-test-root box-input)) (content (ebox-pixel-space 40)) (state (list :scroll-offset 0 :sentinel t)) region-id) @@ -4182,7 +4264,7 @@ (setq region-id (ebox--ensure-region-id box)) (puthash region-id state ebox--scroll-global-state) (let ((ebox--intrinsic-layout-measurement t)) - (ebox-render box)) + (ebox-test--render-input-node box-input)) (should (eq (gethash region-id ebox--scroll-global-state) state)))) (ert-deftest ebox-flat-preformatted-blank-line-preserves-child-owner-segments () @@ -4200,8 +4282,11 @@ 'ebox-content 'right 'ebox-content-idx 0 'ebox-content-owner 'right 'ebox-content-owners '(right) 'face '(:foreground "#112233"))) - (box (ebox-test-box :content "" :width 100 :wrap-mode nil - :bgcolor "#DDEEFF")) + (box-input + (ebox-test-box (ebox-test-text "") :width 100 :wrap-mode 'none + :bgcolor "#DDEEFF")) + (box (ebox-test-root box-input)) + (ebox--render-source-index (ebox-test-source-index box-input)) (rendered (car (ebox--window-render-flat-wrapper-chunk-lines @@ -4227,22 +4312,24 @@ (ert-deftest ebox-transparent-preformatted-fast-path-keeps-intrinsic-scroll-state () "Intrinsic transparent rendering must not clear the published scroll state." (ebox-test--reset-runtime-state) - (let* ((child (ebox-test-box :content "" :width '(40) :wrap-mode nil - :region-id 'transparent-child)) - (parent (ebox-test-box :content "" :width '(40) :wrap-mode nil - :region-id 'transparent-parent - :ebox-content-node child)) + (let* ((child-input + (ebox-test-box (ebox-test-text "") :width '(40) :wrap-mode 'none + :region-id 'transparent-child)) + (child (ebox-test-root child-input)) + (parent (ebox-test-box (ebox-test-text "") :width '(40) :wrap-mode 'none + :region-id 'transparent-parent + child-input)) (content (ebox-pixel-space 40)) (state (list :scroll-offset 0 :sentinel t)) region-id) (plist-put child :content content) (plist-put child :ebox-content-width-exact-p t) - (ebox-render parent) + (ebox-test--render-input-node parent) (clrhash ebox--region-box-table) - (setq region-id (ebox--ensure-region-id parent)) + (setq region-id (ebox--ensure-region-id (ebox-test-root parent))) (puthash region-id state ebox--scroll-global-state) (let ((ebox--intrinsic-layout-measurement t)) - (ebox-render parent)) + (ebox-test--render-input-node parent)) (should (eq (gethash region-id ebox--scroll-global-state) state)))) (ert-deftest ebox-flat-preformatted-fixed-box-rejects-layout-work () @@ -4252,11 +4339,12 @@ ("one\ntwo\nthree\nfour" hidden top) ("one\ntwo\nthree" scroll top) ("one\ntwo\nthree" hidden center))) - (let* ((box + (let* ((box-input (ebox-test-box - :content (nth 0 case) :width '(120) :height 5 + (ebox-test-text (nth 0 case)) :width '(120) :height 5 :box-sizing 'border-box :overflow (nth 1 case) - :vertical-align (nth 2 case) :wrap-mode nil)) + :vertical-align (nth 2 case) :wrap-mode 'none)) + (box (ebox-test-root box-input)) (calls 0) (original (symbol-function 'ebox--window-render-flat-wrapper-chunk-lines))) @@ -4265,7 +4353,7 @@ (lambda (&rest arguments) (cl-incf calls) (apply original arguments)))) - (ebox-render box)) + (ebox-test--render-input-node box-input)) (should (= calls 0))))) (ert-deftest ebox-string-pixel-width-cache-ignores-ebox-ownership-metadata () @@ -4291,7 +4379,7 @@ (ert-deftest ebox-buffer-update-report-includes-dirty-and-patch-summary () "Update reports should show dirty and patch-set summaries." (ebox-test--reset-runtime-state) - (let* ((box (ebox-test-box :content "Report" :width 80 + (let* ((box (ebox-test-box (ebox-test-text "Report") :width 80 :border '(1 solid "red"))) (region-id (car (ebox-region-ids box)))) (ebox-test--with-rendered-buffer box @@ -4307,8 +4395,8 @@ (ert-deftest ebox-buffer-update-report-is-buffer-local () "Updating one rendered buffer should not replace another buffer's report." (ebox-test--reset-runtime-state) - (let* ((first-box (ebox-test-box :content "First" :width '(80))) - (second-box (ebox-test-box :content "Second" :width '(80))) + (let* ((first-box (ebox-test-box (ebox-test-text "First") :width '(80))) + (second-box (ebox-test-box (ebox-test-text "Second") :width '(80))) (first-id (car (ebox-region-ids first-box))) first-buffer second-buffer) (unwind-protect @@ -4340,7 +4428,7 @@ (ert-deftest ebox-buffer-update-report-returns-defensive-copy () "The public accessor should not return mutable render-state storage." (ebox-test--reset-runtime-state) - (let* ((box (ebox-test-box :content "Copy" :width '(80))) + (let* ((box (ebox-test-box (ebox-test-text "Copy") :width '(80))) buffer) (unwind-protect (progn @@ -4377,7 +4465,7 @@ (ert-deftest ebox-region-update-return-matches-buffer-update-report () "A direct region update should return the report stored on its buffer." (ebox-test--reset-runtime-state) - (let* ((box (ebox-test-box :content "Return" :width '(80) + (let* ((box (ebox-test-box (ebox-test-text "Return") :width '(80) :color "#111111")) (region-id (car (ebox-region-ids box)))) (ebox-test--with-rendered-buffer box @@ -4388,7 +4476,7 @@ (ert-deftest ebox-region-update-no-op-replaces-stale-buffer-report () "A direct unchanged update should publish its own no-op report." (ebox-test--reset-runtime-state) - (let* ((box (ebox-test-box :content "No-op" :width '(80) + (let* ((box (ebox-test-box (ebox-test-text "No-op") :width '(80) :color "#111111")) (region-id (car (ebox-region-ids box)))) (ebox-test--with-rendered-buffer box @@ -4405,7 +4493,7 @@ (ert-deftest ebox-batched-region-update-keeps-prior-report-until-flush () "Batched region updates should return nil and publish only on flush." (ebox-test--reset-runtime-state) - (let* ((box (ebox-test-box :content "Batch" :width '(80) + (let* ((box (ebox-test-box (ebox-test-text "Batch") :width '(80) :color "#111111")) (region-id (car (ebox-region-ids box)))) (ebox-test--with-rendered-buffer box @@ -4425,9 +4513,9 @@ (ert-deftest ebox-batch-flush-stores-report-on-flushed-buffer-only () "Empty and non-empty flushes should update only the target buffer report." (ebox-test--reset-runtime-state) - (let* ((first-box (ebox-test-box :content "First" :width '(80) + (let* ((first-box (ebox-test-box (ebox-test-text "First") :width '(80) :color "#111111")) - (second-box (ebox-test-box :content "Second" :width '(80) + (second-box (ebox-test-box (ebox-test-text "Second") :width '(80) :color "#111111")) (second-id (car (ebox-region-ids second-box))) first-buffer second-buffer) @@ -4475,7 +4563,7 @@ (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") - (ebox-test-box :content "Hello" :width 40))) + (ebox-test-box (ebox-test-text "Hello") :width 40))) (should (eq (current-buffer) origin)) (should (buffer-live-p buffer)) (with-current-buffer buffer @@ -4494,7 +4582,7 @@ (with-current-buffer buffer (use-local-map parent-map) (ebox-render-to-buffer buffer-name - (ebox-test-box :content "Hello" :width 40)) + (ebox-test-box (ebox-test-text "Hello") :width 40)) (should (eq (current-local-map) parent-map)) (should (eq (lookup-key (current-local-map) (kbd "x")) #'ignore))) (when (buffer-live-p buffer) @@ -4503,7 +4591,7 @@ (ert-deftest ebox-buffer-kill-clears-region-runtime-caches () "Killing an ebox buffer should clear region-scoped runtime cache entries." (ebox-test--reset-runtime-state) - (let* ((box (ebox-test-box :content "A\nB\nC" :width 80 :height 1 + (let* ((box (ebox-test-box (ebox-test-text "A\nB\nC") :width 80 :height 1 :overflow 'scroll)) (region-id (car (ebox-region-ids box))) (buffer (ebox-render-to-buffer @@ -4518,7 +4606,7 @@ (ert-deftest ebox-buffer-kill-discards-buffer-owned-runtime-state () "Killing an Ebox buffer should discard every buffer-keyed runtime entry." (ebox-test--reset-runtime-state) - (let* ((box (ebox-test-box :content "Lifecycle" :color "#111111")) + (let* ((box (ebox-test-box (ebox-test-text "Lifecycle") :color "#111111")) (region-id (car (ebox-region-ids box))) buffer surface handle) (unwind-protect @@ -4560,10 +4648,10 @@ (ert-deftest ebox-geometry-update-rerenders-root-once-safely () (ebox-test--reset-runtime-state) - (let* ((top (ebox-test-box :content "Top" :width 60)) - (left (ebox-test-box :content "L" :width 40)) - (right (ebox-test-box :content "R" :width 40)) - (layout (ebox-test-stack top (ebox-test-concat left right))) + (let* ((top (ebox-test-box (ebox-test-text "Top") :width 60)) + (left (ebox-test-box (ebox-test-text "L") :width 40)) + (right (ebox-test-box (ebox-test-text "R") :width 40)) + (layout (ebox-test-column top (ebox-test-row left right))) (ids (ebox-region-ids layout)) (left-id (nth 1 ids))) (ebox-test--with-rendered-buffer layout @@ -4578,10 +4666,10 @@ "A pure border color change should patch owned spans, not rebuild the root." (ebox-test--reset-runtime-state) (let ((layout - (ebox-test-build - '(row - (box :id left :width 40 :border "red" "L") - (box :id right :width 40 :border "green" "R"))))) + (ebox-test-row + (ebox-test-box :id 'left :width 40 :border "red" (ebox-test-text "L")) + (ebox-test-box :id 'right :width 40 :border "green" + (ebox-test-text "R"))))) (ebox-test--with-rendered-buffer layout (let ((left-id (ebox-test--selector-region-id (current-buffer) "#left"))) @@ -4599,25 +4687,24 @@ (ert-deftest ebox-flex-row-border-color-update-patches-region-role () "Paint-only updates inside flex rows should not fall back to root rerender." (ebox-test--reset-runtime-state) - (let* ((layout (ebox-test-build - '(flex :flex-flow (row wrap) :column-gap (12) - :padding (0 (12)) :border "#D97757" :bgcolor "#FFF8F2" - (column - (box :id left :content "Left" :width (120) - :padding (0 (8)) :border "#D97757") - (box :content "Body" :width (120) - :padding (0 (8)) - :border-right "#D97757" - :border-bottom "#D97757" - :border-left "#D97757")) - (column - (box :id right :content "Right" :width (120) - :padding (0 (8)) :border "#5E7F6A") - (box :content "Body" :width (120) - :padding (0 (8)) - :border-right "#5E7F6A" - :border-bottom "#5E7F6A" - :border-left "#5E7F6A"))))) + (let* ((layout (ebox-test-flex :flex-flow '(row wrap) :column-gap '(12) :padding + '(0 (12)) :border "#D97757" :bgcolor "#FFF8F2" + (ebox-test-column + (ebox-test-box :id 'left (ebox-test-text "Left") :width + '(120) :padding '(0 (8)) :border + "#D97757") + (ebox-test-box (ebox-test-text "Body") :width '(120) :padding + '(0 (8)) :border-right "#D97757" + :border-bottom "#D97757" :border-left + "#D97757")) + (ebox-test-column + (ebox-test-box :id 'right (ebox-test-text "Right") :width + '(120) :padding '(0 (8)) :border + "#5E7F6A") + (ebox-test-box (ebox-test-text "Body") :width '(120) :padding + '(0 (8)) :border-right "#5E7F6A" + :border-bottom "#5E7F6A" :border-left + "#5E7F6A")))) (left-id (plist-get (car (ebox-selector-query-all layout "#left")) :region-id))) @@ -4642,31 +4729,26 @@ (ert-deftest ebox-paint-update-synthesizes-missing-horizontal-border-surface () "Adding a missing top/bottom paint surface should patch from region anchors." (ebox-test--reset-runtime-state) - (let* ((layout (ebox-test-build - '(flex :flex-flow (row wrap) :column-gap (12) - :padding (0 (12)) :border "#D97757" :bgcolor "#FFF8F2" - (column - (box :id left :content "Left" :width (120) - :padding (0 (8)) - :border-top "#D97757" - :border-right "#D97757" - :border-left "#D97757") - (box :content "Body" :width (120) - :padding (0 (8)) - :border-right "#D97757" - :border-bottom "#D97757" - :border-left "#D97757")) - (column - (box :content "Right" :width (120) - :padding (0 (8)) - :border-top "#5E7F6A" - :border-right "#5E7F6A" - :border-left "#5E7F6A") - (box :content "Body" :width (120) - :padding (0 (8)) - :border-right "#5E7F6A" - :border-bottom "#5E7F6A" - :border-left "#5E7F6A"))))) + (let* ((layout (ebox-test-flex :flex-flow '(row wrap) :column-gap '(12) :padding + '(0 (12)) :border "#D97757" :bgcolor "#FFF8F2" + (ebox-test-column + (ebox-test-box :id 'left (ebox-test-text "Left") :width + '(120) :padding '(0 (8)) :border-top + "#D97757" :border-right "#D97757" + :border-left "#D97757") + (ebox-test-box (ebox-test-text "Body") :width '(120) :padding + '(0 (8)) :border-right "#D97757" + :border-bottom "#D97757" :border-left + "#D97757")) + (ebox-test-column + (ebox-test-box (ebox-test-text "Right") :width '(120) + :padding '(0 (8)) :border-top + "#5E7F6A" :border-right "#5E7F6A" + :border-left "#5E7F6A") + (ebox-test-box (ebox-test-text "Body") :width '(120) :padding + '(0 (8)) :border-right "#5E7F6A" + :border-bottom "#5E7F6A" :border-left + "#5E7F6A")))) (left-id (plist-get (car (ebox-selector-query-all layout "#left")) :region-id))) @@ -4694,31 +4776,26 @@ (ert-deftest ebox-paint-projection-keeps-old-fragment-metadata-immutable () "Paint projection should not mutate the previous state's fragment metadata." (ebox-test--reset-runtime-state) - (let* ((layout (ebox-test-build - '(flex :flex-flow (row wrap) :column-gap (12) - :padding (0 (12)) :border "#D97757" :bgcolor "#FFF8F2" - (column - (box :id left :content "Left" :width (120) - :padding (0 (8)) - :border-top "#D97757" - :border-right "#D97757" - :border-left "#D97757") - (box :content "Body" :width (120) - :padding (0 (8)) - :border-right "#D97757" - :border-bottom "#D97757" - :border-left "#D97757")) - (column - (box :content "Right" :width (120) - :padding (0 (8)) - :border-top "#5E7F6A" - :border-right "#5E7F6A" - :border-left "#5E7F6A") - (box :content "Body" :width (120) - :padding (0 (8)) - :border-right "#5E7F6A" - :border-bottom "#5E7F6A" - :border-left "#5E7F6A"))))) + (let* ((layout (ebox-test-flex :flex-flow '(row wrap) :column-gap '(12) :padding + '(0 (12)) :border "#D97757" :bgcolor "#FFF8F2" + (ebox-test-column + (ebox-test-box :id 'left (ebox-test-text "Left") :width + '(120) :padding '(0 (8)) :border-top + "#D97757" :border-right "#D97757" + :border-left "#D97757") + (ebox-test-box (ebox-test-text "Body") :width '(120) :padding + '(0 (8)) :border-right "#D97757" + :border-bottom "#D97757" :border-left + "#D97757")) + (ebox-test-column + (ebox-test-box (ebox-test-text "Right") :width '(120) + :padding '(0 (8)) :border-top + "#5E7F6A" :border-right "#5E7F6A" + :border-left "#5E7F6A") + (ebox-test-box (ebox-test-text "Body") :width '(120) :padding + '(0 (8)) :border-right "#5E7F6A" + :border-bottom "#5E7F6A" :border-left + "#5E7F6A")))) (left-id (plist-get (car (ebox-selector-query-all layout "#left")) :region-id))) @@ -4746,7 +4823,7 @@ (let* ((property (nth 0 case)) (removed-role (nth 1 case)) (kept-roles (nth 2 case)) - (layout (ebox-test-box :content "Border" + (layout (ebox-test-box (ebox-test-text "Border") :width 80 :padding '(0 (8)) :border "#D97757")) @@ -4776,7 +4853,7 @@ (ebox-test--reset-runtime-state) (let* ((layout (ebox-test-box - :content "Border" :width 80 + (ebox-test-text "Border") :width 80 :border-left-pixel 2 :border-left-color "#AA0000" :border-right-pixel 2 :border-right-color "#AA0000")) (region-id (car (ebox-region-ids layout)))) @@ -4809,12 +4886,11 @@ "Runtime rerender should resolve viewport-sized boxes with the new context." (ebox-test--reset-runtime-state) (let* ((node (let ((ebox-viewport-width 240)) - (ebox-test-build - '(box :content "Viewport" :width (viewport) :border t)))) + (ebox-test-box (ebox-test-text "Viewport") :width '(viewport) :border t))) buffer) (unwind-protect (progn - (should (eq (plist-get node :width) 'viewport)) + (should (eq (ebox-get (ebox-test-root node) :width) 'viewport)) (let ((ebox-viewport-width 240)) (setq buffer (ebox-render-to-buffer @@ -4836,11 +4912,8 @@ "Runtime rerender should resolve viewport-height boxes with the new context." (ebox-test--reset-runtime-state) (let* ((node (let ((ebox-viewport-height 2)) - (ebox-test-build - '(box :content "One\nTwo\nThree\nFour" - :width (120) - :height (viewport-height) - :overflow scroll)))) + (ebox-test-box (ebox-test-text "One\nTwo\nThree\nFour") :width '(120) :height + '(viewport-height) :overflow 'scroll))) buffer) (unwind-protect (progn @@ -4867,20 +4940,38 @@ "Stable flex-contained geometry changes should stop before root." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-test-build - '(column :id root :width (520) :padding (1 (18)) - (flex :id matrix :flex-flow (row wrap) - :column-gap (12) :row-gap 1 :padding (0 (12)) - :border "#999999" - (column :width (210) :border "#999999" - (box :content "Title" :width (190)) - (box :id target :content "width target\nsame height" - :width (160) :height 3 - :padding (0 (8)) :border "#999999") - (box :content "Metric" :width (190))) - (column :width (210) :border "#999999" - (box :content "Other" :width (190)) - (box :content "Still here" :width (190))))))) + (ebox-test-column :id 'root :width '(520) :padding '(1 (18)) + (ebox-test-flex :id 'matrix :flex-flow '(row wrap) + :column-gap '(12) :row-gap 1 + :padding '(0 (12)) :border "#999999" + (ebox-test-column :width '(210) + :border "#999999" + (ebox-test-box + (ebox-test-text "Title") + :width '(190)) + (ebox-test-box :id + 'target + (ebox-test-text "width target\nsame height") + :width + '(160) + :height + 3 + :padding + '(0 + (8)) + :border + "#999999") + (ebox-test-box + (ebox-test-text "Metric") + :width '(190))) + (ebox-test-column :width '(210) + :border "#999999" + (ebox-test-box + (ebox-test-text "Other") + :width '(190)) + (ebox-test-box + (ebox-test-text "Still here") + :width '(190)))))) (target (car (ebox-selector-query-all layout "#target"))) (target-id (plist-get target :region-id))) (ebox-test--with-rendered-buffer layout @@ -4896,7 +4987,7 @@ (defun ebox-test--nested-width-containment-layout (&optional height) "Return a nested flex width-update fixture with optional fixed HEIGHT." (let* ((target - (ebox-test-box :content "width target\nready to grow" + (ebox-test-box (ebox-test-text "width target\nready to grow") :width '(190) :height 3 :padding '(0 (10)) :border "#999999")) (owner @@ -4909,16 +5000,16 @@ (list target)))) (card (ebox-test-column - (ebox-test-box :content "Width" :width '(300) :height 2) + (ebox-test-box (ebox-test-text "Width") :width '(300) :height 2) owner - (ebox-test-box :content "The target stays in this card" + (ebox-test-box (ebox-test-text "The target stays in this card") :width '(300) :height 3) - (ebox-test-box :content "Metric" :width '(300) :height 4))) + (ebox-test-box (ebox-test-text "Metric") :width '(300) :height 4))) (peer (ebox-test-column - (ebox-test-box :content "Peer" :width '(300) :height 2) - (ebox-test-box :content "Stable peer" :width '(300) :height 5) - (ebox-test-box :content "Footer" :width '(300) :height 7))) + (ebox-test-box (ebox-test-text "Peer") :width '(300) :height 2) + (ebox-test-box (ebox-test-text "Stable peer") :width '(300) :height 5) + (ebox-test-box (ebox-test-text "Footer") :width '(300) :height 7))) (layout (ebox-test-flex :width '(720) :flex-flow '(row wrap) :column-gap '(12) card peer))) @@ -4928,16 +5019,16 @@ "A visible-overflow flex wrapper must not contain child width changes." (ebox-test--reset-runtime-state) (let* ((target - (ebox-test-box :content "child" :width '(190) + (ebox-test-box (ebox-test-text "child") :width '(190) :height 5 :min-height 5)) (owner (ebox-test-flex :width '(300) :height 2 :overflow 'visible :align-items 'flex-start target)) (layout - (ebox-test-stack owner - (ebox-test-box :content "footer" :width '(300) + (ebox-test-column owner + (ebox-test-box (ebox-test-text "footer") :width '(300) :height 2))) - (owner-node-id (ebox--ensure-node-id owner))) + (owner-node-id (ebox--ensure-node-id (ebox-test-root owner)))) (ebox-test--with-rendered-buffer layout (should (= (ebox-string-height (ebox-render owner)) 5)) (should-not (ebox--definite-containment-formatting-context-p @@ -4965,20 +5056,38 @@ "Flex main footprint changes should not render a large flex owner twice." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-test-build - '(column :id root :width (520) :padding (1 (18)) - (flex :id matrix :flex-flow (row wrap) - :column-gap (12) :row-gap 1 :padding (0 (12)) - :border "#999999" - (column :width (210) :border "#999999" - (box :content "Title" :width (190)) - (box :id target :content "width target\nsame height" - :width (160) :height 3 - :padding (0 (8)) :border "#999999") - (box :content "Metric" :width (190))) - (column :width (210) :border "#999999" - (box :content "Other" :width (190)) - (box :content "Still here" :width (190))))))) + (ebox-test-column :id 'root :width '(520) :padding '(1 (18)) + (ebox-test-flex :id 'matrix :flex-flow '(row wrap) + :column-gap '(12) :row-gap 1 + :padding '(0 (12)) :border "#999999" + (ebox-test-column :width '(210) + :border "#999999" + (ebox-test-box + (ebox-test-text "Title") + :width '(190)) + (ebox-test-box :id + 'target + (ebox-test-text "width target\nsame height") + :width + '(160) + :height + 3 + :padding + '(0 + (8)) + :border + "#999999") + (ebox-test-box + (ebox-test-text "Metric") + :width '(190))) + (ebox-test-column :width '(210) + :border "#999999" + (ebox-test-box + (ebox-test-text "Other") + :width '(190)) + (ebox-test-box + (ebox-test-text "Still here") + :width '(190)))))) (target (car (ebox-selector-query-all layout "#target"))) (target-id (plist-get target :region-id))) (ebox-test--with-rendered-buffer layout @@ -4998,14 +5107,14 @@ nil (or width '(2600)))) (target - (ebox-test-box :id "target" :content "width target\nmoves sibling layout" + (ebox-test-box :id "target" (ebox-test-text "width target\nmoves sibling layout") :width '(160) :height 3 :padding '(0 (8)) :border "#999999")) (first (ebox-test-column - (ebox-test-box :content "Title" :width '(190)) + (ebox-test-box (ebox-test-text "Title") :width '(190)) target - (ebox-test-box :content "Metric" :width '(190) :height 4)))) + (ebox-test-box (ebox-test-text "Metric") :width '(190) :height 4)))) (apply #'ebox-test-flex (append (list :flex-flow '(row wrap) :column-gap '(12) @@ -5019,13 +5128,13 @@ collect (ebox-test-column (ebox-test-box - :content (format "Title %02d" index) + (ebox-test-text (format "Title %02d" index)) :width '(190)) - (ebox-test-box :content "Peer\nsame row" + (ebox-test-box (ebox-test-text "Peer\nsame row") :width '(190) :height 3 :padding '(0 (8)) :border "#999999") - (ebox-test-box :content "Metric" + (ebox-test-box (ebox-test-text "Metric") :width '(190) :height 4))))))) (ert-deftest ebox-flex-owner-update-keeps-unrelated-snapshots-current () @@ -5056,61 +5165,160 @@ (defun ebox-test--dynamic-update-row-layout () "Return a small flex-row layout shaped like the dynamic update matrix." - (ebox-test-build - '(column :id root :width (1200) :padding (1 (18)) - (flex :id matrix :flex-flow (row wrap) - :column-gap (12) :row-gap 1 :padding (0 (12)) - :border "#999999" - (column :width (210) :border "#999999" - (box :content "Reference\nshort" :width (190)) - (box :id scroll :content "line 1\nline 2\nline 3\nline 4" - :width (190) :height 2 :padding (0 (8)) - :border "#999999" :overflow scroll) - (box :content "Metric" :width (190) :height 4)) - (column :width (210) :border "#999999" - (box :content "Height\nowner" :width (190)) - (box :id height :content "height target\nexpands block" - :width (190) :height 2 :padding (0 (8)) - :border "#999999") - (box :content "Metric" :width (190) :height 4)) - (column :width (210) :border "#999999" - (box :content "Margin\nslot stable" :width (190)) - (box :id margin :content "margin target\nparent placement" - :width (170) :height 3 :padding (0 (8)) - :border "#999999") - (box :content "Metric" :width (190) :height 4)) - (column :width (210) :border "#999999" - (box :content "Overflow\nowner" :width (190)) - (box :id overflow :content "overflow target\nextra line\nanother line" - :width (190) :height 2 :padding (0 (8)) - :border "#999999" :overflow scroll) - (box :content "Metric" :width (190) :height 4)) - (column :width (210) :border "#999999" - (box :content "Tall peer\nkeeps row slot" :width (190)) - (box :content "tall\npeer\nsets\nline\ncross" :width (190) - :height 5 :padding (0 (8)) :border "#999999") - (box :content "Metric" :width (190) :height 4)))))) + (ebox-test-column :id 'root :width '(1200) :padding '(1 (18)) + (ebox-test-flex :id 'matrix :flex-flow '(row wrap) + :column-gap '(12) :row-gap 1 + :padding '(0 (12)) :border "#999999" + (ebox-test-column :width '(210) + :border "#999999" + (ebox-test-box + (ebox-test-text "Reference\nshort") + :width '(190)) + (ebox-test-box :id + 'scroll + (ebox-test-text "line 1\nline 2\nline 3\nline 4") + :width + '(190) + :height + 2 + :padding + '(0 + (8)) + :border + "#999999" + :overflow + 'scroll) + (ebox-test-box + (ebox-test-text "Metric") + :width '(190) + :height 4)) + (ebox-test-column :width '(210) + :border "#999999" + (ebox-test-box + (ebox-test-text "Height\nowner") + :width '(190)) + (ebox-test-box :id + 'height + (ebox-test-text "height target\nexpands block") + :width + '(190) + :height + 2 + :padding + '(0 + (8)) + :border + "#999999") + (ebox-test-box + (ebox-test-text "Metric") + :width '(190) + :height 4)) + (ebox-test-column :width '(210) + :border "#999999" + (ebox-test-box + (ebox-test-text "Margin\nslot stable") + :width '(190)) + (ebox-test-box :id + 'margin + (ebox-test-text "margin target\nparent placement") + :width + '(170) + :height + 3 + :padding + '(0 + (8)) + :border + "#999999") + (ebox-test-box + (ebox-test-text "Metric") + :width '(190) + :height 4)) + (ebox-test-column :width '(210) + :border "#999999" + (ebox-test-box + (ebox-test-text "Overflow\nowner") + :width '(190)) + (ebox-test-box :id + 'overflow + (ebox-test-text "overflow target\nextra line\nanother line") + :width + '(190) + :height + 2 + :padding + '(0 + (8)) + :border + "#999999" + :overflow + 'scroll) + (ebox-test-box + (ebox-test-text "Metric") + :width '(190) + :height 4)) + (ebox-test-column :width '(210) + :border "#999999" + (ebox-test-box + (ebox-test-text "Tall peer\nkeeps row slot") + :width '(190)) + (ebox-test-box + (ebox-test-text "tall\npeer\nsets\nline\ncross") + :width '(190) + :height 5 + :padding '(0 (8)) + :border "#999999") + (ebox-test-box + (ebox-test-text "Metric") + :width '(190) + :height 4))))) (defun ebox-test--dynamic-update-tight-row-layout () "Return a flex-row layout whose target growth exceeds its old item slot." - (ebox-test-build - '(column :id root :width (720) - (box :content "Header" :width (720)) - (column :id panel :width (720) :border "#999999" - (flex :id matrix :flex-flow (row wrap) - :column-gap (12) :row-gap 1 :padding (0 (12)) - :border "#999999" - (column :width (210) :border "#999999" - (box :content "Height\nowner" :width (190)) - (box :id height :content "height target\nexpands block" - :width (190) :height 2 :padding (0 (8)) - :border "#999999") - (box :content "Metric" :width (190) :height 2)) - (column :width (210) :border "#999999" - (box :content "Peer" :width (190)) - (box :content "peer\nsame row" :width (190) - :height 2 :padding (0 (8)) :border "#999999") - (box :content "Metric" :width (190) :height 2))))))) + (ebox-test-column :id 'root :width '(720) + (ebox-test-box (ebox-test-text "Header") :width '(720)) + (ebox-test-column :id 'panel :width '(720) :border + "#999999" + (ebox-test-flex :id 'matrix + :flex-flow + '(row wrap) + :column-gap '(12) + :row-gap 1 + :padding '(0 (12)) + :border "#999999" + (ebox-test-column + :width '(210) + :border "#999999" + (ebox-test-box + (ebox-test-text "Height\nowner") + :width '(190)) + (ebox-test-box + :id 'height + (ebox-test-text "height target\nexpands block") + :width '(190) + :height 2 + :padding + '(0 (8)) :border + "#999999") + (ebox-test-box + (ebox-test-text "Metric") :width + '(190) :height 2)) + (ebox-test-column + :width '(210) + :border "#999999" + (ebox-test-box + (ebox-test-text "Peer") + :width '(190)) + (ebox-test-box + (ebox-test-text "peer\nsame row") + :width '(190) + :height 2 + :padding + '(0 (8)) :border + "#999999") + (ebox-test-box + (ebox-test-text "Metric") :width + '(190) :height 2)))))) (defun ebox-test--selector-region-id (buffer selector) "Return the first editable region id matching SELECTOR in BUFFER." @@ -5145,22 +5353,28 @@ (defun ebox-test--buffer-visually-matches-runtime-render-p () "Return non-nil when current buffer visually equals its runtime root render." - (let* ((ebox--scroll-global-state (make-hash-table :test 'equal)) + (let* ((state (ebox--buffer-render-state (current-buffer))) + (ebox--scroll-global-state (make-hash-table :test 'equal)) (ebox--region-box-table (make-hash-table :test 'equal)) (expected (ebox--with-buffer-render-context (current-buffer) - (ebox-render (ebox--buffer-root-node (current-buffer)))))) + (ebox--render-node + (plist-get state :root-node) + (plist-get state :source-index))))) (ebox-test--rendered-visually-equal-p (buffer-substring (point-min) (point-max)) expected))) (defun ebox-test--buffer-propertized-matches-runtime-render-p () "Return non-nil when the current buffer exactly matches a runtime render." - (let* ((ebox--scroll-global-state (make-hash-table :test 'equal)) + (let* ((state (ebox--buffer-render-state (current-buffer))) + (ebox--scroll-global-state (make-hash-table :test 'equal)) (ebox--region-box-table (make-hash-table :test 'equal)) (expected (ebox--with-buffer-render-context (current-buffer) - (ebox-render (ebox--buffer-root-node (current-buffer)))))) + (ebox--render-node + (plist-get state :root-node) + (plist-get state :source-index))))) (equal-including-properties (buffer-substring (point-min) (point-max)) expected))) @@ -5182,13 +5396,10 @@ "TP scroll publication must preserve per-character display properties." (ebox-test--reset-runtime-state) (let ((layout - (ebox-test-build - '(box :id scroll - :content "第 1 行\n第 2 行\n第 3 行\n第 4 行" - :width (230) :height 2 :padding (0 (10)) - :border "#9CA3AF" :bgcolor "#F8FAFC" - :color "#3D3A35" :text-align center - :overflow scroll :scroll-offset 0)))) + (ebox-test-box :id 'scroll (ebox-test-text "第 1 行\n第 2 行\n第 3 行\n第 4 行") :width '(230) + :height 2 :padding '(0 (10)) :border "#9CA3AF" :bgcolor + "#F8FAFC" :color "#3D3A35" :text-align 'center + :overflow 'scroll :scroll-offset 0))) (ebox-test--with-rendered-buffer layout (let ((region-id (ebox-test--selector-region-id (current-buffer) "#scroll")) @@ -5210,11 +5421,8 @@ "Scroll marker patches should not shift later buffer positions." (ebox-test--reset-runtime-state) (let ((layout - (ebox-test-build - '(box :id scroll :content - "x\nsubstantially longer visible line" - :width (180) :height 1 :overflow scroll - :scroll-offset 0)))) + (ebox-test-box :id 'scroll (ebox-test-text "x\nsubstantially longer visible line") :width '(180) + :height 1 :overflow 'scroll :scroll-offset 0))) (ebox-test--with-rendered-buffer layout (let* ((region-id (ebox-test--selector-region-id (current-buffer) "#scroll")) @@ -5236,12 +5444,11 @@ "Nested scroll updates should atomically survive outer cache replay." (ebox-test--reset-runtime-state) (let ((layout - (ebox-test-build - '(box :id root :width (180) :height 2 :overflow scroll - (column - (box :id inner :width (180) :height 2 :overflow scroll - :content "inner 0\ninner 1\ninner 2\ninner 3") - (box :content "outer 0\nouter 1" :height 2))))) + (ebox-test-box :id 'root :width '(180) :height 2 :overflow 'scroll + (ebox-test-column + (ebox-test-box :id 'inner :width '(180) :height 2 + :overflow 'scroll (ebox-test-text "inner 0\ninner 1\ninner 2\ninner 3")) + (ebox-test-box (ebox-test-text "outer 0\nouter 1") :height 2)))) root-id inner-id) (ebox-test--with-rendered-buffer layout (setq root-id (ebox-test--selector-region-id (current-buffer) "#root") @@ -5271,13 +5478,12 @@ "Child-layout scroll boxes should publish one retained viewport update." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-test-build - '(box :id "root" :height 2 :overflow scroll - (column - (box :id "a" :content "A" :height 1) - (box :id "b" :content "B" :height 1) - (box :id "c" :content "C" :height 1) - (box :id "d" :content "D" :height 1))))) + (ebox-test-box :id "root" :height 2 :overflow 'scroll + (ebox-test-column + (ebox-test-box :id "a" (ebox-test-text "A") :height 1) + (ebox-test-box :id "b" (ebox-test-text "B") :height 1) + (ebox-test-box :id "c" (ebox-test-text "C") :height 1) + (ebox-test-box :id "d" (ebox-test-text "D") :height 1)))) (root-id (car (ebox-region-ids layout)))) (ebox-test--with-rendered-buffer layout (let* ((state (ebox--scroll-get-state root-id)) @@ -5302,17 +5508,15 @@ "Fast scroll window patches should keep scroll box padding and border." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-test-build - '(box :id "root" :height 4 :box-sizing border-box - :overflow scroll - :padding-top 1 :padding-bottom 1 - :border-top-p t :border-top-color "red" - :border-bottom-p t :border-bottom-color "blue" - (column - (box :id "a" :content "A" :height 1) - (box :id "b" :content "B" :height 1) - (box :id "c" :content "C" :height 1) - (box :id "d" :content "D" :height 1))))) + (ebox-test-box :id "root" :height 4 :box-sizing 'border-box :overflow + 'scroll :padding-top 1 :padding-bottom 1 :border-top-p + t :border-top-color "red" :border-bottom-p t + :border-bottom-color "blue" + (ebox-test-column + (ebox-test-box :id "a" (ebox-test-text "A") :height 1) + (ebox-test-box :id "b" (ebox-test-text "B") :height 1) + (ebox-test-box :id "c" (ebox-test-text "C") :height 1) + (ebox-test-box :id "d" (ebox-test-text "D") :height 1)))) (root-id (car (ebox-region-ids layout))) ) (ebox-test--with-rendered-buffer layout @@ -5406,14 +5610,18 @@ "Fixed border-box padding changes should remain an internal fragment impact." (ebox-test--reset-runtime-state) (let ((layout - (ebox-test-build - '(column :id root :width (480) - (flex :id row :flex-flow (row nowrap) :column-gap (12) - (box :id padding :content "padding target" - :width (190) :height 3 :box-sizing border-box - :padding (0 (8)) :border "#999999") - (box :content "peer" :width (190) :height 3 - :border "#999999")))))) + (ebox-test-column :id 'root :width '(480) + (ebox-test-flex :id 'row :flex-flow '(row nowrap) + :column-gap '(12) + (ebox-test-box :id 'padding (ebox-test-text "padding target") + :width '(190) :height + 3 :box-sizing + 'border-box :padding + '(0 (8)) :border + "#999999") + (ebox-test-box (ebox-test-text "peer") + :width '(190) :height + 3 :border "#999999"))))) (ebox-test--with-rendered-buffer layout (let ((region-id (ebox-test--selector-region-id (current-buffer) "#padding"))) @@ -5430,14 +5638,18 @@ "Mixed block/inline padding shorthand should not promote to a root rerender." (ebox-test--reset-runtime-state) (let ((layout - (ebox-test-build - '(column :id root :width (480) - (flex :id row :flex-flow (row nowrap) :column-gap (12) - (box :id padding :content "padding target" - :width (190) :height 4 :box-sizing border-box - :padding (0 (8)) :border "#999999") - (box :content "peer" :width (190) :height 4 - :border "#999999")))))) + (ebox-test-column :id 'root :width '(480) + (ebox-test-flex :id 'row :flex-flow '(row nowrap) + :column-gap '(12) + (ebox-test-box :id 'padding (ebox-test-text "padding target") + :width '(190) :height + 4 :box-sizing + 'border-box :padding + '(0 (8)) :border + "#999999") + (ebox-test-box (ebox-test-text "peer") + :width '(190) :height + 4 :border "#999999"))))) (ebox-test--with-rendered-buffer layout (let* ((match (car (ebox-selector-query-buffer (current-buffer) "#padding"))) @@ -5468,10 +5680,9 @@ "Border-box padding cannot claim a fixed footprint without a content line." (ebox-test--reset-runtime-state) (let ((layout - (ebox-test-build - '(box :id padding :content "padding target" - :width (190) :height 4 :box-sizing border-box - :padding 2 :border "#999999")))) + (ebox-test-box :id 'padding (ebox-test-text "padding target") :width '(190) + :height 4 :box-sizing 'border-box :padding 2 :border + "#999999"))) (ebox-test--with-rendered-buffer layout (let* ((match (car (ebox-selector-query-buffer (current-buffer) "#padding"))) @@ -5568,13 +5779,13 @@ (ert-deftest ebox-overflow-reversal-skips-layout-only-ancestor () "Old visible suffix coverage should skip ancestors without wrapper ownership." (ebox-test--reset-runtime-state) - (let* ((target (ebox-test-box :content "A\nB\nC" :height 1 + (let* ((target (ebox-test-box (ebox-test-text "A\nB\nC") :height 1 :overflow 'visible)) (region-id (car (ebox-region-ids target))) - (inner (ebox-test-stack target (ebox-test-box :content "TAIL"))) - (layout (ebox-test-stack inner (ebox-test-box :content "ROOT TAIL"))) - (inner-id (ebox--ensure-node-id inner)) - (root-id (ebox--ensure-node-id layout))) + (inner (ebox-test-column target (ebox-test-box (ebox-test-text "TAIL")))) + (layout (ebox-test-column inner (ebox-test-box (ebox-test-text "ROOT TAIL")))) + (inner-id (ebox--ensure-node-id (ebox-test-root inner))) + (root-id (ebox--ensure-node-id (ebox-test-root layout)))) (ebox-test--with-rendered-buffer layout (ebox--clear-layout-snapshots (current-buffer)) (ebox-test--region-update region-id :overflow 'hidden) @@ -5590,14 +5801,14 @@ (ert-deftest ebox-overflow-reversal-skips-visible-wrapper-ancestor () "A visible wrapper cannot cover its own old unowned overflow suffix." (ebox-test--reset-runtime-state) - (let* ((target (ebox-test-box :content "A\nB\nC" :height 1 + (let* ((target (ebox-test-box (ebox-test-text "A\nB\nC") :height 1 :overflow 'visible)) (region-id (car (ebox-region-ids target))) - (wrapper (ebox-test-box :ebox-content-node target :width '(100) + (wrapper (ebox-test-box target :width '(100) :height 1 :overflow 'visible)) - (layout (ebox-test-stack wrapper (ebox-test-box :content "TAIL"))) - (wrapper-id (ebox--ensure-node-id wrapper)) - (root-id (ebox--ensure-node-id layout))) + (layout (ebox-test-column wrapper (ebox-test-box (ebox-test-text "TAIL")))) + (wrapper-id (ebox--ensure-node-id (ebox-test-root wrapper))) + (root-id (ebox--ensure-node-id (ebox-test-root layout)))) (ebox-test--with-rendered-buffer layout (ebox--clear-layout-snapshots (current-buffer)) (ebox-test--region-update region-id :overflow 'hidden) @@ -5614,13 +5825,19 @@ "Coverage should climb above an enclosing visible wrapper's old footprint." (ebox-test--reset-runtime-state) (let ((layout - (ebox-test-build - '(column :id root :width (100) - (box :id outer :width (100) :height 2 :overflow visible - (box :id inner :width (100) - (box :id target :content "A\nB\nC" :height 1 - :overflow visible))) - (box :content "TAIL"))))) + (ebox-test-column :id 'root :width '(100) + (ebox-test-box :id 'outer :width '(100) :height 2 + :overflow 'visible + (ebox-test-box :id 'inner :width + '(100) + (ebox-test-box :id + 'target + (ebox-test-text "A\nB\nC") + :height + 1 + :overflow + 'visible))) + (ebox-test-box (ebox-test-text "TAIL"))))) (ebox-test--with-rendered-buffer layout (let* ((region-id (ebox-test--selector-region-id (current-buffer) "#target")) @@ -5652,11 +5869,11 @@ (let* ((old-overflow (car transition)) (new-overflow (cadr transition)) (layout - (ebox-test-build - `(flex :id root :width (200) - (box :id target :content "A\nB\nC" :width (80) :height 1 - :overflow ,old-overflow) - (box :id peer :content "P" :width (80) :height 1))))) + (ebox-test-flex :id 'root :width '(200) + (ebox-test-box :id 'target (ebox-test-text "A\nB\nC") :width + '(80) :height 1 :overflow old-overflow) + (ebox-test-box :id 'peer (ebox-test-text "P") :width '(80) + :height 1)))) (ebox-test--with-rendered-buffer layout (let* ((region-id (ebox-test--selector-region-id (current-buffer) "#target")) @@ -5681,12 +5898,13 @@ (let* ((old-overflow (car transition)) (new-overflow (cadr transition)) (layout - (ebox-test-build - `(flex :id root :width (220) - (box :id card :width (100) - (box :id target :content "A\nB\nC" :width (80) :height 1 - :overflow ,old-overflow)) - (box :id peer :content "P" :width (80) :height 1))))) + (ebox-test-flex :id 'root :width '(220) + (ebox-test-box :id 'card :width '(100) + (ebox-test-box :id 'target (ebox-test-text "A\nB\nC") :width '(80) + :height 1 :overflow + old-overflow)) + (ebox-test-box :id 'peer (ebox-test-text "P") :width '(80) + :height 1)))) (ebox-test--with-rendered-buffer layout (let* ((region-id (ebox-test--selector-region-id (current-buffer) "#target")) @@ -5712,14 +5930,13 @@ "A batch should carry pre-mutation overflow into coverage planning." (ebox-test--reset-runtime-state) (let ((layout - (ebox-test-build - '(column :id root :width (100) - (box :id wrapper :width (100) - (column - (box :id target :content "A\nB\nC" :height 1 - :overflow visible) - (box :content "TAIL"))) - (box :content "ROOT TAIL"))))) + (ebox-test-column :id 'root :width '(100) + (ebox-test-box :id 'wrapper :width '(100) + (ebox-test-column + (ebox-test-box :id 'target (ebox-test-text "A\nB\nC") :height 1 + :overflow 'visible) + (ebox-test-box (ebox-test-text "TAIL")))) + (ebox-test-box (ebox-test-text "ROOT TAIL"))))) (ebox-test--with-rendered-buffer layout (let* ((region-id (ebox-test--selector-region-id (current-buffer) "#target")) @@ -5749,24 +5966,39 @@ "Multiple owner replacements normalize shifted spans before scoped refresh." (ebox-test--reset-runtime-state) (let ((layout - (ebox-test-build - '(column :id root :width (760) - (flex :id top-matrix :width (600) :height 5 - :flex-flow (row nowrap) - (box :id top-card :width (300) - (box :id top-target - :content "top A\nsubstantially longer top overflow\ntop C" - :width (280) :height 2 :overflow scroll)) - (box :content "top P\ntop Q\ntop R\ntop S\ntop T" - :width (300) :height 5)) - (flex :id bottom-matrix :width (600) :height 5 - :flex-flow (row nowrap) - (box :id bottom-card :width (300) - (box :id bottom-target - :content "bottom A\nbottom overflow\nbottom C" - :width (280) :height 2 :overflow scroll)) - (box :content "bottom P\nbottom Q\nbottom R\nbottom S\nbottom T" - :width (300) :height 5)))))) + (ebox-test-column :id 'root :width '(760) + (ebox-test-flex :id 'top-matrix :width '(600) + :height 5 :flex-flow '(row nowrap) + (ebox-test-box :id 'top-card :width + '(300) + (ebox-test-box :id + 'top-target + (ebox-test-text "top A\nsubstantially longer top overflow\ntop C") + :width + '(280) + :height + 2 + :overflow + 'scroll)) + (ebox-test-box (ebox-test-text "top P\ntop Q\ntop R\ntop S\ntop T") + :width '(300) :height + 5)) + (ebox-test-flex :id 'bottom-matrix :width '(600) + :height 5 :flex-flow '(row nowrap) + (ebox-test-box :id 'bottom-card + :width '(300) + (ebox-test-box :id + 'bottom-target + (ebox-test-text "bottom A\nbottom overflow\nbottom C") + :width + '(280) + :height + 2 + :overflow + 'scroll)) + (ebox-test-box (ebox-test-text "bottom P\nbottom Q\nbottom R\nbottom S\nbottom T") + :width '(300) :height + 5))))) (ebox-test--with-rendered-buffer layout (let ((top-region-id (ebox-test--selector-region-id (current-buffer) "#top-target")) @@ -5824,29 +6056,37 @@ "Failed promoted span patches must fall back to a layout-safe ancestor." (ebox-test--reset-runtime-state) (let ((layout - (ebox-test-build - '(flex :id matrix :width (680) :flex-flow (row wrap) - :column-gap (12) :row-gap 1 - (column :width (210) :border "#999999" - (box :content "Title A" :width (190)) - (box :id target :content "width target\nmoves sibling layout" - :width (160) :height 3 - :padding (0 (8)) :border "#999999")) - (column :width (210) :border "#999999" - (box :content "Title B" :width (190)) - (box :content "Still here" :width (190))) - (column :width (210) :border "#999999" - (box :content "Title C" :width (190)) - (box :content "Third card" :width (190))))))) + (ebox-test-flex :id 'matrix :width '(680) :flex-flow '(row wrap) + :column-gap '(12) :row-gap 1 + (ebox-test-column :width '(210) :border "#999999" + (ebox-test-box (ebox-test-text "Title A") + :width '(190)) + (ebox-test-box :id 'target (ebox-test-text "width target\nmoves sibling layout") + :width '(160) :height + 3 :padding '(0 (8)) + :border "#999999")) + (ebox-test-column :width '(210) :border "#999999" + (ebox-test-box (ebox-test-text "Title B") + :width '(190)) + (ebox-test-box (ebox-test-text "Still here") + :width '(190))) + (ebox-test-column :width '(210) :border "#999999" + (ebox-test-box (ebox-test-text "Title C") + :width '(190)) + (ebox-test-box (ebox-test-text "Third card") + :width '(190)))))) (ebox-test--with-rendered-buffer layout (let* ((match (car (ebox-selector-query-buffer (current-buffer) "#target"))) (region-id (plist-get match :region-id))) (ebox-test--region-update region-id :width '(250)) (let* ((patched (buffer-string)) - (full (ebox--with-buffer-render-context (current-buffer) - (ebox-render - (ebox--buffer-root-node (current-buffer)))))) + (full + (ebox--with-buffer-render-context (current-buffer) + (ebox--render-node + (ebox--buffer-root-node (current-buffer)) + (plist-get (ebox--buffer-render-state (current-buffer)) + :source-index))))) (should (string= (substring-no-properties patched) (substring-no-properties full)))))))) @@ -5884,13 +6124,13 @@ (ebox-test--reset-runtime-state) (let ((ebox-viewport-width 300) (layout - (ebox-test-build - '(flex :id root :width (300) :height 1 - :flex-flow (row nowrap) :align-items flex-start - (box :id target :content "A" :width auto :height 1 - :overflow hidden :wrap-mode word) - (box :id peer :content "P" :width (40) :height 1 - :flex-grow 0 :flex-shrink 0))))) + (ebox-test-flex :id 'root :width '(300) :height 1 :flex-flow + '(row nowrap) :align-items 'flex-start + (ebox-test-box :id 'target (ebox-test-text "A") :width 'auto + :height 1 :overflow 'hidden :wrap-mode + 'word) + (ebox-test-box :id 'peer (ebox-test-text "P") :width '(40) + :height 1 :flex-grow 0 :flex-shrink 0)))) (ebox-test--with-rendered-buffer layout (let* ((match (car (ebox-selector-query-buffer (current-buffer) "#target"))) @@ -5916,7 +6156,7 @@ (ert-deftest ebox-region-update-stable-geometry-uses-span-patch () "Geometry updates with stable span footprint should use the L2 planner path." (ebox-test--reset-runtime-state) - (let* ((box (ebox-test-box :content "Align" :width '(120) :height 1 + (let* ((box (ebox-test-box (ebox-test-text "Align") :width '(120) :height 1 :text-align 'left)) (region-id (car (ebox-region-ids box)))) (ebox-test--with-rendered-buffer box @@ -5931,10 +6171,10 @@ (ert-deftest ebox-region-update-inline-child-stable-geometry-uses-span-patch () "Stable internal changes inside an inline child should not rerender siblings." (ebox-test--reset-runtime-state) - (let* ((left (ebox-test-box :content "Left" :width '(80) :height 1 + (let* ((left (ebox-test-box (ebox-test-text "Left") :width '(80) :height 1 :text-align 'left)) - (right (ebox-test-box :content "Right" :width '(80) :height 1)) - (layout (ebox-test-concat left right)) + (right (ebox-test-box (ebox-test-text "Right") :width '(80) :height 1)) + (layout (ebox-test-row left right)) (left-id (car (ebox-region-ids layout)))) (ebox-test--with-rendered-buffer layout (ebox-test--region-update left-id :text-align 'center) @@ -5952,7 +6192,7 @@ (ert-deftest ebox-region-update-border-box-inline-change-uses-span-patch () "Border-box left/right spacing can be internal when outer width is fixed." (ebox-test--reset-runtime-state) - (let* ((box (ebox-test-box :content "Pad" :width '(120) :height 1 + (let* ((box (ebox-test-box (ebox-test-text "Pad") :width '(120) :height 1 :box-sizing 'border-box)) (region-id (car (ebox-region-ids box)))) (ebox-test--with-rendered-buffer box @@ -5965,7 +6205,7 @@ (ert-deftest ebox-region-update-content-box-inline-change-rerenders-owner () "Content-box left/right spacing changes the parent-facing outer width." (ebox-test--reset-runtime-state) - (let* ((box (ebox-test-box :content "Pad" :width '(120) :height 1 + (let* ((box (ebox-test-box (ebox-test-text "Pad") :width '(120) :height 1 :box-sizing 'content-box)) (region-id (car (ebox-region-ids box)))) (ebox-test--with-rendered-buffer box @@ -5978,7 +6218,7 @@ (ert-deftest ebox-region-update-span-candidate-falls-back-on-wrap-change () "A wrap-changing update should publish a correct scoped TP plan." (ebox-test--reset-runtime-state) - (let* ((box (ebox-test-box :content "abcdefghijklmnopqrstuvwxyz" + (let* ((box (ebox-test-box (ebox-test-text "abcdefghijklmnopqrstuvwxyz") :width '(120) :box-sizing 'border-box :wrap-mode 'char)) @@ -6016,7 +6256,7 @@ (pcase-let ((`(,property ,value ,expected) case)) (ert-info ((format "property %S" property)) (ebox-test--reset-runtime-state) - (let* ((box (ebox-test-box :content "Paint" :width '(120) + (let* ((box (ebox-test-box (ebox-test-text "Paint") :width '(120) :height 1)) (region-id (car (ebox-region-ids box)))) (ebox-test--with-rendered-buffer box @@ -6044,7 +6284,7 @@ (pcase-let ((`(,property ,value ,expected ,_strategy) case)) (ert-info ((format "property %S" property)) (ebox-test--reset-runtime-state) - (let* ((box (ebox-test-box :content "Alpha\nBeta\nGamma\nDelta" + (let* ((box (ebox-test-box (ebox-test-text "Alpha\nBeta\nGamma\nDelta") :width '(160) :height 2 :overflow 'scroll @@ -6074,11 +6314,12 @@ (ert-deftest ebox-bgcolor-update-mutates-box-and-keeps-content () (ebox-test--reset-runtime-state) - (let* ((box (ebox-test-box :content "Color" :width 60 :bgcolor "#111111")) + (let* ((box (ebox-test-box (ebox-test-text "Color") :width 60 :bgcolor "#111111")) (region-id (car (ebox-region-ids box)))) (ebox-test--with-rendered-buffer box (ebox-test--region-update region-id :bgcolor "#222222") - (should (equal (ebox-get box :bgcolor) "#111111")) + (should (equal (ebox-style-node-specified-value box :bgcolor) + "#111111")) (should (equal (ebox-get (gethash region-id (ebox--buffer-region-box-table (current-buffer))) @@ -6092,17 +6333,21 @@ "Background color spellings should share one engine longhand and paint path." (ebox-test--reset-runtime-state) (let* ((background-box - (ebox-test-box :content "Color" :width 60 + (ebox-test-box (ebox-test-text "Color") :width 60 :background-color "#111111")) (bgcolor-box - (ebox-test-box :content "Color" :width 60 :bgcolor "#111111")) + (ebox-test-box (ebox-test-text "Color") :width 60 :bgcolor "#111111")) (background-face (ebox-test--first-content-face (ebox-render background-box))) (bgcolor-face (ebox-test--first-content-face (ebox-render bgcolor-box)))) - (should (equal (ebox-get background-box :bgcolor) "#111111")) - (should (equal (ebox-get bgcolor-box :bgcolor) "#111111")) - (should-not (plist-member background-box :background-color)) + (should (equal (ebox-style-node-specified-value + background-box :bgcolor) + "#111111")) + (should (equal (ebox-style-node-specified-value bgcolor-box :bgcolor) + "#111111")) + (should-not (plist-member (ebox-test-root background-box) + :background-color)) (should (equal background-face bgcolor-face)) (should (ebox-test--face-has-key-value-p background-face :background "#111111")) @@ -6112,10 +6357,13 @@ (let ((runtime-box (gethash region-id (ebox--buffer-region-box-table (current-buffer))))) - (should (equal (ebox-get background-box :bgcolor) "#111111")) + (should (equal (ebox-style-node-specified-value + background-box :bgcolor) + "#111111")) (should (equal (ebox-get runtime-box :bgcolor) "#222222")) (should-not (plist-member runtime-box :background-color))) - (should-not (plist-member background-box :background-color)) + (should-not (plist-member (ebox-test-root background-box) + :background-color)) (should (ebox-test--face-has-key-value-p (ebox-test--first-content-face (buffer-string)) :background "#222222")) @@ -6128,17 +6376,18 @@ (ert-deftest ebox-vertical-align-middle-alias-matches-center () "Creation and updates should normalize middle to center before rendering." (ebox-test--reset-runtime-state) - (let ((middle (ebox-test-box :content "X" :height 3 + (let ((middle (ebox-test-box (ebox-test-text "X") :height 3 :vertical-align 'middle))) (should (equal (ebox--expand-property :vertical-align 'middle) '(:vertical-align center))) - (should (eq (ebox-get middle :vertical-align) 'center)) + (should (eq (ebox-get (ebox-test-root middle) :vertical-align) + 'center)) (should (= (cl-position-if (lambda (line) (string-match-p "X" (substring-no-properties line))) (ebox-string-lines (ebox-render middle))) 1)) - (let* ((updated (ebox-test-box :content "X" :height 3 + (let* ((updated (ebox-test-box (ebox-test-text "X") :height 3 :vertical-align 'top)) (region-id (car (ebox-region-ids updated)))) (ebox-test--with-rendered-buffer updated @@ -6146,7 +6395,9 @@ (search-forward "X") (should (= (line-number-at-pos (match-beginning 0)) 1)) (ebox-test--region-update region-id :vertical-align 'middle) - (should (eq (ebox-get updated :vertical-align) 'top)) + (should (eq (ebox-style-node-specified-value + updated :vertical-align) + 'top)) (should (eq (ebox-get (gethash region-id (ebox--buffer-region-box-table (current-buffer))) @@ -6161,7 +6412,7 @@ (ert-deftest ebox-update-reuses-shorthand-property-expansion () (ebox-test--reset-runtime-state) - (let* ((box (ebox-test-box :content "Box" :width 60)) + (let* ((box (ebox-test-box (ebox-test-text "Box") :width 60)) (region-id (car (ebox-region-ids box)))) (ebox-test--with-rendered-buffer box (ebox-test--region-update region-id @@ -6171,7 +6422,7 @@ (let ((runtime-box (gethash region-id (ebox--buffer-region-box-table (current-buffer))))) - (should (= (ebox-get box :padding-top-height) 0)) + (should (= (ebox-get (ebox-test-root box) :padding-top-height) 0)) (should (= (ebox-get runtime-box :padding-top-height) 1)) (should (= (ebox-get runtime-box :padding-right-pixel) (* 2 (ebox--space-pixel-width)))) @@ -6184,7 +6435,7 @@ (ert-deftest ebox-region-update-adds-absent-shorthand-properties-incrementally () "Shorthand updates should locate the same box and patch without root rebuilds." (ebox-test--reset-runtime-state) - (let* ((box (ebox-test-box :content "Absent properties" :width '(160))) + (let* ((box (ebox-test-box (ebox-test-text "Absent properties") :width '(160))) (region-id (car (ebox-region-ids box))) (ids-before (ebox-region-ids box))) (ebox-test--with-rendered-buffer box @@ -6202,11 +6453,11 @@ runtime-box)) (should-not (eq runtime-box box)) (should (equal (ebox-region-ids box) ids-before)) - (should (= (ebox-get box :padding-top-height) 0)) - (should (= (ebox-get box :padding-right-pixel) 0)) - (should (= (ebox-get box :border-left-pixel) 0)) - (should (null (ebox-get box :border-right-color))) - (should (= (ebox-get box :margin-left-pixel) 0)) + (should (= (ebox-get (ebox-test-root box) :padding-top-height) 0)) + (should (= (ebox-get (ebox-test-root box) :padding-right-pixel) 0)) + (should (= (ebox-get (ebox-test-root box) :border-left-pixel) 0)) + (should (null (ebox-get (ebox-test-root box) :border-right-color))) + (should (= (ebox-get (ebox-test-root box) :margin-left-pixel) 0)) (should (= (ebox-get runtime-box :padding-top-height) 1)) (should (= (ebox-get runtime-box :padding-right-pixel) (* 2 (ebox--space-pixel-width)))) @@ -6225,7 +6476,7 @@ (ebox-test--region-update region-id :background-color "#fed7aa")))) (should (= full-rerenders 0))) - (should (null (ebox-get box :bgcolor))) + (should (null (ebox-style-node-specified-value box :bgcolor))) (should (equal (ebox-get (gethash region-id (ebox--buffer-region-box-table (current-buffer))) @@ -6239,7 +6490,7 @@ (ert-deftest ebox-overflow-hidden-clips-extra-content-lines () (ebox-test--reset-runtime-state) - (let* ((box (ebox-test-box :content "A\nB\nC" + (let* ((box (ebox-test-box (ebox-test-text "A\nB\nC") :height 1 :overflow 'hidden)) (plain (substring-no-properties (ebox-render box)))) @@ -6250,7 +6501,7 @@ (ert-deftest ebox-overflow-visible-appends-extra-content-lines () (ebox-test--reset-runtime-state) - (let* ((box (ebox-test-box :content "A\nB\nC" + (let* ((box (ebox-test-box (ebox-test-text "A\nB\nC") :height 1 :overflow 'visible)) (plain (substring-no-properties (ebox-render box)))) @@ -6304,7 +6555,7 @@ (ert-deftest ebox-overflow-visible-paints-foreground-without-ownership () "Visible overflow should inherit only the outer box foreground." (ebox-test--reset-runtime-state) - (let* ((box (ebox-test-box :content "A\nB\nC" + (let* ((box (ebox-test-box (ebox-test-text "A\nB\nC") :width '(40) :height 1 :overflow 'visible @@ -6312,14 +6563,14 @@ :bgcolor "#123456" :padding-inline '(6) :margin-inline '(4))) - (region-id (ebox--ensure-region-id box))) + (region-id (ebox--ensure-region-id (ebox-test-root box)))) (ebox-test--assert-visible-overflow-paint (ebox-render box) "#abcdef" "#123456" region-id))) (ert-deftest ebox-region-update-refreshes-visible-overflow-foreground () "Color updates should add, replace, and remove visible overflow paint." (ebox-test--reset-runtime-state) - (let* ((box (ebox-test-box :content "A\nB\nC" + (let* ((box (ebox-test-box (ebox-test-text "A\nB\nC") :width '(40) :height 1 :overflow 'visible @@ -6347,7 +6598,7 @@ (ert-deftest ebox-owner-rerender-keeps-visible-overflow-unowned () "Owner rerenders should not restore box ownership to visible overflow." (ebox-test--reset-runtime-state) - (let* ((box (ebox-test-box :content "A\nB\nC" + (let* ((box (ebox-test-box (ebox-test-text "A\nB\nC") :width '(40) :height 1 :overflow 'scroll @@ -6375,7 +6626,7 @@ (ert-deftest ebox-overflow-scroll-keeps-window-height-and-state () (ebox-test--reset-runtime-state) - (let* ((box (ebox-test-box :content "A\nB\nC" + (let* ((box (ebox-test-box (ebox-test-text "A\nB\nC") :height 1 :overflow 'scroll)) (region-id (car (ebox-region-ids box)))) @@ -6398,7 +6649,7 @@ "Scroll commands should let the buffer move when the box has no scroll work." (ebox-test--reset-runtime-state) (with-temp-buffer - (insert (ebox-render (ebox-test-box :content "short" :height 1))) + (insert (ebox-render (ebox-test-box (ebox-test-text "short") :height 1))) (insert "\n") (dotimes (index 40) (insert (format "tail-%02d\n" index))) @@ -6414,12 +6665,11 @@ (ert-deftest ebox-scroll-bubbles-from-child-to-scrollable-ancestor () "Key scrolling over a child box should scroll the nearest scrollable ancestor." (ebox-test--reset-runtime-state) - (let* ((layout (ebox-test-build - '(box :id "root" :height 2 :overflow scroll - (column - (box :id "child" :content "A" :height 1) - (box :content "B" :height 1) - (box :content "C" :height 1))))) + (let* ((layout (ebox-test-box :id "root" :height 2 :overflow 'scroll + (ebox-test-column + (ebox-test-box :id "child" (ebox-test-text "A") :height 1) + (ebox-test-box (ebox-test-text "B") :height 1) + (ebox-test-box (ebox-test-text "C") :height 1)))) (ids (ebox-region-ids layout)) (root-id (car ids))) (ebox-test--with-rendered-buffer layout @@ -6458,7 +6708,7 @@ (ert-deftest ebox-wheel-scroll-falls-back-for-non-scrollable-region () "Mouse wheel over a normal box should keep default buffer scrolling." (ebox-test--reset-runtime-state) - (let* ((box (ebox-test-box :content "short" :height 1)) + (let* ((box (ebox-test-box (ebox-test-text "short") :height 1)) (region-id (car (ebox-region-ids box))) mwheel-args) (ebox-test--with-rendered-buffer box @@ -6473,7 +6723,7 @@ (ert-deftest ebox-wheel-scroll-consumes-scrollable-region () "Mouse wheel should still scroll an overflow='scroll box when it can." (ebox-test--reset-runtime-state) - (let* ((box (ebox-test-box :content "A\nB\nC" + (let* ((box (ebox-test-box (ebox-test-text "A\nB\nC") :height 1 :overflow 'scroll)) (region-id (car (ebox-region-ids box))) @@ -6493,12 +6743,11 @@ (ert-deftest ebox-wheel-scroll-bubbles-from-child-to-scrollable-ancestor () "Mouse wheel scrolling over a child box should scroll its scrollable ancestor." (ebox-test--reset-runtime-state) - (let* ((layout (ebox-test-build - '(box :id "root" :height 2 :overflow scroll - (column - (box :id "child" :content "A" :height 1) - (box :content "B" :height 1) - (box :content "C" :height 1))))) + (let* ((layout (ebox-test-box :id "root" :height 2 :overflow 'scroll + (ebox-test-column + (ebox-test-box :id "child" (ebox-test-text "A") :height 1) + (ebox-test-box (ebox-test-text "B") :height 1) + (ebox-test-box (ebox-test-text "C") :height 1)))) (ids (ebox-region-ids layout)) (root-id (car ids)) (child-id (cadr ids)) @@ -6521,14 +6770,15 @@ (ert-deftest ebox-scroll-state-is-created-only-for-scrollable-boxes () "Non-overflowing boxes should not inflate the global scroll hit-test table." (ebox-test--reset-runtime-state) - (let* ((layout (ebox-test-build - '(column - (box :id "short-a" :content "A" :height 1) - (box :id "short-b" :content "B" :height 1) - (box :id "root" :height 1 :overflow scroll - (column - (box :content "C" :height 1) - (box :content "D" :height 1)))))) + (let* ((layout (ebox-test-column (ebox-test-box :id "short-a" (ebox-test-text "A") :height 1) + (ebox-test-box :id "short-b" (ebox-test-text "B") :height 1) + (ebox-test-box :id "root" :height 1 :overflow + 'scroll + (ebox-test-column + (ebox-test-box (ebox-test-text "C") :height + 1) + (ebox-test-box (ebox-test-text "D") :height + 1))))) root-id short-a-id short-b-id) (ebox-test--with-rendered-buffer layout (setq root-id (ebox-test--selector-region-id (current-buffer) "#root")) @@ -6543,11 +6793,10 @@ (ert-deftest ebox-scroll-hit-test-uses-owner-stack-before-mount-lookup () "Wheel hit testing should avoid TP mount lookup when owners suffice." (ebox-test--reset-runtime-state) - (let* ((layout (ebox-test-build - '(box :id "root" :height 1 :overflow scroll - (column - (box :id "child" :content "A" :height 1) - (box :content "B" :height 1))))) + (let* ((layout (ebox-test-box :id "root" :height 1 :overflow 'scroll + (ebox-test-column + (ebox-test-box :id "child" (ebox-test-text "A") :height 1) + (ebox-test-box (ebox-test-text "B") :height 1)))) (root-id (car (ebox-region-ids layout)))) (ebox-test--with-rendered-buffer layout (goto-char (point-min)) @@ -6560,14 +6809,14 @@ (ert-deftest ebox-chrome-free-scroll-render-reuses-propertized-prefix-lines () "A chrome-free scroll box should not propertize its visible prefix twice." (ebox-test--reset-runtime-state) - (let* ((box (ebox-test-box :content "row-1\nrow-2\nrow-3" + (let* ((box (ebox-test-box (ebox-test-text "row-1\nrow-2\nrow-3") :width 120 :height 2 :overflow 'scroll :color "#112233" :bgcolor "#ddeeff")) - (region-id (ebox--ensure-region-id box)) - (reference (ebox-test-box :content "row-1\nrow-2\nrow-3" + (region-id (ebox--ensure-region-id (ebox-test-root box))) + (reference (ebox-test-box (ebox-test-text "row-1\nrow-2\nrow-3") :width 120 :height 2 :overflow 'hidden @@ -6576,7 +6825,7 @@ (original (symbol-function 'ebox--propertize-content-line)) (root-propertize-count 0) actual) - (plist-put reference :region-id region-id) + (plist-put (ebox-test-root reference) :region-id region-id) (cl-letf (((symbol-function 'ebox--propertize-content-line) (lambda (line owner idx fallback) (when (equal owner region-id) @@ -6603,15 +6852,15 @@ (concat (propertize "alpha" 'face '(:weight bold) 'help-echo "rich") "\n \nomega")) - (box (ebox-test-box :content content + (box (ebox-test-box (ebox-test-text content) :width 140 :height 5 :overflow 'scroll :vertical-align 'top :color "#112233" :bgcolor "#ddeeff")) - (region-id (ebox--ensure-region-id box)) - (reference (ebox-test-box :content content + (region-id (ebox--ensure-region-id (ebox-test-root box))) + (reference (ebox-test-box (ebox-test-text content) :width 140 :height 5 :overflow 'hidden @@ -6619,7 +6868,8 @@ :color "#112233" :bgcolor "#ddeeff")) (expected (progn - (plist-put reference :region-id region-id) + (plist-put (ebox-test-root reference) + :region-id region-id) (ebox-render reference))) (original-align (symbol-function 'ebox--lines-align-vertical)) (original-maplines (symbol-function 'ebox--maplines)) @@ -6646,15 +6896,14 @@ "Lazy scroll windows should not rebuild visible content lines per tick." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-test-build - `(box :id "root" :width (viewport) :height (viewport-height) - :overflow scroll - (column :width (viewport) - ,@(cl-loop for idx below 40 - collect - `(box :id ,(format "row-%02d" idx) - :content ,(format "row %02d" idx) - :height 1)))))) + (ebox-test-box :id "root" :width '(viewport) :height + '(viewport-height) :overflow 'scroll + (apply #'ebox-test-column + (append (list :width '(viewport)) + (cl-loop for idx below 40 collect + (ebox-test-box :id (format "row-%02d" idx) + (ebox-test-text (format "row %02d" idx)) :height + 1)))))) (buffer nil) root-id) (unwind-protect @@ -6683,20 +6932,20 @@ "Viewport resize must preserve lazy scroll's fixed viewport fast path." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-test-build - `(column :id "viewport" :width (viewport) - :height (viewport-height) - (box :content "" :width (viewport) :height 1) - (box :id "root" :width (viewport) - :height (- (viewport-height) 2) - :overflow scroll - (column :width (viewport) :padding (1 (8)) - ,@(cl-loop for idx below 80 - collect - `(box :content ,(format "row %02d" idx) - :width (viewport) - :height 1))) - (box :content "" :width (viewport) :height 1))))) + (ebox-test-column :id "viewport" :width '(viewport) :height + '(viewport-height) + (ebox-test-box (ebox-test-text "") :width '(viewport) + :height 1) + (ebox-test-box :id "root" :width '(viewport) :height + '(- (viewport-height) 2) :overflow + 'scroll + (apply #'ebox-test-column + (append (list :width '(viewport) :padding '(1 (8))) + (cl-loop for idx below 80 collect + (ebox-test-box (ebox-test-text (format "row %02d" idx)) :width + '(viewport) :height 1)))) + (ebox-test-box (ebox-test-text "") :width + '(viewport) :height 1)))) buffer root-id mount-bounds @@ -6758,16 +7007,14 @@ "Flex intrinsic measurement should not render lazy scroll window content." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-test-build - `(box :id "root" :width (viewport) - :height (- (viewport-height) 2) - :overflow scroll - (column :width (viewport) - ,@(cl-loop for idx below 40 - collect - `(box :content ,(format "measurement row %02d" idx) - :width (viewport) - :height 1)))))) + (ebox-test-box :id "root" :width '(viewport) :height + '(- (viewport-height) 2) :overflow 'scroll + (apply #'ebox-test-column + (append (list :width '(viewport)) + (cl-loop for idx below 40 collect + (ebox-test-box (ebox-test-text (format "measurement row %02d" + idx)) + :width '(viewport) :height 1)))))) (calls 0) rendered) (cl-letf (((symbol-function 'ebox--render-scroll-window-source) @@ -6790,16 +7037,13 @@ "Interactive scrolling should extend a lazy prefix even with idle prefetch off." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-test-build - `(box :id "root" :width (viewport) - :height (- (viewport-height) 10) - :overflow scroll - (column :width (viewport) - ,@(cl-loop for idx below 80 - collect - `(box :content ,(format "sync row %02d" idx) - :width (viewport) - :height 1)))))) + (ebox-test-box :id "root" :width '(viewport) :height + '(- (viewport-height) 10) :overflow 'scroll + (apply #'ebox-test-column + (append (list :width '(viewport)) + (cl-loop for idx below 80 collect + (ebox-test-box (ebox-test-text (format "sync row %02d" idx)) + :width '(viewport) :height 1)))))) buffer root-id) (unwind-protect @@ -6839,16 +7083,13 @@ "A bounded GUI cache miss should extend and scroll in one Ebox commit." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-test-build - `(box :id "root" :width (viewport) - :height (- (viewport-height) 10) - :overflow scroll - (column :width (viewport) - ,@(cl-loop for idx below 80 - collect - `(box :content ,(format "gui row %02d" idx) - :width (viewport) - :height 1)))))) + (ebox-test-box :id "root" :width '(viewport) :height + '(- (viewport-height) 10) :overflow 'scroll + (apply #'ebox-test-column + (append (list :width '(viewport)) + (cl-loop for idx below 80 collect + (ebox-test-box (ebox-test-text (format "gui row %02d" idx)) + :width '(viewport) :height 1)))))) buffer root-id (prefix-count 0)) @@ -6906,16 +7147,13 @@ "A caller-provided prefix budget should synchronously extend GUI lazy scrolls." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-test-build - `(box :id "root" :width (viewport) - :height (- (viewport-height) 10) - :overflow scroll - (column :width (viewport) - ,@(cl-loop for idx below 80 - collect - `(box :content ,(format "budget row %02d" idx) - :width (viewport) - :height 1)))))) + (ebox-test-box :id "root" :width '(viewport) :height + '(- (viewport-height) 10) :overflow 'scroll + (apply #'ebox-test-column + (append (list :width '(viewport)) + (cl-loop for idx below 80 collect + (ebox-test-box (ebox-test-text (format "budget row %02d" idx)) + :width '(viewport) :height 1)))))) buffer root-id (prefix-count 0)) @@ -6960,20 +7198,22 @@ (ebox-test--reset-runtime-state) (let* ((flex-section (lambda (label) - `(flex :width (viewport) :flex-flow (row wrap) - ,@(cl-loop for idx below 4 - collect - `(box :content ,(format "%s-%d" label idx) - :width (110) - :height 1))))) + (apply #'ebox-test-flex + (append + (list :width '(viewport) :flex-flow '(row wrap)) + (cl-loop for idx below 4 + collect + (ebox-test-box + (ebox-test-text (format "%s-%d" label idx)) + :width '(110) + :height 1)))))) (layout - (ebox-test-build - `(box :id "root" :width (viewport) :height (viewport-height) - :overflow scroll - (column :width (viewport) - ,(funcall flex-section "a") - (spacer :height 1) - ,(funcall flex-section "b"))))) + (ebox-test-box :id "root" :width '(viewport) :height + '(viewport-height) :overflow 'scroll + (ebox-test-column :width '(viewport) + (funcall flex-section "a") + (ebox-test-box :height 1) + (funcall flex-section "b")))) (buffer nil) root-id (flex-render-count 0) @@ -7005,18 +7245,18 @@ "Lazy scroll prefixes should not render hidden row-wrap flex rows." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-test-build - `(box :id "root" :width (viewport) :height (viewport-height) - :overflow scroll - (column :width (viewport) - (box :id "head" :content "head" :width (viewport) :height 1) - (flex :width (viewport) :flex-flow (row wrap) - :row-gap 0 :column-gap 0 - ,@(cl-loop for idx below 12 - collect - `(box :id ,(format "item-%02d" idx) - :content ,(format "item %02d" idx) - :width (100) :height 2))))))) + (ebox-test-box :id "root" :width '(viewport) :height + '(viewport-height) :overflow 'scroll + (ebox-test-column :width '(viewport) + (ebox-test-box :id "head" (ebox-test-text "head") :width + '(viewport) :height 1) + (apply #'ebox-test-flex + (append + (list :width '(viewport) :flex-flow '(row wrap) :row-gap 0 + :column-gap 0) + (cl-loop for idx below 12 collect + (ebox-test-box :id (format "item-%02d" idx) (ebox-test-text (format "item %02d" idx)) :width '(100) + :height 2))))))) (buffer nil) (hidden-render-count 0) (original-render-box (symbol-function 'ebox--render-box))) @@ -7046,15 +7286,15 @@ "Window prefix rendering must not freeze shrinkable definite widths." (ebox-test--reset-runtime-state) (let* ((node - (ebox-test-build - `(flex :width (160) :flex-flow (row wrap) - (box :id "shrink-a" :content "alpha beta gamma delta" - :width (200) :height 2) - (box :id "shrink-b" :content "second row content" - :width (160) :height 2)))) + (ebox-test-flex :width '(160) :flex-flow '(row wrap) + (ebox-test-box :id "shrink-a" (ebox-test-text "alpha beta gamma delta") :width '(200) + :height 2) + (ebox-test-box :id "shrink-b" (ebox-test-text "second row content") :width '(160) + :height 2))) (limit 2) (full-lines (ebox-string-lines (ebox-render node))) - (window (ebox--render-flex-window-lines node limit))) + (window (ebox-test--call-input-node + #'ebox--render-flex-window-lines node limit))) ;; A nil window means the full-render fallback takes over, which is ;; also correct. Produced window lines must match the full ;; renderer's prefix geometry instead of freezing the declared @@ -7069,24 +7309,26 @@ "Char-wrapped flex prefixes should use the full-render fallback." (ebox-test--reset-runtime-state) (let* ((node - (ebox-test-build - `(flex :width (100) :wrap-mode char :flex-flow (row wrap) - :row-gap 0 :column-gap 0 - ,@(cl-loop for char across "ABCD" - collect - `(box :content ,(char-to-string char) - :width (150) :height 1))))) + (apply #'ebox-test-flex + (append + (list :width '(100) :wrap-mode 'char :flex-flow '(row wrap) + :row-gap 0 :column-gap 0) + (cl-loop for char across "ABCD" collect + (ebox-test-box (ebox-test-text (char-to-string char)) :width + '(150) :height 1))))) (full-lines (ebox-string-lines (ebox-render node))) (expected-signatures (mapcar #'ebox-test--visual-line-signature (seq-take full-lines 3))) (ebox--render-cache-table (make-hash-table :test 'equal)) (ebox--render-cache-signature-cache (make-hash-table :test 'eq)) - (first (ebox--render-stack-window-lines node 1)) + (first (ebox-test--call-input-node + #'ebox--render-stack-window-lines node 1)) (extended - (ebox--render-stack-window-lines - node 3 (plist-get first :cache))) - (producer (ebox--render-node-window-lines node 3)) + (ebox-test--call-input-node + #'ebox--render-stack-window-lines node 3 (plist-get first :cache))) + (producer (ebox-test--call-input-node + #'ebox--render-node-window-lines node 3)) (actual-signatures (mapcar #'ebox-test--visual-line-signature (plist-get extended :lines)))) @@ -7097,24 +7339,26 @@ "Non-left-aligned flex prefixes should use the full-render fallback." (ebox-test--reset-runtime-state) (let* ((node - (ebox-test-build - `(flex :width (100) :text-align center :flex-flow (row wrap) - :row-gap 0 :column-gap 0 - ,@(cl-loop for char across "ABCDEF" - collect - `(box :content ,(char-to-string char) - :width (50) :height 1))))) + (apply #'ebox-test-flex + (append + (list :width '(100) :text-align 'center :flex-flow '(row wrap) + :row-gap 0 :column-gap 0) + (cl-loop for char across "ABCDEF" collect + (ebox-test-box (ebox-test-text (char-to-string char)) :width + '(50) :height 1))))) (full-lines (ebox-string-lines (ebox-render node))) (expected-signatures (mapcar #'ebox-test--visual-line-signature (seq-take full-lines 3))) (ebox--render-cache-table (make-hash-table :test 'equal)) (ebox--render-cache-signature-cache (make-hash-table :test 'eq)) - (first (ebox--render-stack-window-lines node 1)) + (first (ebox-test--call-input-node + #'ebox--render-stack-window-lines node 1)) (extended - (ebox--render-stack-window-lines - node 3 (plist-get first :cache))) - (producer (ebox--render-node-window-lines node 3)) + (ebox-test--call-input-node + #'ebox--render-stack-window-lines node 3 (plist-get first :cache))) + (producer (ebox-test--call-input-node + #'ebox--render-node-window-lines node 3)) (actual-signatures (mapcar #'ebox-test--visual-line-signature (plist-get extended :lines)))) @@ -7129,7 +7373,7 @@ (cl-loop for index below 20 collect (ebox-test-box - :content (format "row %02d" index) + (ebox-test-text (format "row %02d" index)) :height 1)))) (full (ebox-string-lines (ebox-render node))) (render-count 0) @@ -7138,7 +7382,8 @@ (lambda (&rest arguments) (cl-incf render-count) (apply original arguments)))) - (let ((window (ebox--render-stack-window-lines node 3))) + (let ((window (ebox-test--call-input-node + #'ebox--render-stack-window-lines node 3))) (should-not (plist-get window :complete)) (should (equal (mapcar #'substring-no-properties (seq-take full 3)) @@ -7154,19 +7399,15 @@ "A seedless flex prefix should avoid the generic full wrapper renderer." (ebox-test--reset-runtime-state) (let* ((node - (ebox-test-build - `(flex :id "flex-window-wrapper" :width (240) - :flex-flow (row wrap) - :row-gap 1 :column-gap 0 - :padding (0 (8)) - :border-right "#D97757" - :border-bottom "#D97757" - :border-left "#D97757" - :bgcolor "#FFF8F2" - ,@(cl-loop for idx below 8 - collect - `(box :content ,(format "wrapper %02d" idx) - :width (100) :height 2))))) + (apply #'ebox-test-flex + (append + (list :id "flex-window-wrapper" :width '(240) :flex-flow + '(row wrap) :row-gap 1 :column-gap 0 :padding '(0 (8)) + :border-right "#D97757" :border-bottom "#D97757" + :border-left "#D97757" :bgcolor "#FFF8F2") + (cl-loop for idx below 8 collect + (ebox-test-box (ebox-test-text (format "wrapper %02d" idx)) + :width '(100) :height 2))))) (full-lines (ebox-string-lines (ebox-render node))) (full-wrapper-count 0) (chunk-wrapper-count 0) @@ -7190,7 +7431,8 @@ (cl-incf multiline-horizontal-concat-count) (apply original-horizontal-concat args)))) (let* ((ebox--surface-materialization-active t) - (window (ebox--render-flex-window-lines node 4))) + (window (ebox-test--call-input-node + #'ebox--render-flex-window-lines node 4))) (should window) (should-not (plist-get window :complete)) (should (= (length (plist-get window :lines)) 4)) @@ -7216,7 +7458,8 @@ 'ebox-content-owners '(77))) (source-before (copy-sequence source)) (expected - (ebox--flex-window-render-wrapper-chunk-lines + (ebox-test--call-input-node + #'ebox--flex-window-render-wrapper-chunk-lines box (list source) 0 t)) (copying-owner-count 0) (original-copying-owner @@ -7227,7 +7470,8 @@ (cl-incf copying-owner-count) (apply original-copying-owner args)))) (setq actual - (ebox--flex-window-render-wrapper-chunk-lines + (ebox-test--call-input-node + #'ebox--flex-window-render-wrapper-chunk-lines box (list source) 0 t))) (should (equal-including-properties source source-before)) (should @@ -7240,14 +7484,14 @@ "Repeated flex window renders should reuse the render cache." (ebox-test--reset-runtime-state) (let* ((node - (ebox-test-build - `(flex :width (240) :flex-flow (row wrap) - :row-gap 1 :column-gap 0 - ,@(cl-loop for idx below 8 - collect - `(box :id ,(format "cache-item-%02d" idx) - :content ,(format "cache %02d" idx) - :width (100) :height 2))))) + (apply #'ebox-test-flex + (append + (list :width '(240) :flex-flow '(row wrap) :row-gap 1 + :column-gap 0) + (cl-loop for idx below 8 collect + (ebox-test-box :id (format "cache-item-%02d" idx) + (ebox-test-text (format "cache %02d" idx)) + :width '(100) :height 2))))) (ebox--render-cache-table (make-hash-table :test 'equal)) (row-render-count 0) (original-render-row-line @@ -7256,24 +7500,26 @@ (lambda (&rest args) (cl-incf row-render-count) (apply original-render-row-line args)))) - (should (ebox--render-flex-window-lines node 4)) + (should (ebox-test--call-input-node + #'ebox--render-flex-window-lines node 4)) (let ((after-first row-render-count)) (should (> after-first 0)) - (should (ebox--render-flex-window-lines node 4)) + (should (ebox-test--call-input-node + #'ebox--render-flex-window-lines node 4)) (should (= row-render-count after-first)))))) (ert-deftest ebox-flex-window-lines-reuses-node-cache-signature () "One flex window render should not repeatedly compute the node signature." (ebox-test--reset-runtime-state) (let* ((node - (ebox-test-build - `(flex :width (240) :flex-flow (row wrap) - :row-gap 1 :column-gap 0 - ,@(cl-loop for idx below 8 - collect - `(box :id ,(format "sig-item-%02d" idx) - :content ,(format "sig %02d" idx) - :width (100) :height 2))))) + (apply #'ebox-test-flex + (append + (list :width '(240) :flex-flow '(row wrap) :row-gap 1 + :column-gap 0) + (cl-loop for idx below 8 collect + (ebox-test-box :id (format "sig-item-%02d" idx) + (ebox-test-text (format "sig %02d" idx)) + :width '(100) :height 2))))) (ebox--render-cache-table (make-hash-table :test 'equal)) (ebox--render-cache-signature-cache (make-hash-table :test 'eq)) (signature-count 0) @@ -7281,29 +7527,26 @@ (symbol-function 'ebox--render-cache-node-signature))) (cl-letf (((symbol-function 'ebox--render-cache-node-signature) (lambda (&rest args) - (when (eq (car args) node) + (when (eq (car args) (ebox-test-root node)) (cl-incf signature-count)) (apply original-signature args)))) - (should (ebox--render-flex-window-lines node 4))) + (should (ebox-test--call-input-node + #'ebox--render-flex-window-lines node 4))) (should (<= signature-count 2)))) (ert-deftest ebox-flex-window-prefix-extension-reuses-wrapper-lines () "Extending a flex prefix should not rerender prior wrapper lines." (ebox-test--reset-runtime-state) (let* ((node - (ebox-test-build - `(flex :id "flex-prefix-wrapper" :width (240) - :flex-flow (row wrap) - :row-gap 1 :column-gap 0 - :padding (0 (8)) - :border-right "#D97757" - :border-bottom "#D97757" - :border-left "#D97757" - :bgcolor "#FFF8F2" - ,@(cl-loop for idx below 8 - collect - `(box :content ,(format "extend %02d" idx) - :width (100) :height 2))))) + (apply #'ebox-test-flex + (append + (list :id "flex-prefix-wrapper" :width '(240) :flex-flow + '(row wrap) :row-gap 1 :column-gap 0 :padding '(0 (8)) + :border-right "#D97757" :border-bottom "#D97757" + :border-left "#D97757" :bgcolor "#FFF8F2") + (cl-loop for idx below 8 collect + (ebox-test-box (ebox-test-text (format "extend %02d" idx)) + :width '(100) :height 2))))) (full-lines (mapcar #'substring-no-properties (ebox-string-lines (ebox-render node)))) (ebox--render-cache-table (make-hash-table :test 'equal)) @@ -7315,9 +7558,11 @@ "flex-prefix-wrapper") (cl-incf generic-wrapper-render-count)) (funcall original-render-box box)))) - (should (ebox--render-flex-window-lines node 4)) + (should (ebox-test--call-input-node + #'ebox--render-flex-window-lines node 4)) (should (= generic-wrapper-render-count 0)) - (let ((window (ebox--render-flex-window-lines node 8))) + (let ((window (ebox-test--call-input-node + #'ebox--render-flex-window-lines node 8))) (should window) (should (= generic-wrapper-render-count 0)) (should (equal (seq-take full-lines 8) @@ -7328,18 +7573,15 @@ "Extending a flex prefix should not rejoin the whole cached prefix." (ebox-test--reset-runtime-state) (let* ((node - (ebox-test-build - `(flex :width (240) :flex-flow (row wrap) - :row-gap 1 :column-gap 0 - :padding (0 (8)) - :border-right "#D97757" - :border-bottom "#D97757" - :border-left "#D97757" - :bgcolor "#FFF8F2" - ,@(cl-loop for idx below 16 - collect - `(box :content ,(format "piece %02d" idx) - :width (100) :height 2))))) + (apply #'ebox-test-flex + (append + (list :width '(240) :flex-flow '(row wrap) :row-gap 1 + :column-gap 0 :padding '(0 (8)) :border-right "#D97757" + :border-bottom "#D97757" :border-left "#D97757" :bgcolor + "#FFF8F2") + (cl-loop for idx below 16 collect + (ebox-test-box (ebox-test-text (format "piece %02d" idx)) + :width '(100) :height 2))))) (ebox--render-cache-table (make-hash-table :test 'equal)) join-piece-counts (original-join-pieces @@ -7348,9 +7590,11 @@ (lambda (pieces) (push (length pieces) join-piece-counts) (funcall original-join-pieces pieces)))) - (should (ebox--render-flex-window-lines node 4)) + (should (ebox-test--call-input-node + #'ebox--render-flex-window-lines node 4)) (setq join-piece-counts nil) - (should (ebox--render-flex-window-lines node 8))) + (should (ebox-test--call-input-node + #'ebox--render-flex-window-lines node 8))) (should join-piece-counts) (should-not (cl-some (lambda (count) (> count 4)) join-piece-counts)))) @@ -7359,20 +7603,16 @@ "A complete flex source should not full-render bottom chrome before LIMIT." (ebox-test--reset-runtime-state) (let* ((node - (ebox-test-build - `(flex :id "flex-complete-prefix" :width (240) - :flex-flow (row wrap) - :row-gap 1 :column-gap 0 - :padding (0 (8) 2 (8)) - :margin-bottom 2 - :border-right "#D97757" - :border-bottom "#D97757" - :border-left "#D97757" - :bgcolor "#FFF8F2" - ,@(cl-loop for idx below 2 - collect - `(box :content ,(format "complete %02d" idx) - :width (100) :height 8))))) + (apply #'ebox-test-flex + (append + (list :id "flex-complete-prefix" :width '(240) :flex-flow + '(row wrap) :row-gap 1 :column-gap 0 :padding + '(0 (8) 2 (8)) :margin-bottom 2 :border-right "#D97757" + :border-bottom "#D97757" :border-left "#D97757" :bgcolor + "#FFF8F2") + (cl-loop for idx below 2 collect + (ebox-test-box (ebox-test-text (format "complete %02d" idx)) + :width '(100) :height 8))))) (limit 4) (full-lines (ebox-string-lines (ebox-render node))) (complete-wrapper-count 0) @@ -7384,7 +7624,8 @@ (cl-incf complete-wrapper-count)) (funcall original-render-box-lines node content complete limit)))) - (let ((window (ebox--render-flex-window-lines node limit))) + (let ((window (ebox-test--call-input-node + #'ebox--render-flex-window-lines node limit))) (should window) (should-not (plist-get window :complete)) (should (= complete-wrapper-count 0)) @@ -7406,33 +7647,31 @@ "Completing a cached flex prefix should append bottom border without full render." (ebox-test--reset-runtime-state) (let* ((node - (ebox-test-build - `(flex :id "flex-complete-border" :width (240) - :flex-flow (row wrap) - :row-gap 1 :column-gap 0 - :padding (0 (8)) - :border-right "#D97757" - :border-bottom "#D97757" - :border-left "#D97757" - :bgcolor "#FFF8F2" - ,@(cl-loop for idx below 4 - collect - `(box :content ,(format "border %02d" idx) - :width (100) :height 2))))) + (apply #'ebox-test-flex + (append + (list :id "flex-complete-border" :width '(240) :flex-flow + '(row wrap) :row-gap 1 :column-gap 0 :padding '(0 (8)) + :border-right "#D97757" :border-bottom "#D97757" + :border-left "#D97757" :bgcolor "#FFF8F2") + (cl-loop for idx below 4 collect + (ebox-test-box (ebox-test-text (format "border %02d" idx)) + :width '(100) :height 2))))) (ebox--render-cache-table (make-hash-table :test 'equal)) (full-lines (mapcar #'substring-no-properties (ebox-string-lines (ebox-render node)))) (complete-wrapper-count 0) (original-render-box-lines (symbol-function 'ebox--flex-window-render-box-lines))) - (should (ebox--render-flex-window-lines node 4)) + (should (ebox-test--call-input-node + #'ebox--render-flex-window-lines node 4)) (cl-letf (((symbol-function 'ebox--flex-window-render-box-lines) (lambda (node content complete limit) (when complete (cl-incf complete-wrapper-count)) (funcall original-render-box-lines node content complete limit)))) - (let ((window (ebox--render-flex-window-lines node 8))) + (let ((window (ebox-test--call-input-node + #'ebox--render-flex-window-lines node 8))) (should window) (should (plist-get window :complete)) (should (= complete-wrapper-count 0)) @@ -7444,22 +7683,22 @@ "A failed flex completion should not publish half-appended cache lists." (ebox-test--reset-runtime-state) (let* ((node - (ebox-test-build - `(flex :id "flex-atomic-prefix" :width (240) - :flex-flow (row wrap) - :row-gap 1 :column-gap 0 - :padding (0 (8)) - :border-right "#D97757" - :border-bottom "#D97757" - :border-left "#D97757" - ,@(cl-loop for idx below 4 - collect - `(box :content ,(format "atomic %02d" idx) - :width (100) :height 2))))) + (apply #'ebox-test-flex + (append + (list :id "flex-atomic-prefix" :width '(240) :flex-flow + '(row wrap) :row-gap 1 :column-gap 0 :padding '(0 (8)) + :border-right "#D97757" :border-bottom "#D97757" + :border-left "#D97757") + (cl-loop for idx below 4 collect + (ebox-test-box (ebox-test-text (format "atomic %02d" idx)) + :width '(100) :height 2))))) (ebox--render-cache-table (make-hash-table :test 'equal)) - (context (ebox--flex-window-cache-context node))) - (should (ebox--render-flex-window-lines node 4)) - (let* ((before (ebox--flex-window-prefix-cache-lookup node context)) + (context (ebox-test--call-input-node + #'ebox--flex-window-cache-context node))) + (should (ebox-test--call-input-node + #'ebox--render-flex-window-lines node 4)) + (let* ((before (ebox-test--call-input-node + #'ebox--flex-window-prefix-cache-lookup node context)) (before-lines (plist-get before :lines)) (before-content-lines (plist-get before :content-lines)) (before-line-count (plist-get before :line-count)) @@ -7467,8 +7706,11 @@ (cl-letf (((symbol-function 'ebox--flex-window-complete-tail-line) (lambda (&rest _) (error "injected flex completion failure")))) - (should-error (ebox--render-flex-window-lines node 8))) - (let ((after (ebox--flex-window-prefix-cache-lookup node context))) + (should-error + (ebox-test--call-input-node + #'ebox--render-flex-window-lines node 8))) + (let ((after (ebox-test--call-input-node + #'ebox--flex-window-prefix-cache-lookup node context))) (should (eq before-lines (plist-get after :lines))) (should (eq before-content-lines (plist-get after :content-lines))) @@ -7478,7 +7720,8 @@ (length (plist-get after :lines)))) (should (= (plist-get after :content-line-count) (length (plist-get after :content-lines))))) - (should (plist-get (ebox--render-flex-window-lines node 8) + (should (plist-get (ebox-test--call-input-node + #'ebox--render-flex-window-lines node 8) :complete))))) (ert-deftest ebox-stack-window-completion-reconciles-declared-prefix-tail () @@ -7565,24 +7808,28 @@ (should (= 1 (ebox--flex-window-completion-prefix-tail-rewrite-count - (list :box (ebox-test-box :border-bottom-p t - :padding-bottom 0))))) + (list :box (ebox-test-root + (ebox-test-box :border-bottom-p t + :padding-bottom 0)))))) (should (= 1 (ebox--flex-window-completion-prefix-tail-rewrite-count - (list :box (ebox-test-box :border-bottom-p t - :padding-bottom 0 - :margin-bottom 2))))) + (list :box (ebox-test-root + (ebox-test-box :border-bottom-p t + :padding-bottom 0 + :margin-bottom 2)))))) (should (= 0 (ebox--flex-window-completion-prefix-tail-rewrite-count - (list :box (ebox-test-box :border-bottom-p t - :padding-bottom 2))))) + (list :box (ebox-test-root + (ebox-test-box :border-bottom-p t + :padding-bottom 2)))))) (should (= 0 (ebox--flex-window-completion-prefix-tail-rewrite-count - (list :box (ebox-test-box :border-bottom-p nil - :padding-bottom 0)))))) + (list :box (ebox-test-root + (ebox-test-box :border-bottom-p nil + :padding-bottom 0))))))) (ert-deftest ebox-flex-window-cache-rejects-short-incomplete-exact-hit () "An underfilled exact-limit cache must resume from its prefix seed." @@ -7607,8 +7854,9 @@ (ert-deftest ebox-byte-compiled-scroll-window-binds-wrapper-viewport () "Compiled lazy rendering must dynamically bind the wrapper content width." (ebox-test--reset-runtime-state) - (let* ((wrapper (ebox-test-box :content "" :width '(278) :wrap-mode nil)) - (source (list :stack :test-stack :wrapper wrapper)) + (let* ((wrapper (ebox-test-box (ebox-test-text "") :width '(278) :wrap-mode 'none)) + (source (list :stack :test-stack + :wrapper (ebox-test-root wrapper))) observed-viewport) (cl-letf (((symbol-function 'ebox--render-stack-window-lines) (lambda (&rest _args) @@ -7622,8 +7870,9 @@ (ert-deftest ebox-scroll-window-wrapper-applies-declared-prefix-rewrite () "Wrapper prefix reuse should rerender only a declared changed range." (ebox-test--reset-runtime-state) - (let* ((wrapper (ebox-test-box :content "" :width 20 :wrap-mode nil)) - (source (list :stack :test-stack :wrapper wrapper)) + (let* ((wrapper (ebox-test-box (ebox-test-text "") :width 20 :wrap-mode 'none)) + (source (list :stack :test-stack + :wrapper (ebox-test-root wrapper))) (plain-tail "tail") (complete-tail (ebox--propertize-underline "tail" "blue")) @@ -7687,12 +7936,13 @@ (ert-deftest ebox-scroll-window-wrapper-maps-top-chrome-rewrite-ranges () "Wrapper reuse should include top chrome only when source line zero changes." (ebox-test--reset-runtime-state) - (let ((wrapper (ebox-test-box :content "" :width 20 :wrap-mode nil + (let ((wrapper (ebox-test-box (ebox-test-text "") :width 20 :wrap-mode 'none :margin-top 1 :padding-top 2))) (cl-labels ((mapped-range (source-range) (let* ((source - (list :stack :test-stack :wrapper wrapper)) + (list :stack :test-stack + :wrapper (ebox-test-root wrapper))) (source-lines (list "head" "body")) (calls 0)) (cl-letf @@ -7730,14 +7980,15 @@ (ert-deftest ebox-scroll-prefix-renderer-applies-equal-length-prefix-rewrite () "Equal-length completion should rerender only its declared changed line." (ebox-test--reset-runtime-state) - (let* ((box (ebox-test-box :content "" :width 20 :height 1 - :wrap-mode nil)) + (let* ((box (ebox-test-box (ebox-test-text "") :width 20 :height 1 + :wrap-mode 'none)) (plain-lines '("head" "body" "tail")) (source-lines (copy-sequence plain-lines)) (complete-tail (ebox--propertize-underline "tail" "blue")) (renderer - (ebox--scroll-window-prefix-renderer box :test-source 20 4)) + (ebox--scroll-window-prefix-renderer + (ebox-test-root box) :test-source 20 4)) (rendered-line-count 0) (original-renderer (symbol-function 'ebox--scroll-rendered-content-lines))) @@ -7789,11 +8040,12 @@ (ert-deftest ebox-scroll-prefix-projects-wrapper-top-chrome-delta () "A published prefix should reuse a source cache with hidden top chrome." (ebox-test--reset-runtime-state) - (let* ((box (ebox-test-box :content "" :width 20 :height 1 - :wrap-mode nil)) - (wrapper (ebox-test-box :content "" :width 20 :wrap-mode nil + (let* ((box (ebox-test-box (ebox-test-text "") :width 20 :height 1 + :wrap-mode 'none)) + (wrapper (ebox-test-box (ebox-test-text "") :width 20 :wrap-mode 'none :padding-top 2 :margin-top 1)) - (source (list :stack :test-stack :wrapper wrapper)) + (source (list :stack :test-stack + :wrapper (ebox-test-root wrapper))) stack-lines stack-tail (stack-count 0) @@ -7802,7 +8054,8 @@ (original-renderer (symbol-function 'ebox--scroll-rendered-content-lines)) (renderer - (ebox--scroll-window-prefix-renderer box source 20 4))) + (ebox--scroll-window-prefix-renderer + (ebox-test-root box) source 20 4))) (cl-letf (((symbol-function 'ebox--render-stack-window-lines) (lambda (_stack limit &optional _cache) (cl-incf calls) @@ -7857,8 +8110,8 @@ (ert-deftest ebox-scroll-prefix-exact-extension-scans-only-appended-lines () "A long exact prefix should retain its cons cells and scan only its delta." (ebox-test--reset-runtime-state) - (let* ((box (ebox-test-box :content "" :width 20 :height 1 - :wrap-mode nil)) + (let* ((box (ebox-test-box (ebox-test-text "") :width 20 :height 1 + :wrap-mode 'none)) (source-lines (cl-loop for idx below 1000 collect (format "line-%d" idx))) (source-tail (last source-lines)) @@ -7990,8 +8243,8 @@ (ert-deftest ebox-scroll-prefix-renderer-failure-keeps-published-pair-atomic () "A failed rendered delta should preserve both published prefix lists." (ebox-test--reset-runtime-state) - (let* ((box (ebox-test-box :content "" :width 20 :height 1 - :wrap-mode nil)) + (let* ((box (ebox-test-box (ebox-test-text "") :width 20 :height 1 + :wrap-mode 'none)) (source-lines (list "line-0" "line-1" "line-2")) (source-tail (last source-lines)) (calls 0) @@ -8002,7 +8255,7 @@ (ebox--render-cache-table (make-hash-table :test 'equal)) (renderer (ebox--scroll-window-prefix-renderer - box :test-source 20 4))) + (ebox-test-root box) :test-source 20 4))) (cl-letf (((symbol-function 'ebox--render-scroll-window-source) (lambda (_source _limit &optional cache) @@ -8106,11 +8359,11 @@ "A prewarm-only prefix slice should yield once, then publish exact content." (ebox-test--reset-runtime-state) (let* ((node (list :ebox-type 'test-yielding-prewarm)) - (box (ebox-test-box :content "" :width 20 :height 1 - :wrap-mode nil :overflow 'scroll)) + (box (ebox-test-box (ebox-test-text "") :width 20 :height 1 + :wrap-mode 'none :overflow 'scroll)) (renderer (ebox--scroll-window-prefix-renderer - box (list :stack node) 20 1)) + (ebox-test-root box) (list :stack node) 20 1)) (prewarm-count 0) (render-count 0)) (unwind-protect @@ -8154,15 +8407,15 @@ "Incremental lazy scroll renders should cover one smooth wheel drain." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-test-build - `(box :id "root" :width (viewport) :height (viewport-height) - :overflow scroll - (column :width (viewport) - ,@(cl-loop for idx below 40 - collect - `(box :id ,(format "budget-row-%02d" idx) - :content ,(format "budget row %02d" idx) - :height 1)))))) + (ebox-test-box :id "root" :width '(viewport) :height + '(viewport-height) :overflow 'scroll + (apply #'ebox-test-column + (append (list :width '(viewport)) + (cl-loop for idx below 40 collect + (ebox-test-box :id + (format "budget-row-%02d" idx) + (ebox-test-text (format "budget row %02d" idx)) + :height 1)))))) (limits nil) (original-render-source (symbol-function 'ebox--render-scroll-window-source))) @@ -8184,15 +8437,14 @@ "Small lazy scroll steps should not materialize the whole source." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-test-build - `(box :id "root" :width (viewport) :height (viewport-height) - :overflow scroll - (column :width (viewport) - ,@(cl-loop for idx below 40 - collect - `(box :id ,(format "row-%02d" idx) - :content ,(format "row %02d" idx) - :height 1)))))) + (ebox-test-box :id "root" :width '(viewport) :height + '(viewport-height) :overflow 'scroll + (apply #'ebox-test-column + (append (list :width '(viewport)) + (cl-loop for idx below 40 collect + (ebox-test-box :id (format "row-%02d" idx) + (ebox-test-text (format "row %02d" idx)) :height + 1)))))) (buffer nil) root-id (hidden-render-count 0) @@ -8202,7 +8454,10 @@ (ebox-viewport-height 2)) (cl-letf (((symbol-function 'ebox--render-box) (lambda (box) - (when (equal (ebox-tree-node-id box) "row-30") + (when (equal + (ebox-tree-node-id + (ebox-test-source-index layout) box) + "row-30") (cl-incf hidden-render-count)) (funcall original-render-box box)))) (setq buffer @@ -8231,15 +8486,15 @@ "Large lazy scroll misses should schedule prefetch without inline render." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-test-build - `(box :id "root" :width (viewport) :height (viewport-height) - :overflow scroll - (column :width (viewport) - ,@(cl-loop for idx below 40 - collect - `(box :id ,(format "cache-row-%02d" idx) - :content ,(format "cache row %02d" idx) - :height 1)))))) + (ebox-test-box :id "root" :width '(viewport) :height + '(viewport-height) :overflow 'scroll + (apply #'ebox-test-column + (append (list :width '(viewport)) + (cl-loop for idx below 40 collect + (ebox-test-box :id + (format "cache-row-%02d" idx) + (ebox-test-text (format "cache row %02d" idx)) + :height 1)))))) (buffer nil) root-id (prefix-count 0) @@ -8292,15 +8547,17 @@ "Smooth scroll cache misses should keep moving with bounded prefix render." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-test-build - `(box :id "root" :width (viewport) :height (viewport-height) - :overflow scroll - (column :width (viewport) - ,@(cl-loop for idx below 40 - collect - `(box :id ,(format "smooth-cache-row-%02d" idx) - :content ,(format "smooth cache row %02d" idx) - :height 1)))))) + (ebox-test-box :id "root" :width '(viewport) :height + '(viewport-height) :overflow 'scroll + (apply #'ebox-test-column + (append (list :width '(viewport)) + (cl-loop for idx below 40 collect + (ebox-test-box :id + (format "smooth-cache-row-%02d" + idx) + (ebox-test-text (format "smooth cache row %02d" + idx)) + :height 1)))))) (buffer nil) root-id (prefix-count 0) @@ -8353,15 +8610,17 @@ "Smooth scroll cache misses should extend a bounded prefix without stalling." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-test-build - `(box :id "root" :width (viewport) :height (viewport-height) - :overflow scroll - (column :width (viewport) - ,@(cl-loop for idx below 40 - collect - `(box :id ,(format "smooth-inline-row-%02d" idx) - :content ,(format "smooth inline row %02d" idx) - :height 1)))))) + (ebox-test-box :id "root" :width '(viewport) :height + '(viewport-height) :overflow 'scroll + (apply #'ebox-test-column + (append (list :width '(viewport)) + (cl-loop for idx below 40 collect + (ebox-test-box :id + (format + "smooth-inline-row-%02d" idx) + (ebox-test-text (format + "smooth inline row %02d" idx)) + :height 1)))))) (buffer nil) root-id (prefix-count 0) @@ -8414,15 +8673,17 @@ "Large smooth pending input should not render a large lazy prefix at once." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-test-build - `(box :id "root" :width (viewport) :height (viewport-height) - :overflow scroll - (column :width (viewport) - ,@(cl-loop for idx below 200 - collect - `(box :id ,(format "smooth-large-row-%03d" idx) - :content ,(format "smooth large row %03d" idx) - :height 1)))))) + (ebox-test-box :id "root" :width '(viewport) :height + '(viewport-height) :overflow 'scroll + (apply #'ebox-test-column + (append (list :width '(viewport)) + (cl-loop for idx below 200 collect + (ebox-test-box :id + (format "smooth-large-row-%03d" + idx) + (ebox-test-text (format "smooth large row %03d" + idx)) + :height 1)))))) (buffer nil) root-id (prefix-count 0) @@ -8470,15 +8731,17 @@ "Idle prefetch should not compete with an active smooth scroll timer." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-test-build - `(box :id "root" :width (viewport) :height (viewport-height) - :overflow scroll - (column :width (viewport) - ,@(cl-loop for idx below 40 - collect - `(box :id ,(format "smooth-defer-row-%02d" idx) - :content ,(format "smooth defer row %02d" idx) - :height 1)))))) + (ebox-test-box :id "root" :width '(viewport) :height + '(viewport-height) :overflow 'scroll + (apply #'ebox-test-column + (append (list :width '(viewport)) + (cl-loop for idx below 40 collect + (ebox-test-box :id + (format "smooth-defer-row-%02d" + idx) + (ebox-test-text (format "smooth defer row %02d" + idx)) + :height 1)))))) (buffer nil) root-id (prefix-count 0)) @@ -8523,15 +8786,17 @@ "Smooth scroll should not pause at a lazy prefix boundary." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-test-build - `(box :id "root" :width (viewport) :height (viewport-height) - :overflow scroll - (column :width (viewport) - ,@(cl-loop for idx below 40 - collect - `(box :id ,(format "smooth-resume-row-%02d" idx) - :content ,(format "smooth resume row %02d" idx) - :height 1)))))) + (ebox-test-box :id "root" :width '(viewport) :height + '(viewport-height) :overflow 'scroll + (apply #'ebox-test-column + (append (list :width '(viewport)) + (cl-loop for idx below 40 collect + (ebox-test-box :id + (format + "smooth-resume-row-%02d" idx) + (ebox-test-text (format + "smooth resume row %02d" idx)) + :height 1)))))) (buffer nil) root-id (prefix-count 0)) @@ -8578,15 +8843,14 @@ "Repeated lazy scroll steps should keep TP mounts valid." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-test-build - `(box :id "root" :width (viewport) :height (viewport-height) - :overflow scroll - (column :width (viewport) - ,@(cl-loop for idx below 40 - collect - `(box :id ,(format "row-%02d" idx) - :content ,(format "row %02d" idx) - :height 1)))))) + (ebox-test-box :id "root" :width '(viewport) :height + '(viewport-height) :overflow 'scroll + (apply #'ebox-test-column + (append (list :width '(viewport)) + (cl-loop for idx below 40 collect + (ebox-test-box :id (format "row-%02d" idx) + (ebox-test-text (format "row %02d" idx)) :height + 1)))))) (buffer nil) root-id (hidden-render-count 0) @@ -8597,7 +8861,10 @@ (ebox-viewport-height 2)) (cl-letf (((symbol-function 'ebox--render-box) (lambda (box) - (when (equal (ebox-tree-node-id box) "row-30") + (when (equal + (ebox-tree-node-id + (ebox-test-source-index layout) box) + "row-30") (cl-incf hidden-render-count)) (funcall original-render-box box)))) (setq buffer @@ -8636,15 +8903,14 @@ "Repeated lazy scroll steps should reuse a prefetched lazy prefix." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-test-build - `(box :id "root" :width (viewport) :height (viewport-height) - :overflow scroll - (column :width (viewport) - ,@(cl-loop for idx below 40 - collect - `(box :id ,(format "row-%02d" idx) - :content ,(format "row %02d" idx) - :height 1)))))) + (ebox-test-box :id "root" :width '(viewport) :height + '(viewport-height) :overflow 'scroll + (apply #'ebox-test-column + (append (list :width '(viewport)) + (cl-loop for idx below 40 collect + (ebox-test-box :id (format "row-%02d" idx) + (ebox-test-text (format "row %02d" idx)) :height + 1)))))) (buffer nil) root-id (prefix-count 0)) @@ -8682,15 +8948,14 @@ "Idle prefetch should fill lazy scroll lines before wheel ticks need them." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-test-build - `(box :id "root" :width (viewport) :height (viewport-height) - :overflow scroll - (column :width (viewport) - ,@(cl-loop for idx below 80 - collect - `(box :id ,(format "row-%02d" idx) - :content ,(format "row %02d" idx) - :height 1)))))) + (ebox-test-box :id "root" :width '(viewport) :height + '(viewport-height) :overflow 'scroll + (apply #'ebox-test-column + (append (list :width '(viewport)) + (cl-loop for idx below 80 collect + (ebox-test-box :id (format "row-%02d" idx) + (ebox-test-text (format "row %02d" idx)) :height + 1)))))) (buffer nil) root-id (prefix-count 0)) @@ -8731,14 +8996,13 @@ "Lazy prefix slices should retain the initial render's display cache." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-test-build - `(box :id "root" :width (viewport) :height (viewport-height) - :overflow scroll - (column :width (viewport) - ,@(cl-loop for idx below 80 - collect - `(box :content ,(format "repeated row %02d" idx) - :height 1)))))) + (ebox-test-box :id "root" :width '(viewport) :height + '(viewport-height) :overflow 'scroll + (apply #'ebox-test-column + (append (list :width '(viewport)) + (cl-loop for idx below 80 collect + (ebox-test-box (ebox-test-text (format "repeated row %02d" idx)) + :height 1)))))) (buffer nil) root-id caches) @@ -8958,15 +9222,14 @@ "Successful lazy scroll steps should schedule the next generic prefetch." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-test-build - `(box :id "root" :width (viewport) :height (viewport-height) - :overflow scroll - (column :width (viewport) - ,@(cl-loop for idx below 80 - collect - `(box :id ,(format "row-%02d" idx) - :content ,(format "row %02d" idx) - :height 1)))))) + (ebox-test-box :id "root" :width '(viewport) :height + '(viewport-height) :overflow 'scroll + (apply #'ebox-test-column + (append (list :width '(viewport)) + (cl-loop for idx below 80 collect + (ebox-test-box :id (format "row-%02d" idx) + (ebox-test-text (format "row %02d" idx)) :height + 1)))))) (buffer nil) root-id) (unwind-protect @@ -8990,15 +9253,14 @@ "Smooth scroll should prefetch only after the active animation stops." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-test-build - `(box :id "root" :width (viewport) :height (viewport-height) - :overflow scroll - (column :width (viewport) - ,@(cl-loop for idx below 80 - collect - `(box :id ,(format "row-%02d" idx) - :content ,(format "row %02d" idx) - :height 1)))))) + (ebox-test-box :id "root" :width '(viewport) :height + '(viewport-height) :overflow 'scroll + (apply #'ebox-test-column + (append (list :width '(viewport)) + (cl-loop for idx below 80 collect + (ebox-test-box :id (format "row-%02d" idx) + (ebox-test-text (format "row %02d" idx)) :height + 1)))))) (buffer nil) root-id) (unwind-protect @@ -9056,17 +9318,21 @@ (defun ebox-test--complete-lazy-scroll-batch-layout () "Return a complete five-row lazy scroll cache fixture." - (ebox-test-build - '(box :id "root" :width (viewport) :height (viewport-height) - :overflow scroll - (column :width (viewport) - (box :content "row 00" :height 1) - (box :id "target" :content "target row" :height 1 - :color "#111111") - (box :id "second-target" :content "second target row" :height 1 - :color "#111111") - (box :content "row 03" :height 1) - (box :content "row 04" :height 1))))) + (ebox-test-box :id "root" :width '(viewport) :height + '(viewport-height) :overflow 'scroll + (ebox-test-column :width '(viewport) + (ebox-test-box (ebox-test-text "row 00") + :height 1) + (ebox-test-box :id "target" (ebox-test-text "target row") :height 1 + :color "#111111") + (ebox-test-box :id "second-target" + (ebox-test-text "second target row") + :height 1 :color + "#111111") + (ebox-test-box (ebox-test-text "row 03") + :height 1) + (ebox-test-box (ebox-test-text "row 04") + :height 1)))) (defun ebox-test--text-has-foreground-p (string text color) "Return non-nil when TEXT in STRING has foreground COLOR." @@ -9095,15 +9361,14 @@ "Extending a lazy prefix should not replay producer caches from before an update." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-test-build - `(box :id "root" :width (viewport) :height (viewport-height) - :overflow scroll - (column :width (viewport) - ,@(cl-loop for idx below 12 - collect - `(box :id ,(format "row-%02d" idx) - :content ,(format "row %02d" idx) - :height 1)))))) + (ebox-test-box :id "root" :width '(viewport) :height + '(viewport-height) :overflow 'scroll + (apply #'ebox-test-column + (append (list :width '(viewport)) + (cl-loop for idx below 12 collect + (ebox-test-box :id (format "row-%02d" idx) + (ebox-test-text (format "row %02d" idx)) :height + 1)))))) (ebox-viewport-width 240) (ebox-viewport-height 2) (ebox-scroll-lazy-prefix-lookahead-lines 0) @@ -9128,15 +9393,14 @@ "Extending a lazy prefix should preserve committed content until batch flush." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-test-build - `(box :id "root" :width (viewport) :height (viewport-height) - :overflow scroll - (column :width (viewport) - ,@(cl-loop for idx below 12 - collect - `(box :id ,(format "row-%02d" idx) - :content ,(format "row %02d" idx) - :height 1)))))) + (ebox-test-box :id "root" :width '(viewport) :height + '(viewport-height) :overflow 'scroll + (apply #'ebox-test-column + (append (list :width '(viewport)) + (cl-loop for idx below 12 collect + (ebox-test-box :id (format "row-%02d" idx) + (ebox-test-text (format "row %02d" idx)) :height + 1)))))) (ebox-viewport-width 240) (ebox-viewport-height 2) (ebox-scroll-lazy-prefix-lookahead-lines 0) @@ -9268,7 +9532,7 @@ (ert-deftest ebox-explicit-batch-flush-retries-after-quit () "A quit during a batch flush must keep the recorded changes retryable." (ebox-test--reset-runtime-state) - (let* ((box (ebox-test-box :content "Batch retry" :color "black")) + (let* ((box (ebox-test-box (ebox-test-text "Batch retry") :color "black")) (id (car (ebox-region-ids box)))) (ebox-test--with-rendered-buffer box (let ((buffer (current-buffer))) @@ -9302,26 +9566,25 @@ "Lazy prefix extension should not recompute node signatures each slice." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-test-build - `(box :id "root" :width (viewport) :height (viewport-height) - :overflow scroll))) + (ebox-test-box :id "root" :width '(viewport) :height + '(viewport-height) :overflow 'scroll)) (source-node - (ebox-test-build - `(column :width (viewport) - (flex :id "signature-flex" :width (viewport) - :flex-flow (row wrap) - :row-gap 1 :column-gap (8) - :padding (0 (12)) - :border-right "#D97757" - :border-bottom "#D97757" - :border-left "#D97757" - ,@(cl-loop for idx below 24 - collect - `(box :content ,(format "signature %02d" idx) - :width (100) :height 2)))))) - (source (list :stack source-node)) + (ebox-test-column :width '(viewport) + (apply #'ebox-test-flex + (append + (list :id "signature-flex" :width '(viewport) :flex-flow + '(row wrap) :row-gap 1 :column-gap '(8) :padding + '(0 (12)) :border-right "#D97757" :border-bottom + "#D97757" :border-left "#D97757") + (cl-loop for idx below 24 collect + (ebox-test-box (ebox-test-text (format "signature %02d" idx)) + :width '(100) :height 2)))))) + (combined-input (ebox-test-forest-input layout source-node)) + (ebox--render-source-index + (ebox-test-source-index combined-input)) + (source (list :stack (ebox-test-root source-node))) (renderer (ebox--scroll-window-prefix-renderer - layout source 240 2 nil nil)) + (ebox-test-root layout) source 240 2 nil nil)) signature-caches (original-node-signature (symbol-function 'ebox--render-cache-node-signature))) @@ -9349,15 +9612,14 @@ "The first smooth wheel input should only replace visible lines." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-test-build - `(box :id "root" :width (viewport) :height (viewport-height) - :overflow scroll - (column :width (viewport) - ,@(cl-loop for idx below 40 - collect - `(box :id ,(format "row-%02d" idx) - :content ,(format "row %02d" idx) - :height 1)))))) + (ebox-test-box :id "root" :width '(viewport) :height + '(viewport-height) :overflow 'scroll + (apply #'ebox-test-column + (append (list :width '(viewport)) + (cl-loop for idx below 40 collect + (ebox-test-box :id (format "row-%02d" idx) + (ebox-test-text (format "row %02d" idx)) :height + 1)))))) (buffer nil) root-id (prefix-count 0)) @@ -9402,15 +9664,15 @@ "Post-resize lazy scroll should not extend prefixes inside smooth ticks." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-test-build - `(box :id "root" :width (viewport) :height (viewport-height) - :overflow scroll - (column :width (viewport) - ,@(cl-loop for idx below 40 - collect - `(box :id ,(format "resize-row-%02d" idx) - :content ,(format "resize row %02d" idx) - :height 1)))))) + (ebox-test-box :id "root" :width '(viewport) :height + '(viewport-height) :overflow 'scroll + (apply #'ebox-test-column + (append (list :width '(viewport)) + (cl-loop for idx below 40 collect + (ebox-test-box :id + (format "resize-row-%02d" idx) + (ebox-test-text (format "resize row %02d" idx)) + :height 1)))))) (buffer nil) root-id (prefix-count 0)) @@ -9457,15 +9719,15 @@ "Viewport rerender should warm lazy scroll prefixes before fast scrolling." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-test-build - `(box :id "root" :width (viewport) :height (viewport-height) - :overflow scroll - (column :width (viewport) - ,@(cl-loop for idx below 80 - collect - `(box :id ,(format "urgent-row-%02d" idx) - :content ,(format "urgent row %02d" idx) - :height 1)))))) + (ebox-test-box :id "root" :width '(viewport) :height + '(viewport-height) :overflow 'scroll + (apply #'ebox-test-column + (append (list :width '(viewport)) + (cl-loop for idx below 80 collect + (ebox-test-box :id + (format "urgent-row-%02d" idx) + (ebox-test-text (format "urgent row %02d" idx)) + :height 1)))))) (buffer nil) root-id delays @@ -9495,14 +9757,13 @@ "Smooth scrolling should keep render GC settings until the animation stops." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-test-build - `(box :id "root" :width (viewport) :height (viewport-height) - :overflow scroll - (column :width (viewport) - ,@(cl-loop for idx below 40 - collect - `(box :content ,(format "row %02d" idx) - :height 1)))))) + (ebox-test-box :id "root" :width '(viewport) :height + '(viewport-height) :overflow 'scroll + (apply #'ebox-test-column + (append (list :width '(viewport)) + (cl-loop for idx below 40 collect + (ebox-test-box (ebox-test-text (format "row %02d" idx)) :height + 1)))))) (buffer nil) root-id (old-threshold gc-cons-threshold) @@ -9553,12 +9814,11 @@ "A parent paint-patch must not clobber a child's own declared paint." (ebox-test--reset-runtime-state) (let ((layout - (ebox-test-build - '(box :id parent :width (120) :color "black" - (column :width (120) - (box :id child :content "Child" :width (120) - :color "blue") - (box :id plain :content "Plain" :width (120))))))) + (ebox-test-box :id 'parent :width '(120) :color "black" + (ebox-test-column :width '(120) + (ebox-test-box :id 'child (ebox-test-text "Child") :width '(120) + :color "blue") + (ebox-test-box :id 'plain (ebox-test-text "Plain") :width '(120)))))) (ebox-test--with-rendered-buffer layout (let* ((buffer (current-buffer)) (parent-id (ebox-test--selector-region-id buffer "#parent"))) @@ -9582,14 +9842,13 @@ "Intrinsic measurement must not reset live scroll offsets or state." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-test-build - `(box :id "root" :width (viewport) :height (viewport-height) - :overflow scroll - (column :width (viewport) - ,@(cl-loop for idx below 20 - collect - `(box :content ,(format "row %02d" idx) - :height 1)))))) + (ebox-test-box :id "root" :width '(viewport) :height + '(viewport-height) :overflow 'scroll + (apply #'ebox-test-column + (append (list :width '(viewport)) + (cl-loop for idx below 20 collect + (ebox-test-box (ebox-test-text (format "row %02d" idx)) :height + 1)))))) (buffer nil) root-id) (unwind-protect @@ -9611,7 +9870,10 @@ ;; flag bound; the measurement render must be side-effect ;; free for the published runtime. (let ((ebox--intrinsic-layout-measurement t)) - (ebox-render box)) + (ebox--render-node + box + (plist-get (ebox--buffer-render-state buffer) + :source-index))) (should (equal (ebox-get box :scroll-offset) 5))) (let ((state (ebox--scroll-get-state root-id))) (should state) @@ -9623,9 +9885,7 @@ "A quit during viewport reflow must not wedge later same-size reflows." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-test-build - '(box :id "root" :width (viewport) - :content "viewport reflow retry"))) + (ebox-test-box :id "root" :width '(viewport) (ebox-test-text "viewport reflow retry"))) (buffer nil)) (unwind-protect (let ((ebox-viewport-width 240)) @@ -9657,14 +9917,13 @@ "Coalesced wheel events must not leak the deferred GC lease depth." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-test-build - `(box :id "root" :width (viewport) :height (viewport-height) - :overflow scroll - (column :width (viewport) - ,@(cl-loop for idx below 40 - collect - `(box :content ,(format "row %02d" idx) - :height 1)))))) + (ebox-test-box :id "root" :width '(viewport) :height + '(viewport-height) :overflow 'scroll + (apply #'ebox-test-column + (append (list :width '(viewport)) + (cl-loop for idx below 40 collect + (ebox-test-box (ebox-test-text (format "row %02d" idx)) :height + 1)))))) (buffer nil) root-id (old-threshold gc-cons-threshold) @@ -9707,15 +9966,14 @@ "Smooth wheel clamp should extend lazy scroll prefixes without full render." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-test-build - `(box :id "root" :width (viewport) :height (viewport-height) - :overflow scroll - (column :width (viewport) - ,@(cl-loop for idx below 40 - collect - `(box :id ,(format "row-%02d" idx) - :content ,(format "row %02d" idx) - :height 1)))))) + (ebox-test-box :id "root" :width '(viewport) :height + '(viewport-height) :overflow 'scroll + (apply #'ebox-test-column + (append (list :width '(viewport)) + (cl-loop for idx below 40 collect + (ebox-test-box :id (format "row-%02d" idx) + (ebox-test-text (format "row %02d" idx)) :height + 1)))))) (buffer nil) root-id (hidden-render-count 0) @@ -9726,7 +9984,10 @@ (ebox-wheel-scroll-step 16)) (cl-letf (((symbol-function 'ebox--render-box) (lambda (box) - (when (equal (ebox-tree-node-id box) "row-30") + (when (equal + (ebox-tree-node-id + (ebox-test-source-index layout) box) + "row-30") (cl-incf hidden-render-count)) (funcall original-render-box box)))) (setq buffer @@ -9750,15 +10011,14 @@ "Hidden updates inside lazy scroll windows should stay deferred." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-test-build - `(box :id "root" :width (viewport) :height (viewport-height) - :overflow scroll - (column :width (viewport) - ,@(cl-loop for idx below 32 - collect - `(box :id ,(format "row-%02d" idx) - :content ,(format "row %02d" idx) - :height 1)))))) + (ebox-test-box :id "root" :width '(viewport) :height + '(viewport-height) :overflow 'scroll + (apply #'ebox-test-column + (append (list :width '(viewport)) + (cl-loop for idx below 32 collect + (ebox-test-box :id (format "row-%02d" idx) + (ebox-test-text (format "row %02d" idx)) :height + 1)))))) (buffer nil) root-id target-id next-target-id (materialize-count 0) @@ -9841,14 +10101,13 @@ "A clean offscreen prefix extension should not stall the live window." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-test-build - `(box :id "root" :width (viewport) :height (viewport-height) - :overflow scroll - (column :width (viewport) - ,@(cl-loop for index below 40 - collect - `(box :content ,(format "row %02d" index) - :width (300) :height 1)))))) + (ebox-test-box :id "root" :width '(viewport) :height + '(viewport-height) :overflow 'scroll + (apply #'ebox-test-column + (append (list :width '(viewport)) + (cl-loop for index below 40 collect + (ebox-test-box (ebox-test-text (format "row %02d" index)) + :width '(300) :height 1)))))) root-id (ebox-viewport-width 300) (ebox-viewport-height 2) @@ -9874,14 +10133,13 @@ "A visibly changed prefix should publish atomically without offset motion." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-test-build - `(box :id "root" :width (viewport) :height (viewport-height) - :overflow scroll - (column :width (viewport) - ,@(cl-loop for index below 40 - collect - `(box :content ,(format "row %02d" index) - :width (300) :height 1)))))) + (ebox-test-box :id "root" :width '(viewport) :height + '(viewport-height) :overflow 'scroll + (apply #'ebox-test-column + (append (list :width '(viewport)) + (cl-loop for index below 40 collect + (ebox-test-box (ebox-test-text (format "row %02d" index)) + :width '(300) :height 1)))))) root-id (ebox-viewport-width 300) (ebox-viewport-height 2) @@ -9921,20 +10179,23 @@ "A visible slot miss should publish rebuilt lazy lines before returning." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-test-build - `(box :id "root" :width (viewport) :height (viewport-height) - :overflow scroll - (column :width (viewport) - (flex :width (300) :height 5 :padding (0 (10)) - :border "#6F6A95" :justify-content center - :align-items center - (box :id "target" :content "width target\nexpands" - :width (190) :height 3 :padding (0 (10)) - :border "#9CA3AF" :text-align center)) - ,@(cl-loop for index below 40 - collect - `(box :content ,(format "row %02d" index) - :width (300) :height 1)))))) + (ebox-test-box :id "root" :width '(viewport) :height + '(viewport-height) :overflow 'scroll + (apply #'ebox-test-column + (append + (list :width '(viewport) + (ebox-test-flex :width '(300) :height 5 :padding + '(0 (10)) :border "#6F6A95" + :justify-content 'center :align-items + 'center + (ebox-test-box :id "target" (ebox-test-text "width target\nexpands") + :width '(190) :height 3 + :padding '(0 (10)) + :border "#9CA3AF" + :text-align 'center))) + (cl-loop for index below 40 collect + (ebox-test-box (ebox-test-text (format "row %02d" index)) + :width '(300) :height 1)))))) root-id target-id (ebox-viewport-width 300) (ebox-viewport-height 5) @@ -9980,15 +10241,14 @@ "Hidden updates should patch cached lazy prefix lines before deferring." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-test-build - `(box :id "root" :width (viewport) :height (viewport-height) - :overflow scroll - (column :width (viewport) - ,@(cl-loop for idx below 40 - collect - `(box :id ,(format "row-%02d" idx) - :content ,(format "row %02d" idx) - :height 1)))))) + (ebox-test-box :id "root" :width '(viewport) :height + '(viewport-height) :overflow 'scroll + (apply #'ebox-test-column + (append (list :width '(viewport)) + (cl-loop for idx below 40 collect + (ebox-test-box :id (format "row-%02d" idx) + (ebox-test-text (format "row %02d" idx)) :height + 1)))))) buffer root-id) (unwind-protect @@ -10027,15 +10287,14 @@ "Deferred hidden updates should be visible when lazy prefix reaches them." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-test-build - `(box :id "root" :width (viewport) :height (viewport-height) - :overflow scroll - (column :width (viewport) - ,@(cl-loop for idx below 40 - collect - `(box :id ,(format "row-%02d" idx) - :content ,(format "row %02d" idx) - :height 1)))))) + (ebox-test-box :id "root" :width '(viewport) :height + '(viewport-height) :overflow 'scroll + (apply #'ebox-test-column + (append (list :width '(viewport)) + (cl-loop for idx below 40 collect + (ebox-test-box :id (format "row-%02d" idx) + (ebox-test-text (format "row %02d" idx)) :height + 1)))))) buffer root-id (materialize-count 0)) @@ -10075,9 +10334,9 @@ (ert-deftest ebox-lazy-scroll-dirty-prefix-rebinds-signature-cache () "Dirty lazy prefixes should rebind the render signature cache before rendering." (ebox-test--reset-runtime-state) - (let* ((box (ebox-test-box :content "" :width 10)) + (let* ((box (ebox-test-box (ebox-test-text "") :width 10)) (renderer (ebox--scroll-window-prefix-renderer - box :test-source 120 4)) + (ebox-test-root box) :test-source 120 4)) first-cache second-cache (render-count 0)) @@ -10110,16 +10369,14 @@ "Visible content plus paint updates should patch scroll lines directly." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-test-build - `(box :id "root" :width (viewport) :height (viewport-height) - :overflow scroll - (column :width (viewport) - ,@(cl-loop for idx below 8 - collect - `(box :id ,(format "metric-%02d" idx) - :content "待运行" - :width (120) - :height 2)))))) + (ebox-test-box :id "root" :width '(viewport) :height + '(viewport-height) :overflow 'scroll + (apply #'ebox-test-column + (append (list :width '(viewport)) + (cl-loop for idx below 8 collect + (ebox-test-box :id (format "metric-%02d" idx) + (ebox-test-text "待运行") :width '(120) + :height 2)))))) buffer root-id) (unwind-protect @@ -10155,16 +10412,14 @@ "Post-patch scroll line sync should retain TP-owned viewport ranges." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-test-build - `(box :id "root" :width (viewport) :height (viewport-height) - :overflow scroll - (column :width (viewport) - ,@(cl-loop for idx below 8 - collect - `(box :id ,(format "metric-%02d" idx) - :content "待运行" - :width (120) - :height 2)))))) + (ebox-test-box :id "root" :width '(viewport) :height + '(viewport-height) :overflow 'scroll + (apply #'ebox-test-column + (append (list :width '(viewport)) + (cl-loop for idx below 8 collect + (ebox-test-box :id (format "metric-%02d" idx) + (ebox-test-text "待运行") :width '(120) + :height 2)))))) buffer root-id) (unwind-protect @@ -10195,15 +10450,14 @@ "Paint updates and later scrolling should retain TP-owned ranges." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-test-build - `(box :id "root" :width (viewport) :height (viewport-height) - :overflow scroll - (column :width (viewport) - ,@(cl-loop for idx below 24 - collect - `(box :id ,(format "row-%02d" idx) - :content ,(format "row %02d" idx) - :height 1)))))) + (ebox-test-box :id "root" :width '(viewport) :height + '(viewport-height) :overflow 'scroll + (apply #'ebox-test-column + (append (list :width '(viewport)) + (cl-loop for idx below 24 collect + (ebox-test-box :id (format "row-%02d" idx) + (ebox-test-text (format "row %02d" idx)) :height + 1)))))) buffer root-id) (unwind-protect @@ -10242,9 +10496,8 @@ "A scroll-free root reflow should not inspect scroll containment." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-test-build - '(column :width (viewport) - (box :content "resize" :width (viewport))))) + (ebox-test-column :width '(viewport) + (ebox-test-box (ebox-test-text "resize") :width '(viewport)))) buffer (containment-count 0) (original-containment @@ -10276,16 +10529,14 @@ "Hidden geometry changes in lazy scroll windows should not rerender roots." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-test-build - `(box :id "root" :width (viewport) :height (viewport-height) - :overflow scroll - (column :width (viewport) - ,@(cl-loop for idx below 32 - collect - `(box :id ,(format "row-%02d" idx) - :content ,(format "row %02d" idx) - :width (120) - :height 1)))))) + (ebox-test-box :id "root" :width '(viewport) :height + '(viewport-height) :overflow 'scroll + (apply #'ebox-test-column + (append (list :width '(viewport)) + (cl-loop for idx below 32 collect + (ebox-test-box :id (format "row-%02d" idx) + (ebox-test-text (format "row %02d" idx)) :width + '(120) :height 1)))))) (buffer nil) target-id) (unwind-protect @@ -10309,16 +10560,14 @@ "Paint-only updates should not pay lazy scroll geometry deferral costs." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-test-build - `(box :id "root" :width (viewport) :height (viewport-height) - :overflow scroll - (column :width (viewport) - ,@(cl-loop for idx below 32 - collect - `(box :id ,(format "row-%02d" idx) - :content ,(format "row %02d" idx) - :color "#111111" - :height 1)))))) + (ebox-test-box :id "root" :width '(viewport) :height + '(viewport-height) :overflow 'scroll + (apply #'ebox-test-column + (append (list :width '(viewport)) + (cl-loop for idx below 32 collect + (ebox-test-box :id (format "row-%02d" idx) + (ebox-test-text (format "row %02d" idx)) :color + "#111111" :height 1)))))) (buffer nil) target-id) (unwind-protect @@ -10393,9 +10642,9 @@ "Predictive warming should start from the first valid resize velocity." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-test-build - '(box :id "root" :width (viewport) :height 2 - (box :content "steady" :width (viewport) :height 1)))) + (ebox-test-box :id "root" :width '(viewport) :height 2 + (ebox-test-box (ebox-test-text "steady") :width '(viewport) + :height 1))) buffer scheduled-delay scheduled-repeat @@ -10438,15 +10687,14 @@ "Predicted renders should publish only persistent render-cache entries." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-test-build - `(box :id "root" :width (viewport) :height (viewport-height) - :overflow scroll - (flex :width (viewport) :gap 1 - ,@(cl-loop for index below 8 - collect - `(box :id ,(format "card-%d" index) - :content ,(format "card %d" index) - :width 72 :height 1)))))) + (ebox-test-box :id "root" :width '(viewport) :height + '(viewport-height) :overflow 'scroll + (apply #'ebox-test-flex + (append (list :width '(viewport) :gap 1) + (cl-loop for index below 8 collect + (ebox-test-box :id (format "card-%d" index) + (ebox-test-text (format "card %d" index)) :width + 72 :height 1)))))) buffer) (unwind-protect (let ((ebox-viewport-width 240) @@ -10496,8 +10744,7 @@ "A stale predicted render should exit before touching render caches." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-test-build - '(box :id "root" :width (viewport) :content "stale"))) + (ebox-test-box :id "root" :width '(viewport) (ebox-test-text "stale"))) buffer (render-count 0) (original-render (symbol-function 'ebox-render))) @@ -10528,14 +10775,12 @@ "Idle prewarm should prepare shared update state without materializing scrolls." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-test-build - `(box :id "root" :width (viewport) :height 2 :overflow scroll - (column - ,@(cl-loop for idx below 40 - collect - `(box :id ,(format "row-%02d" idx) - :content ,(format "row %02d" idx) - :height 1)))))) + (ebox-test-box :id "root" :width '(viewport) :height 2 :overflow + 'scroll + (apply #'ebox-test-column + (append + (cl-loop for idx below 40 collect + (ebox-test-box :id (format "row-%02d" idx) (ebox-test-text (format "row %02d" idx)) :height 1)))))) (buffer nil) root-id expected-viewport-ids @@ -10593,12 +10838,11 @@ (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-visible-prewarm*") - (ebox-test-build - '(box :id "root" :height 2 :overflow scroll - (column - (box :content "one" :height 1) - (box :content "two" :height 1) - (box :content "three" :height 1)))))) + (ebox-test-box :id "root" :height 2 :overflow 'scroll + (ebox-test-column + (ebox-test-box (ebox-test-text "one") :height 1) + (ebox-test-box (ebox-test-text "two") :height 1) + (ebox-test-box (ebox-test-text "three") :height 1))))) ;; Rendering happened before the buffer had a window, so the ;; original job is allowed to finish without native materializing. (ebox--runtime-prewarm-drain buffer) @@ -10648,12 +10892,11 @@ explicit visible-window handoff may admit its full-content publication." (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-native-scroll-opt-in*") - (ebox-test-build - '(box :id "root" :height 2 :overflow scroll - (column - (box :content "one" :height 1) - (box :content "two" :height 1) - (box :content "three" :height 1)))))) + (ebox-test-box :id "root" :height 2 :overflow 'scroll + (ebox-test-column + (ebox-test-box (ebox-test-text "one") :height 1) + (ebox-test-box (ebox-test-text "two") :height 1) + (ebox-test-box (ebox-test-text "three") :height 1))))) (let ((state (ebox--buffer-render-state buffer))) (cl-letf (((symbol-function 'ebox--native-buffer-scroll-root-region-id) @@ -10683,13 +10926,12 @@ explicit visible-window handoff may admit its full-content publication." (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-native-scroll-viewport*") - (ebox-test-build - '(box :id "root" :width (viewport) :height 2 - :overflow scroll - (column - (box :content "one" :height 1) - (box :content "two" :height 1) - (box :content "three" :height 1)))))) + (ebox-test-box :id "root" :width '(viewport) :height 2 :overflow + 'scroll + (ebox-test-column + (ebox-test-box (ebox-test-text "one") :height 1) + (ebox-test-box (ebox-test-text "two") :height 1) + (ebox-test-box (ebox-test-text "three") :height 1))))) (let* ((state (ebox--buffer-render-state buffer)) (region-id (car (plist-get state :scroll-region-ids)))) (cl-letf (((symbol-function 'get-buffer-window) @@ -10706,12 +10948,12 @@ explicit visible-window handoff may admit its full-content publication." "Prewarm should index current scroll lines while lazy prefix work is active." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-test-build - '(box :id "root" :height 2 :overflow scroll - (column - (box :id "target" :content "target" :height 1) - (box :id "row-1" :content "row 1" :height 1) - (box :id "row-2" :content "row 2" :height 1))))) + (ebox-test-box :id "root" :height 2 :overflow 'scroll + (ebox-test-column + (ebox-test-box :id "target" (ebox-test-text "target") :height + 1) + (ebox-test-box :id "row-1" (ebox-test-text "row 1") :height 1) + (ebox-test-box :id "row-2" (ebox-test-text "row 2") :height 1)))) (buffer nil) root-id) (unwind-protect @@ -10745,11 +10987,10 @@ explicit visible-window handoff may admit its full-content publication." (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-prewarm-test*") - (ebox-test-build - '(box :id "root" :height 2 :overflow scroll - (column - (box :content "row 0" :height 1) - (box :content "row 1" :height 1)))))) + (ebox-test-box :id "root" :height 2 :overflow 'scroll + (ebox-test-column + (ebox-test-box (ebox-test-text "row 0") :height 1) + (ebox-test-box (ebox-test-text "row 1") :height 1))))) (setq root-id (ebox-test--selector-region-id buffer "#root")) (let* ((state (ebox-scroll-state root-id)) (job (gethash buffer ebox--runtime-prewarm-jobs))) @@ -10787,11 +11028,10 @@ explicit visible-window handoff may admit its full-content publication." (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-prewarm-test*") - (ebox-test-build - '(box :id "root" :height 2 :overflow scroll - (column - (box :content "row 0" :height 1) - (box :content "row 1" :height 1)))))) + (ebox-test-box :id "root" :height 2 :overflow 'scroll + (ebox-test-column + (ebox-test-box (ebox-test-text "row 0") :height 1) + (ebox-test-box (ebox-test-text "row 1") :height 1))))) (setq root-id (ebox-test--selector-region-id buffer "#root")) (let ((state (ebox-scroll-state root-id))) (setq state (plist-put state :render-content-prefix t)) @@ -10874,12 +11114,11 @@ explicit visible-window handoff may admit its full-content publication." (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-prewarm-test*") - (ebox-test-build - '(box :height 2 :overflow scroll - (column - (box :content "row 0" :height 1) - (box :content "row 1" :height 1) - (box :content "row 2" :height 1)))))) + (ebox-test-box :height 2 :overflow 'scroll + (ebox-test-column + (ebox-test-box (ebox-test-text "row 0") :height 1) + (ebox-test-box (ebox-test-text "row 1") :height 1) + (ebox-test-box (ebox-test-text "row 2") :height 1))))) (cl-letf (((symbol-function 'current-idle-time) (lambda () (seconds-to-time 0.4))) ((symbol-function 'ebox--runtime-prewarm-schedule-timer) @@ -10900,10 +11139,8 @@ explicit visible-window handoff may admit its full-content publication." (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-prewarm-test*") - (ebox-test-build - '(column - (box :id "a" :content "A") - (box :id "b" :content "B"))))) + (ebox-test-column (ebox-test-box :id "a" (ebox-test-text "A")) + (ebox-test-box :id "b" (ebox-test-text "B"))))) (should (gethash buffer ebox--runtime-prewarm-jobs)) (ebox--bump-buffer-runtime-revision buffer) (should-not (ebox--runtime-prewarm-step buffer)) @@ -10921,10 +11158,8 @@ explicit visible-window handoff may admit its full-content publication." (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-prewarm-test*") - (ebox-test-build - '(column - (box :id "target" :content "old") - (box :id "sibling" :content "same"))))) + (ebox-test-column (ebox-test-box :id "target" (ebox-test-text "old")) + (ebox-test-box :id "sibling" (ebox-test-text "same"))))) (ebox--runtime-prewarm-drain buffer) (should-not (gethash buffer ebox--runtime-prewarm-jobs)) (let ((target-id @@ -10950,7 +11185,7 @@ explicit visible-window handoff may admit its full-content publication." (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-prewarm-test*") - (ebox-test-build '(box :id "target" :content "old")))) + (ebox-test-box :id "target" (ebox-test-text "old")))) (should (gethash buffer ebox--runtime-prewarm-jobs)) (let ((target-id (ebox-test--selector-region-id buffer "#target"))) @@ -10975,11 +11210,9 @@ explicit visible-window handoff may admit its full-content publication." (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-prewarm-test*") - (ebox-test-build - '(column - (box :content "row 0") - (box :content "row 1") - (box :content "row 2"))))) + (ebox-test-column (ebox-test-box (ebox-test-text "row 0")) + (ebox-test-box (ebox-test-text "row 1")) + (ebox-test-box (ebox-test-text "row 2"))))) (ebox--cancel-buffer-runtime-prewarm buffer) (let ((node-table (ebox--buffer-node-table buffer)) (original-maphash (symbol-function 'maphash))) @@ -11015,15 +11248,14 @@ explicit visible-window handoff may admit its full-content publication." (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-prewarm-test*") - (ebox-test-build - '(box :width (210) - (column - (box :id "width-only" :width (viewport)) - (box :id "height-only" - :height (viewport-height)) - (flex :id "flex-height" - :min-height (- (viewport-height) 1) - (box :content "Flex"))))))) + (ebox-test-box :width '(210) + (ebox-test-column + (ebox-test-box :id "width-only" :width '(viewport)) + (ebox-test-box :id "height-only" :height + '(viewport-height)) + (ebox-test-flex :id "flex-height" :min-height + '(- (viewport-height) 1) + (ebox-test-box (ebox-test-text "Flex"))))))) (let* ((state (ebox--buffer-render-state buffer)) (root (plist-get state :root-node)) (expected-axes @@ -11074,10 +11306,9 @@ explicit visible-window handoff may admit its full-content publication." (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-axis-proof*") - (ebox-test-build - '(column :width (viewport) - (box :content "A" :height 1) - (box :content "B" :height 1))))) + (ebox-test-column :width '(viewport) + (ebox-test-box (ebox-test-text "A") :height 1) + (ebox-test-box (ebox-test-text "B") :height 1)))) (let ((original-axes (symbol-function 'ebox-surface--context-axes)) (original-validation @@ -11119,13 +11350,12 @@ explicit visible-window handoff may admit its full-content publication." "Late cached-line slices should not restart traversal from the list head." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-test-build - `(box :id "root" :height 2 :overflow scroll - (column - ,@(cl-loop for idx below 40 - collect - `(box :content ,(format "row %02d" idx) - :height 1)))))) + (ebox-test-box :id "root" :height 2 :overflow 'scroll + (apply #'ebox-test-column + (append + (cl-loop for idx below 40 collect + (ebox-test-box (ebox-test-text (format "row %02d" idx)) + :height 1)))))) (buffer nil) (nth-count 0) (original-nth (symbol-function 'nth))) @@ -11148,12 +11378,11 @@ explicit visible-window handoff may admit its full-content publication." (ert-deftest ebox-hidden-scroll-child-content-update-patches-scroll-state () "Hidden scroll children should update cached scroll lines without rerendering." (ebox-test--reset-runtime-state) - (let* ((layout (ebox-test-build - '(box :id "root" :height 1 :overflow scroll - (column - (box :content "visible" :height 1) - (box :id "target" :content "old" :height 1) - (box :content "tail" :height 1))))) + (let* ((layout (ebox-test-box :id "root" :height 1 :overflow 'scroll + (ebox-test-column + (ebox-test-box (ebox-test-text "visible") :height 1) + (ebox-test-box :id "target" (ebox-test-text "old") :height 1) + (ebox-test-box (ebox-test-text "tail") :height 1)))) root-id target-id) (ebox-test--with-rendered-buffer layout (setq root-id (ebox-test--selector-region-id (current-buffer) "#root")) @@ -11179,12 +11408,13 @@ explicit visible-window handoff may admit its full-content publication." "Lazy scroll children in the current visible range should patch immediately." (ebox-test--reset-runtime-state) (let* ((children (cl-loop for idx below 40 - collect `(box :id ,(format "row-%02d" idx) - :content ,(format "row %02d" idx) - :height 1))) - (layout (ebox-test-build - `(box :id "root" :height 2 :overflow scroll - (column ,@children)))) + collect + (ebox-test-box + :id (format "row-%02d" idx) + (ebox-test-text (format "row %02d" idx)) + :height 1))) + (layout (ebox-test-box :id "root" :height 2 :overflow 'scroll + (apply #'ebox-test-column (append children)))) root-id target-id) (ebox-test--with-rendered-buffer layout (setq root-id (ebox-test--selector-region-id (current-buffer) "#root")) @@ -11209,18 +11439,21 @@ explicit visible-window handoff may admit its full-content publication." "An empty fixed box in a visible scene must not be mistaken for hidden." (ebox-test--reset-runtime-state) (let* ((layout - (ebox-test-build - `(box :id "root" :width (viewport) :height (viewport-height) - :overflow scroll - (column :width (viewport) - (flex :id "scene" :width (viewport) :height 4 - :align-items center :justify-content center - (box :id "target" :content "SEED" :width (180) :height 3 - :overflow hidden :bgcolor "#DDEAE4")) - ,@(cl-loop for index below 40 - collect - `(box :content ,(format "row %02d" index) - :height 1)))))) + (ebox-test-box :id "root" :width '(viewport) :height + '(viewport-height) :overflow 'scroll + (apply #'ebox-test-column + (append + (list :width '(viewport) + (ebox-test-flex :id "scene" :width '(viewport) :height 4 + :align-items 'center :justify-content + 'center + (ebox-test-box :id "target" (ebox-test-text "SEED") :width '(180) + :height 3 :overflow + 'hidden :bgcolor + "#DDEAE4"))) + (cl-loop for index below 40 collect + (ebox-test-box (ebox-test-text (format "row %02d" index)) + :height 1)))))) (ebox-viewport-width 300) (ebox-viewport-height 4) (ebox-scroll-lazy-prefix-lookahead-lines 12) @@ -11259,17 +11492,19 @@ explicit visible-window handoff may admit its full-content publication." "A sibling update must not materialize an unrelated lazy scroll state." (ebox-test--reset-runtime-state) (let* ((rows (cl-loop for index below 40 - collect `(box :id ,(format "row-%02d" index) - :content ,(format "row %02d" index) - :height 1))) + collect + (ebox-test-box + :id (format "row-%02d" index) + (ebox-test-text (format "row %02d" index)) + :height 1))) (layout - (ebox-test-build - `(column :width (viewport) - (box :id "rail" :content "ready" :width (viewport) - :height 1) - (box :id "root" :width (viewport) - :height (viewport-height) :overflow scroll - (column ,@rows))))) + (ebox-test-column :width '(viewport) + (ebox-test-box :id "rail" (ebox-test-text "ready") :width + '(viewport) :height 1) + (ebox-test-box :id "root" :width '(viewport) :height + '(viewport-height) :overflow 'scroll + (apply #'ebox-test-column + (append rows))))) (ebox-viewport-width 240) (ebox-viewport-height 3) (ebox-scroll-lazy-prefix-lookahead-lines 0) @@ -11303,14 +11538,14 @@ explicit visible-window handoff may admit its full-content publication." "Geometry updates on a revealed child should use a scoped TP plan." (ebox-test--reset-runtime-state) (let ((layout - (ebox-test-build - '(box :id "root" :width (180) :height 2 :overflow scroll - (column - (box :content "row 0" :height 1) - (box :content "row 1" :height 1) - (box :content "row 2" :height 1) - (box :id "target" :content "target" :width (120) :height 1 - :box-sizing border-box :padding-left (2)))))) + (ebox-test-box :id "root" :width '(180) :height 2 :overflow 'scroll + (ebox-test-column + (ebox-test-box (ebox-test-text "row 0") :height 1) + (ebox-test-box (ebox-test-text "row 1") :height 1) + (ebox-test-box (ebox-test-text "row 2") :height 1) + (ebox-test-box :id "target" (ebox-test-text "target") :width + '(120) :height 1 :box-sizing + 'border-box :padding-left '(2))))) root-id target-id) (ebox-test--with-rendered-buffer layout (setq root-id (ebox-test--selector-region-id (current-buffer) "#root")) @@ -11328,12 +11563,11 @@ explicit visible-window handoff may admit its full-content publication." (ert-deftest ebox-wheel-scroll-uses-buffer-scroll-region-without-point () "Wheel events from fringe-like areas should still scroll the ebox page." (ebox-test--reset-runtime-state) - (let* ((layout (ebox-test-build - '(box :id "root" :height 2 :overflow scroll - (column - (box :content "A" :height 1) - (box :content "B" :height 1) - (box :content "C" :height 1))))) + (let* ((layout (ebox-test-box :id "root" :height 2 :overflow 'scroll + (ebox-test-column + (ebox-test-box (ebox-test-text "A") :height 1) + (ebox-test-box (ebox-test-text "B") :height 1) + (ebox-test-box (ebox-test-text "C") :height 1)))) (root-id (car (ebox-region-ids layout))) mwheel-called) (ebox-test--with-rendered-buffer layout @@ -11410,17 +11644,21 @@ explicit visible-window handoff may admit its full-content publication." "Nested boxes should consume their capacity before the native buffer." (ebox-test--reset-runtime-state) (let ((layout - (ebox-test-build - '(box :id "outer" :height 4 :overflow scroll - (column - (box :id "middle" :height 2 :overflow scroll - (column - (box :id "inner" :content "inner 0\ninner 1" - :height 1 :overflow scroll) - (box :content "middle 0" :height 1) - (box :content "middle 1" :height 1) - (box :content "middle 2" :height 1))) - (box :content "outer 0\nouter 1" :height 2)))))) + (ebox-test-box :id "outer" :height 4 :overflow 'scroll + (ebox-test-column + (ebox-test-box :id "middle" :height 2 :overflow + 'scroll + (ebox-test-column + (ebox-test-box :id "inner" (ebox-test-text "inner 0\ninner 1") + :height 1 :overflow + 'scroll) + (ebox-test-box (ebox-test-text "middle 0") + :height 1) + (ebox-test-box (ebox-test-text "middle 1") + :height 1) + (ebox-test-box (ebox-test-text "middle 2") + :height 1))) + (ebox-test-box (ebox-test-text "outer 0\nouter 1") :height 2))))) (ebox-test--with-rendered-buffer layout (let* ((surface (with-current-buffer (current-buffer) ebox-surface--buffer-surface)) @@ -11577,9 +11815,7 @@ explicit visible-window handoff may admit its full-content publication." (ert-deftest ebox-scroll-fast-report-uses-boolean-proof-flag () "Retained-window proof payload should not leak into the public report." (ebox-test--reset-runtime-state) - (let ((layout (ebox-test-build - '(box :id "root" :content "A\nB" - :height 1 :overflow scroll)))) + (let ((layout (ebox-test-box :id "root" (ebox-test-text "A\nB") :height 1 :overflow 'scroll))) (ebox-test--with-rendered-buffer layout (let ((region-id (ebox-test--selector-region-id (current-buffer) "#root"))) @@ -11605,12 +11841,11 @@ explicit visible-window handoff may admit its full-content publication." (ert-deftest ebox-smooth-wheel-scroll-moves-immediately-then-drains () "Smooth wheel scrolling should respond immediately and drain pending lines." (ebox-test--reset-runtime-state) - (let* ((layout (ebox-test-build - '(box :id "root" :height 1 :overflow scroll - (column - (box :content "A" :height 1) - (box :content "B" :height 1) - (box :content "C" :height 1))))) + (let* ((layout (ebox-test-box :id "root" :height 1 :overflow 'scroll + (ebox-test-column + (ebox-test-box (ebox-test-text "A") :height 1) + (ebox-test-box (ebox-test-text "B") :height 1) + (ebox-test-box (ebox-test-text "C") :height 1)))) (root-id (car (ebox-region-ids layout)))) (ebox-test--with-rendered-buffer layout (let ((ebox-wheel-smooth-scroll-lines-per-tick 1) @@ -11628,13 +11863,12 @@ explicit visible-window handoff may admit its full-content publication." (ert-deftest ebox-smooth-wheel-scroll-coalesces-while-timer-active () "Further wheel input should queue behind the active timer, not edit at once." (ebox-test--reset-runtime-state) - (let* ((layout (ebox-test-build - '(box :id "root" :height 1 :overflow scroll - (column - (box :content "A" :height 1) - (box :content "B" :height 1) - (box :content "C" :height 1) - (box :content "D" :height 1))))) + (let* ((layout (ebox-test-box :id "root" :height 1 :overflow 'scroll + (ebox-test-column + (ebox-test-box (ebox-test-text "A") :height 1) + (ebox-test-box (ebox-test-text "B") :height 1) + (ebox-test-box (ebox-test-text "C") :height 1) + (ebox-test-box (ebox-test-text "D") :height 1)))) (root-id (car (ebox-region-ids layout)))) (ebox-test--with-rendered-buffer layout (let ((ebox-wheel-smooth-scroll-lines-per-tick 1) @@ -11657,13 +11891,12 @@ explicit visible-window handoff may admit its full-content publication." (ert-deftest ebox-smooth-wheel-scroll-clamps-pending-to-range () "Large smooth wheel input should not leave an oversized timer backlog." (ebox-test--reset-runtime-state) - (let* ((layout (ebox-test-build - '(box :id "root" :height 1 :overflow scroll - (column - (box :content "A" :height 1) - (box :content "B" :height 1) - (box :content "C" :height 1) - (box :content "D" :height 1))))) + (let* ((layout (ebox-test-box :id "root" :height 1 :overflow 'scroll + (ebox-test-column + (ebox-test-box (ebox-test-text "A") :height 1) + (ebox-test-box (ebox-test-text "B") :height 1) + (ebox-test-box (ebox-test-text "C") :height 1) + (ebox-test-box (ebox-test-text "D") :height 1)))) (root-id (car (ebox-region-ids layout)))) (ebox-test--with-rendered-buffer layout (let ((ebox-wheel-smooth-scroll-lines-per-tick 1) @@ -11685,15 +11918,14 @@ explicit visible-window handoff may admit its full-content publication." (ert-deftest ebox-page-scroll-bubbles-from-child-to-scrollable-ancestor () "Page scrolling over a child box should scroll the nearest scrollable ancestor." (ebox-test--reset-runtime-state) - (let* ((layout (ebox-test-build - '(box :id "root" :height 3 :overflow scroll - (column - (box :id "child" :content "A" :height 1) - (box :content "B" :height 1) - (box :content "C" :height 1) - (box :content "D" :height 1) - (box :content "E" :height 1) - (box :content "F" :height 1))))) + (let* ((layout (ebox-test-box :id "root" :height 3 :overflow 'scroll + (ebox-test-column + (ebox-test-box :id "child" (ebox-test-text "A") :height 1) + (ebox-test-box (ebox-test-text "B") :height 1) + (ebox-test-box (ebox-test-text "C") :height 1) + (ebox-test-box (ebox-test-text "D") :height 1) + (ebox-test-box (ebox-test-text "E") :height 1) + (ebox-test-box (ebox-test-text "F") :height 1)))) (root-id (car (ebox-region-ids layout))) fallback-called) (ebox-test--with-rendered-buffer layout @@ -11715,16 +11947,42 @@ explicit visible-window handoff may admit its full-content publication." (should (string-match-p "F" plain)) (should-not (string-match-p "A" plain)))))) +(ert-deftest ebox-page-scroll-preserves-native-page-fallback-semantics () + "Whole-page fallback should stay native; residuals should stay exact." + (let (down-argument up-argument partial-p) + (cl-letf (((symbol-function 'ebox--scroll-page-lines) + (lambda (&optional arg) + (if arg (prefix-numeric-value arg) 10))) + ((symbol-function 'ebox--scroll-by) + (lambda (delta fallback) + (funcall fallback (if partial-p 2 (abs delta))))) + ((symbol-function 'scroll-up-command) + (lambda (&optional arg) (setq down-argument arg))) + ((symbol-function 'scroll-down-command) + (lambda (&optional arg) (setq up-argument arg)))) + (ebox-scroll-page-down) + (ebox-scroll-page-up) + (should-not down-argument) + (should-not up-argument) + (ebox-scroll-page-down 7) + (ebox-scroll-page-up 7) + (should (= down-argument 7)) + (should (= up-argument 7)) + (setq partial-p t) + (ebox-scroll-page-down) + (ebox-scroll-page-up) + (should (= down-argument 2)) + (should (= up-argument 2))))) + (ert-deftest ebox-scroll-rerender-preserves-viewport-height-context () "Scroll rerender fallback should keep viewport-height boxes clipped." (ebox-test--reset-runtime-state) - (let* ((layout (ebox-test-build - '(box :id "root" :height (viewport-height) :overflow scroll - (column - (box :id "child" :content "A" :height 1) - (box :content "B" :height 1) - (box :content "C" :height 1) - (box :content "D" :height 1))))) + (let* ((layout (ebox-test-box :id "root" :height '(viewport-height) :overflow 'scroll + (ebox-test-column + (ebox-test-box :id "child" (ebox-test-text "A") :height 1) + (ebox-test-box (ebox-test-text "B") :height 1) + (ebox-test-box (ebox-test-text "C") :height 1) + (ebox-test-box (ebox-test-text "D") :height 1)))) (root-id (car (ebox-region-ids layout)))) (let ((ebox-viewport-height 2)) (ebox-test--with-rendered-buffer layout @@ -11750,7 +12008,7 @@ explicit visible-window handoff may admit its full-content publication." (ert-deftest ebox-wheel-scroll-falls-back-at-scroll-boundary () "Mouse wheel should bubble to the buffer when inner scroll is at its edge." (ebox-test--reset-runtime-state) - (let* ((box (ebox-test-box :content "A\nB\nC" + (let* ((box (ebox-test-box (ebox-test-text "A\nB\nC") :height 1 :overflow 'scroll)) (region-id (car (ebox-region-ids box))) @@ -11832,7 +12090,7 @@ explicit visible-window handoff may admit its full-content publication." (ert-deftest ebox-border-box-max-height-includes-vertical-padding () (ebox-test--reset-runtime-state) - (let* ((box (ebox-test-box :content "A\nB\nC\nD" + (let* ((box (ebox-test-box (ebox-test-text "A\nB\nC\nD") :box-sizing 'border-box :max-height 3 :padding '(1 0) @@ -11845,39 +12103,39 @@ explicit visible-window handoff may admit its full-content publication." (ert-deftest ebox-content-box-min-height-wins-when-min-exceeds-max () "Content-box height conflicts should use the minimum as the used height." (ebox-test--reset-runtime-state) - (let* ((box (ebox-test-box :content "A" + (let* ((box (ebox-test-box (ebox-test-text "A") :box-sizing 'content-box :min-height 5 :max-height 3 :padding '(1 0) :overflow 'hidden)) (rendered (ebox-render box))) - (should (= (ebox--content-height box 1) 5)) + (should (= (ebox-test--input-content-height box 1) 5)) (should (= (ebox-string-height rendered) 7)))) (ert-deftest ebox-border-box-min-height-wins-when-min-exceeds-max () "Border-box height conflicts should use the minimum as the outer height." (ebox-test--reset-runtime-state) - (let* ((box (ebox-test-box :content "A" + (let* ((box (ebox-test-box (ebox-test-text "A") :box-sizing 'border-box :min-height 5 :max-height 3 :padding '(1 0) :overflow 'hidden)) (rendered (ebox-render box))) - (should (= (ebox--content-height box 1) 3)) + (should (= (ebox-test--input-content-height box 1) 3)) (should (= (ebox-string-height rendered) 5)))) (ert-deftest ebox-border-box-max-width-includes-horizontal-padding-and-border () (ebox-test--reset-runtime-state) - (let ((box (ebox-test-box :content "abc abc abc abc abc abc abc abc abc abc" + (let ((box (ebox-test-box (ebox-test-text "abc abc abc abc abc abc abc abc abc abc") :box-sizing 'border-box :max-width '(100) :padding-inline '(20) :border-left '(10) :border-right '(10) :overflow 'hidden))) - (should (<= (ebox--total-pixel box) 100)))) + (should (<= (ebox-test--input-total-pixel box) 100)))) (ert-deftest ebox-auto-width-without-viewport-uses-max-content-across-all-lines () "Omitted :width should fall back to the widest unwrapped content line." @@ -11886,11 +12144,11 @@ explicit visible-window handoff may admit its full-content publication." (expected (apply #'max (mapcar #'ebox--string-pixel-width (ebox-string-lines content)))) - (box (ebox-test-box :content content)) + (box (ebox-test-box (ebox-test-text content))) (rendered (ebox-render box)) (widths (mapcar #'ebox--string-pixel-width (ebox-string-lines rendered)))) - (should (= (ebox--content-pixel box) expected)) + (should (= (ebox-test--input-content-pixel box) expected)) (should (cl-every (lambda (width) (= width expected)) widths)))) (ert-deftest ebox-auto-width-stretches-to-viewport-when-available () @@ -11899,21 +12157,21 @@ explicit visible-window handoff may admit its full-content publication." (let ((ebox-viewport-width 240)) (dolist (width-value '(nil auto)) (let ((box (apply #'ebox-test-box - (append (list :content "short line wraps into viewport" - :padding-inline '(20) + (ebox-test-text "short line wraps into viewport") + (append (list :padding-inline '(20) :border-left '(10) :border-right '(10) :margin-inline '(5)) (when width-value (list :width width-value)))))) - (should (= (ebox--total-pixel box) 240)))))) + (should (= (ebox-test--input-total-pixel box) 240)))))) (ert-deftest ebox-explicit-width-can-overflow-containing-block () "A definite width remains definite even when the containing block is narrower." (ebox-test--reset-runtime-state) (let* ((ebox-viewport-width 120) (content "alpha beta gamma delta") - (box (ebox-test-box :content content :width '(220) :wrap-mode 'word)) + (box (ebox-test-box (ebox-test-text content) :width '(220) :wrap-mode 'word)) (rendered (ebox-render box)) (widths (mapcar #'ebox--string-pixel-width (ebox-string-lines rendered)))) @@ -11925,10 +12183,9 @@ explicit visible-window handoff may admit its full-content publication." "A column wrapper width should contain auto-sized children." (ebox-test--reset-runtime-state) (let* ((ebox-viewport-width 1000) - (node (ebox-test-build - '(column :width (120) - (box :content "alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu nu xi omicron pi rho sigma tau upsilon phi chi psi omega" - :wrap-mode word)))) + (node (ebox-test-column :width '(120) + (ebox-test-box (ebox-test-text "alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu nu xi omicron pi rho sigma tau upsilon phi chi psi omega") + :wrap-mode 'word))) (rendered (ebox-render node)) (plain (substring-no-properties rendered)) (widths (mapcar #'ebox--string-pixel-width @@ -11944,9 +12201,7 @@ explicit visible-window handoff may admit its full-content publication." (ebox-test--reset-runtime-state) (let ((ebox-viewport-width 1000)) (cl-letf (((symbol-function 'ebox--space-pixel-width) (lambda () 7))) - (let* ((node (ebox-test-build - '(column :width (400) - (box :content "X")))) + (let* ((node (ebox-test-column :width '(400) (ebox-test-box (ebox-test-text "X")))) (widths (mapcar #'ebox--string-pixel-width (ebox-string-lines (ebox-render node))))) (should (equal widths '(400))))))) @@ -11955,12 +12210,14 @@ explicit visible-window handoff may admit its full-content publication." "A column wrapper width should become the viewport for nested auto flex." (ebox-test--reset-runtime-state) (let* ((ebox-viewport-width 1000) - (node (ebox-test-build - '(column :width (240) - (flex :flex-wrap wrap :column-gap (10) - (box :content "A" :width (100)) - (box :content "B" :width (100)) - (box :content "C" :width (100)))))) + (node (ebox-test-column :width '(240) + (ebox-test-flex :flex-wrap 'wrap :column-gap '(10) + (ebox-test-box (ebox-test-text "A") :width + '(100)) + (ebox-test-box (ebox-test-text "B") :width + '(100)) + (ebox-test-box (ebox-test-text "C") :width + '(100))))) (rendered (ebox-render node)) (plain-lines (ebox-string-lines (substring-no-properties rendered))) @@ -11976,8 +12233,8 @@ explicit visible-window handoff may admit its full-content publication." (ebox-test--reset-runtime-state) (let* ((ebox-viewport-width 200) (node (ebox-test-column - (ebox-test-box :content "Auto row") - (ebox-test-box :content "Fixed row" :width '(300)))) + (ebox-test-box (ebox-test-text "Auto row")) + (ebox-test-box (ebox-test-text "Fixed row") :width '(300)))) (widths (mapcar #'ebox--string-pixel-width (ebox-string-lines (ebox-render node))))) (should (equal widths '(200 300))))) @@ -11988,21 +12245,21 @@ explicit visible-window handoff may admit its full-content publication." (let* ((content "aa bbbb") (max-content (ebox--string-pixel-width content)) (min-content (ebox--string-pixel-width "bbbb"))) - (should (= (ebox--content-pixel - (ebox-test-box :content content :width 'auto)) + (should (= (ebox-test--input-content-pixel + (ebox-test-box (ebox-test-text content) :width 'auto)) max-content)) - (should (= (ebox--content-pixel - (ebox-test-box :content content :width 'max-content)) + (should (= (ebox-test--input-content-pixel + (ebox-test-box (ebox-test-text content) :width 'max-content)) max-content)) - (should (= (ebox--content-pixel - (ebox-test-box :content content :width 'min-content)) + (should (= (ebox-test--input-content-pixel + (ebox-test-box (ebox-test-text content) :width 'min-content)) min-content)) - (should (= (ebox--content-pixel - (ebox-test-box :content content - :width '(80) - :max-width 'none)) + (should (= (ebox-test--input-content-pixel + (ebox-test-box (ebox-test-text content) + :width '(80) + :max-width 'none)) 80)) - (should-error (ebox-test-box :content content :width 'none) + (should-error (ebox-test-box (ebox-test-text content) :width 'none) :type 'error))) (ert-deftest ebox-max-content-wrapper-measures-composite-child-intrinsically () @@ -12015,10 +12272,9 @@ explicit visible-window handoff may admit its full-content publication." (node (ebox-test-box :width 'max-content - :ebox-content-node (apply #'ebox-test-column (mapcar (lambda (line) - (ebox-test-box :content line)) + (ebox-test-box (ebox-test-text line))) lines)))) (widths (mapcar #'ebox--string-pixel-width @@ -12028,7 +12284,7 @@ explicit visible-window handoff may admit its full-content publication." (ert-deftest ebox-definite-width-skips-unused-max-content-measurement () "A definite preferred width should not evaluate its unused auto fallback." (ebox-test--reset-runtime-state) - (let ((box (ebox-test-box :content "measurement must stay cold" + (let ((box (ebox-test-box (ebox-test-text "measurement must stay cold") :width '(80))) (measurements 0) (original (symbol-function 'ebox--content-max-pixel))) @@ -12036,7 +12292,7 @@ explicit visible-window handoff may admit its full-content publication." (lambda (candidate) (cl-incf measurements) (funcall original candidate)))) - (should (= (ebox--content-pixel box) 80)) + (should (= (ebox-test--input-content-pixel box) 80)) (should (= measurements 0))))) (ert-deftest ebox-definite-width-still-resolves-intrinsic-constraints () @@ -12044,7 +12300,7 @@ explicit visible-window handoff may admit its full-content publication." (ebox-test--reset-runtime-state) (let* ((content "intrinsic constraint") (expected (ebox--string-pixel-width content)) - (box (ebox-test-box :content content + (box (ebox-test-box (ebox-test-text content) :width '(20) :min-width 'max-content)) (measurements 0) @@ -12053,20 +12309,20 @@ explicit visible-window handoff may admit its full-content publication." (lambda (candidate) (cl-incf measurements) (funcall original candidate)))) - (should (= (ebox--content-pixel box) expected)) + (should (= (ebox-test--input-content-pixel box) expected)) (should (= measurements 1))))) (ert-deftest ebox-auto-width-keeps-max-content-fallback-without-viewport () "An unresolved auto width should retain its intrinsic max-content fallback." (ebox-test--reset-runtime-state) - (let ((box (ebox-test-box :content "auto fallback")) + (let ((box (ebox-test-box (ebox-test-text "auto fallback"))) (measurements 0) (original (symbol-function 'ebox--content-max-pixel))) (cl-letf (((symbol-function 'ebox--content-max-pixel) (lambda (candidate) (cl-incf measurements) (funcall original candidate)))) - (should (> (ebox--content-pixel box) 0)) + (should (> (ebox-test--input-content-pixel box) 0)) (should (= measurements 1))))) (ert-deftest ebox-fit-content-width-clamps-between-min-and-max-content () @@ -12078,18 +12334,18 @@ explicit visible-window handoff may admit its full-content publication." (between (+ min-content (/ (- max-content min-content) 2)))) (should (< min-content between)) (should (< between max-content)) - (should (= (ebox--content-pixel - (ebox-test-box :content content - :width (list 'fit-content (list between)))) + (should (= (ebox-test--input-content-pixel + (ebox-test-box (ebox-test-text content) + :width (list 'fit-content (list between)))) between)) - (should (= (ebox--content-pixel - (ebox-test-box :content content - :width (list 'fit-content (list 1)))) + (should (= (ebox-test--input-content-pixel + (ebox-test-box (ebox-test-text content) + :width (list 'fit-content (list 1)))) min-content)) - (should (= (ebox--content-pixel - (ebox-test-box :content content - :width (list 'fit-content - (list (+ max-content 50))))) + (should (= (ebox-test--input-content-pixel + (ebox-test-box (ebox-test-text content) + :width (list 'fit-content + (list (+ max-content 50))))) max-content)))) (ert-deftest ebox-fit-content-keyword-uses-stretch-when-viewport-is-definite () @@ -12100,8 +12356,8 @@ explicit visible-window handoff may admit its full-content publication." (max-content (ebox--string-pixel-width content)) (stretch (+ min-content (/ (- max-content min-content) 2))) (ebox-viewport-width stretch)) - (should (= (ebox--content-pixel - (ebox-test-box :content content :width 'fit-content)) + (should (= (ebox-test--input-content-pixel + (ebox-test-box (ebox-test-text content) :width 'fit-content)) stretch)))) (ert-deftest ebox-min-and-max-width-keywords-constrain-preferred-width () @@ -12111,27 +12367,27 @@ explicit visible-window handoff may admit its full-content publication." (min-content (ebox--string-pixel-width "widest")) (max-content (ebox--string-pixel-width content)) (between (+ min-content (/ (- max-content min-content) 2)))) - (should (= (ebox--content-pixel - (ebox-test-box :content content - :width '(1) - :min-width 'max-content)) + (should (= (ebox-test--input-content-pixel + (ebox-test-box (ebox-test-text content) + :width '(1) + :min-width 'max-content)) max-content)) - (should (= (ebox--content-pixel - (ebox-test-box :content content - :width (list (+ max-content 50)) - :max-width 'min-content)) + (should (= (ebox-test--input-content-pixel + (ebox-test-box (ebox-test-text content) + :width (list (+ max-content 50)) + :max-width 'min-content)) min-content)) - (should (= (ebox--content-pixel - (ebox-test-box :content content - :width '(1) - :min-width (list 'fit-content - (list between)))) + (should (= (ebox-test--input-content-pixel + (ebox-test-box (ebox-test-text content) + :width '(1) + :min-width (list 'fit-content + (list between)))) between)) - (should (= (ebox--content-pixel - (ebox-test-box :content content - :width (list (+ max-content 50)) - :max-width (list 'fit-content - (list between)))) + (should (= (ebox-test--input-content-pixel + (ebox-test-box (ebox-test-text content) + :width (list (+ max-content 50)) + :max-width (list 'fit-content + (list between)))) between)))) (ert-deftest ebox-stretch-and-contain-width-fill-viewport-margin-box () @@ -12139,13 +12395,13 @@ explicit visible-window handoff may admit its full-content publication." (ebox-test--reset-runtime-state) (let ((ebox-viewport-width 240)) (dolist (keyword '(stretch contain)) - (let ((box (ebox-test-box :content "x" + (let ((box (ebox-test-box (ebox-test-text "x") :width keyword :padding-inline '(20) :border-left '(10) :border-right '(10) :margin-inline '(5)))) - (should (= (ebox--total-pixel box) 240)))))) + (should (= (ebox-test--input-total-pixel box) 240)))))) (ert-deftest ebox-max-width-clamps-used-width-before-content-minimum () "A max-width smaller than one glyph should constrain used width." @@ -12154,28 +12410,28 @@ explicit visible-window handoff may admit its full-content publication." (lambda (_string) 60)) ((symbol-function 'ebox--max-char-pixel) (lambda (_string) 60))) - (let ((box (ebox-test-box :content "W" + (let ((box (ebox-test-box (ebox-test-text "W") :max-width '(20) :wrap-mode 'char))) - (should (= (ebox--content-pixel box) 20)) - (should (= (ebox--total-pixel box) 20))))) + (should (= (ebox-test--input-content-pixel box) 20)) + (should (= (ebox-test--input-total-pixel box) 20))))) (ert-deftest ebox-min-width-wins-when-min-width-exceeds-max-width () "CSS width constraints make min-width override max-width conflicts." (ebox-test--reset-runtime-state) - (let ((box (ebox-test-box :content "short" + (let ((box (ebox-test-box (ebox-test-text "short") :width '(40) :min-width '(120) :max-width '(80)))) - (should (= (ebox--content-pixel box) 120)) - (should (= (ebox--total-pixel box) 120)))) + (should (= (ebox-test--input-content-pixel box) 120)) + (should (= (ebox-test--input-total-pixel box) 120)))) (ert-deftest ebox-width-properties-reject-negative-values () "Width, min-width, and max-width should reject negative lengths." (ebox-test--reset-runtime-state) - (should-error (ebox-test-box :content "bad" :width '(-1)) :type 'error) - (should-error (ebox-test-box :content "bad" :min-width '(-1)) :type 'error) - (should-error (ebox-test-box :content "bad" :max-width '(-1)) :type 'error)) + (should-error (ebox-test-box (ebox-test-text "bad") :width '(-1)) :type 'error) + (should-error (ebox-test-box (ebox-test-text "bad") :min-width '(-1)) :type 'error) + (should-error (ebox-test-box (ebox-test-text "bad") :max-width '(-1)) :type 'error)) (ert-deftest ebox-edge-properties-reject-negative-values () "Public edge properties and engine longhands should reject negatives." @@ -12188,7 +12444,7 @@ explicit visible-window handoff may admit its full-content publication." (:border-top-width -1) (:border-left-pixel -1))) (should-error - (apply #'ebox-test-box (append '(:content "bad") props)) + (apply #'ebox-test-box (ebox-test-text "bad") props) :type 'error))) (ert-deftest ebox-line-min-content-skips-discarded-space-measurements () @@ -12216,7 +12472,7 @@ explicit visible-window handoff may admit its full-content publication." (ert-deftest ebox-region-update-valid-paint-and-invalid-edge-is-atomic () "Invalid geometry should fail before a valid sibling property mutates." (ebox-test--reset-runtime-state) - (let* ((box (ebox-test-box :content "Atomic" + (let* ((box (ebox-test-box (ebox-test-text "Atomic") :width '(120) :bgcolor "#111111" :padding-left-pixel 2)) @@ -12227,16 +12483,19 @@ explicit visible-window handoff may admit its full-content publication." :bgcolor "#222222" :padding-left-pixel -1) :type 'error) - (should (equal (ebox-get box :bgcolor) "#111111")) - (should (= (ebox-get box :padding-left-pixel) 2))))) + (should (equal (ebox-style-node-specified-value box :bgcolor) + "#111111")) + (should (= (ebox-style-node-specified-value + box :padding-left-pixel) + 2))))) (ert-deftest ebox-region-update-no-op-skips-rollback-snapshot () "A legal no-op should return before discovering rollback scope." (ebox-test--reset-runtime-state) (let ((layout - (ebox-test-build - '(box :id "root" :width (240) :height 1 :overflow scroll - (box :id "target" :content "same" :width (120)))))) + (ebox-test-box :id "root" :width '(240) :height 1 :overflow 'scroll + (ebox-test-box :id "target" (ebox-test-text "same") :width + '(120))))) (ebox-test--with-rendered-buffer layout (let ((target-id (ebox-test--selector-region-id (current-buffer) "#target"))) @@ -12257,13 +12516,13 @@ explicit visible-window handoff may admit its full-content publication." "A child rollback snapshot should retain its scroll owner's box identity." (ebox-test--reset-runtime-state) (let ((layout - (ebox-test-build - '(box :id "root" :width (viewport) :height (viewport-height) - :overflow scroll - (column :width (viewport) - (box :id "target" :content "paint" :width (120) - :color "#111111") - (box :content "tail" :width (120))))))) + (ebox-test-box :id "root" :width '(viewport) :height + '(viewport-height) :overflow 'scroll + (ebox-test-column :width '(viewport) + (ebox-test-box :id "target" (ebox-test-text "paint") :width '(120) + :color "#111111") + (ebox-test-box (ebox-test-text "tail") :width + '(120)))))) (let ((ebox-viewport-width 240) (ebox-viewport-height 1)) (ebox-test--with-rendered-buffer layout @@ -12290,8 +12549,7 @@ explicit visible-window handoff may admit its full-content publication." (ert-deftest ebox-region-update-rolls-back-when-rerender-fails () "A failed local rerender must leave model, buffer, and report unchanged." (ebox-test--reset-runtime-state) - (let* ((layout (ebox-test-build - '(box :id "target" :content "before" :width (120)))) + (let* ((layout (ebox-test-box :id "target" (ebox-test-text "before") :width '(120))) (report-before 'keep-report)) (ebox-test--with-rendered-buffer layout (let* ((buffer (current-buffer)) @@ -12320,7 +12578,7 @@ explicit visible-window handoff may admit its full-content publication." (lines (ebox-string-lines (substring-no-properties (ebox-render - (ebox-test-box :content content + (ebox-test-box (ebox-test-text content) :width (list width) :wrap-mode 'word)))))) (should (= (length lines) 2)) @@ -12377,7 +12635,7 @@ explicit visible-window handoff may admit its full-content publication." (lambda (text width) (setq justify-args (list text width)) text))) - (ebox-render (ebox-test-box :content "abc" + (ebox-render (ebox-test-box (ebox-test-text "abc") :width '(100) :wrap-mode 'kp))) (should require-called) @@ -12393,11 +12651,11 @@ explicit visible-window handoff may admit its full-content publication." (lambda (&rest _) "abc\nx"))) (dolist (align '(center right)) - (let* ((box (ebox-test-box :content "ignored" + (let* ((box (ebox-test-box (ebox-test-text "ignored") :width '(100) :wrap-mode 'kp :text-align align)) - (width (ebox--content-pixel box)) + (width (ebox-test--input-content-pixel box)) (rendered (ebox-render box)) (line (car (ebox-string-lines rendered))) (expected (ebox--pixel-reach "abc" width align))) @@ -12413,7 +12671,7 @@ explicit visible-window handoff may admit its full-content publication." (lambda (_text width) (concat (ebox-pixel-space width) "\nshort")))) (let* ((rendered (ebox-render - (ebox-test-box :content "ignored" + (ebox-test-box (ebox-test-text "ignored") :width '(100) :wrap-mode 'kp))) (widths (mapcar #'ebox-string-pixel-width @@ -12423,7 +12681,7 @@ explicit visible-window handoff may admit its full-content publication." (ert-deftest ebox-content-face-keeps-foreground-and-background () (ebox-test--reset-runtime-state) (let* ((rendered (ebox-render - (ebox-test-box :content "Face" + (ebox-test-box (ebox-test-text "Face") :width 40 :color "#abcdef" :bgcolor "#123456"))) @@ -12434,7 +12692,7 @@ explicit visible-window handoff may admit its full-content publication." (ert-deftest ebox-border-faces-keep-side-specific-properties () (ebox-test--reset-runtime-state) (let* ((rendered (ebox-render - (ebox-test-box :content "Border" + (ebox-test-box (ebox-test-text "Border") :width 60 :padding '(1 1) :border '(1 "#ff0000")))) @@ -12463,7 +12721,7 @@ explicit visible-window handoff may admit its full-content publication." sentinel (funcall original-frame-parameter frame parameter))))) (let* ((rendered (ebox-render - (ebox-test-box :content "Border" + (ebox-test-box (ebox-test-text "Border") :width 60 :padding '(1 1) :border t))) @@ -12484,7 +12742,7 @@ explicit visible-window handoff may admit its full-content publication." (require 'face-remap) (let* ((text-scale-mode-amount 2) (rendered (ebox-render - (ebox-test-box :content "Border" + (ebox-test-box (ebox-test-text "Border") :width 120 :padding '(0 (8)) :border '(1 "#ff0000")))) @@ -12510,7 +12768,7 @@ explicit visible-window handoff may admit its full-content publication." (require 'face-remap) (let* ((text-scale-mode-amount -2) (rendered (ebox-render - (ebox-test-box :content "Border" + (ebox-test-box (ebox-test-text "Border") :width 120 :padding '(0 (8)) :border '(1 "#ff0000")))) @@ -12534,7 +12792,7 @@ explicit visible-window handoff may admit its full-content publication." (ert-deftest ebox-horizontal-borders-do-not-cover-horizontal-margins () (ebox-test--reset-runtime-state) (let* ((rendered (ebox-render - (ebox-test-box :content "Border" + (ebox-test-box (ebox-test-text "Border") :width 80 :margin '(0 (8) 0 (8)) :border '(1 "#ff0000")))) @@ -12736,7 +12994,7 @@ explicit visible-window handoff may admit its full-content publication." "A hidden box renders blank with identical text, lengths, and metadata." (let* ((make-box (lambda (visibility) - (ebox-test-box :content "Hello" :width (list 90) + (ebox-test-box (ebox-test-text "Hello") :width (list 90) :border '(1 solid "#AA3311") :bgcolor "#223344" :color "#FFFFFF" :padding '(0 1) :visibility visibility))) @@ -12772,14 +13030,13 @@ publication with properties intact." (let* ((buffer (generate-new-buffer " *ebox-visibility*")) (root (ebox-test-box :width (list 300) - :ebox-content-node (ebox-test-column - (ebox-test-box :content "Header line" :id 'header + (ebox-test-box (ebox-test-text "Header line") :id 'header :width (list 300)) - (ebox-test-box :content "Secret content" :id 'secret + (ebox-test-box (ebox-test-text "Secret content") :id 'secret :width (list 300) :border t :bgcolor "#224466" :color "#FFFFFF") - (ebox-test-box :content "Footer line" :id 'footer + (ebox-test-box (ebox-test-text "Footer line") :id 'footer :width (list 300)))))) (unwind-protect (progn @@ -12817,9 +13074,8 @@ face patch must match that exactly (issue014)." :id 'parent :width (list 400) :border (list '(4) 'solid parent-color) :padding '(1 (12)) - :ebox-content-node (ebox-test-column - (ebox-test-box :id 'child :content "CHILD" :width (list 200) + (ebox-test-box :id 'child (ebox-test-text "CHILD") :width (list 200) :border '((4) solid "#00AA00")))))) (visual-runs (lambda (buffer role) diff --git a/tests/ebox-docs-contract-tests.el b/tests/ebox-docs-contract-tests.el index 33b8442..582a579 100644 --- a/tests/ebox-docs-contract-tests.el +++ b/tests/ebox-docs-contract-tests.el @@ -132,13 +132,18 @@ (require 'ebox) (let ((text-forms '("short" (text "explicit")))) (dolist (form text-forms) - (should (ebox-text-node-p (ebox-build form))))) + (should + (ebox-text-node-p + (ebox-canonical-input--single-root + (ebox-build form) "Ebox documented Text form"))))) (dolist (entry '((box "normal") (row (box "row child")) (column (box "column child")) (flex (box "flex child")) (grid (box "grid child")))) - (let* ((node (ebox-build entry)) + (let* ((node + (ebox-canonical-input--single-root + (ebox-build entry) "Ebox documented Box form")) (layout (ebox-box-node-layout node))) (should (ebox-box-node-p node)) (should (eq (ebox-layout-config-kind layout) diff --git a/tests/ebox-dsl-tests.el b/tests/ebox-dsl-tests.el index 61b95e1..802b6fc 100644 --- a/tests/ebox-dsl-tests.el +++ b/tests/ebox-dsl-tests.el @@ -3,21 +3,109 @@ (require 'ert) (load-file (expand-file-name "../ebox.el" (file-name-directory (or load-file-name buffer-file-name)))) +(load-file (expand-file-name "ebox-fixtures.el" + (file-name-directory (or load-file-name + buffer-file-name)))) -(defun ebox-dsl-test--plain (node) - "Render NODE and strip text properties." - (substring-no-properties (ebox-render node))) +(defun ebox-dsl-test--constructor-input (tag plist) + "Return `(BUILDER . PLIST)' for typed constructor TAG and PLIST." + (let* ((style-keys + (cl-loop for (key _value) on plist by #'cddr + when (and (not (memq key + '(:value :layout :children :outer + :source-handle :owned-facts))) + (ebox-style--property key)) + collect key)) + (style-props + (cl-loop for (key value) on plist by #'cddr + when (memq key style-keys) append (list key value))) + (declarations + (and style-props (ebox-style-compile-form tag style-props))) + (plist + (cl-loop for (key value) on plist by #'cddr + unless (memq key style-keys) append (list key value))) + (values + (cl-loop for (key value) on plist by #'cddr + when (eq key :source-handle) + collect value)) + (builder (ebox-source-builder-create))) + (when (plist-member plist :children) + (setq plist + (plist-put + (copy-sequence plist) :children + (mapcar (lambda (child) + (ebox-test--normalize-child builder child)) + (plist-get plist :children))))) + (when (<= (length values) 1) + (let* ((provided (car values)) + (identity + (if (ebox-source-handle-p provided) + (ebox-source-handle-id provided) + provided)) + (handle + (ebox-source-builder-bind + builder :identity identity :declarations declarations + :provenance '(:adapter ebox-dsl-test)))) + (setq plist (plist-put (copy-sequence plist) :source-handle handle)))) + (setq plist + (append plist + (list :owned-facts + (ebox-canonical-facts-from-declarations + tag declarations)))) + (cons builder plist))) -(defun ebox-dsl-test--line-widths (node) - "Return rendered pixel widths for NODE line by line." - (mapcar #'ebox--string-pixel-width (ebox-string-lines (ebox-render node)))) +(defun ebox-dsl-test--text-create (&rest plist) + "Create a canonical Text input with an explicit source boundary." + (let* ((input (ebox-dsl-test--constructor-input 'text plist)) + (builder (car input)) + (node (apply #'ebox-text-create (cdr input))) + (index (ebox-source-builder-finish builder))) + (ebox-canonical-input-create (list node) index))) -(defun ebox-dsl-test--display-line-widths (node) - "Return rendered display pixel widths for NODE line by line." - (mapcar #'ebox--string-pixel-width (ebox-string-lines (ebox-render node)))) +(defun ebox-dsl-test--box-create (&rest plist) + "Create a canonical Box input with an explicit source boundary." + (let* ((layout (plist-get plist :layout)) + (tag (if (and (ebox-layout-config-p layout) + (not (eq (ebox-layout-config-kind layout) 'normal))) + (ebox-layout-config-kind layout) + 'box)) + (input (ebox-dsl-test--constructor-input tag plist)) + (builder (car input)) + (node + (let ((ebox-canonical--source-builder builder)) + (apply #'ebox-box-create (cdr input)))) + (index (ebox-source-builder-finish builder))) + (ebox-canonical-input-create (list node) index))) + +(defun ebox-dsl-test--revise-source (input &rest facts) + "Return INPUT with its root source immutably rebound by FACTS." + (let* ((node (ebox-test-root input)) + (handle (ebox-node-source-handle node)) + (index (ebox-test-source-index input)) + (binding (apply #'ebox-source-index-rebind index handle facts))) + (plist-put node :ebox-source-handle (cdr binding)) + (ebox-canonical-input-create (list node) (car binding)))) + +(defun ebox-dsl-test--plain (input) + "Render canonical INPUT and strip text properties." + (substring-no-properties (ebox-render input))) + +(defun ebox-dsl-test--root (input) + "Return INPUT's canonical root node." + (ebox-canonical-input--single-root input "Ebox DSL test")) + +(defun ebox-dsl-test--line-widths (input) + "Return rendered pixel widths for canonical INPUT line by line." + (mapcar #'ebox--string-pixel-width (ebox-string-lines (ebox-render input)))) + +(defun ebox-dsl-test--display-line-widths (input) + "Return rendered display pixel widths for canonical INPUT line by line." + (mapcar #'ebox--string-pixel-width (ebox-string-lines (ebox-render input)))) (defun ebox-dsl-test--tree-count (node predicate) "Count nodes below NODE for which PREDICATE returns non-nil." + (when (ebox-canonical-input-p node) + (setq node (ebox-dsl-test--root node))) (if (or (stringp node) (not (listp node))) 0 (+ (if (funcall predicate node) 1 0) @@ -52,7 +140,8 @@ (ert-deftest ebox-typed-form-projects-outer-and-layout-to-display () "A typed Box form should project its two axes to one display pair." (let ((node (ebox-build '(flex :outer inline "A")))) - (should (equal (ebox--computed-display node) '(inline flex))))) + (should (equal (ebox--computed-display (ebox-dsl-test--root node)) + '(inline flex))))) (ert-deftest ebox-style-unrelated-facts-preserve-typed-layout-config () "Unrelated computed facts must not replace direct typed layout values." @@ -62,7 +151,8 @@ (grid :grid-template-columns ((40) (60)) :column-gap (4) "A")))) (let* ((kind (car case)) - (node (ebox-build (cadr case))) + (input (ebox-build (cadr case))) + (node (ebox-dsl-test--root input)) (before (copy-tree (plist-get node :ebox-layout-config))) (style (ebox-style-compute-subject @@ -191,32 +281,47 @@ (ert-deftest ebox-canonical-text-constructor-is-renderable-and-typed () "A canonical TextNode should retain its kind, source, and string value." - (let ((node (ebox-text-create :value "Hello" :source-handle 'source-text))) + (let* ((input (ebox-dsl-test--text-create + :value "Hello" :source-handle 'source-text)) + (node (ebox-dsl-test--root input))) (should (ebox-text-node-p node)) (should-not (ebox-box-node-p node)) - (should (eq (ebox-node-source-handle node) 'source-text)) + (should (eq (ebox-source-handle-id (ebox-node-source-handle node)) + 'source-text)) (should (string= (ebox-text-node-value node) "Hello")) - (should (string= (ebox-dsl-test--plain node) "Hello")) + (should (string= (ebox-dsl-test--plain input) "Hello")) (should-not (ebox-tree-node-visible-overflow-p node)))) (ert-deftest ebox-canonical-text-constructor-rejects-non-text-shapes () "A canonical TextNode should have exactly one string payload." - (should-error (ebox-text-create :value 7) :type 'error) - (should-error (ebox-text-create :value "A" :content "B") :type 'error) - (should-error (ebox-text-create :value "A" :children nil) :type 'error) - (should-error (ebox-text-create :value "A" :color "red") :type 'error) + (should-error (ebox-dsl-test--text-create :value 7) :type 'error) + (should-error (ebox-dsl-test--text-create :value "A" :content "B") :type 'error) + (should-error (ebox-dsl-test--text-create :value "A" :children nil) :type 'error) + (let* ((builder (ebox-source-builder-create)) + (handle (ebox-source-builder-bind builder :declarations nil))) + (should-error + (ebox-text-create + :value "A" :source-handle handle + :owned-facts (ebox-canonical-facts-from-declarations 'text nil) + :color "red") + :type 'error)) + (should-error (ebox-text-create :value "A") :type 'error) + (should-error + (ebox-text-create :value "A" :source-handle 'raw-source) + :type 'error) (should-error (ebox-build '(text :wrap-mode none "A")) :type 'error) (should-error - (ebox-text-create :value "A" :value "B") + (ebox-dsl-test--text-create :value "A" :value "B") :type 'error) (should-error - (ebox-text-create :value "A" :source-handle 'a :source-handle 'b) + (ebox-dsl-test--text-create :value "A" :source-handle 'a :source-handle 'b) :type 'error)) (ert-deftest ebox-canonical-box-uses-registered-runtime-defaults () "A Box should materialize the definite defaults consumed by layout." - (let ((box (ebox-box-create - :layout (ebox-normal-layout-create) :children nil))) + (let* ((input (ebox-dsl-test--box-create + :layout (ebox-normal-layout-create) :children nil)) + (box (ebox-dsl-test--root input))) (should (eq (ebox-get box :overflow) 'scroll)) (dolist (property '(:padding-left-pixel :padding-right-pixel :padding-top-height :padding-bottom-height @@ -227,7 +332,8 @@ (ert-deftest ebox-render-cache-signature-ignores-derived-layout-proofs () "A render must not invalidate its own canonical node cache key." - (let* ((node (ebox-build '(box :width '(80) "Stable"))) + (let* ((node (ebox-dsl-test--root + (ebox-build '(box :width '(80) "Stable")))) (before (let ((ebox--render-cache-signature-cache (make-hash-table :test 'eq))) @@ -256,7 +362,10 @@ (let* ((root (plist-get (ebox--buffer-render-state buffer) :root-node)) (text (car (ebox-box-node-children root)))) - (should (eq (ebox-style-node-specified-value root :wrap-mode) + (should (eq (ebox-style-node-specified-value + root :wrap-mode nil + (plist-get (ebox--buffer-render-state buffer) + :source-index)) 'none)) (should (eq (plist-get root :wrap-mode) 'none)) (should (eq (plist-get text :wrap-mode) 'none)))) @@ -270,14 +379,14 @@ (should-error (ebox-build '(box :wrap-mode arbitrary "A")) :type 'error) (let* ((width (ebox--string-pixel-width "A")) (char-box - (ebox-box-create + (ebox-dsl-test--box-create :layout (ebox-normal-layout-create) - :children (list (ebox-text-create :value "AB")) + :children (list (ebox-dsl-test--text-create :value "AB")) :width (list width) :wrap-mode 'char)) (none-box - (ebox-box-create + (ebox-dsl-test--box-create :layout (ebox-normal-layout-create) - :children (list (ebox-text-create :value "AB")) + :children (list (ebox-dsl-test--text-create :value "AB")) :width (list width) :wrap-mode 'none))) (should (= (length (ebox-string-lines (ebox-render char-box))) 2)) (should (= (length (ebox-string-lines (ebox-render none-box))) 1))) @@ -290,32 +399,42 @@ (setq justify-args (list text width)) text))) (ebox-render - (ebox-box-create + (ebox-dsl-test--box-create :layout (ebox-normal-layout-create) - :children (list (ebox-text-create :value "paragraph")) + :children (list (ebox-dsl-test--text-create :value "paragraph")) :width '(100) :wrap-mode 'kp))) (should (equal justify-args '("paragraph" 100))))) (ert-deftest ebox-canonical-normal-box-renders-inline-runs-and-blocks () "Normal should retain typed children and honor inline/block boundaries." - (let* ((text (ebox-text-create :value "A" :source-handle 'source-text)) - (second-text (ebox-text-create :value "B")) - (inline - (ebox-box-create + (let* ((text-input + (ebox-dsl-test--text-create :value "A" :source-handle 'source-text)) + (text (ebox-dsl-test--root text-input)) + (second-text-input (ebox-dsl-test--text-create :value "B")) + (second-text (ebox-dsl-test--root second-text-input)) + (inline-input + (ebox-dsl-test--box-create :layout (ebox-normal-layout-create) :outer 'inline :width '(100) - :children (list (ebox-text-create :value "A B")))) - (block - (ebox-box-create + :children (list (ebox-dsl-test--text-create :value "A B")))) + (inline (ebox-dsl-test--root inline-input)) + (block-input + (ebox-dsl-test--box-create :layout (ebox-normal-layout-create) - :children (list (ebox-text-create :value "C")))) - (tail (ebox-text-create :value "D")) + :children (list (ebox-dsl-test--text-create :value "C")))) + (block (ebox-dsl-test--root block-input)) + (tail-input (ebox-dsl-test--text-create :value "D")) + (tail (ebox-dsl-test--root tail-input)) (layout (ebox-normal-layout-create)) - (box (ebox-box-create :layout layout - :children (list text second-text inline block tail) - :source-handle 'source-box - :width '(50) :overflow 'visible)) - (rendered (ebox-render box)) + (box-input + (ebox-dsl-test--box-create + :layout layout + :children (list text-input second-text-input inline-input + block-input tail-input) + :source-handle 'source-box + :width '(50) :overflow 'visible)) + (box (ebox-dsl-test--root box-input)) + (rendered (ebox-render box-input)) (rendered-lines (ebox-string-lines rendered)) (lines (mapcar (lambda (line) (string-trim-right @@ -323,7 +442,8 @@ rendered-lines))) (should (ebox-box-node-p box)) (should-not (ebox-text-node-p box)) - (should (eq (ebox-node-source-handle box) 'source-box)) + (should (eq (ebox-source-handle-id (ebox-node-source-handle box)) + 'source-box)) (should (eq (ebox-layout-config-kind (ebox-box-node-layout box)) 'normal)) @@ -333,18 +453,27 @@ (should (equal lines '("AB" "A B" "C" "D"))) (should (>= (ebox--string-pixel-width (nth 1 rendered-lines)) 100)) (let ((ebox-style-stylesheet (ecss-stylesheet-create))) - (plist-put text :class "fixed-inline-text") + (let* ((revised-text + (ebox-dsl-test--revise-source + text-input :class "fixed-inline-text")) + (revised-box + (ebox-dsl-test--box-create + :layout layout + :children (list revised-text second-text-input inline-input + block-input tail-input) + :source-handle 'source-box + :width '(50) :overflow 'visible))) (ebox-style-add-rule ".fixed-inline-text" (list :outer 'block)) - (should-error (ebox-render box) :type 'error)) + (should-error (ebox-render revised-box) :type 'error))) (cl-labels ((normal-lines (value &optional width) (mapcar #'substring-no-properties (ebox-string-lines (ebox-render - (apply #'ebox-box-create + (apply #'ebox-dsl-test--box-create :layout (ebox-normal-layout-create) - :children (list (ebox-text-create :value value)) + :children (list (ebox-dsl-test--text-create :value value)) (when width (list :width (list width) :overflow 'visible)))))))) @@ -371,26 +500,33 @@ (should (= (length (normal-lines "word" 0)) 1)) (should (= (length (normal-lines " " 0)) 1)) (let* ((inline-zero - (ebox-box-create + (ebox-dsl-test--box-create :layout (ebox-normal-layout-create) :outer 'inline :width '(10) - :children (list (ebox-text-create :value "I")))) + :children (list (ebox-dsl-test--text-create :value "I")))) (zero-parent - (ebox-box-create + (ebox-dsl-test--box-create :layout (ebox-normal-layout-create) :width '(0) :overflow 'visible :children (list inline-zero)))) (should (= (ebox-string-height (ebox-render zero-parent)) 1)))))) (ert-deftest ebox-canonical-row-box-retains-and-renders-typed-children () "A Row Box should retain one Box identity around all typed children." - (let* ((left (ebox-text-create :value "A" :source-handle 'left)) - (right (ebox-text-create :value "B\nB\nB" :source-handle 'right)) - (box (ebox-box-create :layout (ebox-row-layout-create - :item-gap 5 :cross-align 'center) - :children (list left right) - :source-handle 'row))) + (let* ((left-input + (ebox-dsl-test--text-create :value "A" :source-handle 'left)) + (right-input + (ebox-dsl-test--text-create :value "B\nB\nB" :source-handle 'right)) + (left (ebox-dsl-test--root left-input)) + (right (ebox-dsl-test--root right-input)) + (box-input + (ebox-dsl-test--box-create + :layout (ebox-row-layout-create :item-gap 5 :cross-align 'center) + :children (list left-input right-input) + :source-handle 'row)) + (box (ebox-dsl-test--root box-input))) (should (ebox-box-node-p box)) - (should (eq (ebox-node-source-handle box) 'row)) + (should (eq (ebox-source-handle-id (ebox-node-source-handle box)) + 'row)) (should (eq (ebox-layout-config-kind (ebox-box-node-layout box)) 'row)) (should (equal (ebox-layout-config-props (ebox-box-node-layout box)) '(:item-gap 5 :cross-align center))) @@ -401,7 +537,7 @@ (should (= (ebox-dsl-test--tree-count box #'ebox-node-kind) 3)) (should-not (plist-member box :ebox-content-node)) (should (equal (ebox--computed-display box) '(block row))) - (let* ((rendered (ebox-render box)) + (let* ((rendered (ebox-render box-input)) (lines (ebox-string-lines rendered)) (middle (nth 1 lines))) (should-not (string-match-p "A" (substring-no-properties (car lines)))) @@ -412,11 +548,15 @@ (ert-deftest ebox-canonical-column-box-retains-and-renders-typed-children () "A Column Box should retain one Box identity around all typed children." - (let* ((top (ebox-text-create :value "A")) - (bottom (ebox-text-create :value "BBB")) - (box (ebox-box-create :layout (ebox-column-layout-create - :item-gap 1 :cross-align 'end) - :children (list top bottom)))) + (let* ((top-input (ebox-dsl-test--text-create :value "A")) + (bottom-input (ebox-dsl-test--text-create :value "BBB")) + (top (ebox-dsl-test--root top-input)) + (bottom (ebox-dsl-test--root bottom-input)) + (box-input + (ebox-dsl-test--box-create + :layout (ebox-column-layout-create :item-gap 1 :cross-align 'end) + :children (list top-input bottom-input))) + (box (ebox-dsl-test--root box-input))) (should (ebox-box-node-p box)) (should (eq (ebox-layout-config-kind (ebox-box-node-layout box)) 'column)) (should (equal (ebox-layout-config-props (ebox-box-node-layout box)) @@ -428,8 +568,8 @@ (should (= (ebox-dsl-test--tree-count box #'ebox-node-kind) 3)) (should-not (plist-member box :ebox-content-node)) (should (equal (ebox--computed-display box) '(block column))) - (should (= (ebox-string-height (ebox-render box)) 3)) - (let ((lines (ebox-string-lines (ebox-render box)))) + (should (= (ebox-string-height (ebox-render box-input)) 3)) + (let ((lines (ebox-string-lines (ebox-render box-input)))) (should (string-prefix-p " " (substring-no-properties (car lines)))) (should (= (ebox--string-pixel-width (car lines)) (ebox--string-pixel-width (nth 2 lines))))))) @@ -446,13 +586,14 @@ (ert-deftest ebox-canonical-flex-box-uses-one-box-identity-and-child-list () "A FlexConfig should select the Box algorithm without a Flex runtime node." - (let* ((left (ebox-text-create :value "A")) - (right (ebox-text-create :value "B")) + (let* ((left (ebox-dsl-test--text-create :value "A")) + (right (ebox-dsl-test--text-create :value "B")) (layout (ebox-flex-layout-create)) - (box (ebox-box-create :layout layout - :children (list left right) - :width '(120))) - (rendered (ebox-render box))) + (input (ebox-dsl-test--box-create :layout layout + :children (list left right) + :width '(120))) + (box (ebox-dsl-test--root input)) + (rendered (ebox-render input))) (should (equal (ebox-layout-config-props layout) '(:flex-direction row :flex-wrap nowrap @@ -491,28 +632,29 @@ '(:width 999))) (setf (ebox-layout-config-kind cross-kind-props) 'row) (should-error - (ebox-box-create :layout invalid-direction :children nil) + (ebox-dsl-test--box-create :layout invalid-direction :children nil) :type 'error) (should-error - (ebox-box-create :layout extra-frame-prop :children nil) + (ebox-dsl-test--box-create :layout extra-frame-prop :children nil) :type 'error) (should-error - (ebox-box-create :layout cross-kind-props :children nil) + (ebox-dsl-test--box-create :layout cross-kind-props :children nil) :type 'error))) (ert-deftest ebox-canonical-box-detaches-validated-layout-config () "Caller and accessor mutations should not alter a constructed Box." (let* ((source-layout (ebox-flex-layout-create)) - (box (ebox-box-create - :layout source-layout - :children (list (ebox-text-create :value "A") - (ebox-text-create :value "B")))) + (input (ebox-dsl-test--box-create + :layout source-layout + :children (list (ebox-dsl-test--text-create :value "A") + (ebox-dsl-test--text-create :value "B")))) + (box (ebox-dsl-test--root input)) (exposed-layout (ebox-box-node-layout box))) (should-not (fboundp 'ebox-layout-config-validator)) (setf (ebox-layout-config-props source-layout) '(:flex-direction bogus)) (setf (ebox-layout-config-props exposed-layout) '(:width 999)) (should (string= (string-trim-right - (substring-no-properties (ebox-render box))) + (substring-no-properties (ebox-render input))) "AB")) (should (equal (ebox-layout-config-props (ebox-box-node-layout box)) ebox--flex-default-config-props)))) @@ -536,7 +678,8 @@ (should (ebox-flex-layout-config-props-p (ebox-layout-config-props layout))) (should (ebox-box-node-p - (ebox-box-create :layout layout :children nil))))) + (ebox-dsl-test--root + (ebox-dsl-test--box-create :layout layout :children nil)))))) (ert-deftest ebox-flex-layout-create-validates-values-and-rejects-author-sugar () "FlexConfig should accept its value domain and reject sugar or foreign props." @@ -567,7 +710,9 @@ "Boundary validation should preserve physical pixels under non-unit fonts." (cl-letf (((symbol-function 'ebox--space-pixel-width) (lambda () 8))) (let* ((layout (ebox-flex-layout-create :column-gap '(8))) - (box (ebox-box-create :layout layout :children nil))) + (box (ebox-dsl-test--root + (ebox-dsl-test--box-create + :layout layout :children nil)))) (should (= (plist-get (ebox-layout-config-props layout) :column-gap) 8)) (should (= (plist-get (ebox-layout-config-props (ebox-box-node-layout box)) @@ -577,12 +722,13 @@ (ert-deftest ebox-canonical-grid-box-uses-one-box-identity-and-child-list () "A GridConfig should select the Box algorithm without a Grid runtime node." (let* ((layout (ebox-grid-layout-create)) - (box (ebox-box-create - :layout layout - :children (list (ebox-text-create :value "A") - (ebox-text-create :value "B")) - :width '(120))) - (rendered (ebox-render box))) + (input (ebox-dsl-test--box-create + :layout layout + :children (list (ebox-dsl-test--text-create :value "A") + (ebox-dsl-test--text-create :value "B")) + :width '(120))) + (box (ebox-dsl-test--root input)) + (rendered (ebox-render input))) (should (equal (ebox-layout-config-props layout) ebox--grid-default-config-props)) (should-not (plist-member (ebox-layout-config-props layout) :width)) @@ -610,14 +756,14 @@ (ert-deftest ebox-canonical-grid-consumes-direct-child-placement () "Grid placement should stay on child Boxes without grid-item wrappers." (let* ((later - (ebox-box-create :layout (ebox-normal-layout-create) - :children (list (ebox-text-create :value "A")) + (ebox-dsl-test--box-create :layout (ebox-normal-layout-create) + :children (list (ebox-dsl-test--text-create :value "A")) :grid-row 2)) (earlier - (ebox-box-create :layout (ebox-normal-layout-create) - :children (list (ebox-text-create :value "B")) + (ebox-dsl-test--box-create :layout (ebox-normal-layout-create) + :children (list (ebox-dsl-test--text-create :value "B")) :grid-row 1)) - (box (ebox-box-create :layout (ebox-grid-layout-create) + (box (ebox-dsl-test--box-create :layout (ebox-grid-layout-create) :children (list later earlier))) (rendered (substring-no-properties (ebox-render box)))) (should (string= @@ -630,12 +776,12 @@ "Participation should be legal only under its matching direct parent layout." (cl-labels ((child (&rest props) - (apply #'ebox-box-create + (apply #'ebox-dsl-test--box-create :layout (ebox-normal-layout-create) - :children (list (ebox-text-create :value "A")) + :children (list (ebox-dsl-test--text-create :value "A")) props)) (parent (layout child) - (ebox-box-create :layout layout :children (list child)))) + (ebox-dsl-test--box-create :layout layout :children (list child)))) (should (stringp (ebox-render (parent (ebox-flex-layout-create) (child :flex-grow 1))))) @@ -656,8 +802,8 @@ (ebox-render (parent (ebox-grid-layout-create) (child :flex-grow 1))) :type 'error) - (let ((text (ebox-text-create :value "A"))) - (plist-put text :flex-grow 1) + (let ((text (ebox-dsl-test--text-create :value "A"))) + (plist-put (ebox-dsl-test--root text) :flex-grow 1) (should-error (ebox-render (parent (ebox-flex-layout-create) text)) :type 'error)) ;; The final candidate is authoritative after ECSS projection too. @@ -670,7 +816,9 @@ ;; Detached Flex geometry retains its final parent during computed style. (let ((ebox-style-stylesheet (ecss-stylesheet-create)) (detached (child))) - (plist-put detached :class "detached-flex-item") + (setq detached + (ebox-dsl-test--revise-source + detached :class "detached-flex-item")) (ebox-style-add-rule ".detached-flex-item" (list :flex-grow 1)) (let ((ebox--render-root-parent-kind 'flex)) (should (stringp (ebox-render detached)))) @@ -678,7 +826,9 @@ (should-error (ebox-render detached) :type 'error)) (let ((ebox-style-stylesheet (ecss-stylesheet-create)) (detached (child))) - (plist-put detached :class "detached-flex-item") + (setq detached + (ebox-dsl-test--revise-source + detached :class "detached-flex-item")) (ebox-style-add-rule ".detached-flex-item" (list :grid-row 1)) (let ((ebox--render-root-parent-kind 'flex)) (should-error (ebox-render detached) :type 'error))) @@ -688,9 +838,9 @@ (ebox-render (parent (ebox-grid-layout-create) - (ebox-box-create + (ebox-dsl-test--box-create :layout (ebox-flex-layout-create) - :children (list (ebox-text-create :value "A")) + :children (list (ebox-dsl-test--text-create :value "A")) :grid-row 1))))))) (ert-deftest ebox-canonical-grid-revalidates-the-typed-config-boundary () @@ -702,10 +852,10 @@ '(:width 999))) (setf (ebox-layout-config-kind cross-kind-props) 'column) (should-error - (ebox-box-create :layout extra-frame-prop :children nil) + (ebox-dsl-test--box-create :layout extra-frame-prop :children nil) :type 'error) (should-error - (ebox-box-create :layout cross-kind-props :children nil) + (ebox-dsl-test--box-create :layout cross-kind-props :children nil) :type 'error))) (ert-deftest ebox-grid-layout-create-normalizes-the-complete-config-schema () @@ -733,7 +883,8 @@ (should (= (plist-get props :column-gap) 8)) (should (ebox-grid-layout-config-props-p props)) (should (ebox-box-node-p - (ebox-box-create :layout layout :children nil))))) + (ebox-dsl-test--root + (ebox-dsl-test--box-create :layout layout :children nil)))))) (ert-deftest ebox-grid-layout-create-validates-values-and-rejects-author-sugar () "GridConfig should accept its value domain and reject sugar or foreign props." @@ -771,7 +922,8 @@ (let* ((layout (ebox-grid-layout-create :grid-template-columns '((20)) :column-gap '(8))) - (box (ebox-box-create :layout layout :children nil)) + (box (ebox-dsl-test--root + (ebox-dsl-test--box-create :layout layout :children nil))) (props (ebox-layout-config-props (ebox-box-node-layout box)))) (should (= (plist-get props :column-gap) 8)) (should (= (plist-get (car (plist-get props :grid-template-columns)) @@ -797,21 +949,21 @@ :max (:kind fixed :size 2)) :max (:kind auto :factor 0))))) (should-error - (ebox-box-create :layout fr-minimum :children nil) :type 'error) + (ebox-dsl-test--box-create :layout fr-minimum :children nil) :type 'error) (should-error - (ebox-box-create :layout nested-minimum :children nil) :type 'error))) + (ebox-dsl-test--box-create :layout nested-minimum :children nil) :type 'error))) (ert-deftest ebox-canonical-flex-consumes-direct-child-participation () "Flex participation should stay on child Boxes without item wrappers." - (let* ((left-text (ebox-text-create :value "A")) - (right-text (ebox-text-create :value "B")) - (left (ebox-box-create :layout (ebox-normal-layout-create) + (let* ((left-text (ebox-dsl-test--text-create :value "A")) + (right-text (ebox-dsl-test--text-create :value "B")) + (left (ebox-dsl-test--box-create :layout (ebox-normal-layout-create) :children (list left-text) :order 2)) - (right (ebox-box-create :layout (ebox-normal-layout-create) + (right (ebox-dsl-test--box-create :layout (ebox-normal-layout-create) :children (list right-text) :order 1)) - (box (ebox-box-create :layout (ebox-flex-layout-create) + (box (ebox-dsl-test--box-create :layout (ebox-flex-layout-create) :children (list left right))) (rendered (substring-no-properties (ebox-render box)))) (should (string= (string-trim-right rendered) "BA")) @@ -824,11 +976,16 @@ (ert-deftest ebox-canonical-row-and-column-never-collapse-single-child-boxes () "A one-child Row or Column should preserve its canonical Box boundary." - (let* ((child (ebox-text-create :value "A")) - (row (ebox-box-create :layout (ebox-row-layout-create) - :children (list child))) - (column (ebox-box-create :layout (ebox-column-layout-create) - :children (list child)))) + (let* ((child-input (ebox-dsl-test--text-create :value "A")) + (child (ebox-dsl-test--root child-input)) + (row-input + (ebox-dsl-test--box-create :layout (ebox-row-layout-create) + :children (list child-input))) + (column-input + (ebox-dsl-test--box-create :layout (ebox-column-layout-create) + :children (list child-input))) + (row (ebox-dsl-test--root row-input)) + (column (ebox-dsl-test--root column-input))) (should (ebox-box-node-p row)) (should (ebox-box-node-p column)) (should-not (eq row child)) @@ -837,8 +994,8 @@ (should (equal (ebox-tree-node-children column) (list child))) (should (= (ebox-dsl-test--tree-count row (lambda (_node) t)) 2)) (should (= (ebox-dsl-test--tree-count column (lambda (_node) t)) 2)) - (should (string= (ebox-dsl-test--plain row) "A")) - (should (string= (ebox-dsl-test--plain column) "A")))) + (should (string= (ebox-dsl-test--plain row-input) "A")) + (should (string= (ebox-dsl-test--plain column-input) "A")))) (ert-deftest ebox-canonical-empty-layout-boxes-have-no-runtime-child () "Empty layout Boxes should not synthesize layout identities." @@ -846,19 +1003,25 @@ (ebox-column-layout-create) (ebox-flex-layout-create) (ebox-grid-layout-create))) - (let ((box (ebox-box-create :layout layout :children nil))) + (let* ((input (ebox-dsl-test--box-create + :layout layout :children nil)) + (box (ebox-dsl-test--root input))) (should (ebox-box-node-p box)) (should-not (ebox-tree-node-children box)) (should (= (ebox-dsl-test--tree-count box (lambda (_node) t)) 1)) (should (= (ebox-dsl-test--tree-count box #'ebox-node-kind) 1)) - (should (string= (ebox-dsl-test--plain box) ""))))) + (should (string= (ebox-dsl-test--plain input) ""))))) (ert-deftest ebox-canonical-box-copy-keeps-one-authoritative-child-list () "A copied Box should expose exactly its copied runtime children." - (let* ((left (ebox-text-create :value "A")) - (right (ebox-text-create :value "B")) - (source (ebox-box-create :layout (ebox-row-layout-create) - :children (list left right))) + (let* ((left-input (ebox-dsl-test--text-create :value "A")) + (right-input (ebox-dsl-test--text-create :value "B")) + (left (ebox-dsl-test--root left-input)) + (right (ebox-dsl-test--root right-input)) + (source-input + (ebox-dsl-test--box-create :layout (ebox-row-layout-create) + :children (list left-input right-input))) + (source (ebox-dsl-test--root source-input)) (copy (ebox-tree-copy-node-structure source)) (copied-children (ebox-tree-node-children copy))) (should (eq copied-children (ebox-box-node-children copy))) @@ -870,29 +1033,35 @@ (ert-deftest ebox-canonical-box-rejects-paint-only-properties () "Canonical Box geometry should not duplicate paint-only state." - (should-error - (ebox-box-create :layout (ebox-normal-layout-create) - :bgcolor "red") - :type 'error)) + (let* ((builder (ebox-source-builder-create)) + (handle (ebox-source-builder-bind builder :declarations nil))) + (should-error + (ebox-box-create + :layout (ebox-normal-layout-create) :children nil + :source-handle handle + :owned-facts (ebox-canonical-facts-from-declarations 'box nil) + :bgcolor "red") + :type 'error))) (ert-deftest ebox-build-projects-source-style-without-polluting-canonical-geometry () "Track the G4 boundary: author paint must leave canonical Box geometry." :expected-result :failed - (let ((node (ebox-build '(box :background-color "red" "Paint")))) + (let ((node (ebox-dsl-test--root + (ebox-build '(box :background-color "red" "Paint"))))) (should-not (ebox-get node :bgcolor)))) (ert-deftest ebox-canonical-box-requires-unique-reserved-fields () "Typed Box reserved fields should never be missing or duplicated." (let ((normal (ebox-normal-layout-create))) - (should-error (ebox-box-create :children nil) :type 'error) + (should-error (ebox-dsl-test--box-create :children nil) :type 'error) (should-error - (ebox-box-create :layout normal :layout normal :children nil) + (ebox-dsl-test--box-create :layout normal :layout normal :children nil) :type 'error) (should-error - (ebox-box-create :layout normal :children nil :children nil) + (ebox-dsl-test--box-create :layout normal :children nil :children nil) :type 'error) (should-error - (ebox-box-create :layout normal :outer nil :children nil) + (ebox-dsl-test--box-create :layout normal :outer nil :children nil) :type 'error))) (ert-deftest ebox-build-normalizes-the-complete-author-grammar () @@ -908,24 +1077,36 @@ (let* ((form (nth 0 case)) (kind (nth 1 case)) (layout-kind (nth 2 case)) - (node (ebox-build form))) + (node (ebox-dsl-test--root (ebox-build form)))) (should (eq (ebox-node-kind node) kind)) - (should (symbolp (ebox-node-source-handle node))) - (should-not (intern-soft - (symbol-name (ebox-node-source-handle node)))) + (should (ebox-source-handle-p (ebox-node-source-handle node))) + (should (symbolp + (ebox-source-handle-id (ebox-node-source-handle node)))) + (should-not + (eq (ebox-source-handle-id (ebox-node-source-handle node)) + (intern-soft + (symbol-name + (ebox-source-handle-id + (ebox-node-source-handle node)))))) (when layout-kind (should (eq (ebox-layout-config-kind (ebox-box-node-layout node)) layout-kind))))) (let ((inline-row (ebox-build '(row :outer inline "A" "B")))) - (should (equal (ebox--computed-display inline-row) '(inline row))))) + (should (equal (ebox--computed-display (ebox-dsl-test--root inline-row)) + '(inline row))))) (ert-deftest ebox-build-gives-equal-forms-distinct-source-identities () "Structurally equal author forms must not share opaque source identity." - (let* ((node (ebox-build '(row (box "same") (box "same")))) + (let* ((node (ebox-dsl-test--root + (ebox-build '(row (box "same") (box "same"))))) (children (ebox-box-node-children node))) - (should-not (equal (plist-get (car children) :host-ref) - (plist-get (cadr children) :host-ref))))) + (should-not + (equal + (ebox-source-handle-id + (ebox-node-source-handle (car children))) + (ebox-source-handle-id + (ebox-node-source-handle (cadr children))))))) (ert-deftest ebox-build-validates-form-owned-config-and-direct-participation () "Layout config belongs to its form; item roles belong to direct child Boxes." @@ -934,12 +1115,14 @@ '(flex :width '(300) :gap '(1 (5)) (box :flex 1 "A") "B"))) - (flex-child (car (ebox-box-node-children flex))) + (flex-child (car (ebox-box-node-children + (ebox-dsl-test--root flex)))) (grid (ebox-build '(grid :grid-template-columns '((20) (20)) (box :grid-column '(1 :span 2) "Header")))) - (grid-child (car (ebox-box-node-children grid)))) + (grid-child (car (ebox-box-node-children + (ebox-dsl-test--root grid))))) (should (= (plist-get flex-child :flex-grow) 1)) (should (equal (plist-get grid-child :grid-column) '(1 :span 2))) (should (string-match-p "A.*B" @@ -977,7 +1160,7 @@ (unknown "A"))) (should-error (ebox-build form) :type 'error)) (should-error - (ebox-build (ebox-test-box :content "raw runtime node")) + (ebox-build (ebox-test-box (ebox-test-text "raw runtime node"))) :type 'error)) (provide 'ebox-dsl-tests) diff --git a/tests/ebox-fixtures.el b/tests/ebox-fixtures.el index b48779d..e7004a2 100644 --- a/tests/ebox-fixtures.el +++ b/tests/ebox-fixtures.el @@ -11,21 +11,59 @@ (require 'ebox) (defconst ebox-test--source-fields - '(:source-handle :key :class :id) - "Canonical source fields accepted by typed fixture nodes.") + '(:source-identity :key :class :id + :selector-state :selector-attributes) + "Author source fields owned by opaque fixture source handles.") (defconst ebox-test--direct-fields - '(:region-id :selector-state :selector-attributes :surface-properties - :scroll-offset :ebox-scroll-offset-controlled-p) + '(:region-id :surface-properties :scroll-offset + :ebox-scroll-offset-controlled-p) "Private runtime fields used only by focused backend tests.") (defconst ebox-test--non-style-fields - (append '(:source-handle :host-ref :key :class :id - :outer :content :ebox-content-node - :item-gap :cross-align) + (append ebox-test--source-fields + '(:outer :item-gap :cross-align) ebox-test--direct-fields) "Fixture fields that do not enter the canonical Ebox style schema.") +(defun ebox-test-root (input) + "Return the single canonical root owned by fixture INPUT." + (ebox-canonical-input--single-root input "Ebox test fixture")) + +(defun ebox-test-forest (input) + "Return fixture INPUT's ordered canonical forest." + (unless (ebox-canonical-input-p input) + (signal 'wrong-type-argument (list 'ebox-canonical-input-p input))) + (copy-sequence (ebox-canonical-input--nodes input))) + +(defun ebox-test-source-index (input) + "Return the immutable source generation owned by fixture INPUT." + (unless (ebox-canonical-input-p input) + (signal 'wrong-type-argument (list 'ebox-canonical-input-p input))) + (ebox-canonical-input--source-index input)) + +(defun ebox-test-child-range (range-ref &rest inputs) + "Return a test Range descriptor over canonical INPUTS. +The descriptor is consumed only by a fixture parent, which imports every input +source generation before lowering the descriptor to raw canonical nodes." + (unless range-ref + (error "Ebox test Range ref must be non-nil")) + (dolist (input inputs) + (unless (ebox-canonical-input-p input) + (signal 'wrong-type-argument (list 'ebox-canonical-input-p input)))) + (ebox-child-range--descriptor-create range-ref inputs)) + +(defun ebox-test-forest-input (&rest inputs) + "Combine canonical INPUTS into one ordered forest input." + (let ((builder (ebox-source-builder-create)) + nodes) + (dolist (input inputs) + (unless (ebox-canonical-input-p input) + (signal 'wrong-type-argument (list 'ebox-canonical-input-p input))) + (ebox-source-builder-import builder (ebox-test-source-index input)) + (setq nodes (nconc nodes (ebox-test-forest input)))) + (ebox-canonical-input-create nodes (ebox-source-builder-finish builder)))) + (defun ebox-test--keep (plist keys) "Return PLIST entries whose keys occur in KEYS." (cl-loop for (key value) on plist by #'cddr @@ -49,31 +87,24 @@ (cl-loop for (key value) on plist by #'cddr unless (memq key keys) append (list key value))) -(defun ebox-test--unquote (value) - "Return VALUE without one inert source-level quote wrapper." - (if (and (consp value) - (eq (car value) 'quote) - (null (cddr value))) - (cadr value) - value)) - -(defun ebox-test--normalize-properties (props) - "Return fixture PROPS with source literals normalized. -The typed author boundary spells explicit no-wrap as `none'; old backend -fixtures used nil for the same engine condition." - (cl-loop for (key value) on props by #'cddr - append (list key - (if (and (eq key :wrap-mode) (null value)) - 'none - (ebox-test--unquote value))))) - -(defun ebox-test--source-properties (props) - "Return typed source metadata projected from fixture PROPS." - (let ((source (ebox-test--keep props '(:key :class :id)))) - (when-let* ((handle (or (plist-get props :source-handle) - (plist-get props :host-ref)))) - (setq source (append source (list :source-handle handle)))) - source)) +(defun ebox-test--source-handle (builder props declarations) + "Return one opaque fixture source for PROPS and DECLARATIONS. +The optional `:source-identity' fixture property explicitly names its stable +author identity; opaque source handles remain internal to SourceBuilder." + (let* ((identity (plist-get props :source-identity)) + (arguments + (list :key (plist-get props :key) + :id (plist-get props :id) + :class (plist-get props :class) + :selector-state (plist-get props :selector-state) + :selector-attributes (plist-get props :selector-attributes) + :declarations declarations + :provenance '(:adapter ebox-test)))) + (apply #'ebox-source-builder-bind + builder + (append + (and identity (list :identity identity)) + arguments)))) (defun ebox-test--declarations (tag props) "Return canonical TAG declarations from evaluated fixture PROPS." @@ -92,43 +123,61 @@ fixtures used nil for the same engine condition." (memq (plist-get property :name) (ebox-layout-config-property-names tag))))))) -(defun ebox-test--text (value &optional props) - "Return one canonical Text fixture for VALUE and author PROPS." - (let ((declarations (ebox-test--declarations 'text props))) - (apply #'ebox-text-create - (append (list :value value :declarations declarations) - (ebox-test--source-properties props))))) +(defun ebox-test-text (value &rest props) + "Return one canonical Text input for VALUE and author PROPS." + (let* ((builder (ebox-source-builder-create)) + (declarations (ebox-test--declarations 'text props)) + (node + (ebox-text-create + :value value + :owned-facts + (ebox-canonical-facts-from-declarations 'text declarations) + :source-handle + (ebox-test--source-handle builder props declarations))) + (index (ebox-source-builder-finish builder))) + (ebox-canonical-input-create (list node) index))) + +(defun ebox-test--normalize-child (builder child) + "Import fixture CHILD into BUILDER and return its canonical node value." + (cond + ((ebox-canonical-input-p child) + (ebox-source-builder-import builder (ebox-test-source-index child)) + (ebox-test-root child)) + ((ebox-child-range--descriptor-p child) + (ebox-child-range--descriptor-create + (ebox-child-range--descriptor-ref child) + (mapcar (lambda (item) + (ebox-test--normalize-child builder item)) + (ebox-child-range--descriptor-items child)))) + (t + (error "Fixture child must be a canonical input or Range: %S" child)))) (defun ebox-test--box-node (tag props children) - "Return canonical Box form TAG from PROPS and CHILDREN." - (let* ((props (ebox-test--normalize-properties props)) + "Return canonical Box input TAG from PROPS and CHILDREN." + (let* ((builder (ebox-source-builder-create)) (declarations (ebox-test--declarations tag props)) + (nodes (mapcar (lambda (child) + (ebox-test--normalize-child builder child)) + children)) (node - (apply #'ebox-box-create - (append - (list :layout (ebox-test--layout tag props declarations) - :outer (or (plist-get props :outer) 'block) - :children children - :declarations declarations) - (ebox-test--source-properties props))))) + (let ((ebox-canonical--source-builder builder)) + (ebox-box-create + :layout (ebox-test--layout tag props declarations) + :outer (or (plist-get props :outer) 'block) + :children nodes + :owned-facts + (ebox-canonical-facts-from-declarations tag declarations) + :source-handle + (ebox-test--source-handle builder props declarations)))) + (index (ebox-source-builder-finish builder))) (dolist (key ebox-test--direct-fields) (when (plist-member props key) (plist-put node key (copy-tree (plist-get props key))))) - node)) + (ebox-canonical-input-create (list node) index))) -(defun ebox-test-box (&rest props) - "Return one typed Normal Box fixture from evaluated PROPS." - (let* ((content-present-p (plist-member props :content)) - (content (plist-get props :content)) - (child (plist-get props :ebox-content-node)) - (children - (cond - (child (list child)) - (content-present-p - (if (stringp content) - (list (ebox-test--text content)) - (error "Fixture :content must be a string: %S" content))) - (t nil)))) +(defun ebox-test-box (&rest items) + "Return one typed Normal Box fixture from evaluated ITEMS." + (pcase-let ((`(,props . ,children) (ebox-test--split-items items))) (ebox-test--box-node 'box props children))) (defun ebox-test-row (&rest items) @@ -141,18 +190,6 @@ fixtures used nil for the same engine condition." (pcase-let ((`(,props . ,children) (ebox-test--split-items items))) (ebox-test--box-node 'column props (delq nil children)))) -(defun ebox-test-concat (left right) - "Return a two-child typed Row fixture." - (ebox-test-row left right)) - -(defun ebox-test-stack (top bottom) - "Return a two-child typed Column fixture." - (ebox-test-column top bottom)) - -(defun ebox-test-spacer (&rest props) - "Return an empty typed Normal Box fixture." - (apply #'ebox-test-box props)) - (defun ebox-test-flex (&rest items) "Return one typed Flex fixture from evaluated ITEMS." (pcase-let ((`(,props . ,children) (ebox-test--split-items items))) @@ -168,85 +205,34 @@ fixtures used nil for the same engine condition." (list 'fr factor)) (defun ebox-test-flex-item (node &rest props) - "Return NODE with direct Flex participation and Box properties from PROPS." - (setq props (ebox-test--normalize-properties props)) - (unless (ebox-box-node-p node) + "Return INPUT with direct Flex participation and Box properties from PROPS." + (unless (ebox-canonical-input-p node) + (signal 'wrong-type-argument (list 'ebox-canonical-input-p node))) + (unless (ebox-box-node-p (ebox-test-root node)) (setq node (ebox-test--box-node 'box nil (list node)))) - (let* ((layout (ebox-box-node-layout node)) + (let* ((source-index (ebox-test-source-index node)) + (node (ebox-test-root node)) + (source (or (ebox-node-source-handle node) + (error "Fixture Box lacks an opaque source handle: %S" + node))) + (layout (ebox-box-node-layout node)) (children (ebox-box-node-children node)) - (base (ebox-style-node-declarations node)) + (base (ebox-style-node-declarations source-index node)) (extra (ebox-test--declarations 'box props)) + (declarations (ebox-style-merge-declarations base extra)) + (binding + (ebox-source-index-rebind + source-index source :declarations declarations)) (copy - (apply #'ebox-box-create - (append - (list :layout layout :children children - :outer (ebox-tree-display-outer node) - :declarations - (ebox-style-merge-declarations base extra)) - (ebox-test--source-properties node))))) - copy)) - -(defun ebox-test--build-item (items) - "Return one typed direct-child Box from old backend fixture ITEMS. -`item' never reaches production input; this test helper only projects its -participation properties onto the material child being exercised." - (pcase-let* ((`(,props . ,children) (ebox-test--split-items items)) - (props (ebox-test--normalize-properties props))) - (unless (= (length children) 1) - (error "Ebox test item requires exactly one child: %S" items)) - (apply #'ebox-test-flex-item - (ebox-test-build (car children)) props))) - -(defun ebox-test--build-box-form (tag items) - "Return one typed TAG Box fixture from old backend fixture ITEMS." - (pcase-let* ((`(,props . ,child-forms) (ebox-test--split-items items)) - (props (ebox-test--normalize-properties props)) - (content-present-p (plist-member props :content)) - (content (plist-get props :content)) - (content-node (plist-get props :ebox-content-node)) - (props (ebox-test--without - props '(:content :ebox-content-node))) - (children (mapcar #'ebox-test-build child-forms))) - (when content-node - (setq children (append children (list content-node)))) - (when content-present-p - (unless (stringp content) - (error "Ebox test :content must be a string: %S" content)) - (setq children (append children (list (ebox-test--text content))))) - (ebox-test--box-node tag props children))) - -(defun ebox-test-build (form) - "Build canonical typed nodes from one backend test FORM. - -This is deliberately not an author DSL. It isolates retained-layout and -publication tests from source parsing while their compact historical fixture -data is migrated. Production `ebox-build' remains strict and never accepts -`:content', `item', `concat', `stack', or `spacer'." - (cond - ((or (ebox-text-node-p form) (ebox-box-node-p form)) form) - ((stringp form) (ebox-test--text form)) - ((not (and (consp form) (symbolp (car form)))) - (error "Invalid Ebox typed test form: %S" form)) - ((eq (car form) 'text) - (pcase-let* ((`(,props . ,payloads) - (ebox-test--split-items (cdr form))) - (props (ebox-test--normalize-properties props))) - (unless (and (= (length payloads) 1) - (stringp (car payloads))) - (error "Ebox test text requires exactly one string payload: %S" form)) - (ebox-test--text (car payloads) props))) - ((memq (car form) '(box row column flex grid)) - (ebox-test--build-box-form (car form) (cdr form))) - ((eq (car form) 'item) - (ebox-test--build-item (cdr form))) - ((eq (car form) 'concat) - (ebox-test--build-box-form 'row (cdr form))) - ((eq (car form) 'stack) - (ebox-test--build-box-form 'column (cdr form))) - ((eq (car form) 'spacer) - (ebox-test--build-box-form 'box (cdr form))) - (t - (error "Unknown Ebox typed test form: %S" (car form))))) + (ebox-box-create + :layout layout + :children children + :outer (ebox-tree-display-outer node) + :owned-facts + (ebox-canonical-facts-from-declarations + 'box declarations) + :source-handle (cdr binding)))) + (ebox-canonical-input-create (list copy) (car binding)))) (provide 'ebox-fixtures) diff --git a/tests/ebox-flex-tests.el b/tests/ebox-flex-tests.el index 52f7f53..b6823d0 100644 --- a/tests/ebox-flex-tests.el +++ b/tests/ebox-flex-tests.el @@ -61,15 +61,16 @@ (setq pos end))) (max max-width current-width))) -(defun ebox-flex-test--content-widths (node) - "Return max content widths for NODE's direct material children." +(defun ebox-flex-test--content-widths (input) + "Return max content widths for INPUT's direct material children." + (let ((node (ebox-test-root input))) (let* ((ids (mapcar (lambda (child) (car (ebox-region-ids child))) (ebox-box-node-children node))) - (rendered (ebox-flex-test--render node))) + (rendered (ebox-flex-test--render input))) (mapcar (lambda (id) (ebox-flex-test--region-owner-width rendered id)) - ids))) + ids)))) (defun ebox-flex-test--face-has-key-value-p (face key value) "Return non-nil when FACE contains KEY with VALUE." @@ -147,8 +148,8 @@ (layout (ebox-test-flex (ebox-test-box :id "rule" :class 'grow - :content "Rule" :width '(40)) - (ebox-test-box :id "inline" :content "Inline" :width '(40) + (ebox-test-text "Rule") :width '(40)) + (ebox-test-box :id "inline" (ebox-test-text "Inline") :width '(40) :flex-grow 2))) (buffer (generate-new-buffer " *ebox-flex-style-reset*"))) (unwind-protect @@ -175,8 +176,8 @@ "Flex item participation props may live directly on child boxes." (let* ((layout (ebox-test-flex :width 300 - (ebox-test-box :content "A" :flex-grow 1 :flex-basis '(80)) - (ebox-test-box :content "B" :flex-grow 1 :flex-basis '(80)))) + (ebox-test-box (ebox-test-text "A") :flex-grow 1 :flex-basis '(80)) + (ebox-test-box (ebox-test-text "B") :flex-grow 1 :flex-basis '(80)))) (rendered (let ((ebox-viewport-width 300)) (ebox-render layout))) (widths (mapcar #'ebox--string-pixel-width @@ -190,10 +191,10 @@ (let* ((layout (ebox-test-flex :width '(300) (ebox-test-flex-item - (ebox-test-box :content "A" :width '(80)) + (ebox-test-box (ebox-test-text "A") :width '(80)) :flex-grow 1 :flex-basis '(80)) (ebox-test-flex-item - (ebox-test-box :content "B" :width '(80)) + (ebox-test-box (ebox-test-text "B") :width '(80)) :flex-grow 2 :flex-basis '(80)))) (widths (ebox-flex-test--content-widths layout))) (should (= (length widths) 2)) @@ -205,7 +206,7 @@ "A row flex target should include a border-box item's inline margins." (let ((layout (ebox-test-flex :width '(200) :align-items 'flex-start - (ebox-test-box :content "A" + (ebox-test-box (ebox-test-text "A") :width '(40) :box-sizing 'border-box :margin-left '(20) @@ -219,7 +220,7 @@ (ebox-test-flex :width '(80) :height 6 :flex-direction 'column :align-items 'flex-start - (ebox-test-box :content "A" + (ebox-test-box (ebox-test-text "A") :width '(40) :height 1 :box-sizing 'border-box @@ -232,7 +233,7 @@ "Content-box flex targets should subtract padding, borders, and margins." (let ((layout (ebox-test-flex :width '(200) :align-items 'flex-start - (ebox-test-box :content "A" + (ebox-test-box (ebox-test-text "A") :width '(40) :box-sizing 'content-box :padding-left '(10) @@ -249,10 +250,10 @@ (let* ((layout (ebox-test-flex :width '(150) (ebox-test-flex-item - (ebox-test-box :content "A" :width '(100)) + (ebox-test-box (ebox-test-text "A") :width '(100)) :flex-shrink 1 :flex-basis '(100)) (ebox-test-flex-item - (ebox-test-box :content "B" :width '(100)) + (ebox-test-box (ebox-test-text "B") :width '(100)) :flex-shrink 3 :flex-basis '(100)))) (widths (ebox-flex-test--content-widths layout))) (should (= (length widths) 2)) @@ -267,10 +268,10 @@ (layout (ebox-test-flex :width '(300) (ebox-test-flex-item - (ebox-test-box :content content :width '(160) :wrap-mode 'word) + (ebox-test-box (ebox-test-text content) :width '(160) :wrap-mode 'word) :flex-basis 'content) (ebox-test-flex-item - (ebox-test-box :content content :width '(160) :wrap-mode 'word) + (ebox-test-box (ebox-test-text content) :width '(160) :wrap-mode 'word) :flex-basis 'min-content))) (widths (ebox-flex-test--content-widths layout))) (should (equal widths (list max-content min-content))))) @@ -280,10 +281,10 @@ (let* ((layout (ebox-test-flex :width '(200) (ebox-test-flex-item - (ebox-test-box :content "A" :width '(60) :max-width '(80)) + (ebox-test-box (ebox-test-text "A") :width '(60) :max-width '(80)) :flex-grow 1 :flex-basis '(60)) (ebox-test-flex-item - (ebox-test-box :content "B" :width '(60)) + (ebox-test-box (ebox-test-text "B") :width '(60)) :flex-grow 1 :flex-basis '(60)))) (widths (ebox-flex-test--content-widths layout))) (should (equal widths '(80 120))) @@ -294,10 +295,10 @@ (let* ((layout (ebox-test-flex :width '(200) (ebox-test-flex-item - (ebox-test-box :content "A" :width '(50) :min-width '(80)) + (ebox-test-box (ebox-test-text "A") :width '(50) :min-width '(80)) :flex-grow 1 :flex-basis '(50)) (ebox-test-flex-item - (ebox-test-box :content "B" :width '(50)) + (ebox-test-box (ebox-test-text "B") :width '(50)) :flex-grow 1 :flex-basis '(50)))) (widths (ebox-flex-test--content-widths layout))) (should (equal widths '(100 100))) @@ -308,10 +309,10 @@ (let* ((layout (ebox-test-flex :width '(300) (ebox-test-flex-item - (ebox-test-box :content "A" :width '(50)) + (ebox-test-box (ebox-test-text "A") :width '(50)) :flex-grow 0.25) (ebox-test-flex-item - (ebox-test-box :content "B" :width '(50)) + (ebox-test-box (ebox-test-text "B") :width '(50)) :flex-grow 0.25))) (widths (ebox-flex-test--content-widths layout))) (should (equal widths '(100 100))) @@ -322,10 +323,10 @@ (let* ((layout (ebox-test-flex :width '(150) (ebox-test-flex-item - (ebox-test-box :content "A" :width '(100) :min-width '(80)) + (ebox-test-box (ebox-test-text "A") :width '(100) :min-width '(80)) :flex-shrink 1 :flex-basis '(100)) (ebox-test-flex-item - (ebox-test-box :content "B" :width '(100)) + (ebox-test-box (ebox-test-text "B") :width '(100)) :flex-shrink 1 :flex-basis '(100)))) (widths (ebox-flex-test--content-widths layout))) (should (equal widths '(80 70))) @@ -336,11 +337,11 @@ (let* ((layout (ebox-test-flex :width '(150) (ebox-test-flex-item - (ebox-test-box :content "A" :width '(100) :max-width '(80) + (ebox-test-box (ebox-test-text "A") :width '(100) :max-width '(80) :wrap-mode 'word) :flex-shrink 1 :flex-basis '(100)) (ebox-test-flex-item - (ebox-test-box :content "B" :width '(100) :wrap-mode 'word) + (ebox-test-box (ebox-test-text "B") :width '(100) :wrap-mode 'word) :flex-shrink 1 :flex-basis '(100)))) (widths (ebox-flex-test--content-widths layout))) (should (equal widths '(75 75))) @@ -351,10 +352,10 @@ (let* ((layout (ebox-test-flex :width '(150) (ebox-test-flex-item - (ebox-test-box :content "A" :width '(100)) + (ebox-test-box (ebox-test-text "A") :width '(100)) :flex-shrink 0.25) (ebox-test-flex-item - (ebox-test-box :content "B" :width '(100)) + (ebox-test-box (ebox-test-text "B") :width '(100)) :flex-shrink 0.25))) (widths (ebox-flex-test--content-widths layout))) (should (equal widths '(87 88))) @@ -365,24 +366,24 @@ (should-error (ebox-flex-test--render (ebox-test-flex :width '(-1) - (ebox-test-box :content "A" :width '(20)))) + (ebox-test-box (ebox-test-text "A") :width '(20)))) :type 'error) (should-error (ebox-flex-test--render (ebox-test-flex - (ebox-test-flex-item (ebox-test-box :content "A" :width '(20)) + (ebox-test-flex-item (ebox-test-box (ebox-test-text "A") :width '(20)) :flex-grow -1))) :type 'error) (should-error (ebox-flex-test--render (ebox-test-flex - (ebox-test-flex-item (ebox-test-box :content "A" :width '(20)) + (ebox-test-flex-item (ebox-test-box (ebox-test-text "A") :width '(20)) :flex-shrink -1))) :type 'error) (should-error (ebox-flex-test--render (ebox-test-flex - (ebox-test-flex-item (ebox-test-box :content "A" :width '(20)) + (ebox-test-flex-item (ebox-test-box (ebox-test-text "A") :width '(20)) :flex-basis '(-1)))) :type 'error)) @@ -390,9 +391,9 @@ "Row wrap should create new flex lines using column gaps." (let* ((layout (ebox-test-flex :width '(210) :flex-wrap 'wrap :column-gap '(10) - (ebox-test-box :content "A" :width '(100)) - (ebox-test-box :content "B" :width '(100)) - (ebox-test-box :content "C" :width '(100)))) + (ebox-test-box (ebox-test-text "A") :width '(100)) + (ebox-test-box (ebox-test-text "B") :width '(100)) + (ebox-test-box (ebox-test-text "C") :width '(100)))) (lines (ebox-flex-test--plain-lines layout))) (should (= (length lines) 2)) (should (string-match-p "A.*B" (car lines))) @@ -506,10 +507,10 @@ (let* ((layout (ebox-test-flex :width '(210) :flex-wrap 'wrap :column-gap '(10) (ebox-test-flex-item - (ebox-test-box :content "A" :width '(200) :max-width '(100)) + (ebox-test-box (ebox-test-text "A") :width '(200) :max-width '(100)) :flex-basis '(200)) (ebox-test-flex-item - (ebox-test-box :content "B" :width '(200) :max-width '(100)) + (ebox-test-box (ebox-test-text "B") :width '(200) :max-width '(100)) :flex-basis '(200)))) (lines (ebox-flex-test--plain-lines layout)) (widths (ebox-flex-test--content-widths layout))) @@ -522,9 +523,9 @@ (let* ((ebox-viewport-width 240) (layout (ebox-test-flex :flex-wrap 'wrap :column-gap '(10) - (ebox-test-box :content "A" :width '(100)) - (ebox-test-box :content "B" :width '(100)) - (ebox-test-box :content "C" :width '(100))))) + (ebox-test-box (ebox-test-text "A") :width '(100)) + (ebox-test-box (ebox-test-text "B") :width '(100)) + (ebox-test-box (ebox-test-text "C") :width '(100))))) (should (equal (ebox-flex-test--line-widths layout) '(240 240))))) (ert-deftest ebox-flex-stretch-width-is-independent-of-container-wrapper () @@ -532,10 +533,10 @@ (let* ((ebox-viewport-width 200) (plain (ebox-test-flex :width 'stretch - (ebox-test-box :content "A" :width '(40)))) + (ebox-test-box (ebox-test-text "A") :width '(40)))) (padded (ebox-test-flex :width 'stretch :padding 0 - (ebox-test-box :content "A" :width '(40))))) + (ebox-test-box (ebox-test-text "A") :width '(40))))) (should (equal (mapcar #'ebox-flex-test--line-widths (list plain padded)) '((200) (200)))))) @@ -548,10 +549,10 @@ (lambda (width) (let ((plain (ebox-test-flex :width width - (ebox-test-box :content "A" :width '(40)))) + (ebox-test-box (ebox-test-text "A") :width '(40)))) (padded (ebox-test-flex :width width :padding 0 - (ebox-test-box :content "A" :width '(40))))) + (ebox-test-box (ebox-test-text "A") :width '(40))))) (list width plain padded))) '(stretch auto))) (results @@ -573,8 +574,8 @@ :border-left '(10) :border-right '(10) :justify-content 'space-between - (ebox-test-box :content "A" :width '(40)) - (ebox-test-box :content "B" :width '(40)))) + (ebox-test-box (ebox-test-text "A") :width '(40)) + (ebox-test-box (ebox-test-text "B") :width '(40)))) (rendered (ebox-flex-test--render layout)) (display-widths (cl-loop for index below (length rendered) @@ -594,8 +595,8 @@ :border-left '(10) :border-right '(10) :flex-wrap 'wrap - (ebox-test-flex-item (ebox-test-box :content "A" :width '(180))) - (ebox-test-flex-item (ebox-test-box :content "B" :width '(180)))))) + (ebox-test-flex-item (ebox-test-box (ebox-test-text "A") :width '(180))) + (ebox-test-flex-item (ebox-test-box (ebox-test-text "B") :width '(180)))))) (should (cl-every (lambda (width) (<= width ebox-viewport-width)) (ebox-flex-test--line-widths layout))))) @@ -603,10 +604,10 @@ "Auto height should preserve natural child height across wrapper branches." (let* ((plain (ebox-test-flex :width '(80) :height 'auto - (ebox-test-box :content "A\nB" :width '(40)))) + (ebox-test-box (ebox-test-text "A\nB") :width '(40)))) (padded (ebox-test-flex :width '(80) :height 'auto :padding 0 - (ebox-test-box :content "A\nB" :width '(40)))) + (ebox-test-box (ebox-test-text "A\nB") :width '(40)))) (results (mapcar (lambda (layout) @@ -618,45 +619,50 @@ (ert-deftest ebox-flex-reuses-measured-non-box-child-render () "Flex should not fully rerender a measured non-box child during one render." - (let* ((child (plist-put - (ebox-test-column - (ebox-test-box :content "A" :width '(100)) - (ebox-test-box :content "B" :width '(100))) - :key 'measured-child)) + (let* ((child (ebox-test-column + :key 'measured-child + (ebox-test-box (ebox-test-text "A") :width '(100)) + (ebox-test-box (ebox-test-text "B") :width '(100)))) (layout (ebox-test-flex :width '(240) :flex-wrap 'wrap child (ebox-test-column - (ebox-test-box :content "C" :width '(100)) - (ebox-test-box :content "D" :width '(100))))) + (ebox-test-box (ebox-test-text "C") :width '(100)) + (ebox-test-box (ebox-test-text "D") :width '(100))))) + (target-handle + (ebox-node-source-handle (ebox-test-root child))) (calls 0) - (original-render (symbol-function 'ebox-render))) - (cl-letf (((symbol-function 'ebox-render) - (lambda (node) - (when (eq (plist-get node :key) 'measured-child) + (original-render + (symbol-function 'ebox--flex-render-source-for-measurement))) + (cl-letf (((symbol-function 'ebox--flex-render-source-for-measurement) + (lambda (node viewport) + (when (eq (ebox-node-source-handle node) target-handle) (cl-incf calls)) - (funcall original-render node)))) + (funcall original-render node viewport)))) (ebox-render layout)) (should (= calls 1)))) (ert-deftest ebox-flex-reuses-natural-non-box-render-before-cross-padding () "Final cross padding should reuse a naturally sized non-box render." - (let* ((source (plist-put - (ebox-test-column - (ebox-test-box :content "A" :width '(80)) - (ebox-test-box :content "A2" :width '(80))) - :key 'natural-child)) + (let* ((source (ebox-test-column + :key 'natural-child + (ebox-test-box (ebox-test-text "A") :width '(80)) + (ebox-test-box (ebox-test-text "A2") :width '(80)))) + (item (ebox-test-flex-item + source :flex-grow 1 :flex-basis '(80))) (layout (ebox-test-flex :width '(220) - (ebox-test-flex-item source :flex-grow 1 :flex-basis '(80)) - (ebox-test-box :content "B" :width '(80) :height 4))) + item + (ebox-test-box (ebox-test-text "B") :width '(80) :height 4))) + (target-handle + (ebox-node-source-handle (ebox-test-root item))) (calls 0) - (original (symbol-function 'ebox-render)) + (original (symbol-function 'ebox--render-node)) rendered) - (cl-letf (((symbol-function 'ebox-render) - (lambda (node) - (when (eq (plist-get node :key) 'natural-child) + (cl-letf (((symbol-function 'ebox--render-node) + (lambda (node &optional source-index) + (when (eq (ebox-node-source-handle node) target-handle) (cl-incf calls)) - (funcall original node)))) + (funcall original node source-index)))) (setq rendered (ebox-render layout))) (should (= (ebox-string-height rendered) 4)) (should (equal (mapcar #'ebox--string-pixel-width @@ -666,9 +672,9 @@ (ert-deftest ebox-flex-natural-reuse-preserves-stretched-visible-overflow-ownership () "Stretch sizing should absorb visible overflow into the final box region." - (let* ((box (ebox-test-box :content "A\nB" :width '(80) :height 1 + (let* ((box (ebox-test-box (ebox-test-text "A\nB") :width '(80) :height 1 :overflow 'visible :flex-grow 1)) - (region-id (car (ebox-region-ids box))) + (region-id (car (ebox-region-ids (ebox-test-root box)))) (rendered (ebox-render (ebox-test-flex :width '(120) box))) (lines (ebox-string-lines rendered))) (should (= (length lines) 2)) @@ -705,16 +711,16 @@ (ert-deftest ebox-flex-natural-row-padding-keeps-overflow-line-local () "Visible overflow should widen only the line that actually overflows." (let* ((viewport-child - (ebox-test-box :content "A" :width '(viewport) + (ebox-test-box (ebox-test-text "A") :width '(viewport) :box-sizing 'content-box :padding-left '(10) :padding-right '(10))) (source (ebox-test-column viewport-child - (ebox-test-box :content "A2" :width '(20)))) + (ebox-test-box (ebox-test-text "A2") :width '(20)))) (layout (ebox-test-flex :width '(240) (ebox-test-flex-item source :flex-basis '(20)) - (ebox-test-box :content "B" :width '(20) :height 4))) + (ebox-test-box (ebox-test-text "B") :width '(20) :height 4))) (widths (mapcar #'ebox--string-pixel-width (ebox-string-lines (ebox-render layout))))) @@ -727,17 +733,19 @@ (let* ((layout (ebox-test-flex :width '(120) (ebox-test-row - (ebox-test-box :content "Left" :border t) - (ebox-test-box :content "Right" :border t)))) + (ebox-test-box (ebox-test-text "Left") :border t) + (ebox-test-box (ebox-test-text "Right") :border t)))) (widths (mapcar #'ebox--string-pixel-width (ebox-string-lines (ebox-render layout))))) (should (cl-every (lambda (width) (<= width 120)) widths)))) (ert-deftest ebox-flex-sized-persistent-hit-populates-pass-local-cache () "Persistent sized renders should become reusable in the current pass." - (let* ((source (ebox-test-box :content "A" :width '(80))) + (let* ((source-input (ebox-test-box (ebox-test-text "A") :width '(80))) + (source (ebox-test-root source-input)) (ebox--render-cache-table (make-hash-table :test 'equal)) (ebox--render-cache-signature-cache (make-hash-table :test 'eq)) + (ebox--surface-materialization-active t) (first (car (ebox--flex-collect-items (list source) 'row 220))) (main (plist-get first :target)) (cache-key (list 'row main nil 'flex-start))) @@ -760,11 +768,14 @@ (ert-deftest ebox-flex-measurement-miss-probes-cache-once () "A flex measurement miss should compute one exact cache probe." - (let* ((source (ebox-test-box :content "Measured once" :width '(80))) + (let* ((source-input + (ebox-test-box (ebox-test-text "Measured once") :width '(80))) + (source (ebox-test-root source-input)) (props '(:flex-basis auto)) (ebox--render-cache-table (make-hash-table :test 'equal)) (ebox--render-cache-signature-cache (make-hash-table :test 'eq)) (ebox--viewport-dependent-subtree-cache (make-hash-table :test 'eq)) + (ebox--surface-materialization-active t) (signature-calls 0) (lookup-calls 0) (original-signature @@ -786,14 +797,16 @@ (ert-deftest ebox-flex-reuses-direct-column-natural-measurement () "A direct Column's nil-viewport natural size should survive parent resize." - (let* ((source + (let* ((source-input (ebox-test-column - (ebox-test-box :content "A" :width '(80)) - (ebox-test-box :content "B" :width '(80)))) + (ebox-test-box (ebox-test-text "A") :width '(80)) + (ebox-test-box (ebox-test-text "B") :width '(80)))) + (source (ebox-test-root source-input)) (props '(:flex-basis auto)) (ebox--render-cache-table (make-hash-table :test 'equal)) (ebox--render-cache-signature-cache (make-hash-table :test 'eq)) (ebox--viewport-dependent-subtree-cache (make-hash-table :test 'eq)) + (ebox--surface-materialization-active t) (renders 0) (original (symbol-function 'ebox--flex-render-source-for-measurement))) @@ -809,7 +822,7 @@ "Known flex metrics should not render a wrapped fixed-basis box twice." (let* ((source (ebox-test-box - :content "A responsive wrapped child" + (ebox-test-text "A responsive wrapped child") :width '(viewport) :min-width 0 :height 4 :wrap-mode 'word)) (item (ebox-test-flex-item @@ -833,27 +846,30 @@ (ert-deftest ebox-flex-fixed-basis-nowrap-box-keeps-intrinsic-measurement () "No-wrap auto minimums should retain their intrinsic render measurement." - (let* ((source - (ebox-test-box :content "UNBREAKABLE" :min-width 0 :height 1 - :wrap-mode nil)) + (let* ((source-input + (ebox-test-box (ebox-test-text "UNBREAKABLE") :min-width 0 :height 1 + :wrap-mode 'none)) + (source (ebox-test-root source-input)) + (item-input (ebox-test-flex-item source-input :flex-basis '(40))) (props - (ebox--flex-item-props - (ebox-test-flex-item source :flex-basis '(40))))) + (ebox--flex-item-props (ebox-test-root item-input)))) (should-not (ebox--flex-unrendered-measurement source props 'row)))) (ert-deftest ebox-flex-explicit-zero-min-width-allows-no-wrap-shrink () "An explicit zero minimum lets a no-wrap card shrink in its Flex line." - (let* ((source - (ebox-test-box :content "A long no-wrap card title" - :min-width 0 :wrap-mode nil)) - (rendered (ebox-render source))) + (let* ((source-input + (ebox-test-box (ebox-test-text "A long no-wrap card title") + :min-width 0 :wrap-mode 'none)) + (source (ebox-test-root source-input)) + (rendered (ebox-render source-input))) (should (zerop (ebox--flex-box-min-main source rendered 'row))))) (ert-deftest ebox-flex-bounded-auto-basis-keeps-intrinsic-measurement () "Clipping must not replace an auto-basis item's intrinsic contribution." - (let* ((source - (ebox-test-box :content "UNBREAKABLE" :width '(120) :height 2 - :min-width 0 :wrap-mode nil :overflow 'hidden)) + (let* ((source-input + (ebox-test-box (ebox-test-text "UNBREAKABLE") :width '(120) :height 2 + :min-width 0 :wrap-mode 'none :overflow 'hidden)) + (source (ebox-test-root source-input)) (props (ebox--flex-item-props source))) (should (eq (plist-get props :flex-basis) 'auto)) (should-not (ebox--flex-unrendered-measurement source props 'row)))) @@ -861,17 +877,19 @@ (ert-deftest ebox-flex-column-natural-cache-respects-final-cross-viewport () "Column flex must render composite items in the final cross viewport." (let* ((viewport-child - (ebox-test-box :content "VP" :width '(viewport))) - (source + (ebox-test-box (ebox-test-text "VP") :width '(viewport))) + (source-input (ebox-test-column viewport-child - (ebox-test-box :content "F" :width '(100)))) - (region-id (car (ebox-region-ids viewport-child))) + (ebox-test-box (ebox-test-text "F") :width '(100)))) + (source (ebox-test-root source-input)) + (region-id + (car (ebox-region-ids (ebox-test-root viewport-child)))) (layout (ebox-test-flex :height 4 :flex-direction 'column :align-items 'stretch - (ebox-test-flex-item source :flex-grow 1 :flex-basis 2))) + (ebox-test-flex-item source-input :flex-grow 1 :flex-basis 2))) (rendered (ebox-render layout))) (should (ebox-box-node-p source)) (should (eq (ebox-layout-config-kind (ebox-box-node-layout source)) @@ -883,8 +901,8 @@ "Nowrap containers with fixed cross size should not measure natural cross." (let* ((layout (ebox-test-flex :width '(220) :height 4 :flex-wrap 'nowrap - (ebox-test-box :content "A" :width '(80)) - (ebox-test-box :content "B" :width '(80)))) + (ebox-test-box (ebox-test-text "A") :width '(80)) + (ebox-test-box (ebox-test-text "B") :width '(80)))) (calls 0) (original (symbol-function 'ebox--flex-line-cross))) (cl-letf (((symbol-function 'ebox--flex-line-cross) @@ -898,8 +916,8 @@ "Align-items center should move short row items within the line height." (let* ((layout (ebox-test-flex :width '(180) :height 3 :align-items 'center - (ebox-test-box :content "A" :width '(80) :height 1) - (ebox-test-box :content "B\nB\nB" :width '(80) :height 3))) + (ebox-test-box (ebox-test-text "A") :width '(80) :height 1) + (ebox-test-box (ebox-test-text "B\nB\nB") :width '(80) :height 3))) (lines (ebox-flex-test--plain-lines layout))) (should (= (length lines) 3)) (should (= (cl-position-if (lambda (line) @@ -911,12 +929,12 @@ "Order and reverse directions should change visual item order." (let* ((ordered (ebox-test-flex - (ebox-test-flex-item (ebox-test-box :content "A" :width '(40)) :order 2) - (ebox-test-flex-item (ebox-test-box :content "B" :width '(40)) :order 1))) + (ebox-test-flex-item (ebox-test-box (ebox-test-text "A") :width '(40)) :order 2) + (ebox-test-flex-item (ebox-test-box (ebox-test-text "B") :width '(40)) :order 1))) (column-reverse (ebox-test-flex :flex-direction 'column-reverse - (ebox-test-box :content "A" :width '(40)) - (ebox-test-box :content "B" :width '(40))))) + (ebox-test-box (ebox-test-text "A") :width '(40)) + (ebox-test-box (ebox-test-text "B") :width '(40))))) (should (string-match-p "B.*A" (car (ebox-flex-test--plain-lines ordered)))) (should (string-match-p "B" @@ -927,9 +945,9 @@ (let* ((layout (ebox-test-flex :width '(180) :height 3 :align-items 'flex-start (ebox-test-flex-item - (ebox-test-box :content "A" :width '(80) :height 1) + (ebox-test-box (ebox-test-text "A") :width '(80) :height 1) :align-self 'flex-end) - (ebox-test-box :content "B\nB\nB" :width '(80) :height 3))) + (ebox-test-box (ebox-test-text "B\nB\nB") :width '(80) :height 3))) (lines (ebox-flex-test--plain-lines layout))) (should (= (length lines) 3)) (should (= (cl-position-if (lambda (line) @@ -944,7 +962,7 @@ :flex-wrap 'wrap :align-content 'flex-start :align-items 'center - (ebox-test-box :content "A" :width '(60) :height 1))) + (ebox-test-box (ebox-test-text "A") :width '(60) :height 1))) (lines (ebox-flex-test--plain-lines layout))) (should (= (length lines) 4)) (should (= (cl-position-if (lambda (line) @@ -959,7 +977,7 @@ :padding '(1 (8)) :border '(1 "#ff0000") :bgcolor "#101010" - (ebox-test-box :content "A" :width '(60)))) + (ebox-test-box (ebox-test-text "A") :width '(60)))) (rendered (ebox-flex-test--render layout)) (left-face (ebox-flex-test--face-at-property rendered 'ebox-bl)) (right-face (ebox-flex-test--face-at-property rendered 'ebox-br)) @@ -981,13 +999,13 @@ (ert-deftest ebox-flex-container-box-preserves-blank-child-bottom-border () "A visual flex container should preserve child bottom borders on blank lines." - (let* ((child (ebox-test-box :content "A" + (let* ((child (ebox-test-box (ebox-test-text "A") :width '(80) :height 2 :padding '(0 (6)) :border '(1 "#00aa00") :flex-basis '(80))) - (child-id (car (ebox-region-ids child))) + (child-id (car (ebox-region-ids (ebox-test-root child)))) (layout (ebox-test-flex :width '(140) :padding '(1 (4)) :border '(1 "#444444") @@ -1010,18 +1028,18 @@ (ebox-test-flex :width '(260) :height 3 :border "gray" :flex-flow '(row wrap) :justify-content 'space-between - (ebox-test-box :content "sample box two" + (ebox-test-box (ebox-test-text "sample box two") :height 3 :box-sizing 'border-box :padding '(0 (8)) :border t - :wrap-mode nil) - (ebox-test-box :content "sample box three" + :wrap-mode 'none) + (ebox-test-box (ebox-test-text "sample box three") :height 3 :box-sizing 'border-box :padding '(0 (8)) :border t - :wrap-mode nil))) + :wrap-mode 'none))) (plain (substring-no-properties (ebox-flex-test--render layout)))) (should-not (string-match-p "REWRAPPED" plain)) (should (string-match-p "sample box two" plain)) @@ -1032,7 +1050,7 @@ (let* ((layout (ebox-test-flex :width '(180) (ebox-test-flex-item - (ebox-test-box :content "A" :width '(60)) + (ebox-test-box (ebox-test-text "A") :width '(60)) :flex 1 :box-sizing 'border-box :padding '(0 (6)) @@ -1068,8 +1086,8 @@ (ebox-test-flex :width '(220) (ebox-test-flex-item (ebox-test-column - (ebox-test-box :content "Top" :width '(80)) - (ebox-test-box :content "Bottom" :width '(80))) + (ebox-test-box (ebox-test-text "Top") :width '(80)) + (ebox-test-box (ebox-test-text "Bottom") :width '(80))) :width '(180) :box-sizing 'border-box :border t))) @@ -1083,40 +1101,43 @@ (dolist (props '((:width (120)) (:height 2) (:box-sizing border-box))) - (let* ((left (ebox-test-box :content "A" :width '(40))) - (right (ebox-test-box :content "B" :width '(40))) + (let* ((left (ebox-test-box (ebox-test-text "A") :width '(40))) + (right (ebox-test-box (ebox-test-text "B") :width '(40))) (layout (apply #'ebox-test-flex (append props (list left right))))) - (should (equal (ebox-box-node-children layout) (list left right))) + (should (equal (ebox-box-node-children (ebox-test-root layout)) + (mapcar #'ebox-test-root (list left right)))) (should (string-match-p "A.*B" (substring-no-properties (ebox-flex-test--render layout))))))) (ert-deftest ebox-flex-box-sizing-alone-keeps-child-region-ids () "Box-sizing by itself should not wrap a flex container." - (let* ((left (ebox-test-box :content "A" :width '(40))) - (right (ebox-test-box :content "B" :width '(40))) + (let* ((left (ebox-test-box (ebox-test-text "A") :width '(40))) + (right (ebox-test-box (ebox-test-text "B") :width '(40))) (layout (ebox-test-flex :width '(120) :box-sizing 'border-box left right))) - (should (equal (ebox-box-node-children layout) (list left right))) + (should (equal (ebox-box-node-children (ebox-test-root layout)) + (mapcar #'ebox-test-root (list left right)))) (should (equal (ebox-flex-test--line-widths layout) '(120))))) (ert-deftest ebox-flex-direct-child-combines-frame-and-participation () "One child Box should own both frame and Flex participation facts." - (let* ((left (ebox-test-box :content "A" :width '(60) + (let* ((left (ebox-test-box (ebox-test-text "A") :width '(60) :flex-grow 1 :flex-basis '(60) :box-sizing 'border-box :border t)) - (right (ebox-test-box :content "B" :width '(40))) + (right (ebox-test-box (ebox-test-text "B") :width '(40))) (layout (ebox-test-flex :width '(160) left right)) - (left-id (car (ebox-region-ids left))) - (right-id (car (ebox-region-ids right))) + (left-id (car (ebox-region-ids (ebox-test-root left)))) + (right-id (car (ebox-region-ids (ebox-test-root right)))) (rendered (ebox-flex-test--render layout)) (left-width (ebox-flex-test--region-owner-width rendered left-id)) (right-width (ebox-flex-test--region-owner-width rendered right-id))) - (should (equal (ebox-box-node-children layout) (list left right))) + (should (equal (ebox-box-node-children (ebox-test-root layout)) + (mapcar #'ebox-test-root (list left right)))) (should (> left-width 60)) (should (> left-width right-width)))) @@ -1127,12 +1148,12 @@ :flex-flow '(row wrap) :column-gap '(10) (ebox-test-flex-item - (ebox-test-box :content "wide no-wrap label" + (ebox-test-box (ebox-test-text "wide no-wrap label") :width '(150) :box-sizing 'border-box - :wrap-mode nil) + :wrap-mode 'none) :flex 1) - (ebox-test-box :content "B" :width '(120)))) + (ebox-test-box (ebox-test-text "B") :width '(120)))) (lines (ebox-flex-test--plain-lines layout))) (should (= (length lines) 2)) (should (cl-every (lambda (width) (<= width 160)) @@ -1146,36 +1167,36 @@ (should (stringp (ebox-render (ebox-test-flex :width '(220) :height 4 :flex-direction direction - (ebox-test-box :content "A" :width '(60) :height 1) - (ebox-test-box :content "B" :width '(60) :height 1)))))) + (ebox-test-box (ebox-test-text "A") :width '(60) :height 1) + (ebox-test-box (ebox-test-text "B") :width '(60) :height 1)))))) (dolist (wrap '(nowrap wrap wrap-reverse)) (should (stringp (ebox-render (ebox-test-flex :width '(130) :height 4 :flex-wrap wrap - (ebox-test-box :content "A" :width '(60) :height 1) - (ebox-test-box :content "B" :width '(60) :height 1) - (ebox-test-box :content "C" :width '(60) :height 1)))))) + (ebox-test-box (ebox-test-text "A") :width '(60) :height 1) + (ebox-test-box (ebox-test-text "B") :width '(60) :height 1) + (ebox-test-box (ebox-test-text "C") :width '(60) :height 1)))))) (dolist (justify '(flex-start flex-end center space-between space-around space-evenly start end left right normal)) (should (stringp (ebox-render (ebox-test-flex :width '(220) :justify-content justify - (ebox-test-box :content "A" :width '(60)) - (ebox-test-box :content "B" :width '(60))))))) + (ebox-test-box (ebox-test-text "A") :width '(60)) + (ebox-test-box (ebox-test-text "B") :width '(60))))))) (dolist (align '(stretch flex-start flex-end center baseline normal start end self-start self-end)) (should (stringp (ebox-render (ebox-test-flex :width '(220) :height 4 :align-items align - (ebox-test-box :content "A" :width '(60) :height 1) - (ebox-test-box :content "B" :width '(60) :height 1)))))) + (ebox-test-box (ebox-test-text "A") :width '(60) :height 1) + (ebox-test-box (ebox-test-text "B") :width '(60) :height 1)))))) (dolist (align '(stretch flex-start flex-end center space-between space-around space-evenly baseline normal start end)) (should (stringp (ebox-render (ebox-test-flex :width '(130) :height 5 :flex-wrap 'wrap :align-content align - (ebox-test-box :content "A" :width '(60) :height 1) - (ebox-test-box :content "B" :width '(60) :height 1) - (ebox-test-box :content "C" :width '(60) :height 1))))))) + (ebox-test-box (ebox-test-text "A") :width '(60) :height 1) + (ebox-test-box (ebox-test-text "B") :width '(60) :height 1) + (ebox-test-box (ebox-test-text "C") :width '(60) :height 1))))))) (provide 'ebox-flex-tests) ;;; ebox-flex-tests.el ends here diff --git a/tests/ebox-grid-tests.el b/tests/ebox-grid-tests.el index 8ffc1af..bbbb13f 100644 --- a/tests/ebox-grid-tests.el +++ b/tests/ebox-grid-tests.el @@ -19,10 +19,10 @@ :grid-template-columns '((20) (20)) :grid-template-rows '(1 1) :gap '(0 (1)) - (ebox-test-box :content "A") - (ebox-test-box :content "B") - (ebox-test-box :content "C") - (ebox-test-box :content "D"))))) + (ebox-test-box (ebox-test-text "A")) + (ebox-test-box (ebox-test-text "B")) + (ebox-test-box (ebox-test-text "C")) + (ebox-test-box (ebox-test-text "D")))))) (should (string-match-p "A.*B" plain)) (should (string-match-p "C.*D" plain)))) @@ -31,8 +31,8 @@ (let* ((node (ebox-test-grid :width '(120) :grid-template-columns '(1fr 2fr) - (ebox-test-box :content "A") - (ebox-test-box :content "B"))) + (ebox-test-box (ebox-test-text "A")) + (ebox-test-box (ebox-test-text "B")))) (lines (ebox-string-lines (ebox-render node)))) (should (= (length lines) 1)) (should (= (ebox--string-pixel-width (car lines)) 120)))) @@ -41,8 +41,8 @@ "Grid render and resize paths should not reparse canonical track config." (let* ((node (ebox-test-grid :width '(120) :grid-template-columns '(1fr 2fr) - (ebox-test-box :content "A") - (ebox-test-box :content "B"))) + (ebox-test-box (ebox-test-text "A")) + (ebox-test-box (ebox-test-text "B")))) (calls 0) (original (symbol-function 'ebox-grid--normalize-config-props))) (cl-letf (((symbol-function 'ebox-grid--normalize-config-props) @@ -60,15 +60,15 @@ :width (list width) :grid-template-columns '((minmax (0) (fr 1)) (minmax (0) (fr 2))) - (ebox-test-box :content "A" :width 'stretch) - (ebox-test-box :content "B" :width 'stretch))) + (ebox-test-box (ebox-test-text "A") :width 'stretch) + (ebox-test-box (ebox-test-text "B") :width 'stretch))) (lines (ebox-string-lines (ebox-render node)))) (should (= (ebox--string-max-pixel-width (car lines)) width))))) (ert-deftest ebox-grid-stretch-children-fill-fractional-tracks () "Stretch-width children must reflow to their assigned fractional tracks." - (let* ((left (ebox-test-box :content "left" :width 'stretch)) + (let* ((left (ebox-test-box (ebox-test-text "left") :width 'stretch)) (calls 0) (entry-left (list :node left :column 1 :column-span 1)) (rendered (make-hash-table :test #'eq))) @@ -90,8 +90,8 @@ :grid-template-columns '((50) (50)) :gap '(0 (10)) :border "#334155" - (ebox-test-box :content "A\nA-long" :padding '(0 (4))) - (ebox-test-box :content "B\nB-long" :padding '(0 (4))))) + (ebox-test-box (ebox-test-text "A\nA-long") :padding '(0 (4))) + (ebox-test-box (ebox-test-text "B\nB-long") :padding '(0 (4))))) (lines (ebox-string-lines (ebox-render node))) (widths (mapcar #'ebox--string-pixel-width lines))) (should (equal widths (make-list (length widths) 120))))) @@ -102,8 +102,8 @@ :width '(120) :grid-template-columns '((120)) (ebox-test-row - (ebox-test-box :content "Left" :border t) - (ebox-test-box :content "Right" :border t)))) + (ebox-test-box (ebox-test-text "Left") :border t) + (ebox-test-box (ebox-test-text "Right") :border t)))) (widths (mapcar #'ebox--string-pixel-width (ebox-string-lines (ebox-render node))))) (should (equal widths '(120))))) @@ -115,7 +115,7 @@ :grid-template-columns '((38)) :border "#334155" (ebox-test-box - :content "WIDE CONTENT WIDE CONTENT WIDE CONTENT WIDE CONTENT" + (ebox-test-text "WIDE CONTENT WIDE CONTENT WIDE CONTENT WIDE CONTENT") :wrap-mode 'word))) (lines (ebox-string-lines (ebox-render node))) (widths (mapcar #'ebox--string-pixel-width lines))) @@ -132,10 +132,10 @@ (ebox-test-column :width '(20) (ebox-test-box - :content "fractional symbols\n1fr distributes by weight" + (ebox-test-text "fractional symbols\n1fr distributes by weight") :padding '(0 (2)) :border "#B85C3C" :wrap-mode 'word) - (ebox-test-box :content "body" :height 1)))) + (ebox-test-box (ebox-test-text "body") :height 1)))) (lines (ebox-string-lines (ebox-render node))) (plain (mapcar #'substring-no-properties lines))) (should (> (length lines) 3)) @@ -153,24 +153,24 @@ :gap '(1 (4)) :justify-content 'space-between :border "#334155" - (ebox-test-box :content "FIXED\n30 PX") - (ebox-test-box :content "AUTO\nINTRINSIC") - (ebox-test-box :content "1FR\nREMAINDER"))) + (ebox-test-box (ebox-test-text "FIXED\n30 PX")) + (ebox-test-box (ebox-test-text "AUTO\nINTRINSIC")) + (ebox-test-box (ebox-test-text "1FR\nREMAINDER")))) (lines (ebox-string-lines (ebox-render node))) (widths (mapcar #'ebox--string-pixel-width lines))) (should (equal widths (make-list (length widths) 120))))) (ert-deftest ebox-grid-keeps-padded-items-inside-column-width () "Stack padding should use the widest Grid line instead of its first line." - (let* ((header (ebox-test-box :content "Header" :width '(720))) + (let* ((header (ebox-test-box (ebox-test-text "Header") :width '(720))) (grid (ebox-test-grid :width '(718) :grid-template-columns '((220) 1fr) :gap '(1 (12)) :border "#C97252" - (ebox-test-box :content "Fixed" :width '(196) + (ebox-test-box (ebox-test-text "Fixed") :width '(196) :padding '(1 (12))) - (ebox-test-box :content "Fractional" :width '(460) + (ebox-test-box (ebox-test-text "Fractional") :width '(460) :padding '(1 (12))))) (lines (ebox-string-lines (ebox-render (ebox-test-column header grid)))) (widths (mapcar #'ebox--string-pixel-width lines))) @@ -183,9 +183,9 @@ (ebox-test-grid :grid-template-columns '((20) (20)) :grid-template-rows '(1 1) - (ebox-test-box :content "Header" :grid-column '(1 :span 2)) - (ebox-test-box :content "Left" :grid-row 2 :grid-column 1) - (ebox-test-box :content "Right" :grid-row 2 :grid-column 2))))) + (ebox-test-box (ebox-test-text "Header") :grid-column '(1 :span 2)) + (ebox-test-box (ebox-test-text "Left") :grid-row 2 :grid-column 1) + (ebox-test-box (ebox-test-text "Right") :grid-row 2 :grid-column 2))))) (should (string-match-p "Header" plain)) (should (string-match-p "Left.*Right" plain)))) @@ -195,8 +195,8 @@ :grid-template-columns '((20)) :grid-auto-columns '((30)) :grid-auto-flow 'column - (ebox-test-box :content "A") - (ebox-test-box :content "B"))) + (ebox-test-box (ebox-test-text "A")) + (ebox-test-box (ebox-test-text "B")))) (line (car (ebox-string-lines (ebox-render node))))) (should (= (ebox--string-pixel-width line) 50)))) @@ -206,12 +206,12 @@ (ebox-grid-test--plain (ebox-test-grid :grid-template-columns '((20) (20) (20)) - (ebox-test-box :content "A") - (ebox-test-box :content "B") - (ebox-test-box :content "C") - (ebox-test-box :content "D") - (ebox-test-box :content "E") - (ebox-test-box :content "F"))))) + (ebox-test-box (ebox-test-text "A")) + (ebox-test-box (ebox-test-text "B")) + (ebox-test-box (ebox-test-text "C")) + (ebox-test-box (ebox-test-text "D")) + (ebox-test-box (ebox-test-text "E")) + (ebox-test-box (ebox-test-text "F")))))) (dolist (letter '("A" "B" "C" "D" "E" "F")) (should (string-match-p letter plain))))) @@ -221,8 +221,8 @@ :width '(100) :justify-content 'center :grid-template-columns '((minmax (20) (30)) (repeat 1 (20))) - (ebox-test-box :content "A") - (ebox-test-box :content "B"))) + (ebox-test-box (ebox-test-text "A")) + (ebox-test-box (ebox-test-text "B")))) (line (car (ebox-string-lines (ebox-render node))))) (should (= (ebox--string-pixel-width line) 100)) (should (= (length (seq-filter (lambda (char) (= char ?\s)) line)) @@ -232,15 +232,15 @@ "Invalid flow and non-positive spans should be rejected." (should-error (ebox-render - (ebox-test-grid :grid-auto-flow 'diagonal (ebox-test-box :content "A")))) + (ebox-test-grid :grid-auto-flow 'diagonal (ebox-test-box (ebox-test-text "A"))))) (should-error (ebox-render (ebox-test-grid - (ebox-test-box :content "A" :grid-column-span 0)))) + (ebox-test-box (ebox-test-text "A") :grid-column-span 0)))) (should-error (ebox-render (ebox-test-grid - (ebox-test-box :content "A" :grid-column 0))))) + (ebox-test-box (ebox-test-text "A") :grid-column 0))))) (ert-deftest ebox-grid-rejects-invalid-ecss-placement-before-layout () "ECSS rules should reject invalid Grid placement before layout runs." @@ -256,8 +256,8 @@ (ert-deftest ebox-grid-region-update-reflows-grid-child () "Replacing one typed Grid child should reflow and preserve its sibling." - (let* ((left (ebox-test-box :content "A" :host-ref 'left :width 20)) - (right (ebox-test-box :content "B" :id "right" :width 20)) + (let* ((left (ebox-test-box (ebox-test-text "A") :source-identity 'left :width 20)) + (right (ebox-test-box (ebox-test-text "B") :id "right" :width 20)) (node (ebox-test-grid :grid-template-columns '((20) (20)) left right)) (buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-grid-update*") node))) @@ -269,7 +269,7 @@ (let ((candidate (ebox-candidate-begin buffer))) (ebox-candidate-replace-host-ref candidate 'left - (ebox-test-box :content "Updated" :host-ref 'left :width 20)) + (ebox-test-box (ebox-test-text "Updated") :source-identity 'left :width 20)) (ebox-commit buffer candidate)) (let ((updated (with-current-buffer buffer (buffer-string)))) (should (string-match-p "Updated" updated)) @@ -279,25 +279,28 @@ (ert-deftest ebox-build-compiles-grid-layout () "The public Ebox DSL should compile a grid node." - (let ((node - (ebox-build - '(grid :grid-template-columns ((20) (20)) - (box "A") - (box "B"))))) + (let* ((input + (ebox-build + '(grid :grid-template-columns ((20) (20)) + (box "A") + (box "B")))) + (node (ebox-test-root input))) (should (ebox-box-node-p node)) (should (eq (ebox-layout-config-kind (ebox-box-node-layout node)) 'grid)) - (should (string-match-p "A" (ebox-grid-test--plain node))) - (should (string-match-p "B" (ebox-grid-test--plain node))))) + (should (string-match-p "A" (ebox-grid-test--plain input))) + (should (string-match-p "B" (ebox-grid-test--plain input))))) (ert-deftest ebox-build-compiles-direct-grid-placement () "The Ebox DSL should preserve direct child Box placement metadata." - (let ((node - (ebox-build - '(grid :grid-template-columns ((20) (20)) - (box :grid-column (1 :span 2) "Header"))))) - (should (string-match-p "Header" (ebox-grid-test--plain node))) + (let* ((input + (ebox-build + '(grid :grid-template-columns ((20) (20)) + (box :grid-column (1 :span 2) "Header")))) + (node (ebox-test-root input))) + (should (string-match-p "Header" (ebox-grid-test--plain input))) (should (equal (ebox-style-node-specified-value - (car (ebox-box-node-children node)) :grid-column) + (car (ebox-box-node-children node)) :grid-column nil + (ebox-test-source-index input)) '(1 :span 2))))) (provide 'ebox-grid-tests) diff --git a/tests/ebox-package-tests.el b/tests/ebox-package-tests.el index 02035d4..b6a46db 100644 --- a/tests/ebox-package-tests.el +++ b/tests/ebox-package-tests.el @@ -50,7 +50,7 @@ (ert-deftest ebox-facade-loads-internal-model-modules () "The public ebox facade should load all internal foundation modules." (require 'ebox) - (dolist (feature '(ebox-cache ebox-style ebox-tree ebox-measure + (dolist (feature '(ebox-cache ebox-source ebox-style ebox-tree ebox-measure ebox-layout-config ebox-node-factory ebox-fragment ebox-layout ebox-flex ebox-grid @@ -77,8 +77,16 @@ "--eval" (concat "(progn (require 'ebox-canonical) " - "(unless (ebox-text-node-p (ebox-text-create :value \"A\")) " - " (error \"Typed Text autoload failed\")))"))))))) + "(let* ((builder (ebox-source-builder-create)) " + " (handle (ebox-source-builder-bind " + " builder :declarations nil)) " + " (node (ebox-text-create " + " :value \"A\" :source-handle handle " + " :owned-facts " + " (ebox-canonical-facts-from-declarations " + " 'text nil)))) " + " (unless (ebox-text-node-p node) " + " (error \"Typed Text autoload failed\"))))"))))))) (ert-deftest ebox-facade-defines-native-workflow-without-loading-it () "The facade should define native commands without loading the subsystem." @@ -121,7 +129,8 @@ (should (commandp 'ebox-byte-compile)) (should (equal ebox--compile-sources - '("ebox-cache.el" "ebox-style.el" "ebox-layout-config.el" + '("ebox-cache.el" "ebox-source.el" "ebox-style.el" + "ebox-layout-config.el" "ebox-node-factory.el" "ebox-child-range.el" "ebox-tree.el" "ebox-measure.el" diff --git a/tests/ebox-selector-tests.el b/tests/ebox-selector-tests.el index 582811d..1386dc5 100644 --- a/tests/ebox-selector-tests.el +++ b/tests/ebox-selector-tests.el @@ -91,19 +91,25 @@ (ert-deftest ebox-selector-queries-adjacent-and-general-siblings () "Selector query should use TP sibling semantics on logical children." (ebox-selector-test--reset-runtime-state) - (let ((node (ebox-build - '(column - (box :id first "A") - (box :id second :class later "B") - (box :id third :class later "C"))))) + (let* ((node (ebox-build + '(column + (box :id first "A") + (box :id second :class later "B") + (box :id third :class later "C")))) + (root (ebox-canonical-input--single-root node "selector test")) + (source-index + (ebox-tree-source-index + root nil nil (ebox-canonical-input--source-index node)))) (should (equal (mapcar (lambda (match) - (ebox-tree-node-id (plist-get match :node))) + (ebox-tree-node-id + source-index (plist-get match :node))) (ebox-selector-query-all node "#first + #second")) '("second"))) (should (equal (mapcar (lambda (match) - (ebox-tree-node-id (plist-get match :node))) + (ebox-tree-node-id + source-index (plist-get match :node))) (ebox-selector-query-all node "#first ~ .later")) '("second" "third"))))) @@ -126,11 +132,18 @@ (ert-deftest ebox-selector-subject-exposes-only-explicit-metadata () "Selector adaptation should not serialize Ebox runtime containers." (let* ((opaque (vector (make-hash-table :test #'eq))) - (node (list :ebox-type 'box :id 'card :class '(panel active) - :selector-state '(focused) :state opaque - :content "visible" :width 12 :render-cache opaque - :selector-attributes '((role . button) (rank . 3)))) - (subject (ebox-tree-node-subject node)) + (input (ebox-test-box + :id 'card :class '(panel active) + :selector-state '(focused) + (ebox-test-text "visible") :width 12 + :selector-attributes '((role . button) (rank . 3)))) + (node (ebox-test-root input)) + (_state (plist-put node :state opaque)) + (_cache (plist-put node :render-cache opaque)) + (source-index + (ebox-tree-source-index + node nil nil (ebox-test-source-index input))) + (subject (ebox-tree-node-subject source-index node)) (attributes (ecss-subject-attributes subject))) (should (equal (ecss-subject-classes subject) '("panel" "active"))) (should (equal (ecss-subject-states subject) '("focused"))) @@ -145,7 +158,7 @@ (ert-deftest ebox-selector-queries-explicit-attributes () "Attribute queries should read only explicit selector metadata." (let ((node (ebox-test-box - :content "Run" + (ebox-test-text "Run") :selector-attributes '((role . button))))) (should (= (length (ebox-selector-query-all node "[role=button]")) 1)) (should-not (ebox-selector-query-all node "[content=Run]")))) diff --git a/tests/ebox-source-tests.el b/tests/ebox-source-tests.el new file mode 100644 index 0000000..1307bc3 --- /dev/null +++ b/tests/ebox-source-tests.el @@ -0,0 +1,403 @@ +;;; ebox-source-tests.el --- Source ownership contracts -*- lexical-binding: t; -*- + +;;; Code: + +(require 'ert) +(require 'ebox) + +(ert-deftest ebox-source-index-is-the-sole-source-fact-owner () + "Source construction and public reads cannot mutate an immutable index." + (let* ((class (list (copy-sequence "row"))) + (declaration-value (vector (copy-sequence "red"))) + (declarations (list 'ebox/color declaration-value)) + (provenance-table (make-hash-table :test #'eq)) + (_ (puthash 'origin (copy-sequence "fixture") provenance-table)) + (builder (ebox-source-builder-create)) + (first + (ebox-source-builder-bind + builder + :key 'row-1 :id "first" :class class + :selector-attributes '((role . button)) + :selector-state '(focused) + :declarations declarations + :provenance (list :table provenance-table))) + (second + (ebox-source-builder-bind + builder + :key 'row-1 :id "first" :class class + :declarations declarations)) + (builder-records (ebox-source--builder-records builder)) + (builder-handles (ebox-source--builder-handle-records builder)) + (index (ebox-source-builder-finish builder))) + (should-not (eq first second)) + (should-not (eq (ebox-source-handle-id first) + (ebox-source-handle-id second))) + (setcar class "damaged") + (aset (aref declaration-value 0) 0 ?X) + (puthash 'origin "damaged" provenance-table) + (clrhash builder-records) + (clrhash builder-handles) + (should (ebox-source-index-handle-member-p index first)) + (should (ebox-source-index-record index first)) + (should-not (ebox-source--builder-records builder)) + (should-not (ebox-source--builder-handle-records builder)) + (let ((record (ebox-source-index-record index first))) + (should (equal '("row") (ebox-source-record-classes record))) + (should (equal ["red"] + (plist-get (ebox-source-record-declarations record) + 'ebox/color))) + (should (equal "fixture" + (gethash + 'origin + (plist-get (ebox-source-record-provenance record) + :table)))) + (setf (ebox-source-record-id record) "damaged") + (setcar (ebox-source-record-classes record) "damaged") + (aset (plist-get (ebox-source-record-declarations record) 'ebox/color) + 0 "damaged")) + (let ((fresh (ebox-source-index-record index first))) + (should (equal "first" (ebox-source-record-id fresh))) + (should (equal '("row") (ebox-source-record-classes fresh))) + (should (equal ["red"] + (plist-get (ebox-source-record-declarations fresh) + 'ebox/color)))) + (let ((handles (ebox-source-index-handles index))) + (setcar handles second) + (should (equal (list first second) + (ebox-source-index-handles index)))) + (let ((duplicate-builder (ebox-source-builder-create))) + (ebox-source-builder-bind duplicate-builder :identity 'same) + (should-error + (ebox-source-builder-bind duplicate-builder :identity 'same) + :type 'error)) + (should-error (ebox-source-builder-bind builder :identity 'sealed) + :type 'error) + (let* ((private (ebox-node-factory--create-from-properties + :bgcolor "red")) + (empty-builder (ebox-source-builder-create)) + (empty-index (ebox-source-builder-finish empty-builder))) + (should-not (plist-member private :ebox-style-declarations)) + (should-not (ebox-style-node-declarations empty-index private))))) + +(ert-deftest ebox-source-rebind-keeps-order-and-bounded-persistence () + "Long-lived local updates must preserve order without growing recursion." + (let* ((builder (ebox-source-builder-create)) + (first (ebox-source-builder-bind builder :identity 'a)) + (middle (ebox-source-builder-bind builder :identity 'b)) + (last (ebox-source-builder-bind builder :identity 'c)) + (index (ebox-source-builder-finish builder)) + durations) + (let ((gc-cons-threshold most-positive-fixnum) + (gc-cons-percentage 1.0)) + (dotimes (revision 2000) + (let ((started (float-time))) + (pcase-let ((`(,next-index . ,next-handle) + (ebox-source-index-rebind + index middle + :declarations + (list 'ebox/color (number-to-string revision))))) + (setq index next-index + middle next-handle)) + (push (* 1000.0 (- (float-time) started)) durations)))) + (let ((handles (ebox-source-index-handles index))) + (should (= (length handles) 3)) + (should (eq first (car handles))) + (should (eq middle (cadr handles))) + (should (eq last (caddr handles))) + (should (equal '(a b c) (mapcar #'ebox-source-handle-id handles)))) + (should (< (ebox-source-table-depth + (ebox-source--index-records index)) + ebox-source--persistent-max-depth)) + (should (< (ebox-source-table-depth + (ebox-source--index-handle-records index)) + ebox-source--persistent-max-depth)) + (should (< (ebox-source-order-depth (ebox-source--index-order index)) + ebox-source--persistent-max-depth)) + (let* ((ordered (sort durations #'<)) + (p95 (nth (1- (ceiling (* 0.95 (length ordered)))) ordered)) + (maximum (car (last ordered)))) + (should (< p95 50.0)) + (should (< maximum 50.0))))) + +(ert-deftest ebox-source-persistent-lookups-compress-inherited-paths () + "An immutable overlay should not repeatedly traverse its retained history." + (let* ((base-values (make-hash-table :test #'equal)) + (_ (puthash 'retained 'value base-values)) + (table (ebox-source--flat-table base-values #'equal))) + (dotimes (revision 12) + (let ((added (make-hash-table :test #'equal)) + (removed (make-hash-table :test #'equal))) + (puthash (list 'revision revision) revision added) + (setq table + (ebox-source--table-overlay + table added removed (1+ (ebox-source-table-count table)))))) + (should (eq 'value + (ebox-source--table-get table 'retained 'missing))) + (should (eq 'value + (gethash 'retained (ebox-source-table-memo table)))) + (should (eq 'missing + (ebox-source--table-get table 'absent 'missing))) + (should (eq ebox-source--memo-missing + (gethash 'absent (ebox-source-table-memo table)))))) + +(ert-deftest ebox-source-delta-resolves-each-node-record-once () + "Node-local source diff should materialize one record view per generation." + (let* ((old-input (ebox-build '(box :id "before" "Same"))) + (new-input (ebox-build '(box :id "after" "Same"))) + (old-node + (ebox-canonical-input--single-root old-input "source delta test")) + (new-node + (ebox-canonical-input--single-root new-input "source delta test")) + (old-index (ebox-canonical-input--source-index old-input)) + (new-index (ebox-canonical-input--source-index new-input)) + (original (symbol-function 'ebox-source--index-record)) + (lookups 0) + changed) + (cl-letf (((symbol-function 'ebox-source--index-record) + (lambda (index handle) + (cl-incf lookups) + (funcall original index handle)))) + (setq changed + (ebox-incremental--candidate-local-changed-keys + old-node old-index new-node new-index))) + (should (= lookups 2)) + (should (equal changed '(:id))))) + +(ert-deftest ebox-build-produces-handle-only-canonical-nodes () + "The DSL should place author source facts behind opaque handles only." + (let* ((input + (ebox-build + '(column :id "root" :class "shelf" + (box :key row-1 :class "row" "One") + (box :key row-2 :class "row" "Two")))) + (root (ebox-canonical-input--single-root input "source test")) + (base-index (ebox-canonical-input--source-index input)) + nodes handles) + (cl-labels + ((visit (node) + (push node nodes) + (push (ebox-node-source-handle node) handles) + (when (ebox-box-node-p node) + (mapc #'visit (ebox-box-node-children node))))) + (visit root)) + (setq nodes (nreverse nodes) + handles (nreverse handles)) + (dolist (node nodes) + (should (ebox-source-handle-p (ebox-node-source-handle node))) + (should-not (fboundp 'ebox-source--handle-record)) + (dolist (field '(:key :id :class :ebox-style-declarations)) + (should-not (plist-member node field)))) + (let* ((index (ebox-tree-source-index root nil nil base-index)) + (root-record (ebox-source-index-record index (car handles))) + (first-box (nth 1 nodes)) + (first-record + (ebox-source-index-record + index (ebox-node-source-handle first-box)))) + (should (equal "root" (ebox-source-record-id root-record))) + (should (equal '("shelf") + (ebox-source-record-classes root-record))) + (should (eq 'row-1 (ebox-source-record-key first-record))) + (should (equal '("row") + (ebox-source-record-classes first-record)))) + (let* ((source-index (ebox-tree-source-index root nil nil base-index)) + (copy (copy-sequence root))) + (should (eq (ebox-tree-node-subject source-index root) + (ebox-tree-node-subject source-index copy)))) + (should-error (ebox-render root) :type 'wrong-type-argument) + (should (string-match-p "One" (ebox-render input))) + (should (string-match-p "Two" (ebox-render input))))) + +(ert-deftest ebox-canonical-input-owns-root-ref-and-full-equivalence () + "Root addressing and no-op comparison must include immutable source facts." + (cl-labels + ((input + (color &optional identity) + (let* ((builder (ebox-source-builder-create)) + (declarations + (ebox-style-compile-form 'text (list :color color))) + (handle + (ebox-source-builder-bind + builder :identity (or identity '(host root)) + :declarations declarations + :provenance '(:adapter source-test))) + (node + (ebox-text-create + :value "Same" + :owned-facts + (ebox-canonical-facts-from-declarations 'text declarations) + :source-handle handle))) + (ebox-canonical-input-create + (list node) (ebox-source-builder-finish builder))))) + (let* ((first (input "red")) + (same (input "red")) + (changed (input "blue")) + (root-ref (ebox-canonical-input-root-host-ref first))) + (should (equal '(host root) root-ref)) + (setcar root-ref 'damaged) + (should (equal '(host root) + (ebox-canonical-input-root-host-ref first))) + (should (ebox-canonical-input-equal-p first same)) + (should-not (ebox-canonical-input-equal-p first changed)) + (let ((foreign-builder (ebox-source-builder-create))) + (should-error + (ebox-canonical-input-create + (copy-sequence (ebox-canonical-input--nodes first)) + (ebox-source-builder-finish foreign-builder)) + :type 'error)) + (let* ((left (input "red" '(host left))) + (right (input "red" '(host right))) + (reverse-builder (ebox-source-builder-create))) + (ebox-source-builder-import + reverse-builder (ebox-canonical-input--source-index right)) + (ebox-source-builder-import + reverse-builder (ebox-canonical-input--source-index left)) + (let* ((combined + (ebox-canonical-input-create + (append (copy-sequence (ebox-canonical-input--nodes left)) + (copy-sequence (ebox-canonical-input--nodes right))) + (ebox-source-builder-finish reverse-builder))) + (ordered + (ebox-source-index-handles + (ebox-canonical-input--source-index combined)))) + (should + (equal '((host left) (host right)) + (mapcar #'ebox-source-handle-id ordered)))))))) + +(ert-deftest ebox-canonical-input-selectively-imports-retained-roots () + "Retained roots should adopt only their exact immutable source subtrees." + (let* ((builder (ebox-source-builder-create)) + (left-box-handle + (ebox-source-builder-bind builder :identity '(box left))) + (left-text-handle + (ebox-source-builder-bind builder :identity '(text left))) + (right-box-handle + (ebox-source-builder-bind builder :identity '(box right))) + (right-text-handle + (ebox-source-builder-bind builder :identity '(text right))) + (text-facts (ebox-canonical-facts-from-declarations 'text nil)) + (box-facts (ebox-canonical-facts-from-declarations 'box nil)) + (left-text + (ebox-text-create + :value "Left" :source-handle left-text-handle + :owned-facts text-facts)) + (right-text + (ebox-text-create + :value "Right" :source-handle right-text-handle + :owned-facts text-facts)) + (left + (ebox-box-create + :layout (ebox-normal-layout-create) :children (list left-text) + :source-handle left-box-handle :owned-facts box-facts)) + (right + (ebox-box-create + :layout (ebox-normal-layout-create) :children (list right-text) + :source-handle right-box-handle :owned-facts box-facts)) + (input + (ebox-canonical-input-create + (list left right) (ebox-source-builder-finish builder))) + (selected-builder (ebox-source-builder-create))) + (should (equal (list left right) (ebox-canonical-input-roots input))) + (should + (equal (list right) + (ebox-canonical-input-import-roots + input (list right) selected-builder))) + (let* ((selected + (ebox-canonical-input-create + (list right) + (ebox-source-builder-finish selected-builder))) + (index (ebox-canonical-input--source-index selected))) + (should-not + (ebox-source-index-handle-member-p index left-box-handle)) + (should-not + (ebox-source-index-handle-member-p index left-text-handle)) + (should (ebox-source-index-handle-member-p index right-box-handle)) + (should (ebox-source-index-handle-member-p index right-text-handle)) + (should + (equal '((box right) (text right)) + (mapcar + #'ebox-source-handle-id + (ebox-source-index-handles index))))) + (should-error + (ebox-canonical-input-import-roots + input (list right right) (ebox-source-builder-create)) + :type 'error))) + +(ert-deftest ebox-selector-and-reconcile-read-only-source-index () + "Selectors and keyed reconciliation should consume generation source indexes." + (let ((buffer (generate-new-buffer " *ebox-source-index-cutover*"))) + (unwind-protect + (progn + (ebox-render-to-buffer + buffer + (ebox-build + '(column :id root :class list + (box :key a :id first :class row "A") + (box :key b :id second :class row "B")))) + (let* ((old-state (ebox--buffer-render-state buffer)) + (old-index (plist-get old-state :source-index)) + (old-first (car (ebox-selector-query-buffer buffer "#first"))) + (old-id (plist-get old-first :node-id)) + (old-handle + (ebox-node-source-handle (plist-get old-first :node)))) + (ebox-commit + buffer + (ebox-build + '(column :id root :class list + (box :key b :id second :class row "B") + (box :key a :id first :class (row selected) "A2")))) + (let* ((state (ebox--buffer-render-state buffer)) + (index (plist-get state :source-index)) + (first (car (ebox-selector-query-buffer buffer "#first"))) + (selected + (car (ebox-selector-query-buffer buffer ".selected")))) + (should-not (eq index old-index)) + (should (= old-id (plist-get first :node-id))) + (should (= old-id (plist-get selected :node-id))) + (should + (equal + '(b a) + (delq + nil + (mapcar + (lambda (handle) + (ebox-source-record-key + (ebox-source-index-record index handle))) + (ebox-source-index-handles index))))) + (should (equal '("row") + (ebox-source-record-classes + (ebox-source-index-record + old-index old-handle)))) + (maphash + (lambda (_node-id node) + (when (ebox-node-kind node) + (should (ebox-source-index-record + index (ebox-node-source-handle node))) + (dolist (field + '(:key :id :class :host-ref + :ebox-style-declarations)) + (should-not (plist-member node field))))) + (plist-get state :node-table)) + (let ((records (ebox-source--index-records index)) + (subjects (ebox-source--index-subjects index)) + (node-subjects (ebox-source--index-node-subjects index))) + (ebox-selector-update-buffer + buffer "#first" :color "#123456") + (let* ((next-index + (plist-get (ebox--buffer-render-state buffer) + :source-index)) + (next-records + (ebox-source--index-records next-index)) + (next-subjects + (ebox-source--index-subjects next-index)) + (next-node-subjects + (ebox-source--index-node-subjects next-index))) + (should (eq records (ebox-source-table-base next-records))) + (should (eq subjects next-subjects)) + (should + (eq node-subjects + (ebox-source-table-base next-node-subjects)))))))) + (when (buffer-live-p buffer) + (kill-buffer buffer))))) + +(provide 'ebox-source-tests) +;;; ebox-source-tests.el ends here diff --git a/tests/ebox-surface-tests.el b/tests/ebox-surface-tests.el index 7dc81cf..63cafb4 100644 --- a/tests/ebox-surface-tests.el +++ b/tests/ebox-surface-tests.el @@ -24,6 +24,17 @@ ebox--scroll-global-state)) (clrhash table))) +(defun ebox-surface-test--producer (input &optional previous-state) + "Return an internal surface producer for canonical INPUT." + (ebox-surface-producer + (ebox-test-root input) previous-state nil + (list :source-base-index (ebox-test-source-index input)))) + +(defun ebox-surface-test--render-runtime (state) + "Render retained runtime STATE through Ebox's private node boundary." + (ebox--render-node + (plist-get state :root-node) (plist-get state :source-index))) + (defun ebox-surface-test--interactive-content () "Return fresh interactive propertized content for projection tests." (let ((map (make-sparse-keymap))) @@ -327,7 +338,7 @@ candidate cannot hide mutations by restoring the old hash-table pointer." (list (cons 'box (lambda () - (ebox-test-box :key 'box :content (ebox-surface-test--interactive-content) + (ebox-test-box :key 'box (ebox-test-text (ebox-surface-test--interactive-content)) :width '(120) :padding '(1 (4)) :border "#334155" :bgcolor "#E2E8F0" :color "#0F172A"))) @@ -335,22 +346,22 @@ candidate cannot hide mutations by restoring the old hash-table pointer." (lambda () (ebox-test-column (ebox-test-row - (ebox-test-box :key 'left :content "Left" :width '(70) + (ebox-test-box :key 'left (ebox-test-text "Left") :width '(70) :bgcolor "#DBEAFE" :color "#172554") - (ebox-test-box :key 'right :content "Right\nDetail" :width '(90) + (ebox-test-box :key 'right (ebox-test-text "Right\nDetail") :width '(90) :bgcolor "#DCFCE7" :color "#14532D")) - (ebox-test-box :key 'footer :content "Footer" :width '(160) + (ebox-test-box :key 'footer (ebox-test-text "Footer") :width '(160) :bgcolor "#F1F5F9" :color "#0F172A")))) (cons 'flex (lambda () (ebox-test-flex :width '(210) :flex-wrap 'wrap :column-gap '(10) (ebox-test-flex-item - (ebox-test-box :key 'grow :content "Grow" :width '(80) + (ebox-test-box :key 'grow (ebox-test-text "Grow") :width '(80) :bgcolor "#EDE9FE" :color "#2E1065") :flex-grow 1 :flex-basis '(80)) (ebox-test-flex-item - (ebox-test-box :key 'fixed :content "Fixed" :width '(120) + (ebox-test-box :key 'fixed (ebox-test-text "Fixed") :width '(120) :bgcolor "#FFEDD5" :color "#7C2D12"))))) (cons 'grid (lambda () @@ -358,13 +369,13 @@ candidate cannot hide mutations by restoring the old hash-table pointer." :width '(220) :grid-template-columns '((70) 1fr) :grid-template-rows '(2) :gap '(1 (8)) :border "#475569" - (ebox-test-box :key 'grid-left :content "A\nAA" + (ebox-test-box :key 'grid-left (ebox-test-text "A\nAA") :bgcolor "#E0F2FE" :color "#0C4A6E") - (ebox-test-box :key 'grid-right :content "B\nBB" + (ebox-test-box :key 'grid-right (ebox-test-text "B\nBB") :bgcolor "#FCE7F3" :color "#831843")))) (cons 'overflow-scroll (lambda () - (ebox-test-box :key 'scroll :content "zero\none\ntwo\nthree" + (ebox-test-box :key 'scroll (ebox-test-text "zero\none\ntwo\nthree") :width '(100) :height 2 :overflow 'scroll :bgcolor "#1E293B" :color "#F8FAFC"))))) @@ -373,11 +384,11 @@ candidate cannot hide mutations by restoring the old hash-table pointer." (ebox-surface-test--reset-render-state) (let ((ebox-viewport-width 240) (ebox-viewport-height 12) - (node (funcall builder))) + (input (funcall builder))) (if projector-p (tp-surface-materialize-string - (ebox-surface-producer node)) - (ebox-render node)))) + (ebox-surface-test--producer input)) + (ebox-render input)))) (defun ebox-surface-test--project-row-column () "Project the row/column fixture through the pure TP materializer." @@ -588,12 +599,12 @@ candidate cannot hide mutations by restoring the old hash-table pointer." "Serialize and optionally render the Ebox default foreground reset." (let* ((ebox-viewport-width 80) (ebox-viewport-height 20) - (node + (input (ebox-test-box :width 20 - :ebox-content-node - (ebox-test--text - "native" '(:color ebox/default-foreground)))) + (ebox-test-text + "native" :color 'ebox/default-foreground))) + (node (ebox-test-root input)) (package (ebox-native-reflow--compile-layout-package node)) (document (plist-get package :document)) (styles (plist-get document :styles)) @@ -623,15 +634,16 @@ candidate cannot hide mutations by restoring the old hash-table pointer." :fragment-span-template))) (should (equal-including-properties (plist-get frame :rendered) - (ebox-render node))))))) + (ebox-render input))))))) (ert-deftest ebox-native-canonical-text-matches-elisp-render () "Canonical Text IR must preserve Text output and paint properties exactly." (let* ((ebox-viewport-width 80) (ebox-viewport-height 20) - (text (ebox-text-create :value "native" :font-weight 'bold)) - (node (ebox-box-create :layout (ebox-normal-layout-create) - :children (list text))) + (text-input (ebox-test-text "native" :font-weight 'bold)) + (text (ebox-test-root text-input)) + (input (ebox-test-box text-input)) + (node (ebox-test-root input)) (package (ebox-native-reflow--compile-layout-package node)) (root (plist-get (plist-get package :document) :root))) (should (eq (plist-get (plist-get package :document) :version) 2)) @@ -665,7 +677,7 @@ candidate cannot hide mutations by restoring the old hash-table pointer." (let* ((ebox-viewport-width 80) (ebox-viewport-height 20) (content (propertize "native" 'face 'italic 'help-echo "source")) - (node (ebox-test-box :content content :width 20 + (node (ebox-test-box (ebox-test-text content) :width 20 :color "#f0f0f0" :bgcolor "#101010")) (package (ebox-native-reflow--compile-layout-package node)) (normal @@ -809,28 +821,34 @@ candidate cannot hide mutations by restoring the old hash-table pointer." (lambda () (ebox-test-box :font-height 1.25 - :ebox-content-node - (ebox-test-box :content "Inherited"))) + (ebox-test-box (ebox-test-text "Inherited")))) t)) (should (> calls 0)))) (ert-deftest ebox-surface-inline-inheritance-crosses-transparent-layout () "Transparent layout nodes do not consume inherited paint themselves." - (let ((closed + (let* ((closed-input (ebox-test-box :color "red" - :ebox-content-node (ebox-test-column (ebox-test-box - :ebox-content-node - (ebox-test--text "child" '(:color "blue")))))) - (open + (ebox-test-text "child" :color "blue"))))) + (closed (ebox-test-root closed-input)) + (open-input (ebox-test-box :color "red" - :ebox-content-node - (ebox-test-column (ebox-test-box :content "child"))))) - (should-not (ebox-surface--inline-inheritance-required-p closed)) - (should (ebox-surface--inline-inheritance-required-p open)))) + (ebox-test-column (ebox-test-box (ebox-test-text "child"))))) + (open (ebox-test-root open-input))) + (should-not + (ebox-surface--inline-inheritance-required-p + closed + (ebox-tree-source-index + closed nil nil (ebox-test-source-index closed-input)))) + (should + (ebox-surface--inline-inheritance-required-p + open + (ebox-tree-source-index + open nil nil (ebox-test-source-index open-input)))))) (ert-deftest ebox-box-content-cache-reuses-fixed-viewport-subtree () "A fixed box content viewport should reuse its exact composite layout." @@ -845,13 +863,16 @@ candidate cannot hide mutations by restoring the old hash-table pointer." (ebox--scroll-global-state (make-hash-table :test 'equal)) (ebox-viewport-width 240) (ebox-viewport-height 8) - (child + (child-input (ebox-test-column (ebox-test-box :key 'responsive-child - :content "fixed local viewport" + (ebox-test-text "fixed local viewport") :width '(viewport) :height 1))) - (wrapper (ebox-test-box :width '(160))) + (child (ebox-test-root child-input)) + (wrapper-input (ebox-test-box :width '(160))) + (wrapper (ebox-test-root wrapper-input)) + (ebox--surface-materialization-active t) (renders 0) (original (symbol-function 'ebox--render-cache-render-with-scroll-actions))) @@ -986,7 +1007,7 @@ candidate cannot hide mutations by restoring the old hash-table pointer." (setq surface (tp-surface-mount buffer - (ebox-surface-producer + (ebox-surface-test--producer (funcall (cdr (assq 'box (ebox-surface-test--fixtures))))) '(:capability content))) (let* ((state (tp-surface-client-state surface)) @@ -1006,8 +1027,9 @@ candidate cannot hide mutations by restoring the old hash-table pointer." "One source description should mount into two independent TP surfaces." (ebox-surface-test--reset-render-state) (let* ((source - (ebox-test-box :key 'shared :content "Shared" :width '(100) + (ebox-test-box :key 'shared (ebox-test-text "Shared") :width '(100) :bgcolor "#E2E8F0" :color "#0F172A")) + (source-node (ebox-test-root source)) (first-buffer (generate-new-buffer " *ebox-surface-first*")) (second-buffer (generate-new-buffer " *ebox-surface-second*")) first second) @@ -1015,11 +1037,11 @@ candidate cannot hide mutations by restoring the old hash-table pointer." (progn (setq first (tp-surface-mount - first-buffer (ebox-surface-producer source) + first-buffer (ebox-surface-test--producer source) '(:capability content))) (setq second (tp-surface-mount - second-buffer (ebox-surface-producer source) + second-buffer (ebox-surface-test--producer source) '(:capability content))) (let* ((first-state (tp-surface-client-state first)) (second-state (tp-surface-client-state second)) @@ -1028,9 +1050,9 @@ candidate cannot hide mutations by restoring the old hash-table pointer." (should-not (eq first-root second-root)) (should-not (eq (plist-get first-root :surface-object) (plist-get second-root :surface-object))) - (should-not (plist-member source :node-id)) - (should-not (plist-member source :region-id)) - (should-not (plist-member source :surface-object)))) + (should-not (plist-member source-node :node-id)) + (should-not (plist-member source-node :region-id)) + (should-not (plist-member source-node :surface-object)))) (dolist (surface (list first second)) (when (and surface (tp-surface-live-p surface)) (tp-surface-unmount surface))) @@ -1046,22 +1068,22 @@ candidate cannot hide mutations by restoring the old hash-table pointer." (unwind-protect (let* ((first-source (ebox-test-row - (ebox-test-box :key 'left :content "Left" :width '(60)) - (ebox-test-box :key 'right :content "Right" :width '(60)))) + (ebox-test-box :key 'left (ebox-test-text "Left") :width '(60)) + (ebox-test-box :key 'right (ebox-test-text "Right") :width '(60)))) (_mount (setq surface (tp-surface-mount - buffer (ebox-surface-producer first-source) + buffer (ebox-surface-test--producer first-source) '(:capability content)))) (first-state (tp-surface-client-state surface)) (left (ebox-surface-test--object-by-key first-state 'left)) (right (ebox-surface-test--object-by-key first-state 'right)) (next-source (ebox-test-row - (ebox-test-box :key 'right :content "Right!" :width '(60)) - (ebox-test-box :key 'left :content "Left!" :width '(60))))) + (ebox-test-box :key 'right (ebox-test-text "Right!") :width '(60)) + (ebox-test-box :key 'left (ebox-test-text "Left!") :width '(60))))) (tp-surface-update - surface (ebox-surface-producer next-source first-state)) + surface (ebox-surface-test--producer next-source first-state)) (let ((next-state (tp-surface-client-state surface))) (should (eq left (ebox-surface-test--object-by-key next-state 'left))) @@ -1087,9 +1109,9 @@ candidate cannot hide mutations by restoring the old hash-table pointer." (ebox-render-to-buffer buffer (ebox-test-column - (ebox-test-box :key 'target :id 'target :content "Before" + (ebox-test-box :key 'target :id 'target (ebox-test-text "Before") :width '(100)) - (ebox-test-box :key 'sibling :content "Sibling" :width '(100)))) + (ebox-test-box :key 'sibling (ebox-test-text "Sibling") :width '(100)))) (let* ((state (ebox--buffer-render-state buffer)) (contents (with-current-buffer buffer @@ -1131,20 +1153,21 @@ candidate cannot hide mutations by restoring the old hash-table pointer." (funcall original producer)))) (should (stringp (ebox-render - (ebox-test-box :content "Materialized" :width '(100)))))) + (ebox-test-box (ebox-test-text "Materialized") :width '(100)))))) (should (= calls 0)))) (ert-deftest ebox-render-is-repeatable-without-consuming-runtime-identities () "Ephemeral rendering should be exact and leave live identity counters alone." (ebox-surface-test--reset-render-state) - (let* ((source (ebox-test-box :content "Repeatable" :width '(100))) + (let* ((source (ebox-test-box (ebox-test-text "Repeatable") :width '(100))) + (source-node (ebox-test-root source)) (first (ebox-render source)) (second (ebox-render source))) (should (equal-including-properties first second)) (should (= ebox--region-id-counter 0)) (should (= ebox--runtime-node-id-counter 0)) - (should-not (plist-member source :region-id)) - (should-not (plist-member source :node-id)))) + (should-not (plist-member source-node :region-id)) + (should-not (plist-member source-node :node-id)))) (ert-deftest ebox-render-to-buffer-mounts-one-tp-surface () "The public buffer renderer should expose TP's committed client state." @@ -1155,7 +1178,7 @@ candidate cannot hide mutations by restoring the old hash-table pointer." (progn (ebox-render-to-buffer buffer - (ebox-test-box :content "Mounted" :width '(100))) + (ebox-test-box (ebox-test-text "Mounted") :width '(100))) (setq surface (with-current-buffer buffer ebox-surface--buffer-surface)) (should (tp-surface-live-p surface)) @@ -1176,7 +1199,7 @@ candidate cannot hide mutations by restoring the old hash-table pointer." (ebox-runtime-idle-reflow-cache-prewarm nil)) (ebox-render-to-buffer buffer - (ebox-test-box :content "Viewport" :width '(viewport))) + (ebox-test-box (ebox-test-text "Viewport") :width '(viewport))) (let* ((surface (with-current-buffer buffer ebox-surface--buffer-surface)) (signals @@ -1231,7 +1254,7 @@ candidate cannot hide mutations by restoring the old hash-table pointer." (ebox-test-flex-item (ebox-test-box :key (list 'viewport-reflow-item index) - :content (format "item-%02d" index) + (ebox-test-text (format "item-%02d" index)) :width '(70) :padding '(0 1) :color "#172554" @@ -1255,7 +1278,7 @@ candidate cannot hide mutations by restoring the old hash-table pointer." (expected (let ((ebox-viewport-width 320) (ebox-viewport-height 6)) - (ebox-render (plist-get state :root-node)))) + (ebox-surface-test--render-runtime state))) (actual (with-current-buffer buffer (buffer-substring (point-min) (point-max)))) @@ -1288,7 +1311,7 @@ candidate cannot hide mutations by restoring the old hash-table pointer." (ebox-render-to-buffer buffer (ebox-test-box :key 'native-viewport-root - :content "Viewport" + (ebox-test-text "Viewport") :width '(viewport))) (let* ((state (ebox--buffer-render-state buffer)) (session (list 'native-session)) @@ -1365,7 +1388,7 @@ candidate cannot hide mutations by restoring the old hash-table pointer." (ebox-render-to-buffer buffer (ebox-test-box :key 'native-viewport-rollback - :content "Viewport" + (ebox-test-text "Viewport") :width '(viewport))) (let* ((surface (with-current-buffer buffer ebox-surface--buffer-surface)) @@ -1441,28 +1464,24 @@ candidate cannot hide mutations by restoring the old hash-table pointer." (ebox-runtime-idle-prewarm nil) (ebox-runtime-idle-reflow-cache-prewarm nil)) (setq stable-source - (let ((node - (ebox-test-column - (ebox-test-box :key 'stable-leaf - :content "zero\none\ntwo" - :width '(100) - :height 1 - :overflow 'hidden - :surface-properties - '(help-echo "stable"))))) - (plist-put node :key 'stable-source) - node)) + (ebox-test-column + :key 'stable-source + (ebox-test-box :key 'stable-leaf + (ebox-test-text "zero\none\ntwo") + :width '(100) + :height 1 + :overflow 'hidden + :surface-properties + '(help-echo "stable")))) (setq growing-source - (let ((node - (ebox-test-column - (ebox-test-box :key 'growing-leaf - :content "growing" - :width '(viewport) - :height 1 - :color "#0F172A" - :bgcolor "#DBEAFE")))) - (plist-put node :key 'growing-source) - node)) + (ebox-test-column + :key 'growing-source + (ebox-test-box :key 'growing-leaf + (ebox-test-text "growing") + :width '(viewport) + :height 1 + :color "#0F172A" + :bgcolor "#DBEAFE"))) (cl-letf (((symbol-function 'ebox-native-commit-render) (lambda (&rest _) nil))) (let* ((layout @@ -1474,12 +1493,7 @@ candidate cannot hide mutations by restoring the old hash-table pointer." (ebox-test-flex-item stable-source :flex-grow 0 :flex-basis '(120)) (ebox-test-flex-item growing-source - :flex-grow 1 :flex-basis '(80)))) - (items (plist-get layout :children))) - (setq stable-source - (plist-get (car items) :node) - growing-source - (plist-get (cadr items) :node)) + :flex-grow 1 :flex-basis '(80))))) (ebox-render-to-buffer buffer layout)) (let* ((old-state (ebox--buffer-render-state buffer)) (old-root-object @@ -1501,7 +1515,7 @@ candidate cannot hide mutations by restoring the old hash-table pointer." expected (let ((ebox-viewport-width 360) (ebox-viewport-height 6)) - (ebox-render (plist-get state :root-node))) + (ebox-surface-test--render-runtime state)) actual (with-current-buffer buffer (buffer-substring (point-min) (point-max)))) @@ -1552,8 +1566,9 @@ candidate cannot hide mutations by restoring the old hash-table pointer." (ert-deftest ebox-flex-fragment-key-normalizes-only-height-independent-contexts () "Retained Flex keys ignore height only after an explicit dependency proof." - (let ((ebox--layout-fragments-table (make-hash-table :test 'equal)) - (node (ebox-test-box :content "stable" :width '(80)))) + (let* ((ebox--layout-fragments-table (make-hash-table :test 'equal)) + (input (ebox-test-box (ebox-test-text "stable") :width '(80))) + (node (ebox-test-root input))) (cl-labels ((key-at (height dependency) (let ((ebox-viewport-height height)) @@ -1567,7 +1582,8 @@ candidate cannot hide mutations by restoring the old hash-table pointer." (ert-deftest ebox-flex-fragment-key-includes-rendered-region-ownership () "Retained Flex output must not replay stale region text properties." (let* ((ebox--layout-fragments-table (make-hash-table :test 'equal)) - (source (ebox-test-box :content "stable" :width '(80))) + (source-input (ebox-test-box (ebox-test-text "stable") :width '(80))) + (source (ebox-test-root source-input)) (first-key (ebox-fragment-flex-allocation-key source 'row 80 1 'stretch 80 @@ -1613,7 +1629,7 @@ candidate cannot hide mutations by restoring the old hash-table pointer." (ebox-render-to-buffer buffer (ebox-test-box :class 'viewport-cascade - :content "Cascade" + (ebox-test-text "Cascade") :width '(viewport))) (cl-letf (((symbol-function 'ebox-surface--ensure-node-tree) (lambda (&rest args) @@ -1657,8 +1673,7 @@ candidate cannot hide mutations by restoring the old hash-table pointer." (ebox-test-box :font-height 1.25 :width '(viewport) - :ebox-content-node - (ebox-test-box :content "Inherited"))) + (ebox-test-box (ebox-test-text "Inherited")))) (cl-letf (((symbol-function 'ebox-surface--ensure-node-tree) (lambda (&rest args) (cl-incf ensured-node-count) @@ -1669,7 +1684,7 @@ candidate cannot hide mutations by restoring the old hash-table pointer." (expected (let ((ebox-viewport-width 240) (ebox-viewport-height 6)) - (ebox-render (plist-get state :root-node)))) + (ebox-surface-test--render-runtime state))) (actual (with-current-buffer buffer (buffer-substring (point-min) (point-max))))) @@ -1688,17 +1703,16 @@ candidate cannot hide mutations by restoring the old hash-table pointer." (list (cons 'nested-scroll (lambda () - (ebox-test-box :content "outer" + (ebox-test-box (ebox-test-text "outer") :width '(viewport) :height 2 :overflow 'scroll - :ebox-content-node - (ebox-test-box :content "zero\none\ntwo" + (ebox-test-box (ebox-test-text "zero\none\ntwo") :height 2 :overflow 'scroll)))) (cons 'visible-overflow (lambda () - (ebox-test-box :content "one\ntwo" + (ebox-test-box (ebox-test-text "one\ntwo") :width '(viewport) :height 1 :overflow 'visible))))) @@ -1738,7 +1752,7 @@ candidate cannot hide mutations by restoring the old hash-table pointer." (ebox-render-to-buffer buffer (ebox-test-box :key 'root-scroll - :content "zero\none\ntwo\nthree" + (ebox-test-text "zero\none\ntwo\nthree") :width '(viewport) :height '(viewport-height) :overflow 'scroll)) @@ -1752,7 +1766,7 @@ candidate cannot hide mutations by restoring the old hash-table pointer." (expected (let ((ebox-viewport-width 240) (ebox-viewport-height 2)) - (ebox-render (plist-get state :root-node)))) + (ebox-surface-test--render-runtime state))) (actual (with-current-buffer buffer (buffer-substring (point-min) (point-max))))) @@ -1783,7 +1797,7 @@ candidate cannot hide mutations by restoring the old hash-table pointer." (ebox-runtime-idle-reflow-cache-prewarm nil)) (ebox-render-to-buffer buffer - (ebox-test-box :key name :content "One\nTwo" + (ebox-test-box :key name (ebox-test-text "One\nTwo") :width (if (eq axes 'height) '(120) '(viewport)) @@ -1820,7 +1834,7 @@ candidate cannot hide mutations by restoring the old hash-table pointer." (ebox-render-to-buffer buffer (ebox-test-box :key 'rollback - :content "zero\none\ntwo\nthree" + (ebox-test-text "zero\none\ntwo\nthree") :width '(viewport) :height '(viewport-height) :overflow 'scroll)) @@ -1895,7 +1909,7 @@ candidate cannot hide mutations by restoring the old hash-table pointer." (ebox-viewport-height 2)) (ebox-render-to-buffer responsive - (ebox-test-box :content "zero\none\ntwo\nthree" + (ebox-test-box (ebox-test-text "zero\none\ntwo\nthree") :width '(viewport) :height '(viewport-height) :overflow 'scroll)) @@ -1914,7 +1928,7 @@ candidate cannot hide mutations by restoring the old hash-table pointer." (should (= (tp-signal-subscriber-count signal) 1))) (ebox-render-to-buffer static - (ebox-test-box :content "Static" :width '(100))) + (ebox-test-box (ebox-test-text "Static") :width '(100))) (setq static-signals (with-current-buffer static ebox-surface--context-signals)) (should (= (tp-signal-subscriber-count @@ -1952,7 +1966,7 @@ candidate cannot hide mutations by restoring the old hash-table pointer." (ebox-runtime-idle-reflow-cache-prewarm nil)) (ebox-render-to-buffer buffer - (ebox-test-box :content "Static" :width '(100))) + (ebox-test-box (ebox-test-text "Static") :width '(100))) (let* ((surface (with-current-buffer buffer ebox-surface--buffer-surface)) (signals @@ -1992,7 +2006,7 @@ candidate cannot hide mutations by restoring the old hash-table pointer." (ebox-runtime-idle-reflow-cache-prewarm nil)) (ebox-render-to-buffer buffer - (ebox-test-box :content "Theme" :width '(100))) + (ebox-test-box (ebox-test-text "Theme") :width '(100))) (let* ((surface (with-current-buffer buffer ebox-surface--buffer-surface)) (signals @@ -2018,6 +2032,79 @@ candidate cannot hide mutations by restoring the old hash-table pointer." (when (buffer-live-p buffer) (kill-buffer buffer))))) +(ert-deftest ebox-inline-style-reuse-projects-onto-fresh-text-node () + "A reused computed style must still materialize a fresh Text projection." + (let* ((builder (ebox-source-builder-create)) + (parent-declarations + '(ebox/color foreground-slot + ebox/background-color background-slot)) + (parent-handle + (ebox-source-builder-bind + builder :identity 'parent :declarations parent-declarations)) + (child-handle + (ebox-source-builder-bind builder :identity 'child)) + (child-facts (ebox-canonical-facts-from-declarations 'text nil)) + (old-child + (ebox-text-create + :value "Old" :source-handle child-handle + :owned-facts child-facts)) + (new-child + (ebox-text-create + :value "New" :source-handle child-handle + :owned-facts child-facts)) + (parent + (ebox-box-create + :layout (ebox-normal-layout-create) + :children (list old-child) + :source-handle parent-handle + :owned-facts + (ebox-canonical-facts-from-declarations + 'box parent-declarations))) + (input + (ebox-canonical-input-create + (list parent) (ebox-source-builder-finish builder))) + (source-index + (ebox-tree-source-index + parent nil nil (ebox-canonical-input--source-index input))) + (parent-subject (ebox-tree-node-subject source-index parent)) + (child-subject (ebox-tree-node-subject source-index old-child)) + (parent-style + (ebox-style-compute-subject + parent-subject parent-declarations)) + (child-style + (progn + (setf (ecss-subject-parent child-subject) parent-subject) + (ebox-style-compute-subject child-subject nil parent-style))) + (old-nodes (make-hash-table :test #'eql)) + (new-nodes (make-hash-table :test #'eql)) + (parents (make-hash-table :test #'eql)) + (old-state + (list :node-table old-nodes :parent-table parents + :source-index source-index)) + (candidate + (list :node-table new-nodes :parent-table parents + :source-index source-index))) + (ebox-style-apply-computed parent parent-style) + (ebox-style-apply-computed old-child child-style) + (plist-put parent :node-id 1) + (plist-put old-child :node-id 2) + (plist-put new-child :node-id 2) + (puthash 1 parent old-nodes) + (puthash 2 old-child old-nodes) + (puthash 1 parent new-nodes) + (puthash 2 new-child new-nodes) + (puthash 2 1 parents) + (should-not (plist-get new-child :color)) + (ebox-surface-prepare-inline-candidate-styles + old-state candidate '(1 2)) + (should (eq child-style (plist-get new-child :ebox-computed-style))) + (should (eq 'foreground-slot (plist-get new-child :color))) + (should (equal "New" (ebox-text-node-value new-child))) + (should (eq child-handle (ebox-node-source-handle new-child))) + (dolist (property ebox-tree--participation-keys) + (should-not (plist-member new-child property))) + (should (eq 'background-slot (plist-get parent :bgcolor))))) + (ert-deftest ebox-style-theme-delta-reuses-cascade-facts () "Copy a paint-only Theme delta without rerunning ECSS cascade computation." (let* ((subject (ecss-subject-create :type "box")) @@ -2097,7 +2184,8 @@ changes. Geometry and non-inherited computed values must remain identical." (ert-deftest ebox-tree-source-signature-ignores-derived-width-proof () "A layout-derived exact-width flag must not dirty declarative content." - (let* ((old (ebox-test-box :content "Stable" :width '(100))) + (let* ((input (ebox-test-box (ebox-test-text "Stable") :width '(100))) + (old (ebox-test-root input)) (new (copy-tree old))) (plist-put old :ebox-content-width-exact-p nil) (plist-put new :ebox-content-width-exact-p t) @@ -2216,7 +2304,7 @@ changes. Geometry and non-inherited computed values must remain identical." "The public mount path should never transfer ownership of its source tree." (ebox-surface-test--reset-render-state) (let* ((source - (ebox-test-box :key 'shared :content "Shared" :width '(100))) + (ebox-test-box :key 'shared (ebox-test-text "Shared") :width '(100))) (first (generate-new-buffer " *ebox-public-first*")) (second (generate-new-buffer " *ebox-public-second*"))) (unwind-protect @@ -2234,9 +2322,10 @@ changes. Geometry and non-inherited computed values must remain identical." (should-not (eq (plist-get (ebox--buffer-root-node first) :surface-object) (plist-get (ebox--buffer-root-node second) :surface-object))) - (should-not (plist-member source :node-id)) - (should-not (plist-member source :region-id)) - (should-not (plist-member source :surface-object))) + (let ((source-node (ebox-test-root source))) + (should-not (plist-member source-node :node-id)) + (should-not (plist-member source-node :region-id)) + (should-not (plist-member source-node :surface-object)))) (dolist (buffer (list first second)) (when (buffer-live-p buffer) (kill-buffer buffer)))))) @@ -2246,7 +2335,7 @@ changes. Geometry and non-inherited computed values must remain identical." (ebox-surface-test--reset-render-state) (let* ((source (generate-new-buffer " *ebox-context-source*")) (target (generate-new-buffer " *ebox-context-target*")) - (node (ebox-test-box :content "MMMM" :width '(100))) + (node (ebox-test-box (ebox-test-text "MMMM") :width '(100))) (seen-buffers nil) (original (symbol-function 'ebox--render-layout))) (unwind-protect @@ -2276,7 +2365,7 @@ changes. Geometry and non-inherited computed values must remain identical." (progn (ebox-render-to-buffer buffer - (ebox-test-box :key 'root :content "Before" :width '(100))) + (ebox-test-box :key 'root (ebox-test-text "Before") :width '(100))) (let* ((surface (with-current-buffer buffer ebox-surface--buffer-surface)) (revision (tp-surface-revision surface)) @@ -2284,7 +2373,7 @@ changes. Geometry and non-inherited computed values must remain identical." (setq report (ebox-commit buffer - (ebox-test-box :key 'root :content "After" + (ebox-test-box :key 'root (ebox-test-text "After") :width '(100)))) (should (= (tp-surface-revision surface) (1+ revision))) (should (plist-get report :runtime-published)) @@ -2302,7 +2391,7 @@ changes. Geometry and non-inherited computed values must remain identical." (progn (ebox-render-to-buffer buffer - (ebox-test-box :key 'root :content "Stable" :width '(100))) + (ebox-test-box :key 'root (ebox-test-text "Stable") :width '(100))) (let* ((surface (with-current-buffer buffer ebox-surface--buffer-surface)) (state (tp-surface-client-state surface)) @@ -2313,7 +2402,7 @@ changes. Geometry and non-inherited computed values must remain identical." (should-error (ebox-commit buffer - (ebox-test-box :key 'root :content "Rejected" :width '(100)) + (ebox-test-box :key 'root (ebox-test-text "Rejected") :width '(100)) (lambda (_report) (error "Reject publication")))) (should (= (tp-surface-revision surface) revision)) (should (eq (tp-surface-client-state surface) state)) @@ -2334,7 +2423,7 @@ changes. Geometry and non-inherited computed values must remain identical." (ebox-viewport-height 4)) (ebox-render-to-buffer buffer - (ebox-test-box :key 'root :content "Stable" + (ebox-test-box :key 'root (ebox-test-text "Stable") :width '(viewport))) (let* ((surface (with-current-buffer buffer ebox-surface--buffer-surface)) @@ -2351,7 +2440,7 @@ changes. Geometry and non-inherited computed values must remain identical." (should-error (ebox-surface-mount-buffer buffer - (ebox-test-box :key 'root :content "Rejected" + (ebox-test-box :key 'root (ebox-test-text "Rejected") :width '(viewport)) (ebox--update-report nil 'root-rerender) (lambda (_report) (error "Reject context publication")) @@ -2375,12 +2464,12 @@ changes. Geometry and non-inherited computed values must remain identical." (progn (ebox-render-to-buffer buffer - (ebox-test-box :key 'root :content "Stable" :width '(100))) + (ebox-test-box :key 'root (ebox-test-text "Stable") :width '(100))) (should (gethash buffer ebox--buffer-render-state-table)) (should-error (ebox-commit buffer - (ebox-test-box :key 'root :content "Rejected" :width '(100)) + (ebox-test-box :key 'root (ebox-test-text "Rejected") :width '(100)) (lambda (_report) (kill-buffer buffer) (error "Reject publication after teardown")))) @@ -2397,13 +2486,13 @@ changes. Geometry and non-inherited computed values must remain identical." (progn (ebox-render-to-buffer buffer - (ebox-test-box :key 'root :host-ref 'root - :content "Before" :width '(100))) + (ebox-test-box :key 'root :source-identity 'root + (ebox-test-text "Before") :width '(100))) (let ((candidate (ebox-candidate-begin buffer))) (ebox-candidate-replace-host-ref candidate 'root - (ebox-test-box :key 'root :host-ref 'root - :content "After" :width '(100))) + (ebox-test-box :key 'root :source-identity 'root + (ebox-test-text "After") :width '(100))) (let ((report (ebox-commit buffer candidate))) (should (equal report (ebox-buffer-update-report buffer))) (should (eq (plist-get report :constraint-source) @@ -2441,7 +2530,7 @@ changes. Geometry and non-inherited computed values must remain identical." "One logical id should resolve to distinct handles on independent surfaces." (ebox-surface-test--reset-render-state) (let* ((source - (ebox-test-box :id "status" :content "Ready" :width '(100))) + (ebox-test-box :id "status" (ebox-test-text "Ready") :width '(100))) (first (generate-new-buffer " *ebox-handle-first*")) (second (generate-new-buffer " *ebox-handle-second*"))) (unwind-protect @@ -2473,8 +2562,8 @@ changes. Geometry and non-inherited computed values must remain identical." (ebox-render-to-buffer buffer (ebox-test-column - (ebox-test-box :id "left" :content "Left" :width '(100)) - (ebox-test-box :id "right" :content "Right" :width '(100)))) + (ebox-test-box :id "left" (ebox-test-text "Left") :width '(100)) + (ebox-test-box :id "right" (ebox-test-text "Right") :width '(100)))) (let* ((surface (with-current-buffer buffer ebox-surface--buffer-surface)) (revision (tp-surface-revision surface)) @@ -2501,7 +2590,7 @@ changes. Geometry and non-inherited computed values must remain identical." (progn (ebox-render-to-buffer buffer - (ebox-test-box :id "target" :content "before target after" + (ebox-test-box :id "target" (ebox-test-text "before target after") :width '(200))) (with-current-buffer buffer (goto-char 4)) (let ((point-before (with-current-buffer buffer (point)))) @@ -2520,7 +2609,7 @@ changes. Geometry and non-inherited computed values must remain identical." (progn (ebox-render-to-buffer buffer - (ebox-test-box :id "target" :content "Before" :width '(100))) + (ebox-test-box :id "target" (ebox-test-text "Before") :width '(100))) (let* ((match (car (ebox-selector-query-buffer buffer "#target"))) (region-id (plist-get match :region-id))) (should-error (ebox-region-update region-id :content "Wrong") @@ -2541,7 +2630,7 @@ changes. Geometry and non-inherited computed values must remain identical." (progn (ebox-render-to-buffer buffer - (ebox-test-box :id "action" :content "Closed" :width '(100))) + (ebox-test-box :id "action" (ebox-test-text "Closed") :width '(100))) (let* ((match (car (ebox-selector-query-buffer buffer "#action"))) (handle (plist-get match :region-handle))) (should (ebox-region-handle-p handle)) @@ -2561,12 +2650,12 @@ changes. Geometry and non-inherited computed values must remain identical." (ebox-render-to-buffer buffer (ebox-test-box :key 'old :id "old" - :content "Old" :width '(100))) + (ebox-test-text "Old") :width '(100))) (let ((handle (ebox-region-resolve buffer "old"))) (ebox-commit buffer (ebox-test-box :key 'new :id "new" - :content "New" :width '(100))) + (ebox-test-text "New") :width '(100))) (should-error (ebox-region-update handle :content "Invalid") :type 'user-error))) (when (buffer-live-p buffer) @@ -2581,7 +2670,7 @@ changes. Geometry and non-inherited computed values must remain identical." (ebox-viewport-height 2)) (ebox-render-to-buffer buffer - (ebox-test-box :id "scroll" :content "zero\none\ntwo\nthree" + (ebox-test-box :id "scroll" (ebox-test-text "zero\none\ntwo\nthree") :width '(120) :height 2 :overflow 'scroll)) (let* ((surface (with-current-buffer buffer ebox-surface--buffer-surface)) @@ -2627,8 +2716,7 @@ changes. Geometry and non-inherited computed values must remain identical." (full-renders 0)) (ebox-render-to-buffer buffer - (ebox-test-box :id "scroll" :content - (mapconcat #'number-to-string (number-sequence 0 31) "\n") + (ebox-test-box :id "scroll" (ebox-test-text (mapconcat #'number-to-string (number-sequence 0 31) "\n")) :width '(120) :height 2 :overflow 'scroll)) (let* ((surface (with-current-buffer buffer ebox-surface--buffer-surface)) @@ -2705,7 +2793,7 @@ changes. Geometry and non-inherited computed values must remain identical." (ebox-viewport-height 2)) (ebox-render-to-buffer buffer - (ebox-test-box :id "scroll" :content "zero\none\ntwo\nthree" + (ebox-test-box :id "scroll" (ebox-test-text "zero\none\ntwo\nthree") :width '(120) :height 2 :overflow 'scroll)) (let* ((surface (with-current-buffer buffer ebox-surface--buffer-surface)) @@ -2746,13 +2834,13 @@ the staged rendered window must not rerun the Flex wrapper layout." (cl-loop for index below 80 collect (ebox-test-box :key (intern (format "flex-cell-%03d" index)) - :content (format "Cell %03d" index) + (ebox-test-text (format "Cell %03d" index)) :width '(80) :height 1))) (flex (apply #'ebox-test-flex :flex-flow '(row wrap) :width '(180) :column-gap '(8) :row-gap 1 children)) (root (ebox-test-box :key 'scroll-root :width '(180) :height 6 - :overflow 'scroll :ebox-content-node flex)) + :overflow 'scroll flex)) (buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-incomplete-flex-scroll*") root)) @@ -2791,7 +2879,7 @@ the staged rendered window must not rerun the Flex wrapper layout." (ebox-viewport-height 2)) (ebox-render-to-buffer buffer - (ebox-test-box :id "scroll" :content "zero\none\ntwo\nthree" + (ebox-test-box :id "scroll" (ebox-test-text "zero\none\ntwo\nthree") :width '(120) :height 2 :overflow 'scroll)) (let* ((surface (with-current-buffer buffer ebox-surface--buffer-surface)) @@ -2837,7 +2925,7 @@ participant and rollback contract without creating a real frame." (window-point 1)) (ebox-render-to-buffer buffer - (ebox-test-box :id "native-scroll" :content "zero\none\ntwo\nthree" + (ebox-test-box :id "native-scroll" (ebox-test-text "zero\none\ntwo\nthree") :width '(120) :height 2 :overflow 'scroll)) (let* ((state (ebox--buffer-render-state buffer)) (region-id (car (plist-get state :scroll-region-ids)))