From 8da2fe68cc02672fe8d4ea358a9e33662f8b32c2 Mon Sep 17 00:00:00 2001 From: Kinneyzhang Date: Fri, 11 Sep 2026 01:32:19 +0800 Subject: [PATCH] fix: preserve paint across clipped overlap edges --- docs/user/ebox-api-reference.en.md | 9 +- docs/user/ebox-api-reference.zh.md | 6 +- docs/user/ebox-user-guide.en.md | 7 +- docs/user/ebox-user-guide.zh.md | 5 +- ebox-composite.el | 42 ++++- ebox-layout.el | 6 +- ebox-render-context.el | 18 +- ebox-surface.el | 101 ++++++----- tests/ebox-composite-tests.el | 261 +++++++++++++++++++++++++++++ tests/ebox-surface-tests.el | 29 ++++ 10 files changed, 422 insertions(+), 62 deletions(-) diff --git a/docs/user/ebox-api-reference.en.md b/docs/user/ebox-api-reference.en.md index 732be17..8bd2b7e 100644 --- a/docs/user/ebox-api-reference.en.md +++ b/docs/user/ebox-api-reference.en.md @@ -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 diff --git a/docs/user/ebox-api-reference.zh.md b/docs/user/ebox-api-reference.zh.md index 8266e2d..4baab2e 100644 --- a/docs/user/ebox-api-reference.zh.md +++ b/docs/user/ebox-api-reference.zh.md @@ -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)。 diff --git a/docs/user/ebox-user-guide.en.md b/docs/user/ebox-user-guide.en.md index 06f19ae..fc26899 100644 --- a/docs/user/ebox-user-guide.en.md +++ b/docs/user/ebox-user-guide.en.md @@ -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. diff --git a/docs/user/ebox-user-guide.zh.md b/docs/user/ebox-user-guide.zh.md index 1e6ff5a..6b65586 100644 --- a/docs/user/ebox-user-guide.zh.md +++ b/docs/user/ebox-user-guide.zh.md @@ -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。 diff --git a/ebox-composite.el b/ebox-composite.el index 6353934..022667b 100644 --- a/ebox-composite.el +++ b/ebox-composite.el @@ -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)) diff --git a/ebox-layout.el b/ebox-layout.el index 9e6c685..741c527 100644 --- a/ebox-layout.el +++ b/ebox-layout.el @@ -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) diff --git a/ebox-render-context.el b/ebox-render-context.el index 7de8270..b4d29a8 100644 --- a/ebox-render-context.el +++ b/ebox-render-context.el @@ -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) diff --git a/ebox-surface.el b/ebox-surface.el index b1ad743..f60a691 100644 --- a/ebox-surface.el +++ b/ebox-surface.el @@ -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) - (+ (- (car span) origin) old-end) - published-props))) + (origin-for (+ (- (car span) origin) old-position) + (+ (- (car span) origin) old-end) + 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)) - (list :roles (copy-tree roles) - :content-owner content-owner - :content-index 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) + (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)) diff --git a/tests/ebox-composite-tests.el b/tests/ebox-composite-tests.el index 7ff1d58..d180e28 100644 --- a/tests/ebox-composite-tests.el +++ b/tests/ebox-composite-tests.el @@ -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 diff --git a/tests/ebox-surface-tests.el b/tests/ebox-surface-tests.el index fcf0bc4..4c7c9ae 100644 --- a/tests/ebox-surface-tests.el +++ b/tests/ebox-surface-tests.el @@ -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)