fix: preserve mixed updates beside independent scroll regions

This commit is contained in:
Kinneyzhang 2026-09-06 22:11:05 +08:00
parent 7c0ddbe933
commit e7066b27f4
3 changed files with 548 additions and 81 deletions

View File

@ -7601,11 +7601,11 @@ line count; its outside siblings are shifted by the one scoped replacement."
(setq candidate-id (gethash candidate-id parents))))))
proof))
(defun ebox-incremental--two-owner-allocation-proof
(defun ebox-incremental--two-owner-geometry-proof
(buffer old-state candidate-state geometry-prepared)
"Return two disjoint geometry proofs containing one allocation closure.
This narrow fallback runs only after the ordinary combined span proof misses.
It never partitions larger dirty sets, preserving their existing coalescing."
"Combine exact geometry proofs for two changes.
Changes sharing one safe owner reuse its grouped proof; separate owners retain
their disjoint span or allocation proofs. Larger dirty sets keep coalescing."
(let ((dirty-set (plist-get geometry-prepared :dirty-set))
(root-id (ebox--buffer-root-node-id buffer))
proofs owner-proofs valid allocation-p)
@ -7684,36 +7684,42 @@ It never partitions larger dirty sets, preserving their existing coalescing."
(copy-sequence local-owner-proofs)))
(setq allocation-p (or allocation-p allocation-proof)))
(setq valid nil)))))
(when (and valid allocation-p
(= (length proofs) 2)
(ebox-incremental--owner-proofs-disjoint-p
buffer owner-proofs))
(when (and valid (= (length proofs) 2))
(setq proofs (nreverse proofs))
(let ((owner-ids
(delete-dups
(mapcar (lambda (proof) (plist-get proof :owner-id))
owner-proofs))))
(list :owner-id (car owner-ids)
:owner-ids owner-ids
:owner-proofs owner-proofs
:dirty-set (copy-sequence dirty-set)
:variable-content-p
(cl-some (lambda (proof)
(plist-get proof :variable-content-p))
proofs)
:ancestor-slot-signature
(cl-mapcan
(lambda (proof)
(copy-sequence
(plist-get proof :ancestor-slot-signature)))
proofs)
:layout-snapshot-detail-generation
(plist-get old-state :layout-snapshot-detail-generation)
:static-cascade-p
(cl-every (lambda (proof)
(plist-get proof :static-cascade-p))
proofs)
:contains-allocation-closure-p t)))))
(if (= (length owner-ids) 1)
(when-let* ((grouped
(ebox-incremental--grouped-owner-span-proof
buffer old-state geometry-prepared candidate-state
(list (ebox--patch-op 'span-patch (car owner-ids))))))
(dolist (owner-proof (plist-get grouped :owner-proofs))
(plist-put owner-proof :retain-external-owner-suffix-p t))
grouped)
(when (ebox-incremental--owner-proofs-disjoint-p buffer owner-proofs)
(list :owner-id (car owner-ids)
:owner-ids owner-ids
:owner-proofs owner-proofs
:dirty-set (copy-sequence dirty-set)
:variable-content-p
(cl-some (lambda (proof)
(plist-get proof :variable-content-p))
proofs)
:ancestor-slot-signature
(cl-mapcan
(lambda (proof)
(copy-sequence
(plist-get proof :ancestor-slot-signature)))
proofs)
:layout-snapshot-detail-generation
(plist-get old-state :layout-snapshot-detail-generation)
:static-cascade-p
(cl-every (lambda (proof)
(plist-get proof :static-cascade-p))
proofs)
:contains-allocation-closure-p allocation-p)))))))
(defun ebox-incremental--mixed-owner-proof
(buffer old-state candidate-state prepared owner-plan)
@ -7763,7 +7769,7 @@ suppresses the ordinary fallback when any fact is missing."
;; two local proofs already close exactly.
(geometry-two-owner-proof
(and (= (length geometry-dirty) 2)
(ebox-incremental--two-owner-allocation-proof
(ebox-incremental--two-owner-geometry-proof
buffer old-state candidate-state geometry-prepared)))
(geometry-context-proof
(and (not geometry-two-owner-proof)
@ -7903,12 +7909,13 @@ suppresses the ordinary fallback when any fact is missing."
:retain-external-owner-suffix-p t)))
(when (and geometry-proof paint-valid-p disjoint-p
allocation-disjoint-p cascade-safe-p
;; The single-owner span proof can retain unrelated scroll
;; subtrees. Its geometry partition does not prove that this
;; mixed transaction's paint owners leave scroll caches intact.
(null (plist-get old-state :scroll-region-ids))
(null (ebox-incremental--hash-keys
(plist-get prepared :scroll-state-table)))
;; Both the rendered geometry and inherited paint must leave
;; every retained scroll producer outside their dependency scope.
(cl-every
(lambda (owner-id)
(ebox-incremental--owner-disjoint-from-scroll-p
buffer old-state prepared candidate-state owner-id))
(append geometry-owner-ids paint-owner-ids))
(not (plist-get prepared :scroll-state-transaction))
(not (plist-get prepared :scroll-patch-fast-p))
root-id
@ -8484,18 +8491,25 @@ closure may still plan one for a later owner-scoped surface projection."
(ebox-incremental--commit-report prepared report candidate-state)))
(defun ebox-incremental--paint-projection-p
(old-state owner-plan dirty-set)
"Return non-nil when OWNER-PLAN can reuse OLD-STATE spatial topology."
(buffer old-state prepared candidate-state owner-plan)
"Return non-nil when OWNER-PLAN can reuse OLD-STATE spatial topology.
BUFFER, PREPARED and CANDIDATE-STATE prove independence from retained scroll."
(and owner-plan
(null (plist-get old-state :scroll-region-ids))
(not (plist-get prepared :scroll-state-transaction))
(not (plist-get prepared :scroll-patch-fast-p))
(cl-every (lambda (op) (eq (plist-get op :op) 'paint-patch))
owner-plan)
(cl-every
(lambda (op)
(ebox-incremental--owner-disjoint-from-scroll-p
buffer old-state prepared candidate-state (plist-get op :owner-id)))
owner-plan)
(cl-every
(lambda (dirty)
(cl-every (lambda (key)
(memq key ebox--paint-style-signature-keys))
(plist-get dirty :changed-keys)))
dirty-set)))
(plist-get prepared :dirty-set))))
(defun ebox-incremental--hash-key-set-equal-p (left right)
"Return non-nil when hash tables LEFT and RIGHT have the same keys."
@ -9288,7 +9302,7 @@ the surface renderer and role/overflow validators remain the final proof."
(plist-get slot :start-pixel)))
slots))))))
(defun ebox-incremental--span-owner-disjoint-from-scroll-p
(defun ebox-incremental--owner-disjoint-from-scroll-p
(buffer old-state prepared candidate-state owner-id)
"Return non-nil when OWNER-ID cannot change any retained scroll content.
The active scroll set and each scroll box identity must survive preparation.
@ -9313,8 +9327,10 @@ regenerate its cached lines; only separate branches may keep those lines."
(eq next-box (gethash scroll-id (plist-get candidate-state :node-table)))
(eq next-box (gethash region (plist-get candidate-state :region-box-table)))
(not (equal scroll-id owner-id))
(not (ebox--runtime-ancestor-id-p buffer scroll-id owner-id))
(not (ebox--runtime-ancestor-id-p buffer owner-id scroll-id))
(let ((ebox-incremental--buffer-render-state-override
(cons buffer old-state)))
(and (not (ebox--runtime-ancestor-id-p buffer scroll-id owner-id))
(not (ebox--runtime-ancestor-id-p buffer owner-id scroll-id))))
(let ((ebox-incremental--buffer-render-state-override
(cons buffer candidate-state)))
(and (not (ebox--runtime-ancestor-id-p buffer scroll-id owner-id))
@ -9450,7 +9466,7 @@ metadata; this predicate only authorizes the local attempt."
local-typography-p)
(ebox-incremental--cascade-local-owner-proof-p
old-state candidate-state (list dirty))))
(ebox-incremental--span-owner-disjoint-from-scroll-p
(ebox-incremental--owner-disjoint-from-scroll-p
buffer old-state prepared candidate-state owner-id)
;; Admit the new scroll coexistence case only for an exact
;; content extent. Length or width changes can alter an
@ -9729,7 +9745,8 @@ role, and outside-complement compatibility before publication."
new-node))
(ebox-incremental--same-root-surface-identity-p
old-state candidate-state)
(null (plist-get old-state :scroll-region-ids))
(ebox-incremental--owner-disjoint-from-scroll-p
buffer old-state prepared candidate-state owner-id)
(not (ebox--node-visible-overflow-p old-node))
(not (ebox--node-visible-overflow-p new-node))
ancestor-slot-signature
@ -9741,7 +9758,8 @@ role, and outside-complement compatibility before publication."
:snapshot snapshot
:allocated-width allocated-width
:variable-content-p
(and allocated-width
(and (null (plist-get old-state :scroll-region-ids))
allocated-width
(cl-some
(lambda (entry)
(memq :content
@ -10331,7 +10349,7 @@ role, and outside-complement compatibility before publication."
(paint-p
(and (not native-program-p)
(ebox-incremental--paint-projection-p
old-state owner-plan (plist-get prepared :dirty-set))))
buffer old-state prepared candidate-state owner-plan)))
(span-proof
(unless (or native-program-p structural-dirty-p paint-p mixed-proof)
(ebox-incremental--span-patch-projection-proof
@ -10512,7 +10530,7 @@ whether NEXT-ROOT shares untouched published nodes."
(plist-get prepared :dirty-set)))))
(paint-p
(ebox-incremental--paint-projection-p
old-state owner-plan (plist-get prepared :dirty-set)))
buffer old-state prepared candidate-state owner-plan))
(span-proof
(unless (or paint-p mixed-proof skip-span-proof-p)
(ebox-incremental--span-patch-projection-proof

View File

@ -86,12 +86,11 @@
(defun ebox-surface--mixed-range-splice-p (state)
"Return non-nil when STATE's mixed proof contains one Range splice."
(cl-some
(lambda (owner-proof)
(plist-get owner-proof :range-splice-p))
(plist-get
(plist-get (plist-get state :mixed-owner-proof) :geometry-proof)
:owner-proofs)))
(let ((proof (plist-get (plist-get state :mixed-owner-proof) :geometry-proof)))
(or (plist-get proof :range-splice-p)
(cl-some (lambda (owner-proof)
(plist-get owner-proof :range-splice-p))
(plist-get proof :owner-proofs)))))
(declare-function ebox--render-layout "ebox-layout" (node))
(declare-function ebox--maplines "ebox" (function string))
@ -1813,7 +1812,12 @@ the projection roots a second time."
(region-objects
(or (and scroll-fast-p
(plist-get state :region-surface-object-table))
(ebox-surface--region-object-table state node-objects))))
(ebox-surface--region-object-table state node-objects)))
(property-contributions
(plist-get state :paint-property-contributions)))
;; Paint contributions belong to this one publication. Keep them out of
;; the transferred client state so later paint cannot replay old layers.
(cl-remf state :paint-property-contributions)
(plist-put state :surface-node-object-table node-objects)
(plist-put state :region-surface-object-table region-objects)
(unless (and scroll-fast-p
@ -1827,7 +1831,8 @@ the projection roots a second time."
(or (and native-retained-p
(ebox-surface--native-patch-result
context projection state output node-objects region-objects))
(and (memq projection-kind
(and (null property-contributions)
(memq projection-kind
'(span-patch owner-scoped mixed-owner-reflow))
(ebox-surface--span-coordinate-result
context projection state output previous-owned-ranges))
@ -1840,10 +1845,9 @@ the projection roots a second time."
scroll-fast-p))
(plan (car prepared))
(rendered (cadr prepared))
(owned-ranges (nth 2 prepared))
(property-contributions
(plist-get state :paint-property-contributions)))
(or (and (memq projection-kind
(owned-ranges (nth 2 prepared)))
(or (and (null property-contributions)
(memq projection-kind
'(span-patch owner-scoped mixed-owner-reflow))
(ebox-surface--coordinate-commit
context state rendered owned-ranges (nth 3 prepared)
@ -3395,11 +3399,14 @@ old allocated width while the owner changes its natural content length."
(cons buffer state)))
(ebox--flex-item-slot-sized-node
buffer owner-id old-snapshot changed-keys)))
;; Variable content is explicitly shaped into a retained slot.
;; Typography-only changes must render at their natural size so
;; Grouped owners and variable content have a proven retained
;; allocation. Rendering there does not grant permission for
;; the final footprint to vary.
;; Ungrouped typography changes render at their natural size so
;; the final footprint check observes any intrinsic-width change
;; instead of hiding it behind the old allocation.
(if (and node allocated-width variable-content-p)
(if (and node allocated-width
(or variable-content-p role-owned-lines-p))
(plist-put (copy-sequence node) :width allocated-width)
node))))
(when (and old-snapshot spans node render-node)
@ -3876,7 +3883,12 @@ owners are then recomposed from the committed semantic fragment ledger, never
by peeling an already-composed face suffix. Any missing address/baseline
causes the caller to use the ordinary full projection."
(let* ((proof (plist-get state :mixed-owner-proof))
(geometry-proof (and proof (plist-get proof :geometry-proof)))
;; Range splices may change the TP object set, so the retained
;; content result cannot carry their paint layers. Preserve the
;; ordinary renderer before producing a cleaned paint baseline.
(geometry-proof (and proof
(not (ebox-surface--mixed-range-splice-p state))
(plist-get proof :geometry-proof)))
(geometry-kind (and proof (plist-get proof :geometry-kind)))
(paint-owner-ids (and proof (plist-get proof :paint-owner-ids)))
(output
@ -4005,7 +4017,9 @@ causes the caller to use the ordinary full projection."
output fragments state))
(plist-put state :mixed-owner-fragment-data fragments)
(plist-put state :mixed-owner-content-p t)
output)))))))
(apply #'concat
(mapcar (lambda (fragment) (plist-get fragment :text))
fragments)))))))))
(defun ebox-surface--scroll-patch-output (_buffer state)
"Return a staged visible scroll window without rerunning layout when safe.
@ -5344,6 +5358,7 @@ but cannot introduce or remove a viewport expression or node identity."
(copy-sequence previous-state)
(ebox--new-buffer-render-state root))
(copy-sequence state-overrides))))
(cl-remf state :paint-property-contributions)
(plist-put state :root-node root)
(when (plist-get state :native-sync-confirmed-p)
(ebox-native-commit-attach-confirmed-base previous-state state))
@ -5622,7 +5637,14 @@ published runtime and must be consumed without clearing those shared nodes."
(with-current-buffer buffer
(save-restriction
(widen)
(buffer-substring (point-min) (point-max)))))))
(buffer-substring (point-min) (point-max)))))))
;; Retaining scroll pixels still requires the producer's
;; reactive scroll subscription in this paint-only evaluation.
(when (and signals
(hash-table-p (plist-get state :scroll-state-table))
(> (hash-table-count
(plist-get state :scroll-state-table)) 0))
(tp-signal-read (ebox-surface--signals-scroll signals)))
(setq state (ebox-surface--finish-runtime-state state))
(ebox-surface--refresh-paint-snapshots state dirty-set)
(cl-remf state :paint-dirty-set)

View File

@ -153,31 +153,43 @@
(prepared (list :scroll-state-table table))
(candidate (copy-sequence state)))
(puthash region scroll table)
(should (ebox-incremental--span-owner-disjoint-from-scroll-p
(should (ebox-incremental--owner-disjoint-from-scroll-p
(current-buffer) state prepared candidate owner))
(should-not (ebox-incremental--span-owner-disjoint-from-scroll-p
(should-not (ebox-incremental--owner-disjoint-from-scroll-p
(current-buffer) state prepared candidate root))
(should-not (ebox-incremental--span-owner-disjoint-from-scroll-p
(should-not (ebox-incremental--owner-disjoint-from-scroll-p
(current-buffer) state prepared candidate scroll-id))
(remhash region table)
(should-not (ebox-incremental--span-owner-disjoint-from-scroll-p
(should-not (ebox-incremental--owner-disjoint-from-scroll-p
(current-buffer) state prepared candidate owner))
(puthash 'different-region scroll table)
(should-not (ebox-incremental--span-owner-disjoint-from-scroll-p
(should-not (ebox-incremental--owner-disjoint-from-scroll-p
(current-buffer) state prepared candidate owner))
(clrhash table)
(puthash region scroll table)
(let ((regions (copy-hash-table (plist-get state :region-box-table))))
(remhash region regions)
(plist-put candidate :region-box-table regions)
(should-not (ebox-incremental--span-owner-disjoint-from-scroll-p
(should-not (ebox-incremental--owner-disjoint-from-scroll-p
(current-buffer) state prepared candidate owner)))
(plist-put candidate :region-box-table (plist-get state :region-box-table))
(let ((parents (copy-hash-table (plist-get state :parent-table))))
(puthash scroll-id owner parents)
(plist-put candidate :parent-table parents)
(should-not (ebox-incremental--span-owner-disjoint-from-scroll-p
(current-buffer) state prepared candidate owner))))))
(should-not (ebox-incremental--owner-disjoint-from-scroll-p
(current-buffer) state prepared candidate owner)))
(plist-put candidate :parent-table (plist-get state :parent-table))
(let* ((parents (copy-hash-table (plist-get state :parent-table)))
(overlapping-old-state (copy-sequence state))
(ebox-incremental--buffer-render-state-override
(cons (current-buffer) candidate)))
;; The caller may already expose candidate ancestry. Old-state
;; ancestry must still reject an owner that used to be inside scroll.
(puthash owner scroll-id parents)
(plist-put overlapping-old-state :parent-table parents)
(should-not (ebox-incremental--owner-disjoint-from-scroll-p
(current-buffer) overlapping-old-state prepared
candidate owner))))))
(ert-deftest ebox-commit-content-shift-preserves-later-scroll-targets ()
"Longer and shorter local text retain the later scroll widget's targets."
@ -221,6 +233,224 @@
(should (= 1 (ebox--scroll-region-by scroll-id 1)))
(should (= -1 (ebox--scroll-region-by scroll-id -1)))))))
(defun ebox-commit-test--scroll-family-root (left right color)
"Return independent fixed-width LEFT, RIGHT and COLOR owners beside scroll."
(ebox-test-column :key 'family-root :width '(120)
(ebox-test-box :key 'left :source-identity 'left :width '(80)
(ebox-test-text left))
(ebox-test-box :key 'right :source-identity 'right :width '(80)
(ebox-test-text right))
(ebox-test-box :key 'paint :source-identity 'paint :color color
(ebox-test-text "paint"))
(ebox-commit-test--scroll-sibling-root "untouched")))
(defun ebox-commit-test--scroll-family-candidate (buffer color &optional content)
"Return BUFFER candidate changing COLOR and, when CONTENT, both text owners."
(let ((candidate (ebox-candidate-begin buffer)))
(when content
(ebox-candidate-replace-host-ref
candidate 'left
(ebox-test-box :key 'left :source-identity 'left :width '(80)
(ebox-test-text "left-new")))
(ebox-candidate-replace-host-ref
candidate 'right
(ebox-test-box :key 'right :source-identity 'right :width '(80)
(ebox-test-text "right-new"))))
(ebox-candidate-replace-host-ref
candidate 'paint
(if color
(ebox-test-box :key 'paint :source-identity 'paint :color color
(ebox-test-text "paint"))
(ebox-test-box :key 'paint :source-identity 'paint
(ebox-test-text "paint"))))
candidate))
(defun ebox-commit-test--assert-scroll-family-output (left right color)
"Assert current buffer contains LEFT, RIGHT, and the painted COLOR."
(should (string-match-p left (buffer-string)))
(should (string-match-p right (buffer-string)))
(save-excursion
(goto-char (point-min))
(search-forward "paint")
(should (equal color
(ebox-commit-test--face-value
(get-text-property (1- (point)) 'face) :foreground)))))
(ert-deftest ebox-commit-paint-retains-disjoint-scroll ()
"Pure sibling paint retains scroll caches and stays painted after scrolling."
(with-temp-buffer
(ebox-render-to-buffer
(current-buffer)
(ebox-commit-test--scroll-family-root "left-old" "right-old" "#123456"))
(let* ((state (ebox--buffer-render-state (current-buffer)))
(scroll-id (car (plist-get state :scroll-region-ids)))
(scroll (gethash scroll-id ebox--scroll-global-state))
(raw (plist-get scroll :content-lines))
(rendered (plist-get scroll :rendered-content-lines))
(region-count (hash-table-count ebox--region-box-table))
(root-render (symbol-function 'ebox-surface--render-candidate))
(root-renders 0)
report)
(should scroll-id)
(cl-letf (((symbol-function 'ebox-surface--render-candidate)
(lambda (candidate-state)
(cl-incf root-renders)
(funcall root-render candidate-state))))
(setq report
(ebox-commit
(current-buffer)
(ebox-commit-test--scroll-family-candidate
(current-buffer) "#654321"))))
(should (eq (plist-get report :projection-kind) 'paint))
(should (zerop root-renders))
(should-not (plist-get report :tp-full-root))
(should (= 1 (tp-signal-subscriber-count
(ebox-surface--signals-scroll
ebox-surface--context-signals))))
(let ((next-scroll (gethash scroll-id ebox--scroll-global-state)))
(should (eq raw (plist-get next-scroll :content-lines)))
(should (eq rendered (plist-get next-scroll :rendered-content-lines)))
(should (eq (plist-get next-scroll :box)
(gethash scroll-id ebox--region-box-table)))
(should (= region-count (hash-table-count ebox--region-box-table))))
(ebox-commit-test--assert-scroll-family-output "left-old" "right-old" "#654321")
(should (= 1 (ebox--scroll-region-by scroll-id 1)))
(should (string-match-p "line-c" (buffer-string)))
(ebox-commit-test--assert-scroll-family-output "left-old" "right-old" "#654321")
(should (= -1 (ebox--scroll-region-by scroll-id -1)))
(should (string-match-p "line-a" (buffer-string)))
(ebox-commit-test--assert-scroll-family-output "left-old" "right-old" "#654321"))))
(ert-deftest ebox-commit-mixed-owners-retain-disjoint-scroll-and-rollback ()
"Two text owners plus paint retain exact output, scroll caches and rollback."
(with-temp-buffer
(ebox-render-to-buffer
(current-buffer)
(ebox-commit-test--scroll-family-root "left-old" "right-old" "#123456"))
(let* ((state (ebox--buffer-render-state (current-buffer)))
(scroll-id (car (plist-get state :scroll-region-ids)))
(scroll (gethash scroll-id ebox--scroll-global-state))
(box (gethash scroll-id ebox--region-box-table))
(raw (plist-get scroll :content-lines))
(rendered (plist-get scroll :rendered-content-lines))
(region-count (hash-table-count ebox--region-box-table))
(before (buffer-string))
(footprint (ebox--rendered-span-footprint-signature before))
(expected (replace-regexp-in-string
"right-old" "right-new"
(replace-regexp-in-string "left-old" "left-new"
(substring-no-properties before))))
(root-render (symbol-function 'ebox-surface--render-candidate))
(root-renders 0)
report)
(should scroll-id)
(cl-letf (((symbol-function 'accept-change-group)
(lambda (_) (error "Reject mixed sibling publication"))))
(should
(equal
(should-error
(ebox-commit
(current-buffer)
(ebox-commit-test--scroll-family-candidate
(current-buffer) "#654321" t)))
'(error "Reject mixed sibling publication"))))
(should (eq state (ebox--buffer-render-state (current-buffer))))
(should (equal-including-properties before (buffer-string)))
(should (eq scroll (gethash scroll-id ebox--scroll-global-state)))
(should (eq box (gethash scroll-id ebox--region-box-table)))
(should (eq raw (plist-get scroll :content-lines)))
(should (eq rendered (plist-get scroll :rendered-content-lines)))
(should (= region-count (hash-table-count ebox--region-box-table)))
(should (= 1 (ebox--scroll-region-by scroll-id 1)))
(ebox-commit-test--assert-scroll-family-output "left-old" "right-old" "#123456")
(should (= -1 (ebox--scroll-region-by scroll-id -1)))
(cl-letf (((symbol-function 'ebox-surface--render-candidate)
(lambda (candidate-state)
(cl-incf root-renders)
(funcall root-render candidate-state))))
(setq report
(ebox-commit
(current-buffer)
(ebox-commit-test--scroll-family-candidate
(current-buffer) "#654321" t))))
(should (eq (plist-get report :projection-kind) 'mixed-owner-reflow))
(should (zerop root-renders))
(should-not (plist-get report :tp-full-root))
(should (equal expected (buffer-substring-no-properties (point-min) (point-max))))
(should (equal footprint
(ebox--rendered-span-footprint-signature (buffer-string))))
(let ((next-scroll (gethash scroll-id ebox--scroll-global-state)))
(should (eq raw (plist-get next-scroll :content-lines)))
(should (eq rendered (plist-get next-scroll :rendered-content-lines)))
(should (eq (plist-get next-scroll :box)
(gethash scroll-id ebox--region-box-table)))
(should (= region-count (hash-table-count ebox--region-box-table))))
(ebox-commit-test--assert-scroll-family-output "left-new" "right-new" "#654321")
(should (= 1 (ebox--scroll-region-by scroll-id 1)))
(should (string-match-p "line-c" (buffer-string)))
(ebox-commit-test--assert-scroll-family-output "left-new" "right-new" "#654321")
(should (= -1 (ebox--scroll-region-by scroll-id -1)))
(should (string-match-p "line-a" (buffer-string)))
(ebox-commit-test--assert-scroll-family-output "left-new" "right-new" "#654321"))))
(ert-deftest ebox-commit-mixed-paint-followups-replace-and-remove-prior-color ()
"Mixed paint must not reappear after pure paint, removal, rollback or scroll."
(with-temp-buffer
(ebox-render-to-buffer
(current-buffer)
(ebox-commit-test--scroll-family-root "left-old" "right-old" "#123456"))
(let* ((initial (ebox--buffer-render-state (current-buffer)))
(scroll-id (car (plist-get initial :scroll-region-ids)))
(scroll (gethash scroll-id ebox--scroll-global-state))
(raw (plist-get scroll :content-lines))
(rendered (plist-get scroll :rendered-content-lines))
(paint-id (plist-get (ebox--host-ref-node (current-buffer) 'paint) :node-id)))
(dolist (stage '(("#0000FF" t mixed-owner-reflow)
("#00FF00" nil paint)
(nil nil paint)))
(let ((color (car stage))
(content-p (cadr stage)))
(ert-info ((format "paint transition: %S" stage))
(when (equal color "#00FF00")
(let ((before (buffer-string))
(state (ebox--buffer-render-state (current-buffer)))
(old-scroll (gethash scroll-id ebox--scroll-global-state)))
(cl-letf (((symbol-function 'accept-change-group)
(lambda (_) (error "Reject followup paint"))))
(should
(equal
(should-error
(ebox-commit
(current-buffer)
(ebox-commit-test--scroll-family-candidate (current-buffer) color)))
'(error "Reject followup paint"))))
(should (eq state (ebox--buffer-render-state (current-buffer))))
(should (eq old-scroll (gethash scroll-id ebox--scroll-global-state)))
(should (equal-including-properties before (buffer-string)))))
(let ((report
(ebox-commit
(current-buffer)
(ebox-commit-test--scroll-family-candidate
(current-buffer) color content-p))))
(should (eq (plist-get report :projection-kind) (nth 2 stage))))
(should (= paint-id (plist-get (ebox--host-ref-node (current-buffer) 'paint)
:node-id)))
(dolist (delta '(0 1 -1))
(unless (zerop delta)
(should (= delta (ebox--scroll-region-by scroll-id delta))))
(ebox-commit-test--assert-scroll-family-output "left-new" "right-new" color)
(let* ((contents (buffer-string))
(start (string-match "paint" contents)))
(dotimes (offset (length "paint"))
(should (equal (get-text-property (+ start offset) 'face contents)
(and color (list :foreground color)))))))
(should (string-match-p "line-a" (buffer-string)))
(let ((next-scroll (gethash scroll-id ebox--scroll-global-state)))
(should (eq raw (plist-get next-scroll :content-lines)))
(should (eq rendered (plist-get next-scroll :rendered-content-lines)))
(should (eq (plist-get next-scroll :box)
(gethash scroll-id ebox--region-box-table))))))))))
(ert-deftest ebox-commit-disjoint-scroll-validates-rendered-font-extent ()
"Equal raw widths do not authorize changed rendered glyph widths."
(let ((measure (symbol-function 'ebox--string-pixel-width)))
@ -2158,15 +2388,21 @@ remain retained identities."
:foreground))))))
(when (buffer-live-p buffer) (kill-buffer buffer)))))
(defun ebox-commit-test--fixed-basis-selection-root (row-1 row-2)
"Return a stretched fixed-basis panel containing two selectable rows."
(let ((panel
(ebox-test-box
:key 'selection-panel :width 'stretch :min-width 0 :min-height 24
:flex-grow 2 :flex-shrink 1 :flex-basis '(340)
(defun ebox-commit-test--fixed-basis-selection-root (row-1 row-2 &optional scroll)
"Return a fixed-basis panel with two rows, inside a scroll box when SCROLL."
(let* ((rows
(ebox-test-column
(ebox-test-box :key 'row-1 :source-identity 'row-1 (ebox-test-text row-1))
(ebox-test-box :key 'row-2 :source-identity 'row-2 (ebox-test-text row-2))))))
(ebox-test-box :key 'row-2 :source-identity 'row-2 (ebox-test-text row-2))))
(panel
(ebox-test-box
:key 'selection-panel :width 'stretch :min-width 0 :min-height 24
:flex-grow 2 :flex-shrink 1 :flex-basis '(340)
(if scroll
(ebox-test-box :key 'scroll :id "scroll" :height 2 :width '(80)
:overflow 'scroll
(ebox-test-column rows (ebox-test-text "line-c\nline-d")))
rows))))
(ebox-test-flex
:key 'fixed-basis-selection-root
:width '(900) :height 24
@ -2188,6 +2424,125 @@ remain retained identities."
(ebox-test-box :key 'row-2 :source-identity 'row-2 (ebox-test-text row-2)))
candidate))
(ert-deftest ebox-commit-grouped-owners-retain-disjoint-scroll ()
"A stable slot groups two text edits, with optional paint, beside scroll."
(dolist (paint-p '(nil t))
(ert-info ((format "grouped geometry with external paint: %S" paint-p))
(with-temp-buffer
(ebox-render-to-buffer
(current-buffer)
(ebox-test-column :width '(900)
(if paint-p
(ebox-test-box :width '(200) :height 2
(ebox-test-column :key 'header :source-identity 'header
(ebox-test-text "[x] row 1" :key 'row-1 :source-identity 'row-1)
(ebox-test-text "[ ] row 2" :key 'row-2 :source-identity 'row-2)))
(ebox-commit-test--fixed-basis-selection-root "[x] row 1" "[ ] row 2"))
(ebox-test-box :key 'paint :source-identity 'paint :color "#123456"
(ebox-test-text "paint"))
(ebox-commit-test--scroll-sibling-root "untouched")))
(let* ((state (ebox--buffer-render-state (current-buffer)))
(scroll-id (car (plist-get state :scroll-region-ids)))
(scroll (gethash scroll-id ebox--scroll-global-state))
(raw (plist-get scroll :content-lines))
(rendered (plist-get scroll :rendered-content-lines))
(region-count (hash-table-count ebox--region-box-table))
(owner-id
(if paint-p
(plist-get (ebox--host-ref-node (current-buffer) 'header) :node-id)
(ebox-incremental--nearest-fixed-basis-flex-item-owner-id
(current-buffer)
(plist-get (ebox--host-ref-node (current-buffer) 'row-1) :node-id))))
(before (buffer-substring-no-properties (point-min) (point-max)))
(footprint (ebox--rendered-span-footprint-signature (buffer-string)))
(expected (replace-regexp-in-string
(regexp-quote "[ ] row 2") "[x] row 2"
(replace-regexp-in-string
(regexp-quote "[x] row 1") "[ ] row 1" before)))
(root-render (symbol-function 'ebox-surface--render-candidate))
(root-renders 0)
report)
(should scroll-id)
(cl-letf (((symbol-function 'ebox-surface--render-candidate)
(lambda (candidate-state)
(cl-incf root-renders)
(funcall root-render candidate-state))))
(let ((candidate
(if paint-p
(ebox-candidate-begin (current-buffer))
(ebox-commit-test--fixed-basis-selection-candidate
(current-buffer) "[ ] row 1" "[x] row 2"))))
(when paint-p
(ebox-candidate-replace-host-ref
candidate 'row-1
(ebox-test-text "[ ] row 1" :key 'row-1 :source-identity 'row-1))
(ebox-candidate-replace-host-ref
candidate 'row-2
(ebox-test-text "[x] row 2" :key 'row-2 :source-identity 'row-2))
(ebox-candidate-replace-host-ref
candidate 'paint
(ebox-test-box :key 'paint :source-identity 'paint :color "#654321"
(ebox-test-text "paint"))))
(setq report (ebox-commit (current-buffer) candidate))))
(if paint-p
(should (eq (plist-get report :projection-kind) 'mixed-owner-reflow))
(should (memq (plist-get report :projection-kind) '(span-patch owner-scoped))))
(should (equal (plist-get report :owner-ids)
(append (list owner-id)
(when paint-p
(list (plist-get
(ebox--host-ref-node (current-buffer) 'paint)
:node-id))))))
(should (zerop root-renders))
(should-not (plist-get report :tp-full-root))
(should (equal expected (buffer-substring-no-properties (point-min) (point-max))))
(should (equal footprint
(ebox--rendered-span-footprint-signature (buffer-string))))
(let ((next-scroll (gethash scroll-id ebox--scroll-global-state)))
(should (eq raw (plist-get next-scroll :content-lines)))
(should (eq rendered (plist-get next-scroll :rendered-content-lines)))
(should (eq (plist-get next-scroll :box)
(gethash scroll-id ebox--region-box-table)))
(should (= region-count (hash-table-count ebox--region-box-table))))
(should (= 1 (ebox--scroll-region-by scroll-id 1)))
(should (string-match-p "line-c" (buffer-string)))
(should (string-match-p (regexp-quote "[x] row 2") (buffer-string)))
(should (= -1 (ebox--scroll-region-by scroll-id -1)))
(should (string-match-p "line-a" (buffer-string)))
(should (equal expected (buffer-substring-no-properties (point-min) (point-max))))
(ebox-commit-test--assert-scroll-family-output
(regexp-quote "[ ] row 1") (regexp-quote "[x] row 2")
(if paint-p "#654321" "#123456")))))))
(ert-deftest ebox-commit-group-containing-scroll-refreshes-cache ()
"A group containing scrolling descendants must rebuild their cached text."
(with-temp-buffer
(ebox-render-to-buffer
(current-buffer)
(ebox-commit-test--fixed-basis-selection-root "[x] row 1" "[ ] row 2" t))
(let* ((state (ebox--buffer-render-state (current-buffer)))
(scroll-id (car (plist-get state :scroll-region-ids)))
(raw (plist-get (gethash scroll-id ebox--scroll-global-state)
:content-lines))
(report
(ebox-commit
(current-buffer)
(ebox-commit-test--fixed-basis-selection-candidate
(current-buffer) "[ ] row 1" "[x] row 2"))))
(should scroll-id)
(should-not (memq (plist-get report :projection-kind)
'(span-patch owner-scoped)))
(should-not (eq raw (plist-get (gethash scroll-id ebox--scroll-global-state)
:content-lines)))
(should (= 1 (ebox--scroll-region-by scroll-id 1)))
(should (string-match-p "line-c" (buffer-string)))
(should (string-match-p (regexp-quote "[x] row 2") (buffer-string)))
(should (= -1 (ebox--scroll-region-by scroll-id -1)))
(should (string-match-p (regexp-quote "[ ] row 1") (buffer-string)))
(should (string-match-p (regexp-quote "[x] row 2") (buffer-string)))
(should-not (string-match-p (regexp-quote "[x] row 1") (buffer-string)))
(should-not (string-match-p (regexp-quote "[ ] row 2") (buffer-string))))))
(ert-deftest ebox-commit-fixed-basis-selection-round-trip-stays-local ()
"Continuous row-1 -> row-2 -> row-1 publication keeps local TP scope."
(let ((planner-render-count 0)
@ -2389,6 +2744,78 @@ remain retained identities."
(ebox-commit-test--buffer-string buffer))))
(when (buffer-live-p buffer) (kill-buffer buffer)))))
(ert-deftest ebox-commit-range-splice-paint-preserves-kept-identities-and-faces ()
"Range object replacement falls back with current paint and kept peers."
(with-temp-buffer
(ebox-render-to-buffer
(current-buffer)
(ebox-test-column :width '(120)
(ebox-test-box :key 'keep :source-identity 'keep :height 1
(ebox-test-text "keep"))
(ebox-test-box :width '(80) :height 1
(ebox-test-column :key 'rows :source-identity 'rows
(ebox-test-child-range
'items
(ebox-test-box :key 'old :source-identity 'old :height 1
(ebox-test-text "old")))))
(ebox-test-box :key 'paint :source-identity 'paint :color "#123456"
(ebox-test-text "paint"))))
(let* ((state (ebox--buffer-render-state (current-buffer)))
(keep-id (plist-get (ebox--host-ref-node (current-buffer) 'keep) :node-id))
(old-id (plist-get (ebox--host-ref-node (current-buffer) 'old) :node-id))
(paint-id (plist-get (ebox--host-ref-node (current-buffer) 'paint) :node-id))
(objects (plist-get state :surface-node-object-table))
(keep-object (gethash keep-id objects))
(paint-object (gethash paint-id objects))
(candidate (ebox-candidate-begin (current-buffer)))
(root-render (symbol-function 'ebox-surface--render-candidate))
(root-renders 0)
report)
(ebox-candidate-replace-range-ref
candidate 'items
(ebox-test-box :key 'new :source-identity 'new :height 1
(ebox-test-text "new")))
(ebox-candidate-replace-host-ref
candidate 'paint
(ebox-test-box :key 'paint :source-identity 'paint :color "#0000FF"
(ebox-test-text "paint")))
(cl-letf (((symbol-function 'ebox-surface--render-candidate)
(lambda (candidate-state)
(cl-incf root-renders)
(should (ebox-surface--mixed-range-splice-p candidate-state))
(funcall root-render candidate-state))))
(setq report (ebox-commit (current-buffer) candidate)))
(should (eq (plist-get report :projection-kind) 'mixed-owner-reflow))
(should (= 1 root-renders))
(should-not (plist-get report :tp-retained-content))
(should (> (plist-get report :created-objects) 0))
(should (> (plist-get report :removed-objects) 0))
(should-not (ebox--host-ref-node (current-buffer) 'old))
(should-not (= old-id (plist-get (ebox--host-ref-node (current-buffer) 'new)
:node-id)))
(ebox-commit-test--assert-scroll-family-output "keep" "new" "#0000FF")
(dolist (color '("#0000FF" "#00FF00"))
(when (equal color "#00FF00")
(let ((paint-report
(ebox-commit
(current-buffer)
(ebox-commit-test--scroll-family-candidate (current-buffer) color))))
(should (eq (plist-get paint-report :projection-kind) 'paint))))
(ebox-commit-test--assert-scroll-family-output "keep" "new" color)
(let* ((next (ebox--buffer-render-state (current-buffer)))
(next-objects (plist-get next :surface-node-object-table))
(contents (buffer-string))
(start (string-match "paint" contents)))
(should (= keep-id (plist-get (ebox--host-ref-node (current-buffer) 'keep)
:node-id)))
(should (= paint-id (plist-get (ebox--host-ref-node (current-buffer) 'paint)
:node-id)))
(should (eq keep-object (gethash keep-id next-objects)))
(should (eq paint-object (gethash paint-id next-objects)))
(dotimes (offset (length "paint"))
(should (equal (get-text-property (+ start offset) 'face contents)
(list :foreground color)))))))))
(defun ebox-commit-test--allocation-closure-root
(toast paint-a paint-b &optional width footer-overflow root-overflow)