diff --git a/ebox-surface.el b/ebox-surface.el index fd81aeb..63ecb11 100644 --- a/ebox-surface.el +++ b/ebox-surface.el @@ -3022,9 +3022,44 @@ valid because their declarative layout and viewport did not change." (puthash region-id state table))) snapshot)) +(defun ebox-surface--refresh-rendered-layout-snapshots (state) + "Refresh stale base snapshots after rendering STATE's complete root. +Keep unchanged entries and their details. Old published geometry remains +available during planning; replacements here belong only to the candidate." + (let ((snapshots (plist-get state :layout-snapshots)) + (nodes (plist-get state :node-table)) + (ebox--node-region-ids-cache (make-hash-table :test 'eq)) + updated) + (when (hash-table-p snapshots) + (maphash + (lambda (node-id snapshot) + (let* ((node (gethash node-id nodes)) + (fresh (and node (ebox--node-layout-snapshot nil node nil)))) + (unless (and fresh + (cl-every + (lambda (key) + (equal (plist-get snapshot key) (plist-get fresh key))) + '(:node-id :type :display :region-ids + :style-signature :child-ids))) + (unless updated (setq updated (copy-hash-table snapshots))) + (if fresh + (puthash node-id fresh updated) + (remhash node-id updated))))) + snapshots) + (when updated + (plist-put state :layout-snapshots updated)))) + state) + (defun ebox-surface--render-candidate (state) - "Render candidate STATE or optional NODE in isolated side tables." - (ebox-surface--render-candidate-node state (plist-get state :root-node))) + "Render candidate STATE's complete root in isolated side tables." + (let ((rendered + (ebox-surface--render-candidate-node state (plist-get state :root-node)))) + ;; A retained node id can now describe a different display or subtree. + ;; Refresh the complete lightweight snapshot, including ancestor region + ;; membership; stripping only span details would preserve stale style facts. + (setq state (ebox-surface--finish-runtime-state state)) + (ebox-surface--refresh-rendered-layout-snapshots state) + rendered)) (defun ebox-surface--render-candidate-node (state node) "Render NODE from candidate STATE in isolated Ebox side tables." @@ -3594,17 +3629,15 @@ Proof mismatches return nil; unexpected rendering errors propagate." node))) (retain-properties-p (and (memq (plist-get state :projection-kind) - '(span-patch owner-scoped)) + '(span-patch owner-scoped mixed-owner-reflow)) (ebox-surface--owner-needs-ancestor-paint-p state owner-id)))) (when (and old-snapshot spans node render-node - ;; Mixed projection has a separate paint recomposition step. - ;; Ordinary spans have only the detached owner's rendered - ;; faces; retain the full renderer when enclosing paint would - ;; otherwise disappear from changed text. + ;; Detached geometry omits enclosing effects. Mixed paint + ;; only recomposes dirty paint owners, so unchanged ancestors + ;; must also survive its geometry replacement. (or (not retain-properties-p) (and previous-state - (not (or role-owned-lines-p allocation-closure-p - retain-external-owner-suffix-p)) + (not (or role-owned-lines-p allocation-closure-p)) (ebox-surface--unchanged-enclosing-effects-p previous-state state owner-id)))) (let* ((rendered @@ -3642,9 +3675,14 @@ Proof mismatches return nil; unexpected rendering errors propagate." retain-external-owner-suffix-p) (and owned-rendered (when-let* ((owners - (ebox-surface--retained-external-owner-suffix + (ebox-surface--retained-external-owner-suffix buffer spans region-ids))) - (ebox--add-content-owners owned-rendered owners))) + ;; The retained-property proof compares detached + ;; renders, then restores the complete published + ;; owner stack together with every other property. + (if retain-properties-p + owned-rendered + (ebox--add-content-owners owned-rendered owners)))) owned-rendered)) (replacement (and owned-rendered diff --git a/tests/ebox-child-range-tests.el b/tests/ebox-child-range-tests.el index 313c052..e0340b8 100644 --- a/tests/ebox-child-range-tests.el +++ b/tests/ebox-child-range-tests.el @@ -816,6 +816,196 @@ (buffer-string))))))))) (when (buffer-live-p buffer) (kill-buffer buffer)))))) +(defun ebox-child-range-test--snapshot-detail (expanded &optional title) + "Return a retained detail root, with TITLE and status when EXPANDED." + (if expanded + (ebox-test-column :key 'detail :source-identity 'detail :overflow 'scroll + :bgcolor "#E8DED1" + (ebox-test-text (propertize (or title "title-1") 'face '(:weight bold)) + :key 'title :source-identity 'title) + (ebox-test-text "status" :key 'status :source-identity 'status)) + (ebox-test-text "placeholder" :key 'detail :source-identity 'detail))) + +(defun ebox-child-range-test--snapshot-replacement-root () + "Return a detail Range beside painted rows and an active scroll sibling." + (ebox-test-column :width '(200) :bgcolor "#FFFDF8" + (ebox-test-box :key 'row-1 :source-identity 'row-1 :color "#123456" + (ebox-test-text "row-1")) + (ebox-test-box :key 'row-2 :source-identity 'row-2 :color "#654321" + (ebox-test-text "row-2")) + (ebox-test-column :bgcolor "#DDE7EF" + :surface-properties '(help-echo "retained detail help") + (ebox-test-child-range + 'details (ebox-child-range-test--snapshot-detail nil))) + (ebox-test-box :key 'scroll :source-identity 'scroll + :height 2 :width '(80) :overflow 'scroll + (ebox-test-text "line-a\nline-b\nline-c\nline-d")))) + +(defun ebox-child-range-test--replace-snapshot-detail (buffer expanded) + "Return a BUFFER candidate replacing its detail Range with EXPANDED content." + (let ((candidate (ebox-candidate-begin buffer))) + (ebox-candidate-replace-range-ref + candidate 'details (ebox-child-range-test--snapshot-detail expanded)) + candidate)) + +(defun ebox-child-range-test--assert-current-snapshot (buffer node-id) + "Assert BUFFER's NODE-ID snapshot reflects its current structural/style facts." + (let* ((node (ebox--buffer-runtime-node buffer node-id)) + (expected (ebox--node-layout-snapshot buffer node)) + (actual (ebox--layout-snapshot buffer node-id))) + (dolist (key '(:type :display :region-ids :style-signature :child-ids)) + (ert-info ((format "current snapshot field %S" key)) + (should (equal (plist-get expected key) (plist-get actual key))))))) + +(defun ebox-child-range-test--assert-full-render-equivalent (buffer) + "Assert BUFFER exactly matches a full render of its published root." + (with-current-buffer buffer + (let* ((state (ebox--buffer-render-state buffer)) + (expected (ebox--render-node (plist-get state :root-node) + (plist-get state :source-index)))) + (should (equal-including-properties expected (buffer-string)))))) + +(ert-deftest ebox-child-range-retained-kind-replacement-refreshes-snapshots () + "Text/Column roundtrips refresh facts only after successful publication." + (with-temp-buffer + (ebox-render-to-buffer + (current-buffer) (ebox-child-range-test--snapshot-replacement-root)) + (let* ((owner-id (plist-get (ebox--host-ref-node (current-buffer) 'detail) + :node-id)) + (sibling-id (plist-get (ebox--host-ref-node (current-buffer) 'row-1) + :node-id)) + (ancestors + (let ((node-id owner-id) + (parents (plist-get (ebox--buffer-render-state (current-buffer)) + :parent-table)) + result) + (while (setq node-id (gethash node-id parents)) + (push node-id result)) + result))) + (dolist (expanded '(t nil t)) + (ert-info ((format "detail expanded=%S" expanded)) + (dolist (ancestor ancestors) + (ebox--layout-snapshot (current-buffer) ancestor)) + (ebox--ensure-layout-snapshot-details (current-buffer) sibling-id) + (let* ((old (ebox--ensure-layout-snapshot-details (current-buffer) owner-id)) + (old-facts (copy-tree old)) + (state (ebox--buffer-render-state (current-buffer))) + (snapshots (plist-get state :layout-snapshots)) + (runtime-revision (plist-get state :runtime-revision)) + (surface-revision (tp-surface-revision ebox-surface--buffer-surface)) + (scroll-id (car (plist-get state :scroll-region-ids))) + (scroll (gethash scroll-id ebox--scroll-global-state)) + (before (buffer-string)) + sibling-seed sibling-seed-facts + (seed-observer + (lambda (candidate-state) + (setq sibling-seed + (gethash sibling-id (plist-get candidate-state + :layout-snapshots)) + sibling-seed-facts (copy-tree sibling-seed))))) + (should (equal (if expanded '(inline flow) '(block column)) + (plist-get old :display))) + (should (eq (if expanded 'visible 'scroll) + (plist-get (plist-get old :style-signature) :overflow))) + (cl-letf (((symbol-function 'accept-change-group) + (lambda (_) (error "Reject retained kind replacement")))) + (should (equal + (should-error + (ebox-commit + (current-buffer) + (ebox-child-range-test--replace-snapshot-detail + (current-buffer) expanded))) + '(error "Reject retained kind replacement")))) + (should (eq state (ebox--buffer-render-state (current-buffer)))) + (should (eq snapshots (plist-get state :layout-snapshots))) + (should (eq old (gethash owner-id snapshots))) + (should (equal old-facts old)) + (should (= runtime-revision (plist-get state :runtime-revision))) + (should (= surface-revision + (tp-surface-revision ebox-surface--buffer-surface))) + (should (eq scroll (gethash scroll-id ebox--scroll-global-state))) + (should (equal-including-properties before (buffer-string))) + (advice-add 'ebox-surface--render-candidate :before seed-observer) + (unwind-protect + (ebox-commit + (current-buffer) + (ebox-child-range-test--replace-snapshot-detail + (current-buffer) expanded)) + (advice-remove 'ebox-surface--render-candidate seed-observer)) + (should (ebox--layout-snapshot-detailed-p sibling-seed)) + (should (eq sibling-seed + (gethash sibling-id + (plist-get (ebox--buffer-render-state (current-buffer)) + :layout-snapshots)))) + (should (equal sibling-seed-facts sibling-seed)) + (should (= owner-id + (plist-get (ebox--host-ref-node (current-buffer) 'detail) + :node-id))) + (ebox-child-range-test--assert-full-render-equivalent (current-buffer)) + (ebox-child-range-test--assert-current-snapshot + (current-buffer) owner-id) + (dolist (ancestor ancestors) + (ebox-child-range-test--assert-current-snapshot + (current-buffer) ancestor)))))))) + +(ert-deftest ebox-child-range-retained-kind-replacement-allows-mixed-followups () + "Later title and row paint edits remain local beside disjoint scrolling." + (with-temp-buffer + (ebox-render-to-buffer + (current-buffer) (ebox-child-range-test--snapshot-replacement-root)) + (ebox--ensure-layout-snapshot-details + (current-buffer) + (plist-get (ebox--host-ref-node (current-buffer) 'detail) :node-id)) + (ebox-commit + (current-buffer) + (ebox-child-range-test--replace-snapshot-detail (current-buffer) t)) + (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)) + (identities + (mapcar (lambda (ref) + (cons ref (plist-get (ebox--host-ref-node (current-buffer) ref) + :node-id))) + '(row-1 row-2 detail title status scroll)))) + (should scroll-id) + (should (= (length (plist-get state :scroll-region-ids)) 1)) + (dolist (selected '(2 1 2)) + (let* ((candidate (ebox-candidate-begin (current-buffer))) + (root-renders 0) + (observer (lambda (&rest _arguments) (cl-incf root-renders))) + report) + (ebox-candidate-replace-host-ref + candidate 'title + (ebox-test-text (propertize (format "title-%d" selected) + 'face '(:weight bold)) + :key 'title :source-identity 'title)) + (dolist (row '(1 2)) + (let ((ref (intern (format "row-%d" row)))) + (ebox-candidate-replace-host-ref + candidate ref + (ebox-test-box :key ref :source-identity ref + :color (if (= row selected) "#123456" "#654321") + (ebox-test-text (format "row-%d" row)))))) + (advice-add 'ebox-surface--render-candidate :before observer) + (unwind-protect + (setq report (ebox-commit (current-buffer) candidate)) + (advice-remove 'ebox-surface--render-candidate observer)) + (ebox-child-range-test--assert-full-render-equivalent (current-buffer)) + (dolist (entry identities) + (should (= (cdr entry) + (plist-get (ebox--host-ref-node (current-buffer) (car entry)) + :node-id)))) + (ert-info ((format "selected=%S root-renders=%S report=%S" + selected root-renders report)) + (should (eq (plist-get report :projection-kind) 'mixed-owner-reflow)) + (should (zerop root-renders)) + (should-not (plist-get report :tp-full-root))) + (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))))))))) + (ert-deftest ebox-child-range-does-not-reuse-disappeared-key-positionally () "A new keyed item must not inherit a removed peer's runtime identity." (let ((buffer (generate-new-buffer " *ebox-range-key-reentry*")))