diff --git a/ebox-incremental.el b/ebox-incremental.el index f39263a..bf1bf10 100644 --- a/ebox-incremental.el +++ b/ebox-incremental.el @@ -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) diff --git a/ebox-layer.el b/ebox-layer.el index d3f4b13..1271539 100644 --- a/ebox-layer.el +++ b/ebox-layer.el @@ -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 diff --git a/ebox-state-contract.el b/ebox-state-contract.el index 2f3cf34..a0d9d1e 100644 --- a/ebox-state-contract.el +++ b/ebox-state-contract.el @@ -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) diff --git a/ebox-surface.el b/ebox-surface.el index f60a691..4be7fff 100644 --- a/ebox-surface.el +++ b/ebox-surface.el @@ -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 diff --git a/tests/ebox-core-render-tests.el b/tests/ebox-core-render-tests.el index 720ee9c..d1920ae 100644 --- a/tests/ebox-core-render-tests.el +++ b/tests/ebox-core-render-tests.el @@ -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 diff --git a/tests/ebox-css-layout-tests.el b/tests/ebox-css-layout-tests.el index 42cf2a8..19a9679 100644 --- a/tests/ebox-css-layout-tests.el +++ b/tests/ebox-css-layout-tests.el @@ -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." diff --git a/tests/ebox-layer-publication-tests.el b/tests/ebox-layer-publication-tests.el index c35fae1..69d2c01 100644 --- a/tests/ebox-layer-publication-tests.el +++ b/tests/ebox-layer-publication-tests.el @@ -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