diff --git a/ebox-buffer-backend.el b/ebox-buffer-backend.el index 7d32174..26b6f9e 100644 --- a/ebox-buffer-backend.el +++ b/ebox-buffer-backend.el @@ -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 diff --git a/ebox-flex.el b/ebox-flex.el index 94c242c..e42bef5 100644 --- a/ebox-flex.el +++ b/ebox-flex.el @@ -390,8 +390,15 @@ grapheme." box (ebox-get box :min-width) 0) 0))) (if (null (ebox-get box :wrap-mode)) - (max (ebox--flex-string-main rendered axis) - (+ (ebox--side-pixel box) declared-min)) + ;; 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))) (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) diff --git a/ebox-fragment.el b/ebox-fragment.el index 3203d75..00f05d6 100644 --- a/ebox-fragment.el +++ b/ebox-fragment.el @@ -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 diff --git a/ebox-incremental.el b/ebox-incremental.el index 1f9148f..f867d57 100644 --- a/ebox-incremental.el +++ b/ebox-incremental.el @@ -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))) - (and changed-keys - (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)) - (ebox--flex-content-allocation-stable-p - buffer style-node (ebox--flex-item-props child) - axis current-main))))) + (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 fixed-basis-p + (not (cl-some #'ebox--flex-content-flow-main-key-p + changed-keys)) + (ebox--flex-content-allocation-stable-p + 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 - (with-current-buffer buffer - (ebox-buffer--rendered-in-existing-slots spans rendered))) - (final-rendered (or slot-rendered rendered)) + (and owned-rendered + (with-current-buffer buffer + (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 - (ebox--external-footprint-signature-from-span-footprint - new-span-footprint)) + (and new-span-footprint + (ebox--external-footprint-signature-from-span-footprint + new-span-footprint))) (new-parent-slot - (ebox--project-parent-slot-signature - old-parent-slot new-span-footprint))) - (and (ebox--span-footprint-compatible-p + (and new-span-footprint + (ebox--project-parent-slot-signature + 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 - (cl-remove-if-not - (lambda (entry) (eq (plist-get entry :dirty-kind) 'geometry)) - dirty-set)) + (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))) (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 - buffer old-state prepared candidate-state owner-plan path-copied-p) + (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) diff --git a/ebox-surface.el b/ebox-surface.el index 339d6f9..4bd4df0 100644 --- a/ebox-surface.el +++ b/ebox-surface.el @@ -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 allocated-width - (plist-put (copy-sequence node) :width allocated-width) - node))) - (when (and old-snapshot spans 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 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 - (ebox-surface--span-patch-lines - buffer spans rendered variable-content-p - variable-content-max-width)) + (and owned-rendered + (ebox-surface--span-patch-lines + 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 diff --git a/ebox.el b/ebox.el index acbd3c6..5fc8dfd 100644 --- a/ebox.el +++ b/ebox.el @@ -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. diff --git a/tests/ebox-child-range-tests.el b/tests/ebox-child-range-tests.el index 62b0f19..26d2d69 100644 --- a/tests/ebox-child-range-tests.el +++ b/tests/ebox-child-range-tests.el @@ -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)) diff --git a/tests/ebox-commit-tests.el b/tests/ebox-commit-tests.el index ce4ad40..6df633a 100644 --- a/tests/ebox-commit-tests.el +++ b/tests/ebox-commit-tests.el @@ -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." diff --git a/tests/ebox-core-render-tests.el b/tests/ebox-core-render-tests.el index 697a8c0..87907f0 100644 --- a/tests/ebox-core-render-tests.el +++ b/tests/ebox-core-render-tests.el @@ -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) diff --git a/tests/ebox-flex-tests.el b/tests/ebox-flex-tests.el index 4fab863..98c7f3f 100644 --- a/tests/ebox-flex-tests.el +++ b/tests/ebox-flex-tests.el @@ -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 diff --git a/tests/ebox-surface-tests.el b/tests/ebox-surface-tests.el index 8592d10..6231964 100644 --- a/tests/ebox-surface-tests.el +++ b/tests/ebox-surface-tests.el @@ -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))