Fix retained Flex ownership and responsive viewport updates

This commit is contained in:
Kinneyzhang 2026-08-22 17:37:03 +08:00
parent 33c625ad82
commit b65b5937cc
11 changed files with 798 additions and 52 deletions

View File

@ -25,6 +25,7 @@
"ebox" (lines))
(declare-function ebox-string-lines
"ebox" (string))
(defvar ebox-region-types)
(defvar ebox-render-gc-cons-threshold (* 128 1024 1024)
"Temporary `gc-cons-threshold' used during Ebox render transactions.
@ -501,6 +502,81 @@ may be added in place."
;;; Existing Slot Shaping
(defun ebox-buffer--rendered-owned-lines
(rendered region-ids &optional expected-line-count)
"Project RENDERED to its contiguous direct role-owned lines.
REGION-IDS is the same owner set used to derive retained TP spans. Leading
and trailing lines without a direct target role are stable wrapper chrome and
are omitted. When EXPECTED-LINE-COUNT is non-nil, any remaining excess may
be removed only from role-proven outer margin/border lines (`mt'/`bt' at the
start, `mb'/`bb' at the end). Padding and content are never inferred from a
numeric box-model delta. An unowned interior line or a foreign direct
`content' role fails closed. Returned lines retain all text properties."
(let ((region-set (make-hash-table :test 'equal))
(lines (ebox-string-lines rendered))
infos first last invalid)
(dolist (region-id region-ids)
(puthash region-id t region-set))
(dolist (line lines)
(let ((position 0)
(limit (length line))
owned foreign-content roles)
(while (< position limit)
(let ((next limit))
(dolist (entry ebox-region-types)
(let* ((property (cdr entry))
(region-id (get-text-property position property line))
(change (next-single-property-change
position property line limit)))
(when (and region-id (gethash region-id region-set))
(setq owned t)
(cl-pushnew (car entry) roles))
(when (and (eq (car entry) 'content) region-id
(not (gethash region-id region-set)))
(setq foreign-content t))
(when (and change (< change next))
(setq next change))))
(setq position (max next (1+ position)))))
(push (list :line line :owned (and owned (not foreign-content))
:roles roles)
infos)
(when (and owned foreign-content)
(setq invalid t))))
(setq infos (nreverse infos))
(cl-loop for info in infos
for index from 0
when (plist-get info :owned)
do (unless first (setq first index))
and do (setq last index))
(when (and first (not invalid)
(cl-every (lambda (info) (plist-get info :owned))
(cl-subseq infos first (1+ last))))
(let ((owned (cl-subseq infos first (1+ last))))
(when expected-line-count
(while (> (length owned) expected-line-count)
(let ((leading-roles (plist-get (car owned) :roles))
(trailing-roles (plist-get (car (last owned)) :roles)))
(cond
((and (not (memq 'content leading-roles))
(not (memq 'content-owner leading-roles))
(cl-some (lambda (role) (memq role '(mt bt)))
leading-roles))
(setq owned (cdr owned)))
((and (not (memq 'content trailing-roles))
(not (memq 'content-owner trailing-roles))
(cl-some (lambda (role) (memq role '(mb bb)))
trailing-roles))
(setq owned (butlast owned)))
(t
(setq invalid t
owned nil))))))
(when (and owned (not invalid)
(or (null expected-line-count)
(= (length owned) expected-line-count)))
(ebox-lines-join
(mapcar (lambda (info) (plist-get info :line)) owned)))))))
(defun ebox-buffer--span-slot-width (span)
"Return SPAN's visual slot width in its current buffer line."
(save-excursion

View File

@ -390,8 +390,15 @@ grapheme."
box (ebox-get box :min-width) 0)
0)))
(if (null (ebox-get box :wrap-mode))
;; CSS's automatic minimum is min-content only when the author
;; leaves `min-width' at its automatic value. A component that
;; deliberately declares `:min-width 0' must be allowed to
;; shrink even when its own rows remain no-wrap (the surrounding
;; card may then own horizontal overflow).
(if (ebox--flex-explicit-min-main-p box axis)
(+ (ebox--side-pixel box) declared-min)
(max (ebox--flex-string-main rendered axis)
(+ (ebox--side-pixel box) declared-min))
(+ (ebox--side-pixel box) declared-min)))
(let ((content-min (ebox--content-min-pixel box)))
(when (hash-table-p ebox--flex-content-min-width-table)
(puthash box content-min
@ -404,6 +411,11 @@ grapheme."
box (ebox-get box :min-height))
0)))))
(defun ebox--flex-explicit-min-main-p (box axis)
"Return non-nil when BOX declares a main-axis minimum explicitly."
(let ((property (if (eq axis 'row) 'ebox/min-width 'ebox/min-height)))
(plist-member (plist-get box :ebox-style-declarations) property)))
(defun ebox--flex-box-main-constraint (box axis property)
"Return BOX's total main-axis size constraint from PROPERTY."
(when (plist-member box property)

View File

@ -48,6 +48,7 @@ are not part of its final allocated geometry."
:viewport-height-independent
(and (boundp 'ebox-viewport-height) ebox-viewport-height))))
(list (ebox--ensure-node-id source)
(copy-sequence (ebox--node-all-region-ids source))
'flex-final-sized
axis main cross align viewport
height-key

View File

@ -2911,6 +2911,36 @@ its new minimum must still fit CURRENT-MAIN."
(<= (ebox--flex-min-main style-node rendered axis)
current-main))))
(defun ebox--flex-fixed-basis-content-allocation-stable-p
(style-node item-props axis)
"Return non-nil when content cannot affect a fixed-basis flex allocation.
A definite basis owns the item's base size. An explicit zero main-axis
minimum removes the content-minimum clamp, so grow/shrink allocation depends
only on the stable container and sibling flex inputs. Cross-axis compatibility
is still verified by the existing rendered slot-footprint proof."
(let* ((basis-value (plist-get item-props :flex-basis))
(basis (and (not (memq basis-value
'(nil auto min-content max-content
fit-content stretch contain viewport)))
(ebox--flex-main-value axis basis-value nil)))
(minimum (ebox-get style-node
(if (eq axis 'row) :min-width :min-height))))
(and (numberp basis) (>= basis 0)
(numberp minimum) (= minimum 0))))
(defun ebox--flex-item-fixed-basis-content-allocation-stable-p
(buffer node-id)
"Return whether NODE-ID has a content-independent fixed flex basis."
(when-let* ((node (ebox--buffer-runtime-node buffer node-id))
(parent-id (ebox--runtime-parent-id buffer node-id))
(parent (ebox--buffer-runtime-node buffer parent-id))
((eq (ebox--display-inner parent) 'flex))
(child (ebox--flex-child-for-source-node parent node-id))
(style-node (ebox-fragment-style-source-node node)))
(ebox--flex-fixed-basis-content-allocation-stable-p
style-node (ebox--flex-item-props child) (ebox--flex-main-axis parent))))
(defun ebox--flex-slot-allocation-stable-p
(buffer node child axis current-main changed-keys)
"Return non-nil when CHANGED-KEYS preserve NODE's flex allocation.
@ -2918,19 +2948,29 @@ Unknown changes fail closed. A fixed outer size is insufficient for content
updates unless the auto flex basis stays fixed and the new minimum still fits
the already published slot."
(when-let ((style-node (ebox-fragment-style-source-node node)))
(let* ((item-props (ebox--flex-item-props child))
(fixed-basis-p
(ebox--flex-fixed-basis-content-allocation-stable-p
style-node item-props axis)))
(and changed-keys
;; A fixed basis plus zero main minimum makes content independent
;; of main allocation. Admit cross-axis content changes to the
;; retained-slot render; stretch sizes the candidate to the old
;; cross slot, while non-stretch items remain natural and fail the
;; final footprint comparison if their block size changed.
(or fixed-basis-p
(cl-every
(lambda (key)
(and (not (ebox--flex-main-footprint-key-p
key axis style-node))
(not (ebox--flex-main-footprint-key-p
key (ebox--flex-cross-axis axis) style-node))))
changed-keys)
(or (not (cl-some #'ebox--flex-content-flow-main-key-p
changed-keys))
(or fixed-basis-p
(not (cl-some #'ebox--flex-content-flow-main-key-p
changed-keys))
(ebox--flex-content-allocation-stable-p
buffer style-node (ebox--flex-item-props child)
axis current-main)))))
buffer style-node item-props axis current-main))))))
(defun ebox--flex-item-slot-sized-node
(buffer node-id snapshot &optional changed-keys)
@ -3006,19 +3046,36 @@ CHANGED-KEYS must prove that reusing the allocation is safe."
(or (ebox--render-node-in-current-flex-slot
buffer node-id snapshot changed-keys)
(ebox-render node)))
(role-owned-lines-p
(ebox--flex-item-fixed-basis-content-allocation-stable-p
buffer node-id))
(owned-rendered
(if role-owned-lines-p
(ebox-buffer--rendered-owned-lines
rendered (plist-get snapshot :region-ids) (length spans))
rendered))
(owner-set (ebox--region-id-set
(plist-get snapshot :region-ids)))
(slot-rendered
(and owned-rendered
(with-current-buffer buffer
(ebox-buffer--rendered-in-existing-slots spans rendered)))
(final-rendered (or slot-rendered rendered))
(ebox-buffer--rendered-in-existing-slots
spans owned-rendered
(and role-owned-lines-p owner-set)))))
(final-rendered slot-rendered)
(new-span-footprint
(ebox--rendered-span-footprint-signature final-rendered))
(and final-rendered
(ebox--rendered-span-footprint-signature final-rendered)))
(new-external-footprint
(and new-span-footprint
(ebox--external-footprint-signature-from-span-footprint
new-span-footprint))
new-span-footprint)))
(new-parent-slot
(and new-span-footprint
(ebox--project-parent-slot-signature
old-parent-slot new-span-footprint)))
(and (ebox--span-footprint-compatible-p
old-parent-slot new-span-footprint))))
(and final-rendered
(ebox--span-footprint-compatible-p
old-span-footprint new-span-footprint)
(ebox--external-footprint-compatible-p
old-external-footprint new-external-footprint)
@ -3058,7 +3115,9 @@ grow/shrink) legitimately changes with it, so a slot-preserving
;; size remains allocation-stable even when its own content changes.
;; The following slot-footprint proof still renders the candidate
;; in that size and rejects wrapping/overflow/parent-slot misses.
(ebox--flex-item-slot-at-declared-main-p buffer node-id))
(ebox--flex-item-slot-at-declared-main-p buffer node-id)
(ebox--flex-item-fixed-basis-content-allocation-stable-p
buffer node-id))
(ebox--cached-flex-item-slot-footprint-safe-p
buffer node-id changed-keys)))
@ -4634,6 +4693,14 @@ DETACHED-HISTORY receives identity snapshots for semantic replacements."
(aref old-payload offset)))))
(when (and old key (null (plist-get old :key))) (setq old nil))
(when (and old (null key) (plist-get old :key)) (setq old nil))
;; Positional fallback is valid only for unkeyed peers. A
;; disappeared keyed item must never donate its runtime
;; identity to a newly introduced key at the same offset;
;; doing so leaves its old region properties in the candidate
;; output while the new region index has already retired them.
(when (and old key (plist-get old :key)
(not (equal (plist-get old :key) key)))
(setq old nil))
(ebox-tree-reconcile-runtime old item)))
items))
@ -4694,14 +4761,14 @@ DETACHED-HISTORY receives identity snapshots for semantic replacements."
table)))))
(defun ebox-incremental--candidate-path-copy-overlay
(root node-id replacement state)
(root node-id replacement state structure-p)
"Path-copy NODE-ID in ROOT using STATE tables plus the candidate trace."
(let* ((parent-table (plist-get state :parent-table))
(old-child (ebox-incremental--candidate-current-node state node-id))
(new-child replacement)
(parent-id (gethash node-id parent-table)))
(ebox-incremental--candidate-record-path-copy
node-id replacement parent-id t t)
node-id replacement parent-id t structure-p)
(while parent-id
(let* ((current-parent-id parent-id)
(old-parent
@ -4721,7 +4788,7 @@ DETACHED-HISTORY receives identity snapshots for semantic replacements."
old-parent (list (cons old-child new-child)))))
(grandparent-id (gethash current-parent-id parent-table)))
(ebox-incremental--candidate-record-path-copy
current-parent-id new-parent grandparent-id nil t)
current-parent-id new-parent grandparent-id nil structure-p)
(setq old-child old-parent new-child new-parent
parent-id grandparent-id)))
(if (equal node-id (plist-get root :node-id)) replacement new-child)))
@ -4808,17 +4875,30 @@ DETACHED-HISTORY receives identity snapshots for semantic replacements."
(ebox-child-range--lookup-ref
(car result) (car replacement))) nil))
ordered-replacements))
(children-changed
(not
(cl-every
(lambda (pair)
(equal
(mapcar (lambda (node)
(plist-get node :node-id))
(car pair))
(mapcar (lambda (node)
(plist-get node :node-id))
(cdr pair))))
(cl-mapcar #'cons old-payloads new-payloads))))
(new-parent (copy-sequence parent)))
(plist-put new-parent :ebox-child-sequence (car result))
(push (ebox-child-range--metrics-plist (cdr result)) metrics)
(setq root
(ebox-incremental--candidate-path-copy-overlay
root parent-id new-parent state))
root parent-id new-parent state children-changed))
(let ((trace (gethash parent-id
ebox-incremental--candidate-path-copy-trace)))
(plist-put trace :anchor-p nil))
(push (list :old-parent base-parent :new-parent new-parent
:parent-id parent-id
:children-changed children-changed
:old-payloads old-payloads
:new-payloads new-payloads)
ebox-incremental--candidate-range-index-deltas))))))))
@ -5297,7 +5377,8 @@ complete preorder first-owner and postorder last-box semantics."
entry))
(when old-node
(let* ((changed-keys
(ebox-tree-node-local-changed-keys old-node new-node))
(ebox-incremental--candidate-local-changed-keys
old-node new-node))
(range-delta
(and range-delta-table
(gethash (plist-get new-node :node-id)
@ -5437,7 +5518,7 @@ replace those O(n) table copies without changing this delta contract."
(record-touched old-parent new-parent
(gethash parent-id
(plist-get old-state :parent-table))
t)
(plist-get delta :children-changed))
(dolist (payload (plist-get delta :old-payloads))
(dolist (node payload) (remove-old-subtree node)))
(dolist (payload (plist-get delta :new-payloads))
@ -5722,6 +5803,23 @@ recognized from its first untouched sibling without walking the whole tree."
(visit candidate-root)
nil)))))
(defun ebox-incremental--candidate-local-changed-keys (old new)
"Return real source changes between published OLD and candidate NEW.
Published nodes carry computed ECSS longhands while fresh candidate nodes are
still declaration-stage values. When canonical declarations are unchanged,
discard style longhand and container-prop differences caused solely by that
stage mismatch; selector metadata and non-style source fields remain visible."
(let ((changed (ebox-tree-node-local-changed-keys old new)))
(if (equal (ebox-style-node-declarations old)
(ebox-style-node-declarations new))
(cl-remove-if
(lambda (key)
(or (eq key :props)
(ebox-style-property key)))
changed)
changed)))
(defun ebox-incremental--declarative-dirty-set
(old-state candidate-root candidate-index)
"Return source-level dirty entries from OLD-STATE to CANDIDATE-ROOT.
@ -6386,9 +6484,18 @@ This function is deliberately proof-only: it never widens a scope or
suppresses the ordinary fallback when any fact is missing."
(let* ((dirty-set (plist-get prepared :dirty-set))
(geometry-dirty
(mapcar
(lambda (entry)
;; A merged provenance entry may carry sibling paint owners in
;; `:node-ids'. Geometry proof must start from the geometry
;; entry's own node, otherwise a second selected row can lift
;; the LCA to the root and forfeit mixed-owner publication.
(let ((copy (copy-sequence entry)))
(plist-put copy :node-ids nil)
copy))
(cl-remove-if-not
(lambda (entry) (eq (plist-get entry :dirty-kind) 'geometry))
dirty-set))
dirty-set)))
(paint-dirty
(cl-remove-if-not
(lambda (entry) (eq (plist-get entry :dirty-kind) 'paint))
@ -6425,14 +6532,27 @@ suppresses the ordinary fallback when any fact is missing."
(geometry-span-proof
(copy-sequence (plist-get geometry-span-proof :owner-ids)))))
(geometry-owner-id (car geometry-owner-ids))
(external-paint-dirty
(and geometry-owner-ids
(cl-remove-if
(lambda (entry)
(cl-some
(lambda (geometry-id)
(let ((node-id (plist-get entry :node-id)))
(or (equal geometry-id node-id)
(ebox--runtime-ancestor-id-p
buffer geometry-id node-id))))
geometry-owner-ids))
paint-dirty)))
(paint-owner-ids
(delete-dups
(cl-mapcan #'ebox--dirty-provenance-node-ids paint-dirty)))
(cl-mapcan #'ebox--dirty-provenance-node-ids
external-paint-dirty)))
(old-nodes (plist-get old-state :node-table))
(new-nodes (plist-get candidate-state :node-table))
(root-id (ebox--buffer-root-node-id buffer))
(paint-valid-p
(and paint-dirty
(and external-paint-dirty
(cl-every
(lambda (entry)
(and (plist-get entry :node-id)
@ -6469,7 +6589,7 @@ suppresses the ordinary fallback when any fact is missing."
(plist-get
(plist-get entry :new-signature)
:content))))))
paint-dirty)))
external-paint-dirty)))
(disjoint-p
(and geometry-owner-ids
(not (cl-some (lambda (geometry-id)
@ -6598,6 +6718,54 @@ suppresses the ordinary fallback when any fact is missing."
'owner-rerender owner-id
:dirty (ebox--merge-dirty-provenance-list render-dirty-set))))))
(defun ebox-incremental--nearest-fixed-basis-flex-item-owner-id
(buffer node-id)
"Return NODE-ID's nearest content-independent fixed-basis Flex item."
(let ((candidate node-id)
(root-id (ebox--buffer-root-node-id buffer))
owner)
(while (and candidate (not owner) (not (equal candidate root-id)))
(if (ebox--flex-item-fixed-basis-content-allocation-stable-p
buffer candidate)
(setq owner candidate)
(setq candidate (ebox--runtime-parent-id buffer candidate))))
owner))
(defun ebox-incremental--fixed-basis-flex-local-op
(buffer render-dirty-set)
"Return one local Flex-item op for related geometry RENDER-DIRTY-SET.
Multiple descendant content changes can share a fixed-basis, zero-minimum Flex
item without reflowing the outer Flex line. The candidate item is still
rendered into its retained slot and must pass the complete footprint proof."
(when (and (> (length render-dirty-set) 1)
(cl-every
(lambda (entry)
(and (eq (plist-get entry :dirty-kind) 'geometry)
(not (plist-get entry :children))))
render-dirty-set))
(let* ((owner-ids
(delete-dups
(mapcar
(lambda (entry)
(ebox-incremental--nearest-fixed-basis-flex-item-owner-id
buffer (plist-get entry :node-id)))
render-dirty-set)))
(owner-id (and (= (length owner-ids) 1) (car owner-ids)))
(changed-keys
(delete-dups
(cl-mapcan
(lambda (entry)
(copy-sequence (plist-get entry :changed-keys)))
render-dirty-set))))
(when (and owner-id
(not (equal owner-id (ebox--buffer-root-node-id buffer)))
(ebox--cached-flex-item-slot-footprint-safe-p
buffer owner-id changed-keys))
(ebox--patch-op
'owner-rerender owner-id
:dirty (ebox--merge-dirty-provenance-list render-dirty-set))))))
(defun ebox-incremental--dirty-set-has-child-splice-p
(buffer render-dirty-set)
"Return non-nil when RENDER-DIRTY-SET contains a provable child splice."
@ -6624,6 +6792,8 @@ suppresses the ordinary fallback when any fact is missing."
buffer render-dirty-set))
(or (ebox-incremental--broad-dirty-op
buffer render-dirty-set)
(ebox-incremental--fixed-basis-flex-local-op
buffer render-dirty-set)
(ebox-incremental--local-reflow-op
buffer render-dirty-set)))))
(setq ops (list coalesced-op))
@ -7603,13 +7773,120 @@ metadata; this predicate only authorizes the local attempt."
(setq valid nil)))))
valid))
(defun ebox-incremental--fixed-basis-flex-group-span-proof
(buffer old-state prepared candidate-state owner-plan)
"Return a single-owner proof for grouped fixed-basis Flex descendants.
The layout owner plan has already established one content-independent Flex
allocation. This final gate renders the complete candidate item into its
published slot, verifies exact footprint compatibility, and packages the
result in the same owner-proof shape consumed by span publication."
(when (and (= (length owner-plan) 1)
(> (length (plist-get prepared :dirty-set)) 1))
(let* ((operation (car owner-plan))
(owner-id (plist-get operation :owner-id))
(dirty-set (plist-get prepared :dirty-set))
(root-id (ebox--buffer-root-node-id buffer))
(old-node (gethash owner-id (plist-get old-state :node-table)))
(new-node (gethash owner-id
(plist-get candidate-state :node-table)))
(snapshot
(and old-node
(ebox-incremental--cached-layout-snapshot-details
buffer owner-id)))
(changed-keys
(delete-dups
(cl-mapcan
(lambda (entry)
(copy-sequence (plist-get entry :changed-keys)))
dirty-set)))
(allocated-width
(and snapshot
(plist-get
(plist-get snapshot :external-footprint-signature)
:max-line-pixel-width)))
(cascade-safe-p
(or (and (not (plist-get old-state :cascade-active-p))
(not (plist-get old-state :cascade-required-p))
(not (ebox-style-cascade-active-p)))
(ebox-surface--cascade-local-owner-proof-p
old-state candidate-state dirty-set))))
(let ((ebox-incremental--buffer-render-state-override
(cons buffer candidate-state))
(ebox-incremental--candidate-base-state
(cons buffer old-state)))
(when (and owner-id old-node new-node snapshot allocated-width
(not (equal owner-id root-id))
(eq (plist-get operation :op) 'owner-rerender)
(cl-every
(lambda (entry)
(and (eq (plist-get entry :dirty-kind) 'geometry)
(not (plist-get entry :children))
(ebox--runtime-ancestor-id-p
buffer owner-id (plist-get entry :node-id))
(cl-every
(lambda (key)
(or (eq key :content)
(eq key :surface-properties)
(memq key ebox--paint-style-signature-keys)))
(plist-get entry :changed-keys))))
dirty-set)
(ebox--flex-item-fixed-basis-content-allocation-stable-p
buffer owner-id)
(ebox--cached-flex-item-slot-footprint-safe-p
buffer owner-id changed-keys)
(equal (gethash owner-id
(plist-get old-state :parent-table))
(gethash owner-id
(plist-get candidate-state :parent-table)))
(ebox-incremental--node-child-ids-equal-p
old-node new-node)
(equal (ebox-incremental--layout-slot-style-signature
old-node)
(ebox-incremental--layout-slot-style-signature
new-node))
(ebox-incremental--same-root-surface-identity-p
(plist-get old-state :root-node)
(plist-get candidate-state :root-node))
(null (plist-get old-state :scroll-region-ids))
(not (ebox--node-visible-overflow-p old-node))
cascade-safe-p)
(let* ((owner-proof
(list :owner-id owner-id
:dirty-set (copy-sequence dirty-set)
:changed-keys (copy-sequence changed-keys)
:snapshot snapshot
:allocated-width allocated-width
:role-owned-lines-p t
:ancestor-slot-signature
(list (list :node-id owner-id
:parent-slot-signature
(plist-get snapshot
:parent-slot-signature)))
:layout-snapshot-detail-generation
(plist-get old-state
:layout-snapshot-detail-generation)
:static-cascade-p cascade-safe-p))
(owner-proofs (list owner-proof)))
(list :owner-ids (list owner-id)
:owner-proofs owner-proofs
:dirty-set (copy-sequence dirty-set)
:ancestor-slot-signature
(copy-sequence
(plist-get owner-proof :ancestor-slot-signature))
:layout-snapshot-detail-generation
(plist-get old-state :layout-snapshot-detail-generation)
:static-cascade-p cascade-safe-p)))))))
(defun ebox-incremental--span-patch-projection-proof
(buffer old-state prepared candidate-state owner-plan
&optional path-copied-p)
"Return one atomic single- or multi-owner projection proof, or nil."
(if (= (length owner-plan) 1)
(ebox-incremental--single-span-patch-projection-proof
(or (ebox-incremental--single-span-patch-projection-proof
buffer old-state prepared candidate-state owner-plan path-copied-p)
(ebox-incremental--fixed-basis-flex-group-span-proof
buffer old-state prepared candidate-state owner-plan))
(let (proofs valid)
(setq valid (and owner-plan
(= (length owner-plan)

View File

@ -43,6 +43,7 @@
(defvar ebox-viewport-height)
(defvar ebox--render-display-signature)
(defvar ebox-incremental--allocated-slot-proof-cache)
(defvar ebox-incremental--buffer-render-state-override)
(declare-function ebox--render-layout "ebox-layout" (node))
(declare-function ebox--record-render-output-provenance
@ -57,6 +58,13 @@
"ebox-incremental" (target keys source))
(declare-function ebox--runtime-region-id-conflict
"ebox-incremental" (region-id-set target-buffer))
(declare-function ebox--region-id-set
"ebox-incremental" (region-ids))
(declare-function ebox--flex-item-slot-sized-node
"ebox-incremental"
(buffer node-id snapshot &optional changed-keys))
(declare-function ebox--layout-snapshot-strip-details
"ebox-incremental" (snapshot))
(declare-function ebox--viewport-dependent-node-id-axes
"ebox-incremental" (node))
(declare-function ebox--update-report
@ -575,6 +583,19 @@ FRAMEWORK-PARTICIPANT owns paired framework publication when non-nil."
(plist-put new-state :surface surface)
(plist-put new-state :runtime-revision
(1- (tp-surface-revision surface)))
;; A fixed-basis role-owned patch rendered against the previous
;; allocation. Its installed details describe that proof render,
;; not the now-committed TP mounts. Drop only those owners' derived
;; geometry so the next transaction lazily captures the committed
;; spans and cannot drift across a repeated selection round trip.
(when-let ((snapshots (plist-get new-state :layout-snapshots)))
(dolist (proof (plist-get new-state :owner-scoped-proofs))
(when (plist-get proof :role-owned-lines-p)
(let ((owner-id (plist-get proof :owner-id)))
(when-let ((snapshot (gethash owner-id snapshots)))
(puthash owner-id
(ebox--layout-snapshot-strip-details snapshot)
snapshots))))))
(when (plist-get report-base :scroll-state-transaction)
;; Scroll changes invalidate every buffer-span coordinate. Keep
;; the committed generation structurally snapshot-free so a later
@ -1615,9 +1636,9 @@ valid because their declarative layout and viewport did not change."
(string< (symbol-name left)
(symbol-name right))))))))
(defun ebox-surface--span-patch-lines (buffer spans rendered
&optional variable-content-p
variable-content-max-width)
(defun ebox-surface--span-patch-lines
(buffer spans rendered &optional variable-content-p
variable-content-max-width owner-set)
"Return RENDERED shaped for BUFFER SPANS, or nil when unsafe.
When VARIABLE-CONTENT-P is non-nil, allow a stable partial slot to retain its
old allocated width while the owner changes its natural content length."
@ -1633,7 +1654,8 @@ old allocated width while the owner changes its natural content length."
variable-content-max-width))
lines)))
rendered))
(ebox-buffer--rendered-in-existing-slots spans rendered nil t))
(ebox-buffer--rendered-in-existing-slots
spans rendered owner-set t))
(and (= (length spans) (length (ebox-string-lines rendered)))
rendered))))
@ -1756,7 +1778,8 @@ old allocated width while the owner changes its natural content length."
(defun ebox-surface--owner-patch-candidate
(buffer state owner-id allocated-width
&optional variable-content-p variable-content-max-width)
&optional variable-content-p variable-content-max-width
role-owned-lines-p changed-keys)
"Return validated patch details for OWNER-ID, or nil."
(let* (
(old-snapshot
@ -1764,16 +1787,36 @@ old allocated width while the owner changes its natural content length."
(spans (and old-snapshot (plist-get old-snapshot :buffer-spans)))
(node (and owner-id (gethash owner-id (plist-get state :node-table))))
(render-node
(if role-owned-lines-p
;; The planner proved this fixed-basis owner in its published
;; Flex slot. Recreate that exact candidate-state witness for
;; publication; a natural render can have fewer owned lines and
;; would spuriously widen the TP scope to the root.
(and (fboundp 'ebox--flex-item-slot-sized-node)
(let ((ebox-incremental--buffer-render-state-override
(cons buffer state)))
(ebox--flex-item-slot-sized-node
buffer owner-id old-snapshot changed-keys)))
(if allocated-width
(plist-put (copy-sequence node) :width allocated-width)
node)))
(when (and old-snapshot spans node)
node))))
(when (and old-snapshot spans node render-node)
(let* ((rendered
(ebox-surface--render-candidate-node state render-node))
(owned-rendered
(if role-owned-lines-p
(ebox-buffer--rendered-owned-lines
rendered (plist-get old-snapshot :region-ids)
(length spans))
rendered))
(replacement
(and owned-rendered
(ebox-surface--span-patch-lines
buffer spans rendered variable-content-p
variable-content-max-width))
buffer spans owned-rendered variable-content-p
variable-content-max-width
(and role-owned-lines-p
(ebox--region-id-set
(plist-get old-snapshot :region-ids))))))
(details
(and replacement
(ebox-surface--span-patch-details
@ -1794,7 +1837,9 @@ old allocated width while the owner changes its natural content length."
buffer state (plist-get proof :owner-id)
(plist-get proof :allocated-width)
(plist-get proof :variable-content-p)
(plist-get proof :variable-content-max-width)))
(plist-get proof :variable-content-max-width)
(plist-get proof :role-owned-lines-p)
(plist-get proof :changed-keys)))
proofs)
(list
(ebox-surface--owner-patch-candidate

36
ebox.el
View File

@ -2743,6 +2743,42 @@ that was rendered off-window cannot miss the native-scroll handoff."
(when (boundp 'window-state-change-functions)
(add-hook 'window-state-change-functions #'ebox--window-state-change))
(defvar ebox--window-size-change-in-progress nil
"Non-nil while visible Ebox buffers are synchronizing their viewport.")
(defun ebox--window-size-change (frame)
"Synchronize visible Ebox buffers after FRAME changes size.
Ebox stores the containing-block viewport in its mounted surface. A frame
resize must publish the new window content width before the next render;
otherwise responsive Flex/Grid trees keep using the width from mount time.
One mounted surface represents one viewport, so when a buffer is visible in
multiple windows the first window in FRAME supplies the shared dimensions."
(when (and (frame-live-p frame)
(not noninteractive)
(not ebox--window-size-change-in-progress))
(let ((ebox--window-size-change-in-progress t)
(seen (make-hash-table :test #'eq)))
(dolist (window (window-list frame 'no-minibuf))
(when (and (window-live-p window)
(not (gethash (window-buffer window) seen)))
(let ((buffer (window-buffer window)))
(puthash buffer t seen)
(when (and (buffer-live-p buffer)
(ebox--buffer-render-state buffer)
(ebox-surface-buffer-mounted-p buffer))
(let* ((state (ebox--buffer-render-state buffer))
(width (ebox-surface--window-content-width window))
(height (window-body-height window)))
(unless (and (= width (or (plist-get state :viewport-width)
-1))
(= height (or (plist-get state :viewport-height)
-1)))
(ebox-rerender-buffer-with-context buffer width height))))))))))
(when (boundp 'window-size-change-functions)
(add-hook 'window-size-change-functions #'ebox--window-size-change))
(defun ebox--runtime-prewarm-record-viewport-node
(job node width-allowed)
"Record NODE's direct viewport dependencies in JOB.

View File

@ -481,6 +481,109 @@
:range-ref-table))))
(when (buffer-live-p buffer) (kill-buffer buffer)))))
(ert-deftest ebox-child-range-stable-keyed-refresh-is-not-structural ()
"Stable keyed Range refreshes must not become root structure updates."
(let ((buffer (generate-new-buffer " *ebox-stable-range-dirty*")))
(cl-labels
((rows (selected)
(cl-loop for id from 1 to 12
collect
(ebox-create
:key id :content (format "row-%02d" id) :width '(420)
:color (and (= id selected) "#2F6B43"))))
(root ()
(ebox-create
:width '(900) :color "#172033" :wrap-mode nil
:ebox-content-node
(ebox-flex
:width '(900)
(ebox-create
:key 'list :width 'stretch :min-width 0 :wrap-mode nil
:min-height 24 :padding '(2 2) :border "#687386"
:flex-grow 2 :flex-shrink 1 :flex-basis '(600)
:ebox-content-node
(ebox-column
(apply #'ebox-child-range 'items (rows 1))))
(ebox-create :key 'detail :host-ref 'detail
:width 'stretch :min-width 0
:min-height 24 :padding '(2 2) :border "#687386"
:flex-grow 1 :flex-shrink 1 :flex-basis '(300)
:content "detail-1")))))
(unwind-protect
(progn
(ebox-render-to-buffer buffer (root))
(let ((root-id (ebox--buffer-root-node-id buffer)))
(dolist (selected '(2 1 2))
(let ((candidate (ebox-candidate-begin buffer)))
(ebox-candidate-replace-range-ref
candidate 'items (rows selected))
(ebox-candidate-replace-host-ref
candidate 'detail
(ebox-create :key 'detail :host-ref 'detail
:width 'stretch :min-width 0
:min-height 24 :padding '(2 2)
:border "#687386"
:flex-grow 1 :flex-shrink 1 :flex-basis '(300)
:content (format "detail-%d" selected)))
(let ((report (ebox-commit buffer candidate)))
(ert-info ((format "selected=%S report=%S"
selected report))
(should-not (memq 'structure
(plist-get report :dirty-kinds)))
(should-not (member root-id
(plist-get report :owner-ids)))
(should (memq (plist-get report :strategy)
'(span-patch owner-rerender
mixed-owner-reflow)))
(should (= (plist-get report :tp-scope-count) 3))
(should (= (plist-get report :created-objects) 0))
(should (= (plist-get report :removed-objects) 0))))))))
(when (buffer-live-p buffer) (kill-buffer buffer))))))
(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*")))
(cl-labels
((rows (keys)
(mapcar (lambda (key)
(ebox-create :key key
:content (format "row-%s" key)))
keys))
(row-id (key)
(let* ((state (ebox--buffer-render-state buffer))
(resolved (ebox-incremental--range-ref-resolve
state 'items))
(segment (ebox-child-range--segment-at
(plist-get resolved :sequence)
(plist-get resolved :segment-index)))
(node (cl-find key
(append
(ebox-child-range--segment-payload segment)
nil)
:key (lambda (item)
(plist-get item :key)))))
(plist-get node :node-id))))
(unwind-protect
(progn
(ebox-render-to-buffer
buffer
(ebox-column
(apply #'ebox-child-range 'items (rows '(1 2 3 4)))))
(let ((initial-1 (row-id 1))
(initial-4 (row-id 4)))
(let ((candidate (ebox-candidate-begin buffer)))
(ebox-candidate-replace-range-ref
candidate 'items (rows '(1 4)))
(ebox-commit buffer candidate))
(let ((candidate (ebox-candidate-begin buffer)))
(ebox-candidate-replace-range-ref
candidate 'items (rows '(1 2 3 4)))
(ebox-commit buffer candidate))
(should (= initial-1 (row-id 1)))
(should (= initial-4 (row-id 4)))
(should-not (= initial-4 (row-id 2)))))
(when (buffer-live-p buffer) (kill-buffer buffer))))))
(ert-deftest ebox-child-range-sole-child-keeps-public-layout-parent ()
"Keep row/column material parents for one empty or populated Range."
(dolist (constructor '(ebox-row ebox-column))

View File

@ -1254,6 +1254,91 @@ remain retained identities."
(ebox-create :key 'untouched :host-ref 'untouched
:content "untouched")))
(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-create
:key 'selection-panel :width 'stretch :min-width 0 :min-height 24
:flex-grow 2 :flex-shrink 1 :flex-basis '(340)
:ebox-content-node
(ebox-column
(ebox-create :key 'row-1 :host-ref 'row-1 :content row-1)
(ebox-create :key 'row-2 :host-ref 'row-2 :content row-2)))))
(ebox-flex
:key 'fixed-basis-selection-root
:width '(900) :height 24
:flex-flow '(row nowrap) :align-items 'stretch
(ebox-create :key 'peer :content "peer" :width 'stretch
:min-width 0 :min-height 24
:flex-grow 4 :flex-shrink 1 :flex-basis '(620))
panel)))
(defun ebox-commit-test--fixed-basis-selection-candidate
(buffer row-1 row-2)
"Return BUFFER candidate replacing both fixed-basis selection rows."
(let ((candidate (ebox-candidate-begin buffer)))
(ebox-candidate-replace-host-ref
candidate 'row-1
(ebox-create :key 'row-1 :host-ref 'row-1 :content row-1))
(ebox-candidate-replace-host-ref
candidate 'row-2
(ebox-create :key 'row-2 :host-ref 'row-2 :content row-2))
candidate))
(ert-deftest ebox-commit-fixed-basis-selection-round-trip-stays-local ()
"Continuous row-1 -> row-2 -> row-1 publication keeps local TP scope."
(let ((buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-fixed-basis-round-trip* ")
(ebox-commit-test--fixed-basis-selection-root
"[x] row 1" "[ ] row 2"))))
(unwind-protect
(let* ((root-id (ebox--buffer-root-node-id buffer))
(panel-id
(plist-get (ebox--host-ref-node buffer 'row-1) :node-id))
reports)
(setq panel-id
(ebox-incremental--nearest-fixed-basis-flex-item-owner-id
buffer panel-id))
(dolist (contents '(("[ ] row 1" "[x] row 2")
("[x] row 1" "[ ] row 2")))
(let* ((report
(ebox-commit
buffer
(ebox-commit-test--fixed-basis-selection-candidate
buffer (car contents) (cadr contents))))
(surface (with-current-buffer
buffer ebox-surface--buffer-surface))
(tp-report (tp-surface-report surface))
(object-count
(plist-get (tp-surface-inspect surface) :object-count))
(snapshots
(plist-get (ebox--buffer-render-state buffer)
:layout-snapshots))
(panel-snapshot (and snapshots
(gethash panel-id snapshots))))
(push report reports)
(should (eq (plist-get report :projection-kind)
'owner-scoped))
(should-not (member root-id (plist-get report :owner-ids)))
(should-not (plist-get report :tp-full-root))
(should-not (plist-get report :tp-scope-fallback))
(should (< (plist-get tp-report :reconciled-objects)
object-count))
(should (<= (plist-get tp-report :reconciled-objects) 4))
;; Successful publication leaves the fixed-basis owner ready
;; to recapture geometry from the committed TP mounts.
(should panel-snapshot)
(should-not (plist-member panel-snapshot :buffer-spans))
(with-current-buffer buffer
(should
(equal
(buffer-substring-no-properties (point-min) (point-max))
(substring-no-properties
(ebox-render (ebox--buffer-root-node buffer))))))))
(should (= (length reports) 2)))
(when (buffer-live-p buffer) (kill-buffer buffer)))))
(defun ebox-commit-test--mixed-owner-candidate
(buffer left right paint-a paint-b paint-c)
"Return BUFFER candidate replacing all mixed fixture owners."

View File

@ -6698,6 +6698,61 @@
(buffer-substring-no-properties (point-min) (point-max))))
(should (ebox-test--buffer-visually-matches-runtime-render-p))))))
(ert-deftest ebox-flex-grown-fixed-basis-item-content-update-keeps-current-slot ()
"A zero-minimum fixed-basis item may update inside its grown slot."
(ebox-test--reset-runtime-state)
(let ((layout
(ebox-build
'(flex :id root :width (900) :height 24
:flex-flow (row nowrap) :align-items stretch
(box :id list :content "peer" :width stretch :min-width 0
:min-height 24 :flex-grow 4 :flex-shrink 1
:flex-basis (620))
(box :id target :content "detail one" :width stretch :min-width 0
:min-height 24 :flex-grow 2 :flex-shrink 1
:flex-basis (340))))))
(ebox-test--with-rendered-buffer layout
(let* ((match (car (ebox-selector-query-buffer
(current-buffer) "#target")))
(region-id (plist-get match :region-id))
(node-id (plist-get match :node-id))
(root-id (ebox--buffer-root-node-id (current-buffer))))
(ebox-test--region-update region-id :content "detail two")
(let ((report (ebox-test--buffer-update-report)))
(should (eq (plist-get report :strategy) 'span-patch))
(should (equal (plist-get report :owner-ids) (list node-id)))
(should-not (member root-id (plist-get report :owner-ids))))
(should (ebox-test--buffer-visually-matches-runtime-render-p))))))
(ert-deftest ebox-rendered-owned-lines-trims-only-contiguous-outer-chrome ()
"Role projection keeps owned lines and rejects an unowned interior gap."
(let* ((owned-a (propertize "owned-a" 'ebox-content 7))
(owned-b (propertize "owned-b" 'ebox-content 7))
(projected
(ebox-buffer--rendered-owned-lines
(string-join (list "chrome-top" owned-a owned-b "chrome-bottom")
"\n")
'(7))))
(should (equal (substring-no-properties projected)
"owned-a\nowned-b"))
(should-not
(ebox-buffer--rendered-owned-lines
(string-join (list owned-a "foreign-gap" owned-b) "\n") '(7)))
(should-not
(ebox-buffer--rendered-owned-lines
(concat owned-a (propertize "foreign" 'ebox-content 8)) '(7)))
(should
(equal
(substring-no-properties
(ebox-buffer--rendered-owned-lines
(string-join
(list (propertize "border-top" 'ebox-bt 7)
owned-a owned-b
(propertize "border-bottom" 'ebox-bb 7))
"\n")
'(7) 2))
"owned-a\nowned-b"))))
(ert-deftest ebox-flex-auto-size-content-update-refuses-current-slot ()
"Auto-sized content must reflow instead of being forced into its old slot."
(ebox-test--reset-runtime-state)

View File

@ -841,6 +841,14 @@
(ebox-flex-item source :flex-basis '(40)))))
(should-not (ebox--flex-unrendered-measurement source props 'row))))
(ert-deftest ebox-flex-explicit-zero-min-width-allows-no-wrap-shrink ()
"An explicit zero minimum lets a no-wrap card shrink in its Flex line."
(let* ((source
(ebox-create :content "A long no-wrap card title"
:min-width 0 :wrap-mode nil))
(rendered (ebox-render source)))
(should (zerop (ebox--flex-box-min-main source rendered 'row)))))
(ert-deftest ebox-flex-bounded-auto-basis-keeps-intrinsic-measurement ()
"Clipping must not replace an auto-basis item's intrinsic contribution."
(let* ((source

View File

@ -170,6 +170,33 @@
(when (get-buffer " *other-window*")
(kill-buffer " *other-window*")))))
(ert-deftest ebox-window-size-change-publishes-visible-viewport ()
"A live frame resize updates the mounted surface's containing block."
(let* ((buffer (generate-new-buffer " *ebox-window-size-change*"))
(window (selected-window))
(old-buffer (window-buffer window))
calls)
(unwind-protect
(progn
(set-window-buffer window buffer)
(cl-letf (((symbol-function 'ebox--buffer-render-state)
(lambda (_buffer)
'(:viewport-width 100 :viewport-height 10)))
((symbol-function 'ebox-surface-buffer-mounted-p)
(lambda (_buffer) t))
((symbol-function 'ebox-surface--window-content-width)
(lambda (_window) 240))
((symbol-function 'window-body-height)
(lambda (_window) 20))
((symbol-function 'ebox-rerender-buffer-with-context)
(lambda (target width height)
(setq calls (list target width height))))
(noninteractive nil))
(ebox--window-size-change (selected-frame))
(should (equal calls (list buffer 240 20)))))
(set-window-buffer window old-buffer)
(kill-buffer buffer))))
(defun ebox-surface-test--hash-fingerprint (table)
"Return a stable content fingerprint for hash TABLE.
The fingerprint checks entries rather than only table identity, so a failed
@ -1227,6 +1254,27 @@ candidate cannot hide mutations by restoring the old hash-table pointer."
(should-not (equal (key-at nil t) (key-at 36 t)))
(should-not (equal (key-at nil :unknown) (key-at 36 :unknown))))))
(ert-deftest ebox-flex-fragment-key-includes-rendered-region-ownership ()
"Retained Flex output must not replay stale region text properties."
(let* ((ebox--layout-fragments-table (make-hash-table :test 'equal))
(source (ebox-create :content "stable" :width '(80)))
(first-key
(ebox-fragment-flex-allocation-key
source 'row 80 1 'stretch 80
'(:viewport-height-dependent nil)))
(candidate (copy-tree source)))
;; Candidate reconciliation can preserve a source node id while replacing
;; one generated box region. The cached string embeds that region in its
;; ownership properties, so node identity and geometry alone are unsafe.
(plist-put candidate :region-id nil)
(should (= (plist-get source :node-id)
(plist-get candidate :node-id)))
(should-not
(equal first-key
(ebox-fragment-flex-allocation-key
candidate 'row 80 1 'stretch 80
'(:viewport-height-dependent nil))))))
(ert-deftest ebox-flex-fragment-retention-evicts-one-entry-at-capacity ()
"Fragment retention capacity must evict one old entry, not clear the table."
(let ((ebox--layout-fragments-table (make-hash-table :test 'equal))