diff --git a/AGENTS.md b/AGENTS.md index e43686e..d185390 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. +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. 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. diff --git a/DESIGN.md b/DESIGN.md index 354ddaa..99b2387 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -32,7 +32,36 @@ Use the functions and properties in `docs/user/ebox-api-reference.en.md`; the fa ## 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 diff --git a/DESIGN.zh.md b/DESIGN.zh.md index 145bead..e0deca0 100644 --- a/DESIGN.zh.md +++ b/DESIGN.zh.md @@ -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 提供对应尺寸。 ## 验证规则 diff --git a/Makefile b/Makefile index f832e91..5f2bc0c 100644 --- a/Makefile +++ b/Makefile @@ -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: node-memo-generation-tests +.PHONY: interaction-tests all: check +interaction-tests: + $(EMACS_TEST) -l tests/ebox-interaction-tests.el -f ert-run-tests-batch-and-exit + +test: interaction-tests + check: ci ci: checkdoc load compile test visual-check diff-check @@ -27,7 +33,14 @@ compile: rm -f *.elc tests/*.elc scripts/*.elc $(EMACS_BATCH) --eval '(setq byte-compile-error-on-warn t byte-compile-warnings (quote (not obsolete)))' -l ebox.el --eval '(ebox-byte-compile)' -test: 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: $(EMACS_BATCH) -l tests/ebox-runtime-index-tests.el -f ert-run-tests-batch-and-exit diff --git a/README.md b/README.md index 3617d41..302e29b 100644 --- a/README.md +++ b/README.md @@ -33,10 +33,10 @@ field-based child syntax. (ebox-render-to-buffer "*Ebox Example*" (ebox-build - '(column :padding (1 2) - :border (1 solid "#8A93A6") + '(column :padding ((lh 1) (ch 2)) + :border ((px 1) solid "#8A93A6") (text :color "#263244" "Hello Ebox") - (row :item-gap 1 + (row :item-gap (ch 1) (box "Left") (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 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 - `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 only when requested; ordinary updates do not create them. Their canonical input remains usable after later updates or unmounting. Mutable -node data is detached, while immutable source facts and opaque capabilities -(callbacks, keymaps, records) retain identity. The display environment and +node data and interaction keymaps are detached, while immutable source facts +and opaque capabilities (callbacks, records) retain identity. The display environment and external capabilities are not frozen. Querying during a TP transaction or after unmounting signals an error. Compare both mount ID and revision when 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 compile 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 check EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs make native-rust-tests diff --git a/README.zh-CN.md b/README.zh-CN.md index f0bcf05..b2054d5 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -30,10 +30,10 @@ Ebox 需要 Emacs 29.1 或更高版本、ECSS 0.1.0 或更高版本,以及 TP (ebox-render-to-buffer "*Ebox Example*" (ebox-build - '(column :padding (1 2) - :border (1 solid "#8A93A6") + '(column :padding ((lh 1) (ch 2)) + :border ((px 1) solid "#8A93A6") (text :color "#263244" "Hello Ebox") - (row :item-gap 1 + (row :item-gap (ch 1) (box "Left") (box "Right"))))) ``` @@ -58,6 +58,32 @@ Ebox 需要 Emacs 29.1 或更高版本、ECSS 0.1.0 或更高版本,以及 TP 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; @@ -72,8 +98,8 @@ Ebox 会在分配 runtime identity 前复制 canonical input,因此同一个 b 挂载到多个 buffer,而不会共享 live ownership。 快照只在显式查询时遍历导出数据和 TP 最近的诊断报告,普通更新不会创建快照。其 canonical input 在后续 -更新、卸载后仍可使用;节点中的可变数据独立复制,不可变 source facts 与不透明能力 -(回调、keymap、record)保留身份。快照不冻结显示环境或外部能力。TP 事务期间、 +更新、卸载后仍可使用;节点中的可变数据与交互 keymap 独立复制,不可变 source facts +与不透明能力(回调、record)保留身份。快照不冻结显示环境或外部能力。TP 事务期间、 卸载后查询会报错。识别提交版本时同时比较 mount ID 与 revision,避免重新挂载后 revision 从头计数导致混淆。 @@ -97,6 +123,7 @@ fallback。Ebox 加载时不会自动构建它。 make load 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 interaction-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 native-rust-tests diff --git a/docs/maintainer/ebox-current-implementation-reference.en.md b/docs/maintainer/ebox-current-implementation-reference.en.md index 7f8b89a..9a07feb 100644 --- a/docs/maintainer/ebox-current-implementation-reference.en.md +++ b/docs/maintainer/ebox-current-implementation-reference.en.md @@ -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-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-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-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. | @@ -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`. +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 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. | | State Ownership Contract | `ebox-state-contract.el` | Live publication or mutable authority. | | 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. | | 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. | @@ -81,7 +98,9 @@ Caller-owned Source Tree ## Invariants - 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. - 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. @@ -107,6 +126,7 @@ Caller-owned Source Tree - `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. - 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 with its exact document revision. Candidate-session forks share that document. A synchronous frame may omit the document only when its base and target @@ -127,7 +147,7 @@ Caller-owned Source Tree ## 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 @@ -139,6 +159,9 @@ The standalone package has no bundled playground fixtures. The migrated `.ebox` make load make compile make check +make size-tests +make size-migration-tests +make interaction-tests make core-tests make runtime-index-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. + +`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' +``` diff --git a/docs/maintainer/ebox-current-implementation-reference.zh.md b/docs/maintainer/ebox-current-implementation-reference.zh.md index 35e89f3..f53b0b8 100644 --- a/docs/maintainer/ebox-current-implementation-reference.zh.md +++ b/docs/maintainer/ebox-current-implementation-reference.zh.md @@ -22,6 +22,8 @@ | `ebox-runtime-index.el` | 压缩持久 radix map 与旧 hash-table adapter。 | | `ebox-state-contract.el` | 封闭的 retained-state 所有权清单与只读 compatibility-mirror 重建探针。 | | `ebox-style.el` | ECSS property schema、declaration 与 cascade、shorthand 展开、computed style、颜色、border 和 dirty effect。 | +| `ebox-interaction.el` | 提供目标 buffer 上下文的原生帮助与 keymap 适配、四种显式节点能力的验证与独立复制、受限悬停绘制编译,以及保留嵌套显式 nil 优先级的原生 surface-property 投影。 | +| `ebox-size.el` | 显式单位的纯验证、量纲运算、依赖识别,以及基于注入测量值的浮点像素换算。 | | `ebox-tree.el` | 节点遍历、逻辑子节点访问、ECSS subject 适配、identity、父路径、key 和树 snapshot。 | | `ebox-child-range.el` | 不可变 weighted segment trie、稀疏持久 key trie、Range replacement 与 Gate A metrics。 | | `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` 覆盖。 +原生节点能力由 `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 的变更。 | | 状态所有权合同 | `ebox-state-contract.el` | Live 发布或可变 authority。 | | Computed Style | `ebox-style.el` | 布局 identity 或 patch 执行。 | +| 长度值 | `ebox-size.el` | 窗口/节点读取、属性默认值、决定显示量化边界或任意 Lisp 求值。 | | Measurement | `ebox-measure.el` | 应用状态或 dirty 策略。 | | Formatting Context | `ebox-layout.el`、`ebox-flex.el`、`ebox-grid.el` | Buffer 编辑。 | | 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--*` 名称是私有实现。 -- `'(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 的宏。 - 声明式输入始终由调用者拥有。live mount/commit 只在 surface-owned copy 上分配 identity,因此同一 source 可以挂载到多个 buffer。 - 逻辑 `: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`。 - Buffer 坐标属于生成它的 generation,变更后必须重新获取。 - Grid 使用普通测量与渲染流水线;native reflow 可以拒绝不适合的树并回退到 Elisp,正确性不变。 +- Native 尺寸路径支持公共轴向约束内的固定值和静态尺寸运算,前提是解析后的几何在 native 表示中是整数。百分比/视口依赖、小数几何和纵向固有尺寸使用普通 Elisp fallback;公共入口接受这些值,不代表 native 会加速它们。 - 已确认的 retained-native frame 持有不可变布局文档及其精确文档 revision;候选 session 的 fork 共享该文档。同步 frame 只有在文档 base/target revision 相等且 命中 confirmed 文档、同时 TP runtime revision 命中 confirmed frame 时,才可 @@ -122,7 +140,7 @@ Runtime index 行为由 `tests/ebox-runtime-index-tests.el` 覆盖。 ## 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 示例与测试 @@ -132,6 +150,9 @@ Runtime index 行为由 `tests/ebox-runtime-index-tests.el` 覆盖。 ```sh make check +make size-tests +make size-migration-tests +make interaction-tests make core-tests make runtime-index-tests make child-range-tests @@ -164,3 +185,26 @@ make diff-check ``` 先运行聚焦测试;修改共享渲染、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' +``` diff --git a/docs/user/ebox-api-reference.en.md b/docs/user/ebox-api-reference.en.md index 114f91f..925d34c 100644 --- a/docs/user/ebox-api-reference.en.md +++ b/docs/user/ebox-api-reference.en.md @@ -38,9 +38,9 @@ functions; it does not extract a node or construct a source index. ```elisp (ebox-build - '(column :padding (1 2) + '(column :padding ((lh 1) (ch 2)) (box :id "status" :color "#166534" "Ready") - (row :item-gap 1 + (row :item-gap (ch 1) (box "Left") (box "Right")))) ``` @@ -71,12 +71,107 @@ another public node or wrapper. | Text layout | `:text-align`, `:wrap-mode` (`word`, `char`, `kp`, `none`) | | Overflow | `:overflow` (`visible`, `hidden`, `scroll`) | -Ordinary horizontal numbers are character columns; a one-element horizontal -list is pixels. Vertical numbers are lines. Border widths are the exception: -they are always nonnegative integer pixels, so use `(1 solid "#687386")`, not -`((1) solid "#687386")`. `(viewport)` and -`(viewport-height)` use the current render context. Property validation belongs -to Ebox and invalid context/value combinations signal an error. +Geometry values use explicit `(unit number)` lists: `(px 240)`, `(% 50)`, +`(vw 100)`, `(vh 100)`, `(ch 80)`, and `(lh 3)`. Each property accepts only +the units in the [shared unit-constraint table](ebox-user-guide.en.md#size-unit-constraints); +lengths cannot cross from one geometry axis to the other. CSS strings, bare lengths, +singleton pixel lists, and the old viewport/contain keywords are rejected. +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 @@ -118,7 +213,7 @@ one value: (root (ebox-box-create :layout (ebox-row-layout-create - :item-gap 1 :cross-align 'center) + :item-gap '(ch 1) :cross-align 'center) :children (list left right) :source-handle root-handle :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-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-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`. | @@ -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 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 children and never call `ebox-child-range`; Range addresses exist only for 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-column-layout-create`, `ebox-flex-layout-create`, `ebox-grid-layout-create`, `ebox-box-create`. +- Interaction: `ebox-help-create`, `ebox-keymap-create`. - Render/publication: `ebox-render`, `ebox-render-to-buffer`, `ebox-unmount-buffer`, `ebox-display-buffer`, `ebox-commit`, `ebox-buffer-set-observer`, @@ -317,6 +428,7 @@ names every entry: make load 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 interaction-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 ``` diff --git a/docs/user/ebox-api-reference.zh.md b/docs/user/ebox-api-reference.zh.md index f249e13..eb312b3 100644 --- a/docs/user/ebox-api-reference.zh.md +++ b/docs/user/ebox-api-reference.zh.md @@ -35,9 +35,9 @@ forest 和拥有其 author facts 的 source generation。普通 author 代码只 ```elisp (ebox-build - '(column :padding (1 2) + '(column :padding ((lh 1) (ch 2)) (box :id "status" :color "#166534" "Ready") - (row :item-gap 1 + (row :item-gap (ch 1) (box "Left") (box "Right")))) ``` @@ -67,10 +67,87 @@ participation property 就是普通子 Box property,不会创建另一种公 | 文本布局 | `:text-align`、`:wrap-mode`(`word`、`char`、`kp`、`none`) | | Overflow | `:overflow`(`visible`、`hidden`、`scroll`) | -普通横向数字表示字符列,单元素横向 list 表示像素;纵向数字表示行数。Border -width 是例外,始终使用非负整数像素,因此写 `(1 solid "#687386")`,不写 -`((1) solid "#687386")`。`(viewport)` 与 `(viewport-height)` 使用当前 render context。property 验证属于 -Ebox;context/value 组合无效时会直接报错。 +几何值统一使用 `(单位 数值)`:`(px 240)`、`(% 50)`、`(vw 100)`、`(vh 100)`、 +`(ch 80)`、`(lh 3)`。每个属性只接受[统一单位约束表](ebox-user-guide.zh.md#size-unit-constraints) +规定的单位,不能跨几何轴使用。不接受 CSS 字符串、裸长度、单元素像素列表及旧的 +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` 接收 alist,key 是按键描述字符串或事件向量,value 是零参数函数。 +两项都可省略,`nil` 表示不添加相应绑定。重复按键和重叠前缀会报错,包括与激活键 +重叠的绑定。辅助函数返回原生 Emacs keymap;回调保留为函数,直到对应命令触发时才调用。 +鼠标事件会自动从事件取得窗口,并在其 buffer 中运行回调,不选择窗口或移动 point。 +键盘回调在当前 buffer 中运行,由 point 所在位置进行原生分派。 + +仍支持直接传入原生 keymap;其鼠标命令需要自行从事件取得目标窗口,并在该窗口的 +buffer 中操作。这些能力不引入 Ebox 焦点导航或应用状态系统。 +可运行示例见[交互指南](ebox-user-guide.zh.md#native-node-interaction)。 ## 2. Evaluated typed construction @@ -110,7 +187,7 @@ facts。最后用一个 `CanonicalEboxInput` 同时传递 forest 与封存的 so (root (ebox-box-create :layout (ebox-row-layout-create - :item-gap 1 :cross-align 'center) + :item-gap '(ch 1) :cross-align 'center) :children (list left right) :source-handle root-handle :owned-facts @@ -203,7 +280,7 @@ observer 为 nil 时,Ebox 不创建 observation context,不取 timestamp/GC | --- | --- | | `ebox-region-ids` | 按文档顺序返回当前底层 region id。 | | `ebox-region-resolve` | 把 mounted buffer 中一个逻辑 `:id` 解析为不透明、surface-scoped 的 handle。 | -| `ebox-region-update` | 通过一次 retained transaction 应用支持的可变样式或滚动 property。 | +| `ebox-region-update` | 通过一次 retained transaction 应用支持的可变样式、滚动或原生节点能力 property;接受不透明 region handle,或当前已挂载 buffer 中的语义 `:id`。 | | `ebox-child-range` | 仅供框架集成:在组装 typed canonical input 时构造非视觉、可寻址的 child Range descriptor。 | | `ebox-candidate-begin` | 在 one-shot candidate 中捕获当前准确 mounted generation。 | | `ebox-candidate-replace` | 用 single-root `CanonicalEboxInput` 替换一个 candidate node address。 | @@ -217,6 +294,19 @@ observer 为 nil 时,Ebox 不创建 observation context,不取 timestamp/GC handle 和位置只属于一个 live publication generation;对象被删除后要重新解析。 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,不调用 `ebox-child-range`;Range address 只服务已经拥有 semantic child range 与 candidate update 的框架。 @@ -265,6 +355,7 @@ native 模块只是加速器,不是正确性依赖。加载包不会构建它 - 构造:`ebox-build`、`ebox-text-create`、`ebox-normal-layout-create`、 `ebox-row-layout-create`、`ebox-column-layout-create`、 `ebox-flex-layout-create`、`ebox-grid-layout-create`、`ebox-box-create`; +- 交互:`ebox-help-create`、`ebox-keymap-create`; - 渲染/发布:`ebox-render`、`ebox-render-to-buffer`、`ebox-unmount-buffer`、 `ebox-display-buffer`、`ebox-commit`、`ebox-buffer-set-observer`、`ebox-buffer-update-report`、 `ebox-rerender-buffer-with-context`、`ebox-viewport-window-width`、 @@ -292,6 +383,7 @@ native 模块只是加速器,不是正确性依赖。加载包不会构建它 make load 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 interaction-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 ``` diff --git a/docs/user/ebox-user-guide.en.md b/docs/user/ebox-user-guide.en.md index 21cf952..f1a9989 100644 --- a/docs/user/ebox-user-guide.en.md +++ b/docs/user/ebox-user-guide.en.md @@ -37,10 +37,10 @@ child-layout algorithm, not a different kind of visual object. ```elisp (defvar ebox-guide-input (ebox-build - '(column :padding (1 2) - :border (1 solid "#8A93A6") + '(column :padding ((lh 1) (ch 2)) + :border ((px 1) solid "#8A93A6") (text :color "#263244" "Research notes") - (row :item-gap 1 + (row :item-gap (ch 1) (box :id "status" :background-color "#F4F6FB" "Inbox") (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. 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 Use `box` for ordinary content, `row` or `column` for direct one-axis @@ -66,9 +101,9 @@ ordinary Text or Box nodes. ```elisp (ebox-build - '(row :item-gap 2 :cross-align center - (box :width 12 "Left") - (box :width 12 "Right"))) + '(row :item-gap (ch 2) :cross-align center + (box :width (ch 12) "Left") + (box :width (ch 12) "Right"))) ``` ### Flex @@ -78,9 +113,9 @@ directly to a child `box` because they describe the parent-child relationship. ```elisp (ebox-build - '(flex :width (480) + '(flex :width (px 480) :flex-flow (row wrap) - :gap (1 (12)) + :gap ((lh 1) (px 12)) (box :flex (1 1 auto) "Primary") (box :flex-grow 2 "Secondary"))) ``` @@ -93,10 +128,10 @@ child `box`. ```elisp (ebox-build - '(grid :width (640) - :grid-template-columns ((200) (fr 1) (fr 1)) - :grid-template-rows (1 1) - :gap (1 (12)) + '(grid :width (px 640) + :grid-template-columns ((px 200) (fr 1) (fr 1)) + :grid-template-rows ((lh 1) (lh 1)) + :gap ((lh 1) (px 12)) (box :grid-column (1 :span 3) "Header") (box :grid-column 1 :grid-row 2 "Navigation") (box :grid-column 2 :grid-row 2 "Main") @@ -105,19 +140,126 @@ child `box`. ## 4. Use geometry and paint properties -Horizontal numbers are character columns; a one-element list such as `(240)` -is a pixel width. Vertical numbers are lines. Text accepts only font, -foreground/background, and text-decoration properties. Padding, margin, +Box geometry uses explicit `(unit number)` values. The same syntax applies to +width, height, their minimum/maximum bounds, padding, margins, gaps, borders, +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. + + + +### 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 to Box. Font and color on Box may feed inherited Text facts, but never give Text Box geometry. ```elisp (ebox-build - '(box :width (420) - :padding (1 2) - :margin (0 1) - :border (1 solid "#8A93A6") + '(box :width (px 420) + :padding ((lh 1) (ch 2)) + :margin ((lh 0) (ch 1)) + :border ((px 1) solid "#8A93A6") :color "#263244" :background-color "#FFFFFF" "A readable panel")) @@ -126,6 +268,74 @@ Text Box geometry. 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. + +### 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 `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 Guide*" (ebox-build - '(column :padding (1 2) + '(column :padding ((lh 1) (ch 2)) (text "Updated notes") (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 `: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 (let ((handle (ebox-region-resolve "*Ebox Guide*" "status"))) (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 buffer. `ebox-selector-update-buffer` applies one style update to all editable 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 (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")) ``` @@ -193,18 +430,71 @@ explicit viewport with: ## 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 -(column :padding '(1 2) - (text :color "#263244" "Title") - (box :width '(240) "Body")) +'(column :padding ((lh 1) (ch 2)) + :cross-align center + (text :color "#263244" "Title") + (box :width (px 240) "Body")) ``` -The sibling `ebox-playground` evaluates property expressions before calling -`ebox-build`. Quote list and symbol constants in a `.ebox` file, as shown -above. When calling `ebox-build` directly, pass inert data as in the earlier -examples. +The sibling `ebox-playground` reads exactly one expression, evaluates it with +lexical bindings, and passes the resulting data to `ebox-build`. It does not +evaluate individual properties or children. The expression is exactly what you +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 @@ -246,7 +536,7 @@ the forest and source index together as one `CanonicalEboxInput`. (root (ebox-box-create :layout (ebox-row-layout-create - :item-gap 1 :cross-align 'center) + :item-gap '(ch 1) :cross-align 'center) :children (list left right) :source-handle root-handle :owned-facts @@ -277,6 +567,7 @@ From the repository root: ```sh 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 check EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs ``` diff --git a/docs/user/ebox-user-guide.zh.md b/docs/user/ebox-user-guide.zh.md index 4d42bf0..fe0a034 100644 --- a/docs/user/ebox-user-guide.zh.md +++ b/docs/user/ebox-user-guide.zh.md @@ -33,10 +33,10 @@ Ebox 文档由 Text 和 Box 节点组成。author 语法只有七个入口: ```elisp (defvar ebox-guide-input (ebox-build - '(column :padding (1 2) - :border (1 solid "#8A93A6") + '(column :padding ((lh 1) (ch 2)) + :border ((px 1) solid "#8A93A6") (text :color "#263244" "Research notes") - (row :item-gap 1 + (row :item-gap (ch 1) (box :id "status" :background-color "#F4F6FB" "Inbox") (box :background-color "#EEF2FF" "Archive"))))) ``` @@ -47,6 +47,33 @@ generation 保持为一个整体。普通 author 代码只需把这个值原样 需要空矩形区域时,使用只有几何 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. 用布局名称直接表达意图 普通内容用 `box`;直接的一维组合用 `row` 或 `column`;需要分配剩余空间或换行时 @@ -59,9 +86,9 @@ generation 保持为一个整体。普通 author 代码只需把这个值原样 ```elisp (ebox-build - '(row :item-gap 2 :cross-align center - (box :width 12 "Left") - (box :width 12 "Right"))) + '(row :item-gap (ch 2) :cross-align center + (box :width (ch 12) "Left") + (box :width (ch 12) "Right"))) ``` ### Flex @@ -71,9 +98,9 @@ Flex 容器 property 属于 `flex`。participation property 直接属于子 `box ```elisp (ebox-build - '(flex :width (480) + '(flex :width (px 480) :flex-flow (row wrap) - :gap (1 (12)) + :gap ((lh 1) (px 12)) (box :flex (1 1 auto) "Primary") (box :flex-grow 2 "Secondary"))) ``` @@ -85,10 +112,10 @@ placement property 同样直接属于子 `box`。 ```elisp (ebox-build - '(grid :width (640) - :grid-template-columns ((200) (fr 1) (fr 1)) - :grid-template-rows (1 1) - :gap (1 (12)) + '(grid :width (px 640) + :grid-template-columns ((px 200) (fr 1) (fr 1)) + :grid-template-rows ((lh 1) (lh 1)) + :gap ((lh 1) (px 12)) (box :grid-column (1 :span 3) "Header") (box :grid-column 1 :grid-row 2 "Navigation") (box :grid-column 2 :grid-row 2 "Main") @@ -97,17 +124,108 @@ placement property 同样直接属于子 `box`。 ## 4. 使用几何与绘制 property -普通横向数字表示字符列,`(240)` 这样的单元素 list 表示像素宽度;纵向数字表示 -行数。Text 只接受字体、前景/背景和文本装饰 property;padding、margin、border、 +Box 几何统一使用 `(单位 数值)`。宽高及其最小/最大值、内外边距、gap、边框宽度、 +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 使用预览窗口。 + + + +### 按属性限制单位 + +下表是所有 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)` 中的 basis;row 方向用 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 上的字体与颜色可以 作为其 Text 子树的继承来源,但不会让 Text 获得 Box 几何。 ```elisp (ebox-build - '(box :width (420) - :padding (1 2) - :margin (0 1) - :border (1 solid "#8A93A6") + '(box :width (px 420) + :padding ((lh 1) (ch 2)) + :margin ((lh 0) (ch 1)) + :border ((px 1) solid "#8A93A6") :color "#263244" :background-color "#FFFFFF" "A readable panel")) @@ -116,6 +234,65 @@ placement property 同样直接属于子 `box`。 用 `:outer inline` 或 `:outer block` 表达 Box 如何参与父布局。子布局算法仍由 form 名称决定。 + +### 原生节点交互 + +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. 渲染与发布 `ebox-render` 返回带属性文本,不修改 live buffer;`ebox-render-to-buffer` 挂载 @@ -133,7 +310,7 @@ retained TP surface 并返回 buffer。它们和 `ebox-display-buffer` 都接收 (ebox-commit "*Ebox Guide*" (ebox-build - '(column :padding (1 2) + '(column :padding ((lh 1) (ch 2)) (text "Updated notes") (box :key body "The new input is caller-owned.")))) ``` @@ -143,14 +320,36 @@ retained TP surface 并返回 buffer。它们和 `ebox-display-buffer` 都接收 ## 6. 查询和更新 mounted surface -`:id`、`:class` 与 `:key` 是 author metadata。Selector 使用 ECSS 语义。直接更新 -前,把 `:id` 解析为不透明、surface-scoped 的 handle: +`:id`、`:class` 与 `:key` 是 author metadata。Selector 使用 ECSS 语义。 +在目标已挂载 buffer 中,可以直接传入语义 `:id`: + +```elisp +(with-current-buffer "*Ebox Guide*" + (ebox-region-update "status" :color "#166534")) +``` + +需要显式指定 buffer 时,把 `:id` 解析为不透明、surface-scoped 的 handle: ```elisp (let ((handle (ebox-region-resolve "*Ebox Guide*" "status"))) (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-update-buffer` 对所有可编辑匹配应用一次样式更新。数字 region id 只是诊断用渲染 metadata,不是稳定更新 handle。 @@ -164,7 +363,7 @@ retained TP surface 并返回 buffer。它们和 `ebox-display-buffer` 都接收 ```elisp (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")) ``` @@ -177,17 +376,62 @@ retained TP surface 并返回 buffer。它们和 `ebox-display-buffer` 都接收 ## 8. 独立 `.ebox` 文件 -`.ebox` 文件包含一个不加 quote 的结构 form: +`.ebox` 文件包含一个普通 Elisp 表达式,其求值结果就是交给 `ebox-build` 的布局 +数据。静态布局对整个列表加 quote: ```elisp -(column :padding '(1 2) - (text :color "#263244" "Title") - (box :width '(240) "Body")) +'(column :padding ((lh 1) (ch 2)) + :cross-align center + (text :color "#263244" "Title") + (box :width (px 240) "Body")) ``` -同级 `ebox-playground` 会先求值 property 表达式,再调用 `ebox-build`。因此 `.ebox` -文件中的 list 与 symbol 常量要加 quote,如上所示。直接调用 `ebox-build` 时,则像 -前面的例子一样传入 inert data。 +同级 `ebox-playground` 读取唯一一个表达式,以词法绑定求值,再将得到的数据传给 +`ebox-build`,不再单独求值属性或子节点。这与 `.el` 中 `(ebox-build ...)` 的参数 +表达式完全一致;在 `.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 @@ -228,7 +472,7 @@ declarations 投影出的 node-owned facts。最后封存 builder,把 forest (root (ebox-box-create :layout (ebox-row-layout-create - :item-gap 1 :cross-align 'center) + :item-gap '(ch 1) :cross-align 'center) :children (list left right) :source-handle root-handle :owned-facts @@ -258,6 +502,7 @@ Rust 模块是可选加速器,Ebox 加载时不会构建它: ```sh 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 check EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs ``` diff --git a/ebox-buffer-backend.el b/ebox-buffer-backend.el index 149df53..ac3d78a 100644 --- a/ebox-buffer-backend.el +++ b/ebox-buffer-backend.el @@ -9,10 +9,12 @@ (define-error 'ebox-surface-capability-error "Ebox surface capability violation") +(declare-function ebox-get "ebox" (box property)) + (defun ebox-buffer-validate-border-capability (style) "Return STYLE when its border can be represented exactly by Emacs text." (dolist (side '(top bottom) style) - (let ((width (or (plist-get + (let ((width (or (ebox-get style (intern (format ":border-%s-pixel" side))) 0))) (when (> width 1) diff --git a/ebox-canonical.el b/ebox-canonical.el index cd45354..f0f4452 100644 --- a/ebox-canonical.el +++ b/ebox-canonical.el @@ -18,6 +18,8 @@ (require 'ebox-child-range) (require 'ebox-tree) +(declare-function ebox--flex-validate-item-bases "ebox-flex" (props children)) + (defun ebox-canonical--validate-plist (plist context) "Validate PLIST shape for error CONTEXT and return PLIST." (unless (proper-list-p plist) @@ -321,10 +323,11 @@ and author TAG." (eq (plist-get property :group) 'paint))))) 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. 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 (if (eq tag 'text) #'ebox-canonical--text-engine-property-p @@ -335,7 +338,12 @@ only the current canonical-node projection needed before G4c separates paint." predicate (if (eq tag 'text) "Ebox Text" "Ebox Box") tag))) (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 :specified-property-ids (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)) (error "Ebox Box Layout is not implemented: %S" (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 (error "Ebox Box accepts only normalized owned facts")) (when (not (ebox-canonical-facts-p facts)) diff --git a/ebox-dsl.el b/ebox-dsl.el index bbfb3c9..200cb70 100644 --- a/ebox-dsl.el +++ b/ebox-dsl.el @@ -145,7 +145,11 @@ function." (if outer-field (cdr outer-field) 'block))) (style-properties (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 (ebox-dsl--compile-style tag style-properties context))) @@ -158,6 +162,7 @@ function." :id (ebox-dsl--source-field properties :id context) :class (ebox-dsl--source-field properties :class context) :declarations declarations + :interactions interactions :provenance (list :adapter 'ebox-dsl :tag tag :source-present-p (not (null source)))) :outer outer @@ -166,7 +171,7 @@ function." tag declarations context) :declarations declarations :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) "The complete Ebox author tag set.") diff --git a/ebox-flex.el b/ebox-flex.el index 37fcdfa..898b6a2 100644 --- a/ebox-flex.el +++ b/ebox-flex.el @@ -11,6 +11,7 @@ (require 'subr-x) (require 'ebox-layout-config) (require 'ebox-layout) +(require 'ebox-size) (declare-function ebox--flex-fragment-allocation-key "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) "Return flex layout PROPS adjusted to WRAPPER-BOX's content area." - (if (null wrapper-box) - props - (let ((props (copy-sequence props))) + (let ((props (copy-sequence props))) + (when wrapper-box (when (or (plist-member raw-props :width) (and (null (plist-get props :width)) (ebox--viewport-pixel-width nil))) (when-let* ((content-viewport (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) (ebox--resolve-size-content-height wrapper-box (ebox-get wrapper-box :height) nil)) - (plist-put props :height (ebox--content-height wrapper-box 1))) - props))) + (plist-put props :height (list 'lh (ebox--content-height wrapper-box 0))))) + (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 - '(auto min-content max-content fit-content stretch contain) + '(auto min-content max-content fit-content stretch) "Supported CSS preferred-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.") (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.") (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." (cond ((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) - ((and (consp value) (eq (car value) 'fit-content)) - (if-let* ((limit (cadr value))) - (let ((pixels (if (ebox--viewport-size-value-p limit) - '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)))) + ((ebox-size-value-p value nil t) value) + (t (error "ebox: unsupported size %S; use (UNIT NUMBER) or a size keyword" + value)))) (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 ((null value) (or default (ebox--viewport-pixel-width nil))) ((memq value '(auto stretch)) (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) - "Return VALUE as line count using Ebox vertical units." + "Resolve VALUE as whole display lines, using DEFAULT when unresolved." (cond ((null value) default) ((eq value 'auto) default) - ((and (consp value) (numberp (car value))) - (let ((lines (floor (car value)))) - (when (< lines 0) - (error "ebox: flex line values cannot be negative: %S" value)) - lines)) - ((numberp value) - (let ((lines (floor value))) - (when (< lines 0) - (error "ebox: flex line values cannot be negative: %S" value)) - lines)) + ((numberp value) (max 0 (floor value))) + ((ebox-size-value-p value nil t) + (if-let* ((pixels (ebox--size-pixels value nil 'height))) + (max 0 (floor (/ pixels (float (ebox--size-line-height nil))))) + default)) (t 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) "Return (ROW-GAP . COLUMN-GAP) raw values from GAP shorthand." (cond - ((and (listp gap) + ((and (proper-list-p gap) (= (length gap) 2) - (not (keywordp (car gap)))) + (not (ebox-size-value-p gap nil t))) (cons (nth 0 gap) (nth 1 gap))) (t (cons gap gap)))) @@ -290,18 +269,45 @@ ALLOWED-KEYWORDS is the CSS keyword set valid for the property being parsed." resolved)) (defun ebox--flex-row-gap-value (value) - "Return canonical nonnegative line count for FlexConfig row gap VALUE." - (unless (or (null value) - (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)) + "Return canonical, unresolved FlexConfig row gap VALUE." + (ebox-size-validate-for-axis (or value '(lh 0)) :row-gap 'block)) (defun ebox--flex-column-gap-value (value) - "Return canonical nonnegative pixels for FlexConfig column gap VALUE." - (or (ebox--nonnegative-horizontal-size-pixels value 0) 0)) + "Return canonical, unresolved FlexConfig column gap VALUE." + (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) "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) (let ((row-gap (plist-get props :row-gap)) (column-gap (plist-get props :column-gap))) - (and (integerp row-gap) (>= row-gap 0) - (numberp column-gap) (>= column-gap 0))))) + (and (ebox-size-value-for-axis-p row-gap 'block) + (ebox-size-value-for-axis-p column-gap 'inline))))) ;;;###autoload (defun ebox-flex-layout-create (&rest plist) @@ -443,7 +449,7 @@ programmatic port and are therefore rejected here." (let ((flex (plist-get props :flex))) (cond ((numberp flex) - (setq grow flex shrink 1 basis 0)) + (setq grow flex shrink 1 basis '(% 0))) ((eq flex 'none) (setq grow 0 shrink 0 basis 'auto)) ((eq flex 'auto) @@ -476,20 +482,40 @@ programmatic port and are therefore rejected here." "Return normalized flex item metadata for 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) - "Return max pixel width among STRING lines." - (ebox--string-max-pixel-width string)) + "Return STRING's layout width, excluding separately observed visible ink." + (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) "Return STRING main-axis size for AXIS." (if (eq axis 'row) (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) "Return STRING cross-axis size for AXIS." (if (eq axis 'row) - (ebox-string-height string) + (or (ebox--rendered-intrinsic-size string :height) + (ebox-string-height string)) (ebox--flex-string-width string))) (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) "Return BOX's minimum renderable main-axis size for AXIS. -RENDERED is BOX rendered at its natural size. No-wrap content and -preformatted wrapper boxes cannot be safely compressed below that rendered -size; wrapping content may shrink down to its declared minimum and widest -grapheme." - (if (eq axis 'row) - (let ((declared-min (or (ebox--resolve-size-content-pixel - box (ebox-get box :min-width) 0) - 0))) - (if (ebox-style-no-soft-wrap-p (ebox-get box :wrap-mode)) - ;; CSS's automatic minimum is min-content only when the author - ;; leaves `min-width' at its automatic value. A component that - ;; deliberately declares `:min-width 0' must be allowed to - ;; shrink even when its own rows remain no-wrap (the surrounding - ;; card may then own horizontal overflow). - (if (ebox--flex-explicit-min-main-p box axis) - (+ (ebox--side-pixel box) declared-min) - (max (ebox--flex-string-main rendered axis) - (+ (ebox--side-pixel box) declared-min))) - (let ((content-min (ebox--content-min-pixel box))) - (when (hash-table-p ebox--flex-content-min-width-table) - (puthash box content-min - ebox--flex-content-min-width-table)) - (+ (ebox--side-pixel box) - (max declared-min content-min))))) - (+ (ebox--side-height box) - (max 1 - (or (ebox--box-sizing-content-height - box (ebox-get box :min-height)) - 0))))) +RENDERED is BOX rendered at its natural size. Automatic minima use content +only for non-scrollable overflow; explicit minima bypass that constraint." + (let* ((horizontal (eq axis 'row)) + (side (if horizontal (ebox--side-pixel box) (ebox--side-height box))) + (minimum (ebox-get box (if horizontal :min-width :min-height))) + (declared (if horizontal + (ebox--resolve-size-content-pixel box minimum 0) + (ebox--resolve-size-content-height box minimum 0))) + (automatic (and (not (ebox--flex-explicit-min-main-p box axis)) + (not (memq (ebox-get box :overflow) + '(auto scroll hidden)))))) + (if (not automatic) + (+ side (or declared 0)) + (let* ((content-min + (if (and horizontal + (ebox-style-soft-wrap-p (ebox-get box :wrap-mode))) + (ebox--content-min-pixel box) + (max 0 (- (ebox--flex-string-main rendered axis) side)))) + (preferred (ebox-get box (if horizontal :width :height))) + (definite (and (or (numberp preferred) + (ebox-size-value-p preferred nil t)) + (ebox--flex-box-main-constraint + box axis (if horizontal :width :height)))) + (maximum (ebox--flex-box-main-constraint + box axis (if horizontal :max-width :max-height)))) + (when (and horizontal (hash-table-p ebox--flex-content-min-width-table)) + (puthash box content-min ebox--flex-content-min-width-table)) + (max side (min (+ side content-min) + (or definite most-positive-fixnum) + (or maximum most-positive-fixnum))))))) (defun ebox--flex-explicit-min-main-p (box axis) "Return non-nil when BOX declares a main-axis minimum explicitly." - (plist-get box - (if (eq axis 'row) - :ebox-explicit-min-width-p - :ebox-explicit-min-height-p))) + (not (memq (ebox-get box (if (eq axis 'row) :min-width :min-height)) + '(nil auto)))) (defun ebox--flex-box-main-constraint (box axis property) "Return BOX's total main-axis size constraint from PROPERTY." @@ -654,8 +679,8 @@ grapheme." box (ebox-get box property) nil))) (+ (ebox--side-pixel box) content-size)) (when-let* ((block-size - (ebox--box-sizing-content-height - box (ebox-get box property)))) + (ebox--resolve-size-content-height + box (ebox-get box property) nil))) (+ (ebox--side-height box) block-size))))) (defun ebox--flex-min-main (source rendered axis) @@ -681,20 +706,26 @@ grapheme." "Return SOURCE's flex base size for AXIS from BASIS." (cond ((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) (if (ebox--flex-box-source-p source) (ebox--flex-box-content-basis-main source 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 (ebox--normalize-horizontal-size-value basis ebox--preferred-size-keywords)) (content-size - (or (ebox--resolve-size-content-pixel source normalized nil) - (ebox--content-max-pixel source)))) - (+ (ebox--side-pixel source) content-size))) + (if (eq axis 'row) + (or (ebox--resolve-size-content-pixel source normalized nil) + (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 (ebox--flex-main-value axis basis 0)))) @@ -745,17 +776,40 @@ grapheme." (if horizontal (ebox--side-pixel box) (ebox--side-height box))))) - (max (if horizontal 0 1) - (- target outer-size)))) + (max 0 (- target outer-size)))) (defun ebox--flex-copy-put-size (copy property value) "Set COPY's layout-computed size PROPERTY to 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) "Return NODE or a copy with flex-computed MAIN/CROSS size." (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) (plist-put copy :region-id (ebox-get node :region-id)) (when main @@ -828,8 +882,12 @@ PROBE reuses the caller's exact measurement cache lookup when supplied." (ebox--flex-render-source-for-measurement source viewport) (ebox--flex-recache-source-boxes source)))) - (rendered-main (ebox--flex-string-main rendered axis)) - (rendered-cross (ebox--flex-string-cross rendered axis)) + (rendered-main (if (eq axis 'row) + (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)) (min-main (ebox--flex-min-main source rendered 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) "Return non-nil when BASIS is a context-free numeric flex size." - (or (numberp basis) - (and (consp basis) - (numberp (car basis)) - (null (cdr basis))))) + (and (ebox-size-value-p basis nil t) + (not (cl-intersection (ebox-size-dependencies basis) + '(percent viewport-width viewport-height))))) + +(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) "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))) (when (and (ebox--flex-box-source-p source) (ebox--flex-fixed-basis-value-p basis) - (or (not (eq axis 'row)) - (ebox-style-soft-wrap-p - (ebox-get source :wrap-mode)))) + (if (eq axis 'row) + (ebox-style-soft-wrap-p (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)) (max-main (ebox--flex-max-main source axis)) (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-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) "Pad every line of STRING to WIDTH pixels." (let ((align (or align 'flex-start))) - (ebox--maplines + (ebox--flex-record-composite-intrinsic-size + (ebox--maplines (lambda (line) (let* ((line-width (ebox--string-pixel-width line)) (extra (max 0 (- width line-width))) @@ -911,7 +991,8 @@ metrics, and render intrinsically only when layout semantics require it." (setq right-space (ebox--add-content-owner right-space owner-id))) (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) "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 (ebox--add-content-owner blank owner-id) blank))) - (ebox-lines-join - (append (make-list top blank) - lines - (make-list bottom blank))))) + (ebox--flex-record-composite-intrinsic-size + (ebox-lines-join + (append (make-list top blank) + lines + (make-list bottom blank))) + (list string) (ebox--rendered-intrinsic-size string :width) height))) (defun ebox--flex-sized-entry (rendered main cross) "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)) (or (not stretch) (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. ;; Auto/relative sizes and overflow-expanded output ;; still need the final stretched Box render. (let ((declared-cross - (ebox-get source (if (eq axis 'row) - :height :width)))) + (ebox--flex-definite-property + source (if (eq axis 'row) 'column 'row) + (ebox-get source (if (eq axis 'row) + :height :width))))) (and (numberp declared-cross) (= declared-cross (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 render-main (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)) (if (eq axis 'row) (or cross (if can-use-measured render-cross - (ebox-string-height rendered))) + (ebox--flex-string-cross rendered axis))) (or cross (if can-use-measured render-cross (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))) (defun ebox--flex-distribute (amount weights) - "Distribute integer AMOUNT across WEIGHTS, preserving total exactly." - (let* ((amount (max 0 (floor amount))) + "Distribute AMOUNT across WEIGHTS, retaining fractional free space. +Whole amounts retain the established deterministic remainder order." + (let* ((amount (max 0 amount)) (positive (cl-remove-if-not (lambda (weight) (> weight 0)) weights)) (total (apply #'+ (or positive '(0))))) - (if (or (<= amount 0) (<= total 0)) - (make-list (length weights) 0) + (cond + ((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 (mapcar (lambda (weight) (if (> weight 0) @@ -1244,7 +1335,7 @@ Return a sized render entry containing the rendered string and dimensions." when (> weight 0) do (cl-incf (car cell)) and do (cl-decf remaining))) - shares)))) + shares))))) (defun ebox--flex-sum (numbers) "Return the sum of NUMBERS." @@ -1472,6 +1563,7 @@ metadata and rendering decisions." (t (dolist (index (or max-violations active-indices)) (aset frozen index t))))))))))))) + (setq targets (vconcat (ebox-size-quantize (append targets nil)))) (cl-loop for item in line for index from 0 collect (let ((copy (copy-sequence item))) @@ -1516,7 +1608,7 @@ metadata and rendering decisions." (ebox--flex-render-sized-entry item axis (plist-get item :target) nil container-align))) 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) "Return plist describing cross-axis line placement." @@ -1548,7 +1640,8 @@ metadata and rendering decisions." (leading (nth 0 spacing)) (between (nth 1 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 for tail on line for main = (plist-get item :target) @@ -1557,11 +1650,14 @@ metadata and rendering decisions." do (push (cons (ebox--flex-sized-entry-rendered entry) main) parts) - unless (null (cdr tail)) - do (push (cons (ebox-pixel-space between) between) parts)) + when (and (cdr tail) (> between 0)) + do (push (cons (ebox--pixel-blank between line-cross) between) parts)) (when (> trailing 0) - (push (cons (ebox-pixel-space trailing) trailing) parts)) - (ebox--flex-lines-concat-horizontal-sized (nreverse parts) line-cross))) + (push (cons (ebox--pixel-blank trailing line-cross) trailing) parts)) + (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) "Render one column-axis flex LINE." @@ -1582,8 +1678,11 @@ metadata and rendering decisions." unless (null (cdr tail)) do (when (> between 0) (push (ebox--pixel-blank line-cross between) pieces))) - (let ((result (apply #'ebox--lines-stack-vertical (nreverse pieces)))) - (ebox--flex-pad-height result target-main 0 line-cross)))) + (setq pieces (nreverse pieces)) + (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) "Render flex LINES for a row-axis container." @@ -1622,9 +1721,14 @@ metadata and rendering decisions." do (when (> between 0) (push (ebox--pixel-blank container-main between) pieces))) (let ((result (apply #'ebox--lines-stack-vertical (nreverse pieces)))) - (if cross-size - (ebox--flex-pad-height result cross-size 0 container-main) - result)))) + (ebox--flex-record-composite-intrinsic-size + (if cross-size + (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) "Render flex LINES for a column-axis container." @@ -1649,16 +1753,29 @@ metadata and rendering decisions." (ebox--flex-render-column-line line cross main-size main-gap justify align)) 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 for tail on rendered-columns do (push rendered parts) - unless (null (cdr tail)) - do (push (ebox-pixel-space between) parts)) + when (and (cdr tail) (> between 0)) + do (push (ebox--pixel-blank between height) parts)) (let ((result (apply #'ebox--lines-concat-horizontal (nreverse parts)))) - (if cross-size - (ebox--flex-pad-width result cross-size) - result)))) + (ebox--flex-record-composite-intrinsic-size + (if cross-size + (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) "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))) 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. Width is in pixels and height in lines; nil means unbounded, including an intrinsic max-content measurement. Author units and viewport defaults have -already been resolved by the Box or legacy adapter." - (let* ((props config) +already been resolved by the Box or legacy adapter. BOX supplies font metrics." + (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)) (main-size (if (eq axis 'row) (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 ;; a deeper responsive Flex to recover this Box's outer viewport. (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 axis main-size cross-size)) (items (ebox--flex-collect-items children axis inline-viewport)) @@ -1696,7 +1823,7 @@ already been resolved by the Box or legacy adapter." (nreverse items) items)) (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)) (main-gap (if (eq axis 'row) column-gap row-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) "Project BOX content dimensions to resolved private FlexConstraints." (list :width (ebox--wrapper-content-viewport-pixel box) - :height (ebox--resolve-size-content-height - box (ebox-get box :height) nil))) + :height (when-let* ((height (ebox--resolve-size-content-height + box (ebox-get box :height) nil))) + (floor height)))) (defun ebox--flex-legacy-constraints (props) "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 props (ebox--flex-box-constraints box) - children)) + children box)) (defun ebox--render-flex (node) "Render legacy flex NODE to a propertized string." @@ -1839,9 +1967,12 @@ assembles already-rendered items." (defun ebox--flex-window-props (node) "Return NODE's Flex properties with resolved dimensions for lazy rows." (if (ebox--flex-window-canonical-node-p node) - (append - (ebox-layout-config-props (plist-get node :ebox-layout-config)) - (ebox--flex-box-constraints node)) + (let ((constraints (ebox--flex-box-constraints node))) + (append + (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 (plist-get node :props) (plist-get node :raw-props) @@ -1852,10 +1983,13 @@ assembles already-rendered items." (defun ebox--flex-window-wrapper-supported-p (box) "Return non-nil when BOX can safely wrap an incomplete flex prefix." (or (null box) - (and (null (ebox-get box :height)) - (or (null (ebox-get box :min-height)) - (equal (ebox-get box :min-height) 0)) - (null (ebox-get box :max-height)) + (and (memq (ebox-get box :height) '(nil auto)) + (or (memq (ebox-get box :min-height) '(nil auto)) + (equal (ebox-get box :min-height) 0) + (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)) (ebox--flex-window-canonical-node-p box)) (eq (ebox-get box :text-align) 'left) diff --git a/ebox-font.el b/ebox-font.el index c3bf99c..39fa637 100644 --- a/ebox-font.el +++ b/ebox-font.el @@ -108,7 +108,8 @@ (info (and name (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 :identity (if font @@ -124,6 +125,7 @@ :metrics (if info (list :pixel-size (aref info 2) + :zero-width zero-width :height (aref info 3) :baseline-offset (aref info 4) :max-width (aref info 7) @@ -132,6 +134,7 @@ :space-width (aref info 10) :average-width (aref info 11)) (list :probe-width (string-pixel-width probe) + :zero-width zero-width :space-width (string-pixel-width (propertize " " 'face face))))))) diff --git a/ebox-grid.el b/ebox-grid.el index 9e9a881..0be51aa 100644 --- a/ebox-grid.el +++ b/ebox-grid.el @@ -4,7 +4,7 @@ ;;; Commentary: ;; 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. ;;; Code: @@ -34,6 +34,10 @@ (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--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) (defconst ebox--grid-layout-prop-keys @@ -84,14 +88,15 @@ (defun ebox-grid--track-form-p (value) "Return non-nil when VALUE is one composite track expression." (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) "Return VALUE as a list of track expressions." (cond ((null value) nil) ((ebox-grid--track-form-p value) (list value)) - ((or (numberp value) (symbolp value)) (list value)) + ((symbolp value) (list value)) (t value))) (defun ebox-grid--expand-repeat (tracks) @@ -107,9 +112,7 @@ (let ((body (cond ((or (ebox-grid--track-form-p value) - (numberp value) (symbolp value) - (and (consp value) (numberp (car value)) - (null (cdr value)))) + (symbolp value)) (list value)) ((proper-list-p value) value) (t (error "ebox-grid: invalid repeat body %S" value))))) @@ -118,17 +121,17 @@ (list track))) tracks)) -(defun ebox-grid--normalize-track (value axis) - "Normalize one track VALUE for AXIS (`columns' or `rows')." - (setq value (if (and (consp value) (eq (car value) 'quote)) - (cadr value) - value)) +(defun ebox-grid--normalize-track (value axis &optional property) + "Normalize track VALUE for AXIS, reporting errors against PROPERTY." + (setq property (or property (if (eq axis 'columns) + :grid-template-columns :grid-template-rows))) (cond ((eq value 'auto) '(:kind auto :factor 0)) ((memq value '(min-content max-content)) (list :kind value)) ((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)) (list :kind 'fr :factor (cadr value))) ((and (consp value) (eq (car value) 'minmax)) @@ -136,38 +139,24 @@ (consp (cddr value)) (null (cdddr value))) (error "ebox-grid: invalid minmax track %S" value)) - (let ((minimum (ebox-grid--normalize-track (cadr value) axis)) - (maximum (ebox-grid--normalize-track (caddr value) axis))) + (let ((minimum (ebox-grid--normalize-track (cadr value) axis property)) + (maximum (ebox-grid--normalize-track (caddr value) axis property))) (unless (memq (plist-get minimum :kind) '(fixed auto min-content max-content)) (error "ebox-grid: minmax minimum must be fixed or auto: %S" value)) (unless (memq (plist-get maximum :kind) '(fixed auto min-content max-content fr)) (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))) - ((and (eq axis 'columns) - (or (numberp value) - (and (consp value) - (numberp (car value)) - (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))) + ((ebox-size-value-p value nil t) + (list :kind 'fixed :size + (ebox-size-validate-for-axis + value property (if (eq axis 'columns) 'inline 'block)))) (t (error "ebox-grid: unsupported %S track: %S" axis value)))) -(defun ebox-grid--normalize-tracks (value axis) - "Normalize a track template VALUE along AXIS." - (mapcar (lambda (track) (ebox-grid--normalize-track track axis)) +(defun ebox-grid--normalize-tracks (value axis &optional property) + "Normalize track template VALUE along AXIS for optional PROPERTY." + (mapcar (lambda (track) (ebox-grid--normalize-track track axis property)) (ebox-grid--expand-repeat (ebox-grid--track-list value)))) (defun ebox-grid--gap-pair (props) @@ -180,8 +169,8 @@ ((plist-member props :column-gap) (plist-get props :column-gap)) (t (cdr pair))))) - (cons (or (ebox--flex-line-value row 0) 0) - (or (ebox--nonnegative-horizontal-size-pixels column 0) 0)))) + (cons (ebox--flex-row-gap-value row) + (ebox--flex-column-gap-value column)))) (defun ebox-grid--enum-value (value default allowed property) "Return VALUE or DEFAULT after checking ALLOWED values for PROPERTY." @@ -192,7 +181,7 @@ (defun ebox-grid--one-auto-track (value axis 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) (error "Ebox GridConfig %S accepts at most one track" property)) (car tracks))) @@ -246,8 +235,8 @@ (ebox-grid--normalize-config-props nil) "Computed default property set for GridConfig.") -(defun ebox-grid--canonical-atomic-track-p (track allowed-kinds) - "Return non-nil when TRACK is canonical and its kind is in ALLOWED-KINDS." +(defun ebox-grid--canonical-atomic-track-p (track allowed-kinds &optional axis) + "Return non-nil when TRACK has an ALLOWED-KINDS kind and valid AXIS units." (and (proper-list-p track) (memq (plist-get track :kind) allowed-kinds) (pcase (plist-get track :kind) @@ -256,7 +245,8 @@ ('max-content (equal track '(:kind max-content))) ('fixed (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))))) ('fr (let ((factor (plist-get track :factor))) @@ -264,17 +254,17 @@ (equal track (list :kind 'fr :factor factor))))) (_ nil)))) -(defun ebox-grid--canonical-track-p (track) - "Return non-nil when TRACK is one canonical top-level track descriptor." +(defun ebox-grid--canonical-track-p (track &optional axis) + "Return non-nil when TRACK is canonical, including optional AXIS units." (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) (eq (plist-get track :kind) 'minmax) (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 (plist-get track :max) - '(fixed auto min-content max-content fr)) + '(fixed auto min-content max-content fr) axis) (equal track (list :kind 'minmax :min (plist-get track :min) @@ -291,21 +281,21 @@ cursor (cddr cursor) keys (cdr keys))) (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)) - (cl-every #'ebox-grid--canonical-track-p + (cl-every (lambda (track) (ebox-grid--canonical-track-p track 'rows)) (plist-get props :grid-template-rows)) (let ((auto-column (plist-get props :grid-auto-columns)) (auto-row (plist-get props :grid-auto-rows))) (and (or (null auto-column) - (ebox-grid--canonical-track-p auto-column)) + (ebox-grid--canonical-track-p auto-column 'columns)) (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)) (let ((row-gap (plist-get props :row-gap)) (column-gap (plist-get props :column-gap))) - (and (integerp row-gap) (>= row-gap 0) - (numberp column-gap) (>= column-gap 0))) + (and (ebox-size-value-for-axis-p row-gap 'block) + (ebox-size-value-for-axis-p column-gap 'inline))) (memq (plist-get props :justify-items) ebox--grid-justify-items-values) (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) (push entry entries)))) (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) "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." (let* ((node (plist-get entry :node)) (string (gethash node rendered))) - (if (eq axis 'rows) - (ebox-string-height string) - (pcase mode - ('min-content - (if (ebox-box-node-p node) - (+ (ebox--side-pixel node) (ebox--content-min-pixel node)) - (ebox-grid--string-min-content-pixel string))) - ('max-content - (if (ebox-box-node-p node) - (+ (ebox--side-pixel node) (ebox--content-max-pixel node)) - (ebox--string-max-pixel-width string))) - (_ (ebox--string-pixel-width string)))))) + (if (eq mode 'auto-min) + (let* ((horizontal (eq axis 'columns)) + (span (plist-get entry (if horizontal :column-span :row-span)))) + (if (ebox-box-node-p node) + (if (and (> (or span 1) 1) + (not (ebox--flex-explicit-min-main-p + node (if horizontal 'row 'column)))) + 0 + (/ (ebox--flex-box-min-main + node (or string "") (if horizontal 'row 'column)) + (float (or span 1)))) + (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) "Return intrinsic size for TRACK from RENDERED grid ENTRIES." (pcase (plist-get track :kind) ('fixed (plist-get track :size)) - ('fr 0) ('minmax (ebox-grid--track-intrinsic (plist-get track :min) axis entries rendered key 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)) (dolist (entry entries best) (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) 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 - (tracks count axis entries rendered gap available &optional auto-track) - "Resolve TRACKS to COUNT sizes along AXIS using AUTO-TRACK for implicit tracks." - (let* ((auto-track (or auto-track '(:kind auto :factor 0))) - (tracks (append tracks nil)) + (tracks count axis entries rendered gap available &optional auto-track box) + "Resolve TRACKS to COUNT sizes along AXIS within AVAILABLE space. +ENTRIES and RENDERED supply intrinsic content, GAP is the used gap, BOX +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 (make-list (max 0 (- count (length tracks))) auto-track))) @@ -606,29 +658,68 @@ letting the grid fall back to each child's intrinsic width." track axis entries rendered (if (eq axis 'columns) :column :row) index)))) - (fr-total (apply #'+ (mapcar #'ebox-grid--track-flex-factor tracks))) - (used (+ (ebox-grid--sum sizes) (* gap (max 0 (1- count)))))) - (when (and available (> fr-total 0)) - (let ((remaining (max 0 (- available used)))) - (cl-loop for track in tracks - for index from 0 - for factor = (ebox-grid--track-flex-factor track) - when (> factor 0) - do (setf (nth index sizes) + (flexible (cl-loop for track in tracks for index from 0 + when (> (ebox-grid--track-flex-factor track) 0) + collect index))) + (when (and (null available) flexible) + (let ((fraction + (cl-loop for index in flexible + for factor = (ebox-grid--track-flex-factor (nth index tracks)) + maximize + (/ (float (max (nth index sizes) - (floor (* remaining - (/ (float factor) fr-total)))))))) - sizes)) + (ebox-grid--track-intrinsic + '(:kind max-content) axis entries rendered + (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. -Require a positive definite AVAILABLE width and explicit fixed/fr tracks. -Implicit and intrinsic tracks retain the ordinary measurement path." +Require a definite AVAILABLE width and explicit fixed/fr tracks. CHILDREN +must have zero automatic minima when fractional tracks are present." (and (numberp available) (> available 0) (>= (length tracks) count) (cl-every (lambda (track) (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) "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) :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) "Return the target pixel/line size for ENTRY across SIZES." (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) "Return non-nil when NODE derives its width from the active viewport." (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 ;; 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) - "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 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)))) (unless (or (and auto-width-p stretch-p) (stringp source)) (error "ebox-grid: Grid item requires missing intrinsic output")) - (if (and auto-width-p - (or stretch-p (> (ebox--string-max-pixel-width source) width))) - (let ((ebox-viewport-width width)) + (if (or (and auto-width-p + (or stretch-p (> (ebox--string-max-pixel-width source) 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)) 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) "Align STRING to WIDTH and HEIGHT using JUSTIFY and ALIGN." (let ((result (ebox--lines-justify - string width + (if (or (null string) (string-empty-p string)) + (ebox-grid--blank width height) + string) + width (pcase justify ((or 'end 'right) 'right) ('center 'center) @@ -712,17 +829,46 @@ size for start/center/end alignment unless it would overflow its track." (ebox--lines-align-vertical result height (pcase align - ((or 'end 'bottom) 'bottom) + ((or 'end 'flex-end 'bottom) 'bottom) ('center 'center) (_ 'top))))) (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)) (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)) - (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))) (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) "Return a blank grid area of WIDTH pixels and HEIGHT lines." (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) "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)) (parts nil) (column 0)) + (when (and (> height 0) (null widths)) + (push (ebox-grid--blank total-width height) parts)) (while (and (> height 0) (< column (length widths))) (let ((entry (ebox-grid--entry-at matrix 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))) (push (ebox-grid--blank width height) parts) (setq column (1+ column)))) - (when (< column (length widths)) + (when (and (< column (length widths)) (> col-gap 0)) (push (ebox-pixel-space col-gap) parts)))) (when parts (setq result @@ -867,9 +1015,11 @@ size for start/center/end alignment unless it would overflow its track." :height (ebox--resolve-size-content-height box (ebox-get box :height) nil))) -(defun ebox--render-grid-children (config constraints children) - "Render Grid CONFIG under frame CONSTRAINTS over flat CHILDREN." - (let* ((props config) +(defun ebox--render-grid-children (config constraints children &optional box) + "Render Grid CONFIG under CONSTRAINTS over CHILDREN using BOX font metrics." + (let* ((props (ebox--layout-used-gaps + config (plist-get constraints :width) + (plist-get constraints :height) box)) (columns (plist-get props :grid-template-columns)) (rows (plist-get props :grid-template-rows)) (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))) (flow (or (plist-get props :grid-auto-flow) 'row)) (placed (ebox-grid--place-children children - (max 1 (length columns)) - (max 1 (length rows)) flow)) + (if children (max 1 (length columns)) + (length columns)) + (if children (max 1 (length rows)) + (length rows)) flow)) (entries (nth 0 placed)) (matrix (nth 1 placed)) (row-count (nth 2 placed)) (column-count (nth 3 placed)) - (available (plist-get constraints :width)) - (height (plist-get constraints :height)) + (available (when-let* ((width (plist-get constraints :width))) + (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 (and (ebox-grid--content-independent-columns-p - columns column-count available) + columns column-count available children) (memq (or (plist-get props :justify-items) 'stretch) '(normal stretch)))) (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))))) (let* ((widths (ebox-grid--resolve-sizes columns column-count 'columns entries rendered (cdr gaps) - available auto-columns)) + available auto-columns box)) (column-layout - (ebox-grid--content-layout - widths (cdr gaps) available - (or (plist-get props :justify-content) 'start))) + (ebox-grid--final-content-layout + widths (plist-get config :column-gap) 'width available + (or (plist-get props :justify-content) 'start) box)) (sized-rendered (ebox-grid--sized-rendered-entries entries rendered (plist-get column-layout :sizes) (plist-get column-layout :between) props)) (heights (ebox-grid--resolve-sizes rows row-count 'rows entries sized-rendered (car gaps) - height auto-rows)) + height auto-rows box)) (row-layout - (ebox-grid--content-layout - heights (car gaps) height - (or (plist-get props :align-content) 'start))) + (ebox-grid--final-content-layout + heights (plist-get config :row-gap) 'height height + (or (plist-get props :align-content) 'start) box)) (body (ebox-grid--render entries matrix column-layout row-layout 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) "Render canonical BOX Grid CONFIG over flat 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) "Render legacy GRID NODE to a propertized string." diff --git a/ebox-incremental.el b/ebox-incremental.el index 6ecaf55..7d345e2 100644 --- a/ebox-incremental.el +++ b/ebox-incremental.el @@ -15,6 +15,7 @@ (require 'ebox-render-context) (require 'ebox-patch-plan) (require 'ebox-runtime-index) +(require 'ebox-size) (defvar ebox-region-types) (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 "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) "Return non-nil when NODE bounds descendant viewport-dependent layout." (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) "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--node-all-region-ids node)) (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) (not (ebox--render-cache-contained-viewport-node-p node))) @@ -1728,14 +1767,11 @@ Defaults to the current buffer." role-ids)) (defun ebox--next-region-property-change (pos limit) - "Return the next ebox region property change after POS, capped at LIMIT." - (let ((next limit)) - (dolist (entry ebox-region-types) - (let ((change (next-single-property-change - pos (cdr entry) nil limit))) - (when (< change next) - (setq next change)))) - next)) + "Return the next possible region boundary after POS, capped at LIMIT. +An unrelated property may split a region run; ownership indexes merge those +adjacent ranges. One native interval lookup avoids searching the same string +separately for every role property." + (or (next-property-change pos nil limit) limit)) (defun ebox--index-add-region-line-range (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)))))) (defun ebox--span-patch-definite-size-value-p (value) - "Return non-nil when VALUE is definite enough for local span planning." - (and value - (not (memq value '(auto min-content max-content fit-content - stretch contain viewport))) - (not (ebox--viewport-dependent-size-value-p value)))) + "Return non-nil when VALUE has no external layout-size dependency. +Private used numbers and fixed CSS expressions admit local footprint proofs. +Keywords, percentages, and viewport units still require containing-layout +evidence rather than a value resolved in the current ambient viewport." + (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) "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) "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) (>= width (+ (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) "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) (>= height (+ 1 @@ -2704,7 +2758,8 @@ outer width and height are definite and still contain their inner chrome." '(internal-fragment external-footprint-proof)) ((and (eq (ebox-get style-node :box-sizing) 'border-box) (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)) ((eq key :overflow) '(clipping external-footprint)) @@ -2966,19 +3021,27 @@ patched." (let ((min-width (ebox-get style-node :min-width)) (max-width (ebox-get style-node :max-width)) (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 (ebox-get style-node :width)) (or (null min-width) + (and auto-min-independent (eq min-width 'auto)) (ebox--definite-containment-inline-size-value-p min-width)) (or (null max-width) (eq max-width 'none) (ebox--definite-containment-inline-size-value-p max-width)) - (numberp (ebox-get style-node :height)) - (or (null min-height) (numberp min-height)) + (ebox--span-patch-definite-size-value-p (ebox-get style-node :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) (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 (ebox--node-visible-overflow-p node)))))) @@ -3012,7 +3075,8 @@ patched." (if (eq axis 'row) (ebox--span-patch-definite-size-value-p (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) "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 only on the stable container and sibling flex inputs. Cross-axis compatibility is still verified by the existing rendered slot-footprint proof." - (let* ((basis-value (plist-get item-props :flex-basis)) - (basis (and (not (memq basis-value - '(nil auto min-content max-content - fit-content stretch contain viewport))) - (ebox--flex-main-value axis basis-value nil))) - (minimum (ebox-get style-node - (if (eq axis 'row) :min-width :min-height)))) + (let* ((size-axis (if (eq axis 'row) 'horizontal 'vertical)) + (basis (ebox--span-patch-used-size + style-node (plist-get item-props :flex-basis) size-axis)) + (minimum + (ebox--span-patch-used-size + style-node + (ebox-get style-node (if (eq axis 'row) :min-width :min-height)) + size-axis))) (and (numberp basis) (>= basis 0) (numberp minimum) (= minimum 0)))) @@ -3558,6 +3623,23 @@ running expensive span and snapshot checks." buffer entry ebox-incremental--candidate-state-for-slot-proof)) 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) (not (ebox--impact-vector-requires-parent-promotion-p 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." (or (and nil-dependent (null value)) (and nil-dependent (eq value 'auto)) - (memq value '(stretch contain fit-content viewport)) - (and (consp value) - (or (eq (car value) 'viewport) - (and (eq (car value) 'fit-content) - (ebox--viewport-dependent-size-value-p (cadr value))))))) + (memq value '(stretch fit-content)) + (ebox--size-data-viewport-dependent-p value 'viewport-width))) + +(defun ebox--size-data-viewport-dependent-p (value axis) + "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) "Return non-nil when vertical size VALUE depends on viewport height." - (or (eq value 'viewport-height) - (and (consp value) - (or (eq (car value) 'viewport-height) - (and (memq (car value) '(+ -)) - (cl-some #'ebox--viewport-dependent-height-value-p - (cdr value))))))) + (or (eq value 'stretch) + (ebox--size-data-viewport-dependent-p value 'viewport-height))) (defun ebox--viewport-dependent-height-props-p (props) "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." (when (and (listp 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 (ebox--viewport-dependent-height-props-p node)) ('flex (or (ebox--viewport-dependent-height-props-p (plist-get node :props)) (ebox--viewport-dependent-height-props-p - (plist-get node :raw-props))))))) + (plist-get node :raw-props)))))))) (defun ebox--flex-participation-viewport-dependent-p (node) "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." (when (listp node) (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 ((eq (plist-get node :ebox-kind) 'box) (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 index cannot identify a directly replaceable region owner, leaving callers to 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)) (root (plist-get state :root-node)) (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-get patch :ebox-source-handle)) (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)) (when (plist-member patch key) (plist-put @@ -6785,8 +6910,10 @@ Each immutable source record is resolved once for this node pair." (new-entry (plist-member new key))) (unless (and (eq (not (null old-entry)) (not (null new-entry))) - (equal-including-properties - (cadr old-entry) (cadr new-entry))) + (funcall (if (eq key :surface-properties) + #'ebox-interaction-surface-equal-p + #'equal-including-properties) + (cadr old-entry) (cadr new-entry))) (push key changed)))))))) (setq changed (nreverse changed)) (unless (eq old-record new-record) @@ -7172,9 +7299,16 @@ certificate." (plist-get local-preparation :dirty-set) (ebox-incremental--declarative-dirty-set 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) (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 ;; local Style Adapter succeeded, on computed values. Validate that ;; final contract once here; Surface consumes the resulting @@ -7182,8 +7316,9 @@ certificate." (ebox-tree-validate-indexed-participation (plist-get candidate-index :node-table) (plist-get candidate-index :parent-table) - (or (plist-get local-preparation :participation-node-ids) - style-node-ids) + (append (or (plist-get local-preparation :participation-node-ids) + style-node-ids) + axis-node-ids) (if styles-prepared-p 'computed 'author) candidate-source-index)) (when styles-prepared-p @@ -7621,9 +7756,10 @@ line count; its outside siblings are shifted by the one scoped replacement." old-node) (ebox-incremental--layout-slot-style-signature new-node)) - (numberp (ebox-get old-node :width)) - (= (ebox-get old-node :width) - (ebox-get new-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)) (eq (ebox-tree-display-inner parent) 'column) (eq (ebox-tree-display-inner parent-new) 'column) ancestors-stable-p @@ -7645,7 +7781,9 @@ line count; its outside siblings are shifted by the one scoped replacement." :old-spans spans :old-block-span block-span :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 (plist-get old-state :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)) (right-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 (let* ((local-prepared (plist-put (copy-sequence geometry-prepared) :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 ;; expensive span-footprint proof that usually rejects it. (or (eq (plist-get (car plan) :op) 'owner-rerender) - (not (and (numberp old-width) - (numberp new-width) - (= old-width new-width)))))) + (not (and (ebox--span-patch-definite-size-value-p old-width) + (ebox--span-patch-definite-size-value-p new-width) + (equal old-width new-width)))))) (early-allocation-proof (and allocation-first-p (ebox-incremental--allocation-closure-proof @@ -8620,7 +8760,7 @@ the surface is allowed to reuse retained TP objects." (buffer node spans) "Return non-nil when path-copied NODE keeps the published line count." (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)) (= (length spans) (with-current-buffer buffer @@ -8887,16 +9027,15 @@ candidate's unrendered caches." (defun ebox-incremental--stable-grid-track-p (track) "Return non-nil when TRACK is fixed or a positive fractional track." - (or (numberp track) - (and (consp track) (null (cdr track)) (numberp (car track))) + (or (ebox--span-patch-definite-size-value-p track) (and (proper-list-p track) (memq (plist-get track :kind) '(fixed fr)) (let ((value (if (eq (plist-get track :kind) 'fixed) (plist-get track :size) (plist-get track :factor)))) - (and (numberp value) (>= value 0) - (or (eq (plist-get track :kind) 'fixed) - (> value 0))))) + (if (eq (plist-get track :kind) 'fixed) + (ebox--span-patch-definite-size-value-p value) + (and (numberp value) (> value 0))))) (and (symbolp track) (string-match-p "\\`[0-9]+\\(?:\\.[0-9]+\\)?fr\\'" (symbol-name track))) @@ -8905,14 +9044,13 @@ candidate's unrendered caches." (null (cddr 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 allocation remains on the full ancestor-slot proof path." - (or (numberp track) - (and (consp track) (null (cdr track)) (numberp (car track))) + (or (ebox--span-patch-definite-size-value-p track) (and (proper-list-p track) (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 (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))) (and old-state old-node new-node old-grid new-grid (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) (ebox-get new-node :height))) (and (equal (plist-get @@ -8977,12 +9116,14 @@ complete published ancestor slot chain remains identical." (defun ebox-incremental--compute-content-slot-stable (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 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 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)) (node-id (plist-get entry :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)))) (memq :content (plist-get entry :changed-keys)) (stringp old-content) (stringp new-content) - (not (string-match-p "\n" old-content)) - (not (string-match-p "\n" new-content)) snapshot - (equal (plist-get - (plist-get snapshot :external-footprint-signature) - :block-lines) - 1) + (or (and (not (string-match-p "\n" old-content)) + (not (string-match-p "\n" new-content)) + (equal (plist-get + (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))) (defun ebox-incremental--content-slot-stable-p (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) (ebox-incremental--compute-content-slot-stable 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) (equal (ebox-incremental--layout-slot-style-signature old-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)) (not (ebox-get old-node :flex-grow)) (not (ebox-get old-node :flex-shrink)) @@ -9552,6 +9699,12 @@ metadata; this predicate only authorizes the local attempt." spans (or local-typography-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 buffer new-node spans) content-slot-evidence @@ -10077,6 +10230,9 @@ role, and outside-complement compatibility before publication." :geometry-proof)))) (certificate-proofs (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))) (overrides (append @@ -10232,6 +10388,7 @@ role, and outside-complement compatibility before publication." (ebox-incremental--owner-proof-list (plist-get (plist-get prepared :mixed-owner-proof) :geometry-proof)))) + :scroll-content-proof (plist-get span-proof :scroll-content-proof) :formatting-context-reflow-proof (and (or (eq projection-kind 'formatting-context-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)))) 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 (buffer old-state next-root isolated-candidate-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)) (span-proof (unless (or paint-p mixed-proof skip-span-proof-p) - (ebox-incremental--span-patch-projection-proof - buffer old-state prepared candidate-state owner-plan - path-copied-p))) + (or (ebox-incremental--span-patch-projection-proof + buffer old-state prepared candidate-state owner-plan + path-copied-p) + (ebox-incremental--scroll-content-span-proof + buffer old-state prepared candidate-state)))) (projection-kind (cond (mixed-proof 'mixed-owner-reflow) @@ -10614,6 +10833,10 @@ whether NEXT-ROOT shares untouched published nodes." (not (plist-get span-proof :variable-content-p))) 'owner-scoped) (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 skip-span-proof-p)) (when mixed-proof @@ -11018,8 +11241,14 @@ fresh regardless of whether any stable producers exist." (plist-get old-state :region-box-table))) (plist-put candidate-state :scroll-state-table candidate-scroll-table) (setq owner-plan - (ebox-incremental--layout-owner-plan - buffer old-state candidate-state dirty-set) + (if (or display-changed (not style-context-stable-p)) + ;; 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 (ebox-incremental--plist-overlay (ebox-incremental--layout-owner-report diff --git a/ebox-interaction.el b/ebox-interaction.el new file mode 100644 index 0000000..9744bc1 --- /dev/null +++ b/ebox-interaction.el @@ -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 diff --git a/ebox-layout-config.el b/ebox-layout-config.el index c6d80c6..efdde68 100644 --- a/ebox-layout-config.el +++ b/ebox-layout-config.el @@ -9,6 +9,7 @@ (require 'cl-lib) (require 'seq) +(require 'ebox-size) (declare-function ebox-flex-layout-config-props-p "ebox-flex" (props)) @@ -28,13 +29,14 @@ '(start center end stretch) "Canonical Row/Column cross-axis alignment values.") -(defun ebox-layout-config--simple-axis-props-p (props) - "Return non-nil when PROPS is canonical Row/Column config data." +(defun ebox-layout-config--simple-axis-props-p (props kind) + "Return non-nil when PROPS is canonical Row/Column KIND config data." (and (equal (mapcar #'car (seq-partition props 2)) '(:item-gap :cross-align)) (let ((gap (plist-get props :item-gap)) (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))))) (defun ebox-layout-config--simple-axis-create (kind plist) @@ -51,11 +53,14 @@ (let ((props (list :item-gap (if (plist-member 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) (plist-get plist :cross-align) '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)) (ebox-layout-config--create :kind kind :props props))) @@ -69,7 +74,10 @@ (pcase kind ('normal (null props)) ((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 (and (fboundp 'ebox-flex-layout-config-props-p) (ebox-flex-layout-config-props-p props))) diff --git a/ebox-layout.el b/ebox-layout.el index 642f510..8d46503 100644 --- a/ebox-layout.el +++ b/ebox-layout.el @@ -14,6 +14,7 @@ (require 'ebox-measure) (require 'ebox-fragment) (require 'ebox-render-context) +(require 'ebox-size) (require 'ebox-buffer-backend) (require 'ebox-runtime-index) @@ -95,15 +96,20 @@ must not stretch their own outer width to that containing block.") (defvar ebox--inline-auto-width-intrinsic-p nil "Non-nil while a horizontal layout sizes auto-width children intrinsically. -The ambient viewport remains available to explicit `viewport' and `stretch' -widths, but an omitted or `auto' width must not consume the whole horizontal -containing block merely because the row itself is being rendered in it.") +Explicit viewport units keep their root reference; `stretch' fills the +containing block. An omitted or `auto' width must not consume the whole +horizontal containing block merely because the row is rendered in it.") (defvar ebox--box-content-render-cache nil - "Dynamic render-pass cache for rendered lazy child content.") + "Dynamic render-pass cache for lazy content and empty-content widths.") + +(defvar ebox--box-content-empty-width nil + "Inline extent returned by the current empty content materialization. +The content cache saves this fact with its node and render context because +empty strings cannot carry distinct geometry through string identity.") (defvar ebox--box-content-cache-purpose nil - "Dynamic discriminator for distinct box content materializations.") + "Intrinsic probe depth, or nil for ordinary content materialization.") (defvar ebox--containing-wrap-mode nil "Box-owned soft-wrap policy inherited by canonical Text descendants.") @@ -112,6 +118,10 @@ containing block merely because the row itself is being rendered in it.") (make-hash-table :test 'eq :weakness 'key) "Weak map from rendered strings to their proven uniform pixel width.") +(defvar ebox--rendered-intrinsic-size-table + (make-hash-table :test 'eq :weakness 'key) + "Weak map from rendered strings to intrinsic extents excluding overflow.") + (defvar ebox--transparent-preformatted-box-fast-path-disabled nil "Non-nil disables transparent preformatted box reuse for diagnostics.") @@ -123,10 +133,31 @@ containing block merely because the row itself is being rendered in it.") (defun ebox--record-rendered-uniform-width (rendered pixel-width) "Record that every line of RENDERED is exactly PIXEL-WIDTH wide." - (when (and (stringp rendered) (numberp pixel-width)) + (when (and (stringp rendered) (not (string-empty-p rendered)) + (numberp pixel-width)) (puthash rendered pixel-width ebox--rendered-uniform-width-table)) rendered) +(defun ebox--record-rendered-intrinsic-size (rendered width height) + "Record RENDERED's definite intrinsic WIDTH pixels and HEIGHT lines. +Nil on either axis leaves its ordinary string measurement in force. Layout +uses this fact when cyclic percentage gaps produce ink outside the intrinsic +container. The weak string association keeps candidate facts out of source +nodes and remains valid when rendered content is reused from a cache. Empty +content instead returns WIDTH through `ebox--box-content-empty-width'." + (when (stringp rendered) + (if (string-empty-p rendered) + (setq ebox--box-content-empty-width width) + (when (or (numberp width) (numberp height)) + (puthash rendered (list :width width :height height) + ebox--rendered-intrinsic-size-table)))) + rendered) + +(defun ebox--rendered-intrinsic-size (rendered axis) + "Return RENDERED's recorded intrinsic AXIS extent, or nil." + (unless (or (null rendered) (string-empty-p rendered)) + (plist-get (gethash rendered ebox--rendered-intrinsic-size-table) axis))) + (defun ebox--preformatted-content-lines-fit-p (lines pixel-width) "Return non-nil when LINES fit the exact content PIXEL-WIDTH. Blank lines without data-bearing properties are safe because the wrapper @@ -175,8 +206,8 @@ replaces them with its full-width filler line." (defun ebox--apply-surface-properties (rendered properties) "Apply text-property PROPERTIES to RENDERED's character surface. -Newlines are excluded. For each property independently, an existing non-nil -value on an inner rendered surface wins over the enclosing box's value." +Newlines are excluded. Inner values win independently; explicit nil also +blocks enclosing values for Ebox's fixed native interaction properties." (when properties (unless (and (proper-list-p properties) (zerop (% (length properties) 2))) @@ -194,22 +225,12 @@ value on an inner rendered surface wins over the enclosing box's value." (tail properties)) (while tail (let ((property (pop tail)) - (value (pop tail)) - (position line-start)) - ;; A nil text-property value denotes absence. Skipping it also - ;; prevents an enclosing surface from deleting an inner value. - (when value - (while (< position line-end) - (let ((next (or (next-single-property-change - position property rendered line-end) - line-end))) - (unless (get-text-property position property rendered) - (add-text-properties - position next (list property value) rendered)) - (setq position (max next (1+ position)))))))) + (value (pop tail))) + (ebox-interaction--fill-property + rendered line-start line-end property value))) (setq line-start (if (< line-end limit) (1+ line-end) limit)))))) - rendered) + (ebox-interaction--refresh-hover! rendered 0 (length rendered))) (defun ebox--render-transparent-preformatted-box (box) "Render BOX by owning proven exact preformatted child content directly. @@ -228,9 +249,10 @@ the generic box pipeline." (memq layout-kind '(row column flex grid))) (eq (ebox-get box :text-align) 'left) (eq (ebox-get box :vertical-align) 'top) - (null (ebox-get box :height)) - (equal (or (ebox-get box :min-height) 0) 0) - (null (ebox-get box :max-height)) + (memq (ebox-get box :height) '(nil auto)) + (zerop (ebox--resolve-size-content-height + box (ebox-get box :min-height) 0)) + (memq (ebox-get box :max-height) '(nil none)) (eq (ebox-get box :overflow) 'scroll) (equal (ebox-get box :padding-left-pixel) 0) (equal (ebox-get box :padding-right-pixel) 0) @@ -261,7 +283,7 @@ the generic box pipeline." (ebox--finish-preformatted-content-ownership! rendered region-id) (ebox--apply-surface-properties - rendered (ebox-get box :surface-properties)) + rendered (ebox-interaction--node-surface-properties box)) (puthash region-id box ebox--region-box-table) (unless ebox--intrinsic-layout-measurement (ebox--scroll-clear-state region-id)) @@ -277,8 +299,9 @@ and its formatted content exactly fills the used height." (not (plist-member box :children)) (stringp (ebox-get box :content)) (eq (ebox-get box :vertical-align) 'top) - (equal (or (ebox-get box :min-height) 0) 0) - (null (ebox-get box :max-height)) + (zerop (ebox--resolve-size-content-height + box (ebox-get box :min-height) 0)) + (memq (ebox-get box :max-height) '(nil none)) (equal (or (ebox-get box :scroll-offset) 0) 0) (equal (ebox-get box :margin-left-pixel) 0) (equal (ebox-get box :margin-right-pixel) 0) @@ -294,7 +317,7 @@ and its formatted content exactly fills the used height." (ebox--format-content-string box content))) (lines (ebox-string-lines formatted)) (text-height (length lines)) - (content-height (ebox--content-height box text-height)) + (content-height (ebox--content-height box text-height formatted)) (content-pixel (ebox--content-pixel box)) (lines-fit-p (ebox--preformatted-content-lines-fit-p lines content-pixel)) @@ -315,7 +338,7 @@ and its formatted content exactly fills the used height." underfilled-simple-scroll-p)) (let* ((region-id (or ebox--render-region-id (ebox--ensure-region-id box))) - (padding-line-filler (ebox-pixel-space content-pixel)) + (padding-line-filler (ebox--pixel-blank content-pixel 1)) (rendered (if underfilled-simple-scroll-p (ebox-lines-join @@ -329,7 +352,7 @@ and its formatted content exactly fills the used height." box lines 0 region-id padding-line-filler t t))))) (when underfilled-simple-scroll-p (ebox--apply-surface-properties - rendered (ebox-get box :surface-properties))) + rendered (ebox-interaction--node-surface-properties box))) (puthash region-id box ebox--region-box-table) (unless ebox--intrinsic-layout-measurement (ebox--scroll-clear-state region-id)) @@ -795,6 +818,73 @@ When WRAP-MODE is `kp', use Knuth-Plass algorithm via ekp package." ;;; Render: Box Rendering Logic ;;; ============================================================ +(defun ebox--size-font-fact (box) + "Return the effective font fact for BOX or the current layout node." + (or (plist-get box :ebox-font-fact) ebox--css-font-fact)) + +(defun ebox--size-line-height (&optional box) + "Return BOX's normal computed line height in pixels. +The buffer backend places vertical geometry on whole text lines. The font +projection supplies its normal line height; the display default is used when +the node has no font override." + (let* ((fact (ebox--size-font-fact box)) + (metrics (and fact (ebox-font-fact-measurement-metrics fact))) + (height (plist-get metrics :height))) + (max 1 (or height (frame-char-height))))) + +(defun ebox--size-character-width (&optional box) + "Return the advance width of digit zero in BOX's effective font." + (let* ((fact (ebox--size-font-fact box)) + (metrics (and fact (ebox-font-fact-measurement-metrics fact))) + (face (and fact (ebox-font-measurement-face fact))) + (probe (if face (propertize "0" 'face face) "0"))) + (max 1 (or (plist-get metrics :zero-width) + (ebox--string-pixel-width probe))))) + +(cl-defun ebox--size-pixels + (value &optional box axis (percent-base nil percent-base-supplied-p)) + "Resolve canonical size VALUE to pixels for BOX along AXIS. +AXIS is `width' (the default) or `height'. PERCENT-BASE, when supplied, +overrides the containing block basis, including nil for an indefinite axis. +Return nil for an unresolved percentage or unavailable viewport dimension. +Numbers here are private engine pixel values, never public author syntax." + (if (numberp value) + value + (let ((dependencies (ebox-size-dependencies value))) + (ebox-size-resolve + value + (list :percent-base + (if percent-base-supplied-p percent-base + (if (eq axis 'height) ebox--css-containing-height + (ebox--viewport-pixel-width nil))) + :viewport-width + (if ebox--css-size-context-active-p ebox--css-viewport-width + ebox-viewport-width) + :viewport-height + (if ebox--css-size-context-active-p ebox--css-viewport-height + (and (numberp ebox-viewport-height) + (* ebox-viewport-height (frame-char-height)))) + :ch (and (memq 'ch dependencies) + (ebox--size-character-width box)) + :lh (and (memq 'lh dependencies) + (ebox--size-line-height box))))))) + +(defun ebox--edge-used-value (box property value) + "Resolve derived edge PROPERTY's canonical VALUE in BOX's context. +CSS padding and margin percentages use containing block width on both axes. +Vertical engine fields store fractional line counts until final rendering." + (if (or (numberp value) (not (consp value))) + value + (let ((pixels (or (ebox--size-pixels value box 'width) 0))) + (when (and (< pixels 0) + (string-prefix-p ":margin-" (symbol-name property))) + (error "ebox: negative margins require overlapping layout, which this buffer backend does not support: %S" + value)) + (setq pixels (max 0 pixels)) + (if (string-suffix-p "-height" (symbol-name property)) + (/ pixels (float (ebox--size-line-height box))) + pixels)))) + (defun ebox--side-pixel (box &optional side) "Calculate side pixel width (padding + border + margin). SIDE can be `left', `right', or nil (both)." @@ -831,34 +921,27 @@ SIDE can be `left', `right', or nil (both)." (when value (if (eq (ebox-get box :box-sizing) 'border-box) (max 0 (- value - (+ (floor (ebox-get box :padding-top-height)) - (floor (ebox-get box :padding-bottom-height))))) + (+ (ebox-get box :padding-top-height) + (ebox-get box :padding-bottom-height)))) value))) (defun ebox--viewport-pixel-width (&optional default) "Return the current viewport width in pixels, or DEFAULT." (if (and (numberp ebox-viewport-width) - (> ebox-viewport-width 0)) - (floor ebox-viewport-width) + (>= ebox-viewport-width 0)) + ebox-viewport-width default)) (defun ebox--viewport-size-value-p (value) - "Return non-nil when VALUE is a viewport-relative size marker." - (or (eq value 'viewport) - (and (consp value) (eq (car value) 'viewport)))) + "Return non-nil when canonical VALUE depends on viewport width." + (and (ebox-size-value-p value) + (memq 'viewport-width (ebox-size-dependencies value)))) (defun ebox--horizontal-size-pixels (value &optional default) - "Return VALUE as pixels using Ebox horizontal size units. -DEFAULT is returned when VALUE is nil or not a supported horizontal size. -In addition to normal Ebox units, `(viewport)' resolves to -`ebox-viewport-width'." - (cond - ((null value) default) - ((ebox--viewport-size-value-p value) - (ebox--viewport-pixel-width default)) - ((and (consp value) (numberp (car value))) (car value)) - ((numberp value) (* value (ebox--space-pixel-width))) - (t default))) + "Return canonical VALUE as horizontal pixels, or DEFAULT if unresolved." + (if (or (numberp value) (ebox-size-value-p value)) + (or (ebox--size-pixels value) default) + default)) (defun ebox--nonnegative-horizontal-size-pixels (value &optional default) "Return VALUE as non-negative horizontal pixels. @@ -877,49 +960,35 @@ Signal an error when VALUE resolves to a negative width-like size." (defun ebox--viewport-height-size-value-p (value) "Return non-nil when vertical size VALUE depends on viewport height." - (or (eq value 'viewport-height) - (and (consp value) - (or (eq (car value) 'viewport-height) - (and (memq (car value) '(+ -)) - (cl-some #'ebox--viewport-height-size-value-p - (cdr value))))))) + (and (ebox-size-value-p value) + (memq 'viewport-height (ebox-size-dependencies value)))) -(defun ebox--resolve-height-expression-lines (value) +(defun ebox--resolve-height-expression-lines (value &optional box) "Resolve vertical size expression VALUE to raw outer height lines. -Return nil when VALUE depends on an unavailable viewport height." - (cond - ((numberp value) value) - ((eq value 'viewport-height) - (ebox--viewport-height-lines nil)) - ((and (consp value) - (eq (car value) 'viewport-height)) - (ebox--viewport-height-lines nil)) - ((and (consp value) - (memq (car value) '(+ -))) - (let ((values - (mapcar #'ebox--resolve-height-expression-lines - (cdr value)))) - (when (cl-every #'numberp values) - (pcase (car value) - ('+ (apply #'+ values)) - ('- (if (= (length values) 1) - (- (car values)) - (apply #'- values))))))) - (t - (error "ebox: unsupported height value after expansion: %S" value)))) +Return nil for an indefinite percentage or unavailable viewport height. +BOX supplies the effective font. Numbers are private engine line counts." + (if (numberp value) value + (when-let* ((pixels (ebox--size-pixels value box 'height))) + (/ pixels (float (ebox--size-line-height box)))))) -(defun ebox--resolve-size-content-height (box value fallback) +(defun ebox--resolve-size-content-height (box value fallback &optional intrinsic) "Resolve vertical size VALUE to BOX content height lines. -FALLBACK is used for nil, auto, or unavailable viewport-height values." +FALLBACK is used for auto and indefinite lengths. INTRINSIC is the natural +content height after inline layout; normal horizontal writing gives the same +block-axis min-content and max-content contribution." (cond - ((null value) fallback) - ((eq value 'auto) fallback) - ((or (numberp value) - (eq value 'viewport-height) - (and (consp value) - (or (eq (car value) 'viewport-height) - (memq (car value) '(+ -))))) - (if-let* ((height (ebox--resolve-height-expression-lines value))) + ((memq value '(nil auto)) fallback) + ((eq value 'none) nil) + ((memq value '(min-content max-content fit-content)) + (or intrinsic fallback)) + ((eq value 'stretch) + (if (numberp ebox--css-containing-height) + (max 0 (- (/ ebox--css-containing-height + (float (ebox--size-line-height box))) + (ebox--side-height box))) + fallback)) + ((or (numberp value) (ebox-size-value-p value)) + (if-let* ((height (ebox--resolve-height-expression-lines value box))) (ebox--box-sizing-content-height box (max 0 height)) fallback)) (t @@ -938,6 +1007,8 @@ FALLBACK is used for nil, auto, or unavailable viewport-height values." "Return the current render context for lazy box content caching." (list ebox-viewport-width ebox-viewport-height + ebox--css-viewport-width ebox--css-viewport-height + ebox--css-containing-height ebox--css-font-fact ebox--intrinsic-layout-measurement ebox--inline-auto-width-intrinsic-p ebox--containing-wrap-mode @@ -945,19 +1016,27 @@ FALLBACK is used for nil, auto, or unavailable viewport-height values." ebox--render-region-id)) (defun ebox--render-box-content-cached (box renderer) - "Return BOX content from RENDERER, reusing the render-pass cache." - (if (null ebox--box-content-render-cache) - (funcall renderer) - (let* ((context (ebox--box-content-cache-context)) - (entries (gethash box ebox--box-content-render-cache)) - (cached (assoc context entries))) - (if cached - (cdr cached) - (let ((rendered (funcall renderer))) - (puthash box - (cons (cons context rendered) entries) - ebox--box-content-render-cache) - rendered))))) + "Return BOX content from RENDERER, reusing the render-pass cache. +Return its empty inline extent through `ebox--box-content-empty-width'." + (let* ((context (and ebox--box-content-render-cache + (ebox--box-content-cache-context))) + (entries (and ebox--box-content-render-cache + (gethash box ebox--box-content-render-cache))) + (cached (assoc context entries)) + (result + (if cached + (cdr cached) + (let* ((ebox--box-content-empty-width nil) + (rendered (or (funcall renderer) "")) + (result (cons rendered + (and (string-empty-p rendered) + ebox--box-content-empty-width)))) + (when ebox--box-content-render-cache + (puthash box (cons (cons context result) entries) + ebox--box-content-render-cache)) + result)))) + (setq ebox--box-content-empty-width (cdr result)) + (car result))) (defun ebox--record-box-content-width-exact (box content composite-p) "Record exact width for BOX CONTENT when COMPOSITE-P is non-nil." @@ -1052,22 +1131,38 @@ FALLBACK is used for nil, auto, or unavailable viewport-height values." (wrap-mode (ebox-get box :wrap-mode)) (soft-wrap-p (not (memq wrap-mode '(nil none kp)))) (line-width 0) - pending-empty-line + (max-width 0) + pending-empty-line line-has-empty-box line-parts segments) (cl-labels ((flush-line () (when line-parts - (push (apply #'ebox--lines-concat-horizontal - (nreverse line-parts)) - segments) - (setq line-parts nil line-width 0))) + (let ((parts (nreverse line-parts))) + (when line-has-empty-box + (let ((height (apply #'max + (mapcar (lambda (part) + (if (consp part) 0 + (ebox-string-height part))) + parts)))) + (setq parts (mapcar (lambda (part) + (if (consp part) + (ebox--pixel-blank (cdr part) height) + part)) + parts)))) + (push (apply #'ebox--lines-concat-horizontal parts) segments)) + (setq max-width (max max-width line-width)) + (setq line-parts nil line-width 0 line-has-empty-box nil))) (append-atomic (rendered width) (when (and soft-wrap-p content-width line-parts (> (+ line-width width) content-width)) (flush-line)) - (push rendered line-parts) + (if (string-empty-p rendered) + (progn + (setq line-has-empty-box t) + (push (cons rendered width) line-parts)) + (push rendered line-parts)) (setq line-width (+ line-width width) pending-empty-line nil)) (append-word @@ -1101,7 +1196,7 @@ FALLBACK is used for nil, auto, or unavailable viewport-height values." () (if line-parts (flush-line) - (push "" segments)) + (push (ebox-lines-join '("")) segments)) (setq pending-empty-line t)) (append-unit (unit) @@ -1133,31 +1228,45 @@ FALLBACK is used for nil, auto, or unavailable viewport-height values." (plist-get unit :width))))) (render-child (child) - (let ((inline-p (eq (ebox-tree-display-outer child) 'inline)) - (rendered + (let* ((inline-p (eq (ebox-tree-display-outer child) 'inline)) + (rendered (let ((ebox--inline-auto-width-intrinsic-p (eq (ebox-tree-display-outer child) 'inline))) - (ebox--render-with-cache child)))) + (ebox--render-with-cache child))) + (width + (if (and (string-empty-p rendered) + (eq (plist-get child :ebox-kind) 'box)) + (let ((ebox--inline-auto-width-intrinsic-p inline-p)) + (ebox--total-pixel child)) + (when (or (not inline-p) + (eq (plist-get child :ebox-kind) 'box)) + (ebox--string-max-pixel-width rendered))))) (if (not inline-p) (progn (flush-line) - (setq pending-empty-line nil) + (setq pending-empty-line nil + max-width (max max-width width)) (push rendered segments)) - (if (eq (plist-get child :ebox-kind) 'text) - (dolist (unit (ebox--normal-text-units rendered wrap-mode)) - (append-unit unit)) - (append-unit - (list :kind 'box :rendered rendered - :width (ebox--string-max-pixel-width rendered)))))))) + (unless (and (string-empty-p rendered) (zerop (or width 0))) + (if (eq (plist-get child :ebox-kind) 'text) + (dolist (unit (ebox--normal-text-units rendered wrap-mode)) + (append-unit unit)) + (append-unit + (list :kind 'box :rendered rendered + :width width)))))))) (ebox--call-with-box-content-context box (lambda () (dolist (child children) (render-child child)))) (when pending-empty-line - (push "" segments) + (push (ebox-lines-join '("")) segments) (setq pending-empty-line nil)) (flush-line)) - (if segments - (apply #'ebox--lines-stack-vertical (nreverse segments)) - "")))) + (let ((rendered + (or (and segments + (apply #'ebox--lines-stack-vertical (nreverse segments))) + ""))) + (when (string-empty-p rendered) + (setq ebox--box-content-empty-width max-width)) + rendered)))) (defun ebox--render-box-layout-children (box) "Render canonical BOX children through its typed LayoutConfig." @@ -1178,12 +1287,15 @@ FALLBACK is used for nil, auto, or unavailable viewport-height values." (ebox-layout-config-props (plist-get box :ebox-layout-config)))))) ('column - (ebox--call-with-box-content-context - box (lambda () - (ebox--render-column-children - children - (ebox-layout-config-props - (plist-get box :ebox-layout-config)))))) + (let ((content-viewport + (ebox--wrapper-content-viewport-pixel box))) + (ebox--call-with-box-content-context + box (lambda () + (ebox--render-column-children + children + (ebox-layout-config-props + (plist-get box :ebox-layout-config)) + content-viewport))))) ('flex (ebox--render-flex-box-children box @@ -1226,19 +1338,22 @@ FALLBACK is used for nil, auto, or unavailable viewport-height values." (defun ebox--intrinsic-box-content (box) "Return BOX content materialized independently of its used width. -A typed Normal Box performs soft wrapping while composing its children. Its +A typed Normal or Column Box soft-wraps text while composing its children. Its intrinsic widths must therefore measure a separate unwrapped materialization, not the content previously composed for the final viewport. Cache that materialization on the source BOX for the current render pass, while applying probe-only properties to a shallow copy so the canonical node remains unchanged." - (if (and (eq (ebox--box-layout-kind box) 'normal) + (if (and (memq (ebox--box-layout-kind box) '(normal column)) (or (plist-member box :children) (plist-get box :ebox-child-sequence))) (let ((ebox-viewport-width nil) (ebox--intrinsic-layout-measurement t) (ebox--inline-auto-width-intrinsic-p t) - (ebox--box-content-cache-purpose 'intrinsic-width)) + ;; A child's used content rendered during an ancestor's probe + ;; must not reuse that child's separate unwrapped materialization. + (ebox--box-content-cache-purpose + (1+ (or ebox--box-content-cache-purpose 0)))) (ebox--render-box-content-cached box (lambda () @@ -1254,11 +1369,14 @@ unchanged." (defun ebox--content-max-pixel (box) "Return BOX's max-content width in content pixels." - (let* ((content (ebox--intrinsic-box-content box)) - (lines (ebox-string-lines content))) - (if lines - (apply #'max (mapcar #'ebox--string-pixel-width lines)) - 0))) + (let* ((ebox--box-content-empty-width nil) + (content (ebox--intrinsic-box-content box))) + (or (and (string-empty-p content) ebox--box-content-empty-width) + (ebox--rendered-intrinsic-size content :width) + (let ((lines (ebox-string-lines content))) + (if lines + (apply #'max (mapcar #'ebox--string-pixel-width lines)) + 0))))) (defun ebox--line-min-content-pixel (line) "Return LINE's min-content width in content pixels." @@ -1302,11 +1420,15 @@ unchanged." (let ((wrap-mode (ebox-get box :wrap-mode))) (if (ebox-style-no-soft-wrap-p wrap-mode) (ebox--content-max-pixel box) - (let ((lines (ebox-string-lines (ebox--intrinsic-box-content box))) - (max-width 0)) - (dolist (line lines max-width) - (setq max-width - (max max-width (ebox--line-min-content-pixel line)))))))) + (let* ((ebox--box-content-empty-width nil) + (content (ebox--intrinsic-box-content box))) + (or (and (string-empty-p content) ebox--box-content-empty-width) + (ebox--rendered-intrinsic-size content :width) + (let ((lines (ebox-string-lines content)) + (max-width 0)) + (dolist (line lines max-width) + (setq max-width + (max max-width (ebox--line-min-content-pixel line)))))))))) (defun ebox--stretch-content-pixel (box) "Return BOX's stretch-fit content width, or nil without a viewport." @@ -1329,65 +1451,75 @@ LIMIT is already resolved to content pixels, or nil for stretch-fit." Return nil for intrinsic sizes such as min-content and max-content." (cond ((null value) nil) - ((ebox--viewport-size-value-p value) - (when-let* ((viewport (ebox--viewport-pixel-width nil))) - (ebox--box-sizing-content-pixel box viewport))) - ((numberp value) - (ebox--box-sizing-content-pixel box value)) - ((memq value '(stretch contain fit-content)) + ((or (numberp value) (ebox-size-value-p value)) + (when-let* ((pixels (ebox--size-pixels value box 'width))) + (ebox--box-sizing-content-pixel box (max 0 pixels)))) + ((memq value '(stretch fit-content)) (ebox--stretch-content-pixel box)) - ((and (consp value) (eq (car value) 'fit-content)) - (let ((limit (cadr value))) - (cond - ((ebox--viewport-size-value-p limit) - (when-let* ((viewport (ebox--viewport-pixel-width nil))) - (ebox--box-sizing-content-pixel box viewport))) - ((numberp limit) - (ebox--box-sizing-content-pixel box limit)) - (t nil)))) (t nil))) (defun ebox--wrapper-content-viewport-pixel (props) "Return PROPS' content viewport for rendering preformatted child layouts. PROPS are already engine-normalized; do not pass them back through public -property parsers." - (let* ((width-value (ebox-get props :width)) - (min-width (ebox--definite-content-size-pixel - props (ebox-get props :min-width))) - (max-width (ebox--definite-content-size-pixel - props (ebox-get props :max-width))) - (preferred-width - (or (ebox--definite-content-size-pixel props width-value) - (and (not ebox--inline-auto-width-intrinsic-p) - (or (null width-value) - (eq width-value 'auto) - (eq width-value 'fit-content)) - (ebox--stretch-content-pixel props)) - max-width))) - (when preferred-width - (max (or min-width 0) - (min (or max-width 999999999) - (max 0 preferred-width)))))) +property parsers. Normal and Column text flow uses its final intrinsic bounds; +the separate unwrapped measurement keeps that resolution non-recursive." + (if (and (memq (ebox--box-layout-kind props) '(normal column)) + (cl-some (lambda (property) + (memq (ebox-get props property) + '(min-content max-content fit-content))) + '(:width :min-width :max-width))) + (ebox--content-pixel props) + (let* ((width-value (ebox-get props :width)) + (min-width (ebox--definite-content-size-pixel + props (ebox-get props :min-width))) + (max-width (ebox--definite-content-size-pixel + props (ebox-get props :max-width))) + (preferred-width + (or (ebox--definite-content-size-pixel props width-value) + (and (not ebox--inline-auto-width-intrinsic-p) + (or (null width-value) + (eq width-value 'auto) + (eq width-value 'fit-content)) + (ebox--stretch-content-pixel props)) + max-width))) + (when preferred-width + (max (or min-width 0) + (min (or max-width 999999999) + (max 0 preferred-width))))))) (defun ebox--call-with-box-content-context (props function) "Call FUNCTION in the content viewport established by box PROPS." (let ((content-viewport (ebox--wrapper-content-viewport-pixel props)) + (content-height + (when-let* ((preferred + (ebox--resolve-size-content-height + props (ebox-get props :height) nil))) + (max (ebox--resolve-size-content-height + props (ebox-get props :min-height) 0) + (min (or (ebox--resolve-size-content-height + props (ebox-get props :max-height) nil) + preferred) + preferred)))) (ebox--containing-wrap-mode (ebox-get props :wrap-mode))) - (cond - ((eq (ebox-get props :width) 'max-content) - (let ((ebox-viewport-width nil) - (ebox--intrinsic-layout-measurement t) - (ebox--inline-auto-width-intrinsic-p nil)) - (funcall function))) - (content-viewport - (let ((ebox-viewport-width content-viewport) - (ebox--inline-auto-width-intrinsic-p nil)) - (funcall function))) - (t (funcall function))))) + (let ((ebox--css-containing-height + (and content-height + (* content-height (ebox--size-line-height props))))) + (cond + ((and (eq (ebox-get props :width) 'max-content) + (not (memq (ebox--box-layout-kind props) '(normal column)))) + (let ((ebox-viewport-width nil) + (ebox--intrinsic-layout-measurement t) + (ebox--inline-auto-width-intrinsic-p nil)) + (funcall function))) + (content-viewport + (let ((ebox-viewport-width content-viewport) + (ebox--inline-auto-width-intrinsic-p nil)) + (funcall function))) + (t (funcall function)))))) (defun ebox--render-node-as-box-content (node props) "Render NODE as preformatted content for a box with PROPS. -When PROPS establish a content-box viewport, nested auto and `(viewport)' +When PROPS establish a content-box viewport, nested auto and percentage widths resolve against that inner content box instead of the outer caller viewport. A max-content wrapper instead measures the composite NODE without an inherited viewport." @@ -1422,12 +1554,10 @@ an inherited viewport." FALLBACK is used for nil and auto values." (cond ((null value) fallback) - ((eq value 'viewport) - (if-let* ((viewport (ebox--viewport-pixel-width nil))) - (ebox--box-sizing-content-pixel box viewport) + ((or (numberp value) (ebox-size-value-p value)) + (if-let* ((pixels (ebox--size-pixels value box 'width))) + (ebox--box-sizing-content-pixel box (max 0 pixels)) fallback)) - ((numberp value) - (ebox--box-sizing-content-pixel box value)) ((eq value 'auto) fallback) ((eq value 'none) nil) ((eq value 'min-content) @@ -1436,11 +1566,8 @@ FALLBACK is used for nil and auto values." (ebox--content-max-pixel box)) ((eq value 'fit-content) (ebox--fit-content-pixel box nil)) - ((memq value '(stretch contain)) + ((eq value 'stretch) (or (ebox--stretch-content-pixel box) fallback)) - ((and (consp value) (eq (car value) 'fit-content)) - (ebox--fit-content-pixel - box (ebox--resolve-size-content-pixel box (cadr value) nil))) (t (error "ebox: unsupported width value after expansion: %S" value)))) @@ -1457,8 +1584,7 @@ FALLBACK is used for nil and auto values." (or (and (not ebox--inline-auto-width-intrinsic-p) (ebox--stretch-content-pixel box)) (ebox--content-max-pixel box))) - ((or (eq width-value 'viewport) - (memq width-value '(stretch contain))) + ((eq width-value 'stretch) (or (ebox--resolve-size-content-pixel box width-value nil) (ebox--content-max-pixel box))) (t @@ -1469,19 +1595,38 @@ FALLBACK is used for nil and auto values." box (ebox-get box :max-width) nil))) (max (or min-width 0) (min (or max-width 999999999) - (max 0 preferred-width))))) + (max 0 (or preferred-width + (ebox--content-max-pixel box))))))) -(defun ebox--content-height (box text-height) - "Calculate content height, respecting box-sizing." - (let* ((min-height (ebox--resolve-size-content-height - box (ebox-get box :min-height) 0)) +(defun ebox--content-height (box text-height &optional rendered) + "Calculate BOX content height from TEXT-HEIGHT, respecting box-sizing. +RENDERED supplies an intrinsic extent when its visible overflow must not +contribute to the containing block's automatic height." + (let* ((text-height (or (ebox--rendered-intrinsic-size rendered :height) + text-height)) + (min-height (ebox--resolve-size-content-height + box (ebox-get box :min-height) 0 text-height)) (max-height (ebox--resolve-size-content-height - box (ebox-get box :max-height) nil)) + box (ebox-get box :max-height) nil text-height)) (fixed-height (ebox--resolve-size-content-height - box (ebox-get box :height) nil)) + box (ebox-get box :height) nil text-height)) (raw-height (or fixed-height text-height))) - (max 1 min-height - (min (or max-height 999999999) raw-height)))) + ;; The buffer renderer consumes complete text lines. Preserve fractional + ;; lengths through constraint arithmetic and quantize once at this boundary. + (max 0 (floor (max min-height + (min (or max-height 999999999) raw-height)))))) + +(defun ebox--border-carrier-height (box content-height) + "Return extra lines needed to paint BOX's border at CONTENT-HEIGHT. +Horizontal borders share existing content or padding lines. With neither, +one carrier line preserves their paint without inventing a content line." + (if (and (zerop (+ content-height + (floor (ebox-get box :padding-top-height)) + (floor (ebox-get box :padding-bottom-height)))) + (or (> (ebox-get box :border-top-pixel) 0) + (> (ebox-get box :border-bottom-pixel) 0))) + 1 + 0)) (defun ebox--format-content-string (box content) "Format BOX CONTENT (wrap and justify) returning a string." @@ -1507,7 +1652,10 @@ FALLBACK is used for nil and auto values." ;; kp mode distributes inner gaps, but Ebox still owns the box geometry. ;; Pad ragged lines (notably the last line) back to the content width ;; while preserving the requested alignment for those lines. - (ebox--lines-justify wrapped-content pixel-width align))))) + (ebox--record-rendered-intrinsic-size + (ebox--lines-justify wrapped-content pixel-width align) + (ebox--rendered-intrinsic-size content :width) + (ebox--rendered-intrinsic-size content :height)))))) (defun ebox--format-content (box) "Format BOX content (wrap and justify) returning a string." @@ -1521,8 +1669,11 @@ FALLBACK is used for nil and auto values." (defun ebox--total-height (box) "Get the total height of the BOX in lines." (let* ((formatted-content (ebox--format-content box)) - (text-height (ebox-string-height formatted-content))) - (+ (ebox--content-height box text-height) (ebox--side-height box)))) + (text-height (ebox-string-height formatted-content)) + (content-height (ebox--content-height box text-height formatted-content))) + (+ content-height + (ebox--border-carrier-height box content-height) + (ebox--side-height box)))) (defun ebox--node-p (x) "Return non-nil if X is an ebox layout node." @@ -1553,7 +1704,8 @@ Internal implementation of `ebox-render' for box nodes." (content-pixel (ebox--content-pixel box)) (content-height (or (plist-get scroll-window-result :content-height) - (ebox--content-height box text-height))) + (ebox--content-height + box text-height formatted-content))) ;; overflow (overflow (ebox-get box :overflow)) @@ -1580,7 +1732,7 @@ Internal implementation of `ebox-render' for box nodes." (color (and (ebox-style--text-paint-owner-p box) (ebox-get box :color))) (bgcolor (ebox-get box :bgcolor)) - (padding-line-filler (ebox-pixel-space content-pixel)) + (padding-line-filler (ebox--pixel-blank content-pixel 1)) (scroll-offset (or (ebox-get box :scroll-offset) 0)) @@ -1695,9 +1847,7 @@ Internal implementation of `ebox-render' for box nodes." ;; 1. Vertical Alignment of content within the box height. Reused scroll ;; lines already fill a top-aligned, chrome-free viewport. (unless simple-scroll-rendered-lines - ;; The former join treated no decorated lines as one empty string line. - ;; Keep that string-adapter convention outside the list pad/clip core. - (let ((lines (or result (list "")))) + (let ((lines result)) (setq result (ebox--pad-lines-vertical lines content-height @@ -1727,9 +1877,7 @@ Internal implementation of `ebox-render' for box nodes." 'ebox-content-idx 0 'ebox-content-owner region-id))) line)) - ;; Splitting the former empty aligned string yielded one line, - ;; even when alignment clipped all content at height zero. - (or result (list ""))))) + result))) ;; Scroll state describes formatted content, independently of chrome. (puthash region-id box ebox--region-box-table) @@ -1763,7 +1911,8 @@ Internal implementation of `ebox-render' for box nodes." ;; full-height padding/border strips and splitting the result again. (if simple-scroll-rendered-lines ;; The existing scroll path has already completed chrome and paint. - (ebox--apply-surface-properties result (ebox-get box :surface-properties)) + (ebox--apply-surface-properties + result (ebox-interaction--node-surface-properties box)) (setq result (ebox-lines-join (ebox--window-render-flat-wrapper-chunk-lines @@ -1817,7 +1966,7 @@ Internal implementation of `ebox-render' for box nodes." (concat overflow-left-pad line overflow-right-pad))) overflow-lines "\n"))) - (setq result (concat result "\n" overflow-text)))) + (setq result (ebox--lines-stack-vertical result overflow-text)))) (when (and (eq overflow 'scroll) (> text-height content-height)) (add-text-properties 0 (length result) @@ -1828,7 +1977,13 @@ Internal implementation of `ebox-render' for box nodes." ;; the same blanks and all metadata properties survive verbatim. (when (eq (ebox-get box :visibility) 'hidden) (setq result (ebox--apply-hidden-visibility result))) - result))) + (ebox--record-rendered-intrinsic-size + result + (and (ebox--rendered-intrinsic-size formatted-content :width) + (+ content-pixel (ebox--side-pixel box))) + (and (ebox--rendered-intrinsic-size formatted-content :height) + (+ content-height (ebox--border-carrier-height box content-height) + (ebox--side-height box))))))) (defvar ebox--surface-materialization-active nil "Non-nil while an Ebox TP producer is running raw layout.") @@ -1848,8 +2003,8 @@ Internal implementation of `ebox-render' for box nodes." styled)) (surfaced (ebox--apply-surface-properties - painted (ebox-get node :surface-properties))) - (fallback (ebox-pixel-space 0)) + painted (ebox-interaction--node-surface-properties node))) + (fallback (ebox--pixel-blank 0 1)) (rendered (ebox-lines-join (cl-loop for line in (ebox-string-lines surfaced) @@ -1870,7 +2025,23 @@ NODE can be: - a flex node created by `ebox-flex' This function owns spatial layout only; it never creates a live TP surface." (ebox--with-validated-display-cache - (let ((ebox--box-content-render-cache + (let* ((ebox--css-viewport-width + (if ebox--css-size-context-active-p ebox--css-viewport-width + ebox-viewport-width)) + (ebox--css-viewport-height + (if ebox--css-size-context-active-p ebox--css-viewport-height + (and (numberp ebox-viewport-height) + (* ebox-viewport-height (frame-char-height))))) + (ebox--css-containing-height + (if ebox--css-size-context-active-p ebox--css-containing-height + ebox--css-viewport-height)) + (ebox--css-size-context-active-p t) + (ebox--css-font-fact + (or (plist-get node :ebox-font-fact) ebox--css-font-fact)) + (ebox--css-viewport-dependent-subtree-cache + (or ebox--css-viewport-dependent-subtree-cache + (make-hash-table :test 'eq))) + (ebox--box-content-render-cache (or ebox--box-content-render-cache (make-hash-table :test 'eq)))) (when (listp node) @@ -1912,7 +2083,7 @@ region/node identities and render side tables for this one materialization." (ebox--render-cache-scroll-state-region-ids nil) (ebox--render-cache-scroll-state-restorable-p nil) (ebox--render-cache-scroll-state-retained-cost-cache nil)) - (ebox--render-layout candidate)))) + (ebox--quantize-pixel-spaces (ebox--render-layout candidate))))) (defun ebox--render-node (node &optional base-source-index) "Render private layout NODE with optional BASE-SOURCE-INDEX. @@ -2019,36 +2190,60 @@ Auto-width children are rendered at intrinsic inline size while explicit viewport/definite widths keep their containing-block semantics. Shorter children are padded with blank lines so all reach the same height before horizontal concatenation." - (let* ((gap (or (plist-get config :item-gap) 0)) + (let* ((gap (max 0 (or (ebox--size-pixels + (or (plist-get config :item-gap) '(px 0)) + nil 'width) + 0))) (cross-align (or (plist-get config :cross-align) 'stretch)) (vertical-align (pcase cross-align ('center 'center) ('end 'bottom) (_ 'top))) - (rendered + (rendered-items (mapcar (lambda (child) ;; A row owns the horizontal concatenation. Its auto ;; width children must therefore measure intrinsically; ;; explicit viewport/stretch widths still resolve against ;; the ambient containing block. (let ((ebox--inline-auto-width-intrinsic-p t)) - (ebox--render-with-cache child))) + (let ((rendered (ebox--render-with-cache child))) + (cons rendered + (when (and (string-empty-p rendered) + (eq (plist-get child :ebox-kind) 'box)) + (ebox--total-pixel child)))))) children)) - (max-h (if rendered - (apply #'max (mapcar #'ebox-string-height rendered)) + (max-h (if rendered-items + (apply #'max (mapcar (lambda (item) + (ebox-string-height (car item))) + rendered-items)) 0)) pieces) - (cl-loop for string in rendered - for tail on rendered - do (push (ebox--lines-align-vertical - string max-h vertical-align) - pieces) + (cl-loop for item in rendered-items + for tail on rendered-items + do (let ((string (car item)) (empty-width (cdr item))) + (when (or (not (string-empty-p string)) + (and empty-width (> empty-width 0))) + (push (if empty-width + (ebox--pixel-blank empty-width max-h) + (ebox--lines-align-vertical + string max-h vertical-align)) + pieces))) unless (null (cdr tail)) do (when (> gap 0) (push (ebox--pixel-blank gap max-h) pieces))) - (if pieces - (apply #'ebox--lines-concat-horizontal (nreverse pieces)) - ""))) + (let ((rendered + (if pieces + (or (apply #'ebox--lines-concat-horizontal (nreverse pieces)) "") + ""))) + (when (string-empty-p rendered) + (setq ebox--box-content-empty-width + (+ (apply #'+ (mapcar (lambda (item) + (or (cdr item) + (ebox--string-max-pixel-width + (car item)))) + rendered-items)) + (* gap (max 0 (1- (length rendered-items))))))) + rendered))) (defun ebox--render-concat (node) "Render a legacy concat NODE to a string." @@ -2484,7 +2679,7 @@ last cached leaf instead of walking the stack from the beginning." (let* ((full (ebox--format-content box)) (lines (ebox-string-lines full))) (list :content-lines lines - :content-height (ebox--content-height box (length lines)) + :content-height (ebox--content-height box (length lines) full) :box box))))) (defun ebox--scroll-window-producer-spec @@ -2767,16 +2962,20 @@ last cached leaf instead of walking the stack from the beginning." (when-let* ((config (plist-get node :ebox-layout-config))) (and (eq (ebox-layout-config-kind config) 'column) (let ((props (ebox-layout-config-props config))) - (and (zerop (or (plist-get props :item-gap) 0)) + (and (zerop (or (ebox--size-pixels + (or (plist-get props :item-gap) '(lh 0)) + node 'height) + 0)) (eq (or (plist-get props :cross-align) 'stretch) 'stretch))))))) (defun ebox--scroll-window-transparent-column-p (node) "Return non-nil when NODE's Column Box adds no visible wrapper chrome." (and (ebox--scroll-window-canonical-column-p node) - (null (ebox-get node :height)) - (zerop (or (ebox-get node :min-height) 0)) - (null (ebox-get node :max-height)) + (memq (ebox-get node :height) '(nil auto)) + (zerop (ebox--resolve-size-content-height + node (ebox-get node :min-height) 0)) + (memq (ebox-get node :max-height) '(nil none)) (cl-every (lambda (property) (zerop (or (ebox-get node property) 0))) '(:padding-left-pixel :padding-right-pixel @@ -2883,10 +3082,10 @@ are always resolved from BOX so no closure can retain a replaced runtime." (defun ebox--scroll-window-wrapper-supported-p (box) "Return non-nil when BOX can wrap an incomplete lazy scroll prefix." (or (null box) - (and (null (ebox-get box :height)) - (or (null (ebox-get box :min-height)) - (equal (ebox-get box :min-height) 0)) - (null (ebox-get box :max-height)) + (and (memq (ebox-get box :height) '(nil auto)) + (zerop (ebox--resolve-size-content-height + box (ebox-get box :min-height) 0)) + (memq (ebox-get box :max-height) '(nil none)) (ebox-style-no-soft-wrap-p (ebox-get box :wrap-mode)) (eq (ebox-get box :text-align) 'left) (eq (ebox-get box :vertical-align) 'top)))) @@ -3062,6 +3261,15 @@ and a window chunk share the same chrome compositor." (concat border-left-line padding-left-line line padding-right-line border-right-line)))) (lines (nconc top-lines wrapped-lines bottom-lines))) + (when (and (null lines) (or border-top-p border-bottom-p)) + (setq lines + (list (concat border-left-line padding-left-line + (if (or color bgcolor) + (ebox--propertize-colors + padding-line-filler color bgcolor) + padding-line-filler) + padding-right-line + border-right-line)))) (when (and border-top-p lines) (setcar lines (propertize @@ -3074,7 +3282,7 @@ and a window chunk share the same chrome compositor." (ebox--propertize-underline (car tail) border-bottom-color) 'ebox-bb region-id)))) - (when-let* ((properties (ebox-get box :surface-properties))) + (when-let* ((properties (ebox-interaction--node-surface-properties box))) (setq lines (mapcar (lambda (line) (ebox--apply-surface-properties line properties)) @@ -3092,7 +3300,7 @@ only used for incomplete lazy prefixes." content-lines (let* ((region-id (ebox--ensure-region-id box)) (content-pixel (ebox--content-pixel box)) - (padding-line-filler (ebox-pixel-space content-pixel)) + (padding-line-filler (ebox--pixel-blank content-pixel 1)) (padding-left (ebox-get box :padding-left-pixel)) (padding-right (ebox-get box :padding-right-pixel)) (padding-top (if include-top @@ -3199,7 +3407,7 @@ only used for incomplete lazy prefixes." 'ebox-bt region-id)) (setq result (ebox-lines-join lines)))) (ebox--apply-surface-properties - result (ebox-get box :surface-properties)) + result (ebox-interaction--node-surface-properties box)) (when (or (> margin-left 0) (> margin-right 0)) (let* ((box-height (ebox-string-height result)) (ml-str (when (> margin-left 0) @@ -3449,26 +3657,33 @@ only used for incomplete lazy prefixes." formatted-lines (seq-take formatted-lines target-lines)))))))))) -(defun ebox--render-column-children (children &optional config) +(defun ebox--render-column-children (children &optional config content-viewport) "Render flat column CHILDREN with optional typed Column CONFIG. +CONTENT-VIEWPORT is the owning Box's resolved content width, when definite. Stack leaves are rendered independently. When a containing-block viewport is bound, narrower rows are padded to that available width, while wider definite children keep their own width and overflow only on their own rows. During intrinsic measurement, descendants still receive the containing block but the -stack itself uses its natural max child width. A stack used as an intrinsic +stack itself uses its natural max child width unless CONTENT-VIEWPORT retains +an independently sized owning Box. A stack used as an intrinsic inline child follows the same natural-width rule and does not consume the ambient horizontal viewport." - (let* ((gap (or (plist-get config :item-gap) 0)) + (let* ((gap (max 0 (floor (/ (or (ebox--size-pixels + (or (plist-get config :item-gap) '(lh 0)) + nil 'height) + 0) + (float (ebox--size-line-height)))))) (cross-align (or (plist-get config :cross-align) 'stretch)) (horizontal-align (pcase cross-align ('center 'center) ('end 'right) (_ 'left))) (content-width - (and (not ebox--intrinsic-layout-measurement) - (not ebox--inline-auto-width-intrinsic-p) - (ebox--viewport-pixel-width nil))) + (or content-viewport + (and (not ebox--intrinsic-layout-measurement) + (not ebox--inline-auto-width-intrinsic-p) + (ebox--viewport-pixel-width nil)))) (rendered-items (mapcar (lambda (leaf) (let* ((rendered (ebox--render-with-cache leaf)) @@ -3479,17 +3694,24 @@ ambient horizontal viewport." (rendered (if (and content-width (> content-width 0) (ebox-style-soft-wrap-p wrap-mode)) - (ebox--wrap-text - rendered content-width wrap-mode) + (ebox--lines-justify + (ebox--wrap-text + rendered content-width wrap-mode) + content-width horizontal-align) rendered))) (cons rendered - (ebox--string-max-pixel-width rendered)))) + (if (and (string-empty-p rendered) + (eq (plist-get leaf :ebox-kind) 'box)) + (let ((ebox--inline-auto-width-intrinsic-p t)) + (ebox--total-pixel leaf)) + (ebox--string-max-pixel-width rendered))))) children)) (max-w (if rendered-items (apply #'max (mapcar #'cdr rendered-items)) 0)) - (target-w (if (or ebox--intrinsic-layout-measurement - ebox--inline-auto-width-intrinsic-p) + (target-w (if (and (null content-viewport) + (or ebox--intrinsic-layout-measurement + ebox--inline-auto-width-intrinsic-p)) max-w (or content-width max-w)))) (let (pieces) @@ -3507,8 +3729,10 @@ ambient horizontal viewport." (push (ebox--pixel-blank target-w gap) pieces))) (let ((rendered (if pieces - (apply #'ebox--lines-stack-vertical (nreverse pieces)) + (or (apply #'ebox--lines-stack-vertical (nreverse pieces)) "") ""))) + (when (string-empty-p rendered) + (setq ebox--box-content-empty-width (max target-w max-w))) ;; When no child overflows the containing block, stack padding proves ;; every output line already has the exact target width. A wrapping box ;; can then preserve this preformatted content without measuring every diff --git a/ebox-measure.el b/ebox-measure.el index 675a94a..c507087 100644 --- a/ebox-measure.el +++ b/ebox-measure.el @@ -9,6 +9,7 @@ (require 'cl-lib) (require 'ebox-cache) (require 'ebox-font) +(require 'ebox-size) (declare-function ebox--register-render-owned-text-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) ((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) "Return STRING with FACTOR applied as an explicit face height." (if (= factor 1.0) @@ -162,7 +228,16 @@ constant across the selected slice." (let* ((sample (if (or start end) (substring line (or start 0) (or end (length 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) (evicted (aref ebox--string-pixel-width-cache-ring index))) (when ebox--render-string-pixel-width-cache @@ -357,18 +432,28 @@ Nested uses reuse the outer validation." (ebox--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)) "" - (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 'display `(space :width (,pixel-width))))) (propertize " " 'display display)))) (defun ebox--pixel-blank (pixel-width height) - "Generate a blank area of PIXEL-WIDTH and HEIGHT lines." - (when (and pixel-width height (> pixel-width 0) (> height 0)) - (ebox--string-repeat-lines (ebox-pixel-space pixel-width) height))) + "Generate a blank area of PIXEL-WIDTH and HEIGHT lines. +A zero pixel width still preserves the requested positive line 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) "Pad STRING with pixel spaces on left (PREFIX-PIXEL) and right (SUFFIX-PIXEL)." diff --git a/ebox-native-reflow.el b/ebox-native-reflow.el index 84c150f..a9c74d4 100644 --- a/ebox-native-reflow.el +++ b/ebox-native-reflow.el @@ -16,6 +16,7 @@ (require 'ebox-measure) (require 'ebox-render-context) (require 'ebox-runtime-index) +(require 'ebox-size) (declare-function ebox-native--module-version "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-line-value "ebox-flex" (value &optional default)) (declare-function ebox--content-min-pixel "ebox-layout" (box)) -(declare-function ebox--horizontal-size-pixels - "ebox-layout" (value &optional default)) +(declare-function ebox--size-pixels + "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-tree-layout-children "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) -(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.") (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 "Fixed number of four-bit branches in a retained native index path.") -(defun ebox-native-reflow--persistent-index-code (key) - "Return a stable nonnegative retained-index code for KEY." +(defun ebox-native-reflow--property-value-hash (value &optional depth) + "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)) 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) - "Return KEY's value from persistent ROOT, or MISSING." +(defun ebox-native-reflow--persistent-index-get (root key &optional missing template-p) + "Return KEY's value from persistent ROOT, or MISSING. +TEMPLATE-P selects the native property-template comparison and hash." (let ((node root) - (code (ebox-native-reflow--persistent-index-code key)) + (code (ebox-native-reflow--persistent-index-code key template-p)) (depth 0)) (while (and node (< depth ebox-native-reflow--persistent-index-depth)) (setq node (and (vectorp node) (aref node (logand (ash code (* -4 depth)) 15))) depth (1+ 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)) 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. -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))) (if (= depth ebox-native-reflow--persistent-index-depth) (cons (cons key value) - (cl-remove key root :key #'car :test #'equal)) - (let* ((code (ebox-native-reflow--persistent-index-code key)) + (cl-remove key root :key #'car :test + (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)) (copy (if (vectorp root) (copy-sequence root) (make-vector 16 nil)))) (aset copy branch (ebox-native-reflow--persistent-index-put - (aref copy branch) key value (1+ depth))) + (aref copy branch) key value (1+ depth) template-p)) copy)))) -(defun ebox-native-reflow--persistent-index-from-sequence (sequence) - "Return an immutable persistent index for ordered SEQUENCE values." +(defun ebox-native-reflow--persistent-index-from-sequence (sequence &optional template-p) + "Return an immutable persistent index for ordered SEQUENCE values. +TEMPLATE-P selects the native property-template comparison and hash." (let (root) (cl-loop for value across (vconcat sequence) for id from 0 do (setq root (ebox-native-reflow--persistent-index-put - root value id))) + root value id nil template-p))) root)) (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." (when properties (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 (cl-loop for (property value) on properties by #'cddr 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) :properties owned-properties)) (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 (ebox-native-reflow--persistent-index-get ebox-native-reflow--compile-property-template-index - entry missing) + entry missing t) (if (hash-table-p ebox-native-reflow--compile-property-template-ids) (gethash @@ -1498,7 +1568,7 @@ copied separately; no pending frame or registered Rust document is inherited." missing) (or (cl-position entry ebox-native-reflow--compile-property-templates - :test #'equal) + :test #'ebox-native-reflow--property-template-equal-p) missing))))) (if (not (eq existing missing)) existing @@ -1511,7 +1581,7 @@ copied separately; no pending frame or registered Rust document is inherited." (setq ebox-native-reflow--compile-property-template-index (ebox-native-reflow--persistent-index-put ebox-native-reflow--compile-property-template-index - entry id))) + entry id nil t))) (when (hash-table-p ebox-native-reflow--compile-property-template-ids) (puthash @@ -1535,8 +1605,16 @@ copied separately; no pending frame or registered Rust document is inherited." (eq (car properties) 'display)) (let ((display (cadr properties))) (when (and (consp display) (eq (car display) 'space)) - (ebox--horizontal-size-pixels - (plist-get (cdr display) :width) nil))))) + (let* ((width (plist-get (cdr display) :width)) + ;; 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) "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))) (if properties (let ((width (ebox-native-reflow--display-space-width properties))) - (unless (and width (> width 0) + (unless (and width (>= width 0) (string-blank-p (substring-no-properties line position next))) (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-string-lines text))))) -(defun ebox-native-reflow--compile-size (value axis nil-kind) - "Compile normalized size VALUE for AXIS, using NIL-KIND when absent." +(defun ebox-native-reflow--static-size-p (value) + "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 + ((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) (if (member nil-kind '("pixels" "lines")) (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) "Return VALUE's fixed horizontal pixel count, or nil when responsive." (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)) ((and (consp value) (null (cdr value)) (numberp (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 cross-axis stretch and item gap remain explicit without legacy runtime nodes." (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)) (row-p (eq kind 'row)) (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) "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))) (children (ebox-tree-node-children box))) (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) "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)) (content (ebox--propertize-typography @@ -1877,7 +2021,7 @@ cross-axis stretch and item gap remain explicit without legacy runtime nodes." (ebox-get node :color)))) (surface-template-id (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))) (list :type "text" :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 (box &optional child-override child-override-p child-resolved-p) "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-layout-kind (and typed-box-p @@ -1912,18 +2059,18 @@ cross-axis stretch and item gap remain explicit without legacy runtime nodes." (eq wrap-mode 'kp) (ebox-native-reflow--compile-kp-content box))) (region-id (ebox--ensure-region-id box)) - (padding-left (floor (or (ebox-get box :padding-left-pixel) 0))) - (padding-right (floor (or (ebox-get box :padding-right-pixel) 0))) - (padding-top (floor (or (ebox-get box :padding-top-height) 0))) - (padding-bottom (floor (or (ebox-get box :padding-bottom-height) 0))) - (margin-left (floor (or (ebox-get box :margin-left-pixel) 0))) - (margin-right (floor (or (ebox-get box :margin-right-pixel) 0))) - (margin-top (floor (or (ebox-get box :margin-top-height) 0))) - (margin-bottom (floor (or (ebox-get box :margin-bottom-height) 0))) - (border-left (floor (or (ebox-get box :border-left-pixel) 0))) - (border-right (floor (or (ebox-get box :border-right-pixel) 0))) - (border-top (floor (or (ebox-get box :border-top-pixel) 0))) - (border-bottom (floor (or (ebox-get box :border-bottom-pixel) 0))) + (padding-left (ebox-native-reflow--compile-edge box :padding-left-pixel)) + (padding-right (ebox-native-reflow--compile-edge box :padding-right-pixel)) + (padding-top (ebox-native-reflow--compile-edge box :padding-top-height)) + (padding-bottom (ebox-native-reflow--compile-edge box :padding-bottom-height)) + (margin-left (ebox-native-reflow--compile-edge box :margin-left-pixel)) + (margin-right (ebox-native-reflow--compile-edge box :margin-right-pixel)) + (margin-top (ebox-native-reflow--compile-edge box :margin-top-height)) + (margin-bottom (ebox-native-reflow--compile-edge box :margin-bottom-height)) + (border-left (ebox-native-reflow--compile-edge box :border-left-pixel)) + (border-right (ebox-native-reflow--compile-edge box :border-right-pixel)) + (border-top (ebox-native-reflow--compile-edge box :border-top-pixel)) + (border-bottom (ebox-native-reflow--compile-edge box :border-bottom-pixel)) (_border-capability (when (or (> border-top 1) (> border-bottom 1)) (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))))))))) (surface-template-id (ebox-native-reflow--register-property-template - (ebox-get box :surface-properties) "surface")) + (ebox-interaction--node-surface-properties box) "surface")) (fused-text-p (and typed-box-p (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)) (gethash source ebox--flex-content-min-width-table 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) (setq content-min-width captured-content-min 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 (plist-get props :flex-basis) (if (eq axis 'row) 'horizontal 'vertical) - "auto") + "auto" source) :align-self (if (eq (plist-get source :ebox-kind) 'text) "flex-start" @@ -2151,9 +2304,10 @@ cross-axis stretch and item gap remain explicit without legacy runtime nodes." :height (ebox-native-reflow--compile-size (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 - (or (plist-get props :column-gap) 0) + (ebox-native-reflow--compile-gap (plist-get props :column-gap) 'horizontal) :items (vconcat (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))) items))))) -(defun ebox-native-reflow--compile-flex - (node &optional fragments flex-content-min-widths) - "Compile normalized legacy flex NODE through the shared Flex IR builder." +(defun ebox-native-reflow--compile-flex-content-props (node) + "Return NODE's native Flex constraints without freezing relative gaps." (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 - (plist-get node :props) - (plist-get node :raw-props) - wrapper))) + source (plist-get node :raw-props) wrapper))) + (dolist (property '(:row-gap :column-gap)) + (when (plist-member source property) + (plist-put props property (plist-get source property)))) (when wrapper ;; The surrounding native box supplies its resolved content width to the ;; child for each frame. Do not freeze the compiler's ambient viewport. (plist-put props :width nil) (when (plist-member (plist-get node :raw-props) :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 (ebox-native-reflow--compile-flex-inner 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" (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) "Return non-nil when NODE contains only native-supported layout nodes. Grid deliberately stays on the ordinary Ebox renderer until the native backend has a matching two-dimensional layout contract." (cond ((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) ((eq (plist-get node :ebox-type) 'grid) nil) ((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 (box fragments flex-content-min-widths) "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)) (children (ebox-tree-node-children box))) (pcase kind @@ -2445,7 +2640,7 @@ Otherwise return CANDIDATE with the next document revision." :string-pixel-width-cache (make-hash-table :test 'equal) :string-max-pixel-width-cache (make-hash-table :test 'eq) :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) "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 (append retained-property-templates nil)) (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--current-display-signature)) (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))) (ebox-tree-node-local-source-signature node) (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 (when-let* ((handle (plist-get node :ebox-source-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))) (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) "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 ((and (eq (plist-get node :ebox-type) '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 :axis (ebox--flex-axis props))) ((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)) (list :parent-id node-id :slot 1 :kind 'direct-axis :axis kind) @@ -2637,9 +2848,7 @@ per call, and no call recursively visits the captured Ebox tree." (plist-get props :cross-align))))))) ((eq (plist-get node :ebox-type) 'flex) (let* ((wrapper (plist-get node :box)) - (props (ebox--flex-container-content-props - (plist-get node :props) (plist-get node :raw-props) - wrapper))) + (props (plist-get node :props))) (list :parent-id node-id :slot (if wrapper 1 0) :kind 'flex-item :axis (ebox--flex-axis props))))))) @@ -2768,7 +2977,7 @@ Resolve node-ID entries through NODE-TABLE; legacy node plists need no index." :property-templates) nil)) (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--current-display-signature)) (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 (not (and 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) entry cached) (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) revision (1+ revision) fragment (plist-put fragment :node-revision revision) - entry (list :signature (copy-tree signature) + entry (list :signature signature :fragment fragment :revision revision))) (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 session) (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--retained-edge-index postorder new-cache node-table)) @@ -3045,10 +3255,14 @@ RULE identifies the compiler lowering, and RETAINED supplies its stable slot." ('box (if (eq (plist-get node :ebox-kind) 'text) (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))) (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) (let ((child (car (ebox-tree-node-children node)))) (and child @@ -3071,13 +3285,8 @@ RULE identifies the compiler lowering, and RETAINED supplies its stable slot." (if inner (vector outer inner) (vector outer))))) ('flex (let* ((wrapper (plist-get node :box)) - (props (ebox--flex-container-content-props - (plist-get node :props) (plist-get node :raw-props) - wrapper))) - (when wrapper - (plist-put props :width nil) - (when (plist-member (plist-get node :raw-props) :height) - (plist-put props :height 'viewport-height))) + (ebox-native-reflow--compile-size-node (or wrapper node)) + (props (ebox-native-reflow--compile-flex-content-props node))) (let ((inner (ebox-native-reflow--compile-flex-inner props nil))) (if wrapper (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))) (if ok-p (progn - (unless (> line-count 0) - (error "Native reflow tape has no lines")) + (when (and (zerop line-count) (not (zerop character-count))) + (error "Native reflow zero-line tape contains characters")) (unless (= style-count (length styles)) (error "Native reflow tape style table mismatch"))) (unless (and (zerop style-count) (zerop line-count) @@ -3683,7 +3892,10 @@ plist supplied by the caller." (if (and (eq kind 'source) (null value)) (ebox-native-reflow--add-absent-nil-source-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)) (ebox-native-reflow--apply-source-face rendered start end value) @@ -3694,7 +3906,7 @@ plist supplied by the caller." (unless (get-text-property position property rendered) (add-text-properties 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 (rendered property-templates) @@ -3726,7 +3938,7 @@ plist supplied by the caller." (ebox-native-reflow--apply-property-template-span rendered position next (aref property-templates id)))) (setq position next))) - rendered)) + (ebox-interaction--refresh-hover! rendered 0 limit))) (defun ebox-native-reflow--validate-literal-properties (rendered styles complete-p &optional property-templates) @@ -3759,7 +3971,7 @@ plist supplied by the caller." (proper-list-p (nth 2 value)) (= (length (nth 2 value)) 1) (integerp (car (nth 2 value))) - (> (car (nth 2 value)) 0)) + (>= (car (nth 2 value)) 0)) (error "Native reflow tape has an invalid display value"))) ('ebox-content-idx (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")) (unless (= (length rendered) declared-characters) (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")) (ebox-native-reflow--expand-property-templates rendered property-templates) diff --git a/ebox-node-factory.el b/ebox-node-factory.el index 1dac85b..bc10c3c 100644 --- a/ebox-node-factory.el +++ b/ebox-node-factory.el @@ -12,8 +12,8 @@ (defconst ebox--longhand '(:content "" :box-sizing border-box - :width nil :min-width 0 :max-width nil - :height nil :min-height 0 :max-height nil + :width nil :min-width auto :max-width nil + :height nil :min-height auto :max-height nil :padding-left-pixel 0 :padding-right-pixel 0 :padding-top-height 0 :padding-bottom-height 0 :margin-left-pixel 0 :margin-right-pixel 0 @@ -53,7 +53,7 @@ style projection.") (defconst ebox-node-factory--box-longhand '(:content "" :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-top-height 0 :padding-bottom-height 0 :margin-left-pixel 0 :margin-right-pixel 0 diff --git a/ebox-render-context.el b/ebox-render-context.el index fdfde97..396fb66 100644 --- a/ebox-render-context.el +++ b/ebox-render-context.el @@ -9,6 +9,9 @@ (require 'cl-lib) +(declare-function ebox-interaction--refresh-hover! "ebox-interaction" + (string start end &optional face)) + (define-error 'ebox-layout-context-error "Ebox layout context is unavailable") @@ -67,6 +70,16 @@ (defvar ebox-viewport-width 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--surface-materialization-active nil) (defvar ebox--render-root-parent-kind nil @@ -76,6 +89,7 @@ (defvar ebox--viewport-dependent-node-ids-cache nil) (defvar ebox--viewport-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 "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) "Add Ebox FACE to STRING after capturing its caller baseline." (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) "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)) (ebox-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 (plist-get ,render-state :runtime-revision)) (ebox--surface-materialization-active t) @@ -257,6 +279,9 @@ including strings returned from a render-cache hit." (ebox--viewport-dependent-subtree-cache (or ebox--viewport-dependent-subtree-cache (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 (or ebox--viewport-height-dependent-subtree-cache (plist-get diff --git a/ebox-selector.el b/ebox-selector.el index 4d8b844..d87af76 100644 --- a/ebox-selector.el +++ b/ebox-selector.el @@ -81,6 +81,7 @@ (let* ((buffer (ebox-selector--region-handle-buffer handle)) (surface (ebox-selector--region-handle-surface handle)) (object (ebox-selector--region-handle-object handle)) + (logical-id (ebox-selector--region-handle-logical-id handle)) (state (and (buffer-live-p buffer) (ebox--buffer-render-state buffer))) (region-id @@ -88,6 +89,12 @@ (eq surface (plist-get state :surface)) (tp-surface-live-p surface) (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 (plist-get state :surface-object-region-table))))) (unless region-id diff --git a/ebox-size.el b/ebox-size.el new file mode 100644 index 0000000..0cd62a9 --- /dev/null +++ b/ebox-size.el @@ -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 diff --git a/ebox-source.el b/ebox-source.el index 8f7af6c..01bdbda 100644 --- a/ebox-source.el +++ b/ebox-source.el @@ -9,11 +9,12 @@ (require 'cl-lib) (require 'subr-x) +(require 'ebox-interaction) (cl-defstruct (ebox-source-record (:constructor ebox-source--make-record)) "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 (:constructor ebox-source--make-handle) @@ -230,6 +231,11 @@ Functions and records are opaque values and retain identity." copy)) (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) "Return non-nil when VALUE is one public stable source key." (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 (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. The returned handle contains identity only; all facts are owned by BUILDER 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) :states (ebox-source--metadata-tokens selector-state) :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))) (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-state nil states-p) (declarations nil declarations-p) + (interactions nil interactions-p) (provenance nil provenance-p)) "Return `(NEW-INDEX . NEW-HANDLE)' after rebinding HANDLE in INDEX. The semantic identity and all omitted immutable facts are retained. A fresh @@ -403,6 +411,8 @@ facts on the canonical node." :declarations (if declarations-p declarations (ebox-source-record-declarations record)) + :interactions (if interactions-p interactions + (ebox-source-record-interactions record)) :provenance (if provenance-p provenance (ebox-source-record-provenance record)))) @@ -742,7 +752,9 @@ for nonstructural changes; structural changes supply FINAL-ORDER." :declarations (ebox-source--copy-data (ebox-source-record-declarations record)) :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) "Return a detached source record for HANDLE in INDEX, or nil." diff --git a/ebox-state-contract.el b/ebox-state-contract.el index b1a58d1..32793c5 100644 --- a/ebox-state-contract.el +++ b/ebox-state-contract.el @@ -51,6 +51,7 @@ (:id region-generation-indexes :storage (:region-id-set :region-node-table :region-box-count-table :region-box-table :layout-snapshots + :fractional-pixel-output-p :render-owned-text-values ebox--render-owned-text-values) :current-contract candidate-state-plus-global-projection :target-contract immutable-generation-value @@ -221,6 +222,17 @@ :rollback discard :rebuild-proof resolve-font-fact-again :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 :storage (ebox--box-content-render-cache ebox--char-width-cache ebox--display-signature-cache ebox--face-height-width-cache @@ -239,13 +251,15 @@ ebox--render-root-cache-table-table ebox--render-string-max-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--string-pixel-width-cache ebox--string-pixel-width-cache-ring ebox--viewport-dependent-node-ids-cache ebox--viewport-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-cache--buffer-report-table ebox-cache--registry @@ -284,6 +298,9 @@ rather than relying on an implicit default.") '((:symbol ebox--defer-scroll-content-index :reason dynamic-boolean-or-index-mode :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 :reason numeric-eviction-cursor :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 :reason dynamically-bound-compile-local-scratch :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 :reason dynamically-bound-render-proof-input :evidence let-bound-to-candidate-and-prior-generations-for-one-plan) diff --git a/ebox-style.el b/ebox-style.el index 9c32d78..37f9dcd 100644 --- a/ebox-style.el +++ b/ebox-style.el @@ -12,6 +12,7 @@ (require 'tp-style) (require 'ebox-source) (require 'ebox-layout-config) +(require 'ebox-size) (defvar ebox--longhand) (declare-function ebox-selector-parse "ebox-selector" (selector)) @@ -21,16 +22,8 @@ "ebox-flex" (props)) (declare-function ebox-grid--normalize-config-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" ()) -(defvar ebox--preferred-size-keywords) -(defvar ebox--min-size-keywords) -(defvar ebox--max-size-keywords) - (defun ebox-style--create-stylesheet () "Return one private ECSS stylesheet for Ebox style state." (ecss-stylesheet-create)) @@ -80,129 +73,129 @@ (:name :width :id ebox/width :initial auto :contexts (box row column flex grid) :group geometry :dirty-kind geometry :signature layout - :validator horizontal-size + :size-axis inline :validator horizontal-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) :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)) (:name :max-width :id ebox/max-width :initial none :contexts (box row column flex grid) :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)) (:name :height :id ebox/height :initial auto :contexts (box row column flex grid) :group geometry :dirty-kind geometry :signature layout - :validator vertical-size) - (:name :min-height :id ebox/min-height :initial 0 + :size-axis block :validator vertical-size) + (:name :min-height :id ebox/min-height :initial auto :contexts (box row column flex grid) :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 :contexts (box row column flex grid) :group geometry :dirty-kind geometry :signature layout - :validator max-vertical-size) - (:name :padding-top :id ebox/padding-top :initial 0 + :size-axis block :validator max-vertical-size) + (:name :padding-top :id ebox/padding-top :initial (lh 0) :contexts (box row column flex grid) :group geometry :dirty-kind geometry :signature layout - :validator nonnegative-size + :size-axis block :validator nonnegative-size :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) :group geometry :dirty-kind geometry :signature layout - :validator nonnegative-size + :size-axis inline :validator nonnegative-size :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) :group geometry :dirty-kind geometry :signature layout - :validator nonnegative-size + :size-axis block :validator nonnegative-size :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) :group geometry :dirty-kind geometry :signature layout - :validator nonnegative-size + :size-axis inline :validator nonnegative-size :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 :dirty-kind geometry :signature layout - :validator nonnegative-size + :size-axis block :validator nonnegative-size :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 :dirty-kind geometry :signature layout - :validator nonnegative-size + :size-axis inline :validator nonnegative-size :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 :dirty-kind geometry :signature layout - :validator nonnegative-size + :size-axis block :validator nonnegative-size :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 :dirty-kind geometry :signature layout - :validator nonnegative-size + :size-axis inline :validator nonnegative-size :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) :group geometry :dirty-kind geometry :signature layout - :validator nonnegative-size + :size-axis block :validator nonnegative-size :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) :group geometry :dirty-kind geometry :signature layout - :validator nonnegative-size + :size-axis inline :validator nonnegative-size :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) :group geometry :dirty-kind geometry :signature layout - :validator nonnegative-size + :size-axis block :validator nonnegative-size :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) :group geometry :dirty-kind geometry :signature layout - :validator nonnegative-size + :size-axis inline :validator nonnegative-size :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 :dirty-kind geometry :signature layout - :validator nonnegative-size + :size-axis block :validator nonnegative-size :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 :dirty-kind geometry :signature layout - :validator nonnegative-size + :size-axis inline :validator nonnegative-size :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 :dirty-kind geometry :signature layout - :validator nonnegative-size + :size-axis block :validator nonnegative-size :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 :dirty-kind geometry :signature layout - :validator nonnegative-size + :size-axis inline :validator nonnegative-size :engine-projection (:to :margin-left-pixel :conv pixel)) (: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 - :validator border-width + :size-axis stroke :validator border-width :engine-projection (:to :border-top-pixel :conv border-pixel)) (: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 - :validator border-width + :size-axis stroke :validator border-width :engine-projection (:to :border-right-pixel :conv border-pixel)) (: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 - :validator border-width + :size-axis stroke :validator border-width :engine-projection (:to :border-bottom-pixel :conv border-pixel)) (: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 - :validator border-width + :size-axis stroke :validator border-width :engine-projection (:to :border-left-pixel :conv border-pixel)) (:name :border-top-style :id ebox/border-top-style :initial none :contexts (box row column flex grid) :group structure @@ -257,9 +250,9 @@ :validator outer :impacts (structure geometry) :projections (structure layout) :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 - :validator nonnegative-integer) + :size-axis main :validator nonnegative-size) (:name :cross-align :id ebox/cross-align :initial stretch :contexts (row column) :group geometry :dirty-kind geometry :signature layout :validator cross-align) @@ -281,12 +274,12 @@ (:name :justify-items :id ebox/justify-items :initial stretch :contexts (grid) :group geometry :dirty-kind geometry :signature layout :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 - :validator nonnegative-size) + :size-axis block :validator nonnegative-size) (:name :column-gap :id ebox/column-gap - :initial 0 :contexts (flex grid) :group geometry :dirty-kind geometry - :signature layout :validator nonnegative-size) + :initial (px 0) :contexts (flex grid) :group geometry :dirty-kind geometry + :signature layout :size-axis inline :validator nonnegative-size) (:name :order :id ebox/order :initial 0 :contexts (item) :group geometry :dirty-kind geometry :signature layout :validator order) (:name :flex-grow :id ebox/flex-grow :initial 0 :contexts (item) @@ -297,7 +290,7 @@ :validator flex-factor) (:name :flex-basis :id ebox/flex-basis :initial auto :contexts (item) :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) :group geometry :dirty-kind geometry :signature layout :validator align-self) @@ -306,16 +299,16 @@ :validator justify-self) (:name :grid-template-columns :id ebox/grid-template-columns :initial nil :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 :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 :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 :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 :contexts (grid) :group structure :dirty-kind structure :signature layout :validator grid-auto-flow) @@ -575,10 +568,18 @@ "Return PLIST with KEY set to 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) "Split VALUE using CSS TRBL rules into top, right, bottom, left." (cond ((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)) ((and (listp value) (keywordp (car value))) (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))) (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))) - (_ (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)))) (defun ebox-style--split-pair (value) "Split VALUE into a start/end pair." (cond ((null value) (list nil nil)) + ((ebox-style--atomic-size-form-p value) (list value value)) ((atom value) (list value value)) - ((and (listp value) (= 1 (length value))) - (list (nth 0 value) (nth 0 value))) - ((listp value) (list (nth 0 value) (nth 1 value))) + ((listp value) + (pcase (length 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)))) (defun ebox-style--wsc-classify (item) "Classify border shorthand ITEM as width, style, or color." (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)) ((or (stringp item) (tp-paint-slot-p item)) (cons 'color item)) (t (error "Invalid Ebox border component: %S" item)))) @@ -613,12 +619,13 @@ "Split VALUE into border width, style, and color." (cond ((null value) (list nil nil nil)) + ((ebox-style--atomic-size-form-p value) (list value 'solid nil)) ((atom value) (let ((classified (ebox-style--wsc-classify value))) (pcase (car classified) ('width (list (cdr classified) nil nil)) - ('style (list nil (cdr classified) nil)) - ('color (list 1 'solid (cdr classified)))))) + ('style (list '(px 1) (cdr classified) nil)) + ('color (list '(px 1) 'solid (cdr classified)))))) ((proper-list-p value) (let (width style color width-p style-p color-p) (dolist (item value) @@ -633,7 +640,7 @@ ('color (when color-p (error "Duplicate Ebox border color in %S" value)) (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" value)))) @@ -730,7 +737,8 @@ "Return canonical boolean border declarations for SIDE and VALUE." (unless (memq value '(nil t)) (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)) (if value 'solid 'none))) @@ -797,7 +805,7 @@ (pcase value ((and (pred numberp) (guard (>= value 0))) (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)) ('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)) @@ -821,55 +829,50 @@ (numberp (car value)) (< (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) - "Return non-nil when VALUE is one canonical edge size." - (or (ebox-style--nonnegative-real-p value) - (ebox-style--pixel-size-p value))) + "Return non-nil when VALUE is a canonical nonnegative length-percentage." + (ebox-size-value-p value nil t)) + +(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) "Return non-nil when VALUE is a canonical horizontal size." - (or (ebox-style--nonnegative-real-p value) - (ebox-style--pixel-size-p value) - (memq value '(viewport auto min-content max-content - fit-content stretch contain)) - (equal value '(viewport)) - (and (proper-list-p value) (= (length value) 2) - (eq (car value) 'fit-content) - (ebox-style--horizontal-size-p (cadr value))))) + (or (memq value ebox-style--preferred-size-keywords) + (ebox-size-value-for-axis-p value 'inline))) + +(defun ebox-style--preferred-size-p (value) + "Return non-nil when VALUE is a size whose parent axis is not known yet." + (or (memq value ebox-style--preferred-size-keywords) + (ebox-style--nonnegative-size-p value))) (defun ebox-style--min-horizontal-size-p (value) "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) "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) "Return non-nil when VALUE is a canonical vertical size." - (or (ebox-style--nonnegative-real-p value) - (memq value '(viewport-height auto)) - (equal value '(viewport-height)))) + (or (memq value ebox-style--preferred-size-keywords) + (ebox-size-value-for-axis-p value 'block))) (defun ebox-style--min-vertical-size-p (value) "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) "Return non-nil when VALUE is a canonical maximum vertical size." - (or (ebox-style--nonnegative-real-p value) - (eq value 'none) - (eq value 'viewport-height) - (equal value '(viewport-height)))) + (or (memq value ebox-style--maximum-size-keywords) + (ebox-size-value-for-axis-p value 'block))) (defun ebox-style--nonnegative-number-p (value) "Return non-nil when VALUE is nil or a non-negative number." @@ -927,8 +930,8 @@ (or (null value) (memq value '(normal italic oblique)))) (defun ebox-style--border-width-p (value) - "Return non-nil when VALUE is an exact nonnegative pixel width." - (and (integerp value) (>= value 0))) + "Return non-nil when VALUE is a nonnegative length without percentages." + (ebox-size-value-p value nil nil)) (defun ebox-style--border-style-p (value) "Return non-nil when VALUE is a supported canonical border style." @@ -993,10 +996,8 @@ (memq value '(row column))) (defun ebox-style--grid-fixed-track-p (value) - "Return non-nil when VALUE is one nonnegative fixed Grid track." - (or (ebox-style--nonnegative-real-p value) - (and (proper-list-p value) (= (length value) 1) - (ebox-style--nonnegative-real-p (car value))))) + "Return non-nil when VALUE is a nonnegative Grid track size expression." + (ebox-style--nonnegative-size-p value)) (defun ebox-style--grid-fr-track-p (value) "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) "Return schema validator named NAME." (pcase name + ('preferred-size #'ebox-style--preferred-size-p) ('horizontal-size #'ebox-style--horizontal-size-p) ('min-horizontal-size #'ebox-style--min-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 . 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) "Return ECSS registration options for PROPERTY metadata." (let ((options (list :initial (plist-get property :initial) :inherits (plist-get property :inherits) :normalizer (ebox-style--normalizer (plist-get property :normalizer)) - :validator (ebox-style--validator - (plist-get property :validator)) + :validator (ebox-style--schema-validator property) :equality #'equal))) (when-let* ((shorthand (ebox-style--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) "Return non-nil when VALIDATOR rejects static VALUE." (pcase validator + ('preferred-size (not (ebox-style--preferred-size-p value))) ('nonnegative-number (and (numberp value) (< value 0))) ('horizontal-size (not (ebox-style--horizontal-size-p value))) @@ -1437,14 +1475,23 @@ not affect declaration compilation and are intentionally excluded." ('wrap-mode (not (ebox-style--wrap-mode-p value))))) -(defun ebox-style--validate-declaration-values (declarations) - "Return DECLARATIONS after static Ebox-owned value validation." +(defun ebox-style--validate-declaration-values (declarations &optional tag) + "Return DECLARATIONS after static value and author TAG axis validation." (cl-loop for (property value) on declarations by #'cddr for schema = (ebox-style--property property) for validator = (and schema (plist-get schema :validator)) - when (ebox-style--static-invalid-declaration-p validator value) - do (error "ebox: invalid value for %S: %S" - (plist-get schema :name) value)) + for axis = (ebox-style--size-axis schema tag) + when (or (ebox-style--static-invalid-declaration-p validator 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) (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) do (error "Ebox %S does not accept %S" tag (plist-get property :name)))) - (ebox-style--validate-declaration-values declarations) + (ebox-style--validate-declaration-values declarations tag) declarations) (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)))) (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 - ('pixel (or (ebox--nonnegative-horizontal-size-pixels value nil) 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)) + ((or 'pixel 'size-pixel 'border-pixel 'line) (or value 0)) ('color (ebox-style--parse-color value)) (_ value))) diff --git a/ebox-surface.el b/ebox-surface.el index 36e32be..f5f2bbc 100644 --- a/ebox-surface.el +++ b/ebox-surface.el @@ -85,6 +85,17 @@ (defvar ebox-surface--font-window nil "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) "Return non-nil when STATE's mixed proof contains one Range splice." (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 (and (eq projection-kind 'mixed-owner-reflow) (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 (if (memq projection-kind '(span-patch owner-scoped scroll-patch @@ -1531,10 +1553,11 @@ PREVIOUS-STATE's node-object table." (make-hash-table :test 'equal))) (stylesheet-signature (and style-required-p - (if (and (memq projection-kind - '(owner-scoped scroll-patch - formatting-context-reflow mixed-owner-reflow)) - (not range-splice-p)) + (if (or retained-span-style-p + (and (memq projection-kind + '(owner-scoped scroll-patch + formatting-context-reflow mixed-owner-reflow)) + (not range-splice-p))) nil (ebox-surface--stylesheet-signature)))) (stylesheet-subject-local-p @@ -1548,11 +1571,12 @@ PREVIOUS-STATE's node-object table." (not (and (eq projection-kind 'native-frame) (plist-get state-overrides :native-topology-stable-p))) - (not (and (memq projection-kind - '(owner-scoped scroll-patch - formatting-context-reflow - mixed-owner-reflow)) - (not range-splice-p))))) + (not (or retained-span-style-p + (and (memq projection-kind + '(owner-scoped scroll-patch + formatting-context-reflow + mixed-owner-reflow)) + (not range-splice-p)))))) (_materialized-source-index (when (and subjects-required-p (ebox-source--index-derived-stale-p source-index) @@ -1578,19 +1602,21 @@ PREVIOUS-STATE's node-object table." (subjects (when subjects-required-p (ebox-tree-subject-index root source-index))) (selector-tree-snapshot - (if (and (memq projection-kind - '(owner-scoped scroll-patch - formatting-context-reflow mixed-owner-reflow - native-frame)) - (not range-splice-p)) + (if (or retained-span-style-p + (and (memq projection-kind + '(owner-scoped scroll-patch + formatting-context-reflow mixed-owner-reflow + native-frame)) + (not range-splice-p))) (plist-get previous-state :selector-tree-snapshot) (and subjects (ebox-surface--subject-signature (plist-get subjects :root-subject))))) (selector-tree-token - (if (and (memq projection-kind - '(owner-scoped scroll-patch native-frame)) - (not range-splice-p)) + (if (or retained-span-style-p + (and (memq projection-kind + '(owner-scoped scroll-patch native-frame)) + (not range-splice-p))) (plist-get previous-state :selector-tree-token) (and selector-tree-snapshot (if (and (equal selector-tree-snapshot @@ -1823,7 +1849,7 @@ the projection roots a second time." :geometry-kind) 'span-patch) (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)) (let ((proofs (plist-get state :owner-scoped-proofs))) (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." (let ((rendered (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. ;; Refresh the complete lightweight snapshot, including ancestor region ;; 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)) (let ((ebox--surface-materialization-active t)) (ebox--render-layout node))))) + (ebox-surface--check-local-pixel-output rendered) (unless (plist-get state :native-render-p) (ebox--record-render-output-provenance rendered)) (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) (let ((ebox-incremental--buffer-render-state-override (cons buffer state))) (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 ;; capacity can include chrome outside this owner's published spans. ;; 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 (plist-get snapshot :external-footprint-signature) :max-line-pixel-width)) @@ -3861,7 +3901,13 @@ Proof mismatches return nil; unexpected rendering errors propagate." (= (length spans) (length old-lines)) (= (length spans) (length new-lines)))) 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 (ebox-surface--retained-slot-owner-text buffer previous-state spans old-lines new-lines local @@ -3927,7 +3973,15 @@ Proof mismatches return nil; unexpected rendering errors propagate." (retain-properties-p (and (memq (plist-get state :projection-kind) '(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 ;; Detached geometry omits enclosing effects. Mixed paint ;; 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 :old-spans spans :details details :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) "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 :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) + "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." (let* ((proofs (plist-get state :owner-scoped-proofs)) (candidates @@ -4220,10 +4454,20 @@ Proof mismatches return nil; unexpected rendering errors propagate." previous-state))) (fragments (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 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-content-p t) (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 (put-text-property start end 'face face output) (remove-text-properties start end '(face nil) output))) + (ebox-interaction--refresh-hover! output start end nil new-state) (cl-remf fragment :text) (plist-put fragment :text-source-p t) (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)) 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. Chrome-free top-aligned root scroll boxes already store fully propertized 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)) (scroll-table (plist-get state :scroll-state-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)) (let* ((lines (cadr visible)) (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) (plist-get state :retained-scroll-content-p)) ;; 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) output)) output) - (let ((ebox--scroll-window-cached-state scroll-state) - (ebox--scroll-window-skip-state-rebuild-p - (plist-get scroll-state :content-lines-complete-p))) - (ebox-surface--render-candidate-node state root))))))) + (unless complete-lines-only-p + (let ((ebox--scroll-window-cached-state scroll-state) + (ebox--scroll-window-skip-state-rebuild-p + (plist-get scroll-state :content-lines-complete-p))) + (ebox-surface--render-candidate-node state root)))))))) (defun ebox-surface--native-scroll-full-output (state) "Return the complete propertized root scroll output for native scrolling." @@ -4915,19 +5181,21 @@ when it needs to mutate them." collect fragment))) (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. Return nil unless every patch is aligned to retained fragment boundaries and preserves the ordered role topology inside its replaced range. On success, 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) (catch 'miss (let ((remaining old-fragments) (old-cursor 0) (new-cursor 0) (line-shift 0) - result new-origin-ranges) + result new-origin-ranges coordinate-patches) (dolist (patch patches) (let ((old-start (plist-get patch :old-start)) (old-end (plist-get patch :old-end)) @@ -5015,6 +5283,11 @@ and shifted without inspecting its text properties." (copy-sequence chain))) (plist-put copy :text output) (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))) old-window new-window) (push (cons new-start new-end) new-origin-ranges))) @@ -5039,6 +5312,9 @@ and shifted without inspecting its text properties." (remove-text-properties (car range) (cdr range) (list ebox--paint-origin-property nil) output)) + (when state + (plist-put state :content-coordinate-patches + (nreverse coordinate-patches))) (nreverse result))))) (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))) (setq faces (ebox-surface--box-face-contributions box)))) (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 (append faces (list (ebox-buffer--color-face (plist-get box :border-top-color) 'overline))))) (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 (append faces @@ -5711,6 +5987,9 @@ relative face contributions and performs the final merge during prepare." (push (list :start offset :end (+ offset length) :props props) 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-source-p nil) (plist-put copy :start offset) @@ -5815,6 +6094,7 @@ relative face contributions and performs the final merge during prepare." (if face (put-text-property 0 (length text) 'face face 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) (cl-remf fragment :old-paint-role-ids) fragment)) @@ -5890,17 +6170,24 @@ OLD-TEXT is a defensive snapshot of the published TP content." (make-hash-table :test 'eq))) (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) - "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 -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, but cannot introduce or remove a viewport expression or node identity." - (and (eq projection-kind 'mixed-owner-reflow) - (plist-get previous-state :viewport-dependent-node-ids-ready) - (plist-member previous-state :viewport-dependent-node-id-axes) - (when-let* ((proof (plist-get state-overrides :mixed-owner-proof))) + (let ((dirty-set + (pcase projection-kind + ('mixed-owner-reflow + (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 (lambda (entry) (cl-every @@ -5908,7 +6195,7 @@ but cannot introduce or remove a viewport expression or node identity." (or (memq key '(:content :surface-properties)) (memq key ebox--paint-style-signature-keys))) (plist-get entry :changed-keys))) - (plist-get proof :dirty-set))))) + dirty-set)))) (defun ebox-surface--projection-state (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)))) (cl-remf state :paint-property-contributions) (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) (when (plist-get state :native-sync-confirmed-p) (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) (setq state-overrides (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 - (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 root source-index))) (author-style-pending-p (> (ebox-tree-author-style-count root) 0)) (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) (or stylesheet-active-p inline-inheritance-required-p))) @@ -6142,7 +6445,7 @@ published runtime and must be consumed without clearing those shared nodes." state-overrides :viewport-dependent-node-id-axes)) 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)))) (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))) (rendered (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 - '(span-patch owner-scoped formatting-context-reflow - mixed-owner-reflow)) + '(span-patch owner-scoped scroll-patch + formatting-context-reflow mixed-owner-reflow)) (if-let* ((local-output - (pcase projection-kind - ('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))))) + (or + ;; Proven root scroll rows own their complete + ;; physical origin, including any remainder. + (and (eq projection-kind 'scroll-patch) + (not (plist-get state :native-scroll-materialize-p)) + (ebox-surface--scroll-patch-output + (ebox-surface--signals-buffer signals) state t)) + ;; A detached fractional owner cannot recover + ;; the remainder spent by preceding siblings. + (unless (plist-get previous-state + :fractional-pixel-output-p) + (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 ;; A declined output may already have staged coordinates, ;; 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 context root source-index 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 state (ebox-surface--projection-state diff --git a/ebox-tree.el b/ebox-tree.el index 684b695..4169aac 100644 --- a/ebox-tree.el +++ b/ebox-tree.el @@ -27,6 +27,7 @@ (declare-function ebox--next-runtime-node-id "ebox" ()) (declare-function ebox--string-region-ids "ebox" (string)) (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) "Default CSS-like display for a plain Ebox box.") @@ -735,6 +736,10 @@ handles, reused node objects, and cycles without consulting node metadata." keys))) (_ (error "Ebox participation requires a Flex or Grid parent: %S" 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) (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) "Validate NODE-IDS in indexed final tree using AUTHOR or COMPUTED SOURCE." (setq source (or source 'author)) - (dolist (node-id (delete-dups (copy-sequence node-ids))) + (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))) (ebox-tree--validate-node-parent-participation node @@ -899,7 +912,10 @@ mark every ancestor dirty." (while (and equal-p old new) (setq equal-p (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)) new (ebox-tree--next-local-source-entry (cddr 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))) (unless (and (eq (not (null old-entry)) (not (null new-entry))) - (equal-including-properties - (cadr old-entry) (cadr new-entry))) + (funcall (if (eq key :surface-properties) + #'ebox-interaction-surface-equal-p + #'equal-including-properties) + (cadr old-entry) (cadr new-entry))) (push (if (eq key :ebox-layout-props) :props key) changed)))))))) (nreverse changed))) diff --git a/ebox.el b/ebox.el index b8d9d1f..91e3896 100644 --- a/ebox.el +++ b/ebox.el @@ -23,9 +23,9 @@ "Directory containing the active Ebox Lisp 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-font.el" "ebox-style.el" + "ebox-font.el" "ebox-size.el" "ebox-style.el" "ebox-layout-config.el" "ebox-node-factory.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 "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.") (defvar ebox-viewport-height nil "Dynamically bound vertical viewport height in lines. -DSL height values may use `(viewport-height)' to resolve to this value while a -caller, such as the Playground preview, renders against a concrete window.") +The render context converts this value to pixels with the frame line metric. +Block-axis DSL sizes use `(vh N)' for N percent of that pixel height.") (defvar ebox--region-box-table) (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)) (defsubst ebox-get (box property) - "Retrieve PROPERTY from BOX." - (plist-get box property)) + "Retrieve PROPERTY from BOX, resolving deferred engine edge lengths." + (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) "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)) (defsubst ebox-string-lines (string) - "Split STRING into a list of lines, preserving text properties." - (when string + "Split STRING into lines, preserving properties; empty input has no lines." + (when (and string (not (string-empty-p string))) (let ((start 0) (lines nil)) (while (string-match "\n" string start) @@ -504,12 +514,18 @@ completed report. Nil removes observation without affecting rendering." (defsubst ebox-string-height (string) "Return the number of lines in STRING. -Optimized: Counts newlines directly to avoid list allocation." - (1+ (cl-count ?\n string))) +Empty input has zero lines. Count newlines without allocating a line list." + (if (or (null string) (string-empty-p string)) + 0 + (1+ (cl-count ?\n string)))) (defsubst ebox-lines-join (lines) - "Join a list of LINES into a single string." - (string-join lines "\n")) + "Join LINES into a string, preserving an explicit empty line's height. +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) "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 and then covered by one pixel-exact display space, so line widths are identical in fixed and variable pitch while nothing is drawn. Buffer -text and every other text property (region, content, and owner -metadata, event surfaces) stay untouched, which keeps spans, markers, -and incremental diffs byte-compatible with the visible rendering." +text and ownership metadata stay intact. Native interaction properties are +explicitly cleared so invisible content cannot retain an active hit surface." (ebox--maplines (lambda (line) (if (string-empty-p line) @@ -544,7 +559,11 @@ and incremental diffs byte-compatible with the visible rendering." (base (/ width length)) (remainder (- width (* base length)))) (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) (put-text-property 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. OFFSET controls content shift: positive (top), negative (bottom). 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))) (copy-sequence string) (ebox-lines-join @@ -664,8 +684,10 @@ ALIGN can be `top', `center', or `bottom'." (apply #'cl-mapcar #'concat line-lists))))) (defun ebox--lines-stack-vertical (&rest strings) - "Stack STRINGS vertically." - (setq strings (delq nil strings)) + "Stack nonempty STRINGS vertically, without adding zero-height separators." + (setq strings (seq-filter (lambda (string) + (and string (not (string-empty-p string)))) + strings)) (ebox-lines-join strings)) (defsubst ebox-default-foreground () @@ -1749,14 +1771,10 @@ object. Unrelated ownerless and foreign-buffer states remain isolated." ids)) (defun ebox--string-next-region-property-change (string pos limit) - "Return next ebox region property change in STRING after POS, capped at LIMIT." - (let ((next limit)) - (dolist (entry ebox-region-types) - (let ((change (next-single-property-change - pos (cdr entry) string limit))) - (when (and change (< change next)) - (setq next change)))) - next)) + "Return the next possible region boundary in STRING after POS, up to LIMIT. +Finer non-region property boundaries are safe: each caller merges equal owner +ranges. Using the native interval cursor avoids one search per role." + (or (next-property-change pos string limit) limit)) (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." @@ -2382,12 +2400,14 @@ published or mutated. Return non-nil when the isolated render ran." (plist-get owner :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)))) (cond ((numberp width) width) - ((and (consp width) (null (cdr width)) (numberp (car width))) - (car width))))) + ((and (consp width) (ebox-size-value-p width) + (null (ebox-size-dependencies width))) + (ebox-size-resolve width nil))))) (defun ebox--invalidate-reflow-prewarm-scratch-signatures (scratch region-id) @@ -3831,7 +3851,7 @@ Defaults to the current buffer." "Longhand box properties accepted by `ebox-region-update'.") (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.") (defun ebox--region-update-style-properties (props) @@ -3855,13 +3875,15 @@ Defaults to the current buffer." (:background-color :bgcolor) (_ property))) -(defun ebox--region-update-direct-text-node (box) - "Return BOX's sole direct canonical Text child, or nil." - (let ((children (and (ebox-box-node-p box) - (ebox-box-node-children box)))) - (and (= (length children) 1) - (ebox-text-node-p (car children)) - (car children)))) +(defun ebox--region-update-direct-text-node (node) + "Return canonical Text NODE itself or NODE's sole direct Text child." + (if (ebox-text-node-p node) + node + (let ((children (and (ebox-box-node-p node) + (ebox-box-node-children node)))) + (and (= (length children) 1) + (ebox-text-node-p (car children)) + (car children))))) (defun ebox--region-update-content-value (box) "Return the mutable region content represented by canonical BOX." @@ -3882,6 +3904,10 @@ Defaults to the current buffer." ((eq target-key :content) (setq content-seen t 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) (when (not (equal (ebox-get box target-key) value)) (setq style-changed-p t))) @@ -3893,7 +3919,7 @@ Defaults to the current buffer." (user-error "Ebox content update requires a string")) (when (and content-seen (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 (or (not (equal declarations (ebox-style-node-declarations source-index box))) @@ -3959,7 +3985,8 @@ cannot be changed through the unpublished batch root." (nreverse changed)))) (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." (let ((box (ebox--root-region-box root region-id)) (candidate-source-index source-index) @@ -3970,11 +3997,15 @@ cannot be changed through the unpublished batch root." (not (ebox-style-declarations-equal-p declarations (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)) (binding (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)) (plist-put box :ebox-source-handle (cdr binding)))) (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 for target-key = (ebox--region-update-normalize-property key) when (or (eq target-key :content) + (eq target-key :surface-properties) (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-get box target-key)) value)) @@ -4001,9 +4037,14 @@ cannot be changed through the unpublished batch root." do (if (eq target-key :content) (if-let* ((text (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 - "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)) and do (push target-key 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 (or (ebox-incremental-surface-batch-source-index buffer) (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 (or (ebox--root-region-box published-root region-id) (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 source-index candidate-box) 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 (append (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)) + (when interaction-delta + (list :surface-properties + (ebox-interaction-surface-properties interactions))) (ebox-style--expand-engine-delta style-declarations declarations (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)) :scroll-offset)) (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) (eq (ebox--region-update-normalize-property (car expanded)) :content) (stringp (cadr expanded)) - (numberp (ebox-get candidate-box :width)) (ebox--region-update-direct-text-node candidate-box) - (not (ebox-style-cascade-active-p)) - (not (plist-get state :cascade-required-p)) - (null (plist-get state :scroll-region-ids)))) + (not (ebox-style-cascade-active-p)))) (candidate-root (ebox--surface-region-candidate-root buffer state region-id content-only-p)) (applied (ebox--surface-region-apply-props candidate-root source-index - region-id expanded declarations style-declarations)) + region-id expanded declarations style-declarations interactions)) (changed-keys (plist-get applied :changed-keys)) (candidate-source-index (plist-get applied :source-index)) (dirty-kind @@ -4314,19 +4374,24 @@ REGION-ID and CHANGED-KEYS describe one update; CHANGES describes a batch." ;;;###autoload (defun ebox-region-update (handle &rest props) - "Update the box identified by surface-scoped HANDLE with PROPS. -HANDLE must come from ebox-region-resolve or an Ebox selector result. PROPS -accepts mutable content, style, and scroll keywords supported by the target; -it does not add children or mutate selector metadata. The update builds an + "Update the node identified by HANDLE with PROPS. +HANDLE is an opaque handle from `ebox-region-resolve' or an Ebox selector, +or a semantic ID (string, non-nil symbol or integer) in the current buffer. +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 mounted TP surface. Return nil while queued in an explicit batch; otherwise return the committed update report. Examples: + (ebox-region-update \"status\" :content \"Ready\") (ebox-region-update (ebox-region-resolve buffer \"status\") :content \"Ready\") - (ebox-region-update handle :padding (list 1 2) + (ebox-region-update handle :padding \='((lh 1) (ch 2)) :border-color \"red\")" + (when (ebox-source-semantic-id-p handle) + (setq handle (ebox-region-resolve (current-buffer) handle))) (unless (ebox-region-handle-p handle) (signal (quote wrong-type-argument) (list (quote ebox-region-handle-p) handle))) @@ -4335,8 +4400,11 @@ Examples: (let ((execute (lambda () (ebox--with-render-gc - (ebox--surface-region-update-target - buffer region-id handle props))))) + (with-current-buffer buffer + (save-restriction + (widen) + (ebox--surface-region-update-target + buffer region-id handle props))))))) (if (ebox-incremental-batching-p buffer) (funcall 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 (let ((ebox-incremental--source-base-index source-base-index)) - (if (ebox-candidate-p next-root) - (ebox-incremental-consume-candidate buffer next-root) - (ebox-incremental-prepare-root-commit - buffer next-root)))) + ;; Slot proofs use absolute mounted coordinates. + ;; Capture them in the same complete buffer view the + ;; surface publisher uses, even when the caller has + ;; 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)) (callback (or framework-publish @@ -4795,8 +4871,8 @@ Return the successful publication report stored by `ebox-buffer-update-report'." (defun ebox-rerender-buffer-with-context (buffer viewport-width &optional viewport-height) "Rerender BUFFER's stored runtime tree using VIEWPORT-WIDTH. -When VIEWPORT-HEIGHT is non-nil, height values using `(viewport-height)' are -resolved against that line count. +VIEWPORT-WIDTH is pixels; optional VIEWPORT-HEIGHT is frame lines. +Viewport units and composed size expressions resolve against this context. This preserves node and region identity and routes viewport-dependent changes through dirty-set and patch-set execution before falling back to root rerender." (let ((execute @@ -4865,6 +4941,8 @@ through dirty-set and patch-set execution before falling back to root rerender." ebox-grid-layout-create ebox-host-ref-bounds ebox-host-ref-position + ebox-help-create + ebox-keymap-create ebox-native-build ebox-native-status ebox-region-ids diff --git a/native/c/ebox_module.c b/native/c/ebox_module.c index 9050ef8..45e5501 100644 --- a/native/c/ebox_module.c +++ b/native/c/ebox_module.c @@ -164,7 +164,7 @@ ebox_module_version(emacs_env *env, ptrdiff_t nargs, emacs_value *args, (void) nargs; (void) args; (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)); } diff --git a/native/src/composition.rs b/native/src/composition.rs index c44c1c8..e86d478 100644 --- a/native/src/composition.rs +++ b/native/src/composition.rs @@ -5,7 +5,7 @@ use std::sync::Arc; use super::evaluation::{record, EvalRecord, EvalWork, RenderScope, RenderedChange}; use super::line_plan::{ChangeKind, LineOp, LinePlan, LineSplice, PlanChange, ProjectionState}; -use super::{LayoutContext, LayoutNode, Line, LocalStep, Rendered}; +use super::{LayoutContext, LayoutNode, LocalStep, Rendered}; #[derive(Debug, Default)] struct ShapeRoute { @@ -121,6 +121,7 @@ impl ColumnShape { struct ColumnSlot { record: Option>, raw: LinePlan, + empty_width: i64, normalized: LinePlan, projection: Option>, } @@ -128,6 +129,7 @@ struct ColumnSlot { impl ColumnSlot { fn normalize( raw: LinePlan, + empty_width: i64, child: Option>, proof: Option<&PlanChange>, target: i64, @@ -163,6 +165,7 @@ impl ColumnSlot { Arc::new(Self { record: child, raw, + empty_width, normalized, projection, }), @@ -183,7 +186,11 @@ struct ColumnMeasure { impl ColumnMeasure { 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(); Self { slots: 1, @@ -377,6 +384,7 @@ impl ColumnNode { Self::leaf( ColumnSlot::normalize( slot.raw.clone(), + slot.empty_width, slot.record.clone(), Some(&same), target, @@ -434,7 +442,7 @@ fn output(root: Option<&Arc>, previous: Option<&ColumnState>) -> Lin }) { return previous.lines.clone(); } - LinePlan::from_lines([Line::default()]) + LinePlan::default() } // A concatenation owns its joining break, including the former last line when @@ -482,6 +490,7 @@ pub(super) fn render_column<'a>( .slot(index); let (slot, change) = ColumnSlot::normalize( result.rendered.lines, + result.rendered.empty_width, result.record, result.change.as_ref(), previous.target, @@ -580,7 +589,15 @@ pub(super) fn render_column<'a>( |RenderedChange { rendered, record, .. }| { - ColumnSlot::normalize(rendered.lines, record, None, target, None).0 + ColumnSlot::normalize( + rendered.lines, + rendered.empty_width, + record, + None, + target, + None, + ) + .0 }, ) .collect::>(); @@ -600,7 +617,11 @@ pub(super) fn render_column<'a>( .map_or(0, |root| root.measure().normalized_max(state.target)), 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); Ok(rendered) } diff --git a/native/src/composition_tests.rs b/native/src/composition_tests.rs index a47fca8..be7e4aa 100644 --- a/native/src/composition_tests.rs +++ b/native/src/composition_tests.rs @@ -124,16 +124,22 @@ mod composition_regressions { .collect::>(); lines.push(TapeLine { width: 4, - atoms: vec![TapeAtom::Space { - width: 4, - properties: AtomProperties { - content: Some(1), - content_idx: Some(prefix_lines as i64), - owner: Some(1), - owners: vec![1], - ..AtomProperties::default() + atoms: vec![ + TapeAtom::Space { + width: 0, + properties: AtomProperties { + content: Some(20), + content_idx: Some(0), + owner: Some(1), + owners: vec![20, 1], + ..AtomProperties::default() + }, }, - }], + TapeAtom::Space { + width: 4, + properties: parent_only.clone(), + }, + ], break_after: Some(AtomProperties::default()), }); let mut suffix = owned_line(30, 0); @@ -146,7 +152,7 @@ mod composition_regressions { } #[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 LayoutNode::Text { content, .. } = &mut empty_suffix else { unreachable!() diff --git a/native/src/layout.rs b/native/src/layout.rs index 4b1671a..331fb5c 100644 --- a/native/src/layout.rs +++ b/native/src/layout.rs @@ -1223,9 +1223,6 @@ fn same_text_source( } fn measured_text_work(text: &MeasuredText, property_template_count: u32) -> Result { - if text.lines.is_empty() { - return Err("Native layout measured text must contain one line".to_owned()); - } let mut work = text.lines.len(); for line in &text.lines { work = work @@ -2012,12 +2009,10 @@ impl Line { ..AtomProperties::default() }; if cluster.pixel_space { - if cluster.width > 0 { - atoms.push(Atom::Space { - width: cluster.width, - properties, - }); - } + atoms.push(Atom::Space { + width: cluster.width, + properties, + }); } else { atoms.push(Atom::Text { text: cluster.text.clone(), @@ -2085,15 +2080,16 @@ impl Line { } fn blank(width: i64) -> Self { - let mut line = Self::default(); - line.push_space(width); - line + Self::blank_with_properties(width, AtomProperties::default()) } fn blank_with_properties(width: i64, properties: AtomProperties) -> Self { - let mut line = Self::default(); - line.push_space_with_properties(width, properties); - line + // A requested line has height even at zero width. Optional horizontal + // edge/gap insertion still uses push/prepend_space and skips width zero. + Self::from_atoms(&[Atom::Space { + width: width.max(0), + properties, + }]) } fn whitespace_only(&self) -> bool { @@ -2127,9 +2123,7 @@ impl Line { if properties.content.is_none() { properties.content = Some(region_id); } - let mut line = Self::default(); - line.push_space_with_properties(width, properties); - line + Self::blank_with_properties(width, properties) } fn apply_style(&mut self, style_id: Option) { @@ -2191,6 +2185,9 @@ impl Atom { #[derive(Debug, Clone)] struct Rendered { 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>, own_scroll_owner: bool, scroll_owners: u8, @@ -2220,6 +2217,7 @@ impl Rendered { fn from_lines(lines: Vec) -> Self { Self { lines: LinePlan::from_lines(lines), + empty_width: 0, root_scroll: None, own_scroll_owner: false, scroll_owners: 0, @@ -2229,6 +2227,7 @@ impl Rendered { fn from_line_plan(lines: LinePlan) -> Self { Self { lines, + empty_width: 0, root_scroll: None, own_scroll_owner: false, scroll_owners: 0, @@ -2236,15 +2235,23 @@ impl Rendered { } 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 { - 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 { - if wrap_mode == WrapMode::None { + if wrap_mode == WrapMode::None || self.lines.is_empty() { return self.max_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 { - if tape.lines.is_empty() { - return Err("Native layout tape has no lines".to_owned()); - } let line_count = tape.lines.len(); let mut characters = Vec::new(); for (line_index, line) in tape.lines.into_iter().enumerate() { @@ -3892,9 +3896,6 @@ fn flatten_layout_tape(tape: LayoutTape, complete: bool) -> Result { validate_tape_style_ids(&properties, tape.style_count)?; let width = tape_width(width, "space width")?; - if width == 0 { - return Err("Native layout tape has empty pixel space".to_owned()); - } characters.push(TapeCharacter { value: ' ', pixel_width: Some(width), @@ -4330,9 +4331,6 @@ pub(crate) fn encode_layout_tape_with_scroll( max_bytes: usize, scroll: Option<&RootScrollPlan>, ) -> Result, 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")?; if tape.style_count as usize != styles.len() { 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 } => { 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; text.push(' '); 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())?; for line in &content.lines { add_work_units(work_units, line.clusters.len())?; @@ -5011,9 +5003,6 @@ fn validate_node( } } 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())?; for line in &text.lines { add_work_units(work_units, line.clusters.len())?; @@ -5403,7 +5392,7 @@ fn resolve_definite_box_content_height( .unwrap_or(i64::MAX); let preferred = override_height .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 { @@ -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 { if clusters.is_empty() { - return vec![Line::default()]; + return vec![Line::blank(0)]; } let total: i64 = clusters.iter().map(|cluster| cluster.width).sum(); if mode == WrapMode::None || total <= max_width { @@ -5534,7 +5523,7 @@ fn measured_lines(text: &MeasuredText, width: i64, mode: WrapMode) -> Vec fn wrap_rendered_line(line: &Line, max_width: i64, mode: WrapMode) -> Vec { if line.atoms.is_empty() { - return vec![Line::default()]; + return vec![Line::blank(0)]; } if mode == WrapMode::None || line.width <= max_width { return vec![line_plan::clone_line(line)]; @@ -5620,7 +5609,7 @@ fn wrap_rendered_line(line: &Line, max_width: i64, mode: WrapMode) -> Vec } 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; } let mut lines = LinePlan::default(); @@ -5933,7 +5922,7 @@ fn flex_min_main( box_vertical_side(source).unwrap_or(0) + flex_box_resolve_height(source, min_height, Some(0), context) .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 { + rendered.empty_width = rendered.empty_width.max(width); rendered.lines = rendered.lines.map_lines(|_, mut line| { let extra = (width - line.width).max(0); 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 { + let empty_width = parts.iter().map(|(_, width)| width).sum(); let height = parts .iter() .map(|(rendered, _)| rendered.height()) .max() - .unwrap_or(1) + .unwrap_or(0) .max(target_height); let mut parts = parts .iter() @@ -6500,9 +6491,15 @@ fn concat_horizontal_sized(parts: Vec<(Rendered, i64)>, target_height: i64) -> R 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 { @@ -6516,11 +6513,10 @@ fn stack_vertical(parts: Vec) -> Rendered { } } } - let mut lines = join_parts(&parts); - if lines.is_empty() { - lines = LinePlan::from_lines([Line::default()]); - } - Rendered::from_line_plan(lines) + let lines = join_parts(&parts); + let mut rendered = Rendered::from_line_plan(lines); + rendered.empty_width = parts.iter().map(Rendered::max_width).max().unwrap_or(0); + 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 end = start.saturating_add(height).min(rendered.lines.len()); 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)) } @@ -6575,7 +6573,7 @@ fn exact_rendered_height( LayoutNode::Row { children, .. } => children .iter() .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)) }), LayoutNode::Column { children, .. } => children @@ -6772,7 +6770,10 @@ fn render_flex_row_line( let line_main = flex_line_main_size(line, main_gap); let (leading, between, trailing) = 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() { let entry = render_flex_sized_entry( item, @@ -6785,12 +6786,12 @@ fn render_flex_row_line( intrinsic, )?; parts.push((entry.rendered, item.target)); - if index + 1 < line.len() { - parts.push((Rendered::from_lines(vec![Line::blank(between)]), between)); + if index + 1 < line.len() && between > 0 { + parts.push((Rendered::from_line_plan(LinePlan::default()), between)); } } 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)) } @@ -6941,10 +6942,13 @@ fn render_flex_column( }; let cross_layout = flex_layout_cross(crosses, cross_size, cross_gap, align_content, single_line); - let mut parts = vec![( - Rendered::from_lines(vec![Line::blank(cross_layout.leading)]), - cross_layout.leading, - )]; + let mut parts = Vec::new(); + if cross_layout.leading > 0 { + parts.push(( + Rendered::from_line_plan(LinePlan::default()), + cross_layout.leading, + )); + } let line_count = lines.len(); for (index, (line, line_cross)) in lines .iter() @@ -6955,14 +6959,14 @@ fn render_flex_column( line, line_cross, main_size, main_gap, justify, align, context, intrinsic, )?; parts.push((rendered, line_cross)); - if index + 1 < line_count { + if index + 1 < line_count && cross_layout.between > 0 { parts.push(( - Rendered::from_lines(vec![Line::blank(cross_layout.between)]), + Rendered::from_line_plan(LinePlan::default()), 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 { rendered = pad_rendered_width(rendered, cross, FlexAlign::FlexStart); } @@ -7424,6 +7428,7 @@ fn render_node_body( }); if transparent_preformatted { let mut rendered = child_rendered.take().expect("validated child"); + rendered.empty_width = content_width; rendered.root_scroll = None; rendered.own_scroll_owner = false; let mut ops = vec![LineOp::OwnContent { @@ -7550,8 +7555,8 @@ fn render_node_body( .unwrap_or(text_height) }); let content_height = minimum_height - .max(1) - .max(preferred_height.max(1).min(maximum_height)); + .max(0) + .max(preferred_height.max(0).min(maximum_height)); let simple_scroll_rendered = *overflow == Overflow::Scroll && text_height >= content_height && *padding_left == 0 @@ -7711,6 +7716,12 @@ fn render_node_body( .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 { left: *padding_left, 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); + rendered.empty_width = total_width; if !overflow_lines.is_empty() { let left_space = margin_left + border_left + padding_left; 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( region_id: i64, content: MeasuredText, @@ -9043,6 +9198,7 @@ mod tests { #[test] fn line_sequence_oracle_wrap_slice_and_join_keep_distinct_breaks() { let source = Rendered { + empty_width: 0, root_scroll: None, own_scroll_owner: false, scroll_owners: 0, diff --git a/native/src/line_ops.rs b/native/src/line_ops.rs index c3c5b40..a0c28d7 100644 --- a/native/src/line_ops.rs +++ b/native/src/line_ops.rs @@ -131,10 +131,10 @@ impl LineOp { Self::CollapseWhitespace { width, .. } if line.whitespace && !line.noncontent => { line = LineMetric { width: (*width).max(0), - chars: u64::from(*width > 0), + chars: 1, min_content: 0, - nonempty: *width > 0, - whitespace: *width > 0, + nonempty: true, + whitespace: true, noncontent: false, }; } diff --git a/scripts/ebox-performance-evaluator.el b/scripts/ebox-performance-evaluator.el index e74c388..6c50767 100644 --- a/scripts/ebox-performance-evaluator.el +++ b/scripts/ebox-performance-evaluator.el @@ -59,6 +59,13 @@ tp-surface-mount tp-surface-update 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)) "Functions grouped by computation or publication stage.") @@ -299,8 +306,8 @@ (append (list 'box :key (format "cell-%d-%d" row column) - :width '(52) - :padding '(0 1) + :width '(px 52) + :padding '((lh 0) (ch 1)) :color "#0F172A" :bgcolor "#F8FAFC") (when (and (= row 2) (= column 1)) '(:id target)) @@ -320,8 +327,8 @@ (push (list 'box :key (format "resize-item-%d" index) - :width '(70) - :padding '(0 1) + :width '(px 70) + :padding '((lh 0) (ch 1)) :color "#172554" :bgcolor "#DBEAFE" (format "resize-%02d" index)) @@ -329,10 +336,10 @@ (ebox-build (append (list 'flex :key 'resize-root - :width '(viewport) + :width '(vw 100) :flex-wrap 'wrap - :column-gap '(6) - :row-gap 1) + :column-gap '(px 6) + :row-gap '(lh 1)) (nreverse items))))) (defun ebox-performance-evaluator--pure-static () diff --git a/scripts/ebox-visual-check.el b/scripts/ebox-visual-check.el index 44d95a4..f53b59d 100644 --- a/scripts/ebox-visual-check.el +++ b/scripts/ebox-visual-check.el @@ -30,25 +30,25 @@ "Return the fixed layout used by visual verification." (ebox-build '(column :id dashboard - (box :id title :box-sizing border-box :width 74 - :padding (0 1) :border "#4A90D9" + (box :id title :box-sizing border-box :width (ch 74) + :padding ((lh 0) (ch 1)) :border "#4A90D9" :color "#EAF6FF" :bgcolor "#203040" "Ebox Visual Check") (row :id cards - (box :id hidden :box-sizing border-box :width 24 :height 3 - :padding (1 1) :border "#27AE60" + (box :id hidden :box-sizing border-box :width (ch 24) :height (lh 3) + :padding ((lh 1) (ch 1)) :border "#27AE60" :color "#F0FFF4" :bgcolor "#17351F" :overflow hidden "Hidden: visible line\nHIDDEN-CLIPPED-LINE") - (box :id scroll :box-sizing border-box :width 24 :height 3 - :padding (1 1) :border "#E67E22" + (box :id scroll :box-sizing border-box :width (ch 24) :height (lh 3) + :padding ((lh 1) (ch 1)) :border "#E67E22" :color "#FFF4E6" :bgcolor "#3A2410" :overflow scroll "Scroll: first\nSCROLL-SECOND\nSCROLL-THIRD") - (box :id sized :box-sizing border-box :width 26 :max-height 3 - :padding (1 1) :border "#9B59B6" + (box :id sized :box-sizing border-box :width (ch 26) :max-height (lh 3) + :padding ((lh 1) (ch 1)) :border "#9B59B6" :color "#F8EEFF" :bgcolor "#2F1B3A" :overflow hidden "border-box max-height\nline 2\nSIZED-CLIPPED")) - (box :id footer :box-sizing border-box :width 74 - :padding (0 1) :border "#7F8C8D" + (box :id footer :box-sizing border-box :width (ch 74) + :padding ((lh 0) (ch 1)) :border "#7F8C8D" :color "#F8F9F9" :bgcolor "#2C3E50" "Checks: text properties + display specs + GUI screenshot")))) diff --git a/scripts/migrate-css-sizes.py b/scripts/migrate-css-sizes.py new file mode 100644 index 0000000..7eb7159 --- /dev/null +++ b/scripts/migrate-css-sizes.py @@ -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()) diff --git a/scripts/test_migrate_css_sizes.py b/scripts/test_migrate_css_sizes.py new file mode 100644 index 0000000..3ca6575 --- /dev/null +++ b/scripts/test_migrate_css_sizes.py @@ -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() diff --git a/tests/ebox-box-line-flow-tests.el b/tests/ebox-box-line-flow-tests.el index e641e6c..a9d53ab 100644 --- a/tests/ebox-box-line-flow-tests.el +++ b/tests/ebox-box-line-flow-tests.el @@ -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 () "Ordinary decoration should join only at the existing compositor exit." (let* ((input (ebox-build - '(box :width (120) :height 8 :overflow hidden - :padding (1 (3)) :border (1 solid "#112233") + '(box :width (px 120) :height (lh 8) :overflow hidden + :padding ((lh 1) (px 3)) :border ((px 1) solid "#112233") :bgcolor "#ddeeff" (text "alpha\nbeta")))) (expected (ebox-box-line-test--reference-render 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)) (let* ((child (pcase kind ('box `(box (text ,rich))) - ('grid `(grid :grid-template-columns ((110)) (text ,rich))) + ('grid `(grid :grid-template-columns ((px 110)) (text ,rich))) (_ `(,kind (text ,rich))))) (input (ebox-build - `(box :width (140) :height ,height :overflow ,overflow - :padding (1 (3)) - :border (1 solid "#112233") :margin (1 (2)) + `(box :width (px 140) :height (lh ,height) :overflow ,overflow + :padding ((lh 1) (px 3)) + :border ((px 1) solid "#112233") :margin ((lh 1) (px 2)) :color "#123456" :bgcolor "#ddeeff" ,child))) (_alignment (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." (dolist (text '("alpha\nbeta" "alpha\n2\n3\n4\n5\n6\n7\n8\n9")) (let* ((input (ebox-build - `(box :width (120) :height 8 :overflow scroll + `(box :width (px 120) :height (lh 8) :overflow scroll :bgcolor "#ddeeff" (text ,text)))) (expected (ebox-box-line-test--reference-render 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))) (blank (propertize " " 'display '(space :width (7)) '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 (align '(top center bottom)) (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 (cdr (nth 0 record)) (nth 2 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 (list "A") 0))) -(ert-deftest ebox-box-line-flow-retains-empty-string-adapter-bridges () - "Empty formatted content and zero used height retain the former blank line." +(ert-deftest ebox-box-line-flow-empty-content-retains-only-declared-chrome () + "Empty content and zero used height preserve only the declared chrome." (dolist (formatted '(nil "" "A\nB")) (dolist (height '(0 1 3)) (let* ((input (ebox-build - '(box :width (40) :height 3 :overflow hidden :padding (1 (2)) - :border (1 solid "#112233") (text "unused")))) + '(box :width (px 40) :height (lh 3) :overflow hidden :padding ((lh 1) (px 2)) + :border ((px 1) solid "#112233") (text "unused")))) (root (car (ebox-canonical-input-roots input))) (handle (ebox-node-source-handle root)) (format-function (symbol-function 'ebox--format-content)) (height-function (symbol-function 'ebox--content-height))) - ;; Isolate the decoration boundary, including a zero used content - ;; height; the ordinary public height resolver clamps to at least one. + ;; Isolate the decoration boundary, including zero used content height. (cl-letf (((symbol-function 'ebox--format-content) (lambda (box) (if (eq (ebox-node-source-handle box) handle) formatted (funcall format-function box)))) ((symbol-function 'ebox--content-height) - (lambda (box natural) + (lambda (box natural &optional rendered) (if (eq (ebox-node-source-handle box) handle) height - (funcall height-function box natural))))) + (funcall height-function box natural rendered))))) (let ((actual (ebox-box-line-test--reference-render input t)) (expected (ebox-box-line-test--reference-render input))) (should (equal-including-properties actual expected)) (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 () "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 - `(box :width (0) :height 3 :overflow hidden + `(box :width (px 0) :height (lh 3) :overflow hidden :padding ,padding (text "")))) (actual (ebox-box-line-test--reference-render input t)) (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)) (number-sequence 0 9) "\n")) (input (ebox-build - `(box :width (120) :height 4 :overflow scroll - :padding (0 (2)) :border (1 solid "#112233") + `(box :width (px 120) :height (lh 4) :overflow scroll + :padding ((lh 0) (px 2)) :border ((px 1) solid "#112233") (text ,(propertize text 'keymap map 'help-echo "action"))))) (ebox-runtime-idle-prewarm nil) (ebox-runtime-idle-reflow-cache-prewarm nil)) diff --git a/tests/ebox-child-range-tests.el b/tests/ebox-child-range-tests.el index f885bf4..a35977a 100644 --- a/tests/ebox-child-range-tests.el +++ b/tests/ebox-child-range-tests.el @@ -1000,6 +1000,50 @@ (dolist (key '(:batch :retained :mounts :ids :mount-index :index :snapshot)) (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 () "A rejected single-owner publication restores the exact prior generation." (with-temp-buffer @@ -1091,8 +1135,11 @@ (should (eq witness (plist-get state :previous-surface-owned-ranges)))) (pcase miss ('projection - (setcar (nthcdr 4 arguments) 'span-patch) - (plist-put state :projection-kind 'span-patch)) + ;; Both owner-scoped and span-patch now consume this + ;; 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)) ('multi-owner (plist-put state :owner-scoped-proofs diff --git a/tests/ebox-commit-tests.el b/tests/ebox-commit-tests.el index ef96f97..d510d27 100644 --- a/tests/ebox-commit-tests.el +++ b/tests/ebox-commit-tests.el @@ -1003,7 +1003,7 @@ (ebox-render-to-buffer buffer (ebox-build - '(column :width (viewport) + '(column :width (vw 100) (box :id first :class card "One") (box :id second :class card "Two"))) (list :observer @@ -1036,7 +1036,7 @@ (let* ((root (ebox-build - '(box :id scroll-root :height 1 :overflow scroll + '(box :id scroll-root :height (lh 1) :overflow scroll "A\nB\nC"))) (scroll-id (car @@ -2383,8 +2383,8 @@ remain retained identities." (ebox-render-to-buffer (generate-new-buffer-name " *ebox-commit-types*") (ebox-build - '(flex :key root :width (80) - (box :key child :width (40) "A"))))) + '(flex :key root :width (px 80) + (box :key child :width (px 40) "A"))))) (before (gethash buffer ebox--buffer-render-state-table))) (unwind-protect (progn @@ -2398,7 +2398,7 @@ remain retained identities." 'text (plist-get before :runtime-type-count-table)) 1)) (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))) (should-not (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 (list :node-id 1 :ebox-type 'box :ebox-kind 'box :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))) (item (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) :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) ;;; ebox-commit-tests.el ends here diff --git a/tests/ebox-core-render-tests.el b/tests/ebox-core-render-tests.el index 1e1d2f2..2ae9623 100644 --- a/tests/ebox-core-render-tests.el +++ b/tests/ebox-core-render-tests.el @@ -192,9 +192,9 @@ (ebox-render-to-buffer (generate-new-buffer-name " *ebox-typed-flex-lookahead*") (ebox-build - '(flex :width (240) :flex-flow (row wrap) - (box :width (100) "A") - (box :width (100) "B")))))) + '(flex :width (px 240) :flex-flow (row wrap) + (box :width (px 100) "A") + (box :width (px 100) "B")))))) (unwind-protect (should (zerop (ebox--root-reflow-scroll-lookahead-lines-for-buffer @@ -391,20 +391,20 @@ (defconst ebox-test--region-update-geometry-cases '((:box-sizing content-box :box-sizing content-box owner-rerender) - (:width (120) :width 120 owner-rerender) - (:min-width (100) :min-width 100 owner-rerender) - (:max-width (140) :max-width 140 owner-rerender) - (:height 3 :height 3 owner-rerender) - (:min-height 3 :min-height 3 owner-rerender) - (:max-height 1 :max-height 1 owner-rerender) - (:padding-left (9) :padding-left-pixel 9 span-patch) - (:padding-right (10) :padding-right-pixel 10 span-patch) - (:padding-top 1 :padding-top-height 1 span-patch) - (:padding-bottom 1 :padding-bottom-height 1 span-patch) - (:margin-left (7) :margin-left-pixel 7 owner-rerender) - (:margin-right (8) :margin-right-pixel 8 owner-rerender) - (:margin-top 1 :margin-top-height 1 owner-rerender) - (:margin-bottom 1 :margin-bottom-height 1 owner-rerender) + (:width (px 120) :width (px 120) owner-rerender) + (:min-width (px 100) :min-width (px 100) owner-rerender) + (:max-width (px 140) :max-width (px 140) owner-rerender) + (:height (lh 3) :height (lh 3) owner-rerender) + (:min-height (lh 3) :min-height (lh 3) owner-rerender) + (:max-height (lh 1) :max-height (lh 1) owner-rerender) + (:padding-left (px 9) :padding-left-pixel 9 span-patch) + (:padding-right (px 10) :padding-right-pixel 10 span-patch) + (:padding-top (lh 1) :padding-top-height 1 span-patch) + (:padding-bottom (lh 1) :padding-bottom-height 1 span-patch) + (:margin-left (px 7) :margin-left-pixel 7 owner-rerender) + (:margin-right (px 8) :margin-right-pixel 8 owner-rerender) + (:margin-top (lh 1) :margin-top-height 1 owner-rerender) + (:margin-bottom (lh 1) :margin-bottom-height 1 owner-rerender) (:border-top-p t :border-top-pixel 1 span-patch) (:text-align center :text-align center span-patch) (:overflow hidden :overflow hidden owner-rerender) @@ -538,7 +538,7 @@ (let* ((style (ecss-compute-style 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)) (fact (ecss-computed-style-property-fact style 'ebox/width))) (should (eq 'ebox/width (ecss-computed-property-fact-property fact))) @@ -581,7 +581,7 @@ (ebox-style-compute-subject (ecss-subject-create :type "box") (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-active (symbol-function 'ecss-computed-style-active-properties)) @@ -623,7 +623,7 @@ (ebox-style-compute-subject (ecss-subject-create :type "flex") (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)) (original-values (symbol-function 'ecss-computed-style-values)) (original-active @@ -669,7 +669,7 @@ (ebox-test--reset-runtime-state) (let ((ebox-style-stylesheet (ecss-stylesheet-create))) (ebox-style-add-rule - "flex" '(:flex-direction column :width (96)) + "flex" '(:flex-direction column :width (px 96)) :layer 'layout) (let ((rendered (substring-no-properties @@ -711,7 +711,7 @@ (ebox-test--reset-runtime-state) (let ((ebox-style-stylesheet (ecss-stylesheet-create))) (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) (ebox-style-add-rule ".placed" '(:grid-column 2) :layer 'layout) (ebox-test--with-rendered-buffer @@ -724,10 +724,10 @@ (placed (car (ebox-tree-layout-children root)))) (should (eq (ebox-layout-config-kind config) 'grid)) (should (equal (plist-get props :grid-template-columns) - '((:kind fixed :size 40) - (:kind fixed :size 40)))) + '((:kind fixed :size (px 40)) + (:kind fixed :size (px 40))))) (should (equal (plist-get props :column-gap) - 4)) + '(px 4))) (should (= (plist-get placed :grid-column) 2)))))) (ert-deftest ebox-font-cascade-inherits-overrides-and-precedes-measurement () @@ -977,7 +977,7 @@ (let ((old-handle (ebox-region-resolve primary-buffer "old"))) (kill-buffer primary-buffer) (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)) (setq secondary-buffer (ebox-render-to-buffer @@ -2066,8 +2066,8 @@ (ert-deftest ebox-buffer-backend-does-not-map-layout-only-properties () "Layout-only properties should not produce text properties directly." (let* ((style (ebox-style-compute - (list :width '(120) - :padding '(1 2)))) + (list :width '(px 120) + :padding '((lh 1) (ch 2))))) (props (ebox-buffer-paint-text-properties style 'content))) (should-not (memq :width props)) (should-not (memq :padding-inline-start props)))) @@ -2268,7 +2268,7 @@ (child-id (ebox-test--selector-region-id buffer "#child"))) (ebox-incremental-begin-batch buffer) (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)) (goto-char (point-min)) (search-forward "Child") @@ -2340,7 +2340,7 @@ (dolist (target order) (ebox-test--region-update (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)) (let ((report (ebox-test--buffer-update-report))) (should (ebox-test--buffer-visually-matches-runtime-render-p)) @@ -2362,9 +2362,9 @@ (pcase operation ('geometry (ebox-test--region-update target-a-id - :width '(210) - :min-width '(150) - :max-width '(260))) + :width '(px 210) + :min-width '(px 150) + :max-width '(px 260))) ('paint (ebox-test--region-update target-b-id :color "blue")))) (ebox-incremental-flush buffer)) @@ -2709,7 +2709,7 @@ (layout (ebox-test-row left right)) (left-id (car (ebox-region-ids layout)))) (ebox-test--with-rendered-buffer layout - (ebox-test--region-update left-id :width '(120)) + (ebox-test--region-update left-id :width '(px 120)) (let ((report (ebox-test--buffer-update-report))) (should (eq (plist-get report :constraint-source) 'region)) (should (eq (plist-get report :constraint-owner-type) 'box)) @@ -2735,7 +2735,7 @@ (ebox-test-box (ebox-test-text "third") :width '(viewport))))) (root-id (car (ebox-region-ids layout)))) (ebox-test--with-rendered-buffer layout - (ebox-test--region-update root-id :width '(160)) + (ebox-test--region-update root-id :width '(px 160)) (let ((widths (mapcar #'ebox--string-pixel-width (ebox-string-lines (buffer-string))))) @@ -2753,7 +2753,7 @@ (left-id (nth 1 ids))) (ebox-test--with-rendered-buffer node (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))) (should (equal (plist-get report :owner-ids) (list root-node-id))) @@ -3574,7 +3574,7 @@ (lambda (&rest args) (cl-incf index-build-count) (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))) (should (eq (plist-get report :strategy) 'owner-rerender)) (should (equal (plist-get report :owner-ids) (list root-id))) @@ -3884,7 +3884,7 @@ (box-id (car (ebox-region-ids box)))) (ebox-test--with-rendered-buffer box (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) (let ((report (ebox-test--buffer-update-report))) (should (eq (plist-get report :constraint-source) 'viewport)) @@ -4011,8 +4011,8 @@ (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) -(ert-deftest ebox-viewport-dependencies-keep-only-vertical-axis-under-fixed-width () - "Fixed width should retain vertical dependencies without exposing width-only ones." +(ert-deftest ebox-viewport-dependencies-retain-explicit-units-under-fixed-width () + "Fixed parent width does not contain explicit vw or vh dependencies." (ebox-test--reset-runtime-state) (let* ((width-only (ebox-test-box (ebox-test-text "Width") :width '(viewport))) @@ -4046,8 +4046,7 @@ (ebox-viewport-height 12)) (ebox--runtime-node-ids node) (ebox--viewport-dependent-node-ids node)))) - (should-not (member (plist-get (ebox-test-root width-only) :node-id) - ids)) + (should (member (plist-get (ebox-test-root width-only) :node-id) ids)) (dolist (vertical-node (append ordinary-height-nodes flex-height-nodes)) (should (member (plist-get (ebox-test-root vertical-node) :node-id) ids))))) @@ -4073,7 +4072,7 @@ (box-id (car (ebox-region-ids box)))) (ebox-test--with-rendered-buffer box (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))) (axes (plist-get state :viewport-dependent-node-id-axes)) (owner-id @@ -4470,7 +4469,7 @@ (list (ebox-pixel-space 80) (ebox-pixel-space 80)))) (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)) (ebox--render-source-index (ebox-test-source-index box-input)) (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." (let* ((input (ebox-build - '(box :width (120) :height 8 :overflow hidden - :padding (1 (3)) :border (1 solid "#112233") + '(box :width (px 120) :height (lh 8) :overflow hidden + :padding ((lh 1) (px 3)) :border ((px 1) solid "#112233") :bgcolor "#ddeeff" (text "alpha\nbeta")))) (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")) (let* ((input (ebox-build - `(box :width (120) :height 8 :overflow scroll :bgcolor "#ddeeff" + `(box :width (px 120) :height (lh 8) :overflow scroll :bgcolor "#ddeeff" (text ,text)))) (observed (ebox-test--box-full-height-joins input 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)) (left-id (nth 1 ids))) (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)))) (should (string-match-p "Top" 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 (ebox-test--count-full-surface-updates (ebox-test--region-update - left-id :border-bottom '(1 solid "blue"))))) + left-id :border-bottom '((px 1) solid "blue"))))) (should (= full-rerenders 0))) (let ((report (ebox-test--buffer-update-report))) (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))) (narrow-to-region (point) (point-max)) (ebox-test--region-update - left-id :border-bottom '(1 solid "blue"))) + left-id :border-bottom '((px 1) solid "blue"))) (should (equal old-metadata (plist-get old-state :surface-fragments))) (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." (ebox-test--reset-runtime-state) (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)) (removed-role (nth 1 case)) (kept-roles (nth 2 case)) @@ -5345,7 +5344,7 @@ line compositor's complete property output, including empty lines and chrome." (progn (should-error (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 (equal-including-properties text (with-current-buffer buffer (buffer-string))))) @@ -5384,7 +5383,7 @@ line compositor's complete property output, including empty lines and chrome." buffer) (unwind-protect (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)) (setq 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))) (ebox-test--with-rendered-buffer layout (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))) (should-not (plist-get report :full-rerender)) (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)))) (ebox-test--with-rendered-buffer layout (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))) (should (equal (plist-get report :owner-ids) (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))) (ebox-test--with-rendered-buffer layout (ebox-test--region-update target-id - :width '(210) - :min-width '(150) - :max-width '(260)) + :width '(px 210) + :min-width '(px 150) + :max-width '(px 260)) (let ((report (ebox-test--buffer-update-report))) (should (eq (plist-get report :strategy) 'owner-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))))) :node-id))) (ebox-test--region-update region-id - :width '(210) - :min-width '(150) - :max-width '(260)) + :width '(px 210) + :min-width '(px 150) + :max-width '(px 260)) (let ((report (ebox-test--buffer-update-report))) (should-not (plist-get report :tp-full-root)) (should (> (plist-get report :tp-scope-count) 0)) @@ -6048,8 +6047,8 @@ line compositor's complete property output, including empty lines and chrome." "#height")) (root-id (ebox--buffer-root-node-id (current-buffer)))) (ebox-test--region-update region-id - :height 4 :min-height 3 :max-height 6 - :padding-top 1 :padding-bottom 1) + :height '(lh 4) :min-height '(lh 3) :max-height '(lh 6) + :padding-top '(lh 1) :padding-bottom '(lh 1)) (let ((report (ebox-test--buffer-update-report))) (should (memq (plist-get report :strategy) '(span-patch owner-rerender))) @@ -6068,8 +6067,8 @@ line compositor's complete property output, including empty lines and chrome." "#height")) (root-id (ebox--buffer-root-node-id (current-buffer)))) (ebox-test--region-update region-id - :height 4 :min-height 3 :max-height 6 - :padding-top 1 :padding-bottom 1) + :height '(lh 4) :min-height '(lh 3) :max-height '(lh 6) + :padding-top '(lh 1) :padding-bottom '(lh 1)) (let ((report (ebox-test--buffer-update-report))) (should (eq (plist-get report :strategy) 'owner-rerender)) (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")) (root-id (ebox--buffer-root-node-id (current-buffer)))) (ebox-test--region-update height-id - :height 4 :min-height 3 :max-height 6 - :padding-top 1 :padding-bottom 1) + :height '(lh 4) :min-height '(lh 3) :max-height '(lh 6) + :padding-top '(lh 1) :padding-bottom '(lh 1)) (ebox-test--region-update region-id - :margin-inline '((8) (8))) + :margin-inline '((px 8) (px 8))) (let ((report (ebox-test--buffer-update-report))) (should (eq (plist-get report :strategy) 'span-patch)) (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 (let ((region-id (ebox-test--selector-region-id (current-buffer) "#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))) (should (eq (plist-get report :strategy) 'span-patch)) (should (member 'internal-fragment @@ -6158,7 +6157,7 @@ line compositor's complete property output, including empty lines and chrome." (should (ebox--dirty-entry-border-box-internal-span-p (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))) (should (eq (plist-get report :strategy) '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)) (node-id (plist-get handle :node-id))) (ebox-test--region-update region-id - :margin-inline '((8) (8))) + :margin-inline '((px 8) (px 8))) (let* ((report (ebox-test--buffer-update-report)) (snapshot (ebox--ensure-layout-snapshot-spans (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 (current-buffer) "#target"))) (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)) (full (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 (current-buffer))))) (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 (current-buffer))))) (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 (progn (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) :root-node)) (props (ebox-layout-config-props (ebox-box-node-layout root)))) (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 (ebox-test--buffer-propertized-matches-runtime-render-p))))) (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)) (region-id (car (ebox-region-ids 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))) (should (eq (plist-get report :strategy) '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)) (region-id (car (ebox-region-ids 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))) (should (eq (plist-get report :strategy) '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)) (region-id (car (ebox-region-ids 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))) (should (memq (plist-get report :strategy) '(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) runtime-box)) (should-not (eq runtime-box box)) - (should (equal (ebox-get runtime-box engine-property) - expected))) + (let ((actual (ebox-get runtime-box engine-property))) + (should (if (numberp expected) (= actual expected) + (equal actual expected))))) (let ((report (ebox-test--buffer-update-report))) (should (eq (plist-get report :strategy) 'paint-patch)) (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) runtime-box)) (should-not (eq runtime-box box)) - (should (equal (ebox-get runtime-box engine-property) - expected))) + (let ((actual (ebox-get runtime-box engine-property))) + (should (if (numberp expected) (= actual expected) + (equal actual expected))))) (let ((report (ebox-test--buffer-update-report))) (should (memq (plist-get report :strategy) '(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)))) (ebox-test--with-rendered-buffer box (ebox-test--region-update region-id - :padding '(1 2) - :border '(1 "#abcdef") - :margin-inline 3) + :padding '((lh 1) (ch 2)) + :border '((px 1) "#abcdef") + :margin-inline '(ch 3)) (let ((runtime-box (gethash region-id (ebox--buffer-region-box-table (current-buffer))))) @@ -6955,9 +6956,9 @@ line compositor's complete property output, including empty lines and chrome." (let ((full-rerenders (ebox-test--count-full-surface-updates (ebox-test--region-update region-id - :padding '(1 2) - :border '(1 "#abcdef") - :margin-inline 3)))) + :padding '((lh 1) (ch 2)) + :border '((px 1) "#abcdef") + :margin-inline '(ch 3))))) (should (= full-rerenders 0))) (let ((runtime-box (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 (ebox-render (ebox-build - '(column :width (viewport) :height (viewport-height) + '(column :width (vw 100) :height (vh 100) "Viewport")))))))) (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)) (plist-get state :content-height)) 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)) (state (ebox-scroll-state root-id))) (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 (setq root-id (ebox-test--selector-region-id buffer "#root")) (ebox-selector-update-buffer buffer "#metric-01" - :padding-left (list 8) + :padding-left '(px 8) :color "#B91C1C") (let* ((report (ebox-test--buffer-update-report)) (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 (setq target-id (ebox-test--selector-region-id buffer "#row-30")) (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))) (should (eq (plist-get report :strategy) 'span-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-incremental-begin-batch 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-not (gethash buffer ebox--runtime-prewarm-jobs)) (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-column (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)) (ebox-test-flex :id "flex-height" :height '(viewport-height) @@ -11830,7 +11831,7 @@ explicit visible-window handoff may admit its full-content publication." (pcase-let ((`(,width-ids . ,height-ids) (plist-get state :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 height-only-node-id width-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 (ebox-test--selector-region-id (current-buffer) "#target")) (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))) (should (eq (plist-get report :strategy) 'span-patch)) (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))))) (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) (let* ((content "aa 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)))) (should (< min-content between)) (should (< between max-content)) - (should (= (ebox-test--input-content-pixel - (ebox-test-box (ebox-test-text content) - :width (list 'fit-content (list between)))) - between)) - (should (= (ebox-test--input-content-pixel - (ebox-test-box (ebox-test-text content) - :width (list 'fit-content (list 1)))) - min-content)) - (should (= (ebox-test--input-content-pixel - (ebox-test-box (ebox-test-text content) - :width (list 'fit-content - (list (+ max-content 50))))) - max-content)))) + (dolist (case (list (cons between between) + (cons 1 min-content) + (cons (+ max-content 50) max-content))) + (let ((ebox-viewport-width (car case))) + (should (= (ebox-test--input-content-pixel + (ebox-test-box (ebox-test-text content) + :width 'fit-content)) + (cdr case))))))) (ert-deftest ebox-fit-content-keyword-uses-stretch-when-viewport-is-definite () "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") (min-content (ebox--string-pixel-width "widest")) (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 (ebox-test-box (ebox-test-text content) :width '(1) @@ -13106,21 +13103,20 @@ explicit visible-window handoff may admit its full-content publication." (should (= (ebox-test--input-content-pixel (ebox-test-box (ebox-test-text content) :width '(1) - :min-width (list 'fit-content - (list between)))) + :min-width 'fit-content)) between)) (should (= (ebox-test--input-content-pixel (ebox-test-box (ebox-test-text content) :width (list (+ max-content 50)) - :max-width (list 'fit-content - (list between)))) + :max-width 'fit-content)) between)))) -(ert-deftest ebox-stretch-and-contain-width-fill-viewport-margin-box () - "stretch and contain should resolve against the current viewport." +(ert-deftest ebox-stretch-width-fills-viewport-margin-box () + "Stretch fills available space and the removed contain alias is rejected." (ebox-test--reset-runtime-state) (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") :width keyword :padding-inline '(20) @@ -13207,13 +13203,13 @@ explicit visible-window handoff may admit its full-content publication." (should-error (ebox-test--region-update region-id :bgcolor "#222222" - :padding-left -1) + :padding-left '(ch -1)) :type 'error) (should (equal (ebox-style-node-specified-value box :bgcolor) "#111111")) - (should (= (ebox-style-node-specified-value - box :padding-left) - 2))))) + (should (equal (ebox-style-node-specified-value + box :padding-left) + '(ch 2)))))) (ert-deftest ebox-region-update-no-op-skips-rollback-snapshot () "A legal no-op should return before discovering rollback scope." @@ -13845,15 +13841,15 @@ face patch must match that exactly (issue014)." (case '(((box :key root "Before") (box :key root "After") "After") - ((flex :key root :width (240) - (box :key item :width (80) "Item")) - (flex :key root :width (240) - (box :key item :width (120) "Item")) "Item") + ((flex :key root :width (px 240) + (box :key item :width (px 80) "Item")) + (flex :key root :width (px 240) + (box :key item :width (px 120) "Item")) "Item") ((column :key root (box :key first "A")) (column :key root (box :key first "A") (box :key second "B")) "B") - ((box :key root :height 1 :overflow visible "A\nB") - (box :key root :height 1 :overflow hidden "A\nB") "A"))) + ((box :key root :height (lh 1) :overflow visible "A\nB") + (box :key root :height (lh 1) :overflow hidden "A\nB") "A"))) (let ((buffer (generate-new-buffer " *ebox-typed-fresh-equality*"))) (unwind-protect (progn @@ -13873,16 +13869,16 @@ face patch must match that exactly (issue014)." (before (ebox-build '(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") - (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")))) (after (ebox-build '(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") - (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"))))) (unwind-protect (progn @@ -13969,7 +13965,7 @@ face patch must match that exactly (issue014)." (ebox-render-to-buffer buffer (ebox-build - '(column :id scroller :width (viewport) :height 3 + '(column :id scroller :width (vw 100) :height (lh 3) :overflow scroll (box "A") (box "B") (box "C") (box "D") (box "E")))) (with-current-buffer buffer @@ -13985,16 +13981,16 @@ face patch must match that exactly (issue014)." (ert-deftest ebox-typed-local-updates-keep-publication-scoped () "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*"))) (unwind-protect (progn (ebox-render-to-buffer buffer (ebox-build - '(row :width (260) - (box :id target :width (100) "Target") - (box :id peer :width (100) "Peer")))) + '(row :width (px 260) + (box :id target :width (px 100) "Target") + (box :id peer :width (px 100) "Peer")))) (let* ((target (ebox-region-resolve buffer "target")) (peer (car (ebox-selector-query-buffer buffer "#peer"))) (peer-node-id (plist-get peer :node-id))) diff --git a/tests/ebox-css-flex-grid-tests.el b/tests/ebox-css-flex-grid-tests.el new file mode 100644 index 0000000..577955a --- /dev/null +++ b/tests/ebox-css-flex-grid-tests.el @@ -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 diff --git a/tests/ebox-css-layout-tests.el b/tests/ebox-css-layout-tests.el new file mode 100644 index 0000000..b6b19a6 --- /dev/null +++ b/tests/ebox-css-layout-tests.el @@ -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 diff --git a/tests/ebox-css-viewport-tests.el b/tests/ebox-css-viewport-tests.el new file mode 100644 index 0000000..d05790f --- /dev/null +++ b/tests/ebox-css-viewport-tests.el @@ -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 diff --git a/tests/ebox-docs-contract-tests.el b/tests/ebox-docs-contract-tests.el index d0d0cf3..f44769d 100644 --- a/tests/ebox-docs-contract-tests.el +++ b/tests/ebox-docs-contract-tests.el @@ -24,14 +24,19 @@ (defconst ebox-docs-test--active-files '("Makefile" ".github/workflows/ci.yml" - "ebox.el" "ebox-cache.el" "ebox-runtime-index.el" - "ebox-state-contract.el" "ebox-style.el" + "ebox.el" "ebox-cache.el" "ebox-runtime-index.el" "ebox-interaction.el" + "ebox-state-contract.el" "ebox-size.el" "ebox-style.el" "ebox-child-range.el" "ebox-tree.el" "ebox-measure.el" "ebox-fragment.el" "ebox-render-context.el" "ebox-layout.el" "ebox-flex.el" "ebox-grid.el" "ebox-buffer-backend.el" "ebox-patch-plan.el" "ebox-incremental.el" "ebox-surface.el" "ebox-dsl.el" "ebox-spi.el" "ebox-selector.el" "ebox-native-reflow.el" + "tests/ebox-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-state-contract-tests.el" "tests/ebox-layout-boundary-tests.el" @@ -48,11 +53,13 @@ "native/vendor/emacs-30/emacs-module.h" "native/src/lib.rs" "native/src/layout.rs" "native/c/ebox_module.c" "scripts/ebox-package-lint.el" "scripts/ebox-visual-check.el" - "scripts/ebox-performance-evaluator.el") + "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.") (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" "ebox-commit-tests" "surface-tests" "visual-check-tests" "package-tests" "selector-tests" "dsl-tests" "flex-tests" "state-contract-tests" diff --git a/tests/ebox-dsl-tests.el b/tests/ebox-dsl-tests.el index 6b24d12..a3a33e5 100644 --- a/tests/ebox-dsl-tests.el +++ b/tests/ebox-dsl-tests.el @@ -20,7 +20,8 @@ (cl-loop for (key value) on plist by #'cddr when (memq key style-keys) append (list key value))) (declarations - (and style-props (ebox-style-compile-form tag style-props))) + (and style-props (ebox-style-compile-form + tag (ebox-test--explicit-sizes tag style-props)))) (plist (cl-loop for (key value) on plist by #'cddr unless (memq key style-keys) append (list key value))) @@ -115,15 +116,15 @@ "Approved aliases and shorthands should produce canonical longhands." (let ((style (ebox-style-compute (list :bgcolor "#ffffff" - :padding '(1 2) - :margin-left 3 + :padding '((lh 1) (ch 2)) + :margin-left '(ch 3) :border "#333333")))) (should (equal (plist-get style :background-color) "#ffffff")) - (should (= (plist-get style :padding-block-start) 1)) - (should (= (plist-get style :padding-inline-end) 2)) - (should (= (plist-get style :padding-block-end) 1)) - (should (= (plist-get style :padding-inline-start) 2)) - (should (= (plist-get style :margin-left) 3)) + (should (equal (plist-get style :padding-block-start) '(lh 1))) + (should (equal (plist-get style :padding-inline-end) '(ch 2))) + (should (equal (plist-get style :padding-block-end) '(lh 1))) + (should (equal (plist-get style :padding-inline-start) '(ch 2))) + (should (equal (plist-get style :margin-left) '(ch 3))) (should (equal (plist-get style :border-color) "#333333")))) (ert-deftest ebox-style-classifies-incremental-effects () @@ -153,10 +154,10 @@ (ert-deftest ebox-style-unrelated-facts-preserve-typed-layout-config () "Unrelated computed facts must not replace direct typed layout values." (dolist (case '((flex - (flex :flex-direction column :column-gap (6) "A")) + (flex :flex-direction column :column-gap (px 6) "A")) (grid - (grid :grid-template-columns ((40) (60)) - :column-gap (4) "A")))) + (grid :grid-template-columns ((px 40) (px 60)) + :column-gap (px 4) "A")))) (let* ((kind (car case)) (input (ebox-build (cadr case))) (node (ebox-dsl-test--root input)) @@ -233,27 +234,27 @@ "Logical and physical edges conflict locally and cascade across sources." (should-error (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) - (let* ((base (ebox-style-compile-declarations '(:padding (0 (8))) t)) + (let* ((base (ebox-style-compile-declarations '(:padding ((lh 0) (px 8))) t)) (override - (ebox-style-compile-declarations '(:padding-left (24)) t)) + (ebox-style-compile-declarations '(:padding-left (px 24)) t)) (style (ebox-style-compute-subject (ecss-subject-create :type "box") (ebox-style-merge-declarations base override)))) (should (equal (plist-get (ebox-style--box-values style) :padding-left) - '(24)))) + '(px 24)))) (let ((ebox-style-stylesheet (ecss-stylesheet-create))) - (ebox-style-add-rule ".card" '(:margin-inline-start (2))) - (ebox-style-add-rule "#target" '(:margin-left (6))) + (ebox-style-add-rule ".card" '(:margin-inline-start (px 2))) + (ebox-style-add-rule "#target" '(:margin-left (px 6))) (let ((style (ebox-style-compute-subject (ecss-subject-create :type "box" :id "target" :classes '("card")) nil))) (should (equal (plist-get (ebox-style--box-values style) :margin-left) - '(6)))))) + '(px 6)))))) (ert-deftest ebox-style-rejects-canonical-and-alias-duplicates () "One declaration source should not spell one property twice." @@ -269,9 +270,9 @@ (ert-deftest ebox-author-declaration-is-one-atomic-normalization-group () "One author group rejects overlaps while later cascade groups may override." (dolist (declarations - '((:padding 1 :padding-left 2) + '((:padding ((lh 1) (ch 1)) :padding-left (ch 2)) (: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 (1 1 auto) :flex-grow 2) (:border-top-p t :border-top-style none))) @@ -281,9 +282,9 @@ (should (equal (ebox-style-merge-declarations - (ebox-style-compile-declarations '(:gap 1) t) - (ebox-style-compile-declarations '(:row-gap 2) t)) - '(ebox/row-gap 2 ebox/column-gap 1)))) + (ebox-style-compile-declarations '(:gap ((lh 1) (ch 1))) t) + (ebox-style-compile-declarations '(:row-gap (lh 2)) t)) + '(ebox/row-gap (lh 2) ebox/column-gap (ch 1))))) (ert-deftest ebox-style-property-registry-rejects-alias-collisions () "An alias should never shadow another canonical property." @@ -316,24 +317,24 @@ "The concise border boolean should expand to canonical width and style." (should (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 (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))) -(ert-deftest ebox-border-width-unit-is-always-integer-pixels () - "Border width should not reuse Box edge column/pixel wrapper grammar." +(ert-deftest ebox-border-width-requires-an-explicit-length () + "Border declarations retain explicit lengths and reject singleton syntax." (should (equal - (ebox-style-compile-declarations '(:border (1 solid "#687386")) t) - '(ebox/border-top-width 1 ebox/border-top-style solid + (ebox-style-compile-declarations '(:border ((px 1) solid "#687386")) t) + '(ebox/border-top-width (px 1) ebox/border-top-style solid 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-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-left-width 1 ebox/border-left-style solid + ebox/border-left-width (px 1) ebox/border-left-style solid ebox/border-left-color "#687386"))) (should-error (ebox-style-compile-declarations @@ -342,26 +343,31 @@ (ert-deftest ebox-border-engine-projection-keeps-exact-pixels () "Border projection must not reinterpret pixels as character columns." - (cl-letf (((symbol-function 'ebox--space-pixel-width) (lambda () 7))) - (should - (equal - (ebox-style--expand-engine-plist - '(:border-left-width 1 :border-left-style solid - :border-right-width 2 :border-right-style solid - :padding-left 1 :margin-right (3))) - '(:border-left-pixel 1 :border-left-style solid - :border-right-pixel 2 :border-right-style solid - :padding-left-pixel 7 :margin-right-pixel 3)))) + (cl-letf (((symbol-function 'ebox--size-character-width) + (lambda (&optional _box) 7))) + (let ((projected + (ebox-style--expand-engine-plist + '(:border-left-width (px 1) :border-left-style solid + :border-right-width (px 2) :border-right-style solid + :padding-left (ch 1) :margin-right (px 3))))) + (should (equal projected + '(:border-left-pixel (px 1) :border-left-style solid + :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 (equal (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))) (should (equal (ebox-style--expand-engine-plist - '(:border-top-width 2 :border-top-style solid)) - '(:border-top-pixel 2 :border-top-style solid)))) + '(:border-top-width (px 2) :border-top-style solid)) + '(:border-top-pixel (px 2) :border-top-style solid)))) (ert-deftest ebox-style-declaration-cache-expands-shorthand-once () "A cached declaration should not expand its shorthand again." @@ -372,15 +378,15 @@ (lambda (value) (cl-incf calls) (funcall original value)))) - (ebox-style-compile-declarations '(:padding 1) t) - (ebox-style-compile-declarations '(:padding 1) t)) + (ebox-style-compile-declarations '(:padding ((lh 1) (ch 1))) t) + (ebox-style-compile-declarations '(:padding ((lh 1) (ch 1))) t)) (should (= calls 1)))) (ert-deftest ebox-style-border-present-rejects-owned-longhand-conflicts () "One source should not mix the boolean border shorthand with its outputs." (should-error (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) (should-error (ebox-style-compile-declarations @@ -388,11 +394,11 @@ :type 'error) (should-error (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) (should-error (ebox-style-compile-declarations - '(:border-top-p t :border-width 2) t) + '(:border-top-p t :border-width (px 2)) t) :type 'error) (should-error (ebox-style-compile-declarations @@ -400,7 +406,7 @@ :type 'error) (should-error (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)) (ert-deftest ebox-canonical-text-constructor-is-renderable-and-typed () @@ -457,7 +463,7 @@ (ert-deftest ebox-render-cache-signature-ignores-derived-layout-proofs () "A render must not invalidate its own canonical node cache key." (let* ((node (ebox-dsl-test--root - (ebox-build '(box :width '(80) "Stable")))) + (ebox-build '(box :width '(px 80) "Stable")))) (before (let ((ebox--render-cache-signature-cache (make-hash-table :test 'eq))) @@ -644,7 +650,7 @@ (right (ebox-dsl-test--root right-input)) (box-input (ebox-dsl-test--box-create - :layout (ebox-row-layout-create :item-gap 5 :cross-align 'center) + :layout (ebox-row-layout-create :item-gap '(px 5) :cross-align 'center) :children (list left-input right-input) :source-handle 'row)) (box (ebox-dsl-test--root box-input))) @@ -653,7 +659,7 @@ 'row)) (should (eq (ebox-layout-config-kind (ebox-box-node-layout box)) 'row)) (should (equal (ebox-layout-config-props (ebox-box-node-layout box)) - '(:item-gap 5 :cross-align center))) + '(:item-gap (px 5) :cross-align center))) (should (equal (ebox-box-node-children box) (list left right))) (should (eq (ebox-tree-node-children box) (ebox-box-node-children box))) @@ -678,13 +684,13 @@ (bottom (ebox-dsl-test--root bottom-input)) (box-input (ebox-dsl-test--box-create - :layout (ebox-column-layout-create :item-gap 1 :cross-align 'end) + :layout (ebox-column-layout-create :item-gap '(lh 1) :cross-align 'end) :children (list top-input bottom-input))) (box (ebox-dsl-test--root box-input))) (should (ebox-box-node-p box)) (should (eq (ebox-layout-config-kind (ebox-box-node-layout box)) 'column)) (should (equal (ebox-layout-config-props (ebox-box-node-layout box)) - '(:item-gap 1 :cross-align end))) + '(:item-gap (lh 1) :cross-align end))) (should (equal (ebox-box-node-children box) (list top bottom))) (should (eq (ebox-tree-node-children box) (ebox-box-node-children box))) @@ -702,7 +708,11 @@ "Row/Column configs should distinguish defaults from invalid explicit values." (dolist (constructor '(ebox-row-layout-create ebox-column-layout-create)) (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) (:cross-align nil) (:cross-align bogus) (:gap 1) (:item-gap 1 :item-gap 2))) @@ -722,7 +732,7 @@ (equal (ebox-layout-config-props layout) '(:flex-direction row :flex-wrap nowrap :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) :height)) (should (ebox-box-node-p box)) @@ -792,13 +802,13 @@ :justify-content 'space-between :align-items 'center :align-content 'end - :row-gap '(2) - :column-gap '(8)))) + :row-gap '(lh 2) + :column-gap '(px 8)))) (should (equal (ebox-layout-config-props layout) '(:flex-direction column-reverse :flex-wrap wrap :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 (ebox-layout-config-props layout))) (should (ebox-box-node-p @@ -833,15 +843,16 @@ (ert-deftest ebox-flex-config-validation-does-not-reparse-canonical-pixels () "Boundary validation should preserve physical pixels under non-unit fonts." (cl-letf (((symbol-function 'ebox--space-pixel-width) (lambda () 8))) - (let* ((layout (ebox-flex-layout-create :column-gap '(8))) + (let* ((layout (ebox-flex-layout-create :column-gap '(px 8))) (box (ebox-dsl-test--root (ebox-dsl-test--box-create :layout layout :children nil)))) - (should (= (plist-get (ebox-layout-config-props layout) :column-gap) 8)) - (should (= (plist-get - (ebox-layout-config-props (ebox-box-node-layout box)) - :column-gap) - 8))))) + (should (equal (plist-get (ebox-layout-config-props layout) :column-gap) + '(px 8))) + (should (equal (plist-get + (ebox-layout-config-props (ebox-box-node-layout box)) + :column-gap) + '(px 8)))))) (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." @@ -987,25 +998,25 @@ "The typed Grid constructor should emit one canonical closed property set." (let* ((layout (ebox-grid-layout-create - :grid-template-columns '((20) (fr 1)) - :grid-template-rows '(1 auto) - :grid-auto-columns '((30)) - :grid-auto-rows '(2) + :grid-template-columns '((px 20) (fr 1)) + :grid-template-rows '((lh 1) auto) + :grid-auto-columns '((px 30)) + :grid-auto-rows '((lh 2)) :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-content 'space-between :align-content 'center)) (props (ebox-layout-config-props layout))) (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) - '((: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) - '(:kind fixed :size 30))) + '(:kind fixed :size (px 30)))) (should (equal (plist-get props :grid-auto-rows) - '(:kind fixed :size 2))) - (should (= (plist-get props :row-gap) 2)) - (should (= (plist-get props :column-gap) 8)) + '(:kind fixed :size (lh 2)))) + (should (equal (plist-get props :row-gap) '(lh 2))) + (should (equal (plist-get props :column-gap) '(px 8))) (should (ebox-grid-layout-config-props-p props)) (should (ebox-box-node-p (ebox-dsl-test--root @@ -1059,14 +1070,14 @@ (cl-letf (((symbol-function 'ebox--space-pixel-width) (lambda () 8))) (let* ((layout (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 (ebox-dsl-test--box-create :layout layout :children nil))) (props (ebox-layout-config-props (ebox-box-node-layout box)))) - (should (= (plist-get props :column-gap) 8)) - (should (= (plist-get (car (plist-get props :grid-template-columns)) - :size) - 20))))) + (should (equal (plist-get props :column-gap) '(px 8))) + (should (equal (plist-get (car (plist-get props :grid-template-columns)) + :size) + '(px 20)))))) (ert-deftest ebox-grid-config-boundary-rejects-forged-minmax-roles () "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." (let* ((flex (ebox-build - '(flex :width '(300) :gap '(1 (5)) + '(flex :width '(px 300) :gap '((lh 1) (px 5)) (box :flex 1 "A") "B"))) (flex-child (car (ebox-box-node-children (ebox-dsl-test--root flex)))) (grid (ebox-build - '(grid :grid-template-columns '((20) (20)) + '(grid :grid-template-columns '((px 20) (px 20)) (box :grid-column '(1 :span 2) "Header")))) (grid-child (car (ebox-box-node-children (ebox-dsl-test--root grid))))) diff --git a/tests/ebox-fixtures.el b/tests/ebox-fixtures.el index 8175749..948cc37 100644 --- a/tests/ebox-fixtures.el +++ b/tests/ebox-fixtures.el @@ -109,14 +109,94 @@ author identity; opaque source handles remain internal to SourceBuilder." (defun ebox-test--declarations (tag props) "Return canonical TAG declarations from evaluated fixture PROPS." (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) "Return TAG LayoutConfig from PROPS and DECLARATIONS." (ebox-layout-config-for-form tag (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 declarations (lambda (property) diff --git a/tests/ebox-flex-tests.el b/tests/ebox-flex-tests.el index 7de405b..4f49366 100644 --- a/tests/ebox-flex-tests.el +++ b/tests/ebox-flex-tests.el @@ -119,9 +119,9 @@ (should (equal-including-properties lazy eager)))) (ert-deftest ebox-flex-normalized-gap-keeps-pixels-on-wide-fonts () - "Flex gaps convert character units once and preserve explicit pixel units." - (cl-letf (((symbol-function 'ebox--space-pixel-width) (lambda () 7))) - (dolist (case '((2 . 14) ((2) . 2))) + "Flex gaps use the zero advance and preserve explicit pixel units." + (cl-letf (((symbol-function 'ebox--size-character-width) (lambda (&optional _) 7))) + (dolist (case '(((ch 2) . 14) ((px 2) . 2))) (let* ((layout (ebox-test-flex :width '(100) :column-gap (car case) (ebox-test-text "A") @@ -364,16 +364,16 @@ (ert-deftest ebox-flex-module-preserves-item-participation () "Extracted flex code should preserve item participation semantics." (let* ((node (ebox-build - '(flex :width (240) - (box :flex-grow 1 :width (40) "Grow") - (box :width (40) "Fixed")))) + '(flex :width (px 240) + (box :flex-grow 1 :width (px 40) "Grow") + (box :width (px 40) "Fixed")))) (rendered (ebox-render node))) (should (string-match-p "Grow" rendered)) (should (string-match-p "Fixed" rendered)))) (ert-deftest ebox-flex-measures-direct-text-without-box-geometry () "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))) (should (string-match-p "Direct text" (substring-no-properties rendered))) @@ -400,7 +400,7 @@ (should (= (ebox-get (cadr items) :flex-grow) 2))) (ebox-style-reset-rules) (ebox-region-update (ebox-region-resolve buffer "rule") - :width '(41)) + :width '(px 41)) (let ((items (plist-get (plist-get (ebox--buffer-render-state buffer) :root-node) :children))) @@ -515,7 +515,7 @@ (ert-deftest ebox-flex-definite-basis-does-not-materialize-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 (ebox-test-box (ebox-test-column @@ -523,8 +523,7 @@ (ebox-test-text "second line")))) (source (ebox-test-root input)) (ebox--surface-materialization-active t) - (expected (ebox--normalize-horizontal-size-value - basis ebox--preferred-size-keywords)) + (expected (ebox--size-pixels basis source 'width)) (original-max (symbol-function 'ebox--content-max-pixel)) (original-render (symbol-function 'ebox--render-layout)) (max-calls 0) @@ -544,7 +543,7 @@ (ert-deftest ebox-flex-basis-retains-required-intrinsic-fallback () "Viewport-like basis values measure only when their viewport is absent." (dolist (viewport '(nil 120)) - (dolist (basis '(viewport stretch contain)) + (dolist (basis '((vw 100) stretch)) (let* ((content "natural content") (source (ebox-test-root (ebox-test-box (ebox-test-text content)))) (ebox--surface-materialization-active t) @@ -563,14 +562,14 @@ "Public Flex layout retains complete output when unused probes disappear." (let* ((input (ebox-build - '(flex :width (140) :flex-wrap wrap :gap (1 (4)) - (box :flex-basis (40) :flex-grow 1 :min-width 0 - :wrap-mode word :padding (1 (2)) + '(flex :width (px 140) :flex-wrap wrap :gap ((lh 1) (px 4)) + (box :flex-basis (px 40) :flex-grow 1 :min-width (ch 0) + :wrap-mode word :padding ((lh 1) (px 2)) :color "#123456" :bgcolor "#ddeeff" (column (text "alpha beta gamma delta epsilon") (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"))))) (original-basis (symbol-function 'ebox--flex-basis-main)) (fast (ebox-render input)) @@ -715,16 +714,16 @@ "Column flex items should not stretch to the full row before line breaking." (let* ((layout (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 - (box :width (220) "one") - (box :width (42) "A")) + (box :width (px 220) "one") + (box :width (px 42) "A")) (column - (box :width (220) "two") - (box :width (42) "B")) + (box :width (px 220) "two") + (box :width (px 42) "B")) (column - (box :width (220) "three") - (box :width (42) "C"))))) + (box :width (px 220) "three") + (box :width (px 42) "C"))))) (lines (ebox-flex-test--plain-lines layout))) (should (= (length lines) 2)) (should (string-match-p "one.*two.*three" (car lines))) @@ -734,19 +733,19 @@ "Composite flex items should re-render internals for final item width." (let* ((layout (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 - (box :id "title" :max-width (293) - :padding (0 (10)) :border "#5E7F6A" + (box :id "title" :max-width (px 293) + :padding ((lh 0) (px 10)) :border "#5E7F6A" :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-bottom "#5E7F6A" :border-left "#5E7F6A" - :flex-wrap wrap :column-gap (8) - (box :flex-shrink 0 :width (118) "A 118") - (box :flex-shrink 0 :width (118) "B 118") - (box :flex-shrink 0 :width (118) "C 118")))))) + :flex-wrap wrap :column-gap (px 8) + (box :flex-shrink 0 :width (px 118) "A 118") + (box :flex-shrink 0 :width (px 118) "B 118") + (box :flex-shrink 0 :width (px 118) "C 118")))))) (buffer (generate-new-buffer " *ebox-flex-viewport*"))) (unwind-protect (progn @@ -772,36 +771,36 @@ (let* ((ebox-viewport-width 500) (layout (ebox-build - '(flex :flex-flow (row wrap) :row-gap 1 :column-gap (12) - :padding (0 (12)) + '(flex :flex-flow (row wrap) :row-gap (lh 1) :column-gap (px 12) + :padding ((lh 0) (px 12)) :border-right "#6F6A95" :border-bottom "#6F6A95" :border-left "#6F6A95" :bgcolor "#FBF9FF" (column - (box :max-width (440) - :padding (0 (10)) :border "#6F6A95" + (box :max-width (px 440) + :padding ((lh 0) (px 10)) :border "#6F6A95" :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-bottom "#6F6A95" :border-left "#6F6A95" - :flex-flow (row wrap) :gap (1 (12)) - (box :width (170) "A") - (box :width (170) "B") - (box :width (170) "C wraps"))) + :flex-flow (row wrap) :gap ((lh 1) (px 12)) + (box :width (px 170) "A") + (box :width (px 170) "B") + (box :width (px 170) "C wraps"))) (column - (box :max-width (440) - :padding (0 (10)) :border "#6F6A95" + (box :max-width (px 440) + :padding ((lh 0) (px 10)) :border "#6F6A95" :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-bottom "#6F6A95" :border-left "#6F6A95" - :flex-flow (column wrap) :gap (1 (12)) - (box :width (140) "A") - (box :width (140) "B") - (box :width (140) "C new column")))))) + :flex-flow (column wrap) :gap ((lh 1) (px 12)) + (box :width (px 140) "A") + (box :width (px 140) "B") + (box :width (px 140) "C new column")))))) (rendered (ebox-flex-test--render layout)) (line-widths (ebox-flex-test--line-widths layout)) (border-widths @@ -996,8 +995,10 @@ entry before final allocation, providing the original rerendering reference." (eq (ebox-node-source-handle box) handle)) (push (list :allocation allocation - :width (ebox-get box :width) - :height (ebox-get box :height) + :width (ebox--flex-definite-property + box 'row (ebox-get box :width)) + :height (ebox--flex-definite-property + box 'column (ebox-get box :height)) :constraints (ebox--render-cache-plist-signature box) :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." (let* ((input (ebox-build - '(flex :width (120) :flex-wrap wrap - (box :flex-basis (40) :flex-grow 1 :min-width 0 - :height 2 :wrap-mode word + '(flex :width (px 120) :flex-wrap wrap + (box :flex-basis (px 40) :flex-grow 1 :min-width (ch 0) + :height (lh 2) :wrap-mode word (text "short"))))) (observed (ebox-flex-test--observe-sized-box input)) (calls (plist-get observed :observations)) @@ -1074,17 +1075,17 @@ entry before final allocation, providing the original rerendering reference." 'mouse-face 'highlight)) (input (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 - (box :flex-basis ,(if row-p '(40) 2) :flex-grow 1 - :min-width 0 :wrap-mode word :overflow hidden + (box :flex-basis ,(if row-p '(px 40) '(lh 2)) :flex-grow 1 + :min-width (ch 0) :wrap-mode word :overflow hidden ,@(if row-p - (list :height (if (eq sizing 'border-box) 6 2)) - '(:width (60))) - :box-sizing ,sizing :padding (1 (2)) :margin (1 (3)) - :border (1 solid "#123456") + (list :height (list 'lh (if (eq sizing 'border-box) 6 2))) + '(:width (px 60))) + :box-sizing ,sizing :padding ((lh 1) (px 2)) :margin ((lh 1) (px 3)) + :border ((px 1) solid "#123456") :color "#123456" :bgcolor "#ddeeff" - (column :width ,(if row-p '(100) '(40)) + (column :width ,(if row-p '(px 100) '(px 40)) (text ,label) (text "second")))))) (source (car (ebox-tree-layout-children (ebox-test-root input)))) (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 () "Automatic, relative, clamped, and enlarged cross constraints rerender." (dolist (case '(nil - (:height (viewport-height)) - (:height 1 :overflow visible) - (:height 2 :peer-height 4) - (:height 4 :max-height 2) - (:height 2 :min-height 3))) + (:height (vh 100)) + (:height (lh 1) :overflow visible) + (:height (lh 2) :peer-height 4) + (:height (lh 4) :max-height (lh 2)) + (:height (lh 2) :min-height (lh 3)))) (let* ((ebox-viewport-height 2) (peer-height (plist-get case :peer-height)) (input (ebox-build - `(flex :width (120) :flex-wrap wrap - (box :flex-basis (40) :flex-grow 1 :min-width 0 + `(flex :width (px 120) :flex-wrap wrap + (box :flex-basis (px 40) :flex-grow 1 :min-width (ch 0) :wrap-mode word ,@(ebox--plist-remove-keys case '(:peer-height)) (text "A\nB")) ,@(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))) (ert-info ((format "Cross constraint: %S" case)) (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) (input (ebox-build - '(flex :height 4 :flex-direction column :flex-wrap wrap - (box :width (60) :flex-basis 2 :flex-grow 1 :overflow hidden - (box :width (viewport) (text "responsive")))))) + '(flex :height (lh 4) :flex-direction column :flex-wrap wrap + (box :width (px 60) :flex-basis (lh 2) :flex-grow 1 :overflow hidden + (box :width (vw 100) (text "responsive")))))) (observed (ebox-flex-test--observe-sized-box input)) (calls (plist-get observed :observations))) (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." (let* ((input (ebox-build - '(flex :width (120) :flex-wrap wrap - (box :flex-basis (40) :flex-grow 1 :min-width 0 - :height 2 :wrap-mode word :overflow scroll + '(flex :width (px 120) :flex-wrap wrap + (box :flex-basis (px 40) :flex-grow 1 :min-width (ch 0) + :height (lh 2) :wrap-mode word :overflow scroll (column (text "A") (text "B") (text "C") (text "D")))))) (fast (ebox-flex-test--observe-sized-box input)) (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 (= (plist-get state :content-height) 2)) (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 :content-lines-complete-p)) (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) (layout (ebox-build - '(flex :width (120) - (box :id "root" :width (80) :height (viewport-height) + '(flex :width (px 120) + (box :id "root" :width (px 80) :height (vh 100) :overflow scroll :flex-grow 1 - (column :width (80) - (box :height 1 "A") - (box :height 1 "B") - (box :height 1 "C") - (box :height 1 "D")))))) + (column :width (px 80) + (box :height (lh 1) "A") + (box :height (lh 1) "B") + (box :height (lh 1) "C") + (box :height (lh 1) "D")))))) (buffer (generate-new-buffer " *ebox-flex-scroll-state*"))) (unwind-protect (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 () "Visible overflow should widen only the line that actually overflows." (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 + :overflow 'visible :padding-left '(10) :padding-right '(10))) (source (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 () "Column flex must render composite items in the final cross viewport." (let* ((viewport-child - (ebox-test-box (ebox-test-text "VP") :width '(viewport))) + (ebox-test-box (ebox-test-text "VP") :width '(% 100))) (source-input (ebox-test-column viewport-child @@ -1497,7 +1501,7 @@ entry before final allocation, providing the original rerendering reference." (ebox-test-flex :height 4 :flex-direction 'column :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))) (should (ebox-box-node-p 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") :width '(150) :box-sizing 'border-box + :overflow 'visible :min-width 'auto :wrap-mode 'none) :flex 1) (ebox-test-box (ebox-test-text "B") :width '(120)))) diff --git a/tests/ebox-font-tests.el b/tests/ebox-font-tests.el index b7d9b3f..4082658 100644 --- a/tests/ebox-font-tests.el +++ b/tests/ebox-font-tests.el @@ -55,7 +55,7 @@ (ebox-render-to-buffer buffer (ebox-build - '(box :width (viewport) + '(box :width (vw 100) (text :font-size 16 :font-style italic "A")))) (let* ((state-a (ebox--buffer-render-state buffer)) (node-a diff --git a/tests/ebox-grid-tests.el b/tests/ebox-grid-tests.el index a2c0995..038270f 100644 --- a/tests/ebox-grid-tests.el +++ b/tests/ebox-grid-tests.el @@ -109,6 +109,144 @@ entry-left rendered 100 '(:justify-items stretch)))) (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 () "Final Grid assembly must reuse the width-sized cell's complete output." (let* ((cell-region 91001) @@ -155,7 +293,7 @@ (ert-deftest ebox-grid-definite-stretched-cells-skip-unused-intrinsic-output () "Definite fixed/fr columns need only the stretched cell's final output." (dolist (width '(80 120)) - (dolist (cell-width '(nil auto stretch contain)) + (dolist (cell-width '(nil auto stretch)) (let* ((cell-region 91002) (input (ebox-test-grid @@ -485,8 +623,9 @@ (let* ((leading-zero-p (zerop (car rows))) (input (ebox-build - `(grid :width (40) :grid-template-columns ((40)) - :grid-template-rows ,rows :row-gap ,gap + `(grid :width (px 40) :grid-template-columns ((px 40)) + :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 "visible" "hidden"))))) (rendered (ebox-render input)) @@ -621,7 +760,7 @@ "The public Ebox DSL should compile a grid node." (let* ((input (ebox-build - '(grid :grid-template-columns ((20) (20)) + '(grid :grid-template-columns ((px 20) (px 20)) (box "A") (box "B")))) (node (ebox-test-root input))) @@ -634,7 +773,7 @@ "The Ebox DSL should preserve direct child Box placement metadata." (let* ((input (ebox-build - '(grid :grid-template-columns ((20) (20)) + '(grid :grid-template-columns ((px 20) (px 20)) (box :grid-column (1 :span 2) "Header")))) (node (ebox-test-root input))) (should (string-match-p "Header" (ebox-grid-test--plain input))) diff --git a/tests/ebox-interaction-tests.el b/tests/ebox-interaction-tests.el new file mode 100644 index 0000000..afb1905 --- /dev/null +++ b/tests/ebox-interaction-tests.el @@ -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 diff --git a/tests/ebox-m0a-inventory-fixture.el b/tests/ebox-m0a-inventory-fixture.el index 123d4f8..4c9afcd 100644 --- a/tests/ebox-m0a-inventory-fixture.el +++ b/tests/ebox-m0a-inventory-fixture.el @@ -16,6 +16,9 @@ '((:storage (:global ebox--defer-scroll-content-index) :why dynamic-scalar-not-a-store :owner ebox-layout :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) :why eviction-cursor-not-a-store :owner ebox-measure :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 "region-line\\|owner-table" name) 'ebox-incremental) ((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 "window-line\\|box-content\\|region-box\\|scroll-window" name) 'ebox-layout) @@ -135,10 +138,12 @@ ebox--render-output-provenance-table ebox--render-recached-source-node-cache ebox--render-string-max-pixel-width-cache 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--string-pixel-width-cache ebox--viewport-dependent-node-ids-cache ebox--viewport-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-cache--buffer-report-table ebox-cache--registry ebox-canonical--declaration-fact-cache @@ -513,6 +518,12 @@ :proposed-category disposable-cache :owner ebox-font :authority ebox :lifetime process :rollback not-authoritative :rebuild lazy-measure :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 :storage (:global ebox-native-reflow--source-cluster-cache) :proposed-category disposable-cache :owner ebox-native-reflow :authority ebox diff --git a/tests/ebox-node-memo-generation-tests.el b/tests/ebox-node-memo-generation-tests.el index 027aa14..b0bd7cb 100644 --- a/tests/ebox-node-memo-generation-tests.el +++ b/tests/ebox-node-memo-generation-tests.el @@ -13,19 +13,21 @@ (defun ebox-node-memo-test--input (map) "Build an ordinary responsive Flex/Grid tree with nested scroll and MAP." (ebox-build - `(column :width (viewport) + `(column :width (vw 100) (text "Header") - (column :id "body" :width stretch :height (viewport-height) - :overflow scroll :padding (0 (2)) + (column :id "body" :width stretch :height (vh 100) + :overflow scroll :padding ((lh 0) (px 2)) ,@(cl-loop for index below 6 collect `(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 (format "Open %02d %s" index (make-string 48 ?x)) '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 "Footer")))) @@ -169,13 +171,13 @@ (ebox-runtime-idle-reflow-cache-prewarm nil) (input (ebox-build - `(column :width (viewport) - (box :id "fixed" :width (80) (box (text "Cached child"))) + `(column :width (vw 100) + (box :id "fixed" :width (px 80) (box (text "Cached child"))) (flex :width stretch - (box :flex-grow 1 :flex-basis (40) (text "Allocated"))) - (column :width stretch :height 2 :overflow scroll + (box :flex-grow 1 :flex-basis (px 40) (text "Allocated"))) + (column :width stretch :height (lh 2) :overflow scroll ,@(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)) fixed-node-id cache-hits) (unwind-protect @@ -302,6 +304,9 @@ (lambda (window) (if (eq change 'display) '(ebox-node-memo-display changed) (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) (lambda (state) (push t seen) diff --git a/tests/ebox-package-tests.el b/tests/ebox-package-tests.el index e4dca9b..49d063f 100644 --- a/tests/ebox-package-tests.el +++ b/tests/ebox-package-tests.el @@ -156,9 +156,9 @@ (should (commandp 'ebox-byte-compile)) (should (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-font.el" "ebox-style.el" + "ebox-font.el" "ebox-size.el" "ebox-style.el" "ebox-layout-config.el" "ebox-node-factory.el" "ebox-child-range.el" diff --git a/tests/ebox-selector-tests.el b/tests/ebox-selector-tests.el index 1386dc5..6a95658 100644 --- a/tests/ebox-selector-tests.el +++ b/tests/ebox-selector-tests.el @@ -21,22 +21,22 @@ (defun ebox-selector-test--width-unit-form (width) "Return a layout whose root WIDTH constrains nested auto layout." `(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") - (flex :flex-flow (row wrap) :column-gap (12) - :padding (0 (12)) :border "#D97757" + (flex :flex-flow (row wrap) :column-gap (px 12) + :padding ((lh 0) (px 12)) :border "#D97757" (column - (box :width (220) :border "#D97757" "row") - (flex :width (220) :height 4 :border "#D97757" - (box :width (42) "A") - (box :width (42) "B") - (box :width (42) "C"))) + (box :width (px 220) :border "#D97757" "row") + (flex :width (px 220) :height (lh 4) :border "#D97757" + (box :width (px 42) "A") + (box :width (px 42) "B") + (box :width (px 42) "C"))) (column - (box :width (220) :border "#D97757" "row-reverse") - (flex :width (220) :height 4 :border "#D97757" - (box :width (42) "A") - (box :width (42) "B") - (box :width (42) "C")))))) + (box :width (px 220) :border "#D97757" "row-reverse") + (flex :width (px 220) :height (lh 4) :border "#D97757" + (box :width (px 42) "A") + (box :width (px 42) "B") + (box :width (px 42) "C")))))) (defun ebox-selector-test--rendered-widths (rendered) "Return pixel widths for RENDERED lines." @@ -351,7 +351,7 @@ (generate-new-buffer-name " *ebox-selector-test*") node)) (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 :updated) 1))) (let* ((match (car (ebox-selector-query-buffer buffer "#card"))) @@ -363,18 +363,21 @@ (ert-deftest ebox-selector-width-unit-change-invalidates-render-cache () "Changing width units should not reuse stale containing-block output." (ebox-selector-test--reset-runtime-state) - (let (buffer live-widths direct-widths) - (cl-letf (((symbol-function 'ebox--space-pixel-width) - (lambda () 7))) + (let (buffer char-widths live-widths direct-widths) + (cl-letf (((symbol-function 'ebox--size-character-width) + (lambda (&optional _box) 7))) (unwind-protect (let ((ebox-viewport-width 1200)) (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-selector-test*") (ebox-build - (ebox-selector-test--width-unit-form '(550))))) - (ebox-selector-update-buffer buffer "#root" :width 300) - (ebox-selector-update-buffer buffer "#root" :width '(300)) + (ebox-selector-test--width-unit-form '(px 550))))) + (ebox-selector-update-buffer buffer "#root" :width '(ch 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 (setq live-widths (ebox-selector-test--rendered-widths @@ -383,7 +386,8 @@ (ebox-selector-test--rendered-widths (ebox-render (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))) (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer)))))) diff --git a/tests/ebox-size-native-tests.el b/tests/ebox-size-native-tests.el new file mode 100644 index 0000000..80e2133 --- /dev/null +++ b/tests/ebox-size-native-tests.el @@ -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 diff --git a/tests/ebox-size-style-tests.el b/tests/ebox-size-style-tests.el new file mode 100644 index 0000000..2445f35 --- /dev/null +++ b/tests/ebox-size-style-tests.el @@ -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 diff --git a/tests/ebox-size-tests.el b/tests/ebox-size-tests.el new file mode 100644 index 0000000..7557180 --- /dev/null +++ b/tests/ebox-size-tests.el @@ -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 diff --git a/tests/ebox-state-contract-tests.el b/tests/ebox-state-contract-tests.el index 3350e88..7af3055 100644 --- a/tests/ebox-state-contract-tests.el +++ b/tests/ebox-state-contract-tests.el @@ -104,6 +104,10 @@ disposable-cache ebox-font cache-fill-and-evict display-capability-signature discard resolve-font-fact-again 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 (ebox--char-width-cache ebox--face-height-width-cache ebox--display-signature-cache ebox--render-cache-signature-cache diff --git a/tests/ebox-style-schema-tests.el b/tests/ebox-style-schema-tests.el index f394b57..ff90847 100644 --- a/tests/ebox-style-schema-tests.el +++ b/tests/ebox-style-schema-tests.el @@ -215,9 +215,10 @@ :margin-block-start 6 :margin-inline-end (7) :margin-block-end 8 :margin-inline-start (9)))) (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 - (copy-tree fixture)))))) + (ebox-test--explicit-sizes 'box fixture)))))) (ert-deftest ebox-style-schema-malformed-projections-fail-fast () "Ambiguous, missing, or cyclic projection declarations are rejected." diff --git a/tests/ebox-surface-tests.el b/tests/ebox-surface-tests.el index ec6fe89..fcf0bc4 100644 --- a/tests/ebox-surface-tests.el +++ b/tests/ebox-surface-tests.el @@ -758,7 +758,7 @@ (ebox-runtime-idle-reflow-cache-prewarm nil) (map (make-sparse-keymap)) (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))))) (define-key map [mouse-1] #'ignore) (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." (let* ((subject (ecss-subject-create :type "box")) (old-declarations - '(ebox/width (100) ebox/color "#252A2E" + '(ebox/width (px 100) ebox/color "#252A2E" ebox/background-color "#F8F5EE")) (new-declarations - '(ebox/width (100) ebox/color "#F2EEE4" + '(ebox/width (px 100) ebox/color "#F2EEE4" ebox/background-color "#1B1F20")) (old-style (ebox-style-compute-subject subject old-declarations)) @@ -3367,7 +3367,7 @@ candidate cannot hide mutations by restoring the old hash-table pointer." (should-not (ebox-style--theme-delta-computed 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 () "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"))) (parent-new (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 (ebox-style-compute-subject child declarations parent-old)) (delta @@ -3402,7 +3402,7 @@ changes. Geometry and non-inherited computed values must remain identical." (ebox-style-compute-subject parent '(ebox/color "#111111"))) (parent-new (ebox-style-compute-subject parent '(ebox/color "#222222"))) - (declarations '(ebox/color "#ffffff" ebox/width (100))) + (declarations '(ebox/color "#ffffff" ebox/width (px 100))) (style (ebox-style-compute-subject child declarations parent-old)) (delta @@ -3449,15 +3449,15 @@ changes. Geometry and non-inherited computed values must remain identical." "Equivalent Flex gap aliases must not become geometry dirtiness." (let ((old (list :ebox-type 'flex :raw-props '(:width stretch - :row-gap 1 :column-gap (12) - :padding-block-start 0 - :padding-inline-end 2 - :padding-block-end 0 - :padding-inline-start 2 - :border-top-width 1 - :border-right-width 1 - :border-bottom-width 1 - :border-left-width 1 + :row-gap (lh 1) :column-gap (px 12) + :padding-block-start (lh 0) + :padding-inline-end (ch 2) + :padding-block-end (lh 0) + :padding-inline-start (ch 2) + :border-top-width (px 1) + :border-right-width (px 1) + :border-bottom-width (px 1) + :border-left-width (px 1) :border-top-style solid :border-right-style solid :border-bottom-style solid @@ -3471,9 +3471,9 @@ changes. Geometry and non-inherited computed values must remain identical." :background-color "#F8F5EE"))) (new (list :ebox-type 'flex :raw-props '(:width stretch - :gap (1 (12)) - :padding (0 2) - :border (1 solid "#687386") + :gap ((lh 1) (px 12)) + :padding ((lh 0) (ch 2)) + :border ((px 1) solid "#687386") :align-items center :color "#F2EEE4" :bgcolor "#1B1F20")))) @@ -3837,8 +3837,340 @@ changes. Geometry and non-inherited computed values must remain identical." (when (buffer-live-p 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 () - "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) (let ((buffer (generate-new-buffer " *ebox-handle-only-update*"))) (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"))) (region-id (plist-get match :region-id))) (should-error (ebox-region-update region-id :content "Wrong") - :type 'wrong-type-argument) + :type 'user-error) (ebox-region-update (plist-get match :region-handle) :content "After") (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)) '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) ;;; ebox-surface-tests.el ends here diff --git a/tests/ebox-vertical-materialization-tests.el b/tests/ebox-vertical-materialization-tests.el index c7d3f37..cd0b44c 100644 --- a/tests/ebox-vertical-materialization-tests.el +++ b/tests/ebox-vertical-materialization-tests.el @@ -29,7 +29,7 @@ (let ((measure (symbol-function 'ebox--string-pixel-width)) (blank (symbol-function 'ebox--pixel-blank))) (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)) (measure-count 0) (blank-count 0) actual) (cl-letf (((symbol-function 'ebox--string-pixel-width) @@ -71,7 +71,8 @@ "Single-line copies avoid actual substring and mapconcat materialization." (let ((substring-function (symbol-function 'substring)) (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) (cl-letf (((symbol-function 'substring) (lambda (&rest args) diff --git a/tests/ebox-viewport-topology-tests.el b/tests/ebox-viewport-topology-tests.el index fd264bc..6e61f76 100644 --- a/tests/ebox-viewport-topology-tests.el +++ b/tests/ebox-viewport-topology-tests.el @@ -11,11 +11,11 @@ (defun ebox-viewport-topology-test--nested-scroll (map &optional count) "Build one ordinary nested scroll owner with interactive MAP content." (ebox-build - `(column :key root :width (viewport) + `(column :key root :width (vw 100) (text "Header") (column :key body :id "body" :width stretch - :height (viewport-height) :overflow scroll - :padding (0 (2)) + :height (vh 100) :overflow scroll + :padding ((lh 0) (px 2)) ,@(cl-loop for index below (or count 12) collect @@ -173,10 +173,10 @@ (ebox-render-to-buffer buffer (ebox-build - '(column :width (viewport) - (box :id "stable" :width (80) :height 2 :overflow scroll + '(column :width (vw 100) + (box :id "stable" :width (px 80) :height (lh 2) :overflow scroll (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 (text "affected zero\naffected one\naffected two\naffected three\naffected four"))))) (let* ((state (ebox--buffer-render-state buffer)) @@ -335,7 +335,7 @@ (list (ebox-box-create :source-handle handle :source-builder builder :owned-facts (ebox-canonical-facts-from-declarations - 'box '(ebox/width (viewport))) + 'box '(ebox/width (vw 100))) :layout (ebox-column-layout-create) :children (list (apply #'ebox-child-range range-ref children)))) (ebox-source-builder-finish builder))) diff --git a/tests/ebox-zero-height-tests.el b/tests/ebox-zero-height-tests.el new file mode 100644 index 0000000..9d8858b --- /dev/null +++ b/tests/ebox-zero-height-tests.el @@ -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