feat: add CSS sizing and native text interaction capabilities

Normalize size units and intrinsic sizing across Elisp and native layout. Add help, pointer, hover-style and keymap support with reusable interaction adapters.

Keep content updates local, preserve scroll caches and hover borders, and avoid rebuilding retained plans and ownership metadata for stable geometry.

Validation: make check and native-rust-tests passed; targeted native interaction and scroll publication regressions passed.
This commit is contained in:
Kinneyzhang 2026-09-09 22:25:18 +08:00
parent d19305b16c
commit 79f5bc23d1
70 changed files with 9406 additions and 1634 deletions

View File

@ -4,6 +4,11 @@ This repository is the standalone low-level Ebox package. It owns box nodes, sty
Keep this repository independent from ETAF. Do not add Components, Runtime state, Data Controllers, UI controls, or application examples here. Ebox may call ECSS and TP only through their public APIs; private `ebox--*` functions remain internal to this package. Keep this repository independent from ETAF. Do not add Components, Runtime state, Data Controllers, UI controls, or application examples here. Ebox may call ECSS and TP only through their public APIs; private `ebox--*` functions remain internal to this package.
When authoring Ebox DSL, prefer the shortest declaration that preserves the intended behavior: omit redundant defaults, put shared inheritable text styles on an existing common parent, and declare only child differences. Keep explicit overrides and properties that an example is teaching. Check the actual layout context before omitting sizes; `auto` and `stretch` are not universal aliases, and background paint showing through is not property inheritance. Follow the authoring rules in `docs/user/ebox-user-guide.en.md` and verify example simplifications at multiple viewport sizes.
Use `apply_patch` for hand-edited changes. Keep each file lexically bound, document public APIs, preserve deterministic rendering, and add a focused regression test for behavior changes. Run `make check EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs` before claiming completion. Run `make native-rust-tests` when changing `native/` and run `make docs-contract-tests` when changing documentation or the active file boundary. Use `apply_patch` for hand-edited changes. Keep each file lexically bound, document public APIs, preserve deterministic rendering, and add a focused regression test for behavior changes. Run `make check EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs` before claiming completion. Run `make native-rust-tests` when changing `native/` and run `make docs-contract-tests` when changing documentation or the active file boundary.
The old `emacs-box` checkout is a historical full-stack source tree. It is intentionally not a dependency of this package and should not be edited as part of Ebox work unless a separate task explicitly targets that legacy repository. The old `emacs-box` checkout is a historical full-stack source tree. It is intentionally not a dependency of this package and should not be edited as part of Ebox work unless a separate task explicitly targets that legacy repository.
For legacy public CSS size migrations, use `python3 scripts/migrate-css-sizes.py PATH...` first, then the same invocation with `--write` for reviewed public DSL/style callers. The tool preserves comments, strings, and source formatting, and reports dynamic/ambiguous values for manual review. Do not apply it to measured engine structs merely because they use the same property names. See its `--help` for input, exit-status, and cleanup contracts; use `python3 -m unittest discover -s scripts -p 'test_migrate_css_sizes.py'` to verify the tool. Extend this entry point instead of creating ad hoc migration probes.
For mixed test/implementation files, add `--callers ebox-build,ebox-create` (or the actual public API names) to restrict migration to those call subforms. ETAF `(styles ...)` blocks are recognized as literal data; native Emacs `(space ...)` and image display specs retain their own size grammar.

View File

@ -32,7 +32,36 @@ Use the functions and properties in `docs/user/ebox-api-reference.en.md`; the fa
## Layout scope ## Layout scope
Ebox supports column- and pixel-based horizontal dimensions, line-based vertical dimensions, padding, margins, pixel borders, colors, overflow, wrapping, row/column/flex formatting, and Grid tracks including fixed, fractional, implicit, gap, placement, span, and alignment behavior. The public typography subset is `:font-family`, CSS-reference-pixel `:font-size`, `:font-weight`, and `:font-style`; `:font-slant` is only an exact parse-time alias of `:font-style`. Emacs faces and text properties belong to the final adapter and are not Ebox author properties. CSS compatibility is intentionally partial: percentages, absolute positioning, z-index, shadows, border radius, full browser typography, and browser-level bidi are outside this package. Ebox supports explicit `px`, `%`, `vw`, `vh`, `ch`, and `lh` dimensions, padding, margins, borders, colors, overflow, wrapping, row/column/flex formatting, and Grid tracks including fixed, fractional, implicit, gap, placement, span, and alignment behavior. The public typography subset is `:font-family`, CSS-reference-pixel `:font-size`, `:font-weight`, and `:font-style`; `:font-slant` is only an exact parse-time alias of `:font-style`. Emacs faces and text properties belong to the final adapter and are not Ebox author properties. CSS compatibility is intentionally partial: absolute positioning, z-index, shadows, border radius, full browser typography, and browser-level bidi are outside this package.
## Size composition
`ebox-size.el` owns pure length validation, dependency discovery, and resolution
to fractional pixels, plus a pure cumulative quantization helper used at the
display boundaries chosen by layout. Its context contains measured viewport axes, the
property's percentage reference, zero-glyph advance, and line height. It does
not read windows or nodes, evaluate arbitrary Lisp, choose property defaults,
or publish buffers. This keeps unit arithmetic reusable across normal, Flex,
Grid, spacing, and border geometry.
The author format is `(unit number)` and the composable functions are `calc`,
`min`, `max`, and `clamp`. Property schemas own allowed units, keywords, and
range rules. The [shared unit-constraint table](docs/user/ebox-user-guide.en.md#size-unit-constraints)
is the author contract: inline geometry, block geometry, and border paint have
distinct unit domains. Validation traverses every function branch and track
operand; Flex basis uses its parent main axis. Invalid unit combinations fail
during construction, before layout or display.
Layout supplies containing-block and font measurements and resolves
intrinsic/automatic sizes. `fit-content` is a bare keyword only. Removed bare
lengths, singleton pixel lists, viewport keywords, and `contain` have no
compatibility execution path.
Missing reference measurements defer resolution rather than becoming zero.
Intermediate arithmetic keeps fractions until the formatting/display boundary.
The buffer backend materializes block geometry in whole lines, matching the
restricted block-unit contract. An empty Box contributes zero automatic
content height; explicit height, padding, or borders supply any reserved extent.
## Verification rule ## Verification rule

View File

@ -32,7 +32,29 @@ Ebox 是底层空间渲染引擎。它负责把声明式节点树转换为经过
## 布局范围 ## 布局范围
Ebox 支持以列或像素表示的横向尺寸、以行表示的纵向尺寸、padding、margin、像素 border、颜色、overflow、换行、row/column/flex formatting以及固定、分数、隐式、gap、放置、span 和对齐等 Grid 能力。公共 typography 子集是 `:font-family`、以 CSS reference pixel 表示的 `:font-size`、`:font-weight` 和 `:font-style``:font-slant` 只是 `:font-style` 的精确 parse-time alias。Emacs face 与文本属性只属于最终 adapter不是 Ebox 作者属性。CSS 兼容性有意是部分实现百分比、绝对定位、z-index、阴影、border radius、完整浏览器 typography 和浏览器级 bidi 不属于本包。 Ebox 支持显式 `px`、`%`、`vw`、`vh`、`ch`、`lh` 尺寸、padding、margin、border、颜色、overflow、换行、row/column/flex formatting以及固定、分数、隐式、gap、放置、span 和对齐等 Grid 能力。公共 typography 子集是 `:font-family`、以 CSS reference pixel 表示的 `:font-size`、`:font-weight` 和 `:font-style``:font-slant` 只是 `:font-style` 的精确 parse-time alias。Emacs face 与文本属性只属于最终 adapter不是 Ebox 作者属性。CSS 兼容性有意是部分实现绝对定位、z-index、阴影、border radius、完整浏览器 typography 和浏览器级 bidi 不属于本包。
## 尺寸组合
`ebox-size.el` 负责长度验证、依赖识别、浮点像素换算,并提供累计量化的纯函数,
供布局在选定的显示边界调用。上下文显式传入视口两轴、
该属性的百分比参照、`0` 字形前进宽度和行高;模块不读取窗口或节点,不求值任意
Lisp不选择属性默认值也不发布 buffer。Normal、Flex、Grid、间距和边框几何
由此复用同一套单位运算。
作者格式为 `(单位 数值)`,可组合函数是 `calc`、`min`、`max`、`clamp`。
property schema 负责允许的单位、关键词和值域。[统一单位约束表](docs/user/ebox-user-guide.zh.md#size-unit-constraints)
是作者契约inline 几何、block 几何和边框绘制采用不同单位集合。验证递归遍历
所有函数分支和轨道参数Flex basis 使用所在父布局主轴。无效单位组合在构造阶段
就报错,不延后到布局或显示。
布局负责提供包含块及字体测量,并解析固有/自动尺寸。`fit-content` 只接受裸关键词。
旧的裸长度、单元素像素列表、视口
关键词和 `contain` 不保留兼容执行路径。
参照尺寸缺失时延后解析,不当作零;中间运算保留小数,到格式化/显示边界才量化。
buffer 后端按完整行生成 block 几何,与收窄后的纵向单位契约一致。空 Box 的自动
内容高度为零需要占位时由显式高度、padding 或 border 提供对应尺寸。
## 验证规则 ## 验证规则

View File

@ -13,9 +13,15 @@ NATIVE_RELEASE_DIR = native/target/$(NATIVE_TARGET)/release
.PHONY: all check ci load compile test checkdoc runtime-index-tests source-tests font-tests core-tests child-range-tests grid-tests ebox-commit-tests surface-tests visual-check-tests package-tests selector-tests dsl-tests flex-tests viewport-topology-tests vertical-materialization-tests box-line-flow-tests m0a-tests state-contract-tests layout-boundary-tests layout-boundary-performance patch-plan-tests patch-plan-performance style-schema-tests style-schema-performance spi-tests spi-performance c1b-contract-tests docs-contract-tests ci-contract-tests performance-evaluator visual-check native-rust-tests native-build diff-check clean package-lint package-lint-install .PHONY: all check ci load compile test checkdoc runtime-index-tests source-tests font-tests core-tests child-range-tests grid-tests ebox-commit-tests surface-tests visual-check-tests package-tests selector-tests dsl-tests flex-tests viewport-topology-tests vertical-materialization-tests box-line-flow-tests m0a-tests state-contract-tests layout-boundary-tests layout-boundary-performance patch-plan-tests patch-plan-performance style-schema-tests style-schema-performance spi-tests spi-performance c1b-contract-tests docs-contract-tests ci-contract-tests performance-evaluator visual-check native-rust-tests native-build diff-check clean package-lint package-lint-install
.PHONY: node-memo-generation-tests .PHONY: node-memo-generation-tests
.PHONY: interaction-tests
all: check all: check
interaction-tests:
$(EMACS_TEST) -l tests/ebox-interaction-tests.el -f ert-run-tests-batch-and-exit
test: interaction-tests
check: ci check: ci
ci: checkdoc load compile test visual-check diff-check ci: checkdoc load compile test visual-check diff-check
@ -27,7 +33,14 @@ compile:
rm -f *.elc tests/*.elc scripts/*.elc 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)' $(EMACS_BATCH) --eval '(setq byte-compile-error-on-warn t byte-compile-warnings (quote (not obsolete)))' -l ebox.el --eval '(ebox-byte-compile)'
test: runtime-index-tests source-tests font-tests core-tests child-range-tests grid-tests ebox-commit-tests surface-tests visual-check-tests package-tests selector-tests dsl-tests flex-tests viewport-topology-tests vertical-materialization-tests box-line-flow-tests node-memo-generation-tests m0a-tests state-contract-tests layout-boundary-tests patch-plan-tests style-schema-tests spi-tests docs-contract-tests ci-contract-tests test: size-tests runtime-index-tests source-tests font-tests core-tests child-range-tests grid-tests ebox-commit-tests surface-tests visual-check-tests package-tests selector-tests dsl-tests flex-tests viewport-topology-tests vertical-materialization-tests box-line-flow-tests node-memo-generation-tests m0a-tests state-contract-tests layout-boundary-tests patch-plan-tests style-schema-tests spi-tests docs-contract-tests ci-contract-tests
.PHONY: size-tests size-migration-tests
size-tests: size-migration-tests
$(EMACS_BATCH) -L tests -l tests/ebox-size-tests.el -l tests/ebox-size-style-tests.el -l tests/ebox-css-layout-tests.el -l tests/ebox-css-flex-grid-tests.el -l tests/ebox-css-viewport-tests.el -l tests/ebox-size-native-tests.el -l tests/ebox-zero-height-tests.el -f ert-run-tests-batch-and-exit
size-migration-tests:
python3 -m unittest discover -s scripts -p 'test_migrate_css_sizes.py'
runtime-index-tests: runtime-index-tests:
$(EMACS_BATCH) -l tests/ebox-runtime-index-tests.el -f ert-run-tests-batch-and-exit $(EMACS_BATCH) -l tests/ebox-runtime-index-tests.el -f ert-run-tests-batch-and-exit

View File

@ -33,10 +33,10 @@ field-based child syntax.
(ebox-render-to-buffer (ebox-render-to-buffer
"*Ebox Example*" "*Ebox Example*"
(ebox-build (ebox-build
'(column :padding (1 2) '(column :padding ((lh 1) (ch 2))
:border (1 solid "#8A93A6") :border ((px 1) solid "#8A93A6")
(text :color "#263244" "Hello Ebox") (text :color "#263244" "Hello Ebox")
(row :item-gap 1 (row :item-gap (ch 1)
(box "Left") (box "Left")
(box "Right"))))) (box "Right")))))
``` ```
@ -64,6 +64,40 @@ frameworks must not access private canonical fields or dynamic context.
Flex and Grid participation properties belong directly to a child `box`. They Flex and Grid participation properties belong directly to a child `box`. They
do not require a wrapper node. do not require a wrapper node.
Box geometry uses `(unit number)`: `px`, `%`, `vw`, `vh`, `ch`, and `lh`.
For example, `:width (ch 80)` and `:height (calc (- (vh 100) (lh 1)))` mean an
80-zero-glyph width and a viewport height minus one line height. `calc`, `min`,
`max`, and `clamp` compose lengths at layout time. Size keywords such as `auto`
and bare `fit-content` stay symbols. Old bare lengths, singleton pixel lists,
and viewport keywords are rejected. Units are checked against the property axis
at construction, including every branch of a size function. See the [shared
unit-constraint table](docs/user/ebox-user-guide.en.md#size-unit-constraints)
for the allowed combinations, keyword defaults, percentage references, and
vertical line quantization. An empty Box has zero automatic content height;
use `(box :height (lh 1))` to reserve a blank line.
## Native interaction
Text and every Box form accept four explicit node capabilities: `:help-echo`
for a string or native help function, `:pointer` for a native pointer shape,
`:hover-style` for restricted color and text-decoration paint, and `:keymap`
for a native Emacs keymap. Box help, pointer, and keymap cover its content,
padding, and border; they exclude that Box's margin and structural newlines.
Hover shares the declaring node's paint across text and padding, excluding
physical left/right borders. Nested explicit
values override enclosing capabilities; explicit `nil` clears one capability.
Use `ebox-help-create` to adapt a zero-argument business function to native help
with the hovered buffer as its context. Use `ebox-keymap-create` to bind
ordinary zero-argument callbacks to activation
keys or custom keys; it supplies the clicked window's buffer for mouse commands.
Use `ebox-region-update` with a semantic ID in the current mounted buffer or
an explicit region handle to replace or clear these capabilities.
Native keymaps support independent Ebox interactions; application state,
focus navigation, and command behavior remain the author's responsibility.
See the [interaction guide](docs/user/ebox-user-guide.en.md#native-node-interaction)
and the Playground's [interaction lab](../ebox-playground/README.md#native-interaction-lab).
## Render and update ## Render and update
- `ebox-render` returns propertized text without publishing a live buffer. - `ebox-render` returns propertized text without publishing a live buffer.
@ -80,8 +114,8 @@ value may be mounted in multiple buffers without sharing live ownership.
Snapshots traverse exported data and TP's latest retained diagnostic report Snapshots traverse exported data and TP's latest retained diagnostic report
only when requested; ordinary updates do not create them. only when requested; ordinary updates do not create them.
Their canonical input remains usable after later updates or unmounting. Mutable Their canonical input remains usable after later updates or unmounting. Mutable
node data is detached, while immutable source facts and opaque capabilities node data and interaction keymaps are detached, while immutable source facts
(callbacks, keymaps, records) retain identity. The display environment and and opaque capabilities (callbacks, records) retain identity. The display environment and
external capabilities are not frozen. Querying during a TP transaction or external capabilities are not frozen. Querying during a TP transaction or
after unmounting signals an error. Compare both mount ID and revision when after unmounting signals an error. Compare both mount ID and revision when
identifying a committed generation; remounting can restart revision numbers. identifying a committed generation; remounting can restart revision numbers.
@ -108,6 +142,7 @@ exact Elisp fallback. Ebox never builds it while loading.
make load EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs make load EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs
make compile EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs make compile EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs
make docs-contract-tests EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs make docs-contract-tests EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs
make interaction-tests EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs
make c1b-contract-tests EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs make c1b-contract-tests EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs
make check EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs make check EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs
make native-rust-tests make native-rust-tests

View File

@ -30,10 +30,10 @@ Ebox 需要 Emacs 29.1 或更高版本、ECSS 0.1.0 或更高版本,以及 TP
(ebox-render-to-buffer (ebox-render-to-buffer
"*Ebox Example*" "*Ebox Example*"
(ebox-build (ebox-build
'(column :padding (1 2) '(column :padding ((lh 1) (ch 2))
:border (1 solid "#8A93A6") :border ((px 1) solid "#8A93A6")
(text :color "#263244" "Hello Ebox") (text :color "#263244" "Hello Ebox")
(row :item-gap 1 (row :item-gap (ch 1)
(box "Left") (box "Left")
(box "Right"))))) (box "Right")))))
``` ```
@ -58,6 +58,32 @@ Ebox 需要 Emacs 29.1 或更高版本、ECSS 0.1.0 或更高版本,以及 TP
Flex/Grid participation property 直接属于子 `box`,不需要额外 wrapper 节点。 Flex/Grid participation property 直接属于子 `box`,不需要额外 wrapper 节点。
Box 几何统一写成 `(单位 数值)`,支持 `px`、`%`、`vw`、`vh`、`ch`、`lh`。
例如 `:width (ch 80)` 表示 80 个 `0` 字形的前进宽度,
`:height (calc (- (vh 100) (lh 1)))` 表示视口高度减一个行高。
`calc`、`min`、`max`、`clamp` 在布局时组合长度;`auto`、裸 `fit-content`
尺寸关键词仍直接写符号。旧的裸长度、单元素像素列表和视口关键词会报错。
构造时按属性方向检查单位,包括尺寸函数的所有分支。允许的组合、关键词默认值、
百分比参照及纵向行粒度限制见[统一单位约束表](docs/user/ebox-user-guide.zh.md#size-unit-constraints)。
空 Box 的自动内容高度为零;需要一行空白时写 `(box :height (lh 1))`
## 原生交互
Text 与所有 Box form 都接受四种显式节点能力:`:help-echo` 提供字符串或原生帮助
函数,`:pointer` 选择原生鼠标指针形状,`:hover-style` 设置受限的颜色与文本装饰
绘制,`:keymap` 接收原生 Emacs keymap。Box 的帮助、指针和 keymap 范围包含内容、
padding 和 border不包含该 Box 的 margin 和结构性换行符。悬停在文字与 padding
间共享声明节点的绘制,不包含物理左右边框。嵌套节点的显式值覆盖外层能力;
显式 `nil` 清除对应能力。
使用 `ebox-help-create` 将零参数业务函数适配为原生帮助,并提供悬停 buffer 上下文。
使用 `ebox-keymap-create` 将普通零参数回调绑定到激活键或自定义按键;鼠标命令会
自动在被点击窗口的 buffer 中运行。`ebox-region-update` 可以使用当前已挂载 buffer
中的语义 ID或显式 region handle替换或清除节点能力。原生 keymap 可用于
独立的 Ebox 交互;应用状态、焦点导航和命令行为由作者管理。参见
[交互指南](docs/user/ebox-user-guide.zh.md#native-node-interaction)和 Playground 的
[交互实验室](../ebox-playground/README.zh-CN.md#native-interaction-lab)。
## 渲染与更新 ## 渲染与更新
- `ebox-render` 返回带属性文本,不发布 live buffer - `ebox-render` 返回带属性文本,不发布 live buffer
@ -72,8 +98,8 @@ Ebox 会在分配 runtime identity 前复制 canonical input因此同一个 b
挂载到多个 buffer而不会共享 live ownership。 挂载到多个 buffer而不会共享 live ownership。
快照只在显式查询时遍历导出数据和 TP 最近的诊断报告,普通更新不会创建快照。其 canonical input 在后续 快照只在显式查询时遍历导出数据和 TP 最近的诊断报告,普通更新不会创建快照。其 canonical input 在后续
更新、卸载后仍可使用;节点中的可变数据独立复制,不可变 source facts 与不透明能力 更新、卸载后仍可使用;节点中的可变数据与交互 keymap 独立复制,不可变 source facts
(回调、keymap、record保留身份。快照不冻结显示环境或外部能力。TP 事务期间、 与不透明能力回调、record保留身份。快照不冻结显示环境或外部能力。TP 事务期间、
卸载后查询会报错。识别提交版本时同时比较 mount ID 与 revision避免重新挂载后 卸载后查询会报错。识别提交版本时同时比较 mount ID 与 revision避免重新挂载后
revision 从头计数导致混淆。 revision 从头计数导致混淆。
@ -97,6 +123,7 @@ fallback。Ebox 加载时不会自动构建它。
make load EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs make load EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs
make compile EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs make compile EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs
make docs-contract-tests EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs make docs-contract-tests EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs
make interaction-tests EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs
make c1b-contract-tests EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs make c1b-contract-tests EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs
make check EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs make check EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs
make native-rust-tests make native-rust-tests

View File

@ -22,6 +22,8 @@ This is the maintainer entry point for the standalone Ebox repository. It descri
| `ebox-runtime-index.el` | Compressed persistent radix maps and legacy hash-table adapters. | | `ebox-runtime-index.el` | Compressed persistent radix maps and legacy hash-table adapters. |
| `ebox-state-contract.el` | Closed retained-state ownership inventory and read-only compatibility-mirror rebuild probes. | | `ebox-state-contract.el` | Closed retained-state ownership inventory and read-only compatibility-mirror rebuild probes. |
| `ebox-style.el` | ECSS property schemas, declarations and cascade, shorthand expansion, computed style, colors, borders, and dirty effects. | | `ebox-style.el` | ECSS property schemas, declarations and cascade, shorthand expansion, computed style, colors, borders, and dirty effects. |
| `ebox-interaction.el` | Native help and keymap adapters with target-buffer context, validation and owned copies of the four explicit node capabilities, restricted hover paint compilation, and native surface-property projection with nested explicit-nil precedence. |
| `ebox-size.el` | Pure explicit-unit validation, dimensional arithmetic, dependency discovery, and fractional-pixel resolution from injected measurements. |
| `ebox-tree.el` | Node traversal, logical child access, ECSS subject adaptation, identity, parent paths, keys, and tree snapshots. | | `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. | | `ebox-child-range.el` | Immutable weighted segment trie, sparse persistent key trie, Range replacement, and Gate A metrics. |
| `ebox-measure.el` | Display-sensitive character/face/pixel measurement and measurement caches. | | `ebox-measure.el` | Display-sensitive character/face/pixel measurement and measurement caches. |
@ -45,6 +47,20 @@ The active contract also covers `Makefile`, `.github/workflows/ci.yml`, `tests/e
Runtime index behavior is covered by `tests/ebox-runtime-index-tests.el`. Runtime index behavior is covered by `tests/ebox-runtime-index-tests.el`.
Native node capabilities are covered by `tests/ebox-interaction-tests.el` through
`make interaction-tests`. The interactive reference and its command regressions
live in the sibling Playground package.
CSS sizing is covered by `tests/ebox-size-tests.el`,
`tests/ebox-size-style-tests.el`, `tests/ebox-css-layout-tests.el`,
`tests/ebox-css-flex-grid-tests.el`, `tests/ebox-css-viewport-tests.el`, and
`tests/ebox-size-native-tests.el`.
`tests/ebox-zero-height-tests.el` covers empty automatic geometry and explicit
blank-line sizing through the same `make size-tests` entry point.
The supported literal migration tool is `scripts/migrate-css-sizes.py`;
`scripts/test_migrate_css_sizes.py` verifies its source-preserving rewrites and
ambiguous-input reports.
## Runtime model ## Runtime model
The normal data flow is: The normal data flow is:
@ -69,6 +85,7 @@ Caller-owned Source Tree
| Source/Element Tree | `ebox-child-range.el`, `ebox-tree.el`, `ebox-dsl.el` | Published buffer mutation. | | Source/Element Tree | `ebox-child-range.el`, `ebox-tree.el`, `ebox-dsl.el` | Published buffer mutation. |
| State Ownership Contract | `ebox-state-contract.el` | Live publication or mutable authority. | | State Ownership Contract | `ebox-state-contract.el` | Live publication or mutable authority. |
| Computed Style | `ebox-style.el` | Layout identity or patch execution. | | Computed Style | `ebox-style.el` | Layout identity or patch execution. |
| Length Values | `ebox-size.el` | Window/node reads, property defaults, choosing display boundaries, or arbitrary Lisp evaluation. |
| Measurement | `ebox-measure.el` | Application state or dirty policy. | | Measurement | `ebox-measure.el` | Application state or dirty policy. |
| Formatting Context | `ebox-layout.el`, `ebox-flex.el`, `ebox-grid.el` | Buffer edits. | | Formatting Context | `ebox-layout.el`, `ebox-flex.el`, `ebox-grid.el` | Buffer edits. |
| Fragment/Snapshot | `ebox-fragment.el`, `ebox-incremental.el` | Source parsing or identity allocation. | | Fragment/Snapshot | `ebox-fragment.el`, `ebox-incremental.el` | Source parsing or identity allocation. |
@ -81,7 +98,9 @@ Caller-owned Source Tree
## Invariants ## Invariants
- Public Ebox nodes are data; `ebox--*` names are private. - Public Ebox nodes are data; `ebox--*` names are private.
- A one-element horizontal list such as `'(420)` denotes pixels; ordinary horizontal numbers denote character columns. - Box geometry uses explicit `(unit number)` data with `px`, `%`, `vw`, `vh`, `ch`, or `lh`; `calc`, `min`, `max`, and `clamp` compose values. Bare geometry numbers, singleton pixel lists, viewport/contain keywords, and parameterized `fit-content` are rejected.
- The [author unit-constraint table](../user/ebox-user-guide.en.md#size-unit-constraints) is authoritative. The schema and construction boundary validate every nested branch against its inline, block, parent-main-axis, or border-paint domain before any display work. Empty automatic content has zero height.
- Property schemas own allowed keywords and ranges; layout supplies reference measurements. Length arithmetic retains fractions and unresolved dependencies until the appropriate layout/display boundary. Vertical buffer content remains quantized to complete lines.
- `ebox-render` uses an ephemeral TP surface and does not publish to a buffer; `ebox-render-to-buffer` mounts a retained TP surface; `ebox-display-buffer` displays that same retained-surface path; and `ebox-commit` prepares Ebox semantics and updates the mount through TP. Ebox exposes no parallel public macro that erases a live buffer and evaluates arbitrary body forms. - `ebox-render` uses an ephemeral TP surface and does not publish to a buffer; `ebox-render-to-buffer` mounts a retained TP surface; `ebox-display-buffer` displays that same retained-surface path; and `ebox-commit` prepares Ebox semantics and updates the mount through TP. Ebox exposes no parallel public macro that erases a live buffer and evaluates arbitrary body forms.
- Declarative input remains caller-owned. Live mount/commit assigns identity only on a surface-owned copy, so the same source can back multiple buffers. - Declarative input remains caller-owned. Live mount/commit assigns identity only on a surface-owned copy, so the same source can back multiple buffers.
- Logical `:id` values resolve through `ebox-region-resolve` to opaque surface-scoped handles. Handles, not source-tree numeric ids, distinguish the same logical region mounted in different buffers. - Logical `:id` values resolve through `ebox-region-resolve` to opaque surface-scoped handles. Handles, not source-tree numeric ids, distinguish the same logical region mounted in different buffers.
@ -107,6 +126,7 @@ Caller-owned Source Tree
- `owner-rerender` is broader than `span-patch`, which is broader than `paint-patch`. - `owner-rerender` is broader than `span-patch`, which is broader than `paint-patch`.
- Buffer coordinates belong to the generation that produced them and must be refreshed after mutation. - Buffer coordinates belong to the generation that produced them and must be refreshed after mutation.
- Grid uses the normal measurement and rendering pipeline. Native reflow may reject an ineligible tree and must fall back to Elisp without changing correctness. - Grid uses the normal measurement and rendering pipeline. Native reflow may reject an ineligible tree and must fall back to Elisp without changing correctness.
- Native sizing accepts fixed values and static size arithmetic within the public axis constraints when the resolved geometry is integral in the native representation. Percentage/viewport dependencies, fractional geometry, and vertical intrinsic sizing use the normal Elisp fallback. Accepting these values at the public boundary does not imply native acceleration.
- A confirmed retained-native frame owns an immutable layout document together - A confirmed retained-native frame owns an immutable layout document together
with its exact document revision. Candidate-session forks share that document. with its exact document revision. Candidate-session forks share that document.
A synchronous frame may omit the document only when its base and target A synchronous frame may omit the document only when its base and target
@ -127,7 +147,7 @@ Caller-owned Source Tree
## Grid contract ## Grid contract
Grid currently covers fixed and pixel tracks, `auto`, fractional tracks, `minmax`, `repeat`, implicit rows and columns, row/column gap, auto-flow, one-based placement, positive spans, item/content alignment, and ordinary buffer rendering and updates. Ebox supports canonical font inputs (`:font-family`, `:font-size`, `:font-weight`, and `:font-style`) plus text-decoration paint; full browser typography is outside the contract. Percentages, absolute positioning, z-index, border radius, shadows, and browser-level bidi are also outside the contract. Grid currently covers explicit-unit and percentage tracks, `auto`, fractional tracks, `minmax`, `repeat`, implicit rows and columns, row/column gap, auto-flow, one-based placement, positive spans, item/content alignment, and ordinary buffer rendering and updates. Ebox supports canonical font inputs (`:font-family`, `:font-size`, `:font-weight`, and `:font-style`) plus text-decoration paint; full browser typography is outside the contract. Absolute positioning, z-index, border radius, shadows, and browser-level bidi are also outside the contract.
## Active examples and tests ## Active examples and tests
@ -139,6 +159,9 @@ The standalone package has no bundled playground fixtures. The migrated `.ebox`
make load make load
make compile make compile
make check make check
make size-tests
make size-migration-tests
make interaction-tests
make core-tests make core-tests
make runtime-index-tests make runtime-index-tests
make child-range-tests make child-range-tests
@ -171,3 +194,30 @@ make diff-check
``` ```
Run focused tests first, then `make check` after changes to shared rendering, Grid, public constructors, or documentation. Native changes require the Rust checks and a native build. Run focused tests first, then `make check` after changes to shared rendering, Grid, public constructors, or documentation. Native changes require the Rust checks and a native build.
`make size-tests` includes `make size-migration-tests`, so the migration tool's
Python regression suite also runs through `make check`.
## Migrate literal author sizes
For old Ebox author literals, use the supported source-aware migration tool
instead of another search-and-replace script. Its positional inputs are explicit
files or directories; no inputs are inferred from the machine's workspace.
Start with a dry run:
```sh
python3 scripts/migrate-css-sizes.py ../ebox-playground/examples
```
Review the proposed changes and ambiguity reports, then add `--write` for the
same explicit paths. The tool preserves strings, comments, and formatting and
does not evaluate Elisp. Dynamic values and removed/ambiguous constructs need
an intentional source edit. Target author data only: internal measured structs
can use the same property names with already-resolved numeric engine values.
This entry point supersedes temporary literal-rewrite probes.
Validate the migration tool with:
```sh
python3 -m unittest discover -s scripts -p 'test_migrate_css_sizes.py'
```

View File

@ -22,6 +22,8 @@
| `ebox-runtime-index.el` | 压缩持久 radix map 与旧 hash-table adapter。 | | `ebox-runtime-index.el` | 压缩持久 radix map 与旧 hash-table adapter。 |
| `ebox-state-contract.el` | 封闭的 retained-state 所有权清单与只读 compatibility-mirror 重建探针。 | | `ebox-state-contract.el` | 封闭的 retained-state 所有权清单与只读 compatibility-mirror 重建探针。 |
| `ebox-style.el` | ECSS property schema、declaration 与 cascade、shorthand 展开、computed style、颜色、border 和 dirty effect。 | | `ebox-style.el` | ECSS property schema、declaration 与 cascade、shorthand 展开、computed style、颜色、border 和 dirty effect。 |
| `ebox-interaction.el` | 提供目标 buffer 上下文的原生帮助与 keymap 适配、四种显式节点能力的验证与独立复制、受限悬停绘制编译,以及保留嵌套显式 nil 优先级的原生 surface-property 投影。 |
| `ebox-size.el` | 显式单位的纯验证、量纲运算、依赖识别,以及基于注入测量值的浮点像素换算。 |
| `ebox-tree.el` | 节点遍历、逻辑子节点访问、ECSS subject 适配、identity、父路径、key 和树 snapshot。 | | `ebox-tree.el` | 节点遍历、逻辑子节点访问、ECSS subject 适配、identity、父路径、key 和树 snapshot。 |
| `ebox-child-range.el` | 不可变 weighted segment trie、稀疏持久 key trie、Range replacement 与 Gate A metrics。 | | `ebox-child-range.el` | 不可变 weighted segment trie、稀疏持久 key trie、Range replacement 与 Gate A metrics。 |
| `ebox-measure.el` | display 敏感的字符、face、像素测量与测量缓存。 | | `ebox-measure.el` | display 敏感的字符、face、像素测量与测量缓存。 |
@ -45,6 +47,18 @@ active 合同还覆盖 `Makefile`、`.github/workflows/ci.yml`、`tests/ebox-cor
Runtime index 行为由 `tests/ebox-runtime-index-tests.el` 覆盖。 Runtime index 行为由 `tests/ebox-runtime-index-tests.el` 覆盖。
原生节点能力由 `tests/ebox-interaction-tests.el` 通过 `make interaction-tests` 覆盖。
可交互参考示例及其命令回归测试位于同级 Playground 包。
CSS 尺寸由 `tests/ebox-size-tests.el`、`tests/ebox-size-style-tests.el`、
`tests/ebox-css-layout-tests.el`、`tests/ebox-css-flex-grid-tests.el`、
`tests/ebox-css-viewport-tests.el``tests/ebox-size-native-tests.el` 覆盖。
`tests/ebox-zero-height-tests.el` 通过同一个 `make size-tests` 入口覆盖空的自动几何
和显式空白行尺寸。
支持的字面量迁移入口是
`scripts/migrate-css-sizes.py``scripts/test_migrate_css_sizes.py` 验证它保留
源码格式的重写及歧义报告。
## 运行时模型 ## 运行时模型
正常数据流是: 正常数据流是:
@ -69,6 +83,7 @@ Runtime index 行为由 `tests/ebox-runtime-index-tests.el` 覆盖。
| Source/Element Tree | `ebox-child-range.el`、`ebox-tree.el`、`ebox-dsl.el` | 已发布 buffer 的变更。 | | Source/Element Tree | `ebox-child-range.el`、`ebox-tree.el`、`ebox-dsl.el` | 已发布 buffer 的变更。 |
| 状态所有权合同 | `ebox-state-contract.el` | Live 发布或可变 authority。 | | 状态所有权合同 | `ebox-state-contract.el` | Live 发布或可变 authority。 |
| Computed Style | `ebox-style.el` | 布局 identity 或 patch 执行。 | | Computed Style | `ebox-style.el` | 布局 identity 或 patch 执行。 |
| 长度值 | `ebox-size.el` | 窗口/节点读取、属性默认值、决定显示量化边界或任意 Lisp 求值。 |
| Measurement | `ebox-measure.el` | 应用状态或 dirty 策略。 | | Measurement | `ebox-measure.el` | 应用状态或 dirty 策略。 |
| Formatting Context | `ebox-layout.el`、`ebox-flex.el`、`ebox-grid.el` | Buffer 编辑。 | | Formatting Context | `ebox-layout.el`、`ebox-flex.el`、`ebox-grid.el` | Buffer 编辑。 |
| Fragment/Snapshot | `ebox-fragment.el`、`ebox-incremental.el` | Source parsing 或 identity 分配。 | | Fragment/Snapshot | `ebox-fragment.el`、`ebox-incremental.el` | Source parsing 或 identity 分配。 |
@ -81,7 +96,9 @@ Runtime index 行为由 `tests/ebox-runtime-index-tests.el` 覆盖。
## 不变量 ## 不变量
- 公共 Ebox 节点是数据;`ebox--*` 名称是私有实现。 - 公共 Ebox 节点是数据;`ebox--*` 名称是私有实现。
- `'(420)` 这样的单元素横向 list 表示像素;普通横向数字表示字符列。 - Box 几何统一使用 `(单位 数值)`,单位为 `px`、`%`、`vw`、`vh`、`ch`、`lh`,通过 `calc`、`min`、`max`、`clamp` 组合。不接受几何裸数字、单元素像素列表、viewport/contain 关键词和带参数的 `fit-content`
- [作者单位约束表](../user/ebox-user-guide.zh.md#size-unit-constraints) 是统一契约。schema 与构造边界在显示之前,按 inline、block、父布局主轴或边框绘制用途递归验证所有嵌套分支。空的自动内容高度为零。
- property schema 拥有关键词和值域规则,布局注入参照测量值。长度运算保留小数和未解析依赖,到对应布局/显示边界再处理;纵向 buffer 内容仍按完整行量化。
- `ebox-render` 使用临时 TP surface不发布到 buffer`ebox-render-to-buffer` 挂载 retained TP surface`ebox-display-buffer` 展示的也是这条 retained-surface 路径;`ebox-commit` 准备 Ebox 语义后通过 TP 更新该 mount。Ebox 不再公开另一套擦除 live buffer 后执行任意 BODY 的宏。 - `ebox-render` 使用临时 TP surface不发布到 buffer`ebox-render-to-buffer` 挂载 retained TP surface`ebox-display-buffer` 展示的也是这条 retained-surface 路径;`ebox-commit` 准备 Ebox 语义后通过 TP 更新该 mount。Ebox 不再公开另一套擦除 live buffer 后执行任意 BODY 的宏。
- 声明式输入始终由调用者拥有。live mount/commit 只在 surface-owned copy 上分配 identity因此同一 source 可以挂载到多个 buffer。 - 声明式输入始终由调用者拥有。live mount/commit 只在 surface-owned copy 上分配 identity因此同一 source 可以挂载到多个 buffer。
- 逻辑 `:id` 通过 `ebox-region-resolve` 解析为不透明、surface-scoped 的 handle同一逻辑 region 挂载到多个 buffer 时,由 handle 而不是 source-tree 数字 id 区分。 - 逻辑 `:id` 通过 `ebox-region-resolve` 解析为不透明、surface-scoped 的 handle同一逻辑 region 挂载到多个 buffer 时,由 handle 而不是 source-tree 数字 id 区分。
@ -106,6 +123,7 @@ Runtime index 行为由 `tests/ebox-runtime-index-tests.el` 覆盖。
- `owner-rerender` 范围大于 `span-patch``span-patch` 大于 `paint-patch` - `owner-rerender` 范围大于 `span-patch``span-patch` 大于 `paint-patch`
- Buffer 坐标属于生成它的 generation变更后必须重新获取。 - Buffer 坐标属于生成它的 generation变更后必须重新获取。
- Grid 使用普通测量与渲染流水线native reflow 可以拒绝不适合的树并回退到 Elisp正确性不变。 - Grid 使用普通测量与渲染流水线native reflow 可以拒绝不适合的树并回退到 Elisp正确性不变。
- Native 尺寸路径支持公共轴向约束内的固定值和静态尺寸运算,前提是解析后的几何在 native 表示中是整数。百分比/视口依赖、小数几何和纵向固有尺寸使用普通 Elisp fallback公共入口接受这些值不代表 native 会加速它们。
- 已确认的 retained-native frame 持有不可变布局文档及其精确文档 revision候选 - 已确认的 retained-native frame 持有不可变布局文档及其精确文档 revision候选
session 的 fork 共享该文档。同步 frame 只有在文档 base/target revision 相等且 session 的 fork 共享该文档。同步 frame 只有在文档 base/target revision 相等且
命中 confirmed 文档、同时 TP runtime revision 命中 confirmed frame 时,才可 命中 confirmed 文档、同时 TP runtime revision 命中 confirmed frame 时,才可
@ -122,7 +140,7 @@ Runtime index 行为由 `tests/ebox-runtime-index-tests.el` 覆盖。
## Grid 合同 ## Grid 合同
当前 Grid 支持固定和像素轨道、`auto`、分数轨道、`minmax`、`repeat`、隐式行列、行列 gap、auto-flow、从 1 开始的 placement、正整数 span、item/content 对齐,以及普通 buffer 渲染和更新。Ebox 支持规范字体输入(`:font-family`、`:font-size`、`:font-weight`、`:font-style`)与 text-decoration paint完整浏览器 typography 不在合同内。百分比、绝对定位、z-index、圆角、阴影和浏览器级 bidi 也不在合同内。 当前 Grid 支持显式单位和百分比轨道、`auto`、分数轨道、`minmax`、`repeat`、隐式行列、行列 gap、auto-flow、从 1 开始的 placement、正整数 span、item/content 对齐,以及普通 buffer 渲染和更新。Ebox 支持规范字体输入(`:font-family`、`:font-size`、`:font-weight`、`:font-style`)与 text-decoration paint完整浏览器 typography 不在合同内。绝对定位、z-index、圆角、阴影和浏览器级 bidi 也不在合同内。
## Active 示例与测试 ## Active 示例与测试
@ -132,6 +150,9 @@ Runtime index 行为由 `tests/ebox-runtime-index-tests.el` 覆盖。
```sh ```sh
make check make check
make size-tests
make size-migration-tests
make interaction-tests
make core-tests make core-tests
make runtime-index-tests make runtime-index-tests
make child-range-tests make child-range-tests
@ -164,3 +185,26 @@ make diff-check
``` ```
先运行聚焦测试修改共享渲染、Grid、公共构造器或文档后运行 `make check`。修改 native 后必须运行 Rust 检查与 native 构建。 先运行聚焦测试修改共享渲染、Grid、公共构造器或文档后运行 `make check`。修改 native 后必须运行 Rust 检查与 native 构建。
`make size-tests` 包含 `make size-migration-tests`,因此迁移工具的 Python 回归
测试也会通过 `make check` 执行。
## 迁移作者尺寸字面量
旧 Ebox 作者字面量使用已有的源码感知迁移工具,不另写一套搜索替换脚本。
位置参数明确指定文件或目录,不从当前机器的工作区推测目标。推荐先预览:
```sh
python3 scripts/migrate-css-sizes.py ../ebox-playground/examples
```
检查拟修改内容和歧义报告后,对同一批明确路径增加 `--write` 应用。工具保留字符串、
注释和排版,不执行 Elisp动态值、已删除或有歧义的结构需要有意修改源码。
只对作者数据使用工具:内部已测量结构可能用相同属性名存储已经解析的数字引擎值。
该入口替代临时的字面量重写探针。
迁移工具验证命令:
```sh
python3 -m unittest discover -s scripts -p 'test_migrate_css_sizes.py'
```

View File

@ -38,9 +38,9 @@ functions; it does not extract a node or construct a source index.
```elisp ```elisp
(ebox-build (ebox-build
'(column :padding (1 2) '(column :padding ((lh 1) (ch 2))
(box :id "status" :color "#166534" "Ready") (box :id "status" :color "#166534" "Ready")
(row :item-gap 1 (row :item-gap (ch 1)
(box "Left") (box "Left")
(box "Right")))) (box "Right"))))
``` ```
@ -71,12 +71,107 @@ another public node or wrapper.
| Text layout | `:text-align`, `:wrap-mode` (`word`, `char`, `kp`, `none`) | | Text layout | `:text-align`, `:wrap-mode` (`word`, `char`, `kp`, `none`) |
| Overflow | `:overflow` (`visible`, `hidden`, `scroll`) | | Overflow | `:overflow` (`visible`, `hidden`, `scroll`) |
Ordinary horizontal numbers are character columns; a one-element horizontal Geometry values use explicit `(unit number)` lists: `(px 240)`, `(% 50)`,
list is pixels. Vertical numbers are lines. Border widths are the exception: `(vw 100)`, `(vh 100)`, `(ch 80)`, and `(lh 3)`. Each property accepts only
they are always nonnegative integer pixels, so use `(1 solid "#687386")`, not the units in the [shared unit-constraint table](ebox-user-guide.en.md#size-unit-constraints);
`((1) solid "#687386")`. `(viewport)` and lengths cannot cross from one geometry axis to the other. CSS strings, bare lengths,
`(viewport-height)` use the current render context. Property validation belongs singleton pixel lists, and the old viewport/contain keywords are rejected.
to Ebox and invalid context/value combinations signal an error. Zero lengths also require a permitted unit. Border widths use their independent
paint-thickness rule and reject
percentages: `:border ((px 1) solid "#687386")`. Dimensionless values such as
Flex factors and Grid indices remain numbers.
| Properties | Accepted keywords | Default |
| --- | --- | --- |
| `width`, `height`, `min-width`, `min-height` | `auto`, `min-content`, `max-content`, `fit-content`, `stretch` | `auto` |
| `max-width`, `max-height` | `none`, `min-content`, `max-content`, `fit-content`, `stretch` | `none` |
The composable size functions are `calc`, `min`, `max`, and `clamp`, for example
`(calc (- (vh 100) (lh 1)))` and `(clamp (ch 20) (% 50) (ch 80))`.
`fit-content` is only a bare keyword; it never accepts parameters.
Property validation belongs to Ebox; all function branches, expanded shorthands,
Grid tracks, and Flex bases are checked against their property or parent axis
when the tree is built. Invalid context/value combinations signal errors
immediately. See the [geometry section of the user
guide](ebox-user-guide.en.md#4-use-geometry-and-paint-properties) for percentage
references, font units, arithmetic rules, and whole-line vertical display
quantization. This is a CSS semantic subset, not a browser layout engine.
### Native node capabilities
`text`, `box`, `row`, `column`, `flex`, and `grid` accept these four explicit
node properties. They are separate from CSS declarations and do not participate
in the stylesheet cascade.
| Property | Accepted value | Native surface property |
| --- | --- | --- |
| `:help-echo` | `nil`, a string, or a function called by Emacs with `(WINDOW OBJECT POSITION)` | `help-echo` |
| `:pointer` | `nil`, `text`, `arrow`, `vdrag`, `modeline`, `hand`, `hdrag`, `nhdrag`, or `hourglass` | `pointer` |
| `:hover-style` | `nil` or an Ebox paint plist containing only `:color`, `:background-color`, `:text-decoration-line`, `:text-decoration-color`, and `:text-decoration-style` | Compiled `mouse-face` |
| `:keymap` | `nil` or a native Emacs keymap whose bindings use ordinary interactive commands | `keymap` |
Hover colors use color strings; `:text-decoration-color` also accepts
`currentColor`. `:text-decoration-line` accepts `none`, `underline`, `overline`,
`line-through`, or a nonempty list of distinct decoration lines without `none`.
`:text-decoration-style` accepts `solid`, `double`, `dotted`, `dashed`, or `wavy`.
Hover styles cannot change font metrics, geometry, spacing, or layout. Ebox compiles
the restricted paint plist; a raw face or arbitrary native property plist is
not an author input. There is no `:tps`, `:local-map`, or raw `:mouse-face` input.
Within a rendered line, text and padding covered by the same hover owner
(the node declaring `:hover-style`) share one native `mouse-face`. Unspecified
hover attributes use that owner's base style. Child text under that hover
therefore uses the owner's unspecified attributes even if its normal text color
differs. An explicit child hover style creates its own hover region; `nil`
blocks the enclosing hover. Physical left/right borders are excluded from
hover highlighting, and horizontal border strokes keep their own paint.
A Box's help, pointer, and keymap surface includes rendered content, padding,
and border, excluding its own margin and structural newlines; hover follows
the coverage above. Text capabilities apply to its rendered
text. At each nested position, an explicit child value takes precedence for
that capability. An explicit `nil` blocks the enclosing value; an omitted
property leaves enclosing surface coverage in place.
`ebox-help-create` adapts a zero-argument business function into a native
`(WINDOW OBJECT POSITION)` help callback. The business function returns a string
or `nil` and runs in the hovered buffer's context. Point and the selected window
are unchanged. It is not called until Emacs requests help. For example:
```elisp
(ebox-help-create (lambda () (format "Help for %s" (buffer-name))))
```
Raw native help functions are also supported. Help functions are retained as
functions and called by Emacs when help is requested, rather than evaluated
while building the DSL. Use backquote and
comma to insert a computed function or keymap value. Pointer and help display
follow the user's Emacs settings and window system. A pointer shape supplies
no command binding by itself.
`ebox-keymap-create` is the recommended callback-to-keymap helper:
```elisp
(ebox-keymap-create
:activate (lambda () (message "Activated"))
:bindings (list (cons "?" (lambda () (message "Action help")))))
```
`:activate` accepts a zero-argument function and binds it to `RET`, `[return]`,
`SPC`, and `[mouse-1]`. `:bindings` accepts an alist from key-description strings
or event vectors to zero-argument functions. Both options are optional; `nil`
omits their bindings. Duplicate keys and overlapping key prefixes are errors,
including overlaps with the activation keys. The helper returns a native Emacs
keymap; callbacks remain literal until their command is invoked. For mouse
events, the helper obtains the event window and runs the callback in its buffer.
It does not select that window or move point. Keyboard callbacks run in the
current buffer, using native dispatch at point.
Raw native keymaps remain supported. Their mouse commands must obtain the
target window from the event and operate in that window's buffer themselves.
These capabilities add no Ebox focus navigation or application state system.
See the [interaction guide](ebox-user-guide.en.md#native-node-interaction) for
a runnable example.
## 2. Evaluated typed construction ## 2. Evaluated typed construction
@ -118,7 +213,7 @@ one value:
(root (root
(ebox-box-create (ebox-box-create
:layout (ebox-row-layout-create :layout (ebox-row-layout-create
:item-gap 1 :cross-align 'center) :item-gap '(ch 1) :cross-align 'center)
:children (list left right) :children (list left right)
:source-handle root-handle :source-handle root-handle
:owned-facts :owned-facts
@ -220,7 +315,7 @@ They do not publish by themselves.
| --- | --- | | --- | --- |
| `ebox-region-ids` | Return current low-level region ids in document order. | | `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-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-region-update` | Apply supported mutable style, scroll, or native node capability properties through one retained transaction. Accept an opaque region handle or a semantic `:id` in the current mounted buffer. |
| `ebox-child-range` | Framework integration only: build a nonvisual, addressable child Range descriptor while assembling a typed canonical input. | | `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-begin` | Capture the exact current mounted generation in a one-shot candidate. |
| `ebox-candidate-replace` | Replace one candidate node address from a single-root `CanonicalEboxInput`. | | `ebox-candidate-replace` | Replace one candidate node address from a single-root `CanonicalEboxInput`. |
@ -235,6 +330,21 @@ Handles and reported positions belong to one live publication generation.
Resolve them again after their object is removed. A candidate is sealed by Resolve them again after their object is removed. A candidate is sealed by
commit and cannot be reused. commit and cannot be reused.
Within a callback for the target mounted buffer, a direct update can use the
semantic ID: `(ebox-region-update "action" :help-echo "Updated help")`.
Semantic IDs accept strings, non-`nil` symbols, and integers; an integer author
`:id` is distinct from Ebox's diagnostic numeric region metadata.
Use `ebox-region-resolve` with an explicit buffer when addressing another
mounted buffer, then pass that handle to `ebox-region-update`.
`ebox-region-update` accepts `:help-echo`, `:pointer`, `:hover-style`, and
`:keymap` on Text and Box regions, with the same validation as construction.
Passing a new value replaces that capability; passing `nil` clears it and
blocks an enclosing value. Omitted properties are unchanged. Replace the
`:keymap` property when publishing new bindings: Ebox snapshots keymaps, so
later caller-side mutations do not update a mounted surface. Clearing it removes that
node's keymap; ordinary Emacs buffer and global bindings still apply.
The author DSL has no Range form. Ordinary authors compose direct Text/Box 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 children and never call `ebox-child-range`; Range addresses exist only for
frameworks that already own semantic child ranges and candidate updates. frameworks that already own semantic child ranges and candidate updates.
@ -288,6 +398,7 @@ names every entry:
`ebox-normal-layout-create`, `ebox-row-layout-create`, `ebox-normal-layout-create`, `ebox-row-layout-create`,
`ebox-column-layout-create`, `ebox-flex-layout-create`, `ebox-column-layout-create`, `ebox-flex-layout-create`,
`ebox-grid-layout-create`, `ebox-box-create`. `ebox-grid-layout-create`, `ebox-box-create`.
- Interaction: `ebox-help-create`, `ebox-keymap-create`.
- Render/publication: `ebox-render`, `ebox-render-to-buffer`, - Render/publication: `ebox-render`, `ebox-render-to-buffer`,
`ebox-unmount-buffer`, `ebox-display-buffer`, `ebox-commit`, `ebox-unmount-buffer`, `ebox-display-buffer`, `ebox-commit`,
`ebox-buffer-set-observer`, `ebox-buffer-set-observer`,
@ -317,6 +428,7 @@ names every entry:
make load EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs make load EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs
make compile EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs make compile EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs
make docs-contract-tests EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs make docs-contract-tests EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs
make interaction-tests EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs
make dsl-tests EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs make dsl-tests EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs
make check EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs make check EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs
``` ```

View File

@ -35,9 +35,9 @@ forest 和拥有其 author facts 的 source generation。普通 author 代码只
```elisp ```elisp
(ebox-build (ebox-build
'(column :padding (1 2) '(column :padding ((lh 1) (ch 2))
(box :id "status" :color "#166534" "Ready") (box :id "status" :color "#166534" "Ready")
(row :item-gap 1 (row :item-gap (ch 1)
(box "Left") (box "Left")
(box "Right")))) (box "Right"))))
``` ```
@ -67,10 +67,87 @@ participation property 就是普通子 Box property不会创建另一种公
| 文本布局 | `:text-align`、`:wrap-mode``word`、`char`、`kp`、`none` | | 文本布局 | `:text-align`、`:wrap-mode``word`、`char`、`kp`、`none` |
| Overflow | `:overflow``visible`、`hidden`、`scroll` | | Overflow | `:overflow``visible`、`hidden`、`scroll` |
普通横向数字表示字符列,单元素横向 list 表示像素纵向数字表示行数。Border 几何值统一使用 `(单位 数值)``(px 240)`、`(% 50)`、`(vw 100)`、`(vh 100)`、
width 是例外,始终使用非负整数像素,因此写 `(1 solid "#687386")`,不写 `(ch 80)`、`(lh 3)`。每个属性只接受[统一单位约束表](ebox-user-guide.zh.md#size-unit-constraints)
`((1) solid "#687386")`。`(viewport)` 与 `(viewport-height)` 使用当前 render context。property 验证属于 规定的单位,不能跨几何轴使用。不接受 CSS 字符串、裸长度、单元素像素列表及旧的
Eboxcontext/value 组合无效时会直接报错。 viewport/contain 关键词;零长度也需要允许的单位。边框宽度采用独立绘制厚度规则,
不接受百分比,
例如 `:border ((px 1) solid "#687386")`。Flex 因子、Grid 索引等无量纲值仍是数字。
| 属性 | 接受的关键词 | 默认值 |
| --- | --- | --- |
| `width`、`height`、`min-width`、`min-height` | `auto`、`min-content`、`max-content`、`fit-content`、`stretch` | `auto` |
| `max-width`、`max-height` | `none`、`min-content`、`max-content`、`fit-content`、`stretch` | `none` |
尺寸函数支持 `calc`、`min`、`max`、`clamp`,可以组合使用,例如
`(calc (- (vh 100) (lh 1)))``(clamp (ch 20) (% 50) (ch 80))`
`fit-content` 只接受裸关键词不带参数。property 验证属于 Ebox构造树时就按
属性或父布局方向递归检查所有函数分支、展开后的简写、Grid 轨道与 Flex basis。
context/value 组合无效时会直接报错。百分比参照、字体单位、算术规则及纵向完整行显示量化见
[用户指南的几何章节](ebox-user-guide.zh.md#4-使用几何与绘制-property)。这是 CSS
语义的子集,不是完整浏览器布局引擎。
### 原生节点能力
`text`、`box`、`row`、`column`、`flex` 和 `grid` 都接受下面四个显式节点属性。
这些能力与 CSS declaration 分开,不参与样式表 cascade。
| 属性 | 接受的值 | 原生 surface property |
| --- | --- | --- |
| `:help-echo` | `nil`、字符串,或由 Emacs 以 `(WINDOW OBJECT POSITION)` 调用的函数 | `help-echo` |
| `:pointer` | `nil`、`text`、`arrow`、`vdrag`、`modeline`、`hand`、`hdrag`、`nhdrag` 或 `hourglass` | `pointer` |
| `:hover-style` | `nil`,或仅包含 `:color`、`:background-color`、`:text-decoration-line`、`:text-decoration-color`、`:text-decoration-style` 的 Ebox 绘制 plist | 编译后的 `mouse-face` |
| `:keymap` | `nil`,或使用普通交互命令作为绑定的原生 Emacs keymap | `keymap` |
悬停颜色使用颜色字符串,`:text-decoration-color` 还接受 `currentColor`
`:text-decoration-line` 接受 `none`、`underline`、`overline`、`line-through`,或不含
`none`、不重复的非空装饰列表;`:text-decoration-style` 接受 `solid`、`double`、
`dotted`、`dashed`、`wavy`。悬停样式不能改变字体度量、几何、间距或布局。Ebox 负责
编译受限绘制 plist原始 face 或任意原生 property plist 都不是 author 输入。
不提供 `:tps`、`:local-map` 或原始 `:mouse-face` 输入。
同一渲染行中,由同一个 hover owner声明 `:hover-style` 的节点)覆盖的文字与
padding 共享一个原生 `mouse-face`。未指定的悬停属性采用 owner 的基础样式;
因此,即使子文本的正常颜色不同,被父级悬停覆盖时也会采用 owner 未被覆盖的属性。
子节点显式声明 hover style 会形成独立悬停区域,`nil` 阻止外层悬停。物理左右边框
不参与悬停高亮,水平边框线条保留自己的绘制。
Box 的帮助、指针与 keymap 范围包含渲染后的内容、padding 和 border不包含该 Box
自身的 margin 及结构性换行符悬停遵循上述范围。Text 的能力作用于其渲染文本。
嵌套位置上,子节点显式声明的每项
能力优先于外层值;显式 `nil` 阻止外层值覆盖,省略属性则保留外层交互范围的覆盖。
`ebox-help-create` 把零参数业务函数适配为原生的 `(WINDOW OBJECT POSITION)`
帮助回调。业务函数返回字符串或 `nil`,在悬停 buffer 的上下文中运行,不移动 point
或切换选中窗口,直到 Emacs 请求帮助时才调用。例如:
```elisp
(ebox-help-create (lambda () (format "Help for %s" (buffer-name))))
```
仍支持直接传入原生帮助函数。帮助函数保留为函数,由 Emacs 在请求帮助时调用,
不在构造 DSL 时求值。
计算得到的函数或 keymap 通过反引号和逗号插入。帮助显示和指针外观遵循用户的
Emacs 设置及窗口系统;单独设置指针形状不会绑定命令。
推荐使用 `ebox-keymap-create` 将回调转换为 keymap
```elisp
(ebox-keymap-create
:activate (lambda () (message "Activated"))
:bindings (list (cons "?" (lambda () (message "Action help")))))
```
`:activate` 接收零参数函数,将其绑定到 `RET`、`[return]`、`SPC` 和 `[mouse-1]`
`:bindings` 接收 alistkey 是按键描述字符串或事件向量value 是零参数函数。
两项都可省略,`nil` 表示不添加相应绑定。重复按键和重叠前缀会报错,包括与激活键
重叠的绑定。辅助函数返回原生 Emacs keymap回调保留为函数直到对应命令触发时才调用。
鼠标事件会自动从事件取得窗口,并在其 buffer 中运行回调,不选择窗口或移动 point。
键盘回调在当前 buffer 中运行,由 point 所在位置进行原生分派。
仍支持直接传入原生 keymap其鼠标命令需要自行从事件取得目标窗口并在该窗口的
buffer 中操作。这些能力不引入 Ebox 焦点导航或应用状态系统。
可运行示例见[交互指南](ebox-user-guide.zh.md#native-node-interaction)。
## 2. Evaluated typed construction ## 2. Evaluated typed construction
@ -110,7 +187,7 @@ facts。最后用一个 `CanonicalEboxInput` 同时传递 forest 与封存的 so
(root (root
(ebox-box-create (ebox-box-create
:layout (ebox-row-layout-create :layout (ebox-row-layout-create
:item-gap 1 :cross-align 'center) :item-gap '(ch 1) :cross-align 'center)
:children (list left right) :children (list left right)
:source-handle root-handle :source-handle root-handle
:owned-facts :owned-facts
@ -203,7 +280,7 @@ observer 为 nil 时Ebox 不创建 observation context不取 timestamp/GC
| --- | --- | | --- | --- |
| `ebox-region-ids` | 按文档顺序返回当前底层 region id。 | | `ebox-region-ids` | 按文档顺序返回当前底层 region id。 |
| `ebox-region-resolve` | 把 mounted buffer 中一个逻辑 `:id` 解析为不透明、surface-scoped 的 handle。 | | `ebox-region-resolve` | 把 mounted buffer 中一个逻辑 `:id` 解析为不透明、surface-scoped 的 handle。 |
| `ebox-region-update` | 通过一次 retained transaction 应用支持的可变样式或滚动 property。 | | `ebox-region-update` | 通过一次 retained transaction 应用支持的可变样式、滚动或原生节点能力 property接受不透明 region handle或当前已挂载 buffer 中的语义 `:id`。 |
| `ebox-child-range` | 仅供框架集成:在组装 typed canonical input 时构造非视觉、可寻址的 child Range descriptor。 | | `ebox-child-range` | 仅供框架集成:在组装 typed canonical input 时构造非视觉、可寻址的 child Range descriptor。 |
| `ebox-candidate-begin` | 在 one-shot candidate 中捕获当前准确 mounted generation。 | | `ebox-candidate-begin` | 在 one-shot candidate 中捕获当前准确 mounted generation。 |
| `ebox-candidate-replace` | 用 single-root `CanonicalEboxInput` 替换一个 candidate node address。 | | `ebox-candidate-replace` | 用 single-root `CanonicalEboxInput` 替换一个 candidate node address。 |
@ -217,6 +294,19 @@ observer 为 nil 时Ebox 不创建 observation context不取 timestamp/GC
handle 和位置只属于一个 live publication generation对象被删除后要重新解析。 handle 和位置只属于一个 live publication generation对象被删除后要重新解析。
candidate 被 commit 封存后不能复用。 candidate 被 commit 封存后不能复用。
回调在目标已挂载 buffer 中运行时,可以直接用语义 ID 更新,例如
`(ebox-region-update "action" :help-echo "Updated help")`
语义 ID 接受字符串、非 `nil` 符号和整数author 声明的整数 `:id` 与 Ebox 用于
诊断的数字 region metadata 不同。
更新另一个已挂载 buffer 时,使用显式 buffer 调用 `ebox-region-resolve`,再把返回
的 handle 传给 `ebox-region-update`
`ebox-region-update` 接受 Text 和 Box region 上的 `:help-echo`、`:pointer`、
`:hover-style``:keymap`,验证规则与构造时一致。新值替换对应能力,`nil` 清除
对应能力并阻止外层值覆盖省略的属性保持不变。Ebox 对 keymap 创建快照;调用方
之后直接修改原 map 不会更新已挂载的 surface应显式替换 `:keymap` 来发布新绑定。
清除该属性只移除节点 keymap普通 Emacs buffer 和全局绑定仍然适用。
author DSL 没有 Range form。普通 author 只组合直接嵌套的 Text/Box child不调用 author DSL 没有 Range form。普通 author 只组合直接嵌套的 Text/Box child不调用
`ebox-child-range`Range address 只服务已经拥有 semantic child range 与 candidate `ebox-child-range`Range address 只服务已经拥有 semantic child range 与 candidate
update 的框架。 update 的框架。
@ -265,6 +355,7 @@ native 模块只是加速器,不是正确性依赖。加载包不会构建它
- 构造:`ebox-build`、`ebox-text-create`、`ebox-normal-layout-create`、 - 构造:`ebox-build`、`ebox-text-create`、`ebox-normal-layout-create`、
`ebox-row-layout-create`、`ebox-column-layout-create`、 `ebox-row-layout-create`、`ebox-column-layout-create`、
`ebox-flex-layout-create`、`ebox-grid-layout-create`、`ebox-box-create` `ebox-flex-layout-create`、`ebox-grid-layout-create`、`ebox-box-create`
- 交互:`ebox-help-create`、`ebox-keymap-create`
- 渲染/发布:`ebox-render`、`ebox-render-to-buffer`、`ebox-unmount-buffer`、 - 渲染/发布:`ebox-render`、`ebox-render-to-buffer`、`ebox-unmount-buffer`、
`ebox-display-buffer`、`ebox-commit`、`ebox-buffer-set-observer`、`ebox-buffer-update-report`、 `ebox-display-buffer`、`ebox-commit`、`ebox-buffer-set-observer`、`ebox-buffer-update-report`、
`ebox-rerender-buffer-with-context`、`ebox-viewport-window-width`、 `ebox-rerender-buffer-with-context`、`ebox-viewport-window-width`、
@ -292,6 +383,7 @@ native 模块只是加速器,不是正确性依赖。加载包不会构建它
make load EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs make load EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs
make compile EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs make compile EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs
make docs-contract-tests EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs make docs-contract-tests EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs
make interaction-tests EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs
make dsl-tests EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs make dsl-tests EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs
make check EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs make check EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs
``` ```

View File

@ -37,10 +37,10 @@ child-layout algorithm, not a different kind of visual object.
```elisp ```elisp
(defvar ebox-guide-input (defvar ebox-guide-input
(ebox-build (ebox-build
'(column :padding (1 2) '(column :padding ((lh 1) (ch 2))
:border (1 solid "#8A93A6") :border ((px 1) solid "#8A93A6")
(text :color "#263244" "Research notes") (text :color "#263244" "Research notes")
(row :item-gap 1 (row :item-gap (ch 1)
(box :id "status" :background-color "#F4F6FB" "Inbox") (box :id "status" :background-color "#F4F6FB" "Inbox")
(box :background-color "#EEF2FF" "Archive"))))) (box :background-color "#EEF2FF" "Archive")))))
``` ```
@ -53,6 +53,41 @@ reassemble its internal nodes.
Use `box` with geometry but no child when an empty rectangular area is needed. Use `box` with geometry but no child when an empty rectangular area is needed.
No extra node type is necessary. No extra node type is necessary.
### Prefer defaults and inheritance
Write only what changes the intended result. Omit redundant default values,
place shared text styles on an existing common parent, and keep only child
overrides. Reference panels still spell out the property they teach, even
when its value is the default.
Ebox inherits `:color`, `:font-family`, `:font-size`, `:font-weight`,
`:font-style`, `:text-align`, `:wrap-mode`, and `:visibility`. Geometry and
background colors do not inherit; an unpainted child can show its parent's
background. For example:
```elisp
(ebox-build
'(column :width (ch 40) :color "#1F2328" :bgcolor "#FAF7F0"
(box "Shared text color")
(box :color "#B45309" "Local accent")
(box :height (lh 1))))
```
Inheritance does not expand where a property may be authored. For example,
`:text-align` is declared on `box`; it cannot be moved onto a `row`, `column`,
`flex`, or `grid` merely because its value can inherit.
In this ordinary Column, the separator fills the available width and explicitly
reserves one line with `:height (lh 1)`. No width or repeated background is needed.
An otherwise empty Box with `auto` height has zero content height; use
`:height (lh 1)` when one blank line is intended. Padding and borders can still
add their own outer extent.
This is context-dependent: an auto-width child in a Row uses intrinsic width,
and Flex/Grid have their own item sizing rules. Do not replace `stretch` with
`auto` everywhere, or remove a default-looking value that overrides inherited
styles, a stylesheet, or another shorthand. Zero sides can often be omitted
with `:padding-inline` or `:padding-block` when no other declaration sets them.
## 3. Choose the layout that states the intent ## 3. Choose the layout that states the intent
Use `box` for ordinary content, `row` or `column` for direct one-axis Use `box` for ordinary content, `row` or `column` for direct one-axis
@ -66,9 +101,9 @@ ordinary Text or Box nodes.
```elisp ```elisp
(ebox-build (ebox-build
'(row :item-gap 2 :cross-align center '(row :item-gap (ch 2) :cross-align center
(box :width 12 "Left") (box :width (ch 12) "Left")
(box :width 12 "Right"))) (box :width (ch 12) "Right")))
``` ```
### Flex ### Flex
@ -78,9 +113,9 @@ directly to a child `box` because they describe the parent-child relationship.
```elisp ```elisp
(ebox-build (ebox-build
'(flex :width (480) '(flex :width (px 480)
:flex-flow (row wrap) :flex-flow (row wrap)
:gap (1 (12)) :gap ((lh 1) (px 12))
(box :flex (1 1 auto) "Primary") (box :flex (1 1 auto) "Primary")
(box :flex-grow 2 "Secondary"))) (box :flex-grow 2 "Secondary")))
``` ```
@ -93,10 +128,10 @@ child `box`.
```elisp ```elisp
(ebox-build (ebox-build
'(grid :width (640) '(grid :width (px 640)
:grid-template-columns ((200) (fr 1) (fr 1)) :grid-template-columns ((px 200) (fr 1) (fr 1))
:grid-template-rows (1 1) :grid-template-rows ((lh 1) (lh 1))
:gap (1 (12)) :gap ((lh 1) (px 12))
(box :grid-column (1 :span 3) "Header") (box :grid-column (1 :span 3) "Header")
(box :grid-column 1 :grid-row 2 "Navigation") (box :grid-column 1 :grid-row 2 "Navigation")
(box :grid-column 2 :grid-row 2 "Main") (box :grid-column 2 :grid-row 2 "Main")
@ -105,19 +140,126 @@ child `box`.
## 4. Use geometry and paint properties ## 4. Use geometry and paint properties
Horizontal numbers are character columns; a one-element list such as `(240)` Box geometry uses explicit `(unit number)` values. The same syntax applies to
is a pixel width. Vertical numbers are lines. Text accepts only font, width, height, their minimum/maximum bounds, padding, margins, gaps, borders,
foreground/background, and text-decoration properties. Padding, margin, Flex basis, and fixed Grid tracks. Ebox implements a CSS sizing subset with an
Elisp data representation, not CSS strings such as `"80ch"`.
| Unit | Meaning |
| --- | --- |
| `(px 240)` | 240 pixels. |
| `(% 50)` | 50% of the property's containing-block reference size. |
| `(vw 100)` | 100% of the viewport width. |
| `(vh 100)` | 100% of the viewport height. |
| `(ch 80)` | 80 times the effective font's advance width for `0`. |
| `(lh 3)` | Three effective line heights. |
`ch` does not count arbitrary characters or CJK glyphs.
`1vw` and `1vh` each mean 1% of the corresponding viewport axis. A displayed
Ebox viewport is the target window's usable body area, excluding its mode line
and header line; a Playground preview therefore uses the preview window.
<a id="size-unit-constraints"></a>
### Unit constraints by property
The following table is the unit contract for all Ebox author entry points.
Inline means horizontal and block means vertical in Ebox's supported writing
mode. These axis restrictions deliberately narrow CSS's general length model.
| Value context | Allowed units | Properties |
| --- | --- | --- |
| Inline geometry | `px`, `ch`, `vw`, `%` | `width`, `min-width`, `max-width`; left/right and inline padding/margin; `column-gap`; Row `item-gap`; Grid column tracks. |
| Block geometry | `lh`, `vh`, `%` | `height`, `min-height`, `max-height`; top/bottom and block padding/margin; `row-gap`; Column `item-gap`; Grid row tracks. |
| Flex main size | The parent Flex's main-axis units | `flex-basis` and the basis in `(grow shrink basis)`; row directions use inline units, column directions use block units. |
| Border paint thickness | `px`, `ch`, `lh`, `vw`, `vh` | `border-width`, each side's border width, and the width component of border shorthands; percentages are forbidden. |
The same restrictions apply recursively to every branch of `calc`, `min`,
`max`, and `clamp`, and to nested Grid track functions. A disallowed unit is
an error when `ebox-build` constructs the tree, even if arithmetic would
cancel it or another branch would win. For example, `:height (px 24)`,
`:width (lh 3)`, and `:height (min (lh 2) (px 24))` are rejected.
Border thickness is a separate paint value, so `:border ((px 1) solid "red")`
is valid on all four sides.
Stylesheets and dynamic updates follow the same contract: after computed styles
are known and before layout, Ebox rechecks the parent Flex direction and child
basis. Invalid updates do not publish to an existing buffer. Numeric `:flex 1`
uses an implicit `(% 0)` basis, valid on either main axis; explicit bases still
follow the parent direction.
Shorthands are checked after expansion onto their sides or axes. A one-value
`padding`, `margin`, or `gap` must therefore be valid on both axes: use `%`
for such a shared length, or spell out the two axes, for example
`:padding ((lh 1) (ch 2))` and `:gap ((lh 1) (px 12))`.
Use `:padding-inline (px 12)` or `:padding-block (lh 1)` to set only one axis.
Percent widths refer to containing-block width; percent heights require a
definite containing-block height and otherwise follow the property's
indefinite-size rule. Percentage padding and margins on every side refer to
containing-block width. Border widths do not accept percentages.
Negative margins and `auto` margins are outside this subset; the current
buffer backend does not implement overlapping margin geometry.
| Property | Keywords | Default |
| --- | --- | --- |
| `width`, `height` | `auto`, `min-content`, `max-content`, `fit-content`, `stretch` | `auto` |
| `min-width`, `min-height` | `auto`, `min-content`, `max-content`, `fit-content`, `stretch` | `auto` |
| `max-width`, `max-height` | `none`, `min-content`, `max-content`, `fit-content`, `stretch` | `none` |
For a normal block root with an available viewport, `auto` width fills the
available space and `auto` height follows content. `min-content` and
`max-content` request intrinsic sizes. `fit-content` fits available space
between those intrinsic bounds; `stretch` fills available space with the
margin box. `none` removes a maximum-size constraint. Automatic minimums
depend on layout; an explicit `(px 0)` minimum allows an item to shrink below
its automatic content minimum on the inline axis. Use `(lh 0)` for the block
minimum. An empty Box with no padding, border, or explicit height has zero
height; `(box :height (lh 1))` explicitly reserves one blank line.
The four size functions compose units without evaluating Lisp during layout:
```elisp
'(column :width (min (% 100) (ch 80))
:height (calc (- (vh 100) (lh 1)))
(box :width (max (px 120) (% 25)) "Sidebar")
(box :width (clamp (ch 20) (% 50) (ch 60)) "Body"))
```
`calc` accepts one arithmetic expression; `+` and `-` combine lengths, `*`
multiplies a length by a scalar, and `/` divides it by a nonzero scalar.
`min` and `max` choose from one or more permitted lengths. `clamp` takes
minimum, preferred, and maximum lengths. Functions may nest. Bare numbers inside math
are scalars, never implicit `ch` or `lh` lengths.
Units and functions remain data until layout, so viewport and containing-block
changes are resolved again. Floating-point intermediate results are preserved:
`33vw` of an 853-pixel viewport is 281.49 pixels before display quantization.
The buffer backend materializes block geometry in whole lines; fractional
line targets are quantized at that boundary. Vertical `px` lengths are not
part of the author contract. `:overflow hidden` currently clips vertical lines; horizontal
overflow is not pixel-clipped by the buffer backend, so content can extend
beyond the computed width.
Bare geometry numbers, one-element pixel lists, `viewport`, `viewport-height`,
`contain`, and parameterized `fit-content` are rejected. Even zero needs an
axis-appropriate unit: `(px 0)` inline or `(lh 0)` block. Grid `(fr n)` remains a track fraction; Grid placement and
Flex growth/shrink factors remain dimensionless numbers.
Text style declarations accept only font, foreground/background, and
text-decoration properties. Text also accepts the four native node capabilities
described below.
Padding, margin,
border, size, `:outer`, overflow, visibility, and wrapping policy belong only border, size, `:outer`, overflow, visibility, and wrapping policy belong only
to Box. Font and color on Box may feed inherited Text facts, but never give to Box. Font and color on Box may feed inherited Text facts, but never give
Text Box geometry. Text Box geometry.
```elisp ```elisp
(ebox-build (ebox-build
'(box :width (420) '(box :width (px 420)
:padding (1 2) :padding ((lh 1) (ch 2))
:margin (0 1) :margin ((lh 0) (ch 1))
:border (1 solid "#8A93A6") :border ((px 1) solid "#8A93A6")
:color "#263244" :color "#263244"
:background-color "#FFFFFF" :background-color "#FFFFFF"
"A readable panel")) "A readable panel"))
@ -126,6 +268,74 @@ Text Box geometry.
Use `:outer inline` or `:outer block` to state how a Box participates in its Use `:outer inline` or `:outer block` to state how a Box participates in its
parent. The child-layout algorithm still comes from the form name. parent. The child-layout algorithm still comes from the form name.
<a id="native-node-interaction"></a>
### Native node interaction
Text and every Box form accept `:help-echo`, `:pointer`, `:hover-style`, and
`:keymap`. These are explicit node capabilities outside the CSS cascade.
For example, render a Box with dynamic help, a hand pointer, hover paint, and
one callback for both mouse and keyboard activation:
```elisp
(ebox-render-to-buffer
"*Ebox Interaction*"
(ebox-build
`(box :id "action" :padding ((lh 1) (ch 2))
:border ((px 1) solid "#5893A3")
:help-echo ,(ebox-help-create
(lambda ()
(format-time-string "Help requested at %H:%M:%S")))
:pointer hand
:hover-style (:color "#FFFFFF" :background-color "#286477"
:text-decoration-line underline)
:keymap ,(ebox-keymap-create :activate #'describe-mode)
"Click or press RET / SPC here to describe this buffer's mode.")))
```
`:help-echo` accepts a string, a native function with arguments
`(WINDOW OBJECT POSITION)`, or `nil`. Prefer `ebox-help-create` to adapt a
zero-argument business function returning a string or `nil`; Ebox supplies the
hovered buffer's context. Emacs calls the function when requesting help; the DSL
does not call it while building the tree. `:pointer` accepts
`text`, `arrow`, `vdrag`, `modeline`, `hand`, `hdrag`, `nhdrag`, `hourglass`,
or `nil`. Help display and exact pointer appearance depend on the user's Emacs
settings and window system.
`:hover-style` accepts `nil` or an Ebox paint plist containing only `:color`,
`:background-color`, `:text-decoration-line`, `:text-decoration-color`, and
`:text-decoration-style`. Within a rendered line, one declaring node's text
and padding share a native `mouse-face`, with unspecified attributes taken from
that node's base style. Differently colored child text covered by this hover
uses the same hover base. Physical left/right borders are excluded from hover;
horizontal border strokes remain intact. Font metrics and geometry do not change.
The [API reference](ebox-api-reference.en.md#native-node-capabilities)
defines the accepted values. Ebox compiles this plist to native `mouse-face`;
there is no arbitrary native-property passthrough.
Box help, pointer, and keymap cover its rendered content, padding, and border,
excluding that Box's margin and structural newlines; hover follows the rule
above. A nested Text or Box can replace
each capability with an explicit value. Explicit `nil` blocks an enclosing
value; an omitted property leaves the enclosing surface coverage in place.
`:keymap` accepts a native Emacs keymap or `nil`. Prefer `ebox-keymap-create`:
its `:activate` callback takes no arguments and handles `RET`, `[return]`, `SPC`,
and `[mouse-1]`. Use `:bindings` for an alist of key-description strings or event
vectors paired with zero-argument callbacks. The helper uses the event window's
buffer for mouse callbacks, so a callback can directly update a semantic ID
with `(ebox-region-update "action" :help-echo "Activated")`.
Move point into the surface to use keyboard bindings. Raw native keymaps are
also accepted; their mouse commands must handle the event's target buffer
themselves. Ebox uses native command dispatch; it does not create application
state or focus navigation. A hand pointer alone does not bind a click command.
The Playground's [interaction lab](../../../ebox-playground/README.md#native-interaction-lab)
keeps its business functions and state in `interaction-reference.el`, with its
layout in `interaction-reference.ebox`. It demonstrates callbacks, nested
overrides, and replacing or removing all four capabilities through public
region updates.
## 5. Render and publish ## 5. Render and publish
`ebox-render` returns propertized text without changing a live buffer. `ebox-render` returns propertized text without changing a live buffer.
@ -144,7 +354,7 @@ Build a fresh canonical input and use `ebox-commit` for an atomic update:
(ebox-commit (ebox-commit
"*Ebox Guide*" "*Ebox Guide*"
(ebox-build (ebox-build
'(column :padding (1 2) '(column :padding ((lh 1) (ch 2))
(text "Updated notes") (text "Updated notes")
(box :key body "The new input is caller-owned.")))) (box :key body "The new input is caller-owned."))))
``` ```
@ -156,13 +366,40 @@ the last successful update report.
## 6. Query and update a mounted surface ## 6. Query and update a mounted surface
`:id`, `:class`, and `:key` are author metadata. Selectors use ECSS semantics. `:id`, `:class`, and `:key` are author metadata. Selectors use ECSS semantics.
Resolve an `:id` to an opaque, surface-scoped handle before a direct update: In the target mounted buffer, pass the semantic `:id` directly:
```elisp
(with-current-buffer "*Ebox Guide*"
(ebox-region-update "status" :color "#166534"))
```
For an explicit buffer address, resolve an `:id` to an opaque, surface-scoped
handle:
```elisp ```elisp
(let ((handle (ebox-region-resolve "*Ebox Guide*" "status"))) (let ((handle (ebox-region-resolve "*Ebox Guide*" "status")))
(ebox-region-update handle :color "#166534")) (ebox-region-update handle :color "#166534"))
``` ```
The same API replaces native capabilities on Text or Box regions. For the
interaction example above:
```elisp
(with-current-buffer "*Ebox Interaction*"
(ebox-region-update "action" :help-echo "Updated help" :pointer 'arrow
:hover-style '(:background-color "#F6D6AB"))
(ebox-region-update "action" :help-echo nil :pointer nil
:hover-style nil :keymap nil))
```
Update arguments are evaluated Elisp, so literal symbols and plists need
quotes, unlike properties inside already quoted DSL data. Omitted update
properties stay unchanged. Explicit `nil` clears that capability and blocks
an enclosing value.
Ebox snapshots keymaps; replace `:keymap` to publish new bindings instead of
mutating the original map. Clearing a node keymap leaves ordinary buffer and
global bindings available.
`ebox-selector-query-buffer` returns document-ordered matches from a mounted `ebox-selector-query-buffer` returns document-ordered matches from a mounted
buffer. `ebox-selector-update-buffer` applies one style update to all editable buffer. `ebox-selector-update-buffer` applies one style update to all editable
matches. Numeric region ids are diagnostic render metadata, not stable update matches. Numeric region ids are diagnostic render metadata, not stable update
@ -179,7 +416,7 @@ not select an unrelated scroll box elsewhere on the page.
```elisp ```elisp
(ebox-build (ebox-build
'(box :id log :width (420) :height 8 :overflow scroll '(box :id log :width (px 420) :height (lh 8) :overflow scroll
"line 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9")) "line 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9"))
``` ```
@ -193,18 +430,71 @@ explicit viewport with:
## 8. Standalone `.ebox` files ## 8. Standalone `.ebox` files
A `.ebox` file contains one unquoted structural form: A `.ebox` file contains one ordinary Elisp expression whose value is the layout
data passed to `ebox-build`. Quote the whole list for a static layout:
```elisp ```elisp
(column :padding '(1 2) '(column :padding ((lh 1) (ch 2))
(text :color "#263244" "Title") :cross-align center
(box :width '(240) "Body")) (text :color "#263244" "Title")
(box :width (px 240) "Body"))
``` ```
The sibling `ebox-playground` evaluates property expressions before calling The sibling `ebox-playground` reads exactly one expression, evaluates it with
`ebox-build`. Quote list and symbol constants in a `.ebox` file, as shown lexical bindings, and passes the resulting data to `ebox-build`. It does not
above. When calling `ebox-build` directly, pass inert data as in the earlier evaluate individual properties or children. The expression is exactly what you
examples. would write as the argument to `(ebox-build ...)` in an `.el` file; omit that
outer call in `.ebox`.
Put necessary business functions and variables in a same-basename `.el` file
beside the layout. For `notes.ebox`, the Playground loads `notes.el` when it
exists, before evaluating the layout on every preview. This applies to
`C-c C-c`, `ebox-playground-open-file`, and file render entry points. It loads
the exact `.el` source, so saving changes is enough for the next preview;
there is no `require` cache or preference for a stale `.elc`. If the companion
is absent, the standalone `.ebox` still works. Companion load errors stop the
render before the layout is evaluated.
For example, `notes.el` owns the data and action:
```elisp
;;; notes.el --- Notes example behavior -*- lexical-binding: t; -*-
(defvar notes-body "A long article.")
(defun notes-help ()
"Return business help for the article."
(format "Article: %s" notes-body))
(defun notes-activate ()
"Mark the article in the current Ebox buffer."
(ebox-region-update "article" :color "#166534"))
```
`notes.ebox` stays focused on the layout:
```elisp
`(column :padding ((lh 1) (ch 2))
(box :id "article" :width (px 480)
:help-echo ,(ebox-help-create #'notes-help)
:pointer hand
:keymap ,(ebox-keymap-create :activate #'notes-activate)
,notes-body))
```
Backquote keeps layout data literal, `,` inserts a value, and `,@` inserts a
list of children. Avoid helpers whose only purpose is hiding repeated DSL
property lists. Each preview loads the companion and then evaluates the layout
once; ordinary Elisp variable definitions determine whether application state
is initialized, retained, or reset.
Migration from the old property-evaluation format is explicit: add a quote to
the whole static layout and remove the quotes around its list and symbol
properties. For dynamic layouts, use backquote and commas at the values to
evaluate. For example, old `:padding '(1 2)` becomes
`:padding ((lh 1) (ch 2))` inside a quoted layout, and a computed pixel width
becomes `:width (px ,(+ 200 40))` inside a backquoted layout. Bare structural forms
are no longer interpreted as DSL automatically; there is no legacy-format
auto-detection.
## 9. Typed integration API ## 9. Typed integration API
@ -246,7 +536,7 @@ the forest and source index together as one `CanonicalEboxInput`.
(root (root
(ebox-box-create (ebox-box-create
:layout (ebox-row-layout-create :layout (ebox-row-layout-create
:item-gap 1 :cross-align 'center) :item-gap '(ch 1) :cross-align 'center)
:children (list left right) :children (list left right)
:source-handle root-handle :source-handle root-handle
:owned-facts :owned-facts
@ -277,6 +567,7 @@ From the repository root:
```sh ```sh
make docs-contract-tests EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs make docs-contract-tests EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs
make interaction-tests EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs
make dsl-tests EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs make dsl-tests EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs
make check EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs make check EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs
``` ```

View File

@ -33,10 +33,10 @@ Ebox 文档由 Text 和 Box 节点组成。author 语法只有七个入口:
```elisp ```elisp
(defvar ebox-guide-input (defvar ebox-guide-input
(ebox-build (ebox-build
'(column :padding (1 2) '(column :padding ((lh 1) (ch 2))
:border (1 solid "#8A93A6") :border ((px 1) solid "#8A93A6")
(text :color "#263244" "Research notes") (text :color "#263244" "Research notes")
(row :item-gap 1 (row :item-gap (ch 1)
(box :id "status" :background-color "#F4F6FB" "Inbox") (box :id "status" :background-color "#F4F6FB" "Inbox")
(box :background-color "#EEF2FF" "Archive"))))) (box :background-color "#EEF2FF" "Archive")))))
``` ```
@ -47,6 +47,33 @@ generation 保持为一个整体。普通 author 代码只需把这个值原样
需要空矩形区域时,使用只有几何 property、没有子节点的 `box`,不需要额外节点类型。 需要空矩形区域时,使用只有几何 property、没有子节点的 `box`,不需要额外节点类型。
### 优先利用默认值和继承
默认只写改变预期效果的属性:省略冗余默认值,把共同的文字样式放到已有的共同父节点,
子节点只写差异。参考示例正在讲解的属性仍然显式写出,即使它恰好采用默认值。
eBox 自动继承 `:color`、`:font-family`、`:font-size`、`:font-weight`、
`:font-style`、`:text-align`、`:wrap-mode` 和 `:visibility`。几何属性和背景色不继承;
子节点未绘制背景时,可以透出父节点的背景。例如:
```elisp
(ebox-build
'(column :width (ch 40) :color "#1F2328" :bgcolor "#FAF7F0"
(box "Shared text color")
(box :color "#B45309" "Local accent")
(box :height (lh 1))))
```
继承不会扩大属性的声明范围。例如 `:text-align` 只能声明在 `box` 上,不能因为它可以继承,
就把它移到 `row`、`column`、`flex` 或 `grid` 上。
在这个普通 Column 中,分隔框会填满可用宽度,并通过 `:height (lh 1)` 明确保留一行,
无需重复设置宽度和背景。空 Box 的 `auto` 内容高度为零;需要一行留白时显式设置
`:height (lh 1)`。padding 和 border 仍可增加各自的外部占用。
这取决于布局上下文Row 中 auto 宽度的子项按固有宽度排布Flex/Grid 也有各自的子项尺寸规则。
不要把所有 `stretch` 都替换成 `auto`,也不要删除正在覆盖继承样式、样式表或其他简写的值。
没有其他声明设置对应边距时,可以用 `:padding-inline``:padding-block` 省去零值轴。
## 3. 用布局名称直接表达意图 ## 3. 用布局名称直接表达意图
普通内容用 `box`;直接的一维组合用 `row``column`;需要分配剩余空间或换行时 普通内容用 `box`;直接的一维组合用 `row``column`;需要分配剩余空间或换行时
@ -59,9 +86,9 @@ generation 保持为一个整体。普通 author 代码只需把这个值原样
```elisp ```elisp
(ebox-build (ebox-build
'(row :item-gap 2 :cross-align center '(row :item-gap (ch 2) :cross-align center
(box :width 12 "Left") (box :width (ch 12) "Left")
(box :width 12 "Right"))) (box :width (ch 12) "Right")))
``` ```
### Flex ### Flex
@ -71,9 +98,9 @@ Flex 容器 property 属于 `flex`。participation property 直接属于子 `box
```elisp ```elisp
(ebox-build (ebox-build
'(flex :width (480) '(flex :width (px 480)
:flex-flow (row wrap) :flex-flow (row wrap)
:gap (1 (12)) :gap ((lh 1) (px 12))
(box :flex (1 1 auto) "Primary") (box :flex (1 1 auto) "Primary")
(box :flex-grow 2 "Secondary"))) (box :flex-grow 2 "Secondary")))
``` ```
@ -85,10 +112,10 @@ placement property 同样直接属于子 `box`。
```elisp ```elisp
(ebox-build (ebox-build
'(grid :width (640) '(grid :width (px 640)
:grid-template-columns ((200) (fr 1) (fr 1)) :grid-template-columns ((px 200) (fr 1) (fr 1))
:grid-template-rows (1 1) :grid-template-rows ((lh 1) (lh 1))
:gap (1 (12)) :gap ((lh 1) (px 12))
(box :grid-column (1 :span 3) "Header") (box :grid-column (1 :span 3) "Header")
(box :grid-column 1 :grid-row 2 "Navigation") (box :grid-column 1 :grid-row 2 "Navigation")
(box :grid-column 2 :grid-row 2 "Main") (box :grid-column 2 :grid-row 2 "Main")
@ -97,17 +124,108 @@ placement property 同样直接属于子 `box`。
## 4. 使用几何与绘制 property ## 4. 使用几何与绘制 property
普通横向数字表示字符列,`(240)` 这样的单元素 list 表示像素宽度;纵向数字表示 Box 几何统一使用 `(单位 数值)`。宽高及其最小/最大值、内外边距、gap、边框宽度、
行数。Text 只接受字体、前景/背景和文本装饰 propertypadding、margin、border、 Flex basis 和 Grid 固定轨道都使用这套格式。Ebox 实现 CSS 尺寸语义的一个子集,
输入是 Elisp 数据,不接受 `"80ch"` 这样的 CSS 字符串。
| 单位 | 含义 |
| --- | --- |
| `(px 240)` | 240 像素。 |
| `(% 50)` | 该属性所参照的包含块尺寸的 50%。 |
| `(vw 100)` | 视口宽度的 100%。 |
| `(vh 100)` | 视口高度的 100%。 |
| `(ch 80)` | 当前有效字体中字符 `0` 的排版前进宽度的 80 倍。 |
| `(lh 3)` | 三个有效行高。 |
`ch` 不表示任意字符或汉字的个数。`1vw`、`1vh` 分别表示视口对应轴的 1%。
显示中的 Ebox 视口是目标窗口的
可用正文区域,不包含 mode-line 和 header-line因此 Playground 使用预览窗口。
<a id="size-unit-constraints"></a>
### 按属性限制单位
下表是所有 Ebox 作者入口共同遵循的单位契约。Ebox 当前支持的书写模式中inline
表示横向block 表示纵向。这是针对 Emacs 收窄后的规则,比 CSS 的通用长度模型严格。
| 值的用途 | 允许单位 | 对应属性 |
| --- | --- | --- |
| Inline 几何 | `px`、`ch`、`vw`、`%` | `width`、`min-width`、`max-width`;左右和 inline padding/margin`column-gap`Row 的 `item-gap`Grid 列轨道。 |
| Block 几何 | `lh`、`vh`、`%` | `height`、`min-height`、`max-height`;上下和 block padding/margin`row-gap`Column 的 `item-gap`Grid 行轨道。 |
| Flex 主轴尺寸 | 所在父 Flex 主轴允许的单位 | `flex-basis``(grow shrink basis)` 中的 basisrow 方向用 inline 单位column 方向用 block 单位。 |
| 边框绘制厚度 | `px`、`ch`、`lh`、`vw`、`vh` | `border-width`、各侧边框宽度、border 简写中的宽度;不接受百分比。 |
`calc`、`min`、`max`、`clamp` 的所有分支以及嵌套的 Grid 轨道函数,都递归遵循
对应的单位限制。出现不允许的单位时,`ebox-build` 初次构造树就报错;即使计算会
抵消它,或最终会选中其他分支,也不例外。例如 `:height (px 24)`、`:width (lh 3)`
`:height (min (lh 2) (px 24))` 都不接受。边框厚度是独立的绘制值,因此
`:border ((px 1) solid "red")` 可以同时设置四个方向的边框。
样式表和动态更新也遵循同一契约:计算样式确定后、布局开始前,重新检查 Flex
父容器方向与子项 basis。非法更新不会发布到已有 buffer。数字 `:flex 1` 的隐含
basis 为 `(% 0)`,因此可用于两个主轴;显式 basis 仍按父方向验证。
简写展开到各个方向后逐项验证。单值 `padding`、`margin`、`gap` 会同时用于两轴,
因此这种共享长度只能使用 `%`;通常直接分开两轴,例如
`:padding ((lh 1) (ch 2))`、`:gap ((lh 1) (px 12))`。
只设置一轴时,可以写 `:padding-inline (px 12)``:padding-block (lh 1)`
百分比宽度参照包含块宽度;百分比高度需要确定的包含块高度,否则按该属性
的尺寸不确定规则处理。四个方向的百分比 padding、margin 都参照包含块宽度。
边框宽度不接受百分比。
此子集暂不支持负 margin 和 `auto` margin当前 buffer 后端没有实现外边距重叠
所需的几何能力。
| 属性 | 关键词 | 默认值 |
| --- | --- | --- |
| `width`、`height` | `auto`、`min-content`、`max-content`、`fit-content`、`stretch` | `auto` |
| `min-width`、`min-height` | `auto`、`min-content`、`max-content`、`fit-content`、`stretch` | `auto` |
| `max-width`、`max-height` | `none`、`min-content`、`max-content`、`fit-content`、`stretch` | `none` |
普通块级根容器有可用视口时,`auto` 宽度填满可用空间,`auto` 高度由内容决定。
`min-content`、`max-content` 请求内容固有尺寸;`fit-content` 在这两个固有尺寸之间
适应可用空间;`stretch` 让 margin box 填满可用空间。`none` 表示不限制最大尺寸。
自动最小尺寸取决于布局;显式设置横向最小值 `(px 0)` 或纵向最小值 `(lh 0)`
可允许 item 缩小到其自动内容最小尺寸以下。没有 padding、border 和显式高度的空
Box 高度为零;`(box :height (lh 1))` 明确保留一行空白。
四种尺寸函数可以组合单位,不会在布局阶段执行任意 Lisp
```elisp
'(column :width (min (% 100) (ch 80))
:height (calc (- (vh 100) (lh 1)))
(box :width (max (px 120) (% 25)) "侧栏")
(box :width (clamp (ch 20) (% 50) (ch 60)) "正文"))
```
`calc` 接受一个算术表达式;`+`、`-` 组合长度,`*` 将长度乘以标量,`/` 将长度
除以非零标量。`min`、`max` 从一个或多个允许的长度中选取最小值、最大值;`clamp` 的三个
参数依次为最小值、首选值、最大值。函数可以嵌套。算术表达式内的裸数字只是标量,
不会隐式变成 `ch``lh`
单位和函数一直保留为数据,到布局阶段才解析,因此视口或包含块变化后会重新计算。
中间结果保留小数,例如 853 像素视口的 `33vw` 在显示量化前是 281.49 像素。
当前 buffer 后端按完整行生成 block 几何,小数行目标在这个边界量化。作者接口不
接受纵向 `px` 长度。
`:overflow hidden` 当前只裁剪纵向行buffer 后端尚未实现横向像素裁剪,因此内容
可能超出已经计算确定的容器宽度。
不再接受几何裸数字、单元素像素列表、`viewport`、`viewport-height`、`contain`
以及带参数的 `fit-content`。零长度也需要对应轴允许的单位,例如横向 `(px 0)`
纵向 `(lh 0)`。Grid 的 `(fr n)` 仍表示轨道
份额Grid 放置索引、Flex grow/shrink 因子仍是无量纲数字。
Text 的样式声明只接受字体、前景/背景和文本装饰 property此外Text 还接受下文
介绍的四种原生节点能力。padding、margin、border、
尺寸、`:outer`、overflow、可见性和换行策略只属于 Box。Box 上的字体与颜色可以 尺寸、`:outer`、overflow、可见性和换行策略只属于 Box。Box 上的字体与颜色可以
作为其 Text 子树的继承来源,但不会让 Text 获得 Box 几何。 作为其 Text 子树的继承来源,但不会让 Text 获得 Box 几何。
```elisp ```elisp
(ebox-build (ebox-build
'(box :width (420) '(box :width (px 420)
:padding (1 2) :padding ((lh 1) (ch 2))
:margin (0 1) :margin ((lh 0) (ch 1))
:border (1 solid "#8A93A6") :border ((px 1) solid "#8A93A6")
:color "#263244" :color "#263244"
:background-color "#FFFFFF" :background-color "#FFFFFF"
"A readable panel")) "A readable panel"))
@ -116,6 +234,65 @@ placement property 同样直接属于子 `box`。
`:outer inline``:outer block` 表达 Box 如何参与父布局。子布局算法仍由 form `:outer inline``:outer block` 表达 Box 如何参与父布局。子布局算法仍由 form
名称决定。 名称决定。
<a id="native-node-interaction"></a>
### 原生节点交互
Text 与所有 Box form 都接受 `:help-echo`、`:pointer`、`:hover-style` 和
`:keymap`。它们是 CSS cascade 之外的显式节点能力。例如,下面的 Box 组合了
动态帮助、手形指针、悬停绘制,以及供鼠标和键盘激活共用的回调:
```elisp
(ebox-render-to-buffer
"*Ebox Interaction*"
(ebox-build
`(box :id "action" :padding ((lh 1) (ch 2))
:border ((px 1) solid "#5893A3")
:help-echo ,(ebox-help-create
(lambda ()
(format-time-string "Help requested at %H:%M:%S")))
:pointer hand
:hover-style (:color "#FFFFFF" :background-color "#286477"
:text-decoration-line underline)
:keymap ,(ebox-keymap-create :activate #'describe-mode)
"Click or press RET / SPC here to describe this buffer's mode.")))
```
`:help-echo` 接受字符串、参数为 `(WINDOW OBJECT POSITION)` 的原生函数或 `nil`
推荐通过 `ebox-help-create` 适配返回字符串或 `nil` 的零参数业务函数,由 Ebox 提供
悬停 buffer 的上下文。Emacs 在请求帮助时调用函数DSL 构造树时不会调用它。
`:pointer` 接受 `text`
`arrow`、`vdrag`、`modeline`、`hand`、`hdrag`、`nhdrag`、`hourglass` 或 `nil`
帮助的显示方式和指针的具体外观由用户的 Emacs 设置及窗口系统决定。
`:hover-style` 接受 `nil`,或仅包含 `:color`、`:background-color`、
`:text-decoration-line`、`:text-decoration-color`、`:text-decoration-style` 的
Ebox 绘制 plist。同一渲染行中一个声明节点覆盖的文字与 padding 共享原生
`mouse-face`,未指定属性采用该节点的基础样式;被该悬停覆盖的不同色子文本也使用
同一悬停基础样式。物理左右边框不参与悬停,水平边框线条保持完整;字体度量与几何
不改变。允许的值见
[API 参考](ebox-api-reference.zh.md#原生节点能力)。Ebox 将这个 plist 编译为原生
`mouse-face`,不提供任意原生 property 透传。
Box 的帮助、指针和 keymap 覆盖渲染内容、padding 和 border不包含该 Box 自身的
margin 及结构性换行符;悬停遵循上面的规则。嵌套的 Text 或 Box 可通过显式值替换
每项能力;显式 `nil` 阻止外层值覆盖,
省略属性则保留外层交互范围的覆盖。
`:keymap` 接受原生 Emacs keymap 或 `nil`。推荐使用 `ebox-keymap-create`
`:activate` 的回调不带参数,处理 `RET`、`[return]`、`SPC` 和 `[mouse-1]`
`:bindings` 接受 alist按键描述字符串或事件向量对应零参数回调。
辅助函数让鼠标回调在事件窗口的 buffer 中运行,因此回调可以直接按语义 ID 更新:
`(ebox-region-update "action" :help-echo "Activated")`
使用键盘绑定时,先把 point 移到交互范围内。仍可直接传入原生 keymap其鼠标命令
需要自行处理事件的目标 buffer。Ebox 使用原生命令分派,不创建应用状态或焦点导航。
单独设置手形指针不会绑定点击命令。
Playground 的[交互实验室](../../../ebox-playground/README.zh-CN.md#native-interaction-lab)
把业务函数与状态放在 `interaction-reference.el`,把布局放在
`interaction-reference.ebox`,展示回调、嵌套覆盖,以及通过公共 region 更新替换或
清除四种能力。
## 5. 渲染与发布 ## 5. 渲染与发布
`ebox-render` 返回带属性文本,不修改 live buffer`ebox-render-to-buffer` 挂载 `ebox-render` 返回带属性文本,不修改 live buffer`ebox-render-to-buffer` 挂载
@ -133,7 +310,7 @@ retained TP surface 并返回 buffer。它们和 `ebox-display-buffer` 都接收
(ebox-commit (ebox-commit
"*Ebox Guide*" "*Ebox Guide*"
(ebox-build (ebox-build
'(column :padding (1 2) '(column :padding ((lh 1) (ch 2))
(text "Updated notes") (text "Updated notes")
(box :key body "The new input is caller-owned.")))) (box :key body "The new input is caller-owned."))))
``` ```
@ -143,14 +320,36 @@ retained TP surface 并返回 buffer。它们和 `ebox-display-buffer` 都接收
## 6. 查询和更新 mounted surface ## 6. 查询和更新 mounted surface
`:id`、`:class` 与 `:key` 是 author metadata。Selector 使用 ECSS 语义。直接更新 `:id`、`:class` 与 `:key` 是 author metadata。Selector 使用 ECSS 语义。
前,把 `:id` 解析为不透明、surface-scoped 的 handle 在目标已挂载 buffer 中,可以直接传入语义 `:id`
```elisp
(with-current-buffer "*Ebox Guide*"
(ebox-region-update "status" :color "#166534"))
```
需要显式指定 buffer 时,把 `:id` 解析为不透明、surface-scoped 的 handle
```elisp ```elisp
(let ((handle (ebox-region-resolve "*Ebox Guide*" "status"))) (let ((handle (ebox-region-resolve "*Ebox Guide*" "status")))
(ebox-region-update handle :color "#166534")) (ebox-region-update handle :color "#166534"))
``` ```
同一 API 可以替换 Text 或 Box region 的原生能力。对上面的交互示例:
```elisp
(with-current-buffer "*Ebox Interaction*"
(ebox-region-update "action" :help-echo "Updated help" :pointer 'arrow
:hover-style '(:background-color "#F6D6AB"))
(ebox-region-update "action" :help-echo nil :pointer nil
:hover-style nil :keymap nil))
```
更新参数是正常求值的 Elisp因此符号和字面量 plist 需要 quote它们与已经被 quote
的 DSL 数据不同。省略的更新属性保持不变,显式 `nil` 清除对应能力并阻止外层值覆盖。
Ebox 对 keymap 创建快照;发布新绑定时应替换 `:keymap`,而不是修改原 map。
清除节点 keymap 后,普通 buffer 和全局绑定仍然可用。
`ebox-selector-query-buffer` 按文档顺序返回 mounted buffer 中的匹配; `ebox-selector-query-buffer` 按文档顺序返回 mounted buffer 中的匹配;
`ebox-selector-update-buffer` 对所有可编辑匹配应用一次样式更新。数字 region id `ebox-selector-update-buffer` 对所有可编辑匹配应用一次样式更新。数字 region id
只是诊断用渲染 metadata不是稳定更新 handle。 只是诊断用渲染 metadata不是稳定更新 handle。
@ -164,7 +363,7 @@ retained TP surface 并返回 buffer。它们和 `ebox-display-buffer` 都接收
```elisp ```elisp
(ebox-build (ebox-build
'(box :id log :width (420) :height 8 :overflow scroll '(box :id log :width (px 420) :height (lh 8) :overflow scroll
"line 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9")) "line 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9"))
``` ```
@ -177,17 +376,62 @@ retained TP surface 并返回 buffer。它们和 `ebox-display-buffer` 都接收
## 8. 独立 `.ebox` 文件 ## 8. 独立 `.ebox` 文件
`.ebox` 文件包含一个不加 quote 的结构 form `.ebox` 文件包含一个普通 Elisp 表达式,其求值结果就是交给 `ebox-build` 的布局
数据。静态布局对整个列表加 quote
```elisp ```elisp
(column :padding '(1 2) '(column :padding ((lh 1) (ch 2))
(text :color "#263244" "Title") :cross-align center
(box :width '(240) "Body")) (text :color "#263244" "Title")
(box :width (px 240) "Body"))
``` ```
同级 `ebox-playground` 会先求值 property 表达式,再调用 `ebox-build`。因此 `.ebox` 同级 `ebox-playground` 读取唯一一个表达式,以词法绑定求值,再将得到的数据传给
文件中的 list 与 symbol 常量要加 quote如上所示。直接调用 `ebox-build` 时,则像 `ebox-build`,不再单独求值属性或子节点。这与 `.el``(ebox-build ...)` 的参数
前面的例子一样传入 inert data。 表达式完全一致;在 `.ebox` 中只需省略外层的 `ebox-build` 调用。
必要的业务函数与变量放在布局旁边的同名 `.el` 文件中。预览 `notes.ebox` 时,如果
存在 `notes.el`Playground 会在每次求值布局前加载它;`C-c C-c`、
`ebox-playground-open-file` 和文件渲染入口都遵循这个顺序。加载的是指定的 `.el`
源码,因此保存修改后再次预览即可生效,不使用 `require` 缓存,也不优先选择旧的
`.elc`。没有配套文件时,独立 `.ebox` 仍可使用;配套文件加载出错时,渲染停止,
不会继续求值布局。
例如,由 `notes.el` 定义数据与操作:
```elisp
;;; notes.el --- Notes example behavior -*- lexical-binding: t; -*-
(defvar notes-body "A long article.")
(defun notes-help ()
"Return business help for the article."
(format "Article: %s" notes-body))
(defun notes-activate ()
"Mark the article in the current Ebox buffer."
(ebox-region-update "article" :color "#166534"))
```
`notes.ebox` 专注表达布局:
```elisp
`(column :padding ((lh 1) (ch 2))
(box :id "article" :width (px 480)
:help-echo ,(ebox-help-create #'notes-help)
:pointer hand
:keymap ,(ebox-keymap-create :activate #'notes-activate)
,notes-body))
```
反引号保留布局数据,`,` 插入值,`,@` 展开子节点列表。不要仅为了隐藏重复 DSL
属性列表而添加辅助函数。每次预览先加载配套文件,再求值一次布局;普通 Elisp
变量定义决定应用状态是初始化、保留还是重置。
旧的属性求值格式需要显式迁移:静态布局在最外层加 quote去掉内部 list 和 symbol
属性的 quote动态布局使用反引号并在需要求值的位置加逗号。例如旧的
`:padding '(1 2)` 改成引用列表内的 `:padding ((lh 1) (ch 2))`;计算像素宽度时,
在反引号列表内写 `:width (px ,(+ 200 40))`
不再自动将裸结构 form 解释为 DSL也不自动识别旧格式。
## 9. Typed 集成 API ## 9. Typed 集成 API
@ -228,7 +472,7 @@ declarations 投影出的 node-owned facts。最后封存 builder把 forest
(root (root
(ebox-box-create (ebox-box-create
:layout (ebox-row-layout-create :layout (ebox-row-layout-create
:item-gap 1 :cross-align 'center) :item-gap '(ch 1) :cross-align 'center)
:children (list left right) :children (list left right)
:source-handle root-handle :source-handle root-handle
:owned-facts :owned-facts
@ -258,6 +502,7 @@ Rust 模块是可选加速器Ebox 加载时不会构建它:
```sh ```sh
make docs-contract-tests EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs make docs-contract-tests EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs
make interaction-tests EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs
make dsl-tests EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs make dsl-tests EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs
make check EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs make check EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs
``` ```

View File

@ -9,10 +9,12 @@
(define-error 'ebox-surface-capability-error (define-error 'ebox-surface-capability-error
"Ebox surface capability violation") "Ebox surface capability violation")
(declare-function ebox-get "ebox" (box property))
(defun ebox-buffer-validate-border-capability (style) (defun ebox-buffer-validate-border-capability (style)
"Return STYLE when its border can be represented exactly by Emacs text." "Return STYLE when its border can be represented exactly by Emacs text."
(dolist (side '(top bottom) style) (dolist (side '(top bottom) style)
(let ((width (or (plist-get (let ((width (or (ebox-get
style (intern (format ":border-%s-pixel" side))) style (intern (format ":border-%s-pixel" side)))
0))) 0)))
(when (> width 1) (when (> width 1)

View File

@ -18,6 +18,8 @@
(require 'ebox-child-range) (require 'ebox-child-range)
(require 'ebox-tree) (require 'ebox-tree)
(declare-function ebox--flex-validate-item-bases "ebox-flex" (props children))
(defun ebox-canonical--validate-plist (plist context) (defun ebox-canonical--validate-plist (plist context)
"Validate PLIST shape for error CONTEXT and return PLIST." "Validate PLIST shape for error CONTEXT and return PLIST."
(unless (proper-list-p plist) (unless (proper-list-p plist)
@ -321,10 +323,11 @@ and author TAG."
(eq (plist-get property :group) 'paint))))) (eq (plist-get property :group) 'paint)))))
nil)))) nil))))
(defun ebox-canonical-facts-from-declarations (tag declarations) (defun ebox-canonical-facts-from-declarations (tag declarations &optional interactions)
"Return immutable node-owned facts for TAG from DECLARATIONS. "Return immutable node-owned facts for TAG from DECLARATIONS.
Source declarations stay behind the source handle; the returned value contains Source declarations stay behind the source handle; the returned value contains
only the current canonical-node projection needed before G4c separates paint." only the current canonical-node projection needed before G4c separates paint.
INTERACTIONS is a plist of fixed node capabilities, separate from CSS."
(let ((predicate (let ((predicate
(if (eq tag 'text) (if (eq tag 'text)
#'ebox-canonical--text-engine-property-p #'ebox-canonical--text-engine-property-p
@ -335,7 +338,12 @@ only the current canonical-node projection needed before G4c separates paint."
predicate predicate
(if (eq tag 'text) "Ebox Text" "Ebox Box") tag))) (if (eq tag 'text) "Ebox Text" "Ebox Box") tag)))
(ebox-canonical--make-facts (ebox-canonical--make-facts
:engine-properties engine :engine-properties
(append engine
(when interactions
(list :surface-properties
(ebox-interaction-surface-properties
(ebox-interaction-normalize interactions)))))
:style-required-p style-required-p :style-required-p style-required-p
:specified-property-ids :specified-property-ids
(cl-loop for (id _value) on canonical by #'cddr collect id))))) (cl-loop for (id _value) on canonical by #'cddr collect id)))))
@ -528,6 +536,8 @@ algorithm. `:source-handle' remains the node's only author source input."
'(normal row column flex grid)) '(normal row column flex grid))
(error "Ebox Box Layout is not implemented: %S" (error "Ebox Box Layout is not implemented: %S"
(ebox-layout-config-kind layout))) (ebox-layout-config-kind layout)))
(when (eq (ebox-layout-config-kind layout) 'flex)
(ebox--flex-validate-item-bases (ebox-layout-config-props layout) children))
(when props (when props
(error "Ebox Box accepts only normalized owned facts")) (error "Ebox Box accepts only normalized owned facts"))
(when (not (ebox-canonical-facts-p facts)) (when (not (ebox-canonical-facts-p facts))

View File

@ -145,7 +145,11 @@ function."
(if outer-field (cdr outer-field) 'block))) (if outer-field (cdr outer-field) 'block)))
(style-properties (style-properties
(ebox-dsl--without-keys (ebox-dsl--without-keys
properties ebox-dsl--source-fields)) properties (append ebox-dsl--source-fields
ebox-interaction-properties)))
(interactions
(ebox-interaction-normalize
(ebox-dsl--keep-keys properties ebox-interaction-properties)))
(declarations (declarations
(ebox-dsl--compile-style (ebox-dsl--compile-style
tag style-properties context))) tag style-properties context)))
@ -158,6 +162,7 @@ function."
:id (ebox-dsl--source-field properties :id context) :id (ebox-dsl--source-field properties :id context)
:class (ebox-dsl--source-field properties :class context) :class (ebox-dsl--source-field properties :class context)
:declarations declarations :declarations declarations
:interactions interactions
:provenance (list :adapter 'ebox-dsl :tag tag :provenance (list :adapter 'ebox-dsl :tag tag
:source-present-p (not (null source)))) :source-present-p (not (null source))))
:outer outer :outer outer
@ -166,7 +171,7 @@ function."
tag declarations context) tag declarations context)
:declarations declarations :declarations declarations
:owned-facts :owned-facts
(ebox-canonical-facts-from-declarations tag declarations))))) (ebox-canonical-facts-from-declarations tag declarations interactions)))))
(defconst ebox-dsl--tags '(text box row column flex grid) (defconst ebox-dsl--tags '(text box row column flex grid)
"The complete Ebox author tag set.") "The complete Ebox author tag set.")

View File

@ -11,6 +11,7 @@
(require 'subr-x) (require 'subr-x)
(require 'ebox-layout-config) (require 'ebox-layout-config)
(require 'ebox-layout) (require 'ebox-layout)
(require 'ebox-size)
(declare-function ebox--flex-fragment-allocation-key (declare-function ebox--flex-fragment-allocation-key
"ebox-fragment" "ebox-fragment"
@ -180,85 +181,63 @@ That text is preformatted and must not be sent through the default wrapper."
(defun ebox--flex-container-content-props (props raw-props wrapper-box) (defun ebox--flex-container-content-props (props raw-props wrapper-box)
"Return flex layout PROPS adjusted to WRAPPER-BOX's content area." "Return flex layout PROPS adjusted to WRAPPER-BOX's content area."
(if (null wrapper-box) (let ((props (copy-sequence props)))
props (when wrapper-box
(let ((props (copy-sequence props)))
(when (or (plist-member raw-props :width) (when (or (plist-member raw-props :width)
(and (null (plist-get props :width)) (and (null (plist-get props :width))
(ebox--viewport-pixel-width nil))) (ebox--viewport-pixel-width nil)))
(when-let* ((content-viewport (when-let* ((content-viewport
(ebox--wrapper-content-viewport-pixel wrapper-box))) (ebox--wrapper-content-viewport-pixel wrapper-box)))
(plist-put props :width (list content-viewport)))) (plist-put props :width (list 'px content-viewport))))
(when (and (plist-member raw-props :height) (when (and (plist-member raw-props :height)
(ebox--resolve-size-content-height (ebox--resolve-size-content-height
wrapper-box (ebox-get wrapper-box :height) nil)) wrapper-box (ebox-get wrapper-box :height) nil))
(plist-put props :height (ebox--content-height wrapper-box 1))) (plist-put props :height (list 'lh (ebox--content-height wrapper-box 0)))))
props))) (ebox--layout-used-gaps
props (ebox--flex-horizontal-value (plist-get props :width))
(ebox--flex-line-value (plist-get props :height)) wrapper-box)))
(defconst ebox--preferred-size-keywords (defconst ebox--preferred-size-keywords
'(auto min-content max-content fit-content stretch contain) '(auto min-content max-content fit-content stretch)
"Supported CSS preferred-size keywords.") "Supported CSS preferred-size keywords.")
(defconst ebox--min-size-keywords (defconst ebox--min-size-keywords
'(auto min-content max-content fit-content stretch contain) '(auto min-content max-content fit-content stretch)
"Supported CSS min-size keywords.") "Supported CSS min-size keywords.")
(defconst ebox--max-size-keywords (defconst ebox--max-size-keywords
'(none min-content max-content fit-content stretch contain) '(none min-content max-content fit-content stretch)
"Supported CSS max-size keywords.") "Supported CSS max-size keywords.")
(defun ebox--normalize-horizontal-size-value (value allowed-keywords) (defun ebox--normalize-horizontal-size-value (value allowed-keywords)
"Normalize VALUE as a width-like size. "Validate VALUE as a width-like size without resolving relative units.
ALLOWED-KEYWORDS is the CSS keyword set valid for the property being parsed." ALLOWED-KEYWORDS is the CSS keyword set valid for the property being parsed."
(cond (cond
((null value) nil) ((null value) nil)
((ebox--viewport-size-value-p value) 'viewport)
((and (consp value)
(null (cdr value))
(symbolp (car value))
(not (eq (car value) 'viewport)))
(ebox--normalize-horizontal-size-value (car value) allowed-keywords))
((memq value allowed-keywords) value) ((memq value allowed-keywords) value)
((and (consp value) (eq (car value) 'fit-content)) ((ebox-size-value-p value nil t) value)
(if-let* ((limit (cadr value))) (t (error "ebox: unsupported size %S; use (UNIT NUMBER) or a size keyword"
(let ((pixels (if (ebox--viewport-size-value-p limit) value))))
'viewport
(ebox--nonnegative-horizontal-size-pixels limit nil))))
(unless pixels
(error "ebox: invalid fit-content width limit: %S" limit))
(list 'fit-content pixels))
(unless (memq 'fit-content allowed-keywords)
(error "ebox: fit-content is not valid here"))
'fit-content))
(t
(let ((pixels (ebox--nonnegative-horizontal-size-pixels value nil)))
(unless pixels
(error "ebox: unsupported width value: %S" value))
pixels))))
(defun ebox--flex-horizontal-value (value &optional default) (defun ebox--flex-horizontal-value (value &optional default)
"Return VALUE as pixels using Ebox horizontal units." "Resolve VALUE as horizontal pixels, or return DEFAULT when unresolved."
(cond (cond
((null value) (or default (ebox--viewport-pixel-width nil))) ((null value) (or default (ebox--viewport-pixel-width nil)))
((memq value '(auto stretch)) ((memq value '(auto stretch))
(or default (ebox--viewport-pixel-width nil))) (or default (ebox--viewport-pixel-width nil)))
(t (ebox--nonnegative-horizontal-size-pixels value default)))) ((numberp value) value)
(t (or (ebox--size-pixels value nil 'width) default))))
(defun ebox--flex-line-value (value &optional default) (defun ebox--flex-line-value (value &optional default)
"Return VALUE as line count using Ebox vertical units." "Resolve VALUE as whole display lines, using DEFAULT when unresolved."
(cond (cond
((null value) default) ((null value) default)
((eq value 'auto) default) ((eq value 'auto) default)
((and (consp value) (numberp (car value))) ((numberp value) (max 0 (floor value)))
(let ((lines (floor (car value)))) ((ebox-size-value-p value nil t)
(when (< lines 0) (if-let* ((pixels (ebox--size-pixels value nil 'height)))
(error "ebox: flex line values cannot be negative: %S" value)) (max 0 (floor (/ pixels (float (ebox--size-line-height nil)))))
lines)) default))
((numberp value)
(let ((lines (floor value)))
(when (< lines 0)
(error "ebox: flex line values cannot be negative: %S" value))
lines))
(t default))) (t default)))
(defun ebox--flex-main-value (axis value &optional default) (defun ebox--flex-main-value (axis value &optional default)
@ -276,9 +255,9 @@ ALLOWED-KEYWORDS is the CSS keyword set valid for the property being parsed."
(defun ebox--flex-gap-pair (gap) (defun ebox--flex-gap-pair (gap)
"Return (ROW-GAP . COLUMN-GAP) raw values from GAP shorthand." "Return (ROW-GAP . COLUMN-GAP) raw values from GAP shorthand."
(cond (cond
((and (listp gap) ((and (proper-list-p gap)
(= (length gap) 2) (= (length gap) 2)
(not (keywordp (car gap)))) (not (ebox-size-value-p gap nil t)))
(cons (nth 0 gap) (nth 1 gap))) (cons (nth 0 gap) (nth 1 gap)))
(t (cons gap gap)))) (t (cons gap gap))))
@ -290,18 +269,45 @@ ALLOWED-KEYWORDS is the CSS keyword set valid for the property being parsed."
resolved)) resolved))
(defun ebox--flex-row-gap-value (value) (defun ebox--flex-row-gap-value (value)
"Return canonical nonnegative line count for FlexConfig row gap VALUE." "Return canonical, unresolved FlexConfig row gap VALUE."
(unless (or (null value) (ebox-size-validate-for-axis (or value '(lh 0)) :row-gap 'block))
(numberp value)
(and (consp value)
(numberp (car value))
(null (cdr value))))
(error "Ebox FlexConfig :row-gap is invalid: %S" value))
(or (ebox--flex-line-value value 0) 0))
(defun ebox--flex-column-gap-value (value) (defun ebox--flex-column-gap-value (value)
"Return canonical nonnegative pixels for FlexConfig column gap VALUE." "Return canonical, unresolved FlexConfig column gap VALUE."
(or (ebox--nonnegative-horizontal-size-pixels value 0) 0)) (ebox-size-validate-for-axis (or value '(px 0)) :column-gap 'inline))
(defun ebox--layout-used-gap (value axis available &optional box)
"Resolve gap VALUE along AXIS against AVAILABLE using BOX font metrics.
AVAILABLE is pixels for width and display lines for height. Indefinite
percentage gaps contribute zero. Numbers are private, already used values."
(if (numberp value)
value
(let* ((line-height (ebox--size-line-height box))
(ebox-viewport-width (if (eq axis 'width) available
ebox-viewport-width))
(ebox--css-containing-height
(if (eq axis 'height) (and available (* available line-height))
ebox--css-containing-height))
(percent-base (if (eq axis 'height)
(and available (* available line-height))
available))
(pixels (or (ebox--size-pixels
(or value '(px 0)) box axis (or percent-base 0)) 0)))
(max 0 (floor (if (eq axis 'height)
(/ pixels (float line-height))
pixels))))))
(defun ebox--layout-used-gaps (props width height &optional box)
"Return a copy of PROPS with used gaps for WIDTH, HEIGHT, and BOX.
Only the private render configuration contains numeric gap values."
(let ((result (copy-sequence props)))
(plist-put result :row-gap
(ebox--layout-used-gap (plist-get props :row-gap)
'height height box))
(plist-put result :column-gap
(ebox--layout-used-gap (plist-get props :column-gap)
'width width box))
result))
(defun ebox--flex-normalize-config-props (props) (defun ebox--flex-normalize-config-props (props)
"Normalize FlexConfig PROPS to explicit layout-owned fields." "Normalize FlexConfig PROPS to explicit layout-owned fields."
@ -400,8 +406,8 @@ ALLOWED-KEYWORDS is the CSS keyword set valid for the property being parsed."
ebox--flex-align-content-values) ebox--flex-align-content-values)
(let ((row-gap (plist-get props :row-gap)) (let ((row-gap (plist-get props :row-gap))
(column-gap (plist-get props :column-gap))) (column-gap (plist-get props :column-gap)))
(and (integerp row-gap) (>= row-gap 0) (and (ebox-size-value-for-axis-p row-gap 'block)
(numberp column-gap) (>= column-gap 0))))) (ebox-size-value-for-axis-p column-gap 'inline)))))
;;;###autoload ;;;###autoload
(defun ebox-flex-layout-create (&rest plist) (defun ebox-flex-layout-create (&rest plist)
@ -443,7 +449,7 @@ programmatic port and are therefore rejected here."
(let ((flex (plist-get props :flex))) (let ((flex (plist-get props :flex)))
(cond (cond
((numberp flex) ((numberp flex)
(setq grow flex shrink 1 basis 0)) (setq grow flex shrink 1 basis '(% 0)))
((eq flex 'none) ((eq flex 'none)
(setq grow 0 shrink 0 basis 'auto)) (setq grow 0 shrink 0 basis 'auto))
((eq flex 'auto) ((eq flex 'auto)
@ -476,20 +482,40 @@ programmatic port and are therefore rejected here."
"Return normalized flex item metadata for ITEM." "Return normalized flex item metadata for ITEM."
(ebox--flex-apply-shorthand (ebox--flex-participation-props item))) (ebox--flex-apply-shorthand (ebox--flex-participation-props item)))
(defun ebox--flex-validate-item-bases (props children)
"Validate CHILDREN's flex bases against the parent direction in PROPS."
(let ((axis (if (eq (ebox--flex-axis props) 'row) 'inline 'block)))
(dolist (child children)
(let ((basis (plist-get (ebox--flex-item-props child) :flex-basis)))
(unless (memq basis '(auto content min-content max-content fit-content stretch))
(ebox-size-validate-for-axis basis :flex-basis axis)))))
children)
(defun ebox--flex-string-width (string) (defun ebox--flex-string-width (string)
"Return max pixel width among STRING lines." "Return STRING's layout width, excluding separately observed visible ink."
(ebox--string-max-pixel-width string)) (or (ebox--rendered-intrinsic-size string :width)
(ebox--string-max-pixel-width string)))
(defun ebox--flex-source-width (source rendered)
"Return SOURCE's layout width when RENDERED has no height to carry it."
(if (and (ebox--flex-box-source-p source)
(zerop (ebox-string-height rendered)))
(let ((ebox--inline-auto-width-intrinsic-p t))
(ebox--total-pixel source))
(ebox--flex-string-width rendered)))
(defun ebox--flex-string-main (string axis) (defun ebox--flex-string-main (string axis)
"Return STRING main-axis size for AXIS." "Return STRING main-axis size for AXIS."
(if (eq axis 'row) (if (eq axis 'row)
(ebox--flex-string-width string) (ebox--flex-string-width string)
(ebox-string-height string))) (or (ebox--rendered-intrinsic-size string :height)
(ebox-string-height string))))
(defun ebox--flex-string-cross (string axis) (defun ebox--flex-string-cross (string axis)
"Return STRING cross-axis size for AXIS." "Return STRING cross-axis size for AXIS."
(if (eq axis 'row) (if (eq axis 'row)
(ebox-string-height string) (or (ebox--rendered-intrinsic-size string :height)
(ebox-string-height string))
(ebox--flex-string-width string))) (ebox--flex-string-width string)))
(defun ebox--flex-inline-viewport (axis main-size cross-size) (defun ebox--flex-inline-viewport (axis main-size cross-size)
@ -608,42 +634,41 @@ layout with viewport dependence."
(defun ebox--flex-box-min-main (box rendered axis) (defun ebox--flex-box-min-main (box rendered axis)
"Return BOX's minimum renderable main-axis size for AXIS. "Return BOX's minimum renderable main-axis size for AXIS.
RENDERED is BOX rendered at its natural size. No-wrap content and RENDERED is BOX rendered at its natural size. Automatic minima use content
preformatted wrapper boxes cannot be safely compressed below that rendered only for non-scrollable overflow; explicit minima bypass that constraint."
size; wrapping content may shrink down to its declared minimum and widest (let* ((horizontal (eq axis 'row))
grapheme." (side (if horizontal (ebox--side-pixel box) (ebox--side-height box)))
(if (eq axis 'row) (minimum (ebox-get box (if horizontal :min-width :min-height)))
(let ((declared-min (or (ebox--resolve-size-content-pixel (declared (if horizontal
box (ebox-get box :min-width) 0) (ebox--resolve-size-content-pixel box minimum 0)
0))) (ebox--resolve-size-content-height box minimum 0)))
(if (ebox-style-no-soft-wrap-p (ebox-get box :wrap-mode)) (automatic (and (not (ebox--flex-explicit-min-main-p box axis))
;; CSS's automatic minimum is min-content only when the author (not (memq (ebox-get box :overflow)
;; leaves `min-width' at its automatic value. A component that '(auto scroll hidden))))))
;; deliberately declares `:min-width 0' must be allowed to (if (not automatic)
;; shrink even when its own rows remain no-wrap (the surrounding (+ side (or declared 0))
;; card may then own horizontal overflow). (let* ((content-min
(if (ebox--flex-explicit-min-main-p box axis) (if (and horizontal
(+ (ebox--side-pixel box) declared-min) (ebox-style-soft-wrap-p (ebox-get box :wrap-mode)))
(max (ebox--flex-string-main rendered axis) (ebox--content-min-pixel box)
(+ (ebox--side-pixel box) declared-min))) (max 0 (- (ebox--flex-string-main rendered axis) side))))
(let ((content-min (ebox--content-min-pixel box))) (preferred (ebox-get box (if horizontal :width :height)))
(when (hash-table-p ebox--flex-content-min-width-table) (definite (and (or (numberp preferred)
(puthash box content-min (ebox-size-value-p preferred nil t))
ebox--flex-content-min-width-table)) (ebox--flex-box-main-constraint
(+ (ebox--side-pixel box) box axis (if horizontal :width :height))))
(max declared-min content-min))))) (maximum (ebox--flex-box-main-constraint
(+ (ebox--side-height box) box axis (if horizontal :max-width :max-height))))
(max 1 (when (and horizontal (hash-table-p ebox--flex-content-min-width-table))
(or (ebox--box-sizing-content-height (puthash box content-min ebox--flex-content-min-width-table))
box (ebox-get box :min-height)) (max side (min (+ side content-min)
0))))) (or definite most-positive-fixnum)
(or maximum most-positive-fixnum)))))))
(defun ebox--flex-explicit-min-main-p (box axis) (defun ebox--flex-explicit-min-main-p (box axis)
"Return non-nil when BOX declares a main-axis minimum explicitly." "Return non-nil when BOX declares a main-axis minimum explicitly."
(plist-get box (not (memq (ebox-get box (if (eq axis 'row) :min-width :min-height))
(if (eq axis 'row) '(nil auto))))
:ebox-explicit-min-width-p
:ebox-explicit-min-height-p)))
(defun ebox--flex-box-main-constraint (box axis property) (defun ebox--flex-box-main-constraint (box axis property)
"Return BOX's total main-axis size constraint from PROPERTY." "Return BOX's total main-axis size constraint from PROPERTY."
@ -654,8 +679,8 @@ grapheme."
box (ebox-get box property) nil))) box (ebox-get box property) nil)))
(+ (ebox--side-pixel box) content-size)) (+ (ebox--side-pixel box) content-size))
(when-let* ((block-size (when-let* ((block-size
(ebox--box-sizing-content-height (ebox--resolve-size-content-height
box (ebox-get box property)))) box (ebox-get box property) nil)))
(+ (ebox--side-height box) block-size))))) (+ (ebox--side-height box) block-size)))))
(defun ebox--flex-min-main (source rendered axis) (defun ebox--flex-min-main (source rendered axis)
@ -681,20 +706,26 @@ grapheme."
"Return SOURCE's flex base size for AXIS from BASIS." "Return SOURCE's flex base size for AXIS from BASIS."
(cond (cond
((eq basis 'auto) ((eq basis 'auto)
(ebox--flex-string-main rendered axis)) (if (eq axis 'row)
(ebox--flex-source-width source rendered)
(ebox--flex-string-main rendered axis)))
((eq basis 'content) ((eq basis 'content)
(if (ebox--flex-box-source-p source) (if (ebox--flex-box-source-p source)
(ebox--flex-box-content-basis-main source rendered axis) (ebox--flex-box-content-basis-main source rendered axis)
(ebox--flex-string-main rendered axis))) (ebox--flex-string-main rendered axis)))
((and (eq axis 'row) ((ebox--flex-box-source-p source)
(ebox--flex-box-source-p source))
(let* ((normalized (let* ((normalized
(ebox--normalize-horizontal-size-value (ebox--normalize-horizontal-size-value
basis ebox--preferred-size-keywords)) basis ebox--preferred-size-keywords))
(content-size (content-size
(or (ebox--resolve-size-content-pixel source normalized nil) (if (eq axis 'row)
(ebox--content-max-pixel source)))) (or (ebox--resolve-size-content-pixel source normalized nil)
(+ (ebox--side-pixel source) content-size))) (ebox--content-max-pixel source))
(or (ebox--resolve-size-content-height source normalized nil)
(max 0 (- (ebox--flex-string-main rendered axis)
(ebox--side-height source)))))))
(+ (if (eq axis 'row) (ebox--side-pixel source)
(ebox--side-height source)) content-size)))
(t (t
(ebox--flex-main-value axis basis 0)))) (ebox--flex-main-value axis basis 0))))
@ -745,17 +776,40 @@ grapheme."
(if horizontal (if horizontal
(ebox--side-pixel box) (ebox--side-pixel box)
(ebox--side-height box))))) (ebox--side-height box)))))
(max (if horizontal 0 1) (max 0 (- target outer-size))))
(- target outer-size))))
(defun ebox--flex-copy-put-size (copy property value) (defun ebox--flex-copy-put-size (copy property value)
"Set COPY's layout-computed size PROPERTY to VALUE." "Set COPY's layout-computed size PROPERTY to VALUE."
(plist-put copy property value)) (plist-put copy property value))
(defun ebox--flex-freeze-containing-sizes (source copy)
"Freeze SOURCE's used sizes in COPY before assigning its item viewport.
Percentage edges and constraints refer to the flex container. They must not
be recalculated against the smaller viewport assigned to the item itself."
(dolist (property '(:padding-left-pixel :padding-right-pixel
:padding-top-height :padding-bottom-height
:margin-left-pixel :margin-right-pixel
:margin-top-height :margin-bottom-height
:border-left-pixel :border-right-pixel
:border-top-pixel :border-bottom-pixel))
(when (consp (plist-get source property))
(plist-put copy property (ebox-get source property))))
(dolist (property '(:width :min-width :max-width :height :min-height :max-height))
(let ((value (plist-get source property)))
(when (consp value)
(let* ((vertical (memq property '(:height :min-height :max-height)))
(pixels (ebox--size-pixels value source (if vertical 'height 'width))))
(when pixels
(plist-put copy property
(if vertical (/ pixels (float (ebox--size-line-height source)))
pixels)))))))
copy)
(defun ebox--flex-copy-node-for-size (node axis main cross stretch) (defun ebox--flex-copy-node-for-size (node axis main cross stretch)
"Return NODE or a copy with flex-computed MAIN/CROSS size." "Return NODE or a copy with flex-computed MAIN/CROSS size."
(if (ebox--flex-box-source-p node) (if (ebox--flex-box-source-p node)
(let ((copy (copy-sequence node))) (let ((copy (ebox--flex-freeze-containing-sizes
node (copy-sequence node))))
(ebox--ensure-region-id node) (ebox--ensure-region-id node)
(plist-put copy :region-id (ebox-get node :region-id)) (plist-put copy :region-id (ebox-get node :region-id))
(when main (when main
@ -828,8 +882,12 @@ PROBE reuses the caller's exact measurement cache lookup when supplied."
(ebox--flex-render-source-for-measurement (ebox--flex-render-source-for-measurement
source viewport) source viewport)
(ebox--flex-recache-source-boxes source)))) (ebox--flex-recache-source-boxes source))))
(rendered-main (ebox--flex-string-main rendered axis)) (rendered-main (if (eq axis 'row)
(rendered-cross (ebox--flex-string-cross rendered axis)) (ebox--flex-source-width source rendered)
(ebox--flex-string-main rendered axis)))
(rendered-cross (if (eq axis 'column)
(ebox--flex-source-width source rendered)
(ebox--flex-string-cross rendered axis)))
(basis (plist-get props :flex-basis)) (basis (plist-get props :flex-basis))
(min-main (ebox--flex-min-main source rendered axis)) (min-main (ebox--flex-min-main source rendered axis))
(max-main (ebox--flex-max-main source axis)) (max-main (ebox--flex-max-main source axis))
@ -853,10 +911,20 @@ PROBE reuses the caller's exact measurement cache lookup when supplied."
(defun ebox--flex-fixed-basis-value-p (basis) (defun ebox--flex-fixed-basis-value-p (basis)
"Return non-nil when BASIS is a context-free numeric flex size." "Return non-nil when BASIS is a context-free numeric flex size."
(or (numberp basis) (and (ebox-size-value-p basis nil t)
(and (consp basis) (not (cl-intersection (ebox-size-dependencies basis)
(numberp (car basis)) '(percent viewport-width viewport-height)))))
(null (cdr basis)))))
(defun ebox--flex-definite-property (box axis value)
"Return definite VALUE in AXIS units using BOX font metrics.
Context-dependent declarations remain outside the render reuse proof."
(cond
((numberp value) value)
((ebox--flex-fixed-basis-value-p value)
(when-let* ((pixels (ebox--size-pixels value box
(if (eq axis 'row) 'width 'height))))
(if (eq axis 'row) pixels
(/ pixels (float (ebox--size-line-height box))))))))
(defun ebox--flex-unrendered-measurement (source props axis) (defun ebox--flex-unrendered-measurement (source props axis)
"Return SOURCE flex metrics that do not require intrinsic rendering. "Return SOURCE flex metrics that do not require intrinsic rendering.
@ -868,9 +936,10 @@ measurement and immediately rendering it again at the computed size."
(let ((basis (plist-get props :flex-basis))) (let ((basis (plist-get props :flex-basis)))
(when (and (ebox--flex-box-source-p source) (when (and (ebox--flex-box-source-p source)
(ebox--flex-fixed-basis-value-p basis) (ebox--flex-fixed-basis-value-p basis)
(or (not (eq axis 'row)) (if (eq axis 'row)
(ebox-style-soft-wrap-p (ebox-style-soft-wrap-p (ebox-get source :wrap-mode))
(ebox-get source :wrap-mode)))) (or (ebox--flex-explicit-min-main-p source axis)
(memq (ebox-get source :overflow) '(auto scroll hidden)))))
(let* ((min-main (ebox--flex-min-main source "" axis)) (let* ((min-main (ebox--flex-min-main source "" axis))
(max-main (ebox--flex-max-main source axis)) (max-main (ebox--flex-max-main source axis))
(base (ebox--flex-basis-main source "" axis basis)) (base (ebox--flex-basis-main source "" axis basis))
@ -892,10 +961,21 @@ metrics, and render intrinsically only when layout semantics require it."
(ebox--flex-unrendered-measurement source props axis) (ebox--flex-unrendered-measurement source props axis)
(ebox--flex-measure-source source props axis viewport probe)))) (ebox--flex-measure-source source props axis viewport probe))))
(defun ebox--flex-record-composite-intrinsic-size (rendered parts width height)
"Record WIDTH and HEIGHT for empty RENDERED or separate PARTS extents."
(if (or (string-empty-p rendered)
(cl-some (lambda (part)
(or (ebox--rendered-intrinsic-size part :width)
(ebox--rendered-intrinsic-size part :height)))
parts))
(ebox--record-rendered-intrinsic-size rendered width height)
rendered))
(defun ebox--flex-pad-width (string width &optional align owner-id) (defun ebox--flex-pad-width (string width &optional align owner-id)
"Pad every line of STRING to WIDTH pixels." "Pad every line of STRING to WIDTH pixels."
(let ((align (or align 'flex-start))) (let ((align (or align 'flex-start)))
(ebox--maplines (ebox--flex-record-composite-intrinsic-size
(ebox--maplines
(lambda (line) (lambda (line)
(let* ((line-width (ebox--string-pixel-width line)) (let* ((line-width (ebox--string-pixel-width line))
(extra (max 0 (- width line-width))) (extra (max 0 (- width line-width)))
@ -911,7 +991,8 @@ metrics, and render intrinsically only when layout semantics require it."
(setq right-space (setq right-space
(ebox--add-content-owner right-space owner-id))) (ebox--add-content-owner right-space owner-id)))
(concat left-space line right-space)))) (concat left-space line right-space))))
string))) string)
(list string) width (ebox--rendered-intrinsic-size string :height))))
(defun ebox--flex-pad-height (string height offset width &optional owner-id) (defun ebox--flex-pad-height (string height offset width &optional owner-id)
"Pad STRING vertically to HEIGHT using blank lines of WIDTH pixels." "Pad STRING vertically to HEIGHT using blank lines of WIDTH pixels."
@ -924,10 +1005,12 @@ metrics, and render intrinsically only when layout semantics require it."
(blank (if owner-id (blank (if owner-id
(ebox--add-content-owner blank owner-id) (ebox--add-content-owner blank owner-id)
blank))) blank)))
(ebox-lines-join (ebox--flex-record-composite-intrinsic-size
(append (make-list top blank) (ebox-lines-join
lines (append (make-list top blank)
(make-list bottom blank))))) lines
(make-list bottom blank)))
(list string) (ebox--rendered-intrinsic-size string :width) height)))
(defun ebox--flex-sized-entry (rendered main cross) (defun ebox--flex-sized-entry (rendered main cross)
"Return a sized flex render entry for RENDERED." "Return a sized flex render entry for RENDERED."
@ -1057,13 +1140,15 @@ Return a sized render entry containing the rendered string and dimensions."
(= cross (ebox--flex-sized-entry-cross natural-entry)) (= cross (ebox--flex-sized-entry-cross natural-entry))
(or (not stretch) (or (not stretch)
(not source-box-p) (not source-box-p)
;; A numeric cross size already equal to the assigned ;; A definite cross size already equal to the assigned
;; Box property leaves its complete geometry unchanged. ;; Box property leaves its complete geometry unchanged.
;; Auto/relative sizes and overflow-expanded output ;; Auto/relative sizes and overflow-expanded output
;; still need the final stretched Box render. ;; still need the final stretched Box render.
(let ((declared-cross (let ((declared-cross
(ebox-get source (if (eq axis 'row) (ebox--flex-definite-property
:height :width)))) source (if (eq axis 'row) 'column 'row)
(ebox-get source (if (eq axis 'row)
:height :width)))))
(and (numberp declared-cross) (and (numberp declared-cross)
(= declared-cross (= declared-cross
(ebox--flex-box-main-property (ebox--flex-box-main-property
@ -1196,12 +1281,12 @@ Return a sized render entry containing the rendered string and dimensions."
(or main (if can-use-measured (or main (if can-use-measured
render-main render-main
(ebox--flex-string-width rendered))) (ebox--flex-string-width rendered)))
(or main (ebox-string-height rendered)))) (or main (ebox--flex-string-main rendered axis))))
(or (and fragment-entry (plist-get fragment-entry :cross)) (or (and fragment-entry (plist-get fragment-entry :cross))
(if (eq axis 'row) (if (eq axis 'row)
(or cross (if can-use-measured (or cross (if can-use-measured
render-cross render-cross
(ebox-string-height rendered))) (ebox--flex-string-cross rendered axis)))
(or cross (if can-use-measured (or cross (if can-use-measured
render-cross render-cross
(ebox--flex-string-width rendered)))))))) (ebox--flex-string-width rendered))))))))
@ -1224,12 +1309,18 @@ Return a sized render entry containing the rendered string and dimensions."
(ebox--flex-render-sized-entry item axis main cross container-align))) (ebox--flex-render-sized-entry item axis main cross container-align)))
(defun ebox--flex-distribute (amount weights) (defun ebox--flex-distribute (amount weights)
"Distribute integer AMOUNT across WEIGHTS, preserving total exactly." "Distribute AMOUNT across WEIGHTS, retaining fractional free space.
(let* ((amount (max 0 (floor amount))) Whole amounts retain the established deterministic remainder order."
(let* ((amount (max 0 amount))
(positive (cl-remove-if-not (lambda (weight) (> weight 0)) weights)) (positive (cl-remove-if-not (lambda (weight) (> weight 0)) weights))
(total (apply #'+ (or positive '(0))))) (total (apply #'+ (or positive '(0)))))
(if (or (<= amount 0) (<= total 0)) (cond
(make-list (length weights) 0) ((or (<= amount 0) (<= total 0)) (make-list (length weights) 0))
((/= amount (floor amount))
(mapcar (lambda (weight) (* amount (/ (float (max 0 weight)) total)))
weights))
(t
(setq amount (floor amount))
(let* ((shares (let* ((shares
(mapcar (lambda (weight) (mapcar (lambda (weight)
(if (> weight 0) (if (> weight 0)
@ -1244,7 +1335,7 @@ Return a sized render entry containing the rendered string and dimensions."
when (> weight 0) when (> weight 0)
do (cl-incf (car cell)) do (cl-incf (car cell))
and do (cl-decf remaining))) and do (cl-decf remaining)))
shares)))) shares)))))
(defun ebox--flex-sum (numbers) (defun ebox--flex-sum (numbers)
"Return the sum of NUMBERS." "Return the sum of NUMBERS."
@ -1472,6 +1563,7 @@ metadata and rendering decisions."
(t (t
(dolist (index (or max-violations active-indices)) (dolist (index (or max-violations active-indices))
(aset frozen index t))))))))))))) (aset frozen index t)))))))))))))
(setq targets (vconcat (ebox-size-quantize (append targets nil))))
(cl-loop for item in line (cl-loop for item in line
for index from 0 for index from 0
collect (let ((copy (copy-sequence item))) collect (let ((copy (copy-sequence item)))
@ -1516,7 +1608,7 @@ metadata and rendering decisions."
(ebox--flex-render-sized-entry (ebox--flex-render-sized-entry
item axis (plist-get item :target) nil container-align))) item axis (plist-get item :target) nil container-align)))
line))) line)))
(max 1 (apply #'max (or sizes '(1)))))) (apply #'max (or sizes '(0)))))
(defun ebox--flex-layout-cross (crosses container-cross cross-gap align single-line) (defun ebox--flex-layout-cross (crosses container-cross cross-gap align single-line)
"Return plist describing cross-axis line placement." "Return plist describing cross-axis line placement."
@ -1548,7 +1640,8 @@ metadata and rendering decisions."
(leading (nth 0 spacing)) (leading (nth 0 spacing))
(between (nth 1 spacing)) (between (nth 1 spacing))
(trailing (nth 2 spacing)) (trailing (nth 2 spacing))
(parts (list (cons (ebox-pixel-space leading) leading)))) (parts (when (> leading 0)
(list (cons (ebox--pixel-blank leading line-cross) leading)))))
(cl-loop for item in line (cl-loop for item in line
for tail on line for tail on line
for main = (plist-get item :target) for main = (plist-get item :target)
@ -1557,11 +1650,14 @@ metadata and rendering decisions."
do (push (cons (ebox--flex-sized-entry-rendered entry) do (push (cons (ebox--flex-sized-entry-rendered entry)
main) main)
parts) parts)
unless (null (cdr tail)) when (and (cdr tail) (> between 0))
do (push (cons (ebox-pixel-space between) between) parts)) do (push (cons (ebox--pixel-blank between line-cross) between) parts))
(when (> trailing 0) (when (> trailing 0)
(push (cons (ebox-pixel-space trailing) trailing) parts)) (push (cons (ebox--pixel-blank trailing line-cross) trailing) parts))
(ebox--flex-lines-concat-horizontal-sized (nreverse parts) line-cross))) (setq parts (nreverse parts))
(ebox--flex-record-composite-intrinsic-size
(ebox--flex-lines-concat-horizontal-sized parts line-cross)
(mapcar #'car parts) target-main line-cross)))
(defun ebox--flex-render-column-line (line line-cross main-size main-gap justify align) (defun ebox--flex-render-column-line (line line-cross main-size main-gap justify align)
"Render one column-axis flex LINE." "Render one column-axis flex LINE."
@ -1582,8 +1678,11 @@ metadata and rendering decisions."
unless (null (cdr tail)) unless (null (cdr tail))
do (when (> between 0) do (when (> between 0)
(push (ebox--pixel-blank line-cross between) pieces))) (push (ebox--pixel-blank line-cross between) pieces)))
(let ((result (apply #'ebox--lines-stack-vertical (nreverse pieces)))) (setq pieces (nreverse pieces))
(ebox--flex-pad-height result target-main 0 line-cross)))) (let ((result (apply #'ebox--lines-stack-vertical pieces)))
(ebox--flex-record-composite-intrinsic-size
(ebox--flex-pad-height result target-main 0 line-cross)
pieces line-cross target-main))))
(defun ebox--render-flex-row (lines props main-size cross-size main-gap cross-gap) (defun ebox--render-flex-row (lines props main-size cross-size main-gap cross-gap)
"Render flex LINES for a row-axis container." "Render flex LINES for a row-axis container."
@ -1622,9 +1721,14 @@ metadata and rendering decisions."
do (when (> between 0) do (when (> between 0)
(push (ebox--pixel-blank container-main between) pieces))) (push (ebox--pixel-blank container-main between) pieces)))
(let ((result (apply #'ebox--lines-stack-vertical (nreverse pieces)))) (let ((result (apply #'ebox--lines-stack-vertical (nreverse pieces))))
(if cross-size (ebox--flex-record-composite-intrinsic-size
(ebox--flex-pad-height result cross-size 0 container-main) (if cross-size
result)))) (ebox--flex-pad-height result cross-size 0 container-main)
result)
rendered-lines container-main
(or cross-size (+ leading (ebox--flex-sum adjusted-crosses)
(* between (max 0 (1- (length adjusted-crosses))))
(plist-get cross-layout :trailing)))))))
(defun ebox--render-flex-column (lines props main-size cross-size main-gap cross-gap) (defun ebox--render-flex-column (lines props main-size cross-size main-gap cross-gap)
"Render flex LINES for a column-axis container." "Render flex LINES for a column-axis container."
@ -1649,16 +1753,29 @@ metadata and rendering decisions."
(ebox--flex-render-column-line (ebox--flex-render-column-line
line cross main-size main-gap justify align)) line cross main-size main-gap justify align))
sized adjusted-crosses)) sized adjusted-crosses))
(parts (list (ebox-pixel-space leading)))) (height (max (or main-size 0)
(apply #'max (mapcar #'ebox-string-height
(or rendered-columns '(""))))))
(parts (when (> leading 0)
(list (ebox--pixel-blank leading height)))))
(cl-loop for rendered in rendered-columns (cl-loop for rendered in rendered-columns
for tail on rendered-columns for tail on rendered-columns
do (push rendered parts) do (push rendered parts)
unless (null (cdr tail)) when (and (cdr tail) (> between 0))
do (push (ebox-pixel-space between) parts)) do (push (ebox--pixel-blank between height) parts))
(let ((result (apply #'ebox--lines-concat-horizontal (nreverse parts)))) (let ((result (apply #'ebox--lines-concat-horizontal (nreverse parts))))
(if cross-size (ebox--flex-record-composite-intrinsic-size
(ebox--flex-pad-width result cross-size) (if cross-size
result)))) (ebox--flex-pad-width result cross-size)
result)
rendered-columns
(or cross-size (+ leading (ebox--flex-sum adjusted-crosses)
(* between (max 0 (1- (length adjusted-crosses))))
(plist-get cross-layout :trailing)))
(or main-size
(apply #'max (mapcar (lambda (column)
(ebox--flex-string-main column 'column))
(or rendered-columns '("")))))))))
(defun ebox--render-flex-box (node rendered) (defun ebox--render-flex-box (node rendered)
"Render flex NODE's container box around RENDERED, when present." "Render flex NODE's container box around RENDERED, when present."
@ -1673,12 +1790,19 @@ metadata and rendering decisions."
(puthash region-id box ebox--region-box-table))) (puthash region-id box ebox--region-box-table)))
rendered)) rendered))
(defun ebox--render-flex-children (config constraints children) (defun ebox--render-flex-children (config constraints children &optional box)
"Render Flex CONFIG under resolved CONSTRAINTS over flat CHILDREN. "Render Flex CONFIG under resolved CONSTRAINTS over flat CHILDREN.
Width is in pixels and height in lines; nil means unbounded, including an Width is in pixels and height in lines; nil means unbounded, including an
intrinsic max-content measurement. Author units and viewport defaults have intrinsic max-content measurement. Author units and viewport defaults have
already been resolved by the Box or legacy adapter." already been resolved by the Box or legacy adapter. BOX supplies font metrics."
(let* ((props config) (let* ((constraints
(list :width (when-let* ((width (plist-get constraints :width)))
(floor width))
:height (when-let* ((height (plist-get constraints :height)))
(floor height))))
(props (ebox--layout-used-gaps
config (plist-get constraints :width)
(plist-get constraints :height) box))
(axis (ebox--flex-axis props)) (axis (ebox--flex-axis props))
(main-size (if (eq axis 'row) (main-size (if (eq axis 'row)
(plist-get constraints :width) (plist-get constraints :width)
@ -1689,6 +1813,9 @@ already been resolved by the Box or legacy adapter."
;; Nil is an explicit unbounded containing block, not permission for ;; Nil is an explicit unbounded containing block, not permission for
;; a deeper responsive Flex to recover this Box's outer viewport. ;; a deeper responsive Flex to recover this Box's outer viewport.
(ebox-viewport-width (plist-get constraints :width)) (ebox-viewport-width (plist-get constraints :width))
(ebox--css-containing-height
(and (plist-get constraints :height)
(* (plist-get constraints :height) (ebox--size-line-height box))))
(inline-viewport (ebox--flex-inline-viewport (inline-viewport (ebox--flex-inline-viewport
axis main-size cross-size)) axis main-size cross-size))
(items (ebox--flex-collect-items children axis inline-viewport)) (items (ebox--flex-collect-items children axis inline-viewport))
@ -1696,7 +1823,7 @@ already been resolved by the Box or legacy adapter."
(nreverse items) (nreverse items)
items)) items))
(row-gap (or (ebox--flex-line-value (plist-get props :row-gap) 0) 0)) (row-gap (or (ebox--flex-line-value (plist-get props :row-gap) 0) 0))
;; FlexConfig stores the gap in pixels after author-unit expansion. ;; Only this private used configuration contains numeric gaps.
(column-gap (or (plist-get props :column-gap) 0)) (column-gap (or (plist-get props :column-gap) 0))
(main-gap (if (eq axis 'row) column-gap row-gap)) (main-gap (if (eq axis 'row) column-gap row-gap))
(cross-gap (if (eq axis 'row) row-gap column-gap)) (cross-gap (if (eq axis 'row) row-gap column-gap))
@ -1713,8 +1840,9 @@ already been resolved by the Box or legacy adapter."
(defun ebox--flex-box-constraints (box) (defun ebox--flex-box-constraints (box)
"Project BOX content dimensions to resolved private FlexConstraints." "Project BOX content dimensions to resolved private FlexConstraints."
(list :width (ebox--wrapper-content-viewport-pixel box) (list :width (ebox--wrapper-content-viewport-pixel box)
:height (ebox--resolve-size-content-height :height (when-let* ((height (ebox--resolve-size-content-height
box (ebox-get box :height) nil))) box (ebox-get box :height) nil)))
(floor height))))
(defun ebox--flex-legacy-constraints (props) (defun ebox--flex-legacy-constraints (props)
"Resolve legacy PROPS' author units and defaults to FlexConstraints." "Resolve legacy PROPS' author units and defaults to FlexConstraints."
@ -1726,7 +1854,7 @@ already been resolved by the Box or legacy adapter."
(ebox--render-flex-children (ebox--render-flex-children
props props
(ebox--flex-box-constraints box) (ebox--flex-box-constraints box)
children)) children box))
(defun ebox--render-flex (node) (defun ebox--render-flex (node)
"Render legacy flex NODE to a propertized string." "Render legacy flex NODE to a propertized string."
@ -1839,9 +1967,12 @@ assembles already-rendered items."
(defun ebox--flex-window-props (node) (defun ebox--flex-window-props (node)
"Return NODE's Flex properties with resolved dimensions for lazy rows." "Return NODE's Flex properties with resolved dimensions for lazy rows."
(if (ebox--flex-window-canonical-node-p node) (if (ebox--flex-window-canonical-node-p node)
(append (let ((constraints (ebox--flex-box-constraints node)))
(ebox-layout-config-props (plist-get node :ebox-layout-config)) (append
(ebox--flex-box-constraints node)) (ebox--layout-used-gaps
(ebox-layout-config-props (plist-get node :ebox-layout-config))
(plist-get constraints :width) (plist-get constraints :height) node)
constraints))
(let ((props (ebox--flex-container-content-props (let ((props (ebox--flex-container-content-props
(plist-get node :props) (plist-get node :props)
(plist-get node :raw-props) (plist-get node :raw-props)
@ -1852,10 +1983,13 @@ assembles already-rendered items."
(defun ebox--flex-window-wrapper-supported-p (box) (defun ebox--flex-window-wrapper-supported-p (box)
"Return non-nil when BOX can safely wrap an incomplete flex prefix." "Return non-nil when BOX can safely wrap an incomplete flex prefix."
(or (null box) (or (null box)
(and (null (ebox-get box :height)) (and (memq (ebox-get box :height) '(nil auto))
(or (null (ebox-get box :min-height)) (or (memq (ebox-get box :min-height) '(nil auto))
(equal (ebox-get box :min-height) 0)) (equal (ebox-get box :min-height) 0)
(null (ebox-get box :max-height)) (and (ebox-size-value-p (ebox-get box :min-height) nil t)
(equal (ebox--size-pixels
(ebox-get box :min-height) box 'height) 0)))
(memq (ebox-get box :max-height) '(nil none))
(or (ebox-style-no-soft-wrap-p (ebox-get box :wrap-mode)) (or (ebox-style-no-soft-wrap-p (ebox-get box :wrap-mode))
(ebox--flex-window-canonical-node-p box)) (ebox--flex-window-canonical-node-p box))
(eq (ebox-get box :text-align) 'left) (eq (ebox-get box :text-align) 'left)

View File

@ -108,7 +108,8 @@
(info (info
(and name (and name
(ignore-errors (ignore-errors
(font-info name (and window (window-frame window))))))) (font-info name (and window (window-frame window))))))
(zero-width (string-pixel-width (propertize "0" 'face face))))
(list (list
:identity :identity
(if font (if font
@ -124,6 +125,7 @@
:metrics :metrics
(if info (if info
(list :pixel-size (aref info 2) (list :pixel-size (aref info 2)
:zero-width zero-width
:height (aref info 3) :height (aref info 3)
:baseline-offset (aref info 4) :baseline-offset (aref info 4)
:max-width (aref info 7) :max-width (aref info 7)
@ -132,6 +134,7 @@
:space-width (aref info 10) :space-width (aref info 10)
:average-width (aref info 11)) :average-width (aref info 11))
(list :probe-width (string-pixel-width probe) (list :probe-width (string-pixel-width probe)
:zero-width zero-width
:space-width :space-width
(string-pixel-width (propertize " " 'face face))))))) (string-pixel-width (propertize " " 'face face)))))))

View File

@ -4,7 +4,7 @@
;;; Commentary: ;;; Commentary:
;; Grid is a two-dimensional layout node. It deliberately reuses Ebox's ;; Grid is a two-dimensional layout node. It deliberately reuses Ebox's
;; existing pixel/line units, box renderer, and retained tree identity rather ;; shared CSS size expressions, box renderer, and retained tree identity rather
;; than introducing a second styling or measurement model. ;; than introducing a second styling or measurement model.
;;; Code: ;;; Code:
@ -34,6 +34,10 @@
(declare-function ebox--lines-align-vertical "ebox-layout" (string height align)) (declare-function ebox--lines-align-vertical "ebox-layout" (string height align))
(declare-function ebox--flex-spacing "ebox-flex" (mode leftover count base-gap)) (declare-function ebox--flex-spacing "ebox-flex" (mode leftover count base-gap))
(declare-function ebox--render-with-cache "ebox-incremental" (node &optional force cache-probe)) (declare-function ebox--render-with-cache "ebox-incremental" (node &optional force cache-probe))
(declare-function ebox--record-rendered-intrinsic-size
"ebox-layout" (body width height))
(declare-function ebox--rendered-intrinsic-size
"ebox-layout" (rendered axis))
(defvar ebox--region-box-table) (defvar ebox--region-box-table)
(defconst ebox--grid-layout-prop-keys (defconst ebox--grid-layout-prop-keys
@ -84,14 +88,15 @@
(defun ebox-grid--track-form-p (value) (defun ebox-grid--track-form-p (value)
"Return non-nil when VALUE is one composite track expression." "Return non-nil when VALUE is one composite track expression."
(and (consp value) (and (consp value)
(memq (car value) '(fr minmax repeat)))) (or (memq (car value) '(fr minmax repeat))
(ebox-size-value-p value nil t))))
(defun ebox-grid--track-list (value) (defun ebox-grid--track-list (value)
"Return VALUE as a list of track expressions." "Return VALUE as a list of track expressions."
(cond (cond
((null value) nil) ((null value) nil)
((ebox-grid--track-form-p value) (list value)) ((ebox-grid--track-form-p value) (list value))
((or (numberp value) (symbolp value)) (list value)) ((symbolp value) (list value))
(t value))) (t value)))
(defun ebox-grid--expand-repeat (tracks) (defun ebox-grid--expand-repeat (tracks)
@ -107,9 +112,7 @@
(let ((body (let ((body
(cond (cond
((or (ebox-grid--track-form-p value) ((or (ebox-grid--track-form-p value)
(numberp value) (symbolp value) (symbolp value))
(and (consp value) (numberp (car value))
(null (cdr value))))
(list value)) (list value))
((proper-list-p value) value) ((proper-list-p value) value)
(t (error "ebox-grid: invalid repeat body %S" value))))) (t (error "ebox-grid: invalid repeat body %S" value)))))
@ -118,17 +121,17 @@
(list track))) (list track)))
tracks)) tracks))
(defun ebox-grid--normalize-track (value axis) (defun ebox-grid--normalize-track (value axis &optional property)
"Normalize one track VALUE for AXIS (`columns' or `rows')." "Normalize track VALUE for AXIS, reporting errors against PROPERTY."
(setq value (if (and (consp value) (eq (car value) 'quote)) (setq property (or property (if (eq axis 'columns)
(cadr value) :grid-template-columns :grid-template-rows)))
value))
(cond (cond
((eq value 'auto) '(:kind auto :factor 0)) ((eq value 'auto) '(:kind auto :factor 0))
((memq value '(min-content max-content)) ((memq value '(min-content max-content))
(list :kind value)) (list :kind value))
((and (consp value) (eq (car value) 'fr)) ((and (consp value) (eq (car value) 'fr))
(unless (and (numberp (cadr value)) (> (cadr value) 0)) (unless (and (proper-list-p value) (= (length value) 2)
(numberp (cadr value)) (> (cadr value) 0))
(error "ebox-grid: invalid fractional track %S" value)) (error "ebox-grid: invalid fractional track %S" value))
(list :kind 'fr :factor (cadr value))) (list :kind 'fr :factor (cadr value)))
((and (consp value) (eq (car value) 'minmax)) ((and (consp value) (eq (car value) 'minmax))
@ -136,38 +139,24 @@
(consp (cddr value)) (consp (cddr value))
(null (cdddr value))) (null (cdddr value)))
(error "ebox-grid: invalid minmax track %S" value)) (error "ebox-grid: invalid minmax track %S" value))
(let ((minimum (ebox-grid--normalize-track (cadr value) axis)) (let ((minimum (ebox-grid--normalize-track (cadr value) axis property))
(maximum (ebox-grid--normalize-track (caddr value) axis))) (maximum (ebox-grid--normalize-track (caddr value) axis property)))
(unless (memq (plist-get minimum :kind) (unless (memq (plist-get minimum :kind)
'(fixed auto min-content max-content)) '(fixed auto min-content max-content))
(error "ebox-grid: minmax minimum must be fixed or auto: %S" value)) (error "ebox-grid: minmax minimum must be fixed or auto: %S" value))
(unless (memq (plist-get maximum :kind) (unless (memq (plist-get maximum :kind)
'(fixed auto min-content max-content fr)) '(fixed auto min-content max-content fr))
(error "ebox-grid: minmax maximum cannot be nested: %S" value)) (error "ebox-grid: minmax maximum cannot be nested: %S" value))
(when (and (eq (plist-get maximum :kind) 'fixed)
(eq (plist-get minimum :kind) 'fixed)
(> (plist-get minimum :size) (plist-get maximum :size)))
(error "ebox-grid: minmax minimum exceeds maximum: %S" value))
(list :kind 'minmax :min minimum :max maximum))) (list :kind 'minmax :min minimum :max maximum)))
((and (eq axis 'columns) ((ebox-size-value-p value nil t)
(or (numberp value) (list :kind 'fixed :size
(and (consp value) (ebox-size-validate-for-axis
(numberp (car value)) value property (if (eq axis 'columns) 'inline 'block))))
(null (cdr value)))))
(list :kind 'fixed
:size (ebox--nonnegative-horizontal-size-pixels value 0)))
((and (eq axis 'rows)
(or (numberp value)
(and (consp value)
(numberp (car value))
(null (cdr value)))))
(let ((lines (ebox--flex-line-value value 0)))
(list :kind 'fixed :size lines)))
(t (error "ebox-grid: unsupported %S track: %S" axis value)))) (t (error "ebox-grid: unsupported %S track: %S" axis value))))
(defun ebox-grid--normalize-tracks (value axis) (defun ebox-grid--normalize-tracks (value axis &optional property)
"Normalize a track template VALUE along AXIS." "Normalize track template VALUE along AXIS for optional PROPERTY."
(mapcar (lambda (track) (ebox-grid--normalize-track track axis)) (mapcar (lambda (track) (ebox-grid--normalize-track track axis property))
(ebox-grid--expand-repeat (ebox-grid--track-list value)))) (ebox-grid--expand-repeat (ebox-grid--track-list value))))
(defun ebox-grid--gap-pair (props) (defun ebox-grid--gap-pair (props)
@ -180,8 +169,8 @@
((plist-member props :column-gap) ((plist-member props :column-gap)
(plist-get props :column-gap)) (plist-get props :column-gap))
(t (cdr pair))))) (t (cdr pair)))))
(cons (or (ebox--flex-line-value row 0) 0) (cons (ebox--flex-row-gap-value row)
(or (ebox--nonnegative-horizontal-size-pixels column 0) 0)))) (ebox--flex-column-gap-value column))))
(defun ebox-grid--enum-value (value default allowed property) (defun ebox-grid--enum-value (value default allowed property)
"Return VALUE or DEFAULT after checking ALLOWED values for PROPERTY." "Return VALUE or DEFAULT after checking ALLOWED values for PROPERTY."
@ -192,7 +181,7 @@
(defun ebox-grid--one-auto-track (value axis property) (defun ebox-grid--one-auto-track (value axis property)
"Return VALUE normalized as zero or one auto track for AXIS and PROPERTY." "Return VALUE normalized as zero or one auto track for AXIS and PROPERTY."
(let ((tracks (ebox-grid--normalize-tracks value axis))) (let ((tracks (ebox-grid--normalize-tracks value axis property)))
(when (> (length tracks) 1) (when (> (length tracks) 1)
(error "Ebox GridConfig %S accepts at most one track" property)) (error "Ebox GridConfig %S accepts at most one track" property))
(car tracks))) (car tracks)))
@ -246,8 +235,8 @@
(ebox-grid--normalize-config-props nil) (ebox-grid--normalize-config-props nil)
"Computed default property set for GridConfig.") "Computed default property set for GridConfig.")
(defun ebox-grid--canonical-atomic-track-p (track allowed-kinds) (defun ebox-grid--canonical-atomic-track-p (track allowed-kinds &optional axis)
"Return non-nil when TRACK is canonical and its kind is in ALLOWED-KINDS." "Return non-nil when TRACK has an ALLOWED-KINDS kind and valid AXIS units."
(and (proper-list-p track) (and (proper-list-p track)
(memq (plist-get track :kind) allowed-kinds) (memq (plist-get track :kind) allowed-kinds)
(pcase (plist-get track :kind) (pcase (plist-get track :kind)
@ -256,7 +245,8 @@
('max-content (equal track '(:kind max-content))) ('max-content (equal track '(:kind max-content)))
('fixed ('fixed
(let ((size (plist-get track :size))) (let ((size (plist-get track :size)))
(and (numberp size) (>= size 0) (and (ebox-size-value-for-axis-p
size (pcase axis ('columns 'inline) ('rows 'block) (_ 'length)))
(equal track (list :kind 'fixed :size size))))) (equal track (list :kind 'fixed :size size)))))
('fr ('fr
(let ((factor (plist-get track :factor))) (let ((factor (plist-get track :factor)))
@ -264,17 +254,17 @@
(equal track (list :kind 'fr :factor factor))))) (equal track (list :kind 'fr :factor factor)))))
(_ nil)))) (_ nil))))
(defun ebox-grid--canonical-track-p (track) (defun ebox-grid--canonical-track-p (track &optional axis)
"Return non-nil when TRACK is one canonical top-level track descriptor." "Return non-nil when TRACK is canonical, including optional AXIS units."
(or (ebox-grid--canonical-atomic-track-p (or (ebox-grid--canonical-atomic-track-p
track '(fixed auto min-content max-content fr)) track '(fixed auto min-content max-content fr) axis)
(and (proper-list-p track) (and (proper-list-p track)
(eq (plist-get track :kind) 'minmax) (eq (plist-get track :kind) 'minmax)
(ebox-grid--canonical-atomic-track-p (ebox-grid--canonical-atomic-track-p
(plist-get track :min) '(fixed auto min-content max-content)) (plist-get track :min) '(fixed auto min-content max-content) axis)
(ebox-grid--canonical-atomic-track-p (ebox-grid--canonical-atomic-track-p
(plist-get track :max) (plist-get track :max)
'(fixed auto min-content max-content fr)) '(fixed auto min-content max-content fr) axis)
(equal track (equal track
(list :kind 'minmax (list :kind 'minmax
:min (plist-get track :min) :min (plist-get track :min)
@ -291,21 +281,21 @@
cursor (cddr cursor) cursor (cddr cursor)
keys (cdr keys))) keys (cdr keys)))
(and valid (null cursor))) (and valid (null cursor)))
(cl-every #'ebox-grid--canonical-track-p (cl-every (lambda (track) (ebox-grid--canonical-track-p track 'columns))
(plist-get props :grid-template-columns)) (plist-get props :grid-template-columns))
(cl-every #'ebox-grid--canonical-track-p (cl-every (lambda (track) (ebox-grid--canonical-track-p track 'rows))
(plist-get props :grid-template-rows)) (plist-get props :grid-template-rows))
(let ((auto-column (plist-get props :grid-auto-columns)) (let ((auto-column (plist-get props :grid-auto-columns))
(auto-row (plist-get props :grid-auto-rows))) (auto-row (plist-get props :grid-auto-rows)))
(and (or (null auto-column) (and (or (null auto-column)
(ebox-grid--canonical-track-p auto-column)) (ebox-grid--canonical-track-p auto-column 'columns))
(or (null auto-row) (or (null auto-row)
(ebox-grid--canonical-track-p auto-row)))) (ebox-grid--canonical-track-p auto-row 'rows))))
(memq (plist-get props :grid-auto-flow) '(row column)) (memq (plist-get props :grid-auto-flow) '(row column))
(let ((row-gap (plist-get props :row-gap)) (let ((row-gap (plist-get props :row-gap))
(column-gap (plist-get props :column-gap))) (column-gap (plist-get props :column-gap)))
(and (integerp row-gap) (>= row-gap 0) (and (ebox-size-value-for-axis-p row-gap 'block)
(numberp column-gap) (>= column-gap 0))) (ebox-size-value-for-axis-p column-gap 'inline)))
(memq (plist-get props :justify-items) (memq (plist-get props :justify-items)
ebox--grid-justify-items-values) ebox--grid-justify-items-values)
(memq (plist-get props :align-items) (memq (plist-get props :align-items)
@ -512,7 +502,7 @@ grid-gap aliases are rejected at the author schema boundary."
(ebox-grid--mark matrix entry row column row-span column-span) (ebox-grid--mark matrix entry row column row-span column-span)
(push entry entries)))) (push entry entries))))
(list (nreverse entries) matrix (length matrix) (list (nreverse entries) matrix (length matrix)
(length (aref matrix 0))))) (if (> (length matrix) 0) (length (aref matrix 0)) columns))))
(defun ebox-grid--string-min-content-pixel (string) (defun ebox-grid--string-min-content-pixel (string)
"Return STRING's widest unbreakable run in pixels." "Return STRING's widest unbreakable run in pixels."
@ -524,29 +514,52 @@ grid-gap aliases are rejected at the author schema boundary."
"Return ENTRY's intrinsic size from RENDERED for AXIS and MODE." "Return ENTRY's intrinsic size from RENDERED for AXIS and MODE."
(let* ((node (plist-get entry :node)) (let* ((node (plist-get entry :node))
(string (gethash node rendered))) (string (gethash node rendered)))
(if (eq axis 'rows) (if (eq mode 'auto-min)
(ebox-string-height string) (let* ((horizontal (eq axis 'columns))
(pcase mode (span (plist-get entry (if horizontal :column-span :row-span))))
('min-content (if (ebox-box-node-p node)
(if (ebox-box-node-p node) (if (and (> (or span 1) 1)
(+ (ebox--side-pixel node) (ebox--content-min-pixel node)) (not (ebox--flex-explicit-min-main-p
(ebox-grid--string-min-content-pixel string))) node (if horizontal 'row 'column))))
('max-content 0
(if (ebox-box-node-p node) (/ (ebox--flex-box-min-main
(+ (ebox--side-pixel node) (ebox--content-max-pixel node)) node (or string "") (if horizontal 'row 'column))
(ebox--string-max-pixel-width string))) (float (or span 1))))
(_ (ebox--string-pixel-width string)))))) (if horizontal
(or (ebox--rendered-intrinsic-size string :width)
(ebox-grid--string-min-content-pixel (or string "")))
(or (ebox--rendered-intrinsic-size string :height)
(ebox-string-height (or string ""))))))
(if (eq axis 'rows)
(or (ebox--rendered-intrinsic-size string :height)
(ebox-string-height string))
(pcase mode
('min-content
(if (ebox-box-node-p node)
(+ (ebox--side-pixel node) (ebox--content-min-pixel node))
(or (ebox--rendered-intrinsic-size string :width)
(ebox-grid--string-min-content-pixel string))))
('max-content
(if (ebox-box-node-p node)
(+ (ebox--side-pixel node) (ebox--content-max-pixel node))
(or (ebox--rendered-intrinsic-size string :width)
(ebox--string-max-pixel-width string))))
(_ (if (and (ebox-box-node-p node)
(zerop (ebox-string-height string)))
(ebox--flex-source-width node string)
(or (ebox--rendered-intrinsic-size string :width)
(ebox--string-pixel-width string)))))))))
(defun ebox-grid--track-intrinsic (track axis entries rendered key slot-index) (defun ebox-grid--track-intrinsic (track axis entries rendered key slot-index)
"Return intrinsic size for TRACK from RENDERED grid ENTRIES." "Return intrinsic size for TRACK from RENDERED grid ENTRIES."
(pcase (plist-get track :kind) (pcase (plist-get track :kind)
('fixed (plist-get track :size)) ('fixed (plist-get track :size))
('fr 0)
('minmax ('minmax
(ebox-grid--track-intrinsic (plist-get track :min) (ebox-grid--track-intrinsic (plist-get track :min)
axis entries rendered key slot-index)) axis entries rendered key slot-index))
(_ (let ((position (1+ slot-index)) (_ (let ((position (1+ slot-index))
(mode (plist-get track :kind)) (mode (if (eq (plist-get track :kind) 'fr)
'auto-min (plist-get track :kind)))
(best 0)) (best 0))
(dolist (entry entries best) (dolist (entry entries best)
(when (and (<= (plist-get entry key) position) (when (and (<= (plist-get entry key) position)
@ -589,11 +602,50 @@ letting the grid fall back to each child's intrinsic width."
(min size maximum) (min size maximum)
size)) size))
(defun ebox-grid--used-track (track axis available &optional box)
"Resolve TRACK for AXIS and AVAILABLE using BOX's font metrics.
An indefinite percentage track participates as auto. Canonical source
descriptors remain unresolved, so subsequent layouts can use a new context."
(pcase (plist-get track :kind)
('fixed
(let* ((horizontal (eq axis 'columns))
(line-height (ebox--size-line-height box))
(ebox-viewport-width (if horizontal available ebox-viewport-width))
(ebox--css-containing-height
(if horizontal ebox--css-containing-height
(and available (* available line-height))))
(value (plist-get track :size))
(pixels (if (numberp value) value
(ebox--size-pixels value box
(if horizontal 'width 'height)))))
(if pixels
(list :kind 'fixed :size
(max 0 (if (or horizontal (numberp value)) pixels
(/ pixels (float line-height)))))
'(:kind auto :factor 0))))
('minmax
(let ((minimum (ebox-grid--used-track
(plist-get track :min) axis available box))
(maximum (ebox-grid--used-track
(plist-get track :max) axis available box)))
;; CSS ignores a maximum below the minimum once both are definite.
(when (and (eq (plist-get minimum :kind) 'fixed)
(eq (plist-get maximum :kind) 'fixed)
(< (plist-get maximum :size) (plist-get minimum :size)))
(setq maximum minimum))
(list :kind 'minmax :min minimum :max maximum)))
(_ track)))
(defun ebox-grid--resolve-sizes (defun ebox-grid--resolve-sizes
(tracks count axis entries rendered gap available &optional auto-track) (tracks count axis entries rendered gap available &optional auto-track box)
"Resolve TRACKS to COUNT sizes along AXIS using AUTO-TRACK for implicit tracks." "Resolve TRACKS to COUNT sizes along AXIS within AVAILABLE space.
(let* ((auto-track (or auto-track '(:kind auto :factor 0))) ENTRIES and RENDERED supply intrinsic content, GAP is the used gap, BOX
(tracks (append tracks nil)) supplies font metrics, and AUTO-TRACK describes implicit tracks."
(let* ((auto-track (ebox-grid--used-track
(or auto-track '(:kind auto :factor 0)) axis available box))
(tracks (mapcar (lambda (track)
(ebox-grid--used-track track axis available box))
tracks))
(tracks (append tracks (tracks (append tracks
(make-list (max 0 (- count (length tracks))) (make-list (max 0 (- count (length tracks)))
auto-track))) auto-track)))
@ -606,29 +658,68 @@ letting the grid fall back to each child's intrinsic width."
track axis entries rendered track axis entries rendered
(if (eq axis 'columns) :column :row) (if (eq axis 'columns) :column :row)
index)))) index))))
(fr-total (apply #'+ (mapcar #'ebox-grid--track-flex-factor tracks))) (flexible (cl-loop for track in tracks for index from 0
(used (+ (ebox-grid--sum sizes) (* gap (max 0 (1- count)))))) when (> (ebox-grid--track-flex-factor track) 0)
(when (and available (> fr-total 0)) collect index)))
(let ((remaining (max 0 (- available used)))) (when (and (null available) flexible)
(cl-loop for track in tracks (let ((fraction
for index from 0 (cl-loop for index in flexible
for factor = (ebox-grid--track-flex-factor track) for factor = (ebox-grid--track-flex-factor (nth index tracks))
when (> factor 0) maximize
do (setf (nth index sizes) (/ (float
(max (nth index sizes) (max (nth index sizes)
(floor (* remaining (ebox-grid--track-intrinsic
(/ (float factor) fr-total)))))))) '(:kind max-content) axis entries rendered
sizes)) (if (eq axis 'columns) :column :row) index)))
(max 1 factor)))))
(dolist (index flexible)
(setf (nth index sizes)
(* fraction (ebox-grid--track-flex-factor (nth index tracks)))))))
(when (and available flexible)
(let ((remaining
(- available (* gap (max 0 (1- count)))
(cl-loop for size in sizes for index from 0
unless (memq index flexible) sum size))))
(while flexible
(let* ((factor-total
(cl-loop for index in flexible
sum (ebox-grid--track-flex-factor (nth index tracks))))
(fraction (/ (float (max 0 remaining)) (max 1 factor-total)))
(frozen
(cl-remove-if-not
(lambda (index)
(> (nth index sizes)
(* fraction (ebox-grid--track-flex-factor
(nth index tracks)))))
flexible)))
(if frozen
(dolist (index frozen)
(setq remaining (- remaining (nth index sizes))
flexible (delq index flexible)))
(dolist (index flexible)
(setf (nth index sizes)
(* fraction (ebox-grid--track-flex-factor (nth index tracks)))))
(setq flexible nil))))))
(ebox-size-quantize sizes)))
(defun ebox-grid--content-independent-columns-p (tracks count available) (defun ebox-grid--content-independent-columns-p (tracks count available &optional children)
"Return non-nil when TRACKS determine COUNT widths without cell content. "Return non-nil when TRACKS determine COUNT widths without cell content.
Require a positive definite AVAILABLE width and explicit fixed/fr tracks. Require a definite AVAILABLE width and explicit fixed/fr tracks. CHILDREN
Implicit and intrinsic tracks retain the ordinary measurement path." must have zero automatic minima when fractional tracks are present."
(and (numberp available) (> available 0) (and (numberp available) (> available 0)
(>= (length tracks) count) (>= (length tracks) count)
(cl-every (lambda (track) (cl-every (lambda (track)
(memq (plist-get track :kind) '(fixed fr))) (memq (plist-get track :kind) '(fixed fr)))
tracks))) tracks)
(or (cl-every (lambda (track) (eq (plist-get track :kind) 'fixed)) tracks)
(cl-every
(lambda (child)
(and (ebox-box-node-p child)
(or (and (memq (ebox-get child :min-width) '(nil auto))
(memq (ebox-get child :overflow) '(auto scroll hidden)))
(equal (ebox-get child :min-width) 0)
(equal (ebox-get child :min-width) '(px 0)))))
children))))
(defun ebox-grid--distribute (amount count) (defun ebox-grid--distribute (amount count)
"Return COUNT integer shares that sum to AMOUNT." "Return COUNT integer shares that sum to AMOUNT."
@ -656,6 +747,26 @@ Implicit and intrinsic tracks retain the ordinary measurement path."
:trailing (nth 2 spacing) :trailing (nth 2 spacing)
:target target))))) :target target)))))
(defun ebox-grid--final-content-layout (sizes gap-value axis available alignment box)
"Lay out SIZES with GAP-VALUE on AXIS in AVAILABLE space for BOX.
ALIGNMENT positions tracks. Cyclic percentage gaps contribute zero during
intrinsic sizing, then resolve against that intrinsic size for final layout.
The resulting gap may overflow the unchanged intrinsic container extent."
(let* ((gap (ebox--layout-used-gap gap-value axis available box))
(cyclic (and (null available)
(ebox-size-value-p gap-value nil t)
(memq 'percent (ebox-size-dependencies gap-value))))
(intrinsic (+ (ebox-grid--sum sizes)
(* gap (max 0 (1- (length sizes))))))
(used-gap (if cyclic
(ebox--layout-used-gap gap-value axis intrinsic box)
gap))
(layout (ebox-grid--content-layout sizes used-gap available alignment)))
(when cyclic
(plist-put layout :target intrinsic)
(plist-put layout :intrinsic-size intrinsic))
layout))
(defun ebox-grid--entry-size (entry sizes gap) (defun ebox-grid--entry-size (entry sizes gap)
"Return the target pixel/line size for ENTRY across SIZES." "Return the target pixel/line size for ENTRY across SIZES."
(let* ((start (1- (plist-get entry :column))) (let* ((start (1- (plist-get entry :column)))
@ -668,13 +779,13 @@ Implicit and intrinsic tracks retain the ordinary measurement path."
(defun ebox-grid--auto-width-node-p (node) (defun ebox-grid--auto-width-node-p (node)
"Return non-nil when NODE derives its width from the active viewport." "Return non-nil when NODE derives its width from the active viewport."
(let ((width (plist-get node :width))) (let ((width (plist-get node :width)))
;; `stretch' and `contain' are containing-block widths. Treating them ;; `stretch' is a containing-block width. Treating it
;; as intrinsic leaves fractional Grid tracks empty and makes composite ;; as intrinsic leaves fractional Grid tracks empty and makes composite
;; cards hug their natural width instead of filling the assigned track. ;; cards hug their natural width instead of filling the assigned track.
(or (null width) (memq width '(auto stretch contain))))) (or (null width) (memq width '(auto stretch)))))
(defun ebox-grid--entry-source (entry rendered width props) (defun ebox-grid--entry-source (entry rendered width props)
"Return ENTRY's content rendered within WIDTH when it is auto-sized. "Return ENTRY's content rendered within its containing WIDTH.
Stretching is the default grid item alignment. Re-render an auto-sized Stretching is the default grid item alignment. Re-render an auto-sized
child at its assigned width for that alignment, while preserving its natural child at its assigned width for that alignment, while preserving its natural
@ -686,9 +797,12 @@ size for start/center/end alignment unless it would overflow its track."
(stretch-p (memq justify '(normal stretch)))) (stretch-p (memq justify '(normal stretch))))
(unless (or (and auto-width-p stretch-p) (stringp source)) (unless (or (and auto-width-p stretch-p) (stringp source))
(error "ebox-grid: Grid item requires missing intrinsic output")) (error "ebox-grid: Grid item requires missing intrinsic output"))
(if (and auto-width-p (if (or (and auto-width-p
(or stretch-p (> (ebox--string-max-pixel-width source) width))) (or stretch-p (> (ebox--string-max-pixel-width source) width)))
(let ((ebox-viewport-width width)) (and (not auto-width-p)
(ebox--viewport-dependent-subtree-p node)))
(let ((ebox-viewport-width width)
(ebox--render-root-parent-kind 'grid))
(ebox--render-with-cache node)) (ebox--render-with-cache node))
source))) source)))
@ -704,7 +818,10 @@ size for start/center/end alignment unless it would overflow its track."
(defun ebox-grid--align (string width height justify align) (defun ebox-grid--align (string width height justify align)
"Align STRING to WIDTH and HEIGHT using JUSTIFY and ALIGN." "Align STRING to WIDTH and HEIGHT using JUSTIFY and ALIGN."
(let ((result (ebox--lines-justify (let ((result (ebox--lines-justify
string width (if (or (null string) (string-empty-p string))
(ebox-grid--blank width height)
string)
width
(pcase justify (pcase justify
((or 'end 'right) 'right) ((or 'end 'right) 'right)
('center 'center) ('center 'center)
@ -712,17 +829,46 @@ size for start/center/end alignment unless it would overflow its track."
(ebox--lines-align-vertical (ebox--lines-align-vertical
result height result height
(pcase align (pcase align
((or 'end 'bottom) 'bottom) ((or 'end 'flex-end 'bottom) 'bottom)
('center 'center) ('center 'center)
(_ 'top))))) (_ 'top)))))
(defun ebox-grid--entry-string (entry rendered widths heights col-gap row-gap props) (defun ebox-grid--entry-string (entry rendered widths heights col-gap row-gap props)
"Align ENTRY's already width-sized RENDERED output within its rectangle." "Size and align ENTRY's width-sized RENDERED output within its rectangle.
WIDTHS and HEIGHTS hold track sizes separated by COL-GAP and ROW-GAP.
PROPS provides the container's item alignment defaults.
An auto-height Box stretches its own geometry and paint to the assigned rows.
Its normal Box renderer still owns margins, padding, and min/max constraints."
(let* ((width (ebox-grid--entry-size entry widths col-gap)) (let* ((width (ebox-grid--entry-size entry widths col-gap))
(height (ebox-grid--row-entry-size entry heights row-gap)) (height (ebox-grid--row-entry-size entry heights row-gap))
(source (gethash (plist-get entry :node) rendered)) (node (plist-get entry :node))
(source (gethash node rendered))
(justify (or (plist-get props :justify-items) 'stretch)) (justify (or (plist-get props :justify-items) 'stretch))
(align (or (plist-get props :align-items) 'stretch))) (self (plist-get node :align-self))
(align (if (memq self '(nil auto))
(or (plist-get props :align-items) 'stretch)
self))
(stretch-p (and (memq align '(normal stretch))
(ebox-box-node-p node)
(memq (ebox-get node :height) '(nil auto)))))
(when (or (and stretch-p (/= height (ebox-string-height source)))
(ebox--viewport-height-dependent-subtree-p node))
(let* ((ebox-viewport-width width)
(ebox--css-containing-height
(* height (ebox--size-line-height)))
(ebox--render-root-parent-kind 'grid)
;; Height assignment must not stretch the other axis when its
;; already sized output fits the track under start/center/end.
(ebox--inline-auto-width-intrinsic-p
(and (not (memq justify '(normal stretch)))
(< (ebox--string-max-pixel-width source) width)))
(sized (if stretch-p (copy-sequence node) node)))
(when stretch-p
(plist-put sized :region-id (ebox--ensure-region-id node))
(plist-put sized :height 'stretch))
(setq source (ebox--render-with-cache sized))
(when stretch-p
(puthash (ebox-get node :region-id) node ebox--region-box-table))))
(ebox-grid--align source width height justify align))) (ebox-grid--align source width height justify align)))
(defun ebox-grid--sized-rendered-entries (entries rendered widths gap props) (defun ebox-grid--sized-rendered-entries (entries rendered widths gap props)
@ -752,7 +898,7 @@ size for start/center/end alignment unless it would overflow its track."
(defun ebox-grid--blank (width height) (defun ebox-grid--blank (width height)
"Return a blank grid area of WIDTH pixels and HEIGHT lines." "Return a blank grid area of WIDTH pixels and HEIGHT lines."
(or (ebox--pixel-blank width height) (or (ebox--pixel-blank width height)
(ebox-lines-join (make-list (max 1 height) "")))) (ebox-lines-join (make-list (max 0 height) ""))))
(defun ebox-grid--slice (string offset height) (defun ebox-grid--slice (string offset height)
"Return HEIGHT lines from STRING beginning at OFFSET." "Return HEIGHT lines from STRING beginning at OFFSET."
@ -784,6 +930,8 @@ size for start/center/end alignment unless it would overflow its track."
(height (+ row-height gap-after)) (height (+ row-height gap-after))
(parts nil) (parts nil)
(column 0)) (column 0))
(when (and (> height 0) (null widths))
(push (ebox-grid--blank total-width height) parts))
(while (and (> height 0) (< column (length widths))) (while (and (> height 0) (< column (length widths)))
(let ((entry (ebox-grid--entry-at matrix row column))) (let ((entry (ebox-grid--entry-at matrix row column)))
(if (and entry (ebox-grid--entry-start-p entry row column)) (if (and entry (ebox-grid--entry-start-p entry row column))
@ -813,7 +961,7 @@ size for start/center/end alignment unless it would overflow its track."
(let ((width (nth column widths))) (let ((width (nth column widths)))
(push (ebox-grid--blank width height) parts) (push (ebox-grid--blank width height) parts)
(setq column (1+ column)))) (setq column (1+ column))))
(when (< column (length widths)) (when (and (< column (length widths)) (> col-gap 0))
(push (ebox-pixel-space col-gap) parts)))) (push (ebox-pixel-space col-gap) parts))))
(when parts (when parts
(setq result (setq result
@ -867,9 +1015,11 @@ size for start/center/end alignment unless it would overflow its track."
:height (ebox--resolve-size-content-height :height (ebox--resolve-size-content-height
box (ebox-get box :height) nil))) box (ebox-get box :height) nil)))
(defun ebox--render-grid-children (config constraints children) (defun ebox--render-grid-children (config constraints children &optional box)
"Render Grid CONFIG under frame CONSTRAINTS over flat CHILDREN." "Render Grid CONFIG under CONSTRAINTS over CHILDREN using BOX font metrics."
(let* ((props config) (let* ((props (ebox--layout-used-gaps
config (plist-get constraints :width)
(plist-get constraints :height) box))
(columns (plist-get props :grid-template-columns)) (columns (plist-get props :grid-template-columns))
(rows (plist-get props :grid-template-rows)) (rows (plist-get props :grid-template-rows))
(auto-columns (plist-get props :grid-auto-columns)) (auto-columns (plist-get props :grid-auto-columns))
@ -878,17 +1028,23 @@ size for start/center/end alignment unless it would overflow its track."
(plist-get props :column-gap))) (plist-get props :column-gap)))
(flow (or (plist-get props :grid-auto-flow) 'row)) (flow (or (plist-get props :grid-auto-flow) 'row))
(placed (ebox-grid--place-children children (placed (ebox-grid--place-children children
(max 1 (length columns)) (if children (max 1 (length columns))
(max 1 (length rows)) flow)) (length columns))
(if children (max 1 (length rows))
(length rows)) flow))
(entries (nth 0 placed)) (entries (nth 0 placed))
(matrix (nth 1 placed)) (matrix (nth 1 placed))
(row-count (nth 2 placed)) (row-count (nth 2 placed))
(column-count (nth 3 placed)) (column-count (nth 3 placed))
(available (plist-get constraints :width)) (available (when-let* ((width (plist-get constraints :width)))
(height (plist-get constraints :height)) (floor width)))
(height (when-let* ((height (plist-get constraints :height)))
(floor height)))
(ebox--css-containing-height
(and height (* height (ebox--size-line-height box))))
(content-independent-p (content-independent-p
(and (ebox-grid--content-independent-columns-p (and (ebox-grid--content-independent-columns-p
columns column-count available) columns column-count available children)
(memq (or (plist-get props :justify-items) 'stretch) (memq (or (plist-get props :justify-items) 'stretch)
'(normal stretch)))) '(normal stretch))))
(rendered (make-hash-table :test 'eq))) (rendered (make-hash-table :test 'eq)))
@ -907,30 +1063,34 @@ size for start/center/end alignment unless it would overflow its track."
(puthash child (ebox--render-with-cache child) rendered))))) (puthash child (ebox--render-with-cache child) rendered)))))
(let* ((widths (ebox-grid--resolve-sizes columns column-count 'columns (let* ((widths (ebox-grid--resolve-sizes columns column-count 'columns
entries rendered (cdr gaps) entries rendered (cdr gaps)
available auto-columns)) available auto-columns box))
(column-layout (column-layout
(ebox-grid--content-layout (ebox-grid--final-content-layout
widths (cdr gaps) available widths (plist-get config :column-gap) 'width available
(or (plist-get props :justify-content) 'start))) (or (plist-get props :justify-content) 'start) box))
(sized-rendered (sized-rendered
(ebox-grid--sized-rendered-entries (ebox-grid--sized-rendered-entries
entries rendered (plist-get column-layout :sizes) entries rendered (plist-get column-layout :sizes)
(plist-get column-layout :between) props)) (plist-get column-layout :between) props))
(heights (ebox-grid--resolve-sizes rows row-count 'rows (heights (ebox-grid--resolve-sizes rows row-count 'rows
entries sized-rendered (car gaps) entries sized-rendered (car gaps)
height auto-rows)) height auto-rows box))
(row-layout (row-layout
(ebox-grid--content-layout (ebox-grid--final-content-layout
heights (car gaps) height heights (plist-get config :row-gap) 'height height
(or (plist-get props :align-content) 'start))) (or (plist-get props :align-content) 'start) box))
(body (ebox-grid--render entries matrix column-layout row-layout (body (ebox-grid--render entries matrix column-layout row-layout
props sized-rendered))) props sized-rendered)))
body))) (ebox--record-rendered-intrinsic-size
body (or (plist-get column-layout :intrinsic-size)
(and (string-empty-p body)
(plist-get column-layout :target)))
(plist-get row-layout :intrinsic-size)))))
(defun ebox--render-grid-box-children (box config children) (defun ebox--render-grid-box-children (box config children)
"Render canonical BOX Grid CONFIG over flat CHILDREN." "Render canonical BOX Grid CONFIG over flat CHILDREN."
(ebox--render-grid-children (ebox--render-grid-children
config (ebox-grid--box-constraints box) children)) config (ebox-grid--box-constraints box) children box))
(defun ebox--render-grid (node) (defun ebox--render-grid (node)
"Render legacy GRID NODE to a propertized string." "Render legacy GRID NODE to a propertized string."

View File

@ -15,6 +15,7 @@
(require 'ebox-render-context) (require 'ebox-render-context)
(require 'ebox-patch-plan) (require 'ebox-patch-plan)
(require 'ebox-runtime-index) (require 'ebox-runtime-index)
(require 'ebox-size)
(defvar ebox-region-types) (defvar ebox-region-types)
(declare-function ebox--scroll-state-region-ids-containing-node-ids (declare-function ebox--scroll-state-region-ids-containing-node-ids
@ -472,10 +473,46 @@ the short runway that makes immediate post-resize smooth scrolling cheap."
(defvar ebox--render-cache-allow-viewport-dependent nil (defvar ebox--render-cache-allow-viewport-dependent nil
"Non-nil allows caching viewport-dependent nodes for the current context.") "Non-nil allows caching viewport-dependent nodes for the current context.")
(defvar ebox--css-viewport-dependent-subtree-cache nil
"Render-local memo table for explicit CSS viewport dependencies.")
(defvar ebox--size-dependency-properties)
(defun ebox--css-viewport-dependent-subtree-p (node)
"Return non-nil when NODE's subtree explicitly uses vw or vh.
A fixed containing block cannot isolate these units from the root viewport."
(cl-labels
((value-p (value)
(when (consp value)
(if (ebox-size-value-p value t)
(let ((deps (ebox-size-dependencies value)))
(or (memq 'viewport-width deps) (memq 'viewport-height deps)))
(cl-some #'value-p value))))
(props-p (props)
(cl-some (lambda (property) (value-p (plist-get props property)))
ebox--size-dependency-properties))
(compute ()
(let ((config (plist-get node :ebox-layout-config)))
(or (props-p node)
(props-p (plist-get node :props))
(props-p (plist-get node :raw-props))
(and config (props-p (ebox-layout-config-props config)))
(cl-some #'ebox--css-viewport-dependent-subtree-p
(ebox--node-children node))))))
(when (listp node)
(if (not ebox--css-viewport-dependent-subtree-cache)
(compute)
(let* ((missing (make-symbol "missing"))
(cached (gethash node ebox--css-viewport-dependent-subtree-cache
missing)))
(if (eq cached missing)
(puthash node (compute) ebox--css-viewport-dependent-subtree-cache)
cached))))))
(defun ebox--render-cache-contained-viewport-node-p (node) (defun ebox--render-cache-contained-viewport-node-p (node)
"Return non-nil when NODE bounds descendant viewport-dependent layout." "Return non-nil when NODE bounds descendant viewport-dependent layout."
(and (eq (and (listp node) (plist-get node :ebox-type)) 'box) (and (eq (and (listp node) (plist-get node :ebox-type)) 'box)
(ebox--span-patch-definite-size-value-p (ebox-get node :width)))) (ebox--span-patch-definite-size-value-p (ebox-get node :width))
(not (ebox--css-viewport-dependent-subtree-p node))))
(defun ebox--render-cacheable-node-p (node) (defun ebox--render-cacheable-node-p (node)
"Return non-nil when NODE can reuse rendered output in the current context." "Return non-nil when NODE can reuse rendered output in the current context."
@ -555,6 +592,8 @@ Kept as a narrow predicate for callers that need box-only sizing semantics."
(ebox--runtime-node-ids node) (ebox--runtime-node-ids node)
(ebox--node-all-region-ids node)) (ebox--node-all-region-ids node))
(list (ebox--current-display-signature) (list (ebox--current-display-signature)
(when (ebox--css-viewport-dependent-subtree-p node)
(list ebox--css-viewport-width ebox--css-viewport-height))
(when (and (ebox--viewport-dependent-subtree-p node) (when (and (ebox--viewport-dependent-subtree-p node)
(not (ebox--render-cache-contained-viewport-node-p (not (ebox--render-cache-contained-viewport-node-p
node))) node)))
@ -1728,14 +1767,11 @@ Defaults to the current buffer."
role-ids)) role-ids))
(defun ebox--next-region-property-change (pos limit) (defun ebox--next-region-property-change (pos limit)
"Return the next ebox region property change after POS, capped at LIMIT." "Return the next possible region boundary after POS, capped at LIMIT.
(let ((next limit)) An unrelated property may split a region run; ownership indexes merge those
(dolist (entry ebox-region-types) adjacent ranges. One native interval lookup avoids searching the same string
(let ((change (next-single-property-change separately for every role property."
pos (cdr entry) nil limit))) (or (next-property-change pos nil limit) limit))
(when (< change next)
(setq next change))))
next))
(defun ebox--index-add-region-line-range (defun ebox--index-add-region-line-range
(region-lines region-id line-number start end) (region-lines region-id line-number start end)
@ -2602,11 +2638,27 @@ checks pays one walk per distinct node instead of one walk per pair."
(append (nreverse survivors) (list candidate)))))) (append (nreverse survivors) (list candidate))))))
(defun ebox--span-patch-definite-size-value-p (value) (defun ebox--span-patch-definite-size-value-p (value)
"Return non-nil when VALUE is definite enough for local span planning." "Return non-nil when VALUE has no external layout-size dependency.
(and value Private used numbers and fixed CSS expressions admit local footprint proofs.
(not (memq value '(auto min-content max-content fit-content Keywords, percentages, and viewport units still require containing-layout
stretch contain viewport))) evidence rather than a value resolved in the current ambient viewport."
(not (ebox--viewport-dependent-size-value-p value)))) (or (numberp value)
(and (ebox-size-value-p value)
(not (cl-intersection
'(percent viewport-width viewport-height)
(ebox-size-dependencies value))))))
(defun ebox--span-patch-used-size (node value axis)
"Return fixed VALUE in NODE's used units on AXIS, or nil without proof.
Horizontal geometry uses pixels and vertical geometry uses text lines."
(when (ebox--span-patch-definite-size-value-p value)
(if (numberp value)
value
(when-let* ((pixels (ebox--size-pixels
value node (if (eq axis 'vertical) 'height 'width))))
(if (eq axis 'vertical)
(/ (max 0 pixels) (float (ebox--size-line-height node)))
(max 0 pixels))))))
(defun ebox--dirty-entry-local-span-keys-p (entry) (defun ebox--dirty-entry-local-span-keys-p (entry)
"Return non-nil when ENTRY changed only local span candidate keys. "Return non-nil when ENTRY changed only local span candidate keys.
@ -2625,7 +2677,8 @@ keeps the change local to the owner's own rendered spans."
(defun ebox--border-box-inline-content-fits-p (style-node) (defun ebox--border-box-inline-content-fits-p (style-node)
"Return non-nil when STYLE-NODE's inline chrome fits its fixed width." "Return non-nil when STYLE-NODE's inline chrome fits its fixed width."
(let ((width (ebox-get style-node :width))) (let ((width (ebox--span-patch-used-size
style-node (ebox-get style-node :width) 'horizontal)))
(and (numberp width) (and (numberp width)
(>= width (>= width
(+ (ebox-get style-node :padding-left-pixel) (+ (ebox-get style-node :padding-left-pixel)
@ -2635,7 +2688,8 @@ keeps the change local to the owner's own rendered spans."
(defun ebox--border-box-block-content-fits-p (style-node) (defun ebox--border-box-block-content-fits-p (style-node)
"Return non-nil when STYLE-NODE's block padding leaves one content line." "Return non-nil when STYLE-NODE's block padding leaves one content line."
(let ((height (ebox-get style-node :height))) (let ((height (ebox--span-patch-used-size
style-node (ebox-get style-node :height) 'vertical)))
(and (numberp height) (and (numberp height)
(>= height (>= height
(+ 1 (+ 1
@ -2704,7 +2758,8 @@ outer width and height are definite and still contain their inner chrome."
'(internal-fragment external-footprint-proof)) '(internal-fragment external-footprint-proof))
((and (eq (ebox-get style-node :box-sizing) 'border-box) ((and (eq (ebox-get style-node :box-sizing) 'border-box)
(memq key ebox--span-patch-border-box-block-keys) (memq key ebox--span-patch-border-box-block-keys)
(numberp (ebox-get style-node :height))) (ebox--span-patch-definite-size-value-p
(ebox-get style-node :height)))
'(internal-fragment external-footprint-proof)) '(internal-fragment external-footprint-proof))
((eq key :overflow) ((eq key :overflow)
'(clipping external-footprint)) '(clipping external-footprint))
@ -2966,19 +3021,27 @@ patched."
(let ((min-width (ebox-get style-node :min-width)) (let ((min-width (ebox-get style-node :min-width))
(max-width (ebox-get style-node :max-width)) (max-width (ebox-get style-node :max-width))
(min-height (ebox-get style-node :min-height)) (min-height (ebox-get style-node :min-height))
(max-height (ebox-get style-node :max-height))) (max-height (ebox-get style-node :max-height))
(auto-min-independent
(let* ((parent-id (ebox--runtime-parent-id buffer node-id))
(parent (and parent-id
(ebox--buffer-runtime-node buffer parent-id))))
(not (memq (and parent (ebox--display-inner parent)) '(flex grid))))))
(and (ebox--definite-containment-inline-size-value-p (and (ebox--definite-containment-inline-size-value-p
(ebox-get style-node :width)) (ebox-get style-node :width))
(or (null min-width) (or (null min-width)
(and auto-min-independent (eq min-width 'auto))
(ebox--definite-containment-inline-size-value-p min-width)) (ebox--definite-containment-inline-size-value-p min-width))
(or (null max-width) (or (null max-width)
(eq max-width 'none) (eq max-width 'none)
(ebox--definite-containment-inline-size-value-p max-width)) (ebox--definite-containment-inline-size-value-p max-width))
(numberp (ebox-get style-node :height)) (ebox--span-patch-definite-size-value-p (ebox-get style-node :height))
(or (null min-height) (numberp min-height)) (or (null min-height)
(and auto-min-independent (eq min-height 'auto))
(ebox--span-patch-definite-size-value-p min-height))
(or (null max-height) (or (null max-height)
(eq max-height 'none) (eq max-height 'none)
(numberp max-height)) (ebox--span-patch-definite-size-value-p max-height))
(not (eq (ebox-get style-node :overflow) 'visible)) (not (eq (ebox-get style-node :overflow) 'visible))
(not (ebox--node-visible-overflow-p node)))))) (not (ebox--node-visible-overflow-p node))))))
@ -3012,7 +3075,8 @@ patched."
(if (eq axis 'row) (if (eq axis 'row)
(ebox--span-patch-definite-size-value-p (ebox--span-patch-definite-size-value-p
(ebox-get style-node :width)) (ebox-get style-node :width))
(numberp (ebox-get style-node :height))))) (ebox--span-patch-definite-size-value-p
(ebox-get style-node :height)))))
(defun ebox--flex-border-box-main-internal-key-p (key axis) (defun ebox--flex-border-box-main-internal-key-p (key axis)
"Return non-nil when KEY can be internal to a fixed border-box main size." "Return non-nil when KEY can be internal to a fixed border-box main size."
@ -3138,13 +3202,14 @@ A definite basis owns the item's base size. An explicit zero main-axis
minimum removes the content-minimum clamp, so grow/shrink allocation depends minimum removes the content-minimum clamp, so grow/shrink allocation depends
only on the stable container and sibling flex inputs. Cross-axis compatibility only on the stable container and sibling flex inputs. Cross-axis compatibility
is still verified by the existing rendered slot-footprint proof." is still verified by the existing rendered slot-footprint proof."
(let* ((basis-value (plist-get item-props :flex-basis)) (let* ((size-axis (if (eq axis 'row) 'horizontal 'vertical))
(basis (and (not (memq basis-value (basis (ebox--span-patch-used-size
'(nil auto min-content max-content style-node (plist-get item-props :flex-basis) size-axis))
fit-content stretch contain viewport))) (minimum
(ebox--flex-main-value axis basis-value nil))) (ebox--span-patch-used-size
(minimum (ebox-get style-node style-node
(if (eq axis 'row) :min-width :min-height)))) (ebox-get style-node (if (eq axis 'row) :min-width :min-height))
size-axis)))
(and (numberp basis) (>= basis 0) (and (numberp basis) (>= basis 0)
(numberp minimum) (= minimum 0)))) (numberp minimum) (= minimum 0))))
@ -3558,6 +3623,23 @@ running expensive span and snapshot checks."
buffer entry buffer entry
ebox-incremental--candidate-state-for-slot-proof)) ebox-incremental--candidate-state-for-slot-proof))
source-node-id) source-node-id)
((and ebox-incremental--candidate-state-for-slot-proof
(not (equal source-node-id node-id))
(eq dirty-kind 'geometry)
(memq :content (plist-get entry :changed-keys))
(cl-some
(lambda (signature)
(let ((content (plist-get signature :content)))
(and (stringp content) (string-match-p "\n" content))))
(list (plist-get entry :old-signature)
(plist-get entry :new-signature)))
;; Canonical Text owns the content identity; its Box owns the
;; allocated slot. Ask the same slot proof about that owner
;; before promoting a text-length change to the parent layout.
(ebox-incremental--content-slot-stable-p
buffer (plist-put (copy-sequence entry) :node-id node-id)
ebox-incremental--candidate-state-for-slot-proof))
node-id)
((and (ebox--dirty-entry-span-patch-candidate-p buffer entry) ((and (ebox--dirty-entry-span-patch-candidate-p buffer entry)
(not (ebox--impact-vector-requires-parent-promotion-p (not (ebox--impact-vector-requires-parent-promotion-p
impact-vector)) impact-vector))
@ -3731,20 +3813,57 @@ cannot cover an old unowned visible-overflow suffix."
When NIL-DEPENDENT is non-nil, nil is treated as viewport-dependent auto width." When NIL-DEPENDENT is non-nil, nil is treated as viewport-dependent auto width."
(or (and nil-dependent (null value)) (or (and nil-dependent (null value))
(and nil-dependent (eq value 'auto)) (and nil-dependent (eq value 'auto))
(memq value '(stretch contain fit-content viewport)) (memq value '(stretch fit-content))
(and (consp value) (ebox--size-data-viewport-dependent-p value 'viewport-width)))
(or (eq (car value) 'viewport)
(and (eq (car value) 'fit-content) (defun ebox--size-data-viewport-dependent-p (value axis)
(ebox--viewport-dependent-size-value-p (cadr value))))))) "Return non-nil when size VALUE depends on viewport AXIS.
Descend through track and shorthand lists as well as size expressions.
Percentages conservatively depend on both axes: the containing block may
itself obtain either dimension through an aspect or cross-axis constraint."
(when (consp value)
(if (ebox-size-value-p value t)
(let ((dependencies (ebox-size-dependencies value)))
(or (memq axis dependencies) (memq 'percent dependencies)))
(cl-some (lambda (item)
(ebox--size-data-viewport-dependent-p item axis))
value))))
(defconst ebox--size-dependency-properties
'(:width :height :min-width :min-height :max-width :max-height
:padding :padding-top :padding-right :padding-bottom :padding-left
:margin :margin-top :margin-right :margin-bottom :margin-left
:padding-left-pixel :padding-right-pixel
:padding-top-height :padding-bottom-height
:margin-left-pixel :margin-right-pixel
:margin-top-height :margin-bottom-height
:border-left-pixel :border-right-pixel :border-top-pixel :border-bottom-pixel
:border-top-width :border-right-width :border-bottom-width :border-left-width
:flex-basis :gap :row-gap :column-gap :item-gap
:grid-template-columns :grid-template-rows :grid-auto-columns :grid-auto-rows)
"Own-node size fields inspected for retained viewport dependencies.")
(defun ebox--size-props-viewport-dependent-p (props axis)
"Return non-nil when size fields in PROPS depend on viewport AXIS."
(cl-some (lambda (property)
(ebox--size-data-viewport-dependent-p
(plist-get props property) axis))
ebox--size-dependency-properties))
(defun ebox--node-size-viewport-dependent-p (node axis)
"Inspect NODE's own size declarations and layout config for AXIS."
(let ((config (plist-get node :ebox-layout-config)))
(or (ebox--size-props-viewport-dependent-p node axis)
(ebox--size-props-viewport-dependent-p (plist-get node :props) axis)
(ebox--size-props-viewport-dependent-p (plist-get node :raw-props) axis)
(and config
(ebox--size-props-viewport-dependent-p
(ebox-layout-config-props config) axis)))))
(defun ebox--viewport-dependent-height-value-p (value) (defun ebox--viewport-dependent-height-value-p (value)
"Return non-nil when vertical size VALUE depends on viewport height." "Return non-nil when vertical size VALUE depends on viewport height."
(or (eq value 'viewport-height) (or (eq value 'stretch)
(and (consp value) (ebox--size-data-viewport-dependent-p value 'viewport-height)))
(or (eq (car value) 'viewport-height)
(and (memq (car value) '(+ -))
(cl-some #'ebox--viewport-dependent-height-value-p
(cdr value)))))))
(defun ebox--viewport-dependent-height-props-p (props) (defun ebox--viewport-dependent-height-props-p (props)
"Return non-nil when PROPS has a viewport-height size constraint." "Return non-nil when PROPS has a viewport-height size constraint."
@ -3758,14 +3877,15 @@ When NIL-DEPENDENT is non-nil, nil is treated as viewport-dependent auto width."
"Return non-nil when NODE's own vertical size uses viewport height." "Return non-nil when NODE's own vertical size uses viewport height."
(when (and (listp node) (when (and (listp node)
(not (stringp node))) (not (stringp node)))
(pcase (plist-get node :ebox-type) (or (ebox--node-size-viewport-dependent-p node 'viewport-height)
(pcase (plist-get node :ebox-type)
('box ('box
(ebox--viewport-dependent-height-props-p node)) (ebox--viewport-dependent-height-props-p node))
('flex ('flex
(or (ebox--viewport-dependent-height-props-p (or (ebox--viewport-dependent-height-props-p
(plist-get node :props)) (plist-get node :props))
(ebox--viewport-dependent-height-props-p (ebox--viewport-dependent-height-props-p
(plist-get node :raw-props))))))) (plist-get node :raw-props))))))))
(defun ebox--flex-participation-viewport-dependent-p (node) (defun ebox--flex-participation-viewport-dependent-p (node)
"Return non-nil when NODE's flex item metadata depends on viewport context." "Return non-nil when NODE's flex item metadata depends on viewport context."
@ -3777,7 +3897,8 @@ When NIL-DEPENDENT is non-nil, nil is treated as viewport-dependent auto width."
"Return non-nil when NODE's own layout depends on viewport width." "Return non-nil when NODE's own layout depends on viewport width."
(when (listp node) (when (listp node)
(and (not (eq (plist-get node :ebox-kind) 'text)) (and (not (eq (plist-get node :ebox-kind) 'text))
(or (ebox--flex-participation-viewport-dependent-p node) (or (ebox--node-size-viewport-dependent-p node 'viewport-width)
(ebox--flex-participation-viewport-dependent-p node)
(cond (cond
((eq (plist-get node :ebox-kind) 'box) ((eq (plist-get node :ebox-kind) 'box)
(or (ebox--viewport-dependent-size-value-p (or (ebox--viewport-dependent-size-value-p
@ -4613,7 +4734,8 @@ Only the region owner and its ancestor path are copied; untouched runtime
subtrees remain shared with the published root. Return nil when the retained subtrees remain shared with the published root. Return nil when the retained
index cannot identify a directly replaceable region owner, leaving callers to index cannot identify a directly replaceable region owner, leaving callers to
use their ordinary isolated-copy path. ISOLATE-OWNER-SUBTREE-P additionally use their ordinary isolated-copy path. ISOLATE-OWNER-SUBTREE-P additionally
copies the owner's descendants before mutation while retaining their ids." copies the owner's descendants before mutation while retaining their ids.
Text regions use the same path-copy contract as Box regions."
(let* ((state (ebox--buffer-render-state buffer)) (let* ((state (ebox--buffer-render-state buffer))
(root (plist-get state :root-node)) (root (plist-get state :root-node))
(node-table (plist-get state :node-table)) (node-table (plist-get state :node-table))
@ -5623,7 +5745,10 @@ style consumers; the Style Adapter computes their values exactly once later."
(plist-put next :ebox-source-handle (plist-put next :ebox-source-handle
(plist-get patch :ebox-source-handle)) (plist-get patch :ebox-source-handle))
(dolist (key changed-keys) (dolist (key changed-keys)
(plist-put next key (copy-tree (plist-get patch key)))) (plist-put next key
(if (eq key :surface-properties)
(tp-property-value-copy (plist-get patch key))
(copy-tree (plist-get patch key)))))
(dolist (key '(:ebox-style-overrides)) (dolist (key '(:ebox-style-overrides))
(when (plist-member patch key) (when (plist-member patch key)
(plist-put (plist-put
@ -6785,8 +6910,10 @@ Each immutable source record is resolved once for this node pair."
(new-entry (plist-member new key))) (new-entry (plist-member new key)))
(unless (and (eq (not (null old-entry)) (unless (and (eq (not (null old-entry))
(not (null new-entry))) (not (null new-entry)))
(equal-including-properties (funcall (if (eq key :surface-properties)
(cadr old-entry) (cadr new-entry))) #'ebox-interaction-surface-equal-p
#'equal-including-properties)
(cadr old-entry) (cadr new-entry)))
(push key changed)))))))) (push key changed))))))))
(setq changed (nreverse changed)) (setq changed (nreverse changed))
(unless (eq old-record new-record) (unless (eq old-record new-record)
@ -7172,9 +7299,16 @@ certificate."
(plist-get local-preparation :dirty-set) (plist-get local-preparation :dirty-set)
(ebox-incremental--declarative-dirty-set (ebox-incremental--declarative-dirty-set
old-state candidate-root candidate-index))) old-state candidate-root candidate-index)))
(axis-node-ids
(unless (ebox-style-cascade-active-p)
(cl-loop for entry in source-dirty-set
when (cl-intersection
(plist-get entry :changed-keys)
'(:flex :flex-basis :flex-direction :flex-flow))
collect (plist-get entry :node-id))))
(dirty-set source-dirty-set) (dirty-set source-dirty-set)
(current-display-signature (ebox--current-display-signature))) (current-display-signature (ebox--current-display-signature)))
(when (or local-preparation prepare-all-styles-p) (when (or local-preparation prepare-all-styles-p axis-node-ids)
;; Participation depends on the final parent relation and, when the ;; Participation depends on the final parent relation and, when the
;; local Style Adapter succeeded, on computed values. Validate that ;; local Style Adapter succeeded, on computed values. Validate that
;; final contract once here; Surface consumes the resulting ;; final contract once here; Surface consumes the resulting
@ -7182,8 +7316,9 @@ certificate."
(ebox-tree-validate-indexed-participation (ebox-tree-validate-indexed-participation
(plist-get candidate-index :node-table) (plist-get candidate-index :node-table)
(plist-get candidate-index :parent-table) (plist-get candidate-index :parent-table)
(or (plist-get local-preparation :participation-node-ids) (append (or (plist-get local-preparation :participation-node-ids)
style-node-ids) style-node-ids)
axis-node-ids)
(if styles-prepared-p 'computed 'author) (if styles-prepared-p 'computed 'author)
candidate-source-index)) candidate-source-index))
(when styles-prepared-p (when styles-prepared-p
@ -7621,9 +7756,10 @@ line count; its outside siblings are shifted by the one scoped replacement."
old-node) old-node)
(ebox-incremental--layout-slot-style-signature (ebox-incremental--layout-slot-style-signature
new-node)) new-node))
(numberp (ebox-get old-node :width)) (ebox--span-patch-definite-size-value-p
(= (ebox-get old-node :width) (ebox-get old-node :width))
(ebox-get new-node :width)) (equal (ebox-get old-node :width)
(ebox-get new-node :width))
(eq (ebox-tree-display-inner parent) 'column) (eq (ebox-tree-display-inner parent) 'column)
(eq (ebox-tree-display-inner parent-new) 'column) (eq (ebox-tree-display-inner parent-new) 'column)
ancestors-stable-p ancestors-stable-p
@ -7645,7 +7781,9 @@ line count; its outside siblings are shifted by the one scoped replacement."
:old-spans spans :old-spans spans
:old-block-span block-span :old-block-span block-span
:region-ids (plist-get snapshot :region-ids) :region-ids (plist-get snapshot :region-ids)
:allocated-width (ebox-get old-node :width) :allocated-width
(ebox--span-patch-used-size
old-node (ebox-get old-node :width) 'horizontal)
:layout-snapshot-detail-generation :layout-snapshot-detail-generation
(plist-get old-state (plist-get old-state
:layout-snapshot-detail-generation)))) :layout-snapshot-detail-generation))))
@ -7673,7 +7811,9 @@ their disjoint span or allocation proofs. Larger dirty sets keep coalescing."
(plist-get (plist-get left :old-signature) :width)) (plist-get (plist-get left :old-signature) :width))
(right-width (right-width
(plist-get (plist-get right :old-signature) :width))) (plist-get (plist-get right :old-signature) :width)))
(and (numberp left-width) (not (numberp right-width))))))) (and (ebox--span-patch-definite-size-value-p left-width)
(not (ebox--span-patch-definite-size-value-p
right-width)))))))
(when valid (when valid
(let* ((local-prepared (plist-put (copy-sequence geometry-prepared) (let* ((local-prepared (plist-put (copy-sequence geometry-prepared)
:dirty-set (list dirty))) :dirty-set (list dirty)))
@ -7695,9 +7835,9 @@ their disjoint span or allocation proofs. Larger dirty sets keep coalescing."
;; closure anyway, so do that before constructing the more ;; closure anyway, so do that before constructing the more
;; expensive span-footprint proof that usually rejects it. ;; expensive span-footprint proof that usually rejects it.
(or (eq (plist-get (car plan) :op) 'owner-rerender) (or (eq (plist-get (car plan) :op) 'owner-rerender)
(not (and (numberp old-width) (not (and (ebox--span-patch-definite-size-value-p old-width)
(numberp new-width) (ebox--span-patch-definite-size-value-p new-width)
(= old-width new-width)))))) (equal old-width new-width))))))
(early-allocation-proof (early-allocation-proof
(and allocation-first-p (and allocation-first-p
(ebox-incremental--allocation-closure-proof (ebox-incremental--allocation-closure-proof
@ -8620,7 +8760,7 @@ the surface is allowed to reuse retained TP objects."
(buffer node spans) (buffer node spans)
"Return non-nil when path-copied NODE keeps the published line count." "Return non-nil when path-copied NODE keeps the published line count."
(and (eq (plist-get node :ebox-type) 'box) (and (eq (plist-get node :ebox-type) 'box)
(numberp (ebox-get node :width)) (ebox--span-patch-definite-size-value-p (ebox-get node :width))
(null (ebox-tree-node-children node)) (null (ebox-tree-node-children node))
(= (length spans) (= (length spans)
(with-current-buffer buffer (with-current-buffer buffer
@ -8887,16 +9027,15 @@ candidate's unrendered caches."
(defun ebox-incremental--stable-grid-track-p (track) (defun ebox-incremental--stable-grid-track-p (track)
"Return non-nil when TRACK is fixed or a positive fractional track." "Return non-nil when TRACK is fixed or a positive fractional track."
(or (numberp track) (or (ebox--span-patch-definite-size-value-p track)
(and (consp track) (null (cdr track)) (numberp (car track)))
(and (proper-list-p track) (and (proper-list-p track)
(memq (plist-get track :kind) '(fixed fr)) (memq (plist-get track :kind) '(fixed fr))
(let ((value (if (eq (plist-get track :kind) 'fixed) (let ((value (if (eq (plist-get track :kind) 'fixed)
(plist-get track :size) (plist-get track :size)
(plist-get track :factor)))) (plist-get track :factor))))
(and (numberp value) (>= value 0) (if (eq (plist-get track :kind) 'fixed)
(or (eq (plist-get track :kind) 'fixed) (ebox--span-patch-definite-size-value-p value)
(> value 0))))) (and (numberp value) (> value 0)))))
(and (symbolp track) (and (symbolp track)
(string-match-p (string-match-p
"\\`[0-9]+\\(?:\\.[0-9]+\\)?fr\\'" (symbol-name track))) "\\`[0-9]+\\(?:\\.[0-9]+\\)?fr\\'" (symbol-name track)))
@ -8905,14 +9044,13 @@ candidate's unrendered caches."
(null (cddr track))))) (null (cddr track)))))
(defun ebox-incremental--fixed-grid-track-p (track) (defun ebox-incremental--fixed-grid-track-p (track)
"Return non-nil when TRACK has a definite numeric size. "Return non-nil when TRACK has a definite fixed size.
The fast variable-content proof deliberately excludes fractional tracks; their The fast variable-content proof deliberately excludes fractional tracks; their
allocation remains on the full ancestor-slot proof path." allocation remains on the full ancestor-slot proof path."
(or (numberp track) (or (ebox--span-patch-definite-size-value-p track)
(and (consp track) (null (cdr track)) (numberp (car track)))
(and (proper-list-p track) (and (proper-list-p track)
(eq (plist-get track :kind) 'fixed) (eq (plist-get track :kind) 'fixed)
(numberp (plist-get track :size))))) (ebox--span-patch-definite-size-value-p (plist-get track :size)))))
(defun ebox-incremental--compute-allocated-parent-slot-stable-p (defun ebox-incremental--compute-allocated-parent-slot-stable-p
(buffer entry candidate-state) (buffer entry candidate-state)
@ -8957,7 +9095,8 @@ complete published ancestor slot chain remains identical."
(new-content (plist-get (plist-get entry :new-signature) :content))) (new-content (plist-get (plist-get entry :new-signature) :content)))
(and old-state old-node new-node old-grid new-grid (and old-state old-node new-node old-grid new-grid
(memq :content (plist-get entry :changed-keys)) (memq :content (plist-get entry :changed-keys))
(or (and (numberp (ebox-get old-node :height)) (or (and (ebox--span-patch-definite-size-value-p
(ebox-get old-node :height))
(equal (ebox-get old-node :height) (equal (ebox-get old-node :height)
(ebox-get new-node :height))) (ebox-get new-node :height)))
(and (equal (plist-get (and (equal (plist-get
@ -8977,12 +9116,14 @@ complete published ancestor slot chain remains identical."
(defun ebox-incremental--compute-content-slot-stable (defun ebox-incremental--compute-content-slot-stable
(buffer entry candidate-state) (buffer entry candidate-state)
"Return strict evidence that a one-line auto-width owner is local. "Return strict evidence that an auto-width content owner is local.
The owner may have no declared width when its nearest unchanged formatting The owner may have no declared width when its nearest unchanged formatting
context has a stable allocated slot. A column parent is safe when its own context has a stable allocated slot. A column parent is safe when its own
layout facts and child topology are unchanged; a row parent is accepted only layout facts and child topology are unchanged; a row parent is accepted only
for the final child, so changing that child's intrinsic width cannot shift a for the final child, so changing that child's intrinsic width cannot shift a
following sibling. The surface footprint validator remains the final gate." following sibling. Multiline content additionally requires a definite,
unchanged height in a normal Column slot. The surface footprint validator
remains the final gate."
(let* ((old-state (ebox-incremental--proof-base-state buffer)) (let* ((old-state (ebox-incremental--proof-base-state buffer))
(node-id (plist-get entry :node-id)) (node-id (plist-get entry :node-id))
(old-node (and node-id (old-node (and node-id
@ -9028,18 +9169,24 @@ following sibling. The surface footprint validator remains the final gate."
(not (ebox-get old-node :flex-shrink)))) (not (ebox-get old-node :flex-shrink))))
(memq :content (plist-get entry :changed-keys)) (memq :content (plist-get entry :changed-keys))
(stringp old-content) (stringp new-content) (stringp old-content) (stringp new-content)
(not (string-match-p "\n" old-content))
(not (string-match-p "\n" new-content))
snapshot snapshot
(equal (plist-get (or (and (not (string-match-p "\n" old-content))
(plist-get snapshot :external-footprint-signature) (not (string-match-p "\n" new-content))
:block-lines) (equal (plist-get
1) (plist-get snapshot :external-footprint-signature)
:block-lines)
1))
(and (ebox--span-patch-definite-size-value-p
(ebox-get old-node :height))
(equal (ebox-get old-node :height)
(ebox-get new-node :height))
(ebox-incremental--owner-slot-stable-p
buffer old-state candidate-state node-id)))
ancestor-signature))) ancestor-signature)))
(defun ebox-incremental--content-slot-stable-p (defun ebox-incremental--content-slot-stable-p
(buffer entry candidate-state) (buffer entry candidate-state)
"Return cached one-line content-slot evidence for ENTRY." "Return cached content-slot evidence for ENTRY."
(if (not ebox-incremental--allocated-slot-proof-cache) (if (not ebox-incremental--allocated-slot-proof-cache)
(ebox-incremental--compute-content-slot-stable (ebox-incremental--compute-content-slot-stable
buffer entry candidate-state) buffer entry candidate-state)
@ -9148,7 +9295,7 @@ style, and the published ancestor slot chain remain mandatory."
(ebox-incremental--node-child-ids-equal-p old-parent new-parent) (ebox-incremental--node-child-ids-equal-p old-parent new-parent)
(equal (ebox-incremental--layout-slot-style-signature old-parent) (equal (ebox-incremental--layout-slot-style-signature old-parent)
(ebox-incremental--layout-slot-style-signature new-parent)) (ebox-incremental--layout-slot-style-signature new-parent))
(numberp (ebox-get old-node :width)) (ebox--span-patch-definite-size-value-p (ebox-get old-node :width))
(equal (ebox-get old-node :width) (ebox-get new-node :width)) (equal (ebox-get old-node :width) (ebox-get new-node :width))
(not (ebox-get old-node :flex-grow)) (not (ebox-get old-node :flex-grow))
(not (ebox-get old-node :flex-shrink)) (not (ebox-get old-node :flex-shrink))
@ -9552,6 +9699,12 @@ metadata; this predicate only authorizes the local attempt."
spans spans
(or local-typography-p (or local-typography-p
(not path-copied-p) (not path-copied-p)
;; A copied owner subtree has the same mutation
;; isolation as the ordinary full-copy candidate.
;; Its shared siblings are outside this render, and
;; the final slot footprint still proves geometry.
(not (ebox-incremental--candidate-shares-published-node-p
old-state new-node))
(ebox-incremental--path-span-line-count-stable-p (ebox-incremental--path-span-line-count-stable-p
buffer new-node spans) buffer new-node spans)
content-slot-evidence content-slot-evidence
@ -10077,6 +10230,9 @@ role, and outside-complement compatibility before publication."
:geometry-proof)))) :geometry-proof))))
(certificate-proofs (certificate-proofs
(and certificate-proof (and certificate-proof
;; Cache coordinates must never become buffer allocation
;; certificates. Visible snapshots are installed separately.
(not (plist-get certificate-proof :scroll-content-proof))
(ebox-incremental--owner-proof-list certificate-proof))) (ebox-incremental--owner-proof-list certificate-proof)))
(overrides (overrides
(append (append
@ -10232,6 +10388,7 @@ role, and outside-complement compatibility before publication."
(ebox-incremental--owner-proof-list (ebox-incremental--owner-proof-list
(plist-get (plist-get prepared :mixed-owner-proof) (plist-get (plist-get prepared :mixed-owner-proof)
:geometry-proof)))) :geometry-proof))))
:scroll-content-proof (plist-get span-proof :scroll-content-proof)
:formatting-context-reflow-proof :formatting-context-reflow-proof
(and (or (eq projection-kind 'formatting-context-reflow) (and (or (eq projection-kind 'formatting-context-reflow)
(and (eq projection-kind 'mixed-owner-reflow) (and (eq projection-kind 'mixed-owner-reflow)
@ -10502,6 +10659,66 @@ role, and outside-complement compatibility before publication."
(setq base (plist-put base (pop overrides) (pop overrides)))) (setq base (plist-put base (pop overrides) (pop overrides))))
base) base)
(defun ebox-incremental--scroll-content-span-proof
(buffer old-state prepared candidate-state)
"Prove one content owner in a retained root scroll's content coordinates.
The visible buffer may contain only part of the scroll content. Its cached
lines are a separate coordinate space, so run the ordinary owner and footprint
proofs there rather than treating invisible owners as zero-sized. Nested
scrolls, non-content changes and uncached/partial owners retain their ordinary
fallback. The surface must still validate the complete cached replacement and
the visible replacement independently before publishing either one."
(let* ((regions (plist-get old-state :scroll-region-ids))
(region (and (= (length regions) 1) (car regions)))
(scroll (and region (gethash region ebox--scroll-global-state)))
(next-scroll (and region (gethash region
(plist-get prepared :scroll-state-table))))
(root (plist-get old-state :root-node))
(dirty-set (plist-get prepared :dirty-set))
(lines (plist-get scroll :content-lines)))
(when (and region scroll next-scroll lines
(eq (plist-get scroll :buffer) buffer)
(eq (plist-get scroll :box) root)
(eq (plist-get next-scroll :box)
(plist-get candidate-state :root-node))
(= (hash-table-count (plist-get prepared :scroll-state-table)) 1)
(= (length dirty-set) 1)
(equal (plist-get (car dirty-set) :changed-keys) '(:content))
(not (plist-get (car dirty-set) :children))
(not (plist-get scroll :lazy-scroll-prefix-dirty))
(equal (plist-get scroll :scroll-offset)
(plist-get next-scroll :scroll-offset)))
(with-temp-buffer
(insert (ebox-lines-join lines))
(let* ((base (copy-sequence old-state))
(candidate (copy-sequence candidate-state))
(input (copy-sequence prepared))
(ebox-incremental--allocated-slot-proof-cache
(make-hash-table :test 'equal)))
(dolist (state (list base candidate))
(plist-put state :surface nil)
(plist-put state :scroll-region-ids nil)
(plist-put state :scroll-state-table (make-hash-table :test 'equal))
(plist-put state :layout-snapshots (make-hash-table :test 'equal)))
(plist-put input :scroll-state-table (make-hash-table :test 'equal))
(let* ((ebox-incremental--buffer-render-state-override
(cons (current-buffer) base))
(ebox-incremental--candidate-base-state
(cons (current-buffer) base))
(ebox-incremental--candidate-state-for-slot-proof candidate)
(ebox--region-line-index (ebox--build-region-line-index))
(ebox--region-line-index-buffer (current-buffer))
(plan (ebox-incremental--layout-owner-plan
(current-buffer) base candidate dirty-set))
(proof (ebox-incremental--span-patch-projection-proof
(current-buffer) base input candidate plan)))
(when proof
(plist-put proof :scroll-content-proof
(list :region-id region :lines lines :owner-plan plan
:snapshots (plist-get base :layout-snapshots)
:line-index ebox--region-line-index))
proof)))))))
(defun ebox-incremental--prepare-scoped-candidate (defun ebox-incremental--prepare-scoped-candidate
(buffer old-state next-root isolated-candidate-p (buffer old-state next-root isolated-candidate-p
&optional skip-span-proof-p scroll-transaction-p) &optional skip-span-proof-p scroll-transaction-p)
@ -10601,9 +10818,11 @@ whether NEXT-ROOT shares untouched published nodes."
buffer old-state prepared candidate-state owner-plan)) buffer old-state prepared candidate-state owner-plan))
(span-proof (span-proof
(unless (or paint-p mixed-proof skip-span-proof-p) (unless (or paint-p mixed-proof skip-span-proof-p)
(ebox-incremental--span-patch-projection-proof (or (ebox-incremental--span-patch-projection-proof
buffer old-state prepared candidate-state owner-plan buffer old-state prepared candidate-state owner-plan
path-copied-p))) path-copied-p)
(ebox-incremental--scroll-content-span-proof
buffer old-state prepared candidate-state))))
(projection-kind (projection-kind
(cond (cond
(mixed-proof 'mixed-owner-reflow) (mixed-proof 'mixed-owner-reflow)
@ -10614,6 +10833,10 @@ whether NEXT-ROOT shares untouched published nodes."
(not (plist-get span-proof :variable-content-p))) (not (plist-get span-proof :variable-content-p)))
'owner-scoped) 'owner-scoped)
(span-proof 'span-patch)))) (span-proof 'span-patch))))
(when-let* ((scroll-proof (plist-get span-proof :scroll-content-proof)))
(setq owner-plan (plist-get scroll-proof :owner-plan)
scope-node-ids (mapcar (lambda (op) (plist-get op :owner-id))
owner-plan)))
(setq prepared (plist-put prepared :scroll-patch-fast-p (setq prepared (plist-put prepared :scroll-patch-fast-p
skip-span-proof-p)) skip-span-proof-p))
(when mixed-proof (when mixed-proof
@ -11018,8 +11241,14 @@ fresh regardless of whether any stable producers exist."
(plist-get old-state :region-box-table))) (plist-get old-state :region-box-table)))
(plist-put candidate-state :scroll-state-table candidate-scroll-table) (plist-put candidate-state :scroll-state-table candidate-scroll-table)
(setq owner-plan (setq owner-plan
(ebox-incremental--layout-owner-plan (if (or display-changed (not style-context-stable-p))
buffer old-state candidate-state dirty-set) ;; Old overflow and intrinsic-size facts cannot prove a
;; local owner after the display or cascade changed.
;; Recompute through the complete canonical root instead
;; of measuring a detached subtree against stale ancestry.
(ebox-incremental--root-owner-plan buffer dirty-set)
(ebox-incremental--layout-owner-plan
buffer old-state candidate-state dirty-set))
report report
(ebox-incremental--plist-overlay (ebox-incremental--plist-overlay
(ebox-incremental--layout-owner-report (ebox-incremental--layout-owner-report

454
ebox-interaction.el Normal file
View File

@ -0,0 +1,454 @@
;;; ebox-interaction.el --- Fixed native node capabilities -*- lexical-binding: t; -*-
;;; Commentary:
;; Validates Ebox's finite interaction inputs and projects them to native text
;; properties. These node facts do not participate in CSS cascade or geometry.
;;; Code:
(require 'cl-lib)
(require 'tp-core)
(require 'tp-style)
(declare-function ebox-style-compile-form "ebox-style" (tag plist))
(declare-function ebox-style-declaration-properties "ebox-style"
(declarations predicate))
(declare-function ebox-buffer--text-decoration-face "ebox-buffer-backend" (style))
(declare-function ebox-buffer-paint-text-properties "ebox-buffer-backend"
(style role))
(declare-function ebox-get "ebox" (box property))
(declare-function ebox--ensure-region-id "ebox" (box))
(declare-function ebox-runtime-index-get "ebox-runtime-index" (key index &optional default))
(declare-function ebox-fragment-style-source-node "ebox-fragment" (node))
(defvar ebox-buffer--decoration-style-map)
(defconst ebox-interaction-properties
'(:help-echo :pointer :hover-style :keymap)
"Fixed node capabilities accepted outside the Ebox style schema.")
(defconst ebox-interaction--hover-properties
'(:color :background-color :text-decoration-line
:text-decoration-color :text-decoration-style)
"Geometry-independent Ebox declarations accepted by `:hover-style'.")
(defun ebox-interaction--command (callback)
"Wrap zero-argument CALLBACK as a keyboard or mouse command."
(lambda (&optional event)
(interactive (list (and (mouse-event-p last-input-event) last-input-event)))
(if (null event)
(funcall callback)
(unless (and (consp event) (mouse-event-p event))
(user-error "Ebox activation requires a mouse text event"))
(let* ((position (event-start event))
(window (posn-window position))
(point (posn-point position)))
(unless (and (window-live-p window) (null (posn-area position))
(integer-or-marker-p point))
(user-error "Ebox activation requires a live text window"))
(with-current-buffer (window-buffer window)
(unless (and (or (integerp point)
(eq (marker-buffer point) (current-buffer)))
(<= (point-min) point (point-max)))
(user-error "Ebox activation position is outside its text buffer"))
(funcall callback))))))
;;;###autoload
(defun ebox-keymap-create (&rest options)
"Create an ordinary native keymap from OPTIONS.
`:activate' is an optional zero-argument callback for RET, return, SPC and
the primary mouse button. Nil omits these activation bindings. `:bindings'
is an alist of string key descriptions or key vectors paired with
zero-argument callbacks.
Callbacks are not invoked until a generated command runs. Keyboard commands
use the current buffer; mouse commands use the event window's buffer without
selecting a window or moving point. Duplicate or overlapping keys are errors.
This function does not supply focus navigation, state or event propagation."
(unless (and (proper-list-p options) (zerop (% (length options) 2)))
(error "Ebox keymap options must be an even plist"))
(let ((map (make-sparse-keymap)) seen keys)
(cl-loop for (key _value) on options by #'cddr do
(unless (memq key '(:activate :bindings))
(error "Unknown Ebox keymap option: %S" key))
(when (memq key seen)
(error "Repeated Ebox keymap option: %S" key))
(push key seen))
(let ((activate (plist-get options :activate))
(bindings (plist-get options :bindings)))
(unless (proper-list-p bindings)
(error "Ebox keymap :bindings must be an alist"))
(when activate
(setq bindings (append (mapcar (lambda (key) (cons key activate))
'("RET" [return] "SPC" [mouse-1]))
bindings)))
(dolist (entry bindings)
(unless (and (consp entry) (functionp (cdr entry)))
(error "Ebox keymap bindings require a key and a callback"))
(let ((key (car entry)) (callback (cdr entry)))
(unless (or (stringp key) (vectorp key))
(error "Ebox keymap keys must be strings or vectors"))
(unless (or (autoloadp (indirect-function callback))
(zerop (car (func-arity callback))))
(error "Ebox keymap callbacks must accept zero arguments"))
(setq key (vconcat (kbd (if (stringp key) key (key-description key)))))
(when (zerop (length key))
(error "Ebox keymap keys must not be empty"))
(when (cl-some (lambda (other)
(let ((length (min (length key) (length other))))
(equal (cl-subseq key 0 length)
(cl-subseq other 0 length))))
keys)
(error "Ebox keymap binding overlaps an existing key: %s"
(key-description key)))
(define-key map key (ebox-interaction--command callback))
(push key keys))))
map))
;;;###autoload
(defun ebox-help-create (function)
"Adapt zero-argument FUNCTION to a native `help-echo' callback.
The help object's buffer, or the live window buffer for a displayed string,
becomes current while FUNCTION runs. Neither point nor window selection is
changed. FUNCTION runs only when Emacs requests help and must return a string
or nil. Existing native three-argument callbacks need no adapter."
(unless (and (functionp function)
(or (autoloadp (indirect-function function))
(zerop (car (func-arity function)))))
(error "Ebox help requires a zero-argument function"))
(lambda (window object _position)
(let ((buffer (cond ((bufferp object) object)
((overlayp object) (overlay-buffer object))
((and (stringp object) (window-live-p window))
(window-buffer window)))))
(unless (buffer-live-p buffer)
(user-error "Ebox help requires a live text buffer"))
(with-current-buffer buffer
(let ((value (funcall function)))
(unless (or (null value) (stringp value))
(error "Ebox help function must return a string or nil"))
value)))))
(defun ebox-interaction--keymap-snapshot (keymap)
"Copy native KEYMAP, resolving named root, prefix and parent maps.
Only native map positions are resolved; ordinary command symbols and literal
callback environments retain their identities. Sharing and cycles survive."
(let ((copies (make-hash-table :test #'eq)))
(cl-labels
((binding
(value)
(cond
((keymapp value) (map-copy value))
((functionp value) value)
((and (consp value) (eq (car value) 'menu-item))
(let ((copy (tp-property-value-copy value)))
(when (cddr copy)
(setcar (cddr copy) (binding (nth 2 value))))
copy))
((and (consp value) (stringp (car value)))
(cons (tp-property-value-copy (car value)) (binding (cdr value))))
((and (consp value) (keymapp (car value)))
(cons (map-copy (car value)) (tp-property-value-copy (cdr value))))
(t (tp-property-value-copy value))))
(map-copy
(source)
(setq source (if (symbolp source) (indirect-function source) source))
(or (gethash source copies)
(let* ((parent (keymap-parent source))
(local (copy-sequence source))
(copy (tp-property-value-copy source)))
(puthash source copy copies)
;; Only the copied spine is edited, leaving source bindings
;; and character tables untouched while enumerating locals.
;; Replacing existing entries also preserves native menu order.
(set-keymap-parent local nil)
(map-keymap
(lambda (event definition)
(define-key copy (vector event) (binding definition)))
local)
(when parent (set-keymap-parent copy (map-copy parent)))
copy))))
(map-copy keymap))))
(defun ebox-interaction--hover-face (value)
"Validate hover declarations VALUE and return a native mouse face."
(unless (and (proper-list-p value) (zerop (% (length value) 2)))
(error "Ebox :hover-style must be an even declaration plist"))
(cl-loop for (key val) on value by #'cddr
unless (memq key ebox-interaction--hover-properties)
do (error "Ebox :hover-style does not support %S" key)
when (memq key '(:color :background-color :text-decoration-color))
unless (or (stringp val)
(and (eq key :text-decoration-color)
(eq val 'currentColor)))
do (error "Ebox :hover-style %S requires a color string" key))
(when value
(require 'ebox-style)
(require 'ebox-buffer-backend)
(let* ((style (ebox-style-declaration-properties
(ebox-style-compile-form 'text value) (lambda (_property) t)))
(face (ebox-buffer--text-decoration-face style)))
;; A solid underline is represented by t in a face, not an empty plist.
(when (and (plist-member face :underline)
(null (plist-get face :underline)))
(setq face (plist-put face :underline t)))
(when (plist-member style :color)
(setq face (plist-put face :foreground (plist-get style :color))))
(when (plist-member style :background-color)
(setq face (plist-put face :background
(plist-get style :background-color))))
(when (eq (plist-get style :text-decoration-line) 'none)
(setq face (append face '(:underline nil :overline nil
:strike-through nil))))
face)))
(defun ebox-interaction-normalize (properties)
"Return an owned, validated copy of node interaction PROPERTIES.
Absent keys inherit enclosing surface values; explicit nil blocks them.
Callback values remain literal functions. Mutable keymaps are snapshotted;
replace the property to publish subsequent binding changes."
(unless (and (proper-list-p properties)
(zerop (% (length properties) 2)))
(error "Ebox interaction properties must be an even plist"))
(let (seen)
(cl-loop for (key value) on properties by #'cddr do
(when (memq key seen)
(error "Ebox interaction property repeated: %S" key))
(push key seen)
(pcase key
(:help-echo
(unless (or (null value) (stringp value) (functionp value))
(error "Ebox :help-echo requires nil, a string or a function")))
(:pointer
(unless (memq value '(nil text arrow vdrag modeline hand
hdrag nhdrag hourglass))
(error "Ebox :pointer has unsupported shape: %S" value)))
(:keymap
(unless (or (null value) (keymapp value))
(error "Ebox :keymap requires nil or a native keymap")))
(:hover-style (ebox-interaction--hover-face value))
(_ (error "Ebox has no interaction property %S" key)))))
(let ((owned (tp-property-value-copy properties)))
(when (plist-get properties :keymap)
(setq owned (plist-put owned :keymap
(ebox-interaction--keymap-snapshot
(plist-get properties :keymap)))))
owned))
(defun ebox-interaction-surface-properties (properties)
"Project normalized node PROPERTIES to native surface facts."
(cl-loop for (key value) on properties by #'cddr
append (if (eq key :hover-style)
(let* ((face (ebox-interaction--hover-face value))
(decoration
(cl-loop for (property entry) on value by #'cddr
when (memq property
'(:text-decoration-line
:text-decoration-color
:text-decoration-style))
append (list property entry)))
(paint
(cl-loop for (property entry) on face by #'cddr
unless (memq property
'(:underline :overline
:strike-through))
append (list property entry))))
(list 'ebox--hover-style
(when value (list :face paint :decoration decoration))
'mouse-face face))
(list (pcase key
(:help-echo 'help-echo) (:pointer 'pointer)
(:keymap 'keymap)) value))))
(defun ebox-interaction--face-attribute (face attribute)
"Return FACE's explicit ATTRIBUTE entry, following native face precedence."
(cond
((null face) nil)
((and (consp face) (keywordp (car face)))
(if-let* ((entry (plist-member face attribute)))
(unless (eq (cadr entry) 'unspecified) (list attribute (cadr entry)))
(ebox-interaction--face-attribute (plist-get face :inherit) attribute)))
((listp face)
(cl-some (lambda (entry) (ebox-interaction--face-attribute entry attribute))
face))
((facep face)
(let ((value (face-attribute face attribute nil t)))
(unless (eq value 'unspecified) (list attribute value))))))
(defun ebox-interaction--hover-decoration-face (declarations base)
"Apply partial decoration DECLARATIONS to the effective BASE face."
(when declarations
(let* ((line-entry (plist-member declarations :text-decoration-line))
(line (cadr line-entry))
(lines (if (listp line) line (list line)))
(color-entry (plist-member declarations :text-decoration-color))
(color (cadr color-entry))
(style-entry (plist-member declarations :text-decoration-style))
(style (alist-get (cadr style-entry) ebox-buffer--decoration-style-map))
face)
(dolist (entry '((underline . :underline) (overline . :overline)
(line-through . :strike-through)))
(let* ((property (cdr entry))
(old (cadr (ebox-interaction--face-attribute base property)))
(active (if line-entry (memq (car entry) lines) old)))
(when (or line-entry active)
(setq face
(plist-put
face property
(when active
(if (eq property :underline)
(let ((value (cond ((stringp old) (list :color old))
((consp old) (copy-sequence old)))))
(when color-entry
(if (eq color 'currentColor)
(cl-remf value :color)
(setq value (plist-put value :color color))))
(when style-entry
(if (eq style 'line)
(cl-remf value :style)
(setq value (plist-put value :style style))))
(or value t))
(if color-entry
(if (eq color 'currentColor) t color)
(or old t)))))))))
face)))
(defvar ebox-interaction--hover-face-cache
(make-hash-table :test #'equal :weakness 'value)
"Weak cache preserving one native face identity per hover owner and paint.")
(defun ebox-interaction--node-surface-properties (node)
"Return NODE's native surface facts with its computed hover base.
Native mouse highlighting paints one face across a contiguous region. Its
unspecified attributes therefore use the hover owner's style, even when its
children have different ordinary text colors. Explicit child hover remains
an independent region. Physical border paint is handled during projection."
(let* ((properties (ebox-get node :surface-properties))
(declaration (plist-get properties 'ebox--hover-style)))
(if (null declaration)
properties
(plist-put
(copy-sequence properties) 'ebox--hover-style
(append declaration
(list :owner (ebox--ensure-region-id node)
:base (plist-get
(ebox-buffer-paint-text-properties node 'content)
'face)))))))
(defun ebox-interaction--hover-owner-base (declaration state)
"Return DECLARATION's current owner face, resolving it in STATE when supplied."
(let* ((owner (plist-get declaration :owner))
(node-id (and state owner
(ebox-runtime-index-get
owner (plist-get state :region-node-table))))
(node (and node-id
(ebox-runtime-index-get node-id (plist-get state :node-table))))
(box (and node (ebox-fragment-style-source-node node))))
(if box
(plist-get (ebox-buffer-paint-text-properties box 'content) 'face)
(plist-get declaration :base))))
(defun ebox-interaction--refresh-hover! (string start end &optional face state)
"Project owned hover paint in STRING between START and END.
FACE supplies a fallback for legacy declarations without an owner. STATE
resolves current owner paint during retained updates. All interior runs with
the same owner and paint share the exact same native mouse face, including
padding. Border glyphs are excluded; horizontal strokes keep their own paint."
(let ((position start))
(while (< position end)
(setq position (or (text-property-not-all
position end 'ebox--hover-style nil string) end))
(when (< position end)
(let* ((next (next-property-change position string end))
(declaration (get-text-property position 'ebox--hover-style string))
(normal (or face (get-text-property position 'face string)))
(base (if (plist-member declaration :owner)
(ebox-interaction--hover-owner-base declaration state)
normal))
(hover (append
(ebox-interaction--hover-decoration-face
(plist-get declaration :decoration) base)
(plist-get declaration :face)))
(faces (cond ((null base) nil)
((and (consp base) (keywordp (car base))) (list base))
((listp base) base)
(t (list base))))
(side (or (get-text-property position 'ebox-bl string)
(get-text-property position 'ebox-br string)))
(strokes
(append
(when (get-text-property position 'ebox-bt string)
(ebox-interaction--face-attribute normal :overline))
(when (get-text-property position 'ebox-bb string)
(ebox-interaction--face-attribute normal :underline))))
(combined (append (when strokes (list strokes))
(when hover (list hover)) faces))
(value (if (cdr combined) combined (car combined)))
(key (list (plist-get declaration :owner) value))
(shared (or (gethash key ebox-interaction--hover-face-cache)
(when value
(puthash key value ebox-interaction--hover-face-cache)))))
(when (and state (plist-member declaration :owner)
(not (equal base (plist-get declaration :base))))
(put-text-property
position next 'ebox--hover-style
(plist-put (copy-sequence declaration) :base base) string))
(put-text-property position next 'mouse-face
(unless side shared) string)
(setq position next)))))
string)
(defun ebox-interaction-surface-equal-p (left right)
"Compare native surface plists LEFT and RIGHT without collapsing commands.
Literal callbacks retain identity; keymap snapshots use TP's native property
policy, which compares bindings while retaining command identity. Paint and
data values use native value equality, before surface publication loads."
(or (eq left right)
(and (= (length left) (length right))
(cl-loop for (property value) on left by #'cddr
for entry = (plist-member right property)
always (and entry
(cond
((or (keymapp value) (keymapp (cadr entry)))
(funcall
(tp-property-policy-equality
(tp-register-text-property property))
value (cadr entry)))
((or (functionp value) (functionp (cadr entry)))
(eq value (cadr entry)))
(t (equal-including-properties
value (cadr entry)))))))))
(defun ebox-interaction--fill-property (string start end property value)
"Fill surface PROPERTY with VALUE in STRING between START and END.
Newlines are structural. An inner explicit nil blocks enclosing values of
the four supported native properties, just as an inner non-nil value does."
(let ((position start)
(presence-p (memq property '(help-echo pointer mouse-face keymap
ebox--hover-style))))
(when (or value presence-p)
(while (< position end)
(let ((next (min (or (next-property-change position string end) end)
(or (string-match "\n" string position) end))))
(if (= position next)
(setq position (1+ position))
(unless (if presence-p
(let ((props (text-properties-at position string)))
(or (plist-member props property)
(and (eq property 'ebox--hover-style)
(plist-member props 'mouse-face))))
(get-text-property position property string))
(add-text-properties position next (list property value) string))
(when (eq property 'keymap)
;; Key lookup otherwise consults the preceding character at
;; the start of a non-front-sticky text-property interval.
(dolist (sticky '(front-sticky rear-nonsticky))
(let ((existing (get-text-property position sticky string)))
(unless (eq existing t)
(put-text-property
position next sticky
(cons 'keymap (remq 'keymap (copy-sequence existing)))
string)))))
(setq position next)))))))
(provide 'ebox-interaction)
;;; ebox-interaction.el ends here

View File

@ -9,6 +9,7 @@
(require 'cl-lib) (require 'cl-lib)
(require 'seq) (require 'seq)
(require 'ebox-size)
(declare-function ebox-flex-layout-config-props-p (declare-function ebox-flex-layout-config-props-p
"ebox-flex" (props)) "ebox-flex" (props))
@ -28,13 +29,14 @@
'(start center end stretch) '(start center end stretch)
"Canonical Row/Column cross-axis alignment values.") "Canonical Row/Column cross-axis alignment values.")
(defun ebox-layout-config--simple-axis-props-p (props) (defun ebox-layout-config--simple-axis-props-p (props kind)
"Return non-nil when PROPS is canonical Row/Column config data." "Return non-nil when PROPS is canonical Row/Column KIND config data."
(and (equal (mapcar #'car (seq-partition props 2)) (and (equal (mapcar #'car (seq-partition props 2))
'(:item-gap :cross-align)) '(:item-gap :cross-align))
(let ((gap (plist-get props :item-gap)) (let ((gap (plist-get props :item-gap))
(align (plist-get props :cross-align))) (align (plist-get props :cross-align)))
(and (integerp gap) (>= gap 0) (and (ebox-size-value-for-axis-p
gap (if (eq kind 'column) 'block 'inline))
(memq align ebox-layout-config--simple-cross-align-values))))) (memq align ebox-layout-config--simple-cross-align-values)))))
(defun ebox-layout-config--simple-axis-create (kind plist) (defun ebox-layout-config--simple-axis-create (kind plist)
@ -51,11 +53,14 @@
(let ((props (let ((props
(list :item-gap (if (plist-member plist :item-gap) (list :item-gap (if (plist-member plist :item-gap)
(plist-get plist :item-gap) (plist-get plist :item-gap)
0) (if (eq kind 'column) '(lh 0) '(px 0)))
:cross-align (if (plist-member plist :cross-align) :cross-align (if (plist-member plist :cross-align)
(plist-get plist :cross-align) (plist-get plist :cross-align)
'stretch)))) 'stretch))))
(unless (ebox-layout-config--simple-axis-props-p props) (ebox-size-validate-for-axis
(plist-get props :item-gap) :item-gap
(if (eq kind 'column) 'block 'inline))
(unless (ebox-layout-config--simple-axis-props-p props kind)
(error "Invalid Ebox %S LayoutConfig properties: %S" kind props)) (error "Invalid Ebox %S LayoutConfig properties: %S" kind props))
(ebox-layout-config--create :kind kind :props props))) (ebox-layout-config--create :kind kind :props props)))
@ -69,7 +74,10 @@
(pcase kind (pcase kind
('normal (null props)) ('normal (null props))
((or 'row 'column) ((or 'row 'column)
(ebox-layout-config--simple-axis-props-p props)) (ebox-size-validate-for-axis
(plist-get props :item-gap) :item-gap
(if (eq kind 'column) 'block 'inline))
(ebox-layout-config--simple-axis-props-p props kind))
('flex ('flex
(and (fboundp 'ebox-flex-layout-config-props-p) (and (fboundp 'ebox-flex-layout-config-props-p)
(ebox-flex-layout-config-props-p props))) (ebox-flex-layout-config-props-p props)))

File diff suppressed because it is too large Load Diff

View File

@ -9,6 +9,7 @@
(require 'cl-lib) (require 'cl-lib)
(require 'ebox-cache) (require 'ebox-cache)
(require 'ebox-font) (require 'ebox-font)
(require 'ebox-size)
(declare-function ebox--register-render-owned-text-value (declare-function ebox--register-render-owned-text-value
"ebox-render-context" (property value)) "ebox-render-context" (property value))
@ -80,6 +81,71 @@ of pixel measurements it triggers do not each rebuild the display signature.")
((numberp width) width) ((numberp width) width)
((and (consp width) (numberp (car width))) (car width)))))) ((and (consp width) (numberp (car width))) (car width))))))
(defun ebox--fixed-pixel-space-width (display)
"Return DISPLAY's nonnegative fixed pixel width, or nil for other specs.
Bare space widths use character units; align-to spaces depend on placement."
(when (and (consp display) (eq (car display) 'space)
(not (plist-member (cdr display) :align-to)))
(let ((width (plist-get (cdr display) :width)))
(when (and (consp width) (null (cdr width))
(ebox-size--finite-number-p (car width)) (>= (car width) 0))
(car width)))))
(defun ebox--fractional-pixel-spaces-p (string)
"Return non-nil when STRING has a nonintegral fixed-pixel space glyph."
(let ((position 0) (end (length string)))
(catch 'fractional
(while (< position end)
(let* ((next (next-single-property-change position 'display string end))
(width (ebox--fixed-pixel-space-width
(get-text-property position 'display string))))
(when (and width (/= width (floor width))
(cl-position-if (lambda (char) (/= char ?\n)) string
:start position :end next))
(throw 'fractional t))
(setq position next)))
nil)))
(defun ebox--quantize-pixel-spaces (string)
"Return complete STRING lines with cumulative whole-pixel space extents.
Keep raw layout strings and their cached geometry private. Only the final
output copy changes: equal line boundaries share the same rounding regardless
of text alignment, blank fill, borders, padding, or sibling segmentation.
Ordinary glyph advances are whole pixels, so only fixed space extents affect
the fractional remainder. Preserve zero-width glyphs and all other properties.
An already integral STRING is returned unchanged."
(if (not (ebox--fractional-pixel-spaces-p string))
string
(let ((result (copy-sequence string))
(start 0)
(end (length string)))
(while (< start end)
(let* ((line-end (or (string-match "\n" string start) end))
(position start)
positions widths)
(while (< position line-end)
(let* ((next (next-single-property-change
position 'display string line-end))
(width (ebox--fixed-pixel-space-width
(get-text-property position 'display string))))
(when width
(cl-loop for index from position below next
do (push index positions) (push width widths)))
(setq position next)))
(cl-mapc
(lambda (index used)
(let ((display (get-text-property index 'display string)))
(unless (= used (ebox--fixed-pixel-space-width display))
(let ((updated (cons 'space (copy-sequence (cdr display)))))
(setcdr updated (plist-put (cdr updated) :width (list used)))
(put-text-property
index (1+ index) 'display
(ebox--register-render-owned-text-value 'display updated)
result)))))
(nreverse positions) (ebox-size-quantize (nreverse widths)))
(setq start (1+ line-end))))
result)))
(defun ebox--string-with-text-scale (string factor) (defun ebox--string-with-text-scale (string factor)
"Return STRING with FACTOR applied as an explicit face height." "Return STRING with FACTOR applied as an explicit face height."
(if (= factor 1.0) (if (= factor 1.0)
@ -162,7 +228,16 @@ constant across the selected slice."
(let* ((sample (if (or start end) (let* ((sample (if (or start end)
(substring line (or start 0) (or end (length line))) (substring line (or start 0) (or end (length line)))
line)) line))
(width (ebox--scaled-string-pixel-width sample factor)) (space-width
(and (> (length sample) 0)
(ebox--display-space-width
(get-text-property 0 'display sample))))
;; This cache key proves uniform visual properties. Preserve
;; display-space fractions just as the segmented path does;
;; Emacs's glyph measurement quantizes them before padding.
(width (if space-width
(* space-width (length sample))
(ebox--scaled-string-pixel-width sample factor)))
(index ebox--string-pixel-width-cache-ring-index) (index ebox--string-pixel-width-cache-ring-index)
(evicted (aref ebox--string-pixel-width-cache-ring index))) (evicted (aref ebox--string-pixel-width-cache-ring index)))
(when ebox--render-string-pixel-width-cache (when ebox--render-string-pixel-width-cache
@ -357,18 +432,28 @@ Nested uses reuse the outer validation."
(ebox--space-pixel-width)) (ebox--space-pixel-width))
(defsubst ebox-pixel-space (pixel-width) (defsubst ebox-pixel-space (pixel-width)
"Generate a space string with PIXEL-WIDTH." "Generate a space string with PIXEL-WIDTH.
Canonicalize whole pixels so equal computed lengths have identical display
properties. Preserve a genuine fractional remainder until display."
(if (or (null pixel-width) (<= pixel-width 0)) (if (or (null pixel-width) (<= pixel-width 0))
"" ""
(let ((display (let* ((whole-pixels (truncate pixel-width))
(pixel-width (if (= pixel-width whole-pixels)
whole-pixels pixel-width))
(display
(ebox--register-render-owned-text-value (ebox--register-render-owned-text-value
'display `(space :width (,pixel-width))))) 'display `(space :width (,pixel-width)))))
(propertize " " 'display display)))) (propertize " " 'display display))))
(defun ebox--pixel-blank (pixel-width height) (defun ebox--pixel-blank (pixel-width height)
"Generate a blank area of PIXEL-WIDTH and HEIGHT lines." "Generate a blank area of PIXEL-WIDTH and HEIGHT lines.
(when (and pixel-width height (> pixel-width 0) (> height 0)) A zero pixel width still preserves the requested positive line height."
(ebox--string-repeat-lines (ebox-pixel-space pixel-width) height))) (when (and pixel-width height (>= pixel-width 0) (> height 0))
(ebox--string-repeat-lines
(if (zerop pixel-width)
(ebox-lines-join '(""))
(ebox-pixel-space pixel-width))
height)))
(defun ebox--pixel-pad (string prefix-pixel &optional suffix-pixel) (defun ebox--pixel-pad (string prefix-pixel &optional suffix-pixel)
"Pad STRING with pixel spaces on left (PREFIX-PIXEL) and right (SUFFIX-PIXEL)." "Pad STRING with pixel spaces on left (PREFIX-PIXEL) and right (SUFFIX-PIXEL)."

View File

@ -16,6 +16,7 @@
(require 'ebox-measure) (require 'ebox-measure)
(require 'ebox-render-context) (require 'ebox-render-context)
(require 'ebox-runtime-index) (require 'ebox-runtime-index)
(require 'ebox-size)
(declare-function ebox-native--module-version "ebox_native_reflow" ()) (declare-function ebox-native--module-version "ebox_native_reflow" ())
(declare-function ebox-native--module-layout-ready-p "ebox_native_reflow" ()) (declare-function ebox-native--module-layout-ready-p "ebox_native_reflow" ())
@ -71,8 +72,9 @@
(declare-function ebox--flex-item-props "ebox-flex" (item)) (declare-function ebox--flex-item-props "ebox-flex" (item))
(declare-function ebox--flex-line-value "ebox-flex" (value &optional default)) (declare-function ebox--flex-line-value "ebox-flex" (value &optional default))
(declare-function ebox--content-min-pixel "ebox-layout" (box)) (declare-function ebox--content-min-pixel "ebox-layout" (box))
(declare-function ebox--horizontal-size-pixels (declare-function ebox--size-pixels
"ebox-layout" (value &optional default)) "ebox-layout" (value &optional box axis percent-base))
(declare-function ebox--size-line-height "ebox-layout" (&optional box))
(declare-function ebox--layout-children "ebox-tree" (node)) (declare-function ebox--layout-children "ebox-tree" (node))
(declare-function ebox-tree-layout-children "ebox-tree" (node)) (declare-function ebox-tree-layout-children "ebox-tree" (node))
(declare-function ebox-tree-node-local-source-signature "ebox-tree" (node)) (declare-function ebox-tree-node-local-source-signature "ebox-tree" (node))
@ -96,7 +98,10 @@
(defvar ebox--flex-content-min-width-table) (defvar ebox--flex-content-min-width-table)
(defconst ebox-native-reflow-abi-version "12:7:12" (defvar ebox-native-reflow--compile-size-node nil
"Owning node whose font measurements resolve fixed native size inputs.")
(defconst ebox-native-reflow-abi-version "13:7:12"
"Version tuple shared by the native module, layout IR, and render tape.") "Version tuple shared by the native module, layout IR, and render tape.")
(defconst ebox-native-reflow--minimum-rust-version "1.82.0" (defconst ebox-native-reflow--minimum-rust-version "1.82.0"
@ -312,51 +317,104 @@ module while loading the package."
(defconst ebox-native-reflow--persistent-index-depth 16 (defconst ebox-native-reflow--persistent-index-depth 16
"Fixed number of four-bit branches in a retained native index path.") "Fixed number of four-bit branches in a retained native index path.")
(defun ebox-native-reflow--persistent-index-code (key) (defun ebox-native-reflow--property-value-hash (value &optional depth)
"Return a stable nonnegative retained-index code for KEY." "Hash property VALUE consistently with identity-sensitive equality.
Bounded DEPTH keeps cyclic data safe; callback environments are never walked."
(let ((depth (or depth 8)))
(cond
((or (functionp value) (keymapp value)) (sxhash-eq value))
((ebox--paint-origin-p value)
(ebox-native-reflow--property-value-hash
(ebox--paint-origin-baseline value) depth))
((zerop depth) 0)
((consp value)
(logxor (ebox-native-reflow--property-value-hash (car value) (1- depth))
(ash (ebox-native-reflow--property-value-hash
(cdr value) (1- depth)) -1)))
((or (stringp value) (numberp value)) (sxhash-equal value))
(t (sxhash-eq value)))))
(defun ebox-native-reflow--property-template-equal-p (left right)
"Compare template envelopes LEFT and RIGHT and their opaque property values."
(and (eq (plist-get left :kind) (plist-get right :kind))
(let ((old (plist-get left :properties))
(new (plist-get right :properties)))
(and (= (length old) (length new))
(cl-loop for (property value) on old by #'cddr
for entry = (plist-member new property)
always (and entry
(ebox-surface--retained-property-value-equal-p
value (cadr entry))))))))
(defun ebox-native-reflow--property-template-hash (template)
"Hash TEMPLATE's envelope separately from its native property values."
(let ((hash (sxhash-eq (plist-get template :kind))))
(cl-loop for (property value) on (plist-get template :properties) by #'cddr
do (setq hash (logxor hash (sxhash-eq property)
(ebox-native-reflow--property-value-hash value))))
hash))
(define-hash-table-test 'ebox-native-property-template
#'ebox-native-reflow--property-template-equal-p
#'ebox-native-reflow--property-template-hash)
(defun ebox-native-reflow--persistent-index-code (key &optional template-p)
"Return a stable nonnegative retained-index code for KEY.
TEMPLATE-P selects native property equality with opaque callback identities."
(if (and (integerp key) (>= key 0)) (if (and (integerp key) (>= key 0))
key key
(logand (sxhash-equal key) #xffffffffffffffff))) (logand (if template-p (ebox-native-reflow--property-template-hash key)
(sxhash-equal key)) #xffffffffffffffff)))
(defun ebox-native-reflow--persistent-index-get (root key &optional missing) (defun ebox-native-reflow--persistent-index-get (root key &optional missing template-p)
"Return KEY's value from persistent ROOT, or MISSING." "Return KEY's value from persistent ROOT, or MISSING.
TEMPLATE-P selects the native property-template comparison and hash."
(let ((node root) (let ((node root)
(code (ebox-native-reflow--persistent-index-code key)) (code (ebox-native-reflow--persistent-index-code key template-p))
(depth 0)) (depth 0))
(while (and node (< depth ebox-native-reflow--persistent-index-depth)) (while (and node (< depth ebox-native-reflow--persistent-index-depth))
(setq node (and (vectorp node) (setq node (and (vectorp node)
(aref node (logand (ash code (* -4 depth)) 15))) (aref node (logand (ash code (* -4 depth)) 15)))
depth (1+ depth))) depth (1+ depth)))
(if (= depth ebox-native-reflow--persistent-index-depth) (if (= depth ebox-native-reflow--persistent-index-depth)
(let ((pair (cl-assoc key node :test #'equal))) (let ((pair (cl-assoc key node :test
(if template-p
#'ebox-native-reflow--property-template-equal-p
#'equal))))
(if pair (cdr pair) missing)) (if pair (cdr pair) missing))
missing))) missing)))
(defun ebox-native-reflow--persistent-index-put (root key value &optional depth) (defun ebox-native-reflow--persistent-index-put
(root key value &optional depth template-p)
"Return persistent ROOT updated to map KEY to VALUE. "Return persistent ROOT updated to map KEY to VALUE.
Only the fixed-depth path for KEY is copied. DEPTH is for recursion." Only the fixed-depth path for KEY is copied. DEPTH is for recursion.
TEMPLATE-P selects the native property-template comparison and hash."
(let ((depth (or depth 0))) (let ((depth (or depth 0)))
(if (= depth ebox-native-reflow--persistent-index-depth) (if (= depth ebox-native-reflow--persistent-index-depth)
(cons (cons key value) (cons (cons key value)
(cl-remove key root :key #'car :test #'equal)) (cl-remove key root :key #'car :test
(let* ((code (ebox-native-reflow--persistent-index-code key)) (if template-p
#'ebox-native-reflow--property-template-equal-p
#'equal)))
(let* ((code (ebox-native-reflow--persistent-index-code key template-p))
(branch (logand (ash code (* -4 depth)) 15)) (branch (logand (ash code (* -4 depth)) 15))
(copy (if (vectorp root) (copy (if (vectorp root)
(copy-sequence root) (copy-sequence root)
(make-vector 16 nil)))) (make-vector 16 nil))))
(aset copy branch (aset copy branch
(ebox-native-reflow--persistent-index-put (ebox-native-reflow--persistent-index-put
(aref copy branch) key value (1+ depth))) (aref copy branch) key value (1+ depth) template-p))
copy)))) copy))))
(defun ebox-native-reflow--persistent-index-from-sequence (sequence) (defun ebox-native-reflow--persistent-index-from-sequence (sequence &optional template-p)
"Return an immutable persistent index for ordered SEQUENCE values." "Return an immutable persistent index for ordered SEQUENCE values.
TEMPLATE-P selects the native property-template comparison and hash."
(let (root) (let (root)
(cl-loop for value across (vconcat sequence) (cl-loop for value across (vconcat sequence)
for id from 0 for id from 0
do (setq root do (setq root
(ebox-native-reflow--persistent-index-put (ebox-native-reflow--persistent-index-put
root value id))) root value id nil template-p)))
root)) root))
(defvar ebox-native-reflow--compile-property-template-ids nil (defvar ebox-native-reflow--compile-property-template-ids nil
@ -1479,10 +1537,22 @@ copied separately; no pending frame or registered Rust document is inherited."
"Register opaque text PROPERTIES for LABEL and return its native id." "Register opaque text PROPERTIES for LABEL and return its native id."
(when properties (when properties
(ebox-native-reflow--validate-property-template properties label) (ebox-native-reflow--validate-property-template properties label)
(when (and (hash-table-p ebox-native-reflow--compile-property-template-ids)
(not (eq (hash-table-test
ebox-native-reflow--compile-property-template-ids)
'ebox-native-property-template)))
(let ((index (make-hash-table :test 'ebox-native-property-template)))
(maphash (lambda (entry id) (puthash entry id index))
ebox-native-reflow--compile-property-template-ids)
(setq ebox-native-reflow--compile-property-template-ids index)))
(let* ((owned-properties (let* ((owned-properties
(cl-loop for (property value) on properties by #'cddr (cl-loop for (property value) on properties by #'cddr
append (list property append (list property
(tp-property-value-copy value)))) ;; Native maps already belong to the source
;; or node snapshot. Preserve their identity
;; so repeated registration can reuse an id.
(if (keymapp value) value
(tp-property-value-copy value)))))
(entry (list :kind (intern label) (entry (list :kind (intern label)
:properties owned-properties)) :properties owned-properties))
(missing (make-symbol "missing-property-template")) (missing (make-symbol "missing-property-template"))
@ -1490,7 +1560,7 @@ copied separately; no pending frame or registered Rust document is inherited."
(if ebox-native-reflow--compile-retained-registry-p (if ebox-native-reflow--compile-retained-registry-p
(ebox-native-reflow--persistent-index-get (ebox-native-reflow--persistent-index-get
ebox-native-reflow--compile-property-template-index ebox-native-reflow--compile-property-template-index
entry missing) entry missing t)
(if (hash-table-p (if (hash-table-p
ebox-native-reflow--compile-property-template-ids) ebox-native-reflow--compile-property-template-ids)
(gethash (gethash
@ -1498,7 +1568,7 @@ copied separately; no pending frame or registered Rust document is inherited."
missing) missing)
(or (cl-position (or (cl-position
entry ebox-native-reflow--compile-property-templates entry ebox-native-reflow--compile-property-templates
:test #'equal) :test #'ebox-native-reflow--property-template-equal-p)
missing))))) missing)))))
(if (not (eq existing missing)) (if (not (eq existing missing))
existing existing
@ -1511,7 +1581,7 @@ copied separately; no pending frame or registered Rust document is inherited."
(setq ebox-native-reflow--compile-property-template-index (setq ebox-native-reflow--compile-property-template-index
(ebox-native-reflow--persistent-index-put (ebox-native-reflow--persistent-index-put
ebox-native-reflow--compile-property-template-index ebox-native-reflow--compile-property-template-index
entry id))) entry id nil t)))
(when (hash-table-p (when (hash-table-p
ebox-native-reflow--compile-property-template-ids) ebox-native-reflow--compile-property-template-ids)
(puthash (puthash
@ -1535,8 +1605,16 @@ copied separately; no pending frame or registered Rust document is inherited."
(eq (car properties) 'display)) (eq (car properties) 'display))
(let ((display (cadr properties))) (let ((display (cadr properties)))
(when (and (consp display) (eq (car display) 'space)) (when (and (consp display) (eq (car display) 'space))
(ebox--horizontal-size-pixels (let* ((width (plist-get (cdr display) :width))
(plist-get (cdr display) :width) nil))))) ;; Emacs display-space syntax is independent of author CSS:
;; a bare number counts character cells, a singleton pixels.
(pixels (cond
((numberp width) (* width (ebox--space-pixel-width)))
((and (proper-list-p width) (= (length width) 1)
(numberp (car width)))
(car width)))))
(and pixels
(ebox-native-reflow--exact-size-integer pixels width)))))))
(defun ebox-native-reflow--compile-formatted-clusters (line) (defun ebox-native-reflow--compile-formatted-clusters (line)
"Compile LINE while preserving exact pixel display spaces." "Compile LINE while preserving exact pixel display spaces."
@ -1548,7 +1626,7 @@ copied separately; no pending frame or registered Rust document is inherited."
(next (or (next-property-change position line) length))) (next (or (next-property-change position line) length)))
(if properties (if properties
(let ((width (ebox-native-reflow--display-space-width properties))) (let ((width (ebox-native-reflow--display-space-width properties)))
(unless (and width (> width 0) (unless (and width (>= width 0)
(string-blank-p (string-blank-p
(substring-no-properties line position next))) (substring-no-properties line position next)))
(error "Native reflow unsupported formatted text properties")) (error "Native reflow unsupported formatted text properties"))
@ -1625,9 +1703,67 @@ When FORMATTED-P is non-nil, preserve exact display spaces produced by KP."
(ebox-native-reflow--compile-source-clusters line)))) (ebox-native-reflow--compile-source-clusters line))))
(ebox-string-lines text))))) (ebox-string-lines text)))))
(defun ebox-native-reflow--compile-size (value axis nil-kind) (defun ebox-native-reflow--static-size-p (value)
"Compile normalized size VALUE for AXIS, using NIL-KIND when absent." "Return non-nil when VALUE has no context dependency absent from native IR.
Font measurements are bound to the compiled display/style generation.
Containing-block and viewport measurements change between retained frames."
(or (not (ebox-size-value-p value t))
(not (cl-intersection
'(percent viewport-width viewport-height)
(ebox-size-dependencies value)))))
(defun ebox-native-reflow--require-static-size (value)
"Return VALUE or decline native compilation of a contextual size."
(unless (ebox-native-reflow--static-size-p value)
(error "Native reflow uses Elisp for viewport or percentage size: %S" value))
value)
(defun ebox-native-reflow--exact-size-integer (value source)
"Return integral VALUE, or decline native compilation of SOURCE."
(unless (and (numberp value) (= value (floor value)))
(error "Native reflow uses Elisp for fractional geometry: %S" source))
(floor value))
(defun ebox-native-reflow--compile-fixed-size (value axis &optional box)
"Resolve fixed canonical VALUE for AXIS in BOX's measured font context.
The native ABI uses integral pixels horizontally and integral text lines
vertically. Preserve exact values only; other geometry uses the Elisp path."
(ebox-native-reflow--require-static-size value)
(let* ((box (or box ebox-native-reflow--compile-size-node))
(pixels (ebox--size-pixels
value box (if (eq axis 'vertical) 'height 'width))))
(unless (numberp pixels)
(error "Native reflow has no measured context for size: %S" value))
(ebox-native-reflow--exact-size-integer
(if (eq axis 'vertical)
(/ (max 0 pixels) (float (ebox--size-line-height box)))
(max 0 pixels))
value)))
(defun ebox-native-reflow--compile-gap (value axis)
"Compile gap VALUE to the native scalar for AXIS."
(if (ebox-size-value-p value)
(ebox-native-reflow--compile-fixed-size value axis)
(or value 0)))
(defun ebox-native-reflow--compile-edge (box property)
"Compile BOX's used PROPERTY edge without freezing relative geometry."
(let ((source (plist-get box property)))
(ebox-native-reflow--require-static-size source)
(let ((value (or (ebox-get box property) 0)))
(if (ebox-size-value-p source t)
(ebox-native-reflow--exact-size-integer value source)
(floor value)))))
(defun ebox-native-reflow--compile-size (value axis nil-kind &optional box)
"Compile size VALUE for AXIS in BOX, using NIL-KIND when absent."
(cond (cond
((ebox-size-value-p value)
(list :kind (if (eq axis 'horizontal) "pixels" "lines")
:value (ebox-native-reflow--compile-fixed-size value axis box)))
((and (eq axis 'vertical)
(memq value '(min-content max-content fit-content stretch)))
(error "Native reflow uses Elisp for intrinsic or stretch height: %S" value))
((null value) ((null value)
(if (member nil-kind '("pixels" "lines")) (if (member nil-kind '("pixels" "lines"))
(list :kind nil-kind :value 0) (list :kind nil-kind :value 0)
@ -1723,6 +1859,9 @@ When FORMATTED-P is non-nil, preserve exact display spaces produced by KP."
(defun ebox-native-reflow--fixed-horizontal-pixels (value) (defun ebox-native-reflow--fixed-horizontal-pixels (value)
"Return VALUE's fixed horizontal pixel count, or nil when responsive." "Return VALUE's fixed horizontal pixel count, or nil when responsive."
(cond (cond
((and (ebox-size-value-p value)
(ebox-native-reflow--static-size-p value))
(ebox-native-reflow--compile-fixed-size value 'horizontal))
((numberp value) (floor value)) ((numberp value) (floor value))
((and (consp value) (null (cdr value)) (numberp (car value))) ((and (consp value) (null (cdr value)) (numberp (car value)))
(floor (car value))))) (floor (car value)))))
@ -1789,7 +1928,9 @@ Rust still verifies the dynamic equal-width and nonempty-line proof per frame."
The native IR represents this simple-axis layout as fixed Flex items so The native IR represents this simple-axis layout as fixed Flex items so
cross-axis stretch and item gap remain explicit without legacy runtime nodes." cross-axis stretch and item gap remain explicit without legacy runtime nodes."
(let* ((props (ebox-layout-config-props config)) (let* ((props (ebox-layout-config-props config))
(gap (plist-get props :item-gap)) (gap (ebox-native-reflow--compile-gap
(plist-get props :item-gap)
(if (eq kind 'row) 'horizontal 'vertical)))
(cross-align (plist-get props :cross-align)) (cross-align (plist-get props :cross-align))
(row-p (eq kind 'row)) (row-p (eq kind 'row))
(compiled-children (compiled-children
@ -1837,7 +1978,8 @@ cross-axis stretch and item gap remain explicit without legacy runtime nodes."
(defun ebox-native-reflow--compile-typed-box-child (box) (defun ebox-native-reflow--compile-typed-box-child (box)
"Compile canonical BOX's typed child layout, or return nil." "Compile canonical BOX's typed child layout, or return nil."
(let* ((config (plist-get box :ebox-layout-config)) (let* ((ebox-native-reflow--compile-size-node box)
(config (plist-get box :ebox-layout-config))
(kind (and config (ebox-layout-config-kind config))) (kind (and config (ebox-layout-config-kind config)))
(children (ebox-tree-node-children box))) (children (ebox-tree-node-children box)))
(unless config (unless config
@ -1860,6 +2002,8 @@ cross-axis stretch and item gap remain explicit without legacy runtime nodes."
(defun ebox-native-reflow--compile-text-node (node) (defun ebox-native-reflow--compile-text-node (node)
"Compile canonical Text NODE to the lightweight native Text IR." "Compile canonical Text NODE to the lightweight native Text IR."
(when (eq (ebox-get node :visibility) 'hidden)
(error "Native reflow delegates hidden Text to the Elisp renderer"))
(let* ((region-id (ebox--ensure-region-id node)) (let* ((region-id (ebox--ensure-region-id node))
(content (content
(ebox--propertize-typography (ebox--propertize-typography
@ -1877,7 +2021,7 @@ cross-axis stretch and item gap remain explicit without legacy runtime nodes."
(ebox-get node :color)))) (ebox-get node :color))))
(surface-template-id (surface-template-id
(ebox-native-reflow--register-property-template (ebox-native-reflow--register-property-template
(ebox-get node :surface-properties) "text surface")) (ebox-interaction--node-surface-properties node) "text surface"))
(wrap-mode (or (ebox-get node :wrap-mode) 'none))) (wrap-mode (or (ebox-get node :wrap-mode) 'none)))
(list :type "text" (list :type "text"
:region-id region-id :region-id region-id
@ -1893,7 +2037,10 @@ cross-axis stretch and item gap remain explicit without legacy runtime nodes."
(defun ebox-native-reflow--compile-box (defun ebox-native-reflow--compile-box
(box &optional child-override child-override-p child-resolved-p) (box &optional child-override child-override-p child-resolved-p)
"Compile normalized Ebox BOX to strict layout IR." "Compile normalized Ebox BOX to strict layout IR."
(let* ((typed-box-p (eq (plist-get box :ebox-kind) 'box)) (when (eq (ebox-get box :visibility) 'hidden)
(error "Native reflow delegates hidden Box to the Elisp renderer"))
(let* ((ebox-native-reflow--compile-size-node box)
(typed-box-p (eq (plist-get box :ebox-kind) 'box))
(typed-text-p (eq (plist-get box :ebox-kind) 'text)) (typed-text-p (eq (plist-get box :ebox-kind) 'text))
(typed-layout-kind (typed-layout-kind
(and typed-box-p (and typed-box-p
@ -1912,18 +2059,18 @@ cross-axis stretch and item gap remain explicit without legacy runtime nodes."
(eq wrap-mode 'kp) (eq wrap-mode 'kp)
(ebox-native-reflow--compile-kp-content box))) (ebox-native-reflow--compile-kp-content box)))
(region-id (ebox--ensure-region-id box)) (region-id (ebox--ensure-region-id box))
(padding-left (floor (or (ebox-get box :padding-left-pixel) 0))) (padding-left (ebox-native-reflow--compile-edge box :padding-left-pixel))
(padding-right (floor (or (ebox-get box :padding-right-pixel) 0))) (padding-right (ebox-native-reflow--compile-edge box :padding-right-pixel))
(padding-top (floor (or (ebox-get box :padding-top-height) 0))) (padding-top (ebox-native-reflow--compile-edge box :padding-top-height))
(padding-bottom (floor (or (ebox-get box :padding-bottom-height) 0))) (padding-bottom (ebox-native-reflow--compile-edge box :padding-bottom-height))
(margin-left (floor (or (ebox-get box :margin-left-pixel) 0))) (margin-left (ebox-native-reflow--compile-edge box :margin-left-pixel))
(margin-right (floor (or (ebox-get box :margin-right-pixel) 0))) (margin-right (ebox-native-reflow--compile-edge box :margin-right-pixel))
(margin-top (floor (or (ebox-get box :margin-top-height) 0))) (margin-top (ebox-native-reflow--compile-edge box :margin-top-height))
(margin-bottom (floor (or (ebox-get box :margin-bottom-height) 0))) (margin-bottom (ebox-native-reflow--compile-edge box :margin-bottom-height))
(border-left (floor (or (ebox-get box :border-left-pixel) 0))) (border-left (ebox-native-reflow--compile-edge box :border-left-pixel))
(border-right (floor (or (ebox-get box :border-right-pixel) 0))) (border-right (ebox-native-reflow--compile-edge box :border-right-pixel))
(border-top (floor (or (ebox-get box :border-top-pixel) 0))) (border-top (ebox-native-reflow--compile-edge box :border-top-pixel))
(border-bottom (floor (or (ebox-get box :border-bottom-pixel) 0))) (border-bottom (ebox-native-reflow--compile-edge box :border-bottom-pixel))
(_border-capability (_border-capability
(when (or (> border-top 1) (> border-bottom 1)) (when (or (> border-top 1) (> border-bottom 1))
(signal 'ebox-surface-capability-error (signal 'ebox-surface-capability-error
@ -1971,7 +2118,7 @@ cross-axis stretch and item gap remain explicit without legacy runtime nodes."
(when color (list :color color))))))))) (when color (list :color color)))))))))
(surface-template-id (surface-template-id
(ebox-native-reflow--register-property-template (ebox-native-reflow--register-property-template
(ebox-get box :surface-properties) "surface")) (ebox-interaction--node-surface-properties box) "surface"))
(fused-text-p (fused-text-p
(and typed-box-p (and typed-box-p
(eq typed-layout-kind 'normal) (eq typed-layout-kind 'normal)
@ -2080,6 +2227,12 @@ cross-axis stretch and item gap remain explicit without legacy runtime nodes."
(hash-table-p ebox--flex-content-min-width-table)) (hash-table-p ebox--flex-content-min-width-table))
(gethash source ebox--flex-content-min-width-table missing) (gethash source ebox--flex-content-min-width-table missing)
missing))) missing)))
(let ((minimum (ebox-get source (if (eq axis 'row)
:min-width :min-height))))
(when (and (eq (plist-get source :ebox-kind) 'box)
(not (memq minimum '(nil auto))))
(error "Native reflow uses Elisp for an explicit Flex item minimum: %S"
minimum)))
(unless (eq captured-content-min missing) (unless (eq captured-content-min missing)
(setq content-min-width captured-content-min (setq content-min-width captured-content-min
content-min-width-p t)) content-min-width-p t))
@ -2115,7 +2268,7 @@ cross-axis stretch and item gap remain explicit without legacy runtime nodes."
(ebox-native-reflow--compile-size (ebox-native-reflow--compile-size
(plist-get props :flex-basis) (plist-get props :flex-basis)
(if (eq axis 'row) 'horizontal 'vertical) (if (eq axis 'row) 'horizontal 'vertical)
"auto") "auto" source)
:align-self :align-self
(if (eq (plist-get source :ebox-kind) 'text) (if (eq (plist-get source :ebox-kind) 'text)
"flex-start" "flex-start"
@ -2151,9 +2304,10 @@ cross-axis stretch and item gap remain explicit without legacy runtime nodes."
:height :height
(ebox-native-reflow--compile-size (ebox-native-reflow--compile-size
(plist-get props :height) 'vertical "auto") (plist-get props :height) 'vertical "auto")
:row-gap (or (ebox--flex-line-value (plist-get props :row-gap) 0) 0) :row-gap
(ebox-native-reflow--compile-gap (plist-get props :row-gap) 'vertical)
:column-gap :column-gap
(or (plist-get props :column-gap) 0) (ebox-native-reflow--compile-gap (plist-get props :column-gap) 'horizontal)
:items :items
(vconcat (vconcat
(mapcar (mapcar
@ -2172,20 +2326,33 @@ cross-axis stretch and item gap remain explicit without legacy runtime nodes."
(ebox-native-reflow--compile-flex-item item axis))) (ebox-native-reflow--compile-flex-item item axis)))
items))))) items)))))
(defun ebox-native-reflow--compile-flex (defun ebox-native-reflow--compile-flex-content-props (node)
(node &optional fragments flex-content-min-widths) "Return NODE's native Flex constraints without freezing relative gaps."
"Compile normalized legacy flex NODE through the shared Flex IR builder."
(let* ((wrapper (plist-get node :box)) (let* ((wrapper (plist-get node :box))
(source (plist-get node :props))
(_size-proof
(dolist (property '(:width :height :row-gap :column-gap))
(ebox-native-reflow--require-static-size
(plist-get source property))))
(props (ebox--flex-container-content-props (props (ebox--flex-container-content-props
(plist-get node :props) source (plist-get node :raw-props) wrapper)))
(plist-get node :raw-props) (dolist (property '(:row-gap :column-gap))
wrapper))) (when (plist-member source property)
(plist-put props property (plist-get source property))))
(when wrapper (when wrapper
;; The surrounding native box supplies its resolved content width to the ;; The surrounding native box supplies its resolved content width to the
;; child for each frame. Do not freeze the compiler's ambient viewport. ;; child for each frame. Do not freeze the compiler's ambient viewport.
(plist-put props :width nil) (plist-put props :width nil)
(when (plist-member (plist-get node :raw-props) :height) (when (plist-member (plist-get node :raw-props) :height)
(plist-put props :height 'viewport-height))) (plist-put props :height 'viewport-height)))
props))
(defun ebox-native-reflow--compile-flex
(node &optional fragments flex-content-min-widths)
"Compile normalized legacy flex NODE through the shared Flex IR builder."
(let* ((wrapper (plist-get node :box))
(ebox-native-reflow--compile-size-node (or wrapper node))
(props (ebox-native-reflow--compile-flex-content-props node)))
(let ((inner (let ((inner
(ebox-native-reflow--compile-flex-inner (ebox-native-reflow--compile-flex-inner
props (ebox-tree-layout-children node) props (ebox-tree-layout-children node)
@ -2214,12 +2381,39 @@ cross-axis stretch and item gap remain explicit without legacy runtime nodes."
(_ (error "Native reflow unsupported node type: %S" (_ (error "Native reflow unsupported node type: %S"
(and (listp node) (plist-get node :ebox-type)))))) (and (listp node) (plist-get node :ebox-type))))))
(defun ebox-native-reflow--node-static-sizes-p (node)
"Return non-nil if NODE's own sizes can survive retained native resize."
(let ((config (plist-get node :ebox-layout-config)))
(cl-every
(lambda (properties)
(cl-loop for (property value) on properties by #'cddr
always
(and
(or (not (memq property '(:height :min-height :max-height)))
(not (memq value
'(min-content max-content fit-content stretch))))
(or (not (memq property
'(:width :min-width :max-width
:height :min-height :max-height :flex-basis
:item-gap :row-gap :column-gap
:padding-top-height :padding-bottom-height
:padding-left-pixel :padding-right-pixel
:margin-top-height :margin-bottom-height
:margin-left-pixel :margin-right-pixel
:border-top-pixel :border-bottom-pixel
:border-left-pixel :border-right-pixel)))
(ebox-native-reflow--static-size-p value)))))
(list node (plist-get node :props)
(and config (ebox-layout-config-props config))))))
(defun ebox-native-reflow--native-node-supported-p (node) (defun ebox-native-reflow--native-node-supported-p (node)
"Return non-nil when NODE contains only native-supported layout nodes. "Return non-nil when NODE contains only native-supported layout nodes.
Grid deliberately stays on the ordinary Ebox renderer until the native Grid deliberately stays on the ordinary Ebox renderer until the native
backend has a matching two-dimensional layout contract." backend has a matching two-dimensional layout contract."
(cond (cond
((or (null node) (stringp node) (not (listp node))) t) ((or (null node) (stringp node) (not (listp node))) t)
((eq (plist-get node :visibility) 'hidden) nil)
((not (ebox-native-reflow--node-static-sizes-p node)) nil)
((plist-get node :ebox-child-sequence) nil) ((plist-get node :ebox-child-sequence) nil)
((eq (plist-get node :ebox-type) 'grid) nil) ((eq (plist-get node :ebox-type) 'grid) nil)
((eq (plist-get node :ebox-type) 'box) ((eq (plist-get node :ebox-type) 'box)
@ -2251,7 +2445,8 @@ backend has a matching two-dimensional layout contract."
(defun ebox-native-reflow--compile-typed-scene-child (defun ebox-native-reflow--compile-typed-scene-child
(box fragments flex-content-min-widths) (box fragments flex-content-min-widths)
"Compile typed BOX child layout from already compiled FRAGMENTS." "Compile typed BOX child layout from already compiled FRAGMENTS."
(let* ((config (plist-get box :ebox-layout-config)) (let* ((ebox-native-reflow--compile-size-node box)
(config (plist-get box :ebox-layout-config))
(kind (ebox-layout-config-kind config)) (kind (ebox-layout-config-kind config))
(children (ebox-tree-node-children box))) (children (ebox-tree-node-children box)))
(pcase kind (pcase kind
@ -2445,7 +2640,7 @@ Otherwise return CANDIDATE with the next document revision."
:string-pixel-width-cache (make-hash-table :test 'equal) :string-pixel-width-cache (make-hash-table :test 'equal)
:string-max-pixel-width-cache (make-hash-table :test 'eq) :string-max-pixel-width-cache (make-hash-table :test 'eq)
:property-templates nil :property-templates nil
:property-template-ids (make-hash-table :test 'equal))) :property-template-ids (make-hash-table :test 'ebox-native-property-template)))
(defun ebox-native-reflow--layout-builder-current-p (builder) (defun ebox-native-reflow--layout-builder-current-p (builder)
"Return non-nil when BUILDER still identifies its live render scene." "Return non-nil when BUILDER still identifies its live render scene."
@ -2550,7 +2745,7 @@ per call, and no call recursively visits the captured Ebox tree."
(ebox-native-reflow--compile-property-templates (ebox-native-reflow--compile-property-templates
(append retained-property-templates nil)) (append retained-property-templates nil))
(ebox-native-reflow--compile-property-template-ids (ebox-native-reflow--compile-property-template-ids
(make-hash-table :test 'equal)) (make-hash-table :test 'ebox-native-property-template))
(ebox-native-reflow--compile-display-signature (ebox-native-reflow--compile-display-signature
(ebox--current-display-signature)) (ebox--current-display-signature))
(ebox-native-reflow--compile-root-node node)) (ebox-native-reflow--compile-root-node node))
@ -2600,6 +2795,9 @@ per call, and no call recursively visits the captured Ebox tree."
(and wrapper (plist-get wrapper :region-id))) (and wrapper (plist-get wrapper :region-id)))
(ebox-tree-node-local-source-signature node) (ebox-tree-node-local-source-signature node)
(and wrapper (ebox-tree-node-local-source-signature wrapper)) (and wrapper (ebox-tree-node-local-source-signature wrapper))
(plist-get node :ebox-font-fact)
(and wrapper (plist-get wrapper :ebox-font-fact))
ebox-native-reflow--compile-display-signature
(and source-index (and source-index
(when-let* ((handle (plist-get node :ebox-source-handle))) (when-let* ((handle (plist-get node :ebox-source-handle)))
(ebox-source--index-record source-index handle))) (ebox-source--index-record source-index handle)))
@ -2614,9 +2812,20 @@ per call, and no call recursively visits the captured Ebox tree."
(gethash child flex-content-min-widths))) (gethash child flex-content-min-widths)))
(ebox-native-reflow--retained-layout-children node)))))) (ebox-native-reflow--retained-layout-children node))))))
(defun ebox-native-reflow--retained-layout-signature-equal-p (old new)
"Compare OLD and NEW compiler signatures including native callback identity."
(and (equal old new)
;; Positions 3 and 4 contain the node and optional flex-wrapper facts.
;; Keep existing structural comparison for immutable font/source records.
(cl-loop for index in '(3 4)
always (ebox-interaction-surface-equal-p
(plist-get (nth index old) :surface-properties)
(plist-get (nth index new) :surface-properties)))))
(defun ebox-native-reflow--retained-edge-rule (node) (defun ebox-native-reflow--retained-edge-rule (node)
"Return NODE's exact child-edge lowering rule, or nil for plain edges." "Return NODE's exact child-edge lowering rule, or nil for plain edges."
(let ((node-id (plist-get node :node-id))) (let ((node-id (plist-get node :node-id))
(ebox-native-reflow--compile-size-node node))
(cond (cond
((and (eq (plist-get node :ebox-type) 'box) ((and (eq (plist-get node :ebox-type) 'box)
(eq (plist-get node :ebox-kind) 'box)) (eq (plist-get node :ebox-kind) 'box))
@ -2628,7 +2837,9 @@ per call, and no call recursively visits the captured Ebox tree."
(list :parent-id node-id :slot 1 :kind 'flex-item (list :parent-id node-id :slot 1 :kind 'flex-item
:axis (ebox--flex-axis props))) :axis (ebox--flex-axis props)))
((or 'row 'column) ((or 'row 'column)
(if (and (equal (plist-get props :item-gap) 0) (if (and (zerop (ebox-native-reflow--compile-gap
(plist-get props :item-gap)
(if (eq kind 'row) 'horizontal 'vertical)))
(eq (plist-get props :cross-align) 'stretch)) (eq (plist-get props :cross-align) 'stretch))
(list :parent-id node-id :slot 1 :kind 'direct-axis (list :parent-id node-id :slot 1 :kind 'direct-axis
:axis kind) :axis kind)
@ -2637,9 +2848,7 @@ per call, and no call recursively visits the captured Ebox tree."
(plist-get props :cross-align))))))) (plist-get props :cross-align)))))))
((eq (plist-get node :ebox-type) 'flex) ((eq (plist-get node :ebox-type) 'flex)
(let* ((wrapper (plist-get node :box)) (let* ((wrapper (plist-get node :box))
(props (ebox--flex-container-content-props (props (plist-get node :props)))
(plist-get node :props) (plist-get node :raw-props)
wrapper)))
(list :parent-id node-id :slot (if wrapper 1 0) :kind 'flex-item (list :parent-id node-id :slot (if wrapper 1 0) :kind 'flex-item
:axis (ebox--flex-axis props))))))) :axis (ebox--flex-axis props)))))))
@ -2768,7 +2977,7 @@ Resolve node-ID entries through NODE-TABLE; legacy node plists need no index."
:property-templates) :property-templates)
nil)) nil))
(ebox-native-reflow--compile-property-template-ids (ebox-native-reflow--compile-property-template-ids
(make-hash-table :test 'equal)) (make-hash-table :test 'ebox-native-property-template))
(ebox-native-reflow--compile-display-signature (ebox-native-reflow--compile-display-signature
(ebox--current-display-signature)) (ebox--current-display-signature))
(ebox-native-reflow--compile-root-node node) (ebox-native-reflow--compile-root-node node)
@ -2845,7 +3054,8 @@ Resolve node-ID entries through NODE-TABLE; legacy node plists need no index."
(if (and cached (if (and cached
(not (and inherited-touched-set (not (and inherited-touched-set
(gethash node-id inherited-touched-set))) (gethash node-id inherited-touched-set)))
(equal signature (plist-get cached :signature))) (ebox-native-reflow--retained-layout-signature-equal-p
signature (plist-get cached :signature)))
(setq fragment (plist-get cached :fragment) (setq fragment (plist-get cached :fragment)
entry cached) entry cached)
(setq fragment (setq fragment
@ -2854,7 +3064,7 @@ Resolve node-ID entries through NODE-TABLE; legacy node plists need no index."
fragment (plist-put fragment :node-id node-id) fragment (plist-put fragment :node-id node-id)
revision (1+ revision) revision (1+ revision)
fragment (plist-put fragment :node-revision revision) fragment (plist-put fragment :node-revision revision)
entry (list :signature (copy-tree signature) entry (list :signature signature
:fragment fragment :fragment fragment
:revision revision))) :revision revision)))
(puthash current fragment fragments) (puthash current fragment fragments)
@ -2898,7 +3108,7 @@ Resolve node-ID entries through NODE-TABLE; legacy node plists need no index."
(ebox-native-reflow-session-layout-property-template-index (ebox-native-reflow-session-layout-property-template-index
session) session)
(ebox-native-reflow--persistent-index-from-sequence (ebox-native-reflow--persistent-index-from-sequence
(plist-get package :property-templates)) (plist-get package :property-templates) t)
(ebox-native-reflow-session-layout-edge-index session) (ebox-native-reflow-session-layout-edge-index session)
(ebox-native-reflow--retained-edge-index (ebox-native-reflow--retained-edge-index
postorder new-cache node-table)) postorder new-cache node-table))
@ -3045,10 +3255,14 @@ RULE identifies the compiler lowering, and RETAINED supplies its stable slot."
('box ('box
(if (eq (plist-get node :ebox-kind) 'text) (if (eq (plist-get node :ebox-kind) 'text)
(vector (ebox-native-reflow--compile-text-node node)) (vector (ebox-native-reflow--compile-text-node node))
(let* ((config (and (eq (plist-get node :ebox-kind) 'box) (let* ((ebox-native-reflow--compile-size-node node)
(config (and (eq (plist-get node :ebox-kind) 'box)
(plist-get node :ebox-layout-config))) (plist-get node :ebox-layout-config)))
(kind (and config (ebox-layout-config-kind config))) (kind (and config (ebox-layout-config-kind config)))
(old-child (plist-get old-fragment :child)) (old-child
(let ((child (plist-get old-fragment :child)))
;; JSON null is an absent edge, not a compiled child.
(unless (eq child :null) child)))
(fused-p (and (eq kind 'normal) (fused-p (and (eq kind 'normal)
(let ((child (car (ebox-tree-node-children node)))) (let ((child (car (ebox-tree-node-children node))))
(and child (and child
@ -3071,13 +3285,8 @@ RULE identifies the compiler lowering, and RETAINED supplies its stable slot."
(if inner (vector outer inner) (vector outer))))) (if inner (vector outer inner) (vector outer)))))
('flex ('flex
(let* ((wrapper (plist-get node :box)) (let* ((wrapper (plist-get node :box))
(props (ebox--flex-container-content-props (ebox-native-reflow--compile-size-node (or wrapper node))
(plist-get node :props) (plist-get node :raw-props) (props (ebox-native-reflow--compile-flex-content-props node)))
wrapper)))
(when wrapper
(plist-put props :width nil)
(when (plist-member (plist-get node :raw-props) :height)
(plist-put props :height 'viewport-height)))
(let ((inner (ebox-native-reflow--compile-flex-inner props nil))) (let ((inner (ebox-native-reflow--compile-flex-inner props nil)))
(if wrapper (if wrapper
(vector (ebox-native-reflow--compile-box wrapper inner t) inner) (vector (ebox-native-reflow--compile-box wrapper inner t) inner)
@ -3593,8 +3802,8 @@ plist supplied by the caller."
(error "Native reflow tape identity mismatch for %S" field))) (error "Native reflow tape identity mismatch for %S" field)))
(if ok-p (if ok-p
(progn (progn
(unless (> line-count 0) (when (and (zerop line-count) (not (zerop character-count)))
(error "Native reflow tape has no lines")) (error "Native reflow zero-line tape contains characters"))
(unless (= style-count (length styles)) (unless (= style-count (length styles))
(error "Native reflow tape style table mismatch"))) (error "Native reflow tape style table mismatch")))
(unless (and (zerop style-count) (zerop line-count) (unless (and (zerop style-count) (zerop line-count)
@ -3683,7 +3892,10 @@ plist supplied by the caller."
(if (and (eq kind 'source) (null value)) (if (and (eq kind 'source) (null value))
(ebox-native-reflow--add-absent-nil-source-property (ebox-native-reflow--add-absent-nil-source-property
rendered start end property) rendered start end property)
(when value (if (not (eq kind 'source))
(ebox-interaction--fill-property
rendered start end property value)
(when value
(if (and (eq kind 'source) (eq property 'face)) (if (and (eq kind 'source) (eq property 'face))
(ebox-native-reflow--apply-source-face (ebox-native-reflow--apply-source-face
rendered start end value) rendered start end value)
@ -3694,7 +3906,7 @@ plist supplied by the caller."
(unless (get-text-property position property rendered) (unless (get-text-property position property rendered)
(add-text-properties (add-text-properties
position next (list property value) rendered)) position next (list property value) rendered))
(setq position (max next (1+ position)))))))))))) (setq position (max next (1+ position)))))))))))))
(defun ebox-native-reflow--expand-property-templates (defun ebox-native-reflow--expand-property-templates
(rendered property-templates) (rendered property-templates)
@ -3726,7 +3938,7 @@ plist supplied by the caller."
(ebox-native-reflow--apply-property-template-span (ebox-native-reflow--apply-property-template-span
rendered position next (aref property-templates id)))) rendered position next (aref property-templates id))))
(setq position next))) (setq position next)))
rendered)) (ebox-interaction--refresh-hover! rendered 0 limit)))
(defun ebox-native-reflow--validate-literal-properties (defun ebox-native-reflow--validate-literal-properties
(rendered styles complete-p &optional property-templates) (rendered styles complete-p &optional property-templates)
@ -3759,7 +3971,7 @@ plist supplied by the caller."
(proper-list-p (nth 2 value)) (proper-list-p (nth 2 value))
(= (length (nth 2 value)) 1) (= (length (nth 2 value)) 1)
(integerp (car (nth 2 value))) (integerp (car (nth 2 value)))
(> (car (nth 2 value)) 0)) (>= (car (nth 2 value)) 0))
(error "Native reflow tape has an invalid display value"))) (error "Native reflow tape has an invalid display value")))
('ebox-content-idx ('ebox-content-idx
(unless (and complete-p (integerp value) (>= value 0)) (unless (and complete-p (integerp value) (>= value 0))
@ -4467,7 +4679,7 @@ HEADER supplies the already validated target identity. STYLES owns the face
(error "Native reflow tape literal has trailing or non-string data")) (error "Native reflow tape literal has trailing or non-string data"))
(unless (= (length rendered) declared-characters) (unless (= (length rendered) declared-characters)
(error "Native reflow tape character count mismatch")) (error "Native reflow tape character count mismatch"))
(unless (= (cl-count ?\n rendered) (1- line-count)) (unless (= (ebox-string-height rendered) line-count)
(error "Native reflow tape has invalid line break metadata")) (error "Native reflow tape has invalid line break metadata"))
(ebox-native-reflow--expand-property-templates (ebox-native-reflow--expand-property-templates
rendered property-templates) rendered property-templates)

View File

@ -12,8 +12,8 @@
(defconst ebox--longhand (defconst ebox--longhand
'(:content "" '(:content ""
:box-sizing border-box :box-sizing border-box
:width nil :min-width 0 :max-width nil :width nil :min-width auto :max-width nil
:height nil :min-height 0 :max-height nil :height nil :min-height auto :max-height nil
:padding-left-pixel 0 :padding-right-pixel 0 :padding-left-pixel 0 :padding-right-pixel 0
:padding-top-height 0 :padding-bottom-height 0 :padding-top-height 0 :padding-bottom-height 0
:margin-left-pixel 0 :margin-right-pixel 0 :margin-left-pixel 0 :margin-right-pixel 0
@ -53,7 +53,7 @@ style projection.")
(defconst ebox-node-factory--box-longhand (defconst ebox-node-factory--box-longhand
'(:content "" '(:content ""
:box-sizing border-box :box-sizing border-box
:min-width 0 :min-height 0 :min-width auto :min-height auto
:padding-left-pixel 0 :padding-right-pixel 0 :padding-left-pixel 0 :padding-right-pixel 0
:padding-top-height 0 :padding-bottom-height 0 :padding-top-height 0 :padding-bottom-height 0
:margin-left-pixel 0 :margin-right-pixel 0 :margin-left-pixel 0 :margin-right-pixel 0

View File

@ -9,6 +9,9 @@
(require 'cl-lib) (require 'cl-lib)
(declare-function ebox-interaction--refresh-hover! "ebox-interaction"
(string start end &optional face))
(define-error 'ebox-layout-context-error (define-error 'ebox-layout-context-error
"Ebox layout context is unavailable") "Ebox layout context is unavailable")
@ -67,6 +70,16 @@
(defvar ebox-viewport-width nil) (defvar ebox-viewport-width nil)
(defvar ebox-viewport-height nil) (defvar ebox-viewport-height nil)
(defvar ebox--css-viewport-width nil
"Root viewport width in pixels, unchanged by nested containing blocks.")
(defvar ebox--css-viewport-height nil
"Root viewport height in pixels, unchanged by nested containing blocks.")
(defvar ebox--css-containing-height nil
"Definite containing block content height in pixels, or nil.")
(defvar ebox--css-size-context-active-p nil
"Non-nil when root CSS viewport dimensions have been captured.")
(defvar ebox--css-font-fact nil
"Effective font projection for the current layout node.")
(defvar ebox--render-runtime-revision nil) (defvar ebox--render-runtime-revision nil)
(defvar ebox--surface-materialization-active nil) (defvar ebox--surface-materialization-active nil)
(defvar ebox--render-root-parent-kind nil (defvar ebox--render-root-parent-kind nil
@ -76,6 +89,7 @@
(defvar ebox--viewport-dependent-node-ids-cache nil) (defvar ebox--viewport-dependent-node-ids-cache nil)
(defvar ebox--viewport-dependent-subtree-cache nil) (defvar ebox--viewport-dependent-subtree-cache nil)
(defvar ebox--viewport-height-dependent-subtree-cache nil) (defvar ebox--viewport-height-dependent-subtree-cache nil)
(defvar ebox--css-viewport-dependent-subtree-cache nil)
(defvar ebox--flex-content-min-width-table nil (defvar ebox--flex-content-min-width-table nil
"Render-owned box measurements reusable by native scene compilation.") "Render-owned box measurements reusable by native scene compilation.")
@ -152,7 +166,9 @@ the render boundary."
(defun ebox--add-render-face! (string start end face &optional append) (defun ebox--add-render-face! (string start end face &optional append)
"Add Ebox FACE to STRING after capturing its caller baseline." "Add Ebox FACE to STRING after capturing its caller baseline."
(ebox--capture-paint-origins! string start end) (ebox--capture-paint-origins! string start end)
(add-face-text-property start end face append string)) (add-face-text-property start end face append string)
(when (fboundp 'ebox-interaction--refresh-hover!)
(ebox-interaction--refresh-hover! string start end)))
(defun ebox--strip-paint-origins! (string) (defun ebox--strip-paint-origins! (string)
"Remove transient caller-face carriers from STRING before it escapes. "Remove transient caller-face carriers from STRING before it escapes.
@ -242,6 +258,12 @@ including strings returned from a render-cache hit."
(plist-get ,render-state :viewport-width)) (plist-get ,render-state :viewport-width))
(ebox-viewport-height (ebox-viewport-height
(plist-get ,render-state :viewport-height)) (plist-get ,render-state :viewport-height))
(ebox--css-viewport-width ebox-viewport-width)
(ebox--css-viewport-height
(and (numberp ebox-viewport-height)
(* ebox-viewport-height (frame-char-height))))
(ebox--css-containing-height ebox--css-viewport-height)
(ebox--css-size-context-active-p t)
(ebox--render-runtime-revision (ebox--render-runtime-revision
(plist-get ,render-state :runtime-revision)) (plist-get ,render-state :runtime-revision))
(ebox--surface-materialization-active t) (ebox--surface-materialization-active t)
@ -257,6 +279,9 @@ including strings returned from a render-cache hit."
(ebox--viewport-dependent-subtree-cache (ebox--viewport-dependent-subtree-cache
(or ebox--viewport-dependent-subtree-cache (or ebox--viewport-dependent-subtree-cache
(make-hash-table :test 'eq))) (make-hash-table :test 'eq)))
(ebox--css-viewport-dependent-subtree-cache
(or ebox--css-viewport-dependent-subtree-cache
(make-hash-table :test 'eq)))
(ebox--viewport-height-dependent-subtree-cache (ebox--viewport-height-dependent-subtree-cache
(or ebox--viewport-height-dependent-subtree-cache (or ebox--viewport-height-dependent-subtree-cache
(plist-get (plist-get

View File

@ -81,6 +81,7 @@
(let* ((buffer (ebox-selector--region-handle-buffer handle)) (let* ((buffer (ebox-selector--region-handle-buffer handle))
(surface (ebox-selector--region-handle-surface handle)) (surface (ebox-selector--region-handle-surface handle))
(object (ebox-selector--region-handle-object handle)) (object (ebox-selector--region-handle-object handle))
(logical-id (ebox-selector--region-handle-logical-id handle))
(state (and (buffer-live-p buffer) (state (and (buffer-live-p buffer)
(ebox--buffer-render-state buffer))) (ebox--buffer-render-state buffer)))
(region-id (region-id
@ -88,6 +89,12 @@
(eq surface (plist-get state :surface)) (eq surface (plist-get state :surface))
(tp-surface-live-p surface) (tp-surface-live-p surface)
(tp-object-live-p object) (tp-object-live-p object)
;; A retained positional object can be reused for a different
;; source node. A named handle must still name this object.
(or (null logical-id)
(assq object
(gethash (ebox-tree-metadata-string logical-id)
(plist-get state :logical-id-region-table))))
(gethash object (gethash object
(plist-get state :surface-object-region-table))))) (plist-get state :surface-object-region-table)))))
(unless region-id (unless region-id

230
ebox-size.el Normal file
View File

@ -0,0 +1,230 @@
;;; ebox-size.el --- Explicit CSS length values for Ebox -*- lexical-binding: t; -*-
;;; Commentary:
;; Pure validation and pixel resolution for (UNIT NUMBER) lengths and
;; calc/min/max/clamp expressions. Callers supply measured context and own
;; property keywords, range constraints, and final display quantization.
;;; Code:
(require 'cl-lib)
(define-error 'ebox-size-error "Invalid Ebox size")
(defconst ebox-size-axis-units
'((inline px ch vw %)
(block lh vh %)
(stroke px ch lh vw vh)
(length px ch vw lh vh %))
"Allowed units for each Ebox size domain.
Inline and block lengths follow the renderer's horizontal and line axes.
Stroke thickness is a paint measurement independent of edge orientation.
Length is only for declarations whose parent-dependent axis is not known yet.")
(defun ebox-size--finite-number-p (value)
"Return non-nil if VALUE is a finite real number."
(and (numberp value)
(or (integerp value) (= (- value value) 0.0))))
(defun ebox-size--fail (value reason)
"Reject VALUE with a size error explaining REASON."
(signal 'ebox-size-error (list reason value)))
(defun ebox-size--checked-number (value source)
"Return finite VALUE, or reject SOURCE for arithmetic overflow."
(unless (ebox-size--finite-number-p value)
(ebox-size--fail source "Expected a finite number"))
value)
(defun ebox-size--unit (value context)
"Read a unit VALUE using pixel measurements in CONTEXT.
Return (DIMENSION PIXELS DEPENDENCIES), with nil PIXELS when unresolved."
(unless (and (= (length value) 2)
(ebox-size--finite-number-p (cadr value)))
(ebox-size--fail value "Use (unit finite-number)"))
(let* ((unit (car value))
(amount (cadr value))
(dependency (pcase unit
('% 'percent)
('vw 'viewport-width)
('vh 'viewport-height)
('ch 'ch)
('lh 'lh)))
(base (pcase unit
('px 1)
('% (plist-get context :percent-base))
('vw (plist-get context :viewport-width))
('vh (plist-get context :viewport-height))
('ch (plist-get context :ch))
('lh (plist-get context :lh)))))
(when (and base
(not (and (ebox-size--finite-number-p base) (>= base 0))))
(ebox-size--fail value "Context measurements must be finite and nonnegative"))
(list 'length
(when base
(ebox-size--checked-number
(if (memq unit '(% vw vh))
(/ (* amount base) 100.0)
(* amount base))
value))
(when dependency (list dependency)))))
(defun ebox-size--same-dimension (arguments source)
"Return the shared dimension of ARGUMENTS, or reject SOURCE."
(let ((dimension (caar arguments)))
(unless (cl-every (lambda (argument) (eq (car argument) dimension))
arguments)
(ebox-size--fail source "Cannot add or compare numbers and lengths"))
dimension))
(defun ebox-size--math (value context)
"Read mathematical VALUE in CONTEXT without evaluating Lisp."
(let* ((operator (car value))
(count (length (cdr value))))
(unless (pcase operator
((or '* '/) (= count 2))
('clamp (= count 3))
('calc (= count 1))
(_ (> count 0)))
(ebox-size--fail value "Wrong number of size function arguments"))
(let* ((arguments (mapcar (lambda (argument)
(ebox-size--read argument context t))
(cdr value)))
(numbers (mapcar #'cadr arguments))
(dimensions (mapcar #'car arguments))
(dimension
(pcase operator
('*
(when (> (cl-count 'length dimensions) 1)
(ebox-size--fail value "Multiplication needs a scalar operand"))
(if (memq 'length dimensions) 'length 'number))
('/
(unless (eq (cadr dimensions) 'number)
(ebox-size--fail value "Division needs a scalar divisor"))
(when (zerop (cadr numbers))
(ebox-size--fail value "Division by zero"))
(car dimensions))
(_ (ebox-size--same-dimension arguments value))))
(dependencies (delete-dups
(apply #'append (mapcar #'caddr arguments)))))
(list dimension
(when (cl-every #'numberp numbers)
(ebox-size--checked-number
(pcase operator
('calc (car numbers))
('+ (apply #'+ numbers))
('- (apply #'- numbers))
('* (apply #'* numbers))
('/ (/ (float (car numbers)) (cadr numbers)))
('min (apply #'min numbers))
('max (apply #'max numbers))
('clamp (max (nth 0 numbers)
(min (nth 1 numbers) (nth 2 numbers)))))
value))
dependencies))))
(defun ebox-size--read (value context in-math)
"Read VALUE with CONTEXT and scalar permission IN-MATH.
The result is (DIMENSION PIXELS DEPENDENCIES); nil PIXELS is unresolved."
(cond
((and in-math (ebox-size--finite-number-p value))
(list 'number value nil))
((not (and (consp value) (proper-list-p value)))
(ebox-size--fail value "Use an explicit (unit number) length or size function"))
((memq (car value) '(px % vw vh ch lh))
(ebox-size--unit value context))
((or (memq (car value) '(calc min max clamp))
(and in-math (memq (car value) '(+ - * /))))
(ebox-size--math value context))
(t (ebox-size--fail value "Unknown size unit or function"))))
(defun ebox-size--parse (value context)
"Read a length VALUE with CONTEXT, rejecting dimensionless results."
(let ((result (ebox-size--read value context nil)))
(unless (eq (car result) 'length)
(ebox-size--fail value "A size expression must produce a length"))
result))
(cl-defun ebox-size-value-p (value &optional allow-negative (allow-percent t))
"Return non-nil when VALUE is a supported length or size expression.
Supported units are px, %, vw, vh, ch, and lh, each written (UNIT NUMBER).
Supported functions are calc, min, max, and clamp. Inside functions,
+ and - combine like dimensions; * and / accept scalar operands.
Bare numbers, old singleton pixel lists, and keywords are not lengths.
ALLOW-NEGATIVE permits negative literal lengths; arithmetic intermediates
may always be negative. Callers enforce the resolved property range.
ALLOW-PERCENT defaults to t; explicit nil forbids percentages anywhere
in the expression, for properties such as border widths."
(condition-case nil
(let ((result (ebox-size--parse value nil)))
(and (or allow-negative
(not (memq (car value) '(px % vw vh ch lh)))
(>= (cadr value) 0))
(or allow-percent (not (memq 'percent (caddr result))))
t))
(error nil)))
(defun ebox-size-validate-for-axis (value property axis)
"Return length VALUE after validating PROPERTY's size AXIS.
AXIS is inline, block, stroke, or length as in `ebox-size-axis-units'.
Every unit inside calc, min, max, and clamp must be allowed on that axis,
including operands whose value would later cancel out. Invalid syntax,
negative literals, and forbidden units signal `ebox-size-error' with the
property name and allowed units. Keywords are validated by the property."
(let ((allowed (or (cdr (assq axis ebox-size-axis-units))
(error "Unknown Ebox size axis: %S" axis))))
(cl-labels ((units-valid-p (expression)
(or (atom expression)
(if (memq (car expression) '(px ch vw lh vh %))
(memq (car expression) allowed)
(cl-every #'units-valid-p (cdr expression))))))
(unless (and (ebox-size-value-p value nil (memq '% allowed))
(units-valid-p value))
(signal 'ebox-size-error
(list (format "Invalid value for %S; allowed units: %S"
property allowed)
value)))))
value)
(defun ebox-size-value-for-axis-p (value axis)
"Return non-nil when length VALUE is valid for size AXIS."
(condition-case nil
(progn (ebox-size-validate-for-axis value 'size axis) t)
(ebox-size-error nil)))
(defun ebox-size-resolve (value context)
"Resolve length VALUE to pixels using CONTEXT, or nil if unresolved.
CONTEXT is a plist of pixel measurements: :percent-base is the containing
block reference, :viewport-width and :viewport-height are viewport sizes,
and :ch and :lh are the effective font's zero advance and line height.
Missing or nil measurements defer resolution; zero is a valid measurement.
Fractional and negative results are preserved. The caller owns range
clamping and display rounding. Invalid VALUE signals `ebox-size-error'."
(cadr (ebox-size--parse value context)))
(defun ebox-size-dependencies (value)
"Return the measurements required by length VALUE in first-use order.
Possible symbols are percent, viewport-width, viewport-height, ch, and lh.
Each appears at most once. Invalid VALUE signals `ebox-size-error'."
(caddr (ebox-size--parse value nil)))
(defun ebox-size-quantize (values)
"Quantize nonnegative VALUES at cumulative display boundaries.
The returned whole extents preserve the floor of the combined extent instead
of losing each separate fraction. A small tolerance absorbs floating-point
error at integer boundaries. This is a display operation, not CSS arithmetic."
(let ((position 0.0) (previous 0))
(mapcar
(lambda (value)
(unless (and (ebox-size--finite-number-p value) (>= value 0))
(ebox-size--fail value "Display extents must be finite and nonnegative"))
(setq position (ebox-size--checked-number (+ position value) values))
(let* ((boundary (floor (+ position 1e-9)))
(extent (- boundary previous)))
(setq previous boundary)
extent))
values)))
(provide 'ebox-size)
;;; ebox-size.el ends here

View File

@ -9,11 +9,12 @@
(require 'cl-lib) (require 'cl-lib)
(require 'subr-x) (require 'subr-x)
(require 'ebox-interaction)
(cl-defstruct (ebox-source-record (cl-defstruct (ebox-source-record
(:constructor ebox-source--make-record)) (:constructor ebox-source--make-record))
"Immutable facts supplied by one author source." "Immutable facts supplied by one author source."
key id classes attributes states declarations provenance) key id classes attributes states declarations provenance interactions)
(cl-defstruct (ebox-source-handle (cl-defstruct (ebox-source-handle
(:constructor ebox-source--make-handle) (:constructor ebox-source--make-handle)
@ -230,6 +231,11 @@ Functions and records are opaque values and retain identity."
copy)) copy))
(t value))) (t value)))
(defun ebox-source-index-interactions (index handle)
"Return detached fixed interaction facts for HANDLE in INDEX."
(when-let* ((record (ebox-source--index-record index handle)))
(tp-property-value-copy (ebox-source-record-interactions record))))
(defun ebox-source-stable-key-p (value) (defun ebox-source-stable-key-p (value)
"Return non-nil when VALUE is one public stable source key." "Return non-nil when VALUE is one public stable source key."
(and value (or (symbolp value) (stringp value) (integerp value)))) (and value (or (symbolp value) (stringp value) (integerp value))))
@ -326,7 +332,7 @@ Functions and records are opaque values and retain identity."
(cl-defun ebox-source-builder-bind (cl-defun ebox-source-builder-bind
(builder &key identity key id class selector-attributes selector-state (builder &key identity key id class selector-attributes selector-state
declarations provenance) declarations provenance interactions)
"Bind normalized source facts in BUILDER and return an opaque handle. "Bind normalized source facts in BUILDER and return an opaque handle.
The returned handle contains identity only; all facts are owned by BUILDER The returned handle contains identity only; all facts are owned by BUILDER
until `ebox-source-builder-finish' seals them into a source index." until `ebox-source-builder-finish' seals them into a source index."
@ -351,7 +357,8 @@ until `ebox-source-builder-finish' seals them into a source index."
:attributes (ebox-source--attributes selector-attributes) :attributes (ebox-source--attributes selector-attributes)
:states (ebox-source--metadata-tokens selector-state) :states (ebox-source--metadata-tokens selector-state)
:declarations (ebox-source--declarations declarations) :declarations (ebox-source--declarations declarations)
:provenance (ebox-source--copy-data provenance)))) :provenance (ebox-source--copy-data provenance)
:interactions (ebox-interaction-normalize interactions))))
(ebox-source--builder-add-record builder handle record))) (ebox-source--builder-add-record builder handle record)))
(defun ebox-source--builder-record (builder handle) (defun ebox-source--builder-record (builder handle)
@ -379,6 +386,7 @@ error because one canonical forest cannot contain the same source twice."
(selector-attributes nil attributes-p) (selector-attributes nil attributes-p)
(selector-state nil states-p) (selector-state nil states-p)
(declarations nil declarations-p) (declarations nil declarations-p)
(interactions nil interactions-p)
(provenance nil provenance-p)) (provenance nil provenance-p))
"Return `(NEW-INDEX . NEW-HANDLE)' after rebinding HANDLE in INDEX. "Return `(NEW-INDEX . NEW-HANDLE)' after rebinding HANDLE in INDEX.
The semantic identity and all omitted immutable facts are retained. A fresh The semantic identity and all omitted immutable facts are retained. A fresh
@ -403,6 +411,8 @@ facts on the canonical node."
:declarations (if declarations-p :declarations (if declarations-p
declarations declarations
(ebox-source-record-declarations record)) (ebox-source-record-declarations record))
:interactions (if interactions-p interactions
(ebox-source-record-interactions record))
:provenance :provenance
(if provenance-p provenance (if provenance-p provenance
(ebox-source-record-provenance record)))) (ebox-source-record-provenance record))))
@ -742,7 +752,9 @@ for nonstructural changes; structural changes supply FINAL-ORDER."
:declarations (ebox-source--copy-data :declarations (ebox-source--copy-data
(ebox-source-record-declarations record)) (ebox-source-record-declarations record))
:provenance (ebox-source--copy-data :provenance (ebox-source--copy-data
(ebox-source-record-provenance record))))) (ebox-source-record-provenance record))
:interactions (tp-property-value-copy
(ebox-source-record-interactions record)))))
(defun ebox-source-index-record (index handle) (defun ebox-source-index-record (index handle)
"Return a detached source record for HANDLE in INDEX, or nil." "Return a detached source record for HANDLE in INDEX, or nil."

View File

@ -51,6 +51,7 @@
(:id region-generation-indexes (:id region-generation-indexes
:storage (:region-id-set :region-node-table :region-box-count-table :storage (:region-id-set :region-node-table :region-box-count-table
:region-box-table :layout-snapshots :region-box-table :layout-snapshots
:fractional-pixel-output-p
:render-owned-text-values ebox--render-owned-text-values) :render-owned-text-values ebox--render-owned-text-values)
:current-contract candidate-state-plus-global-projection :current-contract candidate-state-plus-global-projection
:target-contract immutable-generation-value :target-contract immutable-generation-value
@ -221,6 +222,17 @@
:rollback discard :rollback discard
:rebuild-proof resolve-font-fact-again :rebuild-proof resolve-font-fact-again
:cleanup ebox-font-clear-cache) :cleanup ebox-font-clear-cache)
(:id native-hover-face-cache
:storage (ebox-interaction--hover-face-cache)
:current-contract weakly-shared-native-hover-faces
:target-contract discardable-derived-values
:category disposable-cache
:owner ebox-interaction
:mutation-api weak-cache-fill
:generation-binding hover-owner-and-computed-paint
:rollback discard-unreferenced-candidates
:rebuild-proof reproject-owner-native-hover-face
:cleanup garbage-collect-unreferenced-faces)
(:id derived-caches (:id derived-caches
:storage (ebox--box-content-render-cache ebox--char-width-cache :storage (ebox--box-content-render-cache ebox--char-width-cache
ebox--display-signature-cache ebox--face-height-width-cache ebox--display-signature-cache ebox--face-height-width-cache
@ -239,13 +251,15 @@
ebox--render-root-cache-table-table ebox--render-root-cache-table-table
ebox--render-string-max-pixel-width-cache ebox--render-string-max-pixel-width-cache
ebox--render-string-pixel-width-cache ebox--render-string-pixel-width-cache
ebox--rendered-uniform-width-table ebox--runtime-ancestor-cache ebox--rendered-uniform-width-table ebox--rendered-intrinsic-size-table
ebox--runtime-ancestor-cache
ebox--scroll-window-cached-state ebox--space-pixel-cache ebox--scroll-window-cached-state ebox--space-pixel-cache
ebox--string-pixel-width-cache ebox--string-pixel-width-cache
ebox--string-pixel-width-cache-ring ebox--string-pixel-width-cache-ring
ebox--viewport-dependent-node-ids-cache ebox--viewport-dependent-node-ids-cache
ebox--viewport-dependent-subtree-cache ebox--viewport-dependent-subtree-cache
ebox--viewport-height-dependent-subtree-cache ebox--viewport-height-dependent-subtree-cache
ebox--css-viewport-dependent-subtree-cache
ebox--window-line-prewarmer-table ebox--window-line-prewarmer-table
ebox--window-line-renderer-table ebox--window-line-renderer-table
ebox-cache--buffer-report-table ebox-cache--registry ebox-cache--buffer-report-table ebox-cache--registry
@ -284,6 +298,9 @@ rather than relying on an implicit default.")
'((:symbol ebox--defer-scroll-content-index '((:symbol ebox--defer-scroll-content-index
:reason dynamic-boolean-or-index-mode :reason dynamic-boolean-or-index-mode
:evidence value-is-never-a-container) :evidence value-is-never-a-container)
(:symbol ebox--box-content-empty-width
:reason dynamic-scalar-result
:evidence numeric-inline-extent-returned-with-one-content-materialization)
(:symbol ebox--string-pixel-width-cache-ring-index (:symbol ebox--string-pixel-width-cache-ring-index
:reason numeric-eviction-cursor :reason numeric-eviction-cursor
:evidence value-is-an-integer-ring-position) :evidence value-is-an-integer-ring-position)
@ -296,6 +313,9 @@ rather than relying on an implicit default.")
(:symbol ebox-native-reflow--compile-style-index (:symbol ebox-native-reflow--compile-style-index
:reason dynamically-bound-compile-local-scratch :reason dynamically-bound-compile-local-scratch
:evidence let-bound-per-compile-and-published-only-through-session-root) :evidence let-bound-per-compile-and-published-only-through-session-root)
(:symbol ebox-native-reflow--compile-size-node
:reason dynamically-bound-compile-local-scratch
:evidence owning-node-font-context-for-one-native-size-projection)
(:symbol ebox--render-source-generations (:symbol ebox--render-source-generations
:reason dynamically-bound-render-proof-input :reason dynamically-bound-render-proof-input
:evidence let-bound-to-candidate-and-prior-generations-for-one-plan) :evidence let-bound-to-candidate-and-prior-generations-for-one-plan)

View File

@ -12,6 +12,7 @@
(require 'tp-style) (require 'tp-style)
(require 'ebox-source) (require 'ebox-source)
(require 'ebox-layout-config) (require 'ebox-layout-config)
(require 'ebox-size)
(defvar ebox--longhand) (defvar ebox--longhand)
(declare-function ebox-selector-parse "ebox-selector" (selector)) (declare-function ebox-selector-parse "ebox-selector" (selector))
@ -21,16 +22,8 @@
"ebox-flex" (props)) "ebox-flex" (props))
(declare-function ebox-grid--normalize-config-props (declare-function ebox-grid--normalize-config-props
"ebox-grid" (props)) "ebox-grid" (props))
(declare-function ebox--normalize-horizontal-size-value
"ebox-flex" (value allowed-keywords))
(declare-function ebox--nonnegative-horizontal-size-pixels
"ebox-layout" (value &optional default))
(declare-function ecss-stylesheet-create "ecss" ()) (declare-function ecss-stylesheet-create "ecss" ())
(defvar ebox--preferred-size-keywords)
(defvar ebox--min-size-keywords)
(defvar ebox--max-size-keywords)
(defun ebox-style--create-stylesheet () (defun ebox-style--create-stylesheet ()
"Return one private ECSS stylesheet for Ebox style state." "Return one private ECSS stylesheet for Ebox style state."
(ecss-stylesheet-create)) (ecss-stylesheet-create))
@ -80,129 +73,129 @@
(:name :width :id ebox/width :initial auto (:name :width :id ebox/width :initial auto
:contexts (box row column flex grid) :contexts (box row column flex grid)
:group geometry :dirty-kind geometry :signature layout :group geometry :dirty-kind geometry :signature layout
:validator horizontal-size :size-axis inline :validator horizontal-size
:engine-projection (:to :width :conv preferred-size)) :engine-projection (:to :width :conv preferred-size))
(:name :min-width :id ebox/min-width :initial 0 (:name :min-width :id ebox/min-width :initial auto
:contexts (box row column flex grid) :contexts (box row column flex grid)
:group geometry :dirty-kind geometry :signature layout :group geometry :dirty-kind geometry :signature layout
:validator min-horizontal-size :size-axis inline :validator min-horizontal-size
:engine-projection (:to :min-width :conv min-size)) :engine-projection (:to :min-width :conv min-size))
(:name :max-width :id ebox/max-width :initial none (:name :max-width :id ebox/max-width :initial none
:contexts (box row column flex grid) :contexts (box row column flex grid)
:group geometry :dirty-kind geometry :signature layout :group geometry :dirty-kind geometry :signature layout
:validator max-horizontal-size :size-axis inline :validator max-horizontal-size
:engine-projection (:to :max-width :conv max-size)) :engine-projection (:to :max-width :conv max-size))
(:name :height :id ebox/height :initial auto (:name :height :id ebox/height :initial auto
:contexts (box row column flex grid) :contexts (box row column flex grid)
:group geometry :dirty-kind geometry :signature layout :group geometry :dirty-kind geometry :signature layout
:validator vertical-size) :size-axis block :validator vertical-size)
(:name :min-height :id ebox/min-height :initial 0 (:name :min-height :id ebox/min-height :initial auto
:contexts (box row column flex grid) :contexts (box row column flex grid)
:group geometry :dirty-kind geometry :signature layout :group geometry :dirty-kind geometry :signature layout
:validator min-vertical-size) :size-axis block :validator min-vertical-size)
(:name :max-height :id ebox/max-height :initial none (:name :max-height :id ebox/max-height :initial none
:contexts (box row column flex grid) :contexts (box row column flex grid)
:group geometry :dirty-kind geometry :signature layout :group geometry :dirty-kind geometry :signature layout
:validator max-vertical-size) :size-axis block :validator max-vertical-size)
(:name :padding-top :id ebox/padding-top :initial 0 (:name :padding-top :id ebox/padding-top :initial (lh 0)
:contexts (box row column flex grid) :contexts (box row column flex grid)
:group geometry :dirty-kind geometry :signature layout :group geometry :dirty-kind geometry :signature layout
:validator nonnegative-size :size-axis block :validator nonnegative-size
:engine-projection (:to :padding-top-height :conv line)) :engine-projection (:to :padding-top-height :conv line))
(:name :padding-right :id ebox/padding-right :initial 0 (:name :padding-right :id ebox/padding-right :initial (px 0)
:contexts (box row column flex grid) :contexts (box row column flex grid)
:group geometry :dirty-kind geometry :signature layout :group geometry :dirty-kind geometry :signature layout
:validator nonnegative-size :size-axis inline :validator nonnegative-size
:engine-projection (:to :padding-right-pixel :conv pixel)) :engine-projection (:to :padding-right-pixel :conv pixel))
(:name :padding-bottom :id ebox/padding-bottom :initial 0 (:name :padding-bottom :id ebox/padding-bottom :initial (lh 0)
:contexts (box row column flex grid) :contexts (box row column flex grid)
:group geometry :dirty-kind geometry :signature layout :group geometry :dirty-kind geometry :signature layout
:validator nonnegative-size :size-axis block :validator nonnegative-size
:engine-projection (:to :padding-bottom-height :conv line)) :engine-projection (:to :padding-bottom-height :conv line))
(:name :padding-left :id ebox/padding-left :initial 0 (:name :padding-left :id ebox/padding-left :initial (px 0)
:contexts (box row column flex grid) :contexts (box row column flex grid)
:group geometry :dirty-kind geometry :signature layout :group geometry :dirty-kind geometry :signature layout
:validator nonnegative-size :size-axis inline :validator nonnegative-size
:engine-projection (:to :padding-left-pixel :conv pixel)) :engine-projection (:to :padding-left-pixel :conv pixel))
(:name :padding-block-start :id ebox/padding-block-start :initial 0 (:name :padding-block-start :id ebox/padding-block-start :initial (lh 0)
:contexts (box row column flex grid) :group geometry :contexts (box row column flex grid) :group geometry
:dirty-kind geometry :signature layout :dirty-kind geometry :signature layout
:validator nonnegative-size :size-axis block :validator nonnegative-size
:engine-projection (:to :padding-top-height :conv line)) :engine-projection (:to :padding-top-height :conv line))
(:name :padding-inline-end :id ebox/padding-inline-end :initial 0 (:name :padding-inline-end :id ebox/padding-inline-end :initial (px 0)
:contexts (box row column flex grid) :group geometry :contexts (box row column flex grid) :group geometry
:dirty-kind geometry :signature layout :dirty-kind geometry :signature layout
:validator nonnegative-size :size-axis inline :validator nonnegative-size
:engine-projection (:to :padding-right-pixel :conv pixel)) :engine-projection (:to :padding-right-pixel :conv pixel))
(:name :padding-block-end :id ebox/padding-block-end :initial 0 (:name :padding-block-end :id ebox/padding-block-end :initial (lh 0)
:contexts (box row column flex grid) :group geometry :contexts (box row column flex grid) :group geometry
:dirty-kind geometry :signature layout :dirty-kind geometry :signature layout
:validator nonnegative-size :size-axis block :validator nonnegative-size
:engine-projection (:to :padding-bottom-height :conv line)) :engine-projection (:to :padding-bottom-height :conv line))
(:name :padding-inline-start :id ebox/padding-inline-start :initial 0 (:name :padding-inline-start :id ebox/padding-inline-start :initial (px 0)
:contexts (box row column flex grid) :group geometry :contexts (box row column flex grid) :group geometry
:dirty-kind geometry :signature layout :dirty-kind geometry :signature layout
:validator nonnegative-size :size-axis inline :validator nonnegative-size
:engine-projection (:to :padding-left-pixel :conv pixel)) :engine-projection (:to :padding-left-pixel :conv pixel))
(:name :margin-top :id ebox/margin-top :initial 0 (:name :margin-top :id ebox/margin-top :initial (lh 0)
:contexts (box row column flex grid) :contexts (box row column flex grid)
:group geometry :dirty-kind geometry :signature layout :group geometry :dirty-kind geometry :signature layout
:validator nonnegative-size :size-axis block :validator nonnegative-size
:engine-projection (:to :margin-top-height :conv line)) :engine-projection (:to :margin-top-height :conv line))
(:name :margin-right :id ebox/margin-right :initial 0 (:name :margin-right :id ebox/margin-right :initial (px 0)
:contexts (box row column flex grid) :contexts (box row column flex grid)
:group geometry :dirty-kind geometry :signature layout :group geometry :dirty-kind geometry :signature layout
:validator nonnegative-size :size-axis inline :validator nonnegative-size
:engine-projection (:to :margin-right-pixel :conv pixel)) :engine-projection (:to :margin-right-pixel :conv pixel))
(:name :margin-bottom :id ebox/margin-bottom :initial 0 (:name :margin-bottom :id ebox/margin-bottom :initial (lh 0)
:contexts (box row column flex grid) :contexts (box row column flex grid)
:group geometry :dirty-kind geometry :signature layout :group geometry :dirty-kind geometry :signature layout
:validator nonnegative-size :size-axis block :validator nonnegative-size
:engine-projection (:to :margin-bottom-height :conv line)) :engine-projection (:to :margin-bottom-height :conv line))
(:name :margin-left :id ebox/margin-left :initial 0 (:name :margin-left :id ebox/margin-left :initial (px 0)
:contexts (box row column flex grid) :contexts (box row column flex grid)
:group geometry :dirty-kind geometry :signature layout :group geometry :dirty-kind geometry :signature layout
:validator nonnegative-size :size-axis inline :validator nonnegative-size
:engine-projection (:to :margin-left-pixel :conv pixel)) :engine-projection (:to :margin-left-pixel :conv pixel))
(:name :margin-block-start :id ebox/margin-block-start :initial 0 (:name :margin-block-start :id ebox/margin-block-start :initial (lh 0)
:contexts (box row column flex grid) :group geometry :contexts (box row column flex grid) :group geometry
:dirty-kind geometry :signature layout :dirty-kind geometry :signature layout
:validator nonnegative-size :size-axis block :validator nonnegative-size
:engine-projection (:to :margin-top-height :conv line)) :engine-projection (:to :margin-top-height :conv line))
(:name :margin-inline-end :id ebox/margin-inline-end :initial 0 (:name :margin-inline-end :id ebox/margin-inline-end :initial (px 0)
:contexts (box row column flex grid) :group geometry :contexts (box row column flex grid) :group geometry
:dirty-kind geometry :signature layout :dirty-kind geometry :signature layout
:validator nonnegative-size :size-axis inline :validator nonnegative-size
:engine-projection (:to :margin-right-pixel :conv pixel)) :engine-projection (:to :margin-right-pixel :conv pixel))
(:name :margin-block-end :id ebox/margin-block-end :initial 0 (:name :margin-block-end :id ebox/margin-block-end :initial (lh 0)
:contexts (box row column flex grid) :group geometry :contexts (box row column flex grid) :group geometry
:dirty-kind geometry :signature layout :dirty-kind geometry :signature layout
:validator nonnegative-size :size-axis block :validator nonnegative-size
:engine-projection (:to :margin-bottom-height :conv line)) :engine-projection (:to :margin-bottom-height :conv line))
(:name :margin-inline-start :id ebox/margin-inline-start :initial 0 (:name :margin-inline-start :id ebox/margin-inline-start :initial (px 0)
:contexts (box row column flex grid) :group geometry :contexts (box row column flex grid) :group geometry
:dirty-kind geometry :signature layout :dirty-kind geometry :signature layout
:validator nonnegative-size :size-axis inline :validator nonnegative-size
:engine-projection (:to :margin-left-pixel :conv pixel)) :engine-projection (:to :margin-left-pixel :conv pixel))
(:name :border-top-width :id ebox/border-top-width (:name :border-top-width :id ebox/border-top-width
:initial 0 :contexts (box row column flex grid) :initial (px 0) :contexts (box row column flex grid)
:group geometry :dirty-kind geometry :signature layout :group geometry :dirty-kind geometry :signature layout
:validator border-width :size-axis stroke :validator border-width
:engine-projection (:to :border-top-pixel :conv border-pixel)) :engine-projection (:to :border-top-pixel :conv border-pixel))
(:name :border-right-width :id ebox/border-right-width (:name :border-right-width :id ebox/border-right-width
:initial 0 :contexts (box row column flex grid) :initial (px 0) :contexts (box row column flex grid)
:group geometry :dirty-kind geometry :signature layout :group geometry :dirty-kind geometry :signature layout
:validator border-width :size-axis stroke :validator border-width
:engine-projection (:to :border-right-pixel :conv border-pixel)) :engine-projection (:to :border-right-pixel :conv border-pixel))
(:name :border-bottom-width :id ebox/border-bottom-width (:name :border-bottom-width :id ebox/border-bottom-width
:initial 0 :contexts (box row column flex grid) :initial (px 0) :contexts (box row column flex grid)
:group geometry :dirty-kind geometry :signature layout :group geometry :dirty-kind geometry :signature layout
:validator border-width :size-axis stroke :validator border-width
:engine-projection (:to :border-bottom-pixel :conv border-pixel)) :engine-projection (:to :border-bottom-pixel :conv border-pixel))
(:name :border-left-width :id ebox/border-left-width (:name :border-left-width :id ebox/border-left-width
:initial 0 :contexts (box row column flex grid) :initial (px 0) :contexts (box row column flex grid)
:group geometry :dirty-kind geometry :signature layout :group geometry :dirty-kind geometry :signature layout
:validator border-width :size-axis stroke :validator border-width
:engine-projection (:to :border-left-pixel :conv border-pixel)) :engine-projection (:to :border-left-pixel :conv border-pixel))
(:name :border-top-style :id ebox/border-top-style :initial none (:name :border-top-style :id ebox/border-top-style :initial none
:contexts (box row column flex grid) :group structure :contexts (box row column flex grid) :group structure
@ -257,9 +250,9 @@
:validator outer :impacts (structure geometry) :validator outer :impacts (structure geometry)
:projections (structure layout) :projections (structure layout)
:engine-projection (:to nil)) :engine-projection (:to nil))
(:name :item-gap :id ebox/item-gap :initial 0 :contexts (row column) (:name :item-gap :id ebox/item-gap :initial (px 0) :contexts (row column)
:group geometry :dirty-kind geometry :signature layout :group geometry :dirty-kind geometry :signature layout
:validator nonnegative-integer) :size-axis main :validator nonnegative-size)
(:name :cross-align :id ebox/cross-align :initial stretch (:name :cross-align :id ebox/cross-align :initial stretch
:contexts (row column) :group geometry :dirty-kind geometry :contexts (row column) :group geometry :dirty-kind geometry
:signature layout :validator cross-align) :signature layout :validator cross-align)
@ -281,12 +274,12 @@
(:name :justify-items :id ebox/justify-items :initial stretch (:name :justify-items :id ebox/justify-items :initial stretch
:contexts (grid) :group geometry :dirty-kind geometry :signature layout :contexts (grid) :group geometry :dirty-kind geometry :signature layout
:validator justify-items) :validator justify-items)
(:name :row-gap :id ebox/row-gap :initial 0 (:name :row-gap :id ebox/row-gap :initial (lh 0)
:contexts (flex grid) :group geometry :dirty-kind geometry :signature layout :contexts (flex grid) :group geometry :dirty-kind geometry :signature layout
:validator nonnegative-size) :size-axis block :validator nonnegative-size)
(:name :column-gap :id ebox/column-gap (:name :column-gap :id ebox/column-gap
:initial 0 :contexts (flex grid) :group geometry :dirty-kind geometry :initial (px 0) :contexts (flex grid) :group geometry :dirty-kind geometry
:signature layout :validator nonnegative-size) :signature layout :size-axis inline :validator nonnegative-size)
(:name :order :id ebox/order :initial 0 :contexts (item) (:name :order :id ebox/order :initial 0 :contexts (item)
:group geometry :dirty-kind geometry :signature layout :validator order) :group geometry :dirty-kind geometry :signature layout :validator order)
(:name :flex-grow :id ebox/flex-grow :initial 0 :contexts (item) (:name :flex-grow :id ebox/flex-grow :initial 0 :contexts (item)
@ -297,7 +290,7 @@
:validator flex-factor) :validator flex-factor)
(:name :flex-basis :id ebox/flex-basis :initial auto :contexts (item) (:name :flex-basis :id ebox/flex-basis :initial auto :contexts (item)
:group geometry :dirty-kind geometry :signature layout :group geometry :dirty-kind geometry :signature layout
:validator horizontal-size) :size-axis length :validator preferred-size)
(:name :align-self :id ebox/align-self :initial auto :contexts (item) (:name :align-self :id ebox/align-self :initial auto :contexts (item)
:group geometry :dirty-kind geometry :signature layout :group geometry :dirty-kind geometry :signature layout
:validator align-self) :validator align-self)
@ -306,16 +299,16 @@
:validator justify-self) :validator justify-self)
(:name :grid-template-columns :id ebox/grid-template-columns :initial nil (:name :grid-template-columns :id ebox/grid-template-columns :initial nil
:contexts (grid) :group geometry :dirty-kind geometry :signature layout :contexts (grid) :group geometry :dirty-kind geometry :signature layout
:validator grid-track-list) :size-grammar tracks :size-axis inline :validator grid-track-list)
(:name :grid-template-rows :id ebox/grid-template-rows :initial nil (:name :grid-template-rows :id ebox/grid-template-rows :initial nil
:contexts (grid) :group geometry :dirty-kind geometry :signature layout :contexts (grid) :group geometry :dirty-kind geometry :signature layout
:validator grid-track-list) :size-grammar tracks :size-axis block :validator grid-track-list)
(:name :grid-auto-columns :id ebox/grid-auto-columns :initial auto (:name :grid-auto-columns :id ebox/grid-auto-columns :initial auto
:contexts (grid) :group geometry :dirty-kind geometry :signature layout :contexts (grid) :group geometry :dirty-kind geometry :signature layout
:validator grid-track) :size-grammar tracks :size-axis inline :validator grid-track)
(:name :grid-auto-rows :id ebox/grid-auto-rows :initial auto (:name :grid-auto-rows :id ebox/grid-auto-rows :initial auto
:contexts (grid) :group geometry :dirty-kind geometry :signature layout :contexts (grid) :group geometry :dirty-kind geometry :signature layout
:validator grid-track) :size-grammar tracks :size-axis block :validator grid-track)
(:name :grid-auto-flow :id ebox/grid-auto-flow :initial row (:name :grid-auto-flow :id ebox/grid-auto-flow :initial row
:contexts (grid) :group structure :dirty-kind structure :signature layout :contexts (grid) :group structure :dirty-kind structure :signature layout
:validator grid-auto-flow) :validator grid-auto-flow)
@ -575,10 +568,18 @@
"Return PLIST with KEY set to VALUE." "Return PLIST with KEY set to VALUE."
(plist-put plist key value)) (plist-put plist key value))
(defun ebox-style--atomic-size-form-p (value)
"Return non-nil when VALUE denotes one size expression before validation.
Keep malformed unit and function forms intact so their target property reports
the invalid value and its allowed units instead of a shorthand arity error."
(and (consp value)
(memq (car value) '(px ch vw lh vh % calc min max clamp))))
(defun ebox-style--split-trbl (value) (defun ebox-style--split-trbl (value)
"Split VALUE using CSS TRBL rules into top, right, bottom, left." "Split VALUE using CSS TRBL rules into top, right, bottom, left."
(cond (cond
((null value) (list nil nil nil nil)) ((null value) (list nil nil nil nil))
((ebox-style--atomic-size-form-p value) (make-list 4 value))
((atom value) (list value value value value)) ((atom value) (list value value value value))
((and (listp value) (keywordp (car value))) ((and (listp value) (keywordp (car value)))
(list (plist-get value :top) (plist-get value :right) (list (plist-get value :top) (plist-get value :right)
@ -588,23 +589,28 @@
(1 (list (nth 0 value) (nth 0 value) (nth 0 value) (nth 0 value))) (1 (list (nth 0 value) (nth 0 value) (nth 0 value) (nth 0 value)))
(2 (list (nth 0 value) (nth 1 value) (nth 0 value) (nth 1 value))) (2 (list (nth 0 value) (nth 1 value) (nth 0 value) (nth 1 value)))
(3 (list (nth 0 value) (nth 1 value) (nth 2 value) (nth 1 value))) (3 (list (nth 0 value) (nth 1 value) (nth 2 value) (nth 1 value)))
(_ (list (nth 0 value) (nth 1 value) (nth 2 value) (nth 3 value))))) (4 (list (nth 0 value) (nth 1 value) (nth 2 value) (nth 3 value)))
(_ (error "Ebox four-side shorthand requires one to four values: %S"
value))))
(t (list value value value value)))) (t (list value value value value))))
(defun ebox-style--split-pair (value) (defun ebox-style--split-pair (value)
"Split VALUE into a start/end pair." "Split VALUE into a start/end pair."
(cond (cond
((null value) (list nil nil)) ((null value) (list nil nil))
((ebox-style--atomic-size-form-p value) (list value value))
((atom value) (list value value)) ((atom value) (list value value))
((and (listp value) (= 1 (length value))) ((listp value)
(list (nth 0 value) (nth 0 value))) (pcase (length value)
((listp value) (list (nth 0 value) (nth 1 value))) (1 (list (nth 0 value) (nth 0 value)))
(2 (list (nth 0 value) (nth 1 value)))
(_ (error "Ebox pair shorthand requires one or two values: %S" value))))
(t (list value value)))) (t (list value value))))
(defun ebox-style--wsc-classify (item) (defun ebox-style--wsc-classify (item)
"Classify border shorthand ITEM as width, style, or color." "Classify border shorthand ITEM as width, style, or color."
(cond (cond
((numberp item) (cons 'width item)) ((ebox-style--atomic-size-form-p item) (cons 'width item))
((memq item ebox-style--border-styles) (cons 'style item)) ((memq item ebox-style--border-styles) (cons 'style item))
((or (stringp item) (tp-paint-slot-p item)) (cons 'color item)) ((or (stringp item) (tp-paint-slot-p item)) (cons 'color item))
(t (error "Invalid Ebox border component: %S" item)))) (t (error "Invalid Ebox border component: %S" item))))
@ -613,12 +619,13 @@
"Split VALUE into border width, style, and color." "Split VALUE into border width, style, and color."
(cond (cond
((null value) (list nil nil nil)) ((null value) (list nil nil nil))
((ebox-style--atomic-size-form-p value) (list value 'solid nil))
((atom value) ((atom value)
(let ((classified (ebox-style--wsc-classify value))) (let ((classified (ebox-style--wsc-classify value)))
(pcase (car classified) (pcase (car classified)
('width (list (cdr classified) nil nil)) ('width (list (cdr classified) nil nil))
('style (list nil (cdr classified) nil)) ('style (list '(px 1) (cdr classified) nil))
('color (list 1 'solid (cdr classified)))))) ('color (list '(px 1) 'solid (cdr classified))))))
((proper-list-p value) ((proper-list-p value)
(let (width style color width-p style-p color-p) (let (width style color width-p style-p color-p)
(dolist (item value) (dolist (item value)
@ -633,7 +640,7 @@
('color ('color
(when color-p (error "Duplicate Ebox border color in %S" value)) (when color-p (error "Duplicate Ebox border color in %S" value))
(setq color-p t color (cdr classified)))))) (setq color-p t color (cdr classified))))))
(list (or width 1) (or style 'solid) color))) (list (or width '(px 1)) (or style 'solid) color)))
(t (error "Ebox border must be a color or proper component list: %S" (t (error "Ebox border must be a color or proper component list: %S"
value)))) value))))
@ -730,7 +737,8 @@
"Return canonical boolean border declarations for SIDE and VALUE." "Return canonical boolean border declarations for SIDE and VALUE."
(unless (memq value '(nil t)) (unless (memq value '(nil t))
(error "Ebox border-%s-p must be boolean: %S" side value)) (error "Ebox border-%s-p must be boolean: %S" side value))
(list (intern (format "ebox/border-%s-width" side)) (if value 1 0) (list (intern (format "ebox/border-%s-width" side))
(list 'px (if value 1 0))
(intern (format "ebox/border-%s-style" side)) (intern (format "ebox/border-%s-style" side))
(if value 'solid 'none))) (if value 'solid 'none)))
@ -797,7 +805,7 @@
(pcase value (pcase value
((and (pred numberp) (guard (>= value 0))) ((and (pred numberp) (guard (>= value 0)))
(list 'ebox/flex-grow value 'ebox/flex-shrink 1 (list 'ebox/flex-grow value 'ebox/flex-shrink 1
'ebox/flex-basis 0)) 'ebox/flex-basis '(% 0)))
('none '(ebox/flex-grow 0 ebox/flex-shrink 0 ebox/flex-basis auto)) ('none '(ebox/flex-grow 0 ebox/flex-shrink 0 ebox/flex-basis auto))
('auto '(ebox/flex-grow 1 ebox/flex-shrink 1 ebox/flex-basis auto)) ('auto '(ebox/flex-grow 1 ebox/flex-shrink 1 ebox/flex-basis auto))
('initial '(ebox/flex-grow 0 ebox/flex-shrink 1 ebox/flex-basis auto)) ('initial '(ebox/flex-grow 0 ebox/flex-shrink 1 ebox/flex-basis auto))
@ -821,55 +829,50 @@
(numberp (car value)) (numberp (car value))
(< (car value) 0)))) (< (car value) 0))))
(defun ebox-style--nonnegative-real-p (value)
"Return non-nil when VALUE is a nonnegative real number."
(and (numberp value) (>= value 0)))
(defun ebox-style--pixel-size-p (value)
"Return non-nil when VALUE is a one-element nonnegative pixel size."
(and (proper-list-p value) (= (length value) 1)
(ebox-style--nonnegative-real-p (car value))))
(defun ebox-style--nonnegative-size-p (value) (defun ebox-style--nonnegative-size-p (value)
"Return non-nil when VALUE is one canonical edge size." "Return non-nil when VALUE is a canonical nonnegative length-percentage."
(or (ebox-style--nonnegative-real-p value) (ebox-size-value-p value nil t))
(ebox-style--pixel-size-p value)))
(defconst ebox-style--preferred-size-keywords
'(auto min-content max-content fit-content stretch)
"CSS preferred and minimum size keywords in the supported subset.")
(defconst ebox-style--maximum-size-keywords
'(none min-content max-content fit-content stretch)
"CSS maximum size keywords in the supported subset.")
(defun ebox-style--horizontal-size-p (value) (defun ebox-style--horizontal-size-p (value)
"Return non-nil when VALUE is a canonical horizontal size." "Return non-nil when VALUE is a canonical horizontal size."
(or (ebox-style--nonnegative-real-p value) (or (memq value ebox-style--preferred-size-keywords)
(ebox-style--pixel-size-p value) (ebox-size-value-for-axis-p value 'inline)))
(memq value '(viewport auto min-content max-content
fit-content stretch contain)) (defun ebox-style--preferred-size-p (value)
(equal value '(viewport)) "Return non-nil when VALUE is a size whose parent axis is not known yet."
(and (proper-list-p value) (= (length value) 2) (or (memq value ebox-style--preferred-size-keywords)
(eq (car value) 'fit-content) (ebox-style--nonnegative-size-p value)))
(ebox-style--horizontal-size-p (cadr value)))))
(defun ebox-style--min-horizontal-size-p (value) (defun ebox-style--min-horizontal-size-p (value)
"Return non-nil when VALUE is a canonical minimum horizontal size." "Return non-nil when VALUE is a canonical minimum horizontal size."
(and (not (eq value 'none)) (ebox-style--horizontal-size-p value))) (ebox-style--horizontal-size-p value))
(defun ebox-style--max-horizontal-size-p (value) (defun ebox-style--max-horizontal-size-p (value)
"Return non-nil when VALUE is a canonical maximum horizontal size." "Return non-nil when VALUE is a canonical maximum horizontal size."
(or (eq value 'none) (ebox-style--horizontal-size-p value))) (or (memq value ebox-style--maximum-size-keywords)
(ebox-size-value-for-axis-p value 'inline)))
(defun ebox-style--vertical-size-p (value) (defun ebox-style--vertical-size-p (value)
"Return non-nil when VALUE is a canonical vertical size." "Return non-nil when VALUE is a canonical vertical size."
(or (ebox-style--nonnegative-real-p value) (or (memq value ebox-style--preferred-size-keywords)
(memq value '(viewport-height auto)) (ebox-size-value-for-axis-p value 'block)))
(equal value '(viewport-height))))
(defun ebox-style--min-vertical-size-p (value) (defun ebox-style--min-vertical-size-p (value)
"Return non-nil when VALUE is a canonical minimum vertical size." "Return non-nil when VALUE is a canonical minimum vertical size."
(ebox-style--nonnegative-real-p value)) (ebox-style--vertical-size-p value))
(defun ebox-style--max-vertical-size-p (value) (defun ebox-style--max-vertical-size-p (value)
"Return non-nil when VALUE is a canonical maximum vertical size." "Return non-nil when VALUE is a canonical maximum vertical size."
(or (ebox-style--nonnegative-real-p value) (or (memq value ebox-style--maximum-size-keywords)
(eq value 'none) (ebox-size-value-for-axis-p value 'block)))
(eq value 'viewport-height)
(equal value '(viewport-height))))
(defun ebox-style--nonnegative-number-p (value) (defun ebox-style--nonnegative-number-p (value)
"Return non-nil when VALUE is nil or a non-negative number." "Return non-nil when VALUE is nil or a non-negative number."
@ -927,8 +930,8 @@
(or (null value) (memq value '(normal italic oblique)))) (or (null value) (memq value '(normal italic oblique))))
(defun ebox-style--border-width-p (value) (defun ebox-style--border-width-p (value)
"Return non-nil when VALUE is an exact nonnegative pixel width." "Return non-nil when VALUE is a nonnegative length without percentages."
(and (integerp value) (>= value 0))) (ebox-size-value-p value nil nil))
(defun ebox-style--border-style-p (value) (defun ebox-style--border-style-p (value)
"Return non-nil when VALUE is a supported canonical border style." "Return non-nil when VALUE is a supported canonical border style."
@ -993,10 +996,8 @@
(memq value '(row column))) (memq value '(row column)))
(defun ebox-style--grid-fixed-track-p (value) (defun ebox-style--grid-fixed-track-p (value)
"Return non-nil when VALUE is one nonnegative fixed Grid track." "Return non-nil when VALUE is a nonnegative Grid track size expression."
(or (ebox-style--nonnegative-real-p value) (ebox-style--nonnegative-size-p value))
(and (proper-list-p value) (= (length value) 1)
(ebox-style--nonnegative-real-p (car value)))))
(defun ebox-style--grid-fr-track-p (value) (defun ebox-style--grid-fr-track-p (value)
"Return non-nil when VALUE is one positive fractional Grid track." "Return non-nil when VALUE is one positive fractional Grid track."
@ -1072,6 +1073,7 @@ generated wrappers that already contain exact line breaks."
(defun ebox-style--validator (name) (defun ebox-style--validator (name)
"Return schema validator named NAME." "Return schema validator named NAME."
(pcase name (pcase name
('preferred-size #'ebox-style--preferred-size-p)
('horizontal-size #'ebox-style--horizontal-size-p) ('horizontal-size #'ebox-style--horizontal-size-p)
('min-horizontal-size #'ebox-style--min-horizontal-size-p) ('min-horizontal-size #'ebox-style--min-horizontal-size-p)
('max-horizontal-size #'ebox-style--max-horizontal-size-p) ('max-horizontal-size #'ebox-style--max-horizontal-size-p)
@ -1136,14 +1138,49 @@ generated wrappers that already contain exact line breaks."
(flex-flow . ebox-style--flex-flow-shorthand) (flex-flow . ebox-style--flex-flow-shorthand)
(flex . ebox-style--flex-shorthand)))) (flex . ebox-style--flex-shorthand))))
(defun ebox-style--size-axis (property &optional tag)
"Return PROPERTY's size domain for author TAG when known."
(let ((axis (plist-get property :size-axis)))
(if (eq axis 'main)
(pcase tag ('row 'inline) ('column 'block) (_ 'length))
axis)))
(defun ebox-style--size-value-valid-p (property value &optional tag)
"Return non-nil when PROPERTY's VALUE respects TAG's size domain.
Grid track wrappers are traversed after their structural validator succeeds."
(let ((axis (ebox-style--size-axis property tag)))
(or (null axis)
(if (eq (plist-get property :size-grammar) 'tracks)
(cl-labels
((valid-p (entry)
(cond
((atom entry) t)
((eq (car entry) 'fr) t)
((memq (car entry) '(minmax repeat))
(cl-every #'valid-p (cdr entry)))
((ebox-size-value-p entry)
(ebox-size-value-for-axis-p entry axis))
(t (cl-every #'valid-p entry)))))
(valid-p value))
(or (symbolp value)
(ebox-size-value-for-axis-p value axis))))))
(defun ebox-style--schema-validator (property)
"Return PROPERTY's grammar and unit-domain validator."
(let ((validator (ebox-style--validator (plist-get property :validator))))
(if (plist-get property :size-axis)
(lambda (value)
(and (funcall validator value)
(ebox-style--size-value-valid-p property value)))
validator)))
(defun ebox-style--schema-options (property) (defun ebox-style--schema-options (property)
"Return ECSS registration options for PROPERTY metadata." "Return ECSS registration options for PROPERTY metadata."
(let ((options (list :initial (plist-get property :initial) (let ((options (list :initial (plist-get property :initial)
:inherits (plist-get property :inherits) :inherits (plist-get property :inherits)
:normalizer (ebox-style--normalizer :normalizer (ebox-style--normalizer
(plist-get property :normalizer)) (plist-get property :normalizer))
:validator (ebox-style--validator :validator (ebox-style--schema-validator property)
(plist-get property :validator))
:equality #'equal))) :equality #'equal)))
(when-let* ((shorthand (ebox-style--shorthand (when-let* ((shorthand (ebox-style--shorthand
(plist-get property :shorthand)))) (plist-get property :shorthand))))
@ -1392,6 +1429,7 @@ not affect declaration compilation and are intentionally excluded."
(defun ebox-style--static-invalid-declaration-p (validator value) (defun ebox-style--static-invalid-declaration-p (validator value)
"Return non-nil when VALIDATOR rejects static VALUE." "Return non-nil when VALIDATOR rejects static VALUE."
(pcase validator (pcase validator
('preferred-size (not (ebox-style--preferred-size-p value)))
('nonnegative-number ('nonnegative-number
(and (numberp value) (< value 0))) (and (numberp value) (< value 0)))
('horizontal-size (not (ebox-style--horizontal-size-p value))) ('horizontal-size (not (ebox-style--horizontal-size-p value)))
@ -1437,14 +1475,23 @@ not affect declaration compilation and are intentionally excluded."
('wrap-mode ('wrap-mode
(not (ebox-style--wrap-mode-p value))))) (not (ebox-style--wrap-mode-p value)))))
(defun ebox-style--validate-declaration-values (declarations) (defun ebox-style--validate-declaration-values (declarations &optional tag)
"Return DECLARATIONS after static Ebox-owned value validation." "Return DECLARATIONS after static value and author TAG axis validation."
(cl-loop for (property value) on declarations by #'cddr (cl-loop for (property value) on declarations by #'cddr
for schema = (ebox-style--property property) for schema = (ebox-style--property property)
for validator = (and schema (plist-get schema :validator)) for validator = (and schema (plist-get schema :validator))
when (ebox-style--static-invalid-declaration-p validator value) for axis = (ebox-style--size-axis schema tag)
do (error "ebox: invalid value for %S: %S" when (or (ebox-style--static-invalid-declaration-p validator value)
(plist-get schema :name) value)) (not (ebox-style--size-value-valid-p schema value tag)))
do (if axis
(signal 'ebox-size-error
(list
(format "Invalid value for %S; allowed units: %S"
(plist-get schema :name)
(cdr (assq axis ebox-size-axis-units)))
value))
(error "ebox: invalid value for %S: %S"
(plist-get schema :name) value)))
declarations) declarations)
(defconst ebox-style--edge-projection-groups (defconst ebox-style--edge-projection-groups
@ -1539,7 +1586,7 @@ When STRICT is non-nil, reject properties outside the Ebox style domain."
unless (ebox-style--form-property-allowed-p tag property) unless (ebox-style--form-property-allowed-p tag property)
do (error "Ebox %S does not accept %S" do (error "Ebox %S does not accept %S"
tag (plist-get property :name)))) tag (plist-get property :name))))
(ebox-style--validate-declaration-values declarations) (ebox-style--validate-declaration-values declarations tag)
declarations) declarations)
(defun ebox-style--validate-author-values (plist) (defun ebox-style--validate-author-values (plist)
@ -2327,31 +2374,12 @@ owned by NEW; only fields owned by the unchanged computed style are shared."
entries)))) entries))))
(defun ebox-style--convert (value converter) (defun ebox-style--convert (value converter)
"Convert VALUE using Ebox engine CONVERTER." "Project VALUE with CONVERTER without resolving contextual size units.
Layout owns used pixel/line values. Computed style and its cached engine
projection retain expressions so viewport, container, and font changes can
resolve them again. Numeric zero remains valid for private generated edges."
(pcase converter (pcase converter
('pixel (or (ebox--nonnegative-horizontal-size-pixels value nil) 0)) ((or 'pixel 'size-pixel 'border-pixel 'line) (or value 0))
('size-pixel (or (ebox--nonnegative-horizontal-size-pixels value nil) 0))
('border-pixel
(unless (ebox-style--border-width-p value)
(error "ebox: border width must be a nonnegative integer pixel value: %S"
value))
value)
('preferred-size
(ebox--normalize-horizontal-size-value
value ebox--preferred-size-keywords))
('min-size
(ebox--normalize-horizontal-size-value
value ebox--min-size-keywords))
('max-size
(ebox--normalize-horizontal-size-value
value ebox--max-size-keywords))
('line
(if (numberp value)
(progn
(when (< value 0)
(error "ebox: line values cannot be negative: %S" value))
value)
0))
('color (ebox-style--parse-color value)) ('color (ebox-style--parse-color value))
(_ value))) (_ value)))

View File

@ -85,6 +85,17 @@
(defvar ebox-surface--font-window nil (defvar ebox-surface--font-window nil
"Canonical target window frozen for one Surface projection.") "Canonical target window frozen for one Surface projection.")
(defvar ebox-surface--integral-local-output-required-p nil
"Non-nil while a partial render must prove an integral pixel origin.")
(defun ebox-surface--check-local-pixel-output (output)
"Decline fractional local OUTPUT before footprint or ownership proofs.
The enclosing producer catches the decline and renders the complete root."
(when (and ebox-surface--integral-local-output-required-p
(stringp output) (ebox--fractional-pixel-spaces-p output))
(throw 'ebox/fractional-local-output nil))
output)
(defun ebox-surface--mixed-range-splice-p (state) (defun ebox-surface--mixed-range-splice-p (state)
"Return non-nil when STATE's mixed proof contains one Range splice." "Return non-nil when STATE's mixed proof contains one Range splice."
(let ((proof (plist-get (plist-get state :mixed-owner-proof) :geometry-proof))) (let ((proof (plist-get (plist-get state :mixed-owner-proof) :geometry-proof)))
@ -1512,6 +1523,17 @@ PREVIOUS-STATE's node-object table."
(range-splice-p (range-splice-p
(and (eq projection-kind 'mixed-owner-reflow) (and (eq projection-kind 'mixed-owner-reflow)
(ebox-surface--mixed-range-splice-p state-overrides))) (ebox-surface--mixed-range-splice-p state-overrides)))
(retained-span-style-p
;; Variable content can change its slot's text extent without
;; changing the already prepared cascade. Rebuilding subjects here
;; discarded its validated Host index and triggered a full reindex.
(and (eq projection-kind 'span-patch)
(plist-get state-overrides :computed-participation-validated-p)
(let ((proofs (plist-get state-overrides :owner-scoped-proofs)))
(and proofs
(cl-every (lambda (proof)
(plist-get proof :static-cascade-p))
proofs)))))
(objects-by-node (objects-by-node
(if (memq projection-kind (if (memq projection-kind
'(span-patch owner-scoped scroll-patch '(span-patch owner-scoped scroll-patch
@ -1531,10 +1553,11 @@ PREVIOUS-STATE's node-object table."
(make-hash-table :test 'equal))) (make-hash-table :test 'equal)))
(stylesheet-signature (stylesheet-signature
(and style-required-p (and style-required-p
(if (and (memq projection-kind (if (or retained-span-style-p
'(owner-scoped scroll-patch (and (memq projection-kind
formatting-context-reflow mixed-owner-reflow)) '(owner-scoped scroll-patch
(not range-splice-p)) formatting-context-reflow mixed-owner-reflow))
(not range-splice-p)))
nil nil
(ebox-surface--stylesheet-signature)))) (ebox-surface--stylesheet-signature))))
(stylesheet-subject-local-p (stylesheet-subject-local-p
@ -1548,11 +1571,12 @@ PREVIOUS-STATE's node-object table."
(not (and (eq projection-kind 'native-frame) (not (and (eq projection-kind 'native-frame)
(plist-get state-overrides (plist-get state-overrides
:native-topology-stable-p))) :native-topology-stable-p)))
(not (and (memq projection-kind (not (or retained-span-style-p
'(owner-scoped scroll-patch (and (memq projection-kind
formatting-context-reflow '(owner-scoped scroll-patch
mixed-owner-reflow)) formatting-context-reflow
(not range-splice-p))))) mixed-owner-reflow))
(not range-splice-p))))))
(_materialized-source-index (_materialized-source-index
(when (and subjects-required-p (when (and subjects-required-p
(ebox-source--index-derived-stale-p source-index) (ebox-source--index-derived-stale-p source-index)
@ -1578,19 +1602,21 @@ PREVIOUS-STATE's node-object table."
(subjects (when subjects-required-p (subjects (when subjects-required-p
(ebox-tree-subject-index root source-index))) (ebox-tree-subject-index root source-index)))
(selector-tree-snapshot (selector-tree-snapshot
(if (and (memq projection-kind (if (or retained-span-style-p
'(owner-scoped scroll-patch (and (memq projection-kind
formatting-context-reflow mixed-owner-reflow '(owner-scoped scroll-patch
native-frame)) formatting-context-reflow mixed-owner-reflow
(not range-splice-p)) native-frame))
(not range-splice-p)))
(plist-get previous-state :selector-tree-snapshot) (plist-get previous-state :selector-tree-snapshot)
(and subjects (and subjects
(ebox-surface--subject-signature (ebox-surface--subject-signature
(plist-get subjects :root-subject))))) (plist-get subjects :root-subject)))))
(selector-tree-token (selector-tree-token
(if (and (memq projection-kind (if (or retained-span-style-p
'(owner-scoped scroll-patch native-frame)) (and (memq projection-kind
(not range-splice-p)) '(owner-scoped scroll-patch native-frame))
(not range-splice-p)))
(plist-get previous-state :selector-tree-token) (plist-get previous-state :selector-tree-token)
(and selector-tree-snapshot (and selector-tree-snapshot
(if (and (equal selector-tree-snapshot (if (and (equal selector-tree-snapshot
@ -1823,7 +1849,7 @@ the projection roots a second time."
:geometry-kind) :geometry-kind)
'span-patch) 'span-patch)
(not (ebox-surface--mixed-range-splice-p state))) (not (ebox-surface--mixed-range-splice-p state)))
(and (eq projection-kind 'owner-scoped) (and (memq projection-kind '(span-patch owner-scoped))
(null (plist-get state :paint-property-contributions)) (null (plist-get state :paint-property-contributions))
(let ((proofs (plist-get state :owner-scoped-proofs))) (let ((proofs (plist-get state :owner-scoped-proofs)))
(and (= (length proofs) 1) (and (= (length proofs) 1)
@ -3202,6 +3228,16 @@ available during planning; replacements here belong only to the candidate."
"Render candidate STATE's complete root in isolated side tables." "Render candidate STATE's complete root in isolated side tables."
(let ((rendered (let ((rendered
(ebox-surface--render-candidate-node state (plist-get state :root-node)))) (ebox-surface--render-candidate-node state (plist-get state :root-node))))
;; A complete physical line owns its pixel remainder. Keep the raw render
;; and cached child strings untouched; retained output stores only whether
;; local geometry would require the missing fractional origin next time.
(plist-put state :fractional-pixel-output-p
(ebox--fractional-pixel-spaces-p rendered))
(when (plist-get state :fractional-pixel-output-p)
(let ((ebox--render-owned-text-values
(plist-get state :render-owned-text-values)))
(setq rendered (ebox--quantize-pixel-spaces rendered))
(ebox--record-render-output-provenance rendered)))
;; A retained node id can now describe a different display or subtree. ;; A retained node id can now describe a different display or subtree.
;; Refresh the complete lightweight snapshot, including ancestor region ;; Refresh the complete lightweight snapshot, including ancestor region
;; membership; stripping only span details would preserve stale style facts. ;; membership; stripping only span details would preserve stale style facts.
@ -3268,6 +3304,7 @@ available during planning; replacements here belong only to the candidate."
(ebox-native-commit-render state node)) (ebox-native-commit-render state node))
(let ((ebox--surface-materialization-active t)) (let ((ebox--surface-materialization-active t))
(ebox--render-layout node))))) (ebox--render-layout node)))))
(ebox-surface--check-local-pixel-output rendered)
(unless (plist-get state :native-render-p) (unless (plist-get state :native-render-p)
(ebox--record-render-output-provenance rendered)) (ebox--record-render-output-provenance rendered))
(unless (plist-get state :native-render-p) (unless (plist-get state :native-render-p)
@ -3646,10 +3683,13 @@ Compare resolved paint and surface properties, preserving opaque identities."
(when (fboundp 'ebox--flex-item-slot-sized-node) (when (fboundp 'ebox--flex-item-slot-sized-node)
(let ((ebox-incremental--buffer-render-state-override (cons buffer state))) (let ((ebox-incremental--buffer-render-state-override (cons buffer state)))
(ebox--flex-item-slot-sized-node buffer owner-id snapshot changed-keys)))) (ebox--flex-item-slot-sized-node buffer owner-id snapshot changed-keys))))
((and node role-owned-lines-p) ((and node (or role-owned-lines-p
(and allocated-width (not variable-content-p))))
;; The ancestor certificate proves a stable allocation, but its ;; The ancestor certificate proves a stable allocation, but its
;; capacity can include chrome outside this owner's published spans. ;; capacity can include chrome outside this owner's published spans.
;; Recreate this owner's margin-box extent with the normal box model. ;; Recreate this owner's margin-box extent with the normal box model.
;; Exact content patches also need that extent: an auto-width owner
;; detached from a padded parent must not expand to the root viewport.
(when-let* ((width (plist-get (when-let* ((width (plist-get
(plist-get snapshot :external-footprint-signature) (plist-get snapshot :external-footprint-signature)
:max-line-pixel-width)) :max-line-pixel-width))
@ -3861,7 +3901,13 @@ Proof mismatches return nil; unexpected rendering errors propagate."
(= (length spans) (length old-lines)) (= (length spans) (length old-lines))
(= (length spans) (length new-lines)))) (= (length spans) (length new-lines))))
result) result)
(if role-owned-lines-p (if (or role-owned-lines-p
;; Equal geometry need not have equal character counts.
;; The existing run transport proves changed content/filler
;; runs and preserves every external property boundary.
(cl-some (lambda (pair)
(/= (length (car pair)) (length (cdr pair))))
(cl-mapcar #'cons old-lines new-lines)))
(and valid (and valid
(ebox-surface--retained-slot-owner-text (ebox-surface--retained-slot-owner-text
buffer previous-state spans old-lines new-lines local buffer previous-state spans old-lines new-lines local
@ -3927,7 +3973,15 @@ Proof mismatches return nil; unexpected rendering errors propagate."
(retain-properties-p (retain-properties-p
(and (memq (plist-get state :projection-kind) (and (memq (plist-get state :projection-kind)
'(span-patch owner-scoped mixed-owner-reflow)) '(span-patch owner-scoped mixed-owner-reflow))
(ebox-surface--owner-needs-ancestor-paint-p state owner-id)))) (or (ebox-surface--owner-needs-ancestor-paint-p state owner-id)
;; Ancestors also contribute content ownership when
;; they have no paint. Detached Text/Box output must
;; retain that proven published stack as well.
(and previous-state
(not (or allocation-closure-p
retain-external-owner-suffix-p))
(ebox-runtime-index-get
owner-id (plist-get state :parent-table)))))))
(when (and old-snapshot spans node render-node (when (and old-snapshot spans node render-node
;; Detached geometry omits enclosing effects. Mixed paint ;; Detached geometry omits enclosing effects. Mixed paint
;; only recomposes dirty paint owners, so unchanged ancestors ;; only recomposes dirty paint owners, so unchanged ancestors
@ -4006,7 +4060,16 @@ Proof mismatches return nil; unexpected rendering errors propagate."
(list :owner-id owner-id :snapshot old-snapshot (list :owner-id owner-id :snapshot old-snapshot
:old-spans spans :details details :old-spans spans :details details
:retained-properties-p :retained-properties-p
(and retain-properties-p (not role-owned-lines-p)))))))) (and retain-properties-p (not role-owned-lines-p)
;; Rebuild only changed runs when character extents
;; move. Old metadata offsets are not reusable even
;; though the published properties were transported.
(equal
(plist-get (plist-get old-snapshot
:span-footprint-signature)
:char-lengths)
(plist-get (plist-get details :footprint)
:char-lengths)))))))))
(defun ebox-surface--range-patch-candidate (buffer state proof) (defun ebox-surface--range-patch-candidate (buffer state proof)
"Return PROOF's exact equal-line Range replacement candidate, or nil." "Return PROOF's exact equal-line Range replacement candidate, or nil."
@ -4077,7 +4140,178 @@ Proof mismatches return nil; unexpected rendering errors propagate."
:external new-external :parent new-parent :roles new-roles :external new-external :parent new-parent :roles new-roles
:overflow new-overflow :variable-content-p nil)))))) :overflow new-overflow :variable-content-p nil))))))
(defun ebox-surface--scroll-replace-index-lines (index changes)
"Copy INDEX and replace only entries on CHANGES' cached content lines.
Each change is (LINE-NUMBER OLD-LINE NEW-LINE). Line counts are unchanged,
so spans on other lines remain valid even when these strings change length."
(when index
(let ((next (copy-hash-table index)))
(dolist (change changes next)
(let* ((number (nth 0 change))
(old (nth 1 change))
(new (nth 2 change))
(replacement (ebox--scroll-build-region-line-span-index (list new)))
(ids (delete-dups
(append (ebox--scroll-line-region-ids old)
(ebox-surface--hash-keys replacement)))))
(dolist (id ids)
(let* ((retained (cl-remove number (copy-sequence (gethash id next))
:key #'car))
(local (gethash id replacement))
(entries (if local
(cons (cons number (cdar local)) retained)
retained)))
(if entries
(puthash id (sort entries (lambda (a b) (< (car a) (car b)))) next)
(remhash id next)))))))))
(defun ebox-surface--scroll-content-patch-output (buffer previous-state state)
"Validate and stage STATE's scroll content patch and visible projection.
Cached content has no root wrapper chrome and uses its own coordinates. Its
owner replacement therefore receives the normal span validator independently
of the mounted-buffer replacement. Only a fully hidden owner can retain the
visible bytes without that second proof. Every cache mutation is candidate
local; a declined visible proof leaves the committed scroll prefix untouched."
(let* ((proof (plist-get state :scroll-content-proof))
(region (plist-get proof :region-id))
(old-scroll (gethash region (plist-get previous-state :scroll-state-table)))
(next-scroll (gethash region (plist-get state :scroll-state-table)))
(lines (plist-get proof :lines))
(owner-proof (car (plist-get state :owner-scoped-proofs)))
(owner-id (plist-get owner-proof :owner-id))
cached-candidate cached-lines changes output)
(when (and old-scroll next-scroll
(eq lines (plist-get old-scroll :content-lines))
(= (length (plist-get state :owner-scoped-proofs)) 1))
(with-temp-buffer
(insert (ebox-lines-join lines))
(let* ((base (copy-sequence previous-state))
(candidate (copy-sequence state))
(ebox--region-line-index (plist-get proof :line-index))
(ebox--region-line-index-buffer (current-buffer)))
(plist-put base :surface nil)
(plist-put base :layout-snapshots
(copy-hash-table (plist-get proof :snapshots)))
(plist-put candidate :layout-snapshots
(make-hash-table :test 'equal))
(let* ((snapshot (copy-sequence
(gethash owner-id (plist-get base :layout-snapshots))))
(spans (plist-get snapshot :buffer-spans))
(owned (mapcar (lambda (span)
(buffer-substring (car span) (cdr span))) spans))
fragments)
;; This projection is a cache, so it has no TP mounts. Derive
;; its role and paint evidence from the same owned output runs
;; used by ordinary surface publication, in cache coordinates.
(plist-put snapshot :role-topology-signature
(ebox-surface--rendered-role-topology-signature
(ebox-lines-join owned) (plist-get snapshot :region-ids)))
(puthash owner-id snapshot (plist-get base :layout-snapshots))
(cl-mapc
(lambda (span line)
(dolist (fragment (ebox-surface--rendered-fragments line))
(plist-put fragment :start (+ (1- (car span)) (plist-get fragment :start)))
(plist-put fragment :end (+ (1- (car span)) (plist-get fragment :end)))
(push fragment fragments)))
spans owned)
(plist-put base :surface-fragments (nreverse fragments)))
(let ((ebox-incremental--buffer-render-state-override
(cons (current-buffer) base))
(ebox-incremental--candidate-base-state
(cons (current-buffer) base)))
(setq cached-candidate
(ebox-surface--owner-patch-candidate
(current-buffer) candidate owner-id
(plist-get owner-proof :allocated-width)
(plist-get owner-proof :variable-content-p)
(plist-get owner-proof :variable-content-max-width)
(plist-get owner-proof :role-owned-lines-p)
(plist-get owner-proof :changed-keys)
(plist-get owner-proof :allocation-closure-p)
(plist-get owner-proof :retain-external-owner-suffix-p)
base))
(when cached-candidate
(setq cached-lines (copy-sequence lines))
(cl-mapc
(lambda (span replacement)
(goto-char (car span))
(let* ((number (1- (line-number-at-pos)))
(start (- (car span) (line-beginning-position)))
(end (- (cdr span) (line-beginning-position)))
(old (nth number lines))
(new (concat (substring old 0 start) replacement
(substring old end))))
(setcar (nthcdr number cached-lines) new)
(push (list number old new) changes)))
(plist-get cached-candidate :old-spans)
(plist-get (plist-get cached-candidate :details) :lines))))))
(when (and cached-lines (= (length cached-lines) (length lines)))
(let* ((node (ebox-runtime-index-get owner-id
(plist-get previous-state :node-table)))
(visible (ebox--region-ids-visible-in-buffer-p
buffer (ebox--node-all-region-ids node))))
(setq output
(if visible
(ebox-surface--buffer-span-patch-output buffer previous-state state)
(with-current-buffer buffer
(buffer-substring (point-min) (point-max)))))
(when output
(unless visible
(plist-put state :content-base-extent (length output))
;; A zero-width identity patch carries the unchanged visible
;; coordinate proof through the ordinary retained TP batch.
(plist-put state :content-coordinate-patches
(list (list :old-start 0 :old-end 0 :new-start 0 :new-end 0)))
(plist-put state :span-patch-fragment-data
(ebox-surface--materialized-fragment-ledger previous-state))
(plist-put state :span-patch-content-p t)
(plist-put state :span-patch-retained-owned-ranges
(plist-get previous-state :surface-owned-ranges)))
(let* ((private (copy-sequence next-scroll))
(rendered (copy-sequence (plist-get next-scroll :rendered-content-lines)))
rendered-changes)
;; Lazy prefix extension mutates the bounds index in place.
;; Keep that future write isolated from the previous generation.
(when-let* ((bounds (plist-get private :region-line-bounds-index)))
(let ((copy (copy-hash-table bounds)))
(maphash (lambda (id range) (puthash id (cons (car range) (cdr range)) copy))
bounds)
(plist-put private :region-line-bounds-index copy)))
(when rendered
(dolist (change changes)
(let* ((number (car change))
(old (nth number rendered))
(new (car (ebox--scroll-rendered-content-lines
(plist-get private :box) (list (nth 2 change))
region number))))
(setcar (nthcdr number rendered) new)
(push (list number old new) rendered-changes))))
(plist-put private :content-lines cached-lines)
(plist-put private :region-line-span-index
(ebox-surface--scroll-replace-index-lines
(or (plist-get private :region-line-span-index)
(ebox--scroll-build-region-line-span-index lines))
changes))
(plist-put private :region-line-span-index-deferred nil)
(plist-put private :rendered-content-lines rendered)
(plist-put private :rendered-region-line-span-index
(ebox-surface--scroll-replace-index-lines
(plist-get private :rendered-region-line-span-index)
rendered-changes))
;; The validated owner preserves line and property topology;
;; line bounds/membership stay valid, character hints do not.
(cl-remf private :region-line-span-hints)
(puthash region private (plist-get state :scroll-state-table)))
(cl-remf state :scroll-content-proof))))
output)))
(defun ebox-surface--span-patch-output (buffer previous-state state) (defun ebox-surface--span-patch-output (buffer previous-state state)
"Return a proven local patch in BUFFER or its retained scroll content."
(if (plist-get state :scroll-content-proof)
(ebox-surface--scroll-content-patch-output buffer previous-state state)
(ebox-surface--buffer-span-patch-output buffer previous-state state)))
(defun ebox-surface--buffer-span-patch-output (buffer previous-state state)
"Return a proven single- or multi-owner local patch output, or nil." "Return a proven single- or multi-owner local patch output, or nil."
(let* ((proofs (plist-get state :owner-scoped-proofs)) (let* ((proofs (plist-get state :owner-scoped-proofs))
(candidates (candidates
@ -4220,10 +4454,20 @@ Proof mismatches return nil; unexpected rendering errors propagate."
previous-state))) previous-state)))
(fragments (fragments
(if retained-properties-p (if retained-properties-p
(ebox-surface--fragment-metadata old-fragments) (if (and (not coordinate-shift-p)
(eq old-fragments
(plist-get previous-state
:surface-fragments)))
;; The published ledger already contains
;; immutable offsets. Equal-coordinate
;; property transport preserves every run,
;; so retain it without copying the page.
old-fragments
(ebox-surface--fragment-metadata old-fragments))
(ebox-surface--incremental-patched-fragments (ebox-surface--incremental-patched-fragments
output old-fragments output old-fragments
(plist-get state :content-coordinate-patches))))) (plist-get state :content-coordinate-patches)
state))))
(plist-put state :span-patch-fragment-data fragments) (plist-put state :span-patch-fragment-data fragments)
(plist-put state :span-patch-content-p t) (plist-put state :span-patch-content-p t)
(when (and retained-properties-p (not coordinate-shift-p)) (when (and retained-properties-p (not coordinate-shift-p))
@ -4431,6 +4675,7 @@ work proportional to semantic face composition instead of allocation churn."
(if face (if face
(put-text-property start end 'face face output) (put-text-property start end 'face face output)
(remove-text-properties start end '(face nil) output))) (remove-text-properties start end '(face nil) output)))
(ebox-interaction--refresh-hover! output start end nil new-state)
(cl-remf fragment :text) (cl-remf fragment :text)
(plist-put fragment :text-source-p t) (plist-put fragment :text-source-p t)
(cl-remf fragment :old-paint-role-ids) (cl-remf fragment :old-paint-role-ids)
@ -4581,11 +4826,14 @@ causes the caller to use the ordinary full projection."
(mapcar (lambda (fragment) (plist-get fragment :text)) (mapcar (lambda (fragment) (plist-get fragment :text))
fragments))))))))) fragments)))))))))
(defun ebox-surface--scroll-patch-output (_buffer state) (defun ebox-surface--scroll-patch-output (_buffer state &optional complete-lines-only-p)
"Return a staged visible scroll window without rerunning layout when safe. "Return a staged visible scroll window without rerunning layout when safe.
Chrome-free top-aligned root scroll boxes already store fully propertized Chrome-free top-aligned root scroll boxes already store fully propertized
rendered lines. Join only the visible slice and add the scroll marker; any rendered lines. Join only the visible slice and add the scroll marker; any
box with wrapper chrome falls back to the retained wrapper renderer." box with wrapper chrome falls back to the retained wrapper renderer.
When COMPLETE-LINES-ONLY-P is non-nil, return only proven complete cached
physical lines, with private pixel-normalized copies where needed. Decline
wrapper rendering so a caller may keep its ordinary fractional-origin guard."
(let* ((region-id (plist-get state :scroll-patch-region-id)) (let* ((region-id (plist-get state :scroll-patch-region-id))
(scroll-table (plist-get state :scroll-state-table)) (scroll-table (plist-get state :scroll-state-table))
(scroll-state (and region-id scroll-table (scroll-state (and region-id scroll-table
@ -4620,6 +4868,23 @@ box with wrapper chrome falls back to the retained wrapper renderer."
(if (and visible (cadr visible)) (if (and visible (cadr visible))
(let* ((lines (cadr visible)) (let* ((lines (cadr visible))
(output (ebox-lines-join lines))) (output (ebox-lines-join lines)))
(unless complete-lines-only-p
(ebox-surface--check-local-pixel-output output))
(when (ebox--fractional-pixel-spaces-p output)
;; The root and chrome proof above establishes the physical
;; line origin. Normalize only these visible line handles;
;; their raw cache and integral template keys stay unchanged.
(let ((ebox--render-owned-text-values
(or (plist-get state :render-owned-text-values)
(make-hash-table :test #'eq))))
(setq lines (mapcar #'ebox--quantize-pixel-spaces lines)
output (ebox-lines-join lines))
(plist-put state :render-owned-text-values
ebox--render-owned-text-values)
(ebox--record-render-output-provenance output))
;; A later integer-only window cannot disprove fractions in
;; other retained rows, so scrolling only adds this raw fact.
(plist-put state :fractional-pixel-output-p t))
(when (and (ebox-surface--scroll-lines-owned-p lines) (when (and (ebox-surface--scroll-lines-owned-p lines)
(plist-get state :retained-scroll-content-p)) (plist-get state :retained-scroll-content-p))
;; Retain immutable line handles and stream their cached role ;; Retain immutable line handles and stream their cached role
@ -4633,10 +4898,11 @@ box with wrapper chrome falls back to the retained wrapper renderer."
(list 'ebox-scroll-window region-id) (list 'ebox-scroll-window region-id)
output)) output))
output) output)
(let ((ebox--scroll-window-cached-state scroll-state) (unless complete-lines-only-p
(ebox--scroll-window-skip-state-rebuild-p (let ((ebox--scroll-window-cached-state scroll-state)
(plist-get scroll-state :content-lines-complete-p))) (ebox--scroll-window-skip-state-rebuild-p
(ebox-surface--render-candidate-node state root))))))) (plist-get scroll-state :content-lines-complete-p)))
(ebox-surface--render-candidate-node state root))))))))
(defun ebox-surface--native-scroll-full-output (state) (defun ebox-surface--native-scroll-full-output (state)
"Return the complete propertized root scroll output for native scrolling." "Return the complete propertized root scroll output for native scrolling."
@ -4915,19 +5181,21 @@ when it needs to mutate them."
collect fragment))) collect fragment)))
(defun ebox-surface--incremental-patched-fragments (defun ebox-surface--incremental-patched-fragments
(output old-fragments patches) (output old-fragments patches &optional state)
"Rebase OLD-FRAGMENTS through exact text PATCHES into OUTPUT. "Rebase OLD-FRAGMENTS through exact text PATCHES into OUTPUT.
Return nil unless every patch is aligned to retained fragment boundaries and Return nil unless every patch is aligned to retained fragment boundaries and
preserves the ordered role topology inside its replaced range. On success, preserves the ordered role topology inside its replaced range. On success,
only replacement slices are scanned; unchanged fragment metadata is copied only replacement slices are scanned; unchanged fragment metadata is copied
and shifted without inspecting its text properties." and shifted without inspecting its text properties. STATE, when supplied,
receives the proven fragment-boundary coordinate mapping so retained owner
boundaries inside a replaced line can be rebased without guessing."
(when (and (stringp output) old-fragments patches) (when (and (stringp output) old-fragments patches)
(catch 'miss (catch 'miss
(let ((remaining old-fragments) (let ((remaining old-fragments)
(old-cursor 0) (old-cursor 0)
(new-cursor 0) (new-cursor 0)
(line-shift 0) (line-shift 0)
result new-origin-ranges) result new-origin-ranges coordinate-patches)
(dolist (patch patches) (dolist (patch patches)
(let ((old-start (plist-get patch :old-start)) (let ((old-start (plist-get patch :old-start))
(old-end (plist-get patch :old-end)) (old-end (plist-get patch :old-end))
@ -5015,6 +5283,11 @@ and shifted without inspecting its text properties."
(copy-sequence chain))) (copy-sequence chain)))
(plist-put copy :text output) (plist-put copy :text output)
(plist-put copy :text-source-p t) (plist-put copy :text-source-p t)
(push (list :old-start (plist-get old :start)
:old-end (plist-get old :end)
:new-start (plist-get copy :start)
:new-end (plist-get copy :end))
coordinate-patches)
(push copy result))) (push copy result)))
old-window new-window) old-window new-window)
(push (cons new-start new-end) new-origin-ranges))) (push (cons new-start new-end) new-origin-ranges)))
@ -5039,6 +5312,9 @@ and shifted without inspecting its text properties."
(remove-text-properties (remove-text-properties
(car range) (cdr range) (car range) (cdr range)
(list ebox--paint-origin-property nil) output)) (list ebox--paint-origin-property nil) output))
(when state
(plist-put state :content-coordinate-patches
(nreverse coordinate-patches)))
(nreverse result))))) (nreverse result)))))
(defun ebox-surface--rebase-coordinate (position patches) (defun ebox-surface--rebase-coordinate (position patches)
@ -5562,13 +5838,13 @@ ANCESTOR-P means BOX wraps a more specific rendered surface."
(cl-intersection roles '(content content-owner pt pb pl pr))) (cl-intersection roles '(content content-owner pt pb pl pr)))
(setq faces (ebox-surface--box-face-contributions box)))) (setq faces (ebox-surface--box-face-contributions box))))
(when (and (memq 'bt roles) (when (and (memq 'bt roles)
(> (or (plist-get box :border-top-pixel) 0) 0)) (> (or (ebox-get box :border-top-pixel) 0) 0))
(setq faces (setq faces
(append faces (append faces
(list (ebox-buffer--color-face (list (ebox-buffer--color-face
(plist-get box :border-top-color) 'overline))))) (plist-get box :border-top-color) 'overline)))))
(when (and (memq 'bb roles) (when (and (memq 'bb roles)
(> (or (plist-get box :border-bottom-pixel) 0) 0)) (> (or (ebox-get box :border-bottom-pixel) 0) 0))
(setq faces (setq faces
(append (append
faces faces
@ -5711,6 +5987,9 @@ relative face contributions and performs the final merge during prepare."
(push (list :start offset :end (+ offset length) (push (list :start offset :end (+ offset length)
:props props) :props props)
contributions))))) contributions)))))
(ebox-interaction--refresh-hover!
text 0 length
(and affected-p (ebox-surface--compose-face baseline faces)) state)
(plist-put copy :text text) (plist-put copy :text text)
(plist-put copy :text-source-p nil) (plist-put copy :text-source-p nil)
(plist-put copy :start offset) (plist-put copy :start offset)
@ -5815,6 +6094,7 @@ relative face contributions and performs the final merge during prepare."
(if face (if face
(put-text-property 0 (length text) 'face face text) (put-text-property 0 (length text) 'face face text)
(remove-text-properties 0 (length text) '(face nil) text))) (remove-text-properties 0 (length text) '(face nil) text)))
(ebox-interaction--refresh-hover! text 0 (length text) nil new-state)
(plist-put fragment :text text) (plist-put fragment :text text)
(cl-remf fragment :old-paint-role-ids) (cl-remf fragment :old-paint-role-ids)
fragment)) fragment))
@ -5890,17 +6170,24 @@ OLD-TEXT is a defensive snapshot of the published TP content."
(make-hash-table :test 'eq))) (make-hash-table :test 'eq)))
(ebox--viewport-dependent-node-id-axes root))) (ebox--viewport-dependent-node-id-axes root)))
(defun ebox-surface--mixed-projection-preserves-context-axes-p (defun ebox-surface--projection-preserves-context-axes-p
(previous-state state-overrides projection-kind) (previous-state state-overrides projection-kind)
"Return non-nil when a mixed projection preserves viewport dependency axes. "Return non-nil when a proven projection preserves viewport dependency axes.
PREVIOUS-STATE must own a completed axis index. STATE-OVERRIDES must carry a PREVIOUS-STATE must own a completed axis index. STATE-OVERRIDES must carry a
strict mixed-owner proof whose changes are limited to content, TP surface strict owner proof whose changes are limited to content, TP surface
properties, or paint. Those values can change measurement or publication, properties, or paint. Those values can change measurement or publication,
but cannot introduce or remove a viewport expression or node identity." but cannot introduce or remove a viewport expression or node identity."
(and (eq projection-kind 'mixed-owner-reflow) (let ((dirty-set
(plist-get previous-state :viewport-dependent-node-ids-ready) (pcase projection-kind
(plist-member previous-state :viewport-dependent-node-id-axes) ('mixed-owner-reflow
(when-let* ((proof (plist-get state-overrides :mixed-owner-proof))) (plist-get (plist-get state-overrides :mixed-owner-proof)
:dirty-set))
((or 'span-patch 'owner-scoped)
(and (plist-get state-overrides :owner-scoped-proofs)
(plist-get state-overrides :span-patch-dirty-set))))))
(and dirty-set
(plist-get previous-state :viewport-dependent-node-ids-ready)
(plist-member previous-state :viewport-dependent-node-id-axes)
(cl-every (cl-every
(lambda (entry) (lambda (entry)
(cl-every (cl-every
@ -5908,7 +6195,7 @@ but cannot introduce or remove a viewport expression or node identity."
(or (memq key '(:content :surface-properties)) (or (memq key '(:content :surface-properties))
(memq key ebox--paint-style-signature-keys))) (memq key ebox--paint-style-signature-keys)))
(plist-get entry :changed-keys))) (plist-get entry :changed-keys)))
(plist-get proof :dirty-set))))) dirty-set))))
(defun ebox-surface--projection-state (defun ebox-surface--projection-state
(root previous-state state-overrides projection-kind (root previous-state state-overrides projection-kind
@ -5923,6 +6210,8 @@ but cannot introduce or remove a viewport expression or node identity."
(copy-sequence state-overrides)))) (copy-sequence state-overrides))))
(cl-remf state :paint-property-contributions) (cl-remf state :paint-property-contributions)
(cl-remf state :span-patch-retained-owned-ranges) (cl-remf state :span-patch-retained-owned-ranges)
(plist-put state :fractional-pixel-output-p
(plist-get previous-state :fractional-pixel-output-p))
(plist-put state :root-node root) (plist-put state :root-node root)
(when (plist-get state :native-sync-confirmed-p) (when (plist-get state :native-sync-confirmed-p)
(ebox-native-commit-attach-confirmed-base previous-state state)) (ebox-native-commit-attach-confirmed-base previous-state state))
@ -6058,14 +6347,28 @@ published runtime and must be consumed without clearing those shared nodes."
(cl-remf state-overrides :source-base-index) (cl-remf state-overrides :source-base-index)
(setq state-overrides (setq state-overrides
(plist-put state-overrides :source-index source-index)))) (plist-put state-overrides :source-index source-index))))
(retained-inline-styles-p
(and source-path-copied-p
(memq projection-kind '(span-patch owner-scoped))
(plist-get state-overrides
:computed-participation-validated-p)
(let ((dirty-set
(plist-get state-overrides :span-patch-dirty-set)))
(and dirty-set
(cl-every
(lambda (entry)
(equal (plist-get entry :changed-keys) '(:content)))
dirty-set)))))
(inline-inheritance-required-p (inline-inheritance-required-p
(unless (or scroll-fast-p retained-runtime-source-p) (unless (or scroll-fast-p retained-runtime-source-p
retained-inline-styles-p)
(ebox-surface--inline-inheritance-required-p (ebox-surface--inline-inheritance-required-p
root source-index))) root source-index)))
(author-style-pending-p (author-style-pending-p
(> (ebox-tree-author-style-count root) 0)) (> (ebox-tree-author-style-count root) 0))
(cascade-required-p (cascade-required-p
(if (or scroll-fast-p retained-runtime-source-p) (if (or scroll-fast-p retained-runtime-source-p
retained-inline-styles-p)
(plist-get previous-state :cascade-required-p) (plist-get previous-state :cascade-required-p)
(or stylesheet-active-p (or stylesheet-active-p
inline-inheritance-required-p))) inline-inheritance-required-p)))
@ -6142,7 +6445,7 @@ published runtime and must be consumed without clearing those shared nodes."
state-overrides state-overrides
:viewport-dependent-node-id-axes)) :viewport-dependent-node-id-axes))
state-overrides) state-overrides)
((ebox-surface--mixed-projection-preserves-context-axes-p ((ebox-surface--projection-preserves-context-axes-p
previous-state state-overrides projection-kind) previous-state state-overrides projection-kind)
previous-state)))) previous-state))))
(reuse-context-axes-p (not (null context-axis-facts))) (reuse-context-axes-p (not (null context-axis-facts)))
@ -6237,31 +6540,48 @@ published runtime and must be consumed without clearing those shared nodes."
(plist-put state :projection-kind 'native-frame))) (plist-put state :projection-kind 'native-frame)))
(rendered (rendered
(cond (cond
((and (eq projection-kind 'scroll-patch)
(plist-get state :native-scroll-materialize-p))
(or (ebox-surface--native-scroll-full-output state)
(ebox-surface--scroll-patch-output
(ebox-surface--signals-buffer signals) state)))
((eq projection-kind 'scroll-patch)
(or (ebox-surface--scroll-patch-output
(ebox-surface--signals-buffer signals) state)
(ebox-surface--render-candidate state)))
((memq projection-kind ((memq projection-kind
'(span-patch owner-scoped formatting-context-reflow '(span-patch owner-scoped scroll-patch
mixed-owner-reflow)) formatting-context-reflow mixed-owner-reflow))
(if-let* ((local-output (if-let* ((local-output
(pcase projection-kind (or
('formatting-context-reflow ;; Proven root scroll rows own their complete
(ebox-surface--formatting-context-reflow-output ;; physical origin, including any remainder.
(ebox-surface--signals-buffer signals) state)) (and (eq projection-kind 'scroll-patch)
('mixed-owner-reflow (not (plist-get state :native-scroll-materialize-p))
(ebox-surface--mixed-owner-output (ebox-surface--scroll-patch-output
(ebox-surface--signals-buffer signals) (ebox-surface--signals-buffer signals) state t))
previous-state state)) ;; A detached fractional owner cannot recover
(_ ;; the remainder spent by preceding siblings.
(ebox-surface--span-patch-output (unless (plist-get previous-state
(ebox-surface--signals-buffer signals) :fractional-pixel-output-p)
previous-state state))))) (let ((ebox-surface--integral-local-output-required-p t))
(catch 'ebox/fractional-local-output
(pcase projection-kind
('scroll-patch
(or (and (plist-get state :native-scroll-materialize-p)
(ebox-surface--native-scroll-full-output state))
(ebox-surface--scroll-patch-output
(ebox-surface--signals-buffer signals) state)
;; A nested scroll already owns a
;; complete candidate projection.
;; Preserve its ordinary renderer
;; fallback without recascading the
;; retained source. The raw-output
;; guard still rejects fractions.
(unless (plist-get state :native-scroll-materialize-p)
(ebox-surface--render-candidate state))))
('formatting-context-reflow
(ebox-surface--formatting-context-reflow-output
(ebox-surface--signals-buffer signals) state))
('mixed-owner-reflow
(ebox-surface--mixed-owner-output
(ebox-surface--signals-buffer signals)
previous-state state))
(_
(ebox-surface--span-patch-output
(ebox-surface--signals-buffer signals)
previous-state state)))))))))
local-output local-output
;; A declined output may already have staged coordinates, ;; A declined output may already have staged coordinates,
;; fragments or paint layers. Rebuild from the original ;; fragments or paint layers. Rebuild from the original
@ -6274,7 +6594,8 @@ published runtime and must be consumed without clearing those shared nodes."
(ebox-surface--complete-projection (ebox-surface--complete-projection
context root source-index context root source-index
style-required-p projection))) style-required-p projection)))
(setq projection-kind nil) (setq projection-kind nil
scroll-fast-p nil)
(setq source-index (plist-get projection :source-index)) (setq source-index (plist-get projection :source-index))
(setq state (setq state
(ebox-surface--projection-state (ebox-surface--projection-state

View File

@ -27,6 +27,7 @@
(declare-function ebox--next-runtime-node-id "ebox" ()) (declare-function ebox--next-runtime-node-id "ebox" ())
(declare-function ebox--string-region-ids "ebox" (string)) (declare-function ebox--string-region-ids "ebox" (string))
(declare-function ebox--box-visible-overflow-p "ebox" (box)) (declare-function ebox--box-visible-overflow-p "ebox" (box))
(declare-function ebox--flex-validate-item-bases "ebox-flex" (props children))
(defconst ebox--default-display '(block flow) (defconst ebox--default-display '(block flow)
"Default CSS-like display for a plain Ebox box.") "Default CSS-like display for a plain Ebox box.")
@ -735,6 +736,10 @@ handles, reused node objects, and cycles without consulting node metadata."
keys))) keys)))
(_ (error "Ebox participation requires a Flex or Grid parent: %S" (_ (error "Ebox participation requires a Flex or Grid parent: %S"
keys))))) keys)))))
(when (and (or (eq source 'computed) (not (ebox-style-cascade-active-p)))
(eq (ebox-tree-layout-kind node) 'flex))
(ebox--flex-validate-item-bases
(ebox-tree-layout-props node) (ebox-tree-layout-children node)))
node) node)
(defun ebox-tree--indexed-parent-context (defun ebox-tree--indexed-parent-context
@ -748,7 +753,15 @@ handles, reused node objects, and cycles without consulting node metadata."
(node-table parent-table node-ids &optional source source-index) (node-table parent-table node-ids &optional source source-index)
"Validate NODE-IDS in indexed final tree using AUTHOR or COMPUTED SOURCE." "Validate NODE-IDS in indexed final tree using AUTHOR or COMPUTED SOURCE."
(setq source (or source 'author)) (setq source (or source 'author))
(dolist (node-id (delete-dups (copy-sequence node-ids))) (dolist (node-id
(delete-dups
(append (copy-sequence node-ids)
(when (or (eq source 'computed)
(not (ebox-style-cascade-active-p)))
(delq nil (mapcar
(lambda (id)
(ebox-runtime-index-get id parent-table))
node-ids))))))
(when-let* ((node (ebox-runtime-index-get node-id node-table))) (when-let* ((node (ebox-runtime-index-get node-id node-table)))
(ebox-tree--validate-node-parent-participation (ebox-tree--validate-node-parent-participation
node node
@ -899,7 +912,10 @@ mark every ancestor dirty."
(while (and equal-p old new) (while (and equal-p old new)
(setq equal-p (setq equal-p
(and (eq (car old) (car new)) (and (eq (car old) (car new))
(equal-including-properties (cadr old) (cadr new)))) (funcall (if (eq (car old) :surface-properties)
#'ebox-interaction-surface-equal-p
#'equal-including-properties)
(cadr old) (cadr new))))
(setq old (ebox-tree--next-local-source-entry (cddr old)) (setq old (ebox-tree--next-local-source-entry (cddr old))
new (ebox-tree--next-local-source-entry (cddr new)))) new (ebox-tree--next-local-source-entry (cddr new))))
(and equal-p (null old) (null new)))) (and equal-p (null old) (null new))))
@ -921,8 +937,10 @@ OLD and NEW are source nodes with the same retained runtime identity."
(new-entry (plist-member new-signature key))) (new-entry (plist-member new-signature key)))
(unless (and (eq (not (null old-entry)) (unless (and (eq (not (null old-entry))
(not (null new-entry))) (not (null new-entry)))
(equal-including-properties (funcall (if (eq key :surface-properties)
(cadr old-entry) (cadr new-entry))) #'ebox-interaction-surface-equal-p
#'equal-including-properties)
(cadr old-entry) (cadr new-entry)))
(push (if (eq key :ebox-layout-props) :props key) (push (if (eq key :ebox-layout-props) :props key)
changed)))))))) changed))))))))
(nreverse changed))) (nreverse changed)))

212
ebox.el
View File

@ -23,9 +23,9 @@
"Directory containing the active Ebox Lisp sources.") "Directory containing the active Ebox Lisp sources.")
(defconst ebox--compile-sources (defconst ebox--compile-sources
'("ebox-cache.el" "ebox-source.el" "ebox-runtime-index.el" '("ebox-cache.el" "ebox-interaction.el" "ebox-source.el" "ebox-runtime-index.el"
"ebox-state-contract.el" "ebox-state-contract.el"
"ebox-font.el" "ebox-style.el" "ebox-font.el" "ebox-size.el" "ebox-style.el"
"ebox-layout-config.el" "ebox-layout-config.el"
"ebox-node-factory.el" "ebox-node-factory.el"
"ebox-child-range.el" "ebox-tree.el" "ebox-measure.el" "ebox-child-range.el" "ebox-tree.el" "ebox-measure.el"
@ -384,13 +384,13 @@ Set it to nil to inherit the surrounding `gc-cons-percentage'."
(defvar ebox-viewport-width nil (defvar ebox-viewport-width nil
"Dynamically bound horizontal viewport width in pixels. "Dynamically bound horizontal viewport width in pixels.
DSL width values may use `(viewport)' to resolve to this value while a DSL sizes may use `(vw N)' for N percent of this width while a
caller, such as the Playground preview, renders against a concrete window.") caller, such as the Playground preview, renders against a concrete window.")
(defvar ebox-viewport-height nil (defvar ebox-viewport-height nil
"Dynamically bound vertical viewport height in lines. "Dynamically bound vertical viewport height in lines.
DSL height values may use `(viewport-height)' to resolve to this value while a The render context converts this value to pixels with the frame line metric.
caller, such as the Playground preview, renders against a concrete window.") Block-axis DSL sizes use `(vh N)' for N percent of that pixel height.")
(defvar ebox--region-box-table) (defvar ebox--region-box-table)
(defconst ebox-region-types (defconst ebox-region-types
@ -433,8 +433,18 @@ caller, such as the Playground preview, renders against a concrete window.")
(cl-incf ebox--region-id-counter)) (cl-incf ebox--region-id-counter))
(defsubst ebox-get (box property) (defsubst ebox-get (box property)
"Retrieve PROPERTY from BOX." "Retrieve PROPERTY from BOX, resolving deferred engine edge lengths."
(plist-get box property)) (let ((value (plist-get box property)))
(if (and (consp value)
(memq property
'(:padding-left-pixel :padding-right-pixel
:margin-left-pixel :margin-right-pixel
:border-left-pixel :border-right-pixel
:border-top-pixel :border-bottom-pixel
:padding-top-height :padding-bottom-height
:margin-top-height :margin-bottom-height)))
(ebox--edge-used-value box property value)
value)))
(defun ebox-put (box property value) (defun ebox-put (box property value)
"Set private runtime PROPERTY to VALUE in BOX and return BOX. "Set private runtime PROPERTY to VALUE in BOX and return BOX.
@ -492,8 +502,8 @@ completed report. Nil removes observation without affecting rendering."
(plist-get node :node-id)) (plist-get node :node-id))
(defsubst ebox-string-lines (string) (defsubst ebox-string-lines (string)
"Split STRING into a list of lines, preserving text properties." "Split STRING into lines, preserving properties; empty input has no lines."
(when string (when (and string (not (string-empty-p string)))
(let ((start 0) (let ((start 0)
(lines nil)) (lines nil))
(while (string-match "\n" string start) (while (string-match "\n" string start)
@ -504,12 +514,18 @@ completed report. Nil removes observation without affecting rendering."
(defsubst ebox-string-height (string) (defsubst ebox-string-height (string)
"Return the number of lines in STRING. "Return the number of lines in STRING.
Optimized: Counts newlines directly to avoid list allocation." Empty input has zero lines. Count newlines without allocating a line list."
(1+ (cl-count ?\n string))) (if (or (null string) (string-empty-p string))
0
(1+ (cl-count ?\n string))))
(defsubst ebox-lines-join (lines) (defsubst ebox-lines-join (lines)
"Join a list of LINES into a single string." "Join LINES into a string, preserving an explicit empty line's height.
(string-join lines "\n")) A single empty line needs a zero-width display carrier to distinguish it from
no lines. Multiline strings already preserve their blank lines as newlines."
(if (and lines (null (cdr lines)) (equal (car lines) ""))
(propertize " " 'display '(space :width (0)))
(string-join lines "\n")))
(defun ebox--string-repeat-lines (string count) (defun ebox--string-repeat-lines (string count)
"Repeat a single line STRING for COUNT times." "Repeat a single line STRING for COUNT times."
@ -526,9 +542,8 @@ Runs that already render as display specs keep their spec and lose
face color. Glyph runs are measured with their faces still applied face color. Glyph runs are measured with their faces still applied
and then covered by one pixel-exact display space, so line widths are and then covered by one pixel-exact display space, so line widths are
identical in fixed and variable pitch while nothing is drawn. Buffer identical in fixed and variable pitch while nothing is drawn. Buffer
text and every other text property (region, content, and owner text and ownership metadata stay intact. Native interaction properties are
metadata, event surfaces) stay untouched, which keeps spans, markers, explicitly cleared so invisible content cannot retain an active hit surface."
and incremental diffs byte-compatible with the visible rendering."
(ebox--maplines (ebox--maplines
(lambda (line) (lambda (line)
(if (string-empty-p line) (if (string-empty-p line)
@ -544,7 +559,11 @@ and incremental diffs byte-compatible with the visible rendering."
(base (/ width length)) (base (/ width length))
(remainder (- width (* base length)))) (remainder (- width (* base length))))
(remove-text-properties (remove-text-properties
0 length '(face nil mouse-face nil) line) 0 length '(face nil) line)
(add-text-properties
0 length '(mouse-face nil ebox--hover-style nil
help-echo nil pointer nil keymap nil) line)
(ebox-interaction--fill-property line 0 length 'keymap nil)
(dotimes (index length) (dotimes (index length)
(put-text-property (put-text-property
index (1+ index) 'display index (1+ index) 'display
@ -625,7 +644,8 @@ Empty input has zero lines before padding. Height zero clips to nil."
"Pad or clip STRING to HEIGHT lines. "Pad or clip STRING to HEIGHT lines.
OFFSET controls content shift: positive (top), negative (bottom). OFFSET controls content shift: positive (top), negative (bottom).
PADDING-STRING fills added lines; its default matches STRING's first-line width." PADDING-STRING fills added lines; its default matches STRING's first-line width."
(if (and (stringp string) (integerp height) (= height 1) (if (and (stringp string) (not (string-empty-p string))
(integerp height) (= height 1)
(not (string-match-p "\n" string))) (not (string-match-p "\n" string)))
(copy-sequence string) (copy-sequence string)
(ebox-lines-join (ebox-lines-join
@ -664,8 +684,10 @@ ALIGN can be `top', `center', or `bottom'."
(apply #'cl-mapcar #'concat line-lists))))) (apply #'cl-mapcar #'concat line-lists)))))
(defun ebox--lines-stack-vertical (&rest strings) (defun ebox--lines-stack-vertical (&rest strings)
"Stack STRINGS vertically." "Stack nonempty STRINGS vertically, without adding zero-height separators."
(setq strings (delq nil strings)) (setq strings (seq-filter (lambda (string)
(and string (not (string-empty-p string))))
strings))
(ebox-lines-join strings)) (ebox-lines-join strings))
(defsubst ebox-default-foreground () (defsubst ebox-default-foreground ()
@ -1749,14 +1771,10 @@ object. Unrelated ownerless and foreign-buffer states remain isolated."
ids)) ids))
(defun ebox--string-next-region-property-change (string pos limit) (defun ebox--string-next-region-property-change (string pos limit)
"Return next ebox region property change in STRING after POS, capped at LIMIT." "Return the next possible region boundary in STRING after POS, up to LIMIT.
(let ((next limit)) Finer non-region property boundaries are safe: each caller merges equal owner
(dolist (entry ebox-region-types) ranges. Using the native interval cursor avoids one search per role."
(let ((change (next-single-property-change (or (next-property-change pos string limit) limit))
pos (cdr entry) string limit)))
(when (and change (< change next))
(setq next change))))
next))
(defun ebox--string-region-role-ids-compatible-p (role-ids region-set) (defun ebox--string-region-role-ids-compatible-p (role-ids region-set)
"Return non-nil when ROLE-IDS do not cross foreign direct content owners." "Return non-nil when ROLE-IDS do not cross foreign direct content owners."
@ -2382,12 +2400,14 @@ published or mutated. Return non-nil when the isolated render ran."
(plist-get owner :box))))) (plist-get owner :box)))))
(defun ebox--literal-root-pixel-width (box) (defun ebox--literal-root-pixel-width (box)
"Return BOX's demo-compatible literal pixel width, or nil." "Return BOX's fixed pixel-only width, or nil.
Relative and font-dependent widths cannot prove a predicted pixel edit."
(let ((width (and box (ebox-get box :width)))) (let ((width (and box (ebox-get box :width))))
(cond (cond
((numberp width) width) ((numberp width) width)
((and (consp width) (null (cdr width)) (numberp (car width))) ((and (consp width) (ebox-size-value-p width)
(car width))))) (null (ebox-size-dependencies width)))
(ebox-size-resolve width nil)))))
(defun ebox--invalidate-reflow-prewarm-scratch-signatures (defun ebox--invalidate-reflow-prewarm-scratch-signatures
(scratch region-id) (scratch region-id)
@ -3831,7 +3851,7 @@ Defaults to the current buffer."
"Longhand box properties accepted by `ebox-region-update'.") "Longhand box properties accepted by `ebox-region-update'.")
(defconst ebox--region-update-control-properties (defconst ebox--region-update-control-properties
'(:content :scroll-offset) (append '(:content :scroll-offset) ebox-interaction-properties)
"Surface controls accepted by `ebox-region-update' outside style schemas.") "Surface controls accepted by `ebox-region-update' outside style schemas.")
(defun ebox--region-update-style-properties (props) (defun ebox--region-update-style-properties (props)
@ -3855,13 +3875,15 @@ Defaults to the current buffer."
(:background-color :bgcolor) (:background-color :bgcolor)
(_ property))) (_ property)))
(defun ebox--region-update-direct-text-node (box) (defun ebox--region-update-direct-text-node (node)
"Return BOX's sole direct canonical Text child, or nil." "Return canonical Text NODE itself or NODE's sole direct Text child."
(let ((children (and (ebox-box-node-p box) (if (ebox-text-node-p node)
(ebox-box-node-children box)))) node
(and (= (length children) 1) (let ((children (and (ebox-box-node-p node)
(ebox-text-node-p (car children)) (ebox-box-node-children node))))
(car children)))) (and (= (length children) 1)
(ebox-text-node-p (car children))
(car children)))))
(defun ebox--region-update-content-value (box) (defun ebox--region-update-content-value (box)
"Return the mutable region content represented by canonical BOX." "Return the mutable region content represented by canonical BOX."
@ -3882,6 +3904,10 @@ Defaults to the current buffer."
((eq target-key :content) ((eq target-key :content)
(setq content-seen t (setq content-seen t
content-value value)) content-value value))
((eq target-key :surface-properties)
(unless (ebox-surface--retained-property-value-equal-p
(ebox-get box target-key) value)
(setq style-changed-p t)))
((memq target-key ebox--region-update-longhand-props) ((memq target-key ebox--region-update-longhand-props)
(when (not (equal (ebox-get box target-key) value)) (when (not (equal (ebox-get box target-key) value))
(setq style-changed-p t))) (setq style-changed-p t)))
@ -3893,7 +3919,7 @@ Defaults to the current buffer."
(user-error "Ebox content update requires a string")) (user-error "Ebox content update requires a string"))
(when (and content-seen (when (and content-seen
(not (ebox--region-update-direct-text-node box))) (not (ebox--region-update-direct-text-node box)))
(user-error "Ebox content update requires one direct Text child")) (user-error "Ebox content update requires Text or a Box with one direct Text child"))
(list :changed-p (list :changed-p
(or (not (equal declarations (or (not (equal declarations
(ebox-style-node-declarations source-index box))) (ebox-style-node-declarations source-index box)))
@ -3959,7 +3985,8 @@ cannot be changed through the unpublished batch root."
(nreverse changed)))) (nreverse changed))))
(defun ebox--surface-region-apply-props (defun ebox--surface-region-apply-props
(root source-index region-id expanded declarations style-declarations) (root source-index region-id expanded declarations style-declarations
&optional interactions)
"Apply DECLARATIONS then EXPANDED fields to REGION-ID in ROOT." "Apply DECLARATIONS then EXPANDED fields to REGION-ID in ROOT."
(let ((box (ebox--root-region-box root region-id)) (let ((box (ebox--root-region-box root region-id))
(candidate-source-index source-index) (candidate-source-index source-index)
@ -3970,11 +3997,15 @@ cannot be changed through the unpublished batch root."
(not (ebox-style-declarations-equal-p (not (ebox-style-declarations-equal-p
declarations declarations
(ebox-style-node-declarations source-index box))))) (ebox-style-node-declarations source-index box)))))
(when declarations-changed (when (or declarations-changed (plist-member expanded :surface-properties))
(let* ((old-handle (ebox-tree-node-source-handle box)) (let* ((old-handle (ebox-tree-node-source-handle box))
(binding (binding
(ebox-source-index-rebind (ebox-source-index-rebind
source-index old-handle :declarations declarations))) source-index old-handle :declarations declarations
:interactions (if (plist-member expanded :surface-properties)
interactions
(ebox-source-index-interactions
source-index old-handle)))))
(setq candidate-source-index (car binding)) (setq candidate-source-index (car binding))
(plist-put box :ebox-source-handle (cdr binding)))) (plist-put box :ebox-source-handle (cdr binding))))
(when (plist-member style-declarations 'ebox/outer) (when (plist-member style-declarations 'ebox/outer)
@ -3991,8 +4022,13 @@ cannot be changed through the unpublished batch root."
(cl-loop for (key value) on expanded by #'cddr (cl-loop for (key value) on expanded by #'cddr
for target-key = (ebox--region-update-normalize-property key) for target-key = (ebox--region-update-normalize-property key)
when (or (eq target-key :content) when (or (eq target-key :content)
(eq target-key :surface-properties)
(memq target-key ebox--region-update-longhand-props)) (memq target-key ebox--region-update-longhand-props))
when (or (not (equal (if (eq target-key :content) when (or (not (funcall
(if (eq target-key :surface-properties)
#'ebox-surface--retained-property-value-equal-p
#'equal)
(if (eq target-key :content)
(ebox--region-update-content-value box) (ebox--region-update-content-value box)
(ebox-get box target-key)) (ebox-get box target-key))
value)) value))
@ -4001,9 +4037,14 @@ cannot be changed through the unpublished batch root."
do (if (eq target-key :content) do (if (eq target-key :content)
(if-let* ((text (if-let* ((text
(ebox--region-update-direct-text-node box))) (ebox--region-update-direct-text-node box)))
(plist-put text :ebox-text-value value) (progn
(plist-put text :ebox-text-value value)
;; Incremental source signatures and retained owner
;; proofs read the engine content projection. Keep
;; it in step with the canonical Text payload.
(plist-put text :content value))
(user-error (user-error
"Ebox content update requires one direct Text child")) "Ebox content update requires Text or a Box with one direct Text child"))
(plist-put box target-key value)) (plist-put box target-key value))
and do (push target-key changed-keys))) and do (push target-key changed-keys)))
(list :changed-keys (nreverse changed-keys) (list :changed-keys (nreverse changed-keys)
@ -4232,7 +4273,8 @@ REGION-ID and CHANGED-KEYS describe one update; CHANGES describes a batch."
(source-index (source-index
(or (ebox-incremental-surface-batch-source-index buffer) (or (ebox-incremental-surface-batch-source-index buffer)
(plist-get state :source-index))) (plist-get state :source-index)))
(published-root (plist-get state :root-node)) (published-root (or (ebox-incremental-surface-batch-root buffer)
(plist-get state :root-node)))
(candidate-box (candidate-box
(or (ebox--root-region-box published-root region-id) (or (ebox--root-region-box published-root region-id)
(user-error "Ebox region handle no longer resolves to a box"))) (user-error "Ebox region handle no longer resolves to a box")))
@ -4245,11 +4287,27 @@ REGION-ID and CHANGED-KEYS describe one update; CHANGES describes a batch."
(ebox-style-node-declarations (ebox-style-node-declarations
source-index candidate-box) source-index candidate-box)
style-declarations)) style-declarations))
(interaction-delta
(ebox-interaction-normalize
(cl-loop for (property value) on props by #'cddr
when (memq property ebox-interaction-properties)
append (list property value))))
(interactions
(when interaction-delta
(let ((merged (ebox-source-index-interactions
source-index
(ebox-tree-node-source-handle candidate-box))))
(cl-loop for (property value) on interaction-delta by #'cddr
do (setq merged (plist-put merged property value)))
merged)))
(expanded (expanded
(append (append
(cl-loop for (property value) on props by #'cddr (cl-loop for (property value) on props by #'cddr
when (memq property ebox--region-update-control-properties) when (memq property '(:content :scroll-offset))
append (list property value)) append (list property value))
(when interaction-delta
(list :surface-properties
(ebox-interaction-surface-properties interactions)))
(ebox-style--expand-engine-delta (ebox-style--expand-engine-delta
style-declarations declarations style-declarations declarations
(plist-get candidate-box :ebox-computed-style)))) (plist-get candidate-box :ebox-computed-style))))
@ -4270,24 +4328,26 @@ REGION-ID and CHANGED-KEYS describe one update; CHANGES describes a batch."
(eq (ebox--region-update-normalize-property (car expanded)) (eq (ebox--region-update-normalize-property (car expanded))
:scroll-offset)) :scroll-offset))
(ebox--surface-scroll-to-offset buffer region-id (cadr expanded)) (ebox--surface-scroll-to-offset buffer region-id (cadr expanded))
(let* ((content-only-p (let* (;; Source-only text changes leave inline declarations and
;; selector subjects unchanged. Copy the owner path first;
;; the existing slot/style proof decides whether it is safe
;; to retain it. A widened projection takes a private copy
;; in `ebox-incremental-prepare-scoped-commit' before layout.
(content-only-p
(and (= (length expanded) 2) (and (= (length expanded) 2)
(eq (ebox--region-update-normalize-property (eq (ebox--region-update-normalize-property
(car expanded)) (car expanded))
:content) :content)
(stringp (cadr expanded)) (stringp (cadr expanded))
(numberp (ebox-get candidate-box :width))
(ebox--region-update-direct-text-node candidate-box) (ebox--region-update-direct-text-node candidate-box)
(not (ebox-style-cascade-active-p)) (not (ebox-style-cascade-active-p))))
(not (plist-get state :cascade-required-p))
(null (plist-get state :scroll-region-ids))))
(candidate-root (candidate-root
(ebox--surface-region-candidate-root (ebox--surface-region-candidate-root
buffer state region-id content-only-p)) buffer state region-id content-only-p))
(applied (applied
(ebox--surface-region-apply-props (ebox--surface-region-apply-props
candidate-root source-index candidate-root source-index
region-id expanded declarations style-declarations)) region-id expanded declarations style-declarations interactions))
(changed-keys (plist-get applied :changed-keys)) (changed-keys (plist-get applied :changed-keys))
(candidate-source-index (plist-get applied :source-index)) (candidate-source-index (plist-get applied :source-index))
(dirty-kind (dirty-kind
@ -4314,19 +4374,24 @@ REGION-ID and CHANGED-KEYS describe one update; CHANGES describes a batch."
;;;###autoload ;;;###autoload
(defun ebox-region-update (handle &rest props) (defun ebox-region-update (handle &rest props)
"Update the box identified by surface-scoped HANDLE with PROPS. "Update the node identified by HANDLE with PROPS.
HANDLE must come from ebox-region-resolve or an Ebox selector result. PROPS HANDLE is an opaque handle from `ebox-region-resolve' or an Ebox selector,
accepts mutable content, style, and scroll keywords supported by the target; or a semantic ID (string, non-nil symbol or integer) in the current buffer.
it does not add children or mutate selector metadata. The update builds an PROPS accepts mutable content, style, interaction and scroll keywords;
`:content' replaces a Text node's string or a Box's sole direct Text child.
It does not add children or mutate selector metadata. The update builds an
isolated candidate, runs the Ebox owner planner, and publishes once through the isolated candidate, runs the Ebox owner planner, and publishes once through the
mounted TP surface. Return nil while queued in an explicit batch; otherwise mounted TP surface. Return nil while queued in an explicit batch; otherwise
return the committed update report. return the committed update report.
Examples: Examples:
(ebox-region-update \"status\" :content \"Ready\")
(ebox-region-update (ebox-region-resolve buffer \"status\") (ebox-region-update (ebox-region-resolve buffer \"status\")
:content \"Ready\") :content \"Ready\")
(ebox-region-update handle :padding (list 1 2) (ebox-region-update handle :padding \='((lh 1) (ch 2))
:border-color \"red\")" :border-color \"red\")"
(when (ebox-source-semantic-id-p handle)
(setq handle (ebox-region-resolve (current-buffer) handle)))
(unless (ebox-region-handle-p handle) (unless (ebox-region-handle-p handle)
(signal (quote wrong-type-argument) (signal (quote wrong-type-argument)
(list (quote ebox-region-handle-p) handle))) (list (quote ebox-region-handle-p) handle)))
@ -4335,8 +4400,11 @@ Examples:
(let ((execute (let ((execute
(lambda () (lambda ()
(ebox--with-render-gc (ebox--with-render-gc
(ebox--surface-region-update-target (with-current-buffer buffer
buffer region-id handle props))))) (save-restriction
(widen)
(ebox--surface-region-update-target
buffer region-id handle props)))))))
(if (ebox-incremental-batching-p buffer) (if (ebox-incremental-batching-p buffer)
(funcall execute) (funcall execute)
(ebox-surface-call-with-observation buffer 'region execute))))) (ebox-surface-call-with-observation buffer 'region execute)))))
@ -4742,10 +4810,18 @@ Return the successful publication report stored by `ebox-buffer-update-report'."
(commit-input (commit-input
(let ((ebox-incremental--source-base-index (let ((ebox-incremental--source-base-index
source-base-index)) source-base-index))
(if (ebox-candidate-p next-root) ;; Slot proofs use absolute mounted coordinates.
(ebox-incremental-consume-candidate buffer next-root) ;; Capture them in the same complete buffer view the
(ebox-incremental-prepare-root-commit ;; surface publisher uses, even when the caller has
buffer next-root)))) ;; narrowed its editor view.
(with-current-buffer buffer
(save-restriction
(widen)
(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)) (source (plist-get commit-input :root))
(callback (callback
(or framework-publish (or framework-publish
@ -4795,8 +4871,8 @@ Return the successful publication report stored by `ebox-buffer-update-report'."
(defun ebox-rerender-buffer-with-context (defun ebox-rerender-buffer-with-context
(buffer viewport-width &optional viewport-height) (buffer viewport-width &optional viewport-height)
"Rerender BUFFER's stored runtime tree using VIEWPORT-WIDTH. "Rerender BUFFER's stored runtime tree using VIEWPORT-WIDTH.
When VIEWPORT-HEIGHT is non-nil, height values using `(viewport-height)' are VIEWPORT-WIDTH is pixels; optional VIEWPORT-HEIGHT is frame lines.
resolved against that line count. Viewport units and composed size expressions resolve against this context.
This preserves node and region identity and routes viewport-dependent changes This preserves node and region identity and routes viewport-dependent changes
through dirty-set and patch-set execution before falling back to root rerender." through dirty-set and patch-set execution before falling back to root rerender."
(let ((execute (let ((execute
@ -4865,6 +4941,8 @@ through dirty-set and patch-set execution before falling back to root rerender."
ebox-grid-layout-create ebox-grid-layout-create
ebox-host-ref-bounds ebox-host-ref-bounds
ebox-host-ref-position ebox-host-ref-position
ebox-help-create
ebox-keymap-create
ebox-native-build ebox-native-build
ebox-native-status ebox-native-status
ebox-region-ids ebox-region-ids

View File

@ -164,7 +164,7 @@ ebox_module_version(emacs_env *env, ptrdiff_t nargs, emacs_value *args,
(void) nargs; (void) nargs;
(void) args; (void) args;
(void) data; (void) data;
static const char version[] = "12:7:12"; static const char version[] = "13:7:12";
return env->make_string(env, version, (ptrdiff_t) (sizeof version - 1)); return env->make_string(env, version, (ptrdiff_t) (sizeof version - 1));
} }

View File

@ -5,7 +5,7 @@ use std::sync::Arc;
use super::evaluation::{record, EvalRecord, EvalWork, RenderScope, RenderedChange}; use super::evaluation::{record, EvalRecord, EvalWork, RenderScope, RenderedChange};
use super::line_plan::{ChangeKind, LineOp, LinePlan, LineSplice, PlanChange, ProjectionState}; use super::line_plan::{ChangeKind, LineOp, LinePlan, LineSplice, PlanChange, ProjectionState};
use super::{LayoutContext, LayoutNode, Line, LocalStep, Rendered}; use super::{LayoutContext, LayoutNode, LocalStep, Rendered};
#[derive(Debug, Default)] #[derive(Debug, Default)]
struct ShapeRoute { struct ShapeRoute {
@ -121,6 +121,7 @@ impl ColumnShape {
struct ColumnSlot { struct ColumnSlot {
record: Option<Arc<EvalRecord>>, record: Option<Arc<EvalRecord>>,
raw: LinePlan, raw: LinePlan,
empty_width: i64,
normalized: LinePlan, normalized: LinePlan,
projection: Option<Arc<ProjectionState>>, projection: Option<Arc<ProjectionState>>,
} }
@ -128,6 +129,7 @@ struct ColumnSlot {
impl ColumnSlot { impl ColumnSlot {
fn normalize( fn normalize(
raw: LinePlan, raw: LinePlan,
empty_width: i64,
child: Option<Arc<EvalRecord>>, child: Option<Arc<EvalRecord>>,
proof: Option<&PlanChange>, proof: Option<&PlanChange>,
target: i64, target: i64,
@ -163,6 +165,7 @@ impl ColumnSlot {
Arc::new(Self { Arc::new(Self {
record: child, record: child,
raw, raw,
empty_width,
normalized, normalized,
projection, projection,
}), }),
@ -183,7 +186,11 @@ struct ColumnMeasure {
impl ColumnMeasure { impl ColumnMeasure {
fn leaf(slot: &ColumnSlot) -> Self { fn leaf(slot: &ColumnSlot) -> Self {
let first = slot.raw.first_width(); let first = if slot.raw.is_empty() {
slot.empty_width
} else {
slot.raw.first_width()
};
let width = slot.raw.max_width(); let width = slot.raw.max_width();
Self { Self {
slots: 1, slots: 1,
@ -377,6 +384,7 @@ impl ColumnNode {
Self::leaf( Self::leaf(
ColumnSlot::normalize( ColumnSlot::normalize(
slot.raw.clone(), slot.raw.clone(),
slot.empty_width,
slot.record.clone(), slot.record.clone(),
Some(&same), Some(&same),
target, target,
@ -434,7 +442,7 @@ fn output(root: Option<&Arc<ColumnNode>>, previous: Option<&ColumnState>) -> Lin
}) { }) {
return previous.lines.clone(); return previous.lines.clone();
} }
LinePlan::from_lines([Line::default()]) LinePlan::default()
} }
// A concatenation owns its joining break, including the former last line when // A concatenation owns its joining break, including the former last line when
@ -482,6 +490,7 @@ pub(super) fn render_column<'a>(
.slot(index); .slot(index);
let (slot, change) = ColumnSlot::normalize( let (slot, change) = ColumnSlot::normalize(
result.rendered.lines, result.rendered.lines,
result.rendered.empty_width,
result.record, result.record,
result.change.as_ref(), result.change.as_ref(),
previous.target, previous.target,
@ -580,7 +589,15 @@ pub(super) fn render_column<'a>(
|RenderedChange { |RenderedChange {
rendered, record, .. rendered, record, ..
}| { }| {
ColumnSlot::normalize(rendered.lines, record, None, target, None).0 ColumnSlot::normalize(
rendered.lines,
rendered.empty_width,
record,
None,
target,
None,
)
.0
}, },
) )
.collect::<Vec<_>>(); .collect::<Vec<_>>();
@ -600,7 +617,11 @@ pub(super) fn render_column<'a>(
.map_or(0, |root| root.measure().normalized_max(state.target)), .map_or(0, |root| root.measure().normalized_max(state.target)),
state.lines.max_width() state.lines.max_width()
); );
let rendered = Rendered::from_line_plan(state.lines.clone()); let mut rendered = Rendered::from_line_plan(state.lines.clone());
rendered.empty_width = state
.root
.as_ref()
.map_or(0, |root| root.measure().max_first);
scope.store_column(state); scope.store_column(state);
Ok(rendered) Ok(rendered)
} }

View File

@ -124,16 +124,22 @@ mod composition_regressions {
.collect::<Vec<_>>(); .collect::<Vec<_>>();
lines.push(TapeLine { lines.push(TapeLine {
width: 4, width: 4,
atoms: vec![TapeAtom::Space { atoms: vec![
width: 4, TapeAtom::Space {
properties: AtomProperties { width: 0,
content: Some(1), properties: AtomProperties {
content_idx: Some(prefix_lines as i64), content: Some(20),
owner: Some(1), content_idx: Some(0),
owners: vec![1], owner: Some(1),
..AtomProperties::default() owners: vec![20, 1],
..AtomProperties::default()
},
}, },
}], TapeAtom::Space {
width: 4,
properties: parent_only.clone(),
},
],
break_after: Some(AtomProperties::default()), break_after: Some(AtomProperties::default()),
}); });
let mut suffix = owned_line(30, 0); let mut suffix = owned_line(30, 0);
@ -146,7 +152,7 @@ mod composition_regressions {
} }
#[test] #[test]
fn prefix_line_growth_and_shrink_update_only_the_inherited_suffix_content_index() { fn prefix_line_growth_and_shrink_preserve_suffix_carrier_ownership() {
let mut empty_suffix = nonuniform_text(20, &[0]); let mut empty_suffix = nonuniform_text(20, &[0]);
let LayoutNode::Text { content, .. } = &mut empty_suffix else { let LayoutNode::Text { content, .. } = &mut empty_suffix else {
unreachable!() unreachable!()

View File

@ -1223,9 +1223,6 @@ fn same_text_source(
} }
fn measured_text_work(text: &MeasuredText, property_template_count: u32) -> Result<usize, String> { fn measured_text_work(text: &MeasuredText, property_template_count: u32) -> Result<usize, String> {
if text.lines.is_empty() {
return Err("Native layout measured text must contain one line".to_owned());
}
let mut work = text.lines.len(); let mut work = text.lines.len();
for line in &text.lines { for line in &text.lines {
work = work work = work
@ -2012,12 +2009,10 @@ impl Line {
..AtomProperties::default() ..AtomProperties::default()
}; };
if cluster.pixel_space { if cluster.pixel_space {
if cluster.width > 0 { atoms.push(Atom::Space {
atoms.push(Atom::Space { width: cluster.width,
width: cluster.width, properties,
properties, });
});
}
} else { } else {
atoms.push(Atom::Text { atoms.push(Atom::Text {
text: cluster.text.clone(), text: cluster.text.clone(),
@ -2085,15 +2080,16 @@ impl Line {
} }
fn blank(width: i64) -> Self { fn blank(width: i64) -> Self {
let mut line = Self::default(); Self::blank_with_properties(width, AtomProperties::default())
line.push_space(width);
line
} }
fn blank_with_properties(width: i64, properties: AtomProperties) -> Self { fn blank_with_properties(width: i64, properties: AtomProperties) -> Self {
let mut line = Self::default(); // A requested line has height even at zero width. Optional horizontal
line.push_space_with_properties(width, properties); // edge/gap insertion still uses push/prepend_space and skips width zero.
line Self::from_atoms(&[Atom::Space {
width: width.max(0),
properties,
}])
} }
fn whitespace_only(&self) -> bool { fn whitespace_only(&self) -> bool {
@ -2127,9 +2123,7 @@ impl Line {
if properties.content.is_none() { if properties.content.is_none() {
properties.content = Some(region_id); properties.content = Some(region_id);
} }
let mut line = Self::default(); Self::blank_with_properties(width, properties)
line.push_space_with_properties(width, properties);
line
} }
fn apply_style(&mut self, style_id: Option<u32>) { fn apply_style(&mut self, style_id: Option<u32>) {
@ -2191,6 +2185,9 @@ impl Atom {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
struct Rendered { struct Rendered {
lines: LinePlan, lines: LinePlan,
// A zero-height box still has an inline layout extent. Text lines alone
// cannot retain that fact for Row slots, Flex bases, or intrinsic Columns.
empty_width: i64,
root_scroll: Option<Arc<RootScrollPlan>>, root_scroll: Option<Arc<RootScrollPlan>>,
own_scroll_owner: bool, own_scroll_owner: bool,
scroll_owners: u8, scroll_owners: u8,
@ -2220,6 +2217,7 @@ impl Rendered {
fn from_lines(lines: Vec<Line>) -> Self { fn from_lines(lines: Vec<Line>) -> Self {
Self { Self {
lines: LinePlan::from_lines(lines), lines: LinePlan::from_lines(lines),
empty_width: 0,
root_scroll: None, root_scroll: None,
own_scroll_owner: false, own_scroll_owner: false,
scroll_owners: 0, scroll_owners: 0,
@ -2229,6 +2227,7 @@ impl Rendered {
fn from_line_plan(lines: LinePlan) -> Self { fn from_line_plan(lines: LinePlan) -> Self {
Self { Self {
lines, lines,
empty_width: 0,
root_scroll: None, root_scroll: None,
own_scroll_owner: false, own_scroll_owner: false,
scroll_owners: 0, scroll_owners: 0,
@ -2236,15 +2235,23 @@ impl Rendered {
} }
fn first_width(&self) -> i64 { fn first_width(&self) -> i64 {
self.lines.first_width() if self.lines.is_empty() {
self.empty_width
} else {
self.lines.first_width()
}
} }
fn max_width(&self) -> i64 { fn max_width(&self) -> i64 {
self.lines.max_width() if self.lines.is_empty() {
self.empty_width
} else {
self.lines.max_width()
}
} }
fn min_content_width(&self, wrap_mode: WrapMode) -> i64 { fn min_content_width(&self, wrap_mode: WrapMode) -> i64 {
if wrap_mode == WrapMode::None { if wrap_mode == WrapMode::None || self.lines.is_empty() {
return self.max_width(); return self.max_width();
} }
self.lines.min_content_width() self.lines.min_content_width()
@ -3855,9 +3862,6 @@ fn validate_tape_style_ids(properties: &AtomProperties, style_count: u32) -> Res
} }
fn flatten_layout_tape(tape: LayoutTape, complete: bool) -> Result<FlatLayoutTape, String> { fn flatten_layout_tape(tape: LayoutTape, complete: bool) -> Result<FlatLayoutTape, String> {
if tape.lines.is_empty() {
return Err("Native layout tape has no lines".to_owned());
}
let line_count = tape.lines.len(); let line_count = tape.lines.len();
let mut characters = Vec::new(); let mut characters = Vec::new();
for (line_index, line) in tape.lines.into_iter().enumerate() { for (line_index, line) in tape.lines.into_iter().enumerate() {
@ -3892,9 +3896,6 @@ fn flatten_layout_tape(tape: LayoutTape, complete: bool) -> Result<FlatLayoutTap
TapeAtom::Space { width, properties } => { TapeAtom::Space { width, properties } => {
validate_tape_style_ids(&properties, tape.style_count)?; validate_tape_style_ids(&properties, tape.style_count)?;
let width = tape_width(width, "space width")?; let width = tape_width(width, "space width")?;
if width == 0 {
return Err("Native layout tape has empty pixel space".to_owned());
}
characters.push(TapeCharacter { characters.push(TapeCharacter {
value: ' ', value: ' ',
pixel_width: Some(width), pixel_width: Some(width),
@ -4330,9 +4331,6 @@ pub(crate) fn encode_layout_tape_with_scroll(
max_bytes: usize, max_bytes: usize,
scroll: Option<&RootScrollPlan>, scroll: Option<&RootScrollPlan>,
) -> Result<Vec<u8>, String> { ) -> Result<Vec<u8>, String> {
if tape.lines.is_empty() {
return Err("Native layout tape has no lines".to_owned());
}
let line_count = count_u32(tape.lines.len(), "lines")?; let line_count = count_u32(tape.lines.len(), "lines")?;
if tape.style_count as usize != styles.len() { if tape.style_count as usize != styles.len() {
return Err("Native layout tape style table mismatch".to_owned()); return Err("Native layout tape style table mismatch".to_owned());
@ -4386,9 +4384,6 @@ pub(crate) fn encode_layout_tape_with_scroll(
} }
TapeAtom::Space { width, properties } => { TapeAtom::Space { width, properties } => {
let width = tape_width(width, "space width")?; let width = tape_width(width, "space width")?;
if width == 0 {
return Err("Native layout tape has empty pixel space".to_owned());
}
let start = character_count; let start = character_count;
text.push(' '); text.push(' ');
spaces.push(TapeSpaceSpan { start, width }); spaces.push(TapeSpaceSpan { start, width });
@ -4865,9 +4860,6 @@ fn validate_node(
); );
} }
} }
if content.lines.is_empty() {
return Err("Native layout Text must contain one line".to_owned());
}
add_work_units(work_units, content.lines.len())?; add_work_units(work_units, content.lines.len())?;
for line in &content.lines { for line in &content.lines {
add_work_units(work_units, line.clusters.len())?; add_work_units(work_units, line.clusters.len())?;
@ -5011,9 +5003,6 @@ fn validate_node(
} }
} }
if let Some(text) = content { if let Some(text) = content {
if text.lines.is_empty() {
return Err("Native layout measured text must contain one line".to_owned());
}
add_work_units(work_units, text.lines.len())?; add_work_units(work_units, text.lines.len())?;
for line in &text.lines { for line in &text.lines {
add_work_units(work_units, line.clusters.len())?; add_work_units(work_units, line.clusters.len())?;
@ -5403,7 +5392,7 @@ fn resolve_definite_box_content_height(
.unwrap_or(i64::MAX); .unwrap_or(i64::MAX);
let preferred = override_height let preferred = override_height
.or_else(|| resolve_height(height, None, context, sizing, padding_top, padding_bottom))?; .or_else(|| resolve_height(height, None, context, sizing, padding_top, padding_bottom))?;
Some(minimum.max(1).max(preferred.max(1).min(maximum))) Some(minimum.max(0).max(preferred.max(0).min(maximum)))
} }
fn measured_max_width(text: &MeasuredText) -> i64 { fn measured_max_width(text: &MeasuredText) -> i64 {
@ -5442,7 +5431,7 @@ fn measured_min_width(text: &MeasuredText, wrap_mode: WrapMode) -> i64 {
fn wrap_line(clusters: &[MeasuredCluster], max_width: i64, mode: WrapMode) -> Vec<Line> { fn wrap_line(clusters: &[MeasuredCluster], max_width: i64, mode: WrapMode) -> Vec<Line> {
if clusters.is_empty() { if clusters.is_empty() {
return vec![Line::default()]; return vec![Line::blank(0)];
} }
let total: i64 = clusters.iter().map(|cluster| cluster.width).sum(); let total: i64 = clusters.iter().map(|cluster| cluster.width).sum();
if mode == WrapMode::None || total <= max_width { if mode == WrapMode::None || total <= max_width {
@ -5534,7 +5523,7 @@ fn measured_lines(text: &MeasuredText, width: i64, mode: WrapMode) -> Vec<Line>
fn wrap_rendered_line(line: &Line, max_width: i64, mode: WrapMode) -> Vec<Line> { fn wrap_rendered_line(line: &Line, max_width: i64, mode: WrapMode) -> Vec<Line> {
if line.atoms.is_empty() { if line.atoms.is_empty() {
return vec![Line::default()]; return vec![Line::blank(0)];
} }
if mode == WrapMode::None || line.width <= max_width { if mode == WrapMode::None || line.width <= max_width {
return vec![line_plan::clone_line(line)]; return vec![line_plan::clone_line(line)];
@ -5620,7 +5609,7 @@ fn wrap_rendered_line(line: &Line, max_width: i64, mode: WrapMode) -> Vec<Line>
} }
fn wrap_rendered(rendered: Rendered, max_width: i64, mode: WrapMode) -> Rendered { fn wrap_rendered(rendered: Rendered, max_width: i64, mode: WrapMode) -> Rendered {
if mode == WrapMode::None { if mode == WrapMode::None || rendered.lines.is_empty() {
return rendered; return rendered;
} }
let mut lines = LinePlan::default(); let mut lines = LinePlan::default();
@ -5933,7 +5922,7 @@ fn flex_min_main(
box_vertical_side(source).unwrap_or(0) box_vertical_side(source).unwrap_or(0)
+ flex_box_resolve_height(source, min_height, Some(0), context) + flex_box_resolve_height(source, min_height, Some(0), context)
.unwrap_or(0) .unwrap_or(0)
.max(1) .max(0)
} }
}) })
} }
@ -6353,6 +6342,7 @@ fn flex_resolved_align(item: &FlexRuntimeItem<'_>, container: FlexAlign) -> Flex
} }
fn pad_rendered_width(mut rendered: Rendered, width: i64, align: FlexAlign) -> Rendered { fn pad_rendered_width(mut rendered: Rendered, width: i64, align: FlexAlign) -> Rendered {
rendered.empty_width = rendered.empty_width.max(width);
rendered.lines = rendered.lines.map_lines(|_, mut line| { rendered.lines = rendered.lines.map_lines(|_, mut line| {
let extra = (width - line.width).max(0); let extra = (width - line.width).max(0);
let left = match align { let left = match align {
@ -6481,11 +6471,12 @@ fn render_flex_sized_entry(
} }
fn concat_horizontal_sized(parts: Vec<(Rendered, i64)>, target_height: i64) -> Rendered { fn concat_horizontal_sized(parts: Vec<(Rendered, i64)>, target_height: i64) -> Rendered {
let empty_width = parts.iter().map(|(_, width)| width).sum();
let height = parts let height = parts
.iter() .iter()
.map(|(rendered, _)| rendered.height()) .map(|(rendered, _)| rendered.height())
.max() .max()
.unwrap_or(1) .unwrap_or(0)
.max(target_height); .max(target_height);
let mut parts = parts let mut parts = parts
.iter() .iter()
@ -6500,9 +6491,15 @@ fn concat_horizontal_sized(parts: Vec<(Rendered, i64)>, target_height: i64) -> R
line.push_space(*width); line.push_space(*width);
} }
} }
line if line.atoms.is_empty() {
Line::blank(0)
} else {
line
}
})); }));
Rendered::from_line_plan(lines) let mut rendered = Rendered::from_line_plan(lines);
rendered.empty_width = empty_width;
rendered
} }
fn stack_vertical(parts: Vec<Rendered>) -> Rendered { fn stack_vertical(parts: Vec<Rendered>) -> Rendered {
@ -6516,11 +6513,10 @@ fn stack_vertical(parts: Vec<Rendered>) -> Rendered {
} }
} }
} }
let mut lines = join_parts(&parts); let lines = join_parts(&parts);
if lines.is_empty() { let mut rendered = Rendered::from_line_plan(lines);
lines = LinePlan::from_lines([Line::default()]); rendered.empty_width = parts.iter().map(Rendered::max_width).max().unwrap_or(0);
} rendered
Rendered::from_line_plan(lines)
} }
fn slice_rendered(rendered: Rendered, start: i64, height: i64) -> Rendered { fn slice_rendered(rendered: Rendered, start: i64, height: i64) -> Rendered {
@ -6528,7 +6524,9 @@ fn slice_rendered(rendered: Rendered, start: i64, height: i64) -> Rendered {
let height = usize::try_from(height.max(0)).unwrap_or(0); let height = usize::try_from(height.max(0)).unwrap_or(0);
let end = start.saturating_add(height).min(rendered.lines.len()); let end = start.saturating_add(height).min(rendered.lines.len());
if start >= end { if start >= end {
return Rendered::from_line_plan(LinePlan::default()); let mut empty = Rendered::from_line_plan(LinePlan::default());
empty.empty_width = rendered.max_width();
return empty;
} }
Rendered::from_line_plan(rendered.lines.slice(start..end)) Rendered::from_line_plan(rendered.lines.slice(start..end))
} }
@ -6575,7 +6573,7 @@ fn exact_rendered_height(
LayoutNode::Row { children, .. } => children LayoutNode::Row { children, .. } => children
.iter() .iter()
.map(|child| exact_rendered_height(child, resolver, context)) .map(|child| exact_rendered_height(child, resolver, context))
.try_fold(1_i64, |maximum, height| { .try_fold(0_i64, |maximum, height| {
height.map(|height| maximum.max(height)) height.map(|height| maximum.max(height))
}), }),
LayoutNode::Column { children, .. } => children LayoutNode::Column { children, .. } => children
@ -6772,7 +6770,10 @@ fn render_flex_row_line(
let line_main = flex_line_main_size(line, main_gap); let line_main = flex_line_main_size(line, main_gap);
let (leading, between, trailing) = let (leading, between, trailing) =
flex_spacing(justify, main_size - line_main, line.len(), main_gap); flex_spacing(justify, main_size - line_main, line.len(), main_gap);
let mut parts = vec![(Rendered::from_lines(vec![Line::blank(leading)]), leading)]; let mut parts = Vec::new();
if leading > 0 {
parts.push((Rendered::from_line_plan(LinePlan::default()), leading));
}
for (index, item) in line.iter().enumerate() { for (index, item) in line.iter().enumerate() {
let entry = render_flex_sized_entry( let entry = render_flex_sized_entry(
item, item,
@ -6785,12 +6786,12 @@ fn render_flex_row_line(
intrinsic, intrinsic,
)?; )?;
parts.push((entry.rendered, item.target)); parts.push((entry.rendered, item.target));
if index + 1 < line.len() { if index + 1 < line.len() && between > 0 {
parts.push((Rendered::from_lines(vec![Line::blank(between)]), between)); parts.push((Rendered::from_line_plan(LinePlan::default()), between));
} }
} }
if trailing > 0 { if trailing > 0 {
parts.push((Rendered::from_lines(vec![Line::blank(trailing)]), trailing)); parts.push((Rendered::from_line_plan(LinePlan::default()), trailing));
} }
Ok(concat_horizontal_sized(parts, line_cross)) Ok(concat_horizontal_sized(parts, line_cross))
} }
@ -6941,10 +6942,13 @@ fn render_flex_column(
}; };
let cross_layout = let cross_layout =
flex_layout_cross(crosses, cross_size, cross_gap, align_content, single_line); flex_layout_cross(crosses, cross_size, cross_gap, align_content, single_line);
let mut parts = vec![( let mut parts = Vec::new();
Rendered::from_lines(vec![Line::blank(cross_layout.leading)]), if cross_layout.leading > 0 {
cross_layout.leading, parts.push((
)]; Rendered::from_line_plan(LinePlan::default()),
cross_layout.leading,
));
}
let line_count = lines.len(); let line_count = lines.len();
for (index, (line, line_cross)) in lines for (index, (line, line_cross)) in lines
.iter() .iter()
@ -6955,14 +6959,14 @@ fn render_flex_column(
line, line_cross, main_size, main_gap, justify, align, context, intrinsic, line, line_cross, main_size, main_gap, justify, align, context, intrinsic,
)?; )?;
parts.push((rendered, line_cross)); parts.push((rendered, line_cross));
if index + 1 < line_count { if index + 1 < line_count && cross_layout.between > 0 {
parts.push(( parts.push((
Rendered::from_lines(vec![Line::blank(cross_layout.between)]), Rendered::from_line_plan(LinePlan::default()),
cross_layout.between, cross_layout.between,
)); ));
} }
} }
let mut rendered = concat_horizontal_sized(parts, 1); let mut rendered = concat_horizontal_sized(parts, 0);
if let Some(cross) = cross_size { if let Some(cross) = cross_size {
rendered = pad_rendered_width(rendered, cross, FlexAlign::FlexStart); rendered = pad_rendered_width(rendered, cross, FlexAlign::FlexStart);
} }
@ -7424,6 +7428,7 @@ fn render_node_body(
}); });
if transparent_preformatted { if transparent_preformatted {
let mut rendered = child_rendered.take().expect("validated child"); let mut rendered = child_rendered.take().expect("validated child");
rendered.empty_width = content_width;
rendered.root_scroll = None; rendered.root_scroll = None;
rendered.own_scroll_owner = false; rendered.own_scroll_owner = false;
let mut ops = vec![LineOp::OwnContent { let mut ops = vec![LineOp::OwnContent {
@ -7550,8 +7555,8 @@ fn render_node_body(
.unwrap_or(text_height) .unwrap_or(text_height)
}); });
let content_height = minimum_height let content_height = minimum_height
.max(1) .max(0)
.max(preferred_height.max(1).min(maximum_height)); .max(preferred_height.max(0).min(maximum_height));
let simple_scroll_rendered = *overflow == Overflow::Scroll let simple_scroll_rendered = *overflow == Overflow::Scroll
&& text_height >= content_height && text_height >= content_height
&& *padding_left == 0 && *padding_left == 0
@ -7711,6 +7716,12 @@ fn render_node_body(
.into(), .into(),
}, },
); );
// Horizontal border paint needs a carrier even when auto content
// and block padding have no lines. Background alone does not.
if padded.is_empty() && (border_top_style.is_some() || border_bottom_style.is_some()) {
padded = LinePlan::from_lines([Line::blank(content_width)]);
change = None;
}
let mut edge_ops = vec![LineOp::EdgeSpaces { let mut edge_ops = vec![LineOp::EdgeSpaces {
left: *padding_left, left: *padding_left,
left_properties: region_properties(RegionRole::PaddingLeft, *region_id, None) left_properties: region_properties(RegionRole::PaddingLeft, *region_id, None)
@ -7809,6 +7820,7 @@ fn render_node_body(
}, },
); );
let mut rendered = Rendered::from_line_plan(output); let mut rendered = Rendered::from_line_plan(output);
rendered.empty_width = total_width;
if !overflow_lines.is_empty() { if !overflow_lines.is_empty() {
let left_space = margin_left + border_left + padding_left; let left_space = margin_left + border_left + padding_left;
let right_space = padding_right + border_right + margin_right; let right_space = padding_right + border_right + margin_right;
@ -8175,6 +8187,149 @@ mod tests {
} }
} }
#[test]
fn empty_auto_box_and_stack_have_zero_height() {
let mut root = text_box(1, measured_text(vec![]), None);
for (declared, expected) in [
(Size::Auto, 0),
(Size::Lines { value: 0 }, 0),
(Size::Lines { value: 2 }, 2),
] {
let LayoutNode::Box { height, .. } = &mut root else {
unreachable!()
};
*height = declared;
let document = LayoutDocument {
version: LAYOUT_VERSION,
space_width: 1,
style_count: 0,
property_template_count: 0,
styles: Vec::new(),
root: root.clone(),
};
document.validate().unwrap();
let tape = document.layout_tape(test_context(), None).unwrap();
let bytes = encode_layout_tape(tape, &[], complete_identity(), false, 4096)
.expect("zero-line frames must serialize successfully");
assert_eq!(
u32::from_le_bytes(bytes[120..124].try_into().unwrap()) as usize,
expected
);
assert_eq!(
document
.layout_tape(test_context(), None)
.unwrap()
.lines
.len(),
expected
);
}
assert_eq!(stack_vertical(vec![]).height(), 0);
assert_eq!(
stack_vertical(vec![Rendered::from_line_plan(LinePlan::default())]).height(),
0
);
}
#[test]
fn zero_width_carriers_preserve_source_properties_and_encode_at_both_boundaries() {
let mut source = cluster(" ", 0, Some(0));
source.pixel_space = true;
let mut line = Line::from_clusters(&[source]);
line.own_content(17, 3);
line.apply_role(RegionRole::PaddingTop, 17);
let rendered = Rendered::from_lines(vec![line]);
assert_eq!(rendered.height(), 1);
assert_eq!(rendered.max_width(), 0);
assert_eq!(rendered.lines.prefix_chars(1), 1);
let flat = flatten_layout_tape(rendered.clone().into_tape(0), true).unwrap();
assert_eq!(flat.characters.len(), 1);
assert_eq!(flat.characters[0].value, ' ');
assert_eq!(flat.characters[0].pixel_width, Some(0));
assert_eq!(flat.characters[0].properties.content, Some(17));
assert_eq!(flat.characters[0].properties.content_idx, Some(3));
assert_eq!(flat.characters[0].properties.property_template_ids, vec![0]);
let encoded =
encode_layout_tape(rendered.into_tape(0), &[], complete_identity(), false, 4096)
.unwrap();
assert!(literal_from_full_tape(&encoded).contains("(space :width (0))"));
let blank =
Line::blank_with_properties(0, region_properties(RegionRole::PaddingTop, 17, None));
assert_eq!(blank.atoms.char_count(), 1);
assert!(blank.atoms.has_noncontent_properties());
let mut optional_edges = Line::default();
optional_edges.push_space(0);
optional_edges.prepend_space(0);
assert!(optional_edges.atoms.is_empty());
}
#[test]
fn zero_height_inline_extent_survives_row_and_retained_column_composition() {
for width in [0, 10] {
let mut blank = text_box(11, measured_text(vec![]), None);
let LayoutNode::Box {
width: declared, ..
} = &mut blank
else {
unreachable!();
};
*declared = Size::Pixels { value: width };
for nested in [false, true] {
let empty = if nested {
child_box(
12,
LayoutNode::Column {
node_id: None,
node_revision: None,
children: Arc::new(vec![identified(blank.clone(), 3, 1)]),
},
None,
)
} else {
blank.clone()
};
let root = identified(
LayoutNode::Row {
node_id: None,
node_revision: None,
children: Arc::new(vec![
identified(empty, 2, 1),
identified(
text_box(
13,
measured_text(vec![vec![cluster("B", 1, None)]]),
None,
),
4,
1,
),
]),
},
1,
1,
);
let source = retained_document(root);
let eager = source.layout_tape(test_context(), None).unwrap();
let (retained, _) = RetainedDocument::bootstrap(source).unwrap();
let frame = retained
.render_frame(None, None, test_context(), None)
.unwrap();
assert_eq!(frame.materialize_tape(), eager);
assert_eq!(eager.lines.len(), 1);
assert_eq!(eager.lines[0].width, width + 1);
let prefix: i64 = eager.lines[0]
.atoms
.iter()
.take_while(|atom| !matches!(atom, TapeAtom::Text { text, .. } if text == "B"))
.map(|atom| match atom {
TapeAtom::Text { width, .. } | TapeAtom::Space { width, .. } => *width,
})
.sum();
assert_eq!(prefix, width);
}
}
}
fn auto_text_box( fn auto_text_box(
region_id: i64, region_id: i64,
content: MeasuredText, content: MeasuredText,
@ -9043,6 +9198,7 @@ mod tests {
#[test] #[test]
fn line_sequence_oracle_wrap_slice_and_join_keep_distinct_breaks() { fn line_sequence_oracle_wrap_slice_and_join_keep_distinct_breaks() {
let source = Rendered { let source = Rendered {
empty_width: 0,
root_scroll: None, root_scroll: None,
own_scroll_owner: false, own_scroll_owner: false,
scroll_owners: 0, scroll_owners: 0,

View File

@ -131,10 +131,10 @@ impl LineOp {
Self::CollapseWhitespace { width, .. } if line.whitespace && !line.noncontent => { Self::CollapseWhitespace { width, .. } if line.whitespace && !line.noncontent => {
line = LineMetric { line = LineMetric {
width: (*width).max(0), width: (*width).max(0),
chars: u64::from(*width > 0), chars: 1,
min_content: 0, min_content: 0,
nonempty: *width > 0, nonempty: true,
whitespace: *width > 0, whitespace: true,
noncontent: false, noncontent: false,
}; };
} }

View File

@ -59,6 +59,13 @@
tp-surface-mount tp-surface-mount
tp-surface-update tp-surface-update
tp-surface-update-scoped tp-surface-update-scoped
tp--prepare-surface
tp--publish-buffer-content
tp--publish-one-surface
tp--capture-property-journals
tp--surface-state-snapshots
tp--validate-retained-batch-precommit
tp--validate-surface-precommit
ebox-surface--publish-runtime-state)) ebox-surface--publish-runtime-state))
"Functions grouped by computation or publication stage.") "Functions grouped by computation or publication stage.")
@ -299,8 +306,8 @@
(append (append
(list 'box (list 'box
:key (format "cell-%d-%d" row column) :key (format "cell-%d-%d" row column)
:width '(52) :width '(px 52)
:padding '(0 1) :padding '((lh 0) (ch 1))
:color "#0F172A" :color "#0F172A"
:bgcolor "#F8FAFC") :bgcolor "#F8FAFC")
(when (and (= row 2) (= column 1)) '(:id target)) (when (and (= row 2) (= column 1)) '(:id target))
@ -320,8 +327,8 @@
(push (push
(list 'box (list 'box
:key (format "resize-item-%d" index) :key (format "resize-item-%d" index)
:width '(70) :width '(px 70)
:padding '(0 1) :padding '((lh 0) (ch 1))
:color "#172554" :color "#172554"
:bgcolor "#DBEAFE" :bgcolor "#DBEAFE"
(format "resize-%02d" index)) (format "resize-%02d" index))
@ -329,10 +336,10 @@
(ebox-build (ebox-build
(append (list 'flex (append (list 'flex
:key 'resize-root :key 'resize-root
:width '(viewport) :width '(vw 100)
:flex-wrap 'wrap :flex-wrap 'wrap
:column-gap '(6) :column-gap '(px 6)
:row-gap 1) :row-gap '(lh 1))
(nreverse items))))) (nreverse items)))))
(defun ebox-performance-evaluator--pure-static () (defun ebox-performance-evaluator--pure-static ()

View File

@ -30,25 +30,25 @@
"Return the fixed layout used by visual verification." "Return the fixed layout used by visual verification."
(ebox-build (ebox-build
'(column :id dashboard '(column :id dashboard
(box :id title :box-sizing border-box :width 74 (box :id title :box-sizing border-box :width (ch 74)
:padding (0 1) :border "#4A90D9" :padding ((lh 0) (ch 1)) :border "#4A90D9"
:color "#EAF6FF" :bgcolor "#203040" :color "#EAF6FF" :bgcolor "#203040"
"Ebox Visual Check") "Ebox Visual Check")
(row :id cards (row :id cards
(box :id hidden :box-sizing border-box :width 24 :height 3 (box :id hidden :box-sizing border-box :width (ch 24) :height (lh 3)
:padding (1 1) :border "#27AE60" :padding ((lh 1) (ch 1)) :border "#27AE60"
:color "#F0FFF4" :bgcolor "#17351F" :overflow hidden :color "#F0FFF4" :bgcolor "#17351F" :overflow hidden
"Hidden: visible line\nHIDDEN-CLIPPED-LINE") "Hidden: visible line\nHIDDEN-CLIPPED-LINE")
(box :id scroll :box-sizing border-box :width 24 :height 3 (box :id scroll :box-sizing border-box :width (ch 24) :height (lh 3)
:padding (1 1) :border "#E67E22" :padding ((lh 1) (ch 1)) :border "#E67E22"
:color "#FFF4E6" :bgcolor "#3A2410" :overflow scroll :color "#FFF4E6" :bgcolor "#3A2410" :overflow scroll
"Scroll: first\nSCROLL-SECOND\nSCROLL-THIRD") "Scroll: first\nSCROLL-SECOND\nSCROLL-THIRD")
(box :id sized :box-sizing border-box :width 26 :max-height 3 (box :id sized :box-sizing border-box :width (ch 26) :max-height (lh 3)
:padding (1 1) :border "#9B59B6" :padding ((lh 1) (ch 1)) :border "#9B59B6"
:color "#F8EEFF" :bgcolor "#2F1B3A" :overflow hidden :color "#F8EEFF" :bgcolor "#2F1B3A" :overflow hidden
"border-box max-height\nline 2\nSIZED-CLIPPED")) "border-box max-height\nline 2\nSIZED-CLIPPED"))
(box :id footer :box-sizing border-box :width 74 (box :id footer :box-sizing border-box :width (ch 74)
:padding (0 1) :border "#7F8C8D" :padding ((lh 0) (ch 1)) :border "#7F8C8D"
:color "#F8F9F9" :bgcolor "#2C3E50" :color "#F8F9F9" :bgcolor "#2C3E50"
"Checks: text properties + display specs + GUI screenshot")))) "Checks: text properties + display specs + GUI screenshot"))))

View File

@ -0,0 +1,426 @@
#!/usr/bin/env python3
"""Migrate explicit Elisp paths to Ebox CSS sizes without evaluating their code.
Dry-run: python3 scripts/migrate-css-sizes.py ../ebox-playground/examples
Apply: python3 scripts/migrate-css-sizes.py --write path/to/example.ebox
Scoped: python3 scripts/migrate-css-sizes.py --callers ebox-build,ebox-create tests
Tests: python3 -m unittest discover -s scripts -p 'test_migrate_css_sizes.py'
Directories include .el/.ebox/.etaf/.ecss files and skip generated, Git, and historical
trees. Comments, strings, reader quoting, and whitespace outside replaced
values are preserved. This is a property migration, not a type checker: pass
public style/DSL callers, not engine structs whose similarly named fields are
already measured sizes. Inspect the diff. Dynamic expressions, removed forms,
and ambiguous axes are reported for manual review, never evaluated or guessed.
Inline units are px/ch/vw/%, block units are lh/vh/%, and border strokes use
non-percent lengths. Function operands obey the same rule recursively. Flex
bases use a directly enclosing Flex direction when statically available.
Exit 0 means no review items, 1 means review is needed, 2 means an I/O or reader
error. --write applies safe edits even when review items remain. Each file is
atomically replaced; interruption/error cleans up its temporary file.
"""
import argparse
from dataclasses import dataclass, field
import os
from pathlib import Path
import re
import sys
import tempfile
NUMBER = re.compile(r"[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?\Z")
UNITS = {"px", "%", "vw", "vh", "ch", "lh"}
AXIS_UNITS = {"ch": {"px", "ch", "vw", "%"},
"lh": {"lh", "vh", "%"},
"px": UNITS - {"%"}, None: {"%"}}
FUNCTIONS = {"calc", "min", "max", "clamp"}
KEYWORDS = {"auto", "none", "min-content", "max-content", "fit-content"}
KEYWORDS.add("stretch")
SKIP_DIRS = {".git", ".omx", "node_modules", "target", "__pycache__",
"emacs-box", "archive", "historical"}
@dataclass
class Form:
start: int
end: int
kind: str
text: str = ""
children: list = field(default_factory=list)
class Reader:
"""A non-evaluating reader retaining source offsets, comments, and quoting."""
def __init__(self, source):
self.source = source
self.pos = 0
def whitespace(self):
while self.pos < len(self.source):
char = self.source[self.pos]
if char.isspace():
self.pos += 1
elif char == ";":
end = self.source.find("\n", self.pos)
self.pos = len(self.source) if end < 0 else end + 1
else:
break
def read(self):
self.whitespace()
start = self.pos
if start == len(self.source):
raise ValueError("unexpected end of input")
char = self.source[self.pos]
self.pos += 1
if char in "([":
closer = ")" if char == "(" else "]"
children = []
while True:
self.whitespace()
if self.pos == len(self.source):
raise ValueError(f"unclosed {char} at offset {start}")
if self.source[self.pos] == closer:
self.pos += 1
return Form(start, self.pos, "list", char, children)
children.append(self.read())
if char in ")]":
raise ValueError(f"unexpected {char} at offset {start}")
if char in "'`," or (char == "#" and self.source[self.pos:self.pos + 1] == "'"):
if char == "#":
self.pos += 1
char = "#'"
if char == "," and self.source[self.pos:self.pos + 1] == "@":
self.pos += 1
char = ",@"
child = self.read()
return Form(start, child.end, "prefix", char, [child])
if char == '"':
while self.pos < len(self.source):
char = self.source[self.pos]
self.pos += 1
if char == "\\":
self.pos += 1
elif char == '"':
return Form(start, self.pos, "string")
raise ValueError(f"unclosed string at offset {start}")
if char == "?":
# Character literals can contain quote/paren/string delimiters.
if self.source[self.pos:self.pos + 1] == "\\":
self.pos += 1
self.pos += 1
while self.pos < len(self.source):
char = self.source[self.pos]
if char.isspace() or char in "()[];'`\",":
break
self.pos += 2 if char == "\\" else 1
return Form(start, self.pos, "atom", self.source[start:self.pos])
def forms(self):
result = []
self.whitespace()
while self.pos < len(self.source):
result.append(self.read())
self.whitespace()
return result
class Migration:
def __init__(self, source, callers=None, literal=False):
self.source = source
self.callers = set(callers) if callers else None
self.literal = literal
self.edits = []
self.reviews = []
def raw(self, node):
return self.source[node.start:node.end]
def review(self, node, reason):
self.reviews.append((self.source.count("\n", 0, node.start) + 1, reason,
self.raw(node).replace("\n", " ")[:100]))
return self.raw(node)
@staticmethod
def head(node):
return node.children[0].text if node.kind == "list" and node.children else ""
@staticmethod
def numeric(node):
return node.kind == "atom" and bool(NUMBER.fullmatch(node.text))
def compose(self, node, replacements):
result = self.raw(node)
for child, value in reversed(replacements):
left, right = child.start - node.start, child.end - node.start
result = result[:left] + value + result[right:]
return result
def check_units(self, node, unit):
"""Report axis-invalid lengths, including nested function operands."""
head = self.head(node)
if node.kind == "prefix":
self.review(node, "dynamic size operand; review its units on this axis")
elif head in UNITS:
if head not in AXIS_UNITS[unit]:
reason = ("parent axis is unknown; review this length in its Flex context"
if unit is None else
"unit is invalid on this axis; allowed units: " +
", ".join(sorted(AXIS_UNITS[unit])))
self.review(node, reason)
elif node.kind == "list":
for child in node.children[1:]:
self.check_units(child, unit)
return self.raw(node)
def scalar(self, node, unit, row_track=False):
if node.kind == "prefix":
if node.text in ("'", "`"):
return self.compose(node, [(node.children[0], self.scalar(node.children[0], unit, row_track))])
return self.review(node, "dynamic size; wrap the computed value in its explicit unit")
if self.numeric(node):
if unit is None:
return self.review(node, "axis-dependent size; choose ch or lh from the parent layout")
return f"({unit} {node.text})"
if node.kind == "atom" and node.text in KEYWORDS:
return self.raw(node)
if node.text == "contain":
return self.review(node, "contain was removed; choose the intended CSS sizing keyword")
viewport = node.text if node.kind == "atom" else self.head(node)
if viewport in ("viewport", "viewport-height"):
if node.kind == "atom" or len(node.children) == 1:
viewport_unit = "vw" if viewport == "viewport" else "vh"
if viewport_unit not in AXIS_UNITS[unit]:
return self.review(node, "legacy viewport unit is invalid or ambiguous on this axis")
return "(vw 100)" if viewport == "viewport" else "(vh 100)"
if node.kind == "list" and len(node.children) == 1 and self.numeric(node.children[0]):
child = node.children[0]
if unit is None:
return self.review(node, "axis-dependent singleton size; choose px or lh from the parent layout")
if unit == "lh" and not row_track:
return self.review(node, "ambiguous legacy block-axis singleton; choose explicit lh")
# Legacy Grid rows interpreted singleton tracks as lines.
return self.compose(node, [(child, ("lh " if row_track else "px ") + child.text)])
head = self.head(node)
if head in UNITS | FUNCTIONS:
return self.check_units(node, unit)
if head == "fit-content":
return self.review(node, "parameterized fit-content was removed; choose keyword or min/max/clamp")
return self.review(node, "unresolved size expression; manual migration required")
def edges(self, node, axes, gap=False):
if self.head(node) in UNITS | FUNCTIONS:
for axis in dict.fromkeys(axes):
self.check_units(node, axis)
return self.raw(node)
if node.kind != "list":
if len(set(axes)) == 1:
return self.scalar(node, axes[0])
if self.numeric(node):
return "(" + " ".join(self.scalar(node, axis) for axis in axes[:2]) + ")"
return self.review(node, "dynamic shorthand; specify explicit edge units")
parts = node.children
if not 1 <= len(parts) <= len(axes) or parts[0].text.startswith(":"):
return self.review(node, "unsupported edge shorthand; use CSS-ordered unit values")
if len(parts) == 1 and len(set(axes)) > 1:
if not self.numeric(parts[0]):
return self.review(node, "ambiguous single edge shorthand; specify explicit edge units")
child = parts[0]
units = ["lh", "px"] if gap else axes[:2]
return self.compose(node, [(child, " ".join(self.scalar(child, axis) for axis in units))])
return self.compose(node, [(part, self.scalar(part, axes[i], row_track=gap and i == 0))
for i, part in enumerate(parts)])
def track(self, node, axis):
head = self.head(node)
if head == "fr":
return self.raw(node)
if head == "minmax" and len(node.children) == 3:
return self.compose(node, [(part, self.track(part, axis)) for part in node.children[1:]])
if head == "repeat" and len(node.children) == 3:
body = node.children[2]
if body.kind == "list" and not self.head(body) and len(body.children) > 1:
new = self.tracks(body, axis)
else:
new = self.track(body, axis)
return self.compose(node, [(body, new)])
return self.scalar(node, axis, row_track=axis == "lh")
def tracks(self, node, axis):
if node.kind != "list" or self.head(node) in UNITS | FUNCTIONS | {"fr", "minmax", "repeat"}:
return self.track(node, axis)
return self.compose(node, [(part, self.track(part, axis)) for part in node.children])
def value(self, prop, node, context, tag, parent_axis=None):
if node.kind == "prefix":
if node.text in ("'", "`"):
child = node.children[0]
return self.compose(node, [(child, self.value(prop, child, "data", tag, parent_axis))])
return self.review(node, f"dynamic {prop}; manual migration required")
if node.kind == "list" and self.head(node) == "quote" and len(node.children) == 2:
child = node.children[1]
return self.compose(node, [(child, self.value(prop, child, "data", tag, parent_axis))])
if context == "code" and node.kind == "list":
return self.review(node, f"computed {prop}; return an explicit unit value")
if prop in {"max-width", "max-height"} and node.text == "auto":
return self.review(node, "maximum sizes do not accept auto; choose none or an explicit size")
if prop in {"padding", "margin", "border-width", "gap"} or prop in {
"padding-inline", "padding-block", "margin-inline", "margin-block"}:
if prop == "border-width":
axes = ["px"] * 4
elif prop.endswith("-inline"):
axes = ["ch"] * 2
elif prop.endswith("-block"):
axes = ["lh"] * 2
else:
axes = ["lh", "ch"] * (1 if prop == "gap" else 2)
result = self.edges(node, axes, gap=prop == "gap")
elif prop == "border" or prop in {"border-top", "border-right", "border-bottom", "border-left"}:
if node.kind == "string" or node.text in {"none", "solid", "nil"}:
result = self.raw(node)
elif node.kind == "list" and self.head(node) not in UNITS | FUNCTIONS:
result = self.compose(node, [(part, self.scalar(part, "px"))
for part in node.children
if self.numeric(part) or self.head(part) in UNITS | FUNCTIONS])
else:
result = self.scalar(node, "px")
elif prop == "flex":
if node.kind == "atom":
# Numbers here are unitless grow factors, not lengths.
result = self.raw(node)
elif len(node.children) == 3:
basis = node.children[2]
result = self.compose(node, [(basis, self.scalar(basis, parent_axis))])
else:
result = self.review(node, "unresolved flex shorthand; migrate only its basis size")
elif prop.startswith("grid-"):
result = self.tracks(node, "lh" if prop.endswith("rows") else "ch")
else:
axis = ("px" if prop.startswith("border-") else
"lh" if re.search(r"height|top|bottom|block|row-gap", prop) else "ch")
if prop in {"item-gap", "flex-basis"}:
axis = {"row": "ch", "column": "lh"}.get(tag) if prop == "item-gap" else parent_axis
result = self.scalar(node, axis, row_track=prop == "row-gap")
if context == "code" and result != self.raw(node) and result.startswith("("):
result = "'" + result
return result
@staticmethod
def property(node):
if node.kind != "atom" or not node.text.startswith(":"):
return None
prop = node.text[1:]
if re.fullmatch(r"(?:(?:min-|max-)?(?:width|height)|(?:padding|margin)(?:-(?:top|right|bottom|left|inline(?:-start|-end)?|block(?:-start|-end)?))?|border(?:-(?:top|right|bottom|left))?(?:-width)?|(?:row-|column-)?gap|item-gap|flex(?:-basis)?|grid-(?:template|auto)-(?:columns|rows))", prop):
return prop
return None
def visit(self, node, context="code", in_scope=False, parent_axis=None):
if node.kind == "prefix":
self.visit(node.children[0], "data" if node.text in {"'", "`"} else "code", in_scope, parent_axis)
elif node.kind == "list":
children = node.children
tag = self.head(node)
child_axis = None
if tag == "flex":
child_axis = "ch"
for i, child in enumerate(children[:-1]):
if child.text == ":flex-direction":
child_axis = {"row": "ch", "row-reverse": "ch",
"column": "lh", "column-reverse": "lh"}.get(children[i + 1].text)
elif tag not in {"box", "text", "row", "column", "grid"}:
child_axis = parent_axis
in_scope = in_scope or self.callers is None or self.head(node) in self.callers
if self.head(node) == "styles":
# ETAF's styles macro consumes its selector rules as data.
context = "data"
if self.head(node) in {"space", "image", "create-image"}:
# Native Emacs display specs use their own pixel grammar.
return
if self.head(node) in {"quote", "backquote"} and len(children) == 2:
self.visit(children[1], "data", in_scope, parent_axis)
return
i = 0
while i < len(children):
prop = self.property(children[i]) if in_scope else None
if prop and i + 1 < len(children) and not children[i + 1].text.startswith(":"):
value = children[i + 1]
result = self.value(prop, value, context, tag, parent_axis)
if result != self.raw(value):
self.edits.append((value.start, value.end, result))
i += 2
else:
self.visit(children[i], context, in_scope, child_axis)
i += 1
def run(self):
for form in Reader(self.source).forms():
self.visit(form, "data" if self.literal else "code")
output = self.source
for start, end, value in sorted(self.edits, reverse=True):
output = output[:start] + value + output[end:]
return output
def paths_from(arguments):
paths = set()
for argument in arguments:
path = Path(argument)
if path.is_dir():
for root, dirs, files in os.walk(path):
dirs[:] = sorted(d for d in dirs if d not in SKIP_DIRS and not d.startswith("."))
paths.update(Path(root, name) for name in files if Path(name).suffix in {".el", ".ebox", ".etaf", ".ecss"})
else:
paths.add(path)
return sorted(paths)
def atomic_write(path, output):
temporary = None
try:
with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", newline="", dir=path.parent,
prefix=".migrate-css-", delete=False) as stream:
temporary = Path(stream.name)
stream.write(output)
temporary.chmod(path.stat().st_mode)
os.replace(temporary, path)
finally:
if temporary is not None:
temporary.unlink(missing_ok=True)
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--write", action="store_true", help="apply safe edits to the explicitly selected files")
parser.add_argument("--callers", help="restrict edits/reviews to subforms rooted at these comma-separated API names")
parser.add_argument("paths", nargs="+", help="Elisp/ebox or static etaf/ecss files, or directories containing public style callers")
args = parser.parse_args(argv)
changes = reviews = errors = count = 0
for path in paths_from(args.paths):
count += 1
try:
if path.is_symlink():
raise ValueError("symlink inputs require selecting their actual target")
with path.open(encoding="utf-8", newline="") as stream:
source = stream.read()
migration = Migration(source, args.callers.split(",") if args.callers else None,
literal=path.suffix in {".etaf", ".ecss"})
output = migration.run()
changes += len(migration.edits)
reviews += len(migration.reviews)
if args.write and output != source:
atomic_write(path, output)
if migration.edits or migration.reviews:
print(f"{path}: {len(migration.edits)} edits, {len(migration.reviews)} review items")
for line, reason, snippet in migration.reviews:
print(f" {path}:{line}: REVIEW {reason}: {snippet}")
except (OSError, UnicodeError, ValueError) as error:
print(f"{path}: ERROR {error}", file=sys.stderr)
errors += 1
print(f"{'Applied' if args.write else 'Dry run'}: {count} files, {changes} property edits, {reviews} review items, {errors} errors")
return 2 if errors else 1 if reviews else 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1,167 @@
"""Behavior and non-destructive migration tests; standard library only."""
import importlib.util
from pathlib import Path
import subprocess
import sys
import tempfile
import unittest
SCRIPT = Path(__file__).with_name("migrate-css-sizes.py")
SPEC = importlib.util.spec_from_file_location("migrate_css_sizes", SCRIPT)
MODULE = importlib.util.module_from_spec(SPEC)
sys.modules[SPEC.name] = MODULE
SPEC.loader.exec_module(MODULE)
class MigrationTests(unittest.TestCase):
def migrate(self, source):
migration = MODULE.Migration(source)
result = migration.run()
self.assertFalse(migration.reviews)
second = MODULE.Migration(result)
self.assertEqual(second.run(), result)
self.assertFalse(second.reviews)
return result
def test_preserves_reader_quoting_and_text(self):
source = '; :width 999\n`(box\t:width ( 240 ) :height 3 ":height 99" ,label)\n'
self.assertEqual(self.migrate(source), '; :width 999\n`(box\t:width ( px 240 ) :height (lh 3) ":height 99" ,label)\n')
def test_executable_calls_keep_values_quoted(self):
self.assertEqual(self.migrate("(ebox-create :width 80 :height 3 :margin '(1 2))"),
"(ebox-create :width '(ch 80) :height '(lh 3) :margin '((lh 1) (ch 2)))")
def test_scalar_and_edge_shorthands_preserve_axes(self):
self.assertEqual(self.migrate("'(:padding 2 :margin (1 2 3 4) :gap (1 (12)) :border-width (1 2))"),
"'(:padding ((lh 2) (ch 2)) :margin ((lh 1) (ch 2) (lh 3) (ch 4)) :gap ((lh 1) (px 12)) :border-width ((px 1) (px 2)))")
def test_one_edge_and_border_components(self):
self.assertEqual(self.migrate("'(:padding (2) :margin-inline (3) :border (2 solid \"red\"))"),
"'(:padding ((lh 2) (ch 2)) :margin-inline ((ch 3)) :border ((px 2) solid \"red\"))")
def test_grid_counts_and_legacy_row_tracks(self):
self.assertEqual(self.migrate("'(grid :grid-template-columns ((repeat 2 (80)) (fr 1)) :grid-template-rows ((minmax (1) (3)) 1) :grid-row (2 :span 3))"),
"'(grid :grid-template-columns ((repeat 2 (px 80)) (fr 1)) :grid-template-rows ((minmax (lh 1) (lh 3)) (lh 1)) :grid-row (2 :span 3))")
def test_keywords_and_numeric_non_sizes(self):
self.assertEqual(self.migrate("'(:width viewport :height (viewport-height) :flex-grow 3 :order 2 :font-size 16 :border-top-width 1)"),
"'(:width (vw 100) :height (vh 100) :flex-grow 3 :order 2 :font-size 16 :border-top-width (px 1))")
def test_flex_only_converts_basis(self):
self.assertEqual(self.migrate("'(flex (box :flex (2 1 0)) (box :flex 2))"),
"'(flex (box :flex (2 1 (ch 0))) (box :flex 2))")
self.assertEqual(self.migrate("'(flex :flex-direction column (box :flex (2 1 0) :flex-basis 3))"),
"'(flex :flex-direction column (box :flex (2 1 (lh 0)) :flex-basis (lh 3)))")
def test_unknown_flex_axis_never_generates_pixel_zero(self):
source = "'(:flex (2 1 0) :flex-basis (0))"
migration = MODULE.Migration(source)
self.assertEqual(migration.run(), source)
self.assertEqual(len(migration.reviews), 2)
def test_cross_axis_units_are_reported_recursively(self):
source = "'(:height (px 0) :width (lh 2) :padding (ch 1) :gap (vw 2) :height (clamp (lh 1) (calc (* (px 2) 0)) (vh 20)))"
migration = MODULE.Migration(source)
self.assertEqual(migration.run(), source)
self.assertEqual(len(migration.reviews), 5)
self.assertTrue(all("invalid on this axis" in item[1] for item in migration.reviews))
def test_legal_functions_edges_and_strokes_are_preserved(self):
source = "'(:width (calc (- (vw 50) (ch 2))) :height (max (lh 2) (vh 10)) :padding ((lh 1) (px 2)) :border ((px 2) solid \"red\") :border-width (lh 0.1))"
self.assertEqual(self.migrate(source), source)
migration = MODULE.Migration("'(:border ((max (px 1) (% 2)) solid \"red\"))")
self.assertEqual(migration.run(), migration.source)
self.assertEqual(len(migration.reviews), 1)
def test_grid_and_flex_lengths_use_their_actual_axis(self):
source = "'(flex :flex-direction column-reverse (box :flex-basis (px 2)) (flex (box :flex-basis (lh 2))) (grid :grid-template-rows ((repeat 2 (minmax (lh 1) (px 4))))))"
migration = MODULE.Migration(source)
self.assertEqual(migration.run(), source)
self.assertEqual(len(migration.reviews), 3)
def test_cross_axis_legacy_viewports_require_review(self):
source = "'(:width viewport-height :height (viewport))"
migration = MODULE.Migration(source)
self.assertEqual(migration.run(), source)
self.assertEqual(len(migration.reviews), 2)
def test_dynamic_function_operands_require_axis_review(self):
source = "`(:height (calc (+ (lh 1) ,size)))"
migration = MODULE.Migration(source)
self.assertEqual(migration.run(), source)
self.assertEqual(len(migration.reviews), 1)
def test_legacy_row_gap_singletons_are_lines(self):
self.assertEqual(self.migrate("'(:row-gap (2) :gap ((2) (3)))"),
"'(:row-gap (lh 2) :gap ((lh 2) (px 3)))")
self.assertEqual(self.migrate("'(:gap (2))"), "'(:gap ((lh 2) (px 2)))")
def test_ambiguous_singletons_and_max_auto_require_review(self):
source = "'(:flex-basis (3) :padding-top (2) :max-width auto)"
migration = MODULE.Migration(source)
self.assertEqual(migration.run(), source)
self.assertEqual(len(migration.reviews), 3)
def test_native_display_specs_keep_their_own_size_grammar(self):
source = "(list '(space :width (720) :height 2) '(image :width 30 :height 20))"
self.assertEqual(self.migrate(source), source)
def test_etaf_styles_macro_consumes_literal_values(self):
self.assertEqual(self.migrate('(etaf-define-component card () :styles (styles ("&" :width 3 :padding (1 2))))'),
'(etaf-define-component card () :styles (styles ("&" :width (ch 3) :padding ((lh 1) (ch 2)))))')
def test_callers_filter_preserves_measured_structs_and_limits_reviews(self):
source = "(list :width 30 :height unknown (ebox-build '(box :width 4)) (ebox-create :height 3))"
migration = MODULE.Migration(source, {"ebox-build", "ebox-create"})
self.assertEqual(migration.run(),
"(list :width 30 :height unknown (ebox-build '(box :width (ch 4))) (ebox-create :height '(lh 3)))")
self.assertFalse(migration.reviews)
def test_static_etaf_data_keeps_sizes_unquoted(self):
migration = MODULE.Migration('(box :width 20 :height 3)', literal=True)
self.assertEqual(migration.run(), '(box :width (ch 20) :height (lh 3))')
self.assertFalse(migration.reviews)
def test_dynamic_and_removed_forms_report_without_guessing(self):
source = "`(box :width ,width :height (fit-content 3) :max-width contain :flex-basis 2)"
migration = MODULE.Migration(source)
self.assertEqual(migration.run(), source)
self.assertEqual(len(migration.reviews), 4)
def test_reader_explicit_quote_and_char_literals(self):
self.assertEqual(self.migrate('(list ?\\" ?\\( (quote (:width 20)) :height (quote 2))'),
'(list ?\\" ?\\( (quote (:width (ch 20))) :height (quote (lh 2)))')
def test_cli_dry_run_write_and_syntax_error(self):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory, "demo.ebox")
path.write_text("'(box :width 20)\n")
result = subprocess.run([sys.executable, str(SCRIPT), str(path)], capture_output=True, text=True)
self.assertEqual(result.returncode, 0)
self.assertIn("1 property edits", result.stdout)
self.assertEqual(path.read_text(), "'(box :width 20)\n")
result = subprocess.run([sys.executable, str(SCRIPT), "--write", str(path)], capture_output=True, text=True)
self.assertEqual(result.returncode, 0)
self.assertEqual(path.read_text(), "'(box :width (ch 20))\n")
self.assertFalse(list(Path(directory).glob(".migrate-css-*")))
path.write_text("'(box :width 20")
result = subprocess.run([sys.executable, str(SCRIPT), "--write", str(path)], capture_output=True, text=True)
self.assertEqual(result.returncode, 2)
self.assertEqual(path.read_text(), "'(box :width 20")
def test_cli_preserves_cross_axis_values_and_writes_safe_edits(self):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory, "demo.ebox")
path.write_text("'(box :width 20 :height (px 3))\n")
result = subprocess.run([sys.executable, str(SCRIPT), "--write", str(path)],
capture_output=True, text=True)
self.assertEqual(result.returncode, 1)
self.assertIn("unit is invalid on this axis", result.stdout)
self.assertEqual(path.read_text(), "'(box :width (ch 20) :height (px 3))\n")
self.assertFalse(list(Path(directory).glob(".migrate-css-*")))
if __name__ == "__main__":
unittest.main()

View File

@ -143,8 +143,8 @@ the surrounding ephemeral TP surface has its own snapshot value ownership."
(ert-deftest ebox-box-line-flow-avoids-intermediate-join-and-split () (ert-deftest ebox-box-line-flow-avoids-intermediate-join-and-split ()
"Ordinary decoration should join only at the existing compositor exit." "Ordinary decoration should join only at the existing compositor exit."
(let* ((input (ebox-build (let* ((input (ebox-build
'(box :width (120) :height 8 :overflow hidden '(box :width (px 120) :height (lh 8) :overflow hidden
:padding (1 (3)) :border (1 solid "#112233") :padding ((lh 1) (px 3)) :border ((px 1) solid "#112233")
:bgcolor "#ddeeff" (text "alpha\nbeta")))) :bgcolor "#ddeeff" (text "alpha\nbeta"))))
(expected (ebox-box-line-test--reference-render input)) (expected (ebox-box-line-test--reference-render input))
(observed (ebox-box-line-test--materializations input))) (observed (ebox-box-line-test--materializations input)))
@ -168,13 +168,13 @@ the surrounding ephemeral TP surface has its own snapshot value ownership."
(dolist (overflow '(hidden visible scroll)) (dolist (overflow '(hidden visible scroll))
(let* ((child (pcase kind (let* ((child (pcase kind
('box `(box (text ,rich))) ('box `(box (text ,rich)))
('grid `(grid :grid-template-columns ((110)) (text ,rich))) ('grid `(grid :grid-template-columns ((px 110)) (text ,rich)))
(_ `(,kind (text ,rich))))) (_ `(,kind (text ,rich)))))
(input (input
(ebox-build (ebox-build
`(box :width (140) :height ,height :overflow ,overflow `(box :width (px 140) :height (lh ,height) :overflow ,overflow
:padding (1 (3)) :padding ((lh 1) (px 3))
:border (1 solid "#112233") :margin (1 (2)) :border ((px 1) solid "#112233") :margin ((lh 1) (px 2))
:color "#123456" :bgcolor "#ddeeff" ,child))) :color "#123456" :bgcolor "#ddeeff" ,child)))
(_alignment (_alignment
(plist-put (car (ebox-canonical-input-roots input)) (plist-put (car (ebox-canonical-input-roots input))
@ -212,7 +212,7 @@ the surrounding ephemeral TP surface has its own snapshot value ownership."
"Complete simple scroll keeps its single join for overflow and underfill." "Complete simple scroll keeps its single join for overflow and underfill."
(dolist (text '("alpha\nbeta" "alpha\n2\n3\n4\n5\n6\n7\n8\n9")) (dolist (text '("alpha\nbeta" "alpha\n2\n3\n4\n5\n6\n7\n8\n9"))
(let* ((input (ebox-build (let* ((input (ebox-build
`(box :width (120) :height 8 :overflow scroll `(box :width (px 120) :height (lh 8) :overflow scroll
:bgcolor "#ddeeff" (text ,text)))) :bgcolor "#ddeeff" (text ,text))))
(expected (ebox-box-line-test--reference-render input)) (expected (ebox-box-line-test--reference-render input))
(observed (ebox-box-line-test--materializations input))) (observed (ebox-box-line-test--materializations input)))
@ -229,7 +229,10 @@ the surrounding ephemeral TP surface has its own snapshot value ownership."
'ebox-content-owner 18 'ebox-content-owners '(18 17))) 'ebox-content-owner 18 'ebox-content-owners '(18 17)))
(blank (propertize " " 'display '(space :width (7)) (blank (propertize " " 'display '(space :width (7))
'ebox-content-owner 19))) 'ebox-content-owner 19)))
(dolist (lines (list nil (list "") (list rich) (list rich "" blank))) ;; Use the explicit one-line string representation for string/list parity.
;; A raw empty list entry is covered separately below.
(dolist (lines (list nil (list (ebox-lines-join '("")))
(list rich) (list rich "" blank)))
(dolist (height '(0 1 3 5)) (dolist (height '(0 1 3 5))
(dolist (align '(top center bottom)) (dolist (align '(top center bottom))
(let* ((spine (cl-loop for tail on lines (let* ((spine (cl-loop for tail on lines
@ -257,44 +260,49 @@ the surrounding ephemeral TP surface has its own snapshot value ownership."
(should (eq (car (nth 0 record)) (nth 1 record))) (should (eq (car (nth 0 record)) (nth 1 record)))
(should (eq (cdr (nth 0 record)) (nth 2 record))) (should (eq (cdr (nth 0 record)) (nth 2 record)))
(should (equal-including-properties (nth 1 record) (nth 3 record))))))))) (should (equal-including-properties (nth 1 record) (nth 3 record)))))))))
;; The list primitive does not own either legacy string-adapter bridge. (let* ((empty-line "")
(lines (list empty-line))
(padded (ebox--pad-lines-vertical lines 3)))
(should (eq (ebox--pad-lines-vertical lines 1) lines))
(should (eq (car padded) empty-line))
(should (equal lines '("")))
(should (= (ebox-string-height (ebox-lines-join padded)) 3)))
(should-not (ebox--pad-lines-vertical nil 0)) (should-not (ebox--pad-lines-vertical nil 0))
(should-not (ebox--pad-lines-vertical (list "A") 0))) (should-not (ebox--pad-lines-vertical (list "A") 0)))
(ert-deftest ebox-box-line-flow-retains-empty-string-adapter-bridges () (ert-deftest ebox-box-line-flow-empty-content-retains-only-declared-chrome ()
"Empty formatted content and zero used height retain the former blank line." "Empty content and zero used height preserve only the declared chrome."
(dolist (formatted '(nil "" "A\nB")) (dolist (formatted '(nil "" "A\nB"))
(dolist (height '(0 1 3)) (dolist (height '(0 1 3))
(let* ((input (ebox-build (let* ((input (ebox-build
'(box :width (40) :height 3 :overflow hidden :padding (1 (2)) '(box :width (px 40) :height (lh 3) :overflow hidden :padding ((lh 1) (px 2))
:border (1 solid "#112233") (text "unused")))) :border ((px 1) solid "#112233") (text "unused"))))
(root (car (ebox-canonical-input-roots input))) (root (car (ebox-canonical-input-roots input)))
(handle (ebox-node-source-handle root)) (handle (ebox-node-source-handle root))
(format-function (symbol-function 'ebox--format-content)) (format-function (symbol-function 'ebox--format-content))
(height-function (symbol-function 'ebox--content-height))) (height-function (symbol-function 'ebox--content-height)))
;; Isolate the decoration boundary, including a zero used content ;; Isolate the decoration boundary, including zero used content height.
;; height; the ordinary public height resolver clamps to at least one.
(cl-letf (((symbol-function 'ebox--format-content) (cl-letf (((symbol-function 'ebox--format-content)
(lambda (box) (lambda (box)
(if (eq (ebox-node-source-handle box) handle) (if (eq (ebox-node-source-handle box) handle)
formatted formatted
(funcall format-function box)))) (funcall format-function box))))
((symbol-function 'ebox--content-height) ((symbol-function 'ebox--content-height)
(lambda (box natural) (lambda (box natural &optional rendered)
(if (eq (ebox-node-source-handle box) handle) (if (eq (ebox-node-source-handle box) handle)
height height
(funcall height-function box natural))))) (funcall height-function box natural rendered)))))
(let ((actual (ebox-box-line-test--reference-render input t)) (let ((actual (ebox-box-line-test--reference-render input t))
(expected (ebox-box-line-test--reference-render input))) (expected (ebox-box-line-test--reference-render input)))
(should (equal-including-properties actual expected)) (should (equal-including-properties actual expected))
(when (= height 0) (when (= height 0)
(should (= (ebox-string-height actual) 3))))))))) (should (= (ebox-string-height actual) 2)))))))))
(ert-deftest ebox-box-line-flow-zero-width-default-padding-is-a-line () (ert-deftest ebox-box-line-flow-zero-width-default-padding-is-a-line ()
"Zero-width default blanks remain empty string lines at the list boundary." "Zero-width default blanks remain empty string lines at the list boundary."
(dolist (padding '(0 (1 (2)))) (dolist (padding '(((lh 0) (px 0)) ((lh 1) (px 2))))
(let* ((input (ebox-build (let* ((input (ebox-build
`(box :width (0) :height 3 :overflow hidden `(box :width (px 0) :height (lh 3) :overflow hidden
:padding ,padding (text "")))) :padding ,padding (text ""))))
(actual (ebox-box-line-test--reference-render input t)) (actual (ebox-box-line-test--reference-render input t))
(expected (ebox-box-line-test--reference-render input))) (expected (ebox-box-line-test--reference-render input)))
@ -308,8 +316,8 @@ the surrounding ephemeral TP surface has its own snapshot value ownership."
(text (mapconcat (lambda (index) (format "Open %02d" index)) (text (mapconcat (lambda (index) (format "Open %02d" index))
(number-sequence 0 9) "\n")) (number-sequence 0 9) "\n"))
(input (ebox-build (input (ebox-build
`(box :width (120) :height 4 :overflow scroll `(box :width (px 120) :height (lh 4) :overflow scroll
:padding (0 (2)) :border (1 solid "#112233") :padding ((lh 0) (px 2)) :border ((px 1) solid "#112233")
(text ,(propertize text 'keymap map 'help-echo "action"))))) (text ,(propertize text 'keymap map 'help-echo "action")))))
(ebox-runtime-idle-prewarm nil) (ebox-runtime-idle-prewarm nil)
(ebox-runtime-idle-reflow-cache-prewarm nil)) (ebox-runtime-idle-reflow-cache-prewarm nil))

View File

@ -1000,6 +1000,50 @@
(dolist (key '(:batch :retained :mounts :ids :mount-index :index :snapshot)) (dolist (key '(:batch :retained :mounts :ids :mount-index :index :snapshot))
(should (plist-get sample key)))))))) (should (plist-get sample key))))))))
(ert-deftest ebox-child-range-span-followup-retains-proven-mounts ()
"A span projection consumes the same exact single-owner authority."
(with-temp-buffer
(ebox-render-to-buffer
(current-buffer) (ebox-child-range-test--snapshot-replacement-root))
(ebox-commit
(current-buffer)
(ebox-child-range-test--replace-snapshot-detail (current-buffer) t))
(let* ((surface ebox-surface--buffer-surface)
(mounts (tp--surface-mounts surface))
(mount-index (tp--surface-mount-index surface))
(ranges (plist-get (ebox--buffer-render-state (current-buffer))
:surface-owned-ranges))
(candidate (ebox-candidate-begin (current-buffer)))
observed
(observer
(lambda (original &rest arguments)
(setq observed t)
(let* ((state (nth 2 arguments))
(proofs (plist-get state :owner-scoped-proofs)))
(should (eq (nth 4 arguments) 'owner-scoped))
(should (= (length proofs) 1))
(should-not (plist-get (car proofs) :range-splice-p))
(should (eq ranges (plist-get state :span-patch-retained-owned-ranges)))
(setcar (nthcdr 4 arguments) 'span-patch)
(plist-put state :projection-kind 'span-patch)
(apply original arguments)))))
(ebox-candidate-replace-host-ref
candidate 'title
(ebox-test-text (propertize "title-2" 'face '(:weight bold))
:key 'title :source-identity 'title))
(advice-add 'ebox-surface--projection-result :around observer)
(unwind-protect
(ebox-commit (current-buffer) candidate)
(advice-remove 'ebox-surface--projection-result observer))
(should observed)
(ebox-child-range-test--assert-full-render-equivalent (current-buffer))
(should (plist-get (tp-surface-report surface) :commit-batch))
(should (plist-get (tp-surface-report surface) :retained-mount-state))
(should (eq mounts (tp--surface-mounts surface)))
(should (eq mount-index (tp--surface-mount-index surface)))
(should (eq ranges (plist-get (ebox--buffer-render-state (current-buffer))
:surface-owned-ranges))))))
(ert-deftest ebox-child-range-owner-scoped-followup-rolls-back-and-retries () (ert-deftest ebox-child-range-owner-scoped-followup-rolls-back-and-retries ()
"A rejected single-owner publication restores the exact prior generation." "A rejected single-owner publication restores the exact prior generation."
(with-temp-buffer (with-temp-buffer
@ -1091,8 +1135,11 @@
(should (eq witness (plist-get state :previous-surface-owned-ranges)))) (should (eq witness (plist-get state :previous-surface-owned-ranges))))
(pcase miss (pcase miss
('projection ('projection
(setcar (nthcdr 4 arguments) 'span-patch) ;; Both owner-scoped and span-patch now consume this
(plist-put state :projection-kind 'span-patch)) ;; exact ownership proof. Ordinary projection has no
;; retained-span authority and must take the fallback.
(setcar (nthcdr 4 arguments) nil)
(plist-put state :projection-kind nil))
('no-proof (plist-put state :owner-scoped-proofs nil)) ('no-proof (plist-put state :owner-scoped-proofs nil))
('multi-owner ('multi-owner
(plist-put state :owner-scoped-proofs (plist-put state :owner-scoped-proofs

View File

@ -1003,7 +1003,7 @@
(ebox-render-to-buffer (ebox-render-to-buffer
buffer buffer
(ebox-build (ebox-build
'(column :width (viewport) '(column :width (vw 100)
(box :id first :class card "One") (box :id first :class card "One")
(box :id second :class card "Two"))) (box :id second :class card "Two")))
(list :observer (list :observer
@ -1036,7 +1036,7 @@
(let* ((root (let* ((root
(ebox-build (ebox-build
'(box :id scroll-root :height 1 :overflow scroll '(box :id scroll-root :height (lh 1) :overflow scroll
"A\nB\nC"))) "A\nB\nC")))
(scroll-id (scroll-id
(car (car
@ -2383,8 +2383,8 @@ remain retained identities."
(ebox-render-to-buffer (ebox-render-to-buffer
(generate-new-buffer-name " *ebox-commit-types*") (generate-new-buffer-name " *ebox-commit-types*")
(ebox-build (ebox-build
'(flex :key root :width (80) '(flex :key root :width (px 80)
(box :key child :width (40) "A"))))) (box :key child :width (px 40) "A")))))
(before (gethash buffer ebox--buffer-render-state-table))) (before (gethash buffer ebox--buffer-render-state-table)))
(unwind-protect (unwind-protect
(progn (progn
@ -2398,7 +2398,7 @@ remain retained identities."
'text (plist-get before :runtime-type-count-table)) 'text (plist-get before :runtime-type-count-table))
1)) 1))
(ebox-commit (ebox-commit
buffer (ebox-build '(box :key root :width (80) "B"))) buffer (ebox-build '(box :key root :width (px 80) "B")))
(let ((after (gethash buffer ebox--buffer-render-state-table))) (let ((after (gethash buffer ebox--buffer-render-state-table)))
(should-not (should-not
(gethash 'item (plist-get after :runtime-type-count-table))) (gethash 'item (plist-get after :runtime-type-count-table)))
@ -5330,7 +5330,7 @@ When INHERITED-P is non-nil, keep ancestor color across an untouched sibling."
(parent (parent
(list :node-id 1 :ebox-type 'box :ebox-kind 'box (list :node-id 1 :ebox-type 'box :ebox-kind 'box
:ebox-layout-config :ebox-layout-config
(ebox-row-layout-create :item-gap 2 :cross-align 'stretch) (ebox-row-layout-create :item-gap '(px 2) :cross-align 'stretch)
:children (list owner))) :children (list owner)))
(item (item
(list :node '(:type "box") :order 0 :grow 0 :shrink 0 (list :node '(:type "box") :order 0 :grow 0 :shrink 0
@ -5431,6 +5431,42 @@ When INHERITED-P is non-nil, keep ancestor color across an untouched sibling."
(ebox-native-reflow-session-layout-package session) (ebox-native-reflow-session-layout-package session)
:document-delta))))) :document-delta)))))
(ert-deftest ebox-commit-css-fixed-expressions-retain-local-publication ()
"Fixed CSS expressions retain scoped content updates and exact output."
(dolist (width '((px 120) (ch 120) (calc (+ (ch 80) (px 40)))))
(with-temp-buffer
(ebox-render-to-buffer
(current-buffer)
(ebox-test-column :width width :bgcolor "#FFFDF8"
(ebox-test-text "before" :key 'label :source-identity 'label)
(ebox-test-box :height '(lh 1) (ebox-test-text "untouched"))))
(let* ((candidate (ebox-candidate-begin (current-buffer)))
(_ (ebox-candidate-replace-host-ref
candidate 'label
(ebox-test-text "after!" :key 'label :source-identity 'label)))
(result (ebox-commit-test--count-root-renders
(lambda () (ebox-commit (current-buffer) candidate)))))
(should (zerop (cdr result)))
(should-not (plist-get (car result) :tp-full-root))
(should (string-match-p "after" (buffer-string)))
(ebox-commit-test--assert-full-render-equivalent)))))
(ert-deftest ebox-commit-css-proof-requires-independent-size-evidence ()
"Contextual units and automatic minimums do not become fixed-slot proofs."
(dolist (value '(auto none min-content max-content fit-content stretch
(% 50) (vw 100) (vh 100)
(calc (- (vh 100) (lh 1)))))
(should-not (ebox--span-patch-definite-size-value-p value))
(should-not (ebox-incremental--fixed-grid-track-p
(list :kind 'fixed :size value))))
(dolist (axis '(row column))
(let ((node (list :min-width '(px 0) :min-height '(lh 0))))
(should (ebox--flex-fixed-basis-content-allocation-stable-p
node '(:flex-basis (px 80)) axis))
(plist-put node (if (eq axis 'row) :min-width :min-height) 'auto)
(should-not (ebox--flex-fixed-basis-content-allocation-stable-p
node '(:flex-basis (px 80)) axis)))))
(provide 'ebox-commit-tests) (provide 'ebox-commit-tests)
;;; ebox-commit-tests.el ends here ;;; ebox-commit-tests.el ends here

View File

@ -192,9 +192,9 @@
(ebox-render-to-buffer (ebox-render-to-buffer
(generate-new-buffer-name " *ebox-typed-flex-lookahead*") (generate-new-buffer-name " *ebox-typed-flex-lookahead*")
(ebox-build (ebox-build
'(flex :width (240) :flex-flow (row wrap) '(flex :width (px 240) :flex-flow (row wrap)
(box :width (100) "A") (box :width (px 100) "A")
(box :width (100) "B")))))) (box :width (px 100) "B"))))))
(unwind-protect (unwind-protect
(should (zerop (should (zerop
(ebox--root-reflow-scroll-lookahead-lines-for-buffer (ebox--root-reflow-scroll-lookahead-lines-for-buffer
@ -391,20 +391,20 @@
(defconst ebox-test--region-update-geometry-cases (defconst ebox-test--region-update-geometry-cases
'((:box-sizing content-box :box-sizing content-box owner-rerender) '((:box-sizing content-box :box-sizing content-box owner-rerender)
(:width (120) :width 120 owner-rerender) (:width (px 120) :width (px 120) owner-rerender)
(:min-width (100) :min-width 100 owner-rerender) (:min-width (px 100) :min-width (px 100) owner-rerender)
(:max-width (140) :max-width 140 owner-rerender) (:max-width (px 140) :max-width (px 140) owner-rerender)
(:height 3 :height 3 owner-rerender) (:height (lh 3) :height (lh 3) owner-rerender)
(:min-height 3 :min-height 3 owner-rerender) (:min-height (lh 3) :min-height (lh 3) owner-rerender)
(:max-height 1 :max-height 1 owner-rerender) (:max-height (lh 1) :max-height (lh 1) owner-rerender)
(:padding-left (9) :padding-left-pixel 9 span-patch) (:padding-left (px 9) :padding-left-pixel 9 span-patch)
(:padding-right (10) :padding-right-pixel 10 span-patch) (:padding-right (px 10) :padding-right-pixel 10 span-patch)
(:padding-top 1 :padding-top-height 1 span-patch) (:padding-top (lh 1) :padding-top-height 1 span-patch)
(:padding-bottom 1 :padding-bottom-height 1 span-patch) (:padding-bottom (lh 1) :padding-bottom-height 1 span-patch)
(:margin-left (7) :margin-left-pixel 7 owner-rerender) (:margin-left (px 7) :margin-left-pixel 7 owner-rerender)
(:margin-right (8) :margin-right-pixel 8 owner-rerender) (:margin-right (px 8) :margin-right-pixel 8 owner-rerender)
(:margin-top 1 :margin-top-height 1 owner-rerender) (:margin-top (lh 1) :margin-top-height 1 owner-rerender)
(:margin-bottom 1 :margin-bottom-height 1 owner-rerender) (:margin-bottom (lh 1) :margin-bottom-height 1 owner-rerender)
(:border-top-p t :border-top-pixel 1 span-patch) (:border-top-p t :border-top-pixel 1 span-patch)
(:text-align center :text-align center span-patch) (:text-align center :text-align center span-patch)
(:overflow hidden :overflow hidden owner-rerender) (:overflow hidden :overflow hidden owner-rerender)
@ -538,7 +538,7 @@
(let* ((style (let* ((style
(ecss-compute-style (ecss-compute-style
ebox-style-schemas (ecss-subject-create :type "box") ebox-style-schemas (ecss-subject-create :type "box")
:declarations (ebox-style-compile-declarations '(:width (120)) t) :declarations (ebox-style-compile-declarations '(:width (px 120)) t)
:provenance t)) :provenance t))
(fact (ecss-computed-style-property-fact style 'ebox/width))) (fact (ecss-computed-style-property-fact style 'ebox/width)))
(should (eq 'ebox/width (ecss-computed-property-fact-property fact))) (should (eq 'ebox/width (ecss-computed-property-fact-property fact)))
@ -581,7 +581,7 @@
(ebox-style-compute-subject (ebox-style-compute-subject
(ecss-subject-create :type "box") (ecss-subject-create :type "box")
(ebox-style-compile-declarations (ebox-style-compile-declarations
'(:width (120) :padding (1 (8)) :bgcolor "#1E293B")))) '(:width (px 120) :padding ((lh 1) (px 8)) :bgcolor "#1E293B"))))
(original-values (symbol-function 'ecss-computed-style-values)) (original-values (symbol-function 'ecss-computed-style-values))
(original-active (original-active
(symbol-function 'ecss-computed-style-active-properties)) (symbol-function 'ecss-computed-style-active-properties))
@ -623,7 +623,7 @@
(ebox-style-compute-subject (ebox-style-compute-subject
(ecss-subject-create :type "flex") (ecss-subject-create :type "flex")
(ebox-style-compile-declarations (ebox-style-compile-declarations
'(:width (120) :flex-direction row :color "#F8FAFC")))) '(:width (px 120) :flex-direction row :color "#F8FAFC"))))
(node (list :ebox-type 'flex :raw-props nil :props nil)) (node (list :ebox-type 'flex :raw-props nil :props nil))
(original-values (symbol-function 'ecss-computed-style-values)) (original-values (symbol-function 'ecss-computed-style-values))
(original-active (original-active
@ -669,7 +669,7 @@
(ebox-test--reset-runtime-state) (ebox-test--reset-runtime-state)
(let ((ebox-style-stylesheet (ecss-stylesheet-create))) (let ((ebox-style-stylesheet (ecss-stylesheet-create)))
(ebox-style-add-rule (ebox-style-add-rule
"flex" '(:flex-direction column :width (96)) "flex" '(:flex-direction column :width (px 96))
:layer 'layout) :layer 'layout)
(let ((rendered (let ((rendered
(substring-no-properties (substring-no-properties
@ -711,7 +711,7 @@
(ebox-test--reset-runtime-state) (ebox-test--reset-runtime-state)
(let ((ebox-style-stylesheet (ecss-stylesheet-create))) (let ((ebox-style-stylesheet (ecss-stylesheet-create)))
(ebox-style-add-rule (ebox-style-add-rule
"grid" '(:grid-template-columns ((40) (40)) :column-gap (4)) "grid" '(:grid-template-columns ((px 40) (px 40)) :column-gap (px 4))
:layer 'layout) :layer 'layout)
(ebox-style-add-rule ".placed" '(:grid-column 2) :layer 'layout) (ebox-style-add-rule ".placed" '(:grid-column 2) :layer 'layout)
(ebox-test--with-rendered-buffer (ebox-test--with-rendered-buffer
@ -724,10 +724,10 @@
(placed (car (ebox-tree-layout-children root)))) (placed (car (ebox-tree-layout-children root))))
(should (eq (ebox-layout-config-kind config) 'grid)) (should (eq (ebox-layout-config-kind config) 'grid))
(should (equal (plist-get props :grid-template-columns) (should (equal (plist-get props :grid-template-columns)
'((:kind fixed :size 40) '((:kind fixed :size (px 40))
(:kind fixed :size 40)))) (:kind fixed :size (px 40)))))
(should (equal (plist-get props :column-gap) (should (equal (plist-get props :column-gap)
4)) '(px 4)))
(should (= (plist-get placed :grid-column) 2)))))) (should (= (plist-get placed :grid-column) 2))))))
(ert-deftest ebox-font-cascade-inherits-overrides-and-precedes-measurement () (ert-deftest ebox-font-cascade-inherits-overrides-and-precedes-measurement ()
@ -977,7 +977,7 @@
(let ((old-handle (ebox-region-resolve primary-buffer "old"))) (let ((old-handle (ebox-region-resolve primary-buffer "old")))
(kill-buffer primary-buffer) (kill-buffer primary-buffer)
(setq primary-buffer nil) (setq primary-buffer nil)
(should-error (ebox-test--region-update old-handle :width '(140)) (should-error (ebox-test--region-update old-handle :width '(px 140))
:type 'user-error)) :type 'user-error))
(setq secondary-buffer (setq secondary-buffer
(ebox-render-to-buffer (ebox-render-to-buffer
@ -2066,8 +2066,8 @@
(ert-deftest ebox-buffer-backend-does-not-map-layout-only-properties () (ert-deftest ebox-buffer-backend-does-not-map-layout-only-properties ()
"Layout-only properties should not produce text properties directly." "Layout-only properties should not produce text properties directly."
(let* ((style (ebox-style-compute (let* ((style (ebox-style-compute
(list :width '(120) (list :width '(px 120)
:padding '(1 2)))) :padding '((lh 1) (ch 2)))))
(props (ebox-buffer-paint-text-properties style 'content))) (props (ebox-buffer-paint-text-properties style 'content)))
(should-not (memq :width props)) (should-not (memq :width props))
(should-not (memq :padding-inline-start props)))) (should-not (memq :padding-inline-start props))))
@ -2268,7 +2268,7 @@
(child-id (ebox-test--selector-region-id buffer "#child"))) (child-id (ebox-test--selector-region-id buffer "#child")))
(ebox-incremental-begin-batch buffer) (ebox-incremental-begin-batch buffer)
(ebox-test--region-update parent-id :color "red") (ebox-test--region-update parent-id :color "red")
(ebox-test--region-update child-id :width '(80)) (ebox-test--region-update child-id :width '(px 80))
(ebox-incremental-flush buffer)) (ebox-incremental-flush buffer))
(goto-char (point-min)) (goto-char (point-min))
(search-forward "Child") (search-forward "Child")
@ -2340,7 +2340,7 @@
(dolist (target order) (dolist (target order)
(ebox-test--region-update (ebox-test--region-update
(if (eq target 'target-a) target-a-id target-b-id) (if (eq target 'target-a) target-a-id target-b-id)
:width '(210) :min-width '(150) :max-width '(260))) :width '(px 210) :min-width '(px 150) :max-width '(px 260)))
(ebox-incremental-flush buffer)) (ebox-incremental-flush buffer))
(let ((report (ebox-test--buffer-update-report))) (let ((report (ebox-test--buffer-update-report)))
(should (ebox-test--buffer-visually-matches-runtime-render-p)) (should (ebox-test--buffer-visually-matches-runtime-render-p))
@ -2362,9 +2362,9 @@
(pcase operation (pcase operation
('geometry ('geometry
(ebox-test--region-update target-a-id (ebox-test--region-update target-a-id
:width '(210) :width '(px 210)
:min-width '(150) :min-width '(px 150)
:max-width '(260))) :max-width '(px 260)))
('paint ('paint
(ebox-test--region-update target-b-id :color "blue")))) (ebox-test--region-update target-b-id :color "blue"))))
(ebox-incremental-flush buffer)) (ebox-incremental-flush buffer))
@ -2709,7 +2709,7 @@
(layout (ebox-test-row left right)) (layout (ebox-test-row left right))
(left-id (car (ebox-region-ids layout)))) (left-id (car (ebox-region-ids layout))))
(ebox-test--with-rendered-buffer layout (ebox-test--with-rendered-buffer layout
(ebox-test--region-update left-id :width '(120)) (ebox-test--region-update left-id :width '(px 120))
(let ((report (ebox-test--buffer-update-report))) (let ((report (ebox-test--buffer-update-report)))
(should (eq (plist-get report :constraint-source) 'region)) (should (eq (plist-get report :constraint-source) 'region))
(should (eq (plist-get report :constraint-owner-type) 'box)) (should (eq (plist-get report :constraint-owner-type) 'box))
@ -2735,7 +2735,7 @@
(ebox-test-box (ebox-test-text "third") :width '(viewport))))) (ebox-test-box (ebox-test-text "third") :width '(viewport)))))
(root-id (car (ebox-region-ids layout)))) (root-id (car (ebox-region-ids layout))))
(ebox-test--with-rendered-buffer layout (ebox-test--with-rendered-buffer layout
(ebox-test--region-update root-id :width '(160)) (ebox-test--region-update root-id :width '(px 160))
(let ((widths (let ((widths
(mapcar #'ebox--string-pixel-width (mapcar #'ebox--string-pixel-width
(ebox-string-lines (buffer-string))))) (ebox-string-lines (buffer-string)))))
@ -2753,7 +2753,7 @@
(left-id (nth 1 ids))) (left-id (nth 1 ids)))
(ebox-test--with-rendered-buffer node (ebox-test--with-rendered-buffer node
(let ((root-node-id (ebox--buffer-root-node-id (current-buffer)))) (let ((root-node-id (ebox--buffer-root-node-id (current-buffer))))
(ebox-test--region-update left-id :width '(120)) (ebox-test--region-update left-id :width '(px 120))
(let ((report (ebox-test--buffer-update-report))) (let ((report (ebox-test--buffer-update-report)))
(should (equal (plist-get report :owner-ids) (should (equal (plist-get report :owner-ids)
(list root-node-id))) (list root-node-id)))
@ -3574,7 +3574,7 @@
(lambda (&rest args) (lambda (&rest args)
(cl-incf index-build-count) (cl-incf index-build-count)
(apply original args)))) (apply original args))))
(ebox-test--region-update region-id :padding (list 1 '(18) 1 '(18)))) (ebox-test--region-update region-id :padding '((lh 1) (px 18) (lh 1) (px 18))))
(let ((report (ebox-test--buffer-update-report))) (let ((report (ebox-test--buffer-update-report)))
(should (eq (plist-get report :strategy) 'owner-rerender)) (should (eq (plist-get report :strategy) 'owner-rerender))
(should (equal (plist-get report :owner-ids) (list root-id))) (should (equal (plist-get report :owner-ids) (list root-id)))
@ -3884,7 +3884,7 @@
(box-id (car (ebox-region-ids box)))) (box-id (car (ebox-region-ids box))))
(ebox-test--with-rendered-buffer box (ebox-test--with-rendered-buffer box
(ebox-rerender-buffer-with-context (current-buffer) 260) (ebox-rerender-buffer-with-context (current-buffer) 260)
(ebox-test--region-update box-id :width '(viewport)) (ebox-test--region-update box-id :width '(vw 100))
(ebox-rerender-buffer-with-context (current-buffer) 200) (ebox-rerender-buffer-with-context (current-buffer) 200)
(let ((report (ebox-test--buffer-update-report))) (let ((report (ebox-test--buffer-update-report)))
(should (eq (plist-get report :constraint-source) 'viewport)) (should (eq (plist-get report :constraint-source) 'viewport))
@ -4011,8 +4011,8 @@
(when (and buffer (buffer-live-p buffer)) (when (and buffer (buffer-live-p buffer))
(kill-buffer buffer))))) (kill-buffer buffer)))))
(ert-deftest ebox-viewport-dependencies-keep-only-vertical-axis-under-fixed-width () (ert-deftest ebox-viewport-dependencies-retain-explicit-units-under-fixed-width ()
"Fixed width should retain vertical dependencies without exposing width-only ones." "Fixed parent width does not contain explicit vw or vh dependencies."
(ebox-test--reset-runtime-state) (ebox-test--reset-runtime-state)
(let* ((width-only (let* ((width-only
(ebox-test-box (ebox-test-text "Width") :width '(viewport))) (ebox-test-box (ebox-test-text "Width") :width '(viewport)))
@ -4046,8 +4046,7 @@
(ebox-viewport-height 12)) (ebox-viewport-height 12))
(ebox--runtime-node-ids node) (ebox--runtime-node-ids node)
(ebox--viewport-dependent-node-ids node)))) (ebox--viewport-dependent-node-ids node))))
(should-not (member (plist-get (ebox-test-root width-only) :node-id) (should (member (plist-get (ebox-test-root width-only) :node-id) ids))
ids))
(dolist (vertical-node (append ordinary-height-nodes flex-height-nodes)) (dolist (vertical-node (append ordinary-height-nodes flex-height-nodes))
(should (member (plist-get (ebox-test-root vertical-node) :node-id) (should (member (plist-get (ebox-test-root vertical-node) :node-id)
ids))))) ids)))))
@ -4073,7 +4072,7 @@
(box-id (car (ebox-region-ids box)))) (box-id (car (ebox-region-ids box))))
(ebox-test--with-rendered-buffer box (ebox-test--with-rendered-buffer box
(ebox-rerender-buffer-with-context (current-buffer) 320) (ebox-rerender-buffer-with-context (current-buffer) 320)
(ebox-test--region-update box-id :width '(viewport)) (ebox-test--region-update box-id :width '(vw 100))
(let* ((state (ebox--buffer-render-state (current-buffer))) (let* ((state (ebox--buffer-render-state (current-buffer)))
(axes (plist-get state :viewport-dependent-node-id-axes)) (axes (plist-get state :viewport-dependent-node-id-axes))
(owner-id (owner-id
@ -4470,7 +4469,7 @@
(list (ebox-pixel-space 80) (list (ebox-pixel-space 80)
(ebox-pixel-space 80)))) (ebox-pixel-space 80))))
(box-input (box-input
(ebox-test-box (ebox-test-text content) :width 80 :wrap-mode 'none)) (ebox-test-box (ebox-test-text content) :width '(px 80) :wrap-mode 'none))
(box (ebox-test-root box-input)) (box (ebox-test-root box-input))
(ebox--render-source-index (ebox-test-source-index box-input)) (ebox--render-source-index (ebox-test-source-index box-input))
(measure-count 0) (measure-count 0)
@ -4743,8 +4742,8 @@ line compositor's complete property output, including empty lines and chrome."
"One Box should not flatten full-height strips for padding and borders." "One Box should not flatten full-height strips for padding and borders."
(let* ((input (let* ((input
(ebox-build (ebox-build
'(box :width (120) :height 8 :overflow hidden '(box :width (px 120) :height (lh 8) :overflow hidden
:padding (1 (3)) :border (1 solid "#112233") :padding ((lh 1) (px 3)) :border ((px 1) solid "#112233")
:bgcolor "#ddeeff" :bgcolor "#ddeeff"
(text "alpha\nbeta")))) (text "alpha\nbeta"))))
(observed (ebox-test--box-full-height-joins input 6)) (observed (ebox-test--box-full-height-joins input 6))
@ -4761,7 +4760,7 @@ line compositor's complete property output, including empty lines and chrome."
(dolist (text '("alpha\nbeta" "1\n2\n3\n4\n5\n6\n7\n8\n9")) (dolist (text '("alpha\nbeta" "1\n2\n3\n4\n5\n6\n7\n8\n9"))
(let* ((input (let* ((input
(ebox-build (ebox-build
`(box :width (120) :height 8 :overflow scroll :bgcolor "#ddeeff" `(box :width (px 120) :height (lh 8) :overflow scroll :bgcolor "#ddeeff"
(text ,text)))) (text ,text))))
(observed (ebox-test--box-full-height-joins input 8))) (observed (ebox-test--box-full-height-joins input 8)))
(should (= (ebox-string-height (plist-get observed :rendered)) 8)) (should (= (ebox-string-height (plist-get observed :rendered)) 8))
@ -5138,7 +5137,7 @@ line compositor's complete property output, including empty lines and chrome."
(ids (ebox-region-ids layout)) (ids (ebox-region-ids layout))
(left-id (nth 1 ids))) (left-id (nth 1 ids)))
(ebox-test--with-rendered-buffer layout (ebox-test--with-rendered-buffer layout
(ebox-test--region-update left-id :width '(120)) (ebox-test--region-update left-id :width '(px 120))
(let ((plain (buffer-substring-no-properties (point-min) (point-max)))) (let ((plain (buffer-substring-no-properties (point-min) (point-max))))
(should (string-match-p "Top" plain)) (should (string-match-p "Top" plain))
(should (string-match-p "L" plain)) (should (string-match-p "L" plain))
@ -5240,7 +5239,7 @@ line compositor's complete property output, including empty lines and chrome."
(let ((full-rerenders (let ((full-rerenders
(ebox-test--count-full-surface-updates (ebox-test--count-full-surface-updates
(ebox-test--region-update (ebox-test--region-update
left-id :border-bottom '(1 solid "blue"))))) left-id :border-bottom '((px 1) solid "blue")))))
(should (= full-rerenders 0))) (should (= full-rerenders 0)))
(let ((report (ebox-test--buffer-update-report))) (let ((report (ebox-test--buffer-update-report)))
(should (memq (plist-get report :strategy) (should (memq (plist-get report :strategy)
@ -5290,7 +5289,7 @@ line compositor's complete property output, including empty lines and chrome."
(goto-char (1+ (point-min))) (goto-char (1+ (point-min)))
(narrow-to-region (point) (point-max)) (narrow-to-region (point) (point-max))
(ebox-test--region-update (ebox-test--region-update
left-id :border-bottom '(1 solid "blue"))) left-id :border-bottom '((px 1) solid "blue")))
(should (equal old-metadata (should (equal old-metadata
(plist-get old-state :surface-fragments))) (plist-get old-state :surface-fragments)))
(should (cl-every (lambda (fragment) (should (cl-every (lambda (fragment)
@ -5301,7 +5300,7 @@ line compositor's complete property output, including empty lines and chrome."
"Removing top/bottom border surfaces must not corrupt other border faces." "Removing top/bottom border surfaces must not corrupt other border faces."
(ebox-test--reset-runtime-state) (ebox-test--reset-runtime-state)
(dolist (case '(((:border-top-p nil) bt (bb bl br)) (dolist (case '(((:border-top-p nil) bt (bb bl br))
((:border-bottom-width 0) bb (bt bl br)))) ((:border-bottom-width (px 0)) bb (bt bl br))))
(let* ((update (nth 0 case)) (let* ((update (nth 0 case))
(removed-role (nth 1 case)) (removed-role (nth 1 case))
(kept-roles (nth 2 case)) (kept-roles (nth 2 case))
@ -5345,7 +5344,7 @@ line compositor's complete property output, including empty lines and chrome."
(progn (progn
(should-error (should-error
(ebox-selector-update-buffer (ebox-selector-update-buffer
buffer "#root" :border-top '(2 solid "#D97757"))) buffer "#root" :border-top '((px 2) solid "#D97757")))
(should (eq state (ebox--buffer-render-state buffer))) (should (eq state (ebox--buffer-render-state buffer)))
(should (equal-including-properties (should (equal-including-properties
text (with-current-buffer buffer (buffer-string))))) text (with-current-buffer buffer (buffer-string)))))
@ -5384,7 +5383,7 @@ line compositor's complete property output, including empty lines and chrome."
buffer) buffer)
(unwind-protect (unwind-protect
(progn (progn
(should (eq (ebox-get (ebox-test-root node) :width) 'viewport)) (should (equal (ebox-get (ebox-test-root node) :width) '(vw 100)))
(let ((ebox-viewport-width 240)) (let ((ebox-viewport-width 240))
(setq buffer (setq buffer
(ebox-render-to-buffer (ebox-render-to-buffer
@ -5470,7 +5469,7 @@ line compositor's complete property output, including empty lines and chrome."
(target-id (plist-get target :region-id))) (target-id (plist-get target :region-id)))
(ebox-test--with-rendered-buffer layout (ebox-test--with-rendered-buffer layout
(let ((root-id (ebox--buffer-root-node-id (current-buffer)))) (let ((root-id (ebox--buffer-root-node-id (current-buffer))))
(ebox-test--region-update target-id :width '(190)) (ebox-test--region-update target-id :width '(px 190))
(let ((report (ebox-test--buffer-update-report))) (let ((report (ebox-test--buffer-update-report)))
(should-not (plist-get report :full-rerender)) (should-not (plist-get report :full-rerender))
(should-not (member root-id (plist-get report :owner-ids))) (should-not (member root-id (plist-get report :owner-ids)))
@ -5537,7 +5536,7 @@ line compositor's complete property output, including empty lines and chrome."
(owner-region-id (car (ebox-region-ids owner)))) (owner-region-id (car (ebox-region-ids owner))))
(ebox-test--with-rendered-buffer layout (ebox-test--with-rendered-buffer layout
(let ((root-node-id (ebox--buffer-root-node-id (current-buffer)))) (let ((root-node-id (ebox--buffer-root-node-id (current-buffer))))
(ebox-test--region-update owner-region-id :width '(320)) (ebox-test--region-update owner-region-id :width '(px 320))
(let ((report (ebox-test--buffer-update-report))) (let ((report (ebox-test--buffer-update-report)))
(should (equal (plist-get report :owner-ids) (should (equal (plist-get report :owner-ids)
(list root-node-id))) (list root-node-id)))
@ -5586,9 +5585,9 @@ line compositor's complete property output, including empty lines and chrome."
(target-id (plist-get target :region-id))) (target-id (plist-get target :region-id)))
(ebox-test--with-rendered-buffer layout (ebox-test--with-rendered-buffer layout
(ebox-test--region-update target-id (ebox-test--region-update target-id
:width '(210) :width '(px 210)
:min-width '(150) :min-width '(px 150)
:max-width '(260)) :max-width '(px 260))
(let ((report (ebox-test--buffer-update-report))) (let ((report (ebox-test--buffer-update-report)))
(should (eq (plist-get report :strategy) 'owner-rerender)) (should (eq (plist-get report :strategy) 'owner-rerender))
(should-not (plist-get report :root-rerender)) (should-not (plist-get report :root-rerender))
@ -5643,9 +5642,9 @@ line compositor's complete property output, including empty lines and chrome."
(ebox-test--runtime-root (current-buffer))))) (ebox-test--runtime-root (current-buffer)))))
:node-id))) :node-id)))
(ebox-test--region-update region-id (ebox-test--region-update region-id
:width '(210) :width '(px 210)
:min-width '(150) :min-width '(px 150)
:max-width '(260)) :max-width '(px 260))
(let ((report (ebox-test--buffer-update-report))) (let ((report (ebox-test--buffer-update-report)))
(should-not (plist-get report :tp-full-root)) (should-not (plist-get report :tp-full-root))
(should (> (plist-get report :tp-scope-count) 0)) (should (> (plist-get report :tp-scope-count) 0))
@ -6048,8 +6047,8 @@ line compositor's complete property output, including empty lines and chrome."
"#height")) "#height"))
(root-id (ebox--buffer-root-node-id (current-buffer)))) (root-id (ebox--buffer-root-node-id (current-buffer))))
(ebox-test--region-update region-id (ebox-test--region-update region-id
:height 4 :min-height 3 :max-height 6 :height '(lh 4) :min-height '(lh 3) :max-height '(lh 6)
:padding-top 1 :padding-bottom 1) :padding-top '(lh 1) :padding-bottom '(lh 1))
(let ((report (ebox-test--buffer-update-report))) (let ((report (ebox-test--buffer-update-report)))
(should (memq (plist-get report :strategy) (should (memq (plist-get report :strategy)
'(span-patch owner-rerender))) '(span-patch owner-rerender)))
@ -6068,8 +6067,8 @@ line compositor's complete property output, including empty lines and chrome."
"#height")) "#height"))
(root-id (ebox--buffer-root-node-id (current-buffer)))) (root-id (ebox--buffer-root-node-id (current-buffer))))
(ebox-test--region-update region-id (ebox-test--region-update region-id
:height 4 :min-height 3 :max-height 6 :height '(lh 4) :min-height '(lh 3) :max-height '(lh 6)
:padding-top 1 :padding-bottom 1) :padding-top '(lh 1) :padding-bottom '(lh 1))
(let ((report (ebox-test--buffer-update-report))) (let ((report (ebox-test--buffer-update-report)))
(should (eq (plist-get report :strategy) 'owner-rerender)) (should (eq (plist-get report :strategy) 'owner-rerender))
(should (equal (plist-get report :owner-ids) (list root-id))) (should (equal (plist-get report :owner-ids) (list root-id)))
@ -6088,10 +6087,10 @@ line compositor's complete property output, including empty lines and chrome."
"#margin")) "#margin"))
(root-id (ebox--buffer-root-node-id (current-buffer)))) (root-id (ebox--buffer-root-node-id (current-buffer))))
(ebox-test--region-update height-id (ebox-test--region-update height-id
:height 4 :min-height 3 :max-height 6 :height '(lh 4) :min-height '(lh 3) :max-height '(lh 6)
:padding-top 1 :padding-bottom 1) :padding-top '(lh 1) :padding-bottom '(lh 1))
(ebox-test--region-update region-id (ebox-test--region-update region-id
:margin-inline '((8) (8))) :margin-inline '((px 8) (px 8)))
(let ((report (ebox-test--buffer-update-report))) (let ((report (ebox-test--buffer-update-report)))
(should (eq (plist-get report :strategy) 'span-patch)) (should (eq (plist-get report :strategy) 'span-patch))
(should (eq (plist-get report :owner-type) 'box)) (should (eq (plist-get report :owner-type) 'box))
@ -6120,7 +6119,7 @@ line compositor's complete property output, including empty lines and chrome."
(ebox-test--with-rendered-buffer layout (ebox-test--with-rendered-buffer layout
(let ((region-id (ebox-test--selector-region-id (current-buffer) (let ((region-id (ebox-test--selector-region-id (current-buffer)
"#padding"))) "#padding")))
(ebox-test--region-update region-id :padding-left (list 24)) (ebox-test--region-update region-id :padding-left '(px 24))
(let ((report (ebox-test--buffer-update-report))) (let ((report (ebox-test--buffer-update-report)))
(should (eq (plist-get report :strategy) 'span-patch)) (should (eq (plist-get report :strategy) 'span-patch))
(should (member 'internal-fragment (should (member 'internal-fragment
@ -6158,7 +6157,7 @@ line compositor's complete property output, including empty lines and chrome."
(should (should
(ebox--dirty-entry-border-box-internal-span-p (ebox--dirty-entry-border-box-internal-span-p
(current-buffer) mixed-entry)) (current-buffer) mixed-entry))
(ebox-test--region-update region-id :padding '(2 (20) 0 (8))) (ebox-test--region-update region-id :padding '((lh 2) (px 20) (lh 0) (px 8)))
(let ((report (ebox-test--buffer-update-report))) (let ((report (ebox-test--buffer-update-report)))
(should (eq (plist-get report :strategy) 'span-patch)) (should (eq (plist-get report :strategy) 'span-patch))
(should (equal (plist-get report :patch-ops) '(span-patch))) (should (equal (plist-get report :patch-ops) '(span-patch)))
@ -6535,7 +6534,7 @@ line compositor's complete property output, including empty lines and chrome."
(region-id (plist-get handle :region-id)) (region-id (plist-get handle :region-id))
(node-id (plist-get handle :node-id))) (node-id (plist-get handle :node-id)))
(ebox-test--region-update region-id (ebox-test--region-update region-id
:margin-inline '((8) (8))) :margin-inline '((px 8) (px 8)))
(let* ((report (ebox-test--buffer-update-report)) (let* ((report (ebox-test--buffer-update-report))
(snapshot (ebox--ensure-layout-snapshot-spans (snapshot (ebox--ensure-layout-snapshot-spans
(current-buffer) node-id)) (current-buffer) node-id))
@ -6574,7 +6573,7 @@ line compositor's complete property output, including empty lines and chrome."
(let* ((match (car (ebox-selector-query-buffer (let* ((match (car (ebox-selector-query-buffer
(current-buffer) "#target"))) (current-buffer) "#target")))
(region-id (plist-get match :region-id))) (region-id (plist-get match :region-id)))
(ebox-test--region-update region-id :width '(250)) (ebox-test--region-update region-id :width '(px 250))
(let* ((patched (buffer-string)) (let* ((patched (buffer-string))
(full (full
(ebox--with-buffer-render-context (current-buffer) (ebox--with-buffer-render-context (current-buffer)
@ -6696,7 +6695,7 @@ line compositor's complete property output, including empty lines and chrome."
(let ((box (gethash region-id (ebox--buffer-region-box-table (let ((box (gethash region-id (ebox--buffer-region-box-table
(current-buffer))))) (current-buffer)))))
(should (= (ebox-get box :border-left-pixel) 2))) (should (= (ebox-get box :border-left-pixel) 2)))
(ebox-test--region-update region-id :border-left-width 3) (ebox-test--region-update region-id :border-left-width '(px 3))
(let ((box (gethash region-id (ebox--buffer-region-box-table (let ((box (gethash region-id (ebox--buffer-region-box-table
(current-buffer))))) (current-buffer)))))
(should (= (ebox-get box :border-left-pixel) 3))) (should (= (ebox-get box :border-left-pixel) 3)))
@ -6718,13 +6717,13 @@ line compositor's complete property output, including empty lines and chrome."
(unwind-protect (unwind-protect
(progn (progn
(ebox-selector-update-buffer (ebox-selector-update-buffer
buffer "#axis" :outer 'block :item-gap 3) buffer "#axis" :outer 'block :item-gap '(px 3))
(let* ((root (plist-get (ebox--buffer-render-state buffer) (let* ((root (plist-get (ebox--buffer-render-state buffer)
:root-node)) :root-node))
(props (ebox-layout-config-props (props (ebox-layout-config-props
(ebox-box-node-layout root)))) (ebox-box-node-layout root))))
(should (equal (plist-get root :display) '(block row))) (should (equal (plist-get root :display) '(block row)))
(should (= (plist-get props :item-gap) 3)) (should (equal (plist-get props :item-gap) '(px 3)))
(should (with-current-buffer buffer (should (with-current-buffer buffer
(ebox-test--buffer-propertized-matches-runtime-render-p))))) (ebox-test--buffer-propertized-matches-runtime-render-p)))))
(when (buffer-live-p buffer) (kill-buffer buffer))))) (when (buffer-live-p buffer) (kill-buffer buffer)))))
@ -6736,7 +6735,7 @@ line compositor's complete property output, including empty lines and chrome."
:box-sizing 'border-box)) :box-sizing 'border-box))
(region-id (car (ebox-region-ids box)))) (region-id (car (ebox-region-ids box))))
(ebox-test--with-rendered-buffer box (ebox-test--with-rendered-buffer box
(ebox-test--region-update region-id :padding-left '(8)) (ebox-test--region-update region-id :padding-left '(px 8))
(let ((report (ebox-test--buffer-update-report))) (let ((report (ebox-test--buffer-update-report)))
(should (eq (plist-get report :strategy) 'span-patch)) (should (eq (plist-get report :strategy) 'span-patch))
(should (equal (plist-get report :patch-ops) '(span-patch))) (should (equal (plist-get report :patch-ops) '(span-patch)))
@ -6749,7 +6748,7 @@ line compositor's complete property output, including empty lines and chrome."
:box-sizing 'content-box)) :box-sizing 'content-box))
(region-id (car (ebox-region-ids box)))) (region-id (car (ebox-region-ids box))))
(ebox-test--with-rendered-buffer box (ebox-test--with-rendered-buffer box
(ebox-test--region-update region-id :padding-left '(8)) (ebox-test--region-update region-id :padding-left '(px 8))
(let ((report (ebox-test--buffer-update-report))) (let ((report (ebox-test--buffer-update-report)))
(should (eq (plist-get report :strategy) 'owner-rerender)) (should (eq (plist-get report :strategy) 'owner-rerender))
(should (equal (plist-get report :patch-ops) '(owner-rerender))) (should (equal (plist-get report :patch-ops) '(owner-rerender)))
@ -6764,7 +6763,7 @@ line compositor's complete property output, including empty lines and chrome."
:wrap-mode 'char)) :wrap-mode 'char))
(region-id (car (ebox-region-ids box)))) (region-id (car (ebox-region-ids box))))
(ebox-test--with-rendered-buffer box (ebox-test--with-rendered-buffer box
(ebox-test--region-update region-id :padding-left '(100)) (ebox-test--region-update region-id :padding-left '(px 100))
(let ((report (ebox-test--buffer-update-report))) (let ((report (ebox-test--buffer-update-report)))
(should (memq (plist-get report :strategy) (should (memq (plist-get report :strategy)
'(span-patch owner-rerender))) '(span-patch owner-rerender)))
@ -6818,8 +6817,9 @@ line compositor's complete property output, including empty lines and chrome."
(should (eq (gethash region-id ebox--region-box-table) (should (eq (gethash region-id ebox--region-box-table)
runtime-box)) runtime-box))
(should-not (eq runtime-box box)) (should-not (eq runtime-box box))
(should (equal (ebox-get runtime-box engine-property) (let ((actual (ebox-get runtime-box engine-property)))
expected))) (should (if (numberp expected) (= actual expected)
(equal actual expected)))))
(let ((report (ebox-test--buffer-update-report))) (let ((report (ebox-test--buffer-update-report)))
(should (eq (plist-get report :strategy) 'paint-patch)) (should (eq (plist-get report :strategy) 'paint-patch))
(should (equal (plist-get report :patch-ops) (should (equal (plist-get report :patch-ops)
@ -6851,8 +6851,9 @@ line compositor's complete property output, including empty lines and chrome."
(should (eq (gethash region-id ebox--region-box-table) (should (eq (gethash region-id ebox--region-box-table)
runtime-box)) runtime-box))
(should-not (eq runtime-box box)) (should-not (eq runtime-box box))
(should (equal (ebox-get runtime-box engine-property) (let ((actual (ebox-get runtime-box engine-property)))
expected))) (should (if (numberp expected) (= actual expected)
(equal actual expected)))))
(let ((report (ebox-test--buffer-update-report))) (let ((report (ebox-test--buffer-update-report)))
(should (memq (plist-get report :strategy) (should (memq (plist-get report :strategy)
'(paint-patch span-patch owner-rerender))) '(paint-patch span-patch owner-rerender)))
@ -6930,9 +6931,9 @@ line compositor's complete property output, including empty lines and chrome."
(region-id (car (ebox-region-ids box)))) (region-id (car (ebox-region-ids box))))
(ebox-test--with-rendered-buffer box (ebox-test--with-rendered-buffer box
(ebox-test--region-update region-id (ebox-test--region-update region-id
:padding '(1 2) :padding '((lh 1) (ch 2))
:border '(1 "#abcdef") :border '((px 1) "#abcdef")
:margin-inline 3) :margin-inline '(ch 3))
(let ((runtime-box (let ((runtime-box
(gethash region-id (ebox--buffer-region-box-table (gethash region-id (ebox--buffer-region-box-table
(current-buffer))))) (current-buffer)))))
@ -6955,9 +6956,9 @@ line compositor's complete property output, including empty lines and chrome."
(let ((full-rerenders (let ((full-rerenders
(ebox-test--count-full-surface-updates (ebox-test--count-full-surface-updates
(ebox-test--region-update region-id (ebox-test--region-update region-id
:padding '(1 2) :padding '((lh 1) (ch 2))
:border '(1 "#abcdef") :border '((px 1) "#abcdef")
:margin-inline 3)))) :margin-inline '(ch 3)))))
(should (= full-rerenders 0))) (should (= full-rerenders 0)))
(let ((runtime-box (let ((runtime-box
(gethash region-id (ebox--buffer-region-box-table (gethash region-id (ebox--buffer-region-box-table
@ -9587,7 +9588,7 @@ line compositor's complete property output, including empty lines and chrome."
(substring-no-properties (substring-no-properties
(ebox-render (ebox-render
(ebox-build (ebox-build
'(column :width (viewport) :height (viewport-height) '(column :width (vw 100) :height (vh 100)
"Viewport")))))))) "Viewport"))))))))
(ert-deftest ebox-lazy-scroll-idle-prefetch-covers-visible-window () (ert-deftest ebox-lazy-scroll-idle-prefetch-covers-visible-window ()
@ -10761,7 +10762,7 @@ line compositor's complete property output, including empty lines and chrome."
(should (> (- (length (plist-get state :content-lines)) (should (> (- (length (plist-get state :content-lines))
(plist-get state :content-height)) (plist-get state :content-height))
1))) 1)))
(ebox-test--region-update target-id :width '(280)) (ebox-test--region-update target-id :width '(px 280))
(let ((report (ebox-test--buffer-update-report)) (let ((report (ebox-test--buffer-update-report))
(state (ebox-scroll-state root-id))) (state (ebox-scroll-state root-id)))
(should (eq (plist-get report :strategy) 'span-patch)) (should (eq (plist-get report :strategy) 'span-patch))
@ -10977,7 +10978,7 @@ line compositor's complete property output, including empty lines and chrome."
(with-current-buffer buffer (with-current-buffer buffer
(setq root-id (ebox-test--selector-region-id buffer "#root")) (setq root-id (ebox-test--selector-region-id buffer "#root"))
(ebox-selector-update-buffer buffer "#metric-01" (ebox-selector-update-buffer buffer "#metric-01"
:padding-left (list 8) :padding-left '(px 8)
:color "#B91C1C") :color "#B91C1C")
(let* ((report (ebox-test--buffer-update-report)) (let* ((report (ebox-test--buffer-update-report))
(state (ebox--scroll-get-state root-id))) (state (ebox--scroll-get-state root-id)))
@ -11093,7 +11094,7 @@ line compositor's complete property output, including empty lines and chrome."
(with-current-buffer buffer (with-current-buffer buffer
(setq target-id (ebox-test--selector-region-id buffer "#row-30")) (setq target-id (ebox-test--selector-region-id buffer "#row-30"))
(should target-id) (should target-id)
(ebox-selector-update-buffer buffer "#row-30" :width '(180)) (ebox-selector-update-buffer buffer "#row-30" :width '(px 180))
(let ((report (ebox-test--buffer-update-report))) (let ((report (ebox-test--buffer-update-report)))
(should (eq (plist-get report :strategy) 'span-patch)) (should (eq (plist-get report :strategy) 'span-patch))
(should (plist-get report :lazy-scroll-deferred-patch))))) (should (plist-get report :lazy-scroll-deferred-patch)))))
@ -11737,7 +11738,7 @@ explicit visible-window handoff may admit its full-content publication."
(ebox-test--selector-region-id buffer "#target"))) (ebox-test--selector-region-id buffer "#target")))
(ebox-incremental-begin-batch buffer) (ebox-incremental-begin-batch buffer)
(with-current-buffer buffer (with-current-buffer buffer
(ebox-test--region-update target-id :padding-left 9))) (ebox-test--region-update target-id :padding-left '(ch 9))))
(should (= (ebox-incremental-pending-count buffer) 1)) (should (= (ebox-incremental-pending-count buffer) 1))
(should-not (gethash buffer ebox--runtime-prewarm-jobs)) (should-not (gethash buffer ebox--runtime-prewarm-jobs))
(should-not (gethash buffer ebox--runtime-prewarm-timers))) (should-not (gethash buffer ebox--runtime-prewarm-timers)))
@ -11797,7 +11798,7 @@ explicit visible-window handoff may admit its full-content publication."
(ebox-test-box :width '(210) (ebox-test-box :width '(210)
(ebox-test-column (ebox-test-column
(ebox-test-box :id "width-only" :width '(viewport)) (ebox-test-box :id "width-only" :width '(viewport))
(ebox-test-box :id "height-only" :height (ebox-test-box :id "height-only" :width '(px 20) :height
'(viewport-height)) '(viewport-height))
(ebox-test-flex :id "flex-height" :height (ebox-test-flex :id "flex-height" :height
'(viewport-height) '(viewport-height)
@ -11830,7 +11831,7 @@ explicit visible-window handoff may admit its full-content publication."
(pcase-let ((`(,width-ids . ,height-ids) (pcase-let ((`(,width-ids . ,height-ids)
(plist-get state (plist-get state
:viewport-dependent-node-id-axes))) :viewport-dependent-node-id-axes)))
(should-not (member width-only-node-id width-ids)) (should (member width-only-node-id width-ids))
(should-not (member width-only-node-id height-ids)) (should-not (member width-only-node-id height-ids))
(should-not (member height-only-node-id width-ids)) (should-not (member height-only-node-id width-ids))
(should (member height-only-node-id height-ids))) (should (member height-only-node-id height-ids)))
@ -12098,7 +12099,7 @@ explicit visible-window handoff may admit its full-content publication."
(setq target-id (setq target-id
(ebox-test--selector-region-id (current-buffer) "#target")) (ebox-test--selector-region-id (current-buffer) "#target"))
(should (ebox--scroll-region-by root-id 2 48)) (should (ebox--scroll-region-by root-id 2 48))
(ebox-test--region-update target-id :padding-left (list 12)) (ebox-test--region-update target-id :padding-left '(px 12))
(let ((report (ebox-test--buffer-update-report))) (let ((report (ebox-test--buffer-update-report)))
(should (eq (plist-get report :strategy) 'span-patch)) (should (eq (plist-get report :strategy) 'span-patch))
(should (eq (plist-get report :tp-render-scope) 'objects)) (should (eq (plist-get report :tp-render-scope) 'objects))
@ -13052,7 +13053,7 @@ explicit visible-window handoff may admit its full-content publication."
(should (= measurements 1))))) (should (= measurements 1)))))
(ert-deftest ebox-fit-content-width-clamps-between-min-and-max-content () (ert-deftest ebox-fit-content-width-clamps-between-min-and-max-content ()
"fit-content() should clamp by min-content and max-content." "The fit-content keyword clamps available width by intrinsic widths."
(ebox-test--reset-runtime-state) (ebox-test--reset-runtime-state)
(let* ((content "aa widest") (let* ((content "aa widest")
(min-content (ebox--string-pixel-width "widest")) (min-content (ebox--string-pixel-width "widest"))
@ -13060,19 +13061,14 @@ explicit visible-window handoff may admit its full-content publication."
(between (+ min-content (/ (- max-content min-content) 2)))) (between (+ min-content (/ (- max-content min-content) 2))))
(should (< min-content between)) (should (< min-content between))
(should (< between max-content)) (should (< between max-content))
(should (= (ebox-test--input-content-pixel (dolist (case (list (cons between between)
(ebox-test-box (ebox-test-text content) (cons 1 min-content)
:width (list 'fit-content (list between)))) (cons (+ max-content 50) max-content)))
between)) (let ((ebox-viewport-width (car case)))
(should (= (ebox-test--input-content-pixel (should (= (ebox-test--input-content-pixel
(ebox-test-box (ebox-test-text content) (ebox-test-box (ebox-test-text content)
:width (list 'fit-content (list 1)))) :width 'fit-content))
min-content)) (cdr case)))))))
(should (= (ebox-test--input-content-pixel
(ebox-test-box (ebox-test-text content)
:width (list 'fit-content
(list (+ max-content 50)))))
max-content))))
(ert-deftest ebox-fit-content-keyword-uses-stretch-when-viewport-is-definite () (ert-deftest ebox-fit-content-keyword-uses-stretch-when-viewport-is-definite ()
"The fit-content keyword should clamp stretch between intrinsic sizes." "The fit-content keyword should clamp stretch between intrinsic sizes."
@ -13092,7 +13088,8 @@ explicit visible-window handoff may admit its full-content publication."
(let* ((content "aa widest") (let* ((content "aa widest")
(min-content (ebox--string-pixel-width "widest")) (min-content (ebox--string-pixel-width "widest"))
(max-content (ebox--string-pixel-width content)) (max-content (ebox--string-pixel-width content))
(between (+ min-content (/ (- max-content min-content) 2)))) (between (+ min-content (/ (- max-content min-content) 2)))
(ebox-viewport-width between))
(should (= (ebox-test--input-content-pixel (should (= (ebox-test--input-content-pixel
(ebox-test-box (ebox-test-text content) (ebox-test-box (ebox-test-text content)
:width '(1) :width '(1)
@ -13106,21 +13103,20 @@ explicit visible-window handoff may admit its full-content publication."
(should (= (ebox-test--input-content-pixel (should (= (ebox-test--input-content-pixel
(ebox-test-box (ebox-test-text content) (ebox-test-box (ebox-test-text content)
:width '(1) :width '(1)
:min-width (list 'fit-content :min-width 'fit-content))
(list between))))
between)) between))
(should (= (ebox-test--input-content-pixel (should (= (ebox-test--input-content-pixel
(ebox-test-box (ebox-test-text content) (ebox-test-box (ebox-test-text content)
:width (list (+ max-content 50)) :width (list (+ max-content 50))
:max-width (list 'fit-content :max-width 'fit-content))
(list between))))
between)))) between))))
(ert-deftest ebox-stretch-and-contain-width-fill-viewport-margin-box () (ert-deftest ebox-stretch-width-fills-viewport-margin-box ()
"stretch and contain should resolve against the current viewport." "Stretch fills available space and the removed contain alias is rejected."
(ebox-test--reset-runtime-state) (ebox-test--reset-runtime-state)
(let ((ebox-viewport-width 240)) (let ((ebox-viewport-width 240))
(dolist (keyword '(stretch contain)) (should-error (ebox-build '(box :width contain "x")))
(dolist (keyword '(stretch))
(let ((box (ebox-test-box (ebox-test-text "x") (let ((box (ebox-test-box (ebox-test-text "x")
:width keyword :width keyword
:padding-inline '(20) :padding-inline '(20)
@ -13207,13 +13203,13 @@ explicit visible-window handoff may admit its full-content publication."
(should-error (should-error
(ebox-test--region-update region-id (ebox-test--region-update region-id
:bgcolor "#222222" :bgcolor "#222222"
:padding-left -1) :padding-left '(ch -1))
:type 'error) :type 'error)
(should (equal (ebox-style-node-specified-value box :bgcolor) (should (equal (ebox-style-node-specified-value box :bgcolor)
"#111111")) "#111111"))
(should (= (ebox-style-node-specified-value (should (equal (ebox-style-node-specified-value
box :padding-left) box :padding-left)
2))))) '(ch 2))))))
(ert-deftest ebox-region-update-no-op-skips-rollback-snapshot () (ert-deftest ebox-region-update-no-op-skips-rollback-snapshot ()
"A legal no-op should return before discovering rollback scope." "A legal no-op should return before discovering rollback scope."
@ -13845,15 +13841,15 @@ face patch must match that exactly (issue014)."
(case (case
'(((box :key root "Before") '(((box :key root "Before")
(box :key root "After") "After") (box :key root "After") "After")
((flex :key root :width (240) ((flex :key root :width (px 240)
(box :key item :width (80) "Item")) (box :key item :width (px 80) "Item"))
(flex :key root :width (240) (flex :key root :width (px 240)
(box :key item :width (120) "Item")) "Item") (box :key item :width (px 120) "Item")) "Item")
((column :key root (box :key first "A")) ((column :key root (box :key first "A"))
(column :key root (column :key root
(box :key first "A") (box :key second "B")) "B") (box :key first "A") (box :key second "B")) "B")
((box :key root :height 1 :overflow visible "A\nB") ((box :key root :height (lh 1) :overflow visible "A\nB")
(box :key root :height 1 :overflow hidden "A\nB") "A"))) (box :key root :height (lh 1) :overflow hidden "A\nB") "A")))
(let ((buffer (generate-new-buffer " *ebox-typed-fresh-equality*"))) (let ((buffer (generate-new-buffer " *ebox-typed-fresh-equality*")))
(unwind-protect (unwind-protect
(progn (progn
@ -13873,16 +13869,16 @@ face patch must match that exactly (issue014)."
(before (before
(ebox-build (ebox-build
'(column :key root '(column :key root
(box :key target :id target :width (52) :padding (0 1) (box :key target :id target :width (px 52) :padding ((lh 0) (ch 1))
:color "#0F172A" :bgcolor "#F8FAFC" "Before") :color "#0F172A" :bgcolor "#F8FAFC" "Before")
(box :key peer :id peer :width (52) :padding (0 1) (box :key peer :id peer :width (px 52) :padding ((lh 0) (ch 1))
:color "#0F172A" :bgcolor "#F8FAFC" "Peer")))) :color "#0F172A" :bgcolor "#F8FAFC" "Peer"))))
(after (after
(ebox-build (ebox-build
'(column :key root '(column :key root
(box :key target :id target :width (52) :padding (0 1) (box :key target :id target :width (px 52) :padding ((lh 0) (ch 1))
:color "#0F172A" :bgcolor "#F8FAFC" "UPDATED\ndetail") :color "#0F172A" :bgcolor "#F8FAFC" "UPDATED\ndetail")
(box :key peer :id peer :width (52) :padding (0 1) (box :key peer :id peer :width (px 52) :padding ((lh 0) (ch 1))
:color "#0F172A" :bgcolor "#F8FAFC" "Peer"))))) :color "#0F172A" :bgcolor "#F8FAFC" "Peer")))))
(unwind-protect (unwind-protect
(progn (progn
@ -13969,7 +13965,7 @@ face patch must match that exactly (issue014)."
(ebox-render-to-buffer (ebox-render-to-buffer
buffer buffer
(ebox-build (ebox-build
'(column :id scroller :width (viewport) :height 3 '(column :id scroller :width (vw 100) :height (lh 3)
:overflow scroll :overflow scroll
(box "A") (box "B") (box "C") (box "D") (box "E")))) (box "A") (box "B") (box "C") (box "D") (box "E"))))
(with-current-buffer buffer (with-current-buffer buffer
@ -13985,16 +13981,16 @@ face patch must match that exactly (issue014)."
(ert-deftest ebox-typed-local-updates-keep-publication-scoped () (ert-deftest ebox-typed-local-updates-keep-publication-scoped ()
"Paint and geometry updates keep an unrelated keyed peer outside full-root work." "Paint and geometry updates keep an unrelated keyed peer outside full-root work."
(dolist (update '((:background-color "#DBEAFE") (:width (120)))) (dolist (update '((:background-color "#DBEAFE") (:width (px 120))))
(let ((buffer (generate-new-buffer " *ebox-typed-scoped-update*"))) (let ((buffer (generate-new-buffer " *ebox-typed-scoped-update*")))
(unwind-protect (unwind-protect
(progn (progn
(ebox-render-to-buffer (ebox-render-to-buffer
buffer buffer
(ebox-build (ebox-build
'(row :width (260) '(row :width (px 260)
(box :id target :width (100) "Target") (box :id target :width (px 100) "Target")
(box :id peer :width (100) "Peer")))) (box :id peer :width (px 100) "Peer"))))
(let* ((target (ebox-region-resolve buffer "target")) (let* ((target (ebox-region-resolve buffer "target"))
(peer (car (ebox-selector-query-buffer buffer "#peer"))) (peer (car (ebox-selector-query-buffer buffer "#peer")))
(peer-node-id (plist-get peer :node-id))) (peer-node-id (plist-get peer :node-id)))

View File

@ -0,0 +1,496 @@
;;; ebox-css-flex-grid-tests.el --- CSS sizes in formatting contexts -*- lexical-binding: t; -*-
;;; Commentary:
;; Regression coverage for delayed relative sizes and the author/used boundary.
;;; Code:
(require 'ert)
(require 'cl-lib)
(require 'ebox)
(ert-deftest ebox-css-flex-grid-empty-auto-height-is-zero ()
"Empty formatting contexts and empty auto-sized children have no height."
(dolist (form '((flex) (grid)
(flex (box)) (flex (box) (box))
(flex :flex-direction column (box) (box))
(grid (box)) (grid (box) (box))
(grid :grid-auto-rows (lh 3))
(grid :grid-auto-columns (px 40) :grid-auto-rows (lh 3))
(flex :row-gap (lh 2) :column-gap (px 8))))
(let ((rendered (ebox-render (ebox-build form))))
(should (equal rendered ""))
(should (= (ebox-string-height rendered) 0)))))
(ert-deftest ebox-css-flex-grid-empty-items-preserve-gap-and-explicit-height ()
"Empty children count toward gaps; explicit dimensions remain visible."
(dolist (case '(((flex :flex-direction column :row-gap (lh 2)
(box) (box)) 2)
((flex :flex-direction column :row-gap (lh 2)
(box) (box "A") (box)) 5)
((flex :width (px 40) :flex-wrap wrap :row-gap (lh 2)
(box :width (px 40)) (box :width (px 40))) 2)
((flex :column-gap (px 8) (box) (box)) 0)
((flex :height (lh 3)) 3)
((grid :height (lh 3)) 3)
((grid :grid-template-columns ((px 20))
:row-gap (lh 2) (box) (box)) 2)
((grid :grid-template-rows ((lh 0) (lh 0))
:row-gap (lh 2)) 2)
((grid :grid-template-rows ((lh 2) (lh 0) (lh 3))
:row-gap (lh 1)) 7)
((grid :height (lh 4)
:grid-template-columns ((px 20))
:grid-template-rows ((lh 0) (fr 1))
:row-gap (lh 1) (box) (box)) 4)))
(ert-info ((format "Formatting case: %S" (car case)))
(let ((rendered (ebox-render (ebox-build (car case)))))
(should (= (ebox-string-height rendered) (cadr case)))))))
(ert-deftest ebox-css-flex-grid-empty-items-do-not-add-content-lines ()
"An empty sibling adds no line before or after real content."
(dolist (form '((flex (box) (box "A") (box))
(flex :flex-direction column (box) (box "A") (box))
(grid :grid-template-columns ((px 20))
(box) (box "A") (box))))
(let ((rendered (ebox-render (ebox-build form))))
(should (= (ebox-string-height rendered) 1))
(should (string-match-p "A" rendered)))))
(ert-deftest ebox-css-flex-grid-empty-items-keep-their-inline-size ()
"A zero-height item's declared width still positions its visible sibling."
(dolist (form '((flex (box :width (px 20)) (box "A"))
(grid :width max-content :grid-template-columns (auto auto)
(box :width (px 20)) (box "A"))))
(let* ((rendered (ebox-render (ebox-build form)))
(position (string-match "A" rendered)))
(should position)
(should (= (ebox-string-height rendered) 1))
(should (= (ebox--string-pixel-width (substring rendered 0 position)) 20)))))
(ert-deftest ebox-css-flex-grid-empty-composites-keep-their-inline-size ()
"A nested empty layout keeps geometry without introducing a text line."
(dolist (case '(((row :item-gap (px 3)
(box :width (px 10)) (box :width (px 4))) 17)
((column (box :width (px 10))) 10)
((flex (box :width (px 10))) 10)
((flex :flex-direction column (box :width (px 10))) 10)
((grid :grid-template-columns ((px 10) (px 4))
:column-gap (px 3) (box) (box)) 17)
((box (box :width (px 10))) 10)))
(dolist (outer '(row flex grid))
(ert-info ((format "Nested empty layout: %S in %S" (car case) outer))
(let* ((form (append (list outer :width '(px 60))
(when (eq outer 'grid)
'(:grid-template-columns (auto auto)
:justify-items start))
(list (car case) '(box :width (px 5) "B"))))
(rendered (ebox-render (ebox-build form)))
(position (string-match "B" rendered)))
(should position)
(should (= (ebox-string-height rendered) 1))
(should (= (ebox--string-pixel-width (substring rendered 0 position))
(cadr case)))
(should (= (ebox-string-height
(ebox-render (ebox-build (car case))))
0)))))))
(ert-deftest ebox-css-flex-grid-empty-composite-widths-follow-render-context ()
"Distinct empty layouts keep separate widths and resolve each new viewport."
(let ((tree (ebox-build
'(row :width (px 100)
(grid :grid-template-columns ((px 23)))
(column (box :width (vw 25)))
(box :width (px 5) "B")))))
(dolist (case '((80 43) (120 53) (80 43)))
(let* ((ebox-viewport-width (car case))
(rendered (ebox-render tree))
(position (string-match "B" rendered)))
(should position)
(should (= (ebox-string-height rendered) 1))
(should (= (ebox--string-pixel-width (substring rendered 0 position))
(cadr case)))))))
(ert-deftest ebox-css-flex-grid-reject-cross-axis-config-units ()
"Typed configs reject cross-axis lengths, including nested track functions."
(dolist (value '((px 0) (ch 2) (vw 5)
(calc (* 0 (px 2)))
(min (lh 2) (max (lh 1) (px 2)))))
(should-error (ebox-flex-layout-create :row-gap value))
(should-error (ebox-grid-layout-create :grid-template-rows (list value)))
(should-error (ebox-grid-layout-create :grid-auto-rows value)))
(dolist (value '((lh 0) (vh 2)
(clamp (px 1) (calc (+ (px 2) (lh 1))) (px 10))))
(should-error (ebox-flex-layout-create :column-gap value))
(should-error (ebox-grid-layout-create :grid-template-columns (list value)))
(should-error (ebox-grid-layout-create :grid-auto-columns value)))
(should-error
(ebox-grid-layout-create :grid-template-rows
'((repeat 2 (minmax (lh 0) (px 20))))))
(should-error
(ebox-grid-layout-create :grid-template-columns
'((repeat 2 (minmax (px 0) (vh 20)))))))
(ert-deftest ebox-css-flex-basis-validates-parent-axis-before-render ()
"Building a Flex parent validates item bases against its resolved direction."
(dolist (direction '(row row-reverse column column-reverse))
(let ((inline (memq direction '(row row-reverse))))
(dolist (basis (if inline '((lh 0) (vh 5) (calc (* 0 (lh 1))))
'((px 0) (ch 2) (vw 5) (calc (* 0 (px 1))))))
(should-error
(ebox-build `(flex :flex-direction ,direction
(box :flex-basis ,basis)))))
(dolist (basis (if inline '((px 0) (ch 2) (vw 5) (% 20))
'((lh 0) (vh 5) (% 20))))
(should
(ebox-build `(flex :flex-direction ,direction
(box :flex-basis ,basis))))))
(should (ebox-build `(flex :flex-direction ,direction (box :flex 1)))))
(should-error
(ebox-build '(flex :flex-flow (column nowrap)
(box :flex (1 1 (px 0))))))
(should
(ebox-build '(flex :flex-direction column
(flex :flex-basis (lh 2) :flex-direction row
(box :flex-basis (px 20)))))))
(ert-deftest ebox-css-flex-basis-validates-computed-parent-context ()
"Stylesheet directions and bases are validated before layout starts."
(dolist (rule '(("#parent" (:flex-direction column))
("#item" (:flex-basis (lh 2)))))
(let ((ebox-style-stylesheet (ecss-stylesheet-create))
(original (symbol-function 'ebox--render-flex-children))
(layout-count 0))
(ebox-style-add-rule (car rule) (cadr rule))
(cl-letf (((symbol-function 'ebox--render-flex-children)
(lambda (&rest arguments)
(cl-incf layout-count)
(apply original arguments))))
(should-error
(ebox-render
(ebox-build
(if (equal (car rule) "#parent")
'(flex :id "parent" (box :id "item" :flex-basis (px 10) "A"))
'(flex :id "parent" (box :id "item" "A")))))
:type 'ebox-size-error))
(should (= layout-count 0)))))
(ert-deftest ebox-css-flex-basis-invalid-context-update-rolls-back ()
"Changing either side of a parent/basis relation fails before publication."
(dolist (case '((nil "parent" :flex-direction column)
(nil "item" :flex-basis (lh 2))
(t "parent" :flex-direction column)
(t "item" :flex-basis (lh 2))))
(let ((buffer (generate-new-buffer " *ebox-flex-axis-update*"))
(ebox-style-stylesheet (ecss-stylesheet-create)))
(unwind-protect
(progn
(when (car case)
(ebox-style-add-rule "flex" '(:color "red")))
(ebox-render-to-buffer
buffer (ebox-build '(flex :id "parent" :width (px 40)
(box :id "item" :flex-basis (px 10) "A"))))
(let* ((before (with-current-buffer buffer (buffer-string)))
(report (ebox-buffer-update-report buffer))
(state (ebox--buffer-render-state buffer))
(runtime-revision (plist-get state :runtime-revision))
(surface-revision (tp-surface-revision (plist-get state :surface)))
(original (symbol-function 'ebox--render-flex-children))
(layout-count 0))
(cl-letf (((symbol-function 'ebox--render-flex-children)
(lambda (&rest arguments)
(cl-incf layout-count)
(apply original arguments))))
(should-error
(apply #'ebox-region-update
(ebox-region-resolve buffer (cadr case)) (cddr case))
:type 'ebox-size-error))
(should (= layout-count 0))
(should (equal-including-properties
before (with-current-buffer buffer (buffer-string))))
(should (equal report (ebox-buffer-update-report buffer)))
(should (= runtime-revision
(plist-get (ebox--buffer-render-state buffer)
:runtime-revision)))
(should (= surface-revision
(tp-surface-revision (plist-get state :surface)))))
(should (ebox-region-update (ebox-region-resolve buffer "item")
:flex-basis '(px 12))))
(when (buffer-live-p buffer) (kill-buffer buffer))))))
(ert-deftest ebox-css-flex-basis-validates-final-batched-context ()
"A batch can change both parent axis and child basis as one valid update."
(let ((buffer (generate-new-buffer " *ebox-flex-axis-batch*"))
(ebox-style-stylesheet (ecss-stylesheet-create)))
(unwind-protect
(progn
(ebox-render-to-buffer
buffer (ebox-build '(flex :id "parent" :width (px 40)
(box :id "item" :flex-basis (px 10) "A"))))
(ebox-incremental-begin-batch buffer)
(ebox-region-update (ebox-region-resolve buffer "parent")
:flex-direction 'column)
(ebox-region-update (ebox-region-resolve buffer "item")
:flex-basis '(lh 2))
(should (ebox-incremental-flush buffer))
(should (= (ebox-string-height
(with-current-buffer buffer (buffer-string))) 2)))
(when (buffer-live-p buffer) (kill-buffer buffer)))))
(ert-deftest ebox-css-flex-basis-validates-final-class-context ()
"Class selectors compute the parent axis and child basis before validation."
(let ((ebox-style-stylesheet (ecss-stylesheet-create)))
(ebox-style-add-rule "#item" '(:flex-basis (px 10)))
(ebox-style-add-rule ".vertical" '(:flex-direction column))
(ebox-style-add-rule ".vertical > #item" '(:flex-basis (lh 2)))
(dolist (case '((nil 1) (("vertical") 2) (nil 1)))
(should (= (ebox-string-height
(ebox-render
(ebox-build `(flex :class ,(car case) :width (px 40)
(box :id "item" "A")))))
(cadr case))))))
(ert-deftest ebox-css-flex-grid-configs-preserve-size-expressions ()
"Layout configs keep context-dependent author sizes until layout."
(let* ((gap '(calc (+ (% 10) (px 2))))
(flex (ebox-layout-config-props
(ebox-flex-layout-create :column-gap gap :row-gap '(lh 1))))
(grid (ebox-layout-config-props
(ebox-grid-layout-create
:column-gap gap :grid-template-columns
'((repeat 2 (% 50)))))))
(should (equal (plist-get flex :column-gap) gap))
(should (equal (plist-get flex :row-gap) '(lh 1)))
(should (equal (plist-get grid :grid-template-columns)
'((:kind fixed :size (% 50))
(:kind fixed :size (% 50)))))
(should (ebox-flex-layout-config-props-p flex))
(should (ebox-grid-layout-config-props-p grid))))
(ert-deftest ebox-css-flex-grid-reject-implicit-size-syntax ()
"Unitless values and parameterized fit-content never enter configs."
(dolist (value '(2 (2) viewport (viewport-height) (fit-content (ch 80))))
(should-error (ebox-flex-layout-create :column-gap value))
(should-error (ebox-flex-layout-create :row-gap value))
(should-error
(ebox-grid-layout-create :grid-template-columns (list value)))))
(ert-deftest ebox-css-flex-grid-gaps-use-definite-containing-axis ()
"Percentages use each layout axis and unresolved gap percentages are zero."
(cl-letf (((symbol-function 'ebox--size-line-height) (lambda (&optional _) 10)))
(should (= (ebox--layout-used-gap '(% 25) 'width 200) 50))
(should (= (ebox--layout-used-gap '(% 25) 'height 10) 2))
(let ((ebox--css-containing-height 1000))
(should (= (ebox--layout-used-gap '(% 25) 'height nil) 0)))
(should (= (ebox--layout-used-gap '(calc (+ (% 25) (px 5)))
'width nil) 5))
(should (= (ebox--layout-used-gap '(calc (+ (px 5) (lh 1)))
'height 20) 1))))
(ert-deftest ebox-css-grid-relative-tracks-reresolve-and-share-rounding ()
"Track expressions survive resizing and do not each lose their fractions."
(let ((tracks (ebox-grid--normalize-tracks '((% 50) (% 50)) 'columns))
(rendered (make-hash-table :test 'eq)))
(should (equal (ebox-grid--resolve-sizes tracks 2 'columns
nil rendered 0 101)
'(50 51)))
(should (equal (ebox-grid--resolve-sizes tracks 2 'columns
nil rendered 0 201)
'(100 101)))
(should (equal (plist-get (car tracks) :size) '(% 50)))))
(ert-deftest ebox-css-grid-minmax-minimum-wins-after-unit-resolution ()
"A definite minmax maximum below its minimum becomes the minimum."
(let ((track (ebox-grid--normalize-track
'(minmax (% 60) (px 40)) 'columns)))
(should (equal (ebox-grid--resolve-sizes
(list track) 1 'columns nil (make-hash-table) 0 100)
'(60)))))
(ert-deftest ebox-css-grid-percent-height-without-containing-height-is-auto ()
"An indefinite percentage row sizes from content instead of viewport height."
(let* ((node 'cell)
(entries (list (list :node node :row 1 :row-span 1)))
(rendered (make-hash-table :test 'eq))
(tracks (ebox-grid--normalize-tracks '((% 100)) 'rows))
(ebox-viewport-height 100))
(puthash node "first\nsecond" rendered)
(should (equal (ebox-grid--resolve-sizes
tracks 1 'rows entries rendered 0 nil) '(2)))))
(ert-deftest ebox-css-flex-grid-render-public-size-expressions ()
"The public DSL renders both formatting contexts with canonical units."
(dolist (dsl '((flex :width (px 100) :height (lh 3)
:column-gap (px 4)
(box :min-width (px 0) :flex-basis (% 50) "A")
(box :min-width (px 0) :flex-basis (% 50) "B"))
(grid :width (px 100) :height (lh 3)
:grid-template-columns ((fr 1) (fr 1))
:column-gap (px 4)
(box "A") (box "B"))))
(let ((rendered (ebox-render (ebox-build dsl))))
(should (= (ebox--string-max-pixel-width rendered) 100))
(should (= (ebox-string-height rendered) 3))
(should (string-match-p "A.*B" rendered)))))
(ert-deftest ebox-css-flex-column-fractional-bases-round-at-final-boundaries ()
"Two percentage bases divide an odd height without fractional line errors."
(let* ((rendered
(ebox-render
(ebox-build
'(flex :flex-direction column :width (px 50) :height (lh 5)
(box :min-height (lh 0) :flex-basis (% 50) "A")
(box :min-height (lh 0) :flex-basis (% 50) "B")))))
(lines (ebox-string-lines rendered)))
(should (= (length lines) 5))
(should (string-match-p "A" (nth 0 lines)))
(should (string-match-p "B" (nth 2 lines)))))
(ert-deftest ebox-css-size-quantize-preserves-extents-and-zero ()
"Boundary rounding preserves zero sizes and handles floating-point residue."
(should (equal (ebox-size-quantize '(0 0.5 0 0.5 0)) '(0 0 0 1 0)))
(should (equal (ebox-size-quantize '(100.49999999999999 100.49999999999999))
'(100 101)))
(should-error (ebox-size-quantize '(2 -1))))
(ert-deftest ebox-css-flex-automatic-minimum-respects-overflow-and-explicit-zero ()
"Automatic minima are content-based only for non-scrollable overflow."
(dolist (case '((visible auto t) (scroll auto nil) (hidden auto nil)
(visible (px 0) nil)))
(let* ((input (ebox-build `(box :overflow ,(nth 0 case)
:min-width ,(nth 1 case)
:wrap-mode none "UNBREAKABLE")))
(node (car (ebox-canonical-input--nodes input)))
(rendered (ebox-render input))
(minimum (ebox--flex-box-min-main node rendered 'row)))
(if (nth 2 case)
(should (= minimum (ebox--string-pixel-width "UNBREAKABLE")))
(should (= minimum 0))))))
(ert-deftest ebox-css-flex-column-automatic-minimum-preserves-visible-content ()
"Column auto minima retain content height when overflow is visible."
(let* ((input (ebox-build '(box :overflow visible :min-height auto "A\nB\nC")))
(node (car (ebox-canonical-input--nodes input))))
(should (= (ebox--flex-box-min-main node (ebox-render input) 'column) 3))))
(ert-deftest ebox-css-grid-fractions-freeze-content-minima-before-sharing-space ()
"Flexible tracks honor automatic minima and still fill remaining space."
(let* ((first-input (ebox-build '(box :min-width auto :overflow visible
:wrap-mode none "abcdefgh")))
(second-input (ebox-build '(box :min-width auto :overflow visible
:wrap-mode none "X")))
(first (car (ebox-canonical-input--nodes first-input)))
(second (car (ebox-canonical-input--nodes second-input)))
(entries (list (list :node first :column 1 :column-span 1)
(list :node second :column 2 :column-span 1)))
(rendered (make-hash-table :test 'eq))
(tracks (ebox-grid--normalize-tracks '((fr 1) (fr 1)) 'columns))
(minimum (ebox--string-pixel-width "abcdefgh"))
(available (+ minimum 2)))
(puthash first (ebox-render first-input) rendered)
(puthash second (ebox-render second-input) rendered)
(should (equal (ebox-grid--resolve-sizes
tracks 2 'columns entries rendered 0 available)
(list minimum 2)))
(should-not (ebox-grid--content-independent-columns-p
tracks 2 available (list first second)))))
(ert-deftest ebox-css-grid-percent-child-sizes-use-final-cell ()
"Relative children re-render once the grid area has definite dimensions."
(let ((original (symbol-function 'ebox--render-with-cache)) sizes)
(cl-letf (((symbol-function 'ebox--render-with-cache)
(lambda (node &rest arguments)
(let ((result (apply original node arguments)))
(when (and (equal (plist-get node :width) '(% 50))
(equal (plist-get node :height) '(% 50)))
(push (list (ebox--string-max-pixel-width result)
(ebox-string-height result)) sizes))
result))))
(ebox-render
(ebox-build '(grid :width (px 200) :height (lh 12)
:grid-template-columns ((px 100) (px 100))
:grid-template-rows ((lh 6))
:align-content start
(box :width (% 50) :height (% 50) "A")
(box "B")))))
(should (= (caar sizes) 50))
(should (= (cadar sizes) 3))))
(ert-deftest ebox-css-grid-indefinite-fractions-size-from-content ()
"An intrinsic Grid width does not collapse fractional tracks to zero."
(let ((rendered (make-hash-table :test 'eq))
(tracks (ebox-grid--normalize-tracks '((fr 1) (fr 1)) 'columns))
(entries '((:node first :column 1 :column-span 1)
(:node second :column 2 :column-span 1))))
(puthash 'first "abcdefgh" rendered)
(puthash 'second "X" rendered)
(let ((width (ebox--string-pixel-width "abcdefgh")))
(should (equal (ebox-grid--resolve-sizes
tracks 2 'columns entries rendered 0 nil)
(list width width))))))
(ert-deftest ebox-css-grid-cyclic-percentage-gap-resolves-after-intrinsic-size ()
"A cyclic Grid gap uses the intrinsic extent and can overflow it."
(let ((columns (ebox-grid--final-content-layout
'(40 40) '(% 25) 'width nil 'start nil))
(rows (ebox-grid--final-content-layout
'(4 4) '(% 25) 'height nil 'start nil)))
(should (equal (plist-get columns :sizes) '(40 40)))
(should (= (plist-get columns :between) 20))
(should (= (plist-get columns :target) 80))
(should (= (plist-get rows :between) 2))
(should (= (plist-get rows :target) 8))))
(ert-deftest ebox-css-flex-percentage-edges-use-container-before-item-allocation ()
"Assigned item widths do not become percentage padding and margin bases."
(let* ((input (ebox-build
'(flex :width (px 200)
(box :flex-basis (px 60)
:padding ((lh 0) (% 10))
:margin ((lh 0) (% 5)) "X"))))
(child (car (ebox-tree-layout-children
(car (ebox-canonical-input--nodes input)))))
(rendered (ebox-render input))
(position (string-match "X" rendered)))
(should position)
(should (= (ebox--string-pixel-width (substring rendered 0 position)) 30))
(should (equal (plist-get child :padding-left-pixel) '(% 10)))
(should (equal (plist-get child :margin-left-pixel) '(% 5)))))
(ert-deftest ebox-css-grid-cyclic-gap-overflows-intrinsic-box-without-growing-it ()
"Cyclic gaps overflow the auto-sized box while hidden overflow clips them."
(dolist (overflow '(hidden visible))
(let* ((vertical
(ebox-render
(ebox-build
`(grid :width (px 40) :overflow ,overflow
:grid-template-columns ((px 40))
:grid-template-rows ((lh 4) (lh 4))
:row-gap (% 25) (box "A") (box "B")))))
(horizontal
(ebox-render
(ebox-build
`(grid :width max-content :overflow ,overflow
:grid-template-columns ((px 40) (px 40))
:column-gap (% 25) (box "A") (box "B"))))))
(should (= (ebox-string-height vertical) (if (eq overflow 'hidden) 8 10)))
(should (= (ebox--rendered-intrinsic-size horizontal :width) 80))
;; The buffer backend's existing hidden overflow clips vertically only.
(should (= (ebox--string-max-pixel-width horizontal) 100)))))
(ert-deftest ebox-css-flex-preserves-nested-grid-intrinsic-extents ()
"A Grid child's cyclic gap spill does not enlarge its Flex layout size."
(let* ((input
(ebox-build
'(flex :width (px 40) :flex-direction column :overflow visible
(grid :overflow visible
:grid-template-columns ((px 40))
:grid-template-rows ((lh 4) (lh 4))
:row-gap (% 25) (box "A") (box "B"))
(box "Z"))))
(rendered (ebox-render input)))
(should (= (ebox--rendered-intrinsic-size rendered :height) 9))
(should (= (ebox-string-height rendered) 11))))
(provide 'ebox-css-flex-grid-tests)
;;; ebox-css-flex-grid-tests.el ends here

View File

@ -0,0 +1,559 @@
;;; ebox-css-layout-tests.el --- CSS used-size regression tests -*- lexical-binding: t; -*-
;;; Commentary:
;; Exercise the boundary between pure CSS lengths and buffer layout geometry.
;;; Code:
(require 'ert)
(require 'cl-lib)
(require 'ebox-fixtures)
(defun ebox-css-layout-test--glyph-width (string)
"Measure STRING with seven-pixel glyphs and Emacs's per-space truncation."
(cl-loop for index below (or (string-match "\n" string) (length string))
for display = (get-text-property index 'display string)
for width = (and (consp display) (eq (car display) 'space)
(plist-get (cdr display) :width))
sum (cond ((and (consp width) (numberp (car width)))
(floor (car width)))
((numberp width) (floor (* 7 width)))
(t 7))))
(defmacro ebox-css-layout-test--with-pixel-display (&rest body)
"Run BODY with an independent whole-pixel display oracle."
(declare (indent 0) (debug t))
`(let ((ebox-runtime-idle-prewarm nil)
(ebox-runtime-idle-reflow-cache-prewarm nil))
(cl-letf (((symbol-function 'string-pixel-width)
#'ebox-css-layout-test--glyph-width)
((symbol-function 'ebox-native-reflow-layout-ready-p)
(lambda () nil)))
(ebox-clear-cache)
(unwind-protect (progn ,@body)
(ebox-clear-cache)))))
(defun ebox-css-layout-test--display (string)
"Return STRING text and display properties without retained identities."
(list (substring-no-properties string)
(cl-loop for index below (length string)
collect (get-text-property index 'display string))))
(defun ebox-css-layout-test--assert-pixel-lines (output width)
"Assert every OUTPUT line measures WIDTH in the model and real glyph oracle."
(dolist (line (ebox-string-lines output))
(should (= (ebox--string-pixel-width line) width))
(should (= (ebox-css-layout-test--glyph-width line) width))))
(ert-deftest ebox-css-layout-percentage-text-and-blank-share-pixel-boundaries ()
"Centered text and blank fill consume the same final percentage width."
(ebox-css-layout-test--with-pixel-display
(let* ((ebox-viewport-width 207)
(input (ebox-build
'(box :width (% 50) :height (lh 2) :border "red"
:text-align center "X")))
(output (ebox-render input)))
(should (= (ebox-size-resolve '(% 50) '(:percent-base 207)) 103.5))
(should (= (ebox-string-height output) 2))
(ebox-css-layout-test--assert-pixel-lines output 103))))
(ert-deftest ebox-css-layout-percentage-siblings-share-the-remaining-pixel ()
"Two 50-percent Row children divide an odd extent into 50 and 51 pixels."
(ebox-css-layout-test--with-pixel-display
(let* ((ebox-viewport-width 101)
(output
(ebox-render
(ebox-build
'(row :width (px 101)
(box :width (% 50) :height (lh 2) :border "red"
:text-align center "A")
(box :width (% 50) :height (lh 2) :border "blue"
:text-align center "B"))))))
(ebox-css-layout-test--assert-pixel-lines output 101)
(dolist (line (ebox-string-lines output))
(let ((offsets
(cl-loop for index below (length line)
when (get-text-property index 'ebox-bl line)
collect (ebox-css-layout-test--glyph-width
(substring line 0 index)))))
(should (equal offsets '(0 50))))))))
(ert-deftest ebox-css-layout-fractional-chrome-shares-the-content-boundary ()
"Margins, padding and centered content share one cumulative pixel grid."
(ebox-css-layout-test--with-pixel-display
(ebox-css-layout-test--assert-pixel-lines
(ebox-render
(ebox-build
'(box :width (px 20) :height (lh 2) :box-sizing border-box
:padding-inline (px 0.5) :margin-inline (px 0.5)
:border "red" :text-align center "X")))
21)))
(ert-deftest ebox-css-layout-pixel-quantization-preserves-private-input ()
"Fractional runs split per glyph; zero widths keep ownership and newlines."
(let* ((display '(space :width (0.5) :height 1))
(source (propertize " \n " 'display display 'ebox-content 17))
(before (copy-sequence source))
(result (ebox--quantize-pixel-spaces source)))
(should (equal-including-properties source before))
(should (equal display '(space :width (0.5) :height 1)))
(should (equal (mapcar (lambda (index)
(plist-get (cdr (get-text-property index 'display
result)) :width))
'(0 1 3 4))
'((0) (1) (0) (1))))
(dolist (index '(0 1 3 4))
(should (= (get-text-property index 'ebox-content result) 17))
(should (= (plist-get (cdr (get-text-property index 'display result))
:height)
1)))
(should (eq result (ebox--quantize-pixel-spaces result)))
(let ((integer (ebox-pixel-space 12)))
(should (eq integer (ebox--quantize-pixel-spaces integer))))))
(defun ebox-css-layout-test--retained-label (label &optional color)
"Build a stable percentage LABEL whose text parity can introduce fractions."
(ebox-build
`(column :key root :width (vw 100)
(box :key label :width (% 100) :height (lh 2) :border "red"
:text-align center :bgcolor ,(or color "#FFFFFF") ,label))))
(ert-deftest ebox-css-layout-retained-new-fractions-and-paint-match-fresh ()
"A local text edit can introduce a half-pixel, even with unchanged sizes."
(ebox-css-layout-test--with-pixel-display
(let ((ebox-viewport-width 101)
(ebox-viewport-height 20))
(with-temp-buffer
(ebox-render-to-buffer
(current-buffer) (ebox-css-layout-test--retained-label "A"))
(should-not (plist-get (ebox--buffer-render-state (current-buffer))
:fractional-pixel-output-p))
;; The first update introduces fractions into an integer-only prior
;; frame. Paint must preserve the flag; later integer output clears it.
(dolist (case '(("AA" nil t) ("AA" "#DDEEFF" t)
("AAA" "#DDEEFF" nil) ("AAAA" "#DDEEFF" t)
("A" "#DDEEFF" nil)))
(let* ((input (ebox-css-layout-test--retained-label
(nth 0 case) (nth 1 case)))
(_report (ebox-commit (current-buffer) input))
(actual (buffer-string))
(fresh (ebox-render input)))
(should (eq (plist-get (ebox--buffer-render-state (current-buffer))
:fractional-pixel-output-p)
(nth 2 case)))
(ebox-css-layout-test--assert-pixel-lines actual 101)
(should (equal (ebox-css-layout-test--display actual)
(ebox-css-layout-test--display fresh)))))))))
(ert-deftest ebox-css-layout-retained-fractional-sibling-origin-matches-fresh ()
"Sequential edits never quantize the second fractional sibling at origin zero."
(ebox-css-layout-test--with-pixel-display
(let ((ebox-viewport-width 101)
(ebox-viewport-height 20))
(cl-labels ((input (width right)
(ebox-build
`(row :key root :width (px 101)
(box :key left :width ,width :height (lh 2)
:border "red" :text-align center "A")
(box :key right :width ,width :height (lh 2)
:border "blue" :text-align center ,right)))))
(with-temp-buffer
(ebox-render-to-buffer (current-buffer) (input '(% 50) "B"))
(dolist (case '(((% 50) "BB" t) ((% 50) "BBB" t)
((px 50) "BBB" nil) ((px 50) "B" nil)
((% 50) "BB" t)))
(let* ((next (input (nth 0 case) (nth 1 case)))
(_report (ebox-commit (current-buffer) next))
(actual (buffer-string)))
(should (eq (plist-get (ebox--buffer-render-state (current-buffer))
:fractional-pixel-output-p)
(nth 2 case)))
(ebox-css-layout-test--assert-pixel-lines actual 101)
(should (equal (ebox-css-layout-test--display actual)
(ebox-css-layout-test--display
(ebox-render next)))))))))))
(defun ebox-css-layout-test--fractional-scroll (offset &optional boundary mixed)
"Build a centered percentage scroll at OFFSET with optional BOUNDARY.
MIXED starts with three integral rows before the fractional cached rows."
(let ((scroll
(apply #'ebox-test-box
:key 'scroll :width '(% 100) :height 3 :overflow 'scroll
:text-align 'center :scroll-offset offset
(append
(and (eq boundary 'chrome) (list :border "red"))
(list
(ebox-test-text
(mapconcat (lambda (index)
(format (if (and mixed (< index 3)) "%03d" "%02d")
index))
(number-sequence 0 31) "\n")))))))
(if (eq boundary 'nested)
(ebox-test-box :width '(101) scroll)
scroll)))
(ert-deftest ebox-css-layout-fractional-root-scroll-reuses-complete-pixel-lines ()
"Cached full physical rows scroll without layout or projection work."
(ebox-css-layout-test--with-pixel-display
(let ((ebox-viewport-width 101)
(ebox-viewport-height 3))
(with-temp-buffer
(ebox-render-to-buffer
(current-buffer) (ebox-css-layout-test--fractional-scroll 10))
(let* ((state (ebox--buffer-render-state (current-buffer)))
(region (car (plist-get state :scroll-region-ids)))
(scroll (gethash region (plist-get state :scroll-state-table)))
(raw (plist-get scroll :content-lines))
(rendered (plist-get scroll :rendered-content-lines))
(before (copy-tree (mapcar #'ebox-css-layout-test--display
rendered)))
(offset 10))
(should (plist-get state :fractional-pixel-output-p))
(should (ebox--scroll-state-retained-window-ready-p scroll))
(dolist (next '(11 12 11 16 10))
(cl-letf (((symbol-function 'ebox-surface--render-candidate)
(lambda (&rest _) (ert-fail "cached scroll rendered root")))
((symbol-function 'ebox-surface--render-candidate-node)
(lambda (&rest _) (ert-fail "cached scroll reran layout")))
((symbol-function 'ebox-surface--complete-projection)
(lambda (&rest _) (ert-fail "cached scroll reprojected styles"))))
(should (= (ebox--scroll-region-by region (- next offset) 1)
(- next offset))))
(setq offset next
state (ebox--buffer-render-state (current-buffer))
scroll (gethash region (plist-get state :scroll-state-table)))
(let ((lines (plist-get (plist-get state :surface-fragments)
:scroll-lines)))
(should lines)
(should-not (cl-some #'ebox--fractional-pixel-spaces-p lines))
(should (equal (ebox-css-layout-test--display (ebox-lines-join lines))
(ebox-css-layout-test--display (buffer-string)))))
(should (eq (plist-get (ebox-buffer-update-report (current-buffer))
:projection-kind)
'scroll-patch))
(should (plist-get state :fractional-pixel-output-p))
(should (eq raw (plist-get scroll :content-lines)))
(should (eq rendered (plist-get scroll :rendered-content-lines)))
(should (equal before
(mapcar #'ebox-css-layout-test--display rendered)))
(ebox-css-layout-test--assert-pixel-lines (buffer-string) 101)
(should (equal (ebox-css-layout-test--display (buffer-string))
(ebox-css-layout-test--display
(ebox-render
(ebox-css-layout-test--fractional-scroll offset)))))))))))
(ert-deftest ebox-css-layout-fractional-root-scroll-retains-new-raw-fraction-flag ()
"A newly visible fractional row keeps its raw flag after returning to integers."
(ebox-css-layout-test--with-pixel-display
(let ((ebox-viewport-width 101)
(ebox-viewport-height 3))
(with-temp-buffer
(ebox-render-to-buffer
(current-buffer) (ebox-css-layout-test--fractional-scroll 0 nil t))
(let* ((state (ebox--buffer-render-state (current-buffer)))
(region (car (plist-get state :scroll-region-ids)))
(raw (plist-get (gethash region (plist-get state :scroll-state-table))
:rendered-content-lines)))
(should-not (plist-get state :fractional-pixel-output-p))
(dolist (delta '(1 -1))
(cl-letf (((symbol-function 'ebox-surface--render-candidate)
(lambda (&rest _) (ert-fail "fractional row rerendered root"))))
(should (= (ebox--scroll-region-by region delta 1) delta)))
(setq state (ebox--buffer-render-state (current-buffer)))
(should (plist-get state :fractional-pixel-output-p))
(ebox-css-layout-test--assert-pixel-lines (buffer-string) 101))
;; Integral line strings keep their existing ownership-template keys.
(let ((lines (plist-get (plist-get state :surface-fragments) :scroll-lines)))
(cl-mapc (lambda (actual source) (should (eq actual source)))
lines (seq-take raw 3))))))))
(ert-deftest ebox-css-layout-fractional-scroll-keeps-wrapper-fallbacks ()
"Nested or chrome-bearing fractional scrolls retain full-line projection."
(ebox-css-layout-test--with-pixel-display
(let ((ebox-viewport-width 101)
(ebox-viewport-height 3))
(dolist (boundary '(nested chrome))
(with-temp-buffer
(ebox-render-to-buffer
(current-buffer) (ebox-css-layout-test--fractional-scroll 10 boundary))
(let* ((state (ebox--buffer-render-state (current-buffer)))
(region (car (plist-get state :scroll-region-ids)))
(original (symbol-function 'ebox-surface--render-candidate))
(calls 0))
(should (plist-get state :fractional-pixel-output-p))
(cl-letf (((symbol-function 'ebox-surface--render-candidate)
(lambda (&rest args)
(cl-incf calls)
(apply original args))))
(should (= (ebox--scroll-region-by region 1 1) 1)))
(should (= calls 1))
(should (equal (ebox-css-layout-test--display (buffer-string))
(ebox-css-layout-test--display
(ebox-render
(ebox-css-layout-test--fractional-scroll
11 boundary)))))))))))
(ert-deftest ebox-css-layout-fractional-scroll-rolls-back-normalized-output ()
"Failed publication preserves raw caches and retries the cached scroll path."
(ebox-css-layout-test--with-pixel-display
(let ((ebox-viewport-width 101)
(ebox-viewport-height 3))
(with-temp-buffer
(ebox-render-to-buffer
(current-buffer) (ebox-css-layout-test--fractional-scroll 10))
(let* ((surface ebox-surface--buffer-surface)
(state (tp-surface-client-state surface))
(revision (tp-surface-revision surface))
(region (car (plist-get state :scroll-region-ids)))
(scroll (gethash region (plist-get state :scroll-state-table)))
(raw (plist-get scroll :rendered-content-lines))
(before (copy-tree (mapcar #'ebox-css-layout-test--display raw)))
(contents (buffer-string)))
(let ((tp--surface-publication-step-function
(lambda (step _surface)
(when (eq step 'client-state)
(error "reject normalized scroll publication")))))
(should-error (ebox--scroll-region-by region 1 1)))
(should (= (tp-surface-revision surface) revision))
(should (eq (tp-surface-client-state surface) state))
(should (= (plist-get (ebox-scroll-state region) :scroll-offset) 10))
(should (eq raw (plist-get (ebox-scroll-state region) :rendered-content-lines)))
(should (equal before (mapcar #'ebox-css-layout-test--display raw)))
(should (equal-including-properties contents (buffer-string)))
(cl-letf (((symbol-function 'ebox-surface--render-candidate)
(lambda (&rest _) (ert-fail "retry rerendered root"))))
(should (= (ebox--scroll-region-by region 1 1) 1)))
(ebox-css-layout-test--assert-pixel-lines (buffer-string) 101))))))
(ert-deftest ebox-css-layout-percent-and-viewport-use-distinct-bases ()
(let* ((ebox-viewport-width 800)
(ebox-viewport-height 40)
(ebox--css-viewport-width 800)
(ebox--css-viewport-height 800)
(ebox--css-containing-height 800)
(ebox--css-size-context-active-p t)
(box (ebox-test-root
(ebox-build '(box :width (px 200) :height (vh 50) "x")))))
(ebox--call-with-box-content-context
box
(lambda ()
(should (= (ebox--size-pixels '(% 50) nil 'width) 100))
(should (= (ebox--size-pixels '(vw 50) nil 'width) 400))
(should (= (ebox--size-pixels '(% 50) nil 'height) 200))
(should (= (ebox--size-pixels '(vh 50) nil 'height) 400))))))
(ert-deftest ebox-css-layout-auto-height-does-not-resolve-child-percent ()
(let* ((ebox-viewport-width 800)
(ebox--css-containing-height 800)
(box (ebox-test-root
(ebox-build '(box :width (px 200) "x")))))
(ebox--call-with-box-content-context
box
(lambda ()
(should-not (ebox--size-pixels '(% 50) nil 'height))
(should (= (ebox--resolve-size-content-height
box '(% 50) 3)
3))))))
(ert-deftest ebox-css-layout-explicit-indefinite-percent-basis ()
(let ((ebox-viewport-width 400)
(ebox--css-containing-height 600))
(should-not (ebox--size-pixels '(% 100) nil 'width nil))
(should-not (ebox--size-pixels '(% 100) nil 'height nil))
(should (= (ebox--size-pixels '(% 100) nil 'width 0) 0))))
(ert-deftest ebox-css-layout-definite-parent-height-applies-constraints ()
(let ((ebox-viewport-width 400)
(ebox--css-size-context-active-p t)
(ebox--css-viewport-height 400)
(box (ebox-test-root
(ebox-build '(box :height (vh 50) :min-height (vh 12.5)
:max-height (vh 25) "x")))))
(ebox--call-with-box-content-context
box
(lambda ()
(should (= (ebox--size-pixels '(% 50) nil 'height) 50))))))
(ert-deftest ebox-css-layout-font-units-use-projected-zero-and-line ()
(let* ((fact (ebox-font--make-fact
:measurement-metrics '(:zero-width 9 :height 24)))
(box (list :ebox-font-fact fact)))
(should (= (ebox--size-pixels '(ch 10) box) 90))
(should (= (ebox--size-pixels '(lh 3) box 'height) 72))
(should (= (ebox--resolve-height-expression-lines '(lh 3) box) 3))
(let ((ebox--css-font-fact fact))
(should (= (ebox--size-pixels '(ch 10)) 90)))))
(ert-deftest ebox-css-layout-rounds-height-after-complete-expression ()
(let* ((fact (ebox-font--make-fact
:measurement-metrics '(:zero-width 10 :height 20)))
(box (ebox-test-root
(ebox-build
'(box :height (calc (- (vh 50) (lh 0.4))) "x"))))
(ebox--css-size-context-active-p t)
(ebox--css-viewport-height 490))
(setq box (plist-put box :ebox-font-fact fact))
(should (< (abs (- (ebox--resolve-height-expression-lines
'(calc (- (vh 50) (lh 0.4))) box)
11.85))
1e-9))
(should (= (ebox--content-height box 1) 11))))
(ert-deftest ebox-css-layout-vertical-padding-percent-refers-to-width ()
(let ((ebox-viewport-width 200)
(ebox--css-containing-height 1000)
(box (list :ebox-font-fact
(ebox-font--make-fact
:measurement-metrics '(:zero-width 10 :height 20)))))
(should (= (ebox--edge-used-value box :padding-top-height '(% 10)) 1))
(should (= (ebox--edge-used-value box :padding-left-pixel '(% 10)) 20))))
(ert-deftest ebox-css-layout-height-keywords-and-minimum-precedence ()
(let ((ebox--css-containing-height 10))
(dolist (keyword '(min-content max-content fit-content))
(let ((box (ebox-test-root
(ebox-build `(box :height ,keyword "x")))))
(should (= (ebox--content-height box 4) 4))))
(let ((box (ebox-test-root
(ebox-build '(box :height (lh 2)
:min-height (lh 5)
:max-height (lh 3) "x")))))
(should (= (ebox--content-height box 1) 5)))))
(ert-deftest ebox-css-layout-inline-content-wraps-at-final-used-width ()
"Intrinsic keywords and bounds size the text flow as well as its paint."
(ebox-css-layout-test--with-pixel-display
(let ((ebox-viewport-width 56))
(dolist (kind '(single multiple column))
(dolist (case '(((:width min-content)
28 ("red" "ink" "on" "blue"))
((:width fit-content)
56 ("red ink" "on blue"))
((:width max-content)
105 ("red ink on blue"))
((:width (px 7) :min-width min-content)
28 ("red" "ink" "on" "blue"))
((:width (px 7) :min-width fit-content)
56 ("red ink" "on blue"))
((:width (px 7) :min-width max-content)
105 ("red ink on blue"))
((:width (px 140) :max-width min-content)
28 ("red" "ink" "on" "blue"))
((:width (px 140) :max-width fit-content)
56 ("red ink" "on blue"))
((:width (px 140) :max-width max-content)
105 ("red ink on blue"))
((:width max-content :max-width (px 56))
56 ("red ink" "on blue"))
((:width min-content :min-width (px 56))
56 ("red ink" "on blue"))
((:width (px 7) :min-width (px 56)
:max-width (px 28))
56 ("red ink" "on blue"))))
(ert-info ((format "%S flow, declarations %S" kind (car case)))
(let* ((form
(append
(list (if (eq kind 'column) 'column 'box))
(car case)
(pcase kind
('single '("red ink on blue"))
('multiple '((text "red ink ") (text "on blue")))
('column '((text "red ink on blue"))))))
(output (ebox-render (ebox-build form))))
(ebox-css-layout-test--assert-pixel-lines output (nth 1 case))
(should (equal (mapcar #'string-trim (ebox-string-lines output))
(nth 2 case))))))))))
(ert-deftest ebox-css-layout-intrinsic-text-viewport-keeps-nested-contexts ()
"Intrinsic probes stay unwrapped; final nested percentages use the used width."
(ebox-css-layout-test--with-pixel-display
(dolist (form '((box :width fit-content
(column (text "red ink on blue")))
(column :width fit-content
(box :width (% 100) "red ink on blue"))))
(let* ((input (ebox-build form))
(node (ebox-test-root input)))
(dolist (width '(56 140 14 84 28 56))
(let* ((ebox-viewport-width width)
(output (ebox-render input)))
(ebox-css-layout-test--assert-pixel-lines
output (max 28 (min 105 width)))
(should (equal (mapcar #'string-trim (ebox-string-lines output))
(pcase width
((or 14 28) '("red" "ink" "on" "blue"))
(56 '("red ink" "on blue"))
(84 '("red ink on" "blue"))
(140 '("red ink on blue")))))
(should (eq (ebox-get node :width) 'fit-content))
(should (eq (ebox-get node :wrap-mode) 'word))))))
(let ((ebox-viewport-width 56))
(dolist (kind '(box column))
(let ((output (ebox-render
(ebox-build
`(,kind :width min-content :wrap-mode none
(text "red ink on blue"))))))
(ebox-css-layout-test--assert-pixel-lines output 105)
(should (equal (substring-no-properties output)
"red ink on blue")))))))
(ert-deftest ebox-css-layout-intrinsic-parent-keeps-child-width-constraints ()
"Measuring a parent must retain each nested child's independent used width."
(ebox-css-layout-test--with-pixel-display
(let ((ebox-viewport-width 56))
(dolist (parent '(box column))
(dolist (child '(box column))
(dolist (properties '((:width min-content)
(:width (px 7) :min-width min-content)
(:width (px 140) :max-width min-content)))
(ert-info ((format "%S parent, %S child %S"
parent child properties))
(let ((output
(ebox-render
(ebox-build
`(,parent :width max-content
(,child ,@properties
(text "red ink on blue")))))))
(ebox-css-layout-test--assert-pixel-lines output 28)
(should (equal (mapcar #'string-trim (ebox-string-lines output))
'("red" "ink" "on" "blue")))))))))))
(ert-deftest ebox-css-layout-used-edge-ranges-are-explicit ()
(should-error
(ebox--edge-used-value nil :margin-left-pixel
'(calc (- (px 1) (px 2)))))
(should (= (ebox--edge-used-value nil :padding-left-pixel
'(calc (- (px 1) (px 2))))
0)))
(ert-deftest ebox-css-layout-equivalent-pixels-have-identical-display ()
(should (equal-including-properties (ebox-pixel-space 214)
(ebox-pixel-space 214.0)))
(should (equal (get-text-property 0 'display (ebox-pixel-space 2.5))
'(space :width (2.5)))))
(ert-deftest ebox-css-layout-cyclic-grid-gaps-overflow-intrinsic-box ()
(dolist
(case
'(((grid :width max-content
:grid-template-columns ((px 10) (px 10))
:column-gap (% 50) :overflow visible
(box "A") (box "B"))
20 1 30 1)
((grid :width (px 10)
:grid-template-columns ((px 10))
:grid-template-rows ((lh 2) (lh 2))
:row-gap (% 50) :overflow visible
(box "A") (box "B"))
10 4 10 6)))
(let* ((input (ebox-build (car case)))
(node (ebox-test-root input))
(ebox--render-source-index (ebox-test-source-index input))
(ebox--surface-materialization-active t)
(rendered (ebox--render-layout node)))
(should (= (ebox--content-pixel node) (nth 1 case)))
(should (= (ebox--total-height node) (nth 2 case)))
(should (= (ebox--string-max-pixel-width rendered) (nth 3 case)))
(should (= (ebox-string-height rendered) (nth 4 case))))))
(provide 'ebox-css-layout-tests)
;;; ebox-css-layout-tests.el ends here

View File

@ -0,0 +1,147 @@
;;; ebox-css-viewport-tests.el --- CSS viewport retention contracts -*- lexical-binding: t; -*-
;;; Commentary:
;; Public DSL construction and retained rendering must preserve viewport
;; dependencies through fixed containing blocks and retained updates.
;;; Code:
(require 'cl-lib)
(require 'ert)
(require 'ebox)
(defun ebox-css-viewport-test--root (input)
"Return the canonical root owned by INPUT."
(ebox-canonical-input--single-root input "CSS viewport test"))
(defun ebox-css-viewport-test--geometry (output)
"Return OUTPUT's line count and line pixel widths without runtime IDs."
(list (ebox-string-height output)
(mapcar #'ebox--string-pixel-width (ebox-string-lines output))))
(ert-deftest ebox-css-viewport-own-dependencies-follow-valid-axis-units ()
(dolist
(case
'(((box :width (vw 50) :height (lh 1) "x") t nil)
((box :width (px 20) :height (vh 20) "x") nil t)
((box :width (px 20) :height (lh 1)
:padding ((vh 2) (px 0)) "x") nil t)
((box :width (px 20) :height (lh 1)
:margin-top (calc (+ (vh 2) (lh 1))) "x") nil t)
((flex :width (px 20) :height (lh 1)
:row-gap (max (lh 0) (vh 2)) :column-gap (px 0)
(box "x")) nil t)
((grid :width (px 20) :height (lh 1)
:grid-template-columns ((min (ch 8) (vw 50)) (fr 1))
:grid-template-rows ((lh 1))
(box "x")) t nil)
((box :width (ch 20) :height (lh 3)
:padding ((lh 1) (px 1)) "x") nil nil)))
(ert-info ((format "DSL: %S" (car case)))
(let ((node (ebox-css-viewport-test--root (ebox-build (car case)))))
(should (eq (not (null (ebox--node-size-viewport-dependent-p
node 'viewport-width)))
(nth 1 case)))
(should (eq (not (null (ebox--node-size-viewport-dependent-p
node 'viewport-height)))
(nth 2 case)))))))
(ert-deftest ebox-css-viewport-width-id-survives-a-fixed-containing-block ()
(let* ((input (ebox-build
'(box :width (px 80)
(box :width (vw 20) :height (lh 1) "x"))))
(root (ebox-css-viewport-test--root input))
(child (car (ebox--node-children root)))
(axes (ebox--viewport-dependent-node-id-axes root)))
(should (ebox--node-size-viewport-dependent-p child 'viewport-width))
(should (memq (plist-get child :node-id) (car axes)))))
(ert-deftest ebox-css-viewport-both-axes-retained-resize-matches-fresh-render ()
(let ((buffer (generate-new-buffer " *ebox-css-viewport*"))
(ebox-viewport-width 100)
(ebox-viewport-height 20)
(ebox-runtime-idle-prewarm nil)
(ebox-runtime-idle-reflow-cache-prewarm nil)
(input (ebox-build
'(box :width (vw 50)
:height (calc (- (vh 20) (lh 1))) "x"))))
(unwind-protect
(cl-letf (((symbol-function 'ebox-native-reflow-layout-ready-p)
(lambda () nil)))
(ebox-render-to-buffer buffer input)
(let* ((first (with-current-buffer buffer (buffer-string)))
(first-geometry (ebox-css-viewport-test--geometry first)))
(ebox-rerender-buffer-with-context buffer 200 20)
(let* ((second (with-current-buffer buffer (buffer-string)))
(second-geometry (ebox-css-viewport-test--geometry second))
(ebox-viewport-width 200)
(fresh (ebox-render input)))
(should (eq (plist-get (ebox-buffer-update-report buffer)
:viewport-axes)
'width))
(should (= (car second-geometry) (car first-geometry)))
(should (< (car (cadr first-geometry))
(car (cadr second-geometry))))
(should (equal second-geometry
(ebox-css-viewport-test--geometry fresh)))
(ebox-rerender-buffer-with-context buffer 200 40)
(let* ((third (with-current-buffer buffer (buffer-string)))
(third-geometry (ebox-css-viewport-test--geometry third))
(ebox-viewport-height 40))
(should (eq (plist-get (ebox-buffer-update-report buffer)
:viewport-axes)
'height))
(should (< (car second-geometry) (car third-geometry)))
(should (= (car (cadr third-geometry))
(car (cadr second-geometry))))
(should (equal third-geometry
(ebox-css-viewport-test--geometry
(ebox-render input))))))))
(when (buffer-live-p buffer) (kill-buffer buffer)))))
(ert-deftest ebox-css-viewport-nested-percent-and-viewport-use-different-bases ()
(let* ((input (ebox-build
'(box :width (px 80)
(box :width (vw 10) "viewport")
(box :width (% 50) "percent"))))
(resolve (symbol-function 'ebox-size-resolve))
(ebox-viewport-height 20))
(dolist (width '(200 400))
(let ((ebox-viewport-width width)
observations)
(cl-letf (((symbol-function 'ebox-size-resolve)
(lambda (value context)
(let ((result (funcall resolve value context)))
(when (member value '((vw 10) (% 50)))
(push (list value result) observations))
result))))
(ebox-render input))
(should (member (list '(vw 10) (/ width 10.0)) observations))
(should (member '((% 50) 40.0) observations))))))
(ert-deftest ebox-css-viewport-nested-fixed-parent-rerenders-viewport-child ()
(let ((buffer (generate-new-buffer " *ebox-css-nested-viewport*"))
(ebox-viewport-width 100)
(ebox-viewport-height 20)
(ebox-runtime-idle-prewarm nil)
(ebox-runtime-idle-reflow-cache-prewarm nil)
(input (ebox-build
'(box :width (px 80)
(box :width (px 20) :height (vh 20) "x")))))
(unwind-protect
(cl-letf (((symbol-function 'ebox-native-reflow-layout-ready-p)
(lambda () nil)))
(ebox-render-to-buffer buffer input)
(let ((before (with-current-buffer buffer
(ebox-string-height (buffer-string)))))
(ebox-rerender-buffer-with-context buffer 100 40)
(let* ((ebox-viewport-height 40)
(actual (with-current-buffer buffer (buffer-string)))
(fresh (ebox-render input)))
(should (> (ebox-string-height fresh) before))
(should (equal (ebox-css-viewport-test--geometry actual)
(ebox-css-viewport-test--geometry fresh))))))
(when (buffer-live-p buffer) (kill-buffer buffer)))))
(provide 'ebox-css-viewport-tests)
;;; ebox-css-viewport-tests.el ends here

View File

@ -24,14 +24,19 @@
(defconst ebox-docs-test--active-files (defconst ebox-docs-test--active-files
'("Makefile" ".github/workflows/ci.yml" '("Makefile" ".github/workflows/ci.yml"
"ebox.el" "ebox-cache.el" "ebox-runtime-index.el" "ebox.el" "ebox-cache.el" "ebox-runtime-index.el" "ebox-interaction.el"
"ebox-state-contract.el" "ebox-style.el" "ebox-state-contract.el" "ebox-size.el" "ebox-style.el"
"ebox-child-range.el" "ebox-tree.el" "ebox-child-range.el" "ebox-tree.el"
"ebox-measure.el" "ebox-fragment.el" "ebox-render-context.el" "ebox-measure.el" "ebox-fragment.el" "ebox-render-context.el"
"ebox-layout.el" "ebox-flex.el" "ebox-grid.el" "ebox-layout.el" "ebox-flex.el" "ebox-grid.el"
"ebox-buffer-backend.el" "ebox-patch-plan.el" "ebox-incremental.el" "ebox-buffer-backend.el" "ebox-patch-plan.el" "ebox-incremental.el"
"ebox-surface.el" "ebox-dsl.el" "ebox-spi.el" "ebox-surface.el" "ebox-dsl.el" "ebox-spi.el"
"ebox-selector.el" "ebox-native-reflow.el" "ebox-selector.el" "ebox-native-reflow.el"
"tests/ebox-size-tests.el" "tests/ebox-size-style-tests.el"
"tests/ebox-interaction-tests.el"
"tests/ebox-size-native-tests.el" "tests/ebox-zero-height-tests.el"
"tests/ebox-css-layout-tests.el" "tests/ebox-css-flex-grid-tests.el"
"tests/ebox-css-viewport-tests.el"
"tests/ebox-core-render-tests.el" "tests/ebox-runtime-index-tests.el" "tests/ebox-core-render-tests.el" "tests/ebox-runtime-index-tests.el"
"tests/ebox-state-contract-tests.el" "tests/ebox-state-contract-tests.el"
"tests/ebox-layout-boundary-tests.el" "tests/ebox-layout-boundary-tests.el"
@ -48,11 +53,13 @@
"native/vendor/emacs-30/emacs-module.h" "native/src/lib.rs" "native/vendor/emacs-30/emacs-module.h" "native/src/lib.rs"
"native/src/layout.rs" "native/c/ebox_module.c" "native/src/layout.rs" "native/c/ebox_module.c"
"scripts/ebox-package-lint.el" "scripts/ebox-visual-check.el" "scripts/ebox-package-lint.el" "scripts/ebox-visual-check.el"
"scripts/ebox-performance-evaluator.el") "scripts/ebox-performance-evaluator.el"
"scripts/migrate-css-sizes.py" "scripts/test_migrate_css_sizes.py")
"Active Ebox files that the maintainer map must cover.") "Active Ebox files that the maintainer map must cover.")
(defconst ebox-docs-test--targets (defconst ebox-docs-test--targets
'("check" "ci" "load" "compile" "core-tests" "runtime-index-tests" '("check" "ci" "load" "compile" "size-tests" "size-migration-tests"
"core-tests" "runtime-index-tests" "interaction-tests"
"child-range-tests" "grid-tests" "child-range-tests" "grid-tests"
"ebox-commit-tests" "surface-tests" "visual-check-tests" "package-tests" "ebox-commit-tests" "surface-tests" "visual-check-tests" "package-tests"
"selector-tests" "dsl-tests" "flex-tests" "state-contract-tests" "selector-tests" "dsl-tests" "flex-tests" "state-contract-tests"

View File

@ -20,7 +20,8 @@
(cl-loop for (key value) on plist by #'cddr (cl-loop for (key value) on plist by #'cddr
when (memq key style-keys) append (list key value))) when (memq key style-keys) append (list key value)))
(declarations (declarations
(and style-props (ebox-style-compile-form tag style-props))) (and style-props (ebox-style-compile-form
tag (ebox-test--explicit-sizes tag style-props))))
(plist (plist
(cl-loop for (key value) on plist by #'cddr (cl-loop for (key value) on plist by #'cddr
unless (memq key style-keys) append (list key value))) unless (memq key style-keys) append (list key value)))
@ -115,15 +116,15 @@
"Approved aliases and shorthands should produce canonical longhands." "Approved aliases and shorthands should produce canonical longhands."
(let ((style (ebox-style-compute (let ((style (ebox-style-compute
(list :bgcolor "#ffffff" (list :bgcolor "#ffffff"
:padding '(1 2) :padding '((lh 1) (ch 2))
:margin-left 3 :margin-left '(ch 3)
:border "#333333")))) :border "#333333"))))
(should (equal (plist-get style :background-color) "#ffffff")) (should (equal (plist-get style :background-color) "#ffffff"))
(should (= (plist-get style :padding-block-start) 1)) (should (equal (plist-get style :padding-block-start) '(lh 1)))
(should (= (plist-get style :padding-inline-end) 2)) (should (equal (plist-get style :padding-inline-end) '(ch 2)))
(should (= (plist-get style :padding-block-end) 1)) (should (equal (plist-get style :padding-block-end) '(lh 1)))
(should (= (plist-get style :padding-inline-start) 2)) (should (equal (plist-get style :padding-inline-start) '(ch 2)))
(should (= (plist-get style :margin-left) 3)) (should (equal (plist-get style :margin-left) '(ch 3)))
(should (equal (plist-get style :border-color) "#333333")))) (should (equal (plist-get style :border-color) "#333333"))))
(ert-deftest ebox-style-classifies-incremental-effects () (ert-deftest ebox-style-classifies-incremental-effects ()
@ -153,10 +154,10 @@
(ert-deftest ebox-style-unrelated-facts-preserve-typed-layout-config () (ert-deftest ebox-style-unrelated-facts-preserve-typed-layout-config ()
"Unrelated computed facts must not replace direct typed layout values." "Unrelated computed facts must not replace direct typed layout values."
(dolist (case '((flex (dolist (case '((flex
(flex :flex-direction column :column-gap (6) "A")) (flex :flex-direction column :column-gap (px 6) "A"))
(grid (grid
(grid :grid-template-columns ((40) (60)) (grid :grid-template-columns ((px 40) (px 60))
:column-gap (4) "A")))) :column-gap (px 4) "A"))))
(let* ((kind (car case)) (let* ((kind (car case))
(input (ebox-build (cadr case))) (input (ebox-build (cadr case)))
(node (ebox-dsl-test--root input)) (node (ebox-dsl-test--root input))
@ -233,27 +234,27 @@
"Logical and physical edges conflict locally and cascade across sources." "Logical and physical edges conflict locally and cascade across sources."
(should-error (should-error
(ebox-style-compile-declarations (ebox-style-compile-declarations
'(:padding-left (4) :padding-inline-start (8)) t) '(:padding-left (px 4) :padding-inline-start (px 8)) t)
:type 'ecss-invalid-declaration) :type 'ecss-invalid-declaration)
(let* ((base (ebox-style-compile-declarations '(:padding (0 (8))) t)) (let* ((base (ebox-style-compile-declarations '(:padding ((lh 0) (px 8))) t))
(override (override
(ebox-style-compile-declarations '(:padding-left (24)) t)) (ebox-style-compile-declarations '(:padding-left (px 24)) t))
(style (style
(ebox-style-compute-subject (ebox-style-compute-subject
(ecss-subject-create :type "box") (ecss-subject-create :type "box")
(ebox-style-merge-declarations base override)))) (ebox-style-merge-declarations base override))))
(should (equal (plist-get (ebox-style--box-values style) :padding-left) (should (equal (plist-get (ebox-style--box-values style) :padding-left)
'(24)))) '(px 24))))
(let ((ebox-style-stylesheet (ecss-stylesheet-create))) (let ((ebox-style-stylesheet (ecss-stylesheet-create)))
(ebox-style-add-rule ".card" '(:margin-inline-start (2))) (ebox-style-add-rule ".card" '(:margin-inline-start (px 2)))
(ebox-style-add-rule "#target" '(:margin-left (6))) (ebox-style-add-rule "#target" '(:margin-left (px 6)))
(let ((style (let ((style
(ebox-style-compute-subject (ebox-style-compute-subject
(ecss-subject-create (ecss-subject-create
:type "box" :id "target" :classes '("card")) :type "box" :id "target" :classes '("card"))
nil))) nil)))
(should (equal (plist-get (ebox-style--box-values style) :margin-left) (should (equal (plist-get (ebox-style--box-values style) :margin-left)
'(6)))))) '(px 6))))))
(ert-deftest ebox-style-rejects-canonical-and-alias-duplicates () (ert-deftest ebox-style-rejects-canonical-and-alias-duplicates ()
"One declaration source should not spell one property twice." "One declaration source should not spell one property twice."
@ -269,9 +270,9 @@
(ert-deftest ebox-author-declaration-is-one-atomic-normalization-group () (ert-deftest ebox-author-declaration-is-one-atomic-normalization-group ()
"One author group rejects overlaps while later cascade groups may override." "One author group rejects overlaps while later cascade groups may override."
(dolist (declarations (dolist (declarations
'((:padding 1 :padding-left 2) '((:padding ((lh 1) (ch 1)) :padding-left (ch 2))
(:border "red" :border-top none) (:border "red" :border-top none)
(:gap 1 :row-gap 2) (:gap ((lh 1) (ch 1)) :row-gap (lh 2))
(:flex-flow (row wrap) :flex-wrap nowrap) (:flex-flow (row wrap) :flex-wrap nowrap)
(:flex (1 1 auto) :flex-grow 2) (:flex (1 1 auto) :flex-grow 2)
(:border-top-p t :border-top-style none))) (:border-top-p t :border-top-style none)))
@ -281,9 +282,9 @@
(should (should
(equal (equal
(ebox-style-merge-declarations (ebox-style-merge-declarations
(ebox-style-compile-declarations '(:gap 1) t) (ebox-style-compile-declarations '(:gap ((lh 1) (ch 1))) t)
(ebox-style-compile-declarations '(:row-gap 2) t)) (ebox-style-compile-declarations '(:row-gap (lh 2)) t))
'(ebox/row-gap 2 ebox/column-gap 1)))) '(ebox/row-gap (lh 2) ebox/column-gap (ch 1)))))
(ert-deftest ebox-style-property-registry-rejects-alias-collisions () (ert-deftest ebox-style-property-registry-rejects-alias-collisions ()
"An alias should never shadow another canonical property." "An alias should never shadow another canonical property."
@ -316,24 +317,24 @@
"The concise border boolean should expand to canonical width and style." "The concise border boolean should expand to canonical width and style."
(should (should
(equal (ebox-style-compile-declarations '(:border-top-p t) t) (equal (ebox-style-compile-declarations '(:border-top-p t) t)
'(ebox/border-top-width 1 ebox/border-top-style solid))) '(ebox/border-top-width (px 1) ebox/border-top-style solid)))
(should (should
(equal (ebox-style-compile-declarations '(:border-top-p nil) t) (equal (ebox-style-compile-declarations '(:border-top-p nil) t)
'(ebox/border-top-width 0 ebox/border-top-style none))) '(ebox/border-top-width (px 0) ebox/border-top-style none)))
(should (eq (ebox-style-canonical-name :border-top-p) :border-top-p))) (should (eq (ebox-style-canonical-name :border-top-p) :border-top-p)))
(ert-deftest ebox-border-width-unit-is-always-integer-pixels () (ert-deftest ebox-border-width-requires-an-explicit-length ()
"Border width should not reuse Box edge column/pixel wrapper grammar." "Border declarations retain explicit lengths and reject singleton syntax."
(should (should
(equal (equal
(ebox-style-compile-declarations '(:border (1 solid "#687386")) t) (ebox-style-compile-declarations '(:border ((px 1) solid "#687386")) t)
'(ebox/border-top-width 1 ebox/border-top-style solid '(ebox/border-top-width (px 1) ebox/border-top-style solid
ebox/border-top-color "#687386" ebox/border-top-color "#687386"
ebox/border-right-width 1 ebox/border-right-style solid ebox/border-right-width (px 1) ebox/border-right-style solid
ebox/border-right-color "#687386" ebox/border-right-color "#687386"
ebox/border-bottom-width 1 ebox/border-bottom-style solid ebox/border-bottom-width (px 1) ebox/border-bottom-style solid
ebox/border-bottom-color "#687386" ebox/border-bottom-color "#687386"
ebox/border-left-width 1 ebox/border-left-style solid ebox/border-left-width (px 1) ebox/border-left-style solid
ebox/border-left-color "#687386"))) ebox/border-left-color "#687386")))
(should-error (should-error
(ebox-style-compile-declarations (ebox-style-compile-declarations
@ -342,26 +343,31 @@
(ert-deftest ebox-border-engine-projection-keeps-exact-pixels () (ert-deftest ebox-border-engine-projection-keeps-exact-pixels ()
"Border projection must not reinterpret pixels as character columns." "Border projection must not reinterpret pixels as character columns."
(cl-letf (((symbol-function 'ebox--space-pixel-width) (lambda () 7))) (cl-letf (((symbol-function 'ebox--size-character-width)
(should (lambda (&optional _box) 7)))
(equal (let ((projected
(ebox-style--expand-engine-plist (ebox-style--expand-engine-plist
'(:border-left-width 1 :border-left-style solid '(:border-left-width (px 1) :border-left-style solid
:border-right-width 2 :border-right-style solid :border-right-width (px 2) :border-right-style solid
:padding-left 1 :margin-right (3))) :padding-left (ch 1) :margin-right (px 3)))))
'(:border-left-pixel 1 :border-left-style solid (should (equal projected
:border-right-pixel 2 :border-right-style solid '(:border-left-pixel (px 1) :border-left-style solid
:padding-left-pixel 7 :margin-right-pixel 3)))) :border-right-pixel (px 2) :border-right-style solid
:padding-left-pixel (ch 1) :margin-right-pixel (px 3))))
(should (= (ebox-get projected :border-left-pixel) 1))
(should (= (ebox-get projected :border-right-pixel) 2))
(should (= (ebox-get projected :padding-left-pixel) 7))
(should (= (ebox-get projected :margin-right-pixel) 3))))
(should (should
(equal (equal
(ebox-style--expand-engine-plist (ebox-style--expand-engine-plist
'(:border-left-width 2 :border-left-style none)) '(:border-left-width (px 2) :border-left-style none))
'(:border-left-pixel 0 :border-left-style none))) '(:border-left-pixel 0 :border-left-style none)))
(should (should
(equal (equal
(ebox-style--expand-engine-plist (ebox-style--expand-engine-plist
'(:border-top-width 2 :border-top-style solid)) '(:border-top-width (px 2) :border-top-style solid))
'(:border-top-pixel 2 :border-top-style solid)))) '(:border-top-pixel (px 2) :border-top-style solid))))
(ert-deftest ebox-style-declaration-cache-expands-shorthand-once () (ert-deftest ebox-style-declaration-cache-expands-shorthand-once ()
"A cached declaration should not expand its shorthand again." "A cached declaration should not expand its shorthand again."
@ -372,15 +378,15 @@
(lambda (value) (lambda (value)
(cl-incf calls) (cl-incf calls)
(funcall original value)))) (funcall original value))))
(ebox-style-compile-declarations '(:padding 1) t) (ebox-style-compile-declarations '(:padding ((lh 1) (ch 1))) t)
(ebox-style-compile-declarations '(:padding 1) t)) (ebox-style-compile-declarations '(:padding ((lh 1) (ch 1))) t))
(should (= calls 1)))) (should (= calls 1))))
(ert-deftest ebox-style-border-present-rejects-owned-longhand-conflicts () (ert-deftest ebox-style-border-present-rejects-owned-longhand-conflicts ()
"One source should not mix the boolean border shorthand with its outputs." "One source should not mix the boolean border shorthand with its outputs."
(should-error (should-error
(ebox-style-compile-declarations (ebox-style-compile-declarations
'(:border-top-p t :border-top-width 2) t) '(:border-top-p t :border-top-width (px 2)) t)
:type 'error) :type 'error)
(should-error (should-error
(ebox-style-compile-declarations (ebox-style-compile-declarations
@ -388,11 +394,11 @@
:type 'error) :type 'error)
(should-error (should-error
(ebox-style-compile-declarations (ebox-style-compile-declarations
'(:border-top-p t :border-top (2 dashed "red")) t) '(:border-top-p t :border-top ((px 2) dashed "red")) t)
:type 'error) :type 'error)
(should-error (should-error
(ebox-style-compile-declarations (ebox-style-compile-declarations
'(:border-top-p t :border-width 2) t) '(:border-top-p t :border-width (px 2)) t)
:type 'error) :type 'error)
(should-error (should-error
(ebox-style-compile-declarations (ebox-style-compile-declarations
@ -400,7 +406,7 @@
:type 'error) :type 'error)
(should-error (should-error
(ebox-style-compile-declarations (ebox-style-compile-declarations
'(:border-bottom-p t :border (1 solid "red")) t) '(:border-bottom-p t :border ((px 1) solid "red")) t)
:type 'error)) :type 'error))
(ert-deftest ebox-canonical-text-constructor-is-renderable-and-typed () (ert-deftest ebox-canonical-text-constructor-is-renderable-and-typed ()
@ -457,7 +463,7 @@
(ert-deftest ebox-render-cache-signature-ignores-derived-layout-proofs () (ert-deftest ebox-render-cache-signature-ignores-derived-layout-proofs ()
"A render must not invalidate its own canonical node cache key." "A render must not invalidate its own canonical node cache key."
(let* ((node (ebox-dsl-test--root (let* ((node (ebox-dsl-test--root
(ebox-build '(box :width '(80) "Stable")))) (ebox-build '(box :width '(px 80) "Stable"))))
(before (before
(let ((ebox--render-cache-signature-cache (let ((ebox--render-cache-signature-cache
(make-hash-table :test 'eq))) (make-hash-table :test 'eq)))
@ -644,7 +650,7 @@
(right (ebox-dsl-test--root right-input)) (right (ebox-dsl-test--root right-input))
(box-input (box-input
(ebox-dsl-test--box-create (ebox-dsl-test--box-create
:layout (ebox-row-layout-create :item-gap 5 :cross-align 'center) :layout (ebox-row-layout-create :item-gap '(px 5) :cross-align 'center)
:children (list left-input right-input) :children (list left-input right-input)
:source-handle 'row)) :source-handle 'row))
(box (ebox-dsl-test--root box-input))) (box (ebox-dsl-test--root box-input)))
@ -653,7 +659,7 @@
'row)) 'row))
(should (eq (ebox-layout-config-kind (ebox-box-node-layout 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)) (should (equal (ebox-layout-config-props (ebox-box-node-layout box))
'(:item-gap 5 :cross-align center))) '(:item-gap (px 5) :cross-align center)))
(should (equal (ebox-box-node-children box) (list left right))) (should (equal (ebox-box-node-children box) (list left right)))
(should (eq (ebox-tree-node-children box) (should (eq (ebox-tree-node-children box)
(ebox-box-node-children box))) (ebox-box-node-children box)))
@ -678,13 +684,13 @@
(bottom (ebox-dsl-test--root bottom-input)) (bottom (ebox-dsl-test--root bottom-input))
(box-input (box-input
(ebox-dsl-test--box-create (ebox-dsl-test--box-create
:layout (ebox-column-layout-create :item-gap 1 :cross-align 'end) :layout (ebox-column-layout-create :item-gap '(lh 1) :cross-align 'end)
:children (list top-input bottom-input))) :children (list top-input bottom-input)))
(box (ebox-dsl-test--root box-input))) (box (ebox-dsl-test--root box-input)))
(should (ebox-box-node-p box)) (should (ebox-box-node-p box))
(should (eq (ebox-layout-config-kind (ebox-box-node-layout box)) 'column)) (should (eq (ebox-layout-config-kind (ebox-box-node-layout box)) 'column))
(should (equal (ebox-layout-config-props (ebox-box-node-layout box)) (should (equal (ebox-layout-config-props (ebox-box-node-layout box))
'(:item-gap 1 :cross-align end))) '(:item-gap (lh 1) :cross-align end)))
(should (equal (ebox-box-node-children box) (list top bottom))) (should (equal (ebox-box-node-children box) (list top bottom)))
(should (eq (ebox-tree-node-children box) (should (eq (ebox-tree-node-children box)
(ebox-box-node-children box))) (ebox-box-node-children box)))
@ -702,7 +708,11 @@
"Row/Column configs should distinguish defaults from invalid explicit values." "Row/Column configs should distinguish defaults from invalid explicit values."
(dolist (constructor '(ebox-row-layout-create ebox-column-layout-create)) (dolist (constructor '(ebox-row-layout-create ebox-column-layout-create))
(should (equal (ebox-layout-config-props (funcall constructor)) (should (equal (ebox-layout-config-props (funcall constructor))
'(:item-gap 0 :cross-align stretch))) (list :item-gap
(if (eq constructor 'ebox-column-layout-create)
'(lh 0)
'(px 0))
:cross-align 'stretch)))
(dolist (plist '((:item-gap nil) (:item-gap -1) (dolist (plist '((:item-gap nil) (:item-gap -1)
(:cross-align nil) (:cross-align bogus) (:cross-align nil) (:cross-align bogus)
(:gap 1) (:item-gap 1 :item-gap 2))) (:gap 1) (:item-gap 1 :item-gap 2)))
@ -722,7 +732,7 @@
(equal (ebox-layout-config-props layout) (equal (ebox-layout-config-props layout)
'(:flex-direction row :flex-wrap nowrap '(:flex-direction row :flex-wrap nowrap
:justify-content flex-start :align-items stretch :justify-content flex-start :align-items stretch
:align-content stretch :row-gap 0 :column-gap 0))) :align-content stretch :row-gap (lh 0) :column-gap (px 0))))
(should-not (plist-member (ebox-layout-config-props layout) :width)) (should-not (plist-member (ebox-layout-config-props layout) :width))
(should-not (plist-member (ebox-layout-config-props layout) :height)) (should-not (plist-member (ebox-layout-config-props layout) :height))
(should (ebox-box-node-p box)) (should (ebox-box-node-p box))
@ -792,13 +802,13 @@
:justify-content 'space-between :justify-content 'space-between
:align-items 'center :align-items 'center
:align-content 'end :align-content 'end
:row-gap '(2) :row-gap '(lh 2)
:column-gap '(8)))) :column-gap '(px 8))))
(should (should
(equal (ebox-layout-config-props layout) (equal (ebox-layout-config-props layout)
'(:flex-direction column-reverse :flex-wrap wrap '(:flex-direction column-reverse :flex-wrap wrap
:justify-content space-between :align-items center :justify-content space-between :align-items center
:align-content end :row-gap 2 :column-gap 8))) :align-content end :row-gap (lh 2) :column-gap (px 8))))
(should (ebox-flex-layout-config-props-p (should (ebox-flex-layout-config-props-p
(ebox-layout-config-props layout))) (ebox-layout-config-props layout)))
(should (ebox-box-node-p (should (ebox-box-node-p
@ -833,15 +843,16 @@
(ert-deftest ebox-flex-config-validation-does-not-reparse-canonical-pixels () (ert-deftest ebox-flex-config-validation-does-not-reparse-canonical-pixels ()
"Boundary validation should preserve physical pixels under non-unit fonts." "Boundary validation should preserve physical pixels under non-unit fonts."
(cl-letf (((symbol-function 'ebox--space-pixel-width) (lambda () 8))) (cl-letf (((symbol-function 'ebox--space-pixel-width) (lambda () 8)))
(let* ((layout (ebox-flex-layout-create :column-gap '(8))) (let* ((layout (ebox-flex-layout-create :column-gap '(px 8)))
(box (ebox-dsl-test--root (box (ebox-dsl-test--root
(ebox-dsl-test--box-create (ebox-dsl-test--box-create
:layout layout :children nil)))) :layout layout :children nil))))
(should (= (plist-get (ebox-layout-config-props layout) :column-gap) 8)) (should (equal (plist-get (ebox-layout-config-props layout) :column-gap)
(should (= (plist-get '(px 8)))
(ebox-layout-config-props (ebox-box-node-layout box)) (should (equal (plist-get
:column-gap) (ebox-layout-config-props (ebox-box-node-layout box))
8))))) :column-gap)
'(px 8))))))
(ert-deftest ebox-canonical-grid-box-uses-one-box-identity-and-child-list () (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." "A GridConfig should select the Box algorithm without a Grid runtime node."
@ -987,25 +998,25 @@
"The typed Grid constructor should emit one canonical closed property set." "The typed Grid constructor should emit one canonical closed property set."
(let* ((layout (let* ((layout
(ebox-grid-layout-create (ebox-grid-layout-create
:grid-template-columns '((20) (fr 1)) :grid-template-columns '((px 20) (fr 1))
:grid-template-rows '(1 auto) :grid-template-rows '((lh 1) auto)
:grid-auto-columns '((30)) :grid-auto-columns '((px 30))
:grid-auto-rows '(2) :grid-auto-rows '((lh 2))
:grid-auto-flow 'column :grid-auto-flow 'column
:row-gap '(2) :column-gap '(8) :row-gap '(lh 2) :column-gap '(px 8)
:justify-items 'center :align-items 'end :justify-items 'center :align-items 'end
:justify-content 'space-between :align-content 'center)) :justify-content 'space-between :align-content 'center))
(props (ebox-layout-config-props layout))) (props (ebox-layout-config-props layout)))
(should (equal (plist-get props :grid-template-columns) (should (equal (plist-get props :grid-template-columns)
'((:kind fixed :size 20) (:kind fr :factor 1)))) '((:kind fixed :size (px 20)) (:kind fr :factor 1))))
(should (equal (plist-get props :grid-template-rows) (should (equal (plist-get props :grid-template-rows)
'((:kind fixed :size 1) (:kind auto :factor 0)))) '((:kind fixed :size (lh 1)) (:kind auto :factor 0))))
(should (equal (plist-get props :grid-auto-columns) (should (equal (plist-get props :grid-auto-columns)
'(:kind fixed :size 30))) '(:kind fixed :size (px 30))))
(should (equal (plist-get props :grid-auto-rows) (should (equal (plist-get props :grid-auto-rows)
'(:kind fixed :size 2))) '(:kind fixed :size (lh 2))))
(should (= (plist-get props :row-gap) 2)) (should (equal (plist-get props :row-gap) '(lh 2)))
(should (= (plist-get props :column-gap) 8)) (should (equal (plist-get props :column-gap) '(px 8)))
(should (ebox-grid-layout-config-props-p props)) (should (ebox-grid-layout-config-props-p props))
(should (ebox-box-node-p (should (ebox-box-node-p
(ebox-dsl-test--root (ebox-dsl-test--root
@ -1059,14 +1070,14 @@
(cl-letf (((symbol-function 'ebox--space-pixel-width) (lambda () 8))) (cl-letf (((symbol-function 'ebox--space-pixel-width) (lambda () 8)))
(let* ((layout (let* ((layout
(ebox-grid-layout-create (ebox-grid-layout-create
:grid-template-columns '((20)) :column-gap '(8))) :grid-template-columns '((px 20)) :column-gap '(px 8)))
(box (ebox-dsl-test--root (box (ebox-dsl-test--root
(ebox-dsl-test--box-create :layout layout :children nil))) (ebox-dsl-test--box-create :layout layout :children nil)))
(props (ebox-layout-config-props (ebox-box-node-layout box)))) (props (ebox-layout-config-props (ebox-box-node-layout box))))
(should (= (plist-get props :column-gap) 8)) (should (equal (plist-get props :column-gap) '(px 8)))
(should (= (plist-get (car (plist-get props :grid-template-columns)) (should (equal (plist-get (car (plist-get props :grid-template-columns))
:size) :size)
20))))) '(px 20))))))
(ert-deftest ebox-grid-config-boundary-rejects-forged-minmax-roles () (ert-deftest ebox-grid-config-boundary-rejects-forged-minmax-roles ()
"Trusted Grid validation should enforce minmax roles after mutation." "Trusted Grid validation should enforce minmax roles after mutation."
@ -1250,14 +1261,14 @@
"Layout config belongs to its form; item roles belong to direct child Boxes." "Layout config belongs to its form; item roles belong to direct child Boxes."
(let* ((flex (let* ((flex
(ebox-build (ebox-build
'(flex :width '(300) :gap '(1 (5)) '(flex :width '(px 300) :gap '((lh 1) (px 5))
(box :flex 1 "A") (box :flex 1 "A")
"B"))) "B")))
(flex-child (car (ebox-box-node-children (flex-child (car (ebox-box-node-children
(ebox-dsl-test--root flex)))) (ebox-dsl-test--root flex))))
(grid (grid
(ebox-build (ebox-build
'(grid :grid-template-columns '((20) (20)) '(grid :grid-template-columns '((px 20) (px 20))
(box :grid-column '(1 :span 2) "Header")))) (box :grid-column '(1 :span 2) "Header"))))
(grid-child (car (ebox-box-node-children (grid-child (car (ebox-box-node-children
(ebox-dsl-test--root grid))))) (ebox-dsl-test--root grid)))))

View File

@ -109,14 +109,94 @@ author identity; opaque source handles remain internal to SourceBuilder."
(defun ebox-test--declarations (tag props) (defun ebox-test--declarations (tag props)
"Return canonical TAG declarations from evaluated fixture PROPS." "Return canonical TAG declarations from evaluated fixture PROPS."
(ebox-style-compile-form (ebox-style-compile-form
tag (ebox-test--without props ebox-test--non-style-fields))) tag (ebox-test--without (ebox-test--explicit-sizes tag props)
ebox-test--non-style-fields)))
(defun ebox-test--explicit-length (value axis &optional track)
"Express fixture VALUE in explicit units for AXIS.
Fixture numbers are historical cell measurements; singleton lists are pixel
measurements, except for vertical Grid TRACK values, which are line counts.
This adapter belongs only to old backend characterization fixtures. Public
DSL and style tests call the production API directly and reject these forms."
(cond
((numberp value) (list axis value))
((memq value '(viewport viewport-height))
(list (if (eq value 'viewport) 'vw 'vh) 100))
((and (consp value) (null (cdr value)))
(if (numberp (car value))
(list (if (and track (eq axis 'lh)) 'lh 'px) (car value))
(if (memq (car value) '(viewport viewport-height))
(ebox-test--explicit-length (car value) axis)
value)))
((and track (consp value) (memq (car value) '(minmax repeat)))
(cons (car value)
(cl-loop for part in (cdr value) for index from 0
collect (if (and (eq (car value) 'repeat) (= index 0))
part
(ebox-test--explicit-length part axis t)))))
(t value)))
(defun ebox-test--explicit-sizes (tag props)
"Convert old backend fixture PROPS to explicit sizes for TAG."
(cl-loop
for (property value) on props by #'cddr
for name = (symbol-name property)
append
(list
property
(cond
((memq property '(:padding :margin :gap :padding-inline :padding-block
:margin-inline :margin-block :border-width))
(if (ebox-size-value-p value t) value
(let* ((axes (cond ((eq property :border-width) '(px px px px))
((string-suffix-p "-inline" name) '(ch ch))
((string-suffix-p "-block" name) '(lh lh))
((eq property :gap) '(lh ch))
(t '(lh ch lh ch))))
(parts (if (listp value) value (list value))))
(when (and (= (length parts) 1)
(not (eq (car axes) (cadr axes))))
(setq parts (list (car parts) (car parts))))
(cl-loop for part in parts for axis in axes
collect (ebox-test--explicit-length part axis)))))
((memq property '(:border :border-top :border-right :border-bottom
:border-left))
(if (and (listp value) (> (length value) 1)
(numberp (car value)))
(cons (list 'px (car value)) (cdr value))
(ebox-test--explicit-length value 'px)))
((memq property '(:grid-template-columns :grid-template-rows
:grid-auto-columns :grid-auto-rows))
(let ((axis (if (string-suffix-p "rows" name) 'lh 'ch)))
(if (and (listp value)
(not (memq (car value)
'(px % vw vh ch lh calc min max clamp fr minmax repeat))))
(mapcar (lambda (part) (ebox-test--explicit-length part axis t)) value)
(ebox-test--explicit-length value axis t))))
((eq property :flex)
(if (and (listp value) (= (length value) 3))
(list (car value) (cadr value)
(ebox-test--explicit-length (nth 2 value) 'ch))
value))
((or (memq property '(:width :height :min-width :min-height :max-width
:max-height :flex-basis :row-gap :column-gap :item-gap))
(string-match-p "\\`:\\(?:padding\\|margin\\)-" name)
(string-match-p "\\`:\\(?:border-.*-width\\)\\'" name))
(ebox-test--explicit-length
value (cond ((string-prefix-p ":border-" name) 'px)
((eq property :item-gap) (if (eq tag 'column) 'lh 'px))
((string-match-p "height\\|top\\|bottom\\|block\\|row-gap" name) 'lh)
(t 'ch))
(eq property :row-gap)))
(t value)))))
(defun ebox-test--layout (tag props declarations) (defun ebox-test--layout (tag props declarations)
"Return TAG LayoutConfig from PROPS and DECLARATIONS." "Return TAG LayoutConfig from PROPS and DECLARATIONS."
(ebox-layout-config-for-form (ebox-layout-config-for-form
tag tag
(if (memq tag '(row column)) (if (memq tag '(row column))
(ebox-test--keep props '(:item-gap :cross-align)) (ebox-test--keep (ebox-test--explicit-sizes tag props)
'(:item-gap :cross-align))
(ebox-style-declaration-properties (ebox-style-declaration-properties
declarations declarations
(lambda (property) (lambda (property)

View File

@ -119,9 +119,9 @@
(should (equal-including-properties lazy eager)))) (should (equal-including-properties lazy eager))))
(ert-deftest ebox-flex-normalized-gap-keeps-pixels-on-wide-fonts () (ert-deftest ebox-flex-normalized-gap-keeps-pixels-on-wide-fonts ()
"Flex gaps convert character units once and preserve explicit pixel units." "Flex gaps use the zero advance and preserve explicit pixel units."
(cl-letf (((symbol-function 'ebox--space-pixel-width) (lambda () 7))) (cl-letf (((symbol-function 'ebox--size-character-width) (lambda (&optional _) 7)))
(dolist (case '((2 . 14) ((2) . 2))) (dolist (case '(((ch 2) . 14) ((px 2) . 2)))
(let* ((layout (let* ((layout
(ebox-test-flex :width '(100) :column-gap (car case) (ebox-test-flex :width '(100) :column-gap (car case)
(ebox-test-text "A") (ebox-test-text "A")
@ -364,16 +364,16 @@
(ert-deftest ebox-flex-module-preserves-item-participation () (ert-deftest ebox-flex-module-preserves-item-participation ()
"Extracted flex code should preserve item participation semantics." "Extracted flex code should preserve item participation semantics."
(let* ((node (ebox-build (let* ((node (ebox-build
'(flex :width (240) '(flex :width (px 240)
(box :flex-grow 1 :width (40) "Grow") (box :flex-grow 1 :width (px 40) "Grow")
(box :width (40) "Fixed")))) (box :width (px 40) "Fixed"))))
(rendered (ebox-render node))) (rendered (ebox-render node)))
(should (string-match-p "Grow" rendered)) (should (string-match-p "Grow" rendered))
(should (string-match-p "Fixed" rendered)))) (should (string-match-p "Fixed" rendered))))
(ert-deftest ebox-flex-measures-direct-text-without-box-geometry () (ert-deftest ebox-flex-measures-direct-text-without-box-geometry ()
"A canonical Text child should participate as text, not a geometry Box." "A canonical Text child should participate as text, not a geometry Box."
(let* ((node (ebox-build '(flex :width (120) "Direct text"))) (let* ((node (ebox-build '(flex :width (px 120) "Direct text")))
(rendered (ebox-render node))) (rendered (ebox-render node)))
(should (string-match-p "Direct text" (should (string-match-p "Direct text"
(substring-no-properties rendered))) (substring-no-properties rendered)))
@ -400,7 +400,7 @@
(should (= (ebox-get (cadr items) :flex-grow) 2))) (should (= (ebox-get (cadr items) :flex-grow) 2)))
(ebox-style-reset-rules) (ebox-style-reset-rules)
(ebox-region-update (ebox-region-resolve buffer "rule") (ebox-region-update (ebox-region-resolve buffer "rule")
:width '(41)) :width '(px 41))
(let ((items (plist-get (plist-get (ebox--buffer-render-state buffer) (let ((items (plist-get (plist-get (ebox--buffer-render-state buffer)
:root-node) :root-node)
:children))) :children)))
@ -515,7 +515,7 @@
(ert-deftest ebox-flex-definite-basis-does-not-materialize-fallback () (ert-deftest ebox-flex-definite-basis-does-not-materialize-fallback ()
"A definite basis must not materialize a subtree for an unused fallback." "A definite basis must not materialize a subtree for an unused fallback."
(dolist (basis '((0) (40) 40)) (dolist (basis '((px 0) (px 40) (ch 40)))
(let* ((input (let* ((input
(ebox-test-box (ebox-test-box
(ebox-test-column (ebox-test-column
@ -523,8 +523,7 @@
(ebox-test-text "second line")))) (ebox-test-text "second line"))))
(source (ebox-test-root input)) (source (ebox-test-root input))
(ebox--surface-materialization-active t) (ebox--surface-materialization-active t)
(expected (ebox--normalize-horizontal-size-value (expected (ebox--size-pixels basis source 'width))
basis ebox--preferred-size-keywords))
(original-max (symbol-function 'ebox--content-max-pixel)) (original-max (symbol-function 'ebox--content-max-pixel))
(original-render (symbol-function 'ebox--render-layout)) (original-render (symbol-function 'ebox--render-layout))
(max-calls 0) (max-calls 0)
@ -544,7 +543,7 @@
(ert-deftest ebox-flex-basis-retains-required-intrinsic-fallback () (ert-deftest ebox-flex-basis-retains-required-intrinsic-fallback ()
"Viewport-like basis values measure only when their viewport is absent." "Viewport-like basis values measure only when their viewport is absent."
(dolist (viewport '(nil 120)) (dolist (viewport '(nil 120))
(dolist (basis '(viewport stretch contain)) (dolist (basis '((vw 100) stretch))
(let* ((content "natural content") (let* ((content "natural content")
(source (ebox-test-root (ebox-test-box (ebox-test-text content)))) (source (ebox-test-root (ebox-test-box (ebox-test-text content))))
(ebox--surface-materialization-active t) (ebox--surface-materialization-active t)
@ -563,14 +562,14 @@
"Public Flex layout retains complete output when unused probes disappear." "Public Flex layout retains complete output when unused probes disappear."
(let* ((input (let* ((input
(ebox-build (ebox-build
'(flex :width (140) :flex-wrap wrap :gap (1 (4)) '(flex :width (px 140) :flex-wrap wrap :gap ((lh 1) (px 4))
(box :flex-basis (40) :flex-grow 1 :min-width 0 (box :flex-basis (px 40) :flex-grow 1 :min-width (ch 0)
:wrap-mode word :padding (1 (2)) :wrap-mode word :padding ((lh 1) (px 2))
:color "#123456" :bgcolor "#ddeeff" :color "#123456" :bgcolor "#ddeeff"
(column (column
(text "alpha beta gamma delta epsilon") (text "alpha beta gamma delta epsilon")
(text "second line"))) (text "second line")))
(box :flex-basis (20) :min-width 0 :wrap-mode word (box :flex-basis (px 20) :min-width (ch 0) :wrap-mode word
(text "peer"))))) (text "peer")))))
(original-basis (symbol-function 'ebox--flex-basis-main)) (original-basis (symbol-function 'ebox--flex-basis-main))
(fast (ebox-render input)) (fast (ebox-render input))
@ -715,16 +714,16 @@
"Column flex items should not stretch to the full row before line breaking." "Column flex items should not stretch to the full row before line breaking."
(let* ((layout (let* ((layout
(ebox-build (ebox-build
'(flex :flex-flow (row wrap) :column-gap (12) :width (800) '(flex :flex-flow (row wrap) :column-gap (px 12) :width (px 800)
(column (column
(box :width (220) "one") (box :width (px 220) "one")
(box :width (42) "A")) (box :width (px 42) "A"))
(column (column
(box :width (220) "two") (box :width (px 220) "two")
(box :width (42) "B")) (box :width (px 42) "B"))
(column (column
(box :width (220) "three") (box :width (px 220) "three")
(box :width (42) "C"))))) (box :width (px 42) "C")))))
(lines (ebox-flex-test--plain-lines layout))) (lines (ebox-flex-test--plain-lines layout)))
(should (= (length lines) 2)) (should (= (length lines) 2))
(should (string-match-p "one.*two.*three" (car lines))) (should (string-match-p "one.*two.*three" (car lines)))
@ -734,19 +733,19 @@
"Composite flex items should re-render internals for final item width." "Composite flex items should re-render internals for final item width."
(let* ((layout (let* ((layout
(ebox-build (ebox-build
'(flex :flex-flow (row wrap) :column-gap (12) :width (800) '(flex :flex-flow (row wrap) :column-gap (px 12) :width (px 800)
(column (column
(box :id "title" :max-width (293) (box :id "title" :max-width (px 293)
:padding (0 (10)) :border "#5E7F6A" :padding ((lh 0) (px 10)) :border "#5E7F6A"
:text-align center "wrap") :text-align center "wrap")
(flex :id "body" :max-width (293) :padding (0 (8)) (flex :id "body" :max-width (px 293) :padding ((lh 0) (px 8))
:border-right "#5E7F6A" :border-right "#5E7F6A"
:border-bottom "#5E7F6A" :border-bottom "#5E7F6A"
:border-left "#5E7F6A" :border-left "#5E7F6A"
:flex-wrap wrap :column-gap (8) :flex-wrap wrap :column-gap (px 8)
(box :flex-shrink 0 :width (118) "A 118") (box :flex-shrink 0 :width (px 118) "A 118")
(box :flex-shrink 0 :width (118) "B 118") (box :flex-shrink 0 :width (px 118) "B 118")
(box :flex-shrink 0 :width (118) "C 118")))))) (box :flex-shrink 0 :width (px 118) "C 118"))))))
(buffer (generate-new-buffer " *ebox-flex-viewport*"))) (buffer (generate-new-buffer " *ebox-flex-viewport*")))
(unwind-protect (unwind-protect
(progn (progn
@ -772,36 +771,36 @@
(let* ((ebox-viewport-width 500) (let* ((ebox-viewport-width 500)
(layout (layout
(ebox-build (ebox-build
'(flex :flex-flow (row wrap) :row-gap 1 :column-gap (12) '(flex :flex-flow (row wrap) :row-gap (lh 1) :column-gap (px 12)
:padding (0 (12)) :padding ((lh 0) (px 12))
:border-right "#6F6A95" :border-right "#6F6A95"
:border-bottom "#6F6A95" :border-bottom "#6F6A95"
:border-left "#6F6A95" :border-left "#6F6A95"
:bgcolor "#FBF9FF" :bgcolor "#FBF9FF"
(column (column
(box :max-width (440) (box :max-width (px 440)
:padding (0 (10)) :border "#6F6A95" :padding ((lh 0) (px 10)) :border "#6F6A95"
:text-align center ":flex-flow (row wrap)") :text-align center ":flex-flow (row wrap)")
(flex :max-width (440) :padding (0 (8)) (flex :max-width (px 440) :padding ((lh 0) (px 8))
:border-right "#6F6A95" :border-right "#6F6A95"
:border-bottom "#6F6A95" :border-bottom "#6F6A95"
:border-left "#6F6A95" :border-left "#6F6A95"
:flex-flow (row wrap) :gap (1 (12)) :flex-flow (row wrap) :gap ((lh 1) (px 12))
(box :width (170) "A") (box :width (px 170) "A")
(box :width (170) "B") (box :width (px 170) "B")
(box :width (170) "C wraps"))) (box :width (px 170) "C wraps")))
(column (column
(box :max-width (440) (box :max-width (px 440)
:padding (0 (10)) :border "#6F6A95" :padding ((lh 0) (px 10)) :border "#6F6A95"
:text-align center ":flex-flow (column wrap)") :text-align center ":flex-flow (column wrap)")
(flex :max-width (440) :padding (0 (8)) (flex :max-width (px 440) :padding ((lh 0) (px 8))
:border-right "#6F6A95" :border-right "#6F6A95"
:border-bottom "#6F6A95" :border-bottom "#6F6A95"
:border-left "#6F6A95" :border-left "#6F6A95"
:flex-flow (column wrap) :gap (1 (12)) :flex-flow (column wrap) :gap ((lh 1) (px 12))
(box :width (140) "A") (box :width (px 140) "A")
(box :width (140) "B") (box :width (px 140) "B")
(box :width (140) "C new column")))))) (box :width (px 140) "C new column"))))))
(rendered (ebox-flex-test--render layout)) (rendered (ebox-flex-test--render layout))
(line-widths (ebox-flex-test--line-widths layout)) (line-widths (ebox-flex-test--line-widths layout))
(border-widths (border-widths
@ -996,8 +995,10 @@ entry before final allocation, providing the original rerendering reference."
(eq (ebox-node-source-handle box) handle)) (eq (ebox-node-source-handle box) handle))
(push (push
(list :allocation allocation (list :allocation allocation
:width (ebox-get box :width) :width (ebox--flex-definite-property
:height (ebox-get box :height) box 'row (ebox-get box :width))
:height (ebox--flex-definite-property
box 'column (ebox-get box :height))
:constraints :constraints
(ebox--render-cache-plist-signature box) (ebox--render-cache-plist-signature box)
:context :context
@ -1039,9 +1040,9 @@ entry before final allocation, providing the original rerendering reference."
"An already assigned numeric cross size must not materialize a Box twice." "An already assigned numeric cross size must not materialize a Box twice."
(let* ((input (let* ((input
(ebox-build (ebox-build
'(flex :width (120) :flex-wrap wrap '(flex :width (px 120) :flex-wrap wrap
(box :flex-basis (40) :flex-grow 1 :min-width 0 (box :flex-basis (px 40) :flex-grow 1 :min-width (ch 0)
:height 2 :wrap-mode word :height (lh 2) :wrap-mode word
(text "short"))))) (text "short")))))
(observed (ebox-flex-test--observe-sized-box input)) (observed (ebox-flex-test--observe-sized-box input))
(calls (plist-get observed :observations)) (calls (plist-get observed :observations))
@ -1074,17 +1075,17 @@ entry before final allocation, providing the original rerendering reference."
'mouse-face 'highlight)) 'mouse-face 'highlight))
(input (input
(ebox-build (ebox-build
`(flex ,@(if row-p '(:width (140)) '(:height 10)) `(flex ,@(if row-p '(:width (px 140)) '(:height (lh 10)))
:flex-direction ,axis :flex-wrap wrap :flex-direction ,axis :flex-wrap wrap
(box :flex-basis ,(if row-p '(40) 2) :flex-grow 1 (box :flex-basis ,(if row-p '(px 40) '(lh 2)) :flex-grow 1
:min-width 0 :wrap-mode word :overflow hidden :min-width (ch 0) :wrap-mode word :overflow hidden
,@(if row-p ,@(if row-p
(list :height (if (eq sizing 'border-box) 6 2)) (list :height (list 'lh (if (eq sizing 'border-box) 6 2)))
'(:width (60))) '(:width (px 60)))
:box-sizing ,sizing :padding (1 (2)) :margin (1 (3)) :box-sizing ,sizing :padding ((lh 1) (px 2)) :margin ((lh 1) (px 3))
:border (1 solid "#123456") :border ((px 1) solid "#123456")
:color "#123456" :bgcolor "#ddeeff" :color "#123456" :bgcolor "#ddeeff"
(column :width ,(if row-p '(100) '(40)) (column :width ,(if row-p '(px 100) '(px 40))
(text ,label) (text "second")))))) (text ,label) (text "second"))))))
(source (car (ebox-tree-layout-children (ebox-test-root input)))) (source (car (ebox-tree-layout-children (ebox-test-root input))))
(handle (ebox-node-source-handle source)) (handle (ebox-node-source-handle source))
@ -1109,22 +1110,22 @@ entry before final allocation, providing the original rerendering reference."
(ert-deftest ebox-flex-identical-cross-keeps-changing-constraints () (ert-deftest ebox-flex-identical-cross-keeps-changing-constraints ()
"Automatic, relative, clamped, and enlarged cross constraints rerender." "Automatic, relative, clamped, and enlarged cross constraints rerender."
(dolist (case '(nil (dolist (case '(nil
(:height (viewport-height)) (:height (vh 100))
(:height 1 :overflow visible) (:height (lh 1) :overflow visible)
(:height 2 :peer-height 4) (:height (lh 2) :peer-height 4)
(:height 4 :max-height 2) (:height (lh 4) :max-height (lh 2))
(:height 2 :min-height 3))) (:height (lh 2) :min-height (lh 3))))
(let* ((ebox-viewport-height 2) (let* ((ebox-viewport-height 2)
(peer-height (plist-get case :peer-height)) (peer-height (plist-get case :peer-height))
(input (input
(ebox-build (ebox-build
`(flex :width (120) :flex-wrap wrap `(flex :width (px 120) :flex-wrap wrap
(box :flex-basis (40) :flex-grow 1 :min-width 0 (box :flex-basis (px 40) :flex-grow 1 :min-width (ch 0)
:wrap-mode word :wrap-mode word
,@(ebox--plist-remove-keys case '(:peer-height)) ,@(ebox--plist-remove-keys case '(:peer-height))
(text "A\nB")) (text "A\nB"))
,@(when peer-height ,@(when peer-height
`((box :width (20) :height ,peer-height "peer")))))) `((box :width (px 20) :height (lh ,peer-height) "peer"))))))
(observed (ebox-flex-test--observe-sized-box input))) (observed (ebox-flex-test--observe-sized-box input)))
(ert-info ((format "Cross constraint: %S" case)) (ert-info ((format "Cross constraint: %S" case))
(should (= (length (plist-get observed :observations)) 2)) (should (= (length (plist-get observed :observations)) 2))
@ -1138,9 +1139,9 @@ entry before final allocation, providing the original rerendering reference."
(let* ((ebox-viewport-width nil) (let* ((ebox-viewport-width nil)
(input (input
(ebox-build (ebox-build
'(flex :height 4 :flex-direction column :flex-wrap wrap '(flex :height (lh 4) :flex-direction column :flex-wrap wrap
(box :width (60) :flex-basis 2 :flex-grow 1 :overflow hidden (box :width (px 60) :flex-basis (lh 2) :flex-grow 1 :overflow hidden
(box :width (viewport) (text "responsive")))))) (box :width (vw 100) (text "responsive"))))))
(observed (ebox-flex-test--observe-sized-box input)) (observed (ebox-flex-test--observe-sized-box input))
(calls (plist-get observed :observations))) (calls (plist-get observed :observations)))
(should (= (length calls) 2)) (should (= (length calls) 2))
@ -1157,9 +1158,9 @@ entry before final allocation, providing the original rerendering reference."
"A reused fixed-height Box retains the already rendered scroll geometry." "A reused fixed-height Box retains the already rendered scroll geometry."
(let* ((input (let* ((input
(ebox-build (ebox-build
'(flex :width (120) :flex-wrap wrap '(flex :width (px 120) :flex-wrap wrap
(box :flex-basis (40) :flex-grow 1 :min-width 0 (box :flex-basis (px 40) :flex-grow 1 :min-width (ch 0)
:height 2 :wrap-mode word :overflow scroll :height (lh 2) :wrap-mode word :overflow scroll
(column (text "A") (text "B") (text "C") (text "D")))))) (column (text "A") (text "B") (text "C") (text "D"))))))
(fast (ebox-flex-test--observe-sized-box input)) (fast (ebox-flex-test--observe-sized-box input))
(reference (ebox-flex-test--observe-sized-box input t)) (reference (ebox-flex-test--observe-sized-box input t))
@ -1172,7 +1173,9 @@ entry before final allocation, providing the original rerendering reference."
(should state) (should state)
(should (= (plist-get state :content-height) 2)) (should (= (plist-get state :content-height) 2))
(should (= (ebox-get (plist-get state :box) :width) 120)) (should (= (ebox-get (plist-get state :box) :width) 120))
(should (= (ebox-get (plist-get state :box) :height) 2)) (should (= (ebox--flex-definite-property
(plist-get state :box) 'column
(ebox-get (plist-get state :box) :height)) 2))
(dolist (property '(:scroll-offset :content-height (dolist (property '(:scroll-offset :content-height
:content-lines-complete-p)) :content-lines-complete-p))
(should (equal (plist-get state property) (plist-get old-state property)))) (should (equal (plist-get state property) (plist-get old-state property))))
@ -1218,14 +1221,14 @@ entry before final allocation, providing the original rerendering reference."
(ebox-viewport-height 2) (ebox-viewport-height 2)
(layout (layout
(ebox-build (ebox-build
'(flex :width (120) '(flex :width (px 120)
(box :id "root" :width (80) :height (viewport-height) (box :id "root" :width (px 80) :height (vh 100)
:overflow scroll :flex-grow 1 :overflow scroll :flex-grow 1
(column :width (80) (column :width (px 80)
(box :height 1 "A") (box :height (lh 1) "A")
(box :height 1 "B") (box :height (lh 1) "B")
(box :height 1 "C") (box :height (lh 1) "C")
(box :height 1 "D")))))) (box :height (lh 1) "D"))))))
(buffer (generate-new-buffer " *ebox-flex-scroll-state*"))) (buffer (generate-new-buffer " *ebox-flex-scroll-state*")))
(unwind-protect (unwind-protect
(progn (progn
@ -1241,8 +1244,9 @@ entry before final allocation, providing the original rerendering reference."
(ert-deftest ebox-flex-natural-row-padding-keeps-overflow-line-local () (ert-deftest ebox-flex-natural-row-padding-keeps-overflow-line-local ()
"Visible overflow should widen only the line that actually overflows." "Visible overflow should widen only the line that actually overflows."
(let* ((viewport-child (let* ((viewport-child
(ebox-test-box (ebox-test-text "A") :width '(viewport) (ebox-test-box (ebox-test-text "A") :width '(% 100)
:box-sizing 'content-box :box-sizing 'content-box
:overflow 'visible
:padding-left '(10) :padding-right '(10))) :padding-left '(10) :padding-right '(10)))
(source (source
(ebox-test-column viewport-child (ebox-test-column viewport-child
@ -1485,7 +1489,7 @@ entry before final allocation, providing the original rerendering reference."
(ert-deftest ebox-flex-column-natural-cache-respects-final-cross-viewport () (ert-deftest ebox-flex-column-natural-cache-respects-final-cross-viewport ()
"Column flex must render composite items in the final cross viewport." "Column flex must render composite items in the final cross viewport."
(let* ((viewport-child (let* ((viewport-child
(ebox-test-box (ebox-test-text "VP") :width '(viewport))) (ebox-test-box (ebox-test-text "VP") :width '(% 100)))
(source-input (source-input
(ebox-test-column (ebox-test-column
viewport-child viewport-child
@ -1497,7 +1501,7 @@ entry before final allocation, providing the original rerendering reference."
(ebox-test-flex :height 4 (ebox-test-flex :height 4
:flex-direction 'column :flex-direction 'column
:align-items 'stretch :align-items 'stretch
(ebox-test-flex-item source-input :flex-grow 1 :flex-basis 2))) (ebox-test-flex-item source-input :flex-grow 1 :flex-basis '(lh 2))))
(rendered (ebox-render layout))) (rendered (ebox-render layout)))
(should (ebox-box-node-p source)) (should (ebox-box-node-p source))
(should (eq (ebox-layout-config-kind (ebox-box-node-layout source)) (should (eq (ebox-layout-config-kind (ebox-box-node-layout source))
@ -1759,6 +1763,7 @@ entry before final allocation, providing the original rerendering reference."
(ebox-test-box (ebox-test-text "wide no-wrap label") (ebox-test-box (ebox-test-text "wide no-wrap label")
:width '(150) :width '(150)
:box-sizing 'border-box :box-sizing 'border-box
:overflow 'visible :min-width 'auto
:wrap-mode 'none) :wrap-mode 'none)
:flex 1) :flex 1)
(ebox-test-box (ebox-test-text "B") :width '(120)))) (ebox-test-box (ebox-test-text "B") :width '(120))))

View File

@ -55,7 +55,7 @@
(ebox-render-to-buffer (ebox-render-to-buffer
buffer buffer
(ebox-build (ebox-build
'(box :width (viewport) '(box :width (vw 100)
(text :font-size 16 :font-style italic "A")))) (text :font-size 16 :font-style italic "A"))))
(let* ((state-a (ebox--buffer-render-state buffer)) (let* ((state-a (ebox--buffer-render-state buffer))
(node-a (node-a

View File

@ -109,6 +109,144 @@
entry-left rendered 100 '(:justify-items stretch)))) entry-left rendered 100 '(:justify-items stretch))))
(should (= calls 1))))) (should (= calls 1)))))
(defun ebox-grid-test--background-widths (rendered color)
"Return the width painted COLOR on each line of RENDERED."
(mapcar
(lambda (line)
(let ((start 0) (width 0))
(while (< start (length line))
(let ((end (next-single-property-change
start 'face line (length line))))
(when (equal (plist-get (get-text-property start 'face line)
:background)
color)
(cl-incf width (ebox--string-pixel-width
(substring line start end))))
(setq start end)))
width))
(ebox-string-lines rendered)))
(ert-deftest ebox-grid-block-alignment-stretches-the-item-background ()
"Default and explicit stretch paint the item; other alignments position it."
(dolist (case '((nil (40 40 40)) (normal (40 40 40))
(stretch (40 40 40)) (start (40 0 0))
(center (0 40 0)) (end (0 0 40))
(flex-start (40 0 0)) (flex-end (0 0 40))))
(dolist (height '(nil auto))
(let* ((form `(grid :width (px 40)
:grid-template-columns ((fr 1))
:grid-template-rows ((lh 3))
,@(when (car case) (list :align-items (car case)))
(box :bgcolor "#ddeeff"
,@(when height (list :height height)) "A")))
(rendered (ebox-render (ebox-build form))))
(ert-info ((format "Grid alignment %S, height %S" (car case) height))
(should (equal (ebox-grid-test--background-widths rendered "#ddeeff")
(cadr case))))))))
(ert-deftest ebox-grid-stretch-preserves-height-constraints-and-edges ()
"Track stretch respects explicit heights, min/max constraints and margins."
(dolist (case '((nil (40 40 40 40 40))
((:height (lh 1)) (40 0 0 0 0))
((:height (% 40)) (40 40 0 0 0))
((:height min-content) (40 0 0 0 0))
((:height max-content) (40 0 0 0 0))
((:height fit-content) (40 0 0 0 0))
((:min-height (lh 2)) (40 40 40 40 40))
((:max-height (lh 2)) (40 40 0 0 0))
((:min-height (lh 3) :max-height (lh 2)) (40 40 40 0 0))
((:margin ((lh 1) (px 0))) (0 40 40 40 0))
((:padding ((lh 1) (px 0))) (40 40 40 40 40))
((:box-sizing border-box :padding ((lh 1) (px 0)))
(40 40 40 40 40))))
(let* ((rendered
(ebox-render
(ebox-build
`(grid :width (px 40) :grid-template-columns ((fr 1))
:grid-template-rows ((lh 5))
(box :bgcolor "#ddeeff" ,@(car case) "A"))))))
(ert-info ((format "Grid item properties %S" (car case)))
(should (equal (ebox-grid-test--background-widths rendered "#ddeeff")
(cadr case)))))))
(ert-deftest ebox-grid-align-self-overrides-container-block-alignment ()
"The child's align-self controls both used height and vertical position."
(dolist (case '((start stretch (40 40 40))
(start normal (40 40 40))
(stretch start (40 0 0))
(stretch center (0 40 0))
(stretch end (0 0 40))
(end auto (0 0 40))))
(let ((rendered
(ebox-render
(ebox-build
`(grid :width (px 40) :grid-template-columns ((fr 1))
:grid-template-rows ((lh 3)) :align-items ,(nth 0 case)
(box :bgcolor "#ddeeff" :align-self ,(nth 1 case) "A"))))))
(should (equal (ebox-grid-test--background-widths rendered "#ddeeff")
(nth 2 case))))))
(ert-deftest ebox-grid-stretched-height-preserves-inline-alignment ()
"Stretching the block axis must preserve a naturally sized inline axis."
(dolist (justify '(start center end))
(let* ((rendered
(ebox-render
(ebox-build
`(grid :width (px 40) :grid-template-columns ((fr 1))
:grid-template-rows ((lh 3)) :justify-items ,justify
(box :bgcolor "#ddeeff" "A")))))
(width (ebox--string-pixel-width "A"))
(offset (pcase justify ('center (/ (- 40 width) 2))
('end (- 40 width)) (_ 0)))
(first (car (ebox-string-lines rendered))))
(should (equal (ebox-grid-test--background-widths rendered "#ddeeff")
(make-list 3 width)))
(should (= (ebox--string-pixel-width
(substring first 0 (string-match "A" first)))
offset)))))
(ert-deftest ebox-grid-stretch-paints-composites-across-row-spans ()
"Nested layouts stretch across the full row span including intervening gaps."
(dolist (kind '(box column row flex grid))
(let ((rendered
(ebox-render
(ebox-build
`(grid :width (px 40) :grid-template-columns ((fr 1))
:grid-template-rows ((lh 2) (lh 2)) :row-gap (lh 1)
(,kind :grid-row (1 :span 2) :bgcolor "#ddeeff"
(box "A")))))))
(ert-info ((format "Stretched Grid item layout %S" kind))
(should (equal (ebox-grid-test--background-widths rendered "#ddeeff")
'(40 40 40 40 40)))))))
(ert-deftest ebox-grid-stretched-item-commits-preserve-source-geometry ()
"Retained updates recompute stretch without retaining its temporary Box copy."
(with-temp-buffer
(dolist (case '((40 3 stretch) (60 5 stretch)
(60 5 start) (40 3 stretch)))
(let ((input
(ebox-build
`(grid :key root :width (px ,(nth 0 case))
:grid-template-columns ((fr 1))
:grid-template-rows ((lh ,(nth 1 case)))
:align-items ,(nth 2 case)
(box :key item :bgcolor "#ddeeff" "A")))))
(if (ebox--buffer-render-state (current-buffer))
(ebox-commit (current-buffer) input)
(ebox-render-to-buffer (current-buffer) input))
(let* ((state (ebox--buffer-render-state (current-buffer)))
(child (car (ebox-tree-node-children
(plist-get state :root-node))))
(actual (buffer-string))
(fresh (ebox-render input)))
(should (memq (ebox-get child :height) '(nil auto)))
(should (eq child (gethash (ebox-get child :region-id)
(plist-get state :region-box-table))))
(should (equal (ebox-grid-test--background-widths actual "#ddeeff")
(ebox-grid-test--background-widths fresh "#ddeeff")))
(should (equal (substring-no-properties actual)
(substring-no-properties fresh))))))))
(ert-deftest ebox-grid-renders-assigned-cell-width-once () (ert-deftest ebox-grid-renders-assigned-cell-width-once ()
"Final Grid assembly must reuse the width-sized cell's complete output." "Final Grid assembly must reuse the width-sized cell's complete output."
(let* ((cell-region 91001) (let* ((cell-region 91001)
@ -155,7 +293,7 @@
(ert-deftest ebox-grid-definite-stretched-cells-skip-unused-intrinsic-output () (ert-deftest ebox-grid-definite-stretched-cells-skip-unused-intrinsic-output ()
"Definite fixed/fr columns need only the stretched cell's final output." "Definite fixed/fr columns need only the stretched cell's final output."
(dolist (width '(80 120)) (dolist (width '(80 120))
(dolist (cell-width '(nil auto stretch contain)) (dolist (cell-width '(nil auto stretch))
(let* ((cell-region 91002) (let* ((cell-region 91002)
(input (input
(ebox-test-grid (ebox-test-grid
@ -485,8 +623,9 @@
(let* ((leading-zero-p (zerop (car rows))) (let* ((leading-zero-p (zerop (car rows)))
(input (input
(ebox-build (ebox-build
`(grid :width (40) :grid-template-columns ((40)) `(grid :width (px 40) :grid-template-columns ((px 40))
:grid-template-rows ,rows :row-gap ,gap :grid-template-rows ,(mapcar (lambda (n) (list 'lh n)) rows)
:row-gap (lh ,gap)
(text ,(if leading-zero-p "hidden" "visible")) (text ,(if leading-zero-p "hidden" "visible"))
(text ,(if leading-zero-p "visible" "hidden"))))) (text ,(if leading-zero-p "visible" "hidden")))))
(rendered (ebox-render input)) (rendered (ebox-render input))
@ -621,7 +760,7 @@
"The public Ebox DSL should compile a grid node." "The public Ebox DSL should compile a grid node."
(let* ((input (let* ((input
(ebox-build (ebox-build
'(grid :grid-template-columns ((20) (20)) '(grid :grid-template-columns ((px 20) (px 20))
(box "A") (box "A")
(box "B")))) (box "B"))))
(node (ebox-test-root input))) (node (ebox-test-root input)))
@ -634,7 +773,7 @@
"The Ebox DSL should preserve direct child Box placement metadata." "The Ebox DSL should preserve direct child Box placement metadata."
(let* ((input (let* ((input
(ebox-build (ebox-build
'(grid :grid-template-columns ((20) (20)) '(grid :grid-template-columns ((px 20) (px 20))
(box :grid-column (1 :span 2) "Header")))) (box :grid-column (1 :span 2) "Header"))))
(node (ebox-test-root input))) (node (ebox-test-root input)))
(should (string-match-p "Header" (ebox-grid-test--plain input))) (should (string-match-p "Header" (ebox-grid-test--plain input)))

View File

@ -0,0 +1,963 @@
;;; ebox-interaction-tests.el --- Native node interaction tests -*- lexical-binding: t; -*-
;;; Code:
(require 'ert)
(require 'ebox)
(require 'ebox-native-reflow)
(defun ebox-interaction-test--position (string text)
"Return the position of TEXT in STRING, asserting that it exists."
(or (string-match (regexp-quote text) string) (ert-fail text)))
(ert-deftest ebox-help-create-adapts-literal-business-functions ()
"Help callbacks use buffer, overlay and displayed-string contexts lazily."
(let ((target (generate-new-buffer " *ebox-help-target*"))
(calls 0) observed)
(unwind-protect
(save-window-excursion
(let* ((selected (selected-window))
(help (ebox-help-create
(lambda () (cl-incf calls)
(setq observed (list (current-buffer) (point)
(selected-window)))
"help")))
overlay)
(with-current-buffer target
(insert "target") (goto-char 3)
(setq overlay (make-overlay 1 3)))
(ebox-build (list 'text :help-echo help "text"))
(should (= calls 0))
(dolist (object (list target overlay))
(should (equal (funcall help nil object 1) "help"))
(should (equal observed (list target 3 selected))))
(set-window-buffer selected target)
(should (equal (funcall help selected "displayed" 0) "help"))
(should (equal observed (list target 3 selected)))
(should (= calls 3))
(delete-overlay overlay)
(should-error (funcall help nil overlay 1) :type 'user-error)
(should-error (funcall help nil "displayed" 0) :type 'user-error)
(should-not (funcall (ebox-help-create #'ignore) nil target 1))
(should-error (funcall (ebox-help-create (lambda () 7)) nil target 1))
(should-error (ebox-help-create nil))
(should-error (ebox-help-create (lambda (required) required)))))
(kill-buffer target))))
(ert-deftest ebox-interaction-surface-compares-owned-keymap-bindings ()
"Copied maps do not turn paint changes into interaction changes."
(let* ((command (let ((value 1)) (lambda () (interactive) value)))
(other (let ((value 1)) (lambda () (interactive) value)))
(map (make-sparse-keymap "Actions"))
(parent (make-sparse-keymap)))
(define-key map (kbd "RET") command)
(define-key parent (kbd "p") #'ignore)
(set-keymap-parent map parent)
(let ((copy (ebox-interaction--keymap-snapshot map)))
(should-not (eq map copy))
(should (ebox-interaction-surface-equal-p
(list 'keymap map 'help-echo command)
(list 'help-echo command 'keymap copy)))
(should-not (ebox-interaction-surface-equal-p
(list 'help-echo command) (list 'help-echo other)))
(define-key copy (kbd "RET") other)
(should-not (ebox-interaction-surface-equal-p
(list 'keymap map) (list 'keymap copy))))))
(ert-deftest ebox-interaction-full-keymap-is-an-owned-snapshot ()
"Full character-table maps cannot mutate rendered or future input snapshots."
(let ((map (make-keymap)))
(define-key map (kbd "RET") #'ignore)
(let* ((input (ebox-build (list 'text :keymap map "text")))
(rendered (ebox-render input)))
(define-key map (kbd "RET") #'forward-char)
(dolist (text (list rendered (ebox-render input)))
(should (eq (lookup-key (get-text-property 0 'keymap text) (kbd "RET"))
#'ignore))))))
(ert-deftest ebox-interaction-named-keymap-graphs-are-owned-snapshots ()
"Named root, prefix and parent maps cannot mutate an accepted node."
(let* ((name (make-symbol "root-map"))
(prefix-name (make-symbol "prefix-map"))
(parent-name (make-symbol "parent-map"))
(map (make-keymap))
(prefix (make-sparse-keymap))
(parent (make-sparse-keymap)))
(fset name map)
(fset prefix-name prefix)
(fset parent-name parent)
(define-key map (kbd "RET") #'ignore)
(define-key prefix (kbd "x") #'forward-char)
(define-key prefix (kbd "r") name)
(define-key parent (kbd "p") #'backward-char)
(define-key map (kbd "C-c") prefix-name)
(define-key map [menu-bar item] (list 'menu-item "Prefix" prefix-name))
(set-keymap-parent map parent-name)
(let* ((input (ebox-build (list 'text :keymap name "text")))
(rendered (ebox-render input)))
(define-key map (kbd "RET") #'forward-char)
(define-key prefix (kbd "x") #'ignore)
(define-key parent (kbd "p") #'ignore)
(fset name (make-sparse-keymap))
(dolist (text (list rendered (ebox-render input)))
(let ((actual (get-text-property 0 'keymap text)))
(should-not (symbolp actual))
(should (eq (lookup-key actual (kbd "RET")) #'ignore))
(should (eq (lookup-key actual (kbd "C-c x")) #'forward-char))
(should (eq (lookup-key actual (kbd "p")) #'backward-char))
(should (eq (lookup-key actual (kbd "C-c r")) actual))
(should (eq (lookup-key actual (vconcat [menu-bar item] (kbd "x")))
#'forward-char)))))))
(ert-deftest ebox-interaction-native-templates-reuse-owned-keymaps ()
"Template registration preserves the identity of already owned native maps."
(let* ((map (ebox-interaction--keymap-snapshot (make-sparse-keymap)))
(ebox-native-reflow--compile-property-templates nil)
(ebox-native-reflow--compile-property-template-base-count 0)
(ebox-native-reflow--compile-retained-registry-p nil)
(ebox-native-reflow--compile-property-template-ids
(make-hash-table :test 'ebox-native-property-template)))
(dotimes (_ 2)
(should (= (ebox-native-reflow--register-property-template
(list 'keymap map) "surface") 0)))
(should (eq (plist-get (plist-get
(car ebox-native-reflow--compile-property-templates)
:properties) 'keymap)
map))
(let ((ebox-native-reflow--compile-property-template-index
(ebox-native-reflow--persistent-index-from-sequence
ebox-native-reflow--compile-property-templates t))
(ebox-native-reflow--compile-property-template-base-count 1)
(ebox-native-reflow--compile-property-templates nil)
(ebox-native-reflow--compile-retained-registry-p t))
(should (= (ebox-native-reflow--register-property-template
(list 'keymap map) "surface") 0)))))
(ert-deftest ebox-interaction-batch-reads-the-current-source-generation ()
"Two updates to one node in a batch retain every accumulated native fact."
(let ((buffer (generate-new-buffer " *ebox-interaction-batch*")))
(unwind-protect
(progn
(ebox-render-to-buffer
buffer (ebox-build '(text :id t :help-echo "old" :pointer hand "text")))
(let ((handle (ebox-region-resolve buffer t)))
(ebox-incremental-begin-batch buffer)
(ebox-region-update handle :help-echo "new")
(ebox-region-update handle :pointer 'text)
(ebox-incremental-flush buffer))
(with-current-buffer buffer
(should (equal (get-text-property (point-min) 'help-echo) "new"))
(should (eq (get-text-property (point-min) 'pointer) 'text))))
(kill-buffer buffer))))
(ert-deftest ebox-interaction-direct-text-content-update-retains-node-facts ()
"Named Text content supports replacement, no-op, batching and native facts."
(dolist (nested '(nil t))
(let ((buffer (generate-new-buffer " *ebox-direct-text-content*"))
(form '(text :id "direct" :help-echo "before" :pointer hand
:hover-style (:background-color "blue")
:keymap (keymap (13 . ignore)) "old")))
(unwind-protect
(progn
(ebox-render-to-buffer
buffer (ebox-build (if nested (list 'row form '(text "sibling")) form)))
(with-current-buffer buffer
(let* ((handle (ebox-region-resolve buffer "direct"))
(region-id (cdr (ebox-selector--region-target handle)))
(old (ebox--root-region-box
(ebox--buffer-root-node buffer) region-id)))
(should (ebox-text-node-p old))
(ebox-region-update "direct" :content "new")
(should (string-prefix-p "new" (buffer-string)))
(should (equal (get-text-property (point-min) 'help-echo) "before"))
(let* ((surface (plist-get (ebox--buffer-render-state buffer) :surface))
(revision (tp-surface-revision surface))
(report (ebox-region-update handle :content "new")))
(should (eq (plist-get report :strategy) 'no-op))
(should (= (tp-surface-revision surface) revision)))
(ebox-region-update handle :help-echo "after" :pointer 'text
:keymap '(keymap (13 . forward-char)))
(ebox-region-update handle :content "latest")
(should (string-prefix-p "latest" (buffer-string)))
(ebox-incremental-begin-batch buffer)
(ebox-region-update handle :content "batch-first")
(ebox-region-update handle :pointer 'hand)
(ebox-region-update handle :content "batch-final")
(ebox-incremental-flush buffer)
(should (string-prefix-p "batch-final" (buffer-string)))
(when nested (should (string-suffix-p "sibling" (buffer-string))))
(should (eq (get-text-property (point-min) 'pointer) 'hand))
(should (equal (get-text-property (point-min) 'help-echo) "after"))
(should (eq (lookup-key (get-text-property (point-min) 'keymap)
(kbd "RET")) #'forward-char))
(let ((current (ebox--root-region-box
(ebox--buffer-root-node buffer) region-id)))
(should (ebox-text-node-p current))
(should (equal (ebox-text-node-value current) "batch-final"))
(should (equal (plist-get current :content) "batch-final")))
(should (equal (ebox-text-node-value old) "old"))
(should-error (ebox-region-update handle :content nil)
:type 'user-error)
(should (string-prefix-p "batch-final" (buffer-string))))))
(kill-buffer buffer)))))
(ert-deftest ebox-interaction-stateful-callbacks-keep-distinct-identities ()
"Equal-shaped closures remain distinct during updates and native interning."
(let* ((factory (eval '(lambda ()
(let ((n 0))
(lambda (_window _object _position)
(setq n (1+ n))))) t))
(first (funcall factory)) (second (funcall factory))
(buffer (generate-new-buffer " *ebox-callback-identity*")))
(should-not (eq first second))
(should (equal first second))
(unwind-protect
(progn
(ebox-render-to-buffer
buffer (ebox-build (list 'text :id t :help-echo first "text")))
(ebox-region-update (ebox-region-resolve buffer t) :help-echo second)
(with-current-buffer buffer
(should (eq (get-text-property (point-min) 'help-echo) second)))
(dolist (hashed '(nil t))
(let ((ebox-native-reflow--compile-property-templates nil)
(ebox-native-reflow--compile-property-template-base-count 0)
(ebox-native-reflow--compile-retained-registry-p nil)
(ebox-native-reflow--compile-property-template-ids
(and hashed (make-hash-table :test 'equal))))
(should (= (ebox-native-reflow--register-property-template
(list 'help-echo first) "surface") 0))
(should (= (ebox-native-reflow--register-property-template
(list 'help-echo second) "surface") 1))
(funcall first nil nil nil)
(should (= (ebox-native-reflow--register-property-template
(list 'help-echo first) "surface") 0))
(let ((ebox-native-reflow--compile-property-template-index
(ebox-native-reflow--persistent-index-from-sequence
ebox-native-reflow--compile-property-templates t))
(ebox-native-reflow--compile-property-template-base-count 2)
(ebox-native-reflow--compile-property-templates nil)
(ebox-native-reflow--compile-retained-registry-p t))
(should (= (ebox-native-reflow--register-property-template
(list 'help-echo first) "surface") 0))
(should (= (ebox-native-reflow--register-property-template
(list 'help-echo second) "surface") 1))))))
(kill-buffer buffer))))
(ert-deftest ebox-keymap-create-wraps-literal-local-callbacks ()
"Reusable maps expose activation and extra keys as ordinary native commands."
(let* ((activations 0) (extras 0)
(map (ebox-keymap-create
:activate (lambda () (cl-incf activations))
:bindings (list (cons "x" (lambda () (cl-incf extras)))
(cons [f5] (lambda () (cl-incf extras)))))))
(should (keymapp map))
(should (= activations 0))
(should (= extras 0))
(dolist (key '("RET" [return] "SPC" [mouse-1]))
(should (commandp (lookup-key map (if (stringp key) (kbd key) key)))))
(let ((last-input-event ?\r))
(call-interactively (lookup-key map (kbd "RET")))
(call-interactively (lookup-key map "x"))
(call-interactively (lookup-key map [f5])))
(should (= activations 1))
(should (= extras 2))
(with-temp-buffer
(insert (ebox-render (ebox-build (list 'text :keymap map "action"))))
(goto-char (point-min))
(should (eq (key-binding (kbd "RET")) (lookup-key map (kbd "RET"))))))
(should (keymapp (ebox-keymap-create)))
(should-not (lookup-key (ebox-keymap-create :activate nil :bindings nil)
(kbd "RET"))))
(ert-deftest ebox-keymap-create-rejects-malformed-and-overlapping-bindings ()
"Invalid callbacks and duplicate or shadowed key sequences fail at creation."
(dolist (options '((:unknown t) (:activate) (:activate ignore :activate ignore)
(:activate 2) (:activate (lambda (required) required))
(:bindings malformed) (:bindings (invalid))
(:bindings ((nil . ignore)))
(:bindings (("x" . nil)))
(:bindings (("" . ignore)))
(:bindings (([] . ignore)))
(:bindings (([1.5] . ignore)))
(:bindings (("x" . ignore) ([120] . ignore)))
(:bindings (("C-m" . ignore) ("RET" . ignore)))
(:bindings (("x" . ignore) ("x y" . ignore)))
(:bindings (("x y" . ignore) ("x" . ignore)))
(:activate ignore :bindings (("RET" . ignore)))
(:activate ignore :bindings (([mouse-1] . ignore)))))
(should-error (apply #'ebox-keymap-create options))))
(ert-deftest ebox-keymap-create-mouse-uses-event-buffer-without-moving-focus ()
"Mouse callbacks target the clicked buffer even when another buffer is current."
(let ((target (generate-new-buffer " *ebox-keymap-target*"))
(other (generate-new-buffer " *ebox-keymap-other*"))
observed)
(unwind-protect
(save-window-excursion
(delete-other-windows)
(set-window-buffer (selected-window) other)
(let* ((selected (selected-window))
(window (split-window-right))
(map (ebox-keymap-create
:activate (lambda ()
(setq observed
(list (current-buffer) (point)
(selected-window))))))
(command (lookup-key map [mouse-1])))
(set-window-buffer window target)
(with-current-buffer target (insert "target") (goto-char 4))
(with-current-buffer other
(insert "other") (goto-char 2)
(let ((last-input-event
(list 'mouse-1 (list window 1 '(1 . 1) 0))))
(call-interactively command))
(should (equal observed (list target 4 selected)))
(should (eq (current-buffer) other))
(should (= (point) 2))
(should (eq (selected-window) selected))
(setq observed nil)
(dolist (position '(mode-line -1 100))
(should-error
(funcall command (list 'mouse-1
(list window position '(1 . 1) 0)))
:type 'user-error))
(should-error (funcall command 'mouse-1) :type 'user-error)
(delete-window window)
(should-error (funcall command
(list 'mouse-1 (list window 1 '(1 . 1) 0)))
:type 'user-error)
(should-not observed))))
(kill-buffer target)
(kill-buffer other))))
(ert-deftest ebox-region-update-semantic-id-keeps-handle-lifecycle ()
"IDs resolve locally and opaque handles retain their owning surface checks."
(let ((first (generate-new-buffer " *ebox-id-first*"))
(second (generate-new-buffer " *ebox-id-second*")))
(unwind-protect
(progn
(dolist (buffer (list first second))
(ebox-render-to-buffer
buffer (ebox-build '(column
(box :id "target" "before")
(box :id 7 "integer")
(box :id "duplicate" "first")
(box :id "duplicate" "second")))))
(let ((handle (ebox-region-resolve first "target")))
(with-current-buffer second
(ebox-region-update 'target :content "local")
(ebox-region-update 7 :content "seven")
(should (string-match-p "local" (buffer-string)))
(should (string-match-p "seven" (buffer-string)))
(ebox-region-update handle :content "other")
(dolist (id '(nil 1.5 [] (target)))
(should-error (ebox-region-update id :content "invalid")
:type 'wrong-type-argument))
(should-error (ebox-region-update "missing" :content "invalid")
:type 'user-error)
(should-error (ebox-region-update "duplicate" :content "invalid")
:type 'user-error))
(with-current-buffer first
(should (string-match-p "other" (buffer-string))))
(ebox-commit first (ebox-build '(box :id "replacement" "new")))
(with-current-buffer second
(should-error (ebox-region-update handle :content "stale")
:type 'user-error))
(with-current-buffer first
(should (equal (buffer-substring-no-properties
(point-min) (point-max)) "new"))))
(with-temp-buffer
(should-error (ebox-region-update "target" :content "invalid")
:type 'user-error)))
(kill-buffer first)
(kill-buffer second))))
(ert-deftest ebox-interaction-closed-node-contract ()
"The four capabilities are validated independently from the style schema."
(dolist (tag '(text box row column flex grid))
(let* ((input (ebox-build
(list tag :help-echo "Hint" :pointer 'hand
:hover-style '(:color "red"
:background-color "blue"
:text-decoration-line underline)
:keymap (make-sparse-keymap) "hello")))
(text (ebox-render input))
(position (ebox-interaction-test--position text "hello")))
(should (equal (get-text-property position 'help-echo text) "Hint"))
(should (eq (get-text-property position 'pointer text) 'hand))
(should (keymapp (get-text-property position 'keymap text)))
(should (equal (get-text-property position 'mouse-face text)
'(:underline t :overline nil :strike-through nil
:foreground "red" :background "blue")))))
(dolist (props '((:help-echo 1) (:pointer invalid) (:keymap (bogus))
(:hover-style (:font-weight bold))
(:hover-style (:padding (px 2)))
(:hover-style (:face highlight))
(:hover-style (:color red))
(:hover-style (:color "red" :color "blue"))
(:pointer hand :pointer text)
(:tps (help-echo "x")) (:local-map nil)
(:surface-properties (help-echo "x"))))
(should-error (ebox-build (append '(box) props '("x"))))))
(ert-deftest ebox-interaction-scope-and-explicit-nil ()
"Inner nil blocks outer values without extending own effects to margins."
(let* ((text (ebox-render
(ebox-build
'(box :width (ch 30) :padding ((lh 1) (ch 1))
:margin ((lh 1) (ch 1))
:border-width (px 1) :border-style solid :border-color "red"
:help-echo "outer" :pointer hand
:hover-style (:background-color "blue")
:keymap (keymap (13 . ignore))
(row (text :help-echo nil :pointer nil
:hover-style nil :keymap nil "blocked")
(text :help-echo "child" "child")
"inherited")))))
(blocked (ebox-interaction-test--position text "blocked"))
(child (ebox-interaction-test--position text "child"))
(inherited (ebox-interaction-test--position text "inherited")))
(dolist (property '(help-echo pointer mouse-face keymap))
(should (plist-member (text-properties-at blocked text) property))
(should-not (get-text-property blocked property text))
(should (get-text-property inherited property text)))
(should (equal (get-text-property child 'help-echo text) "child"))
(dotimes (position (length text))
(when (or (= (aref text position) ?\n)
(cl-some (lambda (property)
(get-text-property position property text))
'(ebox-ml ebox-mr ebox-mt ebox-mb)))
(dolist (property '(help-echo pointer mouse-face keymap))
(should-not (get-text-property position property text)))))))
(defun ebox-interaction-test--hover-input ()
"Return a nested hover sample with distinct paint and every border role."
(ebox-build
'(box :id "frame" :width (ch 30) :padding ((lh 1) (ch 1))
:margin ((lh 1) (ch 1)) :color "#273B42"
:background-color "#F8EDDC"
:border-width (px 1) :border-style solid :border-color "#804020"
:hover-style (:background-color "#F6D6AB" :text-decoration-line none)
:help-echo "frame" :keymap (keymap (13 . ignore))
(row (text "base") (text :color "#123456" "child")
(text :hover-style nil "blocked")))))
(defun ebox-interaction-test--assert-hover-paint (text foreground)
"Assert one owner's hover paint in TEXT retains FOREGROUND and box chrome."
(let ((base (ebox-interaction-test--position text "base"))
(child (ebox-interaction-test--position text "child"))
(blocked (ebox-interaction-test--position text "blocked"))
(sides 0) (corners 0) (padding 0))
(should (equal (ebox-interaction--face-attribute
(get-text-property base 'mouse-face text) :foreground)
(list :foreground foreground)))
(should (equal (ebox-interaction--face-attribute
(get-text-property child 'mouse-face text) :foreground)
(list :foreground foreground)))
(should (eq (get-text-property base 'mouse-face text)
(get-text-property child 'mouse-face text)))
(should (equal (ebox-interaction--face-attribute
(get-text-property child 'face text) :foreground)
'(:foreground "#123456")))
(should-not (get-text-property blocked 'mouse-face text))
(dotimes (position (length text))
(let* ((normal (get-text-property position 'face text))
(hover (get-text-property position 'mouse-face text))
(side (or (get-text-property position 'ebox-bl text)
(get-text-property position 'ebox-br text)))
(top (get-text-property position 'ebox-bt text))
(bottom (get-text-property position 'ebox-bb text)))
(when side
(cl-incf sides)
(when (or top bottom) (cl-incf corners))
(should-not hover)
(should (equal (ebox-interaction--face-attribute normal :background)
'(:background "#804020")))
(should (keymapp (get-text-property position 'keymap text)))
(should (equal (get-text-property position 'help-echo text) "frame")))
(when hover
(dolist (entry (list (cons top :overline) (cons bottom :underline)))
(when (car entry)
(should (equal (ebox-interaction--face-attribute hover (cdr entry))
(ebox-interaction--face-attribute normal (cdr entry))))))
(when (and (not side)
(cl-some (lambda (property)
(get-text-property position property text))
'(ebox-pl ebox-pr ebox-pt ebox-pb)))
(cl-incf padding)
(should (equal (ebox-interaction--face-attribute hover :background)
'(:background "#F6D6AB")))))))
(should (> sides 0))
(should (> corners 0))
(should (> padding 0))))
(ert-deftest ebox-interaction-hover-preserves-base-paint-and-borders ()
"Shared native hover uses its owner's paint and keeps every border."
(let ((text (ebox-render
(ebox-build
(list 'box :hover-style '(:background-color "blue")
(list 'text (propertize "native" 'mouse-face 'highlight)))))))
(should (eq (get-text-property 0 'mouse-face text) 'highlight))
(should-not (get-text-property 0 'ebox--hover-style text)))
(let ((input (ebox-interaction-test--hover-input))
(buffer (generate-new-buffer " *ebox-hover-paint*")))
(unwind-protect
(progn
(ebox-interaction-test--assert-hover-paint (ebox-render input) "#273B42")
(ebox-render-to-buffer buffer input)
(with-current-buffer buffer
(ebox-interaction-test--assert-hover-paint (buffer-string) "#273B42")
(ebox-region-update "frame" :color "#445566")
(ebox-interaction-test--assert-hover-paint (buffer-string) "#445566")
(ebox-region-update "frame" :hover-style nil)
(should-not (text-property-not-all (point-min) (point-max)
'mouse-face nil))))
(kill-buffer buffer))))
(ert-deftest ebox-interaction-native-hover-preserves-base-paint-and-borders ()
"The native template projection retains the same owner hover appearance."
(skip-unless (ebox-native-reflow-layout-ready-p))
(let ((buffer (generate-new-buffer " *ebox-native-hover*")))
(unwind-protect
(progn
(ebox-render-to-buffer buffer (ebox-interaction-test--hover-input))
(let ((node (plist-get (ebox--buffer-render-state buffer) :root-node)))
(dolist (width '(100 160))
(let ((frame (ebox-native-reflow-execute-sync
node (list :key width :viewport-width width
:viewport-height 30 :root-width width
:runtime-revision 0 :context-hash 0
:complete t))))
(should (plist-get frame :native-frame))
(ebox-interaction-test--assert-hover-paint
(plist-get frame :rendered) "#273B42")))))
(kill-buffer buffer))))
(defun ebox-interaction-test--assert-hover-row (text)
"Assert TEXT contains one native hover group from left to right padding."
(let* ((position (ebox-interaction-test--position text "action"))
(line-start (1+ (or (cl-position ?\n text :end position :from-end t) -1)))
(line-end (or (cl-position ?\n text :start position) (length text)))
(mouse-face (get-text-property position 'mouse-face text))
(interior 0) (sides 0) (padding 0))
(should mouse-face)
(cl-loop for index from line-start below line-end do
(cond
((or (get-text-property index 'ebox-bl text)
(get-text-property index 'ebox-br text))
(cl-incf sides)
(should-not (get-text-property index 'mouse-face text)))
((get-text-property index 'ebox--hover-style text)
(cl-incf interior)
(when (or (get-text-property index 'ebox-pl text)
(get-text-property index 'ebox-pr text))
(cl-incf padding))
;; Emacs discovers native hover boundaries by identity, not by
;; equal face attributes. A per-run copy would split this row.
(should (eq mouse-face
(get-text-property index 'mouse-face text))))))
(should (> interior (length "action")))
(should (= sides 2))
(should (= padding 2))))
(ert-deftest ebox-interaction-hover-is-one-native-row-group ()
"Text and both paddings share identity through snapshots and retained paint."
(let* ((input (ebox-build
'(box :id "action" :width (ch 24)
:padding ((lh 1) (ch 2)) :color "#123456"
:background-color "#EEEEEE"
:border-width (px 1) :border-style solid
:border-color "#804020"
:hover-style (:background-color "#ACDCEC") "action")))
(buffer (generate-new-buffer " *ebox-one-hover-row*")))
(unwind-protect
(progn
(let ((rendered (ebox-render input)))
(ebox-interaction-test--assert-hover-row rendered)
(ebox-interaction-test--assert-hover-row (tp-text-snapshot rendered)))
(ebox-render-to-buffer buffer input)
(with-current-buffer buffer
(ebox-interaction-test--assert-hover-row (buffer-string))
(ebox-region-update "action" :color "#654321")
(ebox-interaction-test--assert-hover-row (buffer-string))
(ebox-region-update "action" :content "action again")
(ebox-interaction-test--assert-hover-row (buffer-string))
(ebox-region-update "action" :background-color "#DDDDDD")
(ebox-interaction-test--assert-hover-row (buffer-string))
(ebox-region-update "action" :hover-style '(:background-color "#CDEECC"))
(ebox-interaction-test--assert-hover-row (buffer-string)))
(when (ebox-native-reflow-layout-ready-p)
(let ((node (plist-get (ebox--buffer-render-state buffer) :root-node)))
(dolist (width '(100 160))
(let ((frame (ebox-native-reflow-execute-sync
node (list :key width :viewport-width width
:viewport-height 30 :root-width width
:runtime-revision 0 :context-hash 0
:complete t))))
(should (plist-get frame :native-frame))
(ebox-interaction-test--assert-hover-row
(plist-get frame :rendered)))))))
(kill-buffer buffer))))
(ert-deftest ebox-interaction-child-update-keeps-parent-hover-group ()
"A child-only publication cannot separate its parent's native hover identity."
(let ((buffer (generate-new-buffer " *ebox-hover-child-patch*")))
(unwind-protect
(progn
(ebox-render-to-buffer
buffer
(ebox-build
'(box :id "frame" :width (ch 24) :padding ((lh 1) (ch 2))
:color "#123456" :background-color "#EEEEEE"
:border-width (px 1) :border-style solid :border-color "#804020"
:hover-style (:background-color "#ACDCEC")
(text :id "label" "action1"))))
(with-current-buffer buffer
(dolist (label '("action2" "action3" "action longer"))
(ebox-region-update "label" :content label)
(ebox-interaction-test--assert-hover-row (buffer-string)))
(ebox-region-update "label" :help-echo "child hint")
(ebox-interaction-test--assert-hover-row (buffer-string))
(ebox-region-update "label" :keymap '(keymap (13 . ignore)))
(ebox-interaction-test--assert-hover-row (buffer-string))))
(kill-buffer buffer))))
(ert-deftest ebox-interaction-equal-explicit-child-hover-is-another-group ()
"An explicit child hover owns a different group even with equal paint."
(let* ((text (ebox-render
(ebox-build
'(row :color "#123456" :hover-style (:background-color "#ACDCEC")
(text "outer")
(text :hover-style (:background-color "#ACDCEC") "inner")
(text :hover-style nil "blocked")))))
(outer (get-text-property
(ebox-interaction-test--position text "outer") 'mouse-face text))
(inner (get-text-property
(ebox-interaction-test--position text "inner") 'mouse-face text)))
(should (equal outer inner))
(should-not (eq outer inner))
(should-not (get-text-property
(ebox-interaction-test--position text "blocked") 'mouse-face text))))
(defun ebox-interaction-test--cached-hover-sample (owner)
"Return a projected hover string owned by OWNER for cache lifecycle tests."
(let ((text (propertize
(copy-sequence "hover") 'ebox--hover-style
(list :owner owner :face (list :background "#ACDCEC")
:base (list :foreground "#123456")))))
(ebox-interaction--refresh-hover! text 0 (length text))))
(defun ebox-interaction-test--populate-discarded-hover-faces ()
"Populate transient hover faces without retaining the resulting strings."
(dotimes (owner 16)
(ebox-interaction-test--cached-hover-sample owner)))
(defun ebox-interaction-test--live-hover-cache-cycle ()
"Return cache counts and identity proof from a temporary live buffer.
The helper keeps face objects out of the caller's ERT assertion temporaries."
(with-temp-buffer
(insert (ebox-interaction-test--cached-hover-sample 100))
(ebox-interaction-test--populate-discarded-hover-faces)
(let ((before (hash-table-count ebox-interaction--hover-face-cache)))
(garbage-collect)
(list before (hash-table-count ebox-interaction--hover-face-cache)
(eq (get-text-property (point-min) 'mouse-face)
(get-text-property
0 'mouse-face
(ebox-interaction-test--cached-hover-sample 100)))))))
(ert-deftest ebox-interaction-hover-cache-releases-unreferenced-faces ()
"GC releases retired faces while a live buffer keeps its shared hover identity."
(let ((ebox-interaction--hover-face-cache
(make-hash-table :test #'equal :weakness 'value)))
(should (equal (ebox-interaction-test--live-hover-cache-cycle) '(17 1 t)))
(garbage-collect)
(should (zerop (hash-table-count ebox-interaction--hover-face-cache)))))
(ert-deftest ebox-interaction-partial-hover-decoration-composes-with-base ()
"Color and style overrides retain the other decoration components per run."
(let* ((input
(ebox-build
'(box (text :id "decoration" :color "#123456"
:text-decoration-line (underline overline)
:text-decoration-color "red" :text-decoration-style dashed
"sample"))))
(buffer (generate-new-buffer " *ebox-hover-decoration*")))
(unwind-protect
(progn
(ebox-render-to-buffer buffer input)
(dolist (case '(((:text-decoration-color "blue")
(:color "blue" :style dashes) "blue")
((:text-decoration-style wavy)
(:color "red" :style wave) "red")
((:text-decoration-color currentColor)
(:style dashes) t)
((:text-decoration-line underline)
(:color "red" :style dashes) nil)
((:text-decoration-line none) nil nil)))
(with-current-buffer buffer
(ebox-region-update "decoration" :hover-style (car case)))
(let* ((node (plist-get (ebox--buffer-render-state buffer) :root-node))
(strings (list (with-current-buffer buffer (buffer-string)))))
(when (ebox-native-reflow-layout-ready-p)
(push (plist-get
(ebox-native-reflow-execute-sync
node '(:key 1 :viewport-width 80 :viewport-height 20
:root-width 80 :runtime-revision 0
:context-hash 0 :complete t)) :rendered)
strings))
(dolist (text strings)
(let ((hover (get-text-property 0 'mouse-face text)))
(should (equal (ebox-interaction--face-attribute hover :underline)
(list :underline (nth 1 case))))
(should (equal (ebox-interaction--face-attribute hover :overline)
(list :overline (nth 2 case))))
(should (equal (ebox-interaction--face-attribute hover :foreground)
'(:foreground "#123456")))))))
(with-current-buffer buffer
(ebox-region-update "decoration" :hover-style nil)
(should-not (get-text-property (point-min) 'mouse-face))))
(kill-buffer buffer))))
(ert-deftest ebox-interaction-literal-callback-and-cyclic-keymap ()
"Callbacks remain literal and a recursive keymap can be safely snapshotted."
(let* ((calls 0)
(hint (lambda (_window _object _position)
(cl-incf calls) "dynamic"))
(map (make-sparse-keymap)))
(define-key map [prefix] map)
(define-key map (kbd "RET") #'ignore)
(let* ((input (ebox-build (list 'box :help-echo hint :keymap map "x")))
(text (ebox-render input))
(actual (get-text-property 0 'keymap text)))
(should (= calls 0))
(should (eq (get-text-property 0 'help-echo text) hint))
(should (eq (lookup-key actual [prefix]) actual))
(define-key map (kbd "RET") #'forward-char)
(should (eq (lookup-key actual (kbd "RET")) #'ignore))
(should (equal (funcall (get-text-property 0 'help-echo text) nil text 0)
"dynamic"))
(should (= calls 1)))))
(ert-deftest ebox-interaction-public-update-replace-clear-and-rollback ()
"Mounted interaction changes publish atomically and replace or clear maps."
(let* ((ebox-native-reflow-module-path nil)
(buffer (ebox-render-to-buffer
(generate-new-buffer-name " *ebox-interaction-test*")
(ebox-build '(box :id "target" :width (ch 20)
:help-echo "before" :pointer hand
:keymap (keymap (13 . ignore)) "value")))))
(unwind-protect
(with-current-buffer buffer
(let ((handle (ebox-region-resolve buffer "target"))
(map (make-sparse-keymap)))
(define-key map (kbd "RET") #'forward-char)
(ebox-region-update handle :keymap map :help-echo "after"
:pointer 'text
:hover-style '(:color "red"))
(goto-char (point-min))
(search-forward "value")
(let ((position (- (point) 5)))
(should (equal (get-text-property position 'help-echo) "after"))
(should (eq (lookup-key (get-text-property position 'keymap)
(kbd "RET")) #'forward-char))
(should (eq (get-text-property position 'pointer) 'text))
(should (equal (get-text-property position 'mouse-face)
'(:foreground "red")))
(let ((before (buffer-substring (point-min) (point-max))))
(should-error (ebox-region-update handle :help-echo "bad"
:pointer 'invalid))
(should (equal-including-properties
before (buffer-substring (point-min) (point-max)))))
(ebox-region-update handle :help-echo nil :pointer nil
:hover-style nil :keymap nil)
(dolist (property '(help-echo pointer mouse-face keymap))
(should-not (get-text-property position property))))))
(when (buffer-live-p buffer) (kill-buffer buffer)))))
(ert-deftest ebox-interaction-native-template-preserves-inner-nil ()
"Native template expansion shares Elisp presence and newline rules."
(let* ((text (copy-sequence "child\nouter"))
(inner '(:kind surface :properties
(help-echo nil pointer nil mouse-face nil keymap nil)))
(outer '(:kind surface :properties
(help-echo "outer" pointer hand
mouse-face (:foreground "red") keymap (keymap)))))
(ebox-native-reflow--apply-property-template-span text 0 5 inner)
(ebox-native-reflow--apply-property-template-span text 0 (length text) outer)
(dolist (property '(help-echo pointer mouse-face keymap))
(should-not (get-text-property 0 property text))
(should-not (get-text-property 5 property text))
(should (get-text-property 6 property text)))))
(ert-deftest ebox-interaction-hidden-and-zero-height-have-no-actions ()
"Hidden and empty surfaces cannot expose commands or ancestor hover effects."
(dolist (tag '(box row column flex grid))
(let ((text (ebox-render
(ebox-build
(list 'box :help-echo "parent" :pointer 'hand
:keymap '(keymap (13 . ignore))
:hover-style '(:color "red")
(list tag :visibility 'hidden :keymap '(keymap)
:help-echo "hidden" "hidden"))))))
(dotimes (position (length text))
(dolist (property '(help-echo pointer mouse-face keymap))
(should-not (get-text-property position property text))))))
(should (equal (ebox-render
(ebox-build '(box :height (lh 0) :keymap (keymap)
:help-echo "empty" "content"))) "")))
(ert-deftest ebox-interaction-native-execution-and-resize ()
"Actual native frames preserve native input scopes at different widths."
(skip-unless (ebox-native-reflow-layout-ready-p))
(let* ((input (ebox-build
'(box :width (px 60) :padding-inline (px 2)
:margin-inline (px 3) :help-echo "outer"
:pointer hand :hover-style (:color "red")
:keymap (keymap (13 . ignore))
(text :help-echo nil :pointer nil :keymap nil
:hover-style nil "child"))))
(node (ebox-canonical-input--single-root input "Native interactions")))
(dolist (width '(80 120))
(let* ((frame (ebox-native-reflow-execute-sync
node (list :key width :viewport-width width
:viewport-height 20 :root-width width
:runtime-revision 0 :context-hash 0 :complete t)))
(text (plist-get frame :rendered))
(position (ebox-interaction-test--position text "child")))
(should (plist-get frame :native-frame))
(dolist (property '(help-echo pointer mouse-face keymap))
(should-not (get-text-property position property text)))
(should (cl-loop for index below (length text)
thereis (equal (get-text-property index 'help-echo text)
"outer")))
(dotimes (index (length text))
(when (or (get-text-property index 'ebox-ml text)
(get-text-property index 'ebox-mr text)
(= (aref text index) ?\n))
(should-not (get-text-property index 'help-echo text))))))))
(ert-deftest ebox-interaction-keymap-boundaries-use-current-node ()
"First characters, nil overrides, margins and the end use the correct map."
(with-temp-buffer
(let ((map (make-sparse-keymap)))
(define-key map (kbd "RET") #'backward-char)
(use-local-map map))
(insert (ebox-render
(ebox-build
'(row :keymap (keymap (13 . ignore))
(text "parent")
(text :keymap nil "blocked")
(box :keymap (keymap (13 . forward-char))
:margin-inline (px 2) "child")
(text "tail")))))
(dolist (entry '(("parent" . ignore) ("blocked" . backward-char)
("child" . forward-char) ("tail" . ignore)))
(goto-char (point-min))
(search-forward (car entry))
(goto-char (- (point) (length (car entry))))
(should (eq (key-binding (kbd "RET")) (cdr entry))))
(goto-char (point-max))
(should (eq (key-binding (kbd "RET")) #'backward-char))))
(ert-deftest ebox-interaction-source-typed-and-retained-updates ()
"Typed input ownership, declarative removal and viewport updates stay coherent."
(let* ((builder (ebox-source-builder-create))
(interactions '(:help-echo "typed" :pointer hand :keymap (keymap)))
(handle (ebox-source-builder-bind builder :id "text"
:interactions interactions))
(node (ebox-text-create
:source-handle handle :value "typed"
:owned-facts (ebox-canonical-facts-from-declarations
'text nil interactions)))
(input (ebox-canonical-input-create
(list node) (ebox-source-builder-finish builder)))
(buffer (generate-new-buffer " *ebox-interaction-retained*")))
(unwind-protect
(progn
(ebox-render-to-buffer buffer input)
(ebox-region-update (ebox-region-resolve buffer "text")
:help-echo "new" :pointer 'text
:keymap '(keymap (13 . forward-char))
:hover-style '(:color "red"))
(ebox-region-update (ebox-region-resolve buffer "text") :color "blue")
(ebox-rerender-buffer-with-context buffer 120 30)
(with-current-buffer buffer
(should (equal (get-text-property (point-min) 'help-echo) "new"))
(should (eq (get-text-property (point-min) 'pointer) 'text))
(should (equal (ebox-interaction--face-attribute
(get-text-property (point-min) 'mouse-face) :foreground)
'(:foreground "red")))
(should (eq (lookup-key (get-text-property (point-min) 'keymap)
(kbd "RET")) #'forward-char)))
(ebox-commit buffer (ebox-build '(text :id "text" "typed")))
(with-current-buffer buffer
(dolist (property '(help-echo pointer keymap mouse-face))
(should-not (get-text-property (point-min) property)))))
(when (buffer-live-p buffer) (kill-buffer buffer)))))
(ert-deftest ebox-interaction-parent-update-preserves-child-and-rolls-back ()
"Ancestor changes keep local nil and a failed commit restores current maps."
(let ((buffer (generate-new-buffer " *ebox-interaction-parent*")))
(unwind-protect
(progn
(ebox-render-to-buffer
buffer (ebox-build '(row :id "parent" :keymap (keymap (13 . ignore))
:help-echo "old"
(text :keymap nil :help-echo nil "blocked")
(text :keymap (keymap (13 . forward-char))
:help-echo "child" "child")
"parent")))
(ebox-region-update (ebox-region-resolve buffer "parent")
:keymap '(keymap (13 . backward-char))
:help-echo "new")
(with-current-buffer buffer
(let* ((text (buffer-string))
(blocked (1+ (ebox-interaction-test--position text "blocked")))
(child (1+ (ebox-interaction-test--position text "child")))
(parent (1+ (ebox-interaction-test--position text "parent"))))
(should-not (get-text-property blocked 'keymap))
(should-not (get-text-property blocked 'help-echo))
(should (eq (lookup-key (get-text-property child 'keymap)
(kbd "RET")) #'forward-char))
(should (equal (get-text-property child 'help-echo) "child"))
(should (eq (lookup-key (get-text-property parent 'keymap)
(kbd "RET")) #'backward-char))
(should (equal (get-text-property parent 'help-echo) "new"))
(let ((revision (ebox-surface-buffer-revision buffer)))
(should-error
(ebox-commit buffer (ebox-build '(row :id "parent"
:keymap (keymap) "changed"))
(lambda (_report) (error "Reject interaction"))))
(should (= revision (ebox-surface-buffer-revision buffer)))
(should (equal-including-properties text (buffer-string)))))))
(when (buffer-live-p buffer) (kill-buffer buffer)))))
(ert-deftest ebox-interaction-scroll-replaces-the-visible-actions ()
"Scrolling clipped content publishes only the currently visible binding."
(let ((buffer (generate-new-buffer " *ebox-interaction-scroll*")))
(unwind-protect
(progn
(ebox-render-to-buffer
buffer (ebox-build '(box :id "scroll" :height (lh 1) :width (ch 20)
:overflow scroll
(column
(text :help-echo "first"
:keymap (keymap (13 . forward-char))
"first")
(text :help-echo "second"
:keymap (keymap (13 . backward-char))
"second")))))
(ebox-region-update (ebox-region-resolve buffer "scroll") :scroll-offset 1)
(with-current-buffer buffer
(should-not (string-match-p "first" (buffer-string)))
(goto-char (point-min))
(search-forward "second")
(goto-char (- (point) 6))
(should (equal (get-text-property (point) 'help-echo) "second"))
(should (eq (key-binding (kbd "RET")) #'backward-char))))
(when (buffer-live-p buffer) (kill-buffer buffer)))))
(provide 'ebox-interaction-tests)
;;; ebox-interaction-tests.el ends here

View File

@ -16,6 +16,9 @@
'((:storage (:global ebox--defer-scroll-content-index) '((:storage (:global ebox--defer-scroll-content-index)
:why dynamic-scalar-not-a-store :owner ebox-layout :why dynamic-scalar-not-a-store :owner ebox-layout
:evidence defvar-is-a-dynamic-boolean-or-index-value) :evidence defvar-is-a-dynamic-boolean-or-index-value)
(:storage (:global ebox--box-content-empty-width)
:why dynamic-scalar-not-a-store :owner ebox-layout
:evidence value-is-a-numeric-inline-extent-or-nil)
(:storage (:global ebox--string-pixel-width-cache-ring-index) (:storage (:global ebox--string-pixel-width-cache-ring-index)
:why eviction-cursor-not-a-store :owner ebox-measure :why eviction-cursor-not-a-store :owner ebox-measure
:evidence defvar-holds-a-numeric-ring-cursor) :evidence defvar-holds-a-numeric-ring-cursor)
@ -102,7 +105,7 @@
((string-match-p "frame-cache\\|key-table" name) 'ebox-native-reflow) ((string-match-p "frame-cache\\|key-table" name) 'ebox-native-reflow)
((string-match-p "region-line\\|owner-table" name) 'ebox-incremental) ((string-match-p "region-line\\|owner-table" name) 'ebox-incremental)
((string-match-p "render-output-provenance" name) 'ebox-render-context) ((string-match-p "render-output-provenance" name) 'ebox-render-context)
((string-match-p "uniform-width\\|scroll-table" name) 'ebox-layout) ((string-match-p "uniform-width\\|intrinsic-size\\|scroll-table" name) 'ebox-layout)
((string-match-p "deferred-render-gc" name) 'ebox-buffer-backend) ((string-match-p "deferred-render-gc" name) 'ebox-buffer-backend)
((string-match-p "window-line\\|box-content\\|region-box\\|scroll-window" name) ((string-match-p "window-line\\|box-content\\|region-box\\|scroll-window" name)
'ebox-layout) 'ebox-layout)
@ -135,10 +138,12 @@
ebox--render-output-provenance-table ebox--render-recached-source-node-cache ebox--render-output-provenance-table ebox--render-recached-source-node-cache
ebox--render-string-max-pixel-width-cache ebox--render-string-max-pixel-width-cache
ebox--render-string-pixel-width-cache ebox--rendered-uniform-width-table ebox--render-string-pixel-width-cache ebox--rendered-uniform-width-table
ebox--rendered-intrinsic-size-table
ebox--runtime-ancestor-cache ebox--space-pixel-cache ebox--runtime-ancestor-cache ebox--space-pixel-cache
ebox--string-pixel-width-cache ebox--viewport-dependent-node-ids-cache ebox--string-pixel-width-cache ebox--viewport-dependent-node-ids-cache
ebox--viewport-dependent-subtree-cache ebox--viewport-dependent-subtree-cache
ebox--viewport-height-dependent-subtree-cache ebox--viewport-height-dependent-subtree-cache
ebox--css-viewport-dependent-subtree-cache
ebox--window-line-prewarmer-table ebox--window-line-renderer-table ebox--window-line-prewarmer-table ebox--window-line-renderer-table
ebox-cache--buffer-report-table ebox-cache--registry ebox-cache--buffer-report-table ebox-cache--registry
ebox-canonical--declaration-fact-cache ebox-canonical--declaration-fact-cache
@ -513,6 +518,12 @@
:proposed-category disposable-cache :owner ebox-font :authority ebox :proposed-category disposable-cache :owner ebox-font :authority ebox
:lifetime process :rollback not-authoritative :rebuild lazy-measure :lifetime process :rollback not-authoritative :rebuild lazy-measure
:cleanup ebox-font-clear-cache) :cleanup ebox-font-clear-cache)
(:id process/native-hover-face-cache
:storage (:global ebox-interaction--hover-face-cache)
:proposed-category disposable-cache :owner ebox-interaction :authority ebox
:lifetime live-face-references :rollback discard-unreferenced-candidates
:rebuild reproject-owner-native-hover-face
:cleanup garbage-collect-unreferenced-faces)
(:id process/native-source-cluster-cache (:id process/native-source-cluster-cache
:storage (:global ebox-native-reflow--source-cluster-cache) :storage (:global ebox-native-reflow--source-cluster-cache)
:proposed-category disposable-cache :owner ebox-native-reflow :authority ebox :proposed-category disposable-cache :owner ebox-native-reflow :authority ebox

View File

@ -13,19 +13,21 @@
(defun ebox-node-memo-test--input (map) (defun ebox-node-memo-test--input (map)
"Build an ordinary responsive Flex/Grid tree with nested scroll and MAP." "Build an ordinary responsive Flex/Grid tree with nested scroll and MAP."
(ebox-build (ebox-build
`(column :width (viewport) `(column :width (vw 100)
(text "Header") (text "Header")
(column :id "body" :width stretch :height (viewport-height) (column :id "body" :width stretch :height (vh 100)
:overflow scroll :padding (0 (2)) :overflow scroll :padding ((lh 0) (px 2))
,@(cl-loop ,@(cl-loop
for index below 6 for index below 6
collect collect
`(flex :width stretch :align-items stretch `(flex :width stretch :align-items stretch
(box :flex-grow 1 :flex-basis (40) :wrap-mode char ;; Visible overflow exercises content-based automatic minima,
;; populating the intrinsic-width memo this fixture audits.
(box :flex-grow 1 :flex-basis (px 40) :wrap-mode char :overflow visible
(text ,(propertize (text ,(propertize
(format "Open %02d %s" index (make-string 48 ?x)) (format "Open %02d %s" index (make-string 48 ?x))
'keymap map 'mouse-face 'highlight 'help-echo "Open row"))) 'keymap map 'mouse-face 'highlight 'help-echo "Open row")))
(grid :width (40) :grid-template-columns ((fr 1)) (grid :width (px 40) :grid-template-columns ((fr 1))
(text "Tag"))))) (text "Tag")))))
(text "Footer")))) (text "Footer"))))
@ -169,13 +171,13 @@
(ebox-runtime-idle-reflow-cache-prewarm nil) (ebox-runtime-idle-reflow-cache-prewarm nil)
(input (input
(ebox-build (ebox-build
`(column :width (viewport) `(column :width (vw 100)
(box :id "fixed" :width (80) (box (text "Cached child"))) (box :id "fixed" :width (px 80) (box (text "Cached child")))
(flex :width stretch (flex :width stretch
(box :flex-grow 1 :flex-basis (40) (text "Allocated"))) (box :flex-grow 1 :flex-basis (px 40) (text "Allocated")))
(column :width stretch :height 2 :overflow scroll (column :width stretch :height (lh 2) :overflow scroll
,@(cl-loop for index below 64 collect ,@(cl-loop for index below 64 collect
`(box :height 1 (text ,(format "Row %02d" index)))))))) `(box :height (lh 1) (text ,(format "Row %02d" index))))))))
(probe (symbol-function 'ebox--render-cache-probe)) (probe (symbol-function 'ebox--render-cache-probe))
fixed-node-id cache-hits) fixed-node-id cache-hits)
(unwind-protect (unwind-protect
@ -302,6 +304,9 @@
(lambda (window) (lambda (window)
(if (eq change 'display) '(ebox-node-memo-display changed) (if (eq change 'display) '(ebox-node-memo-display changed)
(funcall display window)))) (funcall display window))))
((symbol-function 'ebox-incremental--layout-owner-plan)
(lambda (&rest _)
(ert-fail "Changed context must not probe old local geometry")))
((symbol-function 'ebox-surface--render-candidate) ((symbol-function 'ebox-surface--render-candidate)
(lambda (state) (lambda (state)
(push t seen) (push t seen)

View File

@ -156,9 +156,9 @@
(should (commandp 'ebox-byte-compile)) (should (commandp 'ebox-byte-compile))
(should (should
(equal ebox--compile-sources (equal ebox--compile-sources
'("ebox-cache.el" "ebox-source.el" "ebox-runtime-index.el" '("ebox-cache.el" "ebox-interaction.el" "ebox-source.el" "ebox-runtime-index.el"
"ebox-state-contract.el" "ebox-state-contract.el"
"ebox-font.el" "ebox-style.el" "ebox-font.el" "ebox-size.el" "ebox-style.el"
"ebox-layout-config.el" "ebox-layout-config.el"
"ebox-node-factory.el" "ebox-node-factory.el"
"ebox-child-range.el" "ebox-child-range.el"

View File

@ -21,22 +21,22 @@
(defun ebox-selector-test--width-unit-form (width) (defun ebox-selector-test--width-unit-form (width)
"Return a layout whose root WIDTH constrains nested auto layout." "Return a layout whose root WIDTH constrains nested auto layout."
`(column :id root :width ,width `(column :id root :width ,width
(box :padding (0 (12)) :border "#111111" (box :padding ((lh 0) (px 12)) :border "#111111"
"Header text wraps when the root is narrow") "Header text wraps when the root is narrow")
(flex :flex-flow (row wrap) :column-gap (12) (flex :flex-flow (row wrap) :column-gap (px 12)
:padding (0 (12)) :border "#D97757" :padding ((lh 0) (px 12)) :border "#D97757"
(column (column
(box :width (220) :border "#D97757" "row") (box :width (px 220) :border "#D97757" "row")
(flex :width (220) :height 4 :border "#D97757" (flex :width (px 220) :height (lh 4) :border "#D97757"
(box :width (42) "A") (box :width (px 42) "A")
(box :width (42) "B") (box :width (px 42) "B")
(box :width (42) "C"))) (box :width (px 42) "C")))
(column (column
(box :width (220) :border "#D97757" "row-reverse") (box :width (px 220) :border "#D97757" "row-reverse")
(flex :width (220) :height 4 :border "#D97757" (flex :width (px 220) :height (lh 4) :border "#D97757"
(box :width (42) "A") (box :width (px 42) "A")
(box :width (42) "B") (box :width (px 42) "B")
(box :width (42) "C")))))) (box :width (px 42) "C"))))))
(defun ebox-selector-test--rendered-widths (rendered) (defun ebox-selector-test--rendered-widths (rendered)
"Return pixel widths for RENDERED lines." "Return pixel widths for RENDERED lines."
@ -351,7 +351,7 @@
(generate-new-buffer-name " *ebox-selector-test*") (generate-new-buffer-name " *ebox-selector-test*")
node)) node))
(let ((result (ebox-selector-update-buffer (let ((result (ebox-selector-update-buffer
buffer "#card" :padding '(1 2) :bgcolor "#fef3c7"))) buffer "#card" :padding '((lh 1) (ch 2)) :bgcolor "#fef3c7")))
(should (= (plist-get result :matched) 1)) (should (= (plist-get result :matched) 1))
(should (= (plist-get result :updated) 1))) (should (= (plist-get result :updated) 1)))
(let* ((match (car (ebox-selector-query-buffer buffer "#card"))) (let* ((match (car (ebox-selector-query-buffer buffer "#card")))
@ -363,18 +363,21 @@
(ert-deftest ebox-selector-width-unit-change-invalidates-render-cache () (ert-deftest ebox-selector-width-unit-change-invalidates-render-cache ()
"Changing width units should not reuse stale containing-block output." "Changing width units should not reuse stale containing-block output."
(ebox-selector-test--reset-runtime-state) (ebox-selector-test--reset-runtime-state)
(let (buffer live-widths direct-widths) (let (buffer char-widths live-widths direct-widths)
(cl-letf (((symbol-function 'ebox--space-pixel-width) (cl-letf (((symbol-function 'ebox--size-character-width)
(lambda () 7))) (lambda (&optional _box) 7)))
(unwind-protect (unwind-protect
(let ((ebox-viewport-width 1200)) (let ((ebox-viewport-width 1200))
(setq buffer (setq buffer
(ebox-render-to-buffer (ebox-render-to-buffer
(generate-new-buffer-name " *ebox-selector-test*") (generate-new-buffer-name " *ebox-selector-test*")
(ebox-build (ebox-build
(ebox-selector-test--width-unit-form '(550))))) (ebox-selector-test--width-unit-form '(px 550)))))
(ebox-selector-update-buffer buffer "#root" :width 300) (ebox-selector-update-buffer buffer "#root" :width '(ch 300))
(ebox-selector-update-buffer buffer "#root" :width '(300)) (setq char-widths
(with-current-buffer buffer
(ebox-selector-test--rendered-widths (buffer-string))))
(ebox-selector-update-buffer buffer "#root" :width '(px 300))
(with-current-buffer buffer (with-current-buffer buffer
(setq live-widths (setq live-widths
(ebox-selector-test--rendered-widths (ebox-selector-test--rendered-widths
@ -383,7 +386,8 @@
(ebox-selector-test--rendered-widths (ebox-selector-test--rendered-widths
(ebox-render (ebox-render
(ebox-build (ebox-build
(ebox-selector-test--width-unit-form '(300)))))) (ebox-selector-test--width-unit-form '(px 300))))))
(should-not (equal char-widths live-widths))
(should (equal live-widths direct-widths))) (should (equal live-widths direct-widths)))
(when (and buffer (buffer-live-p buffer)) (when (and buffer (buffer-live-p buffer))
(kill-buffer buffer)))))) (kill-buffer buffer))))))

View File

@ -0,0 +1,303 @@
;;; ebox-size-native-tests.el --- Native CSS size capability tests -*- lexical-binding: t; -*-
;;; Code:
(require 'ert)
(require 'ebox)
(require 'ebox-native-reflow)
(ert-deftest ebox-size-native-rejects-pre-zero-height-module-abi ()
"An installed module with old empty-line semantics cannot be activated."
(let ((module (make-temp-file "ebox-old-native-abi-"))
(ebox-native-reflow--load-attempted-p nil)
(ebox-native-reflow--available-p nil)
(ebox-native-reflow--load-error nil)
(ebox-native-reflow--loaded-module-path nil)
(ebox-native-reflow--loaded-module-hash nil))
(unwind-protect
(cl-letf (((symbol-function 'ebox-native-reflow--candidate-paths)
(lambda () (list module)))
((symbol-function 'module-load) #'ignore)
((symbol-function 'ebox-native--module-version)
(lambda () "12:7:12")))
(should-not (ebox-native-reflow-load))
(should (string-match-p "ABI mismatch" ebox-native-reflow--load-error))
(should-not ebox-native-reflow--loaded-module-path))
(delete-file module))))
(ert-deftest ebox-size-native-fixed-units-use-owning-font ()
"Fixed units and math compile using the source node's measured font."
(let ((node (list :font-owner t)))
(cl-letf (((symbol-function 'ebox--size-character-width)
(lambda (&optional box) (should (eq node box)) 8))
((symbol-function 'ebox--size-line-height)
(lambda (&optional box) (should (eq node box)) 20)))
(dolist (case '(((px 80) horizontal 80)
((ch 10) horizontal 80)
((lh 3) vertical 3)
((calc (- (ch 12) (px 16))) horizontal 80)
((min (ch 10) (px 100)) horizontal 80)
((max (ch 10) (px 40)) horizontal 80)
((clamp (px 40) (ch 10) (px 100)) horizontal 80)))
(should
(equal (ebox-native-reflow--compile-size
(nth 0 case) (nth 1 case) "auto" node)
(list :kind (if (eq (nth 1 case) 'horizontal) "pixels" "lines")
:value (nth 2 case))))))))
(ert-deftest ebox-size-native-declines-responsive-expressions ()
"Viewport and container lengths cannot become retained constants."
(let ((ebox-viewport-width 800)
(ebox-viewport-height 40))
(dolist (value '((% 50) (vw 100) (vh 100)
(calc (- (vh 100) (lh 1)))
(min (ch 80) (% 100))))
(dolist (axis '(horizontal vertical))
(should-error (ebox-native-reflow--compile-size value axis "auto")))
(should-error (ebox-native-reflow--compile-gap value 'horizontal)))))
(ert-deftest ebox-size-native-declines-unrepresented-precision ()
"The integral native ABI does not silently round fractional lengths."
(cl-letf (((symbol-function 'ebox--size-line-height) (lambda (&optional _) 20)))
(should-error (ebox-native-reflow--compile-size '(px 1.5) 'horizontal "auto"))
(should-error (ebox-native-reflow--compile-size '(lh 1.25) 'vertical "auto"))
(should-error (ebox-native-reflow--compile-gap '(lh 1.5) 'vertical)))
(should (equal (ebox-native-reflow--compile-size 3 'vertical "auto")
'(:kind "lines" :value 3)))
(should (equal (ebox-native-reflow--compile-size 40 'horizontal "auto")
'(:kind "pixels" :value 40)))
;; Emacs display spaces retain their private singleton pixel representation.
(should (= (ebox-native-reflow--display-space-width
'(display (space :width (7)))) 7))
(cl-letf (((symbol-function 'ebox--space-pixel-width) (lambda () 7)))
(should (= (ebox-native-reflow--display-space-width
'(display (space :width 2))) 14)))
(should-error (ebox-native-reflow--display-space-width
'(display (space :width (1.5))))))
(ert-deftest ebox-size-native-relative-edges-decline-before-resolution ()
"A rendered relative edge must not be frozen into one native frame."
(let ((reads 0))
(cl-letf (((symbol-function 'ebox-get)
(lambda (&rest _) (cl-incf reads) 50)))
(dolist (property '(:padding-left-pixel :padding-top-height
:margin-right-pixel :border-left-pixel))
(should-error
(ebox-native-reflow--compile-edge (list property '(vw 10)) property)))
(should (zerop reads)))))
(ert-deftest ebox-size-native-canonical-gaps-and-retained-edges ()
"Canonical gaps share exact native values and retained edge classification."
(let* ((input (ebox-build
'(row :width (px 80) :item-gap (ch 2)
(box "a") (box "b"))))
(node (ebox-canonical-input--single-root input "Native size test")))
(cl-letf (((symbol-function 'ebox--size-character-width)
(lambda (&optional box) (if (eq box node) 7 3))))
(let* ((package (ebox-native-reflow--compile-layout-package node))
(root (plist-get (plist-get package :document) :root))
(inner (plist-get root :child))
(slots (ebox-native-reflow--compile-delta-slots node root)))
(should (= (plist-get inner :column-gap) 14))
(should (= (plist-get (aref slots 1) :column-gap) 14))
(should (eq (plist-get (ebox-native-reflow--retained-edge-rule node)
:kind) 'typed-axis-item)))))
(let* ((input (ebox-build '(row (box "a"))))
(node (ebox-canonical-input--single-root input "Native zero gap test")))
(should (eq (plist-get (ebox-native-reflow--retained-edge-rule node) :kind)
'direct-axis))))
(ert-deftest ebox-size-native-capability-boundaries-use-elisp ()
"Relative geometry and unsupported height keywords decline native layout."
(dolist (form '((box :width (vw 100) "a")
(box :padding-block (vh 1) "a")
(row :item-gap (% 1) (box "a"))
(flex :column-gap (vw 1) (box "a"))
(box :height stretch "a")
(grid :grid-template-columns ((ch 10)) (box "a"))))
(let* ((input (ebox-build form))
(node (ebox-canonical-input--single-root input "Native capability test")))
(should-not (ebox-native-reflow--native-node-supported-p node))
(should-error (ebox-native-reflow--compile-layout-package node))))
(dolist (keyword '(min-content max-content fit-content stretch))
(should-error (ebox-native-reflow--compile-size keyword 'vertical "auto")))
(let* ((input (ebox-build '(flex (box :min-width (px 0) "abcdef"))))
(node (ebox-canonical-input--single-root input "Native minimum test")))
(should-error (ebox-native-reflow--compile-layout-package node))))
(ert-deftest ebox-size-native-fixed-box-compiles-and-renders ()
"Fixed canonical sizes produce valid IR and native output when available."
(let* ((ebox-viewport-width 80)
(ebox-viewport-height 20)
(input (ebox-build '(box :width (px 20) :height (lh 3) "fixed")))
(node (ebox-canonical-input--single-root input "Native fixed test"))
(normal (let ((ebox--surface-materialization-active t))
(ebox--render-layout node)))
(package (ebox-native-reflow--compile-layout-package node))
(root (plist-get (plist-get package :document) :root)))
(should (equal (plist-get root :width) '(:kind "pixels" :value 20)))
(should (equal (plist-get root :height) '(:kind "lines" :value 3)))
(should (ebox-native-reflow--native-node-supported-p node))
(when (ebox-native-reflow-layout-ready-p)
(let ((frame (ebox-native-reflow-execute-sync
node '(:key 1 :viewport-width 80 :viewport-height 20
:root-width 20 :runtime-revision 0
:context-hash 0 :complete t)
package)))
(should (plist-get frame :native-frame))
(should (equal-including-properties normal (plist-get frame :rendered)))))))
(ert-deftest ebox-size-native-empty-content-has-no-phantom-line ()
"Empty source compiles to no lines; explicit height stays independent."
(should (equal (ebox-native-reflow--compile-text "") '(:lines [])))
(dolist (height '(auto (lh 0) (lh 2)))
(let* ((input (ebox-build `(box :width (px 20) :height ,height)))
(node (ebox-canonical-input--single-root input "Empty native size"))
(package (ebox-native-reflow--compile-layout-package node))
(root (plist-get (plist-get package :document) :root)))
(should (equal (plist-get (plist-get root :content) :lines) []))
(when (ebox-native-reflow-layout-ready-p)
(let* ((frame (ebox-native-reflow-execute-sync
node '(:key 1 :viewport-width 80 :viewport-height 20
:root-width 20 :runtime-revision 0
:context-hash 0 :complete t)
package))
(output (plist-get frame :rendered)))
(should (= (ebox-string-height output)
(if (equal height '(lh 2)) 2 0))))))))
(ert-deftest ebox-size-native-zero-width-lines-retain-height-and-properties ()
"Native zero-width lines retain their positive height and paint carrier."
(skip-unless (ebox-native-reflow-layout-ready-p))
(dolist (form '((box :width (px 0) :height (lh 1))
(box :width (px 0) :height (lh 2))
(box :width (px 0) :height (lh 1) :bgcolor "#123456")
(box :width (px 0) :padding-top (lh 1) :bgcolor "#123456")
(box :width (px 0) :border-top ((px 1) solid "red"))
(box :width (px 0) :margin-top (lh 1))))
(ert-info ((format "zero-width native form: %S" form))
(let* ((input (ebox-build form))
(node (ebox-canonical-input--single-root input "Zero-width native"))
(normal (ebox-render input))
(frame (ebox-native-reflow-execute-sync
node '(:key 1 :viewport-width 80 :viewport-height 20
:runtime-revision 0 :context-hash 0 :complete t)))
(actual (plist-get frame :rendered)))
;; Omitting :root-width is essential: it must not override the zero
;; declaration and turn this into a positive-width fixture.
(should (plist-get frame :native-frame))
(should (= (ebox-string-height actual) (ebox-string-height normal)))
(should (> (ebox-string-height actual) 0))
(should (= (ebox--string-max-pixel-width actual) 0))
(should (equal-including-properties actual normal))))))
(ert-deftest ebox-size-native-empty-inline-items-retain-declared-width ()
"Zero-height items retain their inline extent in Row and Flex."
(skip-unless (ebox-native-reflow-layout-ready-p))
(dolist (kind '(row flex))
(dolist (empty '((box :width (px 10))
(box :width (px 10) :padding-left (px 2))
(box :width (px 10) :border-left ((px 1) solid "red"))
(column (box :width (px 10)))
(box)))
(ert-info ((format "native %S empty item: %S" kind empty))
(let* ((input (ebox-build `(,kind :width (px 30)
,empty (box :width (px 5) "B"))))
(node (ebox-canonical-input--single-root input "Native empty item"))
(normal (ebox-render input))
(frame (ebox-native-reflow-execute-sync
node '(:key 1 :viewport-width 80 :viewport-height 20
:runtime-revision 0 :context-hash 0 :complete t)))
(actual (plist-get frame :rendered))
(normal-index (string-match "B" normal))
(actual-index (string-match "B" actual)))
(should (plist-get frame :native-frame))
(should actual-index)
(should (= (ebox-string-pixel-width (substring actual 0 actual-index))
(ebox-string-pixel-width (substring normal 0 normal-index))))
(should (= (ebox--string-max-pixel-width actual)
(ebox--string-max-pixel-width normal))))))))
(ert-deftest ebox-size-native-zero-width-formatted-carrier-validation ()
"Formatted zero-width carriers retain the source property boundary."
(let* ((blank (propertize " " 'display '(space :width (0))))
(compiled (ebox-native-reflow--compile-text blank t))
(cluster (aref (plist-get (aref (plist-get compiled :lines) 0)
:clusters) 0)))
(should (= (plist-get cluster :width) 0))
(should (eq (plist-get cluster :pixel-space) t))
(should-not
(ebox-native-reflow--validate-literal-properties blank [] t))
;; Source display forms remain outside the native property capability.
(should-error (ebox-native-reflow--compile-text blank)))
(dolist (width '(-1 0.5))
(should-error
(ebox-native-reflow--validate-literal-properties
(propertize " " 'display `(space :width (,width))) [] t))))
(ert-deftest ebox-size-native-empty-leaf-delta-retains-content ()
"A retained null child edge still leaves an empty measured text value."
(dolist (form '((box) (box :height (lh 1)) (box "")))
(let* ((input (ebox-build form))
(node (ebox-canonical-input--single-root input "Empty native delta"))
(package (ebox-native-reflow--compile-layout-package node))
(root (plist-get (plist-get package :document) :root))
(updated (aref (ebox-native-reflow--compile-delta-slots node root) 0)))
(should (eq (plist-get updated :child) :null))
(should (equal (plist-get updated :content) '(:lines []))))))
(ert-deftest ebox-size-native-zero-width-retained-height-paint-and-rollback ()
"Retained zero-width carriers survive paint, height, and failed commits."
(skip-unless (ebox-native-reflow-layout-ready-p))
(let ((ebox-viewport-width 80)
(ebox-viewport-height 20)
(ebox-runtime-idle-prewarm nil)
(ebox-runtime-idle-reflow-cache-prewarm nil))
(dolist (peer '(nil t))
(ert-info ((format "stable native peer: %S" peer))
(cl-labels ((source (height color)
(ebox-build
`(column :key root :width (px 30)
(box :key child :width (px 0) :height (lh ,height)
:bgcolor ,color)
,@(when peer '((box :key peer "P")))))))
(with-temp-buffer
(ebox-render-to-buffer (current-buffer) (source 1 "#123456"))
(should (eq (plist-get (ebox--buffer-render-state (current-buffer))
:projection-kind) 'native-frame))
(dolist (case '((1 "#654321") (2 "#123456") (0 "#123456") (1 "#123456")))
(let* ((empty-base (zerop (buffer-size)))
(report (ebox-commit (current-buffer)
(source (car case) (cadr case))))
(state (ebox--buffer-render-state (current-buffer))))
(if empty-base
;; No mounted ranges exist for scoped TP publication.
;; Keep its ordinary full-surface fallback explicit.
(should (plist-get report :empty-range-owner-fallback))
(should (eq (plist-get report :projection-kind) 'native-frame)))
(should (= (ebox-string-height (buffer-string))
(+ (car case) (if peer 1 0))))
(should (equal-including-properties
(buffer-string)
(ebox--render-node (plist-get state :root-node)
(plist-get state :source-index))))))
(let ((revision (ebox-surface-buffer-revision (current-buffer)))
(before (buffer-string)))
(should-error
(ebox-commit (current-buffer) (source 0 "#123456")
(lambda (_report) (error "Reject zero-width carrier removal"))))
(should (= (ebox-surface-buffer-revision (current-buffer)) revision))
(should (equal-including-properties before (buffer-string)))
(let* ((report (ebox-commit (current-buffer) (source 0 "#123456")))
(state (ebox--buffer-render-state (current-buffer))))
(when peer
(should (eq (plist-get report :projection-kind) 'native-frame)))
(should (= (ebox-string-height (buffer-string)) (if peer 1 0)))
(should (equal-including-properties
(buffer-string)
(ebox--render-node (plist-get state :root-node)
(plist-get state :source-index))))))
(ebox-unmount-buffer (current-buffer))))))))
(provide 'ebox-size-native-tests)
;;; ebox-size-native-tests.el ends here

View File

@ -0,0 +1,223 @@
;;; ebox-size-style-tests.el --- CSS size style boundary tests -*- lexical-binding: t; -*-
;;; Code:
(require 'ert)
(require 'ebox-style)
(ert-deftest ebox-size-style-canonical-values ()
"Each size property accepts explicit lengths in its own rendering axis."
(dolist (case '(((:width :min-width :max-width)
(px 12) (% 50) (vw 33.5) (ch 80)
(calc (- (vw 100) (ch 1)))
(min (% 100) (ch 80))
(clamp (px 1) (% 50) (ch 80)))
((:height :min-height :max-height)
(lh 3) (vh 100) (% 50)
(calc (- (vh 100) (lh 1)))
(min (% 100) (lh 8))
(clamp (lh 1) (% 50) (vh 80)))))
(dolist (property (car case))
(dolist (value (cdr case))
(should (ebox-style-compile-form 'box (list property value)))))))
(ert-deftest ebox-size-style-rejects-legacy-values ()
"Legacy implicit lengths fail at every author dimension boundary."
(dolist (value '(12 0 (12) (0) viewport (viewport) viewport-height
(viewport-height) contain (fit-content (ch 80))))
(dolist (property '(:width :height :min-width :min-height
:max-width :max-height))
(should-error (ebox-style-compile-form 'box (list property value))))))
(ert-deftest ebox-size-style-keyword-applicability ()
"Maximum sizes accept none and reject auto on either axis."
(dolist (property '(:max-width :max-height))
(should-error (ebox-style-compile-form 'box (list property 'auto)))
(dolist (keyword '(none min-content max-content fit-content stretch))
(should (ebox-style-compile-form 'box (list property keyword)))))
(dolist (property '(:width :height :min-width :min-height))
(should-error (ebox-style-compile-form 'box (list property 'none)))))
(ert-deftest ebox-size-style-shorthands-preserve-atomic-expressions ()
"Shorthands distinguish one size expression from several edge values."
(let ((declarations (ebox-style-compile-form
'box '(:padding ((lh 1) (ch 2))
:margin ((lh 1) (ch 2))
:border ((px 1) solid "red")))))
(should (equal (plist-get declarations 'ebox/padding-block-start) '(lh 1)))
(should (equal (plist-get declarations 'ebox/padding-inline-end) '(ch 2)))
(should (equal (plist-get declarations 'ebox/margin-block-start) '(lh 1)))
(should (equal (plist-get declarations 'ebox/margin-inline-end) '(ch 2)))
(should (equal (plist-get declarations 'ebox/border-top-width) '(px 1)))))
(ert-deftest ebox-size-style-trbl-shorthand-arity ()
"Four-side shorthands keep arity rules and each target edge's unit domain."
(dolist (property '(:padding :margin :border-width))
(let ((unit (if (eq property :border-width) 'px '%)))
(dolist (amounts '((1) (1 2) (1 2 3) (1 2 3 4)))
(should (ebox-style-compile-form
'box (list property (mapcar (lambda (n) (list unit n))
amounts)))))))
(let ((declarations
(ebox-style-compile-form
'box '(:padding ((lh 1) (px 2) (lh 3) (px 4))))))
(should
(equal declarations
'(ebox/padding-block-start (lh 1)
ebox/padding-inline-end (px 2)
ebox/padding-block-end (lh 3)
ebox/padding-inline-start (px 4)))))
(dolist (property '(:padding :margin :border-width))
(dolist (values '(nil ((px 1) (px 2) (px 3) (px 4) (px 5))))
(should-error (ebox-style-compile-form 'box (list property values))))))
(ert-deftest ebox-size-style-pair-shorthand-arity ()
"Axis and gap shorthands preserve atomic expressions and target axes."
(dolist (case '((:padding-inline px) (:padding-block lh)
(:margin-inline px) (:margin-block lh) (:gap %)))
(dolist (amounts '((1) (1 2)))
(should (ebox-style-compile-form
'flex (list (car case)
(mapcar (lambda (n) (list (cadr case) n))
amounts))))))
(should
(equal (ebox-style-compile-form 'flex '(:gap ((lh 1) (ch 2))))
'(ebox/row-gap (lh 1) ebox/column-gap (ch 2))))
(let ((expression '(min (% 1) (% 2) (% 3) (% 4) (% 5))))
(should
(equal (ebox-style-compile-form 'flex (list :gap expression))
(list 'ebox/row-gap expression 'ebox/column-gap expression))))
(dolist (property '(:padding-inline :padding-block
:margin-inline :margin-block :gap))
(dolist (values '(nil ((px 1) (px 2) (px 3))))
(should-error (ebox-style-compile-form 'flex (list property values))))))
(ert-deftest ebox-size-style-property-ranges ()
"Each property enforces its supported size and percentage ranges."
(dolist (property '(:padding :gap :border-width :border-top-width))
(should-error (ebox-style-compile-form 'flex (list property 1))))
(should-error (ebox-style-compile-form 'box '(:padding (px -1))))
(should-error (ebox-style-compile-form 'box '(:margin (px -1))))
(should-error (ebox-style-compile-form 'box '(:border-width (% 2))))
(should-error (ebox-style-compile-form
'box '(:border-width (calc (+ (px 1) (% 2)))))))
(ert-deftest ebox-size-style-projection-remains-unresolved ()
"Computed projection preserves contextual sizes until used layout."
(let* ((expression '(calc (- (vh 100) (lh 1))))
(projected (ebox-style--expand-engine-plist
(list :height expression :width '(ch 80)
:padding-top '(lh 1) :margin-left '(ch 2)
:border-top-width '(px 1)
:border-top-style 'solid))))
(should (equal (plist-get projected :height) expression))
(should (equal (plist-get projected :width) '(ch 80)))
(should (equal (plist-get projected :padding-top-height) '(lh 1)))
(should (equal (plist-get projected :margin-left-pixel) '(ch 2)))
(should (equal (plist-get projected :border-top-pixel) '(px 1)))))
(ert-deftest ebox-size-style-generated-defaults ()
"Schema and generated declarations use canonical dimensional defaults."
(should (eq (plist-get (ebox-style-property :min-width) :initial) 'auto))
(should (eq (plist-get (ebox-style-property :min-height) :initial) 'auto))
(should (equal (plist-get (ebox-style-compile-form 'box '(:flex 1))
'ebox/flex-basis) '(% 0)))
(should (equal (plist-get (ebox-style-compile-form 'box '(:border "red"))
'ebox/border-top-width) '(px 1)))
(let ((declarations (ebox-style-compile-form 'box '(:border none))))
(should (equal (plist-get declarations 'ebox/border-top-width) '(px 1)))
(should (eq (plist-get declarations 'ebox/border-top-style) 'none)))
(should (equal (ebox-style--convert 0 'pixel) 0)))
(ert-deftest ebox-size-style-axis-matrix-is-enforced-during-build ()
"Dimensions, physical/logical edges, and gaps reject wrong-axis units early."
(require 'ebox)
(dolist (case
'((box inline
:width :min-width :max-width
:padding-right :padding-left :padding-inline-start :padding-inline-end
:margin-right :margin-left :margin-inline-start :margin-inline-end)
(box block
:height :min-height :max-height
:padding-top :padding-bottom :padding-block-start :padding-block-end
:margin-top :margin-bottom :margin-block-start :margin-block-end)
(flex inline :column-gap)
(flex block :row-gap)
(row inline :item-gap)
(column block :item-gap)))
(dolist (property (cddr case))
(dolist (unit '(px ch vw lh vh %))
(let ((form (list (car case) property (list unit 2))))
(ert-info ((format "initial build %S" form))
(if (memq unit (cdr (assq (cadr case) ebox-size-axis-units)))
(should (ebox-build form))
(let ((message
(condition-case condition
(progn (ebox-build form) nil)
(error (error-message-string condition)))))
(should (string-match-p (regexp-quote (symbol-name property))
message))
(should (string-match-p "allowed units:" message))))))))))
(ert-deftest ebox-size-style-recursive-axis-errors-reject-shorthands-and-tracks ()
"Expressions cannot hide forbidden units in shorthand or Grid wrappers."
(require 'ebox)
(dolist (form
'((box :height (calc (* 0 (px 1))))
(box :max-width (clamp (px 1) (lh 1) (ch 80)))
(box :padding ((calc (+ (lh 1) (px 0))) (ch 1)))
(box :margin-inline (min (ch 1) (vh 1)))
(box :padding (lh 1))
(box :margin (px 1))
(flex :gap (px 1))
(grid :grid-template-rows ((minmax (lh 1) (px 2))))
(grid :grid-template-columns ((repeat 2 (minmax (lh 1) (fr 1)))))))
(should-error (ebox-build form)))
(dolist (form
'((grid :grid-template-rows ((repeat 2 (minmax (lh 1) (fr 1)))))
(grid :grid-template-columns ((minmax (ch 1) (vw 20))))
(grid :grid-auto-rows (max (lh 1) (vh 20)))
(grid :grid-auto-columns (calc (+ (ch 1) (% 20))))))
(should (ebox-build form))))
(ert-deftest ebox-size-style-stroke-units-are-independent-of-edge-orientation ()
"All border edges retain their non-percentage paint-thickness domain."
(dolist (property '(:border-top-width :border-right-width
:border-bottom-width :border-left-width))
(dolist (unit '(px ch lh vw vh))
(should (ebox-style-compile-form 'box (list property (list unit 1)))))
(should-error (ebox-style-compile-form 'box (list property '(% 1))))))
(ert-deftest ebox-size-style-malformed-shorthand-functions-keep-unit-diagnostics ()
"Malformed atomic expressions report the receiving property's unit domain."
(dolist (case '((:gap (calc (lh 1) (lh 2) (lh 3)) ":row-gap")
(:padding (clamp (lh 1)) ":padding-block-start")
(:border ((calc (px 1) (px 2)) solid "red")
":border-top-width")))
(let ((message
(condition-case condition
(progn (ebox-style-compile-form 'flex (list (car case) (cadr case)))
nil)
(error (error-message-string condition)))))
(should (string-match-p (nth 2 case) message))
(should (string-match-p "allowed units:" message)))))
(ert-deftest ebox-size-style-simple-configs-enforce-axis-before-render ()
"Both direct LayoutConfig constructors enforce the same item-gap rules."
(require 'ebox-layout-config)
(should (equal (plist-get (ebox-layout-config-props
(ebox-row-layout-create)) :item-gap) '(px 0)))
(should (equal (plist-get (ebox-layout-config-props
(ebox-column-layout-create)) :item-gap) '(lh 0)))
(should-error (ebox-row-layout-create :item-gap '(lh 1))
:type 'ebox-size-error)
(should-error (ebox-column-layout-create :item-gap '(px 0))
:type 'ebox-size-error)
(should-error
(ebox-layout-config-with-props
(ebox-column-layout-create)
'(:item-gap (calc (* 0 (px 1))) :cross-align stretch))
:type 'ebox-size-error))
(provide 'ebox-size-style-tests)
;;; ebox-size-style-tests.el ends here

128
tests/ebox-size-tests.el Normal file
View File

@ -0,0 +1,128 @@
;;; ebox-size-tests.el --- Pure size expression contracts -*- lexical-binding: t; -*-
;;; Code:
(require 'ert)
(require 'ebox-size)
(ert-deftest ebox-size-explicit-units-resolve-against-their-reference ()
(let ((context '(:percent-base 300 :viewport-width 853 :viewport-height 701
:ch 8.5 :lh 19)))
(dolist (case '(((px 12.5) . 12.5) ((% 50) . 150.0)
((vw 33) . 281.49) ((vh 50) . 350.5)
((ch 80) . 680.0) ((lh 3) . 57)))
(should (ebox-size-value-p (car case)))
(should (< (abs (- (ebox-size-resolve (car case) context)
(cdr case)))
0.000001)))))
(ert-deftest ebox-size-fractions-survive-until-the-renderer ()
(should (= (ebox-size-resolve '(calc (- (vh 50) (lh 1)))
'(:viewport-height 931 :lh 19))
446.5))
(should (= (ebox-size-resolve '(calc (/ (px 3) 2)) nil) 1.5))
(should (= (ebox-size-resolve '(calc (* (/ 1 2) (lh 3))) '(:lh 19))
28.5)))
(ert-deftest ebox-size-math-preserves-dimensions-and-css-clamp-precedence ()
(let ((context '(:percent-base 300 :ch 8)))
(should (= (ebox-size-resolve '(min (% 100) (ch 80)) context) 300))
(should (= (ebox-size-resolve '(max (px 320) (% 50)) context) 320))
(should (= (ebox-size-resolve '(clamp (ch 20) (% 50) (ch 80)) context)
160))
;; CSS gives the minimum precedence when bounds are reversed.
(should (= (ebox-size-resolve '(clamp (px 80) (px 40) (px 20)) nil)
80))
(should (= (ebox-size-resolve '(calc (+ (px 10) (- (px 4)) (px -2)))
nil)
4))
(should (= (ebox-size-resolve '(calc (* (px 6) (min 2 3))) nil) 12))))
(ert-deftest ebox-size-missing-reference-defers-but-zero-resolves ()
(dolist (unit '(% vw vh ch lh))
(should-not (ebox-size-resolve (list unit 3) nil)))
(should-not (ebox-size-resolve '(min (px 20) (% 50)) nil))
(should-not (ebox-size-resolve '(calc (+ (vh 100) (px 1))) nil))
(should (= (ebox-size-resolve '(% 50) '(:percent-base 0)) 0))
(should (= (ebox-size-resolve '(calc (+ (vh 100) (px 1)))
'(:viewport-height 0))
1)))
(ert-deftest ebox-size-dependencies-are-recursive-distinct-and-stable ()
(let ((expression '(clamp (ch 20)
(calc (+ (% 30) (vw 4) (ch 2) (vh 1)))
(max (lh 8) (% 100)))))
(should (equal (ebox-size-dependencies expression)
'(ch percent viewport-width viewport-height lh)))
(should (equal (ebox-size-dependencies expression)
'(ch percent viewport-width viewport-height lh)))
(should-not (ebox-size-dependencies '(calc (+ (px 1) (px 2)))))))
(ert-deftest ebox-size-property-restrictions-apply-to-literals-and-dependencies ()
(should-not (ebox-size-value-p '(px -1)))
(should (ebox-size-value-p '(lh -1) t))
(should (ebox-size-value-p '(calc (- (px 1) (px 2)))))
(should (= (ebox-size-resolve '(calc (- (px 1) (px 2))) nil) -1))
(should (ebox-size-value-p '(% 50)))
(should-not (ebox-size-value-p '(% 50) t nil))
(should-not (ebox-size-value-p '(min (px 20) (calc (* 2 (% 10)))) t nil))
(should (ebox-size-value-p '(calc (- (vw 100) (px 1))) nil nil)))
(ert-deftest ebox-size-rejects-legacy-values-malformed-functions-and-unit-algebra ()
(dolist (value '(0 3 (240) "50%" auto viewport (viewport-height)
(fit-content (ch 80)) (em 2) (px) (px 1 2) (px "2")
(px . 2) (+ (px 1) (px 2))
(calc) (calc (px 1) (px 2)) (min) (max)
(clamp (px 1) (px 2)) (calc (+))
(calc (* (px 1) (px 2))) (calc (/ (px 1) (lh 2)))
(calc (+ (px 1) 1)) (min (px 1) 2)
(calc 2) (calc (/ 2 3))
(calc (/ (% 50) 0)) (calc (/ (px 1) (- 3 3)))
(calc (/ (px 1) 2 3))))
(should-not (ebox-size-value-p value t))
(should-error (ebox-size-resolve value nil) :type 'ebox-size-error)))
(ert-deftest ebox-size-rejects-nonfinite-input-context-and-overflow ()
(dolist (number (list 0.0e+NaN 1.0e+INF -1.0e+INF))
(should-not (ebox-size-value-p (list 'px number) t))
(should-error (ebox-size-resolve '(ch 1) (list :ch number))
:type 'ebox-size-error))
(should-error (ebox-size-resolve '(vh 1) '(:viewport-height -1))
:type 'ebox-size-error)
(should-not (ebox-size-value-p '(calc (* (px 1.0e+308) 10))))
(should-error (ebox-size-resolve '(ch 1.0e+308) '(:ch 100))
:type 'ebox-size-error))
(ert-deftest ebox-size-axis-contract-inspects-every-math-operand ()
"Property domains constrain every literal, even inside zero-valued math."
(dolist (case '((inline (px ch vw %) (lh vh))
(block (lh vh %) (px ch vw))
(stroke (px ch lh vw vh) (%))))
(dolist (unit (cadr case))
(should (ebox-size-value-for-axis-p (list unit 2) (car case))))
(dolist (unit (caddr case))
(let ((value (list unit 0)))
(dolist (expression
(list value (list 'min value value)
(list 'clamp value value value)
(list 'calc (list '* 0 value))
(list 'calc (list '- value value))))
(should (ebox-size-value-p expression))
(should-not (ebox-size-value-for-axis-p expression (car case)))
(should-error
(ebox-size-validate-for-axis expression :example (car case))
:type 'ebox-size-error))))))
(ert-deftest ebox-size-axis-errors-describe-property-and-allowed-units ()
"Malformed functions and unknown units give the same actionable context."
(dolist (value '((px 1) (rh 1) (calc (+ (lh 1) (px 0)))
(clamp (lh 1)) (lh -1) (lh "1")))
(let ((message
(condition-case condition
(progn (ebox-size-validate-for-axis value :height 'block) nil)
(ebox-size-error (error-message-string condition)))))
(should (string-match-p ":height" message))
(should (string-match-p "allowed units: (lh vh %)" message)))))
(provide 'ebox-size-tests)
;;; ebox-size-tests.el ends here

View File

@ -104,6 +104,10 @@
disposable-cache ebox-font cache-fill-and-evict disposable-cache ebox-font cache-fill-and-evict
display-capability-signature discard resolve-font-fact-again display-capability-signature discard resolve-font-fact-again
ebox-font-clear-cache) ebox-font-clear-cache)
(native-hover-face-cache (ebox-interaction--hover-face-cache)
disposable-cache ebox-interaction weak-cache-fill
hover-owner-and-computed-paint discard-unreferenced-candidates
reproject-owner-native-hover-face garbage-collect-unreferenced-faces)
(derived-caches (derived-caches
(ebox--char-width-cache ebox--face-height-width-cache (ebox--char-width-cache ebox--face-height-width-cache
ebox--display-signature-cache ebox--render-cache-signature-cache ebox--display-signature-cache ebox--render-cache-signature-cache

View File

@ -215,9 +215,10 @@
:margin-block-start 6 :margin-inline-end (7) :margin-block-start 6 :margin-inline-end (7)
:margin-block-end 8 :margin-inline-start (9)))) :margin-block-end 8 :margin-inline-start (9))))
(should (should
(equal (ebox-style--expand-engine-plist (copy-tree fixture)) (equal (ebox-style--expand-engine-plist
(ebox-test--explicit-sizes 'box fixture))
(ebox-style-schema-test--legacy-expand-plist (ebox-style-schema-test--legacy-expand-plist
(copy-tree fixture)))))) (ebox-test--explicit-sizes 'box fixture))))))
(ert-deftest ebox-style-schema-malformed-projections-fail-fast () (ert-deftest ebox-style-schema-malformed-projections-fail-fast ()
"Ambiguous, missing, or cyclic projection declarations are rejected." "Ambiguous, missing, or cyclic projection declarations are rejected."

View File

@ -758,7 +758,7 @@
(ebox-runtime-idle-reflow-cache-prewarm nil) (ebox-runtime-idle-reflow-cache-prewarm nil)
(map (make-sparse-keymap)) (map (make-sparse-keymap))
(text (propertize "Open" 'keymap map 'help-echo "Open item")) (text (propertize "Open" 'keymap map 'help-echo "Open item"))
(input (ebox-build `(box :width (viewport) :height (viewport-height) (input (ebox-build `(box :width (vw 100) :height (vh 100)
(text ,text))))) (text ,text)))))
(define-key map [mouse-1] #'ignore) (define-key map [mouse-1] #'ignore)
(unwind-protect (unwind-protect
@ -3345,10 +3345,10 @@ candidate cannot hide mutations by restoring the old hash-table pointer."
"Copy a paint-only Theme delta without rerunning ECSS cascade computation." "Copy a paint-only Theme delta without rerunning ECSS cascade computation."
(let* ((subject (ecss-subject-create :type "box")) (let* ((subject (ecss-subject-create :type "box"))
(old-declarations (old-declarations
'(ebox/width (100) ebox/color "#252A2E" '(ebox/width (px 100) ebox/color "#252A2E"
ebox/background-color "#F8F5EE")) ebox/background-color "#F8F5EE"))
(new-declarations (new-declarations
'(ebox/width (100) ebox/color "#F2EEE4" '(ebox/width (px 100) ebox/color "#F2EEE4"
ebox/background-color "#1B1F20")) ebox/background-color "#1B1F20"))
(old-style (old-style
(ebox-style-compute-subject subject old-declarations)) (ebox-style-compute-subject subject old-declarations))
@ -3367,7 +3367,7 @@ candidate cannot hide mutations by restoring the old hash-table pointer."
(should-not (should-not
(ebox-style--theme-delta-computed (ebox-style--theme-delta-computed
old-style old-declarations old-style old-declarations
(plist-put (copy-sequence new-declarations) 'ebox/width '(120)))))) (plist-put (copy-sequence new-declarations) 'ebox/width '(px 120))))))
(ert-deftest ebox-style-theme-delta-propagates-inherited-color () (ert-deftest ebox-style-theme-delta-propagates-inherited-color ()
"Copy a proven inherited Theme color without rerunning ECSS. "Copy a proven inherited Theme color without rerunning ECSS.
@ -3379,7 +3379,7 @@ changes. Geometry and non-inherited computed values must remain identical."
(ebox-style-compute-subject parent '(ebox/color "#111111"))) (ebox-style-compute-subject parent '(ebox/color "#111111")))
(parent-new (parent-new
(ebox-style-compute-subject parent '(ebox/color "#222222"))) (ebox-style-compute-subject parent '(ebox/color "#222222")))
(declarations '(ebox/width (100) ebox/background-color "#eeeeee")) (declarations '(ebox/width (px 100) ebox/background-color "#eeeeee"))
(style (style
(ebox-style-compute-subject child declarations parent-old)) (ebox-style-compute-subject child declarations parent-old))
(delta (delta
@ -3402,7 +3402,7 @@ changes. Geometry and non-inherited computed values must remain identical."
(ebox-style-compute-subject parent '(ebox/color "#111111"))) (ebox-style-compute-subject parent '(ebox/color "#111111")))
(parent-new (parent-new
(ebox-style-compute-subject parent '(ebox/color "#222222"))) (ebox-style-compute-subject parent '(ebox/color "#222222")))
(declarations '(ebox/color "#ffffff" ebox/width (100))) (declarations '(ebox/color "#ffffff" ebox/width (px 100)))
(style (style
(ebox-style-compute-subject child declarations parent-old)) (ebox-style-compute-subject child declarations parent-old))
(delta (delta
@ -3449,15 +3449,15 @@ changes. Geometry and non-inherited computed values must remain identical."
"Equivalent Flex gap aliases must not become geometry dirtiness." "Equivalent Flex gap aliases must not become geometry dirtiness."
(let ((old (list :ebox-type 'flex (let ((old (list :ebox-type 'flex
:raw-props '(:width stretch :raw-props '(:width stretch
:row-gap 1 :column-gap (12) :row-gap (lh 1) :column-gap (px 12)
:padding-block-start 0 :padding-block-start (lh 0)
:padding-inline-end 2 :padding-inline-end (ch 2)
:padding-block-end 0 :padding-block-end (lh 0)
:padding-inline-start 2 :padding-inline-start (ch 2)
:border-top-width 1 :border-top-width (px 1)
:border-right-width 1 :border-right-width (px 1)
:border-bottom-width 1 :border-bottom-width (px 1)
:border-left-width 1 :border-left-width (px 1)
:border-top-style solid :border-top-style solid
:border-right-style solid :border-right-style solid
:border-bottom-style solid :border-bottom-style solid
@ -3471,9 +3471,9 @@ changes. Geometry and non-inherited computed values must remain identical."
:background-color "#F8F5EE"))) :background-color "#F8F5EE")))
(new (list :ebox-type 'flex (new (list :ebox-type 'flex
:raw-props '(:width stretch :raw-props '(:width stretch
:gap (1 (12)) :gap ((lh 1) (px 12))
:padding (0 2) :padding ((lh 0) (ch 2))
:border (1 solid "#687386") :border ((px 1) solid "#687386")
:align-items center :align-items center
:color "#F2EEE4" :color "#F2EEE4"
:bgcolor "#1B1F20")))) :bgcolor "#1B1F20"))))
@ -3837,8 +3837,340 @@ changes. Geometry and non-inherited computed values must remain identical."
(when (buffer-live-p buffer) (when (buffer-live-p buffer)
(kill-buffer buffer))))) (kill-buffer buffer)))))
(ert-deftest ebox-region-content-update-preserves-canonical-dirty-facts ()
"Successive Text updates retain content facts needed by the owner planner."
(ebox-surface-test--with-elisp-backend
(with-temp-buffer
(let ((buffer (current-buffer)))
(ebox-render-to-buffer
buffer
(ebox-test-column
(ebox-test-box :id "counter" :width '(100)
(ebox-test-text "COUNT 00"))
(ebox-test-box :id "unchanged" :width '(100)
(ebox-test-text "Still here"))))
(let* ((handle (ebox-region-resolve buffer "counter"))
(region-id (cdr (ebox-selector--region-target handle)))
(original-text
(ebox--region-update-direct-text-node
(ebox--root-region-box
(ebox--buffer-root-node buffer) region-id))))
(dolist (content '("COUNT 01" "COUNT 02" "COUNT 10"))
(let* ((report (ebox-region-update handle :content content))
(text
(ebox--region-update-direct-text-node
(ebox--root-region-box
(ebox--buffer-root-node buffer) region-id))))
(should (> (plist-get report :dirty-count) 0))
(should-not (eq (plist-get report :strategy) 'no-op))
(should (equal (ebox-text-node-value text) content))
(should (equal (plist-get text :content) content))
(should (string-match-p content (buffer-string)))
(should (string-match-p "Still here" (buffer-string)))))
(should (equal (ebox-text-node-value original-text) "COUNT 00"))
(should (equal (plist-get original-text :content) "COUNT 00")))))))
(ert-deftest ebox-region-content-update-honors-retained-nested-slot ()
"A nested auto-width owner keeps its proven slot during a text patch."
(ebox-surface-test--with-elisp-backend
(with-temp-buffer
(let ((buffer (current-buffer))
(full-render (symbol-function 'ebox-surface--render-candidate))
(fragments (symbol-function 'ebox-surface--rendered-fragments))
(full-renders 0) (full-fragments 0))
(ebox-render-to-buffer
buffer
(ebox-build
'(column :width (px 120) :height (lh 20) :color "#273B42"
(box :padding ((lh 1) (ch 1)) :border "#5893A3"
:background-color "#FFFFFF"
(box :id "counter" :color "#205A6C" :font-weight bold
"COUNT 00")
(box "Unchanged sibling")))))
(cl-letf (((symbol-function 'ebox-surface--render-candidate)
(lambda (&rest arguments)
(cl-incf full-renders)
(apply full-render arguments)))
((symbol-function 'ebox-surface--rendered-fragments)
(lambda (&rest arguments)
(cl-incf full-fragments)
(apply fragments arguments))))
(dolist (content '("COUNT 01" "COUNT 02"))
(let ((report (ebox-region-update "counter" :content content)))
(should (eq (plist-get report :strategy) 'span-patch))
(should (string-match-p content (buffer-string))))))
(should (= full-renders 0))
(should (= full-fragments 0))
(should
(equal (buffer-substring-no-properties (point-min) (point-max))
(substring-no-properties
(ebox-render
(plist-get (ebox-surface-buffer-snapshot buffer) :input)))))))))
(ert-deftest ebox-region-fixed-coordinate-content-retains-surface-plan ()
"A plain span proof keeps its unchanged published ownership projection."
(ebox-surface-test--with-elisp-backend
(with-temp-buffer
(ebox-render-to-buffer
(current-buffer)
(ebox-build
'(column :width (px 180)
(box :id "counter" :width (ch 20) "000000000")
(text "Unchanged sibling"))))
(let ((ledger (plist-get (ebox--buffer-render-state (current-buffer))
:surface-fragments))
(surface-plan (symbol-function 'ebox-surface--surface-plan))
(inheritance-scan
(symbol-function 'ebox-surface--inline-inheritance-required-p))
(inheritance-scans 0)
(plans 0))
(cl-letf (((symbol-function 'ebox-surface--surface-plan)
(lambda (&rest arguments)
(cl-incf plans)
(apply surface-plan arguments)))
((symbol-function 'ebox-surface--inline-inheritance-required-p)
(lambda (&rest arguments)
(cl-incf inheritance-scans)
(apply inheritance-scan arguments))))
(dolist (content '("000000001" "000000002" "000000010"))
(should
(eq (plist-get (ebox-region-update "counter" :content content)
:strategy)
'span-patch))
(should
(eq ledger
(plist-get (ebox--buffer-render-state (current-buffer))
:surface-fragments)))))
(should (= plans 0))
(should (= inheritance-scans 0)))
(should
(equal-including-properties
(buffer-string)
(ebox-surface-test--render-runtime
(ebox--buffer-render-state (current-buffer))))))))
(ert-deftest ebox-region-content-retains-viewport-axis-proof ()
"Content cannot alter the retained topology's viewport expressions."
(ebox-surface-test--with-elisp-backend
(with-temp-buffer
(ebox-render-to-buffer
(current-buffer)
(ebox-build
'(column :width (vw 80)
(box :id "counter" :width (ch 20) "000000000")
(box :height (vh 20) "Unchanged sibling"))))
(let* ((old-state (ebox--buffer-render-state (current-buffer)))
(axes (plist-get old-state :viewport-dependent-node-id-axes))
(scan (symbol-function 'ebox-surface--context-axes))
(scans 0))
(cl-letf (((symbol-function 'ebox-surface--context-axes)
(lambda (&rest arguments)
(cl-incf scans)
(apply scan arguments))))
(ebox-region-update "counter" :content "000000001"))
(should (= scans 0))
(should
(eq axes (plist-get (ebox--buffer-render-state (current-buffer))
:viewport-dependent-node-id-axes)))))))
(ert-deftest ebox-region-content-path-copy-preserves-inline-inheritance ()
"Content-only candidates share untouched nodes even with inherited styles."
(ebox-surface-test--with-elisp-backend
(dolist (target '((box :id "counter" "000000000")
(box :width (ch 20)
(text :id "counter" "000000000"))
(box :padding ((lh 1) (ch 1)) :border "#5893A3"
:background-color "#FFFFFF"
(box :id "counter" :color "#205A6C" "000000000")
(box "Near sibling"))))
(with-temp-buffer
(ebox-render-to-buffer
(current-buffer)
(ebox-build
`(column :width (px 180) :color "#123456" :font-weight bold
,target
(box :id "sibling" "Unchanged sibling"))))
(let* ((before (ebox--buffer-render-state (current-buffer)))
(sibling-region
(cdr (ebox-selector--region-target
(ebox-region-resolve (current-buffer) "sibling"))))
(sibling-id (ebox-runtime-index-get
sibling-region
(plist-get before :region-node-table)))
(sibling (ebox-runtime-index-get
sibling-id (plist-get before :node-table)))
(indexer (symbol-function 'ebox--runtime-index))
(root-id (plist-get (plist-get before :root-node) :node-id))
(full-indexes 0))
(cl-letf (((symbol-function 'ebox--runtime-index)
(lambda (root &rest arguments)
(when (equal root-id (plist-get root :node-id))
(cl-incf full-indexes))
(apply indexer root arguments))))
(let ((published (buffer-string)))
(cl-letf (((symbol-function 'accept-change-group)
(lambda (_) (error "Reject path-copied content"))))
(should-error
(ebox-region-update "counter" :content "000000001")))
(should (eq before (ebox--buffer-render-state (current-buffer))))
(should (equal-including-properties published (buffer-string))))
(dolist (content '("000000001" "000000002"))
(should
(eq (plist-get (ebox-region-update "counter" :content content)
:strategy)
'span-patch))
(should
(eq sibling
(ebox-runtime-index-get
sibling-id (plist-get (ebox--buffer-render-state
(current-buffer))
:node-table))))))
(should (= full-indexes 0))
(should
(equal-including-properties
(buffer-string)
(ebox-surface-test--render-runtime
(ebox--buffer-render-state (current-buffer))))))))))
(ert-deftest ebox-region-content-after-stylesheet-scope-keeps-fallback ()
"Leaving a stylesheet scope cannot reuse its stale local style proof."
(ebox-surface-test--with-elisp-backend
(let ((ebox-style-stylesheet (ecss-stylesheet-create)))
(with-temp-buffer
(let ((ebox-style-stylesheet (ecss-stylesheet-create)))
(ebox-style-add-rule ".tone" '(:color "#EF4444"))
(ebox-render-to-buffer
(current-buffer)
(ebox-build
'(column :width (px 180)
(box :id "counter" :class tone "000000000")
(box :class tone "Unchanged sibling")))))
(should (plist-get (ebox--buffer-render-state (current-buffer))
:cascade-active-p))
(let ((render (symbol-function 'ebox-surface--render-candidate))
(renders 0))
(cl-letf (((symbol-function 'ebox-surface--render-candidate)
(lambda (&rest arguments)
(cl-incf renders)
(apply render arguments))))
(ebox-region-update "counter" :content "000000001"))
(should (> renders 0)))
(should-not (plist-get (ebox--buffer-render-state (current-buffer))
:cascade-active-p))
(should
(equal-including-properties
(buffer-string)
(ebox-surface-test--render-runtime
(ebox--buffer-render-state (current-buffer)))))))))
(ert-deftest ebox-region-multiline-content-keeps-fixed-column-slot ()
"Changed text lengths cannot widen a proven fixed-height Column child."
(ebox-surface-test--with-elisp-backend
(with-temp-buffer
(let ((buffer (current-buffer))
(full-render (symbol-function 'ebox-surface--render-candidate))
(fragments (symbol-function 'ebox-surface--rendered-fragments))
(surface-plan (symbol-function 'ebox-surface--surface-plan))
(full-renders 0) (full-fragments 0) (surface-plans 0))
(ebox-render-to-buffer
buffer
(ebox-build
'(column :width (px 180) :height (lh 20) :color "#273B42"
(column :padding ((lh 1) (ch 1)) :background-color "#FFFFFF"
(box :id "log" :height (lh 4) :padding-inline (ch 1)
"No actions yet.\n\n\n")
(box :id "following" "Unchanged sibling")))))
(cl-letf (((symbol-function 'ebox-surface--render-candidate)
(lambda (&rest arguments)
(cl-incf full-renders)
(apply full-render arguments)))
((symbol-function 'ebox-surface--rendered-fragments)
(lambda (rendered)
(when (>= (length rendered) (buffer-size))
(cl-incf full-fragments))
(funcall fragments rendered)))
((symbol-function 'ebox-surface--surface-plan)
(lambda (&rest arguments)
(cl-incf surface-plans)
(apply surface-plan arguments))))
(dolist (content '("One\n\n\n"
"Longer first line\nSecond\n\n"
"One\nTwo\nThree\nFour"))
(let ((report (ebox-region-update "log" :content content)))
(should (eq (plist-get report :strategy) 'span-patch))
(should (string-match-p "Unchanged sibling" (buffer-string))))))
(should (= full-renders 0))
(should (= full-fragments 0))
(should (= surface-plans 0))
(should
(equal-including-properties
(buffer-string)
(ebox-surface-test--render-runtime
(ebox--buffer-render-state buffer))))))))
(ert-deftest ebox-region-multiline-slot-patch-rolls-back-and-retries ()
"A late failure preserves content, ownership, and the next local update."
(ebox-surface-test--with-elisp-backend
(with-temp-buffer
(ebox-render-to-buffer
(current-buffer)
(ebox-build
'(column :width (px 180) :color "#123456"
(box :id "log" :height (lh 3) :padding-inline (ch 1)
"One\nTwo\nThree")
(text :id "following" "Following"))))
(let ((before (buffer-string))
(state (ebox--buffer-render-state (current-buffer)))
(revision (ebox-surface-buffer-revision (current-buffer))))
(cl-letf (((symbol-function 'accept-change-group)
(lambda (_) (error "Reject multiline publication"))))
(should-error
(ebox-region-update "log" :content "Longer one\nTwo\nThree")))
(should (equal-including-properties before (buffer-string)))
(should (eq state (ebox--buffer-render-state (current-buffer))))
(should (= revision (ebox-surface-buffer-revision (current-buffer))))
(ebox-region-update "log" :content "Longer one\nTwo\nThree")
(ebox-region-update "following" :color "#654321")
(should
(equal-including-properties
(buffer-string)
(ebox-surface-test--render-runtime
(ebox--buffer-render-state (current-buffer)))))))))
(ert-deftest ebox-region-multiline-content-without-fixed-slot-falls-back ()
"Natural height and visible overflow keep their geometry fallback."
(ebox-surface-test--with-elisp-backend
(dolist (style '(nil (:height (lh 2) :overflow visible)))
(with-temp-buffer
(ebox-render-to-buffer
(current-buffer)
(ebox-build
`(column :width (px 120)
(box :id "log" ,@style "One")
(text "Following"))))
(ebox-region-update "log" :content "One\nTwo\nThree\nFour")
(should
(equal-including-properties
(buffer-string)
(ebox-surface-test--render-runtime
(ebox--buffer-render-state (current-buffer)))))))))
(ert-deftest ebox-region-content-update-ignores-caller-narrowing ()
"The local owner proof uses mounted coordinates outside the editor view."
(ebox-surface-test--with-elisp-backend
(with-temp-buffer
(ebox-render-to-buffer
(current-buffer)
(ebox-build '(box :id "target" :width (px 120) "Before")))
(goto-char (1+ (point-min)))
(narrow-to-region (point) (point-max))
(ebox-region-update "target" :content "After!")
(save-restriction
(widen)
(should (string-prefix-p "After!" (buffer-string)))))))
(ert-deftest ebox-region-update-rejects-process-global-region-ids () (ert-deftest ebox-region-update-rejects-process-global-region-ids ()
"Direct updates should require a surface-scoped region handle." "Numeric targets resolve only as current-buffer semantic IDs, never globals."
(ebox-surface-test--reset-render-state) (ebox-surface-test--reset-render-state)
(let ((buffer (generate-new-buffer " *ebox-handle-only-update*"))) (let ((buffer (generate-new-buffer " *ebox-handle-only-update*")))
(unwind-protect (unwind-protect
@ -3849,7 +4181,7 @@ changes. Geometry and non-inherited computed values must remain identical."
(let* ((match (car (ebox-selector-query-buffer buffer "#target"))) (let* ((match (car (ebox-selector-query-buffer buffer "#target")))
(region-id (plist-get match :region-id))) (region-id (plist-get match :region-id)))
(should-error (ebox-region-update region-id :content "Wrong") (should-error (ebox-region-update region-id :content "Wrong")
:type 'wrong-type-argument) :type 'user-error)
(ebox-region-update (plist-get match :region-handle) (ebox-region-update (plist-get match :region-handle)
:content "After") :content "After")
(should (string-match-p (should (string-match-p
@ -4382,6 +4714,120 @@ participant and rollback contract without creating a real frame."
:native-render-frame (:native-frame t :native-patch nil)) :native-render-frame (:native-frame t :native-patch nil))
'paint))) 'paint)))
(ert-deftest ebox-region-scroll-content-keeps-local-owner-and-cache ()
"Clipping preserves local owner proofs and later scrolling sees the update."
(let ((ebox-viewport-width 240) (ebox-viewport-height 12))
(with-temp-buffer
(ebox-render-to-buffer
(current-buffer)
(ebox-build
'(column :width (px 240) :height (vh 60)
:padding ((lh 1) (ch 1))
:background-color "#EEEEEE" :color "#123456"
(box :id "counter" "COUNT 00")
(box "Unchanged sibling")
(box :height (lh 5) "Spacer")
(box :id "log" :height (lh 4) :padding-inline (ch 1)
:background-color "#224466" :color "#FFFFFF"
"old\n\n\n")
(box "Tail"))))
(let* ((before (ebox--buffer-render-state (current-buffer)))
(region (car (plist-get before :scroll-region-ids)))
(scroll (gethash region ebox--scroll-global-state))
(old-lines (copy-tree (plist-get scroll :content-lines)))
(plan (symbol-function 'ebox-surface--surface-plan))
(plans 0))
(should region)
(cl-letf (((symbol-function 'ebox-surface--surface-plan)
(lambda (&rest args) (cl-incf plans) (apply plan args))))
(dotimes (index 5)
(ebox-region-update "counter" :content (format "COUNT %02d" (1+ index)))
(ebox-region-update "log" :content (format "event %d\nprevious\n\n" index))))
(should (= plans 0))
(should (equal-including-properties
(ebox-lines-join old-lines)
(ebox-lines-join (plist-get scroll :content-lines))))
(should (string-match-p "COUNT 05" (buffer-string)))
(should-not (string-match-p "event 4" (buffer-string)))
(should (equal-including-properties
(buffer-string)
(ebox-surface-test--render-runtime
(ebox--buffer-render-state (current-buffer)))))
(should (ebox--scroll-region-by region 7))
(should (string-match-p "event 4" (buffer-string)))
(should (equal-including-properties
(buffer-string)
(ebox-surface-test--render-runtime
(ebox--buffer-render-state (current-buffer)))))))))
(ert-deftest ebox-region-scroll-content-failed-publication-keeps-cache ()
"A rejected local scroll update leaves cache, producers and runtime intact."
(let ((ebox-viewport-width 240) (ebox-viewport-height 12))
(with-temp-buffer
(ebox-render-to-buffer
(current-buffer)
(ebox-build
'(column :width (px 240) :height (vh 60) :padding ((lh 1) (ch 1))
(box :id "counter" "COUNT 00")
(box :height (lh 6) "Spacer")
(box :id "log" :height (lh 4) "old\n\n\n")
(box "Tail"))))
(dolist (target '("counter" "log"))
(let* ((before (ebox--buffer-render-state (current-buffer)))
(region (car (plist-get before :scroll-region-ids)))
(scroll (gethash region ebox--scroll-global-state))
(lines (copy-tree (plist-get scroll :content-lines)))
(text (buffer-string))
(content (if (equal target "counter") "COUNT 01" "new\n\n\n")))
(cl-letf (((symbol-function 'accept-change-group)
(lambda (_) (error "Reject scroll content"))))
(should-error (ebox-region-update target :content content)))
(should (eq before (ebox--buffer-render-state (current-buffer))))
(should (eq scroll (gethash region ebox--scroll-global-state)))
(should (equal-including-properties text (buffer-string)))
(should (equal-including-properties
(ebox-lines-join lines) (ebox-lines-join (plist-get scroll :content-lines))))
(ebox-region-update target :content content)))
(should (equal-including-properties
(buffer-string)
(ebox-surface-test--render-runtime
(ebox--buffer-render-state (current-buffer))))))))
(ert-deftest ebox-region-scroll-content-proof-miss-keeps-fallback ()
"A partial viewport owner or changed block height cannot skip fallback."
(let ((ebox-viewport-width 240) (ebox-viewport-height 12))
(dolist (target '((box :id "target" :height (lh 6) "old\nsecond\nthird")
(box :id "target" "old\nsecond\nthird")))
(with-temp-buffer
(ebox-render-to-buffer
(current-buffer)
(ebox-build
`(column :width (px 240) :height (vh 60)
:padding ((lh 1) (ch 1))
(box "Header") ,target (box "Tail"))))
(let ((plan (symbol-function 'ebox-surface--surface-plan))
(plans 0))
(cl-letf (((symbol-function 'ebox-surface--surface-plan)
(lambda (&rest args) (cl-incf plans) (apply plan args))))
(ebox-region-update "target" :content "new\nsecond\nthird\nfourth"))
(should (> plans 0)))
(should (equal-including-properties
(buffer-string)
(ebox-surface-test--render-runtime
(ebox--buffer-render-state (current-buffer)))))))))
(ert-deftest ebox-scroll-region-cursors-preserve-owner-hulls ()
"Extra paint property boundaries must not change cached region spans."
(let* ((line (propertize "abcdef" 'ebox-content 7))
(expected '((0 0 . 6))))
(put-text-property 1 3 'help-echo "hint" line)
(put-text-property 4 5 'face 'bold line)
(should (equal expected (gethash 7 (ebox--scroll-build-region-line-span-index (list line)))))
(with-temp-buffer
(insert line)
(let ((ebox--region-line-index (ebox--build-region-line-index)))
(should (equal '((1 . 7)) (ebox--region-set-line-spans '(7))))))))
(provide 'ebox-surface-tests) (provide 'ebox-surface-tests)
;;; ebox-surface-tests.el ends here ;;; ebox-surface-tests.el ends here

View File

@ -29,7 +29,7 @@
(let ((measure (symbol-function 'ebox--string-pixel-width)) (let ((measure (symbol-function 'ebox--string-pixel-width))
(blank (symbol-function 'ebox--pixel-blank))) (blank (symbol-function 'ebox--pixel-blank)))
(dolist (case '(("A\nB" 2) ("A\nB" 1) ("A\nB" 0) (dolist (case '(("A\nB" 2) ("A\nB" 1) ("A\nB" 0)
(nil 0) ("A" 1) ("" 1))) (nil 0) ("A" 1) ("" 0)))
(let ((expected (apply #'ebox-vertical-test--previous-pad case)) (let ((expected (apply #'ebox-vertical-test--previous-pad case))
(measure-count 0) (blank-count 0) actual) (measure-count 0) (blank-count 0) actual)
(cl-letf (((symbol-function 'ebox--string-pixel-width) (cl-letf (((symbol-function 'ebox--string-pixel-width)
@ -71,7 +71,8 @@
"Single-line copies avoid actual substring and mapconcat materialization." "Single-line copies avoid actual substring and mapconcat materialization."
(let ((substring-function (symbol-function 'substring)) (let ((substring-function (symbol-function 'substring))
(join-function (symbol-function 'mapconcat))) (join-function (symbol-function 'mapconcat)))
(dolist (input (list "" (propertize "Open" 'help-echo "kept" 'ebox-content-owner 17))) (dolist (input (list (ebox-lines-join '(""))
(propertize "Open" 'help-echo "kept" 'ebox-content-owner 17)))
(let ((split-count 0) (join-count 0) actual) (let ((split-count 0) (join-count 0) actual)
(cl-letf (((symbol-function 'substring) (cl-letf (((symbol-function 'substring)
(lambda (&rest args) (lambda (&rest args)

View File

@ -11,11 +11,11 @@
(defun ebox-viewport-topology-test--nested-scroll (map &optional count) (defun ebox-viewport-topology-test--nested-scroll (map &optional count)
"Build one ordinary nested scroll owner with interactive MAP content." "Build one ordinary nested scroll owner with interactive MAP content."
(ebox-build (ebox-build
`(column :key root :width (viewport) `(column :key root :width (vw 100)
(text "Header") (text "Header")
(column :key body :id "body" :width stretch (column :key body :id "body" :width stretch
:height (viewport-height) :overflow scroll :height (vh 100) :overflow scroll
:padding (0 (2)) :padding ((lh 0) (px 2))
,@(cl-loop ,@(cl-loop
for index below (or count 12) for index below (or count 12)
collect collect
@ -173,10 +173,10 @@
(ebox-render-to-buffer (ebox-render-to-buffer
buffer buffer
(ebox-build (ebox-build
'(column :width (viewport) '(column :width (vw 100)
(box :id "stable" :width (80) :height 2 :overflow scroll (box :id "stable" :width (px 80) :height (lh 2) :overflow scroll
(text "stable zero\nstable one\nstable two")) (text "stable zero\nstable one\nstable two"))
(box :id "affected" :width stretch :height (viewport-height) (box :id "affected" :width stretch :height (vh 100)
:overflow scroll :wrap-mode char :overflow scroll :wrap-mode char
(text "affected zero\naffected one\naffected two\naffected three\naffected four"))))) (text "affected zero\naffected one\naffected two\naffected three\naffected four")))))
(let* ((state (ebox--buffer-render-state buffer)) (let* ((state (ebox--buffer-render-state buffer))
@ -335,7 +335,7 @@
(list (ebox-box-create (list (ebox-box-create
:source-handle handle :source-builder builder :source-handle handle :source-builder builder
:owned-facts (ebox-canonical-facts-from-declarations :owned-facts (ebox-canonical-facts-from-declarations
'box '(ebox/width (viewport))) 'box '(ebox/width (vw 100)))
:layout (ebox-column-layout-create) :layout (ebox-column-layout-create)
:children (list (apply #'ebox-child-range range-ref children)))) :children (list (apply #'ebox-child-range range-ref children))))
(ebox-source-builder-finish builder))) (ebox-source-builder-finish builder)))

View File

@ -0,0 +1,139 @@
;;; ebox-zero-height-tests.el --- Empty layout height contracts -*- lexical-binding: t; -*-
;;; Commentary:
;; Empty content has no line box. Explicit lines and box chrome retain their
;; own extents, including when their inline width is zero.
;;; Code:
(require 'ert)
(require 'ebox-fixtures)
(ert-deftest ebox-zero-height-empty-boxes-and-containers ()
(dolist (dsl '((box) (box "") (box :width (px 20))
(box :bgcolor "#112233") (box :height auto)
(box :height (lh 0))
(box :padding-inline (px 2))
(box (box)) (column) (row)
(column (box) (box ""))
(row (box) (box ""))))
(ert-info ((format "empty DSL: %S" dsl))
(let* ((input (ebox-build dsl))
(root (ebox-test-root input))
(ebox--render-source-index (ebox-test-source-index input))
(rendered (ebox-render input)))
(should (equal rendered ""))
(should (= (ebox-string-height rendered) 0))
(should (= (ebox--total-height root) 0))))))
(ert-deftest ebox-zero-height-retains-content-and-explicit-lines ()
(dolist (case '(((box "x") 1)
((box " ") 1)
((box "x\ny") 2)
((box "\n") 2)
((box :height (lh 1)) 1)
((box :width (px 0) :height (lh 2)) 2)
((box :min-height (lh 2)) 2)
((box :height (lh 0.75)) 0)
((box :height (lh 0) "x") 0)
((box :height (lh 0) :overflow hidden "x") 0)
((box :height (lh 0) :overflow visible "x") 1)
((column :height (lh 0) (box "x")) 0)
((row :height (lh 0) (box "x")) 0)
((column :item-gap (lh 1) (box) (box)) 1)
((column :item-gap (lh 2) (box) (box)) 2)))
(ert-info ((format "line DSL: %S" (car case)))
(let ((rendered (ebox-render (ebox-build (car case)))))
(should (= (ebox-string-height rendered) (cadr case)))))))
(ert-deftest ebox-zero-height-composition-does-not-invent-separators ()
(dolist (dsl '((column (box "A") (box) (box "B"))
(box (box :outer block "A")
(box :outer block)
(box :outer block "B"))))
(should (equal (substring-no-properties (ebox-render (ebox-build dsl)))
"A\nB")))
(should (equal (substring-no-properties
(ebox-render (ebox-build '(row (box "A") (box) (box "B")))))
"AB"))
(should (= (ebox--string-pixel-width
(ebox-render (ebox-build '(row (box "A") (box :width (px 20))
(box "B")))))
(+ 20 (ebox--string-pixel-width "AB"))))
(should (= (ebox-string-height
(ebox-render (ebox-build '(column (box :height (lh 1))
(box :height (lh 1))))))
2)))
(ert-deftest ebox-zero-height-retains-padding-margin-and-border ()
(dolist (case '(((box :padding-block (lh 1)) 2)
((box :width (px 20) :padding-block (lh 1)) 2)
((box :margin-block (lh 1)) 2)
((box :width (px 20) :border-top "red") 1)
((box :width (px 20) :border "red") 1)
((box :width (px 20) :height (lh 0) :border "red") 1)
((box :width (px 20) :padding-block (lh 1) :border "red") 2)))
(ert-info ((format "chrome DSL: %S" (car case)))
(let* ((input (ebox-build (car case)))
(root (ebox-test-root input))
(rendered (ebox-render input)))
(should (= (ebox-string-height rendered) (cadr case)))
(should (= (ebox--total-height root) (cadr case)))))))
(ert-deftest ebox-zero-height-shared-line-representation ()
(should-not (ebox-string-lines ""))
(should (= (ebox-string-height nil) 0))
(should (= (ebox-string-height "") 0))
(should (= (ebox-string-height (ebox-lines-join '(""))) 1))
(should (= (ebox--string-pixel-width (ebox-lines-join '(""))) 0))
(should (= (ebox-string-height (ebox--pixel-blank 0 2)) 2))
(should (equal (ebox--lines-stack-vertical "A" "" "B") "A\nB"))
(should (equal (ebox--lines-concat-horizontal "" "") "")))
(ert-deftest ebox-line-measurement-preserves-display-space-widths ()
(dolist (width '(0 86.4))
(let ((line (propertize " " 'display `(space :width (,width)))))
(should (= (ebox--string-pixel-width line) width))
(should (= (ebox--string-pixel-width (ebox--lines-justify line 240))
240)))))
(ert-deftest ebox-zero-height-retained-surface-empty-content-transitions ()
(dolist (initial '((box :id "root")
(column :id "root" (box :id "child"))))
(with-temp-buffer
(ebox-render-to-buffer (current-buffer) (ebox-build initial))
(should (equal (buffer-string) ""))
(let ((revision (ebox-surface-buffer-revision (current-buffer))))
(dolist (content '("A\nB" "" "C" ""))
(let ((input
(ebox-build
(if (eq (car initial) 'column)
`(column :id "root" (box :id "child" ,content))
`(box :id "root" ,content)))))
(ebox-commit (current-buffer) input)
(should (> (ebox-surface-buffer-revision (current-buffer)) revision))
(setq revision (ebox-surface-buffer-revision (current-buffer)))
(should (equal (buffer-string) content))
(should (equal (buffer-string)
(substring-no-properties (ebox-render input)))))))
(ebox-unmount-buffer (current-buffer)))))
(ert-deftest ebox-zero-height-retained-surface-transition-rollback ()
(dolist (contents '(("" "Added") ("Removed" "")))
(with-temp-buffer
(let ((before (ebox-build `(box :id "root" ,(car contents))))
(after (ebox-build `(box :id "root" ,(cadr contents)))))
(ebox-render-to-buffer (current-buffer) before)
(let ((revision (ebox-surface-buffer-revision (current-buffer)))
(rendered (buffer-string)))
(should-error
(ebox-commit (current-buffer) after
(lambda (_report) (error "Reject height transition"))))
(should (= (ebox-surface-buffer-revision (current-buffer)) revision))
(should (equal-including-properties (buffer-string) rendered))
(ebox-commit (current-buffer) after)
(should (equal (buffer-string) (cadr contents))))
(ebox-unmount-buffer (current-buffer))))))
(provide 'ebox-zero-height-tests)
;;; ebox-zero-height-tests.el ends here