diff --git a/ebox-incremental.el b/ebox-incremental.el index 36ff3da..95ea3f7 100644 --- a/ebox-incremental.el +++ b/ebox-incremental.el @@ -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 diff --git a/ebox-surface.el b/ebox-surface.el index 44aee34..56de05c 100644 --- a/ebox-surface.el +++ b/ebox-surface.el @@ -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) diff --git a/tests/ebox-commit-tests.el b/tests/ebox-commit-tests.el index bc862ca..435455b 100644 --- a/tests/ebox-commit-tests.el +++ b/tests/ebox-commit-tests.el @@ -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)