diff --git a/README.md b/README.md index 644860e..3617d41 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/README.zh-CN.md b/README.zh-CN.md index f2e3a58..f0bcf05 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -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。 diff --git a/ebox-buffer-backend.el b/ebox-buffer-backend.el index 4db1818..149df53 100644 --- a/ebox-buffer-backend.el +++ b/ebox-buffer-backend.el @@ -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))) diff --git a/ebox-canonical.el b/ebox-canonical.el index dd66e14..cd45354 100644 --- a/ebox-canonical.el +++ b/ebox-canonical.el @@ -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) diff --git a/ebox-flex.el b/ebox-flex.el index 648672f..eab67e4 100644 --- a/ebox-flex.el +++ b/ebox-flex.el @@ -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) diff --git a/ebox-grid.el b/ebox-grid.el index 9c071f5..9e9a881 100644 --- a/ebox-grid.el +++ b/ebox-grid.el @@ -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)) diff --git a/ebox-layout.el b/ebox-layout.el index ee85509..33ee3e5 100644 --- a/ebox-layout.el +++ b/ebox-layout.el @@ -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 diff --git a/ebox-native-reflow.el b/ebox-native-reflow.el index 5e73466..9b3478c 100644 --- a/ebox-native-reflow.el +++ b/ebox-native-reflow.el @@ -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 diff --git a/ebox-surface.el b/ebox-surface.el index 0da7d3f..bb97bae 100644 --- a/ebox-surface.el +++ b/ebox-surface.el @@ -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 diff --git a/tests/ebox-core-render-tests.el b/tests/ebox-core-render-tests.el index a815933..54cdb0a 100644 --- a/tests/ebox-core-render-tests.el +++ b/tests/ebox-core-render-tests.el @@ -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) diff --git a/tests/ebox-dsl-tests.el b/tests/ebox-dsl-tests.el index d5a0053..6b24d12 100644 --- a/tests/ebox-dsl-tests.el +++ b/tests/ebox-dsl-tests.el @@ -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." diff --git a/tests/ebox-fixtures.el b/tests/ebox-fixtures.el index e7004a2..8175749 100644 --- a/tests/ebox-fixtures.el +++ b/tests/ebox-fixtures.el @@ -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) diff --git a/tests/ebox-flex-tests.el b/tests/ebox-flex-tests.el index 247c1f1..f223d65 100644 --- a/tests/ebox-flex-tests.el +++ b/tests/ebox-flex-tests.el @@ -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 diff --git a/tests/ebox-grid-tests.el b/tests/ebox-grid-tests.el index 16ba772..a2c0995 100644 --- a/tests/ebox-grid-tests.el +++ b/tests/ebox-grid-tests.el @@ -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 diff --git a/tests/ebox-source-tests.el b/tests/ebox-source-tests.el index 4edfe14..0a3e146 100644 --- a/tests/ebox-source-tests.el +++ b/tests/ebox-source-tests.el @@ -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))))) diff --git a/tests/ebox-surface-tests.el b/tests/ebox-surface-tests.el index 40eb1a8..a17c6f4 100644 --- a/tests/ebox-surface-tests.el +++ b/tests/ebox-surface-tests.el @@ -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)