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)))))) (setq candidate-id (gethash candidate-id parents))))))
proof)) proof))
(defun ebox-incremental--two-owner-allocation-proof (defun ebox-incremental--two-owner-geometry-proof
(buffer old-state candidate-state geometry-prepared) (buffer old-state candidate-state geometry-prepared)
"Return two disjoint geometry proofs containing one allocation closure. "Combine exact geometry proofs for two changes.
This narrow fallback runs only after the ordinary combined span proof misses. Changes sharing one safe owner reuse its grouped proof; separate owners retain
It never partitions larger dirty sets, preserving their existing coalescing." their disjoint span or allocation proofs. Larger dirty sets keep coalescing."
(let ((dirty-set (plist-get geometry-prepared :dirty-set)) (let ((dirty-set (plist-get geometry-prepared :dirty-set))
(root-id (ebox--buffer-root-node-id buffer)) (root-id (ebox--buffer-root-node-id buffer))
proofs owner-proofs valid allocation-p) 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))) (copy-sequence local-owner-proofs)))
(setq allocation-p (or allocation-p allocation-proof))) (setq allocation-p (or allocation-p allocation-proof)))
(setq valid nil))))) (setq valid nil)))))
(when (and valid allocation-p (when (and valid (= (length proofs) 2))
(= (length proofs) 2)
(ebox-incremental--owner-proofs-disjoint-p
buffer owner-proofs))
(setq proofs (nreverse proofs)) (setq proofs (nreverse proofs))
(let ((owner-ids (let ((owner-ids
(delete-dups (delete-dups
(mapcar (lambda (proof) (plist-get proof :owner-id)) (mapcar (lambda (proof) (plist-get proof :owner-id))
owner-proofs)))) owner-proofs))))
(list :owner-id (car owner-ids) (if (= (length owner-ids) 1)
:owner-ids owner-ids (when-let* ((grouped
:owner-proofs owner-proofs (ebox-incremental--grouped-owner-span-proof
:dirty-set (copy-sequence dirty-set) buffer old-state geometry-prepared candidate-state
:variable-content-p (list (ebox--patch-op 'span-patch (car owner-ids))))))
(cl-some (lambda (proof) (dolist (owner-proof (plist-get grouped :owner-proofs))
(plist-get proof :variable-content-p)) (plist-put owner-proof :retain-external-owner-suffix-p t))
proofs) grouped)
:ancestor-slot-signature (when (ebox-incremental--owner-proofs-disjoint-p buffer owner-proofs)
(cl-mapcan (list :owner-id (car owner-ids)
(lambda (proof) :owner-ids owner-ids
(copy-sequence :owner-proofs owner-proofs
(plist-get proof :ancestor-slot-signature))) :dirty-set (copy-sequence dirty-set)
proofs) :variable-content-p
:layout-snapshot-detail-generation (cl-some (lambda (proof)
(plist-get old-state :layout-snapshot-detail-generation) (plist-get proof :variable-content-p))
:static-cascade-p proofs)
(cl-every (lambda (proof) :ancestor-slot-signature
(plist-get proof :static-cascade-p)) (cl-mapcan
proofs) (lambda (proof)
:contains-allocation-closure-p t))))) (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 (defun ebox-incremental--mixed-owner-proof
(buffer old-state candidate-state prepared owner-plan) (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. ;; two local proofs already close exactly.
(geometry-two-owner-proof (geometry-two-owner-proof
(and (= (length geometry-dirty) 2) (and (= (length geometry-dirty) 2)
(ebox-incremental--two-owner-allocation-proof (ebox-incremental--two-owner-geometry-proof
buffer old-state candidate-state geometry-prepared))) buffer old-state candidate-state geometry-prepared)))
(geometry-context-proof (geometry-context-proof
(and (not geometry-two-owner-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))) :retain-external-owner-suffix-p t)))
(when (and geometry-proof paint-valid-p disjoint-p (when (and geometry-proof paint-valid-p disjoint-p
allocation-disjoint-p cascade-safe-p allocation-disjoint-p cascade-safe-p
;; The single-owner span proof can retain unrelated scroll ;; Both the rendered geometry and inherited paint must leave
;; subtrees. Its geometry partition does not prove that this ;; every retained scroll producer outside their dependency scope.
;; mixed transaction's paint owners leave scroll caches intact. (cl-every
(null (plist-get old-state :scroll-region-ids)) (lambda (owner-id)
(null (ebox-incremental--hash-keys (ebox-incremental--owner-disjoint-from-scroll-p
(plist-get prepared :scroll-state-table))) 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-state-transaction))
(not (plist-get prepared :scroll-patch-fast-p)) (not (plist-get prepared :scroll-patch-fast-p))
root-id 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))) (ebox-incremental--commit-report prepared report candidate-state)))
(defun ebox-incremental--paint-projection-p (defun ebox-incremental--paint-projection-p
(old-state owner-plan dirty-set) (buffer old-state prepared candidate-state owner-plan)
"Return non-nil when OWNER-PLAN can reuse OLD-STATE spatial topology." "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 (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)) (cl-every (lambda (op) (eq (plist-get op :op) 'paint-patch))
owner-plan) 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 (cl-every
(lambda (dirty) (lambda (dirty)
(cl-every (lambda (key) (cl-every (lambda (key)
(memq key ebox--paint-style-signature-keys)) (memq key ebox--paint-style-signature-keys))
(plist-get dirty :changed-keys))) (plist-get dirty :changed-keys)))
dirty-set))) (plist-get prepared :dirty-set))))
(defun ebox-incremental--hash-key-set-equal-p (left right) (defun ebox-incremental--hash-key-set-equal-p (left right)
"Return non-nil when hash tables LEFT and RIGHT have the same keys." "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))) (plist-get slot :start-pixel)))
slots)))))) 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) (buffer old-state prepared candidate-state owner-id)
"Return non-nil when OWNER-ID cannot change any retained scroll content. "Return non-nil when OWNER-ID cannot change any retained scroll content.
The active scroll set and each scroll box identity must survive preparation. 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 scroll-id (plist-get candidate-state :node-table)))
(eq next-box (gethash region (plist-get candidate-state :region-box-table))) (eq next-box (gethash region (plist-get candidate-state :region-box-table)))
(not (equal scroll-id owner-id)) (not (equal scroll-id owner-id))
(not (ebox--runtime-ancestor-id-p buffer scroll-id owner-id)) (let ((ebox-incremental--buffer-render-state-override
(not (ebox--runtime-ancestor-id-p buffer owner-id scroll-id)) (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 (let ((ebox-incremental--buffer-render-state-override
(cons buffer candidate-state))) (cons buffer candidate-state)))
(and (not (ebox--runtime-ancestor-id-p buffer scroll-id owner-id)) (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) local-typography-p)
(ebox-incremental--cascade-local-owner-proof-p (ebox-incremental--cascade-local-owner-proof-p
old-state candidate-state (list dirty)))) 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) buffer old-state prepared candidate-state owner-id)
;; Admit the new scroll coexistence case only for an exact ;; Admit the new scroll coexistence case only for an exact
;; content extent. Length or width changes can alter an ;; content extent. Length or width changes can alter an
@ -9729,7 +9745,8 @@ role, and outside-complement compatibility before publication."
new-node)) new-node))
(ebox-incremental--same-root-surface-identity-p (ebox-incremental--same-root-surface-identity-p
old-state candidate-state) 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 old-node))
(not (ebox--node-visible-overflow-p new-node)) (not (ebox--node-visible-overflow-p new-node))
ancestor-slot-signature ancestor-slot-signature
@ -9741,7 +9758,8 @@ role, and outside-complement compatibility before publication."
:snapshot snapshot :snapshot snapshot
:allocated-width allocated-width :allocated-width allocated-width
:variable-content-p :variable-content-p
(and allocated-width (and (null (plist-get old-state :scroll-region-ids))
allocated-width
(cl-some (cl-some
(lambda (entry) (lambda (entry)
(memq :content (memq :content
@ -10331,7 +10349,7 @@ role, and outside-complement compatibility before publication."
(paint-p (paint-p
(and (not native-program-p) (and (not native-program-p)
(ebox-incremental--paint-projection-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 (span-proof
(unless (or native-program-p structural-dirty-p paint-p mixed-proof) (unless (or native-program-p structural-dirty-p paint-p mixed-proof)
(ebox-incremental--span-patch-projection-proof (ebox-incremental--span-patch-projection-proof
@ -10512,7 +10530,7 @@ whether NEXT-ROOT shares untouched published nodes."
(plist-get prepared :dirty-set))))) (plist-get prepared :dirty-set)))))
(paint-p (paint-p
(ebox-incremental--paint-projection-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 (span-proof
(unless (or paint-p mixed-proof skip-span-proof-p) (unless (or paint-p mixed-proof skip-span-proof-p)
(ebox-incremental--span-patch-projection-proof (ebox-incremental--span-patch-projection-proof

View File

@ -86,12 +86,11 @@
(defun ebox-surface--mixed-range-splice-p (state) (defun ebox-surface--mixed-range-splice-p (state)
"Return non-nil when STATE's mixed proof contains one Range splice." "Return non-nil when STATE's mixed proof contains one Range splice."
(cl-some (let ((proof (plist-get (plist-get state :mixed-owner-proof) :geometry-proof)))
(lambda (owner-proof) (or (plist-get proof :range-splice-p)
(plist-get owner-proof :range-splice-p)) (cl-some (lambda (owner-proof)
(plist-get (plist-get owner-proof :range-splice-p))
(plist-get (plist-get state :mixed-owner-proof) :geometry-proof) (plist-get proof :owner-proofs)))))
:owner-proofs)))
(declare-function ebox--render-layout "ebox-layout" (node)) (declare-function ebox--render-layout "ebox-layout" (node))
(declare-function ebox--maplines "ebox" (function string)) (declare-function ebox--maplines "ebox" (function string))
@ -1813,7 +1812,12 @@ the projection roots a second time."
(region-objects (region-objects
(or (and scroll-fast-p (or (and scroll-fast-p
(plist-get state :region-surface-object-table)) (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 :surface-node-object-table node-objects)
(plist-put state :region-surface-object-table region-objects) (plist-put state :region-surface-object-table region-objects)
(unless (and scroll-fast-p (unless (and scroll-fast-p
@ -1827,7 +1831,8 @@ the projection roots a second time."
(or (and native-retained-p (or (and native-retained-p
(ebox-surface--native-patch-result (ebox-surface--native-patch-result
context projection state output node-objects region-objects)) 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)) '(span-patch owner-scoped mixed-owner-reflow))
(ebox-surface--span-coordinate-result (ebox-surface--span-coordinate-result
context projection state output previous-owned-ranges)) context projection state output previous-owned-ranges))
@ -1840,10 +1845,9 @@ the projection roots a second time."
scroll-fast-p)) scroll-fast-p))
(plan (car prepared)) (plan (car prepared))
(rendered (cadr prepared)) (rendered (cadr prepared))
(owned-ranges (nth 2 prepared)) (owned-ranges (nth 2 prepared)))
(property-contributions (or (and (null property-contributions)
(plist-get state :paint-property-contributions))) (memq projection-kind
(or (and (memq projection-kind
'(span-patch owner-scoped mixed-owner-reflow)) '(span-patch owner-scoped mixed-owner-reflow))
(ebox-surface--coordinate-commit (ebox-surface--coordinate-commit
context state rendered owned-ranges (nth 3 prepared) 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))) (cons buffer state)))
(ebox--flex-item-slot-sized-node (ebox--flex-item-slot-sized-node
buffer owner-id old-snapshot changed-keys))) buffer owner-id old-snapshot changed-keys)))
;; Variable content is explicitly shaped into a retained slot. ;; Grouped owners and variable content have a proven retained
;; Typography-only changes must render at their natural size so ;; 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 ;; the final footprint check observes any intrinsic-width change
;; instead of hiding it behind the old allocation. ;; 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) (plist-put (copy-sequence node) :width allocated-width)
node)))) node))))
(when (and old-snapshot spans node render-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 by peeling an already-composed face suffix. Any missing address/baseline
causes the caller to use the ordinary full projection." causes the caller to use the ordinary full projection."
(let* ((proof (plist-get state :mixed-owner-proof)) (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))) (geometry-kind (and proof (plist-get proof :geometry-kind)))
(paint-owner-ids (and proof (plist-get proof :paint-owner-ids))) (paint-owner-ids (and proof (plist-get proof :paint-owner-ids)))
(output (output
@ -4005,7 +4017,9 @@ causes the caller to use the ordinary full projection."
output fragments state)) output fragments state))
(plist-put state :mixed-owner-fragment-data fragments) (plist-put state :mixed-owner-fragment-data fragments)
(plist-put state :mixed-owner-content-p t) (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) (defun ebox-surface--scroll-patch-output (_buffer state)
"Return a staged visible scroll window without rerunning layout when safe. "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) (copy-sequence previous-state)
(ebox--new-buffer-render-state root)) (ebox--new-buffer-render-state root))
(copy-sequence state-overrides)))) (copy-sequence state-overrides))))
(cl-remf state :paint-property-contributions)
(plist-put state :root-node root) (plist-put state :root-node root)
(when (plist-get state :native-sync-confirmed-p) (when (plist-get state :native-sync-confirmed-p)
(ebox-native-commit-attach-confirmed-base previous-state state)) (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 (with-current-buffer buffer
(save-restriction (save-restriction
(widen) (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)) (setq state (ebox-surface--finish-runtime-state state))
(ebox-surface--refresh-paint-snapshots state dirty-set) (ebox-surface--refresh-paint-snapshots state dirty-set)
(cl-remf state :paint-dirty-set) (cl-remf state :paint-dirty-set)

View File

@ -153,31 +153,43 @@
(prepared (list :scroll-state-table table)) (prepared (list :scroll-state-table table))
(candidate (copy-sequence state))) (candidate (copy-sequence state)))
(puthash region scroll table) (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)) (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)) (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)) (current-buffer) state prepared candidate scroll-id))
(remhash region table) (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)) (current-buffer) state prepared candidate owner))
(puthash 'different-region scroll table) (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)) (current-buffer) state prepared candidate owner))
(clrhash table) (clrhash table)
(puthash region scroll table) (puthash region scroll table)
(let ((regions (copy-hash-table (plist-get state :region-box-table)))) (let ((regions (copy-hash-table (plist-get state :region-box-table))))
(remhash region regions) (remhash region regions)
(plist-put candidate :region-box-table 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))) (current-buffer) state prepared candidate owner)))
(plist-put candidate :region-box-table (plist-get state :region-box-table)) (plist-put candidate :region-box-table (plist-get state :region-box-table))
(let ((parents (copy-hash-table (plist-get state :parent-table)))) (let ((parents (copy-hash-table (plist-get state :parent-table))))
(puthash scroll-id owner parents) (puthash scroll-id owner parents)
(plist-put candidate :parent-table parents) (plist-put candidate :parent-table parents)
(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)))))) (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 () (ert-deftest ebox-commit-content-shift-preserves-later-scroll-targets ()
"Longer and shorter local text retain the later scroll widget's 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)))
(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 () (ert-deftest ebox-commit-disjoint-scroll-validates-rendered-font-extent ()
"Equal raw widths do not authorize changed rendered glyph widths." "Equal raw widths do not authorize changed rendered glyph widths."
(let ((measure (symbol-function 'ebox--string-pixel-width))) (let ((measure (symbol-function 'ebox--string-pixel-width)))
@ -2158,15 +2388,21 @@ remain retained identities."
:foreground)))))) :foreground))))))
(when (buffer-live-p buffer) (kill-buffer buffer))))) (when (buffer-live-p buffer) (kill-buffer buffer)))))
(defun ebox-commit-test--fixed-basis-selection-root (row-1 row-2) (defun ebox-commit-test--fixed-basis-selection-root (row-1 row-2 &optional scroll)
"Return a stretched fixed-basis panel containing two selectable rows." "Return a fixed-basis panel with two rows, inside a scroll box when SCROLL."
(let ((panel (let* ((rows
(ebox-test-box
:key 'selection-panel :width 'stretch :min-width 0 :min-height 24
:flex-grow 2 :flex-shrink 1 :flex-basis '(340)
(ebox-test-column (ebox-test-column
(ebox-test-box :key 'row-1 :source-identity 'row-1 (ebox-test-text row-1)) (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 (ebox-test-flex
:key 'fixed-basis-selection-root :key 'fixed-basis-selection-root
:width '(900) :height 24 :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))) (ebox-test-box :key 'row-2 :source-identity 'row-2 (ebox-test-text row-2)))
candidate)) 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 () (ert-deftest ebox-commit-fixed-basis-selection-round-trip-stays-local ()
"Continuous row-1 -> row-2 -> row-1 publication keeps local TP scope." "Continuous row-1 -> row-2 -> row-1 publication keeps local TP scope."
(let ((planner-render-count 0) (let ((planner-render-count 0)
@ -2389,6 +2744,78 @@ remain retained identities."
(ebox-commit-test--buffer-string buffer)))) (ebox-commit-test--buffer-string buffer))))
(when (buffer-live-p buffer) (kill-buffer 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 (defun ebox-commit-test--allocation-closure-root
(toast paint-a paint-b &optional width footer-overflow root-overflow) (toast paint-a paint-b &optional width footer-overflow root-overflow)