diff --git a/docs/maintainer/ebox-incremental-update-contract.en.md b/docs/maintainer/ebox-incremental-update-contract.en.md index c1d8ac1..395ac1a 100644 --- a/docs/maintainer/ebox-incremental-update-contract.en.md +++ b/docs/maintainer/ebox-incremental-update-contract.en.md @@ -54,6 +54,16 @@ roots may reuse their complete composed content and render only the wrapper. Pure root offset changes preserve already composed portal and anchor content. Style, placement, content and display-context changes must invalidate that proof. +Proved scroll-only changes build a complete candidate from the published state +and a local immutable index delta, bypassing general declarative preparation. +Surface stages rich replacement text and exact old/new character ranges, then +returns an authenticated TP commit batch with complete target mount specs; +it does not build a surface plan. Equal text lengths never authorize reuse of +old mounts: newly visible rows may have different owners and interaction tags. +Owner resolution can reuse a private cache while its topology and TP object +indexes remain valid. Attribute updates and layout reflow retain their own +preparation paths; only terminal publication and rollback are shared. + A nested owner can replace its viewport within a proven unchanged allocation, without rendering unrelated siblings. With one unchanged root scroll ancestor, the update must also patch that ancestor's retained content, ownership membership @@ -68,6 +78,14 @@ freshly rebound producers. A producer must not mutate a published continuation before TP commits. This isolation can rebuild the target prefix on a miss; cached-hit timings do not describe that cost. +The prepared viewport batch is consumed once inside the existing scoped TP +transaction. Subsequent signal-driven evaluation uses the current committed +source and ordinary projection. A proof miss chooses the ordinary path before +publication; a publication error propagates through rollback without retrying +through another renderer. General preparation and surface-plan counters in the +shared evaluator verify the bypass separately from TP bookkeeping and buffer +write time; this path does not establish constant cost in total object count. + Scroll publication still goes through TP and the Ebox participant. Rejection preserves the old text, properties, offsets, raw and rendered caches, source nodes, indexes, objects, mounts and report. The focused regression targets are diff --git a/docs/maintainer/ebox-incremental-update-contract.zh.md b/docs/maintainer/ebox-incremental-update-contract.zh.md index a0d7db1..eb33cf7 100644 --- a/docs/maintainer/ebox-incremental-update-contract.zh.md +++ b/docs/maintainer/ebox-incremental-update-contract.zh.md @@ -53,6 +53,13 @@ layered 或带 chrome 的 root 可以复用完整合成内容,只渲染 wrappe 变化保留已经合成的 portal 和 anchor 内容;style、placement、content 或 display context 变化必须使该证明失效。 +通过证明的纯滚动变更从已发布状态与局部不可变 index delta 构造完整 candidate, +跳过通用声明式准备。Surface 提前生成带属性的替换文本及精确的新旧字符范围, +再通过带完整目标 mount specs 的认证 TP commit batch 发布,不构造 surface plan。 +文本等长不能证明旧 mount 可复用:新进入视口的行可能具有不同 owner 与交互 tag。 +Topology 与 TP object index 有效时,可复用私有 owner 解析缓存。属性更新与布局 +重排仍使用各自的准备路径,只共享最终发布与回滚边界。 + Nested owner 在 allocation 不变且通过证明时,可以只替换自己的 viewport,避免渲染 无关兄弟节点。若存在一个 offset 不变的 root scroll ancestor,还必须更新该祖先的 保留内容、ownership membership 和 line index,并将 producer 重新绑定到 candidate @@ -64,6 +71,12 @@ Nested owner 在 allocation 不变且通过证明时,可以只替换自己的 不得在 TP commit 前修改已发布的 continuation。该隔离可能在 miss 时重建目标 prefix, 因此 cached-hit 耗时不能代表 miss 的代价。 +预备的 viewport batch 在现有 scoped TP transaction 中只消费一次。后续 signal +触发的求值读取当前已提交 source,使用普通 projection。证明失败在发布前选择普通 +路径;发布错误直接进入 rollback,不换另一个 renderer 重试。共享 evaluator 的 +通用准备与 surface-plan 计数分别验证这两项工作已跳过;TP bookkeeping 与实际 +buffer 写入另行测量。该路径并不保证耗时与总 object 数无关。 + Scroll publication 仍经过 TP 与 Ebox participant。发布失败时,旧文本、属性、offset、 raw/rendered cache、source node、index、object、mount 和 report 均保持不变。聚焦回归 入口为 `make layer-tests`、`make nested-scroll-tests`、`make scroll-cold-tests` diff --git a/ebox-state-contract.el b/ebox-state-contract.el index f7e8858..a646bf1 100644 --- a/ebox-state-contract.el +++ b/ebox-state-contract.el @@ -233,6 +233,17 @@ :rollback discard-unreferenced-candidates :rebuild-proof reproject-owner-native-hover-face :cleanup garbage-collect-unreferenced-faces) + (:id scroll-viewport-owner-cache + :storage (:scroll-viewport-owner-cache) + :current-contract candidate-private-resolved-owner-table + :target-contract discardable-derived-values + :category disposable-cache + :owner ebox-surface + :mutation-api copy-before-candidate-fill + :generation-binding unchanged-parent-region-and-tp-object-indexes + :rollback discard-candidate-cache + :rebuild-proof resolve-rich-line-roles-against-retained-topology + :cleanup ordinary-projection-or-generation-replacement) (:id derived-caches :storage (ebox--box-content-render-cache ebox--char-width-cache ebox--display-signature-cache ebox--face-height-width-cache diff --git a/ebox-surface.el b/ebox-surface.el index 0d2ba6b..b1ad743 100644 --- a/ebox-surface.el +++ b/ebox-surface.el @@ -184,6 +184,7 @@ the winning client state; otherwise only the candidate is disposable." (owner key compute value dependencies &rest options)) (declare-function ebox--record-render-output-provenance "ebox-render-context" (rendered)) +(declare-function ebox--strip-paint-origins! "ebox-render-context" (string)) (declare-function ebox--render-owned-text-value-p "ebox-render-context" (property value &optional registry)) (declare-function ebox-incremental--hash-snapshot @@ -2424,10 +2425,13 @@ FRAMEWORK-PARTICIPANT retains post-TP publication facts when non-nil." (signals (car signals-result)) (signals-created-p (cdr signals-result)) (producer - (ebox-surface-producer - source old-state t state-overrides signals projection-kind - source-isolated-p - (plist-get state-overrides :source-path-copied-p))) + (if (plist-get state-overrides :scroll-viewport-batch) + (ebox-surface--scroll-viewport-producer + old-state state-overrides signals) + (ebox-surface-producer + source old-state t state-overrides signals projection-kind + source-isolated-p + (plist-get state-overrides :source-path-copied-p)))) success native-settled-p) (when (and (null on-mismatch) (or (plist-get state-overrides :owner-scoped-proofs) @@ -5275,6 +5279,173 @@ copies where needed. Detached owners keep their fractional-origin guard." 0 (length output) (list 'ebox-scroll-window region-id) output)) output)))) +(defun ebox-surface--prepare-scroll-viewport (buffer previous-state state) + "Stage STATE's exact rich viewport without document or surface planning. +PREVIOUS-STATE remains the published generation in BUFFER. Existing root +wrapper and nested allocation proofs select the output; a proof miss returns +nil before any TP publication. The resulting ordinary TP batch contains only +the proved replacement coordinates, including an enclosing cache's separate +content-coordinate update when present." + (with-current-buffer buffer + (save-restriction + (widen) + (let* ((nested (plist-get state :nested-scroll-patch-proof)) + (output + (if nested + (when (and (not (plist-get previous-state :fractional-pixel-output-p)) + (or (not (plist-get nested :scroll-content-region-id)) + (plist-get state :scroll-content-proof))) + (unless (plist-get state :owner-scoped-proofs) + (plist-put state :owner-scoped-proofs (list nested))) + (let ((ebox-surface--integral-local-output-required-p t)) + (catch 'ebox/fractional-local-output + (ebox-surface--span-patch-output buffer previous-state state)))) + (ebox-surface--scroll-patch-output buffer state t)))) + (when (and (stringp output) + (not (plist-get state :paint-property-contributions))) + (unless (plist-get state :scroll-patch-lines) + (plist-put state :scroll-viewport-fragments + (or (plist-get state :span-patch-fragment-data) + (ebox-surface--rendered-fragments output)))) + (ebox--strip-paint-origins! output) + (let* ((base-extent (- (point-max) (point-min))) + (target-extent (length output)) + (coordinates + (or (plist-get state :content-coordinate-patches) + (list (list :old-start 0 :old-end base-extent + :new-start 0 :new-end target-extent)))) + (patches + (mapcar + (lambda (patch) + (append patch + (list :replacement + (substring output (plist-get patch :new-start) + (plist-get patch :new-end))))) + coordinates)) + (batch + (condition-case nil + (tp-commit-batch-create + :base-revision (plist-get state :runtime-revision) + :target-revision (1+ (plist-get state :runtime-revision)) + :base-extent base-extent :target-extent target-extent + :patches patches :coordinate-patches coordinates) + (tp-surface-error nil)))) + (when batch + (plist-put state :scroll-viewport-batch batch) + output))))))) + +(defun ebox-surface--scroll-viewport-owner-cache (previous-state state) + "Return a private owner-resolution cache for STATE's unchanged topology. +Reuse requires PREVIOUS-STATE's exact parent, region and TP object indexes. +The scroll-only source proof preserves their meanings when rebinding copied +Boxes; ordinary document projection discards this optional derived cache." + (let* ((old (plist-get previous-state :scroll-viewport-owner-cache)) + (keys '(:parent-table :region-node-table + :surface-node-object-table :region-surface-object-table)) + (valid (and old + (cl-every (lambda (key) + (eq (plist-get old key) (plist-get previous-state key))) + keys))) + (cache (list :owners + (if valid (copy-hash-table (plist-get old :owners)) + (make-hash-table :test #'equal))))) + (dolist (key keys) + (plist-put cache key (plist-get state key))) + cache)) + +(defun ebox-surface--scroll-viewport-result (context previous-state state output batch) + "Publish STATE's proved viewport OUTPUT and BATCH in the active CONTEXT. +Reuse PREVIOUS-STATE's keyed topology and resolve rich roles against that +unchanged topology. Every batch supplies complete target ownership, since +equal character lengths do not imply equal visible owners or interaction." + (let* ((surface-root (tp-object-ensure context nil ebox-surface--root-key 'ebox/surface)) + (node-root (tp-object-ensure context surface-root ebox-surface--nodes-key 'ebox/nodes)) + (fragment-root (tp-object-ensure context surface-root + ebox-surface--fragments-key 'ebox/fragments)) + (text-leaf (tp-object-ensure context fragment-root ebox-surface--text-key 'ebox/text)) + (node-objects (plist-get state :surface-node-object-table)) + (region-objects (plist-get state :region-surface-object-table)) + (lines (plist-get state :scroll-patch-lines)) + (fragments + (unless lines + (plist-get state :scroll-viewport-fragments))) + (_cache + (plist-put state :scroll-viewport-owner-cache + (ebox-surface--scroll-viewport-owner-cache previous-state state))) + (owned-ranges + (if lines + (ebox-surface--scroll-owned-ranges + context text-leaf lines state node-objects region-objects nil) + (ebox-surface--owned-ranges + context text-leaf fragments state node-objects region-objects nil))) + (length (length output)) + (records + (list (list :object text-leaf :start 0 :end length :props nil :tags nil :leaf t) + (list :object fragment-root :start 0 :end length :props nil :tags nil :leaf nil) + (list :object surface-root :start 0 :end length :props nil :tags nil :leaf nil)))) + (tp-object-reuse-subtree context node-root) + (plist-put state :surface-owned-ranges owned-ranges) + (plist-put state :surface-fragments + (if lines (list :scroll-lines (copy-sequence lines)) + (ebox-surface--fragment-metadata fragments state))) + (dolist (key '(:scroll-viewport-output :scroll-viewport-batch :scroll-viewport-fragments + :content-coordinate-patches :content-base-extent + :span-patch-fragment-data :span-patch-content-p + :span-patch-retained-owned-ranges :scroll-patch-lines + :nested-scroll-patch-proof :scroll-content-proof + :owner-scoped-proofs :scroll-state-transaction + :scroll-fast-window-p :scroll-patch-region-id :span-patch-owner-id + :preserve-scroll-producer-region-ids :previous-surface-owned-ranges)) + (cl-remf state key)) + (plist-put state :retained-scroll-content-p nil) + (tp-commit-batch-result-create + context batch :mount-specs (ebox-surface--commit-mount-specs records owned-ranges) + :client-state state))) + +(defun ebox-surface--scroll-viewport-producer (previous-state state signals) + "Return a scroll-only producer using the existing scoped transaction. +STATE owns one prepared viewport batch after PREVIOUS-STATE. Subscribe to +SIGNALS just like ordinary projection, but consume that batch only once. +A later reactive evaluation renders the then-current source normally rather +than replaying old coordinates, properties or revision authority." + (let ((output (plist-get state :scroll-viewport-output)) + (batch (plist-get state :scroll-viewport-batch)) + (offsets (ebox-surface--scroll-offsets (plist-get state :scroll-state-table)))) + (lambda (context) + (let* ((buffer (ebox-surface--signals-buffer signals)) + (surface (ebox-surface--live-buffer-surface buffer)) + (current (tp-surface-client-state surface)) + (prepared state) + (base previous-state) + (rendered output) + (prepared-batch batch) + (expected-offsets offsets) + (_consume + ;; A producer remains subscribed after this evaluation. Keep + ;; only current signals in that closure, on success or fallback; + ;; later evaluations must not retain an obsolete document. + (setq state nil previous-state nil output nil batch nil offsets nil)) + (axes (plist-get (or prepared current) :viewport-dependent-node-id-axes)) + (display (tp-signal-read (ebox-surface--signals-display signals))) + (width (funcall (if (car axes) #'tp-signal-read #'tp-signal-peek) + (ebox-surface--signals-viewport-width signals))) + (height (funcall (if (cdr axes) #'tp-signal-read #'tp-signal-peek) + (ebox-surface--signals-viewport-height signals))) + (scroll (tp-signal-read (ebox-surface--signals-scroll signals)))) + (with-current-buffer buffer + (if (and prepared (eq current base) + (equal display (plist-get prepared :display-signature)) + (equal width (plist-get prepared :viewport-width)) + (equal height (plist-get prepared :viewport-height)) + (equal scroll expected-offsets)) + (ebox-surface--scroll-viewport-result + context base prepared rendered prepared-batch) + (ebox-surface--project + context (ebox-tree-copy-node-structure (plist-get current :root-node)) + current t (list :runtime-revision (tp-surface-revision surface) + :source-base-index (plist-get current :source-index)) + signals nil t nil))))))) + (defun ebox-surface--runtime-index-ready-p (state) "Return non-nil when STATE carries a complete prepared runtime index." (let* ((root (plist-get state :root-node)) @@ -5766,7 +5937,9 @@ VISIT-RUNS receives one callback accepting ROLE-IDS, START, END, and optional layout dependency region ids. Adjacent ranges merge only when both their owner and opaque tags match." (let ((active (make-hash-table :test #'eq)) - (owner-cache (make-hash-table :test #'equal)) + (owner-cache + (or (plist-get (plist-get state :scroll-viewport-owner-cache) :owners) + (make-hash-table :test #'equal))) ranges) (funcall visit-runs diff --git a/ebox.el b/ebox.el index 05ff564..e7bb883 100644 --- a/ebox.el +++ b/ebox.el @@ -3393,29 +3393,35 @@ A miss owns its subtree, indexes, cache lines and producers before staging." (or (and (ebox--scroll-state-retained-window-ready-p staged-scroll-state) t) (and (plist-get runtime :nested-scroll-patch-proof) t))) + (report-overrides + (append + (ebox--constraint-change-report-props + (ebox--region-constraint-change + buffer region-id 'geometry '(:scroll-offset))) + (list :region-id region-id + :constraint-source 'scroll + :strategy 'span-patch + :patch-count 1 + :patch-ops '(span-patch) + :owner-id owner-id + :owner-ids (list owner-id) + :scroll-state-transaction t + :scroll-patch-fast-p scroll-patch-fast-p + :native-scroll-materialize-p + (plist-get transition :native-materialize-p)))) + (viewport-commit + (unless (plist-get transition :native-materialize-p) + (ebox--surface-scroll-viewport-commit + buffer region-id runtime report-overrides))) (commit-input + (or viewport-commit (let ((ebox--scroll-global-state table)) (ebox-incremental-prepare-scoped-commit - buffer root - (append - (ebox--constraint-change-report-props - (ebox--region-constraint-change - buffer region-id 'geometry '(:scroll-offset))) - (list :region-id region-id - :constraint-source 'scroll - :strategy 'span-patch - :patch-count 1 - :patch-ops '(span-patch) - :owner-id owner-id - :owner-ids (list owner-id) - :scroll-state-transaction t - :scroll-patch-fast-p scroll-patch-fast-p - :native-scroll-materialize-p - (plist-get transition :native-materialize-p))) - t))) + buffer root report-overrides t)))) (_staged-cache - (ebox--surface-restore-staged-scroll-cache - commit-input region-id staged-scroll-state)) + (unless viewport-commit + (ebox--surface-restore-staged-scroll-cache + commit-input region-id staged-scroll-state))) (scope-node-ids (or (plist-get commit-input :scope-node-ids) (list owner-id))) @@ -3455,6 +3461,124 @@ A miss owns its subtree, indexes, cache lines and producers before staging." (ignore surface) (plist-get transition :result)))) +(defun ebox--surface-scroll-viewport-commit + (buffer region-id runtime report) + "Prepare BUFFER's REGION-ID retained viewport from isolated RUNTIME. +Return an ordinary scoped commit input, or nil before publication when the +document, display context or allocation needs general preparation. REPORT +describes the scroll intent; Surface supplies exact text and ownership. +The local index delta preserves immutable source facts and rebinds copied +Boxes without publishing RUNTIME's deliberately sparse staging indexes." + (let* ((base (plist-get runtime :base-state)) + (root (plist-get runtime :root)) + (old-root (plist-get base :root-node)) + (owner (plist-get report :owner-id)) + (root-p (equal owner (plist-get root :node-id))) + (nested-proof (plist-get runtime :nested-scroll-patch-proof)) + (old-scroll (gethash region-id (plist-get base :scroll-state-table)))) + (when (and owner old-scroll + (ebox-surface--runtime-index-ready-p base) + (hash-table-p (plist-get base :surface-node-object-table)) + (hash-table-p (plist-get base :region-surface-object-table)) + (or root-p nested-proof) + (not (plist-get base :native-sync-session)) + (not (plist-get base :native-sync-confirmed-p)) + (not (plist-get old-scroll :lazy-scroll-prefix-dirty)) + (not (plist-get old-scroll :lazy-scroll-window-refresh-required)) + (not (plist-get old-scroll :native-reflow-target-prefix-p)) + (not (plist-get old-scroll :native-reflow-materialize-p)) + (zerop (ebox-tree-author-style-count old-root)) + (zerop (ebox-tree-author-style-count root)) + (equal (plist-get base :display-signature) + (ebox--current-display-signature)) + (eq (not (null (plist-get base :cascade-active-p))) + (not (null (ebox-style-cascade-active-p)))) + (or (not (plist-get base :cascade-active-p)) + (equal (plist-get base :stylesheet-signature) + (ebox-surface--stylesheet-signature)))) + (let* ((delta (ebox-incremental--candidate-local-index-delta base root)) + (dirty (plist-get delta :dirty-set)) + (index (plist-get delta :index))) + (when (and (null (plist-get delta :removed-node-ids)) + (cl-every + (lambda (entry) + (and (equal owner (plist-get entry :node-id)) + (not (plist-get entry :children)) + (cl-every + (lambda (key) + (memq key '(:scroll-offset :ebox-scroll-offset-controlled-p))) + (plist-get entry :changed-keys)))) + dirty)) + (let* ((candidate (ebox--render-state-install-index (copy-sequence base) index)) + (table (plist-get runtime :scroll-table)) + (prepared (list :root root :dirty-set dirty :scroll-state-table table)) + (structural-caches + (ebox-incremental--candidate-structural-caches base index dirty delta))) + (dolist (key '(:owner-scoped-proofs :scroll-content-proof + :content-coordinate-patches :content-base-extent + :span-patch-fragment-data :span-patch-content-p + :span-patch-retained-owned-ranges :scroll-patch-lines + :paint-property-contributions :previous-surface-owned-ranges + :mixed-owner-proof :mixed-owner-fragment-data + :mixed-owner-content-p :formatting-context-reflow-proof + :layer-recompose-p :native-scroll-materialize-p + :native-buffer-scroll-p :native-render-fallback)) + (cl-remf candidate key)) + (ebox-incremental--isolate-candidate-caches candidate) + (plist-put candidate :root-node root) + (plist-put candidate :scroll-state-table table) + (plist-put candidate :runtime-index-prepared-p t) + (plist-put candidate :runtime-revision + (1+ (or (plist-get base :runtime-revision) 0))) + (plist-put candidate :selector-index-stale-p + (plist-get index :selector-index-stale-p)) + (plist-put candidate :layout-snapshots (make-hash-table :test 'equal)) + (plist-put candidate :layout-snapshots-complete-p nil) + (plist-put candidate :layout-snapshot-detail-generation + (1+ (or (plist-get base :layout-snapshot-detail-generation) 0))) + (plist-put candidate :retained-allocation-certificates nil) + (plist-put candidate :reflow-prewarm-scratch nil) + ;; Quantization registers nested mutable property-value tables. + ;; A viewport candidate owns that registry before touching even + ;; one fractional carrier, including when TP later rejects it. + (plist-put candidate :render-owned-text-values (make-hash-table :test #'eq)) + (plist-put candidate :flex-content-min-widths (make-hash-table :test #'eq)) + (dolist (key '(:render-signature-cache :viewport-height-dependent-subtree-cache)) + (plist-put candidate key (plist-get structural-caches key))) + (plist-put candidate :projection-kind 'scroll-patch) + (plist-put candidate :scroll-state-transaction t) + (plist-put candidate :scroll-patch-region-id region-id) + (plist-put candidate :span-patch-owner-id owner) + (plist-put candidate :scroll-fast-window-p t) + (plist-put candidate :retained-scroll-content-p root-p) + (plist-put candidate :preserve-scroll-producer-region-ids (list region-id)) + (plist-put candidate :nested-scroll-patch-proof nested-proof) + (let ((ebox--render-source-index (plist-get candidate :source-index)) + (ebox--render-source-generations + (list (plist-get candidate :source-index) (plist-get base :source-index))) + (ebox--render-source-states (list candidate base))) + (when (plist-get nested-proof :scroll-content-region-id) + (when-let* ((proof (ebox-incremental--scroll-content-span-proof + buffer base prepared candidate owner))) + (plist-put candidate :owner-scoped-proofs (list proof)) + (plist-put candidate :scroll-content-proof + (plist-get proof :scroll-content-proof)))) + (when-let* (((or (not root-p) + (not (or (plist-get base :layered-p) + (plist-get base :root-portals-p))) + (ebox-incremental--layer-root-scroll-p + base candidate prepared owner))) + (output (ebox-surface--prepare-scroll-viewport buffer base candidate))) + (plist-put candidate :scroll-viewport-output output) + (list :root root :scope-node-ids (list owner) + :report-base + (ebox-incremental--plist-overlay + (ebox-incremental--layout-owner-report + prepared (list (ebox--patch-op 'span-patch owner :dirty dirty)) candidate) + (append report '(:projection-kind scroll-patch :full-rerender nil))) + :projection-kind 'scroll-patch + :state-overrides candidate))))))))) + (defun ebox--surface-restore-staged-scroll-cache (commit-input region-id staged-state) "Restore REGION-ID STAGED-STATE cache fields in COMMIT-INPUT." diff --git a/tests/ebox-layer-scroll-tests.el b/tests/ebox-layer-scroll-tests.el index 5fdd225..3f03e5b 100644 --- a/tests/ebox-layer-scroll-tests.el +++ b/tests/ebox-layer-scroll-tests.el @@ -9,6 +9,27 @@ (require 'ert) (require 'cl-lib) (require 'ebox) +(require 'ebox-layer-publication-tests) + +(defmacro ebox-layer-scroll-test--without-general-preparation (&rest body) + "Run cached publication BODY without document preparation or surface scans." + (declare (indent 0) (debug t)) + `(let ((stages '(ebox-incremental-prepare-scoped-commit + ebox-incremental--prepare-declarative-runtime + ebox--runtime-index ebox-surface--surface-plan)) + counters advices) + (unwind-protect + (progn + (dolist (stage stages) + (let* ((counter (cons stage 0)) + (advice (lambda (&rest _) (cl-incf (cdr counter))))) + (push counter counters) + (push (cons stage advice) advices) + (advice-add stage :before advice))) + ,@body) + (dolist (entry advices) (advice-remove (car entry) (cdr entry)))) + (should (equal (nreverse counters) + (mapcar (lambda (stage) (cons stage 0)) stages))))) (defmacro ebox-layer-scroll-test--with-buffer (&rest body) "Run BODY in an isolated Elisp scroll buffer without native window scrolling." @@ -55,6 +76,24 @@ (root (plist-get runtime :root-node))) (gethash (plist-get root :region-id) (plist-get runtime :scroll-state-table)))) +(defun ebox-layer-scroll-test--ordinary-input () + "Build an ordinary root with fractional carriers in retained physical rows." + (ebox-build + `(column :id "root" :width (vw 100) :height (vh 100) :overflow scroll + ,@(cl-loop for index below 80 + collect `(box :padding-left (px 1.5) + ,(format "ROW%02d" index)))))) + +(defun ebox-layer-scroll-test--materialize-root () + "Fully cache the root and bootstrap its retained physical rows." + (let* ((runtime (ebox--buffer-render-state (current-buffer))) + (state (ebox-layer-scroll-test--root-state)) + (region (plist-get (plist-get state :box) :region-id))) + (setq state (ebox--scroll-state-materialize-lines region state)) + (puthash region state (plist-get runtime :scroll-state-table)) + (should (= 1 (ebox--scroll-region-by region 1 1))) + (should (= -1 (ebox--scroll-region-by region -1 1))))) + (defun ebox-layer-scroll-test--portal-chain-input () "Build two visible anchored root projections through a clipped logical owner." (ebox-build @@ -93,6 +132,7 @@ (unless (eq candidate root) (ebox-put candidate :scroll-offset offset) (ebox-put candidate :ebox-scroll-offset-controlled-p t)))) + (mounts (ebox-layer-publication-test--mount-signature (current-buffer))) actual fresh) (setq actual (buffer-string)) ;; Bind only the first layout invocation, leaving nested scroll offsets @@ -102,7 +142,16 @@ (lambda (candidate) (unless first (setq first t) (funcall set-offset candidate)) (funcall render candidate)))) - (setq fresh (ebox-render input)))) + (with-temp-buffer + (unwind-protect + (progn + (ebox-render-to-buffer (current-buffer) input) + (setq fresh (buffer-string)) + (should (equal mounts + (ebox-layer-publication-test--mount-signature + (current-buffer))))) + (when (ebox-surface-buffer-mounted-p (current-buffer)) + (ebox-unmount-buffer (current-buffer))))))) (should (equal (substring-no-properties actual) (substring-no-properties fresh))) (should (= (length actual) (length fresh))) (dotimes (position (length actual)) @@ -120,22 +169,38 @@ (let* ((state (ebox-layer-scroll-test--root-state)) (region-id (plist-get (plist-get state :box) :region-id)) (content (plist-get state :content-lines)) + (objects (copy-hash-table + (plist-get (ebox--buffer-render-state (current-buffer)) + :surface-node-object-table))) (root-renders 0) (render (symbol-function 'ebox-surface--render-candidate))) (when (eq chrome 'fractional) (should (plist-get (ebox--buffer-render-state (current-buffer)) :fractional-pixel-output-p))) (dolist (delta '(1 1 -1 -1)) - (cl-letf (((symbol-function 'ebox-surface--render-candidate) - (lambda (&rest arguments) - (cl-incf root-renders) - (apply render arguments)))) - (should (= delta (ebox--scroll-region-by region-id delta 1))) - (should (eq content - (plist-get (ebox-layer-scroll-test--root-state) :content-lines))) - (should (eq (plist-get (ebox-buffer-update-report (current-buffer)) - :projection-kind) - 'scroll-patch))) + (ebox-layer-scroll-test--without-general-preparation + (let ((revision (ebox-surface-buffer-revision (current-buffer))) + (before (buffer-string))) + (cl-letf (((symbol-function 'ebox-surface--render-candidate) + (lambda (&rest arguments) + (cl-incf root-renders) + (apply render arguments)))) + (should (= delta (ebox--scroll-region-by region-id delta 1))) + (should (eq content + (plist-get (ebox-layer-scroll-test--root-state) :content-lines))) + (should (eq (plist-get (ebox-buffer-update-report (current-buffer)) + :projection-kind) + 'scroll-patch))) + (should (= (1+ revision) + (ebox-surface-buffer-revision (current-buffer)))) + (should-not (equal-including-properties before (buffer-string)))) + (maphash + (lambda (id object) + (should (eq object + (gethash id (plist-get + (ebox--buffer-render-state (current-buffer)) + :surface-node-object-table))))) + objects)) (ebox-layer-scroll-test--assert-fresh)) (should (= root-renders 0)))))) @@ -185,22 +250,53 @@ (ert-deftest ebox-layer-scroll-rejected-publication-keeps-cache-and-input () "Both publication failure stages preserve composed cache and current offset." - (dolist (failure-step '(text client-state)) + (pcase-dolist (`(,fixture ,failure-step) + '((layer text) (layer client-state) + (ordinary text) (ordinary client-state))) (ebox-layer-scroll-test--with-buffer (ebox-render-to-buffer - (current-buffer) (ebox-layer-scroll-test--input 'fractional)) + (current-buffer) (if (eq fixture 'layer) + (ebox-layer-scroll-test--input 'fractional) + (ebox-layer-scroll-test--ordinary-input))) + (when (eq fixture 'ordinary) (ebox-layer-scroll-test--materialize-root)) (let* ((runtime (ebox--buffer-render-state (current-buffer))) (state (ebox-layer-scroll-test--root-state)) (region-id (plist-get (plist-get state :box) :region-id)) (content (plist-get state :content-lines)) (snapshot (ebox-surface-buffer-snapshot (current-buffer))) (revision (ebox-surface-buffer-revision (current-buffer))) - (before (buffer-string))) - (let ((tp--surface-publication-step-function - (lambda (step _surface) - (when (eq step failure-step) (error "Reject cached root scroll"))))) - (should-error (ebox--scroll-region-by region-id 1 1))) + (mounts (ebox-layer-publication-test--mount-signature (current-buffer))) + (before (buffer-string)) + (owned (plist-get runtime :render-owned-text-values)) + (membership + (let (properties) + (maphash + (lambda (property values) + (let (entries) + (maphash (lambda (value member) (push (cons value member) entries)) + values) + (push (list property values entries) properties))) + owned) + properties)) + rejected-step) + (ebox-layer-scroll-test--without-general-preparation + (let ((tp--surface-publication-step-function + (lambda (step _surface) + (when (eq step failure-step) + (setq rejected-step step) + (error "Reject cached root scroll"))))) + (should-error (ebox--scroll-region-by region-id 1 1)))) + (should (eq rejected-step failure-step)) (should (eq runtime (ebox--buffer-render-state (current-buffer)))) + (should (eq owned (plist-get runtime :render-owned-text-values))) + (should (= (hash-table-count owned) (length membership))) + (dolist (property membership) + (let ((values (gethash (car property) owned)) + (entries (nth 2 property))) + (should (eq values (nth 1 property))) + (should (= (hash-table-count values) (length entries))) + (dolist (entry entries) + (should (eq (gethash (car entry) values 'missing) (cdr entry)))))) (should (eq state (ebox-layer-scroll-test--root-state))) (should (eq content (plist-get state :content-lines))) (let* ((after (ebox-surface-buffer-snapshot (current-buffer))) @@ -210,8 +306,85 @@ (should same-input) (should (= (plist-get snapshot :mount-id) (plist-get after :mount-id)))) (should (= revision (ebox-surface-buffer-revision (current-buffer)))) + (should (equal mounts + (ebox-layer-publication-test--mount-signature (current-buffer)))) (should (equal-including-properties before (buffer-string))) - (should (= 1 (ebox--scroll-region-by region-id 1 1))) + (ebox-layer-scroll-test--without-general-preparation + (should (= 1 (ebox--scroll-region-by region-id 1 1)))) + (should (= (1+ revision) (ebox-surface-buffer-revision (current-buffer)))) + (ebox-layer-scroll-test--assert-fresh))))) + +(ert-deftest ebox-layer-scroll-reactive-width-change-renders-current-viewport () + "A retained cached-scroll producer republishes current width without replay." + (ebox-layer-scroll-test--with-buffer + (ebox-render-to-buffer (current-buffer) (ebox-layer-scroll-test--input 'fractional)) + (ebox-region-update "root" :width '(vw 100)) + (let* ((region (plist-get (plist-get (ebox-layer-scroll-test--root-state) :box) + :region-id)) + (width (ebox-surface--signals-viewport-width ebox-surface--context-signals)) + (publish (symbol-function 'ebox-surface--scroll-viewport-result)) + (prepared-publications 0)) + (cl-letf (((symbol-function 'ebox-surface--scroll-viewport-result) + (lambda (&rest arguments) + (cl-incf prepared-publications) + (apply publish arguments)))) + (ebox-layer-scroll-test--without-general-preparation + (should (= 1 (ebox--scroll-region-by region 1 1)))) + (should (= prepared-publications 1)) + (let ((revision (ebox-surface-buffer-revision (current-buffer))) + (before (buffer-string))) + (should (= (tp-signal-subscriber-count width) 1)) + (tp-with-transaction (tp-signal-set width 30)) + (should (= (1+ revision) (ebox-surface-buffer-revision (current-buffer)))) + (should (= (plist-get (ebox--buffer-render-state (current-buffer)) + :viewport-width) 30)) + (should (= (plist-get (ebox-layer-scroll-test--root-state) :scroll-offset) 1)) + (should-not (equal-including-properties before (buffer-string))) + (should (= prepared-publications 1)) + (ebox-layer-scroll-test--assert-fresh)))))) + +(ert-deftest ebox-layer-scroll-first-guard-miss-releases-batch-and-uses-current-axes () + "A declined prepared producer releases its candidate and follows current axes." + (ebox-layer-scroll-test--with-buffer + (ebox-render-to-buffer (current-buffer) (ebox-layer-scroll-test--input 'fractional)) + (ebox-region-update "root" :width '(vw 100)) + (let* ((region (plist-get (plist-get (ebox-layer-scroll-test--root-state) :box) + :region-id)) + (surface ebox-surface--buffer-surface) + (signals ebox-surface--context-signals) + (width (ebox-surface--signals-viewport-width signals)) + (make-producer (symbol-function 'ebox-surface--scroll-viewport-producer)) + (retired (make-hash-table :test #'eq :weakness 'key)) + retained-producer) + (cl-letf (((symbol-function 'ebox-surface--scroll-viewport-producer) + (lambda (previous prepared context-signals) + (let ((declined (copy-sequence prepared))) + ;; Invalidate only the captured certificate, before TP's + ;; first evaluation, leaving the live display unchanged. + (plist-put declined :display-signature 'stale-display) + (puthash declined t retired) + (puthash (plist-get declined :scroll-viewport-batch) t retired) + (setq retained-producer + (funcall make-producer previous declined context-signals))))) + ((symbol-function 'ebox-surface--scroll-viewport-result) + (lambda (&rest _) (ert-fail "Replayed a declined viewport batch")))) + (should (= 1 (ebox--scroll-region-by region 1 1)))) + (should retained-producer) + (ebox-layer-scroll-test--assert-fresh) + (garbage-collect) + (should (= (hash-table-count retired) 0)) + (ebox-region-update "root" :width '(ch 20)) + (should (= (tp-signal-subscriber-count width) 0)) + (let ((revision (ebox-surface-buffer-revision (current-buffer)))) + (cl-letf (((symbol-function 'ebox-surface--scroll-viewport-result) + (lambda (&rest _) (ert-fail "Replayed a retired viewport batch")))) + (tp-surface-update surface retained-producer)) + (should (= (1+ revision) (ebox-surface-buffer-revision (current-buffer)))) + (should (= (tp-signal-subscriber-count width) 0)) + (should (= (tp-signal-subscriber-count + (ebox-surface--signals-display signals)) 1)) + (should (= (tp-signal-subscriber-count + (ebox-surface--signals-scroll signals)) 1)) (ebox-layer-scroll-test--assert-fresh))))) (ert-deftest ebox-layer-scroll-visible-root-dependencies-retain-composed-cache () @@ -322,11 +495,7 @@ (ebox-layer-scroll-test--with-buffer (ebox-render-to-buffer (current-buffer) - (ebox-build - `(column :id "root" :width (vw 100) :height (vh 100) :overflow scroll - ,@(cl-loop for index below 80 - collect `(box :padding-left (px 1.5) - ,(format "ROW%02d" index)))))) + (ebox-layer-scroll-test--ordinary-input)) (let* ((runtime (ebox--buffer-render-state (current-buffer))) (state (ebox-layer-scroll-test--root-state)) (region-id (plist-get (plist-get state :box) :region-id))) @@ -339,10 +508,25 @@ (should (= 1 (ebox--scroll-region-by region-id 1 1))) (should (= -1 (ebox--scroll-region-by region-id -1 1))) (should (plist-get (ebox-layer-scroll-test--root-state) :rendered-content-lines)) - (cl-letf (((symbol-function 'ebox-surface--render-candidate-node) - (lambda (&rest _) (ert-fail "Cached ordinary scroll rendered wrapper")))) - (should (= 1 (ebox--scroll-region-by region-id 1 1)))) - (ebox-layer-scroll-test--assert-fresh)))) + (dolist (delta '(1 1 -1 -1)) + (let ((revision (ebox-surface-buffer-revision (current-buffer))) + (before (buffer-string)) + (objects (copy-hash-table + (plist-get (ebox--buffer-render-state (current-buffer)) + :surface-node-object-table)))) + (ebox-layer-scroll-test--without-general-preparation + (cl-letf (((symbol-function 'ebox-surface--render-candidate-node) + (lambda (&rest _) (ert-fail "Cached ordinary scroll rendered wrapper")))) + (should (= delta (ebox--scroll-region-by region-id delta 1))))) + (should (= (1+ revision) (ebox-surface-buffer-revision (current-buffer)))) + (should-not (equal-including-properties before (buffer-string))) + (maphash + (lambda (id object) + (should (eq object + (gethash id (plist-get (ebox--buffer-render-state (current-buffer)) + :surface-node-object-table))))) + objects) + (ebox-layer-scroll-test--assert-fresh)))))) (provide 'ebox-layer-scroll-tests) ;;; ebox-layer-scroll-tests.el ends here diff --git a/tests/ebox-m0a-inventory-fixture.el b/tests/ebox-m0a-inventory-fixture.el index d30cbbc..c3f35ea 100644 --- a/tests/ebox-m0a-inventory-fixture.el +++ b/tests/ebox-m0a-inventory-fixture.el @@ -387,6 +387,11 @@ :proposed-category disposable-cache :owner ebox-incremental :authority ebox :lifetime buffer-runtime :rollback copy-on-candidate :rebuild viewport-proof :cleanup clear-cache-or-buffer-kill) + (:id buffer-runtime/scroll-viewport-owner-cache + :storage (:state-key :scroll-viewport-owner-cache) + :proposed-category disposable-cache :owner ebox-surface :authority ebox + :lifetime unchanged-topology-and-object-indexes :rollback copy-on-candidate + :rebuild rich-line-owner-resolution :cleanup ordinary-projection) (:id buffer-runtime/render-owned-text-values :storage (:state-key :render-owned-text-values) :proposed-category generation-bound-mutable :owner ebox-surface :authority ebox diff --git a/tests/ebox-nested-scroll-tests.el b/tests/ebox-nested-scroll-tests.el index 44feb38..3981334 100644 --- a/tests/ebox-nested-scroll-tests.el +++ b/tests/ebox-nested-scroll-tests.el @@ -10,6 +10,26 @@ (require 'cl-lib) (require 'ebox) +(defmacro ebox-nested-scroll-test--without-general-preparation (&rest body) + "Run cached publication BODY without document preparation or surface scans." + (declare (indent 0) (debug t)) + `(let ((stages '(ebox-incremental-prepare-scoped-commit + ebox-incremental--prepare-declarative-runtime + ebox--runtime-index ebox-surface--surface-plan)) + counters advices) + (unwind-protect + (progn + (dolist (stage stages) + (let* ((counter (cons stage 0)) + (advice (lambda (&rest _) (cl-incf (cdr counter))))) + (push counter counters) + (push (cons stage advice) advices) + (advice-add stage :before advice))) + ,@body) + (dolist (entry advices) (advice-remove (car entry) (cdr entry)))) + (should (equal (nreverse counters) + (mapcar (lambda (stage) (cons stage 0)) stages))))) + (defmacro ebox-nested-scroll-test--with-buffer (&rest body) "Run BODY with an isolated mounted surface and the Elisp renderer." (declare (indent 0) (debug t)) @@ -87,7 +107,7 @@ PRESERVE-ORDER-P retains attachment order for exact rollback checks." (should (equal (substring-no-properties actual) (buffer-substring-no-properties (point-min) (point-max)))) (dotimes (position (length actual)) - (dolist (property '(face display help-echo mouse-face pointer keymap)) + (dolist (property '(face font-lock-face display help-echo mouse-face pointer keymap)) (should (equal (get-text-property position property actual) (get-text-property (1+ position) property))))) (should (equal mounts (ebox-nested-scroll-test--mounts)))) @@ -193,10 +213,28 @@ PRESERVE-ORDER-P retains attachment order for exact rollback checks." (ebox-nested-scroll-test--parity) (let* ((state (ebox--buffer-render-state (current-buffer))) (scroll (gethash right (plist-get state :scroll-state-table))) - (lines (plist-get scroll :content-lines))) - (cl-letf (((symbol-function 'ebox-surface--render-candidate) - (lambda (&rest _) (ert-fail "Independent scroll rendered root")))) - (should (= (ebox--surface-scroll-region-by (current-buffer) left 1 nil) 1))) + (lines (plist-get scroll :content-lines)) + (revision (ebox-surface-buffer-revision (current-buffer))) + (objects (copy-hash-table (plist-get state :surface-node-object-table)))) + (dolist (delta '(1 1 -1)) + (ebox-nested-scroll-test--without-general-preparation + (cl-letf (((symbol-function 'ebox-surface--render-candidate) + (lambda (&rest _) (ert-fail "Independent scroll rendered root")))) + (should (= (ebox--surface-scroll-region-by + (current-buffer) left delta nil) delta)))) + (cl-incf revision) + (should (= revision (ebox-surface-buffer-revision (current-buffer)))) + (let ((next (gethash right (plist-get (ebox--buffer-render-state + (current-buffer)) :scroll-state-table)))) + (should (= (plist-get next :scroll-offset) 2)) + (should (eq (plist-get next :content-lines) lines))) + (ebox-nested-scroll-test--parity)) + (maphash + (lambda (id object) + (should (eq object + (gethash id (plist-get (ebox--buffer-render-state (current-buffer)) + :surface-node-object-table))))) + objects) (let ((next (gethash right (plist-get (ebox--buffer-render-state (current-buffer)) :scroll-state-table)))) (should (= (plist-get next :scroll-offset) 2)) @@ -204,10 +242,14 @@ PRESERVE-ORDER-P retains attachment order for exact rollback checks." (ebox-nested-scroll-test--parity) (ebox-region-update "line-1" :content "UPDATED") (should (string-match-p "UPDATED" (buffer-string))) + (should (= (plist-get (ebox--scroll-get-state right) :scroll-offset) 2)) (ebox-nested-scroll-test--parity) (ebox-rerender-buffer-with-context (current-buffer) 120 35) + (should (= (plist-get (ebox--scroll-get-state left) :scroll-offset) 1)) + (should (= (plist-get (ebox--scroll-get-state right) :scroll-offset) 2)) (ebox-nested-scroll-test--parity) - (should (= (ebox--surface-scroll-region-by (current-buffer) left -1 nil) -1)) + (ebox-nested-scroll-test--without-general-preparation + (should (= (ebox--surface-scroll-region-by (current-buffer) left -1 nil) -1))) (ebox-nested-scroll-test--parity)))) (ert-deftest ebox-nested-scroll-rejection-restores-local-and-fallback-state () @@ -224,6 +266,7 @@ PRESERVE-ORDER-P retains attachment order for exact rollback checks." (before (buffer-string)) (mounts (ebox-nested-scroll-test--mounts t)) (revision (ebox-surface-buffer-revision (current-buffer))) + rejected-step (root-renders 0) (render (symbol-function 'ebox-surface--render-candidate))) (cl-letf (((symbol-function 'ebox-surface--render-candidate) @@ -231,9 +274,16 @@ PRESERVE-ORDER-P retains attachment order for exact rollback checks." (cl-incf root-renders) (apply render arguments)))) (let ((tp--surface-publication-step-function (lambda (step _surface) - (when (eq step 'client-state) (error "Reject nested scroll"))))) - (should-error (ebox--surface-scroll-region-by - (current-buffer) region 1 nil)))) + (when (eq step 'client-state) + (setq rejected-step step) + (error "Reject nested scroll")))) + (attempt (lambda () + (should-error (ebox--surface-scroll-region-by + (current-buffer) region 1 nil))))) + (if fallback (funcall attempt) + (ebox-nested-scroll-test--without-general-preparation + (funcall attempt))))) + (should (eq rejected-step 'client-state)) (should (eq state (ebox--buffer-render-state (current-buffer)))) (should (= revision (ebox-surface-buffer-revision (current-buffer)))) (should (equal-including-properties before (buffer-string))) @@ -241,7 +291,11 @@ PRESERVE-ORDER-P retains attachment order for exact rollback checks." (should (= (plist-get scroll :scroll-offset) 0)) (should (= (or (ebox-get box :scroll-offset) 0) 0)) (should (if fallback (> root-renders 0) (= root-renders 0))) - (should (= (ebox--surface-scroll-region-by (current-buffer) region 1 nil) 1)) + (if fallback + (should (= (ebox--surface-scroll-region-by (current-buffer) region 1 nil) 1)) + (ebox-nested-scroll-test--without-general-preparation + (should (= (ebox--surface-scroll-region-by (current-buffer) region 1 nil) 1)))) + (should (= (1+ revision) (ebox-surface-buffer-revision (current-buffer)))) (ebox-nested-scroll-test--parity))))) (ert-deftest ebox-nested-scroll-updates-enclosing-cache-and-role-membership () @@ -253,12 +307,14 @@ PRESERVE-ORDER-P retains attachment order for exact rollback checks." (outer (ebox-nested-scroll-test--region "root")) (hidden (ebox-nested-scroll-test--region "line-0")) (visible (ebox-nested-scroll-test--region "line-5"))) - (cl-letf (((symbol-function 'ebox-surface--render-candidate) - (lambda (&rest _) (ert-fail "Inner scroll rendered enclosing root")))) - (should (= (ebox--surface-scroll-region-by (current-buffer) inner 1 nil) 1))) + (ebox-nested-scroll-test--without-general-preparation + (cl-letf (((symbol-function 'ebox-surface--render-candidate) + (lambda (&rest _) (ert-fail "Inner scroll rendered enclosing root")))) + (should (= (ebox--surface-scroll-region-by (current-buffer) inner 1 nil) 1)))) (let* ((state (ebox--buffer-render-state (current-buffer))) (scroll (gethash outer (plist-get state :scroll-state-table))) (members (ebox--scroll-state-ensure-content-region-id-set scroll))) + (should (= (plist-get scroll :scroll-offset) 0)) (should (eq (plist-get scroll :box) (plist-get state :root-node))) (should-not (gethash hidden members)) (should (gethash visible members))