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