refactor: own author facts behind opaque source handles

This commit is contained in:
Kinneyzhang 2026-08-28 16:48:09 +08:00
parent 31046ad8c9
commit cb0e1823d3
38 changed files with 7100 additions and 3800 deletions

View File

@ -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

View File

@ -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

View File

@ -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 模块

View File

@ -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. |

View File

@ -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。 |

View File

@ -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`,

View File

@ -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 @@ Eboxcontext/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 与 matcherEbox 只提供 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`、

View File

@ -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

View File

@ -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 与验证

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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)))

View File

@ -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))

File diff suppressed because it is too large Load Diff

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -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."

769
ebox-source.el Normal file
View File

@ -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

View File

@ -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)))

View File

@ -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

View File

@ -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."

296
ebox.el
View File

@ -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

View File

@ -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

View File

@ -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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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

View File

@ -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)

View File

@ -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"

View File

@ -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]"))))

403
tests/ebox-source-tests.el Normal file
View File

@ -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

View File

@ -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)))