Optimize incremental overlay layer recomposition
Some checks are pending
CI / test (29.1) (push) Waiting to run
CI / test (30.2) (push) Waiting to run
CI / native-build (macos-latest) (push) Waiting to run
CI / native-build (ubuntu-latest) (push) Waiting to run
CI / native-build (windows-latest) (push) Waiting to run
CI / native-msrv (macos-latest) (push) Waiting to run
CI / native-msrv (ubuntu-latest) (push) Waiting to run
CI / native-msrv (windows-latest) (push) Waiting to run

This commit is contained in:
Kinneyzhang 2026-09-11 13:31:17 +08:00
parent 4d0d46d5be
commit 01b1a86bb3
7 changed files with 549 additions and 25 deletions

View File

@ -10452,7 +10452,14 @@ role, and outside-complement compatibility before publication."
(plist-get candidate-state :runtime-revision)
:display-signature (plist-get prepared :display-signature)
:source-index (plist-get candidate-state :source-index)
:scroll-state-table (plist-get prepared :scroll-state-table))
:scroll-state-table (plist-get prepared :scroll-state-table)
:previous-layer-composition-cache
(plist-get old-state :layer-composition-cache)
:layer-composition-cache (make-hash-table :test 'equal)
:layer-composition-context
(list (plist-get candidate-state :viewport-width)
(plist-get candidate-state :viewport-height)
(plist-get prepared :display-signature)))
(ebox-incremental--native-runtime-overrides candidate-state)
(list :source-path-copied-p source-path-copied-p
:runtime-index-prepared-p
@ -11279,6 +11286,13 @@ When RETAIN-NATIVE-P is non-nil, preserve its committed native frame bases."
:layout-fragments-reuse-p
(plist-get state :layout-fragments-reuse-p)
:render-signature-cache (plist-get state :render-signature-cache)
:previous-layer-composition-cache
(plist-get state :layer-composition-cache)
:layer-composition-cache (make-hash-table :test 'equal)
:layer-composition-context
(list (plist-get state :viewport-width)
(plist-get state :viewport-height)
(plist-get state :display-signature))
:flex-content-min-widths (plist-get state :flex-content-min-widths)
:viewport-height-dependent-subtree-cache
(plist-get state :viewport-height-dependent-subtree-cache)

View File

@ -44,6 +44,27 @@ Keys are source strings by identity. Never retained between renders.")
Retained publication supplies a known boolean; isolated rendering resolves
the fact once for its spatial root.")
(defvar ebox-layer--previous-composition-cache nil
"Read-only retained host composition ledger for the current candidate.
The ledger is an opaque hash table owned by the Surface runtime. Entries are
keyed by host identity and composition phase and contain immutable row
signatures plus their already composed rows. A missing ledger disables reuse.")
(defvar ebox-layer--composition-cache nil
"Candidate-local host composition ledger populated during rendering.
Callers should bind this to a fresh hash table for one candidate; this module
never mutates `ebox-layer--previous-composition-cache'. Use an `equal' hash
table because ledger keys are freshly allocated HOST/PHASE cons cells.")
(defvar ebox-layer--composition-context nil
"Immutable context token for host composition reuse.
The Surface runtime should include every display and allocation fact that can
change pixel measurement. Reuse is disabled when this token is nil.")
(defvar ebox-layer--composition-phase 'final
"Composition phase used as the default host ledger key.
Anchor prepasses and final paints must use distinct phase values.")
(defun ebox-layer-active-p (&optional node)
"Return the current candidate's layer capability without repeated scans."
(if (eq ebox-layer--active-p 'unknown)
@ -214,15 +235,82 @@ Nested scroll changes and source updates cannot use this root-only proof."
(not (ebox-layer-positioned-p an)))
(t (< (gethash an order 0) (gethash bn order 0)))))))))
(defun ebox-layer--compose-rows (host records width height)
"Compose HOST's visible RECORDS in a WIDTH by HEIGHT content canvas."
(let ((rows (make-vector height nil)))
(dolist (record (ebox-layer--ordered-records host records))
(defun ebox-layer--stable-node-id (node)
"Return NODE's retained identity, or nil when it cannot be proven stable."
(or (plist-get node :node-id)
(and ebox--render-source-index
(ebox-tree-node-id ebox--render-source-index node))))
(defun ebox-layer--record-signature (record)
"Return immutable comparison facts for one paint RECORD.
Do not retain NODE itself: copied candidate nodes can have mutable transient
attachments. The sliced text is compared with its complete text properties,
which captures display geometry, paint and interaction values."
(let ((node (plist-get record :node)))
(list :node-id (ebox-layer--stable-node-id node)
:x (plist-get record :x)
:y (plist-get record :y)
:z-index (or (plist-get node :z-index) 0)
:positioned (and (ebox-layer-positioned-p node) t)
:text (plist-get record :text))))
(defun ebox-layer--compose-cache-key (host phase)
"Return the retained-ledger key for HOST and composition PHASE.
Use the retained node id so copied candidate nodes can reuse their prior
host's rows; an unidentifiable host disables the cache."
(when-let* ((id (ebox-layer--stable-node-id host)))
(cons id phase)))
(defun ebox-layer--compose-rows (host records width height &optional phase)
"Compose HOST's visible RECORDS in a WIDTH by HEIGHT content canvas.
When the caller binds the retained and candidate ledgers and an immutable
composition context, rows whose ordered record signatures are unchanged are
returned directly without calling `ebox-composite-line'. PHASE separates
anchor prepasses from final composition so neither can reuse the other."
(let* ((phase (or phase ebox-layer--composition-phase))
(ordered (ebox-layer--ordered-records host records))
(rows (make-vector height nil))
(signatures (make-vector height nil))
(key (ebox-layer--compose-cache-key host phase))
(previous (and ebox-layer--composition-context
(hash-table-p ebox-layer--previous-composition-cache)
(gethash key ebox-layer--previous-composition-cache)))
(old-context (and previous (plist-get previous :context)))
(old-signatures (and previous (plist-get previous :signatures)))
(old-rows (and previous (plist-get previous :rows)))
(cacheable-p (and key ebox-layer--composition-context
(hash-table-p ebox-layer--composition-cache))))
(dolist (record ordered)
(let ((row (plist-get record :y)))
(when (and (>= row 0) (< row height))
(when (and (integerp row) (>= row 0) (< row height))
(push record (aref rows row)))))
(cl-loop for row across rows collect
(ebox-composite-line "" (nreverse row) width))))
(dotimes (row height)
(let* ((placements (nreverse (aref rows row)))
(signature (mapcar #'ebox-layer--record-signature placements))
(reusable-p
(and cacheable-p previous
(equal old-context ebox-layer--composition-context)
(= width (plist-get previous :width))
(= height (plist-get previous :height))
(vectorp old-signatures) (vectorp old-rows)
(= (length old-rows) height)
(cl-every (lambda (entry)
(plist-get entry :node-id))
signature)
(= (length old-signatures) height)
(equal-including-properties
signature (aref old-signatures row)))))
(aset signatures row signature)
(aset rows row
(if reusable-p
(aref old-rows row)
(ebox-composite-line "" placements width)))))
(when cacheable-p
(puthash key (list :context ebox-layer--composition-context
:width width :height height
:signatures signatures :rows rows)
ebox-layer--composition-cache))
(append rows nil)))
(defun ebox-layer--anchor-node (host id)
"Find one logical anchor ID under HOST, rejecting ambiguous IDs."
@ -393,7 +481,8 @@ Only candidate-local lists of visible paint records are sorted."
;; Anchor geometry follows relative movement and visible clipping,
;; not the original flow slot. This extra composition is needed
;; only by anchored panels.
(setq lines (ebox-layer--compose-rows host records width height)))
(setq lines (ebox-layer--compose-rows host records width height
'anchor-prepass)))
(setq records (nconc (ebox-layer--absolute-records host absolute lines width height)
records))
(ebox--record-rendered-intrinsic-size

View File

@ -319,6 +319,12 @@ rather than relying on an implicit default.")
(:symbol ebox-layer--piece-width-cache
:reason dynamically-bound-composition-index
:evidence let-bound-per-layer-host-and-never-retained-between-renders)
(:symbol ebox-layer--composition-cache
:reason dynamically-bound-candidate-composition-ledger
:evidence candidate-local hash table is published through the render state plist)
(:symbol ebox-layer--previous-composition-cache
:reason dynamically-bound-committed-composition-ledger
:evidence read-only prior ledger supplied by committed render state and discarded on replacement)
(:symbol ebox--string-pixel-width-cache-ring-index
:reason numeric-eviction-cursor
:evidence value-is-an-integer-ring-position)

View File

@ -44,6 +44,9 @@
(defvar ebox--render-root-parent-kind)
(defvar ebox--render-cache-table)
(defvar ebox--render-cache-signature-cache)
(defvar ebox-layer--previous-composition-cache)
(defvar ebox-layer--composition-cache)
(defvar ebox-layer--composition-context)
(defvar ebox--layout-fragments-table)
(defvar ebox--layout-fragments-reuse-p)
(defvar ebox--flex-content-min-width-table)
@ -1313,7 +1316,15 @@ FRAMEWORK-PARTICIPANT owns paired framework publication when non-nil."
(with-current-buffer buffer
(setq-local ebox-surface--buffer-surface surface))
(if new-state
(puthash buffer new-state ebox--buffer-render-state-table)
(progn
;; Preserve the committed layer ledger for the next candidate.
;; The renderer owns the fresh candidate cache; this fallback
;; keeps it alive across publication paths that do not render.
(when (and old-mirror
(null (plist-get new-state :layer-composition-cache)))
(plist-put new-state :layer-composition-cache
(plist-get old-mirror :layer-composition-cache)))
(puthash buffer new-state ebox--buffer-render-state-table))
(remhash buffer ebox--buffer-render-state-table))
(ebox-incremental--replace-hash-entries
ebox--region-box-table region-keys
@ -1715,7 +1726,14 @@ PREVIOUS-STATE's node-object table."
:style-binding-states style-state-table
:stylesheet-signature stylesheet-signature
:selector-tree-snapshot selector-tree-snapshot
:selector-tree-token selector-tree-token)))
:selector-tree-token selector-tree-token
:previous-layer-composition-cache
(plist-get previous-state :layer-composition-cache)
:layer-composition-cache (make-hash-table :test 'equal)
:layer-composition-context
(list (plist-get state-overrides :viewport-width)
(plist-get state-overrides :viewport-height)
(plist-get state-overrides :display-signature)))))
(defun ebox-surface--project-stable-node-table
(context nodes node-root old-objects objects-by-node)
@ -3306,8 +3324,9 @@ available during planning; replacements here belong only to the candidate."
(ebox-surface--refresh-rendered-layout-snapshots state)
rendered))
(defun ebox-surface--render-candidate-node (state node)
"Render NODE from candidate STATE in isolated Ebox side tables."
(defun ebox-surface--render-candidate-node (state node &optional complete-lines-p)
"Render NODE from candidate STATE in isolated Ebox side tables.
COMPLETE-LINES-P requires a proven physical line origin for every output row."
(let ((scroll-table
(or (plist-get state :scroll-state-table)
(make-hash-table :test 'equal)))
@ -3351,6 +3370,18 @@ available during planning; replacements here belong only to the candidate."
(plist-get state :layout-fragments-reuse-p))
(ebox--render-cache-signature-cache
(plist-get state :render-signature-cache))
;; Layer composition is a candidate-local ledger. Previous rows
;; are read-only; the fresh table is retained only after commit.
(ebox-layer--previous-composition-cache
(plist-get state :previous-layer-composition-cache))
(ebox-layer--composition-cache
(or (plist-get state :layer-composition-cache)
(make-hash-table :test 'equal)))
(ebox-layer--composition-context
(or (plist-get state :layer-composition-context)
(list (plist-get state :viewport-width)
(plist-get state :viewport-height)
(plist-get state :display-signature))))
(ebox--scroll-window-initial-lookahead-lines-override
(ebox-surface--retained-scroll-lookahead state))
(ebox--viewport-dependent-node-ids-cache
@ -3374,6 +3405,12 @@ available during planning; replacements here belong only to the candidate."
(ebox-native-commit-render state node))
(let ((ebox--surface-materialization-active t))
(ebox--render-layout node)))))
(when (and complete-lines-p
(ebox--fractional-pixel-spaces-p rendered))
;; Untouched buffer rows are already quantized. Their raw
;; fractional evidence cannot be disproved by this local render.
(plist-put state :fractional-pixel-output-p t)
(setq rendered (ebox--quantize-pixel-spaces rendered)))
(ebox-surface--check-local-pixel-output rendered)
(unless (plist-get state :native-render-p)
(ebox--record-render-output-provenance rendered))
@ -3385,6 +3422,8 @@ available during planning; replacements here belong only to the candidate."
(ebox-surface--hash-keys scroll-table))
(plist-put state :render-owned-text-values
ebox--render-owned-text-values)
(plist-put state :layer-composition-cache
ebox-layer--composition-cache)
rendered)))))
(defun ebox-surface--attach-retained-node-objects (state)
@ -3499,6 +3538,24 @@ old allocated width while the owner changes its natural content length."
(push (substring source (- cursor origin)) pieces)
(apply #'concat (nreverse pieces)))))
(defun ebox-surface--changed-span-pairs (source origin pairs)
"Return only changed SOURCE/replacement PAIRS.
PAIRS contains buffer-position spans paired with candidate replacement lines.
Compare both characters and text properties, so a row whose composed pixels
and ownership are unchanged is omitted from the edit set. Keeping unchanged
rows out of the transaction is the damage-region optimization for layer moves:
the candidate may still be rendered for proof, but publication touches only
the old/new affected rows."
(cl-remove-if
(lambda (pair)
(let* ((span (car pair))
(line (cdr pair))
(start (- (car span) origin))
(end (- (cdr span) origin)))
(and (<= 0 start end (length source))
(equal-including-properties (substring source start end) line))))
pairs))
(defun ebox-surface--span-patch-details
(_buffer snapshot node spans rendered
&optional variable-content-p variable-content-max-width)
@ -4260,7 +4317,8 @@ Render STATE's candidate into BUFFER's retained owner allocation."
(t node)))
(rendered
(and render-node
(ebox-surface--render-candidate-node state render-node)))
(ebox-surface--render-candidate-node
state render-node (plist-get proof :complete-physical-lines-p))))
(rendered
(if (and rendered (plist-get proof :nested-scroll-patch-p))
(ebox-surface--nested-scroll-owned-lines
@ -4285,7 +4343,9 @@ Render STATE's candidate into BUFFER's retained owner allocation."
(and rendered
(or (ebox-buffer--rendered-in-existing-slots
spans rendered nil t)
(and (plist-get proof :scroll-content-coordinate-p)
(and (or (plist-get proof :scroll-content-coordinate-p)
(plist-get proof :complete-physical-lines-p))
(= (length spans) (length (ebox-string-lines rendered)))
(cl-every #'ebox--span-whole-line-p spans)
rendered))))
(lines (and replacement (ebox-string-lines replacement)))
@ -4564,11 +4624,85 @@ local; a declined visible proof leaves the committed scroll prefix untouched."
(cl-remf state :scroll-content-proof))))
output)))
(defun ebox-surface--span-patch-output (buffer previous-state state)
"Return a proven local patch in BUFFER or its retained scroll content."
(if (plist-get state :scroll-content-proof)
(ebox-surface--scroll-content-patch-output buffer previous-state state)
(ebox-surface--buffer-span-patch-output buffer previous-state state)))
(defun ebox-surface--complete-line-allocation-p (state proof)
"Prove PROOF's detached slot preserves STATE's raw containing width.
Published pixels have already been rounded. Restrict this proof to the root
and its ordinary flow/Column children, with the same containing viewport."
(let* ((root (plist-get state :root-node))
(root-id (plist-get root :node-id))
(owner-id (plist-get proof :owner-id))
(node (ebox-runtime-index-get owner-id (plist-get state :node-table)))
(width (plist-get
(plist-get (plist-get proof :snapshot) :external-footprint-signature)
:max-line-pixel-width))
(ebox-viewport-width (plist-get state :viewport-width))
(ebox-viewport-height (plist-get state :viewport-height)))
(and node (numberp width)
(memq (ebox-tree-display-inner root) '(flow column))
(or (equal owner-id root-id)
(and (equal (ebox-runtime-index-get
owner-id (plist-get state :parent-table)) root-id)
(eq (ebox-tree-display-outer node) 'block)
(memq (ebox-get node :position) '(nil static))))
(cl-every
(lambda (box)
(cl-every (lambda (value)
(or (memq value '(nil auto stretch none))
(numberp value) (ebox-size-value-p value)))
(mapcar (lambda (key) (ebox-get box key))
'(:width :min-width :max-width))))
(list root node))
(let ((root-width (ebox--wrapper-content-viewport-pixel root)))
(and (numberp root-width) (numberp ebox-viewport-width)
(= root-width ebox-viewport-width)
(let ((raw-width
(if (equal owner-id root-id)
(+ root-width (ebox--side-pixel root))
(ebox--call-with-box-content-context
root
(lambda ()
(when-let* ((content (ebox--wrapper-content-viewport-pixel node)))
(+ content (ebox--side-pixel node))))))))
(and (numberp raw-width) (= raw-width width))))))))
(defun ebox-surface--span-patch-output
(buffer previous-state state &optional complete-lines-only-p)
"Return a proven local patch in BUFFER or its retained scroll content.
COMPLETE-LINES-ONLY-P admits layer composition only at complete physical rows;
detached horizontal owners keep the ordinary fractional-origin fallback."
(if complete-lines-only-p
(let ((proofs (plist-get state :owner-scoped-proofs)))
(when (and proofs
(not (plist-get state :scroll-content-proof))
(cl-some (lambda (proof) (plist-get proof :layer-recompose-p))
proofs)
(with-current-buffer buffer
(save-restriction
(widen)
(cl-every
(lambda (proof)
(let ((spans (plist-get (plist-get proof :snapshot)
:buffer-spans)))
(and spans
(ebox-surface--complete-line-allocation-p state proof)
(not (plist-get proof :scroll-content-coordinate-p))
(not (plist-get proof :nested-scroll-patch-p))
(not (plist-get proof :range-splice-p))
(cl-every #'ebox--span-whole-line-p spans))))
proofs))))
(plist-put state :owner-scoped-proofs
(mapcar (lambda (proof)
(if (plist-get proof :layer-recompose-p)
(plist-put (copy-sequence proof)
:complete-physical-lines-p t)
proof))
proofs))
(let ((ebox-surface--integral-local-output-required-p t))
(catch 'ebox/fractional-local-output
(ebox-surface--buffer-span-patch-output buffer previous-state state)))))
(if (plist-get state :scroll-content-proof)
(ebox-surface--scroll-content-patch-output buffer previous-state state)
(ebox-surface--buffer-span-patch-output buffer previous-state state))))
(defun ebox-surface--buffer-span-patch-output (buffer previous-state state)
"Return a proven single- or multi-owner local patch output, or nil."
@ -4691,10 +4825,22 @@ local; a declined visible proof leaves the committed scroll prefix untouched."
(sort pairs
(lambda (left right)
(< (car (car left)) (car (car right))))))
;; A layer owner is rendered as a complete set of rows so that
;; occlusion can be proven correctly. Publishing that candidate
;; need only touch rows whose bytes or properties actually
;; changed; unchanged rows remain mounted in place and therefore
;; do not participate in the TP edit or fragment rebuild.
(setq pairs (ebox-surface--changed-span-pairs source origin pairs))
;; The paint-origin ledger restores hidden provenance on retained
;; rows before this point. Rows that are then byte-and-property
;; identical need no buffer splice or fragment reconstruction;
;; keep their old carriers in place for a future expose operation.
(when-let* ((output
(ebox-surface--replace-buffer-spans
source origin (mapcar #'car pairs)
(mapcar #'cdr pairs))))
(if pairs
(ebox-surface--replace-buffer-spans
source origin (mapcar #'car pairs)
(mapcar #'cdr pairs))
source)))
(plist-put state :content-base-extent (length source))
(plist-put
state :content-coordinate-patches
@ -4731,7 +4877,12 @@ local; a declined visible proof leaves the committed scroll prefix untouched."
(ebox-surface--materialized-fragment-ledger
previous-state)))
(fragments
(if retained-properties-p
(if (null pairs)
;; No visible row changed. Keep the exact
;; fragment ledger object and avoid scanning or
;; rebasing any text properties.
old-fragments
(if retained-properties-p
(if (and (not coordinate-shift-p)
(eq old-fragments
(plist-get previous-state
@ -4745,7 +4896,7 @@ local; a declined visible proof leaves the committed scroll prefix untouched."
(ebox-surface--incremental-patched-fragments
output old-fragments
(plist-get state :content-coordinate-patches)
state))))
state)))))
(plist-put state :span-patch-fragment-data fragments)
(plist-put state :span-patch-content-p t)
(when (and retained-properties-p (not coordinate-shift-p))
@ -7120,6 +7271,10 @@ published runtime and must be consumed without clearing those shared nodes."
(not (plist-get state :native-scroll-materialize-p))
(ebox-surface--scroll-patch-output
(ebox-surface--signals-buffer signals) state t))
(and (memq projection-kind '(span-patch owner-scoped))
(ebox-surface--span-patch-output
(ebox-surface--signals-buffer signals)
previous-state state t))
;; A detached fractional owner cannot recover
;; the remainder spent by preceding siblings.
(unless (plist-get previous-state

View File

@ -14600,4 +14600,5 @@ face patch must match that exactly (issue014)."
"#674795"))
(should (ebox-test--buffer-propertized-matches-runtime-render-p)))))
;;; ebox-core-render-tests.el ends here

View File

@ -179,6 +179,184 @@
(ebox-css-layout-test--display
(ebox-render next)))))))))))
(defun ebox-css-layout-test--fractional-layer (&optional partial left)
"Build a layer at LEFT with a disjoint status; PARTIAL shares its rows."
(let ((canvas
`(box :id canvas :height (lh 6) :background-color "#112233"
(box :id card :position absolute :left ,(or left '(% 15)) :top (lh 1)
:width (px 35) :height (lh 3) :border "red"
:background-color "#334455" :help-echo "Moving card"
:pointer hand :keymap (keymap (13 . ignore))
"CARD"))))
(ebox-build
`(column :width (px 101)
(box :height (lh 1) "Header")
,(if partial
`(row (box :width (% 50) ,canvas)
(box :width (% 50) "Sibling"))
canvas)
(box :id status :height (lh 1) "0")))))
(defun ebox-css-layout-test--assert-fresh-properties ()
"Compare retained geometry, paint and interactions with a fresh render."
(let ((actual (buffer-string))
(fresh (ebox-render
(plist-get (ebox-surface-buffer-snapshot (current-buffer))
:input))))
(should (equal (substring-no-properties actual)
(substring-no-properties fresh)))
(dotimes (position (length actual))
(dolist (property '(face font-lock-face display help-echo keymap
mouse-face pointer))
(ert-info ((format "Property %S at character %d" property position))
(should (equal (get-text-property position property actual)
(get-text-property position property fresh))))))))
(ert-deftest ebox-css-layout-fractional-layer-moves-in-complete-rows ()
"Complete-row composition keeps local updates and per-line pixel origins."
(ebox-css-layout-test--with-pixel-display
(let ((ebox-viewport-width 101) (ebox-viewport-height 8))
(with-temp-buffer
(ebox-render-to-buffer
(current-buffer) (ebox-css-layout-test--fractional-layer))
(should (plist-get (ebox--buffer-render-state (current-buffer))
:fractional-pixel-output-p))
(dolist (position '(16.5 4.5 -3.5 15.15))
(cl-letf (((symbol-function 'ebox-surface--render-candidate)
(lambda (&rest _) (ert-fail "Complete layer rerendered root"))))
(ebox-call-with-update-batch
(lambda ()
(ebox-region-update 'card :left `(px ,position))
(ebox-region-update 'status :content (format "%s" position)))))
(ebox-css-layout-test--assert-pixel-lines (buffer-string) 101)
(should
(equal (ebox-css-layout-test--display (buffer-string))
(ebox-css-layout-test--display
(ebox-render (plist-get (ebox-surface-buffer-snapshot
(current-buffer)) :input))))))))))
(ert-deftest ebox-css-layout-fractional-layer-keeps-partial-row-fallback ()
"A layer sharing a physical row cannot discard its sibling's remainder."
(ebox-css-layout-test--with-pixel-display
(let ((ebox-viewport-width 101) (ebox-viewport-height 8))
(with-temp-buffer
(ebox-render-to-buffer
(current-buffer) (ebox-css-layout-test--fractional-layer t))
(let ((render (symbol-function 'ebox-surface--render-candidate)) (calls 0))
(cl-letf (((symbol-function 'ebox-surface--render-candidate)
(lambda (&rest args) (cl-incf calls) (apply render args))))
(ebox-region-update 'card :left '(px 16.5)))
(should (> calls 0)))
(should
(equal (ebox-css-layout-test--display (buffer-string))
(ebox-css-layout-test--display
(ebox-render (plist-get (ebox-surface-buffer-snapshot
(current-buffer)) :input)))))))))
(ert-deftest ebox-css-layout-fractional-layer-keeps-raw-allocation-fallback ()
"Whole rows preserve both unrounded widths and the containing CSS basis."
(ebox-css-layout-test--with-pixel-display
(let ((ebox-viewport-width 101) (ebox-viewport-height 4))
(dolist (input
'((column :width (px 100.5)
(box :id host :width (% 100) :height (lh 2)
(box :id card :position absolute :left (% 67.7)
:width (px 7) :height (lh 1) "X")))
;; Published and raw widths both equal 150 here. Detached
;; padding must still resolve against 150, not viewport 101.
(column :width (px 150)
(box :id host :width (% 100) :padding-inline (% 10)
:height (lh 2)
(box :id card :position absolute :left (px 0)
:width (px 7) :height (lh 1) "X")))))
(ert-info ((format "Containing width %S" (plist-get (cdr input) :width)))
(with-temp-buffer
(ebox-render-to-buffer (current-buffer) (ebox-build input))
(let ((render (symbol-function 'ebox-surface--render-candidate)) (calls 0))
(cl-letf (((symbol-function 'ebox-surface--render-candidate)
(lambda (&rest args) (cl-incf calls) (apply render args))))
(ebox-region-update 'card :top '(lh 1)))
(should (> calls 0)))
(ebox-css-layout-test--assert-fresh-properties)))))))
(ert-deftest ebox-css-layout-fractional-layer-retains-new-raw-fraction-flag ()
"Local layers retain newly observed raw fractions after integral movement."
(ebox-css-layout-test--with-pixel-display
(let ((ebox-viewport-width 101) (ebox-viewport-height 8))
(with-temp-buffer
(ebox-render-to-buffer
(current-buffer) (ebox-css-layout-test--fractional-layer nil '(px 0)))
(should-not (plist-get (ebox--buffer-render-state (current-buffer))
:fractional-pixel-output-p))
(dolist (left '(16.5 0))
(cl-letf (((symbol-function 'ebox-surface--render-candidate)
(lambda (&rest _) (ert-fail "Complete layer rerendered root"))))
(ebox-region-update 'card :left `(px ,left)))
(should (plist-get (ebox--buffer-render-state (current-buffer))
:fractional-pixel-output-p))
(ebox-css-layout-test--assert-pixel-lines (buffer-string) 101)
(ebox-css-layout-test--assert-fresh-properties))))))
(ert-deftest ebox-css-layout-fractional-layer-rolls-back-normalized-output ()
"Failed mixed publication restores raw provenance and retries local layers."
(ebox-css-layout-test--with-pixel-display
(let ((ebox-viewport-width 101) (ebox-viewport-height 8))
(with-temp-buffer
(ebox-render-to-buffer
(current-buffer) (ebox-css-layout-test--fractional-layer nil '(px 0)))
(let ((state (ebox--buffer-render-state (current-buffer)))
(revision (ebox-surface-buffer-revision (current-buffer)))
(contents (buffer-string)))
(should-not (plist-get state :fractional-pixel-output-p))
(cl-labels ((update ()
(ebox-call-with-update-batch
(lambda ()
(ebox-region-update 'card :left '(px 16.5))
(ebox-region-update 'status :content "Moved")))))
(cl-letf (((symbol-function 'ebox-surface--render-candidate)
(lambda (&rest _) (ert-fail "Layer retry rerendered root"))))
(dolist (failure-step '(text client-state))
(let* ((failed-step nil)
(tp--surface-publication-step-function
(lambda (step _surface)
(when (eq step failure-step)
(setq failed-step step)
(error "Reject normalized layer %S" step)))))
(should-error (update))
(should (eq failed-step failure-step)))
(should (= (ebox-surface-buffer-revision (current-buffer)) revision))
(should (eq (ebox--buffer-render-state (current-buffer)) state))
(should-not (plist-get state :fractional-pixel-output-p))
(should (equal-including-properties contents (buffer-string))))
(update)))
(should (= (ebox-surface-buffer-revision (current-buffer)) (1+ revision)))
(should (plist-get (ebox--buffer-render-state (current-buffer))
:fractional-pixel-output-p))
(ebox-css-layout-test--assert-pixel-lines (buffer-string) 101)
(ebox-css-layout-test--assert-fresh-properties))))))
(ert-deftest ebox-css-layout-fractional-layer-move-and-paint-preserve-interactions ()
"Normalized movement and later paint keep visible interaction properties."
(ebox-css-layout-test--with-pixel-display
(let ((ebox-viewport-width 101) (ebox-viewport-height 8))
(with-temp-buffer
(ebox-render-to-buffer
(current-buffer) (ebox-css-layout-test--fractional-layer))
(dolist (update '((:left (px 16.5)) (:background-color "#556677")))
(cl-letf (((symbol-function 'ebox-surface--render-candidate)
(lambda (&rest _) (ert-fail "Layer paint rerendered root"))))
(apply #'ebox-region-update 'card update))
(should (plist-get (ebox--buffer-render-state (current-buffer))
:fractional-pixel-output-p))
(ebox-css-layout-test--assert-pixel-lines (buffer-string) 101)
(ebox-css-layout-test--assert-fresh-properties)
(goto-char (point-min))
(search-forward "CARD")
(should (equal (get-text-property (1- (point)) 'help-echo) "Moving card"))
(should (eq (get-text-property (1- (point)) 'pointer) 'hand))
(should (eq (lookup-key (get-text-property (1- (point)) 'keymap) (kbd "RET"))
#'ignore)))))))
(defun ebox-css-layout-test--fractional-scroll (offset &optional boundary mixed)
"Build a centered percentage scroll at OFFSET with optional BOUNDARY.
MIXED starts with three integral rows before the fractional cached rows."

View File

@ -138,6 +138,87 @@
(should (= full-renders 0))
(ebox-layer-publication-test--assert-fresh-render))))
(ert-deftest ebox-layer-publication-drag-recomposes-only-damaged-rows ()
"Moving an overlay recomposes its old and new rows and reuses the rest.
This is the focused regression for retained composition during a drag: the
overlay moves from row one to row two while three underlying rows stay
unchanged. Count calls to `ebox-composite-line' to prove that only the two
damaged rows are recomposed, then compare the result with a cold composition
including all text properties."
(let* ((base-0 (list :node-id "base-0" :ebox-kind 'box))
(base-1 (list :node-id "base-1" :ebox-kind 'box))
(base-2 (list :node-id "base-2" :ebox-kind 'box))
(overlay (list :node-id "overlay" :ebox-kind 'box
:position 'absolute :z-index 1))
(host (list :node-id "drag-host" :ebox-kind 'box
:children (list base-0 base-1 base-2 overlay)))
(face-base '(foreground-color "white"))
(face-overlay '(foreground-color "red" :weight bold))
(base-records
(list (list :node base-0 :x 0 :y 0
:text (propertize "BASE-0" 'face face-base
'help-echo "base-0"))
(list :node base-1 :x 0 :y 1
:text (propertize "BASE-1" 'face face-base
'help-echo "base-1"))
(list :node base-2 :x 0 :y 2
:text (propertize "BASE-2" 'face face-base
'help-echo "base-2"))))
(first-records
(append base-records
(list (list :node overlay :x 2 :y 1
:text (propertize "DRAG" 'face face-overlay
'keymap '(keymap (13 . ignore)))))))
(moved-records
(append base-records
(list (list :node overlay :x 2 :y 2
:text (propertize "DRAG" 'face face-overlay
'keymap '(keymap (13 . ignore)))))))
(context '(drag-test 12 3))
(ebox-layer--composition-context context)
(first-cache (make-hash-table :test #'equal))
(moved-cache (make-hash-table :test #'equal))
(first-calls 0)
(moved-calls 0)
(first-lines nil)
(moved-lines nil)
first moved cold)
;; Cold render seeds the retained ledger for the initial drag position.
(let ((ebox-layer--previous-composition-cache nil)
(ebox-layer--composition-cache first-cache)
(original (symbol-function 'ebox-composite-line)))
(cl-letf (((symbol-function 'ebox-composite-line)
(lambda (&rest args)
(cl-incf first-calls)
(push (plist-get (car (cadr args)) :y) first-lines)
(funcall original (car args) (cadr args) (caddr args)))))
(setq first (ebox-layer--compose-rows host first-records 12 3))))
;; The moved candidate reuses row zero and recomposes exactly old/new rows.
(let ((ebox-layer--previous-composition-cache first-cache)
(ebox-layer--composition-cache moved-cache)
(original (symbol-function 'ebox-composite-line)))
(cl-letf (((symbol-function 'ebox-composite-line)
(lambda (&rest args)
(cl-incf moved-calls)
(push (plist-get (car (cadr args)) :y) moved-lines)
(funcall original (car args) (cadr args) (caddr args)))))
(setq moved (ebox-layer--compose-rows host moved-records 12 3))))
(should (= first-calls 3))
(should (= moved-calls 2))
(should (equal (nreverse first-lines) '(0 1 2)))
(should (equal (sort moved-lines #'<) '(1 2)))
;; A cold render at the moved position is the oracle for text properties.
(let ((ebox-layer--previous-composition-cache nil)
(ebox-layer--composition-cache nil))
(setq cold (ebox-layer--compose-rows host moved-records 12 3)))
(should (equal-including-properties moved cold))
;; The retained row is the same object, proving ledger reuse rather than
;; merely an equivalent recomposition.
(should (eq (nth 0 moved) (nth 0 first)))
(should-not (eq (nth 1 moved) (nth 1 first)))
(should-not (eq (nth 2 moved) (nth 2 first)))))
(ert-deftest ebox-layer-publication-rejected-hidden-commit-rolls-back ()
"Rejected hidden updates leave retained input, revision and text unchanged."
(ebox-layer-publication-test--with-buffer