From 727aba2ec77a31486ef86a1dc93645ca67c0888f Mon Sep 17 00:00:00 2001 From: Kinneyzhang Date: Sat, 5 Sep 2026 17:26:13 +0800 Subject: [PATCH] Preserve native scroll continuity with explicit retained producers --- ebox-incremental.el | 27 +++- ebox-native-commit.el | 104 ++++++++++++- ebox-native-reflow.el | 57 ++++++- ebox-surface.el | 5 +- ebox.el | 40 +++-- native/src/evaluation.rs | 113 +++++++++++++- native/src/evaluation_tests.rs | 195 ++++++++++++++++++++++++ native/src/layout.rs | 260 +++++++++++++++++++++++++++++++- native/src/lib.rs | 42 +++++- native/src/line_ops.rs | 1 + tests/ebox-core-render-tests.el | 31 ++++ tests/ebox-surface-tests.el | 224 +++++++++++++++++++++++++++ 12 files changed, 1062 insertions(+), 37 deletions(-) diff --git a/ebox-incremental.el b/ebox-incremental.el index 539a500..c0e462c 100644 --- a/ebox-incremental.el +++ b/ebox-incremental.el @@ -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 diff --git a/ebox-native-commit.el b/ebox-native-commit.el index f1e76c4..a97950e 100644 --- a/ebox-native-commit.el +++ b/ebox-native-commit.el @@ -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)) diff --git a/ebox-native-reflow.el b/ebox-native-reflow.el index ee441be..3ae6d5e 100644 --- a/ebox-native-reflow.el +++ b/ebox-native-reflow.el @@ -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) diff --git a/ebox-surface.el b/ebox-surface.el index 63b34d2..892b08e 100644 --- a/ebox-surface.el +++ b/ebox-surface.el @@ -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)) diff --git a/ebox.el b/ebox.el index 0a4bb47..aea366f 100644 --- a/ebox.el +++ b/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) diff --git a/native/src/evaluation.rs b/native/src/evaluation.rs index d4f2341..145ff0c 100644 --- a/native/src/evaluation.rs +++ b/native/src/evaluation.rs @@ -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, + 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, value: Arc, 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, root: Option>, + complete_scroll_effects: bool, } impl RenderTxn { @@ -499,7 +548,13 @@ impl RenderTxn { fn finish(&mut self, rendered: Result) -> Result { 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> { + 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, + ) -> Result, String> { + self.render_frame_mode(previous, changes, context, root_width_override, false) + } + + pub(crate) fn render_frame_with_root_scroll( + self: &Arc, + previous: Option<&RetainedFrame>, + changes: Option<&SourceChanges>, + context: LayoutContext, + root_width_override: Option, + ) -> Result, String> { + self.render_frame_mode(previous, changes, context, root_width_override, true) + } + + fn render_frame_mode( + self: &Arc, + previous: Option<&RetainedFrame>, + changes: Option<&SourceChanges>, + context: LayoutContext, + root_width_override: Option, + complete_scroll_effects: bool, ) -> Result, 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 diff --git a/native/src/evaluation_tests.rs b/native/src/evaluation_tests.rs index 27008dd..bdb0f46 100644 --- a/native/src/evaluation_tests.rs +++ b/native/src/evaluation_tests.rs @@ -130,6 +130,145 @@ fn assert_oracle(frame: &RetainedFrame, context: LayoutContext, root_width: Opti ); } +fn sole_root_scroll_document(child: LayoutNode) -> Arc { + 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, ) -> Arc { 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 { .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(), diff --git a/native/src/layout.rs b/native/src/layout.rs index 211e7c1..4b1671a 100644 --- a/native/src/layout.rs +++ b/native/src/layout.rs @@ -2191,17 +2191,48 @@ impl Atom { #[derive(Debug, Clone)] struct Rendered { lines: LinePlan, + root_scroll: Option>, + 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, +} + +#[derive(Debug)] +struct RootTextInput { + source: Arc, + width: i64, + wrap: WrapMode, + region: Option, + styles: [Option; 3], + lines: LinePlan, } impl Rendered { fn from_lines(lines: Vec) -> 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, + 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 { 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, 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, 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, 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, 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 { @@ -6365,7 +6520,7 @@ fn stack_vertical(parts: Vec) -> 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)]), diff --git a/native/src/lib.rs b/native/src/lib.rs index 44c82ad..0d67247 100644 --- a/native/src/lib.rs +++ b/native/src/lib.rs @@ -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, previous: Option<&RetainedFrame>, changes: Option<&SourceChanges>, + root_scroll_producer: bool, ) -> Result<(LayoutTape, Option>), 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, ) -> Result { 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)) } diff --git a/native/src/line_ops.rs b/native/src/line_ops.rs index 23e6bde..c3c5b40 100644 --- a/native/src/line_ops.rs +++ b/native/src/line_ops.rs @@ -16,6 +16,7 @@ pub(in crate::layout) enum BoxProjectionSlot { MarginEdges, OverflowEdges, ScrollWindow, + ScrollContent, TransparentContent, } diff --git a/tests/ebox-core-render-tests.el b/tests/ebox-core-render-tests.el index fa755be..337e526 100644 --- a/tests/ebox-core-render-tests.el +++ b/tests/ebox-core-render-tests.el @@ -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 diff --git a/tests/ebox-surface-tests.el b/tests/ebox-surface-tests.el index 3989dd9..c9d3cdd 100644 --- a/tests/ebox-surface-tests.el +++ b/tests/ebox-surface-tests.el @@ -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))