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

This commit is contained in:
Kinneyzhang 2026-09-05 17:26:13 +08:00
parent 73357dee94
commit 727aba2ec7
12 changed files with 1062 additions and 37 deletions

View File

@ -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

View File

@ -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))

View File

@ -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)

View File

@ -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
View File

@ -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)

View File

@ -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

View File

@ -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(),

View File

@ -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)]),

View File

@ -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))
}

View File

@ -16,6 +16,7 @@ pub(in crate::layout) enum BoxProjectionSlot {
MarginEdges,
OverflowEdges,
ScrollWindow,
ScrollContent,
TransparentContent,
}

View File

@ -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

View File

@ -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))