fix: preserve standalone layout and source ownership across updates

Expose detached committed snapshots and explicit canonical construction ownership. Preserve scoped publication and rollback, share Box decoration, and reuse completed Flex/Grid work only under proven constraints.
This commit is contained in:
Kinneyzhang 2026-09-06 10:45:47 +08:00
parent 85849c01e7
commit 3bd75f90c8
16 changed files with 2408 additions and 346 deletions

View File

@ -46,6 +46,13 @@ assemble typed nodes with one `ebox-source-builder`, then seal the forest and
its source generation as one `CanonicalEboxInput`; that evaluated API is not a
second author grammar.
Typed Box construction containing `ebox-child-range` descriptors passes its
open builder explicitly as `:source-builder`. The builder must own the Box
and its immediate children's source handles; an empty Range still requires
an open builder. It is used only during construction. To compose an existing canonical input,
use `ebox-canonical-input-roots` and `ebox-canonical-input-import-roots`;
frameworks must not access private canonical fields or dynamic context.
## Layout choices
- `box` creates a normal visual box.
@ -64,10 +71,21 @@ do not require a wrapper node.
- `ebox-commit` atomically publishes a newly built canonical input.
- `ebox-buffer-update-report` returns the last successful update report.
- `ebox-rerender-buffer-with-context` applies an explicit viewport change.
- `ebox-surface-buffer-snapshot` explicitly exports the current committed
`:input`, `:revision`, and `:mount-id` as one plist.
Ebox copies canonical input before assigning runtime identity, so one built
value may be mounted in multiple buffers without sharing live ownership.
Snapshots traverse exported data and TP's latest retained diagnostic report
only when requested; ordinary updates do not create them.
Their canonical input remains usable after later updates or unmounting. Mutable
node data is detached, while immutable source facts and opaque capabilities
(callbacks, keymaps, records) retain identity. The display environment and
external capabilities are not frozen. Querying during a TP transaction or
after unmounting signals an error. Compare both mount ID and revision when
identifying a committed generation; remounting can restart revision numbers.
Ebox registers rollback-capable state only through TP's public structured
participant API. TP 1.0.1 supports the consumer-first migration protocol;
TP 2.0.0 publishes the final v2-only protocol. Missing or malformed structured

View File

@ -42,6 +42,12 @@ Ebox 需要 Emacs 29.1 或更高版本、ECSS 0.1.0 或更高版本,以及 TP
`ebox-source-builder` 组合 typed node再把 forest 与同一代 source facts 封装为一个
`CanonicalEboxInput`;这个 evaluated API 不是第二套 author 语法。
已求值的 Box 构造包含 `ebox-child-range` 时,通过 `:source-builder`
显式传入开放的 builder。它必须拥有 Box 及所有直接子节点的 source handle
空 Range 同样需要开放的 builder。builder 只参与构造,不保留在节点中。
组合已有 canonical input 时,使用 `ebox-canonical-input-roots`
`ebox-canonical-input-import-roots`,框架不应读取私有字段或绑定私有动态上下文。
## 如何选择布局
- `box` 创建普通视觉盒子;
@ -59,10 +65,18 @@ Flex/Grid participation property 直接属于子 `box`,不需要额外 wrapper
- `ebox-commit` 原子发布重新构建的 canonical input
- `ebox-buffer-update-report` 返回最近一次成功更新报告;
- `ebox-rerender-buffer-with-context` 应用显式 viewport 变化。
- `ebox-surface-buffer-snapshot` 显式导出当前提交的 `:input`、`:revision`
`:mount-id`,以一个 plist 返回。
Ebox 会在分配 runtime identity 前复制 canonical input因此同一个 built value 可以
挂载到多个 buffer而不会共享 live ownership。
快照只在显式查询时遍历导出数据和 TP 最近的诊断报告,普通更新不会创建快照。其 canonical input 在后续
更新、卸载后仍可使用;节点中的可变数据独立复制,不可变 source facts 与不透明能力
回调、keymap、record保留身份。快照不冻结显示环境或外部能力。TP 事务期间、
卸载后查询会报错。识别提交版本时同时比较 mount ID 与 revision避免重新挂载后
revision 从头计数导致混淆。
Ebox 只通过 TP 的公开 structured participant API 注册可回滚状态。TP 1.0.1
支持 consumer-first 迁移协议TP 2.0.0 发布最终的 v2-only 协议。structured
participant capability 缺失或格式错误时Ebox 会停止加载,不会选择兼容 writer。

View File

@ -162,7 +162,9 @@ Set this to nil to leave `gc-cons-percentage' unchanged.")
:gc-cons-percentage percentage))
(defun ebox--deferred-render-gc-restore ()
"Restore GC settings saved by `ebox--with-deferred-render-gc'."
"Restore caller GC policy saved by `ebox--with-deferred-render-gc'.
The render burst owns temporary settings, not collection of the entire Emacs
heap. Normal allocation pressure decides when to collect after restoration."
(let ((state ebox--deferred-render-gc-state))
(cl-incf ebox--deferred-render-gc-generation)
(setq ebox--deferred-render-gc-state nil)
@ -172,8 +174,7 @@ Set this to nil to leave `gc-cons-percentage' unchanged.")
(setq gc-cons-threshold
(plist-get state :gc-cons-threshold))
(setq gc-cons-percentage
(plist-get state :gc-cons-percentage))
(garbage-collect))))
(plist-get state :gc-cons-percentage)))))
(defun ebox--deferred-render-gc-enter ()
"Raise GC settings for an interactive render transaction."
@ -408,30 +409,56 @@ foreground color."
'(:inherit default)
(list :foreground foreground))))
(defun ebox-buffer--color-face (color role)
"Return COLOR's canonical face contribution for paint ROLE.
Paint slots retain their named-face address. ROLE is `foreground',
`background', `overline', or `underline'; nil horizontal border colors
retain the ordinary default-color border semantics."
(if (tp-paint-slot-p color)
(tp-paint-slot-face color)
(pcase role
('foreground
(if (eq color 'ebox/default-foreground)
(ebox-buffer--default-foreground-face)
(list :foreground color)))
('background (list :background color))
('overline (list :overline (or color t)))
('underline
(list :underline (append '(:position t) (and color (list :color color)))))
(_ (error "Unknown Ebox color paint role: %S" role)))))
(defun ebox-buffer-paint-text-properties (style role)
"Return Emacs text properties for computed STYLE in paint ROLE.
This backend mapper intentionally accepts computed style facts and emits only
buffer-facing paint properties. Layout-only properties never pass through."
(let ((face (ebox-buffer--font-face style)))
(let ((face (ebox-buffer--font-face style))
named-faces)
(when-let* ((decoration (ebox-buffer--text-decoration-face style)))
(setq face (append face decoration)))
(when-let* ((foreground (ebox-buffer--paint-color style role)))
(setq face
(cond
((tp-paint-slot-p foreground)
(let ((slot-face (tp-paint-slot-face foreground)))
(if face (list slot-face face) slot-face)))
((eq foreground 'ebox/default-foreground)
(append (ebox-buffer--default-foreground-face) face))
(t (plist-put face :foreground foreground)))))
(let ((contribution (ebox-buffer--color-face foreground 'foreground)))
(if (symbolp contribution)
(push contribution named-faces)
(setq face
(if (eq foreground 'ebox/default-foreground)
(append contribution face)
(plist-put face :foreground
(plist-get contribution :foreground)))))))
(when-let* ((background (ebox-buffer--paint-background-color style role)))
(setq face
(if (tp-paint-slot-p background)
(let ((slot-face (tp-paint-slot-face background)))
(if face (list slot-face face) slot-face))
(plist-put face :background background))))
(when face
(list 'face face))))
(let ((contribution (ebox-buffer--color-face background 'background)))
(if (symbolp contribution)
(push contribution named-faces)
(setq face (plist-put face :background
(plist-get contribution :background))))))
;; Keep the anonymous plist writable until every color has been mapped.
;; Named faces compose only at the end, as a flat precedence-ordered list.
(when (or named-faces face)
(list 'face
(cond
((null named-faces) face)
(face (append named-faces (list face)))
((cdr named-faces) named-faces)
(t (car named-faces)))))))
(defun ebox--propertize-typography (string style)
"Apply STYLE's typography to one copy of STRING."
@ -479,9 +506,7 @@ buffer-facing paint properties. Layout-only properties never pass through."
(length (length string)))
(ebox--add-render-face!
string 0 length
(if (tp-paint-slot-p bgcolor)
(tp-paint-slot-face bgcolor)
`(:background ,bgcolor))
(ebox-buffer--color-face bgcolor 'background)
t)
(ebox--register-render-owned-face-values source string)))
@ -492,11 +517,7 @@ buffer-facing paint properties. Layout-only properties never pass through."
(length (length string)))
(ebox--add-render-face!
string 0 length
(cond
((tp-paint-slot-p color) (tp-paint-slot-face color))
((eq color 'ebox/default-foreground)
(ebox-buffer--default-foreground-face))
(t `(:foreground ,color)))
(ebox-buffer--color-face color 'foreground)
t)
(ebox--register-render-owned-face-values source string)))
@ -508,18 +529,12 @@ buffer-facing paint properties. Layout-only properties never pass through."
(when color
(ebox--add-render-face!
string 0 length
(cond
((tp-paint-slot-p color) (tp-paint-slot-face color))
((eq color 'ebox/default-foreground)
(ebox-buffer--default-foreground-face))
(t `(:foreground ,color)))
(ebox-buffer--color-face color 'foreground)
t))
(when bgcolor
(ebox--add-render-face!
string 0 length
(if (tp-paint-slot-p bgcolor)
(tp-paint-slot-face bgcolor)
`(:background ,bgcolor))
(ebox-buffer--color-face bgcolor 'background)
t))
(ebox--register-render-owned-face-values source string)))
@ -529,9 +544,7 @@ buffer-facing paint properties. Layout-only properties never pass through."
(string (copy-sequence source)))
(ebox--add-render-face!
string 0 (length string)
(if (tp-paint-slot-p color)
(tp-paint-slot-face color)
`(:overline ,(or color t)))
(ebox-buffer--color-face color 'overline)
t)
(ebox--register-render-owned-face-values source string)))
@ -541,9 +554,7 @@ buffer-facing paint properties. Layout-only properties never pass through."
(string (copy-sequence source)))
(ebox--add-render-face!
string 0 (length string)
(if (tp-paint-slot-p color)
(tp-paint-slot-face color)
`(:underline (:position t ,@(when color `(:color ,color)))))
(ebox-buffer--color-face color 'underline)
t)
(ebox--register-render-owned-face-values source string)))

View File

@ -63,9 +63,6 @@
(make-hash-table :test #'eq :weakness 'key)
"Declaration-identity keyed validated canonical node facts.")
(defvar ebox-canonical--source-builder nil
"Transaction-local source builder available during typed tree assembly.")
(cl-defstruct (ebox-canonical-facts
(:constructor ebox-canonical--make-facts))
"Already computed node-owned projection of one source declaration set."
@ -397,8 +394,8 @@ only the current canonical-node projection needed before G4c separates paint."
(setq offset after))))
(nreverse anchors)))
(defun ebox-canonical--normalize-children (children)
"Return material children and optional Range sequence for CHILDREN."
(defun ebox-canonical--normalize-children (children source-builder)
"Normalize CHILDREN, using explicit SOURCE-BUILDER for Range ownership."
(let (material segments range-p)
(dolist (entry children)
(if (ebox-child-range--descriptor-p entry)
@ -421,19 +418,27 @@ only the current canonical-node projection needed before G4c separates paint."
(push entry material)))
(setq material (nreverse material)
segments (nreverse segments))
(when (and range-p (not (ebox-source-builder-p
ebox-canonical--source-builder)))
(error "Ebox Range children require one canonical source builder"))
(let ((source-builder (and range-p ebox-canonical--source-builder)))
(when source-builder
(ebox-source--builder-assert-open source-builder)
(dolist (child material)
(unless (ebox-source--builder-record
source-builder (ebox-node-source-handle child))
(error "Ebox child is not owned by :source-builder"))))
(when range-p
(unless (ebox-source-builder-p source-builder)
(error "Ebox Range children require an explicit :source-builder"))
(ebox-source--builder-assert-open source-builder))
(let ((source-builder (and range-p source-builder)))
(list material
(and range-p
(ebox-child-range--build
segments
(lambda (node)
(when-let* ((record
(ebox-source--builder-record
source-builder
(ebox-node-source-handle node))))
(let ((record
(ebox-source--builder-record
source-builder (ebox-node-source-handle node))))
(unless record
(error "Ebox Range child is not owned by :source-builder"))
(ebox-source-record-key record)))
nil t))))))
@ -482,14 +487,17 @@ computed Text measurement facts."
"Create a canonical BoxNode from evaluated PLIST.
`:layout' must occur exactly once as an `ebox-layout-config'.
`:children' is a list of canonical nodes. Every Layout accepts zero or more
children according to its own formatting algorithm. `:source-handle' is the
only author source input."
`:children' is a list of canonical nodes or transparent Range descriptors.
Range children require an open `:source-builder' owning their source handles.
Every Layout accepts zero or more children according to its own formatting
algorithm. `:source-handle' remains the node's only author source input."
(ebox-canonical--validate-plist plist "ebox-box-create")
(let* ((layout (ebox-canonical--required-field
plist :layout "Ebox Box"))
(children-field (ebox-canonical--optional-field
plist :children "Ebox Box"))
(builder-field (ebox-canonical--optional-field
plist :source-builder "Ebox Box"))
(outer-field (ebox-canonical--optional-field
plist :outer "Ebox Box"))
(source-handle (ebox-canonical--source-handle plist "Ebox Box"))
@ -499,7 +507,7 @@ only author source input."
child-sequence
(outer (if outer-field (cdr outer-field) 'block))
(props (ebox-canonical--without-keys
plist (append '(:layout :children :outer :owned-facts)
plist (append '(:layout :children :outer :owned-facts :source-builder)
ebox-canonical--source-fields))))
(unless (ebox-layout-config-p layout)
(error "Ebox Box :layout must be a typed layout config: %S"
@ -509,8 +517,11 @@ only author source input."
(error "Ebox Box :outer must be inline or block: %S" outer))
(unless (proper-list-p children)
(error "Ebox Box :children must be a proper list: %S" children))
(when builder-field
(unless (ebox-source--builder-record (cdr builder-field) source-handle)
(error "Ebox Box is not owned by :source-builder")))
(pcase-let ((`(,material-children ,normalized-sequence)
(ebox-canonical--normalize-children children)))
(ebox-canonical--normalize-children children (cdr builder-field))))
(setq children material-children)
(setq child-sequence normalized-sequence))
(unless (memq (ebox-layout-config-kind layout)

View File

@ -692,8 +692,8 @@ grapheme."
(ebox--normalize-horizontal-size-value
basis ebox--preferred-size-keywords))
(content-size
(ebox--resolve-size-content-pixel
source normalized (ebox--content-max-pixel source))))
(or (ebox--resolve-size-content-pixel source normalized nil)
(ebox--content-max-pixel source))))
(+ (ebox--side-pixel source) content-size)))
(t
(ebox--flex-main-value axis basis 0))))
@ -1058,7 +1058,19 @@ Return a sized render entry containing the rendered string and dimensions."
(and natural-context-compatible-p
(= cross (ebox--flex-sized-entry-cross natural-entry))
(or (not stretch)
(not source-box-p))
(not source-box-p)
;; A numeric cross size already equal to the assigned
;; Box property leaves its complete geometry unchanged.
;; Auto/relative sizes and overflow-expanded output
;; still need the final stretched Box render.
(let ((declared-cross
(ebox-get source (if (eq axis 'row)
:height :width))))
(and (numberp declared-cross)
(= declared-cross
(ebox--flex-box-main-property
source (if (eq axis 'row) 'column 'row)
cross)))))
natural-entry)))
(persistent-signature
(and (not local-entry)
@ -1664,15 +1676,21 @@ metadata and rendering decisions."
rendered))
(defun ebox--render-flex-children (config constraints children)
"Render Flex CONFIG under frame CONSTRAINTS over flat CHILDREN."
(let* ((props (append config constraints))
"Render Flex CONFIG under resolved CONSTRAINTS over flat CHILDREN.
Width is in pixels and height in lines; nil means unbounded, including an
intrinsic max-content measurement. Author units and viewport defaults have
already been resolved by the Box or legacy adapter."
(let* ((props config)
(axis (ebox--flex-axis props))
(main-size (if (eq axis 'row)
(ebox--flex-horizontal-value (plist-get props :width))
(ebox--flex-line-value (plist-get props :height))))
(plist-get constraints :width)
(plist-get constraints :height)))
(cross-size (if (eq axis 'row)
(ebox--flex-line-value (plist-get props :height))
(ebox--flex-horizontal-value (plist-get props :width))))
(plist-get constraints :height)
(plist-get constraints :width)))
;; Nil is an explicit unbounded containing block, not permission for
;; a deeper responsive Flex to recover this Box's outer viewport.
(ebox-viewport-width (plist-get constraints :width))
(inline-viewport (ebox--flex-inline-viewport
axis main-size cross-size))
(items (ebox--flex-collect-items children axis inline-viewport))
@ -1680,9 +1698,8 @@ metadata and rendering decisions."
(nreverse items)
items))
(row-gap (or (ebox--flex-line-value (plist-get props :row-gap) 0) 0))
(column-gap (or (ebox--flex-horizontal-value
(plist-get props :column-gap) 0)
0))
;; FlexConfig stores the gap in pixels after author-unit expansion.
(column-gap (or (plist-get props :column-gap) 0))
(main-gap (if (eq axis 'row) column-gap row-gap))
(cross-gap (if (eq axis 'row) row-gap column-gap))
(lines (ebox--flex-break-lines
@ -1696,12 +1713,15 @@ metadata and rendering decisions."
main-gap cross-gap))))
(defun ebox--flex-box-constraints (box)
"Project BOX content dimensions to private FlexConstraints."
(let ((width (ebox--wrapper-content-viewport-pixel box))
(height (ebox--resolve-size-content-height
"Project BOX content dimensions to resolved private FlexConstraints."
(list :width (ebox--wrapper-content-viewport-pixel box)
:height (ebox--resolve-size-content-height
box (ebox-get box :height) nil)))
(list :width (and width (list width))
:height height)))
(defun ebox--flex-legacy-constraints (props)
"Resolve legacy PROPS' author units and defaults to FlexConstraints."
(list :width (ebox--flex-horizontal-value (plist-get props :width))
:height (ebox--flex-line-value (plist-get props :height))))
(defun ebox--render-flex-box-children (box props children)
"Render canonical BOX Flex PROPS over flat CHILDREN."
@ -1720,7 +1740,7 @@ metadata and rendering decisions."
node
(ebox--render-flex-children
(ebox--plist-remove-keys props '(:width :height))
(ebox--plist-keep-keys props '(:width :height))
(ebox--flex-legacy-constraints props)
(ebox-tree-layout-children node)))))
(defun ebox--flex-prewarm-sized-item (node _target-width)
@ -1753,8 +1773,7 @@ assembles already-rendered items."
(ebox--flex-default-order-p children))
(let* ((row-gap (or (ebox--flex-line-value
(plist-get props :row-gap) 0) 0))
(column-gap (or (ebox--flex-horizontal-value
(plist-get props :column-gap) 0) 0)))
(column-gap (or (plist-get props :column-gap) 0)))
(setq entry
(list :children children
:measure-index 0
@ -1820,15 +1839,17 @@ assembles already-rendered items."
(plist-get node :box)))
(defun ebox--flex-window-props (node)
"Return NODE's resolved Flex properties for lazy row production."
"Return NODE's Flex properties with resolved dimensions for lazy rows."
(if (ebox--flex-window-canonical-node-p node)
(append
(ebox-layout-config-props (plist-get node :ebox-layout-config))
(ebox--flex-box-constraints node))
(ebox--flex-container-content-props
(plist-get node :props)
(plist-get node :raw-props)
(plist-get node :box))))
(let ((props (ebox--flex-container-content-props
(plist-get node :props)
(plist-get node :raw-props)
(plist-get node :box))))
(append (ebox--plist-remove-keys props '(:width :height))
(ebox--flex-legacy-constraints props)))))
(defun ebox--flex-window-wrapper-supported-p (box)
"Return non-nil when BOX can safely wrap an incomplete flex prefix."
@ -2447,15 +2468,11 @@ Return nil when the flex container needs the normal full renderer."
(axis (ebox--flex-axis props))
(children (ebox-tree-layout-children node))
(main-size (if (eq axis 'row)
(ebox--flex-horizontal-value
(plist-get props :width))
(ebox--flex-line-value
(plist-get props :height))))
(plist-get props :width)
(plist-get props :height)))
(cross-size (if (eq axis 'row)
(ebox--flex-line-value
(plist-get props :height))
(ebox--flex-horizontal-value
(plist-get props :width))))
(plist-get props :height)
(plist-get props :width)))
(wrap (plist-get props :flex-wrap)))
(unless (ebox--flex-window-supported-p
node props axis main-size cross-size children)
@ -2465,9 +2482,7 @@ Return nil when the flex container needs the normal full renderer."
(row-gap (or (ebox--flex-line-value
(plist-get props :row-gap) 0)
0))
(column-gap (or (ebox--flex-horizontal-value
(plist-get props :column-gap) 0)
0))
(column-gap (or (plist-get props :column-gap) 0))
(main-gap column-gap)
(cross-gap row-gap)
current
@ -2489,7 +2504,11 @@ Return nil when the flex container needs the normal full renderer."
(when add-gap
(push (ebox--pixel-blank main-size cross-gap)
pieces))
(let* ((line (nreverse current))
;; Match the full renderer's content containing block
;; only while sizing/rendering children. The wrapper
;; below still resolves against its outer viewport.
(let* ((ebox-viewport-width main-size)
(line (nreverse current))
(line-main
(+ (cl-reduce
#'+ line
@ -2532,9 +2551,10 @@ Return nil when the flex container needs the normal full renderer."
(catch 'enough
(while (< index (length children))
(let* ((child (nth index children))
(item (or (ebox--flex-window-cheap-item
child axis index)
(throw 'unsupported nil)))
(item (let ((ebox-viewport-width main-size))
(or (ebox--flex-window-cheap-item
child axis index)
(throw 'unsupported nil))))
(base (plist-get item :hypothetical))
(next-size (+ (or current-size 0)
(if current main-gap 0)
@ -2598,9 +2618,7 @@ known-row render is used only if the new item footprint is not smaller."
(row-gap (or (ebox--flex-line-value
(plist-get props :row-gap) 0)
0))
(column-gap (or (ebox--flex-horizontal-value
(plist-get props :column-gap) 0)
0))
(column-gap (or (plist-get props :column-gap) 0))
(main-gap column-gap))
(when (ebox--flex-default-order-p children)
(let* ((items (ebox--flex-collect-items-for-source-ids
@ -2654,9 +2672,7 @@ Return nil when NODE's flex configuration needs a full rerender."
(row-gap (or (ebox--flex-line-value
(plist-get props :row-gap) 0)
0))
(column-gap (or (ebox--flex-horizontal-value
(plist-get props :column-gap) 0)
0))
(column-gap (or (plist-get props :column-gap) 0))
(main-gap column-gap)
(lines (ebox--flex-break-lines items main-size main-gap wrap)))
(when (eq wrap 'wrap-reverse)

View File

@ -424,8 +424,8 @@ grid-gap aliases are rejected at the author schema boundary."
(when (> rows (length result))
(setq result
(vconcat result
(make-vector (- rows (length result))
(make-vector target-columns nil)))))
(ebox-grid--occupancy (- rows (length result))
target-columns))))
result))
(defun ebox-grid--free-p (matrix row column row-span column-span)
@ -620,6 +620,16 @@ letting the grid fall back to each child's intrinsic width."
(/ (float factor) fr-total))))))))
sizes))
(defun ebox-grid--content-independent-columns-p (tracks count available)
"Return non-nil when TRACKS determine COUNT widths without cell content.
Require a positive definite AVAILABLE width and explicit fixed/fr tracks.
Implicit and intrinsic tracks retain the ordinary measurement path."
(and (numberp available) (> available 0)
(>= (length tracks) count)
(cl-every (lambda (track)
(memq (plist-get track :kind) '(fixed fr)))
tracks)))
(defun ebox-grid--distribute (amount count)
"Return COUNT integer shares that sum to AMOUNT."
(let ((base (if (> count 0) (/ amount count) 0))
@ -673,9 +683,11 @@ size for start/center/end alignment unless it would overflow its track."
(source (gethash node rendered))
(auto-width-p (ebox-grid--auto-width-node-p node))
(justify (or (plist-get props :justify-items) 'stretch))
(stretch-p (memq justify '(normal stretch)))
(overflow-p (> (ebox--string-max-pixel-width source) width)))
(if (and auto-width-p (or stretch-p overflow-p))
(stretch-p (memq justify '(normal stretch))))
(unless (or (and auto-width-p stretch-p) (stringp source))
(error "ebox-grid: Grid item requires missing intrinsic output"))
(if (and auto-width-p
(or stretch-p (> (ebox--string-max-pixel-width source) width)))
(let ((ebox-viewport-width width))
(ebox--render-with-cache node))
source)))
@ -705,10 +717,10 @@ size for start/center/end alignment unless it would overflow its track."
(_ 'top)))))
(defun ebox-grid--entry-string (entry rendered widths heights col-gap row-gap props)
"Render and align ENTRY within its grid rectangle."
"Align ENTRY's already width-sized RENDERED output within its rectangle."
(let* ((width (ebox-grid--entry-size entry widths col-gap))
(height (ebox-grid--row-entry-size entry heights row-gap))
(source (ebox-grid--entry-source entry rendered width props))
(source (gethash (plist-get entry :node) rendered))
(justify (or (plist-get props :justify-items) 'stretch))
(align (or (plist-get props :align-items) 'stretch)))
(ebox-grid--align source width height justify align)))
@ -731,9 +743,11 @@ size for start/center/end alignment unless it would overflow its track."
(aref (aref matrix row) column)))
(defun ebox-grid--entry-start-p (entry row column)
"Return non-nil when ENTRY starts at zero-based ROW and COLUMN."
(and (= row (1- (plist-get entry :row)))
(= column (1- (plist-get entry :column)))))
"Return non-nil when ENTRY's slice starts at zero-based ROW and COLUMN."
(let ((start (1- (plist-get entry :row))))
(and (<= start row)
(< row (+ start (plist-get entry :row-span)))
(= column (1- (plist-get entry :column))))))
(defun ebox-grid--blank (width height)
"Return a blank grid area of WIDTH pixels and HEIGHT lines."
@ -765,9 +779,12 @@ size for start/center/end alignment unless it would overflow its track."
col-gap row-gap props)
strings))
(dotimes (row (length heights))
(let ((parts nil)
(column 0))
(while (< column (length widths))
(let* ((row-height (nth row heights))
(gap-after (if (< row (1- (length heights))) row-gap 0))
(height (+ row-height gap-after))
(parts nil)
(column 0))
(while (and (> height 0) (< column (length widths)))
(let ((entry (ebox-grid--entry-at matrix row column)))
(if (and entry (ebox-grid--entry-start-p entry row column))
(let* ((string (gethash entry strings))
@ -775,15 +792,31 @@ size for start/center/end alignment unless it would overflow its track."
(offset (+ (ebox-grid--sum
(seq-subseq heights start row))
(* row-gap (- row start))))
(height (nth row heights)))
(push (ebox-grid--slice string offset height) parts)
(continues-p
(> (+ start (plist-get entry :row-span)) (1+ row)))
(slice-height (if continues-p height row-height))
(piece (and (> slice-height 0)
(ebox-grid--slice
string offset slice-height))))
;; A spanning cell owns the intervening gap lines too.
;; Other cells leave that same area blank after clipping
;; their output to the allocated row height.
(when (and (> gap-after 0) (not continues-p))
(let ((gap (ebox-grid--blank
(ebox-grid--entry-size entry widths col-gap)
gap-after)))
(setq piece (if piece
(ebox-lines-join (list piece gap))
gap))))
(push piece parts)
(setq column (+ column (plist-get entry :column-span))))
(let ((width (nth column widths)))
(push (ebox-grid--blank width (nth row heights)) parts)
(push (ebox-grid--blank width height) parts)
(setq column (1+ column))))
(when (< column (length widths))
(push (ebox-pixel-space col-gap) parts))))
(setq result
(when parts
(setq result
(append result
(list (apply #'ebox--lines-concat-horizontal
(append
@ -793,11 +826,7 @@ size for start/center/end alignment unless it would overflow its track."
(nreverse parts)
(when (> (plist-get column-layout :trailing) 0)
(list (ebox-pixel-space
(plist-get column-layout :trailing)))))))))
(when (< row (1- (length heights)))
(setq result
(append result
(list (ebox-grid--blank total-width row-gap)))))
(plist-get column-layout :trailing))))))))))
))
(ebox-lines-join
(append
@ -857,14 +886,25 @@ size for start/center/end alignment unless it would overflow its track."
(column-count (nth 3 placed))
(available (plist-get constraints :width))
(height (plist-get constraints :height))
(content-independent-p
(and (ebox-grid--content-independent-columns-p
columns column-count available)
(memq (or (plist-get props :justify-items) 'stretch)
'(normal stretch))))
(rendered (make-hash-table :test 'eq)))
(unless (memq flow '(row column))
(error "ebox-grid: :grid-auto-flow must be `row' or `column'"))
(dolist (entry entries)
(let ((child (plist-get entry :node)))
(let ((ebox-viewport-width nil)
(ebox--intrinsic-layout-measurement t))
(puthash child (ebox--render-with-cache child) rendered))))
;; Neither column sizing nor unconditional stretch consumes this
;; natural output. The final Box render still resolves its own
;; min/max constraints before its output is used to size rows.
(unless (and content-independent-p
(ebox-box-node-p child)
(ebox-grid--auto-width-node-p child))
(let ((ebox-viewport-width nil)
(ebox--intrinsic-layout-measurement t))
(puthash child (ebox--render-with-cache child) rendered)))))
(let* ((widths (ebox-grid--resolve-sizes columns column-count 'columns
entries rendered (cdr gaps)
available auto-columns))

View File

@ -490,6 +490,12 @@ START-INDEX is the first source line index represented by LINES."
(lines region-id content-height padding-line-filler color bgcolor)
"Finish chrome-free top-aligned underfilled scroll LINES in one pass."
(let ((ebox--propertize-private-content-line-p t)
;; Resolve paint inputs once, as `ebox--propertize-colors' does, while
;; retaining the private-line, in-place finishing pass below.
(foreground-face
(and color (ebox-buffer--color-face color 'foreground)))
(background-face
(and bgcolor (ebox-buffer--color-face bgcolor 'background)))
rendered-lines)
(cl-labels
((finish-line (line index source-p)
@ -516,12 +522,12 @@ START-INDEX is the first source line index represented by LINES."
;; `ebox-string-lines' made these source lines private, and blank
;; normalization above creates a fresh filler. Apply the same face
;; properties in place instead of copying every viewport-wide line.
(when color
(when foreground-face
(ebox--add-render-face!
line 0 (length line) `(:foreground ,color) t))
(when bgcolor
line 0 (length line) foreground-face t))
(when background-face
(ebox--add-render-face!
line 0 (length line) `(:background ,bgcolor) t))
line 0 (length line) background-face t))
line))
(cl-loop for line in lines
for index from 0
@ -1018,21 +1024,23 @@ FALLBACK is used for nil, auto, or unavailable viewport-height values."
(when (and (= (length children) 1)
(eq (plist-get (car children) :ebox-kind) 'text)
(not (eq (ebox-get box :wrap-mode) 'kp)))
(throw
'rendered
(ebox--call-with-box-content-context
box
(lambda ()
(let* ((child (car children))
(width (ebox--wrapper-content-viewport-pixel box))
(wrap-mode (ebox-get box :wrap-mode))
(rendered
(let ((ebox--inline-auto-width-intrinsic-p t))
(ebox--render-with-cache child))))
(if (and width (> width 0)
(ebox-style-soft-wrap-p wrap-mode))
(ebox--wrap-text rendered width wrap-mode)
rendered))))))
;; Resolve against the outer containing block. Inside the content
;; context a responsive width would subtract this Box's chrome twice.
(let ((width (ebox--wrapper-content-viewport-pixel box)))
(throw
'rendered
(ebox--call-with-box-content-context
box
(lambda ()
(let* ((child (car children))
(wrap-mode (ebox-get box :wrap-mode))
(rendered
(let ((ebox--inline-auto-width-intrinsic-p t))
(ebox--render-with-cache child))))
(if (and width (> width 0)
(ebox-style-soft-wrap-p wrap-mode))
(ebox--wrap-text rendered width wrap-mode)
rendered)))))))
(let* ((resolved-width (ebox--wrapper-content-viewport-pixel box))
(content-width (and resolved-width (> resolved-width 0)
resolved-width))
@ -1559,19 +1567,14 @@ Internal implementation of `ebox-render' for box nodes."
;; Border Dimensions and Colors
(border-left-pixel (ebox-get box :border-left-pixel))
(border-left-color (ebox-get box :border-left-color))
(border-right-pixel (ebox-get box :border-right-pixel))
(border-right-color (ebox-get box :border-right-color))
(border-top-p (> (ebox-get box :border-top-pixel) 0))
(border-top-color (ebox-get box :border-top-color))
(border-bottom-p (> (ebox-get box :border-bottom-pixel) 0))
(border-bottom-color (ebox-get box :border-bottom-color))
;; Style and Layout
(color (and (ebox-style--text-paint-owner-p box)
(ebox-get box :color)))
(bgcolor (ebox-get box :bgcolor))
(inner-height (+ content-height padding-top padding-bottom))
(padding-line-filler (ebox-pixel-space content-pixel))
(scroll-offset (or (ebox-get box :scroll-offset) 0))
@ -1696,7 +1699,7 @@ Internal implementation of `ebox-render' for box nodes."
;; 保留原有的 ebox-content/ebox-content-idx 属性
(unless simple-scroll-rendered-lines
(setq result
(ebox--maplines
(mapcar
(lambda (line)
(if (and (string-empty-p (string-trim line))
(ebox--line-content-metadata-uniform-p line)
@ -1714,116 +1717,46 @@ Internal implementation of `ebox-render' for box nodes."
'ebox-content-idx 0
'ebox-content-owner region-id)))
line))
result)))
(ebox-string-lines result))))
;; 2. Vertical Padding (Top/Bottom) - 添加区域标记
(when (or (> padding-top 0) (> padding-bottom 0))
(let ((pt-str (when (> padding-top 0)
(ebox--propertize-region
(ebox--pixel-blank content-pixel padding-top)
'ebox-pt region-id)))
(pb-str (when (> padding-bottom 0)
(ebox--propertize-region
(ebox--pixel-blank content-pixel padding-bottom)
'ebox-pb region-id))))
(setq result (ebox--lines-stack-vertical pt-str result pb-str))))
;; Scroll state describes formatted content, independently of chrome.
(puthash region-id box ebox--region-box-table)
;; Intrinsic measurement must leave published scroll state alone: its
;; placeholder content would clear the live lazy producer state.
(unless (or ebox--intrinsic-layout-measurement
ebox--scroll-window-skip-state-rebuild-p)
(if (and (eq overflow 'scroll) (> text-height content-height))
(ebox--scroll-set-state
region-id
(append
(list :scroll-offset scroll-offset
:content-lines formatted-lines
:content-height content-height
:rendered-content-lines scroll-rendered-lines
:box box)
(when scroll-window-result
(list :content-lines-complete-p
(plist-get scroll-window-result :complete)
:scroll-window-producer-spec
(plist-get scroll-window-result :scroll-window-producer-spec)
:render-content-prefix
(plist-get scroll-window-result :render-content-prefix)
:materialize-content-lines
(plist-get scroll-window-result :materialize-content-lines)))))
(ebox--scroll-clear-state region-id)))
;; 3. Horizontal Padding (Left/Right)
(let* ((pl-str (when (> padding-left 0)
(ebox--propertize-region
(ebox--pixel-blank padding-left inner-height)
'ebox-pl region-id)))
(pr-str (when (> padding-right 0)
(ebox--propertize-region
(ebox--pixel-blank padding-right inner-height)
'ebox-pr region-id))))
;; Cache box definition for dynamic content updates
(puthash region-id box ebox--region-box-table)
;; Cache scroll state only when the box can actually move. Most boxes
;; use scroll overflow by default but have no hidden lines; keeping them
;; out of the global scroll table keeps wheel hit-testing cheap.
;; Intrinsic measurement must leave published scroll state alone: its
;; placeholder content always looks fully visible and would clear the
;; live lazy producer state.
(unless (or ebox--intrinsic-layout-measurement
ebox--scroll-window-skip-state-rebuild-p)
(if (and (eq overflow 'scroll)
(> text-height content-height))
(ebox--scroll-set-state
region-id
(append
(list :scroll-offset scroll-offset
:content-lines formatted-lines
:content-height content-height
:rendered-content-lines scroll-rendered-lines
:box box)
(when scroll-window-result
(list :content-lines-complete-p
(plist-get scroll-window-result :complete)
:scroll-window-producer-spec
(plist-get scroll-window-result
:scroll-window-producer-spec)
:render-content-prefix
(plist-get scroll-window-result
:render-content-prefix)
:materialize-content-lines
(plist-get scroll-window-result
:materialize-content-lines)))))
(ebox--scroll-clear-state region-id)))
(when (or pl-str pr-str)
(setq result
(ebox--lines-concat-horizontal pl-str result pr-str))))
;; 4. Foreground and Background Color
(when (and (or color bgcolor)
(not simple-scroll-rendered-lines))
;; Ownership, alignment and blank normalization are complete. Compose
;; padding, paint, borders and the host surface as lines, sharing the
;; window wrapper's decoration algorithm instead of repeatedly flattening
;; full-height padding/border strips and splitting the result again.
(if simple-scroll-rendered-lines
;; The existing scroll path has already completed chrome and paint.
(ebox--apply-surface-properties result (ebox-get box :surface-properties))
(setq result
(ebox--maplines
(lambda (line)
(ebox--propertize-colors line color bgcolor))
result)))
;; 5. Left/Right Border - 添加区域标记
(when (or (> border-left-pixel 0) (> border-right-pixel 0))
(let ((bl-str (when (> border-left-pixel 0)
(ebox--propertize-region
(ebox--pixel-border border-left-pixel
inner-height border-left-color)
'ebox-bl region-id)))
(br-str (when (> border-right-pixel 0)
(ebox--propertize-region
(ebox--pixel-border border-right-pixel
inner-height border-right-color)
'ebox-br region-id))))
(setq result
(ebox--lines-concat-horizontal
bl-str result br-str))))
;; 6. Top/Bottom Border (implemented as overline/underline) - 添加区域标记
;;
;; Apply horizontal borders after horizontal padding and side borders are
;; assembled, so the overline/underline spans the same full outer line that
;; owns the left/right border display spaces. This keeps corners visually
;; connected when text-scale changes line metrics.
(when (or border-top-p border-bottom-p)
(let ((lines (ebox-string-lines result)))
(when border-top-p
(setf (car lines)
(propertize
(ebox--propertize-overline (car lines) border-top-color)
'ebox-bt region-id)))
(when border-bottom-p
(setf (car (last lines))
(propertize
(ebox--propertize-underline (car (last lines)) border-bottom-color)
'ebox-bb region-id)))
(setq result (ebox-lines-join lines))))
;; The host interaction surface is the border box. Apply it after all
;; content, padding, and border characters exist, but before margins and
;; visible overflow are appended. Nested text properties retain priority.
(ebox--apply-surface-properties
result (ebox-get box :surface-properties))
(ebox-lines-join
(ebox--window-render-flat-wrapper-chunk-lines
box result 0 region-id padding-line-filler
t t t))))
;; 7. Left/Right Margin - 添加区域标记
(when (or (> margin-left 0) (> margin-right 0))
@ -1976,15 +1909,17 @@ also pass a pre-rendered string while an active surface owns runtime facts."
(let* ((explicit-source-index-p (and base-source-index t))
(handle (and (listp node) (ebox-tree-node-source-handle node)))
(node-id (and (listp node) (plist-get node :node-id)))
(source-state
(and (not explicit-source-index-p) node-id
(cl-find-if
(lambda (state)
(let ((table (plist-get state :node-table)))
(and (hash-table-p table)
(eq node (gethash node-id table)))))
ebox--render-source-states)))
(base-source-index
(or base-source-index
(and node-id
(cl-loop
for state in ebox--render-source-states
for table = (plist-get state :node-table)
when (and (hash-table-p table)
(eq node (gethash node-id table)))
return (plist-get state :source-index)))
(plist-get source-state :source-index)
(and handle
(cl-find-if
(lambda (index)
@ -2009,9 +1944,20 @@ also pass a pre-rendered string while an active surface owns runtime facts."
(if (and (not (stringp node))
(not (ebox-style-cascade-active-p))
(zerop (ebox-tree-author-style-count node))
(not
(ebox-render-context-inline-inheritance-required-p
node source-index)))
(or
;; A retained node already carries its ancestor cascade.
;; Probe an isolated copy of those computed facts instead
;; of publishing the detached subtree with a whole-tree
;; source index. Explicit input and active stylesheets
;; still use the complete canonical projection boundary.
(and source-state
(eq base-source-index
(plist-get source-state :source-index))
(ecss-computed-style-p
(plist-get node :ebox-computed-style)))
(not
(ebox-render-context-inline-inheritance-required-p
node source-index))))
(let ((ebox--region-id-counter ebox--region-id-counter)
(ebox--runtime-node-id-counter ebox--runtime-node-id-counter))
(ebox--render-ephemeral-static node))
@ -2126,6 +2072,15 @@ TARGET-WIDTH is the containing block width used to pad narrower leaves."
(window-lines (plist-get window :lines))
(rendered (unless window
(ebox--render-with-cache leaf)))
(wrap-mode
(and (eq (plist-get leaf :ebox-kind) 'text)
(or ebox--containing-wrap-mode (ebox-get leaf :wrap-mode))))
(rendered
(if (and rendered (> target-width 0)
(ebox-style-soft-wrap-p wrap-mode))
(ebox--lines-justify
(ebox--wrap-text rendered target-width wrap-mode) target-width)
rendered))
(width (cond
((plist-member window :pixel-width)
(plist-get window :pixel-width))
@ -2628,8 +2583,14 @@ last cached leaf instead of walking the stack from the beginning."
rendered-content-lines-tail tail)))
(unless cached-hit
(condition-case err
(let* ((window (ebox--render-scroll-window-source
source limit source-cache))
(let* ((window
(let ((ebox-viewport-width
(or (ebox--wrapper-content-viewport-pixel box)
ebox-viewport-width))
(ebox--containing-wrap-mode
(ebox-get box :wrap-mode)))
(ebox--render-scroll-window-source
source limit source-cache)))
(next-source-cache (plist-get window :cache))
(next-complete-cache (plist-get window :complete))
(source-lines-exact-p
@ -2888,10 +2849,14 @@ are always resolved from BOX so no closure can retain a replaced runtime."
(defun ebox--scroll-window-source-lines-exact-p (box window)
"Return non-nil when WINDOW lines can be used as BOX formatted content."
(and (plist-get window :content-width-exact-p)
(ebox-style-no-soft-wrap-p (ebox-get box :wrap-mode))
(or (ebox-style-no-soft-wrap-p (ebox-get box :wrap-mode))
;; A Column already wrapped its Text leaves; its Box/Flex/Grid
;; children retain their own geometry, including explicit overflow.
(ebox--scroll-window-canonical-column-p box))
(eq (ebox-get box :text-align) 'left)
(= (ebox--content-pixel box)
(or (ebox--viewport-pixel-width nil) 0))))
(let ((content-viewport (ebox--wrapper-content-viewport-pixel box)))
(and (numberp content-viewport)
(= (ebox--content-pixel box) content-viewport)))))
(defun ebox--scroll-window-formatted-lines (box window)
"Return formatted scroll content lines for BOX from WINDOW."
@ -2928,12 +2893,14 @@ are always resolved from BOX so no closure can retain a replaced runtime."
(defun ebox--window-render-flat-wrapper-chunk-lines
(box content-lines start-index region-id padding-line-filler
&optional include-top include-bottom)
"Render a marginless BOX wrapper around CONTENT-LINES in one pass.
&optional include-top include-bottom content-prepared-p)
"Render BOX's border box around CONTENT-LINES in one pass.
START-INDEX and REGION-ID identify the wrapped content lines. INCLUDE-TOP
adds top padding and border chrome. INCLUDE-BOTTOM adds bottom padding and
border chrome. The caller guarantees that BOX has no horizontal margins and
no included vertical margin."
border chrome. This helper does not consume BOX's margins; the caller owns
their outer composition. CONTENT-PREPARED-P means CONTENT-LINES already
carry final ownership, indices and blank normalization, letting a complete Box
and a window chunk share the same chrome compositor."
(let* ((padding-left (ebox-get box :padding-left-pixel))
(padding-right (ebox-get box :padding-right-pixel))
(padding-top (if include-top
@ -3019,12 +2986,18 @@ no included vertical margin."
for idx from start-index
collect
(let* ((blank-replacement-p
(and (string-blank-p source-line)
(and (not content-prepared-p)
(string-blank-p source-line)
(ebox--line-content-metadata-uniform-p source-line)
(not (ebox--line-has-non-content-properties-p
source-line))))
(line
(if blank-replacement-p
(cond
(content-prepared-p
(if (or color bgcolor)
(ebox--propertize-colors source-line color bgcolor)
source-line))
(blank-replacement-p
(let ((content-id
(get-text-property
0 'ebox-content source-line))
@ -3057,9 +3030,10 @@ no included vertical margin."
'ebox-content-owner region-id)
(propertize
padding-line-filler
'ebox-content region-id
'ebox-content-idx idx
'ebox-content-owner region-id))))
'ebox-content region-id
'ebox-content-idx idx
'ebox-content-owner region-id)))))
(t
(let* ((colored-p (or color bgcolor))
(owned-source
(if colored-p
@ -3070,7 +3044,7 @@ no included vertical margin."
colored-p))
(ebox--propertize-content-line
owned-source region-id idx
padding-line-filler))))))
padding-line-filler)))))))
(when (and blank-replacement-p (or color bgcolor))
(setq line (ebox--propertize-colors line color bgcolor)))
(concat border-left-line padding-left-line line
@ -3428,18 +3402,22 @@ only used for incomplete lazy prefixes."
(or (ebox--pixel-blank (ebox--content-pixel box)
content-height)
""))
(let ((ebox-viewport-width
(or content-viewport ebox-viewport-width)))
;; Source children use the inner viewport. Formatting BOX and its
;; retained producers still requires the containing viewport, or its
;; padding and borders would be subtracted from that inner width again.
(let ((viewport-width ebox-viewport-width)
(viewport-height ebox-viewport-height))
(let* ((target-lines
(+ scroll-offset
content-height 1
(ebox--scroll-window-initial-lookahead-lines)))
(window (ebox--render-scroll-window-source
source target-lines))
(window
(let ((ebox-viewport-width
(or content-viewport viewport-width))
(ebox--containing-wrap-mode (ebox-get box :wrap-mode)))
(ebox--render-scroll-window-source source target-lines)))
(formatted-lines
(ebox--scroll-window-formatted-lines box window))
(viewport-width ebox-viewport-width)
(viewport-height ebox-viewport-height))
(ebox--scroll-window-formatted-lines box window)))
(setq ebox--scroll-window-render-result
(list :complete (plist-get window :complete)
:content-height content-height

View File

@ -2152,7 +2152,7 @@ cross-axis stretch and item gap remain explicit without legacy runtime nodes."
(plist-get props :height) 'vertical "auto")
:row-gap (or (ebox--flex-line-value (plist-get props :row-gap) 0) 0)
:column-gap
(or (ebox--horizontal-size-pixels (plist-get props :column-gap) 0) 0)
(or (plist-get props :column-gap) 0)
:items
(vconcat
(mapcar

View File

@ -791,6 +791,119 @@ Headless/test windows retain the body width fallback."
(tp-surface-revision surface)
(error "Ebox buffer has no committed surface revision: %S" buffer)))
(defun ebox-surface--snapshot-root (root)
"Detach ROOT's structural and mutable input data from a live mount.
Opaque capabilities, including functions, keymaps and source handles, keep
identity. No runtime identity, cache or TP attachment is exported."
(let ((copy (ebox-tree-copy-node-structure root))
(copies (make-hash-table :test #'eq)))
(cl-labels
((data (value)
(cond
((or (functionp value) (keymapp value)) value)
((gethash value copies))
((ebox-layout-config-p value)
(let ((result (ebox-layout-config--copy value)))
(puthash value result copies)
result))
((recordp value) value)
((stringp value)
(let ((result (copy-sequence value)) (position 0))
(puthash value result copies)
(while (< position (length value))
(let ((end (next-property-change position value (length value))))
(set-text-properties position end
(data (text-properties-at position value))
result)
(setq position end)))
result))
((consp value)
(let ((result (cons nil nil)))
(puthash value result copies)
(setcar result (data (car value)))
(setcdr result (data (cdr value)))
result))
((hash-table-p value)
(let ((result (copy-hash-table value)))
(puthash value result copies)
(clrhash result)
(maphash (lambda (key item) (puthash (data key) (data item) result))
value)
result))
((char-table-p value)
(let* ((result (copy-sequence value))
(default (char-table-range value nil))
(parent (char-table-parent value))
(missing (make-symbol "no-extra-slot"))
(index 0)
extra)
(puthash value result copies)
;; Traverse only explicit entries; materializing inherited or
;; default values would change later lookup semantics.
(set-char-table-parent result nil)
(set-char-table-range result nil nil)
(map-char-table
(lambda (range item) (set-char-table-range result range (data item)))
result)
(set-char-table-range result nil (data default))
(set-char-table-parent result (data parent))
(while (not (eq missing
(setq extra
(condition-case nil
(char-table-extra-slot value index)
(args-out-of-range missing)))))
(set-char-table-extra-slot result index (data extra))
(setq index (1+ index)))
result))
((vectorp value)
(let ((result (copy-sequence value)))
(puthash value result copies)
(dotimes (index (length result))
(aset result index (data (aref value index))))
result))
((bool-vector-p value)
(let ((result (copy-sequence value)))
(puthash value result copies)
result))
(t value)))
(visit (node)
(cl-loop for tail on node by #'cddr
for key = (car tail)
unless (memq key ebox-tree--child-source-keys)
do (setcar (cdr tail)
(unless (memq key ebox-tree--runtime-source-keys)
(data (cadr tail)))))
(ebox-tree-for-each-direct-child node #'visit)))
(visit copy))
copy))
(defun ebox-surface-buffer-snapshot (buffer)
"Export BUFFER's current committed canonical input on explicit request.
Return a plist with :input, :revision and :mount-id. The integer mount ID
distinguishes remounts whose revisions may coincide. Query is rejected while
a TP transaction is active or BUFFER has no live Ebox surface.
This explicit operation copies node structure and mutable input data, retaining
exact immutable source facts and opaque capabilities such as callbacks and
keymaps. It neither renders nor publishes, and never advances the revision.
Cost is linear in exported data plus TP's latest retained diagnostic report.
The returned input survives later commits and unmounting; it can be rendered
independently. It does not freeze external capabilities or the display
environment, and old Host references gain no authority over another mount."
(when (tp-transaction-active-p)
(error "Ebox committed snapshot is unavailable during a TP transaction"))
(let* ((surface (or (ebox-surface--live-buffer-surface buffer)
(error "Ebox buffer has no committed snapshot: %S" buffer)))
(state (tp-surface-client-state surface))
(revision (tp-surface-revision surface))
(root (ebox-surface--snapshot-root (plist-get state :root-node)))
(builder (ebox-source-builder-create)))
(ebox-source-builder-import builder (plist-get state :source-index))
(list :input (ebox-canonical-input-create
(list root) (ebox-source-builder-finish builder))
:revision revision
:mount-id (plist-get (tp-surface-inspect surface) :id))))
(defun ebox-surface-region-mounts (buffer region-id &optional roles)
"Return REGION-ID's TP-owned output mounts in BUFFER.
When ROLES is non-nil, keep only direct mounts owning a listed Ebox role.
@ -1442,15 +1555,26 @@ PREVIOUS-STATE's node-object table."
(not range-splice-p)))))
(_materialized-source-index
(when (and subjects-required-p
(ebox-source--index-derived-stale-p source-index))
;; A local candidate patches exact handle→subject ancestry in
;; O(changed) while marking document-order selector views stale.
;; Style projection consumes that patched table directly; only a
;; fact-only input with no subjects needs a full tree derivation.
(unless (ebox-source--index-node-subjects source-index)
(setq source-index
(ebox-tree-source-index
root t ebox--render-root-parent-kind source-index)))))
(ebox-source--index-derived-stale-p source-index)
(not
(when-let* ((previous-index
(plist-get previous-state :source-index)))
(and
(ebox-source--index-node-subjects source-index)
(not (ebox-source--index-derived-stale-p previous-index))
(eq (ebox-source--index-subjects source-index)
(ebox-source--index-subjects previous-index))))))
;; A local delta copies touched subjects, while unchanged children
;; can still reference their previous parent subjects. A full
;; style projection needs coherent ancestry for its eq-keyed
;; bindings. A declaration-only rebind instead shares an already
;; validated subject tree and merely invalidates query entries;
;; retain that persistent tree. Never certify a previously stale
;; tree from table identity alone. Local projections above do not
;; request subjects and retain their O(changed) source-index path.
(setq source-index
(ebox-tree-source-index
root t ebox--render-root-parent-kind source-index))))
(subjects (when subjects-required-p
(ebox-tree-subject-index root source-index)))
(selector-tree-snapshot
@ -4094,6 +4218,59 @@ Each owner appears at most once for one rendered fragment."
(setq node-id (gethash node-id parent-table)))))
(nreverse owners)))
(defun ebox-surface--line-layout-dependencies (fragments rendered)
"Record allocation dependencies for one line's FRAGMENTS from RENDERED.
A boundary display-space with only content-owner roles fills its containing
line. Its width depends on descendant content and chrome in that container.
Interior gaps remain outside this permission. Keep publication dependencies
separate from semantic and paint ownership."
(let ((contributors (make-hash-table :test #'equal)))
(dolist (fragment fragments)
(let* ((roles (plist-get fragment :role-ids))
(direct (delete-dups
(cl-loop for (role . id) in roles
unless (eq role 'content-owner)
collect id))))
(when direct
(dolist (owner (delete-dups (mapcar #'cdr roles)))
(dolist (id direct)
;; A container's own padding/border is outside the allocation
;; it fills. Descendant chrome participates in that allocation.
(unless (equal id owner)
(let ((entry
(or (gethash owner contributors)
(puthash
owner
(list :ids nil :seen (make-hash-table :test #'equal)
:start (plist-get fragment :start)
:end (plist-get fragment :end))
contributors))))
(unless (gethash id (plist-get entry :seen))
(plist-put entry :ids (cons id (plist-get entry :ids)))
(puthash id t (plist-get entry :seen)))
(plist-put entry :start
(min (plist-get entry :start)
(plist-get fragment :start)))
(plist-put entry :end
(max (plist-get entry :end)
(plist-get fragment :end))))))))))
(dolist (fragment fragments)
(let* ((roles (plist-get fragment :role-ids))
(start (plist-get fragment :start))
(end (plist-get fragment :end)))
(when (and roles
(cl-every (lambda (role) (eq (car role) 'content-owner))
roles)
(= (- end start) 1)
(= (aref rendered start) ?\s)
(eq (car-safe (get-text-property start 'display rendered))
'space))
(when-let* ((entry (gethash (cdar roles) contributors)))
(when (or (<= end (plist-get entry :start))
(>= start (plist-get entry :end)))
(plist-put fragment :layout-region-ids
(plist-get entry :ids)))))))))
(defun ebox-surface--rendered-fragments (rendered)
"Return an offset index of Ebox-owned property runs in RENDERED.
The complete propertized string remains the sole text storage. Runs keep
@ -4105,7 +4282,7 @@ when it needs to mutate them."
(line 0)
(role-cache (make-hash-table :test #'equal))
(ordinal-table (make-hash-table :test #'equal))
fragments previous-role-ids next-role-ids)
fragments line-fragments previous-role-ids next-role-ids)
(while (< position limit)
(let* ((next (or (next-property-change position rendered limit)
limit))
@ -4136,11 +4313,18 @@ when it needs to mutate them."
:face-baseline baseline
:face-baseline-known-p (and origin t))
fragments)
(push (car fragments) line-fragments)
(when origin
(remove-text-properties
position next (list ebox--paint-origin-property nil) rendered))
(cl-incf line (cl-count ?\n rendered :start position :end next))
(let ((line-count (cl-count ?\n rendered :start position :end next)))
(when (> line-count 0)
(ebox-surface--line-layout-dependencies line-fragments rendered)
(setq line-fragments nil))
(cl-incf line line-count))
(setq position (max next (1+ position)))))
(when line-fragments
(ebox-surface--line-layout-dependencies line-fragments rendered))
(setq fragments (nreverse fragments))
(dolist (fragment fragments)
(if-let* ((roles (plist-get fragment :role-ids)))
@ -4375,20 +4559,40 @@ fragment's complete paint-owner ancestor chain."
(defun ebox-surface--owned-ranges-from-runs
(context leaf state node-objects region-objects attach-p visit-runs)
"Attach merged ownership produced by VISIT-RUNS to candidate LEAF.
VISIT-RUNS receives one callback accepting ROLE-IDS, START, and END.
VISIT-RUNS receives one callback accepting ROLE-IDS, START, END, and optional
layout dependency region ids.
Adjacent ranges merge only when both their owner and opaque tags match."
(let ((active (make-hash-table :test #'eq))
(owner-cache (make-hash-table :test #'equal))
ranges)
(funcall
visit-runs
(lambda (role-ids start end)
(let ((owners (gethash role-ids owner-cache 'ebox/no-owners)))
(lambda (role-ids start end &optional layout-region-ids)
(let* ((cache-key (if layout-region-ids
(cons layout-region-ids role-ids)
role-ids))
(owners (gethash cache-key owner-cache 'ebox/no-owners)))
(when (eq owners 'ebox/no-owners)
(setq owners
(ebox-surface--fragment-owners
role-ids state node-objects region-objects))
(puthash role-ids owners owner-cache))
(when layout-region-ids
(let ((attached (make-hash-table :test #'eq)))
(dolist (owner owners) (puthash (car owner) t attached))
(dolist (region-id layout-region-ids)
(let ((node-id (gethash region-id
(plist-get state :region-node-table))))
(while node-id
(let ((object (gethash node-id node-objects)))
(unless object
(error "Ebox layout dependency has no TP object: %S"
node-id))
(unless (gethash object attached)
(push (list object '(:ebox/layout-output t)) owners)
(puthash object t attached)))
(setq node-id
(gethash node-id (plist-get state :parent-table))))))))
(puthash cache-key owners owner-cache))
(dolist (owner owners)
(let* ((object (nth 0 owner))
(tags (nth 1 owner))
@ -4425,7 +4629,8 @@ Adjacent ranges merge only when both their owner and opaque tags match."
(end (if source-p
(plist-get fragment :end)
(+ offset (length (plist-get fragment :text))))))
(funcall visit (plist-get fragment :role-ids) start end)
(funcall visit (plist-get fragment :role-ids) start end
(plist-get fragment :layout-region-ids))
(setq offset
(if source-p
end
@ -4446,7 +4651,8 @@ Adjacent ranges merge only when both their owner and opaque tags match."
(funcall visit
(plist-get fragment :role-ids)
(+ offset (plist-get fragment :start))
(+ offset (plist-get fragment :end))))
(+ offset (plist-get fragment :end))
(plist-get fragment :layout-region-ids)))
(setq offset (+ offset (length line) 1))))))))
(defun ebox-surface--native-owned-ranges
@ -4750,12 +4956,6 @@ NODE-OBJECTS and REGION-OBJECTS supply retained ownership ranges."
(puthash role-ids chain ebox-surface--paint-node-chain-cache))
chain)))))
(defun ebox-surface--foreground-face (color)
"Return the Ebox foreground face for COLOR."
(if (eq color 'ebox/default-foreground)
'(:inherit default)
(list :foreground color)))
(defun ebox-surface--box-face-contributions (box)
"Return BOX typography and color contributions in render order."
(let (faces)
@ -4766,9 +4966,9 @@ NODE-OBJECTS and REGION-OBJECTS supply retained ownership ranges."
(ebox-buffer--text-decoration-face box)))
(push decoration faces))
(when-let* ((color (plist-get box :color)))
(push (ebox-surface--foreground-face color) faces)))
(push (ebox-buffer--color-face color 'foreground) faces)))
(when-let* ((background (plist-get box :bgcolor)))
(push (list :background background) faces))
(push (ebox-buffer--color-face background 'background) faces))
(nreverse faces)))
(defun ebox-surface--region-face-contributions (box roles ancestor-p)
@ -4778,7 +4978,7 @@ ANCESTOR-P means BOX wraps a more specific rendered surface."
(cond
((memq 'overflow-foreground roles)
(when-let* ((color (plist-get box :color)))
(push (ebox-surface--foreground-face color) faces)))
(push (ebox-buffer--color-face color 'foreground) faces)))
((memq 'bl roles)
(push (ebox-buffer-side-border-face
(plist-get box :border-left-color)) faces))
@ -4792,19 +4992,15 @@ ANCESTOR-P means BOX wraps a more specific rendered surface."
(> (or (plist-get box :border-top-pixel) 0) 0))
(setq faces
(append faces
(list (list :overline
(or (plist-get box :border-top-color) t))))))
(list (ebox-buffer--color-face
(plist-get box :border-top-color) 'overline)))))
(when (and (memq 'bb roles)
(> (or (plist-get box :border-bottom-pixel) 0) 0))
(setq faces
(append
faces
(list
(list :underline
(append '(:position t)
(when-let* ((color
(plist-get box :border-bottom-color)))
(list :color color))))))))
(list (ebox-buffer--color-face
(plist-get box :border-bottom-color) 'underline)))))
faces))
(defconst ebox-surface--node-face-cache-missing

View File

@ -416,6 +416,70 @@
"Return the update property names covered by CASES."
(mapcar #'car cases))
(ert-deftest ebox-normal-single-text-uses-content-width-once ()
"One Text and fragmented Text fit the same padded responsive content box."
(let ((ebox--string-pixel-width-cache (make-hash-table :test 'equal)))
(cl-letf (((symbol-function 'string-pixel-width)
(lambda (string) (* 7 (string-width string))))
((symbol-function 'ebox--space-pixel-width) (lambda () 7)))
(dolist (width '(stretch (viewport) (96)))
(dolist (label '("未完成" "已完成" "Pending"))
(let* ((ebox-viewport-width 96)
(props (list :width width :padding '(0 (14))
:border '(1 solid)))
(single (ebox-render
(apply #'ebox-test-box
(append props (list (ebox-test-text label))))))
(fragmented
(ebox-render
(apply #'ebox-test-box
(append props
(mapcar (lambda (char)
(ebox-test-text (char-to-string char)))
(string-to-list label)))))))
(should (= (ebox-string-height single) 1))
(should (= (ebox--string-max-pixel-width single) 96))
(should (equal (substring-no-properties single)
(substring-no-properties fragmented)))
(should (equal (mapcar #'ebox--string-pixel-width
(ebox-string-lines single))
(mapcar #'ebox--string-pixel-width
(ebox-string-lines fragmented))))))))))
(ert-deftest ebox-normal-single-text-preserves-genuine-narrow-wrap ()
"A real content-width shortage still wraps CJK without losing characters."
(let ((ebox-viewport-width 58)
(ebox--string-pixel-width-cache (make-hash-table :test 'equal)))
(cl-letf (((symbol-function 'string-pixel-width)
(lambda (string) (* 7 (string-width string))))
((symbol-function 'ebox--space-pixel-width) (lambda () 7)))
(let ((single
(ebox-render
(ebox-test-box :width 'stretch :padding '(0 (14))
:border '(1 solid)
(ebox-test-text "未完成"))))
(fragmented
(ebox-render
(ebox-test-box :width 'stretch :padding '(0 (14))
:border '(1 solid)
(ebox-test-text "") (ebox-test-text "")
(ebox-test-text "")))))
(should (= (ebox-string-height single) 2))
(should (equal (mapcar #'ebox--string-pixel-width
(ebox-string-lines single))
'(58 58)))
(should (equal (substring-no-properties single)
(substring-no-properties fragmented)))
(should (string-match-p "未完[^\n]*\n[^\n]*成" single)))
(let ((unwrapped
(ebox-render
(ebox-test-box :width 'stretch :padding '(0 (14))
:border '(1 solid) :wrap-mode 'none
(ebox-test-text "未完成")))))
(should (= (ebox-string-height unwrapped) 1))
(should (= (ebox--string-max-pixel-width unwrapped) 72))
(should (string-match-p "未完成" unwrapped))))))
(ert-deftest ebox-root-background-covers-transparent-layout-wrappers ()
"A root canvas background should remain under transparent child wrappers."
(ebox-test--reset-runtime-state)
@ -700,6 +764,79 @@
"Child Family"))
(should (= (ebox-test--effective-face-value measured-face :height) 120))))
(defun ebox-test--intrinsic-subtree-input (content)
"Return a styled Flex whose intrinsic Column contains CONTENT."
(ebox-test-flex
:width '(240) :color "#112233" :font-family "Parent Family"
:font-size 16 :font-weight 'bold
(ebox-test-column
:id 'item :width 'max-content :flex-shrink 0
(ebox-test-box :id 'label :min-width 'max-content :wrap-mode 'none
(ebox-test-text content)))
(ebox-test-box :id 'sibling :width '(40) (ebox-test-text "other"))))
(ert-deftest ebox-retained-intrinsic-subtree-keeps-inherited-style ()
"A retained subtree probe must keep computed ancestry and private caches."
(ebox-test--reset-runtime-state)
(ebox-test--with-rendered-buffer (ebox-test--intrinsic-subtree-input "alpha")
(let* ((state (ebox--buffer-render-state (current-buffer)))
(root (plist-get state :root-node))
(node (ebox-test--selector-node (current-buffer) "#label"))
(root-before (copy-tree root))
(index (plist-get state :source-index))
(cache (plist-get state :render-cache))
(cache-before (and cache (copy-hash-table cache)))
(expected-face (get-text-property (point-min) 'face))
(ebox--render-source-states (list state))
(ebox--render-source-index index)
(ebox--render-root-parent-kind 'column)
(rendered (ebox--render-node node)))
(should (string-match-p "alpha" rendered))
(should (equal (get-text-property 0 'face rendered) expected-face))
(should (equal (ebox-test--effective-face-value expected-face :family)
"Parent Family"))
(should (eq (ebox-test--effective-face-value expected-face :weight)
'bold))
(should (equal root root-before))
(should (eq index (plist-get state :source-index)))
(when cache
(should (eq cache (plist-get state :render-cache)))
(should (= (hash-table-count cache) (hash-table-count cache-before)))
(maphash (lambda (key value)
(should (eq value (gethash key cache))))
cache-before)))))
(ert-deftest ebox-retained-intrinsic-subtree-requires-exact-source-ownership ()
"A retained probe cannot bless explicit roots, copied nodes, or new rules."
(ebox-test--reset-runtime-state)
(ebox-test--with-rendered-buffer (ebox-test--intrinsic-subtree-input "alpha")
(let* ((state (ebox--buffer-render-state (current-buffer)))
(node (ebox-test--selector-node (current-buffer) "#label"))
(index (plist-get state :source-index))
(ebox--render-source-states (list state))
(ebox--render-source-index index)
(ebox--render-root-parent-kind 'column))
;; Explicit canonical input must still own exactly its complete forest.
(should-error (ebox--render-node node index))
;; A copied node with the same runtime id has no retained-state proof.
(should-error (ebox--render-node (copy-sequence node)))
(let ((ebox-style-stylesheet (ecss-stylesheet-create)))
(ebox-style-add-rule "#label" '(:font-size 24))
(should-error (ebox--render-node node))))))
(ert-deftest ebox-max-content-column-update-keeps-inherited-layout ()
"A public update may intrinsically measure a styled max-content Column."
(ebox-test--reset-runtime-state)
(ebox-test--with-rendered-buffer (ebox-test--intrinsic-subtree-input "alpha")
(ebox-commit (current-buffer)
(ebox-test--intrinsic-subtree-input "alpha expanded"))
(should (string-match-p "alpha expanded" (buffer-string)))
(let ((face (get-text-property (point-min) 'face)))
(should (equal (ebox-test--effective-face-value face :family)
"Parent Family"))
(should (eq (ebox-test--effective-face-value face :weight) 'bold)))
(should (ebox-test--buffer-visually-matches-runtime-render-p))))
(ert-deftest ebox-column-background-owns-blank-lines-with-child-override ()
"A column background should fill its blank lines without hiding child paint."
(ebox-test--reset-runtime-state)
@ -1752,6 +1889,51 @@
(should props)
(should (plist-member props 'face))))
(ert-deftest ebox-buffer-backend-composes-mixed-paint-slots-and-typography ()
"Literal and named colors compose in both directions without losing fonts."
(let* ((foreground (tp-paint-slot-create '(:foreground "#112233")))
(background (tp-paint-slot-create '(:background "#ddeeff")))
(font (ebox-font-project-values
'(ebox/font-weight bold ebox/font-style italic)
'(ebox/font-weight ebox/font-style) nil))
(font-before (ebox-font-paint-face font)))
(dolist (typography '(nil t))
(dolist (slots '((t nil) (nil t) (t t) (nil nil)))
(let* ((style
(append (list :color (if (car slots) foreground "#112233")
:bgcolor (if (cadr slots) background "#ddeeff"))
(and typography
(list :ebox-font-fact font
:text-decoration-line 'underline))))
(before (copy-tree style))
(face (plist-get (ebox-buffer-paint-text-properties
style 'content) 'face))
(faces (if (or (symbolp face) (keywordp (car-safe face)))
(list face)
face)))
;; Emacs accepts a named face, one face plist, or a flat list of
;; those contributions. A named face is never a mutable plist.
(should (cl-every (lambda (entry)
(or (symbolp entry)
(keywordp (car-safe entry))))
faces))
(should (equal (cl-remove-if-not #'symbolp faces)
(append
(and (cadr slots) (list (tp-paint-slot-face background)))
(and (car slots) (list (tp-paint-slot-face foreground))))))
(if (car slots)
(should (memq (tp-paint-slot-face foreground) faces))
(should (ebox-test--face-has-key-value-p face :foreground "#112233")))
(if (cadr slots)
(should (memq (tp-paint-slot-face background) faces))
(should (ebox-test--face-has-key-value-p face :background "#ddeeff")))
(when typography
(should (ebox-test--face-has-key-value-p face :weight 'bold))
(should (ebox-test--face-has-key-value-p face :slant 'italic))
(should (ebox-test--face-has-key-p face :underline)))
(should (equal style before))
(should (equal font-before (ebox-font-paint-face font))))))))
(ert-deftest ebox-text-decoration-projects-to-one-emacs-face ()
"Canonical decoration facts should remain paint-only and visibly compose."
(let* ((style
@ -2709,7 +2891,7 @@
(kill-buffer buffer)))))
(ert-deftest ebox-deferred-render-gc-uses-interactive-burst-threshold ()
"Interactive render bursts should stay bounded and collect when settled."
"Interactive bursts restore caller policy without forcing a global collection."
(let ((gc-cons-threshold 1000)
(gc-cons-percentage 0.1)
(ebox-deferred-render-gc-cons-threshold 2000)
@ -2726,9 +2908,9 @@
(ebox--deferred-render-gc-restore)
(should (= gc-cons-threshold 1000))
(should (= gc-cons-percentage 0.1))
(should (= gc-count 1))
(should (= gc-count 0))
(ebox--deferred-render-gc-restore)
(should (= gc-count 1)))
(should (= gc-count 0)))
(when (timerp ebox--deferred-render-gc-timer)
(cancel-timer ebox--deferred-render-gc-timer)))))
@ -2901,7 +3083,43 @@
(apply (nth 2 current) (nth 3 current)))
(should (= gc-cons-threshold 1000))
(should (= gc-cons-percentage 0.1))
(should (= gc-count 1)))))
(should (= gc-count 0)))))
(ert-deftest ebox-interactive-render-burst-restores-policy-after-nonlocal-exit ()
"Error and quit release the render lease without collecting unrelated heaps."
(dolist (exit '(error quit))
(let ((noninteractive nil)
(gc-cons-threshold 1000000) (gc-cons-percentage 0.1)
(ebox-deferred-render-gc-cons-threshold 4000000)
(ebox-render-gc-cons-percentage 0.8)
(ebox--deferred-render-gc-state nil)
(ebox--deferred-render-gc-timer nil)
(ebox--deferred-render-gc-depth 0)
(ebox--deferred-render-gc-generation 0)
(ebox--render-burst-stack nil)
(ebox--render-burst-records (make-hash-table :test 'eq))
callback observed-exit (collections 0))
(cl-letf (((symbol-function 'run-at-time)
(lambda (_delay _repeat function &rest args)
(setq callback (cons function args)) 'test-timer))
((symbol-function 'garbage-collect)
(lambda () (cl-incf collections))))
(condition-case condition
(ebox-call-with-render-burst
(lambda () (signal exit '("failed render"))))
((error quit) (setq observed-exit (car condition))))
(should (eq observed-exit exit))
(should-not ebox--render-burst-stack)
(should (= 0 (hash-table-count ebox--render-burst-records)))
(should (= 0 ebox--deferred-render-gc-depth))
(should callback)
(apply (car callback) (cdr callback))
(should (= gc-cons-threshold 1000000))
(should (= gc-cons-percentage 0.1))
(should-not ebox--deferred-render-gc-state)
(should-not ebox--deferred-render-gc-timer)
(apply (car callback) (cdr callback))
(should (= 0 collections))))))
(ert-deftest ebox-public-render-burst-nests-around-interactive-commit ()
"A framework burst should own GC before and throughout Ebox commit."
@ -3076,7 +3294,7 @@
(apply (nth 1 scheduled) (nth 2 scheduled))
(should (= gc-cons-threshold 1000))
(should (= gc-cons-percentage 0.1))
(should (= gc-count 1)))))
(should (= gc-count 0)))))
(ebox-test--reset-runtime-state)
(when (and buffer (buffer-live-p buffer))
(kill-buffer buffer)))))
@ -4326,6 +4544,176 @@
(ebox-test--render-input-node parent))
(should (eq (gethash region-id ebox--scroll-global-state) state))))
(defun ebox-test--legacy-box-chrome-lines (box lines region-id filler)
"Decorate prepared LINES using the former string-based Box algorithm.
This independent reference keeps each flattening step to verify the shared
line compositor's complete property output, including empty lines and chrome."
(let* ((pt (floor (ebox-get box :padding-top-height)))
(pb (floor (ebox-get box :padding-bottom-height)))
(pl (ebox-get box :padding-left-pixel))
(pr (ebox-get box :padding-right-pixel))
(bl (ebox-get box :border-left-pixel))
(br (ebox-get box :border-right-pixel))
(height (+ (length lines) pt pb))
(color (and (ebox-style--text-paint-owner-p box)
(ebox-get box :color)))
(bgcolor (ebox-get box :bgcolor))
(result (ebox-lines-join lines)))
(when (or (> pt 0) (> pb 0))
(setq result
(ebox--lines-stack-vertical
(when (> pt 0)
(ebox--propertize-region
(ebox--string-repeat-lines filler pt) 'ebox-pt region-id))
result
(when (> pb 0)
(ebox--propertize-region
(ebox--string-repeat-lines filler pb) 'ebox-pb region-id)))))
(when (or (> pl 0) (> pr 0))
(setq result
(ebox--lines-concat-horizontal
(when (> pl 0)
(ebox--propertize-region
(ebox--pixel-blank pl height) 'ebox-pl region-id))
result
(when (> pr 0)
(ebox--propertize-region
(ebox--pixel-blank pr height) 'ebox-pr region-id)))))
(when (or color bgcolor)
(setq result (ebox--maplines
(lambda (line) (ebox--propertize-colors line color bgcolor))
result)))
(when (or (> bl 0) (> br 0))
(setq result
(ebox--lines-concat-horizontal
(when (> bl 0)
(ebox--propertize-region
(ebox--pixel-border bl height (ebox-get box :border-left-color))
'ebox-bl region-id))
result
(when (> br 0)
(ebox--propertize-region
(ebox--pixel-border br height (ebox-get box :border-right-color))
'ebox-br region-id)))))
(let ((result-lines (ebox-string-lines result)))
(when (> (ebox-get box :border-top-pixel) 0)
(setcar result-lines
(propertize
(ebox--propertize-overline
(car result-lines) (ebox-get box :border-top-color))
'ebox-bt region-id)))
(when (> (ebox-get box :border-bottom-pixel) 0)
(let ((tail (last result-lines)))
(setcar tail
(propertize
(ebox--propertize-underline
(car tail) (ebox-get box :border-bottom-color))
'ebox-bb region-id))))
(setq result (ebox-lines-join result-lines)))
(ebox--apply-surface-properties result (ebox-get box :surface-properties))
(ebox-string-lines result)))
(defun ebox-test--box-full-height-joins (input min-height)
"Render INPUT and record real post-format joins reaching MIN-HEIGHT."
(let* ((handle (ebox-node-source-handle (ebox-test-root input)))
(original-box (symbol-function 'ebox--render-box))
(original-format (symbol-function 'ebox--format-content))
(original-mapconcat (symbol-function 'mapconcat))
active formatting full-height-joins rendered)
(cl-letf (((symbol-function 'ebox--render-box)
(lambda (box)
(let ((previous active))
(setq active (eq (ebox-node-source-handle box) handle))
(unwind-protect (funcall original-box box)
(setq active previous)))))
((symbol-function 'ebox--format-content)
(lambda (box)
(let ((previous formatting))
(setq formatting t)
(unwind-protect (funcall original-format box)
(setq formatting previous)))))
((symbol-function 'mapconcat)
(lambda (function sequence separator)
(let ((result (funcall original-mapconcat
function sequence separator)))
(when (and active (not formatting)
(equal separator "\n")
(>= (ebox-string-height result) min-height))
(push (list (ebox-string-height result)
(string-bytes result))
full-height-joins))
result))))
(setq rendered (ebox-render input)))
(list :rendered rendered :joins (nreverse full-height-joins))))
(ert-deftest ebox-box-chrome-avoids-intermediate-multiline-materialization ()
"One Box should not flatten full-height strips for padding and borders."
(let* ((input
(ebox-build
'(box :width (120) :height 8 :overflow hidden
:padding (1 (3)) :border (1 solid "#112233")
:bgcolor "#ddeeff"
(text "alpha\nbeta"))))
(observed (ebox-test--box-full-height-joins input 6))
(joins (plist-get observed :joins)))
(should (= (ebox-string-height (plist-get observed :rendered)) 8))
;; Alignment joins once; blank normalization retains its line result and
;; the shared compositor adds only the final border-box join.
(ert-info ((format "Materialized (height bytes): %S"
joins))
(should (= (length joins) 2)))))
(ert-deftest ebox-box-chrome-keeps-complete-scroll-materialization ()
"Existing fully painted scroll output must not enter decoration again."
(dolist (text '("alpha\nbeta" "1\n2\n3\n4\n5\n6\n7\n8\n9"))
(let* ((input
(ebox-build
`(box :width (120) :height 8 :overflow scroll :bgcolor "#ddeeff"
(text ,text))))
(observed (ebox-test--box-full-height-joins input 8)))
(should (= (ebox-string-height (plist-get observed :rendered)) 8))
(ert-info ((format "Complete scroll joins: %S" (plist-get observed :joins)))
(should (= (length (plist-get observed :joins)) 1))))))
(ert-deftest ebox-box-chrome-matches-legacy-full-property-output ()
"Shared decoration preserves alignment, margins, overflow and rich blanks."
(ebox-test--reset-runtime-state)
(let ((rich (concat "\n" (propertize "alpha" 'face '(:weight bold)
'help-echo "leaf")
"\n" (propertize " " 'help-echo "rich blank")
"\nomega\n"))
(foreground (tp-paint-slot-create '(:foreground "#112233")))
(background (tp-paint-slot-create '(:background "#ddeeff"))))
(dolist (height '(0 4 8))
(dolist (align '(top center bottom))
(dolist (overflow '(hidden visible scroll))
(dolist (margins '(nil t))
(let* ((input
(ebox-test-box
(ebox-test-text rich) :width '(120) :height height
:padding '(1 (3)) :margin (if margins '(1 (2)) 0)
:border '(1 solid "#112233") :overflow overflow
:color (if margins foreground "#112233")
:bgcolor (if margins background "#ddeeff")
:surface-properties '(help-echo "host" mouse-face highlight)))
(box (ebox-test-root input))
(original
(symbol-function 'ebox--window-render-flat-wrapper-chunk-lines))
actual expected)
(plist-put box :vertical-align align)
(setq actual (ebox-render input))
(cl-letf (((symbol-function 'ebox--window-render-flat-wrapper-chunk-lines)
(lambda (&rest arguments)
(if (nth 7 arguments)
(ebox-test--legacy-box-chrome-lines
(nth 0 arguments) (nth 1 arguments)
(nth 3 arguments) (nth 4 arguments))
(apply original arguments)))))
(setq expected (ebox-render input)))
(ert-info ((format "height=%S align=%S overflow=%S margins=%S"
height align overflow margins))
(should (equal-including-properties actual expected))))))))))
(ert-deftest ebox-flat-preformatted-fixed-box-rejects-layout-work ()
"Fixed wrappers should stay generic when clipping or alignment is required."
(ebox-test--reset-runtime-state)
@ -4339,17 +4727,24 @@
:box-sizing 'border-box :overflow (nth 1 case)
:wrap-mode 'none))
(box (ebox-test-root box-input))
(calls 0)
(raw-calls 0)
(prepared-calls 0)
(original
(symbol-function 'ebox--window-render-flat-wrapper-chunk-lines)))
(plist-put box :vertical-align (nth 2 case))
(plist-put box :ebox-content-width-exact-p t)
(cl-letf (((symbol-function 'ebox--window-render-flat-wrapper-chunk-lines)
(lambda (&rest arguments)
(cl-incf calls)
(if (nth 7 arguments)
(cl-incf prepared-calls)
(cl-incf raw-calls))
(apply original arguments))))
(ebox-test--render-input-node box-input))
(should (= calls 0)))))
;; Clipping/alignment must finish in the generic renderer before the
;; shared decorator consumes prepared lines; raw window input is unsafe.
(should (= raw-calls 0))
;; Complete chrome-free scroll output already owns its final paint.
(should (= prepared-calls (if (eq (nth 1 case) 'scroll) 0 1))))))
(ert-deftest ebox-string-pixel-width-cache-ignores-ebox-ownership-metadata ()
"Reuse visual width measurements across Ebox-owned text properties."
@ -6912,6 +7307,69 @@
(should (equal-including-properties actual expected))
(should-not (ebox--scroll-get-state region-id))))
(ert-deftest ebox-underfilled-scroll-paints-stable-slot-faces ()
"Fused scroll finishing must publish slot face addresses on every line."
(ebox-test--reset-runtime-state)
(let* ((foreground (tp-paint-slot-create '(:foreground "#112233")))
(background (tp-paint-slot-create '(:background "#ddeeff")))
(content (concat (propertize "alpha" 'face '(:weight bold))
"\n \nomega"))
(box (ebox-test-box (ebox-test-text content)
:width 140 :height 5 :overflow 'scroll
:color foreground :bgcolor background))
(region-id (ebox--ensure-region-id (ebox-test-root box)))
(reference (ebox-test-box (ebox-test-text content)
:width 140 :height 5 :overflow 'hidden
:color foreground :bgcolor background))
(original
(symbol-function 'ebox--render-underfilled-simple-scroll-lines))
(finish-calls 0)
actual)
(plist-put (ebox-test-root reference) :region-id region-id)
(cl-letf (((symbol-function 'ebox--render-underfilled-simple-scroll-lines)
(lambda (&rest args)
(cl-incf finish-calls)
(apply original args))))
(setq actual (ebox-render box)))
(should (= finish-calls 1))
(should (equal-including-properties actual (ebox-render reference)))
;; Include rich content, normalized blanks, and added viewport filler.
(let ((lines (ebox-string-lines actual)))
(should (= (length lines) 5))
(dolist (line lines)
(let ((face-values (flatten-tree (get-text-property 0 'face line))))
(should (memq (tp-paint-slot-face background) face-values))
(should-not (seq-some #'tp-paint-slot-p face-values)))))
(should (memq (tp-paint-slot-face foreground)
(flatten-tree (get-text-property 0 'face actual))))
(let ((painted (copy-sequence actual)))
(tp-paint-slot-update foreground '(:foreground "#445566"))
(tp-paint-slot-update background '(:background "#aabbcc"))
(should (equal-including-properties actual painted))
(should (equal (face-attribute (tp-paint-slot-face foreground)
:foreground nil nil)
"#445566"))
(should (equal (face-attribute (tp-paint-slot-face background)
:background nil nil)
"#aabbcc")))))
(ert-deftest ebox-underfilled-scroll-resolves-foreground-faces ()
"Fused scroll finishing must resolve slot and default foreground faces."
(ebox-test--reset-runtime-state)
(cl-letf (((symbol-function 'ebox-buffer--default-foreground-face)
(lambda () '(:foreground "#123456"))))
(dolist (color (list (tp-paint-slot-create '(:foreground "#112233"))
'ebox/default-foreground))
(let ((lines (ebox--render-underfilled-simple-scroll-lines
(list (copy-sequence "alpha"))
1 3 (ebox-pixel-space 140) color "#ddeeff"))
(expected (get-text-property
0 'face
(ebox--propertize-colors "alpha" color "#ddeeff"))))
(should (= (length lines) 3))
(dolist (line lines)
(should (equal (get-text-property 0 'face line) expected)))))))
(ert-deftest ebox-lazy-scroll-rendered-window-uses-preformatted-lines ()
"Lazy scroll windows should not rebuild visible content lines per tick."
(ebox-test--reset-runtime-state)
@ -8113,7 +8571,8 @@
record-scans
(original-seq-take (symbol-function 'seq-take))
(renderer
(ebox--scroll-window-prefix-renderer box :test-source 20 4)))
(ebox--scroll-window-prefix-renderer
(ebox-test-root box) :test-source 20 4)))
(cl-letf (((symbol-function 'ebox--render-scroll-window-source)
(lambda (_source limit &optional _cache)
(cl-incf calls)

View File

@ -72,8 +72,7 @@
(input (ebox-dsl-test--constructor-input tag plist))
(builder (car input))
(node
(let ((ebox-canonical--source-builder builder))
(apply #'ebox-box-create (cdr input))))
(apply #'ebox-box-create :source-builder builder (cdr input)))
(index (ebox-source-builder-finish builder)))
(ebox-canonical-input-create (list node) index)))
@ -876,7 +875,8 @@
"AB"))
(should (equal (mapcar #'ebox--string-pixel-width
(ebox-string-lines rendered))
'(120 120 120)))))
;; Two rows with the default zero gap occupy two lines.
'(120 120)))))
(ert-deftest ebox-canonical-grid-consumes-direct-child-placement ()
"Grid placement should stay on child Boxes without grid-item wrappers."

View File

@ -160,15 +160,15 @@ author identity; opaque source handles remain internal to SourceBuilder."
(ebox-test--normalize-child builder child))
children))
(node
(let ((ebox-canonical--source-builder builder))
(ebox-box-create
(ebox-box-create
:source-builder builder
:layout (ebox-test--layout tag props declarations)
:outer (or (plist-get props :outer) 'block)
:children nodes
:owned-facts
(ebox-canonical-facts-from-declarations tag declarations)
:source-handle
(ebox-test--source-handle builder props declarations))))
(ebox-test--source-handle builder props declarations)))
(index (ebox-source-builder-finish builder)))
(dolist (key ebox-test--direct-fields)
(when (plist-member props key)

View File

@ -4,11 +4,248 @@
(require 'ert)
(load-file (expand-file-name "../ebox.el" (file-name-directory (or load-file-name buffer-file-name))))
(require 'ebox-native-reflow)
(defun ebox-flex-test--render (node)
"Render NODE once for flex tests."
(ebox-render node))
(ert-deftest ebox-flex-resolved-constraints-preserve-pixels-and-unbounded-width ()
"Resolved constraints do not reapply author units or ambient defaults."
(let ((ebox-viewport-width 300)
(config (ebox-layout-config-props (ebox-flex-layout-create))))
(cl-letf (((symbol-function 'ebox--space-pixel-width) (lambda () 7)))
(should (= (ebox--string-max-pixel-width
(ebox--render-flex-children
config '(:width 50 :height nil) '("X")))
50))
(should (= (ebox--string-max-pixel-width
(ebox--render-flex-children
config '(:width nil :height nil) '("X")))
(ebox--string-pixel-width "X")))
;; The private legacy adapter still supplies its omitted-width default.
(should (= (ebox--string-max-pixel-width
(ebox--render-flex
(list :ebox-type 'flex :props config :children '("X"))))
300)))))
(defun ebox-flex-test--intrinsic-toolbar (color &optional grow)
"Return a nested max-content Flex using COLOR and optional GROW."
(ebox-test-flex :width '(240) :flex-wrap 'wrap
(ebox-test-column :width '(40) :flex-grow (if grow 0 1)
(ebox-test-text "Title"))
(ebox-test-flex :id "toolbar" :width 'max-content
:padding '(0 (3)) :margin '(0 (4))
:column-gap '(2) :justify-content 'space-between
:flex-grow (if grow 1 0) :bgcolor color
(ebox-test-box :width '(20) (ebox-test-text "A"))
(ebox-test-box :width '(30) (ebox-test-text "B")))))
(ert-deftest ebox-flex-nested-max-content-survives-paint-and-fresh-render ()
"A paint change preserves the same single-line intrinsic Flex geometry."
(let* ((ebox-viewport-width 240)
(layout (ebox-flex-test--intrinsic-toolbar "white"))
(buffer (generate-new-buffer " *ebox-intrinsic-toolbar*")))
(unwind-protect
(progn
(ebox-render-to-buffer buffer layout)
(dolist (color '("white" "black" "white"))
(ebox-region-update (ebox-region-resolve buffer "toolbar")
:bgcolor color)
(let ((retained (with-current-buffer buffer (buffer-string)))
(fresh (ebox-render
(ebox-flex-test--intrinsic-toolbar color))))
(dolist (rendered (list retained fresh))
(should (= (ebox-string-height rendered) 1))
(should (= (ebox--string-max-pixel-width rendered) 240))
;; 174px title + 4px margin + 3px padding, counted once.
(should (= (ebox--string-pixel-width
(substring rendered 0 (string-match "A" rendered)))
181)))
(should (equal (substring-no-properties retained)
(substring-no-properties fresh))))))
(when (buffer-live-p buffer) (kill-buffer buffer)))))
(ert-deftest ebox-flex-max-content-respects-parent-allocation ()
"Intrinsic measurement does not discard a later definite Flex allocation."
(let* ((ebox-viewport-width 240)
(rendered (ebox-render
(ebox-flex-test--intrinsic-toolbar "white" t))))
(should (= (ebox-string-height rendered) 1))
(should (= (ebox--string-max-pixel-width rendered) 240))
(should (= (ebox--string-pixel-width
(substring rendered 0 (string-match "A" rendered)))
47))
(should (= (ebox--string-pixel-width
(substring rendered 0 (string-match "B" rendered)))
203))))
(ert-deftest ebox-flex-unbounded-constraint-reaches-nested-auto-flex ()
"An auto Flex inside max-content must not recover the outer viewport."
(let* ((ebox-viewport-width 300)
(layout
(ebox-test-flex :width 'max-content
(ebox-test-flex
(ebox-test-box :width '(20) (ebox-test-text "A"))
(ebox-test-box :width '(30) (ebox-test-text "B"))))))
(should (= (ebox--string-max-pixel-width (ebox-render layout)) 50))))
(ert-deftest ebox-flex-padded-lazy-children-use-content-viewport ()
"Lazy responsive Flex items use the same content viewport as full layout."
(let* ((ebox-viewport-width 300)
(ebox-viewport-height 5)
(layout
(ebox-test-column :width '(viewport) :height '(viewport-height)
(ebox-test-flex :width 'stretch :padding '(0 (10))
:flex-wrap 'wrap
(ebox-test-flex :width 'stretch :wrap-mode 'none
(ebox-test-box :width '(20) (ebox-test-text "A"))
(ebox-test-box :width '(30) (ebox-test-text "B"))))))
(_ (ebox-region-ids (ebox-test-root layout)))
(eager (let ((ebox--scroll-window-render-disabled t))
(ebox-render layout)))
(original (symbol-function 'ebox--render-flex-window-lines))
(lazy-renders 0)
lazy)
(cl-letf (((symbol-function 'ebox--render-flex-window-lines)
(lambda (node limit)
(let ((result (funcall original node limit)))
(when result (cl-incf lazy-renders))
result))))
(setq lazy (ebox-render layout)))
(should (> lazy-renders 0))
(should (= (ebox--string-max-pixel-width eager) 300))
(should (= (ebox--string-max-pixel-width lazy) 300))
(should (equal-including-properties lazy eager))))
(ert-deftest ebox-flex-normalized-gap-keeps-pixels-on-wide-fonts ()
"Flex gaps convert character units once and preserve explicit pixel units."
(cl-letf (((symbol-function 'ebox--space-pixel-width) (lambda () 7)))
(dolist (case '((2 . 14) ((2) . 2)))
(let* ((layout
(ebox-test-flex :width '(100) :column-gap (car case)
(ebox-test-text "A")
(ebox-test-text "B")))
(rendered (ebox-render layout))
(left (string-match "A" rendered))
(right (string-match "B" rendered)))
(should left)
(should right)
(should (= (ebox--string-pixel-width
(substring rendered (1+ left) right))
(cdr case)))
(should
(= (plist-get
(ebox-native-reflow--compile-flex-inner
(ebox-layout-config-props
(ebox-box-node-layout (ebox-test-root layout))) nil)
:column-gap)
(cdr case)))))))
(ert-deftest ebox-flex-panels-in-padded-viewport-column-match-eager-layout ()
"Lazy Column layout must preserve nested panel geometry and borders."
(dolist (padding '(0 (1 2)))
(dolist (viewport-width '(140 96 140))
(let* ((ebox-viewport-width viewport-width)
(ebox-viewport-height 30)
(layout
(ebox-test-column
:width '(viewport) :height '(viewport-height)
:padding padding :bgcolor "white"
(ebox-test-flex :width 'stretch :flex-wrap 'wrap :gap '(1 2)
(ebox-test-column
:flex-basis '(20) :flex-shrink 0
:padding '(1 2) :border "gray"
(ebox-test-text "Sidebar"))
(ebox-test-column
:flex-grow 1 :flex-basis '(48) :min-width 0
:padding '(1 2) :border "gray"
(ebox-test-text "Main"))
(ebox-test-column
:flex-grow 1 :flex-basis '(28) :min-width 0
(ebox-test-column :padding '(1 2) :border "gray"
(ebox-test-text "Details") (ebox-test-text "Body"))
(ebox-test-column :padding '(1 2) :border "gray"
(ebox-test-text "Service") (ebox-test-text "Ready"))))))
(_ (ebox-region-ids (ebox-test-root layout)))
(eager (let ((ebox--scroll-window-render-disabled t))
(ebox-render layout)))
(lazy (ebox-render layout)))
(should (equal (substring-no-properties lazy)
(substring-no-properties eager)))
(should (equal (mapcar #'ebox--string-pixel-width
(ebox-string-lines lazy))
(mapcar #'ebox--string-pixel-width
(ebox-string-lines eager))))
(should (equal-including-properties lazy eager))))))
(ert-deftest ebox-flex-viewport-column-wraps-text-and-preserves-box-overflow ()
"A lazy Column wraps raw Text but keeps a definite child's spatial output."
(let* ((ebox-viewport-width 20)
(ebox-viewport-height 10)
(layout
(ebox-test-column
:width '(viewport) :height '(viewport-height) :padding '(0 (2))
(ebox-test-text "one two three four five six")
(ebox-test-box :width '(30) :wrap-mode 'none :border "blue"
(ebox-test-text "wide child"))))
(_ (ebox-region-ids (ebox-test-root layout)))
(eager (let ((ebox--scroll-window-render-disabled t))
(ebox-render layout)))
(lazy (ebox-render layout)))
(should (equal-including-properties lazy eager))))
(ert-deftest ebox-flex-padded-scroll-producers-retain-containing-viewport ()
"Lazy extension and full materialization keep the initial content width."
(let* ((ebox-viewport-width 100)
(ebox-viewport-height 4)
(ebox-scroll-lazy-prefix-lookahead-lines 0)
(ebox-wheel-scroll-step 0)
(ebox-scroll-lazy-idle-prefetch-delay 999)
(layout
(apply #'ebox-test-column
(append
'(:id "padded-scroll" :width (viewport)
:height (viewport-height) :padding (0 (2)))
(cl-loop for index below 30
collect
(ebox-test-column :border "gray"
(ebox-test-text (format "row %02d" index)))))))
(buffer (generate-new-buffer " *ebox-padded-scroll*"))
root-id)
(unwind-protect
(progn
(ebox-render-to-buffer buffer layout)
(setq root-id
(plist-get (car (ebox-selector-query-buffer
buffer "#padded-scroll")) :region-id))
(let* ((state (ebox-scroll-state root-id))
(prefix (plist-get state :render-content-prefix))
(materialize (plist-get state :materialize-content-lines)))
(should prefix)
(should materialize)
(let* ((extended
(ebox--scroll-state-ensure-prefix-lines root-id state 12 t))
(partial-lines (plist-get extended :content-lines))
(complete
(ebox--scroll-state-materialize-lines root-id extended))
(full-lines (plist-get complete :content-lines)))
(should (= (length partial-lines) 12))
(should (= (length full-lines) 30))
(dolist (line (append partial-lines full-lines))
(should (= (ebox--string-pixel-width line) 96)))
(should (cl-every #'identity
(cl-mapcar
(lambda (partial full)
(equal-including-properties
(ebox--strip-paint-origins!
(copy-sequence partial))
(ebox--strip-paint-origins!
(copy-sequence full))))
partial-lines (seq-take full-lines 12)))))))
(when root-id (ebox--scroll-cancel-idle-prefetch root-id))
(when (buffer-live-p buffer) (kill-buffer buffer)))))
(defun ebox-flex-test--plain-lines (node)
"Return rendered NODE as plain lines."
(ebox-string-lines (substring-no-properties (ebox-flex-test--render node))))
@ -276,6 +513,80 @@
(widths (ebox-flex-test--content-widths layout)))
(should (equal widths (list max-content min-content)))))
(ert-deftest ebox-flex-definite-basis-does-not-materialize-fallback ()
"A definite basis must not materialize a subtree for an unused fallback."
(dolist (basis '((0) (40) 40))
(let* ((input
(ebox-test-box
(ebox-test-column
(ebox-test-text "nested content")
(ebox-test-text "second line"))))
(source (ebox-test-root input))
(ebox--surface-materialization-active t)
(expected (ebox--normalize-horizontal-size-value
basis ebox--preferred-size-keywords))
(original-max (symbol-function 'ebox--content-max-pixel))
(original-render (symbol-function 'ebox--render-layout))
(max-calls 0)
(materializations 0))
(cl-letf (((symbol-function 'ebox--content-max-pixel)
(lambda (box)
(cl-incf max-calls)
(funcall original-max box)))
((symbol-function 'ebox--render-layout)
(lambda (node)
(cl-incf materializations)
(funcall original-render node))))
(should (= expected (ebox--flex-basis-main source "" 'row basis))))
(should (zerop max-calls))
(should (zerop materializations)))))
(ert-deftest ebox-flex-basis-retains-required-intrinsic-fallback ()
"Viewport-like basis values measure only when their viewport is absent."
(dolist (viewport '(nil 120))
(dolist (basis '(viewport stretch contain))
(let* ((content "natural content")
(source (ebox-test-root (ebox-test-box (ebox-test-text content))))
(ebox--surface-materialization-active t)
(ebox-viewport-width viewport)
(original-max (symbol-function 'ebox--content-max-pixel))
(max-calls 0))
(cl-letf (((symbol-function 'ebox--content-max-pixel)
(lambda (box)
(cl-incf max-calls)
(funcall original-max box))))
(should (= (or viewport (ebox--string-pixel-width content))
(ebox--flex-basis-main source "" 'row basis))))
(should (= max-calls (if viewport 0 1)))))))
(ert-deftest ebox-flex-lazy-basis-fallback-preserves-public-dsl-output ()
"Public Flex layout retains complete output when unused probes disappear."
(let* ((input
(ebox-build
'(flex :width (140) :flex-wrap wrap :gap (1 (4))
(box :flex-basis (40) :flex-grow 1 :min-width 0
:wrap-mode word :padding (1 (2))
:color "#123456" :bgcolor "#ddeeff"
(column
(text "alpha beta gamma delta epsilon")
(text "second line")))
(box :flex-basis (20) :min-width 0 :wrap-mode word
(text "peer")))))
(original-basis (symbol-function 'ebox--flex-basis-main))
(fast (ebox-render input))
eager)
;; Reintroduce only the eager fallback's materialization, using the same
;; public input and the unchanged resolver for the reference output.
(cl-letf (((symbol-function 'ebox--flex-basis-main)
(lambda (source rendered axis basis)
(when (and (eq axis 'row)
(ebox--flex-box-source-p source)
(ebox--flex-fixed-basis-value-p basis))
(ebox--content-max-pixel source))
(funcall original-basis source rendered axis basis))))
(setq eager (ebox-render input)))
(should (equal-including-properties fast eager))))
(ert-deftest ebox-flex-grow-redistributes-space-after-max-width-clamp ()
"A max-width-clamped item should freeze and leave space for flexible siblings."
(let* ((layout
@ -670,6 +981,205 @@
'(220 220 220 220)))
(should (= calls 2))))
(defun ebox-flex-test--observe-sized-box (input &optional force-final-render)
"Render INPUT and observe its first child's actual Box materializations.
When FORCE-FINAL-RENDER is non-nil, discard only the child's natural sized
entry before final allocation, providing the original rerendering reference."
(let* ((source (car (ebox-tree-layout-children (ebox-test-root input))))
(handle (ebox-node-source-handle source))
(render-box (symbol-function 'ebox--render-box))
(render-sized (symbol-function 'ebox--flex-render-sized-entry))
allocation observations final-source scroll-state rendered)
(cl-letf (((symbol-function 'ebox--render-box)
(lambda (box)
(when (and allocation
(eq (ebox-node-source-handle box) handle))
(push
(list :allocation allocation
:width (ebox-get box :width)
:height (ebox-get box :height)
:constraints
(ebox--render-cache-plist-signature box)
:context
(list ebox-viewport-width ebox-viewport-height
ebox--intrinsic-layout-measurement
ebox--inline-auto-width-intrinsic-p
ebox--containing-wrap-mode
ebox--box-content-cache-purpose
ebox--render-region-id
ebox--render-root-parent-kind
ebox--scroll-window-render-disabled))
observations))
(funcall render-box box)))
((symbol-function 'ebox--flex-render-sized-entry)
(lambda (item axis main cross align)
(let* ((target (eq (ebox-node-source-handle
(plist-get item :source))
handle))
(previous-allocation allocation))
(when (and target cross force-final-render)
(setq item (copy-sequence item))
(plist-put item :render-cache nil))
(setq allocation (and target (list axis main cross align)))
(unwind-protect
(prog1 (funcall render-sized item axis main cross align)
(when (and target cross)
(let ((region-id
(ebox-get (plist-get item :source) :region-id)))
(setq final-source
(ebox-node-source-handle
(gethash region-id ebox--region-box-table))
scroll-state (ebox-scroll-state region-id)))))
(setq allocation previous-allocation))))))
(setq rendered (ebox-render input)))
(list :rendered rendered :observations (nreverse observations)
:source-handle final-source :scroll-state scroll-state)))
(ert-deftest ebox-flex-identical-cross-materializes-box-once ()
"An already assigned numeric cross size must not materialize a Box twice."
(let* ((input
(ebox-build
'(flex :width (120) :flex-wrap wrap
(box :flex-basis (40) :flex-grow 1 :min-width 0
:height 2 :wrap-mode word
(text "short")))))
(observed (ebox-flex-test--observe-sized-box input))
(calls (plist-get observed :observations))
(first (car calls)))
(should (= (ebox-string-height (plist-get observed :rendered)) 2))
(should (equal (mapcar #'ebox--string-pixel-width
(ebox-string-lines (plist-get observed :rendered)))
'(120 120)))
(should (= (plist-get first :width) 120))
(should (= (plist-get first :height) 2))
;; Before the fix both actual Box renders have identical complete node
;; declarations and dynamic contexts; only the caller's allocation differs.
(dolist (call (cdr calls))
(should (equal (plist-get first :constraints)
(plist-get call :constraints)))
(should (equal (plist-get first :context)
(plist-get call :context))))
(ert-info ((format "Actual Box allocations: %S"
(mapcar (lambda (call) (plist-get call :allocation))
calls)))
(should (= (length calls) 1)))))
(ert-deftest ebox-flex-identical-cross-preserves-properties-and-source ()
"Reused row/column output retains chrome, text properties, and source ids."
(dolist (axis '(row column))
(dolist (sizing '(border-box content-box))
(let* ((ebox-viewport-width nil)
(row-p (eq axis 'row))
(label (propertize "alpha beta" 'help-echo "retained leaf"
'mouse-face 'highlight))
(input
(ebox-build
`(flex ,@(if row-p '(:width (140)) '(:height 10))
:flex-direction ,axis :flex-wrap wrap
(box :flex-basis ,(if row-p '(40) 2) :flex-grow 1
:min-width 0 :wrap-mode word :overflow hidden
,@(if row-p
(list :height (if (eq sizing 'border-box) 6 2))
'(:width (60)))
:box-sizing ,sizing :padding (1 (2)) :margin (1 (3))
:border (1 solid "#123456")
:color "#123456" :bgcolor "#ddeeff"
(column :width ,(if row-p '(100) '(40))
(text ,label) (text "second"))))))
(source (car (ebox-tree-layout-children (ebox-test-root input))))
(handle (ebox-node-source-handle source))
(fast (ebox-flex-test--observe-sized-box input))
(reference (ebox-flex-test--observe-sized-box input t))
(rendered (plist-get fast :rendered)))
(ert-info ((format "axis=%S sizing=%S" axis sizing))
(should (= (length (plist-get fast :observations)) 1))
(should (= (length (plist-get reference :observations)) 2))
(should (equal-including-properties
rendered (plist-get reference :rendered)))
(should (eq (plist-get fast :source-handle) handle))
(should (eq (plist-get reference :source-handle) handle))
(let ((position (string-match "alpha" rendered)))
(should position)
(should (equal (get-text-property position 'help-echo rendered)
"retained leaf"))
(should (eq (get-text-property position 'mouse-face rendered)
'highlight))
(should (get-text-property position 'face rendered))))))))
(ert-deftest ebox-flex-identical-cross-keeps-changing-constraints ()
"Automatic, relative, clamped, and enlarged cross constraints rerender."
(dolist (case '(nil
(:height (viewport-height))
(:height 1 :overflow visible)
(:height 2 :peer-height 4)
(:height 4 :max-height 2)
(:height 2 :min-height 3)))
(let* ((ebox-viewport-height 2)
(peer-height (plist-get case :peer-height))
(input
(ebox-build
`(flex :width (120) :flex-wrap wrap
(box :flex-basis (40) :flex-grow 1 :min-width 0
:wrap-mode word
,@(ebox--plist-remove-keys case '(:peer-height))
(text "A\nB"))
,@(when peer-height
`((box :width (20) :height ,peer-height "peer"))))))
(observed (ebox-flex-test--observe-sized-box input)))
(ert-info ((format "Cross constraint: %S" case))
(should (= (length (plist-get observed :observations)) 2))
(should (equal-including-properties
(plist-get observed :rendered)
(plist-get (ebox-flex-test--observe-sized-box input t)
:rendered)))))))
(ert-deftest ebox-flex-identical-cross-keeps-dependent-column-viewport ()
"A numeric cross declaration alone cannot override viewport dependence."
(let* ((ebox-viewport-width nil)
(input
(ebox-build
'(flex :height 4 :flex-direction column :flex-wrap wrap
(box :width (60) :flex-basis 2 :flex-grow 1 :overflow hidden
(box :width (viewport) (text "responsive"))))))
(observed (ebox-flex-test--observe-sized-box input))
(calls (plist-get observed :observations)))
(should (= (length calls) 2))
(should (equal (mapcar (lambda (call) (plist-get call :width)) calls)
'(60 60)))
(should (equal (mapcar (lambda (call) (car (plist-get call :context))) calls)
'(nil 60)))
(should (equal-including-properties
(plist-get observed :rendered)
(plist-get (ebox-flex-test--observe-sized-box input t)
:rendered)))))
(ert-deftest ebox-flex-identical-cross-retains-scroll-state ()
"A reused fixed-height Box retains the already rendered scroll geometry."
(let* ((input
(ebox-build
'(flex :width (120) :flex-wrap wrap
(box :flex-basis (40) :flex-grow 1 :min-width 0
:height 2 :wrap-mode word :overflow scroll
(column (text "A") (text "B") (text "C") (text "D"))))))
(fast (ebox-flex-test--observe-sized-box input))
(reference (ebox-flex-test--observe-sized-box input t))
(state (plist-get fast :scroll-state))
(old-state (plist-get reference :scroll-state)))
(should (= (length (plist-get fast :observations)) 1))
(should (= (length (plist-get reference :observations)) 2))
(should (equal-including-properties
(plist-get fast :rendered) (plist-get reference :rendered)))
(should state)
(should (= (plist-get state :content-height) 2))
(should (= (ebox-get (plist-get state :box) :width) 120))
(should (= (ebox-get (plist-get state :box) :height) 2))
(dolist (property '(:scroll-offset :content-height
:content-lines-complete-p))
(should (equal (plist-get state property) (plist-get old-state property))))
(should (equal-including-properties
(ebox-lines-join (plist-get state :content-lines))
(ebox-lines-join (plist-get old-state :content-lines))))))
(ert-deftest ebox-flex-natural-reuse-preserves-stretched-visible-overflow-ownership ()
"Stretch sizing should absorb visible overflow into the final box region."
(let* ((box (ebox-test-box (ebox-test-text "A\nB") :width '(80) :height 1

View File

@ -109,6 +109,179 @@
entry-left rendered 100 '(:justify-items stretch))))
(should (= calls 1)))))
(ert-deftest ebox-grid-renders-assigned-cell-width-once ()
"Final Grid assembly must reuse the width-sized cell's complete output."
(let* ((cell-region 91001)
(input
(ebox-test-grid
:width '(80)
:grid-template-columns '((minmax (0) (fr 1)) (fr 1))
:column-gap '(4)
(ebox-test-box
:region-id cell-region :width 'stretch :wrap-mode 'word
:color "#112233" :bgcolor "#ddeeff"
(ebox-test-text "alpha beta gamma delta epsilon zeta eta theta"))
(ebox-test-box (ebox-test-text "peer") :width 'stretch)))
(original (symbol-function 'ebox--render-box))
contexts
sized-output
output)
(cl-letf (((symbol-function 'ebox--render-box)
(lambda (box)
(let ((rendered (funcall original box)))
(when (eq (ebox-get box :region-id) cell-region)
(push (list ebox-viewport-width
ebox--intrinsic-layout-measurement)
contexts)
(when (and (equal ebox-viewport-width 38)
(not ebox--intrinsic-layout-measurement))
(setq sized-output rendered)))
rendered))))
(setq output (ebox-render input)))
;; The intrinsic pass is still required by the general Grid algorithm;
;; one final render establishes both row height and the published cell.
(should (= 1 (cl-count '(nil t) contexts :test #'equal)))
(should (= 1 (cl-count '(38 nil) contexts :test #'equal)))
(let ((face (get-text-property
(string-match "alpha" sized-output) 'face sized-output)))
(should face)
(should (equal face (get-text-property
(string-match "alpha" output) 'face output))))
(let ((lines (ebox-string-lines output)))
(should (> (length lines) 1))
(should (equal (mapcar #'ebox--string-pixel-width lines)
(make-list (length lines) 80))))))
(ert-deftest ebox-grid-definite-stretched-cells-skip-unused-intrinsic-output ()
"Definite fixed/fr columns need only the stretched cell's final output."
(dolist (width '(80 120))
(dolist (cell-width '(nil auto stretch contain))
(let* ((cell-region 91002)
(input
(ebox-test-grid
:width (list width)
:grid-template-columns '((fr 1) (28)) :column-gap '(4)
(apply #'ebox-test-box
(append
(list :region-id cell-region :wrap-mode 'word
:color "#112233" :bgcolor "#ddeeff")
(and cell-width (list :width cell-width))
(list
(ebox-test-text
"alpha beta gamma delta epsilon zeta eta theta iota kappa"))))
(ebox-test-box (ebox-test-text "peer") :width 'stretch)))
(original (symbol-function 'ebox--render-box))
(intrinsic-count 0)
(assigned-count 0)
fast slow)
(cl-letf (((symbol-function 'ebox--render-box)
(lambda (box)
(when (eq (ebox-get box :region-id) cell-region)
(if (and (null ebox-viewport-width)
ebox--intrinsic-layout-measurement)
(cl-incf intrinsic-count)
(when (equal ebox-viewport-width (- width 32))
(cl-incf assigned-count))))
(funcall original box))))
(setq fast (ebox-render input)))
(should (zerop intrinsic-count))
(should (= assigned-count 1))
(cl-letf (((symbol-function 'ebox-grid--content-independent-columns-p)
(lambda (&rest _) nil)))
(setq slow (ebox-render input)))
(should (equal-including-properties fast slow))))))
(ert-deftest ebox-grid-intrinsic-elision-preserves-spans-nesting-and-row-height ()
"Reusing final cell output preserves wrapped rows, spans, and paint."
(dolist (alignment '(start center end stretch))
(let* ((input
(ebox-test-grid
:width '(96) :grid-template-columns '((fr 1) (fr 1))
:grid-template-rows '(auto auto auto)
:column-gap '(4) :row-gap 1 :align-items alignment
(ebox-test-column
:grid-column '(1 :span 2) :width 'stretch
:color "#123456" :bgcolor "#ddeeff"
(ebox-test-text "spanning header")
(ebox-test-row
(ebox-test-box (ebox-test-text "control") :padding '(0 (2)))
(ebox-test-box (ebox-test-text "action") :padding '(0 (2)))))
(ebox-test-box
:grid-row '(2 :span 2) :grid-column 1
:width 'stretch :wrap-mode 'word :min-width 'min-content
(ebox-test-text
"alpha beta gamma delta epsilon zeta eta theta iota kappa"))
(ebox-test-box :grid-row 2 :grid-column 2 :width 'stretch
(ebox-test-text "peer"))
(ebox-test-box :grid-row 3 :grid-column 2 :width 'stretch
(ebox-test-text "tail"))))
(fast (ebox-render input))
slow)
(cl-letf (((symbol-function 'ebox-grid--content-independent-columns-p)
(lambda (&rest _) nil)))
(setq slow (ebox-render input)))
(should (equal-including-properties fast slow))
(should (> (ebox-string-height fast) 3)))))
(ert-deftest ebox-grid-keeps-consumed-intrinsic-output ()
"Content tracks, natural alignment, and indefinite sizes keep measurement."
(dolist (case
'((:width (80) :columns (auto (fr 1)))
(:width (80) :columns (min-content (fr 1)))
(:width (80) :columns (max-content (fr 1)))
(:width (80) :columns ((minmax (0) (fr 1)) (fr 1)))
(:width (80) :columns ((40)) :implicit t)
(:width max-content :columns ((fr 1) (fr 1)))
(:width (0) :columns ((fr 1) (fr 1)))
(:width (80) :columns ((fr 1) (fr 1)) :justify start)
(:width (80) :columns ((fr 1) (fr 1)) :justify center)
(:width (80) :columns ((fr 1) (fr 1)) :justify end)
(:width (80) :columns ((fr 1) (fr 1)) :cell-width (20))))
(let* ((cell-region 91003)
(input
(ebox-test-grid
:width (plist-get case :width)
:grid-template-columns (plist-get case :columns)
:grid-auto-flow (if (plist-get case :implicit) 'column 'row)
:justify-items (or (plist-get case :justify) 'stretch)
(ebox-test-box
:region-id cell-region
:width (or (plist-get case :cell-width) 'stretch)
(ebox-test-text "natural content"))
(ebox-test-box (ebox-test-text "peer") :width 'stretch)))
(original (symbol-function 'ebox--render-box))
(intrinsic-count 0))
(cl-letf (((symbol-function 'ebox--render-box)
(lambda (box)
(when (and (eq (ebox-get box :region-id) cell-region)
(null ebox-viewport-width)
ebox--intrinsic-layout-measurement)
(cl-incf intrinsic-count))
(funcall original box))))
(ebox-render input))
(should (> intrinsic-count 0)))))
(ert-deftest ebox-grid-missing-intrinsic-output-requires-unconditional-stretch ()
"Only unconditional width-sized rendering may consume an unmeasured cell."
(let* ((node (ebox-test-root
(ebox-test-box (ebox-test-text "cell") :width 'stretch)))
(entry (list :node node))
(rendered (make-hash-table :test #'eq))
(calls 0))
(cl-letf (((symbol-function 'ebox--render-with-cache)
(lambda (&rest _)
(cl-incf calls)
"assigned")))
(should (equal "assigned"
(ebox-grid--entry-source
entry rendered 40 '(:justify-items stretch))))
(should-error
(ebox-grid--entry-source entry rendered 40 '(:justify-items start)))
(plist-put node :width '(20))
(should-error
(ebox-grid--entry-source entry rendered 40 '(:justify-items stretch)))
(should (= calls 1)))))
(ert-deftest ebox-grid-constrains-auto-width-children-to-track-size ()
"Auto-width grid children should render within their assigned track."
(let* ((ebox-viewport-width 120)
@ -216,6 +389,146 @@
(should (string-match-p "Header" plain))
(should (string-match-p "Left.*Right" plain))))
(ert-deftest ebox-grid-grown-rows-keep-independent-occupancy ()
"Growing several rows must allocate a distinct occupancy vector per row."
(let* ((original (ebox-grid--occupancy 1 2))
(grown (ebox-grid--grow original 3 2)))
(should (eq (aref original 0) (aref grown 0)))
(should-not (eq (aref grown 1) (aref grown 2)))
(aset (aref grown 1) 1 'occupied)
(should-not (aref (aref grown 0) 1))
(should-not (aref (aref grown 2) 1)))
(let* ((input
(ebox-test-grid
:grid-template-columns '((20) (20)) :row-gap 0
(ebox-test-box :grid-row 1 :grid-column '(1 :span 2)
(ebox-test-text "header"))
(ebox-test-box :grid-row '(2 :span 2) :grid-column 1
(ebox-test-text "spanned"))
(ebox-test-box :grid-row 2 :grid-column 2
(ebox-test-text "middle"))
(ebox-test-box :grid-row 3 :grid-column 2
(ebox-test-text "last"))))
(lines (seq-remove
#'string-blank-p
(mapcar #'substring-no-properties
(ebox-string-lines (ebox-render input))))))
(should (= (length lines) 3))
(should (string-match-p "header" (nth 0 lines)))
(should (string-match-p "spanned.*middle" (nth 1 lines)))
(should (string-match-p "last" (nth 2 lines)))))
(ert-deftest ebox-grid-zero-row-gap-adds-no-display-line ()
"Row gaps contribute exactly their declared line count, including zero."
(dolist (gap '(0 1 2))
(let* ((input
(ebox-test-grid
:grid-template-columns '((20) (20))
:grid-template-rows '(1 2) :row-gap gap
(ebox-test-box :grid-row 1 :grid-column '(1 :span 2)
(ebox-test-text "header"))
(ebox-test-box :grid-row 2 :grid-column 1
(ebox-test-text "body\ntail"))
(ebox-test-box :grid-row 2 :grid-column 2
(ebox-test-text ""))))
(lines (ebox-string-lines (ebox-render input))))
(should (= (length lines) (+ 3 gap)))
(should (string-match-p "header" (nth 0 lines)))
(dotimes (index gap)
(should (string-blank-p (nth (1+ index) lines))))
(should (string-match-p "body" (nth (1+ gap) lines)))
(should (string-match-p "tail" (nth (+ 2 gap) lines))))))
(ert-deftest ebox-grid-row-spans-preserve-text-across-gaps ()
"A spanning cell retains text and paint across rows and positive gaps."
(dolist (gap '(0 1 2))
(dolist (alignment '(start center end stretch))
(let* ((input
(ebox-test-grid
:grid-template-columns '((20) (20))
:grid-template-rows '(2 2) :row-gap gap :align-items alignment
(ebox-test-box
:grid-row '(1 :span 2) :grid-column 1
:color "#123456"
:surface-properties '(help-echo "spanning-help")
(ebox-test-text "spanned\ncontinued"))
(ebox-test-box :grid-row 1 :grid-column 2
(ebox-test-text "top"))
(ebox-test-box :grid-row 2 :grid-column 2
(ebox-test-text "bottom"))))
(rendered (ebox-render input))
(lines (ebox-string-lines rendered))
(start (pcase alignment
('center (/ (+ 2 gap) 2))
('end (+ 2 gap))
(_ 0))))
(should (= (length lines) (+ 4 gap)))
(should (string-match-p "spanned" (nth start lines)))
(should (string-match-p "continued" (nth (1+ start) lines)))
(dotimes (index gap)
(should-not (string-match-p "top\\|bottom" (nth (+ 2 index) lines))))
(let ((face (get-text-property (string-match "spanned" rendered)
'face rendered)))
(should face)
(should (equal face (get-text-property
(string-match "continued" rendered)
'face rendered)))
(dolist (label '("spanned" "continued"))
(should (equal "spanning-help"
(get-text-property (string-match label rendered)
'help-echo rendered)))))))))
(ert-deftest ebox-grid-zero-height-track-contributes-only-its-gap ()
"A zero-height track adds no text line, while declared gaps remain real."
(dolist (gap '(0 1 2))
(dolist (rows '((0 1) (1 0)))
(let* ((leading-zero-p (zerop (car rows)))
(input
(ebox-build
`(grid :width (40) :grid-template-columns ((40))
:grid-template-rows ,rows :row-gap ,gap
(text ,(if leading-zero-p "hidden" "visible"))
(text ,(if leading-zero-p "visible" "hidden")))))
(rendered (ebox-render input))
(lines (ebox-string-lines rendered)))
(should (= (length lines) (1+ gap)))
(should-not (string-match-p "hidden" rendered))
(should (string-match-p "visible"
(nth (if leading-zero-p gap 0) lines)))
(dolist (line (if leading-zero-p (butlast lines) (cdr lines)))
(should (string-blank-p line)))))))
(ert-deftest ebox-grid-row-span-preserves-lines-across-zero-height-track ()
"A zero-height row within a span changes offsets only through its gaps."
(dolist (gap '(0 1 2))
(let* ((height (+ 2 (* 2 gap)))
(labels (cl-loop for index below height
collect (format "line-%d" index)))
(input
(ebox-test-grid
:width '(40) :grid-template-columns '((20) (20))
:grid-template-rows '(1 0 1) :row-gap gap
(ebox-test-box
:grid-row '(1 :span 3) :grid-column 1
:surface-properties '(help-echo "spanning-help")
(ebox-test-text (mapconcat #'identity labels "\n")))
(ebox-test-box :grid-row 1 :grid-column 2
(ebox-test-text "top"))
(ebox-test-box :grid-row 2 :grid-column 2
(ebox-test-text "hidden"))
(ebox-test-box :grid-row 3 :grid-column 2
(ebox-test-text "bottom"))))
(rendered (ebox-render input))
(lines (ebox-string-lines rendered)))
(should (= (length lines) height))
(should-not (string-match-p "hidden" rendered))
(cl-loop for label in labels
for line in lines
do (should (string-match-p label line))
do (should (equal "spanning-help"
(get-text-property (string-match label line)
'help-echo line)))))))
(ert-deftest ebox-grid-uses-implicit-track-templates ()
"Implicit columns should use `:grid-auto-columns' when they grow."
(let* ((node (ebox-test-grid

View File

@ -5,6 +5,65 @@
(require 'ert)
(require 'ebox)
(ert-deftest ebox-canonical-range-construction-has-explicit-source-ownership ()
"Typed Range construction needs only a public builder, with exact ownership."
(let* ((builder (ebox-source-builder-create))
(box-handle (ebox-source-builder-bind builder :identity 'range-parent))
(text-handle (ebox-source-builder-bind builder :identity 'range-text :key 'first))
(text (ebox-text-create
:value "explicit range" :source-handle text-handle
:owned-facts (ebox-canonical-facts-from-declarations 'text nil)))
(arguments
(list :layout (ebox-normal-layout-create)
:children (list (ebox-child-range 'items text))
:source-handle box-handle
:owned-facts (ebox-canonical-facts-from-declarations 'box nil))))
(should-error (apply #'ebox-box-create arguments))
(should-error
(apply #'ebox-box-create :source-builder (ebox-source-builder-create) arguments))
(let* ((node (apply #'ebox-box-create :source-builder builder arguments))
(input (ebox-canonical-input-create
(list node) (ebox-source-builder-finish builder))))
(should (eq text-handle
(ebox-node-source-handle (car (ebox-box-node-children node)))))
(should (equal '((:ref items :before 0 :after 1))
(ebox-box-node-range-anchors node)))
(should (string-match-p "explicit range" (ebox-render input)))
(should-error (apply #'ebox-box-create :source-builder builder arguments)))))
(ert-deftest ebox-canonical-builder-validates-empty-ranges-and-static-siblings ()
"Empty ranges retain ownership, and static siblings cannot borrow foreign facts."
(let* ((builder (ebox-source-builder-create))
(foreign (ebox-source-builder-create))
(parent (ebox-source-builder-bind builder :identity 'parent))
(foreign-parent (ebox-source-builder-bind foreign :identity 'foreign-parent))
(foreign-text
(ebox-text-create
:value "foreign" :source-handle
(ebox-source-builder-bind foreign :identity 'foreign-text)
:owned-facts (ebox-canonical-facts-from-declarations 'text nil)))
(arguments (list :layout (ebox-normal-layout-create)
:source-handle parent
:owned-facts (ebox-canonical-facts-from-declarations 'box nil))))
(should-error
(apply #'ebox-box-create :children (list (ebox-child-range 'empty)) arguments))
(should-error
(apply #'ebox-box-create :source-builder builder
:children (list (ebox-child-range 'empty) foreign-text) arguments))
(should-error
(ebox-box-create :layout (ebox-normal-layout-create)
:source-builder builder :source-handle foreign-parent
:owned-facts (ebox-canonical-facts-from-declarations 'box nil)))
(let ((node (apply #'ebox-box-create :source-builder builder
:children (list (ebox-child-range 'empty)) arguments)))
(should (equal '((:ref empty :before 0 :after 0))
(ebox-box-node-range-anchors node)))
(should-not (memq builder node))
(ebox-source-builder-finish builder)
(should-error
(apply #'ebox-box-create :source-builder builder
:children (list (ebox-child-range 'empty)) arguments)))))
(ert-deftest ebox-source-index-is-the-sole-source-fact-owner ()
"Source construction and public reads cannot mutate an immutable index."
(let* ((class (list (copy-sequence "row")))
@ -408,7 +467,26 @@
(plist-get state :node-table))
(let ((records (ebox-source--index-records index))
(subjects (ebox-source--index-subjects index))
(node-subjects (ebox-source--index-node-subjects index)))
(node-subjects (ebox-source--index-node-subjects index))
(handle (ebox-node-source-handle (plist-get first :node)))
(root-subject (ebox-source--index-root-subject index))
(contents (with-current-buffer buffer (buffer-string))))
(let ((children (copy-sequence
(ecss-subject-children root-subject)))
(tp--surface-publication-step-function
(lambda (step _surface)
(when (eq step 'client-state)
(error "Reject declaration-only source rebind")))))
(should-error
(ebox-selector-update-buffer
buffer "#first" :color "#123456"))
(should (eq state (ebox--buffer-render-state buffer)))
(should (eq index (plist-get state :source-index)))
(should (equal-including-properties
contents (with-current-buffer buffer (buffer-string))))
(should (equal children (ecss-subject-children root-subject)))
(dolist (child children)
(should (eq root-subject (ecss-subject-parent child)))))
(ebox-selector-update-buffer
buffer "#first" :color "#123456")
(let* ((next-index
@ -424,7 +502,32 @@
(should (eq subjects next-subjects))
(should
(eq node-subjects
(ebox-source-table-base next-node-subjects))))))))
(ebox-source-table-base next-node-subjects)))
(let* ((next-node
(gethash old-id
(plist-get (ebox--buffer-render-state buffer)
:node-table)))
(next-handle (ebox-node-source-handle next-node)))
(should-not (eq handle next-handle))
(should (equal (ebox-source-handle-id handle)
(ebox-source-handle-id next-handle)))
(should (ebox-source-index-handle-member-p index handle))
(should-not
(ebox-source-index-handle-member-p index next-handle))
(should-not
(ebox-source-index-handle-member-p next-index handle))
(should-not
(plist-get (ebox-source-record-declarations
(ebox-source-index-record index handle))
'ebox/color))
(should (equal "#123456"
(plist-get
(ebox-source-record-declarations
(ebox-source-index-record
next-index next-handle))
'ebox/color)))
(should (eq root-subject
(ebox-source--index-root-subject next-index)))))))))
(when (buffer-live-p buffer)
(kill-buffer buffer)))))

View File

@ -49,6 +49,250 @@
(propertize "Open" 'keymap map 'mouse-face 'highlight
'help-echo "Open this item")))
(ert-deftest ebox-surface-snapshot-exports-current-detached-input ()
"A snapshot follows local commits and owns its mutable node payloads."
(ebox-surface-test--with-elisp-backend
(with-temp-buffer
(let* ((owner (current-buffer))
(payload (list :nested (vector "original")))
(table (make-hash-table :test 'equal))
(keymap (make-sparse-keymap))
(callback (lambda () 'callback))
(value (propertize "before" 'custom payload 'action callback
'custom-table table 'keymap keymap))
(input (ebox-test-column :width '(100) :source-identity 'root
(ebox-test-text value :source-identity 'content))))
(puthash "value" (vector "original") table)
(puthash "self" table table)
(ebox-render-to-buffer owner input)
(let* ((first (ebox-surface-buffer-snapshot owner))
(first-input (plist-get first :input))
(first-root (car (ebox-canonical-input-roots first-input)))
(first-text (car (ebox-box-node-children first-root)))
(text-value (ebox-text-node-value first-text))
(first-render (ebox-render first-input))
(candidate (ebox-candidate-begin owner)))
(should (eq callback (get-text-property 0 'action text-value)))
(should (eq keymap (get-text-property 0 'keymap text-value)))
(should-not (eq payload (get-text-property 0 'custom text-value)))
(let ((export-table (get-text-property 0 'custom-table text-value)))
(should-not (eq table export-table))
(should (eq export-table (gethash "self" export-table)))
(aset (gethash "value" export-table) 0 "changed export")
(should (equal "original" (aref (gethash "value" table) 0))))
(aset (plist-get (get-text-property 0 'custom text-value) :nested)
0 "changed export")
(should (equal "original" (aref (plist-get payload :nested) 0)))
(ebox-candidate-replace-host-ref
candidate 'content (ebox-test-text "after" :source-identity 'content))
(ebox-commit owner candidate)
(let* ((second (ebox-surface-buffer-snapshot owner))
(revision (ebox-surface-buffer-revision owner)))
(should (> (plist-get second :revision) (plist-get first :revision)))
(should (= revision (plist-get second :revision)))
(should (= (plist-get first :mount-id) (plist-get second :mount-id)))
(should (string-match-p "after" (ebox-render (plist-get second :input))))
(should (string-match-p "before" first-render))
(should (string-match-p "before" (ebox-render first-input)))
(should (= revision (ebox-surface-buffer-revision owner)))
(ebox-unmount-buffer owner)
(with-temp-buffer
(ebox-render-to-buffer (current-buffer) (plist-get second :input))
(should (string-match-p "after" (buffer-string))))))))))
(ert-deftest ebox-surface-snapshot-rejects-provisional-and-dead-mounts ()
"Query refuses transaction state and separates remount revisions."
(ebox-surface-test--with-elisp-backend
(with-temp-buffer
(let ((owner (current-buffer))
(input (ebox-test-box (ebox-test-text "committed"))))
(should-error (ebox-surface-buffer-snapshot owner))
(ebox-render-to-buffer owner input)
(let ((snapshot (ebox-surface-buffer-snapshot owner)))
(should-error
(tp-with-transaction (ebox-surface-buffer-snapshot owner)))
(should-error
(ebox-commit
owner (ebox-test-box (ebox-test-text "rejected"))
(lambda (_report)
(should-error (ebox-surface-buffer-snapshot owner))
(error "reject candidate"))))
(should (= (plist-get snapshot :revision)
(plist-get (ebox-surface-buffer-snapshot owner) :revision)))
(should (string-match-p "committed" (buffer-string)))
(ebox-unmount-buffer owner)
(should-error (ebox-surface-buffer-snapshot owner))
(ebox-render-to-buffer owner input)
(should-not (= (plist-get snapshot :mount-id)
(plist-get (ebox-surface-buffer-snapshot owner) :mount-id))))))))
(ert-deftest ebox-surface-snapshot-preserves-empty-and-keyed-ranges ()
"Detached export keeps exact source handles and transparent Range shape."
(ebox-surface-test--with-elisp-backend
(with-temp-buffer
(let* ((builder (ebox-source-builder-create))
(handle (ebox-source-builder-bind builder :identity 'root))
(children (mapcar
(lambda (key)
(ebox-text-create
:value (symbol-name key)
:source-handle (ebox-source-builder-bind
builder :identity key :key key)
:owned-facts (ebox-canonical-facts-from-declarations 'text nil)))
'(one two)))
(input (ebox-canonical-input-create
(list (ebox-box-create
:source-handle handle :source-builder builder
:owned-facts (ebox-canonical-facts-from-declarations 'box nil)
:layout (ebox-column-layout-create)
:children (list (ebox-child-range 'empty)
(apply #'ebox-child-range 'items children))))
(ebox-source-builder-finish builder)))
(root (car (ebox-canonical-input-roots input))))
(ebox-render-to-buffer (current-buffer) input)
(let* ((snapshot (ebox-surface-buffer-snapshot (current-buffer)))
(export (plist-get snapshot :input))
(copy (car (ebox-canonical-input-roots export))))
(should (equal (ebox-box-node-range-anchors root)
(ebox-box-node-range-anchors copy)))
(should (eq (ebox-node-source-handle root) (ebox-node-source-handle copy)))
(cl-mapc (lambda (before after)
(should (eq (ebox-node-source-handle before)
(ebox-node-source-handle after))))
(ebox-box-node-children root) (ebox-box-node-children copy))
(should (string-match-p "one" (ebox-render export)))
(should-not (plist-get copy :node-id))
(should-not (plist-get copy :surface-object))
(should-not (plist-get copy :render-cache)))))))
(ert-deftest ebox-surface-snapshot-detaches-character-tables ()
"Character table defaults, inheritance, extras and shared bits are detached."
(let* ((purpose (make-symbol "ebox-snapshot-character-table"))
(_ (put purpose 'char-table-extra-slots 1))
(parent (make-char-table purpose))
(table (make-char-table purpose))
(bits (make-bool-vector 3 t))
(default (list "default")))
(set-char-table-range table nil default)
(set-char-table-range table ?a (list "entry"))
(set-char-table-range parent ?b (list "inherited"))
(set-char-table-parent table parent)
(set-char-table-extra-slot table 0 (vector table bits bits))
(with-temp-buffer
(ebox-render-to-buffer
(current-buffer) (ebox-test-text (propertize "x" 'custom table)))
(let* ((input (plist-get (ebox-surface-buffer-snapshot (current-buffer)) :input))
(text (ebox-text-node-value (car (ebox-canonical-input-roots input))))
(copy (get-text-property 0 'custom text))
(extra (char-table-extra-slot copy 0)))
(should-not (eq table copy))
(should (eq copy (aref extra 0)))
(should (eq (aref extra 1) (aref extra 2)))
(should-not (eq bits (aref extra 1)))
(setcar (char-table-range copy nil) "changed default")
(setcar (char-table-range copy ?a) "changed entry")
(set-char-table-range copy nil nil)
(setcar (char-table-range (char-table-parent copy) ?b) "changed parent")
(should (equal "default" (car default)))
(should (equal '("entry") (char-table-range table ?a)))
(should (equal '("inherited") (char-table-range parent ?b)))
(should (equal '("changed parent") (char-table-range copy ?b)))))))
(defun ebox-surface-test--filler-root (value kind)
"Return a KIND layout whose named Text VALUE changes ancestor filling."
(let ((content (ebox-test-text value :key 'content :id "content"
:source-identity 'content)))
(ebox-test-column :width '(100) :height 5 :key 'root
(if (eq kind 'column)
(ebox-test-column :key 'group content)
(ebox-test-flex :key 'row
(ebox-test-box :key 'static (ebox-test-text "fixed"))
(ebox-test-column :key 'group :flex-basis '(40) :flex-grow 1
content)))
;; A separate scroll owner prevents the ordinary fixed-span shortcut.
;; Its visible and hidden content must survive this unrelated edit.
(ebox-test-box :key 'outside :height 1 :overflow 'scroll
(ebox-test-text "outside\nother\nmore")))))
(ert-deftest ebox-surface-text-update-owns-dependent-line-filler ()
"Text growth and shrinkage publish the exact dependent ancestor filler."
(ebox-surface-test--with-elisp-backend
(dolist (kind '(column flex))
(with-temp-buffer
(let ((ebox-viewport-width 100) (ebox-viewport-height 5))
(ebox-render-to-buffer
(current-buffer)
(ebox-surface-test--filler-root "okay" kind))
(let* ((entry (car (ebox-selector-query-buffer
(current-buffer) "#content")))
(region-id (plist-get entry :region-id))
(surface ebox-surface--buffer-surface)
(outside (save-excursion
(goto-char (point-min))
(forward-line 1)
(buffer-substring (point) (point-max)))))
(dolist (value '("longer" "x"))
(let* ((input
(if (equal value "x")
(let ((candidate
(ebox-candidate-begin (current-buffer))))
(ebox-candidate-replace-host-ref
candidate 'content
(ebox-test-text value :key 'content :id "content"
:source-identity 'content))
candidate)
(ebox-surface-test--filler-root value kind)))
(report (ebox-commit (current-buffer) input)))
(should (= (plist-get report :tp-scope-count) 1))
(should-not (plist-get report :tp-full-root))
(should-not (plist-get report :tp-scope-fallback))
;; Publication permission must not extend the public semantic
;; bounds of the Text to include its ancestor's blank area.
(let ((bounds (ebox-surface-region-bounds
(current-buffer) region-id)))
(should (equal value (buffer-substring-no-properties
(car bounds) (cdr bounds)))))
(save-excursion
(goto-char (point-min))
(should (= 100 (ebox--string-pixel-width
(buffer-substring (point)
(line-end-position)))))
(forward-line 1)
(should (equal-including-properties
outside (buffer-substring (point) (point-max)))))))
(let ((before (buffer-string))
(state (tp-surface-client-state surface))
(revision (tp-surface-revision surface))
rollback)
(should-error
(ebox-commit
(current-buffer)
(ebox-surface-test--filler-root "failure" kind)
(lambda (_report) (error "reject filler publication"))
(lambda (_report) (setq rollback t))))
(should rollback)
(should (equal-including-properties before (buffer-string)))
(should (eq state (tp-surface-client-state surface)))
(should (= revision (tp-surface-revision surface)))
;; The dependency range authorizes the filler, not unrelated
;; output elsewhere in the complete candidate.
(let ((render (symbol-function 'ebox-surface--render-candidate)))
(cl-letf (((symbol-function 'ebox-surface--render-candidate)
(lambda (candidate)
(let* ((output (funcall render candidate))
(start (string-match "outside" output)))
(put-text-property start (1+ start)
'help-echo "unrelated" output)
output))))
(should-error
(ebox-commit
(current-buffer)
(ebox-surface-test--filler-root "next" kind))
:type 'tp-scope-mismatch)))
(should (equal-including-properties before (buffer-string)))
(should (eq state (tp-surface-client-state surface)))
(should (= revision (tp-surface-revision surface))))))))))
(ert-deftest ebox-range-ref-present-p-is-a-read-only-boundary-query ()
"Expose mounted Range anchor presence without leaking runtime tables."
(let ((buffer (generate-new-buffer " *ebox-range-anchor-query*"))
@ -892,6 +1136,92 @@ candidate cannot hide mutations by restoring the old hash-table pointer."
(ebox-tree-source-index
open nil nil (ebox-test-source-index open-input))))))
(defun ebox-surface-test--inherited-range-root ()
"Return a styled Range with static siblings inheriting from copied parents."
(ebox-test-column :color "red" :width '(100) :height 10
(ebox-test-row :id "header"
(ebox-test-text "Header" :class "title"))
(ebox-test-child-range 'items (ebox-test-text "Old" :key 'old))
(ebox-test-row (ebox-test-text "Footer" :id "footer"))))
(defun ebox-surface-test--replace-inherited-range ()
"Commit a structural change between the static inherited Text siblings."
(let ((candidate (ebox-candidate-begin (current-buffer))))
(ebox-candidate-replace-range-ref
candidate 'items
(ebox-test-forest-input
(ebox-test-text "New" :key 'new)
(ebox-test-text "More" :key 'more)))
(ebox-commit (current-buffer) candidate)))
(defun ebox-surface-test--assert-inherited-range-style ()
"Check that retained Text styles and source parent identities agree."
(let* ((state (ebox--buffer-render-state (current-buffer)))
(source-index (plist-get state :source-index))
(nodes (plist-get state :node-table))
(parents (plist-get state :parent-table))
(checked 0))
(maphash
(lambda (id node)
(when (member (plist-get node :ebox-text-value) '("Header" "Footer"))
(cl-incf checked)
(should (equal (plist-get node :color) "red"))
(should (equal (ecss-computed-style-value
(plist-get node :ebox-computed-style) 'ebox/color)
"red"))
(should (eq (ecss-subject-parent
(ebox-tree-node-subject source-index node))
(ebox-tree-node-subject
source-index (gethash (gethash id parents) nodes))))))
nodes)
(should (= checked 2))))
(ert-deftest ebox-surface-stale-parent-subjects-preserve-inherited-style ()
"Full style projection reconnects static children after a local Range delta."
(ebox-surface-test--with-elisp-backend
(let ((ebox-style-stylesheet (ecss-stylesheet-create))
(ebox-viewport-width 100)
(ebox-viewport-height 10))
(ebox-style-add-rule ".title" '(:font-weight bold))
(with-temp-buffer
(ebox-render-to-buffer
(current-buffer) (ebox-surface-test--inherited-range-root))
(ebox-surface-test--assert-inherited-range-style)
(ebox-surface-test--replace-inherited-range)
(ebox-surface-test--assert-inherited-range-style)
;; A later local edit must agree with the already published siblings.
(ebox-region-update (ebox-region-resolve (current-buffer) "header")
:content "Header!")
(let ((state (ebox--buffer-render-state (current-buffer))))
(should (equal-including-properties
(buffer-string)
(ebox-surface-test--render-runtime state))))))))
(ert-deftest ebox-surface-stale-parent-subjects-rollback-preserves-publication ()
"Failed coherent style preparation leaves the published subject tree intact."
(ebox-surface-test--with-elisp-backend
(let ((ebox-style-stylesheet (ecss-stylesheet-create))
(ebox-viewport-width 100)
(ebox-viewport-height 10))
(ebox-style-add-rule ".title" '(:font-weight bold))
(with-temp-buffer
(ebox-render-to-buffer
(current-buffer) (ebox-surface-test--inherited-range-root))
(let* ((state (ebox--buffer-render-state (current-buffer)))
(source-index (plist-get state :source-index))
(contents (buffer-string))
(tp--surface-publication-step-function
(lambda (step _surface)
(when (eq step 'client-state)
(error "Reject coherent style candidate")))))
(should-error (ebox-surface-test--replace-inherited-range))
(should (eq state (ebox--buffer-render-state (current-buffer))))
(should (eq source-index (plist-get state :source-index)))
(should (equal-including-properties contents (buffer-string)))
(ebox-surface-test--assert-inherited-range-style))
(ebox-surface-test--replace-inherited-range)
(ebox-surface-test--assert-inherited-range-style)))))
(ert-deftest ebox-box-content-cache-reuses-fixed-viewport-subtree ()
"A fixed box content viewport should reuse its exact composite layout."
(let* ((ebox--render-cache-table (make-hash-table :test 'equal))
@ -3086,6 +3416,69 @@ changes. Geometry and non-inherited computed values must remain identical."
(when (buffer-live-p buffer)
(kill-buffer buffer)))))
(ert-deftest ebox-surface-nested-grid-paint-keeps-slot-face-addresses ()
"A local restyle after resize preserves named paint faces outside its scope."
(ebox-surface-test--with-elisp-backend
(let ((foreground (tp-paint-slot-create '(:foreground "#152030")))
(background (tp-paint-slot-create '(:background "#F8FAFC")))
(top (tp-paint-slot-create '(:overline "#CBD5E1")))
(bottom (tp-paint-slot-create
'(:underline (:position t :color "#CBD5E1")))))
(cl-labels
((row (key selected)
(ebox-test-row :key key
:color (if selected "#FFFFFF" foreground)
:bgcolor (if selected "#2563EB" background)
:border-top-width 1 :border-top-style 'solid :border-top-color top
:border-bottom-width 1 :border-bottom-style 'solid
:border-bottom-color bottom
(ebox-test-grid :key 'cells :width 'stretch
:grid-template-columns '((fr 1) (22))
(ebox-test-box :key 'label (ebox-test-text "Label"))
(ebox-test-box :key 'action (ebox-test-text "Action")))))
(root (selected)
(ebox-test-column :key 'root :width 'viewport
(row 'first nil) (row 'second selected))))
(with-temp-buffer
(let ((ebox-viewport-width 240) (ebox-viewport-height 10))
(ebox-render-to-buffer (current-buffer) (root nil)))
(ebox-surface-update-buffer-viewport (current-buffer) 420 10)
(let ((original (buffer-string))
(first-line (buffer-substring (point-min)
(save-excursion
(goto-char (point-min))
(line-end-position)))))
(dolist (slot (list foreground background top bottom))
(should (memq (tp-paint-slot-face slot)
(flatten-tree
(get-text-property 0 'face first-line)))))
(let ((report (ebox-commit (current-buffer) (root t))))
(should (eq (plist-get report :projection-kind) 'paint))
(should-not (plist-get report :tp-full-root))
(should-not (plist-get report :tp-scope-fallback))
(should (= (plist-get report :tp-scope-count) 1)))
(should (equal-including-properties
first-line
(buffer-substring (point-min)
(save-excursion
(goto-char (point-min))
(line-end-position)))))
;; Restoring slot-backed colors must emit the same named faces
;; as the initial renderer, including both horizontal borders.
(ebox-commit (current-buffer) (root nil))
(should (equal-including-properties original (buffer-string)))
(let ((before (buffer-string))
(revision (ebox-surface-buffer-revision (current-buffer)))
rollback)
(should-error
(ebox-commit (current-buffer) (root t)
(lambda (_report) (error "Reject restyle"))
(lambda (_report) (setq rollback t))))
(should rollback)
(should (= revision (ebox-surface-buffer-revision
(current-buffer))))
(should (equal-including-properties before (buffer-string))))))))))
(ert-deftest ebox-region-handle-becomes-stale-with-its-object ()
"A handle should fail after a commit removes its retained object."
(ebox-surface-test--reset-render-state)