Add retained layer layout: position/left/top/z-index/layer/anchor properties, new ebox-layer.el and ebox-composite.el, update docs and Makefile
Some checks are pending
CI / test (29.1) (push) Waiting to run
CI / test (30.2) (push) Waiting to run
CI / native-build (macos-latest) (push) Waiting to run
CI / native-build (ubuntu-latest) (push) Waiting to run
CI / native-build (windows-latest) (push) Waiting to run
CI / native-msrv (macos-latest) (push) Waiting to run
CI / native-msrv (ubuntu-latest) (push) Waiting to run
CI / native-msrv (windows-latest) (push) Waiting to run

This commit is contained in:
Kinneyzhang 2026-09-10 01:58:23 +08:00
parent a8417d56c6
commit 4a25d573c2
18 changed files with 2060 additions and 33 deletions

View File

@ -23,6 +23,12 @@ NATIVE_RELEASE_DIR = native/target/$(NATIVE_TARGET)/release
all: check all: check
.PHONY: layer-tests
layer-tests:
$(EMACS_TEST) -l tests/ebox-composite-tests.el -l tests/ebox-layer-style-tests.el -l tests/ebox-layer-tests.el -l tests/ebox-layer-publication-tests.el -f ert-run-tests-batch-and-exit
test: layer-tests
interaction-tests: interaction-tests:
$(EMACS_TEST) -l tests/ebox-interaction-tests.el -f ert-run-tests-batch-and-exit $(EMACS_TEST) -l tests/ebox-interaction-tests.el -f ert-run-tests-batch-and-exit

View File

@ -74,6 +74,7 @@ another public node or wrapper.
| Paint | `:color`, `:background-color`, exact alias `:bgcolor`, border colors, `:visibility` | | Paint | `:color`, `:background-color`, exact alias `:bgcolor`, border colors, `:visibility` |
| Text layout | `:text-align`, `:wrap-mode` (`word`, `char`, `kp`, `none`) | | Text layout | `:text-align`, `:wrap-mode` (`word`, `char`, `kp`, `none`) |
| Overflow | `:overflow` (`visible`, `hidden`, `scroll`) | | Overflow | `:overflow` (`visible`, `hidden`, `scroll`) |
| Layer placement | `:position`, `:left`, `:top`, `:z-index`, `:layer`, `:anchor`, `:placement` |
`hidden` enforces the content width after composing children, preserving whole `hidden` enforces the content width after composing children, preserving whole
supported text clusters and excluding the container's own padding and border supported text clusters and excluding the container's own padding and border
@ -109,6 +110,53 @@ guide](ebox-user-guide.en.md#4-use-geometry-and-paint-properties) for percentage
references, font units, arithmetic rules, and whole-line vertical display references, font units, arithmetic rules, and whole-line vertical display
quantization. This is a CSS semantic subset, not a browser layout engine. quantization. This is a CSS semantic subset, not a browser layout engine.
### Retained layer placement
These non-inherited geometry properties apply to `box`, `row`, `column`,
`flex`, and `grid`; Text does not accept them. Existing author tags are unchanged.
| Property | Accepted value | Default |
| --- | --- | --- |
| `:position` | `static`, `relative`, `absolute` | `static` |
| `:left` | Signed inline length (`px`, `ch`, `vw`, `%`) or size expression | `(px 0)` |
| `:top` | Signed block length (`lh`, `vh`, `%`) or size expression | `(lh 0)` |
| `:z-index` | Integer, including negative values | `0` |
| `:layer` | `local`, `root` | `local` |
| `:anchor` | Semantic `:id` as a symbol or string, or `nil` | `nil` |
| `:placement` | `bottom-start`, `bottom-end`, `top-start`, `top-end` | `bottom-start` |
`static` uses ordinary flow. `relative` keeps its flow slot and shifts its paint
by `:left` and `:top`. `absolute` contributes no normal-flow size or slot and
uses the parent content origin, or the root content canvas for `:layer root`.
Offsets use that paint host's font and containing dimensions; `%` uses width
for `:left` and height for `:top`. Fractions survive length resolution; the
final vertical offset is floored to the host's whole text rows.
Each local host composes its children as one group. Within a group, smaller
`:z-index` values paint first; at equal depth, normal content paints before
positioned content, and positioned siblings follow logical document order.
A nested child's depth does not escape its local group. A painted Box is
opaque across its content, padding, and border, including blank space; its
margin is excluded. Paint is clipped to the host canvas.
`:layer root` changes the paint target while preserving the logical parent,
selectors, inheritance, and retained identity. Both `:layer root` and a non-nil
`:anchor` require `:position absolute`. Anchors resolve a unique semantic ID in
the paint host's logical subtree and use its visible bounds. The panel starts
above/below and at the start/end edge as requested, flips vertically when the
opposite side fits, then shifts inside the canvas. Missing or invisible anchors
produce no panel paint; ambiguous IDs and cyclic placement dependencies signal
an error before publication.
This is an opaque text compositor: horizontal positions are pixels, vertical
positions share the host's row grid. Positioned descendants must share their
host's effective font line height. Whole supported text clusters and display
units survive clipping; partial glyphs/images become neutral space. There is
no alpha blending, partial-glyph masking, or child-frame rendering. Layered
trees use Elisp when the native representation cannot express their placement.
See the [layer guide](ebox-user-guide.en.md#retained-layers) for examples and
retained update behavior.
### Native node capabilities ### Native node capabilities
`text`, `box`, `row`, `column`, `flex`, and `grid` accept these four explicit `text`, `box`, `row`, `column`, `flex`, and `grid` accept these four explicit

View File

@ -69,6 +69,7 @@ participation property 就是普通子 Box property不会创建另一种公
| 绘制 | `:color`、`:background-color`、精确 alias `:bgcolor`、border color、`:visibility` | | 绘制 | `:color`、`:background-color`、精确 alias `:bgcolor`、border color、`:visibility` |
| 文本布局 | `:text-align`、`:wrap-mode``word`、`char`、`kp`、`none` | | 文本布局 | `:text-align`、`:wrap-mode``word`、`char`、`kp`、`none` |
| Overflow | `:overflow``visible`、`hidden`、`scroll` | | Overflow | `:overflow``visible`、`hidden`、`scroll` |
| 图层放置 | `:position`、`:left`、`:top`、`:z-index`、`:layer`、`:anchor`、`:placement` |
`hidden` 在组合子节点后约束内容宽度,保留完整的受支持文本簇;容器自身的 padding 与 `hidden` 在组合子节点后约束内容宽度,保留完整的受支持文本簇;容器自身的 padding 与
border 不在裁剪区域内。被裁掉的子文本同时移除其属性。固定 display space 可以缩短, border 不在裁剪区域内。被裁掉的子文本同时移除其属性。固定 display space 可以缩短,
@ -95,6 +96,43 @@ context/value 组合无效时会直接报错。百分比参照、字体单位、
[用户指南的几何章节](ebox-user-guide.zh.md#4-使用几何与绘制-property)。这是 CSS [用户指南的几何章节](ebox-user-guide.zh.md#4-使用几何与绘制-property)。这是 CSS
语义的子集,不是完整浏览器布局引擎。 语义的子集,不是完整浏览器布局引擎。
### 保留式图层放置
以下不继承的几何属性适用于 `box`、`row`、`column`、`flex` 和 `grid`Text
不接受这些属性。现有 author tag 不变。
| 属性 | 接受的值 | 默认值 |
| --- | --- | --- |
| `:position` | `static`、`relative`、`absolute` | `static` |
| `:left` | 有符号 inline 长度(`px`、`ch`、`vw`、`%`)或尺寸表达式 | `(px 0)` |
| `:top` | 有符号 block 长度(`lh`、`vh`、`%`)或尺寸表达式 | `(lh 0)` |
| `:z-index` | 整数,允许负值 | `0` |
| `:layer` | `local`、`root` | `local` |
| `:anchor` | 以 symbol 或字符串表示的语义 `:id`,或 `nil` | `nil` |
| `:placement` | `bottom-start`、`bottom-end`、`top-start`、`top-end` | `bottom-start` |
`static` 使用普通流。`relative` 保留流中占位,通过 `:left`、`:top` 平移绘制。
`absolute` 不贡献普通流尺寸或占位,以父容器内容原点为基准;`:layer root` 则使用
根内容画布。偏移使用绘制宿主的字体与包含尺寸;`:left` 的 `%` 参照宽度,`:top`
`%` 参照高度。长度解析保留小数,最终纵向偏移向下取整到宿主的完整文本行。
每个局部宿主把子节点合成为一个组。组内较小的 `:z-index` 先绘制;深度相同时,
普通内容先于定位内容绘制,定位兄弟按逻辑文档顺序绘制。嵌套子节点的深度不能
越过局部分组。Box 的内容、padding 和 border 区域均为不透明绘制,空白也会遮挡;
margin 不参与遮挡。绘制裁剪到宿主画布。
`:layer root` 只改变绘制目标保留逻辑父节点、selector、继承和 retained identity。
`:layer root` 与非 nil 的 `:anchor` 都要求 `:position absolute`。anchor 在绘制
宿主的逻辑子树中解析唯一的语义 ID并使用目标的可见边界。面板按指定的上下及
起止边放置对侧可容纳时纵向翻转然后平移到画布内。anchor 缺失或不可见时不
绘制面板ID 歧义或循环放置依赖在发布前报错。
这是不透明文本合成:横向用像素,纵向共享宿主的文本行网格。定位子树必须与宿主
使用相同的有效字体行高。裁剪保留完整的受支持文本簇和 display unit不完整字形或
图像以中性空白替代。不提供 alpha 混合、半字形遮罩或 child frame。native 表示
无法表达图层放置时使用 Elisp。示例和 retained 更新行为见
[图层指南](ebox-user-guide.zh.md#retained-layers)。
### 原生节点能力 ### 原生节点能力
`text`、`box`、`row`、`column`、`flex` 和 `grid` 都接受下面四个显式节点属性。 `text`、`box`、`row`、`column`、`flex` 和 `grid` 都接受下面四个显式节点属性。

View File

@ -171,6 +171,7 @@ mode. These axis restrictions deliberately narrow CSS's general length model.
| --- | --- | --- | | --- | --- | --- |
| Inline geometry | `px`, `ch`, `vw`, `%` | `width`, `min-width`, `max-width`; left/right and inline padding/margin; `column-gap`; Row `item-gap`; Grid column tracks. | | 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. | | Block geometry | `lh`, `vh`, `%` | `height`, `min-height`, `max-height`; top/bottom and block padding/margin; `row-gap`; Column `item-gap`; Grid row tracks. |
| Positioned offsets | Inline units for `left`; block units for `top` | Signed lengths and size expressions; `%` refers to the paint host's corresponding content axis. |
| 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. | | 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. | | 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. |
@ -280,6 +281,79 @@ Text Box geometry.
Use `:outer inline` or `:outer block` to state how a Box participates in its Use `:outer inline` or `:outer block` to state how a Box participates in its
parent. The child-layout algorithm still comes from the form name. parent. The child-layout algorithm still comes from the form name.
<a id="retained-layers"></a>
### Retained layers
Use positioning properties on existing Box forms to overlap content. Give the
host an explicit height when absolute children need a reserved canvas: they do
not increase its automatic flow size.
```elisp
(ebox-build
'(box :width (ch 12) :height (lh 3)
"ABCDEFGHIJKL\nabcdefghijkl\n0123456789ab"
(box :position absolute :left (ch 2) :top (lh 1)
:width (ch 5) :height (lh 1)
:background-color "#334155" :color "#FFFFFF"
"Panel")))
```
`:position relative` keeps the child's ordinary slot and moves only its paint.
`:position absolute` removes the child from normal, Row, Column, Flex, or Grid
flow and places it from the parent content origin. Signed `:left` uses inline
units; signed `:top` uses block units and is floored to whole host rows at final
placement. Both accept size expressions. A local host clips its composed output
to its canvas. Nested local groups are automatic: a child's high `:z-index`
cannot jump above a sibling of its host. Integer depths paint low to high;
normal content precedes positioned content at equal depth, and equal-depth
positioned boxes follow document order.
The upper box covers its content, padding, and border, including blanks, while
its margin leaves the lower content visible. Visible text retains its native
help, pointer, hover, and keymap properties; covered text contributes none of
those interactions. The retained lower node and its semantic ID still exist.
Updates to it become visible with their latest content and properties when
the covering box moves or becomes hidden.
Use a root layer to place a panel beyond a clipped local container while
keeping its logical parent and inherited styles:
```elisp
(ebox-build
'(box :width (ch 20) :height (lh 4)
(box :height (lh 1) :overflow hidden
(box :id "trigger" :width (ch 6) "Open")
(box :id "panel" :position absolute :layer root
:anchor "trigger" :placement bottom-start
:width (ch 8) :height (lh 2)
"Choice A\nChoice B"))))
```
`:layer root` selects the root content canvas as the paint target. It does not
reparent the panel for selectors, inheritance, or region updates. `:anchor`
uses a unique semantic ID in the paint host's logical subtree. Both properties
require absolute positioning. Placements are `bottom-start`, `bottom-end`,
`top-start`, and `top-end`; offsets adjust the requested origin. Panels flip
vertically if the opposite side fits, then shift within the host canvas.
Missing or invisible anchors do not paint a panel; ambiguous IDs and placement
dependency cycles are errors.
Composition uses horizontal pixels and one shared vertical text-row grid.
Positioned subtrees must have the same effective font line height as their
host. Whole supported clusters survive cuts; partially covered glyphs/images
are replaced with neutral space, and fixed display spaces can be shortened.
There is no alpha blending, half-glyph masking, or child frame. Layered trees
fall back to Elisp when native reflow cannot represent them.
Publication remains a TP transaction, including hidden-node updates and
rollback. Fixed-footprint local changes can recompose only the affected host;
geometry changes and root-layer dependencies may require broader work. No
per-update latency or universal local-update guarantee is implied. Run
`make layer-tests` for the focused contract. The separate `ebox-playground`
package provides `layer-minimal.ebox` and `layer-reference.ebox`; batch tests
do not establish GUI visual parity or remote CI success.
### Wrapping and the optional EKP dependency ### Wrapping and the optional EKP dependency
`:wrap-mode word` wraps ordinary words and permits character breaks for CJK; `:wrap-mode word` wraps ordinary words and permits character breaks for CJK;

View File

@ -152,6 +152,7 @@ Flex basis 和 Grid 固定轨道都使用这套格式。Ebox 实现 CSS 尺寸
| --- | --- | --- | | --- | --- | --- |
| Inline 几何 | `px`、`ch`、`vw`、`%` | `width`、`min-width`、`max-width`;左右和 inline padding/margin`column-gap`Row 的 `item-gap`Grid 列轨道。 | | 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 行轨道。 | | Block 几何 | `lh`、`vh`、`%` | `height`、`min-height`、`max-height`;上下和 block padding/margin`row-gap`Column 的 `item-gap`Grid 行轨道。 |
| 定位偏移 | `left` 使用 inline 单位;`top` 使用 block 单位 | 有符号长度及尺寸表达式;`%` 参照绘制宿主对应内容轴。 |
| Flex 主轴尺寸 | 所在父 Flex 主轴允许的单位 | `flex-basis``(grow shrink basis)` 中的 basisrow 方向用 inline 单位column 方向用 block 单位。 | | Flex 主轴尺寸 | 所在父 Flex 主轴允许的单位 | `flex-basis``(grow shrink basis)` 中的 basisrow 方向用 inline 单位column 方向用 block 单位。 |
| 边框绘制厚度 | `px`、`ch`、`lh`、`vw`、`vh` | `border-width`、各侧边框宽度、border 简写中的宽度;不接受百分比。 | | 边框绘制厚度 | `px`、`ch`、`lh`、`vw`、`vh` | `border-width`、各侧边框宽度、border 简写中的宽度;不接受百分比。 |
@ -242,6 +243,65 @@ Text 的样式声明只接受字体、前景/背景和文本装饰 property
`:outer inline``:outer block` 表达 Box 如何参与父布局。子布局算法仍由 form `:outer inline``:outer block` 表达 Box 如何参与父布局。子布局算法仍由 form
名称决定。 名称决定。
<a id="retained-layers"></a>
### 保留式图层
在现有 Box form 上设置定位属性即可重叠内容。绝对定位子节点不增加普通流的自动
尺寸,需要预留画布时,为宿主声明明确的高度。
```elisp
(ebox-build
'(box :width (ch 12) :height (lh 3)
"ABCDEFGHIJKL\nabcdefghijkl\n0123456789ab"
(box :position absolute :left (ch 2) :top (lh 1)
:width (ch 5) :height (lh 1)
:background-color "#334155" :color "#FFFFFF"
"Panel")))
```
`:position relative` 保留普通流中的占位,只移动绘制。`:position absolute`
从 normal、Row、Column、Flex 或 Grid 流中移除子节点,并从父内容原点放置。
有符号 `:left` 使用 inline 单位;有符号 `:top` 使用 block 单位,最终放置时
向下取整到宿主的完整行。两者都接受尺寸表达式。局部宿主把合成输出裁剪到自身
画布。嵌套局部分组自动建立:子节点再高的 `:z-index` 也无法越过宿主的兄弟。
整数深度从低到高绘制;同深度的普通内容先于定位内容,定位节点按文档顺序绘制。
上层 Box 的内容、padding 和 border 都遮挡下层空白也不例外margin 则保留
下层可见性。可见文本保留原生 help、pointer、hover 和 keymap 属性,被覆盖的
文本不贡献这些交互。下层 retained 节点及语义 ID 仍然存在;遮挡期间更新后,
上层移动或隐藏时会显示下层最新的内容与属性。
使用根图层可把面板绘制到局部裁剪容器之外,同时保留逻辑父节点和继承样式:
```elisp
(ebox-build
'(box :width (ch 20) :height (lh 4)
(box :height (lh 1) :overflow hidden
(box :id "trigger" :width (ch 6) "Open")
(box :id "panel" :position absolute :layer root
:anchor "trigger" :placement bottom-start
:width (ch 8) :height (lh 2)
"Choice A\nChoice B"))))
```
`:layer root` 选择根内容画布作为绘制目标,不改变 selector、继承或 region 更新
所依据的父子关系。`:anchor` 使用绘制宿主逻辑子树中的唯一语义 ID两者都要求
绝对定位。placement 支持 `bottom-start`、`bottom-end`、`top-start`、`top-end`
偏移调整请求的原点。对侧能容纳时面板纵向翻转再平移到宿主画布内。anchor
缺失或不可见时不绘制面板ID 歧义及放置依赖循环会报错。
合成横向使用像素,纵向共享一套文本行网格。定位子树的有效字体行高必须与宿主
相同。裁剪保留完整的受支持文本簇,不完整字形或图像以中性空白替代,固定 display
space 可以缩短。不提供 alpha 混合、半字形遮罩或 child frame。native reflow
无法表示图层时回退到 Elisp。
包括隐藏节点更新和 rollback 在内的发布仍通过 TP transaction。固定外部尺寸的
局部修改可以只重新合成受影响的宿主;几何变化或根图层依赖可能需要扩大范围。
不保证每次更新的耗时或所有更新都保持局部。用 `make layer-tests` 运行聚焦契约。
独立的 `ebox-playground` 包提供 `layer-minimal.ebox`、`layer-reference.ebox`
batch 测试不证明 GUI 视觉一致性或远程 CI 成功。
### 换行与可选 EKP 依赖 ### 换行与可选 EKP 依赖
`:wrap-mode word` 按普通单词换行,并允许 CJK 字符换行;`char` 按 Ebox 支持的常见 `:wrap-mode word` 按普通单词换行,并允许 CJK 字符换行;`char` 按 Ebox 支持的常见

216
ebox-composite.el Normal file
View File

@ -0,0 +1,216 @@
;;; ebox-composite.el --- Pixel interval row composition -*- lexical-binding: t; -*-
;;; Commentary:
;; Compose opaque, already rendered rows without changing their source strings.
;; Resolve coverage first so every visible interval is cut from its original
;; source, independent of the number or ordering of hidden lower placements.
;;; Code:
(require 'cl-lib)
(require 'ebox-measure)
(declare-function ebox--grapheme-cluster-end "ebox-layout" (string start))
(defun ebox-composite--line-p (line)
"Return non-nil if LINE is a single-line string or nil."
(or (null line)
(and (stringp line) (not (string-match-p "\n" line)))))
(defun ebox-composite--replacement-p (display)
"Return non-nil when DISPLAY replaces an entire EQ property run."
(or (stringp display) (memq (car-safe display) '(space image))))
(defun ebox-composite--unit-end (line position)
"Return the complete display unit end in LINE at POSITION."
(let ((end (ebox--grapheme-cluster-end line position)))
(if (ebox-composite--replacement-p
(get-text-property position 'display line))
(max end (next-single-property-change
position 'display line (length line)))
end)))
(defun ebox-composite--space (line start end width)
"Copy LINE's fixed space at START through END using WIDTH pixels.
Retain the source properties and nested display values by identity."
(let* ((result (substring line start end))
(display (get-text-property start 'display line))
(properties (copy-sequence (cdr display))))
(put-text-property
0 (length result) 'display
(cons 'space (plist-put properties :width (list width))) result)
result))
(defun ebox-composite--join (parts)
"Join PARTS while retaining their separately measured pixel extents.
A neutral zero-pixel display boundary prevents adjacent EQ replacement runs
from collapsing or independently shaped source fragments from reshaping."
(let ((result "") (width 0))
(dolist (part parts result)
(unless (equal part "")
(let* ((next-width (+ width (ebox--string-pixel-width part)))
(joined (concat result part)))
(setq result
(if (= (ebox--string-pixel-width joined) next-width)
joined
(concat result
(propertize " " 'display '(space :width (0)))
part))
width next-width))))))
(defun ebox-composite--fit (line start end left right)
"Fit LINE from START through END in its original [LEFT, RIGHT) pixels.
Removing a left shaping context can change a retained unit's width. Resolve
that unit within its original pixel interval, then retry the remaining whole
run. A newly wider glyph becomes neutral fill rather than shifting the next
source glyph or borrowing its pixels."
(let ((position start) (pixel left) parts)
(while (< position end)
(let* ((remaining (substring line position end))
(width (ebox--string-pixel-width remaining)))
(if (= width (- right pixel))
(progn (push remaining parts) (setq position end))
(let* ((next (ebox-composite--unit-end line position))
(next-pixel (ebox--substring-pixel-width line 0 next))
(unit (substring line position next))
(unit-width (ebox--string-pixel-width unit))
(available (- next-pixel pixel)))
(cond
((<= unit-width available)
(push unit parts)
(push (ebox-pixel-space (- available unit-width)) parts))
((ebox--display-space-width
(get-text-property position 'display line))
(push (ebox-composite--space line position next available)
parts))
(t (push (ebox-pixel-space available) parts)))
(setq position next pixel next-pixel)))))
(ebox-composite--join (nreverse parts))))
(defun ebox-composite-slice (line start end)
"Extract LINE's pixel interval [START, END), exactly END minus START wide.
START and END must be finite nonnegative numbers with START <= END. LINE is
a single-line string or nil. Pixels beyond LINE are neutral blank space.
Keep complete supported graphemes and replacement display runs, preserving
their text property values by identity. Fixed pixel spaces can be cut at
either edge; other partial glyphs become neutral blanks at their original
positions. Measure original prefixes so adjacent shaping is not summed as
isolated characters. Neither LINE nor its property values are modified."
(unless (ebox-composite--line-p line)
(error "Ebox composite needs a single-line string: %S" line))
(unless (and (ebox-size--finite-number-p start) (>= start 0)
(ebox-size--finite-number-p end) (>= end start))
(error "Invalid Ebox composite pixel interval: %S %S" start end))
(let* ((line (or line ""))
(line-width (ebox--string-pixel-width line)))
(cond
((= start end) "")
((and (= start 0) (<= line-width end))
(ebox-composite--join
(list line (ebox-pixel-space (- end line-width)))))
((>= start line-width) (ebox-pixel-space (- end start)))
(t
(let ((position 0) (pixel 0) parts
run-start run-end run-left run-right)
(cl-labels
((flush ()
(when run-start
(push (ebox-composite--fit
line run-start run-end run-left run-right)
parts)
(setq run-start nil))))
(while (and (< position (length line)) (< pixel end))
(let* ((next (ebox-composite--unit-end line position))
(next-pixel (ebox--substring-pixel-width line 0 next))
(left (max start pixel))
(right (min end next-pixel)))
(cond
((and (>= pixel start) (<= next-pixel end))
(unless run-start
(setq run-start position run-left pixel))
(setq run-end next run-right next-pixel))
((< left right)
(flush)
(push (if (ebox--display-space-width
(get-text-property position 'display line))
(ebox-composite--space
line position next (- right left))
(ebox-pixel-space (- right left)))
parts)))
(setq position next pixel next-pixel)))
(flush))
(when (> end (max start pixel))
(push (ebox-pixel-space (- end (max start pixel))) parts))
(ebox-composite--join (nreverse parts)))))))
(defun ebox-composite--uncovered (start end covered)
"Return portions of [START, END) outside sorted merged COVERED intervals."
(let ((cursor start) result)
(dolist (interval covered)
(when (and (< cursor end) (> (cdr interval) cursor))
(when (> (car interval) cursor)
(push (cons cursor (min end (car interval))) result))
(setq cursor (max cursor (cdr interval)))))
(when (< cursor end) (push (cons cursor end) result))
(nreverse result)))
(defun ebox-composite--cover (start end covered)
"Return sorted merged COVERED intervals including [START, END).
Do not modify the input interval list or its cons cells."
(let (result)
(dolist (interval covered)
(cond
((< (cdr interval) start) (push interval result))
((> (car interval) end)
(push (cons start end) result)
(setq start (car interval) end (cdr interval)))
(t (setq start (min start (car interval))
end (max end (cdr interval))))))
(push (cons start end) result)
(nreverse result)))
(defun ebox-composite-line (base placements width)
"Compose BASE and opaque PLACEMENTS on a fixed WIDTH-pixel canvas.
BASE is a single-line string or nil. PLACEMENTS is a proper list of plists
with a single-line string :text and finite pixel :x, already ordered bottom
to top. Each placement covers the full measured width of its text, including
blanks. Negative and overflowing placements are clipped to the canvas.
WIDTH must be finite and nonnegative. Uncovered pixels beyond BASE receive
neutral fill. Compute coverage before extracting original source intervals,
so hidden placements cannot introduce cuts into a visible source glyph.
Do not modify BASE, PLACEMENTS, or any of their text property values."
(unless (and (ebox-composite--line-p base)
(proper-list-p placements)
(ebox-size--finite-number-p width) (>= width 0))
(error "Invalid Ebox composite canvas"))
(let (sources covered visible)
(dolist (placement placements)
(let ((text (plist-get placement :text))
(x (plist-get placement :x)))
(unless (and (stringp text) (ebox-composite--line-p text)
(ebox-size--finite-number-p x))
(error "Invalid Ebox composite placement: %S" placement))
(let* ((right (+ x (ebox--string-pixel-width text)))
(left (max 0 x))
(end (min width right)))
(unless (ebox-size--finite-number-p right)
(error "Ebox composite placement extent is not finite"))
(when (< left end)
(push (list left end text x) sources)))))
;; Sources were pushed from bottom to top. Only original source metadata
;; participates in occlusion; no sliced text is ever sliced again.
(dolist (source (append sources (list (list 0 width base 0))))
(pcase-let ((`(,start ,end ,text ,x) source))
(dolist (interval (ebox-composite--uncovered start end covered))
(push (list (car interval) (cdr interval) text x) visible))
(when (< start end)
(setq covered (ebox-composite--cover start end covered)))))
(ebox-composite--join
(mapcar
(lambda (interval)
(pcase-let ((`(,start ,end ,text ,x) interval))
(ebox-composite-slice text (- start x) (- end x))))
(sort visible (lambda (a b) (< (car a) (car b))))))))
(provide 'ebox-composite)
;;; ebox-composite.el ends here

View File

@ -101,6 +101,9 @@
"ebox-native-commit" (old-state candidate-state prepared)) "ebox-native-commit" (old-state candidate-state prepared))
(declare-function ebox-native-reflow-release-session (declare-function ebox-native-reflow-release-session
"ebox-native-reflow" (session)) "ebox-native-reflow" (session))
(declare-function ebox-layer-host-p "ebox-layer" (node))
(declare-function ebox-layer-subtree-p "ebox-layer" (node))
(declare-function ebox-layer-portals-p "ebox-layer" (node))
(cl-defstruct (cl-defstruct
(ebox-incremental-context-port (ebox-incremental-context-port
@ -182,6 +185,9 @@
(defvar ebox--render-cache-table nil (defvar ebox--render-cache-table nil
"Dynamic buffer-local render cache used during incremental rerenders.") "Dynamic buffer-local render cache used during incremental rerenders.")
(defvar ebox-incremental--render-portals-p 'unknown
"Whether the current candidate can produce root portal side effects.")
(defvar ebox--prepared-root-render nil (defvar ebox--prepared-root-render nil
"Dynamically bound validated root render for one visible transaction.") "Dynamically bound validated root render for one visible transaction.")
@ -1063,7 +1069,12 @@ EXTERNAL-SIGNATURE may prove a viewport-dependent non-root context."
When FORCE is non-nil, cache NODE even when its output is viewport-dependent; When FORCE is non-nil, cache NODE even when its output is viewport-dependent;
the complete viewport context remains part of the render signature. the complete viewport context remains part of the render signature.
CACHE-PROBE may supply a prior exact miss from an accelerator boundary." CACHE-PROBE may supply a prior exact miss from an accelerator boundary."
(if-let* ((prepared (and force (if (and ebox-incremental--render-portals-p
(fboundp 'ebox-layer-portals-p)
(ebox-layer-portals-p node))
;; Cached strings do not replay the candidate-local portal collector.
(ebox--render-node node)
(if-let* ((prepared (and force
(ebox--take-prepared-root-render node)))) (ebox--take-prepared-root-render node))))
(prog1 prepared (prog1 prepared
(ebox--replay-render-output-provenance prepared) (ebox--replay-render-output-provenance prepared)
@ -1102,7 +1113,7 @@ CACHE-PROBE may supply a prior exact miss from an accelerator boundary."
portable-p))) portable-p)))
(ebox--render-cache-store-context probe rendered metadata) (ebox--render-cache-store-context probe rendered metadata)
(ebox--record-render-output-provenance rendered)) (ebox--record-render-output-provenance rendered))
rendered)))))) rendered)))))))
;;; Buffer Runtime State ;;; Buffer Runtime State
@ -8606,6 +8617,100 @@ an owner for one descendant after its narrower projection proof has failed."
(ebox-incremental--declarative-tentative-patch-set (ebox-incremental--declarative-tentative-patch-set
buffer render-dirty-set))))) buffer render-dirty-set)))))
(defun ebox-incremental--layer-owner-plan
(buffer old-state candidate-state dirty-set)
"Promote DIRTY-SET to complete compositing owners when layers are affected.
BUFFER owns OLD-STATE; CANDIDATE-STATE supplies the next logical tree.
Ordinary mounted trees use their retained negative layer flag. Introducing
layers only inspects changed declarations or replacement subtrees. Nested
hosts promote through enclosing hosts because their upper siblings may hide
the inner host's output."
(when (and (fboundp 'ebox-layer-host-p)
(or (plist-get old-state :layered-p)
(cl-some
(lambda (dirty)
(cl-intersection (plist-get dirty :changed-keys)
'(:position :layer :children)))
dirty-set)))
(let ((old-nodes (plist-get old-state :node-table))
(nodes (plist-get candidate-state :node-table))
(parents (plist-get candidate-state :parent-table))
(ebox-incremental--buffer-render-state-override
(cons buffer candidate-state))
affected operations)
(dolist (dirty dirty-set)
(unless (eq (plist-get dirty :dirty-kind) 'metadata)
(let ((node-id (plist-get dirty :node-id)) owner)
(while node-id
(when (or (ebox-layer-host-p
(ebox-runtime-index-get node-id nodes))
(ebox-layer-host-p
(ebox-runtime-index-get node-id old-nodes)))
(setq owner node-id))
(setq node-id (ebox-runtime-index-get node-id parents)))
;; Paint or layout on an ordinary ancestor of a layer also
;; requires fresh composition, rather than a blind paint patch.
(unless owner
(let ((node (ebox-runtime-index-get
(plist-get dirty :node-id) nodes)))
(when (and node (ebox-layer-subtree-p node))
(setq owner (plist-get dirty :node-id)))))
(push (if owner
(progn
(setq affected t)
(ebox--patch-op 'owner-rerender owner :dirty dirty
:layer-recompose-p t))
(ebox-incremental--declarative-tentative-op buffer dirty))
operations))))
;; A root portal can cover or anchor to nodes outside its local host.
;; Their updates therefore belong to the root composition as well.
(when (or affected
(and operations (plist-get old-state :root-portals-p)))
(if (or (plist-get old-state :root-portals-p)
(ebox-layer-portals-p (plist-get candidate-state :root-node)))
(list (ebox--patch-op
'owner-rerender
(plist-get (plist-get candidate-state :root-node) :node-id)
:dirty (ebox--merge-dirty-provenance-list dirty-set)
:layer-recompose-p t))
(ebox-patch-plan-merge-ops parents (nreverse operations)))))))
(defun ebox-incremental--layer-span-proof
(buffer old-state prepared candidate-state owner-plan path-copied-p)
"Prove the stable outer allocation of one recomposed layer owner.
Compare BUFFER's OLD-STATE with CANDIDATE-STATE using PREPARED dirty facts
and OWNER-PLAN. PATH-COPIED-P requires private nodes inside that owner.
Internal visibility and role topology may change. The surface validates the
complete replacement footprint before publishing any resulting spans."
(when (and (= (length owner-plan) 1)
(plist-get (car owner-plan) :layer-recompose-p))
(let* ((owner-id (plist-get (car owner-plan) :owner-id))
(node (ebox-runtime-index-get
owner-id (plist-get candidate-state :node-table)))
(snapshot (ebox-incremental--cached-layout-snapshot-details
buffer owner-id))
(spans (plist-get snapshot :buffer-spans))
(ancestors (ebox-incremental--ancestor-slot-signature
buffer old-state candidate-state owner-id)))
(when (and node snapshot spans ancestors
(ebox-incremental--same-root-surface-identity-p
old-state candidate-state)
(ebox-incremental--span-patch-identity-stable-p
buffer old-state candidate-state prepared owner-id)
(or (not path-copied-p)
(not (ebox-incremental--candidate-shares-published-node-p
old-state node)))
(not (ebox--node-visible-overflow-p node))
(not (ebox--ancestor-own-overflow-visible-p buffer owner-id))
(with-current-buffer buffer
(or (ebox-buffer--partial-line-slots-p spans)
(ebox--spans-contiguous-lines-p spans))))
(list :owner-id owner-id :snapshot snapshot
:dirty-set (plist-get prepared :dirty-set)
:layer-recompose-p t :ancestor-slot-signature ancestors
:layout-snapshot-detail-generation
(plist-get old-state :layout-snapshot-detail-generation))))))
(defun ebox-incremental--cascade-root-owner-plan-p (defun ebox-incremental--cascade-root-owner-plan-p
(old-state candidate-state dirty-set) (old-state candidate-state dirty-set)
"Return non-nil when cascade makes a local proof unusable. "Return non-nil when cascade makes a local proof unusable.
@ -10066,7 +10171,10 @@ role, and outside-complement compatibility before publication."
"Return one atomic single- or multi-owner projection proof, or nil." "Return one atomic single- or multi-owner projection proof, or nil."
(if (= (length owner-plan) 1) (if (= (length owner-plan) 1)
(when-let* ((proof (when-let* ((proof
(or (ebox-incremental--single-span-patch-projection-proof (or (ebox-incremental--layer-span-proof
buffer old-state prepared candidate-state owner-plan
path-copied-p)
(ebox-incremental--single-span-patch-projection-proof
buffer old-state prepared candidate-state owner-plan buffer old-state prepared candidate-state owner-plan
path-copied-p) path-copied-p)
(ebox-incremental--grouped-owner-span-proof (ebox-incremental--grouped-owner-span-proof
@ -10135,7 +10243,8 @@ role, and outside-complement compatibility before publication."
;; new line back into that retained slot before TP sees it; ;; new line back into that retained slot before TP sees it;
;; raw string length is not the geometry proof. ;; raw string length is not the geometry proof.
(cl-every (lambda (proof) (cl-every (lambda (proof)
(or (plist-get proof :variable-content-p) (or (plist-get proof :layer-recompose-p)
(plist-get proof :variable-content-p)
(let* ((dirty (car (plist-get proof :dirty-set))) (let* ((dirty (car (plist-get proof :dirty-set)))
(old-content (old-content
(plist-get (plist-get
@ -10239,6 +10348,11 @@ role, and outside-complement compatibility before publication."
(append (append
(list :viewport-width (plist-get old-state :viewport-width) (list :viewport-width (plist-get old-state :viewport-width)
:viewport-height (plist-get old-state :viewport-height) :viewport-height (plist-get old-state :viewport-height)
:layer-recompose-p
(and (fboundp 'ebox-layer-host-p)
(cl-some (lambda (proof)
(plist-get proof :layer-recompose-p))
(ebox-incremental--owner-proof-list span-proof)))
:runtime-revision :runtime-revision
(plist-get candidate-state :runtime-revision) (plist-get candidate-state :runtime-revision)
:display-signature (plist-get prepared :display-signature) :display-signature (plist-get prepared :display-signature)
@ -10486,8 +10600,11 @@ role, and outside-complement compatibility before publication."
'structure)) 'structure))
(plist-get entry :range-delta))) (plist-get entry :range-delta)))
(plist-get prepared :dirty-set)))))) (plist-get prepared :dirty-set))))))
(layer-owner-plan
(ebox-incremental--layer-owner-plan
buffer old-state candidate-state (plist-get prepared :dirty-set)))
(formatting-proof (formatting-proof
(and (not native-program-p) (and (not layer-owner-plan) (not native-program-p)
(not (cl-some (not (cl-some
(lambda (entry) (lambda (entry)
(eq (plist-get entry :dirty-kind) 'paint)) (eq (plist-get entry :dirty-kind) 'paint))
@ -10495,7 +10612,7 @@ role, and outside-complement compatibility before publication."
(ebox-incremental--formatting-context-reflow-proof (ebox-incremental--formatting-context-reflow-proof
buffer old-state candidate-state prepared))) buffer old-state candidate-state prepared)))
(mixed-shape-p (mixed-shape-p
(and (not native-program-p) (and (not layer-owner-plan) (not native-program-p)
(or (not structural-dirty-p) range-structural-p) (or (not structural-dirty-p) range-structural-p)
(cl-some (cl-some
(lambda (entry) (lambda (entry)
@ -10532,6 +10649,7 @@ role, and outside-complement compatibility before publication."
(plist-get mixed-proof :paint-owner-ids))))) (plist-get mixed-proof :paint-owner-ids)))))
(owner-plan (owner-plan
(cond (cond
(layer-owner-plan layer-owner-plan)
((/= (ebox-tree-author-style-count ((/= (ebox-tree-author-style-count
(plist-get old-state :root-node)) (plist-get old-state :root-node))
(ebox-tree-author-style-count (ebox-tree-author-style-count
@ -10577,7 +10695,9 @@ role, and outside-complement compatibility before publication."
(ebox-incremental--paint-projection-p (ebox-incremental--paint-projection-p
buffer old-state prepared candidate-state owner-plan))) buffer old-state prepared candidate-state owner-plan)))
(span-proof (span-proof
(unless (or native-program-p structural-dirty-p paint-p mixed-proof) (unless (or native-program-p
(and structural-dirty-p (not layer-owner-plan))
paint-p mixed-proof)
(ebox-incremental--span-patch-projection-proof (ebox-incremental--span-patch-projection-proof
buffer old-state prepared candidate-state owner-plan))) buffer old-state prepared candidate-state owner-plan)))
(projection-kind (projection-kind
@ -10593,6 +10713,8 @@ role, and outside-complement compatibility before publication."
(span-proof 'span-patch)))) (span-proof 'span-patch))))
(when projection-kind (when projection-kind
(setq report (plist-put report :projection-kind projection-kind))) (setq report (plist-put report :projection-kind projection-kind)))
(when layer-owner-plan
(setq report (plist-put report :layer-recompose-p t)))
(when mixed-proof (when mixed-proof
(setq prepared (plist-put prepared :mixed-owner-proof mixed-proof)) (setq prepared (plist-put prepared :mixed-owner-proof mixed-proof))
(setq report (setq report
@ -10722,8 +10844,11 @@ the visible replacement independently before publishing either one."
(defun ebox-incremental--prepare-scoped-candidate (defun ebox-incremental--prepare-scoped-candidate
(buffer old-state next-root isolated-candidate-p (buffer old-state next-root isolated-candidate-p
&optional skip-span-proof-p scroll-transaction-p) &optional skip-span-proof-p scroll-transaction-p scroll-owner-id)
"Prepare NEXT-ROOT and its scoped owner plan for BUFFER. "Prepare NEXT-ROOT and its scoped owner plan for BUFFER.
OLD-STATE provides the published inputs. ISOLATED-CANDIDATE-P marks a private
logical tree. SKIP-SPAN-PROOF-P permits the retained scroll fast path;
SCROLL-TRANSACTION-P and SCROLL-OWNER-ID identify the changing scroll owner.
Return the intermediate candidate state, owner plan, projection proof, and Return the intermediate candidate state, owner plan, projection proof, and
whether NEXT-ROOT shares untouched published nodes." whether NEXT-ROOT shares untouched published nodes."
(let* ((ebox-incremental--allocated-slot-proof-cache (let* ((ebox-incremental--allocated-slot-proof-cache
@ -10783,8 +10908,16 @@ whether NEXT-ROOT shares untouched published nodes."
(ebox-source--index-derived-stale-p (ebox-source--index-derived-stale-p
(plist-get candidate-state :source-index))) (plist-get candidate-state :source-index)))
(plist-put candidate-state :selector-index-stale-p t))) (plist-put candidate-state :selector-index-stale-p t)))
(layer-owner-plan
(ebox-incremental--layer-owner-plan
buffer old-state candidate-state
(or (plist-get prepared :dirty-set)
(and scroll-transaction-p scroll-owner-id
(list (list :node-id scroll-owner-id
:dirty-kind 'geometry
:changed-keys '(:scroll-offset)))))))
(formatting-proof (formatting-proof
(and (not scroll-transaction-p) (and (not layer-owner-plan) (not scroll-transaction-p)
(not (cl-some (not (cl-some
(lambda (entry) (lambda (entry)
(eq (plist-get entry :dirty-kind) 'paint)) (eq (plist-get entry :dirty-kind) 'paint))
@ -10792,7 +10925,8 @@ whether NEXT-ROOT shares untouched published nodes."
(ebox-incremental--formatting-context-reflow-proof (ebox-incremental--formatting-context-reflow-proof
buffer old-state candidate-state prepared))) buffer old-state candidate-state prepared)))
(owner-plan (owner-plan
(if formatting-proof (if layer-owner-plan layer-owner-plan
(if formatting-proof
(list (list
(ebox--patch-op (ebox--patch-op
'formatting-context-reflow 'formatting-context-reflow
@ -10804,9 +10938,9 @@ whether NEXT-ROOT shares untouched published nodes."
candidate-state)) candidate-state))
(ebox-incremental--layout-owner-plan (ebox-incremental--layout-owner-plan
buffer old-state candidate-state buffer old-state candidate-state
(plist-get prepared :dirty-set)))))) (plist-get prepared :dirty-set)))))))
(mixed-proof (mixed-proof
(and (not scroll-transaction-p) (and (not layer-owner-plan) (not scroll-transaction-p)
(ebox-incremental--mixed-owner-proof (ebox-incremental--mixed-owner-proof
buffer old-state candidate-state prepared owner-plan))) buffer old-state candidate-state prepared owner-plan)))
(scope-node-ids (scope-node-ids
@ -10818,7 +10952,8 @@ whether NEXT-ROOT shares untouched published nodes."
(ebox-incremental--paint-projection-p (ebox-incremental--paint-projection-p
buffer old-state prepared candidate-state owner-plan)) buffer old-state prepared candidate-state owner-plan))
(span-proof (span-proof
(unless (or paint-p mixed-proof skip-span-proof-p) (unless (or paint-p mixed-proof
(and skip-span-proof-p (not layer-owner-plan)))
(or (ebox-incremental--span-patch-projection-proof (or (ebox-incremental--span-patch-projection-proof
buffer old-state prepared candidate-state owner-plan buffer old-state prepared candidate-state owner-plan
path-copied-p) path-copied-p)
@ -10839,7 +10974,7 @@ whether NEXT-ROOT shares untouched published nodes."
scope-node-ids (mapcar (lambda (op) (plist-get op :owner-id)) scope-node-ids (mapcar (lambda (op) (plist-get op :owner-id))
owner-plan))) owner-plan)))
(setq prepared (plist-put prepared :scroll-patch-fast-p (setq prepared (plist-put prepared :scroll-patch-fast-p
skip-span-proof-p)) (and skip-span-proof-p (not layer-owner-plan))))
(when mixed-proof (when mixed-proof
(setq prepared (plist-put prepared :mixed-owner-proof mixed-proof))) (setq prepared (plist-put prepared :mixed-owner-proof mixed-proof)))
(list :prepared prepared (list :prepared prepared
@ -10849,6 +10984,7 @@ whether NEXT-ROOT shares untouched published nodes."
:span-proof span-proof :span-proof span-proof
:mixed-proof mixed-proof :mixed-proof mixed-proof
:formatting-proof formatting-proof :formatting-proof formatting-proof
:layer-recompose-p (and layer-owner-plan t)
:projection-kind projection-kind :projection-kind projection-kind
:path-copied-p path-copied-p))) :path-copied-p path-copied-p)))
@ -10870,13 +11006,15 @@ runtime candidate whose identities already match the published root."
(let* ((candidate (let* ((candidate
(ebox-incremental--prepare-scoped-candidate (ebox-incremental--prepare-scoped-candidate
buffer old-state next-root isolated-candidate-p buffer old-state next-root isolated-candidate-p
scroll-patch-fast-p scroll-transaction-p))) scroll-patch-fast-p scroll-transaction-p
(plist-get report-overrides :owner-id))))
;; A path-copied candidate is safe to render in place only after the ;; A path-copied candidate is safe to render in place only after the
;; strict span proof succeeds. Widened projection would mutate shared ;; strict span proof succeeds. Widened projection would mutate shared
;; layout nodes, so promote it to a private copy before the surface sees ;; layout nodes, so promote it to a private copy before the surface sees
;; it. The ordinary full-copy path remains the correctness fallback. ;; it. The ordinary full-copy path remains the correctness fallback.
(when (and (plist-get candidate :path-copied-p) (when (and (plist-get candidate :path-copied-p)
(not scroll-patch-fast-p) (or (not scroll-patch-fast-p)
(plist-get candidate :layer-recompose-p))
(not (memq (plist-get candidate :projection-kind) (not (memq (plist-get candidate :projection-kind)
'(span-patch owner-scoped scroll-patch '(span-patch owner-scoped scroll-patch
mixed-owner-reflow)))) mixed-owner-reflow))))
@ -10886,8 +11024,11 @@ runtime candidate whose identities already match the published root."
(ebox-tree-copy-node-structure (ebox-tree-copy-node-structure
(plist-get (plist-get candidate :prepared) :root)) (plist-get (plist-get candidate :prepared) :root))
isolated-candidate-p scroll-patch-fast-p isolated-candidate-p scroll-patch-fast-p
scroll-transaction-p))) scroll-transaction-p (plist-get report-overrides :owner-id))))
(let* ((prepared (plist-get candidate :prepared)) (let* ((prepared (plist-get candidate :prepared))
(scroll-transaction-p
(and scroll-transaction-p
(not (plist-get candidate :layer-recompose-p))))
(candidate-state (plist-get candidate :candidate-state)) (candidate-state (plist-get candidate :candidate-state))
(owner-plan (owner-plan
(or (plist-get candidate :owner-plan) (or (plist-get candidate :owner-plan)
@ -10928,6 +11069,8 @@ runtime candidate whose identities already match the published root."
(setq report (plist-put report :projection-kind projection-kind))) (setq report (plist-put report :projection-kind projection-kind)))
(when projection-kind (when projection-kind
(setq report (plist-put report :projection-kind projection-kind))) (setq report (plist-put report :projection-kind projection-kind)))
(when (plist-get candidate :layer-recompose-p)
(setq report (plist-put report :layer-recompose-p t)))
(when span-proof (when span-proof
(setq report (setq report
(plist-put report :ancestor-slot-count (plist-put report :ancestor-slot-count

358
ebox-layer.el Normal file
View File

@ -0,0 +1,358 @@
;;; ebox-layer.el --- Retained box placement and text composition -*- lexical-binding: t; -*-
;;; Commentary:
;; Compose visible pieces without changing the logical tree. Horizontal
;; coordinates are pixels; vertical coordinates share the host's text rows.
;; This module never writes a buffer or owns application state.
;;; Code:
(require 'cl-lib)
(require 'ebox-tree)
(require 'ebox-composite)
(declare-function ebox-get "ebox" (box property))
(declare-function ebox--ensure-region-id "ebox" (box))
(declare-function ebox--substring-pixel-width "ebox-measure" (string start end))
(declare-function ebox--string-pixel-width "ebox-measure" (string))
(declare-function ebox-string-lines "ebox" (string))
(declare-function ebox-lines-join "ebox" (lines))
(declare-function ebox--render-with-cache "ebox" (node &optional width probe))
(declare-function ebox--call-with-box-content-context "ebox-layout" (box function))
(declare-function ebox--wrapper-content-viewport-pixel "ebox-layout" (box))
(declare-function ebox--content-height "ebox-layout" (box height &optional rendered))
(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--record-rendered-uniform-width "ebox-layout" (rendered width))
(declare-function ebox--record-rendered-intrinsic-size "ebox-layout" (rendered width height))
(declare-function ebox-pixel-space "ebox" (pixels))
(defvar ebox-region-types)
(defvar ebox--intrinsic-layout-measurement)
(defvar ebox--render-source-index)
(defvar ebox-layer--render-root nil
"Root of this spatial materialization, never a live buffer root.")
(defvar ebox-layer--portals nil
"Candidate-local absolute nodes awaiting root composition.")
(defvar ebox-layer--active-p 'unknown
"Whether the current candidate contains layered layout.
Retained publication supplies a known boolean; isolated rendering resolves
the fact once for its spatial root.")
(defun ebox-layer-active-p (&optional node)
"Return the current candidate's layer capability without repeated scans."
(if (eq ebox-layer--active-p 'unknown)
(if ebox-layer--render-root
(setq ebox-layer--active-p
(ebox-layer-subtree-p ebox-layer--render-root))
(ebox-layer-subtree-p node))
ebox-layer--active-p))
(defun ebox-layer-positioned-p (node)
"Return non-nil when NODE opts into positioned box painting."
(memq (plist-get node :position) '(relative absolute)))
(defun ebox-layer-host-p (node)
"Return non-nil when NODE composes positioned direct children."
(and (eq (plist-get node :ebox-kind) 'box)
(cl-some #'ebox-layer-positioned-p
(ebox-tree-layout-children node))))
(defun ebox-layer-subtree-p (node)
"Return non-nil when NODE's logical subtree contains positioned painting."
(and (listp node)
(or (ebox-layer-host-p node)
(ebox-layer-positioned-p node)
(cl-some #'ebox-layer-subtree-p (ebox-tree-node-children node)))))
(defun ebox-layer-portals-p (node)
"Return non-nil when NODE's subtree projects content to its render root."
(and (listp node)
(or (eq (plist-get node :layer) 'root)
(cl-some #'ebox-layer-portals-p (ebox-tree-node-children node)))))
(defun ebox-layer--owned-p (line position region)
"Whether LINE at POSITION visibly belongs to REGION, excluding margins."
(and (not (cl-some (lambda (role)
(equal region (get-text-property position role line)))
'(ebox-ml ebox-mr ebox-mt ebox-mb)))
(or (member region (get-text-property position 'ebox-content-owners line))
(equal region (get-text-property position 'ebox-content-owner line))
(cl-some (lambda (role)
(equal region (get-text-property position (cdr role) line)))
ebox-region-types))))
(defun ebox-layer--pieces (lines node)
"Return NODE's visible source intervals in LINES, before positioning."
(let ((region (ebox--ensure-region-id node)) pieces)
(cl-loop for line in lines for row from 0 do
(let ((start 0) (limit (length line)))
(while (< start limit)
(let ((end (next-property-change start line limit)))
(when (ebox-layer--owned-p line start region)
(let ((x (ebox--substring-pixel-width line 0 start))
(right (ebox--substring-pixel-width line 0 end)))
(when (> right x)
;; Keep the full row as source so shaping and shared
;; display objects are measured in their own context.
(push (list :line line :start x :end right :y row)
pieces))))
(setq start end)))))
;; Property boundaries must not become artificial glyph clipping edges.
(let (merged)
(dolist (piece (nreverse pieces))
(let ((last (car merged)))
(if (and last (= (plist-get last :y) (plist-get piece :y))
(= (plist-get last :end) (plist-get piece :start)))
(plist-put last :end (plist-get piece :end))
(push piece merged))))
(nreverse merged))))
(defun ebox-layer--bounds (pieces)
"Return (X Y WIDTH HEIGHT) covering nonempty PIECES, or nil."
(when pieces
(let ((left (apply #'min (mapcar (lambda (p) (plist-get p :start)) pieces)))
(right (apply #'max (mapcar (lambda (p) (plist-get p :end)) pieces)))
(top (apply #'min (mapcar (lambda (p) (plist-get p :y)) pieces)))
(bottom (apply #'max (mapcar (lambda (p) (plist-get p :y)) pieces))))
(list left top (- right left) (1+ (- bottom top))))))
(defun ebox-layer--offset (node property host extent)
"Resolve NODE's PROPERTY against HOST and containing axis EXTENT."
(let* ((vertical (eq property :top))
(value (or (plist-get node property) (if vertical '(lh 0) '(px 0))))
(pixels (ebox--size-pixels value host (if vertical 'height 'width)
(if vertical
(* extent (ebox--size-line-height host))
extent))))
(unless (numberp pixels)
(error "Ebox layer offset has no definite containing size: %S" value))
(if vertical (floor (/ pixels (float (ebox--size-line-height host)))) pixels)))
(defun ebox-layer--validate-child (node host)
"Check text backend placement contracts for NODE inside HOST."
(ebox-layer-validate-node node)
(when (ebox-layer-positioned-p node)
(cl-labels ((check (child)
(unless (= (ebox--size-line-height child)
(ebox--size-line-height host))
(error "Ebox positioned boxes must share their host's line height"))
(mapc #'check (ebox-tree-node-children child))))
(check node))))
(defun ebox-layer-validate-node (node)
"Validate NODE's placement combinations independently of visibility."
(when (and (or (plist-get node :anchor)
(eq (plist-get node :layer) 'root))
(not (eq (plist-get node :position) 'absolute)))
(error "Ebox :anchor and :layer root require :position absolute")))
(defun ebox-layer--ordered-records (host records)
"Sort candidate RECORDS by layer and HOST logical document order."
(let ((order (make-hash-table :test #'eq)) (index 0))
(cl-labels ((visit (node)
(puthash node (cl-incf index) order)
(mapc #'visit (ebox-tree-node-children node))))
(visit host))
(cl-stable-sort
(copy-sequence records)
(lambda (a b)
(let* ((an (plist-get a :node)) (bn (plist-get b :node))
(az (or (plist-get an :z-index) 0))
(bz (or (plist-get bn :z-index) 0)))
(cond
((/= az bz) (< az bz))
((not (eq (and (ebox-layer-positioned-p an) t)
(and (ebox-layer-positioned-p bn) t)))
(not (ebox-layer-positioned-p an)))
(t (< (gethash an order 0) (gethash bn order 0)))))))))
(defun ebox-layer--compose-rows (host records width height)
"Compose HOST's visible RECORDS in a WIDTH by HEIGHT content canvas."
(let ((rows (make-vector height nil)))
(dolist (record (ebox-layer--ordered-records host records))
(let ((row (plist-get record :y)))
(when (and (>= row 0) (< row height))
(push record (aref rows row)))))
(cl-loop for row across rows collect
(ebox-composite-line "" (nreverse row) width))))
(defun ebox-layer--anchor-node (host id)
"Find one logical anchor ID under HOST, rejecting ambiguous IDs."
(let ((name (format "%s" id)) matches)
(cl-labels ((visit (node)
(when (equal name (ebox-tree-node-id ebox--render-source-index node))
(push node matches))
(mapc #'visit (ebox-tree-node-children node))))
(visit host))
(when (cdr matches) (error "Ebox layer anchor is ambiguous: %s" name))
(car matches)))
(defun ebox-layer--absolute-origin (node host bounds width height layer-width layer-height)
"Resolve NODE's origin in HOST's BASE, or nil for an invisible anchor.
BOUNDS is the already resolved anchor rectangle, or nil.
WIDTH and HEIGHT bound the host; LAYER-WIDTH and LAYER-HEIGHT bound the panel."
(let ((x (ebox-layer--offset node :left host width))
(y (ebox-layer--offset node :top host height))
(anchor (plist-get node :anchor)))
(if (not anchor) (list x y)
(when bounds
(let* ((placement (or (plist-get node :placement) 'bottom-start))
(above (memq placement '(top-start top-end)))
(left (+ (car bounds) x
(if (memq placement '(bottom-end top-end))
(- (nth 2 bounds) layer-width) 0)))
(top (+ y (nth 1 bounds)
(if above (- layer-height) (nth 3 bounds))))
(other (+ y (nth 1 bounds)
(if above (nth 3 bounds) (- layer-height)))))
(when (and (or (< top 0) (> (+ top layer-height) height))
(>= other 0) (<= (+ other layer-height) height))
(setq top other))
(list (max 0 (min left (max 0 (- width layer-width))))
(max 0 (min top (max 0 (- height layer-height))))))))))
(defun ebox-layer--visible-bounds (bounds origin width height)
"Translate BOUNDS by ORIGIN and intersect the WIDTH by HEIGHT canvas."
(when (and bounds origin)
(let* ((x (+ (car bounds) (car origin)))
(y (+ (cadr bounds) (cadr origin)))
(left (max 0 x)) (top (max 0 y))
(right (min width (+ x (nth 2 bounds))))
(bottom (min height (+ y (nth 3 bounds)))))
(when (and (> right left) (> bottom top))
(list left top (- right left) (- bottom top))))))
(defun ebox-layer--absolute-records (host nodes base width height)
"Resolve HOST's absolute NODES and return opaque paint records.
BASE supplies normal-flow anchor fragments. Anchor dependencies are resolved
before painting, so source order and z order never select the geometry.
Missing or clipped anchors suppress their dependents; cycles signal before
publication. Nested root projections join this same candidate-local pass."
(let ((panels (make-hash-table :test #'eq))
(parents (make-hash-table :test #'eq))
(status (make-hash-table :test #'eq))
(queue (copy-sequence nodes)) order records)
(cl-labels
((index (node)
(dolist (child (ebox-tree-node-children node))
(puthash child node parents)
(index child)))
(panel-owner (node)
(while (and node (not (gethash node panels)))
(setq node (gethash node parents)))
node)
(resolve (node)
(pcase (gethash node status)
('visiting (error "Ebox layer anchor dependency is cyclic"))
('done (plist-get (gethash node panels) :origin))
(_
(puthash node 'visiting status)
(let* ((panel (gethash node panels))
(anchor (plist-get node :anchor))
(target (and anchor (ebox-layer--anchor-node host anchor)))
(provider (and target (panel-owner target)))
(bounds
(when (and target (not (eq (plist-get target :visibility) 'hidden)))
(if provider
(let ((origin (resolve provider)))
(ebox-layer--visible-bounds
(ebox-layer--bounds
(ebox-layer--pieces
(plist-get (gethash provider panels) :lines) target))
origin width height))
(ebox-layer--bounds (ebox-layer--pieces base target)))))
(origin
(unless (eq (plist-get node :visibility) 'hidden)
(ebox-layer--absolute-origin
node host bounds width height
(plist-get panel :width) (length (plist-get panel :lines))))))
(plist-put panel :origin origin)
(puthash node 'done status)
origin)))))
(index host)
(while queue
(let ((node (pop queue)))
(unless (gethash node panels)
(ebox-layer--validate-child node host)
(let* ((text
(unless (eq (plist-get node :visibility) 'hidden)
(ebox--call-with-box-content-context
host (lambda () (ebox--render-with-cache node)))))
(lines (and text (ebox-string-lines text))))
(puthash node
(list :lines lines :origin nil
:width (if lines (apply #'max (mapcar #'ebox--string-pixel-width lines)) 0))
panels)
(push node order)
(when (eq host ebox-layer--render-root)
(dolist (portal ebox-layer--portals)
(unless (or (gethash portal panels) (memq portal queue))
(setq queue (append queue (list portal))))))))))
(dolist (node (nreverse order))
(let ((origin (resolve node)))
(when origin
(dolist (piece (ebox-layer--pieces (plist-get (gethash node panels) :lines) node))
(push (list :node node
:x (+ (car origin) (plist-get piece :start))
:y (+ (cadr origin) (plist-get piece :y))
:text (ebox-composite-slice (plist-get piece :line)
(plist-get piece :start)
(plist-get piece :end))) records))))))
records))
(defun ebox-layer-render-children (host children renderer)
"Compose HOST's CHILDREN using ordinary flow RENDERER.
RENDERER accepts a child list. Logical nodes are never reordered or removed.
Only candidate-local lists of visible paint records are sorted."
(let* ((flow (cl-remove-if (lambda (child)
(eq (plist-get child :position) 'absolute)) children))
(base (funcall renderer flow)))
(if ebox--intrinsic-layout-measurement base
(let* ((lines (ebox-string-lines base))
(width (or (ebox--wrapper-content-viewport-pixel host)
(if lines (apply #'max (mapcar #'ebox--string-pixel-width lines)) 0)))
(flow-height (length lines))
(height (max flow-height (ceiling (ebox--content-height host flow-height base))))
(absolute (cl-remove-if-not (lambda (child)
(eq (plist-get child :position) 'absolute)) children))
records)
(dolist (child children) (ebox-layer--validate-child child host))
;; Normal children preserve their layout slots. Relative children use
;; the same source intervals and shift only their paint coordinates.
(dolist (child flow)
(unless (eq (plist-get child :visibility) 'hidden)
(let ((dx (if (eq (plist-get child :position) 'relative)
(ebox-layer--offset child :left host width) 0))
(dy (if (eq (plist-get child :position) 'relative)
(ebox-layer--offset child :top host height) 0)))
(dolist (piece (ebox-layer--pieces lines child))
(push (list :node child :y (+ dy (plist-get piece :y))
:x (+ dx (plist-get piece :start))
:text (ebox-composite-slice (plist-get piece :line)
(plist-get piece :start)
(plist-get piece :end))) records)))))
(unless (eq host ebox-layer--render-root)
(dolist (child absolute)
(when (eq (plist-get child :layer) 'root)
(cl-pushnew child ebox-layer--portals :test #'eq)))
(setq absolute (cl-remove-if (lambda (child)
(eq (plist-get child :layer) 'root)) absolute)))
(when (eq host ebox-layer--render-root)
(setq absolute (append absolute (reverse ebox-layer--portals))))
(when (cl-some (lambda (child) (plist-get child :anchor)) absolute)
;; Anchor geometry follows relative movement and visible clipping,
;; not the original flow slot. This extra composition is needed
;; only by anchored panels.
(setq lines (ebox-layer--compose-rows host records width height)))
(setq records (nconc (ebox-layer--absolute-records host absolute lines width height)
records))
(ebox--record-rendered-intrinsic-size
(ebox--record-rendered-uniform-width
(ebox-lines-join (ebox-layer--compose-rows host records width height)) width)
width flow-height)))))
(provide 'ebox-layer)
;;; ebox-layer.el ends here

View File

@ -17,6 +17,7 @@
(require 'ebox-size) (require 'ebox-size)
(require 'ebox-buffer-backend) (require 'ebox-buffer-backend)
(require 'ebox-runtime-index) (require 'ebox-runtime-index)
(require 'ebox-layer)
(declare-function ekp-pixel-justify "ekp" (string line-pixel)) (declare-function ekp-pixel-justify "ekp" (string line-pixel))
(declare-function ebox--render-grid "ebox-grid" (node)) (declare-function ebox--render-grid "ebox-grid" (node))
@ -1331,7 +1332,9 @@ Return its empty inline extent through `ebox--box-content-empty-width'."
(ebox--render-box-content-cached (ebox--render-box-content-cached
box box
(lambda () (lambda ()
(pcase kind (let ((render-flow
(lambda (children)
(pcase kind
('normal ('normal
(ebox--render-normal-box-children box children)) (ebox--render-normal-box-children box children))
('row ('row
@ -1363,7 +1366,12 @@ Return its empty inline extent through `ebox--box-content-empty-width'."
(ebox-layout-config-props (ebox-layout-config-props
(plist-get box :ebox-layout-config)) (plist-get box :ebox-layout-config))
children)) children))
(_ (error "Ebox Box has unsupported LayoutConfig: %S" kind))))))) (_ (error "Ebox Box has unsupported LayoutConfig: %S" kind))))))
(if (or (ebox-layer-host-p box)
(and (eq box ebox-layer--render-root)
(or ebox-layer--portals (ebox-layer-portals-p box))))
(ebox-layer-render-children box children render-flow)
(funcall render-flow children)))))))
(plist-put box :ebox-content-layout-complete-p (plist-put box :ebox-content-layout-complete-p
(not (and (eq kind 'normal) (not (and (eq kind 'normal)
(eq (ebox-get box :wrap-mode) 'kp)))) (eq (ebox-get box :wrap-mode) 'kp))))
@ -1721,7 +1729,8 @@ one carrier line preserves their paint without inventing a content line."
(defun ebox--format-content (box) (defun ebox--format-content (box)
"Format BOX content (wrap and justify) returning a string." "Format BOX content (wrap and justify) returning a string."
(or (ebox--format-scroll-window-content box) (or (and (not (ebox-layer-active-p box))
(ebox--format-scroll-window-content box))
(ebox--format-content-string box (ebox--box-content box)))) (ebox--format-content-string box (ebox--box-content box))))
(defun ebox--total-pixel (box) (defun ebox--total-pixel (box)
@ -2079,6 +2088,15 @@ Internal implementation of `ebox-render' for box nodes."
rendered)) rendered))
(defun ebox--render-layout (node) (defun ebox--render-layout (node)
"Render NODE in one candidate-local layer collection context."
(if ebox-layer--render-root
(ebox--render-layout-in-context node)
(let ((ebox-layer--render-root node)
(ebox-layer--portals nil)
(ebox-layer--active-p ebox-layer--active-p))
(ebox--render-layout-in-context node))))
(defun ebox--render-layout-in-context (node)
"Render layout NODE directly to a multi-line propertized string. "Render layout NODE directly to a multi-line propertized string.
NODE can be: NODE can be:
- a box plist created by `ebox-create' - a box plist created by `ebox-create'
@ -2107,6 +2125,7 @@ This function owns spatial layout only; it never creates a live TP surface."
(or ebox--box-content-render-cache (or ebox--box-content-render-cache
(make-hash-table :test 'eq)))) (make-hash-table :test 'eq))))
(when (listp node) (when (listp node)
(ebox-layer-validate-node node)
(ebox--ensure-node-id node) (ebox--ensure-node-id node)
(ebox-buffer-validate-border-capability node)) (ebox-buffer-validate-border-capability node))
(let ((type (plist-get node :ebox-type))) (let ((type (plist-get node :ebox-type)))

View File

@ -509,8 +509,10 @@ new session with the same bounded configuration used by retained frames."
(when (ebox-runtime-index-like-p nodes) (when (ebox-runtime-index-like-p nodes)
(ebox-runtime-index-map (ebox-runtime-index-map
(lambda (_node-id node) (lambda (_node-id node)
(unless (memq (plist-get node :ebox-type) (unless (and (memq (plist-get node :ebox-type)
'(box concat stack flex)) '(box concat stack flex))
(not (ebox-layer-host-p node))
(not (ebox-layer-positioned-p node)))
(setq supported nil))) (setq supported nil)))
nodes)) nodes))
supported)) supported))

View File

@ -2428,6 +2428,7 @@ Grid deliberately stays on the ordinary Ebox renderer until the native
backend has a matching two-dimensional layout contract." backend has a matching two-dimensional layout contract."
(cond (cond
((or (null node) (stringp node) (not (listp node))) t) ((or (null node) (stringp node) (not (listp node))) t)
((or (ebox-layer-host-p node) (ebox-layer-positioned-p node)) nil)
((eq (plist-get node :visibility) 'hidden) nil) ((eq (plist-get node :visibility) 'hidden) nil)
((not (ebox-native-reflow--node-static-sizes-p node)) nil) ((not (ebox-native-reflow--node-static-sizes-p node)) nil)
((plist-get node :ebox-child-sequence) nil) ((plist-get node :ebox-child-sequence) nil)

View File

@ -70,6 +70,34 @@
(:name :box-sizing :id ebox/box-sizing :initial border-box (:name :box-sizing :id ebox/box-sizing :initial border-box
:contexts (box row column flex grid) :group geometry :contexts (box row column flex grid) :group geometry
:dirty-kind geometry :signature layout) :dirty-kind geometry :signature layout)
(:name :position :id ebox/position :initial static
:contexts (box row column flex grid) :group geometry
:dirty-kind geometry :signature layout :validator position
:runtime-default t)
(:name :left :id ebox/left :initial (px 0)
:contexts (box row column flex grid) :group geometry
:dirty-kind geometry :signature layout
:size-axis inline :validator signed-size :runtime-default t)
(:name :top :id ebox/top :initial (lh 0)
:contexts (box row column flex grid) :group geometry
:dirty-kind geometry :signature layout
:size-axis block :validator signed-size :runtime-default t)
(:name :z-index :id ebox/z-index :initial 0
:contexts (box row column flex grid) :group geometry
:dirty-kind geometry :signature layout :validator integer
:runtime-default t)
(:name :layer :id ebox/layer :initial local
:contexts (box row column flex grid) :group geometry
:dirty-kind geometry :signature layout :validator layer
:runtime-default t)
(:name :anchor :id ebox/anchor :initial nil
:contexts (box row column flex grid) :group geometry
:dirty-kind geometry :signature layout :validator anchor
:runtime-default t)
(:name :placement :id ebox/placement :initial bottom-start
:contexts (box row column flex grid) :group geometry
:dirty-kind geometry :signature layout :validator placement
:runtime-default t)
(:name :width :id ebox/width :initial auto (:name :width :id ebox/width :initial auto
:contexts (box row column flex grid) :contexts (box row column flex grid)
:group geometry :dirty-kind geometry :signature layout :group geometry :dirty-kind geometry :signature layout
@ -904,6 +932,34 @@ the invalid value and its allowed units instead of a shorthand arity error."
"Return non-nil when VALUE is an Ebox outer participation value." "Return non-nil when VALUE is an Ebox outer participation value."
(memq value '(inline block))) (memq value '(inline block)))
(defun ebox-style--signed-size-p (value &optional axis)
"Return non-nil when VALUE is a signed typed length in size AXIS.
Leave fractional and contextual lengths unresolved until used placement."
(and (ebox-size-value-p value t)
(let ((units (cdr (assq (or axis 'length) ebox-size-axis-units))))
(cl-labels ((valid-p (expression)
(or (atom expression)
(if (memq (car expression) '(px ch vw lh vh %))
(memq (car expression) units)
(cl-every #'valid-p (cdr expression))))))
(valid-p value)))))
(defun ebox-style--position-p (value)
"Return non-nil when VALUE is a supported positioning mode."
(memq value '(static relative absolute)))
(defun ebox-style--layer-p (value)
"Return non-nil when VALUE is a supported layer paint target."
(memq value '(local root)))
(defun ebox-style--anchor-p (value)
"Return non-nil when VALUE is nil or a semantic symbol or string id."
(or (symbolp value) (stringp value)))
(defun ebox-style--placement-p (value)
"Return non-nil when VALUE is a supported anchor placement."
(memq value '(bottom-start bottom-end top-start top-end)))
(defun ebox-style--font-family-p (value) (defun ebox-style--font-family-p (value)
"Return non-nil when VALUE is an Ebox font family." "Return non-nil when VALUE is an Ebox font family."
(or (null value) (or (null value)
@ -1085,6 +1141,12 @@ generated wrappers that already contain exact line breaks."
('positive-integer #'ebox-style--positive-integer-p) ('positive-integer #'ebox-style--positive-integer-p)
('grid-placement #'ebox-style--grid-placement-p) ('grid-placement #'ebox-style--grid-placement-p)
('outer #'ebox-style--outer-p) ('outer #'ebox-style--outer-p)
('signed-size #'ebox-style--signed-size-p)
('position #'ebox-style--position-p)
('integer #'integerp)
('layer #'ebox-style--layer-p)
('anchor #'ebox-style--anchor-p)
('placement #'ebox-style--placement-p)
('font-family #'ebox-style--font-family-p) ('font-family #'ebox-style--font-family-p)
('color #'ebox-style--color-p) ('color #'ebox-style--color-p)
('font-size #'ebox-style--font-size-p) ('font-size #'ebox-style--font-size-p)
@ -1162,8 +1224,10 @@ Grid track wrappers are traversed after their structural validator succeeds."
(ebox-size-value-for-axis-p entry axis)) (ebox-size-value-for-axis-p entry axis))
(t (cl-every #'valid-p entry))))) (t (cl-every #'valid-p entry)))))
(valid-p value)) (valid-p value))
(or (symbolp value) (if (eq (plist-get property :validator) 'signed-size)
(ebox-size-value-for-axis-p value axis)))))) (ebox-style--signed-size-p value axis)
(or (symbolp value)
(ebox-size-value-for-axis-p value axis)))))))
(defun ebox-style--schema-validator (property) (defun ebox-style--schema-validator (property)
"Return PROPERTY's grammar and unit-domain validator." "Return PROPERTY's grammar and unit-domain validator."
@ -1243,7 +1307,7 @@ Grid track wrappers are traversed after their structural validator succeeds."
(if (memq (plist-get property :name) (if (memq (plist-get property :name)
'(:wrap-mode :item-gap :cross-align '(:wrap-mode :item-gap :cross-align
:border-top-p :grid-column-span :border-top-p :grid-column-span
:grid-row-span)) :grid-row-span :layer :anchor :placement))
'ebox 'ebox
'css))) 'css)))
(setq metadata (setq metadata
@ -1439,6 +1503,12 @@ not affect declaration compilation and are intentionally excluded."
('min-vertical-size (not (ebox-style--min-vertical-size-p value))) ('min-vertical-size (not (ebox-style--min-vertical-size-p value)))
('max-vertical-size (not (ebox-style--max-vertical-size-p value))) ('max-vertical-size (not (ebox-style--max-vertical-size-p value)))
('nonnegative-size (not (ebox-style--nonnegative-size-p value))) ('nonnegative-size (not (ebox-style--nonnegative-size-p value)))
('signed-size (not (ebox-style--signed-size-p value)))
('position (not (ebox-style--position-p value)))
('integer (not (integerp value)))
('layer (not (ebox-style--layer-p value)))
('anchor (not (ebox-style--anchor-p value)))
('placement (not (ebox-style--placement-p value)))
('positive-integer ('positive-integer
(and (numberp value) (not (ebox-style--positive-integer-p value)))) (and (numberp value) (not (ebox-style--positive-integer-p value))))
('grid-placement ('grid-placement
@ -1590,16 +1660,17 @@ When STRICT is non-nil, reject properties outside the Ebox style domain."
declarations) declarations)
(defun ebox-style--validate-author-values (plist) (defun ebox-style--validate-author-values (plist)
"Reject explicit author values outside the public property grammars. "Reject values in author PLIST outside the public property grammars.
An ECSS schema may use nil as an internal initial value, but nil is not an An ECSS schema may use nil as an internal initial value, but nil is not an
author value unless the public grammar is boolean." author value unless the public grammar is boolean or an optional anchor."
(unless (ebox-style--valid-plist-p plist) (unless (ebox-style--valid-plist-p plist)
(user-error "Ebox style declarations must be an even property list")) (user-error "Ebox style declarations must be an even property list"))
(cl-loop for (name value) on plist by #'cddr (cl-loop for (name value) on plist by #'cddr
for property = (ebox-style--property name) for property = (ebox-style--property name)
when (and property when (and property
(null value) (null value)
(not (eq (plist-get property :validator) 'boolean))) (not (memq (plist-get property :validator)
'(boolean anchor))))
do (error "ebox: invalid value for %S: nil" do (error "ebox: invalid value for %S: nil"
(plist-get property :name))) (plist-get property :name)))
plist) plist)
@ -2012,11 +2083,16 @@ explicit generation SOURCE-INDEX; facts are never recovered from its handle."
(ebox-style--custom-property-p property)) (ebox-style--custom-property-p property))
append (list property value))) append (list property value)))
(defvar ebox-style--box-runtime-defaults)
(defun ebox-style--reset-box-engine-style (box) (defun ebox-style--reset-box-engine-style (box)
"Reset BOX engine style fields to their declared defaults." "Reset BOX engine style fields to their declared defaults."
(cl-loop for (property value) on ebox--longhand by #'cddr (cl-loop for (property value) on ebox--longhand by #'cddr
unless (memq property '(:content :scroll-offset)) unless (memq property '(:content :scroll-offset))
do (plist-put box property (copy-tree value))) do (plist-put box property (copy-tree value)))
(unless (eq (plist-get box :ebox-kind) 'text)
(cl-loop for (property value) on ebox-style--box-runtime-defaults by #'cddr
do (plist-put box property (copy-tree value))))
box) box)
(defun ebox-style--apply-engine-values (box values) (defun ebox-style--apply-engine-values (box values)
@ -2292,6 +2368,9 @@ owned by NEW; only fields owned by the unchanged computed style are shared."
(cl-loop for (property _value) on ebox--longhand by #'cddr (cl-loop for (property _value) on ebox--longhand by #'cddr
unless (memq property '(:content :scroll-offset)) unless (memq property '(:content :scroll-offset))
do (ebox-style--transfer-node-property old new property)) do (ebox-style--transfer-node-property old new property))
(when (eq (plist-get new :ebox-kind) 'box)
(cl-loop for (property _value) on ebox-style--box-runtime-defaults by #'cddr
do (ebox-style--transfer-node-property old new property)))
(dolist (property ebox-style--item-projection-properties) (dolist (property ebox-style--item-projection-properties)
(ebox-style--transfer-node-property old new property)) (ebox-style--transfer-node-property old new property))
new) new)
@ -2470,6 +2549,16 @@ used geometry and become zero when the corresponding style is not `solid'."
(ebox-style--apply-border-used-widths (ebox-style--apply-border-used-widths
(ebox-style--validate-edge-longhands expanded)))) (ebox-style--validate-edge-longhands expanded))))
(defconst ebox-style--box-runtime-defaults
(ebox-style--expand-engine-plist
(cl-loop for property in ebox-style--property-definitions
when (plist-get property :runtime-default)
append (list (plist-get property :name)
(copy-tree (plist-get property :initial)))))
"Schema-owned defaults for additional Box runtime style fields.
Reset and source-stable transfer share this registration. Canonical Text
does not materialize these box-only placement facts.")
(defun ebox-style--border-delta-value (defun ebox-style--border-delta-value
(declarations full-declarations style name) (declarations full-declarations style name)
"Return border NAME from delta, complete declarations, STYLE, or initial." "Return border NAME from delta, complete declarations, STYLE, or initial."

View File

@ -38,6 +38,8 @@
(defvar ebox--viewport-dependent-subtree-cache) (defvar ebox--viewport-dependent-subtree-cache)
(defvar ebox--viewport-height-dependent-subtree-cache) (defvar ebox--viewport-height-dependent-subtree-cache)
(defvar ebox--render-runtime-revision) (defvar ebox--render-runtime-revision)
(defvar ebox--render-source-index)
(defvar ebox-layer--active-p)
(defvar ebox--render-root-parent-kind) (defvar ebox--render-root-parent-kind)
(defvar ebox--render-cache-table) (defvar ebox--render-cache-table)
(defvar ebox--render-cache-signature-cache) (defvar ebox--render-cache-signature-cache)
@ -2386,6 +2388,7 @@ FRAMEWORK-PARTICIPANT retains post-TP publication facts when non-nil."
success native-settled-p) success native-settled-p)
(when (and (null on-mismatch) (when (and (null on-mismatch)
(or (plist-get state-overrides :owner-scoped-proofs) (or (plist-get state-overrides :owner-scoped-proofs)
(plist-get report-base :layer-recompose-p)
(plist-get state-overrides (plist-get state-overrides
:scroll-state-transaction) :scroll-state-transaction)
(> (length scope-node-ids) 1))) (> (length scope-node-ids) 1)))
@ -3284,6 +3287,15 @@ available during planning; replacements here belong only to the candidate."
(ebox--smooth-scroll-state-table smooth-table) (ebox--smooth-scroll-state-table smooth-table)
(ebox--render-runtime-revision (ebox--render-runtime-revision
(plist-get state :runtime-revision)) (plist-get state :runtime-revision))
(ebox--render-source-index (plist-get state :source-index))
(ebox-incremental--render-portals-p
(if (plist-member state :root-portals-p)
(plist-get state :root-portals-p)
'unknown))
(ebox-layer--active-p
(if (plist-member state :layered-p)
(plist-get state :layered-p)
'unknown))
(ebox--render-cache-table (plist-get state :render-cache)) (ebox--render-cache-table (plist-get state :render-cache))
(ebox--layout-fragments-table (ebox--layout-fragments-table
(plist-get state :layout-fragments)) (plist-get state :layout-fragments))
@ -4150,6 +4162,42 @@ Proof mismatches return nil; unexpected rendering errors propagate."
:external new-external :parent new-parent :roles new-roles :external new-external :parent new-parent :roles new-roles
:overflow new-overflow :variable-content-p nil)))))) :overflow new-overflow :variable-content-p nil))))))
(defun ebox-surface--layer-patch-candidate (buffer previous-state state proof)
"Return PROOF's composed host replacement with only newly visible roles.
Render candidate STATE against BUFFER and its PREVIOUS-STATE.
Role topology can change inside the host. The Range footprint validator
already allows that change while requiring identical external allocation.
Enclosing effects must be unchanged; retained owner roles restore their paint
contributions without copying the previous children's hidden properties."
(let ((owner-id (plist-get proof :owner-id)))
(when (and (ebox-surface--unchanged-enclosing-effects-p
previous-state state owner-id)
;; Arbitrary ancestor surface properties need a separate
;; provenance proof; retain the existing full-render fallback.
(let ((parent (ebox-runtime-index-get
owner-id (plist-get state :parent-table)))
(valid t))
(while (and valid parent)
(when (ebox-get (ebox-runtime-index-get
parent (plist-get state :node-table))
:surface-properties)
(setq valid nil))
(setq parent (ebox-runtime-index-get
parent (plist-get state :parent-table))))
valid))
;; Paint-only preparation normally reuses visible descendant styles.
;; Occluded descendants have no visible fragments to refresh, so prepare
;; the isolated host's complete style closure before rendering it.
(when-let* ((node (ebox-runtime-index-get
owner-id (plist-get state :node-table))))
(ebox-surface-prepare-inline-candidate-styles
previous-state state (ebox--runtime-node-ids node))
(dolist (node-id (ebox--runtime-node-ids node))
(when-let* ((child (ebox-runtime-index-get
node-id (plist-get state :node-table))))
(remhash child (plist-get state :render-signature-cache)))))
(ebox-surface--range-patch-candidate buffer state proof))))
(defun ebox-surface--scroll-replace-index-lines (index changes) (defun ebox-surface--scroll-replace-index-lines (index changes)
"Copy INDEX and replace only entries on CHANGES' cached content lines. "Copy INDEX and replace only entries on CHANGES' cached content lines.
Each change is (LINE-NUMBER OLD-LINE NEW-LINE). Line counts are unchanged, Each change is (LINE-NUMBER OLD-LINE NEW-LINE). Line counts are unchanged,
@ -4328,11 +4376,15 @@ local; a declined visible proof leaves the committed scroll prefix untouched."
(if proofs (if proofs
(mapcar (mapcar
(lambda (proof) (lambda (proof)
(if (plist-get proof :range-splice-p) (cond
((plist-get proof :layer-recompose-p)
(ebox-surface--layer-patch-candidate
buffer previous-state state proof))
((plist-get proof :range-splice-p)
(unless (ebox-surface--owner-needs-ancestor-paint-p (unless (ebox-surface--owner-needs-ancestor-paint-p
state (plist-get proof :owner-id)) state (plist-get proof :owner-id))
(ebox-surface--range-patch-candidate buffer state proof)) (ebox-surface--range-patch-candidate buffer state proof)))
(ebox-surface--owner-patch-candidate (t (ebox-surface--owner-patch-candidate
buffer state (plist-get proof :owner-id) buffer state (plist-get proof :owner-id)
(plist-get proof :allocated-width) (plist-get proof :allocated-width)
(plist-get proof :variable-content-p) (plist-get proof :variable-content-p)
@ -4341,7 +4393,7 @@ local; a declined visible proof leaves the committed scroll prefix untouched."
(plist-get proof :changed-keys) (plist-get proof :changed-keys)
(plist-get proof :allocation-closure-p) (plist-get proof :allocation-closure-p)
(plist-get proof :retain-external-owner-suffix-p) (plist-get proof :retain-external-owner-suffix-p)
(and (= (length proofs) 1) previous-state)))) (and (= (length proofs) 1) previous-state)))))
proofs) proofs)
(list (list
(ebox-surface--owner-patch-candidate (ebox-surface--owner-patch-candidate
@ -6223,6 +6275,21 @@ but cannot introduce or remove a viewport expression or node identity."
(plist-put state :fractional-pixel-output-p (plist-put state :fractional-pixel-output-p
(plist-get previous-state :fractional-pixel-output-p)) (plist-get previous-state :fractional-pixel-output-p))
(plist-put state :root-node root) (plist-put state :root-node root)
(plist-put state :layered-p
(if projection-kind
(or (plist-get previous-state :layered-p)
(cl-some (lambda (proof)
(plist-get proof :layer-recompose-p))
(plist-get state :owner-scoped-proofs)))
(and (fboundp 'ebox-layer-subtree-p)
(ebox-layer-subtree-p root))))
(plist-put state :root-portals-p
(if projection-kind
(or (plist-get previous-state :root-portals-p)
(and (plist-get state :layer-recompose-p)
(ebox-layer-portals-p root)))
(and (fboundp 'ebox-layer-portals-p)
(ebox-layer-portals-p root))))
(when (plist-get state :native-sync-confirmed-p) (when (plist-get state :native-sync-confirmed-p)
(ebox-native-commit-attach-confirmed-base previous-state state)) (ebox-native-commit-attach-confirmed-base previous-state state))
(plist-put state :projection-kind projection-kind) (plist-put state :projection-kind projection-kind)

View File

@ -35,7 +35,8 @@
"ebox-layout-config.el" "ebox-layout-config.el"
"ebox-node-factory.el" "ebox-node-factory.el"
"ebox-child-range.el" "ebox-tree.el" "ebox-measure.el" "ebox-child-range.el" "ebox-tree.el" "ebox-measure.el"
"ebox-fragment.el" "ebox-render-context.el" "ebox-layout.el" "ebox-fragment.el" "ebox-render-context.el" "ebox-composite.el"
"ebox-layer.el" "ebox-layout.el"
"ebox-flex.el" "ebox-grid.el" "ebox-canonical.el" "ebox-flex.el" "ebox-grid.el" "ebox-canonical.el"
"ebox-buffer-backend.el" "ebox-patch-plan.el" "ebox-incremental.el" "ebox-buffer-backend.el" "ebox-patch-plan.el" "ebox-incremental.el"
"ebox-native-commit.el" "ebox-surface.el" "ebox-viewport.el" "ebox-native-commit.el" "ebox-surface.el" "ebox-viewport.el"

View File

@ -0,0 +1,286 @@
;;; ebox-composite-tests.el --- Pixel row compositor tests -*- lexical-binding: t; -*-
;;; Commentary:
;; Independent deterministic display metrics exercise cuts and coverage without
;; requiring a GUI frame. Production measurement still handles EQ display runs.
;;; Code:
(require 'ert)
(require 'cl-lib)
(require 'ebox)
(require 'ebox-composite)
(defun ebox-composite-test--plain-width (text)
"Measure TEXT with seven-pixel glyphs, zero-width marks, and fi shaping."
(let ((width 0) (position 0))
(dolist (char (string-to-list text))
(unless (or (memq (get-char-code-property char 'general-category)
'(Mn Mc Me))
(= char #x200d)
(and (>= char #x1f3fb) (<= char #x1f3ff)))
(cl-incf width 7)))
(while (string-match "fi" text position)
(cl-decf width 3)
(setq position (match-end 0)))
width))
(defun ebox-composite-test--pixel-width (text)
"Return TEXT's deterministic width, replacing each EQ display run once."
(let ((position 0) (width 0) (end (length text)))
(while (< position end)
(let* ((display (get-text-property position 'display text))
(next (next-single-property-change position 'display text end)))
(cl-incf
width
(cond
((and (consp display) (eq (car display) 'space))
(car (plist-get (cdr display) :width)))
((and (consp display) (eq (car display) 'image))
(plist-get (cdr display) :width))
((stringp display) (ebox-composite-test--pixel-width display))
(t (ebox-composite-test--plain-width
(substring-no-properties text position next)))))
(setq position next)))
width))
(defmacro ebox-composite-test--with-metrics (&rest body)
"Run BODY using the independent pixel oracle and fresh measurement caches."
(declare (indent 0) (debug t))
`(cl-letf (((symbol-function 'string-pixel-width)
#'ebox-composite-test--pixel-width))
(ebox-clear-cache)
(unwind-protect (progn ,@body)
(ebox-clear-cache))))
(defun ebox-composite-test--assert-width (text width)
"Assert TEXT is WIDTH pixels in the production and independent metrics."
(should (= (ebox--string-pixel-width text) width))
(should (= (ebox-composite-test--pixel-width text) width)))
(ert-deftest ebox-composite-slice-keeps-original-coordinates ()
"Partial edge glyphs leave neutral blanks and do not shift retained text."
(ebox-composite-test--with-metrics
(let* ((keymap (make-sparse-keymap))
(owner (list 'owner))
(source (propertize "ABCD" 'keymap keymap 'ebox-owner owner)))
(dotimes (index (length source))
(put-text-property index (1+ index) 'source-position index source))
(let ((output (ebox-composite-slice source 3 24)))
(should (equal (substring-no-properties output) " BC "))
(ebox-composite-test--assert-width output 21)
(should-not (get-text-property 0 'keymap output))
(should-not (get-text-property 3 'keymap output))
(should-not (get-text-property 0 'ebox-owner output))
(should (eq (get-text-property 1 'keymap output) keymap))
(should (eq (get-text-property 2 'ebox-owner output) owner))
(should (= (get-text-property 1 'source-position output) 1))
(should (= (get-text-property 2 'source-position output) 2))
(should (= (ebox--string-pixel-width (substring output 0 1)) 4))))))
(ert-deftest ebox-composite-slice-fills-exhausted-and-empty-source ()
"Intervals beyond source content retain their exact neutral canvas width."
(ebox-composite-test--with-metrics
(dolist (case '((nil 0 21) ("" 3 10) ("A" 10 21) ("A" 3 21)))
(pcase-let ((`(,source ,start ,end) case))
(let ((output (ebox-composite-slice source start end)))
(ebox-composite-test--assert-width output (- end start))
(should (string-blank-p output))
(should-not (text-property-not-all 0 (length output)
'keymap nil output)))))
(should (equal (ebox-composite-slice "abc" 2 2) ""))))
(ert-deftest ebox-composite-slice-keeps-supported-graphemes-whole ()
"Both cut edges preserve combining, variation, modifier, ZWJ, and flags."
(ebox-composite-test--with-metrics
(dolist (cluster '("" "✈️" "👍🏽" "👩‍💻" "🇨🇳"))
(let* ((source (concat "A" cluster "Z"))
(right (+ 7 (ebox--string-pixel-width cluster)))
(whole (ebox-composite-slice source 7 right))
(cut-left (ebox-composite-slice source 8 (+ right 7)))
(cut-right (ebox-composite-slice source 0 (1- right))))
(should (equal (substring-no-properties whole) cluster))
(should (equal (substring-no-properties cut-left) " Z"))
(should (equal (substring-no-properties cut-right) "A "))
(ebox-composite-test--assert-width whole (- right 7))
(ebox-composite-test--assert-width cut-left (- right 1))
(ebox-composite-test--assert-width cut-right (1- right))))))
(ert-deftest ebox-composite-slice-preserves-whole-prefix-shaping ()
"A shaped fitting prefix is retained, and a detached glyph cannot spill."
(ebox-composite-test--with-metrics
(let ((whole (ebox-composite-slice "fiZ" 0 11))
(suffix (ebox-composite-slice "fiZ" 7 18)))
(should (equal whole "fi"))
(ebox-composite-test--assert-width whole 11)
(should (equal (substring-no-properties suffix) " Z"))
(should (= (ebox--string-pixel-width (substring suffix 0 1)) 4))
(ebox-composite-test--assert-width suffix 11))))
(ert-deftest ebox-composite-slice-cuts-grouped-fractional-spaces ()
"A shared space can be cut on both ends without copying nested properties."
(ebox-composite-test--with-metrics
(let* ((height (list 20))
(ascent (list 80))
(display (list 'space :width '(15.5) :height height :ascent ascent))
(keymap (make-sparse-keymap))
(face (list :foreground "red"))
(source (concat (propertize "abc" 'display display
'keymap keymap 'face face) "Z"))
(before (copy-sequence source))
(output (ebox-composite-slice source 2.25 13.75))
(result-display (get-text-property 0 'display output))
(whole (ebox-composite-slice source 0 15.5)))
(ebox-composite-test--assert-width output 11.5)
(should (equal (substring-no-properties output) "abc"))
(should (= (car (plist-get (cdr result-display) :width)) 11.5))
(should (eq (plist-get (cdr result-display) :height) height))
(should (eq (plist-get (cdr result-display) :ascent) ascent))
(dotimes (index (length output))
(should (eq (get-text-property index 'display output) result-display))
(should (eq (get-text-property index 'keymap output) keymap))
(should (eq (get-text-property index 'face output) face)))
(should (eq (get-text-property 0 'display whole) display))
(should (equal-including-properties before source))
(should (equal (plist-get (cdr display) :width) '(15.5))))))
(ert-deftest ebox-composite-slice-never-splits-replacement-runs ()
"Shared string and image replacements are kept whole, even across faces."
(ebox-composite-test--with-metrics
(dolist (display (list (copy-sequence "WXYZ")
(list 'image :type 'xpm :width 28)))
(let* ((keymap (make-sparse-keymap))
(source (propertize "abc" 'display display 'keymap keymap)))
(put-text-property 1 2 'face 'bold source)
(let ((whole (ebox-composite-slice source 0 28))
(middle (ebox-composite-slice source 7 21)))
(should (equal-including-properties source whole))
(should (eq (get-text-property 2 'display whole) display))
(should (string-blank-p middle))
(should-not (get-text-property 0 'keymap middle))
(ebox-composite-test--assert-width whole 28)
(ebox-composite-test--assert-width middle 14))))))
(ert-deftest ebox-composite-line-resolves-overlapping-opaque-a-b-c ()
"The last placement wins, including its blank pixels, at stable positions."
(ebox-composite-test--with-metrics
(let* ((base "0123456789")
(placements (list (list :text "AAAAAA" :x 7)
(list :text "BBBB" :x 21)
(list :text " C " :x 28)))
(output (ebox-composite-line base placements 70)))
(should (equal (substring-no-properties output) "0AAB C 789"))
(ebox-composite-test--assert-width output 70)
(should (equal base "0123456789"))
(should (equal placements '((:text "AAAAAA" :x 7)
(:text "BBBB" :x 21)
(:text " C " :x 28)))))))
(ert-deftest ebox-composite-line-preserves-underlay-right-coordinates ()
"Cutting an underlay glyph cannot pull its right-hand neighbor leftward."
(ebox-composite-test--with-metrics
(let ((output (ebox-composite-line
"ABCD" (list (list :text (ebox-pixel-space 10) :x 3)) 28)))
(should (equal (substring-no-properties output) " CD"))
(ebox-composite-test--assert-width output 28)
(should (= (ebox--string-pixel-width
(substring output 0 (string-match "C" output))) 14)))))
(ert-deftest ebox-composite-line-handles-negative-and-clipped-placements ()
"Canvas clipping retains original placement coordinates at both edges."
(ebox-composite-test--with-metrics
(let ((output
(ebox-composite-line
"012345" (list (list :text "ABC" :x -10)
(list :text "XY" :x 35)
(list :text "hidden" :x 100)) 42)))
(should (equal (substring-no-properties output) " C 234X"))
(ebox-composite-test--assert-width output 42))))
(ert-deftest ebox-composite-line-hidden-boundaries-do-not-split-top-glyph ()
"A hidden lower placement cannot fragment a fully visible replacement."
(ebox-composite-test--with-metrics
(let* ((display (copy-sequence "ABC"))
(top (propertize "source" 'display display))
(output (ebox-composite-line
nil (list (list :text (ebox-pixel-space 2) :x 4)
(list :text (ebox-pixel-space 3) :x 10)
(list :text top :x 0)) 21)))
(should (equal-including-properties output top))
(should (eq (get-text-property 0 'display output) display))
(ebox-composite-test--assert-width output 21))))
(ert-deftest ebox-composite-line-keeps-adjacent-eq-runs-separate ()
"Independent touching placements sharing a display still occupy two runs."
(ebox-composite-test--with-metrics
(let* ((display (list 'space :width '(10)))
(keymap (make-sparse-keymap))
(text (propertize "ab" 'display display 'keymap keymap))
(output (ebox-composite-line
nil (list (list :text text :x 0)
(list :text text :x 10)) 20)))
(ebox-composite-test--assert-width output 20)
(should (eq (get-text-property 0 'display output) display))
(should (eq (get-text-property (1- (length output)) 'display output)
display))
(should-not (get-text-property 2 'keymap output))
(should (eq (get-text-property 3 'keymap output) keymap)))))
(ert-deftest ebox-composite-line-keeps-independent-shaping-extents ()
"Neighboring sources cannot form a ligature that moves the right edge."
(ebox-composite-test--with-metrics
(let ((output (ebox-composite-line
"fZ" (list (list :text "i" :x 7)) 14)))
(ebox-composite-test--assert-width output 14)
(should (equal (substring-no-properties output) "f i"))
(should (equal (get-text-property 1 'display output)
'(space :width (0)))))))
(ert-deftest ebox-composite-slice-full-line-keeps-zero-width-source-units ()
"A complete line retains even a zero-width source's display height."
(ebox-composite-test--with-metrics
(let* ((display (list 'space :width '(0) :height '(24)))
(source (concat "A" (propertize "tail" 'display display)))
(output (ebox-composite-slice source 0 7)))
(should (equal-including-properties output source))
(should (eq (get-text-property 1 'display output) display))
(ebox-composite-test--assert-width output 7))))
(ert-deftest ebox-composite-line-uses-latest-base-after-placement-removal ()
"Composition retains no stale underlay state between successive calls."
(ebox-composite-test--with-metrics
(let* ((placements (list (list :text "XX" :x 7)))
(old (ebox-composite-line "abcdef" placements 42))
(updated (ebox-composite-line "123456" placements 42))
(restored (ebox-composite-line "123456" nil 42)))
(should (equal old "aXXdef"))
(should (equal updated "1XX456"))
(should (equal restored "123456"))
(dolist (output (list old updated restored))
(ebox-composite-test--assert-width output 42)))))
(ert-deftest ebox-composite-line-neutral-gaps-have-no-source-properties ()
"A short or absent base does not lend interaction properties to root fill."
(ebox-composite-test--with-metrics
(let* ((keymap (make-sparse-keymap))
(base (propertize "A" 'keymap keymap 'help-echo "base"))
(output (ebox-composite-line base nil 21)))
(ebox-composite-test--assert-width output 21)
(should (eq (get-text-property 0 'keymap output) keymap))
(should-not (get-text-property 1 'keymap output))
(should-not (get-text-property 1 'help-echo output)))
(ebox-composite-test--assert-width (ebox-composite-line nil nil 12.5) 12.5)))
(ert-deftest ebox-composite-rejects-invalid-bounds-and-lines ()
"Invalid extents and multiline inputs fail before source mutation."
(dolist (bounds '((-1 2) (3 2) (nil 1) (0 nope) (0 1.0e+INF)))
(should-error (apply #'ebox-composite-slice "abc" bounds)))
(should-error (ebox-composite-slice "a\nb" 0 1))
(should-error (ebox-composite-line "a\nb" nil 10))
(should-error (ebox-composite-line nil '((:text "x" :x nope)) 10))
(should-error (ebox-composite-line nil '((:text "x\ny" :x 0)) 10))
(should-error (ebox-composite-line nil nil -1))
(should-error (ebox-composite-line nil '(broken . list) 10)))
(provide 'ebox-composite-tests)
;;; ebox-composite-tests.el ends here

View File

@ -0,0 +1,302 @@
;;; ebox-layer-publication-tests.el --- Retained layer updates -*- lexical-binding: t; -*-
;;; Commentary:
;; Exercise layer composition through public mounted update APIs. Hidden
;; source objects remain logical inputs, while only visible roles are mounted.
;;; Code:
(require 'cl-lib)
(require 'ert)
(require 'ebox)
(require 'ebox-selector)
(defmacro ebox-layer-publication-test--with-buffer (&rest body)
"Run BODY with an isolated buffer and the explicit Elisp backend."
(declare (indent 0) (debug t))
`(cl-letf (((symbol-function 'ebox-native-reflow-layout-ready-p)
(lambda () nil)))
(let ((ebox-viewport-width 160) (ebox-viewport-height 10))
(with-temp-buffer ,@body))))
(defun ebox-layer-publication-test--input (&optional lower upper-visible)
"Build a nested fixed host with LOWER text and UPPER-VISIBLE overlay."
(ebox-build
`(column :id "root" :width (ch 12) :height (lh 5)
(box :id "before" "Before")
(box :id "host" :width (ch 8) :height (lh 2)
(box :id "lower" :width (ch 8) :height (lh 1)
,(or lower "LOWER001"))
(box :id "upper" :position absolute :left (px 0) :top (lh 0)
:width (ch 8) :height (lh 1) :z-index 1
:visibility ,(if upper-visible 'visible 'hidden)
"UPPER001"))
(box :id "after" "After"))))
(defun ebox-layer-publication-test--assert-fresh-render ()
"Check visible text against a fresh render of the committed logical input."
(should
(equal (buffer-substring-no-properties (point-min) (point-max))
(substring-no-properties
(ebox-render
(plist-get (ebox-surface-buffer-snapshot (current-buffer))
:input))))))
(ert-deftest ebox-layer-publication-hidden-updates-reveal-current-input ()
"Hidden content and paint updates survive without painting over upper text."
(ebox-layer-publication-test--with-buffer
(ebox-render-to-buffer
(current-buffer) (ebox-layer-publication-test--input nil t))
(let ((render (symbol-function 'ebox-surface--render-candidate))
(full-renders 0))
(should (string-match-p "UPPER001" (buffer-string)))
(should-not (string-match-p "LOWER001" (buffer-string)))
(cl-letf (((symbol-function 'ebox-surface--render-candidate)
(lambda (&rest arguments)
(cl-incf full-renders)
(apply render arguments))))
(ebox-region-update "lower" :content "LOWER002")
(ebox-selector-update-buffer
(current-buffer) "#lower" :color "#ff0000")
(should (string-match-p "UPPER001" (buffer-string)))
(should-not (string-match-p "LOWER002" (buffer-string)))
(ebox-region-update "upper" :visibility 'hidden)
(should (string-match-p "LOWER002" (buffer-string)))
(should-not (string-match-p "UPPER001" (buffer-string)))
(let* ((start (string-match "LOWER002" (buffer-string)))
(face (get-text-property (+ (point-min) start) 'face)))
(should (string-match-p "#ff0000" (prin1-to-string face)))))
(should (= full-renders 0))
(ebox-layer-publication-test--assert-fresh-render))))
(ert-deftest ebox-layer-publication-rejected-hidden-commit-rolls-back ()
"Rejected hidden updates leave retained input, revision and text unchanged."
(ebox-layer-publication-test--with-buffer
(ebox-render-to-buffer
(current-buffer) (ebox-layer-publication-test--input nil t))
(let ((before (buffer-string))
(revision (ebox-surface-buffer-revision (current-buffer))))
(should-error
(ebox-commit
(current-buffer) (ebox-layer-publication-test--input "REJECTED" t)
(lambda (_report) (error "Reject hidden layer candidate"))))
(should (= revision (ebox-surface-buffer-revision (current-buffer))))
(should (equal-including-properties before (buffer-string)))
(ebox-region-update "upper" :visibility 'hidden)
(should (string-match-p "LOWER001" (buffer-string)))
(should-not (string-match-p "REJECTED" (buffer-string)))
(ebox-layer-publication-test--assert-fresh-render))))
(ert-deftest ebox-layer-publication-footprint-change-falls-back ()
"Host geometry changes preserve siblings through the existing root fallback."
(ebox-layer-publication-test--with-buffer
(ebox-render-to-buffer
(current-buffer) (ebox-layer-publication-test--input nil t))
(let ((render (symbol-function 'ebox-surface--render-candidate))
(full-renders 0))
(cl-letf (((symbol-function 'ebox-surface--render-candidate)
(lambda (&rest arguments)
(cl-incf full-renders)
(apply render arguments))))
(ebox-region-update "host" :height '(lh 3)))
(should (> full-renders 0))
(should (string-match-p "Before" (buffer-string)))
(should (string-match-p "After" (buffer-string)))
(ebox-layer-publication-test--assert-fresh-render))))
(ert-deftest ebox-layer-publication-ordinary-update-skips-layer-walk ()
"A non-layer tree's owner planner does not scan on content changes."
(ebox-layer-publication-test--with-buffer
(ebox-render-to-buffer
(current-buffer)
(ebox-build '(column :width (ch 12) :height (lh 3)
(box :id "text" :width (ch 8) :height (lh 1) "BEFORE00"))))
(let ((plan (symbol-function 'ebox-incremental--layer-owner-plan)))
(cl-letf (((symbol-function 'ebox-incremental--layer-owner-plan)
(lambda (&rest arguments)
(cl-letf (((symbol-function 'ebox-layer-host-p)
(lambda (&rest _)
(ert-fail "Unexpected layer host walk")))
((symbol-function 'ebox-layer-subtree-p)
(lambda (&rest _)
(ert-fail "Unexpected layer subtree scan"))))
(apply plan arguments)))))
(ebox-region-update "text" :content "AFTER000")))
(should (string-match-p "AFTER000" (buffer-string)))))
(ert-deftest ebox-layer-publication-declines-unsupported-native-layout ()
"A retained layer tree is ineligible for both native layout entry points."
(ebox-layer-publication-test--with-buffer
(ebox-render-to-buffer
(current-buffer) (ebox-layer-publication-test--input nil t))
(let* ((state (ebox--buffer-render-state (current-buffer)))
(root (plist-get state :root-node)))
(should-not (ebox-native-reflow--native-node-supported-p root))
(should-not (ebox-native-commit--runtime-types-supported-p state))
(should-not (plist-get state :native-render-p)))
(ebox-region-update "lower" :content "LOWER002")
(ebox-region-update "upper" :visibility 'hidden)
(ebox-layer-publication-test--assert-fresh-render)))
(ert-deftest ebox-layer-publication-visible-interactions-follow-occlusion ()
"Only the visible layer owns interaction properties after hidden updates."
(ebox-layer-publication-test--with-buffer
(ebox-render-to-buffer
(current-buffer) (ebox-layer-publication-test--input nil t))
(ebox-region-update "lower" :keymap '(keymap (13 . backward-char))
:help-echo "lower-old")
(ebox-region-update "upper" :keymap '(keymap (13 . forward-char))
:help-echo "upper")
(ebox-region-update "lower" :keymap '(keymap (13 . ignore))
:help-echo "lower-new")
(let ((position (+ (point-min) (string-match "UPPER001" (buffer-string)))))
(should (eq (lookup-key (get-text-property position 'keymap) (kbd "RET"))
#'forward-char))
(should (equal (get-text-property position 'help-echo) "upper")))
(ebox-region-update "upper" :visibility 'hidden)
(let ((position (+ (point-min) (string-match "LOWER001" (buffer-string)))))
(should (eq (lookup-key (get-text-property position 'keymap) (kbd "RET"))
#'ignore))
(should (equal (get-text-property position 'help-echo) "lower-new")))
(ebox-layer-publication-test--assert-fresh-render)))
(ert-deftest ebox-layer-publication-ancestor-paint-survives-recomposition ()
"A detached host retains enclosing paint and interaction contributions."
(ebox-layer-publication-test--with-buffer
(ebox-render-to-buffer
(current-buffer) (ebox-layer-publication-test--input nil t))
(ebox-region-update "root" :color "#00ff00" :background-color "#000080"
:help-echo "ancestor")
(ebox-region-update "lower" :content "LOWER002")
(ebox-region-update "upper" :visibility 'hidden)
(let* ((actual (buffer-string))
(expected (ebox-render
(plist-get (ebox-surface-buffer-snapshot (current-buffer))
:input)))
(actual-position (string-match "LOWER002" actual))
(expected-position (string-match "LOWER002" expected)))
(should (equal (get-text-property actual-position 'face actual)
(get-text-property expected-position 'face expected)))
(should (equal (get-text-property actual-position 'help-echo actual)
"ancestor")))
(ebox-layer-publication-test--assert-fresh-render)))
(ert-deftest ebox-layer-publication-batch-retains-disjoint-owner-updates ()
"A hidden update and an ordinary sibling update publish one current input."
(ebox-layer-publication-test--with-buffer
(ebox-render-to-buffer
(current-buffer) (ebox-layer-publication-test--input nil t))
(ebox-incremental-begin-batch (current-buffer))
(ebox-region-update "lower" :content "LOWER002")
(ebox-region-update "after" :content "Later")
(ebox-incremental-flush (current-buffer))
(should (string-match-p "UPPER001" (buffer-string)))
(should (string-match-p "Later" (buffer-string)))
(should-not (string-match-p "LOWER002" (buffer-string)))
(ebox-region-update "upper" :visibility 'hidden)
(should (string-match-p "LOWER002" (buffer-string)))
(ebox-layer-publication-test--assert-fresh-render)))
(ert-deftest ebox-layer-publication-root-portal-occludes-unrelated-owner ()
"Updates outside a root portal's local host cannot paint over the portal."
(ebox-layer-publication-test--with-buffer
(ebox-render-to-buffer
(current-buffer)
(ebox-build
'(column :id "root" :width (ch 12) :height (lh 5)
(box :id "body" :width (ch 8) :height (lh 1) "BODY0001")
(box :id "host" :width (ch 4) :height (lh 1)
(box :id "portal" :position absolute :layer root
:left (px 0) :top (lh 0) :width (ch 8) :height (lh 1)
:z-index 1 "PORTAL01")))))
(ebox-region-update "body" :content "BODY0002")
(should (string-match-p "PORTAL01" (buffer-string)))
(should-not (string-match-p "BODY0002" (buffer-string)))
(ebox-region-update "portal" :visibility 'hidden)
(should (string-match-p "BODY0002" (buffer-string)))
(ebox-layer-publication-test--assert-fresh-render)))
(ert-deftest ebox-layer-publication-root-portal-survives-cached-branch ()
"An unchanged cached branch still collects its root portal on later renders."
(ebox-layer-publication-test--with-buffer
(ebox-render-to-buffer
(current-buffer)
(ebox-build
'(column :id "root" :width (ch 12) :height (lh 5)
(box :id "body" :width (ch 12) :height (lh 1) "BODY0001")
(box :id "host" :width (ch 4) :height (lh 1)
(box :id "local" :width (ch 4) :height (lh 1) "HOST")
(box :id "portal" :position absolute :layer root
:left (ch 4) :top (lh 3) :width (ch 6) :height (lh 1)
"PORTAL")))))
(dotimes (index 3)
(ebox-region-update "body" :content (format "BODY000%d" (+ index 2)))
(should (string-match-p "PORTAL" (buffer-string)))
(ebox-layer-publication-test--assert-fresh-render))
(ebox-region-update "portal" :visibility 'hidden)
(should-not (string-match-p "PORTAL" (buffer-string)))
(ebox-region-update "portal" :content "LATEST")
(ebox-region-update "portal" :visibility 'visible)
(should (string-match-p "LATEST" (buffer-string)))
(ebox-layer-publication-test--assert-fresh-render)))
(ert-deftest ebox-layer-publication-revealed-anchor-uses-candidate-source ()
"A revealed portal resolves its semantic anchor against current input."
(ebox-layer-publication-test--with-buffer
(ebox-render-to-buffer
(current-buffer)
(ebox-build
'(column :id "root" :width (ch 12) :height (lh 5)
(box :id "trigger" :width (ch 8) :height (lh 1) "ANCHOR01")
(box :id "host" :width (ch 4) :height (lh 1)
(box :id "portal" :position absolute :layer root
:anchor "trigger" :width (ch 6) :height (lh 1)
:visibility hidden "PORTAL")))))
(ebox-region-update "portal" :visibility 'visible)
(should (string-match-p "PORTAL" (buffer-string)))
(ebox-layer-publication-test--assert-fresh-render)
(ebox-region-update "trigger" :position 'relative :left '(ch 2))
(should (string-match-p "PORTAL" (buffer-string)))
(ebox-layer-publication-test--assert-fresh-render)))
(ert-deftest ebox-layer-publication-scroll-under-overlay-and-rollback ()
"Scrolling lower content recomposes its host and rolls back with TP."
(ebox-layer-publication-test--with-buffer
(ebox-render-to-buffer
(current-buffer)
(ebox-build
'(column :id "root" :width (ch 12) :height (lh 5)
(box :id "before" "Before")
(box :id "host" :width (ch 8) :height (lh 2)
(box :id "scroll" :width (ch 8) :height (lh 2) :overflow scroll
"ZERO0000\nONE00000\nTWO00000\nTHREE000")
(box :id "upper" :position absolute :width (ch 8) :height (lh 1)
"UPPER001"))
(box :id "after" "After"))))
(let* ((region-id (plist-get
(car (ebox-selector-query-buffer (current-buffer) "#scroll"))
:region-id))
(revision (ebox-surface-buffer-revision (current-buffer)))
(before (buffer-string)))
(cl-letf (((symbol-function 'ebox-surface--render-candidate)
(lambda (&rest _)
(ert-fail "Fixed local layer scroll rendered the root"))))
(let ((tp--surface-publication-step-function
(lambda (step _surface)
(when (eq step 'client-state) (error "Reject layer scroll")))))
(should-error (ebox--scroll-region-by region-id 1 1)))
(should (= revision (ebox-surface-buffer-revision (current-buffer))))
(should (equal-including-properties before (buffer-string)))
(should (= 0 (plist-get (ebox-scroll-state region-id) :scroll-offset)))
(should (= 1 (ebox--scroll-region-by region-id 1 1)))
(should (string-match-p "UPPER001" (buffer-string)))
(should (string-match-p "TWO00000" (buffer-string)))
(should-not (string-match-p "ONE00000" (buffer-string)))
(ebox-region-update "upper" :visibility 'hidden)
(should (string-match-p "ONE00000" (buffer-string)))
(should (string-match-p "TWO00000" (buffer-string)))
(should (string-match-p "Before" (buffer-string)))
(should (string-match-p "After" (buffer-string)))))))
(provide 'ebox-layer-publication-tests)
;;; ebox-layer-publication-tests.el ends here

View File

@ -0,0 +1,236 @@
;;; ebox-layer-style-tests.el --- Layer style boundary tests -*- lexical-binding: t; -*-
;;; Code:
(require 'ert)
(require 'ebox-style)
(require 'ebox)
(defconst ebox-layer-style-test--defaults
'(:position static :left (px 0) :top (lh 0)
:z-index 0 :layer local :anchor nil :placement bottom-start)
"Initial values of the public layer style properties.")
(defconst ebox-layer-style-test--values
'(:position absolute :left (ch -1.25) :top (lh -0.5)
:z-index -2 :layer root :anchor menu-button :placement top-end)
"Nondefault layer declarations covering every public layer property.")
(ert-deftest ebox-layer-style-schema-owns-geometry-facts ()
"Layer properties use box geometry projections without item participation."
(cl-loop for (name initial) on ebox-layer-style-test--defaults by #'cddr
for property = (ebox-style-property name)
do (should (equal (plist-get property :initial) initial))
do (should (equal (plist-get property :contexts)
'(box row column flex grid)))
do (should-not (plist-get property :inherits))
do (should (eq (plist-get property :signature) 'layout))
do (should (eq (ebox-style-dirty-kind name) 'geometry))
do (should (equal (plist-get property :impacts) '(geometry)))
do (should (equal (plist-get property :projections) '(layout)))
do (should (equal (plist-get property :engine-targets) (list name)))
do (should (eq (plist-get property :owner) 'ebox-box-frame)))
(dolist (name '(:layer :anchor :placement))
(should (eq (plist-get (ebox-style-property name) :namespace) 'ebox))))
(ert-deftest ebox-layer-style-accepts-existing-box-layouts-only ()
"Every box layout accepts layer facts, while Text rejects every property."
(dolist (tag '(box row column flex grid))
(let ((compiled (ebox-style-compile-form
tag ebox-layer-style-test--values)))
(cl-loop for (name value) on ebox-layer-style-test--values by #'cddr
do (should (equal (plist-get compiled (ebox-style-schema-id name))
value)))))
(cl-loop for (name value) on ebox-layer-style-test--defaults by #'cddr
do (should-error (ebox-style-compile-form 'text (list name value)))))
(ert-deftest ebox-layer-style-enums-and-anchor-grammar ()
"Public enums, signed integer depth, and optional semantic anchors compile."
(dolist (case '((:position static relative absolute)
(:z-index -100 0 100)
(:layer local root)
(:anchor nil menu-button "menu-button")
(:placement bottom-start bottom-end top-start top-end)))
(dolist (value (cdr case))
(should (equal (ebox-style-compile-form 'box (list (car case) value))
(list (ebox-style-schema-id (car case)) value))))))
(ert-deftest ebox-layer-style-rejects-invalid-static-declarations ()
"Invalid layer values signal at both DSL and stylesheet boundaries."
(dolist (case '((:position nil t fixed sticky normal "absolute" (absolute))
(:z-index nil t auto 1.0 -1.5 "2" (px 2) (lambda () 2))
(:layer nil t absolute global "root" (root))
(:anchor 1 1.5 (target) (:id target) [target])
(:placement nil t bottom center left "top-end" (top-end))))
(dolist (value (cdr case))
(ert-info ((format "%S rejects %S" (car case) value))
(should-error (ebox-style-compile-form 'box (list (car case) value)))
(should-error (ebox-style-compile-declarations
(list (car case) value) t))))))
(ert-deftest ebox-layer-style-signed-offset-axis-matrix ()
"Signed offsets accept only typed lengths from their rendering axis."
(dolist (case '((:left inline) (:top block)))
(dolist (unit '(px ch vw lh vh %))
(dolist (amount '(-2 -0.25 0 0.25 2))
(let ((declarations (list (car case) (list unit amount))))
(ert-info ((format "%S" declarations))
(if (memq unit (cdr (assq (cadr case) ebox-size-axis-units)))
(should (ebox-style-compile-form 'box declarations))
(should-error (ebox-style-compile-form 'box declarations)
:type 'ebox-size-error)))))))
(dolist (name '(:left :top))
(dolist (value '(nil auto none 0 -2 0.25 (2) (px) (lh 1 2)
(em 1) (px "1") (lh -1.0e+INF) (px 0.0e+NaN)
(calc 2) (calc (/ (% 2) 0))))
(should-error (ebox-style-compile-form 'box (list name value))))))
(ert-deftest ebox-layer-style-offset-expressions-preserve-axis-and-sign ()
"Expression operands keep axis validation even when they cancel out."
(dolist (case '((:left (calc (- (ch 1) (% 50))))
(:left (min (px -2) (vw -10)))
(:left (clamp (ch -3) (% -20) (px 4)))
(:top (calc (+ (lh -0.5) (vh 10))))
(:top (max (lh -2) (% -30)))
(:top (clamp (lh -3) (% -20) (vh 4)))))
(should (equal (ebox-style-compile-form 'box case)
(list (ebox-style-schema-id (car case)) (cadr case)))))
(dolist (case '((:left (calc (* 0 (lh -1))))
(:left (min (ch -1) (vh 0)))
(:top (calc (* 0 (px -1))))
(:top (max (lh -1) (vw 0)))))
(should-error (ebox-style-compile-form 'box case) :type 'ebox-size-error)))
(ert-deftest ebox-layer-style-defaults-do-not-inherit ()
"A child starts with layer defaults despite its parent's layer facts."
(let* ((ebox-style-stylesheet (ecss-stylesheet-create))
(subject (ecss-subject-create :type "box"))
(parent (ebox-style-compute-subject
subject (ebox-style-compile-form
'box ebox-layer-style-test--values)))
(child (ebox-style-compute-subject subject nil parent)))
(cl-loop for (name initial) on ebox-layer-style-test--defaults by #'cddr
do (should (equal (ebox-style-computed-value child name)
initial)))))
(ert-deftest ebox-layer-style-projection-preserves-contextual-offsets ()
"Projection retains signed fractional lengths for final used placement."
(let* ((ebox-style-stylesheet (ecss-stylesheet-create))
(style (ebox-style-compute-subject
(ecss-subject-create :type "box")
(ebox-style-compile-form
'box ebox-layer-style-test--values)))
(snapshot (ebox-style--computed-snapshot style))
(projected (ebox-style--expand-engine-plist
(ebox-style--box-values style snapshot)))
(text (ebox-style--text-values style snapshot))
(item (ebox-style--context-values style 'item)))
(cl-loop for (name value) on ebox-layer-style-test--values by #'cddr
do (should (equal (plist-get projected name) value))
do (should-not (plist-member text name))
do (should-not (plist-member item name)))))
(ert-deftest ebox-layer-style-changes-exclude-paint-only-proof ()
"A change to any layer declaration changes the layout signature."
(cl-loop for (name value) on ebox-layer-style-test--values by #'cddr
for old = (ebox-style-compile-form
'box (list name (plist-get
ebox-layer-style-test--defaults name)))
for new = (ebox-style-compile-form 'box (list name value))
do (should-not (ebox-style--paint-declarations-equivalent-p old new))
do (should-not (equal (ebox-style-signature old '(layout))
(ebox-style-signature new '(layout))))
do (should-not (ebox-style-signature new '(paint)))))
(ert-deftest ebox-layer-style-runtime-removal-restores-defaults ()
"Removing or resetting declarations clears every old box layer fact."
(let* ((ebox-style-stylesheet (ecss-stylesheet-create))
(subject (ecss-subject-create :type "box"))
(positioned (ebox-style-compute-subject
subject (ebox-style-compile-form
'box ebox-layer-style-test--values))))
(dolist (tag '(box row column flex grid))
(dolist (declarations (list nil ebox-layer-style-test--defaults))
(let* ((node (ebox-canonical-input--single-root
(ebox-build (list tag "Unchanged")) "layer style test"))
(children (ebox-tree-node-children node))
(config (plist-get node :ebox-layout-config))
(reset (ebox-style-compute-subject
subject (ebox-style-compile-form 'box declarations))))
(ebox-style-apply-computed node positioned)
(ebox-style-apply-computed node reset)
(cl-loop for (name initial) on ebox-layer-style-test--defaults by #'cddr
do (should (equal (plist-get node name) initial)))
(should (equal (ebox-tree-node-children node) children))
(should (equal (plist-get node :ebox-layout-config) config)))))))
(ert-deftest ebox-layer-style-runtime-transfer-preserves-current-projection ()
"Source-stable transfers copy layer values and restore cleared defaults."
(let* ((ebox-style-stylesheet (ecss-stylesheet-create))
(subject (ecss-subject-create :type "box")))
(dolist (tag '(box row column flex grid))
(dolist (declarations (list ebox-layer-style-test--values nil))
(let* ((old (ebox-canonical-input--single-root
(ebox-build (list tag "Old")) "layer style test"))
(new (ebox-canonical-input--single-root
(ebox-build (list tag "New")) "layer style test"))
(children (ebox-tree-node-children new))
(style (ebox-style-compute-subject
subject (ebox-style-compile-form 'box declarations))))
;; Start both nodes with stale nondefaults, so copying a cleared
;; projection must overwrite them as well as retaining active facts.
(ebox-style-apply-computed
old (ebox-style-compute-subject
subject (ebox-style-compile-form
'box ebox-layer-style-test--values)))
(ebox-style-apply-computed old style)
(ebox-style-apply-computed
new (ebox-style-compute-subject
subject (ebox-style-compile-form
'box (if declarations
ebox-layer-style-test--defaults
ebox-layer-style-test--values))))
(ebox-style--transfer-computed-projection old new style)
(cl-loop for (name value)
on (or declarations ebox-layer-style-test--defaults) by #'cddr
do (should (equal (plist-get new name) value)))
(should (equal (ebox-tree-node-children new) children)))))))
(ert-deftest ebox-layer-style-stylesheet-updates-reset-runtime-projection ()
"Changing and removing a rule updates the same canonical node's fields."
(let* ((ebox-style-stylesheet (ecss-stylesheet-create))
(node (ebox-canonical-input--single-root
(ebox-build '(box :class "panel" "Panel")) "layer style test"))
(subject (ecss-subject-create :type "box" :classes '("panel"))))
(dolist (values (list ebox-layer-style-test--values
'(:position relative :left (px 3)
:top (lh 2) :z-index 10 :layer local :anchor nil
:placement bottom-end)
ebox-layer-style-test--defaults))
(ebox-style-add-rule ".panel" values)
(ebox-style-apply-computed
node (ebox-style-compute-subject subject nil))
(cl-loop for (name value) on values by #'cddr
do (should (equal (plist-get node name) value))))
(ebox-style-add-rule ".panel" ebox-layer-style-test--values)
(ebox-style-apply-computed node (ebox-style-compute-subject subject nil))
(setq ebox-style-stylesheet (ecss-stylesheet-create))
(ebox-style-apply-computed node (ebox-style-compute-subject subject nil))
(cl-loop for (name value) on ebox-layer-style-test--defaults by #'cddr
do (should (equal (plist-get node name) value)))))
(ert-deftest ebox-layer-style-runtime-defaults-stay-off-text ()
"Applying a style with layer facts does not materialize them onto Text."
(let* ((ebox-style-stylesheet (ecss-stylesheet-create))
(node (ebox-canonical-input--single-root
(ebox-build '(text "Text")) "layer style test"))
(style (ebox-style-compute-subject
(ecss-subject-create :type "text")
(ebox-style-compile-form
'box ebox-layer-style-test--values))))
(ebox-style-apply-computed node style)
(cl-loop for (name _value) on ebox-layer-style-test--defaults by #'cddr
do (should-not (plist-member node name)))))
(provide 'ebox-layer-style-tests)
;;; ebox-layer-style-tests.el ends here

81
tests/ebox-layer-tests.el Normal file
View File

@ -0,0 +1,81 @@
;;; ebox-layer-tests.el --- Box composition contracts -*- lexical-binding: t; -*-
(require 'ert)
(require 'ebox)
(defun ebox-layer-test--render (form)
"Render FORM through the public canonical boundary."
(ebox-render (ebox-build form)))
(ert-deftest ebox-layer-minimal-opaque-overlap ()
(let ((output
(ebox-layer-test--render
'(box :width (ch 10) :height (lh 3)
(box "abcdefghij\nABCDEFGHIJ\n0123456789")
(box :position absolute :left (ch 2) :top (lh 1)
:width (ch 4) :height (lh 1) :background-color "red"
"TOP")))))
(should (equal (substring-no-properties output)
"abcdefghij\nABTOP GHIJ\n0123456789"))
(should (= (length (ebox-string-lines output)) 3))))
(ert-deftest ebox-layer-relative-reserves-flow-slot ()
(let ((lines
(ebox-string-lines
(ebox-layer-test--render
'(column :width (ch 10) :height (lh 3)
(box :position relative :left (ch 2) :top (lh 1)
:z-index 1 :width (ch 4) "MOVE")
(box "abcdefghij")
(box "0123456789"))))))
(should (= (length lines) 3))
(should-not (string-match-p "MOVE" (car lines)))
(should (equal (substring-no-properties (cadr lines)) "abMOVEghij"))
(should (equal (substring-no-properties (nth 2 lines)) "0123456789"))))
(ert-deftest ebox-layer-negative-z-stays-below-normal-content ()
(let ((output (ebox-layer-test--render
'(box :width (ch 6) :height (lh 1)
(box "BOTTOM")
(box :position absolute :width (ch 6) :z-index -1 "HIDDEN")))))
(should (equal (substring-no-properties output) "BOTTOM"))))
(ert-deftest ebox-layer-nested-isolation-orders-groups ()
(let ((output (ebox-layer-test--render
'(box :width (ch 6) :height (lh 1)
(box
(box "BOTTOM")
(box :position absolute :width (ch 6)
:z-index 100 "INSIDE"))
(box :position absolute :width (ch 6) :z-index 1 "FRONT!")))))
(should (equal (substring-no-properties output) "FRONT!"))))
(ert-deftest ebox-layer-root-projection-escapes-local-clipping ()
(let ((output (ebox-layer-test--render
'(box :width (ch 10) :height (lh 3)
(box :height (lh 1) :overflow hidden
(box :id trigger "BUTTON")
(box :position absolute :layer root :anchor trigger
:width (ch 4) :height (lh 1) "MENU"))
(box "abcdefghij\n0123456789")))))
(should (equal (substring-no-properties output)
"BUTTON \nMENUefghij\n0123456789"))))
(ert-deftest ebox-layer-missing-anchor-does-not-paint ()
(let ((output (ebox-layer-test--render
'(box :width (ch 6) :height (lh 1)
(box "BOTTOM")
(box :position absolute :anchor missing
:width (ch 6) "HIDDEN")))))
(should (equal (substring-no-properties output) "BOTTOM"))))
(ert-deftest ebox-layer-hidden-upper-reveals-base ()
(let ((output (ebox-layer-test--render
'(box :width (ch 6) :height (lh 1)
(box "LATEST")
(box :position absolute :visibility hidden
:width (ch 6) "HIDDEN")))))
(should (equal (substring-no-properties output) "LATEST"))))
(provide 'ebox-layer-tests)
;;; ebox-layer-tests.el ends here