fix: preserve paint across clipped overlap edges
Some checks are pending
CI / test (29.1) (push) Waiting to run
CI / test (30.2) (push) Waiting to run
CI / native-build (macos-latest) (push) Waiting to run
CI / native-build (ubuntu-latest) (push) Waiting to run
CI / native-build (windows-latest) (push) Waiting to run
CI / native-msrv (macos-latest) (push) Waiting to run
CI / native-msrv (ubuntu-latest) (push) Waiting to run
CI / native-msrv (windows-latest) (push) Waiting to run

This commit is contained in:
Kinneyzhang 2026-09-11 01:32:19 +08:00
parent 993e09be6b
commit 8da2fe68cc
10 changed files with 422 additions and 62 deletions

View File

@ -78,8 +78,9 @@ another public node or wrapper.
`hidden` enforces the content width after composing children, preserving whole
supported text clusters and excluding the container's own padding and border
from the clipping area. Clipped-away child properties are removed with their
text. Partial display spaces can be shortened; oversized glyphs/images are
from the clipping area. Clipped-away child interaction properties are removed;
partial-glyph fill retains the source faces and their paint dependencies.
Partial display spaces can be shortened; oversized glyphs/images are
omitted, not pixel-masked. Vertical clipping uses whole lines. `visible` allows
overflow, and `scroll` provides vertical scrolling. No ellipsis or horizontal
scrolling is implied.
@ -151,7 +152,9 @@ an error before publication.
This is an opaque text compositor: horizontal positions are pixels, vertical
positions share the host's row grid. Positioned descendants must share their
host's effective font line height. Whole supported text clusters and display
units survive clipping; partial glyphs/images become neutral space. There is
units survive clipping; partial glyphs/images become noninteractive space
retaining source faces, including background paint. Canvas gaps remain neutral.
There is
no alpha blending, partial-glyph masking, or child-frame rendering. Layered
trees use Elisp when the native representation cannot express their placement.
See the [layer guide](ebox-user-guide.en.md#retained-layers) for examples and

View File

@ -72,7 +72,8 @@ participation property 就是普通子 Box property不会创建另一种公
| 图层放置 | `:position`、`:left`、`:top`、`:z-index`、`:layer`、`:anchor`、`:placement` |
`hidden` 在组合子节点后约束内容宽度,保留完整的受支持文本簇;容器自身的 padding 与
border 不在裁剪区域内。被裁掉的子文本同时移除其属性。固定 display space 可以缩短,
border 不在裁剪区域内。被裁掉的子文本不再保留交互属性,半字形补白仍保留原 face
及其绘制依赖。固定 display space 可以缩短,
放不下的字形/图像则整体省去,不做像素遮罩。纵向按完整行裁剪。`visible` 允许溢出,
`scroll` 提供纵向滚动;这些设置不隐含省略号或横向滚动。
@ -129,7 +130,8 @@ margin 不参与遮挡。绘制裁剪到宿主画布。
这是不透明文本合成:横向用像素,纵向共享宿主的文本行网格。定位子树必须与宿主
使用相同的有效字体行高。裁剪保留完整的受支持文本簇和 display unit不完整字形或
图像以中性空白替代。不提供 alpha 混合、半字形遮罩或 child frame。native 表示
图像以保留原 face含背景色的非交互空白替代源内容之外的画布空白仍保持中性。
不提供 alpha 混合、半字形遮罩或 child frame。native 表示
无法表达图层放置时使用 Elisp。示例和 retained 更新行为见
[图层指南](ebox-user-guide.zh.md#retained-layers)。

View File

@ -242,7 +242,8 @@ part of the author contract. `:overflow hidden` clips excess vertical lines and
limits each content line to the measured content width. It retains the fitting
prefix without splitting supported text clusters; a glyph or image that cannot
fit is omitted as a whole. Fixed display spaces can be shortened to the exact
remaining pixel width. Any remaining gap is filled with box content space, so
remaining pixel width. A cut glyph's remaining gap keeps its source faces,
including background paint, so
padding and borders retain their positions. This is pixel-width truncation,
not partial-glyph/image masking or automatic ellipsis. Omitted child text does
not leave its help, hover, pointer, or keymap active in the filler.
@ -342,7 +343,9 @@ dependency cycles are errors.
Composition uses horizontal pixels and one shared vertical text-row grid.
Positioned subtrees must have the same effective font line height as their
host. Whole supported clusters survive cuts; partially covered glyphs/images
are replaced with neutral space, and fixed display spaces can be shortened.
are replaced with noninteractive space retaining the source faces (including
background paint), and fixed display spaces can be shortened. Blank canvas
outside the source remains neutral.
There is no alpha blending, half-glyph masking, or child frame. Layered trees
fall back to Elisp when native reflow cannot represent them.

View File

@ -210,7 +210,7 @@ Box 高度为零;`(box :height (lh 1))` 明确保留一行空白。
接受纵向 `px` 长度。
`:overflow hidden` 裁掉超出的纵向行,并将每行内容限制在测量后的内容宽度内。
保留放得下的前缀,不拆开受支持的文本簇;放不下的字形或图像整体省去。固定 display
space 可以缩短到精确的剩余像素宽度,剩余空隙用盒子内容空白补齐,使 padding 和边框
space 可以缩短到精确的剩余像素宽度,被裁字形的剩余空隙保留原 face含背景色,使 padding 和边框
保持位置。这是按像素宽度截断,不是显示半个字形/图像,也不自动添加省略号。
被省去的子文本不会在填充空白上留下自己的帮助、hover、指针或 keymap。
@ -292,7 +292,8 @@ Text 的样式声明只接受字体、前景/背景和文本装饰 property
缺失或不可见时不绘制面板ID 歧义及放置依赖循环会报错。
合成横向使用像素,纵向共享一套文本行网格。定位子树的有效字体行高必须与宿主
相同。裁剪保留完整的受支持文本簇,不完整字形或图像以中性空白替代,固定 display
相同。裁剪保留完整的受支持文本簇,不完整字形或图像以保留原 face含背景色
非交互空白替代;源内容之外的画布空白仍保持中性。固定 display
space 可以缩短。不提供 alpha 混合、半字形遮罩或 child frame。native reflow
无法表示图层时回退到 Elisp。

View File

@ -9,6 +9,7 @@
(require 'cl-lib)
(require 'ebox-measure)
(require 'ebox-render-context)
(declare-function ebox--grapheme-cluster-end "ebox-layout" (string start))
@ -45,6 +46,31 @@ Retain the source properties and nested display values by identity."
(cons 'space (plist-put properties :width (list width))) result)
result))
(defun ebox-composite--fill (line position width)
"Replace LINE's glyph at POSITION with WIDTH pixels of painted blank.
Keep face values by identity so named paint slots remain live. Retain paint
dependencies on the transient origin carrier, separately from the omitted
glyph's display, interaction and semantic ownership properties."
(let ((space (ebox-pixel-space width)))
(dolist (property '(face font-lock-face))
(when-let* ((value (get-text-property position property line)))
(put-text-property 0 (length space) property value space)))
(let ((origin (get-text-property position ebox--paint-origin-property line)))
(when (or origin ebox--paint-origin-capture-p)
(put-text-property
0 (length space) ebox--paint-origin-property
(ebox--paint-origin-create
:baseline (if origin (ebox--paint-origin-baseline origin)
(copy-tree (get-text-property position 'face line)))
:source-roles
(delete-dups
(append (and origin
(copy-sequence (ebox--paint-origin-source-roles origin)))
(ebox--render-role-ids-from-properties
(text-properties-at position line)))))
space)))
space))
(defun ebox-composite--join (parts)
"Join PARTS while retaining their separately measured pixel extents.
A neutral zero-pixel display boundary prevents adjacent EQ replacement runs
@ -66,7 +92,7 @@ from collapsing or independently shaped source fragments from reshaping."
"Fit LINE from START through END in its original [LEFT, RIGHT) pixels.
Removing a left shaping context can change a retained unit's width. Resolve
that unit within its original pixel interval, then retry the remaining whole
run. A newly wider glyph becomes neutral fill rather than shifting the next
run. A newly wider glyph becomes painted fill rather than shifting the next
source glyph or borrowing its pixels."
(let ((position start) (pixel left) parts)
(while (< position end)
@ -82,12 +108,13 @@ source glyph or borrowing its pixels."
(cond
((<= unit-width available)
(push unit parts)
(push (ebox-pixel-space (- available unit-width)) parts))
(push (ebox-composite--fill
line position (- available unit-width)) parts))
((ebox--display-space-width
(get-text-property position 'display line))
(push (ebox-composite--space line position next available)
parts))
(t (push (ebox-pixel-space available) parts)))
(t (push (ebox-composite--fill line position available) parts)))
(setq position next pixel next-pixel)))))
(ebox-composite--join (nreverse parts))))
@ -97,9 +124,10 @@ START and END must be finite nonnegative numbers with START <= END. LINE is
a single-line string or nil. Pixels beyond LINE are neutral blank space.
Keep complete supported graphemes and replacement display runs, preserving
their text property values by identity. Fixed pixel spaces can be cut at
either edge; other partial glyphs become neutral blanks at their original
positions. Measure original prefixes so adjacent shaping is not summed as
isolated characters. Neither LINE nor its property values are modified."
either edge; other partial glyphs become noninteractive blanks retaining their
source faces at their original positions. Measure original prefixes so
adjacent shaping is not summed as isolated characters. Neither LINE nor its
property values are modified."
(unless (ebox-composite--line-p line)
(error "Ebox composite needs a single-line string: %S" line))
(unless (and (ebox-size--finite-number-p start) (>= start 0)
@ -139,7 +167,7 @@ isolated characters. Neither LINE nor its property values are modified."
(get-text-property position 'display line))
(ebox-composite--space
line position next (- right left))
(ebox-pixel-space (- right left)))
(ebox-composite--fill line position (- right left)))
parts)))
(setq position next pixel next-pixel)))
(flush))

View File

@ -708,8 +708,8 @@ pairs. It is intentionally small; full Unicode segmentation is out of scope."
"Clip LINE to MAX-PIXEL without splitting supported grapheme clusters.
Retain visible text properties. A partially visible fixed display space
keeps its properties and uses the remaining width; a glyph that cannot fit
is omitted as a whole. Any remaining width is neutral box content, never
an interactive stand-in for an omitted glyph."
is omitted as a whole. Its remaining width retains the source faces, never
the interaction properties of the omitted glyph."
;; The prefix-plus-pixel-filler algorithm follows kinney's GPL-3.0-or-later
;; `s-pixel-floor' and `s-pixel-left' (2026), extended here for graphemes,
;; source properties and clipped display spaces without an extra dependency.
@ -740,7 +740,7 @@ an interactive stand-in for an omitted glyph."
(concat
(substring line 0 position)
(when (> remaining 0)
(let ((space (ebox-pixel-space remaining)))
(let ((space (ebox-composite--fill line position remaining)))
(when space-width
(add-text-properties
0 (length space)

View File

@ -12,6 +12,7 @@
(defvar ebox-layer--active-p)
(defvar ebox-incremental--render-portals-p)
(defvar ebox--render-source-index)
(defvar ebox-region-types)
(declare-function ebox-interaction--refresh-hover! "ebox-interaction"
(string start end &optional face))
@ -102,8 +103,8 @@
(cl-defstruct (ebox--paint-origin
(:constructor ebox--paint-origin-create))
"Transient caller-face baseline captured before Ebox paint composition."
baseline)
"Transient caller-face baseline and optional paint-only source roles."
baseline source-roles)
(defconst ebox--paint-origin-property 'ebox--paint-origin
"Private transient property carrying one render-time paint baseline.")
@ -115,6 +116,19 @@
(make-hash-table :test #'eq :weakness 'key)
"Weak map from rendered strings to Ebox-owned property values.")
(defun ebox--render-role-ids-from-properties (properties)
"Return namespaced Ebox role/id pairs from rendered PROPERTIES."
(let (roles)
(when-let* ((region-id
(plist-get properties 'ebox-overflow-foreground-source)))
(push (cons 'overflow-foreground region-id) roles))
(dolist (region-id (plist-get properties 'ebox-content-owners))
(cl-pushnew (cons 'content-owner region-id) roles :test #'equal))
(dolist (entry ebox-region-types)
(when-let* ((region-id (plist-get properties (cdr entry))))
(cl-pushnew (cons (car entry) region-id) roles :test #'equal)))
(nreverse roles)))
(defun ebox--render-owned-text-values-for (property &optional create)
"Return the active candidate registry for PROPERTY when CREATE is non-nil."
(when (hash-table-p ebox--render-owned-text-values)

View File

@ -3680,7 +3680,7 @@ properties. Its local renderer cannot prove equivalence for such an owner."
(defun ebox-surface--retained-property-value-equal-p (left right)
"Return non-nil when retaining LEFT instead of RIGHT preserves its value.
Opaque payloads, including callbacks, keymaps and records, must keep identity.
Render-created paint origins compare their captured baselines instead."
Render-created paint origins compare their baselines and paint source roles."
(let ((seen (make-hash-table :test #'eq)))
(cl-labels
((same
@ -3688,8 +3688,10 @@ Render-created paint origins compare their captured baselines instead."
(cond
((eq old new) t)
((and (ebox--paint-origin-p old) (ebox--paint-origin-p new))
(same (ebox--paint-origin-baseline old)
(ebox--paint-origin-baseline new)))
(and (same (ebox--paint-origin-baseline old)
(ebox--paint-origin-baseline new))
(same (ebox--paint-origin-source-roles old)
(ebox--paint-origin-source-roles new))))
((or (functionp old) (functionp new)
(keymapp old) (keymapp new)) nil)
((memq new (gethash old seen)) t)
@ -3786,18 +3788,21 @@ Return nil on a proof miss. The caller still validates the final footprint."
(origin (with-current-buffer buffer (point-min)))
result)
(cl-labels
((baseline-for
((origin-for
(start end published-properties)
(while (and fragments (<= (plist-get (car fragments) :end) start))
(setq fragments (cdr fragments)))
(let ((remaining fragments) (position start) seen baseline)
(let ((remaining fragments) (position start) seen baseline source-roles)
(while (< position end)
(let ((fragment (car remaining)))
(unless (and fragment
(<= (plist-get fragment :start) position)
(< position (plist-get fragment :end)))
(throw 'slot-property-miss nil))
(let ((value
(let ((roles (cl-set-difference
(plist-get fragment :paint-role-ids)
(plist-get fragment :role-ids) :test #'equal))
(value
(cond
((plist-get fragment :face-baseline-known-p)
(plist-get fragment :face-baseline))
@ -3806,13 +3811,16 @@ Return nil on a proof miss. The caller still validates the final footprint."
(plist-get published-properties 'face))
(t (throw 'slot-property-miss nil)))))
(when (and seen
(not (ebox-surface--retained-property-value-equal-p
baseline value)))
(or (not (ebox-surface--retained-property-value-equal-p
baseline value))
(not (equal source-roles roles))))
(throw 'slot-property-miss nil))
(setq baseline value seen t))
(setq baseline value source-roles roles seen t))
(setq position (min end (plist-get fragment :end))
remaining (cdr remaining))))
baseline))
(ebox--paint-origin-create
:baseline (copy-tree baseline)
:source-roles (copy-tree source-roles))))
(pixel-filler-p
(line start end properties values)
(let ((display (plist-get properties 'display)))
@ -3876,11 +3884,9 @@ Return nil on a proof miss. The caller still validates the final footprint."
;; old fragment coordinates or peeling a composed face.
(put-text-property
new-position new-end ebox--paint-origin-property
(ebox--paint-origin-create
:baseline (copy-tree
(baseline-for (+ (- (car span) origin) old-position)
(origin-for (+ (- (car span) origin) old-position)
(+ (- (car span) origin) old-end)
published-props)))
published-props)
line)
(setq old-position old-end new-position new-end)))
(unless (and (= old-position old-length) (= new-position new-length))
@ -3911,7 +3917,11 @@ captured baselines; an unknown baseline remains unknown for later proofs."
(- (min end (plist-get fragment :end)) start)
ebox--paint-origin-property
(ebox--paint-origin-create
:baseline (copy-tree (plist-get fragment :face-baseline)))
:baseline (copy-tree (plist-get fragment :face-baseline))
:source-roles
(copy-tree
(cl-set-difference (plist-get fragment :paint-role-ids)
(plist-get fragment :role-ids) :test #'equal)))
line))))))))
lines)
@ -5485,16 +5495,7 @@ than replaying old coordinates, properties or revision authority."
(defun ebox-surface--role-ids-from-properties (properties)
"Return namespaced Ebox role/id pairs from PROPERTIES."
(let (roles)
(when-let* ((region-id
(plist-get properties 'ebox-overflow-foreground-source)))
(push (cons 'overflow-foreground region-id) roles))
(dolist (region-id (plist-get properties 'ebox-content-owners))
(cl-pushnew (cons 'content-owner region-id) roles :test #'equal))
(dolist (entry ebox-region-types)
(when-let* ((region-id (plist-get properties (cdr entry))))
(cl-pushnew (cons (car entry) region-id) roles :test #'equal)))
(nreverse roles)))
(ebox--render-role-ids-from-properties properties))
(defun ebox-surface--role-ids-at (rendered position &optional cache)
"Return namespaced Ebox role/id pairs at POSITION in RENDERED.
@ -5511,27 +5512,29 @@ one output projection."
roles))))
(defun ebox-surface--paint-address-at
(rendered position role-cache ordinal-table)
(rendered position role-cache ordinal-table &optional paint-source-roles)
"Return the semantic paint address at POSITION in RENDERED.
ROLE-CACHE is the per-render role extraction cache and ORDINAL-TABLE tracks
the first-to-last occurrence of an otherwise equal owner/content address.
PAINT-SOURCE-ROLES identifies paint-only producers of clipped glyph fill.
The result deliberately contains semantic owner/role facts only; it never
uses a string, buffer, marker, or TP object identity as an address."
(let* ((properties (text-properties-at position rendered))
(roles (ebox-surface--role-ids-at rendered position role-cache))
(content-owner (plist-get properties 'ebox-content-owner))
(content-index (plist-get properties 'ebox-content-idx))
(prefix (and (or roles content-owner (integerp content-index))
(prefix (and (or roles content-owner (integerp content-index)
paint-source-roles)
(append
(list :roles (copy-tree roles)
:content-owner content-owner
:content-index content-index)))
:content-index content-index)
(when paint-source-roles
(list :paint-source-roles (copy-tree paint-source-roles))))))
(ordinal (and prefix (gethash prefix ordinal-table 0))))
(when prefix
(puthash prefix (1+ ordinal) ordinal-table)
(list :roles (copy-tree roles)
:content-owner content-owner
:content-index content-index
:ordinal ordinal))))
(append prefix (list :ordinal ordinal)))))
(defun ebox-surface--candidate-plan-text (rendered owned-values)
"Return a private snapshot of RENDERED for an owned TP plan.
@ -5654,22 +5657,29 @@ when it needs to mutate them."
ebox--paint-origin-property
rendered))
(roles (ebox-surface--role-ids-at rendered position role-cache))
(paint-roles
(if (and origin (ebox--paint-origin-source-roles origin))
(delete-dups
(append (copy-sequence (ebox--paint-origin-source-roles origin))
roles))
roles))
(address
(ebox-surface--paint-address-at
rendered position role-cache ordinal-table))
rendered position role-cache ordinal-table
(cl-set-difference paint-roles roles :test #'equal)))
(baseline
(and origin
(copy-tree
(ebox--paint-origin-baseline origin)))))
(push (list :text rendered :text-source-p t
:start position :end next
:line line :paint-role-ids roles
:line line :paint-role-ids paint-roles
:role-ids roles
:paint-address address
:paint-token
(and address
(list :baseline (copy-tree baseline)
:roles (copy-tree roles)
:roles (copy-tree paint-roles)
:content-owner
(plist-get address :content-owner)
:content-index
@ -6561,12 +6571,21 @@ relative face contributions and performs the final merge during prepare."
fragment region-id ebox-surface--paint-base-roles)))
(pair (cons role region-id)))
(dolist (key '(:paint-role-ids :role-ids))
(let ((roles (cl-remove pair (plist-get fragment key) :test #'equal)))
(plist-put fragment key (if anchor-p (append roles (list pair)) roles))))
(let* ((roles (cl-remove pair (plist-get fragment key) :test #'equal))
(owned-p
(or (eq key :paint-role-ids)
(cl-some
(lambda (entry)
(and (equal (cdr entry) region-id)
(memq (car entry) ebox-surface--paint-base-roles)))
roles))))
(plist-put fragment key
(if (and anchor-p owned-p)
(append roles (list pair)) roles))))
(when (> (length (plist-get fragment :text)) 0)
(if-let* ((owner
(cdr (cl-find role
(plist-get fragment :paint-role-ids)
(plist-get fragment :role-ids)
:key #'car :test #'eq :from-end t))))
(put-text-property 0 (length (plist-get fragment :text))
property owner (plist-get fragment :text))

View File

@ -10,6 +10,7 @@
(require 'cl-lib)
(require 'ebox)
(require 'ebox-composite)
(require 'ebox-fixtures)
(defun ebox-composite-test--plain-width (text)
"Measure TEXT with seven-pixel glyphs, zero-width marks, and fi shaping."
@ -90,6 +91,266 @@
'keymap nil output)))))
(should (equal (ebox-composite-slice "abc" 2 2) ""))))
(ert-deftest ebox-composite-cut-fill-keeps-source-paint-without-interaction ()
"Cut glyphs keep their face, including dynamic slots, but not source actions."
(ebox-composite-test--with-metrics
(let* ((slot (tp-paint-slot-create '(:background "#eee5ce")))
(face (list 'bold (tp-paint-slot-face slot)))
(font-lock-face (list :background "#765fa1"))
(source (propertize "ABCD" 'face face 'font-lock-face font-lock-face
'keymap (make-sparse-keymap) 'help-echo "hidden"
'mouse-face 'highlight 'ebox-owner '(owner)))
(before (copy-sequence source))
(output (ebox-composite-slice source 3 24)))
(dolist (position '(0 3))
(should (eq (get-text-property position 'face output) face))
(should (eq (get-text-property position 'font-lock-face output)
font-lock-face))
(dolist (property '(keymap help-echo mouse-face ebox-owner))
(should-not (get-text-property position property output))))
(ebox-composite-test--assert-width output 21)
(tp-paint-slot-update slot '(:background "#aabbcc"))
(should (memq (tp-paint-slot-face slot)
(get-text-property 0 'face output)))
(should (equal (face-attribute (tp-paint-slot-face slot) :background)
"#aabbcc"))
(should (equal-including-properties source before)))))
(ert-deftest ebox-composite-cut-fill-covers-shaping-and-replacement-boundaries ()
"Reshaped glyphs, images and replacement strings retain source paint."
(ebox-composite-test--with-metrics
(let ((face (list :background "#eee5ce")))
(dolist (source (list (propertize "fiZ" 'face face)
(propertize "abc" 'face face 'display "WXYZ")
(propertize "abc" 'face face 'display
'(image :type xpm :width 28))))
(let ((output (ebox-composite-slice source 7 18)))
(should (eq (get-text-property 0 'face output) face))
(ebox-composite-test--assert-width output 11))))))
(ert-deftest ebox-composite-overlap-keeps-underlay-paint-at-both-cut-edges ()
"Occluding part of two glyphs preserves both exposed background strips."
(ebox-composite-test--with-metrics
(let* ((under (list :background "#eee5ce"))
(over (list :background "#20353e"))
(base (propertize "ABCD" 'face under))
(top (propertize (ebox-pixel-space 10) 'face over))
(output (ebox-composite-line base (list (list :text top :x 3)) 35)))
(should (eq (get-text-property 0 'face output) under))
(should (eq (get-text-property 1 'face output) over))
(should (eq (get-text-property 2 'face output) under))
(should-not (get-text-property (1- (length output)) 'face output))
(ebox-composite-test--assert-width output 35))))
(ert-deftest ebox-composite-layout-cut-keeps-rich-child-paint ()
"Layout clipping preserves child paint and does not resurrect child actions."
(ebox-composite-test--with-metrics
(dolist (child '("ABCD"
(text :background-color "red" :help-echo "child" "ABCD")
(box :background-color "red" :help-echo "child" "ABCD")))
(let* ((output (ebox-render
(ebox-build
`(box :width (px 17) :wrap-mode none :overflow hidden
:background-color "blue" ,child))))
(fill (1- (length output)))
(expected (if (stringp child) "blue" "red")))
(should (equal (substring-no-properties output) "AB "))
(should (member expected
(flatten-tree (get-text-property fill 'face output))))
(should-not (get-text-property fill 'help-echo output))
(ebox-composite-test--assert-width output 17)))))
(ert-deftest ebox-composite-shaped-shorter-unit-keeps-painted-remainder ()
"A detached narrower glyph leaves painted fill in its original allocation."
(ebox-composite-test--with-metrics
(cl-letf (((symbol-function 'ebox-composite-test--plain-width)
(lambda (text)
(+ (* 7 (length text))
(if (string-match-p "fi" text) 3 0)))))
(let* ((face (list :background "#eee5ce"))
(output (ebox-composite-slice (propertize "fiZ" 'face face) 7 24)))
(should (equal (substring-no-properties output) "i Z"))
(should (eq (get-text-property 1 'face output) face))
(ebox-composite-test--assert-width output 17)))))
(ert-deftest ebox-composite-mounted-overlap-keeps-painted-edges-after-updates ()
"Real publication preserves edge paint when moving, recoloring and hiding."
(ebox-composite-test--with-metrics
(with-temp-buffer
(unwind-protect
(progn
(ebox-render-to-buffer
(current-buffer)
(ebox-build
'(box :width (px 35) :height (lh 1) :background-color "blue"
(box :id "lower" :width (px 35) :height (lh 1)
:background-color "red" "ABCDE")
(box :id "upper" :position absolute :left (px 3)
:width (px 10) :height (lh 1)
:background-color "cyan"))))
(cl-labels
((check (left color visible)
(let* ((actual (buffer-string))
(fresh (ebox-render
(plist-get (ebox-surface-buffer-snapshot
(current-buffer)) :input))))
(ebox-composite-test--assert-width actual 35)
(should (equal (substring-no-properties actual)
(substring-no-properties fresh)))
(dotimes (position (length actual))
(let* ((x (ebox--substring-pixel-width actual 0 position))
(end (ebox--substring-pixel-width
actual 0 (1+ position))))
(when (< x end)
(should
(equal (cadr (memq :background
(flatten-tree
(get-text-property
position 'face actual))))
(if (and visible (>= x left) (< x (+ left 10)))
"cyan" color))))
(should (equal (get-text-property position 'face actual)
(get-text-property position 'face fresh))))))))
(check 3 "red" t)
(ebox-region-update (ebox-region-resolve (current-buffer) "upper")
:left '(px 9))
(check 9 "red" t)
(ebox-region-update (ebox-region-resolve (current-buffer) "lower")
:background-color "orange")
(check 9 "orange" t)
(ebox-region-update (ebox-region-resolve (current-buffer) "upper")
:visibility 'hidden)
(check 9 "orange" nil)))
(when (ebox-surface-buffer-mounted-p (current-buffer))
(ebox-unmount-buffer (current-buffer)))))))
(ert-deftest ebox-composite-mounted-layout-fill-tracks-child-paint ()
"Clipped fill follows paint, content, border and mixed geometry updates."
(ebox-composite-test--with-metrics
(dolist (kind '(text box))
(with-temp-buffer
(unwind-protect
(progn
(ebox-render-to-buffer
(current-buffer)
(ebox-build
`(box :id "parent" :width (px 17) :wrap-mode none :overflow hidden
:background-color "blue"
(,kind :id "child" :background-color "red"
:help-echo "child" "ABCD"))))
(cl-labels
((check (color)
(let* ((actual (buffer-string))
(fill (1- (length actual)))
(fresh (ebox-render
(plist-get (ebox-surface-buffer-snapshot
(current-buffer)) :input))))
(should (equal (substring-no-properties actual)
(substring-no-properties fresh)))
(should (equal (cadr (memq :background
(flatten-tree
(get-text-property fill 'face actual))))
color))
(should-not (get-text-property fill 'help-echo actual))
(should-not (get-text-property fill 'ebox-content actual))
(should-not (get-text-property fill ebox--paint-origin-property actual))
(dotimes (position (length actual))
(should (equal (get-text-property position 'face actual)
(get-text-property position 'face fresh)))))))
(check "red")
(ebox-region-update "child" :background-color "yellow")
(check "yellow")
(dolist (failure-step '(text client-state))
(let ((before (buffer-string))
(state (ebox--buffer-render-state (current-buffer)))
(revision (ebox-surface-buffer-revision (current-buffer)))
(tp--surface-publication-step-function
(lambda (step _surface)
(when (eq step failure-step) (error "Reject fill paint")))))
(should-error (ebox-region-update "child" :background-color "orange"))
(should (equal-including-properties before (buffer-string)))
(should (eq state (ebox--buffer-render-state (current-buffer))))
(should (= revision (ebox-surface-buffer-revision (current-buffer))))))
(check "yellow")
(ebox-region-update "parent" :background-color "green")
(check "yellow")
(when (eq kind 'box)
(ebox-region-update "child" :border-top-width '(lh 1)
:border-top-style 'solid :border-top-color "pink")
(check "yellow")
(ebox-region-update "child" :border-top-width '(lh 0))
(check "yellow"))
(ebox-region-update "child" :content "WXYZ")
(check "yellow")
(ebox-call-with-update-batch
(lambda ()
(ebox-region-update "parent" :width '(px 18))
(ebox-region-update "child" :background-color "purple")))
(check "purple")))
(when (ebox-surface-buffer-mounted-p (current-buffer))
(ebox-unmount-buffer (current-buffer))))))))
(ert-deftest ebox-composite-retained-slot-fill-keeps-paint-origin ()
"Transported clipped content still tracks later paint and rolls back safely."
(ebox-composite-test--with-metrics
(with-temp-buffer
(unwind-protect
(cl-labels
((paint (content color)
(ebox-test-text content :key 'paint-text :source-identity 'paint-text
:bgcolor color))
(slot (content)
(ebox-test-box :key 'left :source-identity 'left
:wrap-mode 'none :overflow 'hidden
(paint content "red")))
(root (content)
(ebox-test-box
:bgcolor "blue"
(ebox-test-grid :key 'grid :width '(60)
:grid-template-columns '((17) (36)) :column-gap '(7)
(slot content)
(ebox-test-box :key 'right :source-identity 'right
(ebox-test-text "R")))))
(repaint (content)
(let ((candidate (ebox-candidate-begin (current-buffer))))
(ebox-candidate-patch-host-paint
candidate 'paint-text (paint content "red") (paint content "yellow"))
(ebox-commit (current-buffer) candidate))))
(ebox-render-to-buffer (current-buffer) (root "ABCD"))
(let ((content (concat "A" (string #x0301) "BCD"))
(transport (symbol-function 'ebox-surface--retained-slot-owner-text))
(transported 0))
(cl-letf (((symbol-function 'ebox-surface--retained-slot-owner-text)
(lambda (&rest args)
(let ((result (apply transport args)))
(when result (cl-incf transported))
result))))
(let ((candidate (ebox-candidate-begin (current-buffer))))
(ebox-candidate-replace-host-ref candidate 'left (slot content))
(ebox-commit (current-buffer) candidate)))
(should (= transported 1))
(dolist (failure-step '(text client-state))
(let ((before (buffer-string))
(revision (ebox-surface-buffer-revision (current-buffer)))
(tp--surface-publication-step-function
(lambda (step _surface)
(when (eq step failure-step) (error "Reject transported paint")))))
(should-error (repaint content))
(should (equal-including-properties before (buffer-string)))
(should (= revision (ebox-surface-buffer-revision (current-buffer))))))
(repaint content)
(let ((actual (buffer-string))
(fresh (ebox-render (plist-get
(ebox-surface-buffer-snapshot (current-buffer))
:input))))
(should (equal (substring-no-properties actual)
(substring-no-properties fresh)))
(dotimes (position (length actual))
(should (equal (get-text-property position 'face actual)
(get-text-property position 'face fresh)))))))
(when (ebox-surface-buffer-mounted-p (current-buffer))
(ebox-unmount-buffer (current-buffer)))))))
(ert-deftest ebox-composite-slice-keeps-supported-graphemes-whole ()
"Both cut edges preserve combining, variation, modifier, ZWJ, and flags."
(ebox-composite-test--with-metrics

View File

@ -1101,6 +1101,35 @@ candidate cannot hide mutations by restoring the old hash-table pointer."
(should (plist-get first :face-baseline-known-p))
(should-not (get-text-property 0 ebox--paint-origin-property rendered)))))
(ert-deftest ebox-surface-clipped-paint-origin-stays-separate-from-ownership ()
"Reclipping retains the paint producer without recovering omitted semantics."
(let* ((ebox--paint-origin-capture-p t)
(face '(:weight bold))
(source (propertize "A" 'face face 'ebox-content 7
'help-echo "child" 'keymap (make-sparse-keymap)))
(fill (ebox-composite--fill source 0 5)))
(put-text-property 0 (length fill) 'ebox-content-owner 2 fill)
(let* ((reclipped (ebox-composite-slice fill 1 4))
(origin (get-text-property 0 ebox--paint-origin-property reclipped))
(other (ebox--paint-origin-create :baseline face
:source-roles '((content . 8))))
(fragment (car (ebox-surface--rendered-fragments reclipped))))
(should (equal (plist-get fragment :role-ids) '((content-owner . 2))))
(should (member '(content . 7) (plist-get fragment :paint-role-ids)))
(should (equal (plist-get fragment :face-baseline) face))
(should-not (ebox-surface--retained-property-value-equal-p origin other))
(should (equal (plist-get (plist-get fragment :paint-address) :paint-source-roles)
'((content . 7))))
(dolist (property '(help-echo keymap ebox-content ebox--paint-origin))
(should-not (get-text-property 0 property reclipped)))
;; Border paint can follow the omitted child, but child semantic tags
;; must not appear on the parent-owned replacement.
(plist-put fragment :text reclipped)
(ebox-surface--set-horizontal-border-role (list fragment) 7 'bt t)
(should (member '(bt . 7) (plist-get fragment :paint-role-ids)))
(should-not (member '(bt . 7) (plist-get fragment :role-ids)))
(should-not (get-text-property 0 'ebox-bt (plist-get fragment :text))))))
(ert-deftest ebox-native-layout-ir-preserves-default-foreground-face ()
"Serialize and optionally render the Ebox default foreground reset."
(let* ((ebox-viewport-width 80)