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 `hidden` enforces the content width after composing children, preserving whole
supported text clusters and excluding the container's own padding and border supported text clusters and excluding the container's own padding and border
from the clipping area. Clipped-away child properties are removed with their from the clipping area. Clipped-away child interaction properties are removed;
text. Partial display spaces can be shortened; oversized glyphs/images are 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 omitted, not pixel-masked. Vertical clipping uses whole lines. `visible` allows
overflow, and `scroll` provides vertical scrolling. No ellipsis or horizontal overflow, and `scroll` provides vertical scrolling. No ellipsis or horizontal
scrolling is implied. scrolling is implied.
@ -151,7 +152,9 @@ an error before publication.
This is an opaque text compositor: horizontal positions are pixels, vertical This is an opaque text compositor: horizontal positions are pixels, vertical
positions share the host's row grid. Positioned descendants must share their positions share the host's row grid. Positioned descendants must share their
host's effective font line height. Whole supported text clusters and display 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 no alpha blending, partial-glyph masking, or child-frame rendering. Layered
trees use Elisp when the native representation cannot express their placement. 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 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` | | 图层放置 | `:position`、`:left`、`:top`、`:z-index`、`:layer`、`:anchor`、`:placement` |
`hidden` 在组合子节点后约束内容宽度,保留完整的受支持文本簇;容器自身的 padding 与 `hidden` 在组合子节点后约束内容宽度,保留完整的受支持文本簇;容器自身的 padding 与
border 不在裁剪区域内。被裁掉的子文本同时移除其属性。固定 display space 可以缩短, border 不在裁剪区域内。被裁掉的子文本不再保留交互属性,半字形补白仍保留原 face
及其绘制依赖。固定 display space 可以缩短,
放不下的字形/图像则整体省去,不做像素遮罩。纵向按完整行裁剪。`visible` 允许溢出, 放不下的字形/图像则整体省去,不做像素遮罩。纵向按完整行裁剪。`visible` 允许溢出,
`scroll` 提供纵向滚动;这些设置不隐含省略号或横向滚动。 `scroll` 提供纵向滚动;这些设置不隐含省略号或横向滚动。
@ -129,7 +130,8 @@ margin 不参与遮挡。绘制裁剪到宿主画布。
这是不透明文本合成:横向用像素,纵向共享宿主的文本行网格。定位子树必须与宿主 这是不透明文本合成:横向用像素,纵向共享宿主的文本行网格。定位子树必须与宿主
使用相同的有效字体行高。裁剪保留完整的受支持文本簇和 display unit不完整字形或 使用相同的有效字体行高。裁剪保留完整的受支持文本簇和 display unit不完整字形或
图像以中性空白替代。不提供 alpha 混合、半字形遮罩或 child frame。native 表示 图像以保留原 face含背景色的非交互空白替代源内容之外的画布空白仍保持中性。
不提供 alpha 混合、半字形遮罩或 child frame。native 表示
无法表达图层放置时使用 Elisp。示例和 retained 更新行为见 无法表达图层放置时使用 Elisp。示例和 retained 更新行为见
[图层指南](ebox-user-guide.zh.md#retained-layers)。 [图层指南](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 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 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 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, padding and borders retain their positions. This is pixel-width truncation,
not partial-glyph/image masking or automatic ellipsis. Omitted child text does not partial-glyph/image masking or automatic ellipsis. Omitted child text does
not leave its help, hover, pointer, or keymap active in the filler. 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. Composition uses horizontal pixels and one shared vertical text-row grid.
Positioned subtrees must have the same effective font line height as their Positioned subtrees must have the same effective font line height as their
host. Whole supported clusters survive cuts; partially covered glyphs/images 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 There is no alpha blending, half-glyph masking, or child frame. Layered trees
fall back to Elisp when native reflow cannot represent them. fall back to Elisp when native reflow cannot represent them.

View File

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

View File

@ -9,6 +9,7 @@
(require 'cl-lib) (require 'cl-lib)
(require 'ebox-measure) (require 'ebox-measure)
(require 'ebox-render-context)
(declare-function ebox--grapheme-cluster-end "ebox-layout" (string start)) (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) (cons 'space (plist-put properties :width (list width))) result)
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) (defun ebox-composite--join (parts)
"Join PARTS while retaining their separately measured pixel extents. "Join PARTS while retaining their separately measured pixel extents.
A neutral zero-pixel display boundary prevents adjacent EQ replacement runs 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. "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 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 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." source glyph or borrowing its pixels."
(let ((position start) (pixel left) parts) (let ((position start) (pixel left) parts)
(while (< position end) (while (< position end)
@ -82,12 +108,13 @@ source glyph or borrowing its pixels."
(cond (cond
((<= unit-width available) ((<= unit-width available)
(push unit parts) (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 ((ebox--display-space-width
(get-text-property position 'display line)) (get-text-property position 'display line))
(push (ebox-composite--space line position next available) (push (ebox-composite--space line position next available)
parts)) parts))
(t (push (ebox-pixel-space available) parts))) (t (push (ebox-composite--fill line position available) parts)))
(setq position next pixel next-pixel))))) (setq position next pixel next-pixel)))))
(ebox-composite--join (nreverse parts)))) (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. a single-line string or nil. Pixels beyond LINE are neutral blank space.
Keep complete supported graphemes and replacement display runs, preserving Keep complete supported graphemes and replacement display runs, preserving
their text property values by identity. Fixed pixel spaces can be cut at their text property values by identity. Fixed pixel spaces can be cut at
either edge; other partial glyphs become neutral blanks at their original either edge; other partial glyphs become noninteractive blanks retaining their
positions. Measure original prefixes so adjacent shaping is not summed as source faces at their original positions. Measure original prefixes so
isolated characters. Neither LINE nor its property values are modified." adjacent shaping is not summed as isolated characters. Neither LINE nor its
property values are modified."
(unless (ebox-composite--line-p line) (unless (ebox-composite--line-p line)
(error "Ebox composite needs a single-line string: %S" line)) (error "Ebox composite needs a single-line string: %S" line))
(unless (and (ebox-size--finite-number-p start) (>= start 0) (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)) (get-text-property position 'display line))
(ebox-composite--space (ebox-composite--space
line position next (- right left)) line position next (- right left))
(ebox-pixel-space (- right left))) (ebox-composite--fill line position (- right left)))
parts))) parts)))
(setq position next pixel next-pixel))) (setq position next pixel next-pixel)))
(flush)) (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. "Clip LINE to MAX-PIXEL without splitting supported grapheme clusters.
Retain visible text properties. A partially visible fixed display space Retain visible text properties. A partially visible fixed display space
keeps its properties and uses the remaining width; a glyph that cannot fit 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 is omitted as a whole. Its remaining width retains the source faces, never
an interactive stand-in for an omitted glyph." the interaction properties of the omitted glyph."
;; The prefix-plus-pixel-filler algorithm follows kinney's GPL-3.0-or-later ;; 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, ;; `s-pixel-floor' and `s-pixel-left' (2026), extended here for graphemes,
;; source properties and clipped display spaces without an extra dependency. ;; source properties and clipped display spaces without an extra dependency.
@ -740,7 +740,7 @@ an interactive stand-in for an omitted glyph."
(concat (concat
(substring line 0 position) (substring line 0 position)
(when (> remaining 0) (when (> remaining 0)
(let ((space (ebox-pixel-space remaining))) (let ((space (ebox-composite--fill line position remaining)))
(when space-width (when space-width
(add-text-properties (add-text-properties
0 (length space) 0 (length space)

View File

@ -12,6 +12,7 @@
(defvar ebox-layer--active-p) (defvar ebox-layer--active-p)
(defvar ebox-incremental--render-portals-p) (defvar ebox-incremental--render-portals-p)
(defvar ebox--render-source-index) (defvar ebox--render-source-index)
(defvar ebox-region-types)
(declare-function ebox-interaction--refresh-hover! "ebox-interaction" (declare-function ebox-interaction--refresh-hover! "ebox-interaction"
(string start end &optional face)) (string start end &optional face))
@ -102,8 +103,8 @@
(cl-defstruct (ebox--paint-origin (cl-defstruct (ebox--paint-origin
(:constructor ebox--paint-origin-create)) (:constructor ebox--paint-origin-create))
"Transient caller-face baseline captured before Ebox paint composition." "Transient caller-face baseline and optional paint-only source roles."
baseline) baseline source-roles)
(defconst ebox--paint-origin-property 'ebox--paint-origin (defconst ebox--paint-origin-property 'ebox--paint-origin
"Private transient property carrying one render-time paint baseline.") "Private transient property carrying one render-time paint baseline.")
@ -115,6 +116,19 @@
(make-hash-table :test #'eq :weakness 'key) (make-hash-table :test #'eq :weakness 'key)
"Weak map from rendered strings to Ebox-owned property values.") "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) (defun ebox--render-owned-text-values-for (property &optional create)
"Return the active candidate registry for PROPERTY when CREATE is non-nil." "Return the active candidate registry for PROPERTY when CREATE is non-nil."
(when (hash-table-p ebox--render-owned-text-values) (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) (defun ebox-surface--retained-property-value-equal-p (left right)
"Return non-nil when retaining LEFT instead of RIGHT preserves its value. "Return non-nil when retaining LEFT instead of RIGHT preserves its value.
Opaque payloads, including callbacks, keymaps and records, must keep identity. 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))) (let ((seen (make-hash-table :test #'eq)))
(cl-labels (cl-labels
((same ((same
@ -3688,8 +3688,10 @@ Render-created paint origins compare their captured baselines instead."
(cond (cond
((eq old new) t) ((eq old new) t)
((and (ebox--paint-origin-p old) (ebox--paint-origin-p new)) ((and (ebox--paint-origin-p old) (ebox--paint-origin-p new))
(same (ebox--paint-origin-baseline old) (and (same (ebox--paint-origin-baseline old)
(ebox--paint-origin-baseline new))) (ebox--paint-origin-baseline new))
(same (ebox--paint-origin-source-roles old)
(ebox--paint-origin-source-roles new))))
((or (functionp old) (functionp new) ((or (functionp old) (functionp new)
(keymapp old) (keymapp new)) nil) (keymapp old) (keymapp new)) nil)
((memq new (gethash old seen)) t) ((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))) (origin (with-current-buffer buffer (point-min)))
result) result)
(cl-labels (cl-labels
((baseline-for ((origin-for
(start end published-properties) (start end published-properties)
(while (and fragments (<= (plist-get (car fragments) :end) start)) (while (and fragments (<= (plist-get (car fragments) :end) start))
(setq fragments (cdr fragments))) (setq fragments (cdr fragments)))
(let ((remaining fragments) (position start) seen baseline) (let ((remaining fragments) (position start) seen baseline source-roles)
(while (< position end) (while (< position end)
(let ((fragment (car remaining))) (let ((fragment (car remaining)))
(unless (and fragment (unless (and fragment
(<= (plist-get fragment :start) position) (<= (plist-get fragment :start) position)
(< position (plist-get fragment :end))) (< position (plist-get fragment :end)))
(throw 'slot-property-miss nil)) (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 (cond
((plist-get fragment :face-baseline-known-p) ((plist-get fragment :face-baseline-known-p)
(plist-get fragment :face-baseline)) (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)) (plist-get published-properties 'face))
(t (throw 'slot-property-miss nil))))) (t (throw 'slot-property-miss nil)))))
(when (and seen (when (and seen
(not (ebox-surface--retained-property-value-equal-p (or (not (ebox-surface--retained-property-value-equal-p
baseline value))) baseline value))
(not (equal source-roles roles))))
(throw 'slot-property-miss nil)) (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)) (setq position (min end (plist-get fragment :end))
remaining (cdr remaining)))) remaining (cdr remaining))))
baseline)) (ebox--paint-origin-create
:baseline (copy-tree baseline)
:source-roles (copy-tree source-roles))))
(pixel-filler-p (pixel-filler-p
(line start end properties values) (line start end properties values)
(let ((display (plist-get properties 'display))) (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. ;; old fragment coordinates or peeling a composed face.
(put-text-property (put-text-property
new-position new-end ebox--paint-origin-property new-position new-end ebox--paint-origin-property
(ebox--paint-origin-create (origin-for (+ (- (car span) origin) old-position)
:baseline (copy-tree (+ (- (car span) origin) old-end)
(baseline-for (+ (- (car span) origin) old-position) published-props)
(+ (- (car span) origin) old-end)
published-props)))
line) line)
(setq old-position old-end new-position new-end))) (setq old-position old-end new-position new-end)))
(unless (and (= old-position old-length) (= new-position new-length)) (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) (- (min end (plist-get fragment :end)) start)
ebox--paint-origin-property ebox--paint-origin-property
(ebox--paint-origin-create (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)))))))) line))))))))
lines) lines)
@ -5485,16 +5495,7 @@ than replaying old coordinates, properties or revision authority."
(defun ebox-surface--role-ids-from-properties (properties) (defun ebox-surface--role-ids-from-properties (properties)
"Return namespaced Ebox role/id pairs from PROPERTIES." "Return namespaced Ebox role/id pairs from PROPERTIES."
(let (roles) (ebox--render-role-ids-from-properties properties))
(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-surface--role-ids-at (rendered position &optional cache) (defun ebox-surface--role-ids-at (rendered position &optional cache)
"Return namespaced Ebox role/id pairs at POSITION in RENDERED. "Return namespaced Ebox role/id pairs at POSITION in RENDERED.
@ -5511,27 +5512,29 @@ one output projection."
roles)))) roles))))
(defun ebox-surface--paint-address-at (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. "Return the semantic paint address at POSITION in RENDERED.
ROLE-CACHE is the per-render role extraction cache and ORDINAL-TABLE tracks 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. 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 The result deliberately contains semantic owner/role facts only; it never
uses a string, buffer, marker, or TP object identity as an address." uses a string, buffer, marker, or TP object identity as an address."
(let* ((properties (text-properties-at position rendered)) (let* ((properties (text-properties-at position rendered))
(roles (ebox-surface--role-ids-at rendered position role-cache)) (roles (ebox-surface--role-ids-at rendered position role-cache))
(content-owner (plist-get properties 'ebox-content-owner)) (content-owner (plist-get properties 'ebox-content-owner))
(content-index (plist-get properties 'ebox-content-idx)) (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)
(list :roles (copy-tree roles) paint-source-roles)
:content-owner content-owner (append
:content-index content-index))) (list :roles (copy-tree roles)
:content-owner content-owner
: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)))) (ordinal (and prefix (gethash prefix ordinal-table 0))))
(when prefix (when prefix
(puthash prefix (1+ ordinal) ordinal-table) (puthash prefix (1+ ordinal) ordinal-table)
(list :roles (copy-tree roles) (append prefix (list :ordinal ordinal)))))
:content-owner content-owner
:content-index content-index
:ordinal ordinal))))
(defun ebox-surface--candidate-plan-text (rendered owned-values) (defun ebox-surface--candidate-plan-text (rendered owned-values)
"Return a private snapshot of RENDERED for an owned TP plan. "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 ebox--paint-origin-property
rendered)) rendered))
(roles (ebox-surface--role-ids-at rendered position role-cache)) (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 (address
(ebox-surface--paint-address-at (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 (baseline
(and origin (and origin
(copy-tree (copy-tree
(ebox--paint-origin-baseline origin))))) (ebox--paint-origin-baseline origin)))))
(push (list :text rendered :text-source-p t (push (list :text rendered :text-source-p t
:start position :end next :start position :end next
:line line :paint-role-ids roles :line line :paint-role-ids paint-roles
:role-ids roles :role-ids roles
:paint-address address :paint-address address
:paint-token :paint-token
(and address (and address
(list :baseline (copy-tree baseline) (list :baseline (copy-tree baseline)
:roles (copy-tree roles) :roles (copy-tree paint-roles)
:content-owner :content-owner
(plist-get address :content-owner) (plist-get address :content-owner)
:content-index :content-index
@ -6561,12 +6571,21 @@ relative face contributions and performs the final merge during prepare."
fragment region-id ebox-surface--paint-base-roles))) fragment region-id ebox-surface--paint-base-roles)))
(pair (cons role region-id))) (pair (cons role region-id)))
(dolist (key '(:paint-role-ids :role-ids)) (dolist (key '(:paint-role-ids :role-ids))
(let ((roles (cl-remove pair (plist-get fragment key) :test #'equal))) (let* ((roles (cl-remove pair (plist-get fragment key) :test #'equal))
(plist-put fragment key (if anchor-p (append roles (list pair)) roles)))) (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) (when (> (length (plist-get fragment :text)) 0)
(if-let* ((owner (if-let* ((owner
(cdr (cl-find role (cdr (cl-find role
(plist-get fragment :paint-role-ids) (plist-get fragment :role-ids)
:key #'car :test #'eq :from-end t)))) :key #'car :test #'eq :from-end t))))
(put-text-property 0 (length (plist-get fragment :text)) (put-text-property 0 (length (plist-get fragment :text))
property owner (plist-get fragment :text)) property owner (plist-get fragment :text))

View File

@ -10,6 +10,7 @@
(require 'cl-lib) (require 'cl-lib)
(require 'ebox) (require 'ebox)
(require 'ebox-composite) (require 'ebox-composite)
(require 'ebox-fixtures)
(defun ebox-composite-test--plain-width (text) (defun ebox-composite-test--plain-width (text)
"Measure TEXT with seven-pixel glyphs, zero-width marks, and fi shaping." "Measure TEXT with seven-pixel glyphs, zero-width marks, and fi shaping."
@ -90,6 +91,266 @@
'keymap nil output))))) 'keymap nil output)))))
(should (equal (ebox-composite-slice "abc" 2 2) "")))) (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 () (ert-deftest ebox-composite-slice-keeps-supported-graphemes-whole ()
"Both cut edges preserve combining, variation, modifier, ZWJ, and flags." "Both cut edges preserve combining, variation, modifier, ZWJ, and flags."
(ebox-composite-test--with-metrics (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 (plist-get first :face-baseline-known-p))
(should-not (get-text-property 0 ebox--paint-origin-property rendered))))) (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 () (ert-deftest ebox-native-layout-ir-preserves-default-foreground-face ()
"Serialize and optionally render the Ebox default foreground reset." "Serialize and optionally render the Ebox default foreground reset."
(let* ((ebox-viewport-width 80) (let* ((ebox-viewport-width 80)