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)) "ebox" (lines))
(declare-function ebox-string-lines (declare-function ebox-string-lines
"ebox" (string)) "ebox" (string))
(defvar ebox-region-types)
(defvar ebox-render-gc-cons-threshold (* 128 1024 1024) (defvar ebox-render-gc-cons-threshold (* 128 1024 1024)
"Temporary `gc-cons-threshold' used during Ebox render transactions. "Temporary `gc-cons-threshold' used during Ebox render transactions.
@ -501,6 +502,81 @@ may be added in place."
;;; Existing Slot Shaping ;;; 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) (defun ebox-buffer--span-slot-width (span)
"Return SPAN's visual slot width in its current buffer line." "Return SPAN's visual slot width in its current buffer line."
(save-excursion (save-excursion

View File

@ -390,8 +390,15 @@ grapheme."
box (ebox-get box :min-width) 0) box (ebox-get box :min-width) 0)
0))) 0)))
(if (null (ebox-get box :wrap-mode)) (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) (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))) (let ((content-min (ebox--content-min-pixel box)))
(when (hash-table-p ebox--flex-content-min-width-table) (when (hash-table-p ebox--flex-content-min-width-table)
(puthash box content-min (puthash box content-min
@ -404,6 +411,11 @@ grapheme."
box (ebox-get box :min-height)) box (ebox-get box :min-height))
0))))) 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) (defun ebox--flex-box-main-constraint (box axis property)
"Return BOX's total main-axis size constraint from PROPERTY." "Return BOX's total main-axis size constraint from PROPERTY."
(when (plist-member box property) (when (plist-member box property)

View File

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

View File

@ -2911,6 +2911,36 @@ its new minimum must still fit CURRENT-MAIN."
(<= (ebox--flex-min-main style-node rendered axis) (<= (ebox--flex-min-main style-node rendered axis)
current-main)))) 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 (defun ebox--flex-slot-allocation-stable-p
(buffer node child axis current-main changed-keys) (buffer node child axis current-main changed-keys)
"Return non-nil when CHANGED-KEYS preserve NODE's flex allocation. "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 updates unless the auto flex basis stays fixed and the new minimum still fits
the already published slot." the already published slot."
(when-let ((style-node (ebox-fragment-style-source-node node))) (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 (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 (cl-every
(lambda (key) (lambda (key)
(and (not (ebox--flex-main-footprint-key-p (and (not (ebox--flex-main-footprint-key-p
key axis style-node)) key axis style-node))
(not (ebox--flex-main-footprint-key-p (not (ebox--flex-main-footprint-key-p
key (ebox--flex-cross-axis axis) style-node)))) key (ebox--flex-cross-axis axis) style-node))))
changed-keys) changed-keys))
(or (not (cl-some #'ebox--flex-content-flow-main-key-p (or fixed-basis-p
(not (cl-some #'ebox--flex-content-flow-main-key-p
changed-keys)) changed-keys))
(ebox--flex-content-allocation-stable-p (ebox--flex-content-allocation-stable-p
buffer style-node (ebox--flex-item-props child) buffer style-node item-props axis current-main))))))
axis current-main)))))
(defun ebox--flex-item-slot-sized-node (defun ebox--flex-item-slot-sized-node
(buffer node-id snapshot &optional changed-keys) (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 (or (ebox--render-node-in-current-flex-slot
buffer node-id snapshot changed-keys) buffer node-id snapshot changed-keys)
(ebox-render node))) (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 (slot-rendered
(and owned-rendered
(with-current-buffer buffer (with-current-buffer buffer
(ebox-buffer--rendered-in-existing-slots spans rendered))) (ebox-buffer--rendered-in-existing-slots
(final-rendered (or slot-rendered rendered)) spans owned-rendered
(and role-owned-lines-p owner-set)))))
(final-rendered slot-rendered)
(new-span-footprint (new-span-footprint
(ebox--rendered-span-footprint-signature final-rendered)) (and final-rendered
(ebox--rendered-span-footprint-signature final-rendered)))
(new-external-footprint (new-external-footprint
(and new-span-footprint
(ebox--external-footprint-signature-from-span-footprint (ebox--external-footprint-signature-from-span-footprint
new-span-footprint)) new-span-footprint)))
(new-parent-slot (new-parent-slot
(and new-span-footprint
(ebox--project-parent-slot-signature (ebox--project-parent-slot-signature
old-parent-slot new-span-footprint))) old-parent-slot new-span-footprint))))
(and (ebox--span-footprint-compatible-p (and final-rendered
(ebox--span-footprint-compatible-p
old-span-footprint new-span-footprint) old-span-footprint new-span-footprint)
(ebox--external-footprint-compatible-p (ebox--external-footprint-compatible-p
old-external-footprint new-external-footprint) 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. ;; size remains allocation-stable even when its own content changes.
;; The following slot-footprint proof still renders the candidate ;; The following slot-footprint proof still renders the candidate
;; in that size and rejects wrapping/overflow/parent-slot misses. ;; 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 (ebox--cached-flex-item-slot-footprint-safe-p
buffer node-id changed-keys))) buffer node-id changed-keys)))
@ -4634,6 +4693,14 @@ DETACHED-HISTORY receives identity snapshots for semantic replacements."
(aref old-payload offset))))) (aref old-payload offset)))))
(when (and old key (null (plist-get old :key))) (setq old nil)) (when (and old key (null (plist-get old :key))) (setq old nil))
(when (and old (null key) (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))) (ebox-tree-reconcile-runtime old item)))
items)) items))
@ -4694,14 +4761,14 @@ DETACHED-HISTORY receives identity snapshots for semantic replacements."
table))))) table)))))
(defun ebox-incremental--candidate-path-copy-overlay (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." "Path-copy NODE-ID in ROOT using STATE tables plus the candidate trace."
(let* ((parent-table (plist-get state :parent-table)) (let* ((parent-table (plist-get state :parent-table))
(old-child (ebox-incremental--candidate-current-node state node-id)) (old-child (ebox-incremental--candidate-current-node state node-id))
(new-child replacement) (new-child replacement)
(parent-id (gethash node-id parent-table))) (parent-id (gethash node-id parent-table)))
(ebox-incremental--candidate-record-path-copy (ebox-incremental--candidate-record-path-copy
node-id replacement parent-id t t) node-id replacement parent-id t structure-p)
(while parent-id (while parent-id
(let* ((current-parent-id parent-id) (let* ((current-parent-id parent-id)
(old-parent (old-parent
@ -4721,7 +4788,7 @@ DETACHED-HISTORY receives identity snapshots for semantic replacements."
old-parent (list (cons old-child new-child))))) old-parent (list (cons old-child new-child)))))
(grandparent-id (gethash current-parent-id parent-table))) (grandparent-id (gethash current-parent-id parent-table)))
(ebox-incremental--candidate-record-path-copy (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 (setq old-child old-parent new-child new-parent
parent-id grandparent-id))) parent-id grandparent-id)))
(if (equal node-id (plist-get root :node-id)) replacement new-child))) (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 (ebox-child-range--lookup-ref
(car result) (car replacement))) nil)) (car result) (car replacement))) nil))
ordered-replacements)) 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))) (new-parent (copy-sequence parent)))
(plist-put new-parent :ebox-child-sequence (car result)) (plist-put new-parent :ebox-child-sequence (car result))
(push (ebox-child-range--metrics-plist (cdr result)) metrics) (push (ebox-child-range--metrics-plist (cdr result)) metrics)
(setq root (setq root
(ebox-incremental--candidate-path-copy-overlay (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 (let ((trace (gethash parent-id
ebox-incremental--candidate-path-copy-trace))) ebox-incremental--candidate-path-copy-trace)))
(plist-put trace :anchor-p nil)) (plist-put trace :anchor-p nil))
(push (list :old-parent base-parent :new-parent new-parent (push (list :old-parent base-parent :new-parent new-parent
:parent-id parent-id :parent-id parent-id
:children-changed children-changed
:old-payloads old-payloads :old-payloads old-payloads
:new-payloads new-payloads) :new-payloads new-payloads)
ebox-incremental--candidate-range-index-deltas)))))))) ebox-incremental--candidate-range-index-deltas))))))))
@ -5297,7 +5377,8 @@ complete preorder first-owner and postorder last-box semantics."
entry)) entry))
(when old-node (when old-node
(let* ((changed-keys (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 (range-delta
(and range-delta-table (and range-delta-table
(gethash (plist-get new-node :node-id) (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 (record-touched old-parent new-parent
(gethash parent-id (gethash parent-id
(plist-get old-state :parent-table)) (plist-get old-state :parent-table))
t) (plist-get delta :children-changed))
(dolist (payload (plist-get delta :old-payloads)) (dolist (payload (plist-get delta :old-payloads))
(dolist (node payload) (remove-old-subtree node))) (dolist (node payload) (remove-old-subtree node)))
(dolist (payload (plist-get delta :new-payloads)) (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) (visit candidate-root)
nil))))) 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 (defun ebox-incremental--declarative-dirty-set
(old-state candidate-root candidate-index) (old-state candidate-root candidate-index)
"Return source-level dirty entries from OLD-STATE to CANDIDATE-ROOT. "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." suppresses the ordinary fallback when any fact is missing."
(let* ((dirty-set (plist-get prepared :dirty-set)) (let* ((dirty-set (plist-get prepared :dirty-set))
(geometry-dirty (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 (cl-remove-if-not
(lambda (entry) (eq (plist-get entry :dirty-kind) 'geometry)) (lambda (entry) (eq (plist-get entry :dirty-kind) 'geometry))
dirty-set)) dirty-set)))
(paint-dirty (paint-dirty
(cl-remove-if-not (cl-remove-if-not
(lambda (entry) (eq (plist-get entry :dirty-kind) 'paint)) (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 (geometry-span-proof
(copy-sequence (plist-get geometry-span-proof :owner-ids))))) (copy-sequence (plist-get geometry-span-proof :owner-ids)))))
(geometry-owner-id (car geometry-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 (paint-owner-ids
(delete-dups (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)) (old-nodes (plist-get old-state :node-table))
(new-nodes (plist-get candidate-state :node-table)) (new-nodes (plist-get candidate-state :node-table))
(root-id (ebox--buffer-root-node-id buffer)) (root-id (ebox--buffer-root-node-id buffer))
(paint-valid-p (paint-valid-p
(and paint-dirty (and external-paint-dirty
(cl-every (cl-every
(lambda (entry) (lambda (entry)
(and (plist-get entry :node-id) (and (plist-get entry :node-id)
@ -6469,7 +6589,7 @@ suppresses the ordinary fallback when any fact is missing."
(plist-get (plist-get
(plist-get entry :new-signature) (plist-get entry :new-signature)
:content)))))) :content))))))
paint-dirty))) external-paint-dirty)))
(disjoint-p (disjoint-p
(and geometry-owner-ids (and geometry-owner-ids
(not (cl-some (lambda (geometry-id) (not (cl-some (lambda (geometry-id)
@ -6598,6 +6718,54 @@ suppresses the ordinary fallback when any fact is missing."
'owner-rerender owner-id 'owner-rerender owner-id
:dirty (ebox--merge-dirty-provenance-list render-dirty-set)))))) :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 (defun ebox-incremental--dirty-set-has-child-splice-p
(buffer render-dirty-set) (buffer render-dirty-set)
"Return non-nil when RENDER-DIRTY-SET contains a provable child splice." "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)) buffer render-dirty-set))
(or (ebox-incremental--broad-dirty-op (or (ebox-incremental--broad-dirty-op
buffer render-dirty-set) buffer render-dirty-set)
(ebox-incremental--fixed-basis-flex-local-op
buffer render-dirty-set)
(ebox-incremental--local-reflow-op (ebox-incremental--local-reflow-op
buffer render-dirty-set))))) buffer render-dirty-set)))))
(setq ops (list coalesced-op)) (setq ops (list coalesced-op))
@ -7603,13 +7773,120 @@ metadata; this predicate only authorizes the local attempt."
(setq valid nil))))) (setq valid nil)))))
valid)) 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 (defun ebox-incremental--span-patch-projection-proof
(buffer old-state prepared candidate-state owner-plan (buffer old-state prepared candidate-state owner-plan
&optional path-copied-p) &optional path-copied-p)
"Return one atomic single- or multi-owner projection proof, or nil." "Return one atomic single- or multi-owner projection proof, or nil."
(if (= (length owner-plan) 1) (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) 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) (let (proofs valid)
(setq valid (and owner-plan (setq valid (and owner-plan
(= (length owner-plan) (= (length owner-plan)

View File

@ -43,6 +43,7 @@
(defvar ebox-viewport-height) (defvar ebox-viewport-height)
(defvar ebox--render-display-signature) (defvar ebox--render-display-signature)
(defvar ebox-incremental--allocated-slot-proof-cache) (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--render-layout "ebox-layout" (node))
(declare-function ebox--record-render-output-provenance (declare-function ebox--record-render-output-provenance
@ -57,6 +58,13 @@
"ebox-incremental" (target keys source)) "ebox-incremental" (target keys source))
(declare-function ebox--runtime-region-id-conflict (declare-function ebox--runtime-region-id-conflict
"ebox-incremental" (region-id-set target-buffer)) "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 (declare-function ebox--viewport-dependent-node-id-axes
"ebox-incremental" (node)) "ebox-incremental" (node))
(declare-function ebox--update-report (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 :surface surface)
(plist-put new-state :runtime-revision (plist-put new-state :runtime-revision
(1- (tp-surface-revision surface))) (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) (when (plist-get report-base :scroll-state-transaction)
;; Scroll changes invalidate every buffer-span coordinate. Keep ;; Scroll changes invalidate every buffer-span coordinate. Keep
;; the committed generation structurally snapshot-free so a later ;; 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) (string< (symbol-name left)
(symbol-name right)))))))) (symbol-name right))))))))
(defun ebox-surface--span-patch-lines (buffer spans rendered (defun ebox-surface--span-patch-lines
&optional variable-content-p (buffer spans rendered &optional variable-content-p
variable-content-max-width) variable-content-max-width owner-set)
"Return RENDERED shaped for BUFFER SPANS, or nil when unsafe. "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 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." 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)) variable-content-max-width))
lines))) lines)))
rendered)) 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))) (and (= (length spans) (length (ebox-string-lines rendered)))
rendered)))) rendered))))
@ -1756,7 +1778,8 @@ old allocated width while the owner changes its natural content length."
(defun ebox-surface--owner-patch-candidate (defun ebox-surface--owner-patch-candidate
(buffer state owner-id allocated-width (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." "Return validated patch details for OWNER-ID, or nil."
(let* ( (let* (
(old-snapshot (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))) (spans (and old-snapshot (plist-get old-snapshot :buffer-spans)))
(node (and owner-id (gethash owner-id (plist-get state :node-table)))) (node (and owner-id (gethash owner-id (plist-get state :node-table))))
(render-node (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 (if allocated-width
(plist-put (copy-sequence node) :width allocated-width) (plist-put (copy-sequence node) :width allocated-width)
node))) node))))
(when (and old-snapshot spans node) (when (and old-snapshot spans node render-node)
(let* ((rendered (let* ((rendered
(ebox-surface--render-candidate-node state render-node)) (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 (replacement
(and owned-rendered
(ebox-surface--span-patch-lines (ebox-surface--span-patch-lines
buffer spans rendered variable-content-p buffer spans owned-rendered variable-content-p
variable-content-max-width)) variable-content-max-width
(and role-owned-lines-p
(ebox--region-id-set
(plist-get old-snapshot :region-ids))))))
(details (details
(and replacement (and replacement
(ebox-surface--span-patch-details (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) buffer state (plist-get proof :owner-id)
(plist-get proof :allocated-width) (plist-get proof :allocated-width)
(plist-get proof :variable-content-p) (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) proofs)
(list (list
(ebox-surface--owner-patch-candidate (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) (when (boundp 'window-state-change-functions)
(add-hook 'window-state-change-functions #'ebox--window-state-change)) (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 (defun ebox--runtime-prewarm-record-viewport-node
(job node width-allowed) (job node width-allowed)
"Record NODE's direct viewport dependencies in JOB. "Record NODE's direct viewport dependencies in JOB.

View File

@ -481,6 +481,109 @@
:range-ref-table)))) :range-ref-table))))
(when (buffer-live-p buffer) (kill-buffer buffer))))) (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 () (ert-deftest ebox-child-range-sole-child-keeps-public-layout-parent ()
"Keep row/column material parents for one empty or populated Range." "Keep row/column material parents for one empty or populated Range."
(dolist (constructor '(ebox-row ebox-column)) (dolist (constructor '(ebox-row ebox-column))

View File

@ -1254,6 +1254,91 @@ remain retained identities."
(ebox-create :key 'untouched :host-ref 'untouched (ebox-create :key 'untouched :host-ref 'untouched
:content "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 (defun ebox-commit-test--mixed-owner-candidate
(buffer left right paint-a paint-b paint-c) (buffer left right paint-a paint-b paint-c)
"Return BUFFER candidate replacing all mixed fixture owners." "Return BUFFER candidate replacing all mixed fixture owners."

View File

@ -6698,6 +6698,61 @@
(buffer-substring-no-properties (point-min) (point-max)))) (buffer-substring-no-properties (point-min) (point-max))))
(should (ebox-test--buffer-visually-matches-runtime-render-p)))))) (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 () (ert-deftest ebox-flex-auto-size-content-update-refuses-current-slot ()
"Auto-sized content must reflow instead of being forced into its old slot." "Auto-sized content must reflow instead of being forced into its old slot."
(ebox-test--reset-runtime-state) (ebox-test--reset-runtime-state)

View File

@ -841,6 +841,14 @@
(ebox-flex-item source :flex-basis '(40))))) (ebox-flex-item source :flex-basis '(40)))))
(should-not (ebox--flex-unrendered-measurement source props 'row)))) (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 () (ert-deftest ebox-flex-bounded-auto-basis-keeps-intrinsic-measurement ()
"Clipping must not replace an auto-basis item's intrinsic contribution." "Clipping must not replace an auto-basis item's intrinsic contribution."
(let* ((source (let* ((source

View File

@ -170,6 +170,33 @@
(when (get-buffer " *other-window*") (when (get-buffer " *other-window*")
(kill-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) (defun ebox-surface-test--hash-fingerprint (table)
"Return a stable content fingerprint for hash TABLE. "Return a stable content fingerprint for hash TABLE.
The fingerprint checks entries rather than only table identity, so a failed 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 t) (key-at 36 t)))
(should-not (equal (key-at nil :unknown) (key-at 36 :unknown)))))) (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 () (ert-deftest ebox-flex-fragment-retention-evicts-one-entry-at-capacity ()
"Fragment retention capacity must evict one old entry, not clear the table." "Fragment retention capacity must evict one old entry, not clear the table."
(let ((ebox--layout-fragments-table (make-hash-table :test 'equal)) (let ((ebox--layout-fragments-table (make-hash-table :test 'equal))