diff --git a/Makefile b/Makefile
index c841540..d74212a 100644
--- a/Makefile
+++ b/Makefile
@@ -23,6 +23,12 @@ NATIVE_RELEASE_DIR = native/target/$(NATIVE_TARGET)/release
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:
$(EMACS_TEST) -l tests/ebox-interaction-tests.el -f ert-run-tests-batch-and-exit
diff --git a/docs/user/ebox-api-reference.en.md b/docs/user/ebox-api-reference.en.md
index 87df6c5..09448b3 100644
--- a/docs/user/ebox-api-reference.en.md
+++ b/docs/user/ebox-api-reference.en.md
@@ -74,6 +74,7 @@ another public node or wrapper.
| Paint | `:color`, `:background-color`, exact alias `:bgcolor`, border colors, `:visibility` |
| Text layout | `:text-align`, `:wrap-mode` (`word`, `char`, `kp`, `none`) |
| 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
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
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
`text`, `box`, `row`, `column`, `flex`, and `grid` accept these four explicit
diff --git a/docs/user/ebox-api-reference.zh.md b/docs/user/ebox-api-reference.zh.md
index 8e554a6..a1166b9 100644
--- a/docs/user/ebox-api-reference.zh.md
+++ b/docs/user/ebox-api-reference.zh.md
@@ -69,6 +69,7 @@ participation property 就是普通子 Box property,不会创建另一种公
| 绘制 | `:color`、`:background-color`、精确 alias `:bgcolor`、border color、`:visibility` |
| 文本布局 | `:text-align`、`:wrap-mode`(`word`、`char`、`kp`、`none`) |
| Overflow | `:overflow`(`visible`、`hidden`、`scroll`) |
+| 图层放置 | `:position`、`:left`、`:top`、`:z-index`、`:layer`、`:anchor`、`:placement` |
`hidden` 在组合子节点后约束内容宽度,保留完整的受支持文本簇;容器自身的 padding 与
border 不在裁剪区域内。被裁掉的子文本同时移除其属性。固定 display space 可以缩短,
@@ -95,6 +96,43 @@ context/value 组合无效时会直接报错。百分比参照、字体单位、
[用户指南的几何章节](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` 都接受下面四个显式节点属性。
diff --git a/docs/user/ebox-user-guide.en.md b/docs/user/ebox-user-guide.en.md
index 181e7b5..ccbdc09 100644
--- a/docs/user/ebox-user-guide.en.md
+++ b/docs/user/ebox-user-guide.en.md
@@ -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. |
| 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. |
| 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
parent. The child-layout algorithm still comes from the form name.
+
+
+### 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
`:wrap-mode word` wraps ordinary words and permits character breaks for CJK;
diff --git a/docs/user/ebox-user-guide.zh.md b/docs/user/ebox-user-guide.zh.md
index 5a28cc8..ebfe5fa 100644
--- a/docs/user/ebox-user-guide.zh.md
+++ b/docs/user/ebox-user-guide.zh.md
@@ -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 列轨道。 |
| 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)` 中的 basis;row 方向用 inline 单位,column 方向用 block 单位。 |
| 边框绘制厚度 | `px`、`ch`、`lh`、`vw`、`vh` | `border-width`、各侧边框宽度、border 简写中的宽度;不接受百分比。 |
@@ -242,6 +243,65 @@ Text 的样式声明只接受字体、前景/背景和文本装饰 property;
用 `:outer inline` 或 `:outer block` 表达 Box 如何参与父布局。子布局算法仍由 form
名称决定。
+
+
+### 保留式图层
+
+在现有 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 依赖
`:wrap-mode word` 按普通单词换行,并允许 CJK 字符换行;`char` 按 Ebox 支持的常见
diff --git a/ebox-composite.el b/ebox-composite.el
new file mode 100644
index 0000000..1de7718
--- /dev/null
+++ b/ebox-composite.el
@@ -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
diff --git a/ebox-incremental.el b/ebox-incremental.el
index 216b497..196fbb3 100644
--- a/ebox-incremental.el
+++ b/ebox-incremental.el
@@ -101,6 +101,9 @@
"ebox-native-commit" (old-state candidate-state prepared))
(declare-function ebox-native-reflow-release-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
(ebox-incremental-context-port
@@ -182,6 +185,9 @@
(defvar ebox--render-cache-table nil
"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
"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;
the complete viewport context remains part of the render signature.
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))))
(prog1 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)))
(ebox--render-cache-store-context probe rendered metadata)
(ebox--record-render-output-provenance rendered))
- rendered))))))
+ rendered)))))))
;;; 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
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
(old-state candidate-state dirty-set)
"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."
(if (= (length owner-plan) 1)
(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
path-copied-p)
(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;
;; raw string length is not the geometry 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)))
(old-content
(plist-get
@@ -10239,6 +10348,11 @@ role, and outside-complement compatibility before publication."
(append
(list :viewport-width (plist-get old-state :viewport-width)
: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
(plist-get candidate-state :runtime-revision)
:display-signature (plist-get prepared :display-signature)
@@ -10486,8 +10600,11 @@ role, and outside-complement compatibility before publication."
'structure))
(plist-get entry :range-delta)))
(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
- (and (not native-program-p)
+ (and (not layer-owner-plan) (not native-program-p)
(not (cl-some
(lambda (entry)
(eq (plist-get entry :dirty-kind) 'paint))
@@ -10495,7 +10612,7 @@ role, and outside-complement compatibility before publication."
(ebox-incremental--formatting-context-reflow-proof
buffer old-state candidate-state prepared)))
(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)
(cl-some
(lambda (entry)
@@ -10532,6 +10649,7 @@ role, and outside-complement compatibility before publication."
(plist-get mixed-proof :paint-owner-ids)))))
(owner-plan
(cond
+ (layer-owner-plan layer-owner-plan)
((/= (ebox-tree-author-style-count
(plist-get old-state :root-node))
(ebox-tree-author-style-count
@@ -10577,7 +10695,9 @@ role, and outside-complement compatibility before publication."
(ebox-incremental--paint-projection-p
buffer old-state prepared candidate-state owner-plan)))
(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
buffer old-state prepared candidate-state owner-plan)))
(projection-kind
@@ -10593,6 +10713,8 @@ role, and outside-complement compatibility before publication."
(span-proof 'span-patch))))
(when 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
(setq prepared (plist-put prepared :mixed-owner-proof mixed-proof))
(setq report
@@ -10722,8 +10844,11 @@ the visible replacement independently before publishing either one."
(defun ebox-incremental--prepare-scoped-candidate
(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.
+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
whether NEXT-ROOT shares untouched published nodes."
(let* ((ebox-incremental--allocated-slot-proof-cache
@@ -10783,8 +10908,16 @@ whether NEXT-ROOT shares untouched published nodes."
(ebox-source--index-derived-stale-p
(plist-get candidate-state :source-index)))
(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
- (and (not scroll-transaction-p)
+ (and (not layer-owner-plan) (not scroll-transaction-p)
(not (cl-some
(lambda (entry)
(eq (plist-get entry :dirty-kind) 'paint))
@@ -10792,7 +10925,8 @@ whether NEXT-ROOT shares untouched published nodes."
(ebox-incremental--formatting-context-reflow-proof
buffer old-state candidate-state prepared)))
(owner-plan
- (if formatting-proof
+ (if layer-owner-plan layer-owner-plan
+ (if formatting-proof
(list
(ebox--patch-op
'formatting-context-reflow
@@ -10804,9 +10938,9 @@ whether NEXT-ROOT shares untouched published nodes."
candidate-state))
(ebox-incremental--layout-owner-plan
buffer old-state candidate-state
- (plist-get prepared :dirty-set))))))
+ (plist-get prepared :dirty-set)))))))
(mixed-proof
- (and (not scroll-transaction-p)
+ (and (not layer-owner-plan) (not scroll-transaction-p)
(ebox-incremental--mixed-owner-proof
buffer old-state candidate-state prepared owner-plan)))
(scope-node-ids
@@ -10818,7 +10952,8 @@ whether NEXT-ROOT shares untouched published nodes."
(ebox-incremental--paint-projection-p
buffer old-state prepared candidate-state owner-plan))
(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
buffer old-state prepared candidate-state owner-plan
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))
owner-plan)))
(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
(setq prepared (plist-put prepared :mixed-owner-proof mixed-proof)))
(list :prepared prepared
@@ -10849,6 +10984,7 @@ whether NEXT-ROOT shares untouched published nodes."
:span-proof span-proof
:mixed-proof mixed-proof
:formatting-proof formatting-proof
+ :layer-recompose-p (and layer-owner-plan t)
:projection-kind projection-kind
:path-copied-p path-copied-p)))
@@ -10870,13 +11006,15 @@ runtime candidate whose identities already match the published root."
(let* ((candidate
(ebox-incremental--prepare-scoped-candidate
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
;; strict span proof succeeds. Widened projection would mutate shared
;; layout nodes, so promote it to a private copy before the surface sees
;; it. The ordinary full-copy path remains the correctness fallback.
(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)
'(span-patch owner-scoped scroll-patch
mixed-owner-reflow))))
@@ -10886,8 +11024,11 @@ runtime candidate whose identities already match the published root."
(ebox-tree-copy-node-structure
(plist-get (plist-get candidate :prepared) :root))
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))
+ (scroll-transaction-p
+ (and scroll-transaction-p
+ (not (plist-get candidate :layer-recompose-p))))
(candidate-state (plist-get candidate :candidate-state))
(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)))
(when 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
(setq report
(plist-put report :ancestor-slot-count
diff --git a/ebox-layer.el b/ebox-layer.el
new file mode 100644
index 0000000..ecfff2b
--- /dev/null
+++ b/ebox-layer.el
@@ -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
diff --git a/ebox-layout.el b/ebox-layout.el
index df56f1f..7ca7bab 100644
--- a/ebox-layout.el
+++ b/ebox-layout.el
@@ -17,6 +17,7 @@
(require 'ebox-size)
(require 'ebox-buffer-backend)
(require 'ebox-runtime-index)
+(require 'ebox-layer)
(declare-function ekp-pixel-justify "ekp" (string line-pixel))
(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
box
(lambda ()
- (pcase kind
+ (let ((render-flow
+ (lambda (children)
+ (pcase kind
('normal
(ebox--render-normal-box-children box children))
('row
@@ -1363,7 +1366,12 @@ Return its empty inline extent through `ebox--box-content-empty-width'."
(ebox-layout-config-props
(plist-get box :ebox-layout-config))
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
(not (and (eq kind 'normal)
(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)
"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))))
(defun ebox--total-pixel (box)
@@ -2079,6 +2088,15 @@ Internal implementation of `ebox-render' for box nodes."
rendered))
(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.
NODE can be:
- 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
(make-hash-table :test 'eq))))
(when (listp node)
+ (ebox-layer-validate-node node)
(ebox--ensure-node-id node)
(ebox-buffer-validate-border-capability node))
(let ((type (plist-get node :ebox-type)))
diff --git a/ebox-native-commit.el b/ebox-native-commit.el
index a97950e..a7ca1ba 100644
--- a/ebox-native-commit.el
+++ b/ebox-native-commit.el
@@ -509,8 +509,10 @@ new session with the same bounded configuration used by retained frames."
(when (ebox-runtime-index-like-p nodes)
(ebox-runtime-index-map
(lambda (_node-id node)
- (unless (memq (plist-get node :ebox-type)
- '(box concat stack flex))
+ (unless (and (memq (plist-get node :ebox-type)
+ '(box concat stack flex))
+ (not (ebox-layer-host-p node))
+ (not (ebox-layer-positioned-p node)))
(setq supported nil)))
nodes))
supported))
diff --git a/ebox-native-reflow.el b/ebox-native-reflow.el
index d489f52..8963748 100644
--- a/ebox-native-reflow.el
+++ b/ebox-native-reflow.el
@@ -2428,6 +2428,7 @@ Grid deliberately stays on the ordinary Ebox renderer until the native
backend has a matching two-dimensional layout contract."
(cond
((or (null node) (stringp node) (not (listp node))) t)
+ ((or (ebox-layer-host-p node) (ebox-layer-positioned-p node)) nil)
((eq (plist-get node :visibility) 'hidden) nil)
((not (ebox-native-reflow--node-static-sizes-p node)) nil)
((plist-get node :ebox-child-sequence) nil)
diff --git a/ebox-style.el b/ebox-style.el
index 37f9dcd..1603071 100644
--- a/ebox-style.el
+++ b/ebox-style.el
@@ -70,6 +70,34 @@
(:name :box-sizing :id ebox/box-sizing :initial border-box
:contexts (box row column flex grid) :group geometry
: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
:contexts (box row column flex grid)
: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."
(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)
"Return non-nil when VALUE is an Ebox font family."
(or (null value)
@@ -1085,6 +1141,12 @@ generated wrappers that already contain exact line breaks."
('positive-integer #'ebox-style--positive-integer-p)
('grid-placement #'ebox-style--grid-placement-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)
('color #'ebox-style--color-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))
(t (cl-every #'valid-p entry)))))
(valid-p value))
- (or (symbolp value)
- (ebox-size-value-for-axis-p value axis))))))
+ (if (eq (plist-get property :validator) 'signed-size)
+ (ebox-style--signed-size-p value axis)
+ (or (symbolp value)
+ (ebox-size-value-for-axis-p value axis)))))))
(defun ebox-style--schema-validator (property)
"Return PROPERTY's grammar and unit-domain validator."
@@ -1243,7 +1307,7 @@ Grid track wrappers are traversed after their structural validator succeeds."
(if (memq (plist-get property :name)
'(:wrap-mode :item-gap :cross-align
:border-top-p :grid-column-span
- :grid-row-span))
+ :grid-row-span :layer :anchor :placement))
'ebox
'css)))
(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)))
('max-vertical-size (not (ebox-style--max-vertical-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
(and (numberp value) (not (ebox-style--positive-integer-p value))))
('grid-placement
@@ -1590,16 +1660,17 @@ When STRICT is non-nil, reject properties outside the Ebox style domain."
declarations)
(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
-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)
(user-error "Ebox style declarations must be an even property list"))
(cl-loop for (name value) on plist by #'cddr
for property = (ebox-style--property name)
when (and property
(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"
(plist-get property :name)))
plist)
@@ -2012,11 +2083,16 @@ explicit generation SOURCE-INDEX; facts are never recovered from its handle."
(ebox-style--custom-property-p property))
append (list property value)))
+(defvar ebox-style--box-runtime-defaults)
+
(defun ebox-style--reset-box-engine-style (box)
"Reset BOX engine style fields to their declared defaults."
(cl-loop for (property value) on ebox--longhand by #'cddr
unless (memq property '(:content :scroll-offset))
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)
(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
unless (memq property '(:content :scroll-offset))
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)
(ebox-style--transfer-node-property old new property))
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--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
(declarations full-declarations style name)
"Return border NAME from delta, complete declarations, STYLE, or initial."
diff --git a/ebox-surface.el b/ebox-surface.el
index 4cc5d76..24b110b 100644
--- a/ebox-surface.el
+++ b/ebox-surface.el
@@ -38,6 +38,8 @@
(defvar ebox--viewport-dependent-subtree-cache)
(defvar ebox--viewport-height-dependent-subtree-cache)
(defvar ebox--render-runtime-revision)
+(defvar ebox--render-source-index)
+(defvar ebox-layer--active-p)
(defvar ebox--render-root-parent-kind)
(defvar ebox--render-cache-table)
(defvar ebox--render-cache-signature-cache)
@@ -2386,6 +2388,7 @@ FRAMEWORK-PARTICIPANT retains post-TP publication facts when non-nil."
success native-settled-p)
(when (and (null on-mismatch)
(or (plist-get state-overrides :owner-scoped-proofs)
+ (plist-get report-base :layer-recompose-p)
(plist-get state-overrides
:scroll-state-transaction)
(> (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--render-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--layout-fragments-table
(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
: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)
"Copy INDEX and replace only entries on CHANGES' cached content lines.
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
(mapcar
(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
state (plist-get proof :owner-id))
- (ebox-surface--range-patch-candidate buffer state proof))
- (ebox-surface--owner-patch-candidate
+ (ebox-surface--range-patch-candidate buffer state proof)))
+ (t (ebox-surface--owner-patch-candidate
buffer state (plist-get proof :owner-id)
(plist-get proof :allocated-width)
(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 :allocation-closure-p)
(plist-get proof :retain-external-owner-suffix-p)
- (and (= (length proofs) 1) previous-state))))
+ (and (= (length proofs) 1) previous-state)))))
proofs)
(list
(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-get previous-state :fractional-pixel-output-p))
(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)
(ebox-native-commit-attach-confirmed-base previous-state state))
(plist-put state :projection-kind projection-kind)
diff --git a/ebox.el b/ebox.el
index 2e55d22..914e67e 100644
--- a/ebox.el
+++ b/ebox.el
@@ -35,7 +35,8 @@
"ebox-layout-config.el"
"ebox-node-factory.el"
"ebox-child-range.el" "ebox-tree.el" "ebox-measure.el"
- "ebox-fragment.el" "ebox-render-context.el" "ebox-layout.el"
+ "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-buffer-backend.el" "ebox-patch-plan.el" "ebox-incremental.el"
"ebox-native-commit.el" "ebox-surface.el" "ebox-viewport.el"
diff --git a/tests/ebox-composite-tests.el b/tests/ebox-composite-tests.el
new file mode 100644
index 0000000..ecbbf4d
--- /dev/null
+++ b/tests/ebox-composite-tests.el
@@ -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
diff --git a/tests/ebox-layer-publication-tests.el b/tests/ebox-layer-publication-tests.el
new file mode 100644
index 0000000..044048e
--- /dev/null
+++ b/tests/ebox-layer-publication-tests.el
@@ -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
diff --git a/tests/ebox-layer-style-tests.el b/tests/ebox-layer-style-tests.el
new file mode 100644
index 0000000..343226a
--- /dev/null
+++ b/tests/ebox-layer-style-tests.el
@@ -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
diff --git a/tests/ebox-layer-tests.el b/tests/ebox-layer-tests.el
new file mode 100644
index 0000000..7a64de2
--- /dev/null
+++ b/tests/ebox-layer-tests.el
@@ -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