fix: preserve standalone layout and source ownership across updates
Expose detached committed snapshots and explicit canonical construction ownership. Preserve scoped publication and rollback, share Box decoration, and reuse completed Flex/Grid work only under proven constraints.
This commit is contained in:
parent
85849c01e7
commit
3bd75f90c8
18
README.md
18
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
|
||||
|
||||
@ -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。
|
||||
|
||||
@ -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)))
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
108
ebox-flex.el
108
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)
|
||||
|
||||
90
ebox-grid.el
90
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))
|
||||
|
||||
306
ebox-layout.el
306
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
|
||||
|
||||
@ -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
|
||||
|
||||
264
ebox-surface.el
264
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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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."
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)))))
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user