From 21cf79cdfaf93c8bbff275220596460171d01f09 Mon Sep 17 00:00:00 2001 From: Kinneyzhang Date: Sun, 6 Sep 2026 20:50:01 +0800 Subject: [PATCH] perf: retain local text updates beside independent scroll regions --- ebox-incremental.el | 63 ++++++++++- ebox-surface.el | 15 ++- tests/ebox-commit-tests.el | 212 +++++++++++++++++++++++++++++++++++++ 3 files changed, 282 insertions(+), 8 deletions(-) diff --git a/ebox-incremental.el b/ebox-incremental.el index ee8fa6d..36ff3da 100644 --- a/ebox-incremental.el +++ b/ebox-incremental.el @@ -7903,6 +7903,12 @@ 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))) (not (plist-get prepared :scroll-state-transaction)) (not (plist-get prepared :scroll-patch-fast-p)) root-id @@ -9282,6 +9288,39 @@ 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 + (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. +An ancestor or descendant scroll box still requires the ordinary renderer to +regenerate its cached lines; only separate branches may keep those lines." + (let* ((regions (plist-get old-state :scroll-region-ids)) + (next-table (plist-get prepared :scroll-state-table))) + (and (= (length regions) + (if (hash-table-p next-table) (hash-table-count next-table) 0)) + (cl-every + (lambda (region) + (let* ((old-scroll (gethash region ebox--scroll-global-state)) + (next-scroll (and next-table (gethash region next-table))) + (old-box (plist-get old-scroll :box)) + (next-box (plist-get next-scroll :box)) + (scroll-id (plist-get old-box :node-id))) + (and scroll-id next-box + (eq (plist-get old-scroll :buffer) buffer) + (equal scroll-id (plist-get next-box :node-id)) + (eq old-box (gethash scroll-id (plist-get old-state :node-table))) + (eq old-box (gethash region (plist-get old-state :region-box-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))) + (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 candidate-state))) + (and (not (ebox--runtime-ancestor-id-p buffer scroll-id owner-id)) + (not (ebox--runtime-ancestor-id-p buffer owner-id scroll-id))))))) + regions)))) + (defun ebox-incremental--single-span-patch-projection-proof (buffer old-state prepared candidate-state owner-plan &optional path-copied-p) @@ -9411,10 +9450,22 @@ metadata; this predicate only authorizes the local attempt." local-typography-p) (ebox-incremental--cascade-local-owner-proof-p old-state candidate-state (list dirty)))) - (null (plist-get old-state :scroll-region-ids)) - (or (null (plist-get candidate-state :scroll-region-ids)) - (null (ebox-incremental--hash-keys - (plist-get candidate-state :scroll-state-table)))) + (ebox-incremental--span-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 + ;; ancestor's line filler outside the local owner's spans. + (or (null (plist-get old-state :scroll-region-ids)) + (let ((before (plist-get (plist-get dirty :old-signature) + :content)) + (after (plist-get (plist-get dirty :new-signature) + :content))) + (and (stringp before) (stringp after) + (not (cl-intersection + changed-keys ebox--typography-style-signature-keys)) + (= (length before) (length after)) + (= (ebox--string-pixel-width before) + (ebox--string-pixel-width after))))) ;; The ancestor allocation proof is tied to the published ;; viewport slot. A declarative commit does not alter that ;; context; final footprint validation still rejects a miss. @@ -9465,6 +9516,10 @@ metadata; this predicate only authorizes the local attempt." :ancestor-slot-signature ancestor-slot-signature :variable-content-p (and snapshot + ;; With retained scroll siblings, the final rendered + ;; footprint must remain exact even when raw content has + ;; equal width under the current buffer's default font. + (null (plist-get old-state :scroll-region-ids)) (memq :content changed-keys) (stringp (plist-get (plist-get dirty :new-signature) :content)) diff --git a/ebox-surface.el b/ebox-surface.el index 595088f..44aee34 100644 --- a/ebox-surface.el +++ b/ebox-surface.el @@ -5630,10 +5630,17 @@ published runtime and must be consumed without clearing those shared nodes." context projection state (ebox-surface--paint-fragments previous-state state dirty-set old-text))) - (unless (memq projection-kind - '(scroll-patch formatting-context-reflow - mixed-owner-reflow)) - (plist-put state :region-box-table (make-hash-table :test 'equal))) + (cond + ((memq projection-kind '(span-patch owner-scoped)) + ;; A local owner render visits only its own regions. Keep the + ;; complete candidate registry, including disjoint retained scroll + ;; boxes, in a private table until the transaction is accepted. + (plist-put state :region-box-table + (copy-hash-table (plist-get state :region-box-table)))) + ((not (memq projection-kind + '(scroll-patch formatting-context-reflow + mixed-owner-reflow))) + (plist-put state :region-box-table (make-hash-table :test 'equal)))) (let* ((_initial-native (when initial-native-p ;; Initial mount has no incremental planner. Surface owns diff --git a/tests/ebox-commit-tests.el b/tests/ebox-commit-tests.el index 991d806..bc862ca 100644 --- a/tests/ebox-commit-tests.el +++ b/tests/ebox-commit-tests.el @@ -40,6 +40,218 @@ "Return one stable declarative root containing CONTENT." (ebox-test-box :key 'root (ebox-test-text content))) +(defun ebox-commit-test--scroll-sibling-root (content &optional nested) + "Return CONTENT beside an active scroll box, or inside it when NESTED." + (let ((label (ebox-test-text content :key 'label :source-identity 'label))) + (ebox-test-column :key 'root :width '(120) + (unless nested label) + (ebox-test-box :key 'scroll :id "scroll" :height 2 :width '(80) + :overflow 'scroll + (ebox-test-column + (when nested label) + (ebox-test-text "line-a\nline-b\nline-c\nline-d" + :key 'scroll-lines :source-identity 'scroll-lines + :color "#123456")))))) + +(defun ebox-commit-test--replace-scroll-sibling-label (buffer content) + "Commit CONTENT into BUFFER's stable label host." + (let ((candidate (ebox-candidate-begin buffer))) + (ebox-candidate-replace-host-ref + candidate 'label + (ebox-test-text content :key 'label :source-identity 'label)) + (ebox-commit buffer candidate))) + +(ert-deftest ebox-commit-local-content-retains-disjoint-scroll () + "An unrelated scroll box keeps its caches and registry across a local edit." + (with-temp-buffer + (ebox-render-to-buffer + (current-buffer) (ebox-commit-test--scroll-sibling-root "old")) + (let* ((before (ebox--buffer-render-state (current-buffer))) + (scroll-id (car (plist-get before :scroll-region-ids))) + (old-scroll (gethash scroll-id ebox--scroll-global-state)) + (raw (plist-get old-scroll :content-lines)) + (rendered (plist-get old-scroll :rendered-content-lines)) + (region-count (hash-table-count (plist-get before :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 (state) + (cl-incf root-renders) + (funcall root-render state)))) + (setq report + (ebox-commit-test--replace-scroll-sibling-label + (current-buffer) "new"))) + (should (memq (plist-get report :projection-kind) '(span-patch owner-scoped))) + (should (zerop root-renders)) + (should-not (plist-get report :tp-full-root)) + (let* ((after (ebox--buffer-render-state (current-buffer))) + (scroll (gethash scroll-id ebox--scroll-global-state))) + (should (= region-count (hash-table-count (plist-get after :region-box-table)))) + (should (eq (plist-get scroll :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 (= 1 (ebox--scroll-region-by scroll-id 1))) + (should (string-match-p "new" (buffer-string))) + (should (string-match-p "line-c" (buffer-string))) + (should (= -1 (ebox--scroll-region-by scroll-id -1))) + (should (string-match-p "new" (buffer-string))) + (should (string-match-p "line-a" (buffer-string)))))) + +(ert-deftest ebox-commit-disjoint-scroll-rolls-back-with-local-content () + "Late rejection restores both the local content and the scroll registry." + (with-temp-buffer + (ebox-render-to-buffer + (current-buffer) (ebox-commit-test--scroll-sibling-root "old")) + (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)) + (before (buffer-string))) + (cl-letf (((symbol-function 'accept-change-group) + (lambda (_) (error "Reject local content publication")))) + (should-error + (ebox-commit-test--replace-scroll-sibling-label (current-buffer) "new"))) + (should (eq state (ebox--buffer-render-state (current-buffer)))) + (should (eq scroll (gethash scroll-id ebox--scroll-global-state))) + (should (eq box (gethash scroll-id ebox--region-box-table))) + (should (equal-including-properties before (buffer-string))) + (should (= 1 (ebox--scroll-region-by scroll-id 1))) + (should (string-match-p "old" (buffer-string))) + (ebox-commit-test--replace-scroll-sibling-label (current-buffer) "new") + (should (= -1 (ebox--scroll-region-by scroll-id -1))) + (should (string-match-p "new" (buffer-string)))))) + +(ert-deftest ebox-commit-local-content-inside-scroll-keeps-full-cache-update () + "A scrolling ancestor still needs its retained content regenerated." + (with-temp-buffer + (ebox-render-to-buffer + (current-buffer) (ebox-commit-test--scroll-sibling-root "old" t)) + (let* ((state (ebox--buffer-render-state (current-buffer))) + (scroll-id (car (plist-get state :scroll-region-ids))) + (report (ebox-commit-test--replace-scroll-sibling-label + (current-buffer) "new"))) + (should-not (memq (plist-get report :projection-kind) '(span-patch owner-scoped))) + (should (= 1 (ebox--scroll-region-by scroll-id 1))) + (should (= -1 (ebox--scroll-region-by scroll-id -1))) + (should (string-match-p "new" (buffer-string))) + (should-not (string-match-p "old" (buffer-string)))))) + +(ert-deftest ebox-commit-disjoint-scroll-proof-rejects-incomplete-or-overlapping-state () + "Missing mappings, changed membership and either ancestor direction reject." + (with-temp-buffer + (ebox-render-to-buffer + (current-buffer) (ebox-commit-test--scroll-sibling-root "old")) + (let* ((state (ebox--buffer-render-state (current-buffer))) + (owner (plist-get (ebox--host-ref-node (current-buffer) 'label) :node-id)) + (root (plist-get (plist-get state :root-node) :node-id)) + (region (car (plist-get state :scroll-region-ids))) + (scroll (gethash region ebox--scroll-global-state)) + (scroll-id (plist-get (plist-get scroll :box) :node-id)) + (table (make-hash-table :test 'equal)) + (prepared (list :scroll-state-table table)) + (candidate (copy-sequence state))) + (puthash region scroll table) + (should (ebox-incremental--span-owner-disjoint-from-scroll-p + (current-buffer) state prepared candidate owner)) + (should-not (ebox-incremental--span-owner-disjoint-from-scroll-p + (current-buffer) state prepared candidate root)) + (should-not (ebox-incremental--span-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 + (current-buffer) state prepared candidate owner)) + (puthash 'different-region scroll table) + (should-not (ebox-incremental--span-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 + (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)))))) + +(ert-deftest ebox-commit-content-shift-preserves-later-scroll-targets () + "Longer and shorter local text retain the later scroll widget's targets." + (with-temp-buffer + (ebox-render-to-buffer + (current-buffer) (ebox-commit-test--scroll-sibling-root "old")) + (let ((scroll-id (car (plist-get (ebox--buffer-render-state (current-buffer)) + :scroll-region-ids)))) + (dolist (value '("longer" "x")) + (ebox-commit-test--replace-scroll-sibling-label (current-buffer) value) + (should (= 1 (ebox--scroll-region-by scroll-id 1))) + (should (string-match-p (concat "\\`" value) (buffer-string))) + (should (string-match-p "line-c" (buffer-string))) + (should (= -1 (ebox--scroll-region-by scroll-id -1))) + (should (string-match-p (concat "\\`" value) (buffer-string))) + (should (string-match-p "line-a" (buffer-string))))))) + +(ert-deftest ebox-commit-mixed-content-and-scroll-paint-refreshes-cache () + "An unrelated label edit cannot authorize stale paint in a scroll cache." + (with-temp-buffer + (ebox-render-to-buffer + (current-buffer) (ebox-commit-test--scroll-sibling-root "old")) + (let* ((scroll-id (car (plist-get (ebox--buffer-render-state (current-buffer)) + :scroll-region-ids))) + (candidate (ebox-candidate-begin (current-buffer)))) + (ebox-candidate-replace-host-ref + candidate 'label (ebox-test-text "new" :key 'label :source-identity 'label)) + (ebox-candidate-replace-host-ref + candidate 'scroll-lines + (ebox-test-text "line-a\nline-b\nline-c\nline-d" + :key 'scroll-lines :source-identity 'scroll-lines + :color "#654321")) + (let ((report (ebox-commit (current-buffer) candidate))) + (should-not (eq (plist-get report :projection-kind) 'mixed-owner-reflow))) + (dotimes (_ 2) + (goto-char (point-min)) + (search-forward "line-a") + (should (equal "#654321" + (ebox-commit-test--face-value + (get-text-property (1- (point)) 'face) :foreground))) + (should (= 1 (ebox--scroll-region-by scroll-id 1))) + (should (= -1 (ebox--scroll-region-by scroll-id -1))))))) + +(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))) + ;; Model an unchanged proportional font: raw WWW/iii both measure three + ;; units, but styled iii is narrower. The final painted line must still + ;; include exactly the filler required by its 120px parent allocation. + (cl-letf (((symbol-function 'ebox--string-pixel-width) + (lambda (string) + (let ((width (funcall measure string)) (start 0)) + (while (string-match "iii" string start) + (when (get-text-property (match-beginning 0) 'face string) + (cl-decf width)) + (setq start (match-end 0))) + width)))) + (with-temp-buffer + (ebox-render-to-buffer + (current-buffer) + (ebox-test-column :key 'root :width '(120) + (ebox-test-text "WWW" :font-size 16 :key 'label :source-identity 'label) + (ebox-test-box :height 1 :overflow 'scroll + (ebox-test-text "first\nsecond")))) + (let ((candidate (ebox-candidate-begin (current-buffer)))) + (ebox-candidate-replace-host-ref + candidate 'label + (ebox-test-text "iii" :font-size 16 :key 'label :source-identity 'label)) + (ebox-commit (current-buffer) candidate)) + (goto-char (point-min)) + (should (looking-at "iii")) + (should (= 120 (ebox--string-pixel-width + (buffer-substring (point) (line-end-position))))))))) + (defun ebox-commit-test--hash-facts (table &optional values) "Return sorted TABLE keys, or key/value pairs when VALUES is non-nil." (let (facts)