Preserve native scroll continuity with explicit retained producers
Some checks are pending
CI / test (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
Some checks are pending
CI / test (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:
parent
73357dee94
commit
727aba2ec7
@ -88,6 +88,10 @@
|
||||
(declare-function ebox-native-commit-plan-eligible-p
|
||||
"ebox-native-commit"
|
||||
(old-state candidate-state prepared))
|
||||
(declare-function ebox-native-commit--root-box
|
||||
"ebox-native-commit" (node))
|
||||
(declare-function ebox-native-commit-root-scroll-continuation-p
|
||||
"ebox-native-commit" (state))
|
||||
(declare-function ebox-native-commit-prepare-viewport-continuity
|
||||
"ebox-native-commit"
|
||||
(previous-state candidate-state))
|
||||
@ -9943,6 +9947,8 @@ role, and outside-complement compatibility before publication."
|
||||
"Return the retained native runtime facts owned by STATE."
|
||||
(list :native-buffer-scroll-p
|
||||
(plist-get state :native-buffer-scroll-p)
|
||||
:native-root-scroll-producer (plist-get state :native-root-scroll-producer)
|
||||
:native-root-scroll-offset-dirty (plist-get state :native-root-scroll-offset-dirty)
|
||||
:native-sync-session
|
||||
(plist-get state :native-sync-session)
|
||||
:native-sync-pending
|
||||
@ -10586,10 +10592,20 @@ runtime candidate whose identities already match the published root."
|
||||
prepared owner-plan candidate-state)
|
||||
report-overrides)))
|
||||
(when scroll-transaction-p
|
||||
;; Scroll changes already carry a bounded rendered window in the
|
||||
;; staged scroll state. Publish that window through the retained
|
||||
;; object scope instead of re-running the full root layout.
|
||||
(setq projection-kind 'scroll-patch)
|
||||
;; A complete native producer must advance its confirmed visible
|
||||
;; frame on scroll as well. Ordinary scroll states publish their
|
||||
;; already staged window through the retained object scope.
|
||||
(setq projection-kind
|
||||
(if (and (require 'ebox-native-commit nil t)
|
||||
(ebox-native-commit-root-scroll-continuation-p old-state)
|
||||
(equal (plist-get report-overrides :region-id)
|
||||
(plist-get (ebox-native-commit--root-box
|
||||
(plist-get old-state :root-node))
|
||||
:region-id))
|
||||
(ebox-native-commit-plan-eligible-p
|
||||
old-state candidate-state prepared))
|
||||
'native-frame
|
||||
'scroll-patch))
|
||||
(setq report (plist-put report :projection-kind projection-kind)))
|
||||
(when projection-kind
|
||||
(setq report (plist-put report :projection-kind projection-kind)))
|
||||
@ -10618,7 +10634,8 @@ runtime candidate whose identities already match the published root."
|
||||
(plist-get report-overrides :owner-id))))
|
||||
(setq state-overrides
|
||||
(plist-put state-overrides :scroll-fast-window-p
|
||||
scroll-patch-fast-p)))
|
||||
(and (eq projection-kind 'scroll-patch)
|
||||
scroll-patch-fast-p))))
|
||||
(list :root (plist-get prepared :root)
|
||||
:scope-node-ids scope-node-ids
|
||||
:report-base report
|
||||
|
||||
@ -34,6 +34,10 @@
|
||||
"ebox-native-reflow" (frame))
|
||||
(declare-function ebox--literal-root-pixel-width "ebox" (box))
|
||||
(declare-function ebox-get "ebox" (box key))
|
||||
(declare-function ebox-put "ebox" (box key value))
|
||||
(declare-function ebox--scroll-set-state "ebox" (region-id state))
|
||||
(declare-function ebox--scroll-clear-state "ebox" (region-id))
|
||||
(defvar ebox--scroll-global-state)
|
||||
(declare-function ebox-style-cascade-active-p "ebox-style" ())
|
||||
(declare-function ebox-style-property "ebox-style" (name))
|
||||
(declare-function ebox-tree-node-key "ebox-tree" (source-index node))
|
||||
@ -406,26 +410,98 @@ new session with the same bounded configuration used by retained frames."
|
||||
('box node)
|
||||
('flex (plist-get node :box)))))
|
||||
|
||||
(defun ebox-native-commit-root-scroll-continuation-p (state)
|
||||
"Return non-nil for STATE's confirmed complete sole-root scroll producer."
|
||||
(let ((producer (plist-get state :native-root-scroll-producer))
|
||||
(box (ebox-native-commit--root-box (plist-get state :root-node))))
|
||||
(and (vectorp producer) (= (length producer) 6) box
|
||||
(equal (aref producer 0) (plist-get box :region-id))
|
||||
(plist-get state :native-sync-session)
|
||||
(plist-get state :native-sync-confirmed-p))))
|
||||
|
||||
(defun ebox-native-commit--region-index-safe-p (state)
|
||||
"Return non-nil when STATE has an unambiguous, non-scrolling region index."
|
||||
(let ((counts (plist-get state :region-box-count-table))
|
||||
(scroll (plist-get state :scroll-state-table))
|
||||
(root-scroll (ebox-native-commit-root-scroll-continuation-p state))
|
||||
(root-region (plist-get (ebox-native-commit--root-box
|
||||
(plist-get state :root-node)) :region-id))
|
||||
safe)
|
||||
(setq safe
|
||||
(and (hash-table-p counts)
|
||||
(or (null scroll)
|
||||
(and (hash-table-p scroll)
|
||||
(zerop (hash-table-count scroll))))))
|
||||
(or (zerop (hash-table-count scroll))
|
||||
(and root-scroll (= (hash-table-count scroll) 1)
|
||||
(gethash root-region scroll)))))))
|
||||
(when safe
|
||||
(maphash
|
||||
(lambda (region-id count)
|
||||
(let ((box (ebox-native-commit--region-box state region-id)))
|
||||
(unless (and (= count 1) box
|
||||
(zerop (or (ebox-get box :scroll-offset) 0)))
|
||||
(or (zerop (or (ebox-get box :scroll-offset) 0))
|
||||
(and root-scroll (equal region-id root-region))))
|
||||
(setq safe nil))))
|
||||
counts))
|
||||
safe))
|
||||
|
||||
(defun ebox-native-commit--install-root-scroll-producer (state producer)
|
||||
"Install complete PRODUCER in STATE's isolated candidate scroll table."
|
||||
(let* ((box (ebox-native-commit--root-box (plist-get state :root-node)))
|
||||
(region (plist-get box :region-id))
|
||||
(previous (plist-get state :native-root-scroll-producer))
|
||||
(previous-scroll (gethash region ebox--scroll-global-state)))
|
||||
(unless (and box (vectorp producer) (= (length producer) 6)
|
||||
(equal region (aref producer 0)))
|
||||
(error "Native scroll producer does not own the actual root"))
|
||||
(when (or (eq (aref producer 4) :reuse) (eq (aref producer 5) :reuse))
|
||||
(unless (and (eq (aref producer 4) :reuse) (eq (aref producer 5) :reuse)
|
||||
(eq (plist-get state :native-frame-kind) 'patch)
|
||||
(ebox-native-commit-root-scroll-continuation-p state)
|
||||
(= (aref producer 1) (aref previous 1))
|
||||
(> (aref previous 1) (aref previous 2))
|
||||
(listp (aref previous 4)) (listp (aref previous 5)))
|
||||
(error "Native scroll producer reuse has no confirmed complete base"))
|
||||
(setq producer (copy-sequence producer))
|
||||
(aset producer 4 (aref previous 4))
|
||||
(aset producer 5 (aref previous 5)))
|
||||
(unless (= (or (ebox-get box :scroll-offset) 0) (aref producer 3))
|
||||
;; Viewport candidates may share their unchanged root. Clamp only a new
|
||||
;; root spine, and compile that normalized offset on the next native job.
|
||||
(let* ((root (copy-sequence (plist-get state :root-node)))
|
||||
(root-id (plist-get root :node-id))
|
||||
(nodes (plist-get state :node-table)))
|
||||
(if (eq (plist-get root :ebox-type) 'flex)
|
||||
(progn (setq box (copy-sequence (plist-get root :box)))
|
||||
(plist-put root :box box))
|
||||
(setq box root))
|
||||
(ebox-put box :scroll-offset (aref producer 3))
|
||||
(plist-put state :root-node root)
|
||||
(plist-put state :node-table
|
||||
(ebox-runtime-index-put root-id root
|
||||
(if (hash-table-p nodes) (copy-hash-table nodes) nodes)))
|
||||
(let ((regions (copy-hash-table (plist-get state :region-box-table))))
|
||||
(puthash region box regions)
|
||||
(plist-put state :region-box-table regions))
|
||||
(plist-put state :native-root-scroll-offset-dirty t)))
|
||||
(plist-put state :native-root-scroll-producer producer)
|
||||
(if (> (aref producer 1) (aref producer 2))
|
||||
(if (and previous-scroll
|
||||
(eq (plist-get previous-scroll :content-lines) (aref producer 4))
|
||||
(eq (plist-get previous-scroll :rendered-content-lines) (aref producer 5)))
|
||||
(let ((scroll-state (copy-sequence previous-scroll)))
|
||||
(plist-put scroll-state :scroll-offset (aref producer 3))
|
||||
(plist-put scroll-state :content-height (aref producer 2))
|
||||
(plist-put scroll-state :box box)
|
||||
(puthash region scroll-state ebox--scroll-global-state))
|
||||
(ebox--scroll-set-state
|
||||
region (list :scroll-offset (aref producer 3)
|
||||
:content-height (aref producer 2)
|
||||
:content-lines (aref producer 4)
|
||||
:rendered-content-lines (aref producer 5)
|
||||
:content-lines-complete-p t :box box)))
|
||||
(ebox--scroll-clear-state region))))
|
||||
|
||||
(defun ebox-native-commit--runtime-types-supported-p (state)
|
||||
"Return non-nil when STATE contains only executable native node types."
|
||||
(let ((nodes (plist-get state :node-table))
|
||||
@ -482,6 +558,7 @@ new session with the same bounded configuration used by retained frames."
|
||||
;; base while font/display changes invalidate it.
|
||||
(sxhash-equal (plist-get state :display-signature))
|
||||
:complete t
|
||||
:root-scroll-producer t
|
||||
:root-metadata nil)
|
||||
(when (and (plist-get state :native-sync-confirmed-p)
|
||||
(plist-get state :native-topology-stable-p))
|
||||
@ -621,6 +698,8 @@ publication transaction can still roll back."
|
||||
|
||||
(defconst ebox-native-commit--failed-render-state-keys
|
||||
'(:native-sync-session
|
||||
:native-root-scroll-producer
|
||||
:native-root-scroll-offset-dirty
|
||||
:native-sync-pending
|
||||
:native-sync-confirmed-p
|
||||
:native-render-p
|
||||
@ -673,16 +752,29 @@ projection so the caller may publish its existing Elisp fallback truthfully."
|
||||
(ebox-native-commit--runtime-types-supported-p state)
|
||||
(ebox-native-commit--region-index-safe-p state))
|
||||
(when-let* ((frame-spec (ebox-native-commit--frame-spec state node)))
|
||||
(when (plist-get state :native-root-scroll-offset-dirty)
|
||||
(let ((root-id (plist-get node :node-id)))
|
||||
(plist-put state :native-touched-node-ids
|
||||
(cons root-id (remove root-id (plist-get state :native-touched-node-ids))))
|
||||
(plist-put state :native-local-dirty-entries
|
||||
(cons (list :node-id root-id :dirty-kind 'geometry
|
||||
:changed-keys '(:scroll-offset))
|
||||
(plist-get state :native-local-dirty-entries))))
|
||||
(cl-remf state :native-root-scroll-offset-dirty))
|
||||
(condition-case err
|
||||
(let* ((frame (ebox-native-commit--retained-frame
|
||||
state node frame-spec))
|
||||
(effects (plist-get frame :effect-tape))
|
||||
(rendered (plist-get frame :rendered))
|
||||
(fragment-template
|
||||
(and (not (plist-get effects :scroll-window-p))
|
||||
(effects (plist-get frame :effect-tape))
|
||||
(rendered (plist-get frame :rendered))
|
||||
(producer (plist-get effects :root-scroll-producer))
|
||||
(fragment-template
|
||||
(and (or (not (plist-get effects :scroll-window-p)) producer)
|
||||
(plist-get effects :fragment-span-template))))
|
||||
(if (and (stringp rendered) (vectorp fragment-template))
|
||||
(progn
|
||||
(if producer
|
||||
(ebox-native-commit--install-root-scroll-producer state producer)
|
||||
(cl-remf state :native-root-scroll-producer))
|
||||
(ebox-native-commit--install-region-boxes state)
|
||||
(plist-put state :render-owned-text-values
|
||||
(make-hash-table :test #'eq))
|
||||
|
||||
@ -3333,6 +3333,7 @@ DOCUMENT-DELTA is the stable-topology local replacement batch."
|
||||
(if (plist-member frame :root-metadata)
|
||||
(plist-get frame :root-metadata)
|
||||
t))
|
||||
(root-scroll-producer (plist-get frame :root-scroll-producer))
|
||||
(delay (or (plist-get frame :delay-ms) 0)))
|
||||
(unless (integerp key)
|
||||
(error "Native reflow layout key must be an integer: %S" key))
|
||||
@ -3362,6 +3363,10 @@ DOCUMENT-DELTA is the stable-topology local replacement batch."
|
||||
(error "Native reflow complete flag must be boolean"))
|
||||
(unless (memq root-metadata '(t nil :false))
|
||||
(error "Native reflow root-metadata flag must be boolean"))
|
||||
(unless (memq root-scroll-producer '(t nil :false))
|
||||
(error "Native reflow root-scroll-producer flag must be boolean"))
|
||||
(when (and (eq root-scroll-producer t) (not (eq complete t)))
|
||||
(error "Native root scroll producer requires complete properties"))
|
||||
(append
|
||||
(list :key key
|
||||
:viewport-width viewport-width
|
||||
@ -3378,6 +3383,8 @@ DOCUMENT-DELTA is the stable-topology local replacement batch."
|
||||
:root-metadata
|
||||
(if (eq root-metadata t) t :false)
|
||||
:delay-ms delay)
|
||||
(when (eq root-scroll-producer t)
|
||||
(list :root-scroll-producer t))
|
||||
(when patch-p
|
||||
(list
|
||||
:base-viewport-width base-viewport-width
|
||||
@ -4004,7 +4011,12 @@ plist supplied by the caller."
|
||||
:role-span-template ,roles
|
||||
:box-extent-template ,extents
|
||||
:scroll-content-span-template ,scroll
|
||||
:scroll-window-p ,(and window-p (or 'nil 't)))
|
||||
:scroll-window-p ,(and window-p (or 'nil 't)) . ,extra)
|
||||
(unless (or (null extra)
|
||||
(and (proper-list-p extra) (= (length extra) 2)
|
||||
(eq (car extra) :root-scroll-producer)
|
||||
(vectorp (cadr extra)) (= (length (cadr extra)) 6)))
|
||||
(error "Native reflow tape has invalid root scroll metadata"))
|
||||
(let ((actual-count
|
||||
(+ (ebox-native-reflow--validate-root-role-template
|
||||
roles character-count)
|
||||
@ -4012,13 +4024,50 @@ plist supplied by the caller."
|
||||
extents character-count)
|
||||
(ebox-native-reflow--validate-root-scroll-template
|
||||
scroll character-count)
|
||||
(if window-p 1 0))))
|
||||
(if window-p 1 0)
|
||||
(if extra 1 0))))
|
||||
(unless (= actual-count record-count)
|
||||
(error "Native reflow tape metadata record count mismatch")))
|
||||
metadata)
|
||||
(_
|
||||
(error "Native reflow tape has invalid prepared root metadata"))))
|
||||
|
||||
(defun ebox-native-reflow--decode-root-scroll-producer
|
||||
(metadata styles property-templates &optional allow-reuse-p)
|
||||
"Validate and decode METADATA's complete retained root scroll producer."
|
||||
(when-let* ((encoded (plist-get metadata :root-scroll-producer)))
|
||||
(unless (and (vectorp encoded) (= (length encoded) 6)
|
||||
(integerp (aref encoded 0)) (> (aref encoded 0) 0)
|
||||
(integerp (aref encoded 1)) (> (aref encoded 1) 0)
|
||||
(integerp (aref encoded 2)) (> (aref encoded 2) 0)
|
||||
(integerp (aref encoded 3)) (>= (aref encoded 3) 0)
|
||||
(<= (aref encoded 3)
|
||||
(max 0 (- (aref encoded 1) (aref encoded 2)))))
|
||||
(error "Native root scroll producer has invalid geometry"))
|
||||
(let ((producer (copy-sequence encoded)))
|
||||
(if (or (and (<= (aref encoded 1) (aref encoded 2))
|
||||
(eq (aref encoded 4) :inactive)
|
||||
(eq (aref encoded 5) :inactive))
|
||||
(and allow-reuse-p (> (aref encoded 1) (aref encoded 2))
|
||||
(eq (aref encoded 4) :reuse)
|
||||
(eq (aref encoded 5) :reuse)))
|
||||
nil
|
||||
(dolist (index '(4 5))
|
||||
(let* ((literal (aref encoded index))
|
||||
(read-circle t)
|
||||
(read-symbol-shorthands nil)
|
||||
(parsed (and (stringp literal) (read-from-string literal)))
|
||||
(rendered (car parsed)))
|
||||
(unless (and (stringp rendered) (= (cdr parsed) (length literal))
|
||||
(= (1+ (cl-count ?\n rendered)) (aref encoded 1)))
|
||||
(error "Native root scroll producer has invalid complete content"))
|
||||
(ebox-native-reflow--expand-property-templates rendered property-templates)
|
||||
(ebox-native-reflow--validate-literal-properties
|
||||
rendered styles t property-templates)
|
||||
(aset producer index (ebox-string-lines rendered)))))
|
||||
(plist-put metadata :root-scroll-producer producer)))
|
||||
metadata)
|
||||
|
||||
(defun ebox-native-reflow--tape-read-root-metadata
|
||||
(cursor metadata-byte-count record-count character-count complete-p
|
||||
property-template-count style-count
|
||||
@ -4459,6 +4508,8 @@ typed line-width and property invariants instead of repeating them in Emacs."
|
||||
(+ (or (plist-get decoded :replacement-bytes) 0)
|
||||
(* patch-count 128)
|
||||
(* metadata-count 64))))
|
||||
(ebox-native-reflow--decode-root-scroll-producer
|
||||
(plist-get decoded :root-render-metadata) styles property-templates t)
|
||||
(plist-put decoded :tape-bytes (string-bytes payload))
|
||||
(plist-put decoded :artifact-byte-estimate
|
||||
(max (string-bytes payload) retained-estimate))
|
||||
@ -4468,6 +4519,8 @@ typed line-width and property invariants instead of repeating them in Emacs."
|
||||
(line-widths (aref decoded 2))
|
||||
(metadata (aref decoded 3))
|
||||
(fragments (aref decoded 4)))
|
||||
(ebox-native-reflow--decode-root-scroll-producer
|
||||
metadata styles property-templates)
|
||||
(unless trusted-native-encoder-p
|
||||
(ebox-native-reflow--validate-literal-properties
|
||||
rendered styles (plist-get header :complete)
|
||||
|
||||
@ -2964,8 +2964,9 @@ valid because their declarative layout and viewport did not change."
|
||||
(ebox--render-layout node)))))
|
||||
(unless (plist-get state :native-render-p)
|
||||
(ebox--record-render-output-provenance rendered))
|
||||
(ebox-surface--restore-scroll-metadata
|
||||
scroll-table scroll-metadata)
|
||||
(unless (plist-get state :native-render-p)
|
||||
(ebox-surface--restore-scroll-metadata
|
||||
scroll-table scroll-metadata))
|
||||
(plist-put state :scroll-state-table scroll-table)
|
||||
(plist-put state :scroll-region-ids
|
||||
(ebox-surface--hash-keys scroll-table))
|
||||
|
||||
40
ebox.el
40
ebox.el
@ -1894,14 +1894,34 @@ uses their rendered descendants."
|
||||
(cl-loop for line in lines
|
||||
for line-index from 0
|
||||
do
|
||||
(dolist (region-id (ebox--scroll-line-region-ids line))
|
||||
(when-let* ((span (ebox--scroll-line-region-span
|
||||
line (ebox--region-id-set
|
||||
(list region-id)))))
|
||||
(puthash region-id
|
||||
(cons (cons line-index span)
|
||||
(gethash region-id index))
|
||||
index))))
|
||||
(let ((spans (make-hash-table :test 'equal))
|
||||
(pos 0)
|
||||
(limit (length line)))
|
||||
;; Aggregate all owners during the same property-run scan.
|
||||
;; A foreign direct content owner invalidates that region's
|
||||
;; entire hull, exactly as the single-region query does.
|
||||
(while (< pos limit)
|
||||
(let ((next (ebox--string-next-region-property-change
|
||||
line pos limit))
|
||||
(roles (ebox--string-region-role-ids-at line pos)))
|
||||
(dolist (id (ebox--string-region-ids-at line pos))
|
||||
(let ((span (gethash id spans)))
|
||||
(unless (eq span 'incompatible)
|
||||
(cond
|
||||
((cl-some (lambda (role)
|
||||
(and (eq (car role) 'content)
|
||||
(not (equal (cdr role) id))))
|
||||
roles)
|
||||
(puthash id 'incompatible spans))
|
||||
(span (setcdr span next))
|
||||
(t (puthash id (cons pos next) spans))))))
|
||||
(setq pos (max next (1+ pos)))))
|
||||
(maphash
|
||||
(lambda (id span)
|
||||
(unless (eq span 'incompatible)
|
||||
(puthash id (cons (cons line-index span)
|
||||
(gethash id index)) index)))
|
||||
spans)))
|
||||
(maphash (lambda (region-id spans)
|
||||
(puthash region-id (nreverse spans) index))
|
||||
index)
|
||||
@ -3189,7 +3209,9 @@ inside the old prefix, so a line-slide never mixes two layout versions."
|
||||
(setq value
|
||||
(plist-put
|
||||
value :retained-scroll-content-p
|
||||
(and scroll-patch-fast-p root-id owner-id
|
||||
(and (eq (plist-get commit-input :projection-kind)
|
||||
'scroll-patch)
|
||||
scroll-patch-fast-p root-id owner-id
|
||||
(= root-id owner-id))))
|
||||
(if (plist-get transition :native-materialize-p)
|
||||
(plist-put value :native-scroll-materialize-p t)
|
||||
|
||||
@ -40,6 +40,9 @@ pub(crate) struct EvalWork {
|
||||
pub(crate) column_tree_nodes_created: u64,
|
||||
pub(crate) column_tree_nodes_visited: u64,
|
||||
pub(crate) column_full_rebuilds: u64,
|
||||
pub(crate) scroll_producer_lines_encoded: u64,
|
||||
pub(crate) scroll_producer_chars_encoded: u64,
|
||||
pub(crate) scroll_producer_reuses: u64,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
@ -77,7 +80,10 @@ impl EvalWork {
|
||||
column_slots_updated,
|
||||
column_tree_nodes_created,
|
||||
column_tree_nodes_visited,
|
||||
column_full_rebuilds
|
||||
column_full_rebuilds,
|
||||
scroll_producer_lines_encoded,
|
||||
scroll_producer_chars_encoded,
|
||||
scroll_producer_reuses
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -142,6 +148,8 @@ struct EvalRequest {
|
||||
context: LayoutContext,
|
||||
intrinsic: bool,
|
||||
size_override: Option<BoxOverride>,
|
||||
root_scroll_request: bool,
|
||||
complete_scroll_effects: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@ -167,6 +175,7 @@ struct UseNode {
|
||||
right: ChildUses,
|
||||
height: u32,
|
||||
len: usize,
|
||||
scroll_owners: u8,
|
||||
}
|
||||
|
||||
impl ChildUses {
|
||||
@ -196,16 +205,52 @@ impl ChildUses {
|
||||
self.0.as_ref().map_or(0, |node| node.len)
|
||||
}
|
||||
|
||||
fn scroll_owners(&self) -> u8 {
|
||||
self.0.as_ref().map_or(0, |node| node.scroll_owners)
|
||||
}
|
||||
|
||||
fn node(slot: Arc<UseSlot>, value: Arc<EvalRecord>, left: Self, right: Self) -> Self {
|
||||
record(EvalWork {
|
||||
child_use_nodes_created: 1,
|
||||
..EvalWork::default()
|
||||
});
|
||||
let final_use = !slot.path.iter().any(|step| {
|
||||
matches!(
|
||||
step,
|
||||
UseStep::Phase(
|
||||
Phase::FlexMeasure
|
||||
| Phase::FlexMinWidth
|
||||
| Phase::FlexAutoMinContent
|
||||
| Phase::FlexMaxWidth
|
||||
| Phase::FlexBasisContent
|
||||
| Phase::FlexBasisWidth
|
||||
| Phase::ContentIntrinsic
|
||||
| Phase::FlexCrossProbe
|
||||
)
|
||||
)
|
||||
});
|
||||
let incomplete_window = slot
|
||||
.path
|
||||
.iter()
|
||||
.any(|step| matches!(step, UseStep::Phase(Phase::WindowChildPartial)));
|
||||
let scroll_owners = (left.scroll_owners()
|
||||
+ right.scroll_owners()
|
||||
+ if final_use {
|
||||
if incomplete_window {
|
||||
2
|
||||
} else {
|
||||
value.rendered.scroll_owners
|
||||
}
|
||||
} else {
|
||||
0
|
||||
})
|
||||
.min(2);
|
||||
Self(Some(Arc::new(UseNode {
|
||||
slot,
|
||||
value,
|
||||
height: 1 + left.height().max(right.height()),
|
||||
len: 1 + left.len() + right.len(),
|
||||
scroll_owners,
|
||||
left,
|
||||
right,
|
||||
})))
|
||||
@ -396,6 +441,9 @@ pub(crate) struct RetainedFrame {
|
||||
}
|
||||
|
||||
impl RetainedFrame {
|
||||
pub(crate) fn root_scroll(&self) -> Option<&super::RootScrollPlan> {
|
||||
self.root.rendered.root_scroll.as_deref()
|
||||
}
|
||||
pub(crate) fn materialize_tape(&self) -> LayoutTape {
|
||||
self.root
|
||||
.rendered
|
||||
@ -424,6 +472,7 @@ struct RenderTxn {
|
||||
same_shape: bool,
|
||||
stack: Vec<BuildingRecord>,
|
||||
root: Option<Arc<EvalRecord>>,
|
||||
complete_scroll_effects: bool,
|
||||
}
|
||||
|
||||
impl RenderTxn {
|
||||
@ -499,7 +548,13 @@ impl RenderTxn {
|
||||
|
||||
fn finish(&mut self, rendered: Result<Rendered, String>) -> Result<RenderedChange, String> {
|
||||
let current = self.stack.pop().expect("native evaluation builder stack");
|
||||
let rendered = rendered?;
|
||||
let mut rendered = rendered?;
|
||||
let children = current.children.finish();
|
||||
let child_owners = children.scroll_owners();
|
||||
rendered.scroll_owners = (child_owners + u8::from(rendered.own_scroll_owner)).min(2);
|
||||
if child_owners != 0 {
|
||||
rendered.root_scroll = None;
|
||||
}
|
||||
let change = current.previous.as_ref().map(|previous| {
|
||||
current
|
||||
.pending_change
|
||||
@ -516,7 +571,7 @@ impl RenderTxn {
|
||||
source: current.source,
|
||||
request: current.request,
|
||||
rendered: rendered.clone(),
|
||||
children: current.children.finish(),
|
||||
children,
|
||||
column: current.column,
|
||||
projections: current.projections,
|
||||
});
|
||||
@ -671,6 +726,9 @@ impl<'a> RenderScope<'a> {
|
||||
context,
|
||||
intrinsic,
|
||||
size_override,
|
||||
root_scroll_request: transaction.borrow().complete_scroll_effects
|
||||
&& transaction.borrow().stack.is_empty(),
|
||||
complete_scroll_effects: transaction.borrow().complete_scroll_effects,
|
||||
};
|
||||
if let Some(rendered) =
|
||||
transaction
|
||||
@ -700,6 +758,33 @@ impl<'a> RenderScope<'a> {
|
||||
self.transaction.is_some()
|
||||
}
|
||||
|
||||
pub(super) fn root_scroll_requested(&self) -> bool {
|
||||
self.transaction.is_some_and(|transaction| {
|
||||
transaction
|
||||
.borrow()
|
||||
.stack
|
||||
.last()
|
||||
.is_some_and(|record| record.request.root_scroll_request)
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn complete_scroll_effects_requested(&self) -> bool {
|
||||
self.transaction
|
||||
.is_some_and(|transaction| transaction.borrow().complete_scroll_effects)
|
||||
}
|
||||
|
||||
pub(super) fn previous_root_scroll(&self) -> Option<Arc<super::RootScrollPlan>> {
|
||||
let transaction = self.transaction?.borrow();
|
||||
let current = transaction.stack.last()?;
|
||||
current
|
||||
.previous
|
||||
.as_ref()
|
||||
.filter(|old| old.source == current.source)?
|
||||
.rendered
|
||||
.root_scroll
|
||||
.clone()
|
||||
}
|
||||
|
||||
pub(super) fn publish_change(&self, change: PlanChange) {
|
||||
if let Some(transaction) = self.transaction {
|
||||
transaction
|
||||
@ -893,6 +978,27 @@ impl RetainedDocument {
|
||||
changes: Option<&SourceChanges>,
|
||||
context: LayoutContext,
|
||||
root_width_override: Option<i64>,
|
||||
) -> Result<Arc<RetainedFrame>, String> {
|
||||
self.render_frame_mode(previous, changes, context, root_width_override, false)
|
||||
}
|
||||
|
||||
pub(crate) fn render_frame_with_root_scroll(
|
||||
self: &Arc<Self>,
|
||||
previous: Option<&RetainedFrame>,
|
||||
changes: Option<&SourceChanges>,
|
||||
context: LayoutContext,
|
||||
root_width_override: Option<i64>,
|
||||
) -> Result<Arc<RetainedFrame>, String> {
|
||||
self.render_frame_mode(previous, changes, context, root_width_override, true)
|
||||
}
|
||||
|
||||
fn render_frame_mode(
|
||||
self: &Arc<Self>,
|
||||
previous: Option<&RetainedFrame>,
|
||||
changes: Option<&SourceChanges>,
|
||||
context: LayoutContext,
|
||||
root_width_override: Option<i64>,
|
||||
complete_scroll_effects: bool,
|
||||
) -> Result<Arc<RetainedFrame>, String> {
|
||||
let reusable = match (previous, changes) {
|
||||
(Some(previous), Some(changes)) => {
|
||||
@ -918,6 +1024,7 @@ impl RetainedDocument {
|
||||
_ => BTreeMap::new(),
|
||||
};
|
||||
let transaction = RefCell::new(RenderTxn {
|
||||
complete_scroll_effects,
|
||||
previous: reusable.map(|previous| Arc::clone(&previous.root)),
|
||||
dirty,
|
||||
same_shape: reusable
|
||||
|
||||
@ -130,6 +130,145 @@ fn assert_oracle(frame: &RetainedFrame, context: LayoutContext, root_width: Opti
|
||||
);
|
||||
}
|
||||
|
||||
fn sole_root_scroll_document(child: LayoutNode) -> Arc<RetainedDocument> {
|
||||
let mut root = child_box(1, child, None);
|
||||
let LayoutNode::Box { height, .. } = &mut root else {
|
||||
unreachable!()
|
||||
};
|
||||
*height = Size::ViewportHeight;
|
||||
RetainedDocument::bootstrap(retained_document(identified(root, 1, 1)))
|
||||
.unwrap()
|
||||
.0
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_scroll_producer_preserves_mixed_children_and_cached_effects() {
|
||||
let document = sole_root_scroll_document(LayoutNode::Column {
|
||||
node_id: None,
|
||||
node_revision: None,
|
||||
children: Arc::new(vec![
|
||||
LayoutNode::Row {
|
||||
node_id: None,
|
||||
node_revision: None,
|
||||
children: Arc::new(vec![
|
||||
nonuniform_text(2, &[1, 1]),
|
||||
child_box(3, nonuniform_text(4, &[1]), None),
|
||||
]),
|
||||
},
|
||||
flex(
|
||||
vec![nonuniform_text(5, &[1]), nonuniform_text(6, &[1])],
|
||||
FlexDirection::Column,
|
||||
FlexWrap::Nowrap,
|
||||
),
|
||||
]),
|
||||
});
|
||||
let context = LayoutContext {
|
||||
viewport_height: 1,
|
||||
..test_context()
|
||||
};
|
||||
let frame = document
|
||||
.render_frame_with_root_scroll(None, None, context, None)
|
||||
.unwrap();
|
||||
assert_oracle(&frame, context, None);
|
||||
let producer = frame.root_scroll().expect("sole actual root owner");
|
||||
assert_eq!(producer.region_id, 1);
|
||||
assert!(producer.full_content.len() > 1);
|
||||
assert_eq!(producer.full_content.len(), producer.rendered_content.len());
|
||||
assert_eq!(producer.visible_height, 1);
|
||||
assert_eq!(frame.root.rendered.scroll_owners, 1);
|
||||
assert!(records(&frame.root)
|
||||
.into_iter()
|
||||
.skip(1)
|
||||
.all(|record| record.rendered.root_scroll.is_none()));
|
||||
reset_work();
|
||||
let reused = document
|
||||
.render_frame_with_root_scroll(Some(&frame), None, context, None)
|
||||
.unwrap();
|
||||
assert_eq!(work().hits, 1);
|
||||
assert_eq!(work().body_runs, 0);
|
||||
assert!(std::ptr::eq(producer, reused.root_scroll().unwrap()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_scroll_producer_rejects_offscreen_nested_owners() {
|
||||
let mut nested = child_box(4, nonuniform_text(5, &[1, 1]), None);
|
||||
let LayoutNode::Box { height, .. } = &mut nested else {
|
||||
unreachable!()
|
||||
};
|
||||
*height = Size::Lines { value: 1 };
|
||||
let document = sole_root_scroll_document(LayoutNode::Column {
|
||||
node_id: None,
|
||||
node_revision: None,
|
||||
children: Arc::new(vec![nonuniform_text(2, &[1, 1, 1]), nested]),
|
||||
});
|
||||
for height in [1, 20] {
|
||||
let context = LayoutContext {
|
||||
viewport_height: height,
|
||||
..test_context()
|
||||
};
|
||||
let frame = document
|
||||
.render_frame_with_root_scroll(None, None, context, None)
|
||||
.unwrap();
|
||||
assert_oracle(&frame, context, None);
|
||||
assert!(
|
||||
frame.root_scroll().is_none(),
|
||||
"nested owner at height {height}"
|
||||
);
|
||||
assert_eq!(
|
||||
frame.root.rendered.scroll_owners,
|
||||
if height == 1 { 2 } else { 1 }
|
||||
);
|
||||
assert!(records(&frame.root)
|
||||
.into_iter()
|
||||
.any(|record| record.rendered.own_scroll_owner && record.source.path.len() > 1));
|
||||
let reused = document
|
||||
.render_frame_with_root_scroll(Some(&frame), None, context, None)
|
||||
.unwrap();
|
||||
assert!(reused.root_scroll().is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_scroll_owner_summary_distinguishes_measurements_and_final_windows() {
|
||||
let document = sole_root_scroll_document(nonuniform_text(2, &[1, 1]));
|
||||
let frame = document
|
||||
.render_frame_with_root_scroll(
|
||||
None,
|
||||
None,
|
||||
LayoutContext {
|
||||
viewport_height: 1,
|
||||
..test_context()
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(frame.root.rendered.scroll_owners, 1);
|
||||
for (phase, expected) in [
|
||||
(Phase::FlexMeasure, 0),
|
||||
(Phase::FlexMinWidth, 0),
|
||||
(Phase::FlexAutoMinContent, 0),
|
||||
(Phase::FlexMaxWidth, 0),
|
||||
(Phase::FlexBasisContent, 0),
|
||||
(Phase::FlexBasisWidth, 0),
|
||||
(Phase::ContentIntrinsic, 0),
|
||||
(Phase::FlexCrossProbe, 0),
|
||||
(Phase::FlexFinal, 1),
|
||||
(Phase::WindowFull, 1),
|
||||
(Phase::WindowFallback, 1),
|
||||
(Phase::WindowChildFull, 1),
|
||||
(Phase::WindowChildPartial, 2),
|
||||
] {
|
||||
let uses = ChildUses::from_fresh(BTreeMap::from([(
|
||||
UseSlot {
|
||||
path: vec![UseStep::Phase(phase)],
|
||||
occurrence: 0,
|
||||
},
|
||||
Arc::clone(&frame.root),
|
||||
)]));
|
||||
assert_eq!(uses.scroll_owners(), expected, "{phase:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instrumentation_records_common_entries_without_changing_full_semantics() {
|
||||
let document = mixed(3);
|
||||
@ -962,6 +1101,7 @@ fn render_request(
|
||||
size_override: Option<BoxOverride>,
|
||||
) -> Arc<RetainedFrame> {
|
||||
let transaction = RefCell::new(RenderTxn {
|
||||
complete_scroll_effects: false,
|
||||
previous: previous.map(|frame| Arc::clone(&frame.root)),
|
||||
dirty: BTreeMap::new(),
|
||||
same_shape: true,
|
||||
@ -1145,6 +1285,57 @@ fn scroll_document() -> Arc<RetainedDocument> {
|
||||
.0
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_scroll_mode_is_explicit_and_cannot_reuse_lazy_caller_records() {
|
||||
let document = scroll_document();
|
||||
let lazy = document
|
||||
.render_frame(None, None, test_context(), None)
|
||||
.unwrap();
|
||||
assert!(lazy.root_scroll().is_none());
|
||||
assert!(!records(&lazy.root)
|
||||
.iter()
|
||||
.any(|record| record.source.owner_id == 2));
|
||||
assert!(records(&lazy.root)
|
||||
.iter()
|
||||
.all(|record| !record.request.complete_scroll_effects));
|
||||
let complete = document
|
||||
.render_frame_with_root_scroll(Some(&lazy), None, test_context(), None)
|
||||
.unwrap();
|
||||
assert_eq!(complete.root_scroll().unwrap().full_content.len(), 12);
|
||||
assert!(records(&complete.root)
|
||||
.iter()
|
||||
.all(|record| record.request.complete_scroll_effects));
|
||||
assert!(!Arc::ptr_eq(
|
||||
owner_record(&lazy.root, 3),
|
||||
owner_record(&complete.root, 3)
|
||||
));
|
||||
assert_oracle(&complete, test_context(), None);
|
||||
let update = document
|
||||
.apply_delta(context_test_delta(vec![serde_json::json!({
|
||||
"node-id":2,"expected-revision":1,"target-revision":2,
|
||||
"slot-patches":[{"slot":0,"local":{"height":{"kind":"lines","value":5}}}]
|
||||
})]))
|
||||
.unwrap();
|
||||
let target = update
|
||||
.document
|
||||
.render_frame_with_root_scroll(Some(&complete), Some(&update.changes), test_context(), None)
|
||||
.unwrap();
|
||||
assert_eq!(target.root_scroll().unwrap().full_content.len(), 14);
|
||||
assert_ne!(complete.materialize_tape(), target.materialize_tape());
|
||||
assert_oracle(&target, test_context(), None);
|
||||
reset_work();
|
||||
let lazy_again = update
|
||||
.document
|
||||
.render_frame(Some(&target), None, test_context(), None)
|
||||
.unwrap();
|
||||
assert!(work().height_queries > 0);
|
||||
assert!(lazy_again.root_scroll().is_none());
|
||||
assert!(records(&lazy_again.root)
|
||||
.iter()
|
||||
.all(|record| !record.request.complete_scroll_effects));
|
||||
assert_oracle(&lazy_again, test_context(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unrendered_height_dependencies_invalidate_scroll_and_windows_cache_only_complete_values() {
|
||||
let document = scroll_document();
|
||||
@ -1290,6 +1481,7 @@ fn repeated_calls(
|
||||
path: Arc::from([]),
|
||||
};
|
||||
let transaction = RefCell::new(RenderTxn {
|
||||
complete_scroll_effects: false,
|
||||
previous: previous.map(|frame| Arc::clone(&frame.root)),
|
||||
dirty: BTreeMap::from([(
|
||||
source.clone(),
|
||||
@ -1313,6 +1505,8 @@ fn repeated_calls(
|
||||
path: Vec::new(),
|
||||
};
|
||||
let request = EvalRequest {
|
||||
root_scroll_request: false,
|
||||
complete_scroll_effects: false,
|
||||
context: test_context(),
|
||||
intrinsic: false,
|
||||
size_override: None,
|
||||
@ -1464,6 +1658,7 @@ fn unwinding_discards_current_builder_records_without_mutating_previous_frame()
|
||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
let source = baseline.root.source.clone();
|
||||
let transaction = RefCell::new(RenderTxn {
|
||||
complete_scroll_effects: false,
|
||||
previous: Some(Arc::clone(&baseline.root)),
|
||||
dirty: BTreeMap::from([(
|
||||
source.clone(),
|
||||
|
||||
@ -2191,17 +2191,48 @@ impl Atom {
|
||||
#[derive(Debug, Clone)]
|
||||
struct Rendered {
|
||||
lines: LinePlan,
|
||||
root_scroll: Option<Arc<RootScrollPlan>>,
|
||||
own_scroll_owner: bool,
|
||||
scroll_owners: u8,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct RootScrollPlan {
|
||||
region_id: i64,
|
||||
full_content: LinePlan,
|
||||
rendered_content: LinePlan,
|
||||
visible_height: i64,
|
||||
effective_offset: i64,
|
||||
text_input: Option<RootTextInput>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct RootTextInput {
|
||||
source: Arc<MeasuredText>,
|
||||
width: i64,
|
||||
wrap: WrapMode,
|
||||
region: Option<i64>,
|
||||
styles: [Option<u32>; 3],
|
||||
lines: LinePlan,
|
||||
}
|
||||
|
||||
impl Rendered {
|
||||
fn from_lines(lines: Vec<Line>) -> Self {
|
||||
Self {
|
||||
lines: LinePlan::from_lines(lines),
|
||||
root_scroll: None,
|
||||
own_scroll_owner: false,
|
||||
scroll_owners: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn from_line_plan(lines: LinePlan) -> Self {
|
||||
Self { lines }
|
||||
Self {
|
||||
lines,
|
||||
root_scroll: None,
|
||||
own_scroll_owner: false,
|
||||
scroll_owners: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn first_width(&self) -> i64 {
|
||||
@ -3520,6 +3551,79 @@ struct RootMetadataPayload {
|
||||
fragment_count: usize,
|
||||
}
|
||||
|
||||
fn append_scroll_metadata(
|
||||
payload: &mut Option<RootMetadataPayload>,
|
||||
scroll: Option<(&RootScrollPlan, Option<&RootScrollPlan>)>,
|
||||
styles: &[CompiledStyle],
|
||||
style_count: u32,
|
||||
max_bytes: usize,
|
||||
) -> Result<(), String> {
|
||||
let (Some(payload), Some((scroll, base))) = (payload.as_mut(), scroll) else {
|
||||
return Ok(());
|
||||
};
|
||||
let mut effect = format!(
|
||||
" :root-scroll-producer [{} {} {} {} ",
|
||||
scroll.region_id,
|
||||
scroll.full_content.len(),
|
||||
scroll.visible_height,
|
||||
scroll.effective_offset
|
||||
);
|
||||
let inactive = scroll.full_content.len() as i64 <= scroll.visible_height;
|
||||
let reused = !inactive
|
||||
&& base.is_some_and(|base| {
|
||||
base.region_id == scroll.region_id
|
||||
&& base.full_content.len() as i64 > base.visible_height
|
||||
&& base.full_content.ptr_eq(&scroll.full_content)
|
||||
&& base.rendered_content.ptr_eq(&scroll.rendered_content)
|
||||
});
|
||||
if inactive {
|
||||
// The receiver needs only geometry to retire its active scroll state.
|
||||
// Keep the complete plans in this frame for a later clipped request.
|
||||
effect.push_str(":inactive :inactive ");
|
||||
} else if reused {
|
||||
effect.push_str(":reuse :reuse ");
|
||||
evaluation::record(EvalWork {
|
||||
scroll_producer_reuses: 1,
|
||||
..EvalWork::default()
|
||||
});
|
||||
} else {
|
||||
for plan in [&scroll.full_content, &scroll.rendered_content] {
|
||||
evaluation::record(EvalWork {
|
||||
scroll_producer_lines_encoded: plan.len() as u64,
|
||||
scroll_producer_chars_encoded: plan.prefix_chars(plan.len()),
|
||||
..EvalWork::default()
|
||||
});
|
||||
let tape = Rendered::from_line_plan(plan.clone()).into_tape(style_count);
|
||||
let flat = flatten_layout_tape(tape, true)?;
|
||||
let (text, spaces, properties) = tape_character_encoding_parts(&flat.characters)?;
|
||||
let literal = encode_lisp_literal(
|
||||
&text,
|
||||
&spaces,
|
||||
&properties,
|
||||
styles,
|
||||
flat.characters.len() as u64,
|
||||
max_bytes,
|
||||
)?;
|
||||
// Each nested literal has its own read-circle label namespace.
|
||||
push_lisp_string(&mut effect, &literal);
|
||||
effect.push(' ');
|
||||
}
|
||||
}
|
||||
effect.push_str("])");
|
||||
payload.literal.pop();
|
||||
payload.literal.push_str(&effect);
|
||||
payload.record_count += 1;
|
||||
if payload
|
||||
.literal
|
||||
.len()
|
||||
.saturating_add(payload.fragment_bytes.len())
|
||||
> max_bytes
|
||||
{
|
||||
return Err("Native scroll producer exceeds its byte limit".to_owned());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn metadata_role_symbol(kind: u8) -> Option<&'static str> {
|
||||
match kind {
|
||||
METADATA_ROLE_CONTENT => Some("content"),
|
||||
@ -3984,6 +4088,7 @@ fn build_emacs_commit_batch(
|
||||
complete: bool,
|
||||
root_metadata: bool,
|
||||
max_bytes: usize,
|
||||
scroll: Option<(&RootScrollPlan, Option<&RootScrollPlan>)>,
|
||||
) -> Result<EmacsCommitBatch, String> {
|
||||
let core = tape_commit_batch(&old.characters, &target.characters);
|
||||
let publication_edits = coalesce_publication_edits(&core.semantic_edits);
|
||||
@ -4010,6 +4115,13 @@ fn build_emacs_commit_batch(
|
||||
root_metadata,
|
||||
payload_limit,
|
||||
)?;
|
||||
append_scroll_metadata(
|
||||
&mut metadata_payload,
|
||||
scroll,
|
||||
compiled_styles,
|
||||
target.style_count,
|
||||
payload_limit,
|
||||
)?;
|
||||
let old_fragments = if metadata_payload.is_some() {
|
||||
let (old_text, old_spaces, old_property_spans) =
|
||||
tape_character_encoding_parts(&old.characters)?;
|
||||
@ -4090,6 +4202,7 @@ fn build_emacs_commit_batch(
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn encode_layout_patch_tape(
|
||||
old_tape: LayoutTape,
|
||||
target_tape: LayoutTape,
|
||||
@ -4097,6 +4210,26 @@ pub(crate) fn encode_layout_patch_tape(
|
||||
identity: TapeIdentity,
|
||||
root_metadata: bool,
|
||||
max_bytes: usize,
|
||||
) -> Result<Vec<u8>, String> {
|
||||
encode_layout_patch_tape_with_scroll(
|
||||
old_tape,
|
||||
target_tape,
|
||||
styles,
|
||||
identity,
|
||||
root_metadata,
|
||||
max_bytes,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn encode_layout_patch_tape_with_scroll(
|
||||
old_tape: LayoutTape,
|
||||
target_tape: LayoutTape,
|
||||
styles: &[StyleTemplate],
|
||||
identity: TapeIdentity,
|
||||
root_metadata: bool,
|
||||
max_bytes: usize,
|
||||
scroll: Option<(&RootScrollPlan, Option<&RootScrollPlan>)>,
|
||||
) -> Result<Vec<u8>, String> {
|
||||
let old = flatten_layout_tape(old_tape, identity.complete)?;
|
||||
let target = flatten_layout_tape(target_tape, identity.complete)?;
|
||||
@ -4114,6 +4247,7 @@ pub(crate) fn encode_layout_patch_tape(
|
||||
identity.complete,
|
||||
root_metadata,
|
||||
max_bytes,
|
||||
scroll.filter(|_| identity.complete),
|
||||
)?;
|
||||
let patch_count = count_u32(batch.publication_edits.len(), "patches")?;
|
||||
let coordinate_patch_count =
|
||||
@ -4177,12 +4311,24 @@ pub(crate) fn encode_layout_patch_tape(
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn encode_layout_tape(
|
||||
tape: LayoutTape,
|
||||
styles: &[StyleTemplate],
|
||||
identity: TapeIdentity,
|
||||
root_metadata: bool,
|
||||
max_bytes: usize,
|
||||
) -> Result<Vec<u8>, String> {
|
||||
encode_layout_tape_with_scroll(tape, styles, identity, root_metadata, max_bytes, None)
|
||||
}
|
||||
|
||||
pub(crate) fn encode_layout_tape_with_scroll(
|
||||
tape: LayoutTape,
|
||||
styles: &[StyleTemplate],
|
||||
identity: TapeIdentity,
|
||||
root_metadata: bool,
|
||||
max_bytes: usize,
|
||||
scroll: Option<&RootScrollPlan>,
|
||||
) -> Result<Vec<u8>, String> {
|
||||
if tape.lines.is_empty() {
|
||||
return Err("Native layout tape has no lines".to_owned());
|
||||
@ -4292,7 +4438,7 @@ pub(crate) fn encode_layout_tape(
|
||||
.checked_sub(TAPE_HEADER_LEN)
|
||||
.and_then(|remaining| remaining.checked_sub(fixed_body_bytes))
|
||||
.ok_or_else(|| "Native layout tape exceeds its byte limit".to_owned())?;
|
||||
let metadata_payload = root_metadata_payload(
|
||||
let mut metadata_payload = root_metadata_payload(
|
||||
&text,
|
||||
&spaces,
|
||||
&property_spans,
|
||||
@ -4300,6 +4446,15 @@ pub(crate) fn encode_layout_tape(
|
||||
root_metadata,
|
||||
metadata_limit,
|
||||
)?;
|
||||
append_scroll_metadata(
|
||||
&mut metadata_payload,
|
||||
scroll
|
||||
.filter(|_| identity.complete)
|
||||
.map(|scroll| (scroll, None)),
|
||||
&compiled_styles,
|
||||
tape.style_count,
|
||||
metadata_limit,
|
||||
)?;
|
||||
let metadata_bytes = metadata_payload
|
||||
.as_ref()
|
||||
.map_or(0, |payload| payload.literal.len());
|
||||
@ -5477,7 +5632,7 @@ fn wrap_rendered(rendered: Rendered, max_width: i64, mode: WrapMode) -> Rendered
|
||||
.map(|view| view.materialize().into_owned())
|
||||
.unwrap_or_default();
|
||||
}
|
||||
Rendered { lines }
|
||||
Rendered::from_line_plan(lines)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@ -6347,7 +6502,7 @@ fn concat_horizontal_sized(parts: Vec<(Rendered, i64)>, target_height: i64) -> R
|
||||
}
|
||||
line
|
||||
}));
|
||||
Rendered { lines }
|
||||
Rendered::from_line_plan(lines)
|
||||
}
|
||||
|
||||
fn stack_vertical(parts: Vec<Rendered>) -> Rendered {
|
||||
@ -6365,7 +6520,7 @@ fn stack_vertical(parts: Vec<Rendered>) -> Rendered {
|
||||
if lines.is_empty() {
|
||||
lines = LinePlan::from_lines([Line::default()]);
|
||||
}
|
||||
Rendered { lines }
|
||||
Rendered::from_line_plan(lines)
|
||||
}
|
||||
|
||||
fn slice_rendered(rendered: Rendered, start: i64, height: i64) -> Rendered {
|
||||
@ -7097,6 +7252,10 @@ fn render_node_body(
|
||||
}
|
||||
};
|
||||
let simple_scroll_window = *overflow == Overflow::Scroll
|
||||
// Only a requested complete producer needs effects from every
|
||||
// child, including offscreen owners. Ordinary retained layout
|
||||
// keeps the existing window shortcut.
|
||||
&& !scope.complete_scroll_effects_requested()
|
||||
&& child.is_some()
|
||||
&& definite_content_width.is_some()
|
||||
&& definite_content_height.is_some()
|
||||
@ -7265,6 +7424,8 @@ fn render_node_body(
|
||||
});
|
||||
if transparent_preformatted {
|
||||
let mut rendered = child_rendered.take().expect("validated child");
|
||||
rendered.root_scroll = None;
|
||||
rendered.own_scroll_owner = false;
|
||||
let mut ops = vec![LineOp::OwnContent {
|
||||
region: *region_id,
|
||||
start: 0,
|
||||
@ -7283,7 +7444,28 @@ fn render_node_body(
|
||||
return Ok(rendered);
|
||||
}
|
||||
|
||||
let mut formatted = if let Some(text) = content {
|
||||
let previous_scroll = scope.previous_root_scroll();
|
||||
let text_styles = [
|
||||
*content_typography_style,
|
||||
*content_foreground_style,
|
||||
*content_surface_template_id,
|
||||
];
|
||||
let reusable_text = previous_scroll
|
||||
.as_ref()
|
||||
.and_then(|scroll| scroll.text_input.as_ref())
|
||||
.filter(|input| {
|
||||
content
|
||||
.as_ref()
|
||||
.is_some_and(|text| Arc::ptr_eq(text, &input.source))
|
||||
&& input.width == content_width
|
||||
&& input.wrap == *wrap_mode
|
||||
&& input.region == *content_region_id
|
||||
&& input.styles == text_styles
|
||||
});
|
||||
let mut formatted = if let Some(input) = reusable_text {
|
||||
child_change = Some(PlanChange::same(&input.lines));
|
||||
input.lines.clone()
|
||||
} else if let Some(text) = content {
|
||||
let mut content_lines = measured_lines(text, content_width, *wrap_mode);
|
||||
if let Some(content_region_id) = content_region_id {
|
||||
for (index, line) in content_lines.iter_mut().enumerate() {
|
||||
@ -7305,6 +7487,18 @@ fn render_node_body(
|
||||
};
|
||||
rendered.lines
|
||||
};
|
||||
let text_input = if scope.root_scroll_requested() {
|
||||
content.as_ref().map(|text| RootTextInput {
|
||||
source: Arc::clone(text),
|
||||
width: content_width,
|
||||
wrap: *wrap_mode,
|
||||
region: *content_region_id,
|
||||
styles: text_styles,
|
||||
lines: formatted.clone(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// Ordinary Box formatting historically rebuilds default breaks;
|
||||
// the transparent preformatted branch above preserves child breaks.
|
||||
let mut change = child_change;
|
||||
@ -7374,6 +7568,55 @@ fn render_node_body(
|
||||
&& border_bottom_style.is_none()
|
||||
&& *vertical == VerticalAlign::Top;
|
||||
|
||||
let root_scroll = if scope.root_scroll_requested()
|
||||
&& *overflow == Overflow::Scroll
|
||||
&& *padding_left == 0
|
||||
&& *padding_right == 0
|
||||
&& *padding_top == 0
|
||||
&& *padding_bottom == 0
|
||||
&& *margin_left == 0
|
||||
&& *margin_right == 0
|
||||
&& *margin_top == 0
|
||||
&& *margin_bottom == 0
|
||||
&& *border_left == 0
|
||||
&& *border_right == 0
|
||||
&& border_top_style.is_none()
|
||||
&& border_bottom_style.is_none()
|
||||
&& surface_template_id.is_none()
|
||||
&& *vertical == VerticalAlign::Top
|
||||
{
|
||||
let full_content = formatted.clone();
|
||||
let mut ops = vec![LineOp::OwnContent {
|
||||
region: *region_id,
|
||||
start: 0,
|
||||
}];
|
||||
ops.extend(
|
||||
[*typography_style, *foreground_style, *background_style]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(LineOp::Style),
|
||||
);
|
||||
let rendered_content = project_box_lines(
|
||||
scope,
|
||||
BoxProjectionSlot::ScrollContent,
|
||||
full_content.clone(),
|
||||
&mut change.clone(),
|
||||
ops,
|
||||
);
|
||||
Some(Arc::new(RootScrollPlan {
|
||||
region_id: *region_id,
|
||||
full_content,
|
||||
rendered_content,
|
||||
visible_height: content_height,
|
||||
effective_offset: (*scroll_offset)
|
||||
.max(0)
|
||||
.min((text_height - content_height).max(0)),
|
||||
text_input,
|
||||
}))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let mut content_index_start = 0_i64;
|
||||
let mut overflow_lines = LinePlan::default();
|
||||
if let Some(start) = windowed_child_start {
|
||||
@ -7590,6 +7833,7 @@ fn render_node_body(
|
||||
change = None;
|
||||
}
|
||||
if *overflow == Overflow::Scroll && text_height > content_height {
|
||||
rendered.own_scroll_owner = true;
|
||||
rendered.lines = project_box_lines(
|
||||
scope,
|
||||
BoxProjectionSlot::ScrollWindow,
|
||||
@ -7598,6 +7842,7 @@ fn render_node_body(
|
||||
vec![LineOp::ScrollWindow(*region_id)],
|
||||
);
|
||||
}
|
||||
rendered.root_scroll = root_scroll;
|
||||
if let Some(change) = change {
|
||||
scope.publish_change(change);
|
||||
}
|
||||
@ -8798,6 +9043,9 @@ mod tests {
|
||||
#[test]
|
||||
fn line_sequence_oracle_wrap_slice_and_join_keep_distinct_breaks() {
|
||||
let source = Rendered {
|
||||
root_scroll: None,
|
||||
own_scroll_owner: false,
|
||||
scroll_owners: 0,
|
||||
lines: LinePlan::from_lines([
|
||||
Line::from_clusters(&[cluster("a", 2, None), cluster("b", 2, None)]),
|
||||
Line::from_clusters(&[cluster("c", 2, None), cluster("d", 2, None)]),
|
||||
|
||||
@ -372,6 +372,8 @@ struct ControlFrame {
|
||||
#[serde(default = "default_true")]
|
||||
root_metadata: bool,
|
||||
#[serde(default)]
|
||||
root_scroll_producer: bool,
|
||||
#[serde(default)]
|
||||
delay_ms: u64,
|
||||
}
|
||||
|
||||
@ -391,6 +393,7 @@ enum JobPayload {
|
||||
context_hash: i64,
|
||||
complete: bool,
|
||||
root_metadata: bool,
|
||||
root_scroll_producer: bool,
|
||||
document_base_revision: u64,
|
||||
document_target_revision: u64,
|
||||
validation_resolver_lookups: u64,
|
||||
@ -435,6 +438,7 @@ impl LayoutSource {
|
||||
root_width: Option<i64>,
|
||||
previous: Option<&RetainedFrame>,
|
||||
changes: Option<&SourceChanges>,
|
||||
root_scroll_producer: bool,
|
||||
) -> Result<(LayoutTape, Option<Arc<RetainedFrame>>), String> {
|
||||
match self {
|
||||
Self::Full(document) => {
|
||||
@ -444,7 +448,12 @@ impl LayoutSource {
|
||||
Ok((document.layout_tape(context, root_width)?, None))
|
||||
}
|
||||
Self::Retained(document) => {
|
||||
let frame = document.render_frame(previous, changes, context, root_width)?;
|
||||
let frame = if root_scroll_producer {
|
||||
document
|
||||
.render_frame_with_root_scroll(previous, changes, context, root_width)?
|
||||
} else {
|
||||
document.render_frame(previous, changes, context, root_width)?
|
||||
};
|
||||
Ok((frame.materialize_tape(), Some(frame)))
|
||||
}
|
||||
}
|
||||
@ -1430,6 +1439,9 @@ fn prepare_layout_job(
|
||||
source_changes: Option<SourceChanges>,
|
||||
) -> Result<PreparedJob, String> {
|
||||
layout::reset_resolver_lookups();
|
||||
if frame.root_scroll_producer && !frame.complete {
|
||||
return Err("Native root scroll producer requires complete properties".to_owned());
|
||||
}
|
||||
if frame.payload.is_some() {
|
||||
return Err(format!(
|
||||
"Native layout frame {} cannot contain an echo payload",
|
||||
@ -1512,6 +1524,7 @@ fn prepare_layout_job(
|
||||
context_hash: frame.context_hash,
|
||||
complete: frame.complete,
|
||||
root_metadata: frame.root_metadata,
|
||||
root_scroll_producer: frame.root_scroll_producer,
|
||||
document_base_revision,
|
||||
document_target_revision,
|
||||
validation_resolver_lookups: layout::resolver_lookups(),
|
||||
@ -1553,6 +1566,7 @@ fn render_layout_payload(
|
||||
context_hash,
|
||||
complete,
|
||||
root_metadata,
|
||||
root_scroll_producer,
|
||||
document_base_revision,
|
||||
document_target_revision,
|
||||
validation_resolver_lookups,
|
||||
@ -1611,6 +1625,7 @@ fn render_layout_payload(
|
||||
.as_ref()
|
||||
.and_then(|baseline| baseline.retained_frame.as_deref()),
|
||||
source_changes.as_ref(),
|
||||
root_scroll_producer,
|
||||
)
|
||||
};
|
||||
if let Some(base_context) = base_context {
|
||||
@ -1637,12 +1652,15 @@ fn render_layout_payload(
|
||||
.cloned();
|
||||
if require_confirmed_patch_base && base_hit.is_none() {
|
||||
let (target, retained_frame) = render_target()?;
|
||||
let bytes = layout::encode_layout_tape(
|
||||
let bytes = layout::encode_layout_tape_with_scroll(
|
||||
target.clone(),
|
||||
&target_styles,
|
||||
identity,
|
||||
output.root_metadata,
|
||||
output.max_bytes,
|
||||
retained_frame
|
||||
.as_ref()
|
||||
.and_then(|frame| frame.root_scroll()),
|
||||
)?;
|
||||
return Ok((bytes, target, target_styles, retained_frame, false, 0, 1));
|
||||
}
|
||||
@ -1659,13 +1677,26 @@ fn render_layout_payload(
|
||||
)
|
||||
};
|
||||
let (target, retained_frame) = render_target()?;
|
||||
let bytes = layout::encode_layout_patch_tape(
|
||||
let bytes = layout::encode_layout_patch_tape_with_scroll(
|
||||
old,
|
||||
target.clone(),
|
||||
&target_styles,
|
||||
identity,
|
||||
output.root_metadata,
|
||||
output.max_bytes,
|
||||
retained_frame
|
||||
.as_ref()
|
||||
.and_then(|frame| frame.root_scroll())
|
||||
.map(|scroll| {
|
||||
(
|
||||
scroll,
|
||||
confirmed_baseline
|
||||
.as_ref()
|
||||
.filter(|_| baseline_hit)
|
||||
.and_then(|base| base.retained_frame.as_ref())
|
||||
.and_then(|frame| frame.root_scroll()),
|
||||
)
|
||||
}),
|
||||
)?;
|
||||
Ok((
|
||||
bytes,
|
||||
@ -1678,12 +1709,15 @@ fn render_layout_payload(
|
||||
))
|
||||
} else {
|
||||
let (target, retained_frame) = render_target()?;
|
||||
let bytes = layout::encode_layout_tape(
|
||||
let bytes = layout::encode_layout_tape_with_scroll(
|
||||
target.clone(),
|
||||
&target_styles,
|
||||
identity,
|
||||
output.root_metadata,
|
||||
output.max_bytes,
|
||||
retained_frame
|
||||
.as_ref()
|
||||
.and_then(|frame| frame.root_scroll()),
|
||||
)?;
|
||||
Ok((bytes, target, target_styles, retained_frame, false, 0, 1))
|
||||
}
|
||||
|
||||
@ -16,6 +16,7 @@ pub(in crate::layout) enum BoxProjectionSlot {
|
||||
MarginEdges,
|
||||
OverflowEdges,
|
||||
ScrollWindow,
|
||||
ScrollContent,
|
||||
TransparentContent,
|
||||
}
|
||||
|
||||
|
||||
@ -13386,4 +13386,35 @@ face patch must match that exactly (issue014)."
|
||||
(should (= (plist-get peer-after :node-id) peer-node-id)))))
|
||||
(when (buffer-live-p buffer) (kill-buffer buffer))))))
|
||||
|
||||
(ert-deftest ebox-scroll-span-index-scans-property-runs-once ()
|
||||
"Index all regions in one pass while preserving single-region semantics."
|
||||
(let* ((line (apply #'concat
|
||||
(cl-loop for id from 1 to 32
|
||||
collect (propertize "x" 'ebox-content id
|
||||
'ebox-content-owners '(999)))))
|
||||
(lines (list line
|
||||
(concat (propertize "a" 'ebox-content 7)
|
||||
"gap" (propertize "b" 'ebox-content 7))
|
||||
"plain"))
|
||||
(expected (make-hash-table :test 'equal))
|
||||
(next (symbol-function 'ebox--string-next-region-property-change))
|
||||
(calls 0) actual)
|
||||
;; The established query is an independent oracle for compatibility and
|
||||
;; disconnected hulls, including rejection of a foreign ancestor owner.
|
||||
(cl-loop for text in lines for number from 0 do
|
||||
(dolist (id (ebox--scroll-line-region-ids text))
|
||||
(when-let* ((span (ebox--scroll-line-region-span
|
||||
text (ebox--region-id-set (list id)))))
|
||||
(puthash id (append (gethash id expected)
|
||||
(list (cons number span))) expected))))
|
||||
(cl-letf (((symbol-function 'ebox--string-next-region-property-change)
|
||||
(lambda (&rest args)
|
||||
(cl-incf calls)
|
||||
(apply next args))))
|
||||
(setq actual (ebox--scroll-build-region-line-span-index lines)))
|
||||
(should (= (hash-table-count actual) (hash-table-count expected)))
|
||||
(maphash (lambda (id spans) (should (equal (gethash id actual) spans))) expected)
|
||||
(should-not (gethash 999 actual))
|
||||
(should (= calls 36))))
|
||||
|
||||
;;; ebox-core-render-tests.el ends here
|
||||
|
||||
@ -1520,6 +1520,230 @@ candidate cannot hide mutations by restoring the old hash-table pointer."
|
||||
(should (ebox-native-reflow-session-released-p
|
||||
committed-session))))))
|
||||
|
||||
(ert-deftest ebox-native-scroll-window-retains-viewport-continuity ()
|
||||
"Clipping and scrolling preserve native frames, including failed commits."
|
||||
(skip-unless (ebox-native-reflow-layout-ready-p))
|
||||
(require 'ebox-native-commit)
|
||||
(ebox-surface-test--reset-render-state)
|
||||
(let ((buffer (generate-new-buffer " *ebox-native-scroll-continuity*"))
|
||||
(oracle (generate-new-buffer " *ebox-scroll-continuity-oracle*"))
|
||||
(ebox-viewport-width 120)
|
||||
(ebox-viewport-height 3)
|
||||
(ebox-native-buffer-scroll nil)
|
||||
(ebox-runtime-idle-prewarm nil)
|
||||
(ebox-runtime-idle-reflow-cache-prewarm nil)
|
||||
(keymap (make-sparse-keymap))
|
||||
(original-frame (symbol-function 'ebox-native-commit--retained-frame))
|
||||
(original-send
|
||||
(symbol-function 'ebox-native--module-render-session-frame))
|
||||
(original-layout (symbol-function 'ebox--render-layout))
|
||||
(original-fork (symbol-function 'ebox-native-reflow-fork-session))
|
||||
(original-confirm (symbol-function 'ebox-native-reflow-confirm-native-frame))
|
||||
frames controls ordinary-renders identities previous-session
|
||||
committed-session)
|
||||
(define-key keymap [mouse-1] #'ignore)
|
||||
(unwind-protect
|
||||
(cl-labels
|
||||
((fixture
|
||||
()
|
||||
(ebox-test-box
|
||||
:key 'scroll-root :source-identity 'scroll-root :id "scroll"
|
||||
:width '(viewport) :height '(viewport-height) :overflow 'scroll
|
||||
(ebox-test-text
|
||||
(mapconcat
|
||||
(lambda (label)
|
||||
(propertize label 'keymap keymap 'mouse-face 'highlight
|
||||
'help-echo (concat "line-" label)
|
||||
'face '(:weight bold)))
|
||||
'("A" "B" "C") "\n")
|
||||
:key 'scroll-text :source-identity 'scroll-text)))
|
||||
(contents
|
||||
(target)
|
||||
(with-current-buffer target
|
||||
(buffer-substring (point-min) (point-max))))
|
||||
(advance
|
||||
(target step)
|
||||
(pcase step
|
||||
('mount (ebox-render-to-buffer target (fixture)))
|
||||
((or 'shrink 'reshrink)
|
||||
(ebox-rerender-buffer-with-context target 120 1))
|
||||
('scroll
|
||||
(let* ((state (ebox--buffer-render-state target))
|
||||
(region (plist-get (plist-get state :root-node)
|
||||
:region-id)))
|
||||
(with-current-buffer target
|
||||
(should (= (ebox--scroll-region-by region 1 1) 1)))))
|
||||
('grow (ebox-rerender-buffer-with-context target 120 3))))
|
||||
(reject-advance
|
||||
(step failure)
|
||||
(let* ((surface (with-current-buffer buffer ebox-surface--buffer-surface))
|
||||
(state (tp-surface-client-state surface))
|
||||
(root (plist-get state :root-node))
|
||||
(region (plist-get root :region-id))
|
||||
(offset (or (ebox-get root :scroll-offset) 0))
|
||||
(producer (plist-get state :native-root-scroll-producer))
|
||||
(scroll (gethash region (plist-get state :scroll-state-table)))
|
||||
(scroll-offset (plist-get scroll :scroll-offset))
|
||||
(scroll-height (plist-get scroll :content-height))
|
||||
(session (plist-get state :native-sync-session))
|
||||
(generation (ebox-native-reflow-session-generation session))
|
||||
(revision (tp-surface-revision surface))
|
||||
(before (contents buffer))
|
||||
candidate)
|
||||
(cl-letf
|
||||
(((symbol-function 'ebox-native-reflow-fork-session)
|
||||
(lambda (&rest args)
|
||||
(setq candidate (apply original-fork args))))
|
||||
((symbol-function 'ebox-native-reflow-confirm-native-frame)
|
||||
(lambda (&rest args)
|
||||
(if (eq failure 'confirm)
|
||||
(error "Reject clipped native confirmation")
|
||||
(apply original-confirm args))))
|
||||
(tp--surface-publication-step-function
|
||||
(lambda (part _surface)
|
||||
(when (and (eq failure 'publication) (eq part 'client-state))
|
||||
(error "Reject clipped native publication")))))
|
||||
(should-error (advance buffer step)))
|
||||
(should candidate)
|
||||
(should (ebox-native-reflow-session-released-p candidate))
|
||||
(should-not (ebox-native-reflow-session-released-p session))
|
||||
(should (= (ebox-native-reflow-session-generation session) generation))
|
||||
(should (= (tp-surface-revision surface) revision))
|
||||
(should (eq (tp-surface-client-state surface) state))
|
||||
(should (eq (plist-get state :root-node) root))
|
||||
(should (= (or (ebox-get root :scroll-offset) 0) offset))
|
||||
(should (eq (plist-get state :native-root-scroll-producer) producer))
|
||||
(should (eq (gethash region (plist-get state :scroll-state-table)) scroll))
|
||||
(should (equal (plist-get scroll :scroll-offset) scroll-offset))
|
||||
(should (equal (plist-get scroll :content-height) scroll-height))
|
||||
(should (equal-including-properties (contents buffer) before)))))
|
||||
(dolist (case '((mount 3 0 "A\nB\nC" nil)
|
||||
(shrink 1 0 "A" t)
|
||||
(scroll 1 1 "B" t)
|
||||
(grow 3 0 "A\nB\nC" nil)
|
||||
(reshrink 1 0 "A" t)))
|
||||
(pcase-let ((`(,step ,height ,offset ,labels ,window-p) case))
|
||||
;; Use an independent ordinary surface, including its real scroll
|
||||
;; transition, so oracle rendering cannot mutate the native state.
|
||||
(ebox-surface-test--with-elisp-backend (advance oracle step))
|
||||
(when (memq step '(shrink grow))
|
||||
(reject-advance step 'publication)
|
||||
(reject-advance step 'confirm))
|
||||
(setq frames nil controls nil ordinary-renders 0)
|
||||
(cl-letf
|
||||
(((symbol-function 'ebox-native-commit--retained-frame)
|
||||
(lambda (&rest args)
|
||||
(let ((frame (apply original-frame args)))
|
||||
(push frame frames)
|
||||
frame)))
|
||||
((symbol-function 'ebox-native--module-render-session-frame)
|
||||
(lambda (handle generation control)
|
||||
(push (json-parse-string control :object-type 'plist
|
||||
:array-type 'array)
|
||||
controls)
|
||||
(funcall original-send handle generation control)))
|
||||
((symbol-function 'ebox--render-layout)
|
||||
(lambda (&rest args)
|
||||
(cl-incf ordinary-renders)
|
||||
(apply original-layout args))))
|
||||
(advance buffer step))
|
||||
(let* ((state (ebox--buffer-render-state buffer))
|
||||
(root (plist-get state :root-node))
|
||||
(region (plist-get root :region-id))
|
||||
(session (plist-get state :native-sync-session))
|
||||
(effects (plist-get (car frames) :effect-tape))
|
||||
(scroll-state (gethash region
|
||||
(plist-get state :scroll-state-table)))
|
||||
(actual (contents buffer))
|
||||
current-identities)
|
||||
(ert-info ((format "Native scroll continuity step %S" step))
|
||||
(message "Native scroll %S: calls=%S frames=%S window=%S projection=%S fallback=%S"
|
||||
step (length controls) (length frames)
|
||||
(plist-get effects :scroll-window-p)
|
||||
(plist-get state :projection-kind)
|
||||
(plist-get state :native-render-fallback))
|
||||
;; Observe the actual module result before asserting native
|
||||
;; publication: the H1 regression must reach the marked frame.
|
||||
(should (= (length controls) 1))
|
||||
(should (eq (plist-get (aref (plist-get (car controls) :frames) 0)
|
||||
:root-scroll-producer) t))
|
||||
(should (= (length frames) 1))
|
||||
(should (stringp (plist-get (car frames) :rendered)))
|
||||
(should (vectorp (plist-get effects :fragment-span-template)))
|
||||
(should (eq (plist-get effects :scroll-window-p) window-p))
|
||||
(should (eq (plist-get state :projection-kind) 'native-frame))
|
||||
(should (= ordinary-renders 0))
|
||||
(should-not (plist-get state :native-render-fallback))
|
||||
(should session)
|
||||
(setq committed-session session)
|
||||
(should-not (ebox-native-reflow-session-released-p session))
|
||||
(should (plist-get state :native-sync-confirmed-p))
|
||||
(should-not (plist-get state :native-sync-pending))
|
||||
(let ((work (plist-get (ebox-native-reflow-stats session) :eval-work))
|
||||
(producer (plist-get state :native-root-scroll-producer)))
|
||||
(should (= (plist-get work :scroll-producer-reuses)
|
||||
(if (eq step 'scroll) 1 0)))
|
||||
(should (= (plist-get work :scroll-producer-lines-encoded)
|
||||
(if (memq step '(shrink reshrink)) 6 0)))
|
||||
(unless window-p
|
||||
(should (eq (aref producer 4) :inactive))
|
||||
(should (eq (aref producer 5) :inactive))))
|
||||
(when previous-session
|
||||
(should-not (eq session previous-session))
|
||||
(should (ebox-native-reflow-session-released-p previous-session))
|
||||
(should-not (plist-get (car controls) :document)))
|
||||
(setq previous-session session)
|
||||
(ebox-surface-test--walk-runtime
|
||||
root
|
||||
(lambda (node)
|
||||
(push (list (plist-get node :node-id)
|
||||
(plist-get node :region-id)
|
||||
(plist-get node :surface-object))
|
||||
current-identities)))
|
||||
(if identities
|
||||
(cl-mapc
|
||||
(lambda (old new)
|
||||
(should (equal (seq-take old 2) (seq-take new 2)))
|
||||
(should (eq (nth 2 old) (nth 2 new))))
|
||||
identities current-identities)
|
||||
(setq identities current-identities))
|
||||
(should (= (length current-identities) 2))
|
||||
(should (= (plist-get state :viewport-height) height))
|
||||
(should (equal (replace-regexp-in-string
|
||||
"[[:blank:]]" "" actual) labels))
|
||||
(should
|
||||
(equal-including-properties
|
||||
(ebox-surface-test--canonical-region-properties actual)
|
||||
(ebox-surface-test--canonical-region-properties
|
||||
(contents oracle))))
|
||||
(with-current-buffer buffer
|
||||
(goto-char (point-min))
|
||||
(while (re-search-forward "[ABC]" nil t)
|
||||
(let* ((position (match-beginning 0))
|
||||
(label (match-string-no-properties 0))
|
||||
(index (- (aref label 0) ?A)))
|
||||
(should (equal (get-text-property position 'keymap) keymap))
|
||||
(should (equal (get-text-property position 'help-echo)
|
||||
(concat "line-" label)))
|
||||
(should (= (get-text-property position 'ebox-content-idx)
|
||||
index))
|
||||
(should (equal (get-text-property position 'ebox-scroll-window)
|
||||
(and window-p region))))))
|
||||
(if window-p
|
||||
(progn
|
||||
(should scroll-state)
|
||||
(should (= (plist-get scroll-state :scroll-offset) offset))
|
||||
(should (= (plist-get scroll-state :content-height) height))
|
||||
(should (plist-get scroll-state :content-lines-complete-p))
|
||||
(should (= (length (plist-get scroll-state
|
||||
:rendered-content-lines)) 3)))
|
||||
(should-not scroll-state)
|
||||
(should (= (or (plist-get root :scroll-offset) 0) 0))))))))
|
||||
(when (buffer-live-p buffer) (kill-buffer buffer))
|
||||
(when (buffer-live-p oracle) (kill-buffer oracle))
|
||||
(when committed-session
|
||||
(should (ebox-native-reflow-session-released-p committed-session))))))
|
||||
|
||||
(ert-deftest ebox-native-full-frame-bootstraps-from-ordinary-surface ()
|
||||
"Native bootstrap falls back truthfully and remains retryable."
|
||||
(skip-unless (ebox-native-reflow-layout-ready-p))
|
||||
|
||||
Loading…
Reference in New Issue
Block a user