From 9767c302085e66c1c58cd91be424d22216763426 Mon Sep 17 00:00:00 2001 From: Kinneyzhang Date: Sat, 5 Sep 2026 07:31:01 +0800 Subject: [PATCH 01/12] perf: retain validated native document input across commits --- ...box-current-implementation-reference.en.md | 8 + ...box-current-implementation-reference.zh.md | 5 + ebox-native-commit.el | 15 +- ebox-native-reflow.el | 113 ++++- native/c/ebox_module.c | 2 +- native/src/lib.rs | 394 ++++++++++++++++-- tests/ebox-commit-tests.el | 168 ++++++++ tests/ebox-surface-tests.el | 15 +- 8 files changed, 651 insertions(+), 69 deletions(-) diff --git a/docs/maintainer/ebox-current-implementation-reference.en.md b/docs/maintainer/ebox-current-implementation-reference.en.md index 1385621..9f7b909 100644 --- a/docs/maintainer/ebox-current-implementation-reference.en.md +++ b/docs/maintainer/ebox-current-implementation-reference.en.md @@ -104,6 +104,14 @@ Caller-owned Source Tree - `owner-rerender` is broader than `span-patch`, which is broader than `paint-patch`. - Buffer coordinates belong to the generation that produced them and must be refreshed after mutation. - Grid uses the normal measurement and rendering pipeline. Native reflow may reject an ineligible tree and must fall back to Elisp without changing correctness. +- A confirmed retained-native frame owns an immutable layout document together + with its exact document revision. Candidate-session forks share that document. + A synchronous frame may omit the document only when its base and target + document revisions are equal and match the confirmed document, and its TP + runtime revision matches the confirmed frame. A changed IR sends a complete + replacement document with the next document revision; confirmation promotes + the candidate document atomically, while failure or cancellation leaves the + parent session unchanged. - A full declarative update continuing a committed native frame computes selector-local styles for every candidate node before stable native eligibility. The `:styles-prepared-p` certificate prevents replacement diff --git a/docs/maintainer/ebox-current-implementation-reference.zh.md b/docs/maintainer/ebox-current-implementation-reference.zh.md index da48302..5e1a47b 100644 --- a/docs/maintainer/ebox-current-implementation-reference.zh.md +++ b/docs/maintainer/ebox-current-implementation-reference.zh.md @@ -103,6 +103,11 @@ active 合同还覆盖 `Makefile`、`.github/workflows/ci.yml`、`tests/ebox-cor - `owner-rerender` 范围大于 `span-patch`,`span-patch` 大于 `paint-patch`。 - Buffer 坐标属于生成它的 generation,变更后必须重新获取。 - Grid 使用普通测量与渲染流水线;native reflow 可以拒绝不适合的树并回退到 Elisp,正确性不变。 +- 已确认的 retained-native frame 持有不可变布局文档及其精确文档 revision;候选 + session 的 fork 共享该文档。同步 frame 只有在文档 base/target revision 相等且 + 命中 confirmed 文档、同时 TP runtime revision 命中 confirmed frame 时,才可 + 省略文档。IR 变化必须携带下一 document revision 的完整 replacement document; + confirm 原子晋升候选文档,失败或取消不修改 parent session。 - 只有继续已提交 native frame 的 full declarative update,才会在 stable native eligibility 前为全部 candidate node 计算 selector-local style;生成的 `:styles-prepared-p` certificate 防止 replacement runtime 丢失 computed style, diff --git a/ebox-native-commit.el b/ebox-native-commit.el index 51684a4..5d15ff7 100644 --- a/ebox-native-commit.el +++ b/ebox-native-commit.el @@ -22,6 +22,8 @@ "ebox-native-reflow" (&rest arguments)) (declare-function ebox-native-reflow-fork-session "ebox-native-reflow" (session &rest options)) +(declare-function ebox-native-reflow-release-session + "ebox-native-reflow" (session)) (declare-function ebox-native-reflow-confirm-native-frame "ebox-native-reflow" (session generation key confirmed-revision)) @@ -637,8 +639,17 @@ publication transaction can still roll back." (defun ebox-native-commit--downgrade-candidate (state reason) "Downgrade STATE after native rendering fails for diagnostic REASON." - (dolist (key ebox-native-commit--failed-render-state-keys) - (cl-remf state key)) + (let ((session (plist-get state :native-sync-session))) + (dolist (key ebox-native-commit--failed-render-state-keys) + (cl-remf state key)) + ;; Once native execution begins STATE owns an isolated candidate session. + ;; Release it before removing the only reference, while containing cleanup + ;; faults so the ordinary semantic fallback remains the primary result. + (when session + (let ((inhibit-quit t) (quit-flag nil)) + (condition-case nil + (ebox-native-reflow-release-session session) + ((error quit) nil))))) (plist-put state :projection-kind nil) ;; Surface consumes this once into the transaction report, then removes it ;; from runtime state. It never participates in projection eligibility. diff --git a/ebox-native-reflow.el b/ebox-native-reflow.el index 877a806..43c67dc 100644 --- a/ebox-native-reflow.el +++ b/ebox-native-reflow.el @@ -95,7 +95,7 @@ (defvar ebox--flex-content-min-width-table) -(defconst ebox-native-reflow-abi-version "10:7:12" +(defconst ebox-native-reflow-abi-version "11:7:12" "Version tuple shared by the native module, layout IR, and render tape.") (defconst ebox-native-reflow--minimum-rust-version "1.82.0" @@ -2222,9 +2222,10 @@ backend has a matching two-dimensional layout contract." (and (listp node) (plist-get node :ebox-type)))))) (defun ebox-native-reflow--layout-package-from-root - (root styles &optional property-templates) + (root styles &optional property-templates document-revision) "Return one immutable native package from compiled ROOT and STYLES. -PROPERTY-TEMPLATES are Emacs-owned plists addressed by opaque native ids." +PROPERTY-TEMPLATES are Emacs-owned plists addressed by opaque native ids. +DOCUMENT-REVISION is the exact retained-input revision, defaulting to one." (list :document (list :version 2 :space-width (ebox--space-pixel-width) @@ -2246,9 +2247,45 @@ PROPERTY-TEMPLATES are Emacs-owned plists addressed by opaque native ids." (plist-get descriptor :face))))) styles)) :root root) + :document-revision (or document-revision 1) :styles (vconcat styles) :property-templates (vconcat property-templates))) +(defun ebox-native-reflow--vector-elements-eq-p (left right) + "Return non-nil when LEFT and RIGHT contain the same objects in order." + (and (vectorp left) + (vectorp right) + (= (length left) (length right)) + (cl-loop for index below (length left) + always (eq (aref left index) (aref right index))))) + +(defun ebox-native-reflow--reuse-exact-layout-package (old candidate) + "Return OLD when CANDIDATE has exactly the same immutable layout input. +Otherwise return CANDIDATE with the next document revision." + (let ((old-document (plist-get old :document)) + (candidate-document (plist-get candidate :document))) + (if (and old old-document candidate-document + (eq (plist-get old-document :root) + (plist-get candidate-document :root)) + (equal (plist-get old-document :version) + (plist-get candidate-document :version)) + (equal (plist-get old-document :space-width) + (plist-get candidate-document :space-width)) + (equal (plist-get old-document :style-count) + (plist-get candidate-document :style-count)) + (equal (plist-get old-document :property-template-count) + (plist-get candidate-document + :property-template-count)) + (ebox-native-reflow--vector-elements-eq-p + (plist-get old :styles) (plist-get candidate :styles)) + (ebox-native-reflow--vector-elements-eq-p + (plist-get old :property-templates) + (plist-get candidate :property-templates))) + old + (let ((replacement (copy-sequence candidate))) + (plist-put replacement :document-revision + (1+ (or (plist-get old :document-revision) 0))))))) + (defun ebox-native-reflow--buffer-display-signature (buffer) "Return BUFFER's live canonical Surface display capability." (with-current-buffer buffer @@ -2610,11 +2647,15 @@ per call, and no call recursively visits the captured Ebox tree." (compile-current current)))) (when retained-fast-p (seed-fragment node))) - (let ((package - (ebox-native-reflow--layout-package-from-root - (ebox-native-reflow--compiled-scene-child fragments node) - ebox-native-reflow--compile-styles - ebox-native-reflow--compile-property-templates))) + (let* ((candidate + (ebox-native-reflow--layout-package-from-root + (ebox-native-reflow--compiled-scene-child fragments node) + ebox-native-reflow--compile-styles + ebox-native-reflow--compile-property-templates)) + (package + (ebox-native-reflow--reuse-exact-layout-package + (ebox-native-reflow-session-layout-package session) + candidate))) (setf (ebox-native-reflow-session-layout-fragment-cache session) new-cache (ebox-native-reflow-session-layout-fragment-revision session) @@ -2639,8 +2680,11 @@ per call, and no call recursively visits the captured Ebox tree." (encode-coding-string (json-encode control) 'utf-8-unix))))) -(defun ebox-native-reflow--layout-control-json (document frames) - "Return strict control JSON for optional layout DOCUMENT and frame contexts." +(defun ebox-native-reflow--layout-control-json + (document frames &optional document-base-revision + document-target-revision) + "Return strict control JSON for optional layout DOCUMENT and FRAMES. +DOCUMENT-BASE-REVISION and DOCUMENT-TARGET-REVISION bind retained input." (unless (and (listp frames) frames) (error "Native reflow requires at least one layout frame")) (let ((control @@ -2738,6 +2782,14 @@ per call, and no call recursively visits the captured Ebox tree." :base-root-width-override (if base-root-width-override-p t :false)))))) frames))))) + (when document-base-revision + (setq control + (plist-put control :document-base-revision + document-base-revision))) + (when document-target-revision + (setq control + (plist-put control :document-target-revision + document-target-revision))) (when document (setq control (plist-put control :document document))) (ebox-native-reflow--serialize-layout-control control))) @@ -2748,22 +2800,29 @@ per call, and no call recursively visits the captured Ebox tree." LAYOUT-PACKAGE may reuse a caller-validated document for the same NODE." (unless (and (integerp generation) (>= generation 0)) (error "Native reflow generation must be a nonnegative integer")) - (let* ((package (or layout-package - (ebox-native-reflow--compile-layout-package - node - (ebox-native-reflow-session-styles session) - (plist-get - (ebox-native-reflow-session-layout-package session) - :property-templates)))) + (let* ((old-package (ebox-native-reflow-session-layout-package session)) + (candidate-package + (or layout-package + (ebox-native-reflow--compile-layout-package + node + (ebox-native-reflow-session-styles session) + (plist-get old-package :property-templates)))) + (package + (ebox-native-reflow--reuse-exact-layout-package + old-package candidate-package)) (register-layout-p (not (eq package - (ebox-native-reflow-session-layout-package session)))) + old-package))) + (base-revision (or (plist-get old-package :document-revision) 0)) + (target-revision (or (plist-get package :document-revision) + (1+ base-revision))) (accepted (ebox-native--module-submit (ebox-native-reflow--live-handle session) generation (ebox-native-reflow--layout-control-json - (and register-layout-p (plist-get package :document)) frames)))) + (and register-layout-p (plist-get package :document)) frames + base-revision target-revision)))) (setf (ebox-native-reflow-session-generation session) generation) (setf (ebox-native-reflow-session-styles session) (plist-get package :styles)) @@ -3896,7 +3955,9 @@ root effect metadata required by a thin host commit." (unless (and (ebox-native-reflow-session-p session) (fboundp 'ebox-native--module-render-session-frame)) (error "Native retained frame execution is unavailable")) - (let* ((package + (let* ((old-package + (ebox-native-reflow-session-layout-package session)) + (candidate-package (or layout-package (if state (ebox-native-reflow--compile-retained-layout-package @@ -3907,9 +3968,16 @@ root effect metadata required by a thin host commit." (plist-get (ebox-native-reflow-session-layout-package session) :property-templates))))) + (package + (ebox-native-reflow--reuse-exact-layout-package + old-package candidate-package)) (generation (1+ (ebox-native-reflow-session-generation session))) (control-frame (copy-sequence frame)) - (native-key (or (plist-get control-frame :key) 1))) + (native-key (or (plist-get control-frame :key) 1)) + (reuse-document-p (eq package old-package)) + (base-revision (or (plist-get old-package :document-revision) 0)) + (target-revision (or (plist-get package :document-revision) + (1+ base-revision)))) (setq control-frame (plist-put control-frame :key native-key)) (let ((result (ebox-native-reflow--materialize-module-frame @@ -3917,7 +3985,8 @@ root effect metadata required by a thin host commit." (ebox-native-reflow--live-handle session) generation (ebox-native-reflow--layout-control-json - (plist-get package :document) (list control-frame))) + (unless reuse-document-p (plist-get package :document)) + (list control-frame) base-revision target-revision)) package control-frame generation))) (setf (ebox-native-reflow-session-generation session) generation (ebox-native-reflow-session-layout-package session) package diff --git a/native/c/ebox_module.c b/native/c/ebox_module.c index 946c7aa..c4ac048 100644 --- a/native/c/ebox_module.c +++ b/native/c/ebox_module.c @@ -164,7 +164,7 @@ ebox_module_version(emacs_env *env, ptrdiff_t nargs, emacs_value *args, (void) nargs; (void) args; (void) data; - static const char version[] = "10:7:12"; + static const char version[] = "11:7:12"; return env->make_string(env, version, (ptrdiff_t) (sizeof version - 1)); } diff --git a/native/src/lib.rs b/native/src/lib.rs index b2a679a..de40b2b 100644 --- a/native/src/lib.rs +++ b/native/src/lib.rs @@ -324,6 +324,10 @@ struct ControlBatch { version: u32, #[serde(default)] document: Option, + #[serde(default, rename = "document-base-revision")] + document_base_revision: Option, + #[serde(default, rename = "document-target-revision")] + document_target_revision: Option, frames: Vec, } @@ -382,6 +386,8 @@ enum JobPayload { context_hash: i64, complete: bool, root_metadata: bool, + document_base_revision: u64, + document_target_revision: u64, }, } @@ -398,6 +404,8 @@ struct BaselineIdentity { #[derive(Clone, Debug)] struct ConfirmedBaseline { identity: BaselineIdentity, + document: Arc, + document_revision: u64, tape: LayoutTape, styles: Vec, } @@ -405,6 +413,8 @@ struct ConfirmedBaseline { #[derive(Debug)] struct PendingBaseline { confirmed_identity: BaselineIdentity, + document: Arc, + document_revision: u64, tape: LayoutTape, styles: Vec, } @@ -450,6 +460,9 @@ struct RuntimeState { baseline_misses: u64, base_renders: u64, target_renders: u64, + document_parses: u64, + document_validations: u64, + document_reuses: u64, confirmed_baseline: Option>, } @@ -500,6 +513,9 @@ struct SessionStats { baseline_misses: u64, base_renders: u64, target_renders: u64, + document_parses: u64, + document_validations: u64, + document_reuses: u64, pending_baselines: usize, confirmed_baseline: bool, confirmed_baseline_bytes: usize, @@ -562,6 +578,9 @@ impl Session { baseline_misses: 0, base_renders: 0, target_renders: 0, + document_parses: 0, + document_validations: 0, + document_reuses: 0, confirmed_baseline, }), readiness_channel: Mutex::new(None), @@ -586,10 +605,17 @@ impl Session { } } } + let layout_document = shared + .state + .lock() + .unwrap_or_else(|poison| poison.into_inner()) + .confirmed_baseline + .as_ref() + .map(|baseline| Arc::clone(&baseline.document)); Ok(Box::new(Self { shared, workers: Mutex::new(Some(handles)), - layout_document: Mutex::new(None), + layout_document: Mutex::new(layout_document), worker_count, })) } @@ -623,16 +649,41 @@ impl Session { let layout_requested = batch.document.is_some() || batch.frames.iter().any(|frame| frame.payload.is_none()); let register_document = batch.document.is_some(); + let confirmed_baseline = self + .shared + .state + .lock() + .unwrap_or_else(|poison| poison.into_inner()) + .confirmed_baseline + .clone(); + let confirmed_document_revision = confirmed_baseline + .as_ref() + .map_or(0, |baseline| baseline.document_revision); + let document_base_revision = batch + .document_base_revision + .unwrap_or(confirmed_document_revision); + let document_target_revision = match batch.document_target_revision { + Some(revision) => revision, + None if register_document => document_base_revision + .checked_add(1) + .ok_or_else(|| "Native layout document revision overflow".to_owned())?, + None => document_base_revision, + }; let document = match batch.document { Some(document) => { document.validate()?; Some(Arc::new(document)) } None if layout_requested => Some( - self.layout_document - .lock() - .unwrap_or_else(|poison| poison.into_inner()) - .clone() + confirmed_baseline + .as_ref() + .map(|baseline| Arc::clone(&baseline.document)) + .or_else(|| { + self.layout_document + .lock() + .unwrap_or_else(|poison| poison.into_inner()) + .clone() + }) .ok_or_else(|| { "Native layout frames require a registered document".to_owned() })?, @@ -648,7 +699,12 @@ impl Session { let mut prepared = Vec::with_capacity(batch.frames.len()); for frame in batch.frames { let job = if let Some(document) = &document { - prepare_layout_job(document, frame)? + prepare_layout_job( + document, + frame, + document_base_revision, + document_target_revision, + )? } else { let payload = frame .payload @@ -780,13 +836,75 @@ impl Session { return Err("Native reflow session is closed".to_owned()); } let batch = parse_control_batch(payload)?; - let document = batch - .document - .ok_or_else(|| "Native retained render requires an inline document".to_owned())?; - document.validate()?; if batch.frames.len() != 1 { return Err("Native retained render requires exactly one frame".to_owned()); } + let document_base_revision = batch + .document_base_revision + .ok_or_else(|| "Native retained render requires exact document revisions".to_owned())?; + let document_target_revision = batch + .document_target_revision + .ok_or_else(|| "Native retained render requires exact document revisions".to_owned())?; + let confirmed = self + .shared + .state + .lock() + .unwrap_or_else(|poison| poison.into_inner()) + .confirmed_baseline + .clone(); + let (document, document_parses, document_validations, document_reuses) = match batch + .document + { + Some(document) => { + if document_base_revision.checked_add(1) != Some(document_target_revision) { + return Err( + "Native retained document replacement must advance one revision".to_owned(), + ); + } + match &confirmed { + Some(baseline) if baseline.document_revision != document_base_revision => { + return Err( + "Native retained document base revision does not match confirmed state" + .to_owned(), + ); + } + None if document_base_revision != 0 => { + return Err( + "Native retained document bootstrap must start at revision zero" + .to_owned(), + ); + } + _ => {} + } + document.validate()?; + (Arc::new(document), 1, 1, 0) + } + None => { + if document_base_revision != document_target_revision { + return Err( + "Native retained document reuse requires equal base and target revisions" + .to_owned(), + ); + } + let baseline = confirmed.as_ref().ok_or_else(|| { + "Native retained document reuse requires a confirmed baseline".to_owned() + })?; + if baseline.document_revision != document_target_revision { + return Err( + "Native retained document revision does not match confirmed state" + .to_owned(), + ); + } + let frame = batch.frames.first().expect("checked retained frame"); + if frame.runtime_revision != baseline.identity.runtime_revision { + return Err( + "Native retained runtime revision does not match confirmed state" + .to_owned(), + ); + } + (Arc::clone(&baseline.document), 0, 0, 1) + } + }; let frame = batch .frames .into_iter() @@ -795,15 +913,12 @@ impl Session { if !frame.complete || frame.delay_ms != 0 { return Err("Native retained render requires one immediate complete frame".to_owned()); } - let document = Arc::new(document); - let prepared = prepare_layout_job(&document, frame)?; - let confirmed = self - .shared - .state - .lock() - .unwrap_or_else(|poison| poison.into_inner()) - .confirmed_baseline - .clone(); + let prepared = prepare_layout_job( + &document, + frame, + document_base_revision, + document_target_revision, + )?; let output = render_layout_payload( prepared.payload, self.shared.id, @@ -828,6 +943,9 @@ impl Session { } state.base_renders += output.base_renders; state.target_renders += output.target_renders; + state.document_parses += document_parses; + state.document_validations += document_validations; + state.document_reuses += document_reuses; state.pending_baselines.clear(); if let Some(pending) = output.pending { state @@ -857,6 +975,8 @@ impl Session { identity.runtime_revision = confirmed_revision; state.confirmed_baseline = Some(Arc::new(ConfirmedBaseline { identity, + document: pending.document, + document_revision: pending.document_revision, tape: pending.tape, styles: pending.styles, })); @@ -955,6 +1075,9 @@ impl Session { baseline_misses: state.baseline_misses, base_renders: state.base_renders, target_renders: state.target_renders, + document_parses: state.document_parses, + document_validations: state.document_validations, + document_reuses: state.document_reuses, pending_baselines, confirmed_baseline: state.confirmed_baseline.is_some(), confirmed_baseline_bytes, @@ -1043,24 +1166,23 @@ fn checked_root_width(frame_key: i64, root_width: i64, label: &str) -> Result bool { - baseline.identity.context == context - && baseline.identity.root_width == root_width - && baseline.identity.root_width_override == root_width_override - && baseline.identity.runtime_revision == runtime_revision - && baseline.identity.context_hash == context_hash - && baseline.identity.complete == complete + baseline.document_revision == document_base_revision + && baseline.identity.context == identity.context + && baseline.identity.root_width == identity.root_width + && baseline.identity.root_width_override == identity.root_width_override + && baseline.identity.runtime_revision == identity.runtime_revision + && baseline.identity.context_hash == identity.context_hash + && baseline.identity.complete == identity.complete } fn prepare_layout_job( document: &Arc, frame: ControlFrame, + document_base_revision: u64, + document_target_revision: u64, ) -> Result { if frame.payload.is_some() { return Err(format!( @@ -1143,6 +1265,8 @@ fn prepare_layout_job( context_hash: frame.context_hash, complete: frame.complete, root_metadata: frame.root_metadata, + document_base_revision, + document_target_revision, }, }) } @@ -1176,6 +1300,8 @@ fn render_layout_payload( context_hash, complete, root_metadata, + document_base_revision, + document_target_revision, } => { let identity = TapeIdentity { session_id, @@ -1208,17 +1334,21 @@ fn render_layout_payload( type LayoutRenderOutcome = Result<(Vec, LayoutTape, bool, u64, u64), String>; let result = catch_unwind(AssertUnwindSafe(|| -> LayoutRenderOutcome { if let Some(base_context) = base_context { + let base_identity = BaselineIdentity { + context: base_context, + root_width: base_root_width, + root_width_override: base_root_width_override, + runtime_revision, + context_hash, + complete, + }; let base_hit = confirmed_baseline .as_ref() .filter(|baseline| { confirmed_baseline_matches( baseline, - base_context, - base_root_width, - base_root_width_override, - runtime_revision, - context_hash, - complete, + document_base_revision, + &base_identity, ) && baseline.styles == document.styles }) .cloned(); @@ -1275,6 +1405,8 @@ fn render_layout_payload( bytes, pending: Some(PendingBaseline { confirmed_identity: pending_identity, + document: Arc::clone(&document), + document_revision: document_target_revision, tape, styles: document.styles.clone(), }), @@ -1329,7 +1461,7 @@ fn render_proof(payload: &[u8]) -> Result, String> { if frame.delay_ms != 0 { return Err("Native proof render cannot contain delay-ms".to_owned()); } - let prepared = prepare_layout_job(&Arc::new(document), frame)?; + let prepared = prepare_layout_job(&Arc::new(document), frame, 0, 1)?; let output = render_layout_payload( prepared.payload, SYNC_RENDER_SESSION_ID, @@ -1847,14 +1979,41 @@ mod tests { fn proof_layout_payload(frames: &str) -> Vec { format!( - r#"{{"version":1,"document":{{"version":2,"space-width":8,"style-count":0,"styles":[],"root":{{"type":"box","region-id":1,"content":{{"lines":[{{"clusters":[{{"text":"x","width":8,"cjk":false,"space":false}}]}}]}},"child":null,"content-width-exact":false,"width":{{"kind":"viewport"}},"min-width":{{"kind":"pixels","value":0}},"max-width":{{"kind":"none"}},"height":{{"kind":"auto"}},"min-height":{{"kind":"lines","value":0}},"max-height":{{"kind":"none"}},"box-sizing":"border-box","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,"foreground-style":null,"background-style":null,"border-left-style":null,"border-right-style":null,"border-top-style":null,"border-bottom-style":null,"text-align":"left","vertical-align":"top","overflow":"scroll","wrap-mode":"word","scroll-offset":0}}}},"frames":{frames}}}"# + r#"{{"version":1,"document-base-revision":0,"document-target-revision":1,"document":{{"version":2,"space-width":8,"style-count":0,"styles":[],"root":{{"type":"box","region-id":1,"content":{{"lines":[{{"clusters":[{{"text":"x","width":8,"cjk":false,"space":false}}]}}]}},"child":null,"content-width-exact":false,"width":{{"kind":"viewport"}},"min-width":{{"kind":"pixels","value":0}},"max-width":{{"kind":"none"}},"height":{{"kind":"auto"}},"min-height":{{"kind":"lines","value":0}},"max-height":{{"kind":"none"}},"box-sizing":"border-box","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,"foreground-style":null,"background-style":null,"border-left-style":null,"border-right-style":null,"border-top-style":null,"border-bottom-style":null,"text-align":"left","vertical-align":"top","overflow":"scroll","wrap-mode":"word","scroll-offset":0}}}},"frames":{frames}}}"# ) .into_bytes() } + fn retained_layout_payload(document_revision: u64, frames: &str) -> Vec { + format!( + r#"{{"version":1,"document-base-revision":{document_revision},"document-target-revision":{document_revision},"frames":{frames}}}"# + ) + .into_bytes() + } + + fn replacement_layout_payload( + document_base_revision: u64, + document_target_revision: u64, + frames: &str, + ) -> Vec { + let payload = String::from_utf8(proof_layout_payload(frames)).unwrap(); + payload + .replacen( + r#""document-base-revision":0"#, + &format!(r#""document-base-revision":{document_base_revision}"#), + 1, + ) + .replacen( + r#""document-target-revision":1"#, + &format!(r#""document-target-revision":{document_target_revision}"#), + 1, + ) + .into_bytes() + } + fn column_proof_layout_payload(frames: &str) -> Vec { format!( - r#"{{"version":1,"document":{{"version":2,"space-width":8,"style-count":0,"styles":[],"root":{{"type":"column","children":[{{"type":"box","region-id":1,"content":{{"lines":[{{"clusters":[{{"text":"x","width":8,"cjk":false,"space":false}}]}}]}},"child":null,"content-width-exact":false,"width":{{"kind":"content"}},"min-width":{{"kind":"pixels","value":0}},"max-width":{{"kind":"none"}},"height":{{"kind":"auto"}},"min-height":{{"kind":"lines","value":0}},"max-height":{{"kind":"none"}},"box-sizing":"border-box","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,"foreground-style":null,"background-style":null,"border-left-style":null,"border-right-style":null,"border-top-style":null,"border-bottom-style":null,"text-align":"left","vertical-align":"top","overflow":"scroll","wrap-mode":"word","scroll-offset":0}},{{"type":"box","region-id":2,"content":{{"lines":[{{"clusters":[{{"text":"yy","width":16,"cjk":false,"space":false}}]}}]}},"child":null,"content-width-exact":false,"width":{{"kind":"content"}},"min-width":{{"kind":"pixels","value":0}},"max-width":{{"kind":"none"}},"height":{{"kind":"auto"}},"min-height":{{"kind":"lines","value":0}},"max-height":{{"kind":"none"}},"box-sizing":"border-box","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,"foreground-style":null,"background-style":null,"border-left-style":null,"border-right-style":null,"border-top-style":null,"border-bottom-style":null,"text-align":"left","vertical-align":"top","overflow":"scroll","wrap-mode":"word","scroll-offset":0}}]}}}},"frames":{frames}}}"# + r#"{{"version":1,"document-base-revision":0,"document-target-revision":1,"document":{{"version":2,"space-width":8,"style-count":0,"styles":[],"root":{{"type":"column","children":[{{"type":"box","region-id":1,"content":{{"lines":[{{"clusters":[{{"text":"x","width":8,"cjk":false,"space":false}}]}}]}},"child":null,"content-width-exact":false,"width":{{"kind":"content"}},"min-width":{{"kind":"pixels","value":0}},"max-width":{{"kind":"none"}},"height":{{"kind":"auto"}},"min-height":{{"kind":"lines","value":0}},"max-height":{{"kind":"none"}},"box-sizing":"border-box","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,"foreground-style":null,"background-style":null,"border-left-style":null,"border-right-style":null,"border-top-style":null,"border-bottom-style":null,"text-align":"left","vertical-align":"top","overflow":"scroll","wrap-mode":"word","scroll-offset":0}},{{"type":"box","region-id":2,"content":{{"lines":[{{"clusters":[{{"text":"yy","width":16,"cjk":false,"space":false}}]}}]}},"child":null,"content-width-exact":false,"width":{{"kind":"content"}},"min-width":{{"kind":"pixels","value":0}},"max-width":{{"kind":"none"}},"height":{{"kind":"auto"}},"min-height":{{"kind":"lines","value":0}},"max-height":{{"kind":"none"}},"box-sizing":"border-box","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,"foreground-style":null,"background-style":null,"border-left-style":null,"border-right-style":null,"border-top-style":null,"border-bottom-style":null,"text-align":"left","vertical-align":"top","overflow":"scroll","wrap-mode":"word","scroll-offset":0}}]}}}},"frames":{frames}}}"# ) .into_bytes() } @@ -2083,10 +2242,11 @@ mod tests { assert_eq!(child_initial.ready_results, 0); assert_eq!(child_initial.pending_baselines, 0); assert!(child_initial.confirmed_baseline); - assert!(!child_initial.layout_registered); + assert!(child_initial.layout_registered); parent.stop(true); - let second = proof_layout_payload( + let second = retained_layout_payload( + 1, r#"[{"key":2,"viewport-width":80,"viewport-height":10,"root-width":80,"patch":true,"base-viewport-width":120,"base-viewport-height":10,"base-root-width":120,"runtime-revision":1,"context-hash":77}]"#, ); let second_tape = child.render_sync(1, &second).unwrap(); @@ -2110,6 +2270,152 @@ mod tests { child.stop(true); } + #[test] + fn confirmed_fork_reuses_retained_document_without_reparse_or_revalidation() { + let parent = Session::new(1, 4, 4, 64 * 1024).unwrap(); + let first = proof_layout_payload( + r#"[{"key":1,"viewport-width":120,"viewport-height":10,"root-width":120,"runtime-revision":0,"context-hash":77}]"#, + ); + assert!(!parent.render_sync(1, &first).unwrap().is_empty()); + assert!(parent.confirm(1, 1, 1).unwrap()); + + let child = parent.fork_confirmed().unwrap(); + let retained = retained_layout_payload( + 1, + r#"[{"key":2,"viewport-width":80,"viewport-height":10,"root-width":80,"patch":true,"base-viewport-width":120,"base-viewport-height":10,"base-root-width":120,"runtime-revision":1,"context-hash":77}]"#, + ); + let tape = child.render_sync(1, &retained).unwrap(); + assert!(tape_patch_p(&tape)); + let stats = child.stats(); + assert_eq!(stats.document_parses, 0); + assert_eq!(stats.document_validations, 0); + assert_eq!(stats.document_reuses, 1); + assert!(child.confirm(1, 2, 2).unwrap()); + + let grandchild = child.fork_confirmed().unwrap(); + let next = retained_layout_payload( + 1, + r#"[{"key":3,"viewport-width":100,"viewport-height":10,"root-width":100,"patch":true,"base-viewport-width":80,"base-viewport-height":10,"base-root-width":80,"runtime-revision":2,"context-hash":77}]"#, + ); + assert!(tape_patch_p(&grandchild.render_sync(1, &next).unwrap())); + assert_eq!(grandchild.stats().document_reuses, 1); + parent.stop(true); + child.stop(true); + grandchild.stop(true); + } + + #[test] + fn retained_document_reuse_rejects_wrong_or_ambiguous_revision() { + let parent = Session::new(1, 4, 4, 64 * 1024).unwrap(); + let first = proof_layout_payload( + r#"[{"key":1,"viewport-width":120,"viewport-height":10,"runtime-revision":0}]"#, + ); + parent.render_sync(1, &first).unwrap(); + assert!(parent.confirm(1, 1, 1).unwrap()); + let child = parent.fork_confirmed().unwrap(); + + let wrong_document = retained_layout_payload( + 2, + r#"[{"key":2,"viewport-width":80,"viewport-height":10,"runtime-revision":1}]"#, + ); + assert!(child + .render_sync(1, &wrong_document) + .unwrap_err() + .contains("document revision")); + let wrong_runtime = retained_layout_payload( + 1, + r#"[{"key":2,"viewport-width":80,"viewport-height":10,"runtime-revision":0}]"#, + ); + assert!(child + .render_sync(1, &wrong_runtime) + .unwrap_err() + .contains("runtime revision")); + let ambiguous = br#"{"version":1,"frames":[{"key":2,"viewport-width":80,"viewport-height":10,"runtime-revision":1}]}"#; + assert!(child + .render_sync(1, ambiguous) + .unwrap_err() + .contains("document revisions")); + assert_eq!(child.stats().document_reuses, 0); + assert!(parent.stats().confirmed_baseline); + parent.stop(true); + child.stop(true); + } + + #[test] + fn cancelled_replacement_does_not_promote_or_contaminate_its_parent() { + let parent = Session::new(1, 4, 4, 64 * 1024).unwrap(); + let first = proof_layout_payload( + r#"[{"key":1,"viewport-width":120,"viewport-height":10,"runtime-revision":0}]"#, + ); + parent.render_sync(1, &first).unwrap(); + assert!(parent.confirm(1, 1, 1).unwrap()); + let child = parent.fork_confirmed().unwrap(); + let replacement = replacement_layout_payload( + 1, + 2, + r#"[{"key":2,"viewport-width":100,"viewport-height":10,"runtime-revision":1}]"#, + ); + assert!(!child.render_sync(1, &replacement).unwrap().is_empty()); + assert_eq!(child.stats().pending_baselines, 1); + child.cancel(1); + assert_eq!(child.stats().pending_baselines, 0); + + let retry = child.fork_confirmed().unwrap(); + let retained = retained_layout_payload( + 1, + r#"[{"key":3,"viewport-width":80,"viewport-height":10,"runtime-revision":1}]"#, + ); + assert!(!retry.render_sync(1, &retained).unwrap().is_empty()); + assert_eq!(retry.stats().document_reuses, 1); + assert_eq!(parent.stats().document_parses, 1); + assert_eq!(parent.stats().document_reuses, 0); + parent.stop(true); + child.stop(true); + retry.stop(true); + } + + #[test] + fn async_omit_uses_the_latest_synchronously_confirmed_document() { + let parent = Session::new(1, 4, 4, 64 * 1024).unwrap(); + let first = proof_layout_payload( + r#"[{"key":1,"viewport-width":120,"viewport-height":10,"runtime-revision":0}]"#, + ); + let first_count = full_tape_character_count(&parent.render_sync(1, &first).unwrap()); + assert!(parent.confirm(1, 1, 1).unwrap()); + let parent_omit = + batch(r#"[{"key":2,"viewport-width":120,"viewport-height":10,"runtime-revision":1}]"#); + assert_eq!(parent.submit(2, &parent_omit).unwrap(), 1); + wait_until(|| parent.ready(2, 2)); + assert_eq!( + full_tape_character_count(&parent.take(2, 2).unwrap()), + first_count + ); + + let child = parent.fork_confirmed().unwrap(); + let replacement = String::from_utf8(replacement_layout_payload( + 1, + 2, + r#"[{"key":3,"viewport-width":120,"viewport-height":10,"runtime-revision":1}]"#, + )) + .unwrap() + .replacen(r#""text":"x","width":8"#, r#""text":"yy","width":16"#, 1) + .into_bytes(); + let replacement_count = + full_tape_character_count(&child.render_sync(1, &replacement).unwrap()); + assert_ne!(replacement_count, first_count); + assert!(child.confirm(1, 3, 2).unwrap()); + let child_omit = + batch(r#"[{"key":4,"viewport-width":120,"viewport-height":10,"runtime-revision":2}]"#); + assert_eq!(child.submit(2, &child_omit).unwrap(), 1); + wait_until(|| child.ready(2, 4)); + assert_eq!( + full_tape_character_count(&child.take(2, 4).unwrap()), + replacement_count + ); + parent.stop(true); + child.stop(true); + } + #[test] fn confirmed_fork_falls_back_to_full_frame_on_identity_mismatch() { let parent = Session::new(1, 4, 4, 64 * 1024).unwrap(); @@ -2120,7 +2426,9 @@ mod tests { assert!(parent.confirm(1, 1, 1).unwrap()); let child = parent.fork_confirmed().unwrap(); - let mismatched = proof_layout_payload( + let mismatched = replacement_layout_payload( + 1, + 2, r#"[{"key":2,"viewport-width":80,"viewport-height":10,"root-width":80,"patch":true,"base-viewport-width":120,"base-viewport-height":10,"base-root-width":120,"runtime-revision":2,"context-hash":77}]"#, ); let tape = child.render_sync(1, &mismatched).unwrap(); diff --git a/tests/ebox-commit-tests.el b/tests/ebox-commit-tests.el index 73f8891..3da1c0e 100644 --- a/tests/ebox-commit-tests.el +++ b/tests/ebox-commit-tests.el @@ -2399,6 +2399,174 @@ remain retained identities." (equal '(:foreground "#222222") (plist-get (aref styles style-id) :face)))))) +(ert-deftest ebox-native-retained-package-reuse-requires-exact-ir-identity () + "Only an identity-preserving compile may reuse the confirmed package." + (let* ((root (list :type "row" :children [])) + (style (list :mode 'face :face '(:foreground "red"))) + (template (list :mouse-face 'highlight)) + (old + (list :document + (list :version 2 :space-width 8 :style-count 1 + :property-template-count 1 + :styles (vector style) :root root) + :document-revision 7 + :styles (vector style) + :property-templates (vector template))) + (same + (list :document + (list :version 2 :space-width 8 :style-count 1 + :property-template-count 1 + :styles (vector style) :root root) + :styles (vector style) + :property-templates (vector template)))) + (should (eq old + (ebox-native-reflow--reuse-exact-layout-package old same))) + (dolist (candidate + (list + (copy-tree same) + (let ((copy (copy-tree same))) + (plist-put (plist-get copy :document) :space-width 9) + copy) + (let ((copy (copy-tree same))) + (plist-put (plist-get copy :document) :root (copy-tree root)) + copy) + (let ((copy (copy-tree same))) + (plist-put copy :styles + (vector (copy-tree style))) + copy) + (let ((copy (copy-tree same))) + (plist-put copy :property-templates + (vector (copy-tree template))) + copy))) + ;; COPY-TREE deliberately destroys the required root/style/template + ;; object identity even when values remain equal. + (should-not + (eq old (ebox-native-reflow--reuse-exact-layout-package old candidate)))))) + +(ert-deftest ebox-native-retained-sync-omits-an-exactly-reused-document () + "A retained frame sends only revisions and context for unchanged IR." + (let* ((document (list :version 2 :space-width 8 :style-count 0 + :property-template-count 0 :styles [] + :root (list :type "row" :children []))) + (package (list :document document :document-revision 4 + :styles [] :property-templates [])) + (session + (ebox-native-reflow--make-session + :handle 'test :generation 3 :styles [] :layout-package package + :layout-fragment-cache (make-hash-table :test 'equal) + :layout-fragment-revision 0)) + control) + (cl-letf (((symbol-function + 'ebox-native-reflow--compile-retained-layout-package) + (lambda (&rest _) package)) + ((symbol-function 'ebox-native--module-render-session-frame) + (lambda (_handle _generation payload) + (setq control + (json-parse-string payload :object-type 'plist + :array-type 'array)) + 'native-frame)) + ((symbol-function 'ebox-native-reflow--materialize-module-frame) + (lambda (&rest _) '(:rendered "ok")))) + (should + (equal + '(:rendered "ok") + (ebox-native-reflow-execute-session-sync + session 'node + '(:key 1 :viewport-width 80 :viewport-height 10 + :runtime-revision 9) + nil 'state))) + (should-not (plist-member control :document)) + (should (= (plist-get control :document-base-revision) 4)) + (should (= (plist-get control :document-target-revision) 4))))) + +(ert-deftest ebox-native-session-input-normalizes-every-replacement-revision () + "Compiled and explicit replacement packages both advance at the boundary." + (let* ((styles []) + (templates []) + (old + (list :document + (list :version 2 :space-width 8 :style-count 0 + :property-template-count 0 :styles [] + :root (list :type "row" :children [])) + :document-revision 4 + :styles styles :property-templates templates)) + (compiled + (list :document + (list :version 2 :space-width 8 :style-count 0 + :property-template-count 0 :styles [] + :root (list :type "column" :children [])) + :document-revision 1 + :styles styles :property-templates templates)) + (explicit + (list :document + (list :version 2 :space-width 8 :style-count 0 + :property-template-count 0 :styles [] + :root (list :type "box")) + :document-revision 1 + :styles styles :property-templates templates)) + (session + (ebox-native-reflow--make-session + :handle 'test :generation 0 :styles styles :layout-package old + :layout-fragment-cache (make-hash-table :test 'equal) + :layout-fragment-revision 0)) + (other-old (copy-tree old)) + (other-session + (progn + (plist-put other-old :document-revision 10) + (ebox-native-reflow--make-session + :handle 'other :generation 0 :styles styles + :layout-package other-old + :layout-fragment-cache (make-hash-table :test 'equal) + :layout-fragment-revision 0))) + controls) + (cl-letf (((symbol-function 'ebox-native-reflow--compile-layout-package) + (lambda (&rest _) compiled)) + ((symbol-function 'ebox-native--module-render-session-frame) + (lambda (_handle _generation payload) + (push (json-parse-string payload :object-type 'plist + :array-type 'array) + controls) + 'native-frame)) + ((symbol-function 'ebox-native-reflow--materialize-module-frame) + (lambda (&rest _) '(:rendered "ok")))) + (ebox-native-reflow-execute-session-sync + session 'node + '(:key 1 :viewport-width 80 :viewport-height 10 + :runtime-revision 9)) + (should (= (plist-get (ebox-native-reflow-session-layout-package session) + :document-revision) + 5)) + (ebox-native-reflow-execute-session-sync + session 'node + '(:key 1 :viewport-width 90 :viewport-height 10 + :runtime-revision 10) + explicit) + (setq controls (nreverse controls)) + (should (plist-member (car controls) :document)) + (should (= (plist-get (car controls) :document-base-revision) 4)) + (should (= (plist-get (car controls) :document-target-revision) 5)) + (should (plist-member (cadr controls) :document)) + (should (= (plist-get (cadr controls) :document-base-revision) 5)) + (should (= (plist-get (cadr controls) :document-target-revision) 6)) + (should (= (plist-get (ebox-native-reflow-session-layout-package session) + :document-revision) + 6)) + (ebox-native-reflow-execute-session-sync + other-session 'node + '(:key 1 :viewport-width 100 :viewport-height 10 + :runtime-revision 20) + explicit) + (should (= (plist-get explicit :document-revision) 1)) + (should (= (plist-get (ebox-native-reflow-session-layout-package session) + :document-revision) + 6)) + (should (= (plist-get + (ebox-native-reflow-session-layout-package other-session) + :document-revision) + 11)) + (should (= (plist-get (car controls) :document-base-revision) 10)) + (should (= (plist-get (car controls) :document-target-revision) 11))))) + (provide 'ebox-commit-tests) ;;; ebox-commit-tests.el ends here diff --git a/tests/ebox-surface-tests.el b/tests/ebox-surface-tests.el index 7b918d8..3c228fc 100644 --- a/tests/ebox-surface-tests.el +++ b/tests/ebox-surface-tests.el @@ -1500,6 +1500,11 @@ candidate cannot hide mutations by restoring the old hash-table pointer." (should (ebox-native-reflow-session-released-p session)) (should-not (ebox-native-reflow-session-released-p committed-session)) + (let ((stats + (ebox-native-reflow-stats committed-session))) + (should (= (plist-get stats :document-parses) 0)) + (should (= (plist-get stats :document-validations) 0)) + (should (= (plist-get stats :document-reuses) 1))) (should (plist-get (ebox--buffer-render-state buffer) :native-sync-confirmed-p)) (should-not (plist-get (ebox--buffer-render-state buffer) @@ -1640,6 +1645,10 @@ candidate cannot hide mutations by restoring the old hash-table pointer." (should (stringp (plist-get (tp-surface-client-state surface) :native-committed-rendered))) + (let ((stats (ebox-native-reflow-stats winning-session))) + (should (= (plist-get stats :document-parses) 1)) + (should (= (plist-get stats :document-validations) 1)) + (should (= (plist-get stats :document-reuses) 0))) (should-not (ebox-native-reflow-session-released-p winning-session))) (let ((report (ebox-commit buffer updated))) @@ -1654,7 +1663,11 @@ candidate cannot hide mutations by restoring the old hash-table pointer." (should-not (eq next-session winning-session)) (should (ebox-native-reflow-session-released-p winning-session)) (should-not (ebox-native-reflow-session-released-p - next-session))))) + next-session)) + (let ((stats (ebox-native-reflow-stats next-session))) + (should (= (plist-get stats :document-parses) 1)) + (should (= (plist-get stats :document-validations) 1)) + (should (= (plist-get stats :document-reuses) 0)))))) (when (buffer-live-p buffer) (kill-buffer buffer)) (when next-session From bc4516e171a5b1c896e347984e8843cd25ded524 Mon Sep 17 00:00:00 2001 From: Kinneyzhang Date: Sat, 5 Sep 2026 07:43:02 +0800 Subject: [PATCH 02/12] perf: preserve native patch locality when styles append --- native/src/layout.rs | 98 ++++++++++++++++++++++++++++++++++++- native/src/lib.rs | 83 ++++++++++++++++++++++++++++++- tests/ebox-surface-tests.el | 52 ++++++++++++++++++++ 3 files changed, 231 insertions(+), 2 deletions(-) diff --git a/native/src/layout.rs b/native/src/layout.rs index bb7e995..6e386bf 100644 --- a/native/src/layout.rs +++ b/native/src/layout.rs @@ -1248,6 +1248,13 @@ impl StyleTemplate { } } +pub(crate) fn style_registry_extends_exact_prefix( + old: &[StyleTemplate], + target: &[StyleTemplate], +) -> bool { + target.starts_with(old) +} + fn composed_face( properties: &AtomProperties, styles: &[CompiledStyle], @@ -2269,6 +2276,17 @@ fn root_metadata_payload( })) } +fn validate_tape_style_ids(properties: &AtomProperties, style_count: u32) -> Result<(), String> { + if properties + .style_ids + .iter() + .any(|style_id| *style_id >= style_count) + { + return Err("Native layout tape has invalid style id".to_owned()); + } + Ok(()) +} + fn flatten_layout_tape(tape: LayoutTape, complete: bool) -> Result { if tape.lines.is_empty() { return Err("Native layout tape has no lines".to_owned()); @@ -2289,6 +2307,7 @@ fn flatten_layout_tape(tape: LayoutTape, complete: bool) -> Result { + validate_tape_style_ids(&properties, tape.style_count)?; let width = tape_width(width, "text width")?; if text.is_empty() || text.contains('\n') { return Err("Native layout tape has invalid text atom".to_owned()); @@ -2304,6 +2323,7 @@ fn flatten_layout_tape(tape: LayoutTape, complete: bool) -> Result { + validate_tape_style_ids(&properties, tape.style_count)?; let width = tape_width(width, "space width")?; if width == 0 { return Err("Native layout tape has empty pixel space".to_owned()); @@ -2323,6 +2343,7 @@ fn flatten_layout_tape(tape: LayoutTape, complete: bool) -> Result Result, String> { let old = flatten_layout_tape(old_tape, identity.complete)?; let target = flatten_layout_tape(target_tape, identity.complete)?; - if old.style_count != target.style_count || target.style_count as usize != styles.len() { + if old.style_count > target.style_count || target.style_count as usize != styles.len() { return Err("Native layout patch style table mismatch".to_owned()); } let compiled_styles = styles @@ -5922,6 +5943,10 @@ mod tests { serde_json::from_str(json).unwrap() } + fn styles(json: &str) -> Vec { + serde_json::from_str(json).unwrap() + } + fn cluster(text: &str, width: i64, source_template_id: Option) -> MeasuredCluster { MeasuredCluster { text: text.to_owned(), @@ -6115,6 +6140,77 @@ mod tests { } } + #[test] + fn style_registry_reuse_requires_an_exact_stable_prefix() { + let old = styles( + r##"[{"mode":"set","face":{"foreground":"#111111"}},{"mode":"add","face":{"background":"#222222"}}]"##, + ); + let appended = styles( + r##"[{"mode":"set","face":{"foreground":"#111111"}},{"mode":"add","face":{"background":"#222222"}},{"mode":"set","face":{"foreground":"#333333"}}]"##, + ); + let changed = styles( + r##"[{"mode":"set","face":{"foreground":"#999999"}},{"mode":"add","face":{"background":"#222222"}},{"mode":"set","face":{"foreground":"#333333"}}]"##, + ); + let removed = styles(r##"[{"mode":"set","face":{"foreground":"#111111"}}]"##); + let renumbered = styles( + r##"[{"mode":"add","face":{"background":"#222222"}},{"mode":"set","face":{"foreground":"#111111"}},{"mode":"set","face":{"foreground":"#333333"}}]"##, + ); + + assert!(style_registry_extends_exact_prefix(&old, &appended)); + assert!(!style_registry_extends_exact_prefix(&old, &changed)); + assert!(!style_registry_extends_exact_prefix(&old, &removed)); + assert!(!style_registry_extends_exact_prefix(&old, &renumbered)); + } + + #[test] + fn layout_patch_accepts_appended_styles_but_validates_each_tape_count() { + fn tape(style_count: u32, style_id: u32) -> LayoutTape { + LayoutTape { + style_count, + lines: vec![TapeLine { + width: 1, + atoms: vec![TapeAtom::Text { + text: "x".to_owned(), + width: 1, + properties: AtomProperties { + style_ids: vec![style_id], + ..AtomProperties::default() + }, + }], + break_after: None, + }], + } + } + + let registry = styles( + r##"[{"mode":"set","face":{"foreground":"#111111"}},{"mode":"set","face":{"background":"#222222"}}]"##, + ); + let encoded = encode_layout_patch_tape( + tape(1, 0), + tape(2, 1), + ®istry, + complete_identity(), + false, + 4096, + ) + .unwrap(); + assert_ne!( + u16::from_le_bytes(encoded[6..8].try_into().unwrap()) & (1 << 2), + 0 + ); + + let error = encode_layout_patch_tape( + tape(1, 1), + tape(2, 1), + ®istry, + complete_identity(), + false, + 4096, + ) + .unwrap_err(); + assert_eq!(error, "Native layout tape has invalid style id"); + } + #[test] fn typed_column_lowering_stretches_centered_child() { let mut text = auto_text_box(3, measured_text(vec![vec![cluster("x", 10, None)]]), None); diff --git a/native/src/lib.rs b/native/src/lib.rs index de40b2b..1464fa3 100644 --- a/native/src/lib.rs +++ b/native/src/lib.rs @@ -1349,7 +1349,10 @@ fn render_layout_payload( baseline, document_base_revision, &base_identity, - ) && baseline.styles == document.styles + ) && layout::style_registry_extends_exact_prefix( + &baseline.styles, + &document.styles, + ) }) .cloned(); if require_confirmed_patch_base && base_hit.is_none() { @@ -2011,6 +2014,35 @@ mod tests { .into_bytes() } + fn styled_layout_payload( + document_base_revision: u64, + document_target_revision: u64, + style_count: u32, + styles: &str, + foreground_style: u32, + frames: &str, + ) -> Vec { + let payload = String::from_utf8(replacement_layout_payload( + document_base_revision, + document_target_revision, + frames, + )) + .unwrap(); + payload + .replacen( + r#""style-count":0"#, + &format!(r#""style-count":{style_count}"#), + 1, + ) + .replacen(r#""styles":[]"#, &format!(r#""styles":{styles}"#), 1) + .replacen( + r#""foreground-style":null"#, + &format!(r#""foreground-style":{foreground_style}"#), + 1, + ) + .into_bytes() + } + fn column_proof_layout_payload(frames: &str) -> Vec { format!( r#"{{"version":1,"document-base-revision":0,"document-target-revision":1,"document":{{"version":2,"space-width":8,"style-count":0,"styles":[],"root":{{"type":"column","children":[{{"type":"box","region-id":1,"content":{{"lines":[{{"clusters":[{{"text":"x","width":8,"cjk":false,"space":false}}]}}]}},"child":null,"content-width-exact":false,"width":{{"kind":"content"}},"min-width":{{"kind":"pixels","value":0}},"max-width":{{"kind":"none"}},"height":{{"kind":"auto"}},"min-height":{{"kind":"lines","value":0}},"max-height":{{"kind":"none"}},"box-sizing":"border-box","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,"foreground-style":null,"background-style":null,"border-left-style":null,"border-right-style":null,"border-top-style":null,"border-bottom-style":null,"text-align":"left","vertical-align":"top","overflow":"scroll","wrap-mode":"word","scroll-offset":0}},{{"type":"box","region-id":2,"content":{{"lines":[{{"clusters":[{{"text":"yy","width":16,"cjk":false,"space":false}}]}}]}},"child":null,"content-width-exact":false,"width":{{"kind":"content"}},"min-width":{{"kind":"pixels","value":0}},"max-width":{{"kind":"none"}},"height":{{"kind":"auto"}},"min-height":{{"kind":"lines","value":0}},"max-height":{{"kind":"none"}},"box-sizing":"border-box","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,"foreground-style":null,"background-style":null,"border-left-style":null,"border-right-style":null,"border-top-style":null,"border-bottom-style":null,"text-align":"left","vertical-align":"top","overflow":"scroll","wrap-mode":"word","scroll-offset":0}}]}}}},"frames":{frames}}}"# @@ -2440,6 +2472,55 @@ mod tests { child.stop(true); } + #[test] + fn confirmed_patch_base_accepts_only_append_stable_style_registries() { + let parent = Session::new(1, 4, 4, 64 * 1024).unwrap(); + let first = styled_layout_payload( + 0, + 1, + 1, + r##"[{"mode":"set","face":{"foreground":"#111111"}}]"##, + 0, + r#"[{"key":1,"viewport-width":120,"viewport-height":10,"root-width":120,"runtime-revision":0,"context-hash":77}]"#, + ); + assert!(!tape_patch_p(&parent.render_sync(1, &first).unwrap())); + assert!(parent.confirm(1, 1, 1).unwrap()); + + let appended_child = parent.fork_confirmed().unwrap(); + let appended = styled_layout_payload( + 1, + 2, + 2, + r##"[{"mode":"set","face":{"foreground":"#111111"}},{"mode":"set","face":{"background":"#222222"}}]"##, + 1, + r#"[{"key":2,"viewport-width":120,"viewport-height":10,"root-width":120,"patch":true,"base-viewport-width":120,"base-viewport-height":10,"base-root-width":120,"runtime-revision":1,"context-hash":77}]"#, + ); + assert!(tape_patch_p( + &appended_child.render_sync(1, &appended).unwrap() + )); + assert_eq!(appended_child.stats().baseline_hits, 1); + assert_eq!(appended_child.stats().base_renders, 0); + + let changed_child = parent.fork_confirmed().unwrap(); + let changed = styled_layout_payload( + 1, + 2, + 2, + r##"[{"mode":"set","face":{"foreground":"#999999"}},{"mode":"set","face":{"background":"#222222"}}]"##, + 1, + r#"[{"key":3,"viewport-width":120,"viewport-height":10,"root-width":120,"patch":true,"base-viewport-width":120,"base-viewport-height":10,"base-root-width":120,"runtime-revision":1,"context-hash":77}]"#, + ); + assert!(!tape_patch_p( + &changed_child.render_sync(1, &changed).unwrap() + )); + assert_eq!(changed_child.stats().baseline_hits, 0); + assert_eq!(changed_child.stats().base_renders, 0); + + parent.stop(true); + appended_child.stop(true); + changed_child.stop(true); + } + #[test] fn synchronous_proof_render_returns_full_tape_without_session() { let payload = diff --git a/tests/ebox-surface-tests.el b/tests/ebox-surface-tests.el index 3c228fc..b868a0e 100644 --- a/tests/ebox-surface-tests.el +++ b/tests/ebox-surface-tests.el @@ -1673,6 +1673,58 @@ candidate cannot hide mutations by restoring the old hash-table pointer." (when next-session (should (ebox-native-reflow-session-released-p next-session)))))) +(ert-deftest ebox-native-appended-style-keeps-a-local-property-patch () + "An appended style preserves native and TP locality with exact output." + (skip-unless (ebox-native-reflow-layout-ready-p)) + (ebox-surface-test--reset-render-state) + (let ((buffer (generate-new-buffer " *ebox-native-appended-style*"))) + (unwind-protect + (cl-labels + ((source + (background) + (apply + #'ebox-test-column + (cl-loop + for index below 32 + collect + (apply + #'ebox-test-box + (append + (list :key index :source-identity index + (ebox-test-text (char-to-string (+ ?A index)))) + (and (= index 16) background + (list :bgcolor background))))))) + (node-ids + (state) + (let (ids) + (ebox-surface-test--walk-runtime + (plist-get state :root-node) + (lambda (node) (push (plist-get node :node-id) ids))) + (nreverse ids)))) + (let ((ebox-viewport-width 240) + (ebox-viewport-height 40) + (ebox-runtime-idle-prewarm nil) + (ebox-runtime-idle-reflow-cache-prewarm nil)) + (ebox-render-to-buffer buffer (source nil)) + (let* ((surface + (with-current-buffer buffer ebox-surface--buffer-surface)) + (old-state (tp-surface-client-state surface)) + (old-node-ids (node-ids old-state)) + (report (ebox-commit buffer (source "#224466"))) + (state (tp-surface-client-state surface)) + (tp-report (tp-surface-report surface)) + (expected (ebox-surface-test--render-runtime state)) + (actual + (with-current-buffer buffer + (buffer-substring (point-min) (point-max))))) + (should (eq (plist-get report :projection-kind) 'native-frame)) + (should (eq (plist-get report :native-frame-kind) 'patch)) + (should (= (plist-get tp-report :touched-characters) 2)) + (should (equal (node-ids state) old-node-ids)) + (should (equal-including-properties actual expected))))) + (when (buffer-live-p buffer) + (kill-buffer buffer))))) + (ert-deftest ebox-viewport-reflow-retains-final-sized-flex-child-fragments () "Viewport reflow should reuse final-sized Flex child fragments exactly." (ebox-surface-test--reset-render-state) From b824467791af36e08527697be01c0d6d05fb4093 Mon Sep 17 00:00:00 2001 From: Kinneyzhang Date: Sat, 5 Sep 2026 08:44:29 +0800 Subject: [PATCH 03/12] Retain native node deltas across confirmed sessions --- ebox-incremental.el | 7 +- ebox-native-commit.el | 5 + ebox-native-reflow.el | 531 ++++++++- ebox-state-contract.el | 6 + native/c/ebox_module.c | 2 +- native/src/layout.rs | 1717 +++++++++++++++++++++++++-- native/src/lib.rs | 347 +++++- tests/ebox-commit-tests.el | 436 +++++++ tests/ebox-m0a-inventory-fixture.el | 49 + tests/ebox-state-contract-tests.el | 22 +- tests/ebox-surface-tests.el | 120 +- 11 files changed, 3040 insertions(+), 202 deletions(-) diff --git a/ebox-incremental.el b/ebox-incremental.el index a5c0014..7910bb9 100644 --- a/ebox-incremental.el +++ b/ebox-incremental.el @@ -9974,6 +9974,10 @@ role, and outside-complement compatibility before publication." (plist-put overrides :native-inherited-dirty-node-ids (plist-get candidate-state :native-inherited-dirty-node-ids))) + (setq overrides + (plist-put overrides :native-local-dirty-entries + (plist-get candidate-state + :native-local-dirty-entries))) (setq overrides (plist-put overrides :native-topology-stable-p @@ -10963,9 +10967,10 @@ fresh by the mixed projection." ((eq projection-kind 'native-frame) (append (ebox-incremental--viewport-state-overrides - candidate-state t) + candidate-state t) (list :native-topology-stable-p t :native-touched-node-ids nil + :native-local-dirty-entries nil :native-removed-node-ids nil))) ((memq projection-kind '(viewport-reflow viewport-reflow-mixed-scroll)) diff --git a/ebox-native-commit.el b/ebox-native-commit.el index 5d15ff7..92dd302 100644 --- a/ebox-native-commit.el +++ b/ebox-native-commit.el @@ -339,6 +339,7 @@ new session with the same bounded configuration used by retained frames." (progn (plist-put candidate-state :native-topology-stable-p t) (plist-put candidate-state :native-touched-node-ids nil) + (plist-put candidate-state :native-local-dirty-entries nil) (plist-put candidate-state :native-removed-node-ids nil) t))) @@ -375,6 +376,8 @@ new session with the same bounded configuration used by retained frames." old-state candidate-state)) (plist-put candidate-state :native-topology-stable-p stable) (plist-put candidate-state :native-touched-node-ids layout-touched) + (plist-put candidate-state :native-local-dirty-entries + (copy-tree (plist-get prepared :dirty-set))) (plist-put candidate-state :native-inherited-dirty-node-ids (ebox-native-commit-inherited-dirty-node-ids prepared)) (when context-axes-stable @@ -612,6 +615,7 @@ publication transaction can still roll back." (1+ (or (plist-get state :runtime-revision) 0)))) (plist-put state :native-frame-kind (if (plist-get frame :native-patch) 'patch 'full)) + (cl-remf state :native-local-dirty-entries) frame))) (defconst ebox-native-commit--failed-render-state-keys @@ -632,6 +636,7 @@ publication transaction can still roll back." :native-reuse-mount-projection-p :native-reuse-ownership-p :native-inherited-dirty-node-ids + :native-local-dirty-entries :native-topology-stable-p :native-touched-node-ids :native-removed-node-ids) diff --git a/ebox-native-reflow.el b/ebox-native-reflow.el index 43c67dc..16559a4 100644 --- a/ebox-native-reflow.el +++ b/ebox-native-reflow.el @@ -95,7 +95,7 @@ (defvar ebox--flex-content-min-width-table) -(defconst ebox-native-reflow-abi-version "11:7:12" +(defconst ebox-native-reflow-abi-version "12:7:12" "Version tuple shared by the native module, layout IR, and render tape.") (defconst ebox-native-reflow--minimum-rust-version "1.82.0" @@ -222,7 +222,10 @@ module while loading the package." styles layout-package layout-fragment-cache + layout-fragment-index layout-fragment-revision + layout-style-index + layout-property-template-index readiness-process readiness-preparation released-p) @@ -286,9 +289,74 @@ module while loading the package." (defvar ebox-native-reflow--compile-styles nil "Dynamically bound ordered style registry for one Layout IR compile.") +(defvar ebox-native-reflow--compile-style-base-count 0 + "Number of retained styles preceding the current append batch.") + +(defvar ebox-native-reflow--compile-style-index nil + "Persistent retained style-to-id index for incremental compilation.") + (defvar ebox-native-reflow--compile-property-templates nil "Dynamically bound ordered opaque text-property registry.") +(defvar ebox-native-reflow--compile-property-template-base-count 0 + "Number of retained property templates preceding current additions.") + +(defvar ebox-native-reflow--compile-property-template-index nil + "Persistent retained property-template-to-id index.") + +(defvar ebox-native-reflow--compile-retained-registry-p nil + "Non-nil while style and property registries append incrementally.") + +(defconst ebox-native-reflow--persistent-index-depth 16 + "Fixed number of four-bit branches in a retained native index path.") + +(defun ebox-native-reflow--persistent-index-code (key) + "Return a stable nonnegative retained-index code for KEY." + (if (and (integerp key) (>= key 0)) + key + (logand (sxhash-equal key) #xffffffffffffffff))) + +(defun ebox-native-reflow--persistent-index-get (root key &optional missing) + "Return KEY's value from persistent ROOT, or MISSING." + (let ((node root) + (code (ebox-native-reflow--persistent-index-code key)) + (depth 0)) + (while (and node (< depth ebox-native-reflow--persistent-index-depth)) + (setq node (and (vectorp node) + (aref node (logand (ash code (* -4 depth)) 15))) + depth (1+ depth))) + (if (= depth ebox-native-reflow--persistent-index-depth) + (let ((pair (cl-assoc key node :test #'equal))) + (if pair (cdr pair) missing)) + missing))) + +(defun ebox-native-reflow--persistent-index-put (root key value &optional depth) + "Return persistent ROOT updated to map KEY to VALUE. +Only the fixed-depth path for KEY is copied. DEPTH is for recursion." + (let ((depth (or depth 0))) + (if (= depth ebox-native-reflow--persistent-index-depth) + (cons (cons key value) + (cl-remove key root :key #'car :test #'equal)) + (let* ((code (ebox-native-reflow--persistent-index-code key)) + (branch (logand (ash code (* -4 depth)) 15)) + (copy (if (vectorp root) + (copy-sequence root) + (make-vector 16 nil)))) + (aset copy branch + (ebox-native-reflow--persistent-index-put + (aref copy branch) key value (1+ depth))) + copy)))) + +(defun ebox-native-reflow--persistent-index-from-sequence (sequence) + "Return an immutable persistent index for ordered SEQUENCE values." + (let (root) + (cl-loop for value across (vconcat sequence) + for id from 0 + do (setq root + (ebox-native-reflow--persistent-index-put + root value id))) + root)) + (defvar ebox-native-reflow--compile-property-template-ids nil "Dynamically bound opaque property-template lookup table.") @@ -1338,14 +1406,20 @@ copied separately; no pending frame or registered Rust document is inherited." (condition-case err (progn (setf (ebox-native-reflow-session-styles fork) - (copy-sequence (ebox-native-reflow-session-styles session)) + (ebox-native-reflow-session-styles session) (ebox-native-reflow-session-layout-package fork) (ebox-native-reflow-session-layout-package session) (ebox-native-reflow-session-layout-fragment-cache fork) - (copy-hash-table - (ebox-native-reflow-session-layout-fragment-cache session)) + (ebox-native-reflow-session-layout-fragment-cache session) + (ebox-native-reflow-session-layout-fragment-index fork) + (ebox-native-reflow-session-layout-fragment-index session) (ebox-native-reflow-session-layout-fragment-revision fork) - (ebox-native-reflow-session-layout-fragment-revision session)) + (ebox-native-reflow-session-layout-fragment-revision session) + (ebox-native-reflow-session-layout-style-index fork) + (ebox-native-reflow-session-layout-style-index session) + (ebox-native-reflow-session-layout-property-template-index fork) + (ebox-native-reflow-session-layout-property-template-index + session)) fork) (error (ebox-native-reflow-release-session fork) @@ -1409,7 +1483,11 @@ copied separately; no pending frame or registered Rust document is inherited." :properties owned-properties)) (missing (make-symbol "missing-property-template")) (existing - (if (hash-table-p + (if ebox-native-reflow--compile-retained-registry-p + (ebox-native-reflow--persistent-index-get + ebox-native-reflow--compile-property-template-index + entry missing) + (if (hash-table-p ebox-native-reflow--compile-property-template-ids) (gethash entry ebox-native-reflow--compile-property-template-ids @@ -1417,13 +1495,19 @@ copied separately; no pending frame or registered Rust document is inherited." (or (cl-position entry ebox-native-reflow--compile-property-templates :test #'equal) - missing)))) + missing))))) (if (not (eq existing missing)) existing - (let ((id (length ebox-native-reflow--compile-property-templates))) + (let ((id (+ ebox-native-reflow--compile-property-template-base-count + (length ebox-native-reflow--compile-property-templates)))) (setq ebox-native-reflow--compile-property-templates (append ebox-native-reflow--compile-property-templates (list entry))) + (when ebox-native-reflow--compile-retained-registry-p + (setq ebox-native-reflow--compile-property-template-index + (ebox-native-reflow--persistent-index-put + ebox-native-reflow--compile-property-template-index + entry id))) (when (hash-table-p ebox-native-reflow--compile-property-template-ids) (puthash @@ -1592,13 +1676,27 @@ When FORMATTED-P is non-nil, preserve exact display spaces produced by KP." (defun ebox-native-reflow--register-style (mode face) "Return the stable numeric id for a MODE and FACE operation." (let* ((entry (list :mode mode :face face)) - (existing (cl-position entry ebox-native-reflow--compile-styles - :test #'equal))) - (or existing - (prog1 (length ebox-native-reflow--compile-styles) + (missing (make-symbol "missing-native-style")) + (existing + (if ebox-native-reflow--compile-retained-registry-p + (ebox-native-reflow--persistent-index-get + ebox-native-reflow--compile-style-index entry missing) + (or (cl-position entry ebox-native-reflow--compile-styles + :test #'equal) + missing)))) + (if (not (eq existing missing)) + existing + (prog1 (+ ebox-native-reflow--compile-style-base-count + (length ebox-native-reflow--compile-styles)) (setq ebox-native-reflow--compile-styles (append ebox-native-reflow--compile-styles - (list entry))))))) + (list entry))) + (when ebox-native-reflow--compile-retained-registry-p + (setq ebox-native-reflow--compile-style-index + (ebox-native-reflow--persistent-index-put + ebox-native-reflow--compile-style-index entry + (+ ebox-native-reflow--compile-style-base-count + (1- (length ebox-native-reflow--compile-styles)))))))))) (defun ebox-native-reflow--compile-add-face-style (face) "Register additive FACE and return its id, or nil." @@ -2286,6 +2384,16 @@ Otherwise return CANDIDATE with the next document revision." (plist-put replacement :document-revision (1+ (or (plist-get old :document-revision) 0))))))) +(defun ebox-native-reflow--confirmed-layout-package (package) + "Return PACKAGE without one-shot retained input transport fields." + (if (plist-get package :document-delta) + (let ((confirmed (copy-sequence package))) + (dolist (key '(:document-delta :native-fragment-index + :native-fragment-revision :native-style-index)) + (cl-remf confirmed key)) + confirmed) + package)) + (defun ebox-native-reflow--buffer-display-signature (buffer) "Return BUFFER's live canonical Surface display capability." (with-current-buffer buffer @@ -2491,9 +2599,9 @@ per call, and no call recursively visits the captured Ebox tree." (gethash child flex-content-min-widths))) (ebox-native-reflow--retained-layout-children node)))))) -(defun ebox-native-reflow--compile-retained-layout-package +(defun ebox-native-reflow--compile-retained-layout-package-full (session state node) - "Compile NODE incrementally into retained native SESSION from STATE." + "Compile a complete retained package for NODE in SESSION from STATE." (let ((postorder (plist-get state :native-node-postorder))) (unless (and (vectorp postorder) (> (length postorder) 0)) (setq postorder @@ -2632,7 +2740,9 @@ per call, and no call recursively visits the captured Ebox tree." (setq fragment (ebox-native-reflow--compile-scene-node current fragments flex-content-min-widths) + fragment (plist-put fragment :node-id node-id) revision (1+ revision) + fragment (plist-put fragment :node-revision revision) entry (list :signature (copy-tree signature) :fragment fragment :revision revision))) @@ -2658,10 +2768,354 @@ per call, and no call recursively visits the captured Ebox tree." candidate))) (setf (ebox-native-reflow-session-layout-fragment-cache session) new-cache + (ebox-native-reflow-session-layout-fragment-index session) + (let (index) + (maphash + (lambda (node-id entry) + (setq index + (ebox-native-reflow--persistent-index-put + index node-id entry))) + new-cache) + index) (ebox-native-reflow-session-layout-fragment-revision session) - revision) + revision + (ebox-native-reflow-session-layout-style-index session) + (ebox-native-reflow--persistent-index-from-sequence + (plist-get package :styles)) + (ebox-native-reflow-session-layout-property-template-index + session) + (ebox-native-reflow--persistent-index-from-sequence + (plist-get package :property-templates))) package))))) +(defconst ebox-native-reflow--delta-edge-fields + '(:type :child :children :items :node-id :node-revision) + "Fragment fields that a stable local node delta cannot replace.") + +(defconst ebox-native-reflow--delta-flex-edge-properties + '(:order :flex-grow :flex-shrink :flex-basis :align-self + :flex-direction :cross-align) + "Changed properties that alter retained Flex item metadata in N1.") + +(defun ebox-native-reflow--delta-flex-edge-change-p (state) + "Return non-nil when STATE changes unsupported Flex edge metadata." + (cl-some + (lambda (dirty) + (let* ((keys (plist-get dirty :changed-keys)) + (parents (plist-get state :parent-table)) + (nodes (plist-get state :node-table)) + (dirty-id (plist-get dirty :node-id)) + (dirty-node (and nodes (gethash dirty-id nodes))) + (raw-parent-id (and parents (gethash dirty-id parents))) + (raw-parent (and nodes (gethash raw-parent-id nodes))) + (owner-id + (if (and (eq (plist-get dirty-node :ebox-kind) 'text) + (eq (plist-get raw-parent :ebox-kind) 'box) + (when-let* ((layout + (plist-get raw-parent + :ebox-layout-config))) + (eq (ebox-layout-config-kind layout) 'normal))) + raw-parent-id + dirty-id)) + (edge-parent-id (and parents (gethash owner-id parents))) + (edge-parent (and nodes (gethash edge-parent-id nodes))) + (config + (and edge-parent + (plist-get edge-parent :ebox-layout-config)))) + (or + (cl-some (lambda (key) + (memq key ebox-native-reflow--delta-flex-edge-properties)) + keys) + ;; A child-local edit can change content-min-width in a Flex item or + ;; derived align-self in typed axis lowering. N1 has no edge patch, + ;; so retain correctness through full input until exact edge facts are + ;; independently addressable. + (or (eq (plist-get edge-parent :ebox-type) 'flex) + (and config + (let ((kind (ebox-layout-config-kind config)) + (props (ebox-layout-config-props config))) + (or (eq kind 'flex) + (and (memq kind '(row column)) + (not (and (equal (plist-get props :item-gap) 0) + (eq (plist-get props :cross-align) + 'stretch))))))))))) + (plist-get state :native-local-dirty-entries))) + +(defun ebox-native-reflow--fragment-local (fragment) + "Return FRAGMENT's scalar and local-content fields." + (let ((tail fragment) local) + (while tail + (let ((key (pop tail)) + (value (pop tail))) + (unless (memq key ebox-native-reflow--delta-edge-fields) + (setq local (nconc local (list key value)))))) + local)) + +(defun ebox-native-reflow--local-difference (old new) + "Return NEW local fields whose values differ from OLD." + (let ((tail new) difference) + (while tail + (let ((key (pop tail)) + (value (pop tail))) + (unless (equal value (plist-get old key)) + (setq difference (nconc difference (list key value)))))) + difference)) + +(defun ebox-native-reflow--apply-local-difference (fragment difference) + "Return FRAGMENT with scalar fields from DIFFERENCE replaced." + (let ((copy (copy-sequence fragment)) + (tail difference)) + (while tail + (setq copy (plist-put copy (pop tail) (pop tail)))) + copy)) + +(defun ebox-native-reflow--delta-owner-id (state node-id index) + "Return addressable owner for NODE-ID in STATE and retained INDEX." + (let ((parents (plist-get state :parent-table)) + (nodes (plist-get state :node-table)) + (current node-id)) + (when-let* ((source (and nodes (gethash current nodes))) + (parent-id (and parents (gethash current parents))) + (parent (and nodes (gethash parent-id nodes)))) + (when (and (eq (plist-get source :ebox-kind) 'text) + (eq (plist-get parent :ebox-kind) 'box) + (eq (ebox-layout-config-kind + (plist-get parent :ebox-layout-config)) + 'normal)) + (setq current parent-id))) + (while (and current + (null (ebox-native-reflow--persistent-index-get + index current))) + (setq current (and parents (gethash current parents)))) + current)) + +(defun ebox-native-reflow--compile-delta-slots (node old-fragment) + "Compile NODE local slots while retaining OLD-FRAGMENT edges." + (pcase (plist-get node :ebox-type) + ('box + (if (eq (plist-get node :ebox-kind) 'text) + (vector (ebox-native-reflow--compile-text-node node)) + (let* ((config (and (eq (plist-get node :ebox-kind) 'box) + (plist-get node :ebox-layout-config))) + (kind (and config (ebox-layout-config-kind config))) + (old-child (plist-get old-fragment :child)) + (fused-p (and (eq kind 'normal) + (let ((child (car (ebox-tree-node-children node)))) + (and child + (eq (plist-get child :ebox-kind) 'text))))) + (inner + (pcase kind + ((or 'row 'column) + (ebox-native-reflow--compile-typed-axis-children + kind config nil)) + ('flex + (ebox-native-reflow--compile-flex-inner + (copy-sequence (ebox-layout-config-props config)) nil)) + (_ nil))) + (child (cond (fused-p + (ebox-native-reflow--compile-text-node + (car (ebox-tree-node-children node)))) + (inner inner) + (t old-child))) + (outer (ebox-native-reflow--compile-box node child nil t))) + (if inner (vector outer inner) (vector outer))))) + ('flex + (let* ((wrapper (plist-get node :box)) + (props (ebox--flex-container-content-props + (plist-get node :props) (plist-get node :raw-props) + wrapper))) + (when wrapper + (plist-put props :width nil) + (when (plist-member (plist-get node :raw-props) :height) + (plist-put props :height 'viewport-height))) + (let ((inner (ebox-native-reflow--compile-flex-inner props nil))) + (if wrapper + (vector (ebox-native-reflow--compile-box wrapper inner t) inner) + (vector inner))))) + (_ (vector old-fragment)))) + +(defun ebox-native-reflow--delta-direct-owner-ids (state index) + "Return owners for STATE's direct dirty entries found in INDEX." + (let ((seen (make-hash-table :test 'equal)) result) + (dolist (dirty (plist-get state :native-local-dirty-entries)) + (when-let* ((owner + (ebox-native-reflow--delta-owner-id + state (plist-get dirty :node-id) index))) + (unless (gethash owner seen) + (puthash owner t seen) + (push owner result)))) + (nreverse result))) + +(defun ebox-native-reflow--compile-retained-layout-delta + (session state node) + "Return SESSION's local delta for NODE under STATE, or nil for fallback." + (let* ((old-package (ebox-native-reflow-session-layout-package session)) + (old-index (ebox-native-reflow-session-layout-fragment-index session)) + (node-table (plist-get state :node-table)) + (touched (plist-get state :native-touched-node-ids))) + (when (and old-package old-index (hash-table-p node-table) + (plist-get state :native-topology-stable-p) + (plist-member state :native-local-dirty-entries) + (not (ebox-native-reflow--delta-flex-edge-change-p state)) + ;; Descendant inherited-style expansion needs its own bounded + ;; affected-node index. Until then it is an explicit full + ;; input fallback rather than a partial semantic update. + (null (plist-get state :native-inherited-dirty-node-ids))) + (if (null touched) + old-package + (let* ((style-base (length (plist-get old-package :styles))) + (template-base + (length (plist-get old-package :property-templates))) + (ebox-native-reflow--compile-styles nil) + (ebox-native-reflow--compile-style-base-count style-base) + (ebox-native-reflow--compile-style-index + (ebox-native-reflow-session-layout-style-index session)) + (ebox-native-reflow--compile-property-templates nil) + (ebox-native-reflow--compile-property-template-base-count + template-base) + (ebox-native-reflow--compile-property-template-index + (ebox-native-reflow-session-layout-property-template-index + session)) + (ebox-native-reflow--compile-retained-registry-p t) + (ebox-native-reflow--compile-display-signature + (ebox--current-display-signature)) + (ebox-native-reflow--compile-root-node node) + (ebox--render-string-pixel-width-cache + (make-hash-table :test 'equal)) + (ebox--render-string-max-pixel-width-cache + (make-hash-table :test 'eq)) + (direct (ebox-native-reflow--delta-direct-owner-ids + state old-index)) + (direct-set (make-hash-table :test 'equal)) + (entry-ids nil) + (seen (make-hash-table :test 'equal)) + (new-index old-index) + (revision + (or (ebox-native-reflow-session-layout-fragment-revision + session) 0)) + entries unsupported) + (dolist (id direct) (puthash id t direct-set)) + (dolist (id touched) + (when-let* ((owner + (ebox-native-reflow--delta-owner-id state id old-index))) + (unless (gethash owner seen) + (puthash owner t seen) + (push owner entry-ids)))) + (dolist (owner-id (nreverse entry-ids)) + (let* ((cached (ebox-native-reflow--persistent-index-get + old-index owner-id)) + (old-fragment (plist-get cached :fragment)) + (source (gethash owner-id node-table)) + (expected (plist-get cached :revision)) + patches new-fragment) + (unless (and cached source (integerp expected)) + (setq unsupported t)) + (when (and (not unsupported) (gethash owner-id direct-set)) + (let ((slots + (ebox-native-reflow--compile-delta-slots + source old-fragment))) + (unless (equal (plist-get (aref slots 0) :type) + (plist-get old-fragment :type)) + (setq unsupported t)) + (unless unsupported + (let ((local + (ebox-native-reflow--local-difference + (ebox-native-reflow--fragment-local old-fragment) + (ebox-native-reflow--fragment-local + (aref slots 0))))) + (when local + (push (list :slot 0 :local local) patches) + (setq new-fragment + (ebox-native-reflow--apply-local-difference + old-fragment local)))) + (when (> (length slots) 1) + (let ((old-inner (plist-get old-fragment :child)) + (new-inner (aref slots 1))) + (unless (equal (plist-get old-inner :type) + (plist-get new-inner :type)) + (setq unsupported t)) + (unless unsupported + (let ((local + (ebox-native-reflow--local-difference + (ebox-native-reflow--fragment-local old-inner) + (ebox-native-reflow--fragment-local new-inner)))) + (when local + (push (list :slot 1 :local local) patches) + (let ((outer (or new-fragment + (copy-sequence old-fragment)))) + (plist-put + outer :child + (ebox-native-reflow--apply-local-difference + old-inner local)) + (setq new-fragment outer)))))))))) + (unless unsupported + (setq revision (1+ revision)) + (unless new-fragment + (setq new-fragment (copy-sequence old-fragment))) + (plist-put new-fragment :node-id owner-id) + (plist-put new-fragment :node-revision revision) + (let* ((entry + (append + (list :node-id owner-id + :expected-revision expected + :target-revision revision) + (when patches + (list :slot-patches (vconcat (nreverse patches)))))) + (new-cache-entry + (list :signature nil + :fragment (or new-fragment old-fragment) + :revision revision))) + (push entry entries) + (setq new-index + (ebox-native-reflow--persistent-index-put + new-index owner-id new-cache-entry)))))) + (when (and (not unsupported) + ;; N1 can authenticate retained templates, but cannot + ;; append their Emacs-owned values to Rust atomically. + (= template-base + (+ template-base + (length ebox-native-reflow--compile-property-templates)))) + (let* ((styles-append ebox-native-reflow--compile-styles) + (styles + (if styles-append + ;; Materializing the global style output is explicitly + ;; outside retained input work; the common no-append + ;; path preserves the old vector by identity. + (vconcat (plist-get old-package :styles) styles-append) + (plist-get old-package :styles))) + (base (or (plist-get old-package :document-revision) 0)) + (package (copy-sequence old-package))) + (plist-put package :document-revision (1+ base)) + (plist-put package :styles styles) + (plist-put + package :document-delta + (list :style-base-count style-base + :styles-append + (vconcat + (mapcar + (lambda (descriptor) + (list :mode (symbol-name (plist-get descriptor :mode)) + :face + (list :lisp + (prin1-to-string + (plist-get descriptor :face))))) + styles-append)) + :property-template-base-count template-base + :property-template-target-count template-base + :entries (vconcat (nreverse entries)))) + (plist-put package :native-fragment-index new-index) + (plist-put package :native-fragment-revision revision) + (plist-put package :native-style-index + ebox-native-reflow--compile-style-index) + package))))))) + +(defun ebox-native-reflow--compile-retained-layout-package + (session state node) + "Compile NODE for SESSION using a delta when STATE proves it supported." + (or (ebox-native-reflow--compile-retained-layout-delta session state node) + (ebox-native-reflow--compile-retained-layout-package-full + session state node))) + (defun ebox-native-reflow-compile-layout-ir (node) "Compile NODE to one complete versioned native layout document." (plist-get (ebox-native-reflow--compile-layout-package node) :document)) @@ -2682,9 +3136,10 @@ per call, and no call recursively visits the captured Ebox tree." (defun ebox-native-reflow--layout-control-json (document frames &optional document-base-revision - document-target-revision) + document-target-revision document-delta) "Return strict control JSON for optional layout DOCUMENT and FRAMES. -DOCUMENT-BASE-REVISION and DOCUMENT-TARGET-REVISION bind retained input." +DOCUMENT-BASE-REVISION and DOCUMENT-TARGET-REVISION bind retained input. +DOCUMENT-DELTA is the stable-topology local replacement batch." (unless (and (listp frames) frames) (error "Native reflow requires at least one layout frame")) (let ((control @@ -2792,6 +3247,8 @@ DOCUMENT-BASE-REVISION and DOCUMENT-TARGET-REVISION bind retained input." document-target-revision))) (when document (setq control (plist-put control :document document))) + (when document-delta + (setq control (plist-put control :document-delta document-delta))) (ebox-native-reflow--serialize-layout-control control))) (defun ebox-native-reflow-submit-layout @@ -2807,9 +3264,12 @@ LAYOUT-PACKAGE may reuse a caller-validated document for the same NODE." node (ebox-native-reflow-session-styles session) (plist-get old-package :property-templates)))) + (document-delta (plist-get candidate-package :document-delta)) (package - (ebox-native-reflow--reuse-exact-layout-package - old-package candidate-package)) + (if document-delta + candidate-package + (ebox-native-reflow--reuse-exact-layout-package + old-package candidate-package))) (register-layout-p (not (eq package old-package))) @@ -2821,12 +3281,14 @@ LAYOUT-PACKAGE may reuse a caller-validated document for the same NODE." (ebox-native-reflow--live-handle session) generation (ebox-native-reflow--layout-control-json - (and register-layout-p (plist-get package :document)) frames - base-revision target-revision)))) + (and register-layout-p (not document-delta) + (plist-get package :document)) + frames base-revision target-revision document-delta)))) (setf (ebox-native-reflow-session-generation session) generation) (setf (ebox-native-reflow-session-styles session) (plist-get package :styles)) - (setf (ebox-native-reflow-session-layout-package session) package) + (setf (ebox-native-reflow-session-layout-package session) + (ebox-native-reflow--confirmed-layout-package package)) accepted)) (defun ebox-native-reflow--tape-cursor (payload &optional position) @@ -3968,13 +4430,16 @@ root effect metadata required by a thin host commit." (plist-get (ebox-native-reflow-session-layout-package session) :property-templates))))) + (document-delta (plist-get candidate-package :document-delta)) (package - (ebox-native-reflow--reuse-exact-layout-package - old-package candidate-package)) + (if document-delta + candidate-package + (ebox-native-reflow--reuse-exact-layout-package + old-package candidate-package))) (generation (1+ (ebox-native-reflow-session-generation session))) (control-frame (copy-sequence frame)) (native-key (or (plist-get control-frame :key) 1)) - (reuse-document-p (eq package old-package)) + (reuse-document-p (or document-delta (eq package old-package))) (base-revision (or (plist-get old-package :document-revision) 0)) (target-revision (or (plist-get package :document-revision) (1+ base-revision)))) @@ -3986,12 +4451,22 @@ root effect metadata required by a thin host commit." generation (ebox-native-reflow--layout-control-json (unless reuse-document-p (plist-get package :document)) - (list control-frame) base-revision target-revision)) + (list control-frame) base-revision target-revision + document-delta)) package control-frame generation))) (setf (ebox-native-reflow-session-generation session) generation - (ebox-native-reflow-session-layout-package session) package + (ebox-native-reflow-session-layout-package session) + (ebox-native-reflow--confirmed-layout-package package) (ebox-native-reflow-session-styles session) (plist-get package :styles)) + (when document-delta + (setf (ebox-native-reflow-session-layout-fragment-cache session) nil + (ebox-native-reflow-session-layout-fragment-index session) + (plist-get package :native-fragment-index) + (ebox-native-reflow-session-layout-fragment-revision session) + (plist-get package :native-fragment-revision) + (ebox-native-reflow-session-layout-style-index session) + (plist-get package :native-style-index))) result))) (defun ebox-native-reflow-render-proof-sync diff --git a/ebox-state-contract.el b/ebox-state-contract.el index d592c66..bfa2367 100644 --- a/ebox-state-contract.el +++ b/ebox-state-contract.el @@ -278,6 +278,12 @@ rather than relying on an implicit default.") (:symbol ebox-native-reflow--compile-property-template-ids :reason dynamically-bound-compile-local-scratch :evidence let-bound-per-compile-and-never-committed) + (:symbol ebox-native-reflow--compile-property-template-index + :reason dynamically-bound-compile-local-scratch + :evidence let-bound-per-compile-and-published-only-through-session-root) + (:symbol ebox-native-reflow--compile-style-index + :reason dynamically-bound-compile-local-scratch + :evidence let-bound-per-compile-and-published-only-through-session-root) (:symbol ebox--render-source-generations :reason dynamically-bound-render-proof-input :evidence let-bound-to-candidate-and-prior-generations-for-one-plan) diff --git a/native/c/ebox_module.c b/native/c/ebox_module.c index c4ac048..9050ef8 100644 --- a/native/c/ebox_module.c +++ b/native/c/ebox_module.c @@ -164,7 +164,7 @@ ebox_module_version(emacs_env *env, ptrdiff_t nargs, emacs_value *args, (void) nargs; (void) args; (void) data; - static const char version[] = "11:7:12"; + static const char version[] = "12:7:12"; return env->make_string(env, version, (ptrdiff_t) (sizeof version - 1)); } diff --git a/native/src/layout.rs b/native/src/layout.rs index 6e386bf..2f40df7 100644 --- a/native/src/layout.rs +++ b/native/src/layout.rs @@ -1,16 +1,51 @@ use etaf_core::{diff_commit_batch, CommitBatch, SpanEdit}; -use serde::Deserialize; -use std::collections::BTreeMap; - -#[cfg(test)] +use serde::{Deserialize, Deserializer}; +use serde_json::{Map as JsonMap, Value as JsonValue}; use std::cell::Cell; +use std::collections::BTreeMap; +use std::collections::HashSet; +use std::sync::Arc; + +fn deserialize_arc_vec<'de, D, T>(deserializer: D) -> Result>, D::Error> +where + D: Deserializer<'de>, + T: Deserialize<'de>, +{ + Vec::::deserialize(deserializer).map(Arc::new) +} + +fn deserialize_optional_arc<'de, D, T>(deserializer: D) -> Result>, D::Error> +where + D: Deserializer<'de>, + T: Deserialize<'de>, +{ + Option::::deserialize(deserializer).map(|value| value.map(Arc::new)) +} + +fn deserialize_arc<'de, D, T>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, + T: Deserialize<'de>, +{ + T::deserialize(deserializer).map(Arc::new) +} -#[cfg(test)] thread_local! { + static RESOLVER_LOOKUP_COUNT: Cell = const { Cell::new(0) }; + #[cfg(test)] static TEST_RENDER_NODE_COUNT: Cell> = const { Cell::new(None) }; + #[cfg(test)] static TEST_DISABLE_WINDOW_RENDER: Cell = const { Cell::new(false) }; } +pub(crate) fn reset_resolver_lookups() { + RESOLVER_LOOKUP_COUNT.with(|count| count.set(0)); +} + +pub(crate) fn resolver_lookups() -> u64 { + RESOLVER_LOOKUP_COUNT.with(Cell::get) +} + const LAYOUT_VERSION: u32 = 2; pub const TAPE_VERSION: u16 = 12; pub const TAPE_HEADER_LEN: usize = 112; @@ -47,7 +82,7 @@ const METADATA_SCROLL_CONTENT: u8 = 16; const METADATA_SCROLL_OWNER: u8 = 17; const METADATA_SCROLL_WINDOW: u8 = 18; -#[derive(Debug, Deserialize)] +#[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "kebab-case", deny_unknown_fields)] pub struct LayoutDocument { version: u32, @@ -60,6 +95,962 @@ pub struct LayoutDocument { root: LayoutNode, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +pub(crate) struct DocumentDelta { + pub(crate) style_base_count: u32, + #[serde(default)] + pub(crate) styles_append: Vec, + pub(crate) property_template_base_count: u32, + pub(crate) property_template_target_count: u32, + pub(crate) entries: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +pub(crate) struct DocumentDeltaEntry { + pub(crate) node_id: u64, + pub(crate) expected_revision: u64, + pub(crate) target_revision: u64, + #[serde(default)] + slot_patches: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +struct LocalSlotPatch { + slot: u8, + local: JsonMap, +} + +#[derive(Debug)] +struct RetainedEntry { + revision: u64, + node: Arc, + slot_work: [Arc; 2], +} + +#[derive(Debug, Clone, Default)] +struct LocalWorkSummary(BTreeMap<&'static str, usize>); + +#[derive(Debug)] +struct RadixNode { + children: [Option>; 16], + value: Option>, +} + +impl Default for RadixNode { + fn default() -> Self { + Self { + children: std::array::from_fn(|_| None), + value: None, + } + } +} + +fn radix_lookup(root: &Arc, key: u64) -> Option<&Arc> { + let mut current = root.as_ref(); + for level in 0..16 { + let shift = 60 - level * 4; + current = current.children[((key >> shift) & 0xf) as usize].as_deref()?; + } + current.value.as_ref() +} + +fn radix_insert( + root: &Arc, + key: u64, + value: Arc, +) -> (Arc, u64) { + fn insert_at( + node: &Arc, + key: u64, + level: usize, + value: &Arc, + copied: &mut u64, + ) -> Arc { + *copied += 1; + let mut children = node.children.clone(); + let mut stored = node.value.clone(); + if level == 16 { + stored = Some(Arc::clone(value)); + } else { + let shift = 60 - level * 4; + let index = ((key >> shift) & 0xf) as usize; + let child = children[index] + .clone() + .unwrap_or_else(|| Arc::new(RadixNode::default())); + children[index] = Some(insert_at(&child, key, level + 1, value, copied)); + } + Arc::new(RadixNode { + children, + value: stored, + }) + } + + let mut copied = 0; + (insert_at(root, key, 0, &value, &mut copied), copied) +} + +#[derive(Debug)] +pub(crate) struct RetainedDocument { + root_id: u64, + entries: Arc, + styles: Arc, + style_count: u32, + property_template_count: u32, + work_units: usize, +} + +#[derive(Debug)] +struct StyleRadixNode { + children: [Option>; 16], + value: Option>, +} + +impl Default for StyleRadixNode { + fn default() -> Self { + Self { + children: std::array::from_fn(|_| None), + value: None, + } + } +} + +fn style_lookup(root: &Arc, key: u64) -> Option<&Arc> { + let mut current = root.as_ref(); + for level in 0..16 { + let shift = 60 - level * 4; + current = current.children[((key >> shift) & 0xf) as usize].as_deref()?; + } + current.value.as_ref() +} + +fn style_insert( + root: &Arc, + key: u64, + value: Arc, +) -> (Arc, u64) { + fn insert_at( + node: &Arc, + key: u64, + level: usize, + value: &Arc, + copied: &mut u64, + ) -> Arc { + *copied += 1; + let mut children = node.children.clone(); + let mut stored = node.value.clone(); + if level == 16 { + stored = Some(Arc::clone(value)); + } else { + let shift = 60 - level * 4; + let index = ((key >> shift) & 0xf) as usize; + let child = children[index] + .clone() + .unwrap_or_else(|| Arc::new(StyleRadixNode::default())); + children[index] = Some(insert_at(&child, key, level + 1, value, copied)); + } + Arc::new(StyleRadixNode { + children, + value: stored, + }) + } + + let mut copied = 0; + (insert_at(root, key, 0, &value, &mut copied), copied) +} + +impl LayoutNode { + fn node_id(&self) -> Option { + match self { + Self::Box { node_id, .. } + | Self::Text { node_id, .. } + | Self::Row { node_id, .. } + | Self::Column { node_id, .. } + | Self::Flex { node_id, .. } => *node_id, + Self::NodeRef { node_id } => Some(*node_id), + } + } + + fn node_revision(&self) -> Option { + match self { + Self::Box { node_revision, .. } + | Self::Text { node_revision, .. } + | Self::Row { node_revision, .. } + | Self::Column { node_revision, .. } + | Self::Flex { node_revision, .. } => *node_revision, + Self::NodeRef { .. } => None, + } + } +} + +fn visit_layout_children(node: &LayoutNode, mut visit: impl FnMut(&LayoutNode)) { + match node { + LayoutNode::Box { child, .. } => { + if let Some(child) = child.as_deref() { + visit(child); + } + } + LayoutNode::Row { children, .. } => { + for child in children.iter() { + visit(child); + } + } + LayoutNode::Column { children, .. } => { + for child in children.iter() { + visit(child); + } + } + LayoutNode::Flex { items, .. } => { + for item in items.iter() { + visit(&item.node); + } + } + LayoutNode::Text { .. } | LayoutNode::NodeRef { .. } => {} + } +} + +impl RetainedDocument { + pub(crate) fn bootstrap(document: LayoutDocument) -> Result<(Arc, u64), String> { + let (node_count, work_units) = document.validate_metrics()?; + fn install_owner( + mut node: LayoutNode, + entries: &mut Arc, + seen: &mut HashSet, + count: &mut u64, + ) -> Result { + fn localize( + node: &mut LayoutNode, + owner_id: u64, + entries: &mut Arc, + seen: &mut HashSet, + count: &mut u64, + ) -> Result<(), String> { + fn localize_child( + child: LayoutNode, + owner_id: u64, + entries: &mut Arc, + seen: &mut HashSet, + count: &mut u64, + ) -> Result { + if child.node_id().is_some() { + let installed = install_owner(child, entries, seen, count)?; + return Ok(LayoutNode::NodeRef { node_id: installed }); + } + let mut child = child; + localize(&mut child, owner_id, entries, seen, count)?; + Ok(child) + } + + match node { + LayoutNode::Box { child, .. } => { + if let Some(old) = child.take() { + let old = Arc::try_unwrap(old).unwrap_or_else(|value| (*value).clone()); + *child = Some(Arc::new(localize_child( + old, owner_id, entries, seen, count, + )?)); + } + } + LayoutNode::Row { children, .. } | LayoutNode::Column { children, .. } => { + let old = std::mem::take(children); + let old = Arc::try_unwrap(old).unwrap_or_else(|value| (*value).clone()); + let mut localized = Vec::with_capacity(old.len()); + for child in old { + localized.push(localize_child(child, owner_id, entries, seen, count)?); + } + *children = Arc::new(localized); + } + LayoutNode::Flex { items, .. } => { + let old = std::mem::take(items); + let old = Arc::try_unwrap(old).unwrap_or_else(|value| (*value).clone()); + let mut localized = Vec::with_capacity(old.len()); + for mut item in old { + item.node = localize_child(item.node, owner_id, entries, seen, count)?; + localized.push(item); + } + *items = Arc::new(localized); + } + LayoutNode::Text { .. } => {} + LayoutNode::NodeRef { .. } => { + return Err( + "Native retained bootstrap received a node reference".to_owned() + ); + } + } + Ok(()) + } + + let node_id = node.node_id().ok_or_else(|| { + "Native retained document owner is missing its node id".to_owned() + })?; + let revision = node.node_revision().ok_or_else(|| { + "Native retained document node is missing its revision".to_owned() + })?; + if node_id == 0 || !seen.insert(node_id) { + return Err("Native retained document has invalid duplicate node id".to_owned()); + } + localize(&mut node, node_id, entries, seen, count)?; + let slot_one = match &node { + LayoutNode::Box { + child: Some(child), .. + } if child.node_id().is_none() => local_work_summary(child)?, + _ => LocalWorkSummary::default(), + }; + let entry = Arc::new(RetainedEntry { + revision, + slot_work: [Arc::new(local_work_summary(&node)?), Arc::new(slot_one)], + node: Arc::new(node), + }); + let (updated, _) = radix_insert(entries, node_id, entry); + *entries = updated; + *count += 1; + Ok(node_id) + } + + let mut entries = Arc::new(RadixNode::default()); + let mut seen = HashSet::new(); + let mut owner_count = 0; + let root_id = install_owner(document.root, &mut entries, &mut seen, &mut owner_count)?; + debug_assert!(usize::try_from(owner_count).is_ok_and(|count| count <= node_count)); + let mut styles = Arc::new(StyleRadixNode::default()); + if document.styles.len() != document.style_count as usize { + return Err("Native retained style table count mismatch".to_owned()); + } + for (index, style) in document.styles.into_iter().enumerate() { + let (updated, _) = style_insert(&styles, index as u64, Arc::new(style)); + styles = updated; + } + Ok(( + Arc::new(Self { + root_id, + entries, + styles, + style_count: document.style_count, + property_template_count: document.property_template_count, + work_units, + }), + node_count as u64, + )) + } + + fn effective_node(&self, node_id: u64) -> Option<&LayoutNode> { + radix_lookup(&self.entries, node_id).map(|entry| entry.node.as_ref()) + } + + fn resolve<'a>(&'a self, fallback: &'a LayoutNode) -> Result<&'a LayoutNode, String> { + match fallback { + LayoutNode::NodeRef { node_id } => { + RESOLVER_LOOKUP_COUNT.with(|count| count.set(count.get().saturating_add(1))); + self.effective_node(*node_id).ok_or_else(|| { + "Native retained document has an unknown node reference".to_owned() + }) + } + _ => Ok(fallback), + } + } + + pub(crate) fn styles(&self) -> Result, String> { + (0..self.style_count) + .map(|index| { + style_lookup(&self.styles, index as u64) + .map(|style| style.as_ref().clone()) + .ok_or_else(|| "Native retained style registry has a gap".to_owned()) + }) + .collect() + } + + pub(crate) fn apply_delta( + &self, + delta: DocumentDelta, + ) -> Result<(Arc, u64, u64), String> { + if delta.style_base_count != self.style_count + || delta.property_template_base_count != self.property_template_count + || delta.property_template_target_count < delta.property_template_base_count + || delta.property_template_target_count > MAX_PROPERTY_TEMPLATE_COUNT + { + return Err("Native retained delta registry base mismatch".to_owned()); + } + for style in &delta.styles_append { + style.face.validate()?; + } + let target_style_count = self + .style_count + .checked_add(delta.styles_append.len() as u32) + .ok_or_else(|| "Native retained style table count overflow".to_owned())?; + if target_style_count as usize > MAX_TAPE_PROPERTY_ENTRIES { + return Err("Native retained style table exceeds its entry limit".to_owned()); + } + let mut seen = HashSet::with_capacity(delta.entries.len()); + let mut prepared = Vec::with_capacity(delta.entries.len()); + let mut work_delta_total = 0_i128; + for update in delta.entries { + if !seen.insert(update.node_id) { + return Err("Native retained delta contains a duplicate node id".to_owned()); + } + let old = radix_lookup(&self.entries, update.node_id) + .ok_or_else(|| "Native retained delta names an unknown node id".to_owned())?; + if old.revision != update.expected_revision + || update.target_revision <= update.expected_revision + { + return Err("Native retained delta node revision mismatch".to_owned()); + } + let node = if update.slot_patches.is_empty() { + Arc::clone(&old.node) + } else { + let (patched, slot_work, work_delta) = patch_owner_slots( + &old.node, + &old.slot_work, + &update.slot_patches, + target_style_count, + delta.property_template_target_count, + )?; + work_delta_total = work_delta_total + .checked_add(work_delta as i128) + .ok_or_else(|| "Native layout work estimate overflowed".to_owned())?; + prepared.push(( + update.node_id, + Arc::new(RetainedEntry { + revision: update.target_revision, + node: Arc::new(patched), + slot_work, + }), + )); + continue; + }; + prepared.push(( + update.node_id, + Arc::new(RetainedEntry { + revision: update.target_revision, + node, + slot_work: old.slot_work.clone(), + }), + )); + } + let work_units = if work_delta_total < 0 { + self.work_units + .checked_sub( + usize::try_from(-work_delta_total) + .map_err(|_| "Native retained work summary invariant failed".to_owned())?, + ) + .ok_or_else(|| "Native retained work summary invariant failed".to_owned())? + } else { + self.work_units + .checked_add( + usize::try_from(work_delta_total) + .map_err(|_| "Native layout work estimate overflowed".to_owned())?, + ) + .ok_or_else(|| "Native layout work estimate overflowed".to_owned())? + }; + if work_units > MAX_LAYOUT_WORK_UNITS { + return Err("Native layout exceeds the work-unit limit".to_owned()); + } + let parsed = prepared.len() as u64; + let mut entries = Arc::clone(&self.entries); + let mut copied = 0; + for (node_id, entry) in prepared { + let (next, count) = radix_insert(&entries, node_id, entry); + entries = next; + copied += count; + } + let mut styles = Arc::clone(&self.styles); + for (offset, style) in delta.styles_append.into_iter().enumerate() { + let style_id = self.style_count as u64 + offset as u64; + let (updated, count) = style_insert(&styles, style_id, Arc::new(style)); + styles = updated; + copied += count; + } + Ok(( + Arc::new(Self { + root_id: self.root_id, + entries, + styles, + style_count: target_style_count, + property_template_count: delta.property_template_target_count, + work_units, + }), + parsed, + copied, + )) + } +} + +fn patch_owner_slots( + base: &LayoutNode, + base_work: &[Arc; 2], + patches: &[LocalSlotPatch], + style_count: u32, + property_template_count: u32, +) -> Result<(LayoutNode, [Arc; 2], isize), String> { + let mut result = base.clone(); + let mut slot_work = base_work.clone(); + let mut work_delta = 0_isize; + let mut seen = [false; 2]; + for patch in patches { + let index = patch.slot as usize; + if index >= seen.len() || std::mem::replace(&mut seen[index], true) { + return Err("Native retained delta has an invalid duplicate slot".to_owned()); + } + if patch.slot == 0 { + let old_work = slot_work[0].changed_work(&patch.local); + result = patch_local_node(&result, &patch.local)?; + validate_changed_fields(&result, &patch.local, style_count, property_template_count)?; + let updated = slot_work[0].updated(&result, &patch.local)?; + let new_work = updated.changed_work(&patch.local); + work_delta += new_work as isize - old_work as isize; + slot_work[0] = Arc::new(updated); + } else { + let LayoutNode::Box { child, .. } = &mut result else { + return Err("Native retained delta slot one requires a box owner".to_owned()); + }; + let old_child = child + .as_deref() + .ok_or_else(|| "Native retained delta slot one is absent".to_owned())?; + if old_child.node_id().is_some() { + return Err("Native retained delta slot one must be anonymous".to_owned()); + } + let old_work = slot_work[1].changed_work(&patch.local); + let new_child = patch_local_node(old_child, &patch.local)?; + validate_changed_fields( + &new_child, + &patch.local, + style_count, + property_template_count, + )?; + let updated = slot_work[1].updated(&new_child, &patch.local)?; + let new_work = updated.changed_work(&patch.local); + work_delta += new_work as isize - old_work as isize; + slot_work[1] = Arc::new(updated); + *child = Some(Arc::new(new_child)); + } + } + Ok((result, slot_work, work_delta)) +} + +fn patch_local_node( + base: &LayoutNode, + fields: &JsonMap, +) -> Result { + fn parsed(value: &JsonValue) -> Result { + serde_json::from_value(value.clone()) + .map_err(|error| format!("Invalid native retained local field: {error}")) + } + let mut output = base.clone(); + for (name, value) in fields { + match &mut output { + LayoutNode::Box { + region_id, + content, + content_region_id, + content_typography_style, + content_foreground_style, + content_surface_template_id, + content_width_exact, + content_min_width, + width, + min_width, + max_width, + height, + min_height, + max_height, + box_sizing, + padding_left, + padding_right, + padding_top, + padding_bottom, + margin_left, + margin_right, + margin_top, + margin_bottom, + border_left, + border_right, + typography_style, + foreground_style, + background_style, + border_left_style, + border_right_style, + border_top_style, + border_bottom_style, + surface_template_id, + text_align, + vertical_align, + overflow, + wrap_mode, + scroll_offset, + .. + } => match name.as_str() { + "region-id" => *region_id = parsed(value)?, + "content" => *content = parsed::>(value)?.map(Arc::new), + "content-region-id" => *content_region_id = parsed(value)?, + "content-typography-style" => *content_typography_style = parsed(value)?, + "content-foreground-style" => *content_foreground_style = parsed(value)?, + "content-surface-template-id" => *content_surface_template_id = parsed(value)?, + "content-width-exact" => *content_width_exact = parsed(value)?, + "content-min-width" => *content_min_width = parsed(value)?, + "width" => *width = parsed(value)?, + "min-width" => *min_width = parsed(value)?, + "max-width" => *max_width = parsed(value)?, + "height" => *height = parsed(value)?, + "min-height" => *min_height = parsed(value)?, + "max-height" => *max_height = parsed(value)?, + "box-sizing" => *box_sizing = parsed(value)?, + "padding-left" => *padding_left = parsed(value)?, + "padding-right" => *padding_right = parsed(value)?, + "padding-top" => *padding_top = parsed(value)?, + "padding-bottom" => *padding_bottom = parsed(value)?, + "margin-left" => *margin_left = parsed(value)?, + "margin-right" => *margin_right = parsed(value)?, + "margin-top" => *margin_top = parsed(value)?, + "margin-bottom" => *margin_bottom = parsed(value)?, + "border-left" => *border_left = parsed(value)?, + "border-right" => *border_right = parsed(value)?, + "typography-style" => *typography_style = parsed(value)?, + "foreground-style" => *foreground_style = parsed(value)?, + "background-style" => *background_style = parsed(value)?, + "border-left-style" => *border_left_style = parsed(value)?, + "border-right-style" => *border_right_style = parsed(value)?, + "border-top-style" => *border_top_style = parsed(value)?, + "border-bottom-style" => *border_bottom_style = parsed(value)?, + "surface-template-id" => *surface_template_id = parsed(value)?, + "text-align" => *text_align = parsed(value)?, + "vertical-align" => *vertical_align = parsed(value)?, + "overflow" => *overflow = parsed(value)?, + "wrap-mode" => *wrap_mode = parsed(value)?, + "scroll-offset" => *scroll_offset = parsed(value)?, + _ => return Err(format!("Unsupported native retained box field {name}")), + }, + LayoutNode::Text { + region_id, + content, + typography_style, + foreground_style, + surface_template_id, + wrap_mode, + .. + } => match name.as_str() { + "region-id" => *region_id = parsed(value)?, + "content" => *content = Arc::new(parsed(value)?), + "typography-style" => *typography_style = parsed(value)?, + "foreground-style" => *foreground_style = parsed(value)?, + "surface-template-id" => *surface_template_id = parsed(value)?, + "wrap-mode" => *wrap_mode = parsed(value)?, + _ => return Err(format!("Unsupported native retained text field {name}")), + }, + LayoutNode::Flex { + direction, + wrap, + justify, + align_items, + align_content, + width, + height, + row_gap, + column_gap, + .. + } => match name.as_str() { + "direction" => *direction = parsed(value)?, + "wrap" => *wrap = parsed(value)?, + "justify" => *justify = parsed(value)?, + "align-items" => *align_items = parsed(value)?, + "align-content" => *align_content = parsed(value)?, + "width" => *width = parsed(value)?, + "height" => *height = parsed(value)?, + "row-gap" => *row_gap = parsed(value)?, + "column-gap" => *column_gap = parsed(value)?, + _ => return Err(format!("Unsupported native retained flex field {name}")), + }, + LayoutNode::Row { .. } | LayoutNode::Column { .. } => { + return Err("Native retained axis slots have no local scalar fields".to_owned()); + } + LayoutNode::NodeRef { .. } => { + return Err("Native retained node reference cannot be patched".to_owned()) + } + } + } + Ok(output) +} + +fn measured_text_work(text: &MeasuredText, property_template_count: u32) -> Result { + if text.lines.is_empty() { + return Err("Native layout measured text must contain one line".to_owned()); + } + let mut work = text.lines.len(); + for line in &text.lines { + work = work + .checked_add(line.clusters.len()) + .ok_or_else(|| "Native layout work estimate overflowed".to_owned())?; + for cluster in &line.clusters { + if cluster.text.is_empty() { + return Err("Native layout cluster text cannot be empty".to_owned()); + } + validate_dimension("cluster width", cluster.width)?; + if cluster + .source_template_id + .is_some_and(|id| id >= property_template_count) + { + return Err( + "Native layout property template id exceeds the property template table" + .to_owned(), + ); + } + } + } + Ok(work) +} + +fn size_work(size: &Size) -> Result { + let mut work = 0; + add_vertical_size_work(size, &mut work)?; + Ok(work) +} + +fn field_work(node: &LayoutNode, name: &str) -> Result, String> { + Ok(match (node, name) { + (LayoutNode::Box { content, .. }, "content") => { + content.as_deref().map_or(Ok(Some(0)), |text| { + measured_text_work(text, u32::MAX).map(Some) + })? + } + (LayoutNode::Text { content, .. }, "content") => { + Some(measured_text_work(content, u32::MAX)?) + } + (LayoutNode::Box { height, .. }, "height") => Some(size_work(height)?), + (LayoutNode::Box { min_height, .. }, "min-height") => Some(size_work(min_height)?), + (LayoutNode::Box { max_height, .. }, "max-height") => Some(size_work(max_height)?), + (LayoutNode::Flex { height, .. }, "height") => Some(size_work(height)?), + (LayoutNode::Box { padding_top, .. }, "padding-top") => { + Some(usize::try_from(*padding_top).unwrap_or(usize::MAX)) + } + (LayoutNode::Box { padding_bottom, .. }, "padding-bottom") => { + Some(usize::try_from(*padding_bottom).unwrap_or(usize::MAX)) + } + (LayoutNode::Box { margin_top, .. }, "margin-top") => { + Some(usize::try_from(*margin_top).unwrap_or(usize::MAX)) + } + (LayoutNode::Box { margin_bottom, .. }, "margin-bottom") => { + Some(usize::try_from(*margin_bottom).unwrap_or(usize::MAX)) + } + (LayoutNode::Flex { row_gap, items, .. }, "row-gap") => Some( + usize::try_from(*row_gap) + .unwrap_or(usize::MAX) + .saturating_mul(items.len()), + ), + _ => None, + }) +} + +fn local_work_summary(node: &LayoutNode) -> Result { + const FIELDS: [&str; 9] = [ + "content", + "height", + "min-height", + "max-height", + "padding-top", + "padding-bottom", + "margin-top", + "margin-bottom", + "row-gap", + ]; + let mut values = BTreeMap::new(); + for name in FIELDS { + if let Some(value) = field_work(node, name)? { + values.insert(name, value); + } + } + Ok(LocalWorkSummary(values)) +} + +impl LocalWorkSummary { + fn changed_work(&self, fields: &JsonMap) -> usize { + fields + .keys() + .filter_map(|name| self.0.get(name.as_str())) + .copied() + .sum() + } + + fn updated( + &self, + node: &LayoutNode, + fields: &JsonMap, + ) -> Result { + let mut output = self.clone(); + for name in fields.keys() { + if let Some(value) = field_work(node, name)? { + output.0.insert( + match name.as_str() { + "content" => "content", + "height" => "height", + "min-height" => "min-height", + "max-height" => "max-height", + "padding-top" => "padding-top", + "padding-bottom" => "padding-bottom", + "margin-top" => "margin-top", + "margin-bottom" => "margin-bottom", + "row-gap" => "row-gap", + _ => continue, + }, + value, + ); + } + } + Ok(output) + } +} + +fn validate_changed_fields( + node: &LayoutNode, + fields: &JsonMap, + style_count: u32, + property_template_count: u32, +) -> Result<(), String> { + let style_field = |name: &str| { + matches!( + name, + "typography-style" + | "foreground-style" + | "background-style" + | "border-left-style" + | "border-right-style" + | "border-top-style" + | "border-bottom-style" + | "content-typography-style" + | "content-foreground-style" + ) + }; + let template_field = + |name: &str| matches!(name, "surface-template-id" | "content-surface-template-id"); + for (name, value) in fields { + if style_field(name) { + let id: Option = serde_json::from_value(value.clone()) + .map_err(|error| format!("Invalid native retained style field: {error}"))?; + if id.is_some_and(|id| id >= style_count) { + return Err("Native retained style id exceeds the style table".to_owned()); + } + } + if template_field(name) { + let id: Option = serde_json::from_value(value.clone()) + .map_err(|error| format!("Invalid native retained template field: {error}"))?; + if id.is_some_and(|id| id >= property_template_count) { + return Err("Native retained property template id exceeds the table".to_owned()); + } + } + } + match node { + LayoutNode::Box { + region_id, + content, + child, + content_region_id, + content_min_width, + width, + min_width, + max_width, + height, + min_height, + max_height, + padding_left, + padding_right, + padding_top, + padding_bottom, + margin_left, + margin_right, + margin_top, + margin_bottom, + border_left, + border_right, + scroll_offset, + .. + } => { + if fields.contains_key("region-id") && *region_id <= 0 { + return Err("Native retained box region id must be positive".to_owned()); + } + if fields.contains_key("content") { + if content.is_some() == child.is_some() { + return Err( + "Native layout box must contain exactly one text or child value".to_owned(), + ); + } + if let Some(text) = content { + measured_text_work(text, property_template_count)?; + } + } + if fields.contains_key("content-region-id") + && (content_region_id.is_some_and(|id| id <= 0) + || content_region_id.is_some() && content.is_none()) + { + return Err("Native retained content region id is invalid".to_owned()); + } + if fields.contains_key("content-min-width") { + if let Some(value) = content_min_width { + validate_dimension("content-min-width", *value)?; + } + } + for (name, size) in [ + ("width", width), + ("min-width", min_width), + ("max-width", max_width), + ("height", height), + ("min-height", min_height), + ("max-height", max_height), + ] { + if fields.contains_key(name) { + validate_size(name, size)?; + } + } + for (name, value) in [ + ("padding-left", *padding_left), + ("padding-right", *padding_right), + ("padding-top", *padding_top), + ("padding-bottom", *padding_bottom), + ("margin-left", *margin_left), + ("margin-right", *margin_right), + ("margin-top", *margin_top), + ("margin-bottom", *margin_bottom), + ("border-left", *border_left), + ("border-right", *border_right), + ("scroll-offset", *scroll_offset), + ] { + if fields.contains_key(name) { + validate_dimension(name, value)?; + } + } + } + LayoutNode::Text { + region_id, content, .. + } => { + if fields.contains_key("region-id") && *region_id <= 0 { + return Err("Native retained text region id must be positive".to_owned()); + } + if fields.contains_key("content") { + measured_text_work(content, property_template_count)?; + } + } + LayoutNode::Flex { + width, + height, + row_gap, + column_gap, + .. + } => { + if fields.contains_key("width") { + validate_size("flex width", width)?; + } + if fields.contains_key("height") { + validate_size("flex height", height)?; + } + if fields.contains_key("row-gap") { + validate_dimension("flex row gap", *row_gap)?; + } + if fields.contains_key("column-gap") { + validate_dimension("flex column gap", *column_gap)?; + } + } + LayoutNode::Row { .. } | LayoutNode::Column { .. } | LayoutNode::NodeRef { .. } => {} + } + Ok(()) +} + #[derive(Debug, Clone, Deserialize, PartialEq, Eq)] #[serde(rename_all = "kebab-case", deny_unknown_fields)] pub(crate) struct StyleTemplate { @@ -108,7 +1099,7 @@ struct UnderlineTemplate { color: Option, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Clone, Deserialize)] // Keeping the node payload inline avoids another allocation on the hot layout path. #[allow(clippy::large_enum_variant)] #[serde( @@ -118,9 +1109,17 @@ struct UnderlineTemplate { deny_unknown_fields )] enum LayoutNode { + NodeRef { + node_id: u64, + }, Box { + #[serde(default)] + node_id: Option, + #[serde(default)] + node_revision: Option, region_id: i64, - content: Option>, + #[serde(default, deserialize_with = "deserialize_optional_arc")] + content: Option>, #[serde(default)] content_region_id: Option, #[serde(default)] @@ -129,7 +1128,12 @@ enum LayoutNode { content_foreground_style: Option, #[serde(default)] content_surface_template_id: Option, - child: Option>, + #[serde( + default, + deserialize_with = "deserialize_optional_arc", + skip_serializing + )] + child: Option>, content_width_exact: bool, #[serde(default)] content_min_width: Option, @@ -167,8 +1171,13 @@ enum LayoutNode { scroll_offset: i64, }, Text { + #[serde(default)] + node_id: Option, + #[serde(default)] + node_revision: Option, region_id: i64, - content: Box, + #[serde(deserialize_with = "deserialize_arc")] + content: Arc, #[serde(default)] typography_style: Option, foreground_style: Option, @@ -177,12 +1186,26 @@ enum LayoutNode { wrap_mode: WrapMode, }, Row { - children: Vec, + #[serde(default)] + node_id: Option, + #[serde(default)] + node_revision: Option, + #[serde(default, deserialize_with = "deserialize_arc_vec", skip_serializing)] + children: Arc>, }, Column { - children: Vec, + #[serde(default)] + node_id: Option, + #[serde(default)] + node_revision: Option, + #[serde(default, deserialize_with = "deserialize_arc_vec", skip_serializing)] + children: Arc>, }, Flex { + #[serde(default)] + node_id: Option, + #[serde(default)] + node_revision: Option, direction: FlexDirection, wrap: FlexWrap, justify: FlexAlign, @@ -192,11 +1215,12 @@ enum LayoutNode { height: Size, row_gap: i64, column_gap: i64, - items: Vec, + #[serde(default, deserialize_with = "deserialize_arc_vec", skip_serializing)] + items: Arc>, }, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Clone, Deserialize)] #[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)] enum Size { Auto, @@ -226,7 +1250,7 @@ enum Size { }, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Clone, Deserialize)] #[serde(transparent)] struct SizeValues(Box<[Size]>); @@ -270,7 +1294,7 @@ enum FlexAlign { SpaceEvenly, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "kebab-case", deny_unknown_fields)] struct FlexItem { node: LayoutNode, @@ -320,13 +1344,13 @@ enum WrapMode { Char, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Clone, Deserialize)] #[serde(deny_unknown_fields)] struct MeasuredText { lines: Vec, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Clone, Deserialize)] #[serde(deny_unknown_fields)] struct MeasuredLine { clusters: Vec, @@ -2922,7 +3946,19 @@ pub fn encode_error_tape(identity: TapeIdentity, message: &str, max_bytes: usize } impl LayoutDocument { - pub fn validate(&self) -> Result<(), String> { + pub(crate) fn retained_root_p(&self) -> bool { + self.root.node_id().is_some() + } + + pub(crate) fn input_node_count(&self) -> u64 { + fn count(node: &LayoutNode) -> u64 { + let mut total = 1; + visit_layout_children(node, |child| total += count(child)); + total + } + count(&self.root) + } + fn validate_metrics(&self) -> Result<(usize, usize), String> { if self.version != LAYOUT_VERSION { return Err(format!( "Unsupported native layout IR version {}", @@ -2953,7 +3989,12 @@ impl LayoutDocument { &mut work_units, self.style_count, self.property_template_count, - ) + )?; + Ok((nodes, work_units)) + } + + pub fn validate(&self) -> Result<(), String> { + self.validate_metrics().map(|_| ()) } pub fn validate_context(&self, context: LayoutContext) -> Result<(), String> { @@ -2971,6 +4012,83 @@ impl LayoutDocument { } else { render_node_with_override( &self.root, + None, + context, + false, + root_width_override.map(|declared_width| BoxOverride { + declared_width: Some(declared_width), + ..BoxOverride::default() + }), + ) + .map(|rendered| rendered.into_tape(self.style_count)) + } + } +} + +impl RetainedDocument { + pub(crate) fn validate_context(&self, context: LayoutContext) -> Result<(), String> { + fn add_retained_context_work( + node: &LayoutNode, + document: &RetainedDocument, + context: LayoutContext, + work_units: &mut usize, + ) -> Result<(), String> { + let node = document.resolve(node)?; + match node { + LayoutNode::NodeRef { .. } => unreachable!("resolver returned a node reference"), + LayoutNode::Text { .. } => {} + LayoutNode::Box { + child, + height, + min_height, + max_height, + .. + } => { + for size in [height, min_height, max_height] { + add_context_size_work(size, context, work_units)?; + } + if let Some(child) = child { + add_retained_context_work(child, document, context, work_units)?; + } + } + LayoutNode::Row { children, .. } | LayoutNode::Column { children, .. } => { + for child in children.iter() { + add_retained_context_work(child, document, context, work_units)?; + } + } + LayoutNode::Flex { height, items, .. } => { + add_context_size_work(height, context, work_units)?; + for item in items.iter() { + add_retained_context_work(&item.node, document, context, work_units)?; + } + } + } + Ok(()) + } + + let root = self + .effective_node(self.root_id) + .ok_or_else(|| "Native retained document lost its root".to_owned())?; + RESOLVER_LOOKUP_COUNT.with(|count| count.set(count.get().saturating_add(1))); + let mut work_units = 0; + add_retained_context_work(root, self, context, &mut work_units) + } + + pub(crate) fn layout_tape( + &self, + context: LayoutContext, + root_width_override: Option, + ) -> Result { + let root = self + .effective_node(self.root_id) + .ok_or_else(|| "Native retained document lost its root".to_owned())?; + RESOLVER_LOOKUP_COUNT.with(|count| count.set(count.get().saturating_add(1))); + if root_width_override.is_some() && !matches!(root, LayoutNode::Box { .. }) { + Err("Native root width override requires a box root".to_owned()) + } else { + render_node_with_override( + root, + Some(self), context, false, root_width_override.map(|declared_width| BoxOverride { @@ -3056,6 +4174,9 @@ fn add_context_work( work_units: &mut usize, ) -> Result<(), String> { match node { + LayoutNode::NodeRef { .. } => { + return Err("Native full layout cannot contain a node reference".to_owned()); + } LayoutNode::Text { .. } => {} LayoutNode::Box { child, @@ -3071,14 +4192,14 @@ fn add_context_work( add_context_work(child, context, work_units)?; } } - LayoutNode::Row { children } | LayoutNode::Column { children } => { - for child in children { + LayoutNode::Row { children, .. } | LayoutNode::Column { children, .. } => { + for child in children.iter() { add_context_work(child, context, work_units)?; } } LayoutNode::Flex { height, items, .. } => { add_context_size_work(height, context, work_units)?; - for item in items { + for item in items.iter() { add_context_work(&item.node, context, work_units)?; } } @@ -3129,6 +4250,9 @@ fn validate_node( } add_work_units(work_units, 1)?; match node { + LayoutNode::NodeRef { .. } => { + return Err("Native full layout cannot contain a node reference".to_owned()); + } LayoutNode::Text { region_id, content, @@ -3332,11 +4456,11 @@ fn validate_node( )?; } } - LayoutNode::Row { children } | LayoutNode::Column { children } => { + LayoutNode::Row { children, .. } | LayoutNode::Column { children, .. } => { if children.is_empty() { return Err("Native layout container must contain a child".to_owned()); } - for child in children { + for child in children.iter() { validate_node( child, depth + 1, @@ -3369,7 +4493,7 @@ fn validate_node( .unwrap_or(usize::MAX) .saturating_mul(items.len()), )?; - for item in items { + for item in items.iter() { if !item.grow.is_finite() || item.grow < 0.0 || !item.shrink.is_finite() @@ -4078,6 +5202,7 @@ fn box_vertical_side(node: &LayoutNode) -> Option { fn box_content_intrinsics( node: &LayoutNode, + resolver: Option<&RetainedDocument>, context: LayoutContext, ) -> Result, String> { let LayoutNode::Box { @@ -4100,7 +5225,7 @@ fn box_content_intrinsics( // current inline viewport. Treating that width as unknown makes // responsive descendants collapse to their narrow intrinsic form and // produces a different automatic minimum from the visible renderer. - let rendered = render_node(child, context, true)?; + let rendered = render_node(child, resolver, context, true)?; Ok(Some(( content_min_width.unwrap_or_else(|| rendered.min_content_width(*wrap_mode)), rendered.max_width(), @@ -4114,6 +5239,7 @@ fn flex_box_resolve_width( node: &LayoutNode, size: &Size, fallback: Option, + resolver: Option<&RetainedDocument>, context: LayoutContext, ) -> Result, String> { let LayoutNode::Box { @@ -4127,7 +5253,8 @@ fn flex_box_resolve_width( else { return Ok(fallback); }; - let (min_content, max_content) = box_content_intrinsics(node, context)?.unwrap_or((0, 0)); + let (min_content, max_content) = + box_content_intrinsics(node, resolver, context)?.unwrap_or((0, 0)); let side = box_horizontal_side(node).unwrap_or(0); let stretch = context .viewport_width_known @@ -4176,6 +5303,7 @@ fn flex_min_main( source: &LayoutNode, rendered: &Rendered, axis: FlexAxis, + resolver: Option<&RetainedDocument>, context: LayoutContext, ) -> Result { let LayoutNode::Box { @@ -4195,13 +5323,17 @@ fn flex_min_main( FlexAxis::Row => { let side = box_horizontal_side(source).unwrap_or(0); let declared = - flex_box_resolve_width(source, min_width, Some(0), context)?.unwrap_or(0); + flex_box_resolve_width(source, min_width, Some(0), resolver, context)?.unwrap_or(0); if *wrap_mode == WrapMode::None { rendered.max_width().max(side + declared) } else { let content_min = match content_min_width { Some(content_min_width) => *content_min_width, - None => box_content_intrinsics(source, context)?.unwrap_or((0, 0)).0, + None => { + box_content_intrinsics(source, resolver, context)? + .unwrap_or((0, 0)) + .0 + } }; side + declared.max(content_min) } @@ -4218,6 +5350,7 @@ fn flex_min_main( fn flex_max_main( source: &LayoutNode, axis: FlexAxis, + resolver: Option<&RetainedDocument>, context: LayoutContext, ) -> Result, String> { let LayoutNode::Box { @@ -4229,7 +5362,7 @@ fn flex_max_main( return Ok(None); }; Ok(match axis { - FlexAxis::Row => flex_box_resolve_width(source, max_width, None, context)? + FlexAxis::Row => flex_box_resolve_width(source, max_width, None, resolver, context)? .map(|value| value + box_horizontal_side(source).unwrap_or(0)), FlexAxis::Column => flex_box_resolve_height(source, max_height, None, context) .map(|value| value + box_vertical_side(source).unwrap_or(0)), @@ -4257,6 +5390,7 @@ fn flex_basis_main( rendered: &Rendered, axis: FlexAxis, basis: &Size, + resolver: Option<&RetainedDocument>, context: LayoutContext, ) -> Result { let rendered_main = match axis { @@ -4268,14 +5402,15 @@ fn flex_basis_main( } if matches!(basis, Size::Content) { if axis == FlexAxis::Row && matches!(source, LayoutNode::Box { .. }) { - let (_, content_max) = box_content_intrinsics(source, context)?.unwrap_or((0, 0)); + let (_, content_max) = + box_content_intrinsics(source, resolver, context)?.unwrap_or((0, 0)); return Ok(box_horizontal_side(source).unwrap_or(0) + content_max); } return Ok(rendered_main); } if axis == FlexAxis::Row && matches!(source, LayoutNode::Box { .. }) { - let (_, content_max) = box_content_intrinsics(source, context)?.unwrap_or((0, 0)); - let content = flex_box_resolve_width(source, basis, Some(content_max), context)? + let (_, content_max) = box_content_intrinsics(source, resolver, context)?.unwrap_or((0, 0)); + let content = flex_box_resolve_width(source, basis, Some(content_max), resolver, context)? .unwrap_or(content_max); return Ok(box_horizontal_side(source).unwrap_or(0) + content); } @@ -4309,9 +5444,14 @@ fn measure_flex_item<'a>( item: &'a FlexItem, axis: FlexAxis, inline_viewport: Option, + resolver: Option<&'a RetainedDocument>, context: LayoutContext, ) -> Result, String> { - let uses_inline_viewport = flex_item_uses_inline_viewport(&item.node); + let source = match resolver { + Some(resolver) => resolver.resolve(&item.node)?, + None => &item.node, + }; + let uses_inline_viewport = flex_item_uses_inline_viewport(source); let measurement_context = LayoutContext { viewport_width: if uses_inline_viewport { inline_viewport.unwrap_or(0) @@ -4322,13 +5462,13 @@ fn measure_flex_item<'a>( viewport_height: context.viewport_height, inline_auto_width_intrinsic: context.inline_auto_width_intrinsic, }; - let rendered = render_node(&item.node, measurement_context, uses_inline_viewport)?; - let min_main = flex_min_main(&item.node, &rendered, axis, context)?; - let max_main = flex_max_main(&item.node, axis, context)?; - let base = flex_basis_main(&item.node, &rendered, axis, &item.basis, context)?.max(0); + let rendered = render_node(source, resolver, measurement_context, uses_inline_viewport)?; + let min_main = flex_min_main(source, &rendered, axis, resolver, context)?; + let max_main = flex_max_main(source, axis, resolver, context)?; + let base = flex_basis_main(source, &rendered, axis, &item.basis, resolver, context)?.max(0); let hypothetical = flex_clamp_main(base, min_main, max_main); Ok(FlexRuntimeItem { - source: &item.node, + source, grow: item.grow, shrink: item.shrink, align_self: item.align_self, @@ -4665,12 +5805,14 @@ fn box_override_for_flex( }) } +#[allow(clippy::too_many_arguments)] fn render_flex_sized_entry( item: &FlexRuntimeItem<'_>, axis: FlexAxis, main: i64, cross: Option, container_align: FlexAlign, + resolver: Option<&RetainedDocument>, context: LayoutContext, intrinsic: bool, ) -> Result { @@ -4693,8 +5835,13 @@ fn render_flex_sized_entry( inline_auto_width_intrinsic: context.inline_auto_width_intrinsic, }; let override_size = box_override_for_flex(item.source, axis, main, cross, stretch); - let mut rendered = - render_node_with_override(item.source, render_context, intrinsic, override_size)?; + let mut rendered = render_node_with_override( + item.source, + resolver, + render_context, + intrinsic, + override_size, + )?; match axis { FlexAxis::Row => { rendered = pad_rendered_width(rendered, main, FlexAlign::FlexStart); @@ -4794,8 +5941,16 @@ fn slice_rendered(rendered: Rendered, start: i64, height: i64) -> Rendered { Rendered { lines, breaks } } -fn exact_rendered_height(node: &LayoutNode, context: LayoutContext) -> Option { +fn exact_rendered_height( + node: &LayoutNode, + resolver: Option<&RetainedDocument>, + context: LayoutContext, +) -> Option { + let node = resolver + .and_then(|value| value.resolve(node).ok()) + .unwrap_or(node); match node { + LayoutNode::NodeRef { .. } => None, LayoutNode::Text { content, .. } => i64::try_from(content.lines.len()).ok(), LayoutNode::Box { height, @@ -4824,15 +5979,15 @@ fn exact_rendered_height(node: &LayoutNode, context: LayoutContext) -> Option children + LayoutNode::Row { children, .. } => children .iter() - .map(|child| exact_rendered_height(child, context)) + .map(|child| exact_rendered_height(child, resolver, context)) .try_fold(1_i64, |maximum, height| { height.map(|height| maximum.max(height)) }), - LayoutNode::Column { children } => children + LayoutNode::Column { children, .. } => children .iter() - .map(|child| exact_rendered_height(child, context)) + .map(|child| exact_rendered_height(child, resolver, context)) .try_fold(0_i64, |total, height| { height.and_then(|height| total.checked_add(height)) }), @@ -4842,25 +5997,31 @@ fn exact_rendered_height(node: &LayoutNode, context: LayoutContext) -> Option, context: LayoutContext, intrinsic: bool, start: i64, height: i64, ) -> Option> { - let total_height = exact_rendered_height(node, context)?; + let node = resolver + .and_then(|value| value.resolve(node).ok()) + .unwrap_or(node); + let total_height = exact_rendered_height(node, resolver, context)?; if start <= 0 && height >= total_height { - return Some(render_node(node, context, intrinsic)); + return Some(render_node(node, resolver, context, intrinsic)); } match node { - LayoutNode::Column { children } + LayoutNode::Column { children, .. } if !intrinsic && !context.inline_auto_width_intrinsic && context.viewport_width_known => { - Some(render_column_window(children, context, start, height)) + Some(render_column_window( + children, resolver, context, start, height, + )) } _ => Some( - render_node(node, context, intrinsic) + render_node(node, resolver, context, intrinsic) .map(|rendered| slice_rendered(rendered, start, height)), ), } @@ -4868,6 +6029,7 @@ fn render_node_window( fn render_column_window( children: &[LayoutNode], + resolver: Option<&RetainedDocument>, context: LayoutContext, start: i64, height: i64, @@ -4880,7 +6042,7 @@ fn render_column_window( let mut parts = Vec::new(); for child in leaves { - let child_height = exact_rendered_height(child, context) + let child_height = exact_rendered_height(child, resolver, context) .ok_or_else(|| "Native layout column window has an unbounded child".to_owned())?; let child_end = offset.saturating_add(child_height); if child_end <= start { @@ -4894,13 +6056,20 @@ fn render_column_window( let child_start = start.saturating_sub(offset); let child_window_height = (child_end.min(end) - (offset + child_start)).max(0); let mut rendered = if child_start == 0 && child_window_height >= child_height { - render_node(child, context, false)? + render_node(child, resolver, context, false)? } else { - render_node_window(child, context, false, child_start, child_window_height) - .unwrap_or_else(|| { - render_node(child, context, false) - .map(|rendered| slice_rendered(rendered, child_start, child_window_height)) - })? + render_node_window( + child, + resolver, + context, + false, + child_start, + child_window_height, + ) + .unwrap_or_else(|| { + render_node(child, resolver, context, false) + .map(|rendered| slice_rendered(rendered, child_start, child_window_height)) + })? }; let extra = (target - rendered.first_width()).max(0); @@ -4924,6 +6093,7 @@ fn flex_line_cross( line: &[FlexRuntimeItem<'_>], axis: FlexAxis, container_align: FlexAlign, + resolver: Option<&RetainedDocument>, context: LayoutContext, intrinsic: bool, ) -> Result { @@ -4936,6 +6106,7 @@ fn flex_line_cross( item.target, None, container_align, + resolver, context, intrinsic, )? @@ -4992,6 +6163,7 @@ fn render_flex_row_line( main_gap: i64, justify: FlexAlign, align: FlexAlign, + resolver: Option<&RetainedDocument>, context: LayoutContext, intrinsic: bool, ) -> Result { @@ -5006,6 +6178,7 @@ fn render_flex_row_line( item.target, Some(line_cross), align, + resolver, context, intrinsic, )?; @@ -5028,6 +6201,7 @@ fn render_flex_column_line( main_gap: i64, justify: FlexAlign, align: FlexAlign, + resolver: Option<&RetainedDocument>, context: LayoutContext, intrinsic: bool, ) -> Result { @@ -5049,6 +6223,7 @@ fn render_flex_column_line( item.target, Some(line_cross), align, + resolver, context, intrinsic, )? @@ -5075,6 +6250,7 @@ fn render_flex_row( align: FlexAlign, align_content: FlexAlign, single_line: bool, + resolver: Option<&RetainedDocument>, context: LayoutContext, intrinsic: bool, ) -> Result { @@ -5093,7 +6269,7 @@ fn render_flex_row( } else { lines .iter() - .map(|line| flex_line_cross(line, FlexAxis::Row, align, context, intrinsic)) + .map(|line| flex_line_cross(line, FlexAxis::Row, align, resolver, context, intrinsic)) .collect::, _>>()? }; let cross_layout = @@ -5119,6 +6295,7 @@ fn render_flex_row( main_gap, justify, align, + resolver, context, intrinsic, )?); @@ -5149,6 +6326,7 @@ fn render_flex_column( align: FlexAlign, align_content: FlexAlign, single_line: bool, + resolver: Option<&RetainedDocument>, context: LayoutContext, intrinsic: bool, ) -> Result { @@ -5160,7 +6338,9 @@ fn render_flex_column( } else { lines .iter() - .map(|line| flex_line_cross(line, FlexAxis::Column, align, context, intrinsic)) + .map(|line| { + flex_line_cross(line, FlexAxis::Column, align, resolver, context, intrinsic) + }) .collect::, _>>()? }; let cross_layout = @@ -5176,7 +6356,7 @@ fn render_flex_column( .enumerate() { let rendered = render_flex_column_line( - line, line_cross, main_size, main_gap, justify, align, context, intrinsic, + line, line_cross, main_size, main_gap, justify, align, resolver, context, intrinsic, )?; parts.push((rendered, line_cross)); if index + 1 < line_count { @@ -5205,6 +6385,7 @@ fn render_flex( row_gap: i64, column_gap: i64, source_items: &[FlexItem], + resolver: Option<&RetainedDocument>, context: LayoutContext, intrinsic: bool, ) -> Result { @@ -5220,7 +6401,15 @@ fn render_flex( indices.sort_by_key(|index| (source_items[*index].order, *index)); let mut items = indices .into_iter() - .map(|index| measure_flex_item(&source_items[index], axis, inline_viewport, context)) + .map(|index| { + measure_flex_item( + &source_items[index], + axis, + inline_viewport, + resolver, + context, + ) + }) .collect::, _>>()?; if flex_direction_reversed(direction) { items.reverse(); @@ -5245,6 +6434,7 @@ fn render_flex( align_items, align_content, single_line, + resolver, context, intrinsic, ), @@ -5258,6 +6448,7 @@ fn render_flex( align_items, align_content, single_line, + resolver, context, intrinsic, ), @@ -5273,18 +6464,24 @@ struct BoxOverride { fn render_node( node: &LayoutNode, + resolver: Option<&RetainedDocument>, context: LayoutContext, intrinsic: bool, ) -> Result { - render_node_with_override(node, context, intrinsic, None) + render_node_with_override(node, resolver, context, intrinsic, None) } fn render_node_with_override( node: &LayoutNode, + resolver: Option<&RetainedDocument>, context: LayoutContext, intrinsic: bool, size_override: Option, ) -> Result { + let node = match resolver { + Some(resolver) => resolver.resolve(node)?, + None => node, + }; #[cfg(test)] TEST_RENDER_NODE_COUNT.with(|count| { if let Some(value) = count.get() { @@ -5292,6 +6489,7 @@ fn render_node_with_override( } }); match node { + LayoutNode::NodeRef { .. } => Err("Native unresolved retained node reference".to_owned()), LayoutNode::Box { region_id, content, @@ -5332,6 +6530,7 @@ fn render_node_with_override( overflow, wrap_mode, scroll_offset, + .. } => { let declared_width_override = size_override.and_then(|override_size| override_size.declared_width); @@ -5445,7 +6644,9 @@ fn render_node_with_override( && matches!(effective_width, Size::Auto), }; if simple_scroll_window { - if let Some(total_height) = exact_rendered_height(child, child_context) { + if let Some(total_height) = + exact_rendered_height(child, resolver, child_context) + { let content_height = definite_content_height.expect("checked above"); let max_offset = (total_height - content_height).max(0); let start = (*scroll_offset).max(0).min(max_offset); @@ -5454,6 +6655,7 @@ fn render_node_with_override( Some( render_node_window( child, + resolver, child_context, intrinsic || intrinsic_child, start, @@ -5464,6 +6666,7 @@ fn render_node_with_override( } else { Some(render_node( child, + resolver, child_context, intrinsic || intrinsic_child, )?) @@ -5471,6 +6674,7 @@ fn render_node_with_override( } else { Some(render_node( child, + resolver, child_context, intrinsic || intrinsic_child, )?) @@ -5807,6 +7011,7 @@ fn render_node_with_override( foreground_style, surface_template_id, wrap_mode, + .. } => { let width = measured_max_width(content); let mut lines = measured_lines(content, width, *wrap_mode); @@ -5818,14 +7023,14 @@ fn render_node_with_override( } Ok(Rendered::from_lines(lines)) } - LayoutNode::Row { children } => { + LayoutNode::Row { children, .. } => { let child_context = LayoutContext { inline_auto_width_intrinsic: true, ..context }; let rendered = children .iter() - .map(|child| render_node(child, child_context, intrinsic)) + .map(|child| render_node(child, resolver, child_context, intrinsic)) .collect::, _>>()?; let height = rendered .iter() @@ -5846,12 +7051,12 @@ fn render_node_with_override( } Ok(Rendered::from_lines(lines)) } - LayoutNode::Column { children } => { + LayoutNode::Column { children, .. } => { let mut leaves = Vec::new(); collect_column_leaves(children, &mut leaves); let rendered = leaves .into_iter() - .map(|child| render_node(child, context, intrinsic)) + .map(|child| render_node(child, resolver, context, intrinsic)) .collect::, _>>()?; let maximum = rendered .iter() @@ -5891,6 +7096,7 @@ fn render_node_with_override( row_gap, column_gap, items, + .. } => render_flex( *direction, *wrap, @@ -5902,6 +7108,7 @@ fn render_node_with_override( *row_gap, *column_gap, items, + resolver, context, intrinsic, ), @@ -5910,7 +7117,7 @@ fn render_node_with_override( fn collect_column_leaves<'a>(children: &'a [LayoutNode], output: &mut Vec<&'a LayoutNode>) { for child in children { - if let LayoutNode::Column { children } = child { + if let LayoutNode::Column { children, .. } = child { collect_column_leaves(children, output); } else { output.push(child); @@ -5973,8 +7180,10 @@ mod tests { surface_template_id: Option, ) -> LayoutNode { LayoutNode::Box { + node_id: None, + node_revision: None, region_id, - content: Some(Box::new(content)), + content: Some(Arc::new(content)), content_region_id: None, content_typography_style: None, content_foreground_style: None, @@ -6034,13 +7243,15 @@ mod tests { surface_template_id: Option, ) -> LayoutNode { LayoutNode::Box { + node_id: None, + node_revision: None, region_id, content: None, content_region_id: None, content_typography_style: None, content_foreground_style: None, content_surface_template_id: None, - child: Some(Box::new(child)), + child: Some(Arc::new(child)), content_width_exact: true, content_min_width: None, width: Size::Content, @@ -6076,23 +7287,79 @@ mod tests { } } + fn identified(mut node: LayoutNode, node_id: u64, revision: u64) -> LayoutNode { + match &mut node { + LayoutNode::Box { + node_id: id, + node_revision, + .. + } + | LayoutNode::Text { + node_id: id, + node_revision, + .. + } + | LayoutNode::Row { + node_id: id, + node_revision, + .. + } + | LayoutNode::Column { + node_id: id, + node_revision, + .. + } + | LayoutNode::Flex { + node_id: id, + node_revision, + .. + } => { + *id = Some(node_id); + *node_revision = Some(revision); + } + LayoutNode::NodeRef { .. } => panic!("cannot identify a retained node reference"), + } + node + } + + fn retained_document(root: LayoutNode) -> LayoutDocument { + LayoutDocument { + version: LAYOUT_VERSION, + space_width: 1, + style_count: 0, + property_template_count: 0, + styles: Vec::new(), + root, + } + } + fn fixed_scroll_column_document(scroll_offset: i64) -> LayoutDocument { - let children = (0..20) - .map(|index| { - let mut node = text_box( - index + 2, - measured_text(vec![vec![cluster(&format!("line-{index:03}"), 8, None)]]), - Some((index % 2) as u32), - ); - let LayoutNode::Box { width, height, .. } = &mut node else { - unreachable!(); - }; - *width = Size::Viewport; - *height = Size::Lines { value: 1 }; - node - }) - .collect(); - let mut root = child_box(1, LayoutNode::Column { children }, Some(2)); + let children = Arc::new( + (0..20) + .map(|index| { + let mut node = text_box( + index + 2, + measured_text(vec![vec![cluster(&format!("line-{index:03}"), 8, None)]]), + Some((index % 2) as u32), + ); + let LayoutNode::Box { width, height, .. } = &mut node else { + unreachable!(); + }; + *width = Size::Viewport; + *height = Size::Lines { value: 1 }; + node + }) + .collect(), + ); + let mut root = child_box( + 1, + LayoutNode::Column { + node_id: None, + node_revision: None, + children, + }, + Some(2), + ); let LayoutNode::Box { width, height, @@ -6234,6 +7501,8 @@ mod tests { *content_width_exact = false; *text_align = HorizontalAlign::Center; let column = LayoutNode::Flex { + node_id: None, + node_revision: None, direction: FlexDirection::Column, wrap: FlexWrap::Nowrap, justify: FlexAlign::FlexStart, @@ -6243,14 +7512,14 @@ mod tests { height: Size::Auto, row_gap: 0, column_gap: 0, - items: vec![FlexItem { + items: Arc::new(vec![FlexItem { node: label, order: 0, grow: 0.0, shrink: 0.0, basis: Size::Auto, align_self: FlexAlign::Stretch, - }], + }]), }; let mut outer = child_box(1, column, None); let LayoutNode::Box { @@ -6265,6 +7534,7 @@ mod tests { *content_width_exact = true; let rendered = render_node_with_override( &outer, + None, test_context(), false, Some(BoxOverride { @@ -7202,22 +8472,32 @@ mod tests { #[test] fn fixed_height_scroll_layout_does_not_render_offscreen_column_suffix() { - let children = (0..200) - .map(|index| { - let mut node = text_box( - index + 2, - measured_text(vec![vec![cluster(&format!("line-{index:03}"), 8, None)]]), - None, - ); - let LayoutNode::Box { width, height, .. } = &mut node else { - unreachable!(); - }; - *width = Size::Viewport; - *height = Size::Lines { value: 1 }; - node - }) - .collect(); - let mut root = child_box(1, LayoutNode::Column { children }, None); + let children = Arc::new( + (0..200) + .map(|index| { + let mut node = text_box( + index + 2, + measured_text(vec![vec![cluster(&format!("line-{index:03}"), 8, None)]]), + None, + ); + let LayoutNode::Box { width, height, .. } = &mut node else { + unreachable!(); + }; + *width = Size::Viewport; + *height = Size::Lines { value: 1 }; + node + }) + .collect(), + ); + let mut root = child_box( + 1, + LayoutNode::Column { + node_id: None, + node_revision: None, + children, + }, + None, + ); let LayoutNode::Box { width, height, @@ -7252,22 +8532,26 @@ mod tests { #[test] fn row_auto_children_do_not_duplicate_column_viewport() { let row = LayoutNode::Row { - children: vec![ + node_id: None, + node_revision: None, + children: Arc::new(vec![ auto_text_box(2, measured_text(vec![vec![cluster("Left", 4, None)]]), None), auto_text_box( 3, measured_text(vec![vec![cluster("Right", 5, None)]]), None, ), - ], + ]), }; let mut root = child_box( 1, LayoutNode::Column { - children: vec![ + node_id: None, + node_revision: None, + children: Arc::new(vec![ row, auto_text_box(4, measured_text(vec![vec![cluster("Body", 4, None)]]), None), - ], + ]), }, None, ); @@ -7540,4 +8824,217 @@ mod tests { ); assert!(fragment_style_delta(&old, &changed).is_none()); } + + #[test] + fn retained_scalar_patch_path_copies_only_radix_and_shares_wide_edges() { + let children = Arc::new( + (0..512) + .map(|index| { + identified( + text_box( + index + 2, + measured_text(vec![vec![cluster("x", 1, None)]]), + None, + ), + index as u64 + 2, + 1, + ) + }) + .collect(), + ); + let root = identified( + child_box( + 1, + LayoutNode::Column { + node_id: None, + node_revision: None, + children, + }, + None, + ), + 1, + 7, + ); + let (base, parsed) = RetainedDocument::bootstrap(retained_document(root)).unwrap(); + assert_eq!(parsed, 514, "anonymous content-layout is also parsed input"); + let base_root = radix_lookup(&base.entries, 1).unwrap(); + let LayoutNode::Box { + child: Some(base_child), + .. + } = base_root.node.as_ref() + else { + panic!("expected retained box root"); + }; + let delta: DocumentDelta = serde_json::from_value(serde_json::json!({ + "style-base-count": 0, + "styles-append": [], + "property-template-base-count": 0, + "property-template-target-count": 0, + "entries": [{ + "node-id": 1, + "expected-revision": 7, + "target-revision": 19, + "slot-patches": [{"slot": 0, "local": {"padding-left": 1}}] + }] + })) + .unwrap(); + let (target, validated, copied) = base.apply_delta(delta).unwrap(); + assert_eq!(validated, 1); + assert_eq!(copied, 17); + let target_root = radix_lookup(&target.entries, 1).unwrap(); + let LayoutNode::Box { + child: Some(target_child), + padding_left, + .. + } = target_root.node.as_ref() + else { + panic!("expected patched box root"); + }; + assert_eq!(*padding_left, 1); + assert!(Arc::ptr_eq(base_child, target_child)); + let LayoutNode::Box { padding_left, .. } = base_root.node.as_ref() else { + unreachable!(); + }; + assert_eq!(*padding_left, 0, "persistent parent was mutated"); + } + + #[test] + fn retained_revision_only_shares_body_and_fork_roots() { + let root = identified( + text_box(1, measured_text(vec![vec![cluster("old", 3, None)]]), None), + 1, + 3, + ); + let (base, _) = RetainedDocument::bootstrap(retained_document(root)).unwrap(); + let revision_only = |expected, target| { + serde_json::from_value(serde_json::json!({ + "style-base-count": 0, + "styles-append": [], + "property-template-base-count": 0, + "property-template-target-count": 0, + "entries": [{ + "node-id": 1, + "expected-revision": expected, + "target-revision": target + }] + })) + .unwrap() + }; + let (left, _, copied) = base.apply_delta(revision_only(3, 20)).unwrap(); + let (right, _, _) = base.apply_delta(revision_only(3, 30)).unwrap(); + assert_eq!(copied, 17); + assert!(Arc::ptr_eq( + &radix_lookup(&base.entries, 1).unwrap().node, + &radix_lookup(&left.entries, 1).unwrap().node, + )); + assert_eq!(radix_lookup(&left.entries, 1).unwrap().revision, 20); + assert_eq!(radix_lookup(&right.entries, 1).unwrap().revision, 30); + assert_eq!(radix_lookup(&base.entries, 1).unwrap().revision, 3); + } + + #[test] + fn retained_bootstrap_rejects_duplicate_ancestor_identity() { + let child = identified( + text_box(2, measured_text(vec![vec![cluster("x", 1, None)]]), None), + 1, + 2, + ); + let root = identified(child_box(1, child, None), 1, 1); + assert!(RetainedDocument::bootstrap(retained_document(root)) + .unwrap_err() + .contains("duplicate node id")); + } + + #[test] + fn retained_delta_rejects_invalid_local_value_without_mutating_parent() { + let root = identified( + text_box(1, measured_text(vec![vec![cluster("x", 1, None)]]), None), + 1, + 4, + ); + let (base, _) = RetainedDocument::bootstrap(retained_document(root)).unwrap(); + let invalid = serde_json::from_value(serde_json::json!({ + "style-base-count": 0, + "styles-append": [], + "property-template-base-count": 0, + "property-template-target-count": 0, + "entries": [{ + "node-id": 1, + "expected-revision": 4, + "target-revision": 5, + "slot-patches": [{"slot": 0, "local": {"padding-left": -1}}] + }] + })) + .unwrap(); + assert!(base + .apply_delta(invalid) + .unwrap_err() + .contains("cannot be negative")); + assert_eq!(radix_lookup(&base.entries, 1).unwrap().revision, 4); + } + + #[test] + fn retained_work_limit_validates_the_final_batch_independent_of_entry_order() { + let mut first = text_box(2, measured_text(vec![vec![cluster("a", 1, None)]]), None); + let mut second = text_box(3, measured_text(vec![vec![cluster("b", 1, None)]]), None); + let LayoutNode::Box { + padding_top: first_padding, + .. + } = &mut first + else { + unreachable!(); + }; + *first_padding = 0; + let LayoutNode::Box { + padding_top: second_padding, + .. + } = &mut second + else { + unreachable!(); + }; + *second_padding = (MAX_LAYOUT_WORK_UNITS - 8) as i64; + let root = identified( + child_box( + 1, + LayoutNode::Column { + node_id: None, + node_revision: None, + children: Arc::new(vec![identified(first, 2, 1), identified(second, 3, 1)]), + }, + None, + ), + 1, + 1, + ); + let (base, _) = RetainedDocument::bootstrap(retained_document(root)).unwrap(); + let entry = |node_id: u64, padding_top: usize| { + serde_json::json!({ + "node-id": node_id, + "expected-revision": 1, + "target-revision": 2, + "slot-patches": [{"slot": 0, "local": {"padding-top": padding_top}}] + }) + }; + let delta = |entries: Vec| { + serde_json::from_value(serde_json::json!({ + "style-base-count": 0, + "styles-append": [], + "property-template-base-count": 0, + "property-template-target-count": 0, + "entries": entries + })) + .unwrap() + }; + let high = MAX_LAYOUT_WORK_UNITS - 18; + let forward = base + .apply_delta(delta(vec![entry(2, 10), entry(3, high)])) + .unwrap() + .0; + let reverse = base + .apply_delta(delta(vec![entry(3, high), entry(2, 10)])) + .unwrap() + .0; + assert_eq!(forward.work_units, MAX_LAYOUT_WORK_UNITS); + assert_eq!(reverse.work_units, MAX_LAYOUT_WORK_UNITS); + } } diff --git a/native/src/lib.rs b/native/src/lib.rs index 1464fa3..f32f597 100644 --- a/native/src/lib.rs +++ b/native/src/lib.rs @@ -1,8 +1,8 @@ mod layout; use layout::{ - encode_error_tape, LayoutContext, LayoutDocument, LayoutTape, TapeIdentity, TapeOutputOptions, - MAX_LAYOUT_DIMENSION, MIN_TAPE_BYTES, + encode_error_tape, DocumentDelta, LayoutContext, LayoutDocument, LayoutTape, RetainedDocument, + TapeIdentity, TapeOutputOptions, MAX_LAYOUT_DIMENSION, MIN_TAPE_BYTES, }; use serde::{Deserialize, Serialize}; use std::collections::{HashMap, HashSet, VecDeque}; @@ -324,6 +324,8 @@ struct ControlBatch { version: u32, #[serde(default)] document: Option, + #[serde(default, rename = "document-delta")] + document_delta: Option, #[serde(default, rename = "document-base-revision")] document_base_revision: Option, #[serde(default, rename = "document-target-revision")] @@ -375,7 +377,7 @@ struct ControlFrame { enum JobPayload { Echo(Vec), Layout { - document: Arc, + document: LayoutSource, context: LayoutContext, root_width: i64, root_width_override: bool, @@ -388,9 +390,43 @@ enum JobPayload { root_metadata: bool, document_base_revision: u64, document_target_revision: u64, + validation_resolver_lookups: u64, }, } +#[derive(Clone, Debug)] +enum LayoutSource { + Full(Arc), + Retained(Arc), +} + +impl LayoutSource { + fn validate_context(&self, context: LayoutContext) -> Result<(), String> { + match self { + Self::Full(document) => document.validate_context(context), + Self::Retained(document) => document.validate_context(context), + } + } + + fn layout_tape( + &self, + context: LayoutContext, + root_width: Option, + ) -> Result { + match self { + Self::Full(document) => document.layout_tape(context, root_width), + Self::Retained(document) => document.layout_tape(context, root_width), + } + } + + fn styles(&self) -> Result, String> { + match self { + Self::Full(document) => Ok(document.styles.clone()), + Self::Retained(document) => document.styles(), + } + } +} + #[derive(Clone, Debug)] struct BaselineIdentity { context: LayoutContext, @@ -404,7 +440,7 @@ struct BaselineIdentity { #[derive(Clone, Debug)] struct ConfirmedBaseline { identity: BaselineIdentity, - document: Arc, + document: LayoutSource, document_revision: u64, tape: LayoutTape, styles: Vec, @@ -413,7 +449,7 @@ struct ConfirmedBaseline { #[derive(Debug)] struct PendingBaseline { confirmed_identity: BaselineIdentity, - document: Arc, + document: LayoutSource, document_revision: u64, tape: LayoutTape, styles: Vec, @@ -431,6 +467,20 @@ struct RenderedJob { baseline_hit: bool, base_renders: u64, target_renders: u64, + resolver_lookups: u64, +} + +#[derive(Default)] +struct DocumentInputStats { + parses: u64, + validations: u64, + reuses: u64, + full_input_bytes: u64, + delta_input_bytes: u64, + full_nodes_parsed: u64, + delta_entries_parsed: u64, + delta_entries_validated: u64, + trie_path_nodes_copied: u64, } #[derive(Debug)] @@ -463,6 +513,13 @@ struct RuntimeState { document_parses: u64, document_validations: u64, document_reuses: u64, + document_full_input_bytes: u64, + document_delta_input_bytes: u64, + document_full_nodes_parsed: u64, + document_delta_entries_parsed: u64, + document_delta_entries_validated: u64, + document_trie_path_nodes_copied: u64, + document_resolver_lookups: u64, confirmed_baseline: Option>, } @@ -491,7 +548,7 @@ struct Shared { struct Session { shared: Arc, workers: Mutex>>>, - layout_document: Mutex>>, + layout_document: Mutex>, worker_count: usize, } @@ -516,6 +573,13 @@ struct SessionStats { document_parses: u64, document_validations: u64, document_reuses: u64, + document_full_input_bytes: u64, + document_delta_input_bytes: u64, + document_full_nodes_parsed: u64, + document_delta_entries_parsed: u64, + document_delta_entries_validated: u64, + document_trie_path_nodes_copied: u64, + document_resolver_lookups: u64, pending_baselines: usize, confirmed_baseline: bool, confirmed_baseline_bytes: usize, @@ -581,6 +645,13 @@ impl Session { document_parses: 0, document_validations: 0, document_reuses: 0, + document_full_input_bytes: 0, + document_delta_input_bytes: 0, + document_full_nodes_parsed: 0, + document_delta_entries_parsed: 0, + document_delta_entries_validated: 0, + document_trie_path_nodes_copied: 0, + document_resolver_lookups: 0, confirmed_baseline, }), readiness_channel: Mutex::new(None), @@ -611,7 +682,7 @@ impl Session { .unwrap_or_else(|poison| poison.into_inner()) .confirmed_baseline .as_ref() - .map(|baseline| Arc::clone(&baseline.document)); + .map(|baseline| baseline.document.clone()); Ok(Box::new(Self { shared, workers: Mutex::new(Some(handles)), @@ -643,6 +714,9 @@ impl Session { fn submit(&self, generation: u64, payload: &[u8]) -> Result { let batch = parse_control_batch(payload)?; + if batch.document_delta.is_some() { + return Err("Native async reflow does not accept retained document deltas".to_owned()); + } if !self.shared.alive.load(Ordering::Acquire) { return Err("Native reflow session is closed".to_owned()); } @@ -672,12 +746,12 @@ impl Session { let document = match batch.document { Some(document) => { document.validate()?; - Some(Arc::new(document)) + Some(LayoutSource::Full(Arc::new(document))) } None if layout_requested => Some( confirmed_baseline .as_ref() - .map(|baseline| Arc::clone(&baseline.document)) + .map(|baseline| baseline.document.clone()) .or_else(|| { self.layout_document .lock() @@ -852,10 +926,13 @@ impl Session { .unwrap_or_else(|poison| poison.into_inner()) .confirmed_baseline .clone(); - let (document, document_parses, document_validations, document_reuses) = match batch - .document - { - Some(document) => { + let (document, input_stats) = match (batch.document, batch.document_delta) { + (Some(_), Some(_)) => { + return Err( + "Native retained render cannot combine document and document-delta".to_owned(), + ); + } + (Some(document), None) => { if document_base_revision.checked_add(1) != Some(document_target_revision) { return Err( "Native retained document replacement must advance one revision".to_owned(), @@ -876,10 +953,60 @@ impl Session { } _ => {} } - document.validate()?; - (Arc::new(document), 1, 1, 0) + let retained = document.retained_root_p(); + let (source, node_count) = if retained { + let (document, node_count) = RetainedDocument::bootstrap(document)?; + (LayoutSource::Retained(document), node_count) + } else { + document.validate()?; + let node_count = document.input_node_count(); + (LayoutSource::Full(Arc::new(document)), node_count) + }; + ( + source, + DocumentInputStats { + parses: 1, + validations: 1, + full_input_bytes: payload.len() as u64, + full_nodes_parsed: node_count, + ..DocumentInputStats::default() + }, + ) } - None => { + (None, Some(delta)) => { + if document_base_revision.checked_add(1) != Some(document_target_revision) { + return Err( + "Native retained document delta must advance one revision".to_owned() + ); + } + let baseline = confirmed.as_ref().ok_or_else(|| { + "Native retained document delta requires a confirmed baseline".to_owned() + })?; + if baseline.document_revision != document_base_revision { + return Err( + "Native retained document delta base revision does not match confirmed state" + .to_owned(), + ); + } + let LayoutSource::Retained(document) = &baseline.document else { + return Err( + "Native retained document delta requires an identified bootstrap" + .to_owned(), + ); + }; + let (document, parsed, copied) = document.apply_delta(delta)?; + ( + LayoutSource::Retained(document), + DocumentInputStats { + delta_input_bytes: payload.len() as u64, + delta_entries_parsed: parsed, + delta_entries_validated: parsed, + trie_path_nodes_copied: copied, + ..DocumentInputStats::default() + }, + ) + } + (None, None) => { if document_base_revision != document_target_revision { return Err( "Native retained document reuse requires equal base and target revisions" @@ -902,7 +1029,13 @@ impl Session { .to_owned(), ); } - (Arc::clone(&baseline.document), 0, 0, 1) + ( + baseline.document.clone(), + DocumentInputStats { + reuses: 1, + ..DocumentInputStats::default() + }, + ) } }; let frame = batch @@ -943,9 +1076,16 @@ impl Session { } state.base_renders += output.base_renders; state.target_renders += output.target_renders; - state.document_parses += document_parses; - state.document_validations += document_validations; - state.document_reuses += document_reuses; + state.document_resolver_lookups += output.resolver_lookups; + state.document_parses += input_stats.parses; + state.document_validations += input_stats.validations; + state.document_reuses += input_stats.reuses; + state.document_full_input_bytes += input_stats.full_input_bytes; + state.document_delta_input_bytes += input_stats.delta_input_bytes; + state.document_full_nodes_parsed += input_stats.full_nodes_parsed; + state.document_delta_entries_parsed += input_stats.delta_entries_parsed; + state.document_delta_entries_validated += input_stats.delta_entries_validated; + state.document_trie_path_nodes_copied += input_stats.trie_path_nodes_copied; state.pending_baselines.clear(); if let Some(pending) = output.pending { state @@ -1078,6 +1218,13 @@ impl Session { document_parses: state.document_parses, document_validations: state.document_validations, document_reuses: state.document_reuses, + document_full_input_bytes: state.document_full_input_bytes, + document_delta_input_bytes: state.document_delta_input_bytes, + document_full_nodes_parsed: state.document_full_nodes_parsed, + document_delta_entries_parsed: state.document_delta_entries_parsed, + document_delta_entries_validated: state.document_delta_entries_validated, + document_trie_path_nodes_copied: state.document_trie_path_nodes_copied, + document_resolver_lookups: state.document_resolver_lookups, pending_baselines, confirmed_baseline: state.confirmed_baseline.is_some(), confirmed_baseline_bytes, @@ -1129,7 +1276,7 @@ fn parse_control_batch(payload: &[u8]) -> Result { } fn checked_layout_context( - document: &LayoutDocument, + document: &LayoutSource, frame_key: i64, viewport_width: i64, viewport_width_known: bool, @@ -1179,11 +1326,12 @@ fn confirmed_baseline_matches( } fn prepare_layout_job( - document: &Arc, + document: &LayoutSource, frame: ControlFrame, document_base_revision: u64, document_target_revision: u64, ) -> Result { + layout::reset_resolver_lookups(); if frame.payload.is_some() { return Err(format!( "Native layout frame {} cannot contain an echo payload", @@ -1197,7 +1345,7 @@ fn prepare_layout_job( .viewport_height .ok_or_else(|| format!("Native layout frame {} requires viewport-height", frame.key))?; let context = checked_layout_context( - document.as_ref(), + document, frame.key, viewport_width, frame.viewport_width_known, @@ -1223,7 +1371,7 @@ fn prepare_layout_job( ) })?; let base_context = checked_layout_context( - document.as_ref(), + document, frame.key, base_viewport_width, frame.base_viewport_width_known, @@ -1254,7 +1402,7 @@ fn prepare_layout_job( key: frame.key, delay_ms: frame.delay_ms, payload: JobPayload::Layout { - document: Arc::clone(document), + document: document.clone(), context, root_width, root_width_override: frame.root_width_override, @@ -1267,6 +1415,7 @@ fn prepare_layout_job( root_metadata: frame.root_metadata, document_base_revision, document_target_revision, + validation_resolver_lookups: layout::resolver_lookups(), }, }) } @@ -1287,6 +1436,7 @@ fn render_layout_payload( baseline_hit: false, base_renders: 0, target_renders: 0, + resolver_lookups: 0, }, JobPayload::Layout { document, @@ -1302,6 +1452,7 @@ fn render_layout_payload( root_metadata, document_base_revision, document_target_revision, + validation_resolver_lookups, } => { let identity = TapeIdentity { session_id, @@ -1331,8 +1482,20 @@ fn render_layout_payload( // job with neither result nor error tape, so the Elisp ready // watcher would poll forever. Convert panics into the same // error-tape channel ordinary layout failures use. - type LayoutRenderOutcome = Result<(Vec, LayoutTape, bool, u64, u64), String>; + type LayoutRenderOutcome = Result< + ( + Vec, + LayoutTape, + Vec, + bool, + u64, + u64, + ), + String, + >; + layout::reset_resolver_lookups(); let result = catch_unwind(AssertUnwindSafe(|| -> LayoutRenderOutcome { + let target_styles = document.styles()?; if let Some(base_context) = base_context { let base_identity = BaselineIdentity { context: base_context, @@ -1351,7 +1514,7 @@ fn render_layout_payload( &base_identity, ) && layout::style_registry_extends_exact_prefix( &baseline.styles, - &document.styles, + &target_styles, ) }) .cloned(); @@ -1360,12 +1523,12 @@ fn render_layout_payload( .layout_tape(context, root_width_override.then_some(root_width))?; let bytes = layout::encode_layout_tape( target.clone(), - &document.styles, + &target_styles, identity, output.root_metadata, output.max_bytes, )?; - return Ok((bytes, target, false, 0, 1)); + return Ok((bytes, target, target_styles, false, 0, 1)); } let (old, baseline_hit, base_renders) = if let Some(baseline) = base_hit { (baseline.tape.clone(), true, 0) @@ -1384,45 +1547,51 @@ fn render_layout_payload( let bytes = layout::encode_layout_patch_tape( old, target.clone(), - &document.styles, + &target_styles, identity, output.root_metadata, output.max_bytes, )?; - Ok((bytes, target, baseline_hit, base_renders, 1)) + Ok((bytes, target, target_styles, baseline_hit, base_renders, 1)) } else { let target = document.layout_tape(context, root_width_override.then_some(root_width))?; let bytes = layout::encode_layout_tape( target.clone(), - &document.styles, + &target_styles, identity, output.root_metadata, output.max_bytes, )?; - Ok((bytes, target, false, 0, 1)) + Ok((bytes, target, target_styles, false, 0, 1)) } })); + let resolver_lookups = + validation_resolver_lookups.saturating_add(layout::resolver_lookups()); match result { - Ok(Ok((bytes, tape, baseline_hit, base_renders, target_renders))) => RenderedJob { - bytes, - pending: Some(PendingBaseline { - confirmed_identity: pending_identity, - document: Arc::clone(&document), - document_revision: document_target_revision, - tape, - styles: document.styles.clone(), - }), - baseline_hit, - base_renders, - target_renders, - }, + Ok(Ok((bytes, tape, styles, baseline_hit, base_renders, target_renders))) => { + RenderedJob { + bytes, + pending: Some(PendingBaseline { + confirmed_identity: pending_identity, + document: document.clone(), + document_revision: document_target_revision, + tape, + styles, + }), + baseline_hit, + base_renders, + target_renders, + resolver_lookups, + } + } Ok(Err(error)) => RenderedJob { bytes: encode_error_tape(identity, &error, max_result_bytes), pending: None, baseline_hit: false, base_renders: 0, target_renders: 0, + resolver_lookups, }, Err(_) => RenderedJob { bytes: encode_error_tape(identity, "native layout panicked", max_result_bytes), @@ -1430,6 +1599,7 @@ fn render_layout_payload( baseline_hit: false, base_renders: 0, target_renders: 0, + resolver_lookups, }, } } @@ -1464,7 +1634,8 @@ fn render_proof(payload: &[u8]) -> Result, String> { if frame.delay_ms != 0 { return Err("Native proof render cannot contain delay-ms".to_owned()); } - let prepared = prepare_layout_job(&Arc::new(document), frame, 0, 1)?; + let document = LayoutSource::Full(Arc::new(document)); + let prepared = prepare_layout_job(&document, frame, 0, 1)?; let output = render_layout_payload( prepared.payload, SYNC_RENDER_SESSION_ID, @@ -1577,6 +1748,7 @@ fn worker_loop(shared: Arc) { } state.base_renders += output.base_renders; state.target_renders += output.target_renders; + state.document_resolver_lookups += output.resolver_lookups; state.results.insert( (job.generation, job.key), ResultEntry { @@ -1994,6 +2166,14 @@ mod tests { .into_bytes() } + fn identified_proof_layout_payload(frames: &str) -> Vec { + let mut payload: serde_json::Value = + serde_json::from_slice(&proof_layout_payload(frames)).unwrap(); + payload["document"]["root"]["node-id"] = serde_json::json!(1); + payload["document"]["root"]["node-revision"] = serde_json::json!(7); + serde_json::to_vec(&payload).unwrap() + } + fn replacement_layout_payload( document_base_revision: u64, document_target_revision: u64, @@ -2336,6 +2516,79 @@ mod tests { grandchild.stop(true); } + #[test] + fn nonzero_document_delta_renders_and_promotes_without_full_reparse() { + let session = Session::new(1, 4, 4, 64 * 1024).unwrap(); + let first = identified_proof_layout_payload( + r#"[{"key":1,"viewport-width":80,"viewport-height":10,"root-width":80,"runtime-revision":0,"context-hash":77}]"#, + ); + let first_tape = session.render_sync(1, &first).unwrap(); + assert!(!tape_patch_p(&first_tape)); + assert!(session.confirm(1, 1, 1).unwrap()); + + let delta = serde_json::to_vec(&serde_json::json!({ + "version": 1, + "document-base-revision": 1, + "document-target-revision": 2, + "document-delta": { + "style-base-count": 0, + "styles-append": [], + "property-template-base-count": 0, + "property-template-target-count": 0, + "entries": [{ + "node-id": 1, + "expected-revision": 7, + "target-revision": 21, + "slot-patches": [{ + "slot": 0, + "local": { + "content": {"lines": [{"clusters": [{ + "text": "y", "width": 8, "cjk": false, "space": false + }]}]} + } + }] + }] + }, + "frames": [{ + "key": 2, + "viewport-width": 80, + "viewport-height": 10, + "root-width": 80, + "patch": true, + "base-viewport-width": 80, + "base-viewport-height": 10, + "base-root-width": 80, + "runtime-revision": 1, + "context-hash": 77 + }] + })) + .unwrap(); + let delta_tape = session.render_sync(2, &delta).unwrap(); + assert!(tape_patch_p(&delta_tape)); + let stats = session.stats(); + assert_eq!(stats.document_parses, 1); + assert_eq!(stats.document_validations, 1); + assert_eq!(stats.document_full_nodes_parsed, 1); + assert_eq!(stats.document_delta_entries_parsed, 1); + assert_eq!(stats.document_delta_entries_validated, 1); + assert_eq!(stats.document_trie_path_nodes_copied, 17); + assert_eq!(stats.document_delta_input_bytes, delta.len() as u64); + assert!( + stats.document_resolver_lookups >= 3, + "target/base validation and target render lookups must all remain visible" + ); + assert!(session.confirm(2, 2, 2).unwrap()); + + let child = session.fork_confirmed().unwrap(); + let retained = retained_layout_payload( + 2, + r#"[{"key":3,"viewport-width":80,"viewport-height":10,"root-width":80,"runtime-revision":2,"context-hash":77}]"#, + ); + assert!(!child.render_sync(1, &retained).unwrap().is_empty()); + session.stop(true); + child.stop(true); + } + #[test] fn retained_document_reuse_rejects_wrong_or_ambiguous_revision() { let parent = Session::new(1, 4, 4, 64 * 1024).unwrap(); diff --git a/tests/ebox-commit-tests.el b/tests/ebox-commit-tests.el index 3da1c0e..cc8e9cf 100644 --- a/tests/ebox-commit-tests.el +++ b/tests/ebox-commit-tests.el @@ -2372,6 +2372,14 @@ remain retained identities." session initial-state root)) (next (copy-tree root)) (next-child (car (ebox-tree-node-children next)))) + (let ((bootstrap-root + (plist-get (plist-get initial-package :document) :root))) + (should (= (plist-get root :node-id) + (plist-get bootstrap-root :node-id))) + (should (integerp (plist-get bootstrap-root :node-revision))) + ;; The Text is fused into its owner and therefore has no addressable + ;; child entry in the full wire document. + (should (eq :null (plist-get bootstrap-root :child)))) (setf (ebox-native-reflow-session-styles session) (plist-get initial-package :styles) (ebox-native-reflow-session-layout-package session) @@ -2567,6 +2575,434 @@ remain retained identities." (should (= (plist-get (car controls) :document-base-revision) 10)) (should (= (plist-get (car controls) :document-target-revision) 11))))) +(ert-deftest ebox-native-persistent-index-path-copies-without-changing-base () + "A retained index update shares the base and leaves its values immutable." + (require 'ebox-native-reflow) + (let* ((base (ebox-native-reflow--persistent-index-put nil 1 'one)) + (next (ebox-native-reflow--persistent-index-put base 17 'seventeen))) + (should (eq 'one (ebox-native-reflow--persistent-index-get base 1))) + (should-not (ebox-native-reflow--persistent-index-get base 17)) + (should (eq 'one (ebox-native-reflow--persistent-index-get next 1))) + (should (eq 'seventeen + (ebox-native-reflow--persistent-index-get next 17))) + (should-not (eq base next)))) + +(ert-deftest ebox-native-session-fork-shares-immutable-compiler-roots () + "Forking does not clone the retained fragment map or persistent indexes." + (require 'ebox-native-reflow) + (let* ((cache (make-hash-table :test 'equal)) + (index (ebox-native-reflow--persistent-index-put nil 1 'entry)) + (styles (vector 'style)) + (session + (ebox-native-reflow--make-session + :handle 'parent :generation 4 :styles styles + :layout-package 'package :layout-fragment-cache cache + :layout-fragment-index index :layout-fragment-revision 8 + :layout-style-index index :layout-property-template-index index))) + (cl-letf (((symbol-function 'ebox-native--module-fork-confirmed) + (lambda (_handle) 'child))) + (let ((fork (ebox-native-reflow-fork-session session))) + (should (eq cache + (ebox-native-reflow-session-layout-fragment-cache fork))) + (should (eq index + (ebox-native-reflow-session-layout-fragment-index fork))) + (should (eq styles (ebox-native-reflow-session-styles fork))) + (should (= 8 + (ebox-native-reflow-session-layout-fragment-revision + fork))))))) + +(ert-deftest ebox-native-failed-delta-keeps-fork-and-parent-indexes-unchanged () + "A rejected candidate cannot publish its path-copied compiler index." + (require 'ebox-native-reflow) + (let* ((cache (make-hash-table :test 'equal)) + (base-index (ebox-native-reflow--persistent-index-put nil 1 'old)) + (next-index + (ebox-native-reflow--persistent-index-put base-index 1 'new)) + (old (list :document '(:version 2) :document-revision 4 + :styles [] :property-templates [])) + (next (copy-sequence old)) + (parent + (ebox-native-reflow--make-session + :handle 'parent :layout-package old :styles [] + :layout-fragment-cache cache :layout-fragment-index base-index))) + (plist-put next :document-revision 5) + (plist-put next :document-delta + '(:style-base-count 0 :styles-append [] + :property-template-base-count 0 + :property-template-target-count 0 :entries [])) + (plist-put next :native-fragment-index next-index) + (cl-letf (((symbol-function 'ebox-native--module-fork-confirmed) + (lambda (_handle) 'child))) + (let ((fork (ebox-native-reflow-fork-session parent))) + (cl-letf (((symbol-function + 'ebox-native-reflow--compile-retained-layout-package) + (lambda (&rest _) next)) + ((symbol-function 'ebox-native--module-render-session-frame) + (lambda (&rest _) (error "reject delta")))) + (should-error + (ebox-native-reflow-execute-session-sync + fork 'node '(:key 1 :viewport-width 80 :viewport-height 10) + nil 'state)) + (should (eq base-index + (ebox-native-reflow-session-layout-fragment-index fork))) + (should (eq base-index + (ebox-native-reflow-session-layout-fragment-index parent))) + (should (eq cache + (ebox-native-reflow-session-layout-fragment-cache fork)))))))) + +(ert-deftest ebox-native-accepted-delta-invalidates-stale-full-cache () + "A later full fallback cannot resurrect pre-delta legacy fragments." + (require 'ebox-native-reflow) + (let* ((cache (make-hash-table :test 'equal)) + (base-index (ebox-native-reflow--persistent-index-put nil 1 'old)) + (next-index + (ebox-native-reflow--persistent-index-put base-index 1 'new)) + (old (list :document '(:version 2) :document-revision 4 + :styles [] :property-templates [])) + (next (copy-sequence old)) + (session + (ebox-native-reflow--make-session + :handle 'test :generation 0 :layout-package old :styles [] + :layout-fragment-cache cache :layout-fragment-index base-index))) + (puthash 1 'stale-fragment cache) + (plist-put next :document-revision 5) + (plist-put next :document-delta + '(:style-base-count 0 :styles-append [] + :property-template-base-count 0 + :property-template-target-count 0 :entries [])) + (plist-put next :native-fragment-index next-index) + (plist-put next :native-fragment-revision 9) + (cl-letf (((symbol-function + 'ebox-native-reflow--compile-retained-layout-package) + (lambda (&rest _) next)) + ((symbol-function 'ebox-native--module-render-session-frame) + (lambda (&rest _) 'native-frame)) + ((symbol-function 'ebox-native-reflow--materialize-module-frame) + (lambda (&rest _) '(:rendered "ok")))) + (ebox-native-reflow-execute-session-sync + session 'node '(:key 1 :viewport-width 80 :viewport-height 10) + nil 'state) + (should-not + (ebox-native-reflow-session-layout-fragment-cache session)) + (should (eq next-index + (ebox-native-reflow-session-layout-fragment-index session))) + (should (= 9 + (ebox-native-reflow-session-layout-fragment-revision + session))) + ;; Model the next unsupported B update. The current source contains A's + ;; accepted value while the persistent index is deliberately stale; the + ;; invalidated legacy cache forces a complete compile from current state. + (let* ((input + (ebox-test-box (ebox-test-text "A") :bgcolor "#00ff00")) + (root (ebox-test-root input)) + (_ids (ebox--runtime-node-ids root)) + (full + (ebox-native-reflow--compile-retained-layout-package-full + session + (list :native-node-postorder + (ebox-native-reflow--retained-layout-postorder root) + :native-topology-stable-p nil + :source-index (ebox-test-source-index input)) + root)) + (document-root (plist-get (plist-get full :document) :root)) + (style-id (plist-get document-root :background-style))) + (should + (equal '(:background "#00ff00") + (plist-get (aref (plist-get full :styles) style-id) + :face))))))) + +(ert-deftest ebox-native-node-delta-is-local-and-bumps-ancestors () + "A local change patches one owner and only revises its retained ancestor." + (require 'ebox-native-reflow) + (let* ((leaf-fragment + '(:type "box" :background-style :null :child :null + :node-id 2 :node-revision 3)) + (root-fragment + (list :type "box" :background-style :null :child leaf-fragment + :node-id 1 :node-revision 4)) + (leaf-entry (list :fragment leaf-fragment :revision 3)) + (root-entry (list :fragment root-fragment :revision 4)) + (index (ebox-native-reflow--persistent-index-put nil 1 root-entry)) + (_index (setq index + (ebox-native-reflow--persistent-index-put + index 2 leaf-entry))) + (style-index + (ebox-native-reflow--persistent-index-put + nil '(:mode add :face (:background "red")) 0)) + (package + (list :document '(:version 2) :document-revision 7 + :styles [] :property-templates [])) + (session + (ebox-native-reflow--make-session + :handle 'test :generation 0 :styles [] :layout-package package + :layout-fragment-index index :layout-fragment-revision 4 + :layout-style-index style-index)) + (nodes (make-hash-table :test 'equal)) + (parents (make-hash-table :test 'equal)) + (state + (list :node-table nodes :parent-table parents + :native-topology-stable-p t + :native-touched-node-ids '(2 1) + :native-local-dirty-entries + '((:node-id 2 :dirty-kind paint + :changed-keys (:background-color)))))) + (puthash 1 '(:node-id 1) nodes) + (puthash 2 '(:node-id 2) nodes) + (puthash 2 1 parents) + (cl-letf (((symbol-function 'ebox--current-display-signature) + (lambda () 'display)) + ((symbol-function 'ebox-native-reflow--compile-delta-slots) + (lambda (_node _old) + (vector + '(:type "box" :background-style 0 :child :null))))) + (let* ((next + (ebox-native-reflow--compile-retained-layout-delta + session state 'root)) + (delta (plist-get next :document-delta)) + (entries (plist-get delta :entries)) + (leaf (aref entries 0)) + (root (aref entries 1))) + (should (= 8 (plist-get next :document-revision))) + (should (= 0 (plist-get delta :style-base-count))) + (should (= 2 (length entries))) + (should (equal [(:slot 0 :local (:background-style 0))] + (plist-get leaf :slot-patches))) + (should-not (plist-member root :slot-patches)) + (should (= 5 (plist-get leaf :target-revision))) + (should (= 6 (plist-get root :target-revision))))))) + +(ert-deftest ebox-native-node-delta-deduplicates-multiple-leaf-closures () + "Multiple changed leaves produce one entry each and one shared ancestor." + (require 'ebox-native-reflow) + (let ((index nil) + (nodes (make-hash-table :test 'equal)) + (parents (make-hash-table :test 'equal))) + (dolist (pair '((1 . 3) (2 . 1) (3 . 2))) + (setq index + (ebox-native-reflow--persistent-index-put + index (car pair) + (list :fragment + '(:type "box" :background-style :null :child :null) + :revision (cdr pair)))) + (puthash (car pair) (list :node-id (car pair)) nodes)) + (puthash 2 1 parents) + (puthash 3 1 parents) + (let* ((package (list :document '(:version 2) :document-revision 2 + :styles [] :property-templates [])) + (session + (ebox-native-reflow--make-session + :handle 'test :layout-package package + :layout-fragment-index index :layout-fragment-revision 3)) + (state + (list :node-table nodes :parent-table parents + :native-topology-stable-p t + :native-touched-node-ids '(2 1 3 1) + :native-local-dirty-entries + '((:node-id 2 :changed-keys (:background-color)) + (:node-id 3 :changed-keys (:background-color)))))) + (cl-letf (((symbol-function 'ebox--current-display-signature) + (lambda () 'display)) + ((symbol-function 'ebox-native-reflow--compile-delta-slots) + (lambda (node _old) + (vector + (list :type "box" :background-style + (plist-get node :node-id) :child :null))))) + (let* ((next + (ebox-native-reflow--compile-retained-layout-delta + session state 'root)) + (entries + (plist-get (plist-get next :document-delta) :entries))) + (should (= 3 (length entries))) + (should (equal '(2 1 3) + (mapcar (lambda (entry) + (plist-get entry :node-id)) + (append entries nil)))) + (should (plist-member (aref entries 0) :slot-patches)) + (should-not (plist-member (aref entries 1) :slot-patches)) + (should (plist-member (aref entries 2) :slot-patches))))))) + +(ert-deftest ebox-native-fused-text-delta-resolves-to-box-owner () + "A fused text id addresses its containing Box rather than a hidden node." + (require 'ebox-native-reflow) + (let ((nodes (make-hash-table :test 'equal)) + (parents (make-hash-table :test 'equal)) + (index (ebox-native-reflow--persistent-index-put nil 10 'owner))) + (puthash 10 + (list :node-id 10 :ebox-kind 'box + :ebox-layout-config + (ebox-normal-layout-create)) + nodes) + (puthash 11 '(:node-id 11 :ebox-kind text) nodes) + (puthash 11 10 parents) + (should (= 10 + (ebox-native-reflow--delta-owner-id + (list :node-table nodes :parent-table parents) 11 index))))) + +(ert-deftest ebox-native-flex-item-metadata-change-uses-full-input () + "N1 does not misrepresent Flex item metadata as a local scalar patch." + (require 'ebox-native-reflow) + (let ((session + (ebox-native-reflow--make-session + :handle 'test :layout-package 'old + :layout-fragment-index + (ebox-native-reflow--persistent-index-put + nil 1 '(:fragment (:type "box" :child :null) :revision 1)))) + (state + '(:native-topology-stable-p t :native-touched-node-ids (1) + :native-local-dirty-entries + ((:node-id 1 :dirty-kind geometry + :changed-keys (:flex-grow))))) + full-called) + (cl-letf (((symbol-function + 'ebox-native-reflow--compile-retained-layout-package-full) + (lambda (&rest _) + (setq full-called t) + 'full)) + ((symbol-function 'ebox-native-reflow--compile-delta-slots) + (lambda (&rest _) + (ert-fail "Flex item metadata reached local delta")))) + (should (eq 'full + (ebox-native-reflow--compile-retained-layout-package + session state 'node))) + (should full-called)))) + +(ert-deftest ebox-native-flex-child-content-change-uses-full-input () + "Child content may alter retained Flex edge measurement in N1." + (require 'ebox-native-reflow) + (let ((nodes (make-hash-table :test 'equal)) + (parents (make-hash-table :test 'equal)) + (index + (ebox-native-reflow--persistent-index-put + nil 2 '(:fragment (:type "box" :content [] :child :null) + :revision 3))) + full-called) + (puthash 1 '(:node-id 1 :ebox-type flex) nodes) + (puthash 2 + (list :node-id 2 :ebox-type 'box :ebox-kind 'box + :ebox-layout-config (ebox-normal-layout-create)) + nodes) + (puthash 3 '(:node-id 3 :ebox-type box :ebox-kind text) nodes) + (puthash 2 1 parents) + (puthash 3 2 parents) + (let ((session + (ebox-native-reflow--make-session + :handle 'test :layout-package 'old + :layout-fragment-index index)) + (state + (list :node-table nodes :parent-table parents + :native-topology-stable-p t + :native-touched-node-ids '(3 2 1) + :native-local-dirty-entries + '((:node-id 3 :dirty-kind content + :changed-keys (:content)))))) + (cl-letf (((symbol-function + 'ebox-native-reflow--compile-retained-layout-package-full) + (lambda (&rest _) + (setq full-called t) + 'full)) + ((symbol-function 'ebox-native-reflow--compile-delta-slots) + (lambda (&rest _) + (ert-fail "Flex child content reached local delta")))) + (should (eq 'full + (ebox-native-reflow--compile-retained-layout-package + session state 'node))) + (should full-called))))) + +(ert-deftest ebox-native-default-axis-child-change-keeps-local-input () + "Default Row/Column edges carry no derived Flex item metadata." + (require 'ebox-native-reflow) + (let ((nodes (make-hash-table :test 'equal)) + (parents (make-hash-table :test 'equal))) + (puthash 1 + (list :node-id 1 :ebox-type 'box :ebox-kind 'box + :ebox-layout-config (ebox-column-layout-create)) + nodes) + (puthash 2 + (list :node-id 2 :ebox-type 'box :ebox-kind 'box + :ebox-layout-config (ebox-normal-layout-create)) + nodes) + (puthash 3 '(:node-id 3 :ebox-type box :ebox-kind text) nodes) + (puthash 2 1 parents) + (puthash 3 2 parents) + (should-not + (ebox-native-reflow--delta-flex-edge-change-p + (list :node-table nodes :parent-table parents + :native-local-dirty-entries + '((:node-id 3 :dirty-kind geometry + :changed-keys (:content)))))))) + +(ert-deftest ebox-native-surface-overrides-carry-local-dirty-entries () + "The incremental producer preserves exact local dirtiness to native input." + (let* ((dirty '((:node-id 7 :dirty-kind paint + :changed-keys (:background-color)))) + (candidate (list :runtime-revision 3 + :native-topology-stable-p t + :native-touched-node-ids '(7 1) + :native-local-dirty-entries dirty)) + (overrides + (ebox-incremental--surface-state-overrides + nil '(:display-signature display) candidate 'native-frame))) + (should (equal dirty + (plist-get overrides :native-local-dirty-entries))))) + +(ert-deftest ebox-native-wide-owner-local-compile-does-not-enumerate-children () + "A scalar slot update never walks a stable owner's unchanged children." + (require 'ebox-native-reflow) + (let ((node (list :ebox-type 'box :ebox-kind 'box + :ebox-layout-config (ebox-flex-layout-create))) + (old '(:type "box" :child (:type "flex" :items [a b c])))) + (cl-letf (((symbol-function 'ebox-tree-node-children) + (lambda (&rest _) + (ert-fail "stable delta enumerated unchanged children"))) + ((symbol-function 'ebox-native-reflow--compile-flex-inner) + (lambda (_props items &rest _) + (should-not items) + '(:type "flex" :items []))) + ((symbol-function 'ebox-native-reflow--compile-box) + (lambda (_box child &rest _) + (list :type "box" :child child)))) + (let ((slots (ebox-native-reflow--compile-delta-slots node old))) + (should (= 2 (length slots))) + (should (equal [] (plist-get (aref slots 1) :items))))))) + +(ert-deftest ebox-native-retained-sync-sends-document-delta-without-document () + "A supported retained compile sends only its node delta and revisions." + (require 'ebox-native-reflow) + (let* ((old (list :document '(:version 2) :document-revision 4 + :styles [] :property-templates [])) + (delta '(:style-base-count 0 :styles-append [] + :property-template-base-count 0 + :property-template-target-count 0 :entries [])) + (next (copy-sequence old)) + (session + (ebox-native-reflow--make-session + :handle 'test :generation 0 :styles [] :layout-package old)) + control) + (plist-put next :document-revision 5) + (plist-put next :document-delta delta) + (cl-letf (((symbol-function + 'ebox-native-reflow--compile-retained-layout-package) + (lambda (&rest _) next)) + ((symbol-function 'ebox-native--module-render-session-frame) + (lambda (_handle _generation payload) + (setq control + (json-parse-string payload :object-type 'plist + :array-type 'array)) + 'native-frame)) + ((symbol-function 'ebox-native-reflow--materialize-module-frame) + (lambda (&rest _) '(:rendered "ok")))) + (ebox-native-reflow-execute-session-sync + session 'node + '(:key 1 :viewport-width 80 :viewport-height 10) nil 'state) + (should-not (plist-member control :document)) + (should (equal delta (plist-get control :document-delta))) + (should (= 4 (plist-get control :document-base-revision))) + (should (= 5 (plist-get control :document-target-revision))) + (should-not + (plist-member + (ebox-native-reflow-session-layout-package session) + :document-delta))))) + (provide 'ebox-commit-tests) ;;; ebox-commit-tests.el ends here diff --git a/tests/ebox-m0a-inventory-fixture.el b/tests/ebox-m0a-inventory-fixture.el index c59c8d3..3cf8fa9 100644 --- a/tests/ebox-m0a-inventory-fixture.el +++ b/tests/ebox-m0a-inventory-fixture.el @@ -411,6 +411,55 @@ :authority ebox :lifetime candidate-until-final-accept :rollback private-session-discard :rebuild native-frame-stage :cleanup confirm-or-rollback) + (:id candidate/native-local-dirty-entries + :storage (:state-key :native-local-dirty-entries) + :proposed-category generation-fact :owner ebox-native-commit + :authority ebox :lifetime candidate-until-native-input + :rollback candidate-discard :rebuild prepared-dirty-set + :cleanup native-frame-consume-or-downgrade) + (:id native-session/layout-fragment-index + :storage (:struct-slot ebox-native-reflow-session layout-fragment-index) + :proposed-category generation-bound-mutable :owner ebox-native-reflow + :authority ebox :lifetime private-native-session + :rollback private-session-discard :rebuild full-bootstrap-or-path-copy + :cleanup native-session-release) + (:id native-session/layout-style-index + :storage (:struct-slot ebox-native-reflow-session layout-style-index) + :proposed-category generation-bound-mutable :owner ebox-native-reflow + :authority ebox :lifetime private-native-session + :rollback private-session-discard :rebuild full-bootstrap-or-style-append + :cleanup native-session-release) + (:id native-session/layout-property-template-index + :storage + (:struct-slot ebox-native-reflow-session layout-property-template-index) + :proposed-category generation-bound-mutable :owner ebox-native-reflow + :authority ebox :lifetime private-native-session + :rollback private-session-discard :rebuild full-bootstrap + :cleanup native-session-release) + (:id native-compile/style-index + :storage (:global ebox-native-reflow--compile-style-index) + :proposed-category generation-fact :owner ebox-native-reflow + :authority ebox :lifetime dynamic-native-compile + :rollback dynamic-binding-unwind :rebuild retained-session-root + :cleanup dynamic-binding-unwind) + (:id native-compile/property-template-index + :storage (:global ebox-native-reflow--compile-property-template-index) + :proposed-category generation-fact :owner ebox-native-reflow + :authority ebox :lifetime dynamic-native-compile + :rollback dynamic-binding-unwind :rebuild retained-session-root + :cleanup dynamic-binding-unwind) + (:id native-package/fragment-index + :storage (:state-key :native-fragment-index) + :proposed-category generation-fact :owner ebox-native-reflow + :authority ebox :lifetime native-call-until-success + :rollback package-discard :rebuild path-copied-session-root + :cleanup strip-from-confirmed-package) + (:id native-package/style-index + :storage (:state-key :native-style-index) + :proposed-category generation-fact :owner ebox-native-reflow + :authority ebox :lifetime native-call-until-success + :rollback package-discard :rebuild style-append-path-copy + :cleanup strip-from-confirmed-package) (:id process/scroll-global-state :storage (:global ebox--scroll-global-state) :proposed-category generation-bound-mutable :owner ebox-incremental diff --git a/tests/ebox-state-contract-tests.el b/tests/ebox-state-contract-tests.el index a9b777a..1f73910 100644 --- a/tests/ebox-state-contract-tests.el +++ b/tests/ebox-state-contract-tests.el @@ -466,18 +466,20 @@ exclusion freshness, but only retained/container evidence is coverage-gated." 'generation-bound-mutable-authority)))) (ert-deftest ebox-state-contract-excludes-native-compile-scratch-from-authority () - "Compile-local template ids are not mislabeled as native session authority." - (should-not - (memq 'ebox-native-reflow--compile-property-template-ids + "Compile-local indexes are not mislabeled as native session authority." + (let ((authority-storage (plist-get (ebox-state-contract-record 'native-runtime-authority) :storage))) - (let ((exclusion - (cl-find 'ebox-native-reflow--compile-property-template-ids - ebox-state-contract-source-scan-exclusions - :key (lambda (entry) (plist-get entry :symbol))))) - (should exclusion) - (should (eq (plist-get exclusion :reason) - 'dynamically-bound-compile-local-scratch)))) + (dolist (symbol '(ebox-native-reflow--compile-property-template-ids + ebox-native-reflow--compile-property-template-index + ebox-native-reflow--compile-style-index)) + (should-not (memq symbol authority-storage)) + (let ((exclusion + (cl-find symbol ebox-state-contract-source-scan-exclusions + :key (lambda (entry) (plist-get entry :symbol))))) + (should exclusion) + (should (eq (plist-get exclusion :reason) + 'dynamically-bound-compile-local-scratch)))))) (ert-deftest ebox-state-contract-tp-custody-is-opaque () "The inventory separates current whole-state custody from its M2a target." diff --git a/tests/ebox-surface-tests.el b/tests/ebox-surface-tests.el index b868a0e..40eb1a8 100644 --- a/tests/ebox-surface-tests.el +++ b/tests/ebox-surface-tests.el @@ -1656,17 +1656,25 @@ candidate cannot hide mutations by restoring the old hash-table pointer." (plist-get (tp-surface-client-state surface) :native-sync-session)) (should (eq (plist-get report :projection-kind) 'native-frame)) - ;; A style-table change cannot consume the prior confirmed - ;; document and therefore remains a truthful full frame. - (should (eq (plist-get report :native-frame-kind) 'full)) + ;; A root paint delta and appended styles preserve the old + ;; registry prefix and publish against the confirmed tape. + (should (eq (plist-get report :native-frame-kind) 'patch)) (should next-session) (should-not (eq next-session winning-session)) (should (ebox-native-reflow-session-released-p winning-session)) (should-not (ebox-native-reflow-session-released-p next-session)) + (should + (equal-including-properties + (with-current-buffer buffer + (buffer-substring (point-min) (point-max))) + (ebox-surface-test--render-runtime + (tp-surface-client-state surface)))) (let ((stats (ebox-native-reflow-stats next-session))) - (should (= (plist-get stats :document-parses) 1)) - (should (= (plist-get stats :document-validations) 1)) + (should (= (plist-get stats :document-parses) 0)) + (should (= (plist-get stats :document-validations) 0)) + (should (= (plist-get stats :document-delta-entries-parsed) 1)) + (should (= (plist-get stats :document-delta-entries-validated) 1)) (should (= (plist-get stats :document-reuses) 0)))))) (when (buffer-live-p buffer) (kill-buffer buffer)) @@ -1725,6 +1733,108 @@ candidate cannot hide mutations by restoring the old hash-table pointer." (when (buffer-live-p buffer) (kill-buffer buffer))))) +(ert-deftest ebox-native-node-delta-survives-viewport-and-repeated-commits () + "Real local commits preserve earlier edits across viewport document reuse." + (skip-unless (ebox-native-reflow-layout-ready-p)) + (ebox-surface-test--reset-render-state) + (let ((buffer (generate-new-buffer " *ebox-native-delta-history*")) + (ebox-viewport-width 320) + (ebox-viewport-height 40) + (ebox-runtime-idle-prewarm nil) + (ebox-runtime-idle-reflow-cache-prewarm nil)) + (unwind-protect + (cl-labels + ((item + (index text) + (ebox-test-box + :key index :source-identity index :width '(8) :height 1 + (ebox-test-text text :key (+ 100 index) + :source-identity (+ 100 index)))) + (source + (count) + (apply #'ebox-test-column + (append (list :key 'delta-history + :source-identity 'delta-history + :width '(viewport)) + (cl-loop for index below count + collect (item index "A"))))) + (ids + (state) + (let (result) + (ebox-surface-test--walk-runtime + (plist-get state :root-node) + (lambda (node) (push (plist-get node :node-id) result))) + (nreverse result)))) + (cl-letf (((symbol-function 'ebox-native-reflow-layout-ready-p) + (lambda () nil))) + (ebox-render-to-buffer buffer (source 15))) + (ebox-commit buffer (source 16)) + (let* ((surface + (with-current-buffer buffer ebox-surface--buffer-surface)) + (baseline-ids (ids (tp-surface-client-state surface))) + (original-send + (symbol-function 'ebox-native--module-render-session-frame)) + (expected-texts (make-vector 16 "A")) + previous-document-revision events) + (should (plist-get (tp-surface-client-state surface) + :native-sync-confirmed-p)) + (cl-letf + (((symbol-function 'ebox-native--module-render-session-frame) + (lambda (handle generation control) + (push (json-parse-string control :object-type 'plist + :array-type 'array) + events) + (funcall original-send handle generation control)))) + (dolist (step '((4 "B") viewport (12 "C") (4 "D"))) + (setq events nil) + (if (eq step 'viewport) + (progn + (setq ebox-viewport-width 360) + (ebox-rerender-buffer-with-context buffer 360 40)) + (let ((candidate (ebox-candidate-begin buffer))) + (ebox-candidate-replace-host-ref + candidate (car step) (item (car step) (cadr step))) + (ebox-commit buffer candidate) + (aset expected-texts (car step) (cadr step)))) + (ert-info ((format "Native delta history step %S" step)) + (should (= 1 (length events)))) + (let* ((control (car events)) + (base (plist-get control :document-base-revision)) + (target (plist-get control :document-target-revision)) + (state (tp-surface-client-state surface)) + (session (plist-get state :native-sync-session))) + (should-not (plist-get control :document)) + (when previous-document-revision + (should (= base previous-document-revision))) + (if (eq step 'viewport) + (progn + (should-not (plist-get control :document-delta)) + (should (= base target))) + (should (plist-get control :document-delta)) + (should (= (1+ base) target))) + (setq previous-document-revision target) + (should (equal baseline-ids (ids state))) + (should + (equal (mapconcat #'identity expected-texts "") + (with-current-buffer buffer + (replace-regexp-in-string + "[[:space:]]" "" + (buffer-substring-no-properties + (point-min) (point-max)))))) + (should (plist-get state :native-sync-confirmed-p)) + (should-not (plist-get state :native-sync-pending)) + (should-not + (plist-get + (ebox-native-reflow-session-layout-package session) + :document-delta)) + (should + (equal-including-properties + (with-current-buffer buffer + (buffer-substring (point-min) (point-max))) + (ebox-surface-test--render-runtime state)))))))) + (when (buffer-live-p buffer) + (kill-buffer buffer))))) + (ert-deftest ebox-viewport-reflow-retains-final-sized-flex-child-fragments () "Viewport reflow should reuse final-sized Flex child fragments exactly." (ebox-surface-test--reset-render-state) From e48af058b53bec53f09d0b237bdfc0b586f6f0fa Mon Sep 17 00:00:00 2001 From: Kinneyzhang Date: Sat, 5 Sep 2026 09:35:43 +0800 Subject: [PATCH 04/12] Reuse exact native edge facts for local mixed-layout deltas --- ebox-native-reflow.el | 215 ++++++++++++++++++++++------ tests/ebox-commit-tests.el | 186 +++++++++++++++++++++++- tests/ebox-m0a-inventory-fixture.el | 6 + 3 files changed, 354 insertions(+), 53 deletions(-) diff --git a/ebox-native-reflow.el b/ebox-native-reflow.el index 16559a4..5e73466 100644 --- a/ebox-native-reflow.el +++ b/ebox-native-reflow.el @@ -226,6 +226,7 @@ module while loading the package." layout-fragment-revision layout-style-index layout-property-template-index + layout-edge-index readiness-process readiness-preparation released-p) @@ -1419,7 +1420,9 @@ copied separately; no pending frame or registered Rust document is inherited." (ebox-native-reflow-session-layout-style-index session) (ebox-native-reflow-session-layout-property-template-index fork) (ebox-native-reflow-session-layout-property-template-index - session)) + session) + (ebox-native-reflow-session-layout-edge-index fork) + (ebox-native-reflow-session-layout-edge-index session)) fork) (error (ebox-native-reflow-release-session fork) @@ -2599,6 +2602,86 @@ per call, and no call recursively visits the captured Ebox tree." (gethash child flex-content-min-widths))) (ebox-native-reflow--retained-layout-children node)))))) +(defun ebox-native-reflow--retained-edge-rule (node) + "Return NODE's exact child-edge lowering rule, or nil for plain edges." + (let ((node-id (plist-get node :node-id))) + (cond + ((and (eq (plist-get node :ebox-type) 'box) + (eq (plist-get node :ebox-kind) 'box)) + (let* ((config (plist-get node :ebox-layout-config)) + (kind (and config (ebox-layout-config-kind config))) + (props (and config (ebox-layout-config-props config)))) + (pcase kind + ('flex + (list :parent-id node-id :slot 1 :kind 'flex-item + :axis (ebox--flex-axis props))) + ((or 'row 'column) + (if (and (equal (plist-get props :item-gap) 0) + (eq (plist-get props :cross-align) 'stretch)) + (list :parent-id node-id :slot 1 :kind 'direct-axis + :axis kind) + (list :parent-id node-id :slot 1 :kind 'typed-axis-item + :axis kind :cross-align + (plist-get props :cross-align))))))) + ((eq (plist-get node :ebox-type) 'flex) + (let* ((wrapper (plist-get node :box)) + (props (ebox--flex-container-content-props + (plist-get node :props) (plist-get node :raw-props) + wrapper))) + (list :parent-id node-id :slot (if wrapper 1 0) :kind 'flex-item + :axis (ebox--flex-axis props))))))) + +(defun ebox-native-reflow--retained-edge-item-local (item) + "Return exact derived edge fields from compiled ITEM." + (let* ((node (plist-get item :node)) + (local + (cl-loop for (key value) on item by #'cddr + unless (eq key :node) append (list key value)))) + ;; Flex compilation may augment the edge's child view with this measured + ;; scalar without changing the independently retained child fragment. + (if (plist-member node :content-min-width) + (append local + (list :content-min-width + (plist-get node :content-min-width))) + local))) + +(defun ebox-native-reflow--retained-edge-index (postorder cache) + "Build exact child-edge facts from full POSTORDER fragments in CACHE." + (let (index) + (cl-loop for parent across postorder + for parent-id = (plist-get parent :node-id) + for rule = (ebox-native-reflow--retained-edge-rule parent) + when rule do + (setq index + (ebox-native-reflow--persistent-index-put + index (cons 'rule parent-id) rule)) + when (memq (plist-get rule :kind) + '(flex-item typed-axis-item)) do + (let* ((entry (gethash parent-id cache)) + (fragment (plist-get entry :fragment)) + (inner (if (= (plist-get rule :slot) 1) + (plist-get fragment :child) + fragment)) + (items (plist-get inner :items)) + (children + (ebox-native-reflow--retained-layout-children parent))) + (unless (= (length children) (length items)) + (error "Native retained edge facts do not match children")) + (cl-loop for child in children + for item across items + for position from 0 do + (setq index + (ebox-native-reflow--persistent-index-put + index + (cons 'child (plist-get child :node-id)) + (append + (list :parent-id parent-id + :slot (plist-get rule :slot) + :item-position position) + (ebox-native-reflow--retained-edge-item-local + item))))))) + index)) + (defun ebox-native-reflow--compile-retained-layout-package-full (session state node) "Compile a complete retained package for NODE in SESSION from STATE." @@ -2785,61 +2868,98 @@ per call, and no call recursively visits the captured Ebox tree." (ebox-native-reflow-session-layout-property-template-index session) (ebox-native-reflow--persistent-index-from-sequence - (plist-get package :property-templates))) + (plist-get package :property-templates)) + (ebox-native-reflow-session-layout-edge-index session) + (ebox-native-reflow--retained-edge-index + postorder new-cache)) package))))) (defconst ebox-native-reflow--delta-edge-fields '(:type :child :children :items :node-id :node-revision) "Fragment fields that a stable local node delta cannot replace.") -(defconst ebox-native-reflow--delta-flex-edge-properties - '(:order :flex-grow :flex-shrink :flex-basis :align-self - :flex-direction :cross-align) - "Changed properties that alter retained Flex item metadata in N1.") +(defun ebox-native-reflow--current-edge-item-fact + (state owner parent rule retained) + "Return OWNER's current edge fact from STATE under PARENT. +RULE identifies the compiler lowering, and RETAINED supplies its stable slot." + (let ((local + (pcase (plist-get rule :kind) + ('flex-item + (let* ((widths (plist-get state :flex-content-min-widths)) + (missing (make-symbol "missing-native-edge-width")) + (width (if (hash-table-p widths) + (gethash owner widths missing) + missing))) + (ebox-native-reflow--retained-edge-item-local + (ebox-native-reflow--compile-flex-item + owner (plist-get rule :axis) nil t width + (not (eq width missing)))))) + ('typed-axis-item + (list :order 0 :grow 0 :shrink 0 + :basis (list :kind "auto") + :align-self + (ebox-native-reflow--simple-axis-item-align + (plist-get rule :axis) owner + (plist-get rule :cross-align))))))) + (and local + (append + (list :parent-id (plist-get parent :node-id) + :slot (plist-get retained :slot) + :item-position (plist-get retained :item-position)) + local)))) -(defun ebox-native-reflow--delta-flex-edge-change-p (state) - "Return non-nil when STATE changes unsupported Flex edge metadata." +(defun ebox-native-reflow--delta-edge-change-p (session state index) + "Return non-nil when STATE changes a retained derived edge in SESSION." + (let ((edge-index (ebox-native-reflow-session-layout-edge-index session)) + (missing (make-symbol "missing-native-edge-fact")) + (ids + (append + (mapcar (lambda (dirty) (plist-get dirty :node-id)) + (plist-get state :native-local-dirty-entries)) + (plist-get state :native-touched-node-ids)))) (cl-some - (lambda (dirty) - (let* ((keys (plist-get dirty :changed-keys)) - (parents (plist-get state :parent-table)) + (lambda (dirty-id) + (let* ((parents (plist-get state :parent-table)) (nodes (plist-get state :node-table)) - (dirty-id (plist-get dirty :node-id)) - (dirty-node (and nodes (gethash dirty-id nodes))) - (raw-parent-id (and parents (gethash dirty-id parents))) - (raw-parent (and nodes (gethash raw-parent-id nodes))) (owner-id - (if (and (eq (plist-get dirty-node :ebox-kind) 'text) - (eq (plist-get raw-parent :ebox-kind) 'box) - (when-let* ((layout - (plist-get raw-parent - :ebox-layout-config))) - (eq (ebox-layout-config-kind layout) 'normal))) - raw-parent-id - dirty-id)) - (edge-parent-id (and parents (gethash owner-id parents))) - (edge-parent (and nodes (gethash edge-parent-id nodes))) - (config - (and edge-parent - (plist-get edge-parent :ebox-layout-config)))) - (or - (cl-some (lambda (key) - (memq key ebox-native-reflow--delta-flex-edge-properties)) - keys) - ;; A child-local edit can change content-min-width in a Flex item or - ;; derived align-self in typed axis lowering. N1 has no edge patch, - ;; so retain correctness through full input until exact edge facts are - ;; independently addressable. - (or (eq (plist-get edge-parent :ebox-type) 'flex) - (and config - (let ((kind (ebox-layout-config-kind config)) - (props (ebox-layout-config-props config))) - (or (eq kind 'flex) - (and (memq kind '(row column)) - (not (and (equal (plist-get props :item-gap) 0) - (eq (plist-get props :cross-align) - 'stretch))))))))))) - (plist-get state :native-local-dirty-entries))) + (and nodes parents + (ebox-native-reflow--delta-owner-id state dirty-id index))) + (owner (and owner-id (gethash owner-id nodes))) + (parent-id (and owner-id (gethash owner-id parents))) + (parent (and parent-id (gethash parent-id nodes))) + (current-parent-rule + (and parent (ebox-native-reflow--retained-edge-rule parent))) + (retained-parent-rule + (and parent-id + (ebox-native-reflow--persistent-index-get + edge-index (cons 'rule parent-id) missing))) + (retained-item + (and owner-id + (ebox-native-reflow--persistent-index-get + edge-index (cons 'child owner-id) missing))) + (current-owner-rule + (and owner (ebox-native-reflow--retained-edge-rule owner))) + (retained-owner-rule + (and owner-id + (ebox-native-reflow--persistent-index-get + edge-index (cons 'rule owner-id) missing)))) + (or (null owner) + (not (equal current-owner-rule + (unless (eq retained-owner-rule missing) + retained-owner-rule))) + (not (equal current-parent-rule + (unless (eq retained-parent-rule missing) + retained-parent-rule))) + (if (memq (plist-get current-parent-rule :kind) + '(flex-item typed-axis-item)) + (or (eq retained-item missing) + (not + (equal + retained-item + (ebox-native-reflow--current-edge-item-fact + state owner parent current-parent-rule retained-item)))) + (not (eq retained-item missing)))))) + (delete-dups (copy-sequence ids))))) (defun ebox-native-reflow--fragment-local (fragment) "Return FRAGMENT's scalar and local-content fields." @@ -2956,7 +3076,8 @@ per call, and no call recursively visits the captured Ebox tree." (when (and old-package old-index (hash-table-p node-table) (plist-get state :native-topology-stable-p) (plist-member state :native-local-dirty-entries) - (not (ebox-native-reflow--delta-flex-edge-change-p state)) + (not (ebox-native-reflow--delta-edge-change-p + session state old-index)) ;; Descendant inherited-style expansion needs its own bounded ;; affected-node index. Until then it is an explicit full ;; input fallback rather than a partial semantic update. diff --git a/tests/ebox-commit-tests.el b/tests/ebox-commit-tests.el index cc8e9cf..991d806 100644 --- a/tests/ebox-commit-tests.el +++ b/tests/ebox-commit-tests.el @@ -2592,13 +2592,17 @@ remain retained identities." (require 'ebox-native-reflow) (let* ((cache (make-hash-table :test 'equal)) (index (ebox-native-reflow--persistent-index-put nil 1 'entry)) + (edge-index + (ebox-native-reflow--persistent-index-put + nil '(rule . 1) '(:kind direct-axis))) (styles (vector 'style)) (session (ebox-native-reflow--make-session :handle 'parent :generation 4 :styles styles :layout-package 'package :layout-fragment-cache cache :layout-fragment-index index :layout-fragment-revision 8 - :layout-style-index index :layout-property-template-index index))) + :layout-style-index index :layout-property-template-index index + :layout-edge-index edge-index))) (cl-letf (((symbol-function 'ebox-native--module-fork-confirmed) (lambda (_handle) 'child))) (let ((fork (ebox-native-reflow-fork-session session))) @@ -2606,6 +2610,8 @@ remain retained identities." (ebox-native-reflow-session-layout-fragment-cache fork))) (should (eq index (ebox-native-reflow-session-layout-fragment-index fork))) + (should (eq edge-index + (ebox-native-reflow-session-layout-edge-index fork))) (should (eq styles (ebox-native-reflow-session-styles fork))) (should (= 8 (ebox-native-reflow-session-layout-fragment-revision @@ -2618,13 +2624,17 @@ remain retained identities." (base-index (ebox-native-reflow--persistent-index-put nil 1 'old)) (next-index (ebox-native-reflow--persistent-index-put base-index 1 'new)) + (edge-index + (ebox-native-reflow--persistent-index-put + nil '(child . 1) '(:parent-id 9))) (old (list :document '(:version 2) :document-revision 4 :styles [] :property-templates [])) (next (copy-sequence old)) (parent - (ebox-native-reflow--make-session + (ebox-native-reflow--make-session :handle 'parent :layout-package old :styles [] - :layout-fragment-cache cache :layout-fragment-index base-index))) + :layout-fragment-cache cache :layout-fragment-index base-index + :layout-edge-index edge-index))) (plist-put next :document-revision 5) (plist-put next :document-delta '(:style-base-count 0 :styles-append [] @@ -2647,6 +2657,10 @@ remain retained identities." (ebox-native-reflow-session-layout-fragment-index fork))) (should (eq base-index (ebox-native-reflow-session-layout-fragment-index parent))) + (should (eq edge-index + (ebox-native-reflow-session-layout-edge-index fork))) + (should (eq edge-index + (ebox-native-reflow-session-layout-edge-index parent))) (should (eq cache (ebox-native-reflow-session-layout-fragment-cache fork)))))))) @@ -2912,7 +2926,8 @@ remain retained identities." "Default Row/Column edges carry no derived Flex item metadata." (require 'ebox-native-reflow) (let ((nodes (make-hash-table :test 'equal)) - (parents (make-hash-table :test 'equal))) + (parents (make-hash-table :test 'equal)) + (index (ebox-native-reflow--persistent-index-put nil 2 'owner))) (puthash 1 (list :node-id 1 :ebox-type 'box :ebox-kind 'box :ebox-layout-config (ebox-column-layout-create)) @@ -2925,11 +2940,170 @@ remain retained identities." (puthash 2 1 parents) (puthash 3 2 parents) (should-not - (ebox-native-reflow--delta-flex-edge-change-p + (ebox-native-reflow--delta-edge-change-p + (ebox-native-reflow--make-session + :layout-edge-index + (ebox-native-reflow--persistent-index-put + nil '(rule . 1) + '(:parent-id 1 :slot 1 :kind direct-axis :axis column))) (list :node-table nodes :parent-table parents :native-local-dirty-entries '((:node-id 3 :dirty-kind geometry - :changed-keys (:content)))))))) + :changed-keys (:content)))) + index)))) + +(ert-deftest ebox-native-flex-fused-paint-proves-retained-edge-unchanged () + "A fused paint edit under Flex uses its exact retained item fact." + (require 'ebox-native-reflow) + (let* ((text '(:node-id 3 :ebox-type box :ebox-kind text)) + (owner + (list :node-id 2 :ebox-type 'box :ebox-kind 'box + :ebox-layout-config (ebox-normal-layout-create) + :children (list text))) + (parent + (list :node-id 1 :ebox-type 'box :ebox-kind 'box + :ebox-layout-config (ebox-flex-layout-create) + :children (list owner))) + (item (ebox-native-reflow--compile-flex-item + owner 'row '(:type "box") t 17 t)) + (fragment + (list :type "box" + :child (list :type "flex" :items (vector item)))) + (cache (make-hash-table :test 'equal)) + (nodes (make-hash-table :test 'equal)) + (parents (make-hash-table :test 'equal)) + (widths (make-hash-table :test 'eq)) + (owner-fragment + '(:type "box" :background-style :null :child :null + :node-id 2 :node-revision 1)) + (index + (ebox-native-reflow--persistent-index-put + nil 2 (list :fragment owner-fragment :revision 1))) + (_index + (setq index + (ebox-native-reflow--persistent-index-put + index 1 (list :fragment fragment :revision 2))))) + (puthash 1 (list :fragment fragment :revision 1) cache) + (puthash 1 parent nodes) + (puthash 2 owner nodes) + (puthash 3 text nodes) + (puthash 2 1 parents) + (puthash 3 2 parents) + (puthash owner 17 widths) + (let* ((session + (ebox-native-reflow--make-session + :layout-package + '(:document (:version 2) :document-revision 1 + :styles [] :property-templates []) + :layout-fragment-index index :layout-fragment-revision 2 + :layout-edge-index + (ebox-native-reflow--retained-edge-index + (vector owner parent) cache))) + (state + (list :node-table nodes :parent-table parents + :flex-content-min-widths widths + :native-topology-stable-p t + :native-touched-node-ids '(3 2 1) + :native-local-dirty-entries + '((:node-id 3 :dirty-kind paint + :changed-keys (:background-color)))))) + (cl-letf (((symbol-function 'ebox-tree-node-children) + (lambda (&rest _) + (ert-fail "delta edge proof enumerated siblings"))) + ((symbol-function 'ebox--current-display-signature) + (lambda () 'display)) + ((symbol-function 'ebox-native-reflow--compile-delta-slots) + (lambda (_node _old) + (vector + '(:type "box" :background-style 0 :child :null))))) + (should-not + (ebox-native-reflow--delta-edge-change-p + session state index)) + (let ((package + (ebox-native-reflow--compile-retained-layout-delta + session state parent))) + (should (plist-get package :document-delta)) + (should (= 2 (length + (plist-get (plist-get package :document-delta) + :entries))))))))) + +(ert-deftest ebox-native-flex-measurement-change-keeps-full-fallback () + "A changed exact Flex measurement still requires the future edge protocol." + (require 'ebox-native-reflow) + (let* ((owner + (list :node-id 2 :ebox-type 'box :ebox-kind 'box + :ebox-layout-config (ebox-normal-layout-create) + :wrap-mode 'word)) + (parent + (list :node-id 1 :ebox-type 'box :ebox-kind 'box + :ebox-layout-config (ebox-flex-layout-create) + :children (list owner))) + (item (ebox-native-reflow--compile-flex-item + owner 'row '(:type "box") t 17 t)) + (fragment + (list :type "box" + :child (list :type "flex" :items (vector item)))) + (cache (make-hash-table :test 'equal)) + (nodes (make-hash-table :test 'equal)) + (parents (make-hash-table :test 'equal)) + (widths (make-hash-table :test 'eq)) + (index (ebox-native-reflow--persistent-index-put nil 2 'owner))) + (puthash 1 (list :fragment fragment :revision 1) cache) + (puthash 1 parent nodes) + (puthash 2 owner nodes) + (puthash 2 1 parents) + (puthash owner 23 widths) + (should + (ebox-native-reflow--delta-edge-change-p + (ebox-native-reflow--make-session + :layout-edge-index + (ebox-native-reflow--retained-edge-index + (vector owner parent) cache)) + (list :node-table nodes :parent-table parents + :flex-content-min-widths widths + :native-local-dirty-entries + '((:node-id 2 :dirty-kind content :changed-keys (:content)))) + index)))) + +(ert-deftest ebox-native-nondefault-axis-fused-edit-uses-exact-edge-fact () + "A fused edit under nondefault Row reuses an unchanged align-self fact." + (require 'ebox-native-reflow) + (let* ((text '(:node-id 3 :ebox-type box :ebox-kind text)) + (owner + (list :node-id 2 :ebox-type 'box :ebox-kind 'box + :ebox-layout-config (ebox-normal-layout-create) + :children (list text))) + (parent + (list :node-id 1 :ebox-type 'box :ebox-kind 'box + :ebox-layout-config + (ebox-row-layout-create :item-gap 2 :cross-align 'stretch) + :children (list owner))) + (item + (list :node '(:type "box") :order 0 :grow 0 :shrink 0 + :basis '(:kind "auto") :align-self "stretch")) + (fragment + (list :type "box" + :child (list :type "flex" :items (vector item)))) + (cache (make-hash-table :test 'equal)) + (nodes (make-hash-table :test 'equal)) + (parents (make-hash-table :test 'equal)) + (index (ebox-native-reflow--persistent-index-put nil 2 'owner))) + (puthash 1 (list :fragment fragment :revision 1) cache) + (puthash 1 parent nodes) + (puthash 2 owner nodes) + (puthash 3 text nodes) + (puthash 2 1 parents) + (puthash 3 2 parents) + (should-not + (ebox-native-reflow--delta-edge-change-p + (ebox-native-reflow--make-session + :layout-edge-index + (ebox-native-reflow--retained-edge-index + (vector owner parent) cache)) + (list :node-table nodes :parent-table parents + :native-local-dirty-entries + '((:node-id 3 :dirty-kind content :changed-keys (:content)))) + index)))) (ert-deftest ebox-native-surface-overrides-carry-local-dirty-entries () "The incremental producer preserves exact local dirtiness to native input." diff --git a/tests/ebox-m0a-inventory-fixture.el b/tests/ebox-m0a-inventory-fixture.el index 3cf8fa9..8533983 100644 --- a/tests/ebox-m0a-inventory-fixture.el +++ b/tests/ebox-m0a-inventory-fixture.el @@ -436,6 +436,12 @@ :authority ebox :lifetime private-native-session :rollback private-session-discard :rebuild full-bootstrap :cleanup native-session-release) + (:id native-session/layout-edge-index + :storage (:struct-slot ebox-native-reflow-session layout-edge-index) + :proposed-category generation-bound-mutable :owner ebox-native-reflow + :authority ebox :lifetime private-native-session + :rollback private-session-discard :rebuild full-bootstrap-edge-facts + :cleanup native-session-release) (:id native-compile/style-index :storage (:global ebox-native-reflow--compile-style-index) :proposed-category generation-fact :owner ebox-native-reflow From c9de7e6329e371ac92ff5428d664e00bcbdf3097 Mon Sep 17 00:00:00 2001 From: Kinneyzhang Date: Sat, 5 Sep 2026 11:15:52 +0800 Subject: [PATCH 05/12] Retain atom composition and incremental context validation facts --- native/src/atom_plan.rs | 782 ++++++++++++++++++++++++++++++++++ native/src/layout.rs | 922 ++++++++++++++++++++++++++++++++-------- native/src/lib.rs | 20 +- native/src/sequence.rs | 751 ++++++++++++++++++++++++++++++++ 4 files changed, 2304 insertions(+), 171 deletions(-) create mode 100644 native/src/atom_plan.rs create mode 100644 native/src/sequence.rs diff --git a/native/src/atom_plan.rs b/native/src/atom_plan.rs new file mode 100644 index 0000000..b298697 --- /dev/null +++ b/native/src/atom_plan.rs @@ -0,0 +1,782 @@ +use std::cell::Cell; +use std::sync::Arc; + +use crate::sequence::{Entry, Measure, Node, NodeView, Sequence, Work}; + +use super::{Atom, AtomProperties, RegionRole, RegionRoleEntry}; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "kebab-case")] +pub(crate) struct AtomPlanWork { + pub(crate) sequence_nodes_created: u64, + pub(crate) sequence_entries_written: u64, + pub(crate) materialized_atoms: u64, + pub(crate) property_applications: u64, + pub(crate) atom_clones: u64, + pub(crate) operation_block_writes: u64, +} + +thread_local! { + static WORK: Cell = Cell::new(AtomPlanWork::default()); +} + +pub(crate) fn reset_work() { + WORK.set(AtomPlanWork::default()); +} + +pub(crate) fn work() -> AtomPlanWork { + WORK.get() +} + +fn record_sequence(work: Work) { + WORK.set(WORK.get().checked_add(AtomPlanWork { + sequence_nodes_created: work.nodes_created, + sequence_entries_written: work.entries_written, + materialized_atoms: 0, + property_applications: 0, + atom_clones: 0, + operation_block_writes: 0, + })); +} + +fn record_materialized(count: usize) { + let count = u64::try_from(count).expect("validated native atom count fits u64"); + WORK.set(WORK.get().checked_add(AtomPlanWork { + materialized_atoms: count, + atom_clones: count, + ..AtomPlanWork::default() + })); +} + +fn record_atom_clones(count: usize) { + let count = u64::try_from(count).expect("validated native atom count fits u64"); + WORK.set(WORK.get().checked_add(AtomPlanWork { + atom_clones: count, + ..AtomPlanWork::default() + })); +} + +fn record_operation_block_writes(count: usize) { + let count = u64::try_from(count).expect("native atom decoration block fits u64"); + WORK.set(WORK.get().checked_add(AtomPlanWork { + operation_block_writes: count, + ..AtomPlanWork::default() + })); +} + +fn record_property_application() { + WORK.set(WORK.get().checked_add(AtomPlanWork { + property_applications: 1, + ..AtomPlanWork::default() + })); +} + +impl AtomPlanWork { + pub(crate) fn accumulate(&mut self, other: Self) { + let combined = Self { + sequence_nodes_created: self + .sequence_nodes_created + .checked_add(other.sequence_nodes_created) + .expect("native atom plan node work counter overflow"), + sequence_entries_written: self + .sequence_entries_written + .checked_add(other.sequence_entries_written) + .expect("native atom plan entry work counter overflow"), + materialized_atoms: self + .materialized_atoms + .checked_add(other.materialized_atoms) + .expect("native atom plan materialization counter overflow"), + property_applications: self + .property_applications + .checked_add(other.property_applications) + .expect("native atom plan property work counter overflow"), + atom_clones: self + .atom_clones + .checked_add(other.atom_clones) + .expect("native atom plan clone work counter overflow"), + operation_block_writes: self + .operation_block_writes + .checked_add(other.operation_block_writes) + .expect("native atom plan operation work counter overflow"), + }; + *self = combined; + } + + fn checked_add(mut self, other: Self) -> Self { + self.accumulate(other); + self + } +} + +#[derive(Clone, Copy, Debug, Default)] +struct RunSummary { + prefix: i64, + suffix: i64, + maximum: i64, + all_run: bool, +} + +impl RunSummary { + fn atom(atom: &Atom) -> Self { + if atom.wrap_space() { + Self::default() + } else if atom.wrap_cjk() { + Self { + maximum: atom.width(), + ..Self::default() + } + } else { + Self { + prefix: atom.width(), + suffix: atom.width(), + maximum: atom.width(), + all_run: true, + } + } + } + + fn combine(self, other: Self) -> Self { + let cross = self + .suffix + .checked_add(other.prefix) + .expect("validated native atom widths fit i64"); + Self { + prefix: if self.all_run { + self.prefix + .checked_add(other.prefix) + .expect("validated native atom widths fit i64") + } else { + self.prefix + }, + suffix: if other.all_run { + self.suffix + .checked_add(other.suffix) + .expect("validated native atom widths fit i64") + } else { + other.suffix + }, + maximum: self.maximum.max(other.maximum).max(cross), + all_run: self.all_run && other.all_run, + } + } +} + +#[derive(Clone, Debug, Default)] +struct Summary { + atom_count: usize, + any_owner: bool, + any_content: bool, + noncontent: bool, + whitespace: bool, + first_properties: Option, + runs: RunSummary, +} + +impl Summary { + fn atom(atom: &Atom) -> Self { + let properties = atom.properties(); + Self { + atom_count: 1, + any_owner: properties.owner.is_some() || !properties.owners.is_empty(), + any_content: properties.content.is_some(), + noncontent: !properties.style_ids.is_empty() + || !properties.roles.is_empty() + || !properties.property_template_ids.is_empty(), + whitespace: match atom { + Atom::Text { text, .. } => text.trim().is_empty(), + Atom::Space { .. } => true, + }, + first_properties: Some(properties.clone()), + runs: RunSummary::atom(atom), + } + } + + fn combine(self, other: Self) -> Self { + Self { + atom_count: self + .atom_count + .checked_add(other.atom_count) + .expect("validated native atom count fits usize"), + any_owner: self.any_owner || other.any_owner, + any_content: self.any_content || other.any_content, + noncontent: self.noncontent || other.noncontent, + whitespace: self.whitespace && other.whitespace, + first_properties: self.first_properties.or(other.first_properties), + runs: self.runs.combine(other.runs), + } + } + + fn atoms(atoms: &[Atom]) -> Self { + let first = atoms.first().expect("nonempty native atom block"); + let mut summary = Self { + atom_count: 0, + any_owner: false, + any_content: false, + noncontent: false, + whitespace: true, + first_properties: Some(first.properties().clone()), + runs: RunSummary { + all_run: true, + ..RunSummary::default() + }, + }; + for atom in atoms { + let properties = atom.properties(); + summary.atom_count = summary + .atom_count + .checked_add(1) + .expect("validated native atom count fits usize"); + summary.any_owner |= properties.owner.is_some() || !properties.owners.is_empty(); + summary.any_content |= properties.content.is_some(); + summary.noncontent |= !properties.style_ids.is_empty() + || !properties.roles.is_empty() + || !properties.property_template_ids.is_empty(); + summary.whitespace &= match atom { + Atom::Text { text, .. } => text.trim().is_empty(), + Atom::Space { .. } => true, + }; + summary.runs = summary.runs.combine(RunSummary::atom(atom)); + } + summary + } +} + +#[derive(Clone, Debug)] +enum Decoration { + OwnContent { + region: i64, + index: i64, + had_owner: bool, + had_content: bool, + }, + Style(u32), + Template(u32), + Role(RegionRole, i64), + ScrollWindow(i64), +} + +#[derive(Clone, Debug)] +enum Part { + Atoms(Box<[Atom]>), + Decorated { + input: Arc, + decorations: Box<[Decoration]>, + }, +} + +#[must_use] +#[derive(Debug, Clone)] +pub(super) struct AtomPlan { + parts: Sequence, + summary: Summary, +} + +impl Default for AtomPlan { + fn default() -> Self { + Self { + parts: Sequence::empty(), + summary: Summary { + whitespace: true, + ..Summary::default() + }, + } + } +} + +impl AtomPlan { + pub(super) fn from_atoms(atoms: &[Atom]) -> Self { + record_atom_clones(atoms.len()); + Self::from_owned_atoms(atoms.to_vec()) + } + + pub(super) fn from_owned_atoms(atoms: Vec) -> Self { + if atoms.is_empty() { + return Self::default(); + } + let summary = Summary::atoms(&atoms); + let mut sequence_work = Work::default(); + let mut entries = Vec::with_capacity(atoms.len().div_ceil(16)); + let mut atoms = atoms.into_iter(); + loop { + let chunk = atoms.by_ref().take(16).collect::>(); + if chunk.is_empty() { + break; + } + let measure = atoms_measure(&chunk); + entries.push( + Entry::new(Arc::new(Part::Atoms(chunk.into_boxed_slice())), measure) + .expect("validated native atom measure is nonnegative"), + ); + } + let parts = Sequence::from_entries(entries, &mut sequence_work) + .expect("validated native atom sequence aggregates fit limits"); + record_sequence(sequence_work); + Self { parts, summary } + } + + pub(super) fn is_empty(&self) -> bool { + self.parts.is_empty() + } + + pub(super) fn append(&self, other: &Self) -> Self { + if self.is_empty() { + return other.clone(); + } + if other.is_empty() { + return self.clone(); + } + let mut sequence_work = Work::default(); + let parts = self + .parts + .concat(&other.parts, &mut sequence_work) + .expect("validated native atom sequence aggregates fit limits"); + record_sequence(sequence_work); + Self { + parts, + summary: self.summary.clone().combine(other.summary.clone()), + } + } + + pub(super) fn push(&self, atom: Atom) -> Self { + self.append(&Self::from_single(atom)) + } + + pub(super) fn prepend(&self, atom: Atom) -> Self { + Self::from_single(atom).append(self) + } + + pub(super) fn to_vec(&self) -> Vec { + let mut output = Vec::with_capacity(self.summary.atom_count); + if let Some(root) = self.parts.root() { + materialize_node(root, &mut output); + } + record_materialized(output.len()); + output + } + + pub(super) fn first(&self) -> Option { + first_node(self.parts.root()?) + } + + pub(super) fn whitespace_only(&self) -> bool { + self.summary.whitespace + } + + pub(super) fn has_noncontent_properties(&self) -> bool { + self.summary.noncontent + } + + pub(super) fn own_content(&self, region: i64, index: i64) -> Self { + if self.is_empty() { + return self.clone(); + } + self.decorate(Decoration::OwnContent { + region, + index, + had_owner: self.summary.any_owner, + had_content: self.summary.any_content, + }) + } + + pub(super) fn apply_style(&self, style: u32) -> Self { + self.decorate(Decoration::Style(style)) + } + + pub(super) fn apply_template(&self, template: u32) -> Self { + self.decorate(Decoration::Template(template)) + } + + pub(super) fn apply_role(&self, role: RegionRole, region: i64) -> Self { + self.decorate(Decoration::Role(role, region)) + } + + pub(super) fn apply_scroll_window(&self, region: i64) -> Self { + self.decorate(Decoration::ScrollWindow(region)) + } + + pub(super) fn min_content_width(&self) -> i64 { + self.summary.runs.maximum + } + + pub(super) fn width(&self) -> i64 { + self.parts.measure().width_sum + } + + fn from_single(atom: Atom) -> Self { + let summary = Summary::atom(&atom); + let measure = atom_measure(&atom); + let mut sequence_work = Work::default(); + let parts = Sequence::from_entries( + [Entry::new( + Arc::new(Part::Atoms(vec![atom].into_boxed_slice())), + measure, + ) + .expect("validated native atom measure is nonnegative")], + &mut sequence_work, + ) + .expect("single native atom sequence fits limits"); + record_sequence(sequence_work); + Self { parts, summary } + } + + fn decorate(&self, decoration: Decoration) -> Self { + if self.is_empty() { + return self.clone(); + } + let mut summary = self.summary.clone(); + if let Some(properties) = &mut summary.first_properties { + apply_decoration(properties, &decoration); + } + match &decoration { + Decoration::OwnContent { .. } => { + summary.any_owner = true; + summary.any_content = true; + } + Decoration::Style(_) | Decoration::Template(_) | Decoration::Role(_, _) => { + summary.noncontent = true; + } + Decoration::ScrollWindow(_) => {} + } + let (input, decorations) = self + .parts + .get(0) + .filter(|_| self.parts.len() == 1) + .and_then(|entry| match entry.value().as_ref() { + Part::Decorated { input, decorations } if decorations.len() < 16 => { + let mut combined = decorations.to_vec(); + combined.push(decoration.clone()); + Some((input.clone(), combined.into_boxed_slice())) + } + _ => None, + }) + .unwrap_or_else(|| (Arc::new(self.clone()), vec![decoration].into_boxed_slice())); + record_operation_block_writes(decorations.len()); + let measure = self.parts.measure(); + let mut sequence_work = Work::default(); + let parts = Sequence::from_entries( + [ + Entry::new(Arc::new(Part::Decorated { input, decorations }), measure) + .expect("validated native atom plan measure is nonnegative"), + ], + &mut sequence_work, + ) + .expect("decorated native atom sequence fits limits"); + record_sequence(sequence_work); + Self { parts, summary } + } +} + +fn atom_measure(atom: &Atom) -> Measure { + let chars = match atom { + Atom::Text { text, .. } => text.chars().count() as u64, + Atom::Space { .. } => 1, + }; + Measure::new(chars, 0, atom.width(), atom.width()) + .expect("validated native atom width is nonnegative") +} + +fn atoms_measure(atoms: &[Atom]) -> Measure { + atoms.iter().fold(Measure::default(), |measure, atom| { + measure + .checked_combine(atom_measure(atom)) + .expect("validated native atom measures fit limits") + }) +} + +fn apply_decoration(properties: &mut AtomProperties, decoration: &Decoration) { + record_property_application(); + match decoration { + Decoration::OwnContent { + region, + index, + had_owner, + had_content, + } => { + if *had_owner { + let mut owners = properties.owners.clone(); + if let Some(owner) = properties.owner { + if !owners.contains(&owner) { + owners.push(owner); + } + } + if !owners.contains(region) { + owners.push(*region); + } + properties.owner = Some(*region); + properties.owners = owners; + } else { + properties.owner = Some(*region); + properties.owners = vec![*region]; + } + if !had_content { + properties.content = Some(*region); + properties.content_idx = Some(*index); + } + } + Decoration::Style(style) => properties.style_ids.push(*style), + Decoration::Template(template) => properties.property_template_ids.push(*template), + Decoration::Role(role, region) => properties.roles.push(RegionRoleEntry { + role: *role, + region_id: *region, + }), + Decoration::ScrollWindow(region) => properties.scroll_window = Some(*region), + } +} + +fn decorate_atom(mut atom: Atom, decoration: &Decoration) -> Atom { + apply_decoration(atom.properties_mut(), decoration); + atom +} + +fn materialize_node(node: &Arc>, output: &mut Vec) { + match node.view() { + NodeView::Leaf { entries, .. } => { + for entry in entries { + match entry.value().as_ref() { + Part::Atoms(atoms) => output.extend(atoms.iter().cloned()), + Part::Decorated { input, decorations } => { + let start = output.len(); + if let Some(root) = input.parts.root() { + materialize_node(root, output); + } + for atom in &mut output[start..] { + for decoration in decorations { + apply_decoration(atom.properties_mut(), decoration); + } + } + } + } + } + } + NodeView::Branch { left, right, .. } => { + materialize_node(left, output); + materialize_node(right, output); + } + } +} + +fn first_node(node: &Arc>) -> Option { + match node.view() { + NodeView::Leaf { entries, .. } => match entries.first()?.value().as_ref() { + Part::Atoms(atoms) => { + record_materialized(1); + Some(atoms.first()?.clone()) + } + Part::Decorated { input, decorations } => input + .first() + .map(|atom| decorations.iter().fold(atom, decorate_atom)), + }, + NodeView::Branch { left, .. } => first_node(left), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn text(value: &str, width: i64, cjk: bool, space: bool) -> Atom { + Atom::Text { + text: value.to_owned(), + width, + cjk, + space, + properties: AtomProperties::default(), + } + } + + #[test] + fn decoration_is_lazy_ordered_and_new_padding_is_not_retroactive() { + reset_work(); + let content = AtomPlan::from_atoms(&[text("x", 1, false, false)]) + .apply_style(3) + .own_content(7, 2); + let plan = content.prepend(Atom::Space { + width: 4, + properties: AtomProperties::default(), + }); + assert_eq!(work().materialized_atoms, 0); + let atoms = plan.to_vec(); + assert!(atoms[0].properties().style_ids.is_empty()); + assert_eq!(atoms[1].properties().style_ids, vec![3]); + assert_eq!(atoms[1].properties().content, Some(7)); + assert_eq!(atoms[1].properties().content_idx, Some(2)); + assert_eq!(work().materialized_atoms, 2); + } + + #[test] + fn own_content_uses_whole_line_owner_and_content_branches() { + let mut owned = text("a", 1, false, false); + owned.properties_mut().owner = Some(5); + owned.properties_mut().owners = vec![5]; + owned.properties_mut().content = Some(4); + let plan = AtomPlan::from_atoms(&[owned, text("b", 1, false, false)]).own_content(9, 3); + let atoms = plan.to_vec(); + assert_eq!(atoms[0].properties().content, Some(4)); + assert_eq!(atoms[1].properties().content, None); + assert_eq!(atoms[0].properties().owner, Some(9)); + assert_eq!(atoms[0].properties().owners, vec![5, 9]); + assert_eq!(atoms[1].properties().owner, Some(9)); + assert_eq!(atoms[1].properties().owners, vec![9]); + } + + #[test] + fn min_content_summary_matches_space_and_cjk_breaks() { + let plan = AtomPlan::from_atoms(&[ + text("ab", 2, false, false), + text(" ", 1, false, true), + text("界", 4, true, false), + text("cd", 3, false, false), + ]); + assert_eq!(plan.min_content_width(), 4); + assert!(!plan.whitespace_only()); + assert!(!plan.has_noncontent_properties()); + assert!(!plan.is_empty()); + assert!(AtomPlan::default().first().is_none()); + } + + #[test] + fn append_shares_parts_and_materializes_once() { + reset_work(); + let left = AtomPlan::from_atoms(&[text("a", 1, false, false)]); + let right = AtomPlan::from_owned_atoms(vec![text("b", 2, false, false)]); + let plan = left.append(&right).apply_template(2).apply_scroll_window(8); + assert_eq!(plan.width(), 3); + assert_eq!(work().materialized_atoms, 0); + let atoms = plan.to_vec(); + assert_eq!(atoms.len(), 2); + assert_eq!(atoms[0].properties().property_template_ids, vec![2]); + assert_eq!(atoms[1].properties().scroll_window, Some(8)); + } + + #[test] + fn wide_append_and_decorations_do_no_atom_work_until_materialized() { + for size in [32, 128, 512, 8192] { + let atoms = (0..size) + .map(|_| text("x", 1, false, false)) + .collect::>(); + let plan = AtomPlan::from_atoms(&atoms); + reset_work(); + let combined = plan + .append(&plan) + .own_content(7, 0) + .apply_style(1) + .apply_template(2) + .apply_role(RegionRole::PaddingLeft, 7) + .apply_scroll_window(7); + let lazy = work(); + assert_eq!(lazy.materialized_atoms, 0); + assert_eq!(lazy.sequence_nodes_created, 6); + assert!(lazy.sequence_entries_written <= 21, "{lazy:?}"); + assert_eq!(lazy.property_applications, 5); + assert_eq!(lazy.atom_clones, 0); + assert_eq!(lazy.operation_block_writes, 15); + + let materialized = combined.to_vec(); + assert_eq!(materialized.len(), size * 2); + let eager = work(); + assert_eq!(eager.materialized_atoms, (size * 2) as u64); + assert_eq!(eager.property_applications, 5 + (size * 2 * 5) as u64); + } + } + + #[test] + fn min_content_randomized_summary_matches_vec_oracle() { + fn oracle(atoms: &[Atom]) -> i64 { + let mut maximum = 0; + let mut run = 0_i64; + for atom in atoms { + if atom.wrap_space() { + maximum = maximum.max(run); + run = 0; + } else if atom.wrap_cjk() { + maximum = maximum.max(run).max(atom.width()); + run = 0; + } else { + run += atom.width(); + } + } + maximum.max(run) + } + + let mut seed = 0x5e91_7ac3_u64; + for size in 0..257 { + let mut atoms = Vec::with_capacity(size); + for _ in 0..size { + seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1); + let width = ((seed >> 32) % 8 + 1) as i64; + atoms.push(match seed % 5 { + 0 => text(" ", width, false, true), + 1 => text("界", width, true, false), + _ => text("x", width, false, false), + }); + } + let split = size / 3; + let plan = AtomPlan::default() + .append(&AtomPlan::from_atoms(&atoms[..split])) + .append(&AtomPlan::default()) + .append(&AtomPlan::from_atoms(&atoms[split..])); + assert_eq!(plan.min_content_width(), oracle(&atoms)); + assert_eq!(plan.is_empty(), atoms.is_empty()); + } + } + + #[test] + fn first_counts_one_atom_and_each_lazy_property_application() { + let plan = AtomPlan::from_atoms(&[text("a", 1, false, false), text("b", 1, false, false)]) + .apply_style(3) + .apply_template(4); + reset_work(); + let first = plan.first().unwrap(); + assert_eq!(first.properties().style_ids, vec![3]); + assert_eq!(first.properties().property_template_ids, vec![4]); + assert_eq!(work().materialized_atoms, 1); + assert_eq!(work().property_applications, 2); + assert_eq!(work().atom_clones, 1); + } + + #[test] + fn decoration_blocks_are_bounded_ordered_and_keep_old_fork() { + let base = AtomPlan::from_owned_atoms(vec![text("x", 1, false, false)]); + let fork = base.clone(); + let old_root = Arc::clone(base.parts.root().unwrap()); + reset_work(); + let decorated = (0..33).fold(base, |plan, style| plan.apply_style(style)); + assert!(Arc::ptr_eq(fork.parts.root().unwrap(), &old_root)); + assert_eq!(work().materialized_atoms, 0); + assert_eq!(work().operation_block_writes, 273); + + let mut block_lengths = Vec::new(); + let mut current = &decorated; + loop { + let part = current.parts.get(0).unwrap().value().as_ref(); + match part { + Part::Decorated { input, decorations } => { + block_lengths.push(decorations.len()); + current = input; + } + Part::Atoms(_) => break, + } + } + assert_eq!(block_lengths, vec![1, 16, 16]); + assert_eq!( + decorated.to_vec()[0].properties().style_ids, + (0..33).collect::>() + ); + assert!(fork.to_vec()[0].properties().style_ids.is_empty()); + } + + #[test] + fn wide_owned_bootstrap_writes_one_sequence_entry_per_atom_block() { + let atoms = (0..8192) + .map(|_| text("x", 1, false, false)) + .collect::>(); + reset_work(); + let plan = AtomPlan::from_owned_atoms(atoms); + let bootstrap = work(); + assert_eq!(bootstrap.atom_clones, 0); + assert_eq!(bootstrap.sequence_entries_written, 512); + assert_eq!(bootstrap.sequence_nodes_created, 63); + assert_eq!(plan.width(), 8192); + assert_eq!(plan.summary.atom_count, 8192); + } +} diff --git a/native/src/layout.rs b/native/src/layout.rs index 2f40df7..74aa0a1 100644 --- a/native/src/layout.rs +++ b/native/src/layout.rs @@ -6,6 +6,19 @@ use std::collections::BTreeMap; use std::collections::HashSet; use std::sync::Arc; +#[path = "atom_plan.rs"] +mod atom_plan; +use atom_plan::AtomPlan; +pub(crate) use atom_plan::AtomPlanWork; + +pub(crate) fn reset_atom_plan_work() { + atom_plan::reset_work(); +} + +pub(crate) fn atom_plan_work() -> AtomPlanWork { + atom_plan::work() +} + fn deserialize_arc_vec<'de, D, T>(deserializer: D) -> Result>, D::Error> where D: Deserializer<'de>, @@ -131,7 +144,18 @@ struct RetainedEntry { } #[derive(Debug, Clone, Default)] -struct LocalWorkSummary(BTreeMap<&'static str, usize>); +struct LocalWorkSummary(BTreeMap<&'static str, LocalFieldWork>); + +#[derive(Debug, Clone)] +struct LocalFieldWork { + units: usize, + viewport_heights: usize, +} + +struct LocalWorkDelta { + units: isize, + viewport_heights: i128, +} #[derive(Debug)] struct RadixNode { @@ -200,6 +224,7 @@ pub(crate) struct RetainedDocument { style_count: u32, property_template_count: u32, work_units: usize, + context_viewport_heights: usize, } #[derive(Debug)] @@ -319,6 +344,7 @@ impl RetainedDocument { entries: &mut Arc, seen: &mut HashSet, count: &mut u64, + context_viewport_heights: &mut usize, ) -> Result { fn localize( node: &mut LayoutNode, @@ -326,6 +352,7 @@ impl RetainedDocument { entries: &mut Arc, seen: &mut HashSet, count: &mut u64, + context_viewport_heights: &mut usize, ) -> Result<(), String> { fn localize_child( child: LayoutNode, @@ -333,22 +360,46 @@ impl RetainedDocument { entries: &mut Arc, seen: &mut HashSet, count: &mut u64, + context_viewport_heights: &mut usize, ) -> Result { if child.node_id().is_some() { - let installed = install_owner(child, entries, seen, count)?; + let installed = + install_owner(child, entries, seen, count, context_viewport_heights)?; return Ok(LayoutNode::NodeRef { node_id: installed }); } let mut child = child; - localize(&mut child, owner_id, entries, seen, count)?; + localize( + &mut child, + owner_id, + entries, + seen, + count, + context_viewport_heights, + )?; Ok(child) } + // Localization visits every input node once, including anonymous + // descendants that cannot be named by a later topology-preserving delta. + visit_context_height_fields(node, |_, size| { + *context_viewport_heights = context_viewport_heights + .checked_add(context_size_occurrences(size)?) + .ok_or_else(|| { + "Native retained context summary invariant failed".to_owned() + })?; + Ok(()) + })?; match node { LayoutNode::Box { child, .. } => { if let Some(old) = child.take() { let old = Arc::try_unwrap(old).unwrap_or_else(|value| (*value).clone()); *child = Some(Arc::new(localize_child( - old, owner_id, entries, seen, count, + old, + owner_id, + entries, + seen, + count, + context_viewport_heights, )?)); } } @@ -357,7 +408,14 @@ impl RetainedDocument { let old = Arc::try_unwrap(old).unwrap_or_else(|value| (*value).clone()); let mut localized = Vec::with_capacity(old.len()); for child in old { - localized.push(localize_child(child, owner_id, entries, seen, count)?); + localized.push(localize_child( + child, + owner_id, + entries, + seen, + count, + context_viewport_heights, + )?); } *children = Arc::new(localized); } @@ -366,7 +424,14 @@ impl RetainedDocument { let old = Arc::try_unwrap(old).unwrap_or_else(|value| (*value).clone()); let mut localized = Vec::with_capacity(old.len()); for mut item in old { - item.node = localize_child(item.node, owner_id, entries, seen, count)?; + item.node = localize_child( + item.node, + owner_id, + entries, + seen, + count, + context_viewport_heights, + )?; localized.push(item); } *items = Arc::new(localized); @@ -390,7 +455,14 @@ impl RetainedDocument { if node_id == 0 || !seen.insert(node_id) { return Err("Native retained document has invalid duplicate node id".to_owned()); } - localize(&mut node, node_id, entries, seen, count)?; + localize( + &mut node, + node_id, + entries, + seen, + count, + context_viewport_heights, + )?; let slot_one = match &node { LayoutNode::Box { child: Some(child), .. @@ -411,7 +483,14 @@ impl RetainedDocument { let mut entries = Arc::new(RadixNode::default()); let mut seen = HashSet::new(); let mut owner_count = 0; - let root_id = install_owner(document.root, &mut entries, &mut seen, &mut owner_count)?; + let mut context_viewport_heights = 0; + let root_id = install_owner( + document.root, + &mut entries, + &mut seen, + &mut owner_count, + &mut context_viewport_heights, + )?; debug_assert!(usize::try_from(owner_count).is_ok_and(|count| count <= node_count)); let mut styles = Arc::new(StyleRadixNode::default()); if document.styles.len() != document.style_count as usize { @@ -429,6 +508,7 @@ impl RetainedDocument { style_count: document.style_count, property_template_count: document.property_template_count, work_units, + context_viewport_heights, }), node_count as u64, )) @@ -484,6 +564,7 @@ impl RetainedDocument { let mut seen = HashSet::with_capacity(delta.entries.len()); let mut prepared = Vec::with_capacity(delta.entries.len()); let mut work_delta_total = 0_i128; + let mut context_delta_total = 0_i128; for update in delta.entries { if !seen.insert(update.node_id) { return Err("Native retained delta contains a duplicate node id".to_owned()); @@ -506,8 +587,11 @@ impl RetainedDocument { delta.property_template_target_count, )?; work_delta_total = work_delta_total - .checked_add(work_delta as i128) + .checked_add(work_delta.units as i128) .ok_or_else(|| "Native layout work estimate overflowed".to_owned())?; + context_delta_total = context_delta_total + .checked_add(work_delta.viewport_heights) + .ok_or_else(|| "Native retained context summary invariant failed".to_owned())?; prepared.push(( update.node_id, Arc::new(RetainedEntry { @@ -545,6 +629,10 @@ impl RetainedDocument { if work_units > MAX_LAYOUT_WORK_UNITS { return Err("Native layout exceeds the work-unit limit".to_owned()); } + let context_viewport_heights = (self.context_viewport_heights as i128) + .checked_add(context_delta_total) + .and_then(|count| usize::try_from(count).ok()) + .ok_or_else(|| "Native retained context summary invariant failed".to_owned())?; let parsed = prepared.len() as u64; let mut entries = Arc::clone(&self.entries); let mut copied = 0; @@ -568,6 +656,7 @@ impl RetainedDocument { style_count: target_style_count, property_template_count: delta.property_template_target_count, work_units, + context_viewport_heights, }), parsed, copied, @@ -581,10 +670,11 @@ fn patch_owner_slots( patches: &[LocalSlotPatch], style_count: u32, property_template_count: u32, -) -> Result<(LayoutNode, [Arc; 2], isize), String> { +) -> Result<(LayoutNode, [Arc; 2], LocalWorkDelta), String> { let mut result = base.clone(); let mut slot_work = base_work.clone(); let mut work_delta = 0_isize; + let mut context_delta = 0_i128; let mut seen = [false; 2]; for patch in patches { let index = patch.slot as usize; @@ -596,6 +686,8 @@ fn patch_owner_slots( result = patch_local_node(&result, &patch.local)?; validate_changed_fields(&result, &patch.local, style_count, property_template_count)?; let updated = slot_work[0].updated(&result, &patch.local)?; + context_delta += updated.changed_context_occurrences(&patch.local) + - slot_work[0].changed_context_occurrences(&patch.local); let new_work = updated.changed_work(&patch.local); work_delta += new_work as isize - old_work as isize; slot_work[0] = Arc::new(updated); @@ -618,13 +710,22 @@ fn patch_owner_slots( property_template_count, )?; let updated = slot_work[1].updated(&new_child, &patch.local)?; + context_delta += updated.changed_context_occurrences(&patch.local) + - slot_work[1].changed_context_occurrences(&patch.local); let new_work = updated.changed_work(&patch.local); work_delta += new_work as isize - old_work as isize; slot_work[1] = Arc::new(updated); *child = Some(Arc::new(new_child)); } } - Ok((result, slot_work, work_delta)) + Ok(( + result, + slot_work, + LocalWorkDelta { + units: work_delta, + viewport_heights: context_delta, + }, + )) } fn patch_local_node( @@ -804,6 +905,28 @@ fn size_work(size: &Size) -> Result { Ok(work) } +fn context_size_occurrences(size: &Size) -> Result { + let mut count = 0_usize; + visit_context_size_occurrences(size, &mut || { + count = count + .checked_add(1) + .ok_or_else(|| "Native retained context summary invariant failed".to_owned())?; + Ok(()) + })?; + Ok(count) +} + +fn context_field_occurrences(node: &LayoutNode, name: &str) -> Result { + let mut count = 0; + visit_context_height_fields(node, |field, size| { + if field == name { + count = context_size_occurrences(size)?; + } + Ok(()) + })?; + Ok(count) +} + fn field_work(node: &LayoutNode, name: &str) -> Result, String> { Ok(match (node, name) { (LayoutNode::Box { content, .. }, "content") => { @@ -854,7 +977,13 @@ fn local_work_summary(node: &LayoutNode) -> Result { let mut values = BTreeMap::new(); for name in FIELDS { if let Some(value) = field_work(node, name)? { - values.insert(name, value); + values.insert( + name, + LocalFieldWork { + units: value, + viewport_heights: context_field_occurrences(node, name)?, + }, + ); } } Ok(LocalWorkSummary(values)) @@ -865,7 +994,15 @@ impl LocalWorkSummary { fields .keys() .filter_map(|name| self.0.get(name.as_str())) - .copied() + .map(|work| work.units) + .sum() + } + + fn changed_context_occurrences(&self, fields: &JsonMap) -> i128 { + fields + .keys() + .filter_map(|name| self.0.get(name.as_str())) + .map(|work| work.viewport_heights as i128) .sum() } @@ -890,7 +1027,10 @@ impl LocalWorkSummary { "row-gap" => "row-gap", _ => continue, }, - value, + LocalFieldWork { + units: value, + viewport_heights: context_field_occurrences(node, name)?, + }, ); } } @@ -1504,23 +1644,27 @@ enum Atom { #[derive(Debug, Clone, Default)] struct Line { - atoms: Vec, + atoms: AtomPlan, width: i64, } impl Line { fn from_clusters(clusters: &[MeasuredCluster]) -> Self { - let mut line = Self::default(); + let mut atoms = Vec::with_capacity(clusters.len()); for cluster in clusters { let properties = AtomProperties { property_template_ids: cluster.source_template_id.into_iter().collect(), ..AtomProperties::default() }; if cluster.pixel_space { - line.push_space_with_properties(cluster.width, properties); + if cluster.width > 0 { + atoms.push(Atom::Space { + width: cluster.width, + properties, + }); + } } else { - line.width += cluster.width; - line.atoms.push(Atom::Text { + atoms.push(Atom::Text { text: cluster.text.clone(), width: cluster.width, cjk: cluster.cjk, @@ -1529,13 +1673,18 @@ impl Line { }); } } - line + let atoms = AtomPlan::from_owned_atoms(atoms); + Self { + width: atoms.width(), + atoms, + } } fn from_atoms(atoms: &[Atom]) -> Self { + let atoms = AtomPlan::from_atoms(atoms); Self { - atoms: atoms.to_vec(), - width: atoms.iter().map(Atom::width).sum(), + width: atoms.width(), + atoms, } } @@ -1548,7 +1697,7 @@ impl Line { return; } self.width += width; - self.atoms.push(Atom::Space { width, properties }); + self.atoms = self.atoms.push(Atom::Space { width, properties }); } fn prepend_space(&mut self, width: i64) { @@ -1560,12 +1709,12 @@ impl Line { return; } self.width += width; - self.atoms.insert(0, Atom::Space { width, properties }); + self.atoms = self.atoms.prepend(Atom::Space { width, properties }); } fn append(&mut self, other: &Self) { self.width += other.width; - self.atoms.extend(other.atoms.iter().cloned()); + self.atoms = self.atoms.append(&other.atoms); } fn padded(mut self, target: i64, align: HorizontalAlign) -> Self { @@ -1593,63 +1742,23 @@ impl Line { } fn whitespace_only(&self) -> bool { - self.atoms.iter().all(|atom| match atom { - Atom::Space { .. } => true, - Atom::Text { text, .. } => text.trim().is_empty(), - }) + self.atoms.whitespace_only() } fn has_noncontent_properties(&self) -> bool { - self.atoms.iter().any(|atom| { - let properties = atom.properties(); - !properties.style_ids.is_empty() - || !properties.roles.is_empty() - || !properties.property_template_ids.is_empty() - }) + self.atoms.has_noncontent_properties() } fn own_content(&mut self, region_id: i64, content_idx: i64) { - let has_owner = self - .atoms - .iter() - .any(|atom| atom.properties().owner.is_some() || !atom.properties().owners.is_empty()); - for atom in &mut self.atoms { - let properties = atom.properties_mut(); - if has_owner { - let mut owners = properties.owners.clone(); - if let Some(owner) = properties.owner { - if !owners.contains(&owner) { - owners.push(owner); - } - } - if !owners.contains(®ion_id) { - owners.push(region_id); - } - properties.owner = Some(region_id); - properties.owners = owners; - } else { - properties.owner = Some(region_id); - properties.owners = vec![region_id]; - } - } - let has_content = self - .atoms - .iter() - .any(|atom| atom.properties().content.is_some()); - if !has_content { - for atom in &mut self.atoms { - let properties = atom.properties_mut(); - properties.content = Some(region_id); - properties.content_idx = Some(content_idx); - } - } + self.atoms = self.atoms.own_content(region_id, content_idx); } fn collapse_whitespace_content(self, width: i64, region_id: i64) -> Self { if !self.whitespace_only() || self.has_noncontent_properties() { return self; } - let first = self.atoms.first().map(Atom::properties); + let first_atom = self.atoms.first(); + let first = first_atom.as_ref().map(Atom::properties); let mut properties = AtomProperties { content: first .and_then(|properties| properties.content) @@ -1670,28 +1779,18 @@ impl Line { fn apply_style(&mut self, style_id: Option) { if let Some(style_id) = style_id { - for atom in &mut self.atoms { - atom.properties_mut().style_ids.push(style_id); - } + self.atoms = self.atoms.apply_style(style_id); } } fn apply_property_template(&mut self, template_id: Option) { if let Some(template_id) = template_id { - for atom in &mut self.atoms { - atom.properties_mut() - .property_template_ids - .push(template_id); - } + self.atoms = self.atoms.apply_template(template_id); } } fn apply_role(&mut self, role: RegionRole, region_id: i64) { - for atom in &mut self.atoms { - atom.properties_mut() - .roles - .push(RegionRoleEntry { role, region_id }); - } + self.atoms = self.atoms.apply_role(role, region_id); } } @@ -1760,22 +1859,7 @@ impl Rendered { } self.lines .iter() - .map(|line| { - let mut maximum = 0; - let mut run = 0; - for atom in &line.atoms { - if atom.wrap_space() { - maximum = maximum.max(run); - run = 0; - } else if atom.wrap_cjk() { - maximum = maximum.max(run).max(atom.width()); - run = 0; - } else { - run += atom.width(); - } - } - maximum.max(run) - }) + .map(|line| line.atoms.min_content_width()) .max() .unwrap_or(0) } @@ -1786,9 +1870,7 @@ impl Rendered { fn apply_scroll_window(&mut self, region_id: i64) { for line in &mut self.lines { - for atom in &mut line.atoms { - atom.properties_mut().scroll_window = Some(region_id); - } + line.atoms = line.atoms.apply_scroll_window(region_id); } for properties in &mut self.breaks { properties.scroll_window = Some(region_id); @@ -1808,6 +1890,7 @@ impl Rendered { width: line.width, atoms: line .atoms + .to_vec() .into_iter() .map(|atom| match atom { Atom::Text { @@ -4027,51 +4110,19 @@ impl LayoutDocument { impl RetainedDocument { pub(crate) fn validate_context(&self, context: LayoutContext) -> Result<(), String> { - fn add_retained_context_work( - node: &LayoutNode, - document: &RetainedDocument, - context: LayoutContext, - work_units: &mut usize, - ) -> Result<(), String> { - let node = document.resolve(node)?; - match node { - LayoutNode::NodeRef { .. } => unreachable!("resolver returned a node reference"), - LayoutNode::Text { .. } => {} - LayoutNode::Box { - child, - height, - min_height, - max_height, - .. - } => { - for size in [height, min_height, max_height] { - add_context_size_work(size, context, work_units)?; - } - if let Some(child) = child { - add_retained_context_work(child, document, context, work_units)?; - } - } - LayoutNode::Row { children, .. } | LayoutNode::Column { children, .. } => { - for child in children.iter() { - add_retained_context_work(child, document, context, work_units)?; - } - } - LayoutNode::Flex { height, items, .. } => { - add_context_size_work(height, context, work_units)?; - for item in items.iter() { - add_retained_context_work(&item.node, document, context, work_units)?; - } - } - } + // Bootstrap establishes the root/reference invariant and deltas cannot + // change topology. The full traversal adds the same nonnegative amount + // for every occurrence, stopping at the work limit before a later add + // could overflow. Division preserves that error without multiplying. + if self.context_viewport_heights == 0 { + return Ok(()); + } + let height = usize::try_from(context.viewport_height).unwrap_or(usize::MAX); + if height > MAX_LAYOUT_WORK_UNITS / self.context_viewport_heights { + Err("Native layout exceeds the work-unit limit".to_owned()) + } else { Ok(()) } - - let root = self - .effective_node(self.root_id) - .ok_or_else(|| "Native retained document lost its root".to_owned())?; - RESOLVER_LOOKUP_COUNT.with(|count| count.set(count.get().saturating_add(1))); - let mut work_units = 0; - add_retained_context_work(root, self, context, &mut work_units) } pub(crate) fn layout_tape( @@ -4145,22 +4196,17 @@ fn add_vertical_size_work(size: &Size, work_units: &mut usize) -> Result<(), Str } } -fn add_context_size_work( +fn visit_context_size_occurrences( size: &Size, - context: LayoutContext, - work_units: &mut usize, + visit: &mut impl FnMut() -> Result<(), String>, ) -> Result<(), String> { + // Validation work adds every operand, including subtraction operands. match size { - Size::ViewportHeight => add_work_units( - work_units, - usize::try_from(context.viewport_height).unwrap_or(usize::MAX), - ), - Size::FitContent { limit: Some(limit) } => { - add_context_size_work(limit, context, work_units) - } + Size::ViewportHeight => visit(), + Size::FitContent { limit: Some(limit) } => visit_context_size_occurrences(limit, visit), Size::Add { values } | Size::Subtract { values } => { for value in &values.0 { - add_context_size_work(value, context, work_units)?; + visit_context_size_occurrences(value, visit)?; } Ok(()) } @@ -4168,26 +4214,58 @@ fn add_context_size_work( } } -fn add_context_work( - node: &LayoutNode, +fn add_context_size_work( + size: &Size, context: LayoutContext, work_units: &mut usize, +) -> Result<(), String> { + visit_context_size_occurrences(size, &mut || { + add_work_units( + work_units, + usize::try_from(context.viewport_height).unwrap_or(usize::MAX), + ) + }) +} + +fn visit_context_height_fields( + node: &LayoutNode, + mut visit: impl FnMut(&'static str, &Size) -> Result<(), String>, ) -> Result<(), String> { match node { - LayoutNode::NodeRef { .. } => { - return Err("Native full layout cannot contain a node reference".to_owned()); - } - LayoutNode::Text { .. } => {} LayoutNode::Box { - child, height, min_height, max_height, .. } => { - for size in [height, min_height, max_height] { - add_context_size_work(size, context, work_units)?; + for (name, size) in [ + ("height", height), + ("min-height", min_height), + ("max-height", max_height), + ] { + visit(name, size)?; } + } + LayoutNode::Flex { height, .. } => visit("height", height)?, + _ => {} + } + Ok(()) +} + +fn add_context_work( + node: &LayoutNode, + context: LayoutContext, + work_units: &mut usize, +) -> Result<(), String> { + visit_context_height_fields(node, |_, size| { + add_context_size_work(size, context, work_units) + })?; + match node { + LayoutNode::NodeRef { .. } => { + return Err("Native full layout cannot contain a node reference".to_owned()); + } + LayoutNode::Text { .. } => {} + LayoutNode::Box { child, .. } => { if let Some(child) = child { add_context_work(child, context, work_units)?; } @@ -4197,8 +4275,7 @@ fn add_context_work( add_context_work(child, context, work_units)?; } } - LayoutNode::Flex { height, items, .. } => { - add_context_size_work(height, context, work_units)?; + LayoutNode::Flex { items, .. } => { for item in items.iter() { add_context_work(&item.node, context, work_units)?; } @@ -4952,7 +5029,9 @@ fn wrap_rendered_line(line: &Line, max_width: i64, mode: WrapMode) -> Vec return vec![line.clone()]; } - let atoms = &line.atoms; + // Wrapping inspects the actual atom boundaries. Composition and decoration + // preserve the plan; only this geometry-dependent operation materializes it. + let atoms = line.atoms.to_vec(); let count = atoms.len(); let mut ranges = Vec::new(); let mut line_start = 0; @@ -7129,6 +7208,94 @@ fn collect_column_leaves<'a>(children: &'a [LayoutNode], output: &mut Vec<&'a La mod tests { use super::*; + #[test] + fn line_composition_preserves_mixed_ownership_and_decoration_order() { + let mut child = Line::from_clusters(&[cluster("x", 2, Some(7))]); + child.own_content(11, 3); + child.apply_style(Some(2)); + let original = Rendered::from_lines(vec![child.clone()]).into_tape(10); + let mut parent = Line::blank(4); + parent.append(&child); + parent.own_content(22, 9); + parent.apply_style(Some(5)); + parent.apply_property_template(Some(8)); + parent.apply_role(RegionRole::BorderTop, 22); + parent.push_space(6); + let tape = Rendered::from_lines(vec![parent]).into_tape(10); + assert_eq!(tape.lines[0].width, 12); + let TapeAtom::Space { + properties: left, .. + } = &tape.lines[0].atoms[0] + else { + panic!() + }; + assert_eq!(left.owner, Some(22)); + assert_eq!(left.owners, vec![22]); + assert_eq!( + left.content, None, + "a sibling's content prevents assigning padding content" + ); + assert_eq!(left.style_ids, vec![5]); + let TapeAtom::Text { + properties: text, .. + } = &tape.lines[0].atoms[1] + else { + panic!() + }; + assert_eq!(text.owner, Some(22)); + assert_eq!(text.owners, vec![11, 22]); + assert_eq!(text.content, Some(11)); + assert_eq!(text.content_idx, Some(3)); + assert_eq!(text.style_ids, vec![2, 5]); + assert_eq!(text.property_template_ids, vec![7, 8]); + assert_eq!( + text.roles, + vec![RegionRoleEntry { + role: RegionRole::BorderTop, + region_id: 22 + }] + ); + let TapeAtom::Space { + properties: right, .. + } = &tape.lines[0].atoms[2] + else { + panic!() + }; + assert_eq!( + right, + &AtomProperties::default(), + "later padding stays outside prior decorations" + ); + assert_eq!( + Rendered::from_lines(vec![child]).into_tape(10), + original, + "composing a parent must preserve its reusable child" + ); + } + + #[test] + fn line_whitespace_collapse_preserves_property_boundaries() { + let mut plain = Line::blank(5); + plain.own_content(10, 4); + let collapsed = + Rendered::from_lines(vec![plain.collapse_whitespace_content(7, 20)]).into_tape(0); + let TapeAtom::Space { width, properties } = &collapsed.lines[0].atoms[0] else { + panic!() + }; + assert_eq!(*width, 7); + assert_eq!(properties.content, Some(10)); + assert_eq!(properties.content_idx, Some(4)); + assert_eq!(properties.owner, Some(20)); + assert!(properties.owners.is_empty()); + let mut styled = Line::blank(5); + styled.apply_property_template(Some(3)); + let before = Rendered::from_lines(vec![styled.clone()]).into_tape(0); + assert_eq!( + Rendered::from_lines(vec![styled.collapse_whitespace_content(7, 20)]).into_tape(0), + before + ); + } + fn counted_layout_tape( document: &LayoutDocument, context: LayoutContext, @@ -7617,6 +7784,7 @@ mod tests { .iter() .map(|line| { line.atoms + .to_vec() .iter() .map(|atom| match atom { Atom::Text { text, .. } => text.as_str(), @@ -8057,6 +8225,7 @@ mod tests { .all(|properties| { properties.scroll_window == Some(9) })); assert!(stacked.lines.iter().all(|line| { line.atoms + .to_vec() .iter() .all(|atom| atom.properties().scroll_window == Some(9)) })); @@ -8825,6 +8994,423 @@ mod tests { assert!(fragment_style_delta(&old, &changed).is_none()); } + // Reconstruct the full document so its original context traversal remains an + // independent oracle for summaries, including after retained local patches. + fn retained_context_full_document(document: &RetainedDocument) -> LayoutDocument { + fn expand(node: &LayoutNode, document: &RetainedDocument) -> LayoutNode { + let mut node = document.resolve(node).unwrap().clone(); + match &mut node { + LayoutNode::Box { child, .. } => { + *child = child + .as_deref() + .map(|child| Arc::new(expand(child, document))); + } + LayoutNode::Row { children, .. } | LayoutNode::Column { children, .. } => { + *children = + Arc::new(children.iter().map(|node| expand(node, document)).collect()); + } + LayoutNode::Flex { items, .. } => { + *items = Arc::new( + items + .iter() + .map(|item| { + let mut item = item.clone(); + item.node = expand(&item.node, document); + item + }) + .collect(), + ); + } + LayoutNode::Text { .. } => {} + LayoutNode::NodeRef { .. } => unreachable!(), + } + node + } + retained_document(expand( + &LayoutNode::NodeRef { + node_id: document.root_id, + }, + document, + )) + } + + fn assert_retained_context_oracle(document: &RetainedDocument, occurrences: usize) { + assert_eq!(document.context_viewport_heights, occurrences); + let full = retained_context_full_document(document); + let boundary = MAX_LAYOUT_WORK_UNITS / occurrences.max(1); + for height in [ + i64::MIN, + -1, + 0, + 1, + boundary as i64, + boundary as i64 + 1, + MAX_LAYOUT_WORK_UNITS as i64, + MAX_LAYOUT_WORK_UNITS as i64 + 1, + MAX_LAYOUT_DIMENSION, + i64::MAX, + ] { + let context = LayoutContext { + viewport_height: height, + ..test_context() + }; + let expected = full.validate_context(context); + assert_eq!( + document.validate_context(context), + expected, + "height {height}" + ); + let accepted = occurrences == 0 || height >= 0 && height <= boundary as i64; + assert_eq!( + expected.is_ok(), + accepted, + "{occurrences} occurrences at height {height}" + ); + if !accepted { + assert_eq!( + expected.unwrap_err(), + "Native layout exceeds the work-unit limit" + ); + } + } + } + + fn context_test_delta(entries: Vec) -> DocumentDelta { + serde_json::from_value(serde_json::json!({ + "style-base-count": 0, + "styles-append": [], + "property-template-base-count": 0, + "property-template-target-count": 0, + "entries": entries + })) + .unwrap() + } + + fn context_test_flex(height: Size, nodes: Vec) -> LayoutNode { + LayoutNode::Flex { + node_id: None, + node_revision: None, + direction: FlexDirection::Column, + wrap: FlexWrap::Nowrap, + justify: FlexAlign::FlexStart, + align_items: FlexAlign::Stretch, + align_content: FlexAlign::Stretch, + width: Size::ViewportHeight, + height, + row_gap: 0, + column_gap: 0, + items: Arc::new( + nodes + .into_iter() + .map(|node| FlexItem { + node, + order: 0, + grow: 0.0, + shrink: 0.0, + basis: Size::ViewportHeight, + align_self: FlexAlign::Stretch, + }) + .collect(), + ), + } + } + + #[test] + fn retained_context_bootstrap_counts_deep_anonymous_and_nested_expressions() { + let mut leaf = text_box(2, measured_text(vec![vec![cluster("x", 1, None)]]), None); + let LayoutNode::Box { + height, + min_height, + max_height, + .. + } = &mut leaf + else { + unreachable!(); + }; + *height = serde_json::from_value(serde_json::json!({ + "kind": "subtract", "values": [ + {"kind": "viewport-height"}, + {"kind": "fit-content", "limit": { + "kind": "add", "values": [ + {"kind": "viewport-height"}, {"kind": "viewport-height"}, + {"kind": "viewport"}, {"kind": "lines", "value": 2} + ] + }} + ] + })) + .unwrap(); + *min_height = Size::ViewportHeight; + *max_height = Size::FitContent { + limit: Some(Box::new(Size::ViewportHeight)), + }; + let mut anonymous = context_test_flex(Size::ViewportHeight, vec![identified(leaf, 2, 1)]); + for index in 0..48 { + let mut wrapper = child_box(index + 3, anonymous, None); + let LayoutNode::Box { height, .. } = &mut wrapper else { + unreachable!() + }; + *height = Size::ViewportHeight; + anonymous = LayoutNode::Row { + node_id: None, + node_revision: None, + children: Arc::new(vec![wrapper]), + }; + } + let root = identified(child_box(1, anonymous, None), 1, 1); + let full = retained_document(root); + let (retained, parsed) = RetainedDocument::bootstrap(full.clone()).unwrap(); + assert_eq!(parsed, 99); + assert_retained_context_oracle(&retained, 54); + for height in [0, 1, 4629, 4630, -1, i64::MAX] { + let context = LayoutContext { + viewport_height: height, + ..test_context() + }; + assert_eq!( + retained.validate_context(context), + full.validate_context(context) + ); + } + } + + #[test] + fn retained_context_ignores_widths_and_preserves_zero_occurrence_errors() { + let mut leaf = text_box(2, measured_text(vec![vec![cluster("x", 1, None)]]), None); + let LayoutNode::Box { + width, + min_width, + max_width, + height, + .. + } = &mut leaf + else { + unreachable!(); + }; + *width = Size::ViewportHeight; + *min_width = Size::ViewportHeight; + *max_width = Size::ViewportHeight; + *height = Size::FitContent { limit: None }; + let root = identified( + child_box(1, context_test_flex(Size::Auto, vec![leaf]), None), + 1, + 1, + ); + let (retained, _) = RetainedDocument::bootstrap(retained_document(root)).unwrap(); + assert_retained_context_oracle(&retained, 0); + } + + #[test] + fn retained_context_slot_orders_revision_only_forks_and_invalid_cas_match_oracle() { + let leaf = identified( + text_box(2, measured_text(vec![vec![cluster("x", 1, None)]]), None), + 2, + 1, + ); + let root = identified( + child_box(1, context_test_flex(Size::ViewportHeight, vec![leaf]), None), + 1, + 7, + ); + let (base, _) = RetainedDocument::bootstrap(retained_document(root)).unwrap(); + let slot_zero = serde_json::json!({"slot": 0, "local": { + "height": {"kind": "viewport-height"}, + "min-height": {"kind": "viewport-height"}, + "max-height": {"kind": "fit-content", "limit": {"kind": "viewport-height"}}, + "padding-left": 2 + }}); + let slot_one = serde_json::json!({"slot": 1, "local": { + "height": {"kind": "subtract", "values": [ + {"kind": "viewport-height"}, {"kind": "viewport-height"} + ]}, + "width": {"kind": "viewport-height"} + }}); + let update = |patches: Vec| { + serde_json::json!({ + "node-id": 1, "expected-revision": 7, "target-revision": 8, + "slot-patches": patches + }) + }; + let forward = base + .apply_delta(context_test_delta(vec![update(vec![ + slot_zero.clone(), + slot_one.clone(), + ])])) + .unwrap() + .0; + let reverse = base + .apply_delta(context_test_delta(vec![update(vec![slot_one, slot_zero])])) + .unwrap() + .0; + assert_retained_context_oracle(&base, 1); + assert_retained_context_oracle(&forward, 5); + assert_retained_context_oracle(&reverse, 5); + + let revision = + serde_json::json!({"node-id": 1, "expected-revision": 8, "target-revision": 9}); + let no_op = forward + .apply_delta(context_test_delta(vec![revision])) + .unwrap() + .0; + assert_retained_context_oracle(&no_op, 5); + assert!(Arc::ptr_eq( + &radix_lookup(&forward.entries, 1).unwrap().node, + &radix_lookup(&no_op.entries, 1).unwrap().node + )); + + let remove = update(vec![ + serde_json::json!({"slot": 1, "local": {"height": {"kind": "auto"}}}), + ]); + let other_fork = base + .apply_delta(context_test_delta(vec![remove])) + .unwrap() + .0; + assert_retained_context_oracle(&other_fork, 0); + assert_retained_context_oracle(&base, 1); + assert_retained_context_oracle(&forward, 5); + + for (expected_revision, target_revision) in [(6, 8), (7, 7), (7, 6)] { + let invalid = serde_json::json!({ + "node-id": 1, "expected-revision": expected_revision, "target-revision": target_revision, + "slot-patches": [{"slot": 1, "local": {"height": {"kind": "auto"}}}] + }); + assert_eq!( + base.apply_delta(context_test_delta(vec![invalid])) + .unwrap_err(), + "Native retained delta node revision mismatch" + ); + assert_retained_context_oracle(&base, 1); + } + } + + #[test] + fn retained_context_large_occurrence_count_accepts_zero_height() { + let mut root = identified( + text_box(1, measured_text(vec![vec![cluster("x", 1, None)]]), None), + 1, + 1, + ); + let LayoutNode::Box { height, .. } = &mut root else { + unreachable!() + }; + *height = Size::Add { + values: Box::new(SizeValues( + vec![Size::ViewportHeight; MAX_LAYOUT_WORK_UNITS + 1].into_boxed_slice(), + )), + }; + let (retained, _) = RetainedDocument::bootstrap(retained_document(root)).unwrap(); + assert_retained_context_oracle(&retained, MAX_LAYOUT_WORK_UNITS + 1); + } + + #[test] + fn retained_context_flex_owner_and_anonymous_box_batch_updates_are_atomic() { + let mut anonymous = text_box(3, measured_text(vec![vec![cluster("x", 1, None)]]), None); + let LayoutNode::Box { + height, + min_height, + max_height, + .. + } = &mut anonymous + else { + unreachable!() + }; + *height = Size::ViewportHeight; + *min_height = Size::ViewportHeight; + *max_height = Size::ViewportHeight; + let owner = identified(child_box(2, anonymous, None), 2, 1); + let root = identified(context_test_flex(Size::ViewportHeight, vec![owner]), 1, 1); + let (base, _) = RetainedDocument::bootstrap(retained_document(root)).unwrap(); + let flex_update = serde_json::json!({ + "node-id": 1, "expected-revision": 1, "target-revision": 2, + "slot-patches": [{"slot": 0, "local": {"height": {"kind": "add", "values": [ + {"kind": "viewport-height"}, {"kind": "viewport-height"} + ]}}}] + }); + let box_update = serde_json::json!({ + "node-id": 2, "expected-revision": 1, "target-revision": 2, + "slot-patches": [{"slot": 1, "local": { + "height": {"kind": "auto"}, + "min-height": {"kind": "lines", "value": 2}, + "max-height": {"kind": "subtract", "values": [ + {"kind": "viewport-height"}, {"kind": "viewport-height"} + ]} + }}] + }); + let flex_only = base + .apply_delta(context_test_delta(vec![flex_update.clone()])) + .unwrap() + .0; + let box_only = base + .apply_delta(context_test_delta(vec![box_update.clone()])) + .unwrap() + .0; + let forward = base + .apply_delta(context_test_delta(vec![ + flex_update.clone(), + box_update.clone(), + ])) + .unwrap() + .0; + let reverse = base + .apply_delta(context_test_delta(vec![box_update, flex_update.clone()])) + .unwrap() + .0; + assert_retained_context_oracle(&base, 4); + assert_retained_context_oracle(&flex_only, 5); + assert_retained_context_oracle(&box_only, 3); + assert_retained_context_oracle(&forward, 4); + assert_retained_context_oracle(&reverse, 4); + let removed = forward + .apply_delta(context_test_delta(vec![ + serde_json::json!({"node-id": 1, "expected-revision": 2, "target-revision": 3, + "slot-patches": [{"slot": 0, "local": {"height": {"kind": "auto"}}}]}), + serde_json::json!({"node-id": 2, "expected-revision": 2, "target-revision": 3, + "slot-patches": [{"slot": 1, "local": {"max-height": {"kind": "none"}}}]}), + ])) + .unwrap() + .0; + assert_retained_context_oracle(&removed, 0); + assert_retained_context_oracle(&forward, 4); + + // A later CAS/field failure must discard the first entry's prepared summary. + for invalid in [ + serde_json::json!({"node-id": 2, "expected-revision": 0, "target-revision": 2}), + serde_json::json!({"node-id": 2, "expected-revision": 1, "target-revision": 2, + "slot-patches": [{"slot": 1, "local": {"height": {"kind": "lines", "value": -1}}}]}), + ] { + assert!(base + .apply_delta(context_test_delta(vec![flex_update.clone(), invalid])) + .is_err()); + assert_retained_context_oracle(&base, 4); + assert_eq!(radix_lookup(&base.entries, 1).unwrap().revision, 1); + } + } + + #[test] + fn retained_context_validation_performs_zero_resolver_lookups() { + let nodes = (2..514) + .map(|id| { + identified( + text_box(id, measured_text(vec![vec![cluster("x", 1, None)]]), None), + id as u64, + 1, + ) + }) + .collect(); + let root = identified( + child_box(1, context_test_flex(Size::ViewportHeight, nodes), None), + 1, + 1, + ); + let (retained, _) = RetainedDocument::bootstrap(retained_document(root)).unwrap(); + reset_resolver_lookups(); + retained.validate_context(test_context()).unwrap(); + assert_eq!( + resolver_lookups(), + 0, + "retained context validation traversed the document" + ); + } + #[test] fn retained_scalar_patch_path_copies_only_radix_and_shares_wide_edges() { let children = Arc::new( diff --git a/native/src/lib.rs b/native/src/lib.rs index f32f597..6ffb988 100644 --- a/native/src/lib.rs +++ b/native/src/lib.rs @@ -1,4 +1,5 @@ mod layout; +pub mod sequence; use layout::{ encode_error_tape, DocumentDelta, LayoutContext, LayoutDocument, LayoutTape, RetainedDocument, @@ -468,6 +469,7 @@ struct RenderedJob { base_renders: u64, target_renders: u64, resolver_lookups: u64, + atom_plan_work: layout::AtomPlanWork, } #[derive(Default)] @@ -520,6 +522,7 @@ struct RuntimeState { document_delta_entries_validated: u64, document_trie_path_nodes_copied: u64, document_resolver_lookups: u64, + atom_plan_work: layout::AtomPlanWork, confirmed_baseline: Option>, } @@ -580,6 +583,7 @@ struct SessionStats { document_delta_entries_validated: u64, document_trie_path_nodes_copied: u64, document_resolver_lookups: u64, + atom_plan_work: layout::AtomPlanWork, pending_baselines: usize, confirmed_baseline: bool, confirmed_baseline_bytes: usize, @@ -652,6 +656,7 @@ impl Session { document_delta_entries_validated: 0, document_trie_path_nodes_copied: 0, document_resolver_lookups: 0, + atom_plan_work: layout::AtomPlanWork::default(), confirmed_baseline, }), readiness_channel: Mutex::new(None), @@ -1077,6 +1082,7 @@ impl Session { state.base_renders += output.base_renders; state.target_renders += output.target_renders; state.document_resolver_lookups += output.resolver_lookups; + state.atom_plan_work.accumulate(output.atom_plan_work); state.document_parses += input_stats.parses; state.document_validations += input_stats.validations; state.document_reuses += input_stats.reuses; @@ -1225,6 +1231,7 @@ impl Session { document_delta_entries_validated: state.document_delta_entries_validated, document_trie_path_nodes_copied: state.document_trie_path_nodes_copied, document_resolver_lookups: state.document_resolver_lookups, + atom_plan_work: state.atom_plan_work, pending_baselines, confirmed_baseline: state.confirmed_baseline.is_some(), confirmed_baseline_bytes, @@ -1437,6 +1444,7 @@ fn render_layout_payload( base_renders: 0, target_renders: 0, resolver_lookups: 0, + atom_plan_work: layout::AtomPlanWork::default(), }, JobPayload::Layout { document, @@ -1494,6 +1502,7 @@ fn render_layout_payload( String, >; layout::reset_resolver_lookups(); + layout::reset_atom_plan_work(); let result = catch_unwind(AssertUnwindSafe(|| -> LayoutRenderOutcome { let target_styles = document.styles()?; if let Some(base_context) = base_context { @@ -1568,6 +1577,7 @@ fn render_layout_payload( })); let resolver_lookups = validation_resolver_lookups.saturating_add(layout::resolver_lookups()); + let atom_plan_work = layout::atom_plan_work(); match result { Ok(Ok((bytes, tape, styles, baseline_hit, base_renders, target_renders))) => { RenderedJob { @@ -1583,6 +1593,7 @@ fn render_layout_payload( base_renders, target_renders, resolver_lookups, + atom_plan_work, } } Ok(Err(error)) => RenderedJob { @@ -1592,6 +1603,7 @@ fn render_layout_payload( base_renders: 0, target_renders: 0, resolver_lookups, + atom_plan_work, }, Err(_) => RenderedJob { bytes: encode_error_tape(identity, "native layout panicked", max_result_bytes), @@ -1600,6 +1612,7 @@ fn render_layout_payload( base_renders: 0, target_renders: 0, resolver_lookups, + atom_plan_work, }, } } @@ -1749,6 +1762,7 @@ fn worker_loop(shared: Arc) { state.base_renders += output.base_renders; state.target_renders += output.target_renders; state.document_resolver_lookups += output.resolver_lookups; + state.atom_plan_work.accumulate(output.atom_plan_work); state.results.insert( (job.generation, job.key), ResultEntry { @@ -2573,9 +2587,9 @@ mod tests { assert_eq!(stats.document_delta_entries_validated, 1); assert_eq!(stats.document_trie_path_nodes_copied, 17); assert_eq!(stats.document_delta_input_bytes, delta.len() as u64); - assert!( - stats.document_resolver_lookups >= 3, - "target/base validation and target render lookups must all remain visible" + assert_eq!( + stats.document_resolver_lookups, stats.target_renders, + "each single-owner render resolves its root; retained context validation resolves no nodes" ); assert!(session.confirm(2, 2, 2).unwrap()); diff --git a/native/src/sequence.rs b/native/src/sequence.rs new file mode 100644 index 0000000..2bf477a --- /dev/null +++ b/native/src/sequence.rs @@ -0,0 +1,751 @@ +use std::sync::Arc; + +const LEAF_CAPACITY: usize = 16; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Measure { + pub chars: u64, + pub lines: u64, + pub width_sum: i64, + pub width_max: i64, +} + +impl Measure { + pub fn new(chars: u64, lines: u64, width_sum: i64, width_max: i64) -> Result { + if width_sum < 0 || width_max < 0 { + return Err(Error::NegativeWidth); + } + Ok(Self { + chars, + lines, + width_sum, + width_max, + }) + } + + pub fn checked_combine(self, other: Self) -> Result { + Ok(Self { + chars: self.chars.checked_add(other.chars).ok_or(Error::Overflow)?, + lines: self.lines.checked_add(other.lines).ok_or(Error::Overflow)?, + width_sum: self + .width_sum + .checked_add(other.width_sum) + .ok_or(Error::Overflow)?, + width_max: self.width_max.max(other.width_max), + }) + } +} + +#[derive(Debug)] +pub struct Entry { + value: Arc, + measure: Measure, +} + +impl Clone for Entry { + fn clone(&self) -> Self { + Self { + value: Arc::clone(&self.value), + measure: self.measure, + } + } +} + +impl Entry { + pub fn new(value: Arc, measure: Measure) -> Result { + if measure.width_sum < 0 || measure.width_max < 0 { + return Err(Error::NegativeWidth); + } + Ok(Self { value, measure }) + } + + pub fn value(&self) -> &Arc { + &self.value + } + + pub fn measure(&self) -> Measure { + self.measure + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Work { + pub nodes_created: u64, + pub entries_written: u64, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Error { + IndexOutOfBounds, + NegativeWidth, + Overflow, +} + +#[derive(Debug)] +pub enum Node { + Leaf { + entries: Box<[Entry]>, + measure: Measure, + }, + Branch { + left: Arc>, + right: Arc>, + len: usize, + height: u32, + measure: Measure, + }, +} + +pub enum NodeView<'a, T> { + Leaf { + entries: &'a [Entry], + measure: Measure, + }, + Branch { + left: &'a Arc>, + right: &'a Arc>, + len: usize, + height: u32, + measure: Measure, + }, +} + +type OptionalRoot = Option>>; +type SplitRoots = (OptionalRoot, OptionalRoot); + +impl Node { + pub fn view(&self) -> NodeView<'_, T> { + match self { + Self::Leaf { entries, measure } => NodeView::Leaf { + entries, + measure: *measure, + }, + Self::Branch { + left, + right, + len, + height, + measure, + } => NodeView::Branch { + left, + right, + len: *len, + height: *height, + measure: *measure, + }, + } + } + + fn len(&self) -> usize { + match self { + Self::Leaf { entries, .. } => entries.len(), + Self::Branch { len, .. } => *len, + } + } + + fn height(&self) -> u32 { + match self { + Self::Leaf { .. } => 1, + Self::Branch { height, .. } => *height, + } + } + + fn measure(&self) -> Measure { + match self { + Self::Leaf { measure, .. } | Self::Branch { measure, .. } => *measure, + } + } +} + +#[derive(Debug)] +pub struct Sequence { + root: Option>>, +} + +impl Clone for Sequence { + fn clone(&self) -> Self { + Self { + root: self.root.as_ref().map(Arc::clone), + } + } +} + +impl Default for Sequence { + fn default() -> Self { + Self::empty() + } +} + +impl Sequence { + pub fn empty() -> Self { + Self { root: None } + } + + pub fn from_entries(entries: I, work: &mut Work) -> Result + where + I: IntoIterator>, + { + let entries = entries.into_iter().collect::>(); + let mut leaves = Vec::with_capacity(entries.len().div_ceil(LEAF_CAPACITY)); + for chunk in entries.chunks(LEAF_CAPACITY) { + leaves.push(make_leaf(chunk.to_vec(), work)?); + } + fn build(leaves: &[Arc>], work: &mut Work) -> Result>, Error> { + if leaves.len() == 1 { + return Ok(Arc::clone(&leaves[0])); + } + let middle = leaves.len() / 2; + make_branch( + build(&leaves[..middle], work)?, + build(&leaves[middle..], work)?, + work, + ) + } + Ok(Self { + root: if leaves.is_empty() { + None + } else { + Some(build(&leaves, work)?) + }, + }) + } + + pub fn len(&self) -> usize { + self.root.as_ref().map_or(0, |root| root.len()) + } + + pub fn is_empty(&self) -> bool { + self.root.is_none() + } + + pub fn measure(&self) -> Measure { + self.root + .as_ref() + .map_or(Measure::default(), |root| root.measure()) + } + + pub fn root(&self) -> Option<&Arc>> { + self.root.as_ref() + } + + pub fn get(&self, index: usize) -> Option<&Entry> { + fn get_at(node: &Node, index: usize) -> Option<&Entry> { + match node { + Node::Leaf { entries, .. } => entries.get(index), + Node::Branch { left, right, .. } => { + if index < left.len() { + get_at(left, index) + } else { + get_at(right, index - left.len()) + } + } + } + } + (index < self.len()).then(|| get_at(self.root.as_deref()?, index))? + } + + pub fn prefix_measure(&self, end: usize) -> Result { + fn prefix(node: &Node, end: usize) -> Result { + match node { + Node::Leaf { entries, .. } => entries[..end] + .iter() + .try_fold(Measure::default(), |sum, entry| { + sum.checked_combine(entry.measure) + }), + Node::Branch { left, right, .. } => { + if end <= left.len() { + prefix(left, end) + } else { + left.measure() + .checked_combine(prefix(right, end - left.len())?) + } + } + } + } + if end > self.len() { + return Err(Error::IndexOutOfBounds); + } + self.root + .as_deref() + .map_or(Ok(Measure::default()), |root| prefix(root, end)) + } + + pub fn split_at(&self, index: usize, work: &mut Work) -> Result<(Self, Self), Error> { + if index > self.len() { + return Err(Error::IndexOutOfBounds); + } + let (left, right) = split_node(self.root.as_ref(), index, work)?; + Ok((Self { root: left }, Self { root: right })) + } + + pub fn concat(&self, other: &Self, work: &mut Work) -> Result { + Ok(Self { + root: join(self.root.clone(), other.root.clone(), work)?, + }) + } + + pub fn replace(&self, index: usize, entry: Entry, work: &mut Work) -> Result { + if index >= self.len() { + return Err(Error::IndexOutOfBounds); + } + Ok(Self { + root: Some(replace_node( + self.root.as_ref().expect("nonempty checked"), + index, + entry, + work, + )?), + }) + } +} + +fn count_node(work: &mut Work) -> Result<(), Error> { + work.nodes_created = work.nodes_created.checked_add(1).ok_or(Error::Overflow)?; + Ok(()) +} + +fn make_leaf(entries: Vec>, work: &mut Work) -> Result>, Error> { + debug_assert!(!entries.is_empty() && entries.len() <= LEAF_CAPACITY); + let measure = entries.iter().try_fold(Measure::default(), |sum, entry| { + sum.checked_combine(entry.measure) + })?; + let written = u64::try_from(entries.len()).map_err(|_| Error::Overflow)?; + let next_written = work + .entries_written + .checked_add(written) + .ok_or(Error::Overflow)?; + count_node(work)?; + work.entries_written = next_written; + Ok(Arc::new(Node::Leaf { + entries: entries.into_boxed_slice(), + measure, + })) +} + +fn make_branch( + left: Arc>, + right: Arc>, + work: &mut Work, +) -> Result>, Error> { + let len = left.len().checked_add(right.len()).ok_or(Error::Overflow)?; + let height = left + .height() + .max(right.height()) + .checked_add(1) + .ok_or(Error::Overflow)?; + let measure = left.measure().checked_combine(right.measure())?; + count_node(work)?; + Ok(Arc::new(Node::Branch { + left, + right, + len, + height, + measure, + })) +} + +fn join( + left: Option>>, + right: Option>>, + work: &mut Work, +) -> Result>>, Error> { + let (left, right) = match (left, right) { + (None, right) => return Ok(right), + (left, None) => return Ok(left), + (Some(left), Some(right)) => (left, right), + }; + if let (Node::Leaf { entries: a, .. }, Node::Leaf { entries: b, .. }) = + (left.as_ref(), right.as_ref()) + { + if a.len() + b.len() <= LEAF_CAPACITY { + let mut entries = Vec::with_capacity(a.len() + b.len()); + entries.extend_from_slice(a); + entries.extend_from_slice(b); + return make_leaf(entries, work).map(Some); + } + } + if left.height() > right.height().saturating_add(1) { + let Node::Branch { + left: outer, + right: inner, + .. + } = left.as_ref() + else { + unreachable!() + }; + let joined = join(Some(Arc::clone(inner)), Some(right), work)?.expect("nonempty join"); + return rebalance(Arc::clone(outer), joined, work).map(Some); + } + if right.height() > left.height().saturating_add(1) { + let Node::Branch { + left: inner, + right: outer, + .. + } = right.as_ref() + else { + unreachable!() + }; + let joined = join(Some(left), Some(Arc::clone(inner)), work)?.expect("nonempty join"); + return rebalance(joined, Arc::clone(outer), work).map(Some); + } + make_branch(left, right, work).map(Some) +} + +fn rebalance( + left: Arc>, + right: Arc>, + work: &mut Work, +) -> Result>, Error> { + if left.height() > right.height().saturating_add(1) { + let Node::Branch { + left: ll, + right: lr, + .. + } = left.as_ref() + else { + unreachable!() + }; + if ll.height() >= lr.height() { + return make_branch( + Arc::clone(ll), + make_branch(Arc::clone(lr), right, work)?, + work, + ); + } + let Node::Branch { + left: lrl, + right: lrr, + .. + } = lr.as_ref() + else { + unreachable!() + }; + return make_branch( + make_branch(Arc::clone(ll), Arc::clone(lrl), work)?, + make_branch(Arc::clone(lrr), right, work)?, + work, + ); + } + if right.height() > left.height().saturating_add(1) { + let Node::Branch { + left: rl, + right: rr, + .. + } = right.as_ref() + else { + unreachable!() + }; + if rr.height() >= rl.height() { + return make_branch( + make_branch(left, Arc::clone(rl), work)?, + Arc::clone(rr), + work, + ); + } + let Node::Branch { + left: rll, + right: rlr, + .. + } = rl.as_ref() + else { + unreachable!() + }; + return make_branch( + make_branch(left, Arc::clone(rll), work)?, + make_branch(Arc::clone(rlr), Arc::clone(rr), work)?, + work, + ); + } + make_branch(left, right, work) +} + +fn split_node( + node: Option<&Arc>>, + index: usize, + work: &mut Work, +) -> Result, Error> { + let Some(node) = node else { + return Ok((None, None)); + }; + if index == 0 { + return Ok((None, Some(Arc::clone(node)))); + } + if index == node.len() { + return Ok((Some(Arc::clone(node)), None)); + } + match node.as_ref() { + Node::Leaf { entries, .. } => Ok(( + Some(make_leaf(entries[..index].to_vec(), work)?), + Some(make_leaf(entries[index..].to_vec(), work)?), + )), + Node::Branch { left, right, .. } => { + if index < left.len() { + let (before, after) = split_node(Some(left), index, work)?; + Ok((before, join(after, Some(Arc::clone(right)), work)?)) + } else if index == left.len() { + Ok((Some(Arc::clone(left)), Some(Arc::clone(right)))) + } else { + let (before, after) = split_node(Some(right), index - left.len(), work)?; + Ok((join(Some(Arc::clone(left)), before, work)?, after)) + } + } + } +} + +fn replace_node( + node: &Arc>, + index: usize, + entry: Entry, + work: &mut Work, +) -> Result>, Error> { + match node.as_ref() { + Node::Leaf { entries, .. } => { + let mut entries = entries.to_vec(); + entries[index] = entry; + make_leaf(entries, work) + } + Node::Branch { left, right, .. } => { + if index < left.len() { + make_branch( + replace_node(left, index, entry, work)?, + Arc::clone(right), + work, + ) + } else { + make_branch( + Arc::clone(left), + replace_node(right, index - left.len(), entry, work)?, + work, + ) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn entry(value: usize) -> Entry { + let text = if value % 5 == 0 { + format!("界{value}") + } else { + value.to_string() + }; + Entry::new( + Arc::new(text.clone()), + Measure::new(text.chars().count() as u64, 1, value as i64, value as i64).unwrap(), + ) + .unwrap() + } + + fn values(sequence: &Sequence) -> Vec { + (0..sequence.len()) + .map(|index| sequence.get(index).unwrap().value().as_ref().clone()) + .collect() + } + + fn height(sequence: &Sequence) -> u32 { + sequence.root().map_or(0, |root| root.height()) + } + + fn shared_nodes(left: &Arc>, right: &Arc>) -> usize { + if Arc::ptr_eq(left, right) { + return 1; + } + match (left.as_ref(), right.as_ref()) { + ( + Node::Branch { + left: ll, + right: lr, + .. + }, + Node::Branch { + left: rl, + right: rr, + .. + }, + ) => shared_nodes(ll, rl) + shared_nodes(lr, rr), + _ => 0, + } + } + + fn assert_valid(node: &Arc>) -> (usize, u32, Measure) { + match node.as_ref() { + Node::Leaf { entries, measure } => { + assert!(!entries.is_empty() && entries.len() <= LEAF_CAPACITY); + let calculated = entries + .iter() + .try_fold(Measure::default(), |sum, entry| { + sum.checked_combine(entry.measure()) + }) + .unwrap(); + assert_eq!(*measure, calculated); + (entries.len(), 1, calculated) + } + Node::Branch { + left, + right, + len, + height, + measure, + } => { + let (left_len, left_height, left_measure) = assert_valid(left); + let (right_len, right_height, right_measure) = assert_valid(right); + assert!(left_height.abs_diff(right_height) <= 1); + assert_eq!(*len, left_len + right_len); + assert_eq!(*height, left_height.max(right_height) + 1); + assert_eq!( + *measure, + left_measure.checked_combine(right_measure).unwrap() + ); + (*len, *height, *measure) + } + } + } + + #[test] + fn empty_and_unicode_measures_are_exact() { + let empty = Sequence::::empty(); + assert!(empty.is_empty()); + assert_eq!(empty.measure(), Measure::default()); + let mut work = Work::default(); + let sequence = Sequence::from_entries([entry(0), entry(12)], &mut work).unwrap(); + assert_eq!(sequence.len(), 2); + assert_eq!(sequence.measure().chars, 4); + assert_eq!(sequence.measure().lines, 2); + assert_eq!(sequence.measure().width_sum, 12); + assert_eq!(sequence.measure().width_max, 12); + assert_eq!(sequence.prefix_measure(1).unwrap().chars, 2); + for size in 1..=257 { + let mut work = Work::default(); + let varied = Sequence::from_entries((0..size).map(entry), &mut work).unwrap(); + assert_eq!(varied.len(), size); + assert_valid(varied.root().unwrap()); + } + } + + #[test] + fn every_split_point_rejoins_exactly() { + let mut setup = Work::default(); + let original = Sequence::from_entries((0..97).map(entry), &mut setup).unwrap(); + let oracle = values(&original); + for index in 0..=original.len() { + let mut work = Work::default(); + let (left, right) = original.split_at(index, &mut work).unwrap(); + assert_eq!(values(&left), oracle[..index]); + assert_eq!(values(&right), oracle[index..]); + let joined = left.concat(&right, &mut work).unwrap(); + assert_eq!(values(&joined), oracle); + assert!(height(&joined) <= 8); + assert_valid(joined.root().unwrap()); + } + } + + #[test] + fn repeated_left_and_right_concat_remain_balanced() { + let mut left = Sequence::empty(); + let mut right = Sequence::empty(); + let mut work = Work::default(); + for index in 0..2048 { + let one = Sequence::from_entries([entry(index)], &mut work).unwrap(); + left = left.concat(&one, &mut work).unwrap(); + right = one.concat(&right, &mut work).unwrap(); + } + assert_eq!(left.len(), 2048); + assert_eq!(right.len(), 2048); + assert!(height(&left) <= 16, "left height={}", height(&left)); + assert!(height(&right) <= 16, "right height={}", height(&right)); + assert_valid(left.root().unwrap()); + assert_valid(right.root().unwrap()); + } + + #[test] + fn deterministic_random_operations_match_vec_oracle() { + let mut seed = 0x91e1_0da5_u64; + let mut oracle = (0..64).collect::>(); + let mut setup = Work::default(); + let mut sequence = + Sequence::from_entries(oracle.iter().copied().map(entry), &mut setup).unwrap(); + for _ in 0..1000 { + seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1); + let index = (seed as usize) % oracle.len(); + seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1); + let value = (seed >> 16) as usize % 10_000; + oracle[index] = value; + let mut work = Work::default(); + sequence = sequence.replace(index, entry(value), &mut work).unwrap(); + assert!(work.entries_written <= LEAF_CAPACITY as u64); + } + assert_eq!( + values(&sequence), + oracle + .iter() + .map(|value| entry(*value).value().as_ref().clone()) + .collect::>() + ); + assert_valid(sequence.root().unwrap()); + } + + #[test] + fn single_replace_is_logarithmic_and_preserves_old_fork() { + for size in [32, 128, 512, 8192] { + let mut setup = Work::default(); + let original = Sequence::from_entries((0..size).map(entry), &mut setup).unwrap(); + let fork = original.clone(); + let old_value = original.get(1).unwrap().value().clone(); + let mut work = Work::default(); + let updated = original.replace(1, entry(99_999), &mut work).unwrap(); + assert!(work.nodes_created <= u64::from(height(&original)) + 1); + assert!(work.entries_written <= LEAF_CAPACITY as u64); + assert!(shared_nodes(original.root().unwrap(), updated.root().unwrap()) > 0); + assert!(Arc::ptr_eq(fork.get(1).unwrap().value(), &old_value)); + assert_eq!(fork.get(1).unwrap().value().as_str(), "1"); + assert_eq!(updated.get(1).unwrap().value().as_str(), "99999"); + assert_valid(updated.root().unwrap()); + } + } + + #[test] + fn large_split_and_rejoin_touch_only_logarithmic_paths() { + let mut setup = Work::default(); + let original = Sequence::from_entries((0..8192).map(entry), &mut setup).unwrap(); + let mut split_work = Work::default(); + let (left, right) = original.split_at(4097, &mut split_work).unwrap(); + assert!(split_work.nodes_created <= 32, "{split_work:?}"); + assert!(split_work.entries_written <= 32, "{split_work:?}"); + let mut concat_work = Work::default(); + let rejoined = left.concat(&right, &mut concat_work).unwrap(); + assert!(concat_work.nodes_created <= 32, "{concat_work:?}"); + assert_eq!(values(&rejoined), values(&original)); + assert_valid(rejoined.root().unwrap()); + } + + #[test] + fn overflow_and_invalid_updates_leave_the_old_root_unchanged() { + let huge = Entry::new( + Arc::new("x".to_owned()), + Measure::new(u64::MAX, 0, i64::MAX, i64::MAX).unwrap(), + ) + .unwrap(); + let one = Entry::new(Arc::new("y".to_owned()), Measure::new(1, 0, 1, 1).unwrap()).unwrap(); + let mut work = Work::default(); + let base = Sequence::from_entries([huge], &mut work).unwrap(); + let old_root = Arc::clone(base.root().unwrap()); + let mut failed_work = Work::default(); + assert!(matches!( + base.concat( + &Sequence::from_entries([one], &mut Work::default()).unwrap(), + &mut failed_work + ), + Err(Error::Overflow) + )); + assert!(Arc::ptr_eq(base.root().unwrap(), &old_root)); + assert!(matches!( + base.replace(2, entry(2), &mut failed_work), + Err(Error::IndexOutOfBounds) + )); + assert!(Measure::new(0, 0, -1, 0).is_err()); + assert!(Arc::ptr_eq(base.root().unwrap(), &old_root)); + } +} From dac004cd65c0d2ce75a9dd70acc9e0c863a62251 Mon Sep 17 00:00:00 2001 From: Kinneyzhang Date: Sat, 5 Sep 2026 12:03:28 +0800 Subject: [PATCH 06/12] Retain compositional line sequences and synchronize worker shutdown --- native/src/atom_plan.rs | 4 + native/src/layout.rs | 621 +++++++++++++++++++++++++++------------- native/src/lib.rs | 100 ++++++- native/src/line_plan.rs | 599 ++++++++++++++++++++++++++++++++++++++ native/src/sequence.rs | 145 ++++++++++ 5 files changed, 1263 insertions(+), 206 deletions(-) create mode 100644 native/src/line_plan.rs diff --git a/native/src/atom_plan.rs b/native/src/atom_plan.rs index b298697..9f0553d 100644 --- a/native/src/atom_plan.rs +++ b/native/src/atom_plan.rs @@ -318,6 +318,10 @@ impl AtomPlan { self.parts.is_empty() } + pub(super) fn char_count(&self) -> u64 { + self.parts.measure().chars + } + pub(super) fn append(&self, other: &Self) -> Self { if self.is_empty() { return other.clone(); diff --git a/native/src/layout.rs b/native/src/layout.rs index 74aa0a1..e646d9f 100644 --- a/native/src/layout.rs +++ b/native/src/layout.rs @@ -19,6 +19,19 @@ pub(crate) fn atom_plan_work() -> AtomPlanWork { atom_plan::work() } +#[path = "line_plan.rs"] +mod line_plan; +use line_plan::LinePlan; +pub(crate) use line_plan::LinePlanWork; + +pub(crate) fn reset_line_plan_work() { + line_plan::reset_work(); +} + +pub(crate) fn line_plan_work() -> LinePlanWork { + line_plan::work() +} + fn deserialize_arc_vec<'de, D, T>(deserializer: D) -> Result>, D::Error> where D: Deserializer<'de>, @@ -1835,14 +1848,18 @@ impl Atom { #[derive(Debug, Clone)] struct Rendered { - lines: Vec, - breaks: Vec, + lines: LinePlan, } impl Rendered { fn from_lines(lines: Vec) -> Self { - let breaks = vec![AtomProperties::default(); lines.len().saturating_sub(1)]; - Self { lines, breaks } + Self { + lines: LinePlan::from_lines(lines), + } + } + + fn from_line_plan(lines: LinePlan) -> Self { + Self { lines } } fn first_width(&self) -> i64 { @@ -1850,7 +1867,7 @@ impl Rendered { } fn max_width(&self) -> i64 { - self.lines.iter().map(|line| line.width).max().unwrap_or(0) + self.lines.max_width() } fn min_content_width(&self, wrap_mode: WrapMode) -> i64 { @@ -1865,28 +1882,29 @@ impl Rendered { } fn height(&self) -> i64 { - self.lines.len() as i64 + self.lines.height() } fn apply_scroll_window(&mut self, region_id: i64) { - for line in &mut self.lines { - line.atoms = line.atoms.apply_scroll_window(region_id); - } - for properties in &mut self.breaks { - properties.scroll_window = Some(region_id); - } + self.lines = self + .lines + .map_lines(|_, mut line| { + line.atoms = line.atoms.apply_scroll_window(region_id); + line + }) + .map_breaks(|properties| { + properties.scroll_window = Some(region_id); + }); } fn into_tape(self, style_count: u32) -> LayoutTape { - let last_line = self.lines.len().saturating_sub(1); - let mut breaks = self.breaks.into_iter(); + line_plan::record_materialized(self.lines.len(), self.lines.prefix_chars(self.lines.len())); LayoutTape { style_count, lines: self .lines - .into_iter() - .enumerate() - .map(|(index, line)| TapeLine { + .iter_with_breaks() + .map(|(line, break_after)| TapeLine { width: line.width, atoms: line .atoms @@ -1908,8 +1926,7 @@ impl Rendered { } }) .collect(), - break_after: (index < last_line) - .then(|| breaks.next().expect("rendered break invariant")), + break_after: break_after.cloned(), }) .collect(), } @@ -5026,7 +5043,7 @@ fn wrap_rendered_line(line: &Line, max_width: i64, mode: WrapMode) -> Vec return vec![Line::default()]; } if mode == WrapMode::None || line.width <= max_width { - return vec![line.clone()]; + return vec![line_plan::clone_line(line)]; } // Wrapping inspects the actual atom boundaries. Composition and decoration @@ -5112,34 +5129,18 @@ fn wrap_rendered(rendered: Rendered, max_width: i64, mode: WrapMode) -> Rendered if mode == WrapMode::None { return rendered; } - let original_breaks = rendered.breaks; - let mut lines = Vec::new(); - let mut breaks = Vec::new(); - for (line_index, line) in rendered.lines.into_iter().enumerate() { - for (piece_index, piece) in wrap_rendered_line(&line, max_width, mode) - .into_iter() - .enumerate() - { - if !lines.is_empty() { - breaks.push(if piece_index == 0 { - original_breaks[line_index - 1].clone() - } else { - AtomProperties::default() - }); - } - lines.push(piece); - } + let mut lines = LinePlan::default(); + let mut joining_break = AtomProperties::default(); + for (line, break_after) in rendered.lines.iter_with_breaks() { + let pieces = LinePlan::from_lines(wrap_rendered_line(line, max_width, mode)); + lines = lines.concat_with_break(&pieces, joining_break); + joining_break = break_after.cloned().unwrap_or_default(); } - Rendered { lines, breaks } + Rendered { lines } } -fn vertical_align( - mut lines: Vec, - height: usize, - align: VerticalAlign, - width: i64, -) -> Vec { - lines.truncate(height); +fn vertical_align(lines: LinePlan, height: usize, align: VerticalAlign, width: i64) -> LinePlan { + let lines = lines.slice(0..lines.len().min(height)); if lines.len() >= height { return lines; } @@ -5149,11 +5150,11 @@ fn vertical_align( VerticalAlign::Bottom => rest, VerticalAlign::Center => rest / 2, }; - let mut output = Vec::with_capacity(height); - output.extend((0..top).map(|_| Line::blank(width))); - output.append(&mut lines); - output.extend((0..(rest - top)).map(|_| Line::blank(width))); - output + LinePlan::from_lines((0..top).map(|_| Line::blank(width))) + .concat(&lines) + .concat(&LinePlan::from_lines( + (0..(rest - top)).map(|_| Line::blank(width)), + )) } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -5824,7 +5825,7 @@ fn flex_resolved_align(item: &FlexRuntimeItem<'_>, container: FlexAlign) -> Flex } fn pad_rendered_width(mut rendered: Rendered, width: i64, align: FlexAlign) -> Rendered { - for line in &mut rendered.lines { + rendered.lines = rendered.lines.map_lines(|_, mut line| { let extra = (width - line.width).max(0); let left = match align { FlexAlign::FlexEnd | FlexAlign::End | FlexAlign::Right | FlexAlign::SelfEnd => extra, @@ -5833,7 +5834,8 @@ fn pad_rendered_width(mut rendered: Rendered, width: i64, align: FlexAlign) -> R }; line.prepend_space(left); line.push_space(extra - left); - } + line + }); rendered } @@ -5960,38 +5962,40 @@ fn concat_horizontal_sized(parts: Vec<(Rendered, i64)>, target_height: i64) -> R .max() .unwrap_or(1) .max(target_height); - let mut lines = Vec::with_capacity(height as usize); - for index in 0..height as usize { + let mut parts = parts + .iter() + .map(|(rendered, width)| (rendered.lines.iter(), *width)) + .collect::>(); + let lines = LinePlan::from_lines((0..height as usize).map(|_| { let mut line = Line::default(); - for (rendered, width) in &parts { - if let Some(part) = rendered.lines.get(index) { + for (lines, width) in &mut parts { + if let Some(part) = lines.next() { line.append(part); } else { line.push_space(*width); } } - lines.push(line); - } - Rendered::from_lines(lines) + line + })); + Rendered { lines } } fn stack_vertical(parts: Vec) -> Rendered { - let mut lines = Vec::new(); - let mut breaks = Vec::new(); - for mut part in parts { - if part.lines.is_empty() { - continue; + fn join_parts(parts: &[Rendered]) -> LinePlan { + match parts { + [] => LinePlan::default(), + [part] => part.lines.clone(), + _ => { + let middle = parts.len() / 2; + join_parts(&parts[..middle]).concat(&join_parts(&parts[middle..])) + } } - if !lines.is_empty() { - breaks.push(AtomProperties::default()); - } - lines.append(&mut part.lines); - breaks.append(&mut part.breaks); } + let mut lines = join_parts(&parts); if lines.is_empty() { - lines.push(Line::default()); + lines = LinePlan::from_lines([Line::default()]); } - Rendered { lines, breaks } + Rendered { lines } } fn slice_rendered(rendered: Rendered, start: i64, height: i64) -> Rendered { @@ -5999,25 +6003,9 @@ fn slice_rendered(rendered: Rendered, start: i64, height: i64) -> Rendered { let height = usize::try_from(height.max(0)).unwrap_or(0); let end = start.saturating_add(height).min(rendered.lines.len()); if start >= end { - return Rendered { - lines: Vec::new(), - breaks: Vec::new(), - }; + return Rendered::from_line_plan(LinePlan::default()); } - let line_count = end - start; - let lines = rendered - .lines - .into_iter() - .skip(start) - .take(line_count) - .collect::>(); - let breaks = rendered - .breaks - .into_iter() - .skip(start) - .take(line_count.saturating_sub(1)) - .collect::>(); - Rendered { lines, breaks } + Rendered::from_line_plan(rendered.lines.slice(start..end)) } fn exact_rendered_height( @@ -6113,14 +6101,12 @@ fn render_column_window( start: i64, height: i64, ) -> Result { - let mut leaves = Vec::new(); - collect_column_leaves(children, &mut leaves); let end = start.saturating_add(height).max(start); let target = context.viewport_width.max(0); let mut offset = 0_i64; let mut parts = Vec::new(); - for child in leaves { + for child in column_leaves(children) { let child_height = exact_rendered_height(child, resolver, context) .ok_or_else(|| "Native layout column window has an unbounded child".to_owned())?; let child_end = offset.saturating_add(child_height); @@ -6152,12 +6138,14 @@ fn render_column_window( }; let extra = (target - rendered.first_width()).max(0); - for line in &mut rendered.lines { - line.push_space(extra); - } if extra > 0 { - rendered.breaks = - vec![AtomProperties::default(); rendered.lines.len().saturating_sub(1)]; + rendered.lines = rendered + .lines + .map_lines(|_, mut line| { + line.push_space(extra); + line + }) + .clear_breaks(); } if !rendered.lines.is_empty() { parts.push(rendered); @@ -6856,10 +6844,11 @@ fn render_node_with_override( }); if transparent_preformatted { let mut rendered = child_rendered.take().expect("validated child"); - for (index, line) in rendered.lines.iter_mut().enumerate() { + rendered.lines = rendered.lines.map_lines(|index, mut line| { line.own_content(*region_id, index as i64); line.apply_property_template(*surface_template_id); - } + line + }); return Ok(rendered); } @@ -6873,10 +6862,8 @@ fn render_node_with_override( line.apply_property_template(*content_surface_template_id); } } - content_lines - .into_iter() - .map(|line| line.padded(content_width, *text_align)) - .collect::>() + LinePlan::from_lines(content_lines) + .map_lines(|_, line| line.padded(content_width, *text_align)) } else { let rendered = child_rendered.expect("validated child"); let uniform_width = rendered @@ -6889,18 +6876,17 @@ fn render_node_with_override( } else { rendered }; - rendered - .lines - .into_iter() - .map(|line| { - if preserve_exact_width { - line - } else { - line.padded(content_width, *text_align) - } - }) - .collect::>() + if preserve_exact_width { + rendered.lines + } else { + rendered + .lines + .map_lines(|_, line| line.padded(content_width, *text_align)) + } }; + // Ordinary Box formatting historically rebuilds default breaks; + // the transparent preformatted branch above preserves child breaks. + formatted = formatted.clear_breaks(); let text_height = windowed_child_height.unwrap_or(formatted.len() as i64); let minimum_height = resolve_height( min_height, @@ -6953,7 +6939,7 @@ fn render_node_with_override( && *vertical == VerticalAlign::Top; let mut content_index_start = 0_i64; - let mut overflow_lines = Vec::new(); + let mut overflow_lines = LinePlan::default(); if let Some(start) = windowed_child_start { content_index_start = start; } else if formatted.len() > content_height as usize { @@ -6964,48 +6950,46 @@ fn render_node_with_override( if simple_scroll_rendered { content_index_start = start as i64; } - formatted = formatted[start..start + content_height as usize].to_vec(); + formatted = formatted.slice(start..start + content_height as usize); } Overflow::Hidden => { - formatted.truncate(content_height as usize); + formatted = formatted.slice(0..content_height as usize); } Overflow::Visible => { - overflow_lines = formatted.split_off(content_height as usize); + overflow_lines = formatted.slice(content_height as usize..formatted.len()); + formatted = formatted.slice(0..content_height as usize); } } } - for (index, line) in formatted.iter_mut().enumerate() { + formatted = formatted.map_lines(|index, mut line| { line.own_content(*region_id, content_index_start + index as i64); - } - let mut lines = + line + }); + let lines = vertical_align(formatted, content_height as usize, *vertical, content_width) - .into_iter() - .map(|line| { + .map_lines(|_, line| { if simple_scroll_rendered { line } else { line.collapse_whitespace_content(content_width, *region_id) } - }) - .collect::>(); + }); - let mut padded = - Vec::with_capacity(lines.len() + *padding_top as usize + *padding_bottom as usize); - padded.extend((0..*padding_top).map(|_| { + let mut padded = LinePlan::from_lines((0..*padding_top).map(|_| { Line::blank_with_properties( content_width, region_properties(RegionRole::PaddingTop, *region_id, None), ) })); - padded.append(&mut lines); - padded.extend((0..*padding_bottom).map(|_| { + padded = padded.concat(&lines); + padded = padded.concat(&LinePlan::from_lines((0..*padding_bottom).map(|_| { Line::blank_with_properties( content_width, region_properties(RegionRole::PaddingBottom, *region_id, None), ) - })); + }))); - for line in &mut padded { + padded = padded.map_lines(|_, mut line| { line.prepend_space_with_properties( *padding_left, region_properties(RegionRole::PaddingLeft, *region_id, None), @@ -7025,23 +7009,27 @@ fn render_node_with_override( *border_right, region_properties(RegionRole::BorderRight, *region_id, *border_right_style), ); + line + }); + if border_top_style.is_some() && !padded.is_empty() { + padded = padded.update_line(0, |mut line| { + line.apply_style(*border_top_style); + line.apply_role(RegionRole::BorderTop, *region_id); + line + }); } - if border_top_style.is_some() { - if let Some(first) = padded.first_mut() { - first.apply_style(*border_top_style); - first.apply_role(RegionRole::BorderTop, *region_id); - } + if border_bottom_style.is_some() && !padded.is_empty() { + padded = padded.update_line(padded.len() - 1, |mut line| { + line.apply_style(*border_bottom_style); + line.apply_role(RegionRole::BorderBottom, *region_id); + line + }); } - if border_bottom_style.is_some() { - if let Some(last) = padded.last_mut() { - last.apply_style(*border_bottom_style); - last.apply_role(RegionRole::BorderBottom, *region_id); - } - } - for line in &mut padded { + padded = padded.map_lines(|_, mut line| { line.apply_property_template(*surface_template_id); - } - for line in &mut padded { + line + }); + padded = padded.map_lines(|_, mut line| { line.prepend_space_with_properties( *margin_left, region_properties(RegionRole::MarginLeft, *region_id, None), @@ -7050,33 +7038,33 @@ fn render_node_with_override( *margin_right, region_properties(RegionRole::MarginRight, *region_id, None), ); - } + line + }); let total_width = content_width + side_width; - let mut output = - Vec::with_capacity(padded.len() + *margin_top as usize + *margin_bottom as usize); - output.extend((0..*margin_top).map(|_| { + let mut output = LinePlan::from_lines((0..*margin_top).map(|_| { Line::blank_with_properties( total_width, region_properties(RegionRole::MarginTop, *region_id, None), ) })); - output.append(&mut padded); - output.extend((0..*margin_bottom).map(|_| { + output = output.concat(&padded); + output = output.concat(&LinePlan::from_lines((0..*margin_bottom).map(|_| { Line::blank_with_properties( total_width, region_properties(RegionRole::MarginBottom, *region_id, None), ) - })); - let mut rendered = Rendered::from_lines(output); + }))); + let mut rendered = Rendered::from_line_plan(output); if !overflow_lines.is_empty() { let left_space = margin_left + border_left + padding_left; let right_space = padding_right + border_right + margin_right; - for line in &mut overflow_lines { + overflow_lines = overflow_lines.map_lines(|_, mut line| { line.apply_style(*foreground_style); line.prepend_space(left_space); line.push_space(right_space); - } - rendered = stack_vertical(vec![rendered, Rendered::from_lines(overflow_lines)]); + line + }); + rendered = stack_vertical(vec![rendered, Rendered::from_line_plan(overflow_lines)]); } if *overflow == Overflow::Scroll && text_height > content_height { rendered.apply_scroll_window(*region_id); @@ -7111,30 +7099,17 @@ fn render_node_with_override( .iter() .map(|child| render_node(child, resolver, child_context, intrinsic)) .collect::, _>>()?; - let height = rendered - .iter() - .map(|item| item.lines.len()) - .max() - .unwrap_or(1); - let mut lines = Vec::with_capacity(height); - for index in 0..height { - let mut line = Line::default(); - for item in &rendered { - if let Some(child_line) = item.lines.get(index) { - line.append(child_line); - } else { - line.push_space(item.first_width()); - } - } - lines.push(line); - } - Ok(Rendered::from_lines(lines)) + let parts = rendered + .into_iter() + .map(|rendered| { + let width = rendered.first_width(); + (rendered, width) + }) + .collect(); + Ok(concat_horizontal_sized(parts, 0)) } LayoutNode::Column { children, .. } => { - let mut leaves = Vec::new(); - collect_column_leaves(children, &mut leaves); - let rendered = leaves - .into_iter() + let rendered = column_leaves(children) .map(|child| render_node(child, resolver, context, intrinsic)) .collect::, _>>()?; let maximum = rendered @@ -7153,12 +7128,14 @@ fn render_node_with_override( let mut parts = Vec::with_capacity(rendered.len()); for mut item in rendered { let extra = (target - item.first_width()).max(0); - for line in &mut item.lines { - line.push_space(extra); - } if extra > 0 { - item.breaks = - vec![AtomProperties::default(); item.lines.len().saturating_sub(1)]; + item.lines = item + .lines + .map_lines(|_, mut line| { + line.push_space(extra); + line + }) + .clear_breaks(); } parts.push(item); } @@ -7194,14 +7171,19 @@ fn render_node_with_override( } } -fn collect_column_leaves<'a>(children: &'a [LayoutNode], output: &mut Vec<&'a LayoutNode>) { - for child in children { - if let LayoutNode::Column { children, .. } = child { - collect_column_leaves(children, output); - } else { - output.push(child); +// Flatten only literal Columns. In particular, a retained NodeRef remains a +// child evaluation boundary even when it resolves to an identified Column. +fn column_leaves(children: &[LayoutNode]) -> impl Iterator { + let mut stack = vec![children.iter()]; + std::iter::from_fn(move || loop { + match stack.last_mut()?.next() { + Some(LayoutNode::Column { children, .. }) => stack.push(children.iter()), + Some(child) => return Some(child), + None => { + stack.pop(); + } } - } + }) } #[cfg(test)] @@ -7711,14 +7693,14 @@ mod tests { }), ) .expect("typed column render"); - assert_eq!(rendered.lines[0].width, 100); + assert_eq!(rendered.lines.get(0).unwrap().width, 100); assert!( matches!( - rendered.lines[0].atoms.first(), + rendered.lines.get(0).unwrap().atoms.first(), Some(Atom::Space { width: 45, .. }) ), "{:?}", - rendered.lines[0].atoms + rendered.lines.get(0).unwrap().atoms ); } @@ -8210,18 +8192,28 @@ mod tests { #[test] fn vertical_stack_and_scroll_preserve_break_metadata() { let mut first = Rendered::from_lines(vec![Line::blank(1), Line::blank(1)]); - first.breaks[0].owner = Some(11); + first.lines = first.lines.with_break( + 0, + AtomProperties { + owner: Some(11), + ..AtomProperties::default() + }, + ); let second = Rendered::from_lines(vec![Line::blank(1)]); let mut stacked = stack_vertical(vec![first, second]); - assert_eq!(stacked.breaks.len(), 2); - assert_eq!(stacked.breaks[0].owner, Some(11)); - assert_eq!(stacked.breaks[1], AtomProperties::default()); + assert_eq!(stacked.lines.len() - 1, 2); + assert_eq!(stacked.lines.break_after(0).unwrap().owner, Some(11)); + assert_eq!( + stacked.lines.break_after(1), + Some(&AtomProperties::default()) + ); stacked.apply_scroll_window(9); assert!(stacked - .breaks - .iter() + .lines + .iter_with_breaks() + .filter_map(|(_, properties)| properties) .all(|properties| { properties.scroll_window == Some(9) })); assert!(stacked.lines.iter().all(|line| { line.atoms @@ -8239,6 +8231,233 @@ mod tests { assert!(tape.lines[2].break_after.is_none()); } + #[test] + fn line_sequence_oracle_wrap_slice_and_join_keep_distinct_breaks() { + let source = Rendered { + 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)]), + ]) + .with_break( + 0, + AtomProperties { + owner: Some(17), + scroll_window: Some(9), + ..AtomProperties::default() + }, + ), + }; + let wrapped = wrap_rendered(source, 2, WrapMode::Char); + let tape = wrapped.clone().into_tape(0); + assert_eq!(tape.lines.len(), 4); + assert_eq!(tape.lines[0].break_after, Some(AtomProperties::default())); + assert_eq!(tape.lines[1].break_after.as_ref().unwrap().owner, Some(17)); + assert_eq!(tape.lines[2].break_after, Some(AtomProperties::default())); + let middle = slice_rendered(wrapped, 1, 2); + assert_eq!( + middle.clone().into_tape(0).lines, + tape.lines[1..3] + .iter() + .cloned() + .enumerate() + .map(|(index, mut line)| { + if index == 1 { + line.break_after = None; + } + line + }) + .collect::>() + ); + let joined = + stack_vertical(vec![middle, Rendered::from_lines(vec![Line::blank(1)])]).into_tape(0); + assert_eq!( + joined.lines[0].break_after.as_ref().unwrap().owner, + Some(17) + ); + assert_eq!(joined.lines[1].break_after, Some(AtomProperties::default())); + } + + fn nonuniform_text(region_id: i64, widths: &[i64]) -> LayoutNode { + LayoutNode::Text { + node_id: None, + node_revision: None, + region_id, + content: Arc::new(measured_text( + widths + .iter() + .map(|width| vec![cluster("x", *width, None)]) + .collect(), + )), + typography_style: None, + foreground_style: None, + surface_template_id: None, + wrap_mode: WrapMode::None, + } + } + + #[test] + fn line_sequence_oracle_column_uses_first_width_and_constant_child_extra() { + let column = LayoutNode::Column { + node_id: None, + node_revision: None, + children: Arc::new(vec![ + nonuniform_text(1, &[2, 9]), + nonuniform_text(2, &[5, 1]), + ]), + }; + let tape = render_node(&column, None, test_context(), true) + .unwrap() + .into_tape(0); + assert_eq!( + tape.lines.iter().map(|line| line.width).collect::>(), + [5, 12, 5, 1] + ); + let tape = render_node( + &column, + None, + LayoutContext { + viewport_width: 3, + ..test_context() + }, + false, + ) + .unwrap() + .into_tape(0); + assert_eq!( + tape.lines.iter().map(|line| line.width).collect::>(), + [3, 10, 5, 1] + ); + } + + #[test] + fn line_sequence_oracle_column_flattens_literal_columns_but_not_node_refs() { + let inner = identified( + LayoutNode::Column { + node_id: None, + node_revision: None, + children: Arc::new(vec![nonuniform_text(1, &[2]), nonuniform_text(2, &[5])]), + }, + 2, + 1, + ); + let root = identified( + LayoutNode::Column { + node_id: None, + node_revision: None, + children: Arc::new(vec![inner, nonuniform_text(3, &[8])]), + }, + 1, + 1, + ); + let document = retained_document(root); + let (retained, _) = RetainedDocument::bootstrap(document.clone()).unwrap(); + let full = render_node(&document.root, None, test_context(), true) + .unwrap() + .into_tape(0); + let referenced = render_node( + &LayoutNode::NodeRef { + node_id: retained.root_id, + }, + Some(&retained), + test_context(), + true, + ) + .unwrap() + .into_tape(0); + assert_eq!( + full.lines + .iter() + .map(|line| line.atoms.len()) + .collect::>(), + [2, 2, 1] + ); + assert_eq!( + referenced + .lines + .iter() + .map(|line| line.atoms.len()) + .collect::>(), + [3, 2, 1] + ); + assert_eq!( + full.lines.iter().map(|line| line.width).collect::>(), + [8, 8, 8] + ); + assert_eq!( + referenced + .lines + .iter() + .map(|line| line.width) + .collect::>(), + [8, 8, 8] + ); + } + + #[test] + fn line_sequence_oracle_column_only_positive_extra_clears_scroll_breaks() { + let mut scrolled = child_box(11, nonuniform_text(12, &[2, 2, 2]), None); + let LayoutNode::Box { height, .. } = &mut scrolled else { + unreachable!() + }; + *height = Size::Lines { value: 2 }; + let column = LayoutNode::Column { + node_id: None, + node_revision: None, + children: Arc::new(vec![scrolled]), + }; + for (width, expected_scroll) in [(1, Some(11)), (2, Some(11)), (3, None)] { + let tape = render_node( + &column, + None, + LayoutContext { + viewport_width: width, + ..test_context() + }, + false, + ) + .unwrap() + .into_tape(0); + assert_eq!( + tape.lines[0].break_after.as_ref().unwrap().scroll_window, + expected_scroll + ); + } + } + + #[test] + fn line_sequence_oracle_horizontal_missing_lines_use_first_width() { + let row = LayoutNode::Row { + node_id: None, + node_revision: None, + children: Arc::new(vec![ + nonuniform_text(1, &[2, 9]), + nonuniform_text(2, &[5, 1, 3]), + ]), + }; + let tape = render_node(&row, None, test_context(), true) + .unwrap() + .into_tape(0); + assert_eq!( + tape.lines.iter().map(|line| line.width).collect::>(), + [7, 10, 5] + ); + let parts = vec![ + ( + Rendered::from_lines(vec![Line::blank(2), Line::blank(9)]), + 4, + ), + ( + Rendered::from_lines(vec![Line::blank(5), Line::blank(1), Line::blank(3)]), + 6, + ), + ]; + let tape = concat_horizontal_sized(parts, 4).into_tape(0); + assert_eq!( + tape.lines.iter().map(|line| line.width).collect::>(), + [7, 10, 7, 10] + ); + } + #[test] fn binary_tapes_are_versioned_bounded_and_support_light_frames() { let properties = AtomProperties { @@ -8258,7 +8477,7 @@ mod tests { Line::blank_with_properties(8, properties.clone()), Line::blank_with_properties(8, properties.clone()), ]); - rendered.breaks[0] = properties; + rendered.lines = rendered.lines.with_break(0, properties); let tape = rendered.into_tape(3); let styles = vec![ StyleTemplate { diff --git a/native/src/lib.rs b/native/src/lib.rs index 6ffb988..7f3769f 100644 --- a/native/src/lib.rs +++ b/native/src/lib.rs @@ -470,6 +470,7 @@ struct RenderedJob { target_renders: u64, resolver_lookups: u64, atom_plan_work: layout::AtomPlanWork, + line_plan_work: layout::LinePlanWork, } #[derive(Default)] @@ -523,6 +524,7 @@ struct RuntimeState { document_trie_path_nodes_copied: u64, document_resolver_lookups: u64, atom_plan_work: layout::AtomPlanWork, + line_plan_work: layout::LinePlanWork, confirmed_baseline: Option>, } @@ -547,6 +549,23 @@ struct Shared { result_available: Condvar, } +impl Shared { + fn request_stop(&self) { + // Both worker condition variables inspect `alive` under this mutex. + // Publish shutdown under the same lock so notification cannot fall + // between a worker's predicate check and its atomic unlock-and-wait. + { + let _state = self + .state + .lock() + .unwrap_or_else(|poison| poison.into_inner()); + self.alive.store(false, Ordering::Release); + } + self.job_available.notify_all(); + self.result_available.notify_all(); + } +} + #[derive(Debug)] struct Session { shared: Arc, @@ -584,6 +603,7 @@ struct SessionStats { document_trie_path_nodes_copied: u64, document_resolver_lookups: u64, atom_plan_work: layout::AtomPlanWork, + line_plan_work: layout::LinePlanWork, pending_baselines: usize, confirmed_baseline: bool, confirmed_baseline_bytes: usize, @@ -657,6 +677,7 @@ impl Session { document_trie_path_nodes_copied: 0, document_resolver_lookups: 0, atom_plan_work: layout::AtomPlanWork::default(), + line_plan_work: layout::LinePlanWork::default(), confirmed_baseline, }), readiness_channel: Mutex::new(None), @@ -672,8 +693,7 @@ impl Session { match spawn { Ok(handle) => handles.push(handle), Err(error) => { - shared.alive.store(false, Ordering::Release); - shared.job_available.notify_all(); + shared.request_stop(); for handle in handles { let _ = handle.join(); } @@ -1083,6 +1103,7 @@ impl Session { state.target_renders += output.target_renders; state.document_resolver_lookups += output.resolver_lookups; state.atom_plan_work.accumulate(output.atom_plan_work); + state.line_plan_work.accumulate(output.line_plan_work); state.document_parses += input_stats.parses; state.document_validations += input_stats.validations; state.document_reuses += input_stats.reuses; @@ -1232,6 +1253,7 @@ impl Session { document_trie_path_nodes_copied: state.document_trie_path_nodes_copied, document_resolver_lookups: state.document_resolver_lookups, atom_plan_work: state.atom_plan_work, + line_plan_work: state.line_plan_work, pending_baselines, confirmed_baseline: state.confirmed_baseline.is_some(), confirmed_baseline_bytes, @@ -1246,9 +1268,7 @@ impl Session { fn stop(&self, join: bool) { self.detach_readiness_channel(); - self.shared.alive.store(false, Ordering::Release); - self.shared.job_available.notify_all(); - self.shared.result_available.notify_all(); + self.shared.request_stop(); if let Some(handles) = self .workers .lock() @@ -1445,6 +1465,7 @@ fn render_layout_payload( target_renders: 0, resolver_lookups: 0, atom_plan_work: layout::AtomPlanWork::default(), + line_plan_work: layout::LinePlanWork::default(), }, JobPayload::Layout { document, @@ -1503,6 +1524,7 @@ fn render_layout_payload( >; layout::reset_resolver_lookups(); layout::reset_atom_plan_work(); + layout::reset_line_plan_work(); let result = catch_unwind(AssertUnwindSafe(|| -> LayoutRenderOutcome { let target_styles = document.styles()?; if let Some(base_context) = base_context { @@ -1578,6 +1600,7 @@ fn render_layout_payload( let resolver_lookups = validation_resolver_lookups.saturating_add(layout::resolver_lookups()); let atom_plan_work = layout::atom_plan_work(); + let line_plan_work = layout::line_plan_work(); match result { Ok(Ok((bytes, tape, styles, baseline_hit, base_renders, target_renders))) => { RenderedJob { @@ -1594,6 +1617,7 @@ fn render_layout_payload( target_renders, resolver_lookups, atom_plan_work, + line_plan_work, } } Ok(Err(error)) => RenderedJob { @@ -1604,6 +1628,7 @@ fn render_layout_payload( target_renders: 0, resolver_lookups, atom_plan_work, + line_plan_work, }, Err(_) => RenderedJob { bytes: encode_error_tape(identity, "native layout panicked", max_result_bytes), @@ -1613,6 +1638,7 @@ fn render_layout_payload( target_renders: 0, resolver_lookups, atom_plan_work, + line_plan_work, }, } } @@ -1763,6 +1789,7 @@ fn worker_loop(shared: Arc) { state.target_renders += output.target_renders; state.document_resolver_lookups += output.resolver_lookups; state.atom_plan_work.accumulate(output.atom_plan_work); + state.line_plan_work.accumulate(output.line_plan_work); state.results.insert( (job.generation, job.key), ResultEntry { @@ -2358,6 +2385,69 @@ mod tests { session.stop(true); } + #[test] + fn stopping_serializes_with_a_worker_holding_the_wait_predicate_lock() { + let session = Arc::new(Session::new(1, 1, 1, 4).unwrap()); + let guard = session.shared.state.lock().unwrap(); + let (started_tx, started_rx) = std::sync::mpsc::channel(); + let (completed_tx, completed_rx) = std::sync::mpsc::channel(); + let stopping = Arc::clone(&session); + let stopper = thread::spawn(move || { + started_tx.send(()).unwrap(); + stopping.stop(false); + completed_tx.send(()).unwrap(); + }); + started_rx.recv_timeout(Duration::from_secs(2)).unwrap(); + // A worker holds this mutex between checking the predicate and + // entering Condvar::wait. Shutdown must not publish/notify in that gap. + let completed_while_locked = completed_rx + .recv_timeout(Duration::from_millis(100)) + .is_ok(); + drop(guard); + if !completed_while_locked { + completed_rx.recv_timeout(Duration::from_secs(2)).unwrap(); + } + stopper.join().unwrap(); + assert!( + !completed_while_locked, + "shutdown raced ahead of the worker wait predicate lock" + ); + } + + #[test] + fn stopping_joins_a_worker_blocked_on_result_capacity() { + let session = Arc::new(Session::new(1, 2, 1, 8).unwrap()); + session + .submit( + 1, + &batch(r#"[{"key":1,"payload":"a"},{"key":2,"payload":"b"}]"#), + ) + .unwrap(); + wait_until(|| { + let stats = session.stats(); + stats.completed_jobs == 1 && stats.queued_jobs == 0 + }); + let stopping = Arc::clone(&session); + let (completed_tx, completed_rx) = std::sync::mpsc::channel(); + let stopper = thread::spawn(move || { + stopping.stop(true); + completed_tx.send(()).unwrap(); + }); + let completed_without_rescue = completed_rx.recv_timeout(Duration::from_secs(2)).is_ok(); + if !completed_without_rescue { + // Preserve the failure, but release a stranded waiter before the + // assertion so a broken notification does not hang the test suite. + session.shared.job_available.notify_all(); + session.shared.result_available.notify_all(); + completed_rx.recv_timeout(Duration::from_secs(2)).unwrap(); + } + stopper.join().unwrap(); + assert!( + completed_without_rescue, + "shutdown left a capacity waiter asleep" + ); + } + #[test] fn malformed_and_over_capacity_batches_are_rejected() { let session = Session::new(1, 1, 1, 4).unwrap(); diff --git a/native/src/line_plan.rs b/native/src/line_plan.rs new file mode 100644 index 0000000..2ae4598 --- /dev/null +++ b/native/src/line_plan.rs @@ -0,0 +1,599 @@ +use std::cell::Cell; +use std::ops::Range; +use std::sync::Arc; + +use crate::sequence::{Entry, Iter, Measure, Sequence, Work}; + +use super::{AtomProperties, Line}; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "kebab-case")] +pub(crate) struct LinePlanWork { + pub(crate) sequence_nodes_created: u64, + pub(crate) sequence_entries_written: u64, + pub(crate) iterator_nodes_visited: u64, + pub(crate) lines_visited: u64, + pub(crate) line_lookups: u64, + pub(crate) range_queries: u64, + pub(crate) lines_mapped: u64, + pub(crate) breaks_mapped: u64, + pub(crate) line_clones: u64, + pub(crate) materialized_lines: u64, + pub(crate) materialized_breaks: u64, + pub(crate) materialized_chars: u64, +} + +thread_local! { + static WORK: Cell = Cell::new(LinePlanWork::default()); +} + +pub(crate) fn reset_work() { + WORK.set(LinePlanWork::default()); +} + +pub(crate) fn work() -> LinePlanWork { + WORK.get() +} + +impl LinePlanWork { + pub(crate) fn accumulate(&mut self, other: Self) { + macro_rules! add { + ($($field:ident),+ $(,)?) => { + $(self.$field = self.$field.checked_add(other.$field) + .expect("native line plan work counter overflow");)+ + }; + } + add!( + sequence_nodes_created, + sequence_entries_written, + iterator_nodes_visited, + lines_visited, + line_lookups, + range_queries, + lines_mapped, + breaks_mapped, + line_clones, + materialized_lines, + materialized_breaks, + materialized_chars + ); + } +} + +fn record(delta: LinePlanWork) { + let mut work = WORK.get(); + work.accumulate(delta); + WORK.set(work); +} + +fn record_sequence(work: Work) { + record(LinePlanWork { + sequence_nodes_created: work.nodes_created, + sequence_entries_written: work.entries_written, + ..LinePlanWork::default() + }); +} + +pub(super) fn record_materialized(lines: usize, chars: u64) { + record(LinePlanWork { + materialized_lines: lines as u64, + materialized_breaks: lines.saturating_sub(1) as u64, + materialized_chars: chars, + ..LinePlanWork::default() + }); +} + +pub(super) fn clone_line(line: &Line) -> Line { + record(LinePlanWork { + line_clones: 1, + ..LinePlanWork::default() + }); + line.clone() +} + +#[derive(Debug)] +struct LineEntry { + line: Line, + // Stored even at the terminal entry, where it is not part of the output. + // Concatenation replaces that boundary explicitly before it becomes visible. + break_after: Arc, +} + +fn entry(line: Line, break_after: Arc) -> Entry { + // One virtual newline per entry makes prefixes additive. The final newline + // is removed by each output-range summary, including empty/zero-width lines. + let chars = line + .atoms + .char_count() + .checked_add(1) + .expect("validated native line character count fits u64"); + let measure = Measure::new(chars, 1, line.width, line.width) + .expect("validated native line width is nonnegative"); + Entry::new(Arc::new(LineEntry { line, break_after }), measure) + .expect("validated native line measure") +} + +#[derive(Clone, Debug, Default)] +pub(super) struct LinePlan { + entries: Sequence, +} + +/// Borrowed traversal; counts both sequence-node visits and yielded lines. +pub(super) struct Lines<'a> { + entries: Iter<'a, LineEntry>, +} + +impl<'a> Iterator for Lines<'a> { + type Item = &'a Line; + + fn next(&mut self) -> Option { + let before = self.entries.nodes_visited(); + let value = self.entries.next(); + record(LinePlanWork { + iterator_nodes_visited: self.entries.nodes_visited() - before, + lines_visited: u64::from(value.is_some()), + ..LinePlanWork::default() + }); + value.map(|entry| &entry.value().line) + } + + fn size_hint(&self) -> (usize, Option) { + self.entries.size_hint() + } +} + +impl ExactSizeIterator for Lines<'_> {} + +pub(super) struct LinesAndBreaks<'a> { + lines: Lines<'a>, +} + +impl<'a> Iterator for LinesAndBreaks<'a> { + type Item = (&'a Line, Option<&'a AtomProperties>); + + fn next(&mut self) -> Option { + let before = self.lines.entries.nodes_visited(); + let value = self.lines.entries.next(); + record(LinePlanWork { + iterator_nodes_visited: self.lines.entries.nodes_visited() - before, + lines_visited: u64::from(value.is_some()), + ..LinePlanWork::default() + }); + let entry = value?.value(); + Some(( + &entry.line, + (self.lines.entries.len() > 0).then_some(entry.break_after.as_ref()), + )) + } + + fn size_hint(&self) -> (usize, Option) { + self.lines.size_hint() + } +} + +impl ExactSizeIterator for LinesAndBreaks<'_> {} + +impl LinePlan { + pub(super) fn from_lines(lines: impl IntoIterator) -> Self { + let default_break = Arc::new(AtomProperties::default()); + Self::from_entries( + lines + .into_iter() + .map(|line| entry(line, Arc::clone(&default_break))), + ) + } + + fn from_entries(entries: impl IntoIterator>) -> Self { + let mut work = Work::default(); + let entries = Sequence::from_entries(entries, &mut work) + .expect("validated native line sequence aggregates fit limits"); + record_sequence(work); + Self { entries } + } + + pub(super) fn len(&self) -> usize { + self.entries.len() + } + + pub(super) fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + pub(super) fn iter(&self) -> Lines<'_> { + Lines { + entries: self.entries.iter(), + } + } + + pub(super) fn iter_with_breaks(&self) -> LinesAndBreaks<'_> { + LinesAndBreaks { lines: self.iter() } + } + + fn get_entry(&self, index: usize) -> Option<&LineEntry> { + record(LinePlanWork { + line_lookups: 1, + ..LinePlanWork::default() + }); + self.entries.get(index).map(|entry| entry.value().as_ref()) + } + + pub(super) fn get(&self, index: usize) -> Option<&Line> { + self.get_entry(index).map(|entry| &entry.line) + } + + pub(super) fn first(&self) -> Option<&Line> { + self.get(0) + } + + pub(super) fn max_width(&self) -> i64 { + self.entries.measure().width_max + } + + pub(super) fn height(&self) -> i64 { + i64::try_from(self.entries.measure().lines).expect("validated native line height fits i64") + } + + pub(super) fn break_after(&self, index: usize) -> Option<&AtomProperties> { + (index < self.len().saturating_sub(1)).then(|| { + self.get_entry(index) + .expect("checked line index") + .break_after + .as_ref() + }) + } + + /// Character offset of a line start, or the complete output length at len(). + pub(super) fn prefix_chars(&self, end: usize) -> u64 { + let chars = self.range_measure(0..end).chars; + chars + .checked_add(u64::from(end > 0 && end < self.len())) + .expect("validated native line prefix fits u64") + } + + /// Exact standalone range extent: height, widths, and chars excluding its final newline. + pub(super) fn range_measure(&self, range: Range) -> Measure { + record(LinePlanWork { + range_queries: 1, + ..LinePlanWork::default() + }); + let mut measure = self + .entries + .range_measure(range) + .expect("validated native line range"); + measure.chars = measure.chars.saturating_sub(u64::from(measure.lines > 0)); + measure + } + + pub(super) fn slice(&self, range: Range) -> Self { + let mut work = Work::default(); + let entries = self + .entries + .slice(range, &mut work) + .expect("validated native line slice"); + record_sequence(work); + Self { entries } + } + + pub(super) fn replace_line(&self, index: usize, line: Line) -> Self { + let old = self + .get_entry(index) + .expect("validated native line replacement"); + self.replace_entry(index, entry(line, Arc::clone(&old.break_after))) + } + + pub(super) fn update_line(&self, index: usize, update: impl FnOnce(Line) -> Line) -> Self { + let line = clone_line(self.get(index).expect("validated native line update")); + record(LinePlanWork { + lines_mapped: 1, + ..LinePlanWork::default() + }); + self.replace_line(index, update(line)) + } + + pub(super) fn with_break(&self, index: usize, properties: AtomProperties) -> Self { + let old = self + .break_after(index) + .expect("validated native break index"); + if *old == properties { + return self.clone(); + } + self.set_stored_break(index, Arc::new(properties)) + } + + fn set_stored_break(&self, index: usize, properties: Arc) -> Self { + let old = self + .get_entry(index) + .expect("validated native break replacement"); + self.replace_entry(index, entry(clone_line(&old.line), properties)) + } + + fn replace_entry(&self, index: usize, entry: Entry) -> Self { + let mut work = Work::default(); + let entries = self + .entries + .replace(index, entry, &mut work) + .expect("validated native line replacement aggregates fit limits"); + record_sequence(work); + Self { entries } + } + + pub(super) fn concat(&self, other: &Self) -> Self { + self.concat_with_break(other, AtomProperties::default()) + } + + pub(super) fn concat_with_break(&self, other: &Self, properties: AtomProperties) -> Self { + if self.is_empty() { + return other.clone(); + } + if other.is_empty() { + return self.clone(); + } + let mut work = Work::default(); + let entries = self + .entries + .concat(&other.entries, &mut work) + .expect("validated native line concatenation aggregates fit limits"); + record_sequence(work); + Self { entries }.with_break(self.len() - 1, properties) + } + + /// Full O(N) line transform. Atom payloads remain shared until changed by the callback. + pub(super) fn map_lines(&self, mut map: impl FnMut(usize, Line) -> Line) -> Self { + let mut iter = self.entries.iter(); + let result = Self::from_entries(iter.by_ref().enumerate().map(|(index, old)| { + record(LinePlanWork { + lines_visited: 1, + lines_mapped: 1, + ..LinePlanWork::default() + }); + entry( + map(index, clone_line(&old.value().line)), + Arc::clone(&old.value().break_after), + ) + })); + record(LinePlanWork { + iterator_nodes_visited: iter.nodes_visited(), + ..LinePlanWork::default() + }); + result + } + + /// Full O(N) break transform. Its traversal and rewritten sequence entries are counted. + pub(super) fn map_breaks(&self, mut map: impl FnMut(&mut AtomProperties)) -> Self { + let mut iter = self.entries.iter(); + let last = self.len().saturating_sub(1); + let result = Self::from_entries(iter.by_ref().enumerate().map(|(index, old)| { + record(LinePlanWork { + lines_visited: 1, + ..LinePlanWork::default() + }); + if index == last { + return old.clone(); + } + let mut properties = old.value().break_after.as_ref().clone(); + map(&mut properties); + record(LinePlanWork { + breaks_mapped: 1, + ..LinePlanWork::default() + }); + entry(clone_line(&old.value().line), Arc::new(properties)) + })); + record(LinePlanWork { + iterator_nodes_visited: iter.nodes_visited(), + ..LinePlanWork::default() + }); + result + } + + pub(super) fn clear_breaks(&self) -> Self { + self.map_breaks(|properties| *properties = AtomProperties::default()) + } +} + +#[cfg(test)] +mod tests { + use super::super::{MeasuredCluster, Rendered}; + use super::*; + + fn text(value: &str, width: i64) -> Line { + Line::from_clusters(&[MeasuredCluster { + text: value.to_owned(), + width, + cjk: false, + space: false, + pixel_space: false, + source_template_id: None, + }]) + } + + #[test] + fn prefix_and_range_summaries_exclude_exactly_one_terminal_newline() { + let empty = LinePlan::default(); + assert_eq!(empty.prefix_chars(0), 0); + assert_eq!(empty.range_measure(0..0), Measure::default()); + let lines = LinePlan::from_lines([text("界a", 3), Line::default(), text("z", 0)]); + assert_eq!((lines.height(), lines.max_width()), (3, 3)); + assert_eq!( + (0..=3) + .map(|end| lines.prefix_chars(end)) + .collect::>(), + [0, 3, 4, 5] + ); + let middle = lines.range_measure(1..3); + assert_eq!((middle.chars, middle.lines, middle.width_max), (2, 2, 0)); + let single_empty = lines.slice(1..2); + assert_eq!(single_empty.prefix_chars(1), 0); + assert_eq!(single_empty.height(), 1); + let two_empty = LinePlan::from_lines([Line::default(), Line::default()]); + assert_eq!(two_empty.prefix_chars(1), 1); + assert_eq!(two_empty.prefix_chars(2), 1); + assert_eq!(two_empty.range_measure(0..2).chars, 1); + } + + #[test] + fn sliced_terminal_break_never_reappears_as_a_default_join() { + let original = LinePlan::from_lines([text("a", 1), text("b", 1), text("c", 1)]) + .with_break( + 0, + AtomProperties { + owner: Some(11), + ..AtomProperties::default() + }, + ) + .with_break( + 1, + AtomProperties { + scroll_window: Some(12), + ..AtomProperties::default() + }, + ); + let prefix = original.slice(0..1); + assert!(prefix.break_after(0).is_none()); + let joined = prefix.concat(&original.slice(1..3)); + assert_eq!(joined.break_after(0), Some(&AtomProperties::default())); + assert_eq!(joined.break_after(1).unwrap().scroll_window, Some(12)); + assert_eq!(original.break_after(0).unwrap().owner, Some(11)); + let explicit = prefix.concat_with_break( + &original.slice(1..3), + AtomProperties { + owner: Some(13), + ..AtomProperties::default() + }, + ); + assert_eq!(explicit.break_after(0).unwrap().owner, Some(13)); + assert_eq!(prefix.concat(&LinePlan::default()).prefix_chars(1), 1); + } + + #[test] + fn replacement_slice_and_concat_share_paths_across_sizes() { + for size in [32_usize, 128, 512, 8192] { + let original = LinePlan::from_lines((0..size).map(|_| text("a", 1))); + let suffix_entry = Arc::clone(original.entries.get(size - 1).unwrap().value()); + let before = Rendered::from_line_plan(original.clone()).into_tape(0); + reset_work(); + let fork = original.clone(); + assert_eq!(work(), LinePlanWork::default()); + let updated = original.replace_line(1, text("hello", 5)); + let replaced = work(); + assert!(replaced.sequence_nodes_created <= u64::from(size.ilog2()) + 1); + assert!(replaced.sequence_entries_written <= 16); + assert_eq!((replaced.lines_mapped, replaced.materialized_lines), (0, 0)); + assert!(Arc::ptr_eq( + updated.entries.get(size - 1).unwrap().value(), + &suffix_entry + )); + assert_eq!( + updated.prefix_chars(size - 1), + original.prefix_chars(size - 1) + 4 + ); + assert_eq!((updated.height(), updated.max_width()), (size as i64, 5)); + reset_work(); + let selected = updated.slice(size / 4..size * 3 / 4); + let rejoined = updated + .slice(0..size / 4) + .concat(&selected) + .concat(&updated.slice(size * 3 / 4..size)); + let maintained = work(); + assert!(maintained.sequence_nodes_created <= 12 * u64::from(size.ilog2()) + 16); + assert!(maintained.sequence_entries_written <= 128); + assert_eq!( + ( + maintained.lines_visited, + maintained.lines_mapped, + maintained.materialized_lines + ), + (0, 0, 0) + ); + assert!(Arc::ptr_eq( + rejoined.entries.get(size - 1).unwrap().value(), + &suffix_entry + )); + assert_eq!(Rendered::from_line_plan(fork).into_tape(0), before); + let mut oracle = before; + oracle.lines[1] = Rendered::from_lines(vec![text("hello", 5)]) + .into_tape(0) + .lines + .remove(0); + oracle.lines[1].break_after = Some(AtomProperties::default()); + assert_eq!(Rendered::from_line_plan(rejoined).into_tape(0), oracle); + } + } + + #[test] + fn full_maps_iteration_and_tape_materialization_report_real_work() { + let size = 128; + let lines = LinePlan::from_lines((0..size).map(|_| text("a", 1))); + reset_work(); + let mapped = lines.map_lines(|_, mut line| { + line.push_space(1); + line + }); + let line_work = work(); + assert_eq!( + ( + line_work.lines_visited, + line_work.lines_mapped, + line_work.line_clones + ), + (size, size, size) + ); + assert_eq!(line_work.sequence_entries_written, size); + assert_eq!(line_work.iterator_nodes_visited, 15); + assert_eq!(line_work.materialized_lines, 0); + reset_work(); + let decorated = mapped.map_breaks(|properties| properties.owner = Some(9)); + let break_work = work(); + assert_eq!(break_work.lines_visited, size); + assert_eq!(break_work.breaks_mapped, size - 1); + assert_eq!(break_work.sequence_entries_written, size); + assert_eq!(break_work.iterator_nodes_visited, 15); + reset_work(); + let borrowed = decorated.iter().count(); + assert_eq!(borrowed, size as usize); + assert_eq!(work().line_clones, 0); + assert_eq!(work().iterator_nodes_visited, 15); + reset_work(); + let tape = Rendered::from_line_plan(decorated).into_tape(0); + assert_eq!(work().materialized_lines, size); + assert_eq!(work().materialized_breaks, size - 1); + assert_eq!(work().materialized_chars, size * 3 - 1); + assert_eq!(work().lines_visited, size); + assert_eq!(work().iterator_nodes_visited, 15); + assert_eq!(work().line_clones, 0); + assert!(tape.lines.last().unwrap().break_after.is_none()); + } + + #[test] + fn bulk_vertical_stack_shares_children_with_linear_sequence_work() { + for size in [32_usize, 128, 512, 8192] { + let children = (0..size) + .map(|_| LinePlan::from_lines([text("a", 1)])) + .collect::>(); + let parts = children + .iter() + .cloned() + .map(Rendered::from_line_plan) + .collect(); + reset_work(); + let stacked = super::super::stack_vertical(parts); + let joined = work(); + assert_eq!(joined.sequence_nodes_created, size as u64 - 1); + assert_eq!(joined.sequence_entries_written, size as u64 * 4); + assert_eq!( + ( + joined.lines_visited, + joined.line_clones, + joined.materialized_lines + ), + (0, 0, 0) + ); + for (index, child) in children.iter().enumerate() { + assert!(Arc::ptr_eq( + stacked.lines.entries.get(index).unwrap().value(), + child.entries.get(0).unwrap().value() + )); + } + assert_eq!(stacked.lines.prefix_chars(size), size as u64 * 2 - 1); + } + } +} diff --git a/native/src/sequence.rs b/native/src/sequence.rs index 2bf477a..4938502 100644 --- a/native/src/sequence.rs +++ b/native/src/sequence.rs @@ -1,3 +1,4 @@ +use std::ops::Range; use std::sync::Arc; const LEAF_CAPACITY: usize = 16; @@ -162,6 +163,51 @@ pub struct Sequence { root: Option>>, } +/// Borrowed in-order traversal with an O(log N) stack and no entry clones. +pub struct Iter<'a, T> { + pending: Vec<&'a Node>, + leaf: std::slice::Iter<'a, Entry>, + remaining: usize, + nodes_visited: u64, +} + +impl Iter<'_, T> { + pub fn nodes_visited(&self) -> u64 { + self.nodes_visited + } +} + +impl<'a, T> Iterator for Iter<'a, T> { + type Item = &'a Entry; + + fn next(&mut self) -> Option { + loop { + if let Some(entry) = self.leaf.next() { + self.remaining -= 1; + return Some(entry); + } + let node = self.pending.pop()?; + self.nodes_visited = self + .nodes_visited + .checked_add(1) + .expect("sequence traversal count fits u64"); + match node { + Node::Leaf { entries, .. } => self.leaf = entries.iter(), + Node::Branch { left, right, .. } => { + self.pending.push(right); + self.pending.push(left); + } + } + } + } + + fn size_hint(&self) -> (usize, Option) { + (self.remaining, Some(self.remaining)) + } +} + +impl ExactSizeIterator for Iter<'_, T> {} + impl Clone for Sequence { fn clone(&self) -> Self { Self { @@ -228,6 +274,15 @@ impl Sequence { self.root.as_ref() } + pub fn iter(&self) -> Iter<'_, T> { + Iter { + pending: self.root.as_deref().into_iter().collect(), + leaf: [].iter(), + remaining: self.len(), + nodes_visited: 0, + } + } + pub fn get(&self, index: usize) -> Option<&Entry> { fn get_at(node: &Node, index: usize) -> Option<&Entry> { match node { @@ -270,6 +325,55 @@ impl Sequence { .map_or(Ok(Measure::default()), |root| prefix(root, end)) } + /// Aggregate a range by visiting its boundary paths and cached interior nodes. + pub fn range_measure(&self, range: Range) -> Result { + fn measure(node: &Node, start: usize, end: usize) -> Result { + if start == end { + return Ok(Measure::default()); + } + if start == 0 && end == node.len() { + return Ok(node.measure()); + } + match node { + Node::Leaf { entries, .. } => entries[start..end] + .iter() + .try_fold(Measure::default(), |sum, entry| { + sum.checked_combine(entry.measure) + }), + Node::Branch { left, right, .. } => { + let middle = left.len(); + if end <= middle { + measure(left, start, end) + } else if start >= middle { + measure(right, start - middle, end - middle) + } else { + measure(left, start, middle)?.checked_combine(measure( + right, + 0, + end - middle, + )?) + } + } + } + } + if range.start > range.end || range.end > self.len() { + return Err(Error::IndexOutOfBounds); + } + self.root.as_deref().map_or(Ok(Measure::default()), |root| { + measure(root, range.start, range.end) + }) + } + + /// Share the interior of a range, copying only its two boundary paths. + pub fn slice(&self, range: Range, work: &mut Work) -> Result { + if range.start > range.end || range.end > self.len() { + return Err(Error::IndexOutOfBounds); + } + let (before, _) = self.split_at(range.end, work)?; + let (_, selected) = before.split_at(range.start, work)?; + Ok(selected) + } + pub fn split_at(&self, index: usize, work: &mut Work) -> Result<(Self, Self), Error> { if index > self.len() { return Err(Error::IndexOutOfBounds); @@ -642,6 +746,47 @@ mod tests { } } + #[test] + fn borrowed_iteration_and_range_operations_match_exact_measures() { + let mut setup = Work::default(); + let original = Sequence::from_entries((0..97).map(entry), &mut setup).unwrap(); + let mut iter = original.iter(); + for index in 0..original.len() { + assert_eq!(iter.len(), original.len() - index); + assert!(std::ptr::eq( + iter.next().unwrap(), + original.get(index).unwrap() + )); + } + assert!(iter.next().is_none()); + assert_eq!(iter.nodes_visited(), setup.nodes_created); + for start in 0..=97 { + for end in start..=97 { + let expected = original + .iter() + .skip(start) + .take(end - start) + .try_fold(Measure::default(), |sum, entry| { + sum.checked_combine(entry.measure()) + }) + .unwrap(); + assert_eq!(original.range_measure(start..end).unwrap(), expected); + let mut work = Work::default(); + let selected = original.slice(start..end, &mut work).unwrap(); + assert_eq!(selected.measure(), expected); + assert!(selected + .iter() + .zip(original.iter().skip(start)) + .all(|(a, b)| Arc::ptr_eq(a.value(), b.value()))); + } + } + assert_eq!( + original.slice(98..98, &mut Work::default()).unwrap_err(), + Error::IndexOutOfBounds + ); + assert_eq!(original.range_measure(98..98), Err(Error::IndexOutOfBounds)); + } + #[test] fn repeated_left_and_right_concat_remain_balanced() { let mut left = Sequence::empty(); From 826f195236a03bf1d59edd73e9c76675394dc816 Mon Sep 17 00:00:00 2001 From: Kinneyzhang Date: Sat, 5 Sep 2026 11:31:49 +0800 Subject: [PATCH 07/12] Use compressed persistent runtime indexes and retained node-id postorder --- .runtime-index-plan.md | 51 ++ Makefile | 7 +- ...box-current-implementation-reference.en.md | 4 + ...box-current-implementation-reference.zh.md | 4 + ebox-incremental.el | 545 ++++++++++-------- ebox-layout.el | 6 +- ebox-native-commit.el | 65 ++- ebox-native-reflow.el | 66 ++- ebox-patch-plan.el | 7 +- ebox-runtime-index.el | 237 ++++++++ ebox-surface.el | 88 +-- ebox-tree.el | 11 +- ebox.el | 16 +- tests/ebox-child-range-tests.el | 18 +- tests/ebox-commit-tests.el | 202 ++++++- tests/ebox-core-render-tests.el | 29 +- tests/ebox-docs-contract-tests.el | 9 +- tests/ebox-package-tests.el | 3 +- tests/ebox-runtime-index-tests.el | 295 ++++++++++ tests/ebox-source-tests.el | 2 +- tests/ebox-surface-tests.el | 17 +- 21 files changed, 1292 insertions(+), 390 deletions(-) create mode 100644 .runtime-index-plan.md create mode 100644 ebox-runtime-index.el create mode 100644 tests/ebox-runtime-index-tests.el diff --git a/.runtime-index-plan.md b/.runtime-index-plan.md new file mode 100644 index 0000000..7308694 --- /dev/null +++ b/.runtime-index-plan.md @@ -0,0 +1,51 @@ +# Runtime persistent index cleanup plan + +1. Lock the persistent index contract: exact `equal` keys, nil values, + collision buckets, immutable put/delete, count/map/keys, and legacy hash + adapters. +2. Add one fixed-depth radix index module and load it before tree, patch-plan, + incremental, surface, and native commit consumers. +3. Bootstrap node, parent, and region-owner indexes directly into immutable + indexes; store native postorder as node IDs. Bind the generated ID format + to the exact read-only vector; convert unmarked legacy entries with complete + candidate membership validation before retaining that format. +4. Change local candidate preparation to path-copy only removed/touched node and + affected-region keys. Keep all other state tables on their current path. +5. Migrate readers and compatibility entry points to the common adapters. + Preserve hash-table inputs at package boundaries. +6. Keep the complete topology comparison, including untouched surface objects + and source keys. Key-set identity tokens prove only persistent index + membership: value updates retain the token, membership changes replace it, + and unrelated index lineages use the exact key comparison. +7. Verify focused index and candidate regressions, then compile/checkdoc/full + ERT serially after coordinating CPU use with the root agent. + +Stop when all production consumers of the three runtime indexes use adapters, +local candidates perform no whole-table copy/remap, and the focused plus full +validation gates pass. Report remaining full work in region-box/count/source +and other state tables. + +## Compressed radix follow-up + +The compiled fixed-depth version passed behavior checks but regressed measured +row/theme/filter/pagination latency. It is not accepted for integration. + +1. Preserve the measured fixed-depth module, compiled bytecode, tests, and + reviewed patch outside the checkout before editing. +2. Add regressions for compressed branching depth and exact key semantics: + prefix divergence above an existing branch; colliding 64-bit integer codes; + colliding noninteger keys; nil values; deletion collapse and fork isolation; + and deletion of the key that supplied a branch's representative code. +3. Replace mandatory-depth roots with nil, exact collision leaves, and + 16-way branch vectors carrying their actual nibble shift and representative + code. Branches have at least two children and increasing child shifts. + Put returns its membership-change flag without a separate lookup. Delete + replaces unary branches with their surviving child. +4. Keep key-set tokens, hash-table adapters, native postorder representation, + full topology validation, and all publication/rollback paths unchanged. +5. After the CPU owner releases its slot, run focused regressions, compiled + old/new index cost comparisons, then required package checks serially. + Report actual costs to the root agent for full workload measurement. + +Accept this follow-up only after semantic checks pass and measured costs +improve; compressed structure alone does not establish end-to-end latency. diff --git a/Makefile b/Makefile index a60f46d..9f10384 100644 --- a/Makefile +++ b/Makefile @@ -10,7 +10,7 @@ NATIVE_MANIFEST = native/Cargo.toml NATIVE_TARGET ?= $(shell $(EMACS_BATCH) -l ebox-native-reflow.el --eval '(princ (ebox-native-reflow--rust-target))') NATIVE_RELEASE_DIR = native/target/$(NATIVE_TARGET)/release -.PHONY: all check ci load compile test checkdoc source-tests font-tests core-tests child-range-tests grid-tests ebox-commit-tests surface-tests visual-check-tests package-tests selector-tests dsl-tests flex-tests m0a-tests state-contract-tests layout-boundary-tests layout-boundary-performance patch-plan-tests patch-plan-performance style-schema-tests style-schema-performance spi-tests spi-performance c1b-contract-tests docs-contract-tests ci-contract-tests performance-evaluator visual-check native-rust-tests native-build diff-check clean package-lint package-lint-install +.PHONY: all check ci load compile test checkdoc runtime-index-tests source-tests font-tests core-tests child-range-tests grid-tests ebox-commit-tests surface-tests visual-check-tests package-tests selector-tests dsl-tests flex-tests m0a-tests state-contract-tests layout-boundary-tests layout-boundary-performance patch-plan-tests patch-plan-performance style-schema-tests style-schema-performance spi-tests spi-performance c1b-contract-tests docs-contract-tests ci-contract-tests performance-evaluator visual-check native-rust-tests native-build diff-check clean package-lint package-lint-install all: check @@ -25,7 +25,10 @@ compile: rm -f *.elc tests/*.elc scripts/*.elc $(EMACS_BATCH) --eval '(setq byte-compile-error-on-warn t byte-compile-warnings (quote (not obsolete)))' -l ebox.el --eval '(ebox-byte-compile)' -test: source-tests font-tests core-tests child-range-tests grid-tests ebox-commit-tests surface-tests visual-check-tests package-tests selector-tests dsl-tests flex-tests m0a-tests state-contract-tests layout-boundary-tests patch-plan-tests style-schema-tests spi-tests docs-contract-tests ci-contract-tests +test: runtime-index-tests source-tests font-tests core-tests child-range-tests grid-tests ebox-commit-tests surface-tests visual-check-tests package-tests selector-tests dsl-tests flex-tests m0a-tests state-contract-tests layout-boundary-tests patch-plan-tests style-schema-tests spi-tests docs-contract-tests ci-contract-tests + +runtime-index-tests: + $(EMACS_BATCH) -l tests/ebox-runtime-index-tests.el -f ert-run-tests-batch-and-exit source-tests: $(EMACS_BATCH) -l tests/ebox-source-tests.el -f ert-run-tests-batch-and-exit diff --git a/docs/maintainer/ebox-current-implementation-reference.en.md b/docs/maintainer/ebox-current-implementation-reference.en.md index 9f7b909..7f8b89a 100644 --- a/docs/maintainer/ebox-current-implementation-reference.en.md +++ b/docs/maintainer/ebox-current-implementation-reference.en.md @@ -19,6 +19,7 @@ This is the maintainer entry point for the standalone Ebox repository. It descri | `ebox.el` | Public facade, construction helpers, rendering, TP-backed buffer entry points, scrolling, commit, and byte compilation. | | `ebox-cache.el` | Measurement/render cache records, invalidation, and cache reports. | | `ebox-source.el` | Opaque source handles, immutable author source records, and candidate source indexes. | +| `ebox-runtime-index.el` | Compressed persistent radix maps and legacy hash-table adapters. | | `ebox-state-contract.el` | Closed retained-state ownership inventory and read-only compatibility-mirror rebuild probes. | | `ebox-style.el` | ECSS property schemas, declarations and cascade, shorthand expansion, computed style, colors, borders, and dirty effects. | | `ebox-tree.el` | Node traversal, logical child access, ECSS subject adaptation, identity, parent paths, keys, and tree snapshots. | @@ -42,6 +43,8 @@ The package intentionally does not include application Components, UI controls, The active contract also covers `Makefile`, `.github/workflows/ci.yml`, `tests/ebox-core-render-tests.el`, `tests/ebox-state-contract-tests.el`, `tests/ebox-layout-boundary-tests.el`, `tests/ebox-patch-plan-tests.el`, `tests/ebox-style-schema-tests.el`, `tests/ebox-spi-tests.el`, `tests/ebox-child-range-tests.el`, `tests/ebox-grid-tests.el`, `tests/ebox-commit-tests.el`, `tests/ebox-surface-tests.el`, `tests/ebox-dsl-tests.el`, `tests/ebox-flex-tests.el`, `tests/ebox-selector-tests.el`, `tests/ebox-package-tests.el`, `tests/ebox-visual-check-tests.el`, `tests/ebox-docs-contract-tests.el`, `tests/ebox-ci-contract-tests.el`, `native/Cargo.toml`, `native/Cargo.lock`, `native/build.rs`, `native/vendor/emacs-30/emacs-module.h`, `native/src/lib.rs`, `native/src/layout.rs`, `native/c/ebox_module.c`, `scripts/ebox-package-lint.el`, `scripts/ebox-visual-check.el`, and `scripts/ebox-performance-evaluator.el`. +Runtime index behavior is covered by `tests/ebox-runtime-index-tests.el`. + ## Runtime model The normal data flow is: @@ -137,6 +140,7 @@ make load make compile make check make core-tests +make runtime-index-tests make child-range-tests make grid-tests make ebox-commit-tests diff --git a/docs/maintainer/ebox-current-implementation-reference.zh.md b/docs/maintainer/ebox-current-implementation-reference.zh.md index 5e1a47b..35e89f3 100644 --- a/docs/maintainer/ebox-current-implementation-reference.zh.md +++ b/docs/maintainer/ebox-current-implementation-reference.zh.md @@ -19,6 +19,7 @@ | `ebox.el` | 公共门面、构造辅助函数、渲染、基于 TP 的 buffer 入口、滚动、commit 与 byte compile。 | | `ebox-cache.el` | 测量/渲染缓存记录、失效和缓存报告。 | | `ebox-source.el` | opaque source handle、不可变 author source record 与 candidate source index。 | +| `ebox-runtime-index.el` | 压缩持久 radix map 与旧 hash-table adapter。 | | `ebox-state-contract.el` | 封闭的 retained-state 所有权清单与只读 compatibility-mirror 重建探针。 | | `ebox-style.el` | ECSS property schema、declaration 与 cascade、shorthand 展开、computed style、颜色、border 和 dirty effect。 | | `ebox-tree.el` | 节点遍历、逻辑子节点访问、ECSS subject 适配、identity、父路径、key 和树 snapshot。 | @@ -42,6 +43,8 @@ active 合同还覆盖 `Makefile`、`.github/workflows/ci.yml`、`tests/ebox-core-render-tests.el`、`tests/ebox-state-contract-tests.el`、`tests/ebox-layout-boundary-tests.el`、`tests/ebox-patch-plan-tests.el`、`tests/ebox-style-schema-tests.el`、`tests/ebox-spi-tests.el`、`tests/ebox-child-range-tests.el`、`tests/ebox-grid-tests.el`、`tests/ebox-commit-tests.el`、`tests/ebox-surface-tests.el`、`tests/ebox-dsl-tests.el`、`tests/ebox-flex-tests.el`、`tests/ebox-selector-tests.el`、`tests/ebox-package-tests.el`、`tests/ebox-visual-check-tests.el`、`tests/ebox-docs-contract-tests.el`、`tests/ebox-ci-contract-tests.el`、`native/Cargo.toml`、`native/Cargo.lock`、`native/build.rs`、`native/vendor/emacs-30/emacs-module.h`、`native/src/lib.rs`、`native/src/layout.rs`、`native/c/ebox_module.c`、`scripts/ebox-package-lint.el`、`scripts/ebox-visual-check.el` 和 `scripts/ebox-performance-evaluator.el`。 +Runtime index 行为由 `tests/ebox-runtime-index-tests.el` 覆盖。 + ## 运行时模型 正常数据流是: @@ -130,6 +133,7 @@ active 合同还覆盖 `Makefile`、`.github/workflows/ci.yml`、`tests/ebox-cor ```sh make check make core-tests +make runtime-index-tests make child-range-tests make grid-tests make ebox-commit-tests diff --git a/ebox-incremental.el b/ebox-incremental.el index 7910bb9..539a500 100644 --- a/ebox-incremental.el +++ b/ebox-incremental.el @@ -14,6 +14,7 @@ (require 'ebox-buffer-backend) (require 'ebox-render-context) (require 'ebox-patch-plan) +(require 'ebox-runtime-index) (defvar ebox-region-types) (declare-function ebox--scroll-state-region-ids-containing-node-ids @@ -440,8 +441,8 @@ unless root reflow and idle prefetch also avoid crossing several such rows.") (or (plist-get state :selector-index-stale-p) (and (hash-table-p type-counts) (> (or (gethash 'flex type-counts) 0) 0)) - (and (hash-table-p node-table) - (>= (hash-table-count node-table) + (and (ebox-runtime-index-like-p node-table) + (>= (ebox-runtime-index-size node-table) ebox--latency-sensitive-scroll-node-limit))))) (defun ebox--root-reflow-scroll-lookahead-lines-for-buffer (buffer) @@ -1148,10 +1149,10 @@ Keys are region-id, values are plists with :scroll-offset, :content-lines, etc." When COLLECT-REGION-BOXES is non-nil, collect `:region-box-table' during the same traversal in complete-render overwrite order. SOURCE-INDEX, when supplied, is the candidate-owned source snapshot for ROOT-NODE." - (let ((node-table (make-hash-table :test 'equal)) - (parent-table (make-hash-table :test 'equal)) + (let ((node-table (ebox-runtime-index-empty)) + (parent-table (ebox-runtime-index-empty)) (region-id-set (make-hash-table :test 'equal)) - (region-node-table (make-hash-table :test 'equal)) + (region-node-table (ebox-runtime-index-empty)) (region-box-table (and collect-region-boxes (make-hash-table :test 'equal))) (region-box-count-table (make-hash-table :test 'equal)) @@ -1181,17 +1182,21 @@ supplied, is the candidate-owned source snapshot for ROOT-NODE." ;; A flex wrapper is visited after its flex owner. Keep ;; that earlier render-owner mapping instead of ;; replacing it with the non-renderable source box. - (unless (gethash region-id region-node-table) - (puthash region-id node-id region-node-table)))) + (unless (ebox-runtime-index-get region-id region-node-table) + (setq region-node-table + (ebox-runtime-index-put + region-id node-id region-node-table))))) ('flex (when-let* ((box (plist-get node :box))) (let ((region-id (ebox--ensure-region-id box))) (puthash region-id t region-id-set) ;; Preserve the recursive lookup's first-match ;; semantics when malformed trees reuse a region id. - (unless (gethash region-id region-node-table) - (puthash region-id node-id - region-node-table))))))) + (unless (ebox-runtime-index-get region-id region-node-table) + (setq region-node-table + (ebox-runtime-index-put + region-id node-id + region-node-table)))))))) (index-region-box (node) ;; Box rendering records region ownership after rendering ;; child content. Preserve that postorder overwrite order @@ -1211,11 +1216,14 @@ supplied, is the candidate-owned source snapshot for ROOT-NODE." (not (stringp node))) (let ((node-id (ebox--ensure-node-id node)) (type (plist-get node :ebox-type))) - (puthash node-id node node-table) + (setq node-table + (ebox-runtime-index-put node-id node node-table)) (ebox-incremental--adjust-runtime-type-count runtime-type-count-table node 1) (when parent-id - (puthash node-id parent-id parent-table)) + (setq parent-table + (ebox-runtime-index-put + node-id parent-id parent-table))) (index-region-id node node-id) (index-host-ref node node-id) (when-let* ((sequence (plist-get node :ebox-child-sequence))) @@ -1257,12 +1265,12 @@ supplied, is the candidate-owned source snapshot for ROOT-NODE." ;; source boxes are indexed for ownership without adding ;; duplicate compiler work. (when native-layout-p - (push node native-node-postorder)))))) + (push node-id native-node-postorder)))))) (visit root-node nil t)) (setq source-index (or source-index (ebox-tree-source-index root-node))) (setq source-index (ebox-source--with-runtime-hosts source-index host-ref-table)) - (let () + (let ((postorder (vconcat (nreverse native-node-postorder)))) (list :node-table node-table :parent-table parent-table :region-id-set region-id-set @@ -1278,8 +1286,8 @@ supplied, is the candidate-owned source snapshot for ROOT-NODE." :selector-type-table (ebox-source--index-selector-table source-index 'type) :runtime-type-count-table runtime-type-count-table - :native-node-postorder - (vconcat (nreverse native-node-postorder)))))) + :native-node-postorder postorder + :native-node-postorder-ids postorder)))) (defun ebox-incremental--ensure-buffer-selector-indexes (buffer) "Return BUFFER's state after materializing deferred selector indexes." @@ -1337,7 +1345,7 @@ supplied, is the candidate-owned source snapshot for ROOT-NODE." :range-ref-table :source-index :selector-id-table :selector-class-table :selector-type-table :runtime-type-count-table - :native-node-postorder)) + :native-node-postorder :native-node-postorder-ids)) (setq state (plist-put state key (plist-get index key)))) state) @@ -1386,7 +1394,7 @@ supplied, is the candidate-owned source snapshot for ROOT-NODE." (defun ebox-incremental--range-ref-resolve (state ref) "Resolve REF in STATE to its parent, sequence, segment index, and rank." (when-let* ((record (gethash ref (plist-get state :range-ref-table))) - (parent (gethash (plist-get record :parent-node-id) + (parent (ebox-runtime-index-get (plist-get record :parent-node-id) (plist-get state :node-table))) (sequence (plist-get parent :ebox-child-sequence))) (list :parent parent :sequence sequence @@ -1399,7 +1407,7 @@ supplied, is the candidate-owned source snapshot for ROOT-NODE." Use the persistent runtime index when available. Legacy runtime states that lack the index, or an indexed entry, fall back to a recursive tree lookup." (or (when-let* ((table (ebox--buffer-region-node-table buffer))) - (gethash region-id table)) + (ebox-runtime-index-get region-id table)) (when-let* ((root (ebox--buffer-root-node buffer)) (path (ebox--node-path-to-region root region-id))) (ebox--ensure-node-id (car path))))) @@ -2187,7 +2195,7 @@ the planning pass has one for this buffer." (or (when-let* ((base ebox-incremental--candidate-base-state) ((eq buffer (car base))) (node-table (plist-get (cdr base) :node-table))) - (gethash node-id node-table)) + (ebox-runtime-index-get node-id node-table)) (ebox--buffer-runtime-node buffer node-id))) (defun ebox--ensure-layout-snapshot-spans (buffer node-id) @@ -2328,17 +2336,17 @@ When DETAILS is non-nil, include expensive per-line detail fields." (or (and ebox-incremental--candidate-proof-node-table (eq buffer (car ebox-incremental--candidate-proof-node-table)) - (gethash node-id + (ebox-runtime-index-get node-id (cdr ebox-incremental--candidate-proof-node-table))) (when-let* ((node-table (ebox--buffer-node-table buffer))) - (gethash node-id node-table)) + (ebox-runtime-index-get node-id node-table)) (when-let* ((root (ebox--buffer-root-node buffer))) (ebox--runtime-node-by-id root node-id)))) (defun ebox--runtime-parent-id (buffer node-id) "Return NODE-ID's parent id in BUFFER runtime state." (when-let* ((parent-table (ebox--buffer-parent-table buffer))) - (gethash node-id parent-table))) + (ebox-runtime-index-get node-id parent-table))) (defun ebox--invalidate-runtime-render-signature-path (node-id node-table parent-table signature-cache height-cache) @@ -2348,10 +2356,10 @@ When DETAILS is non-nil, include expensive per-line detail fields." (when signature-cache (clrhash signature-cache)) (when height-cache (clrhash height-cache))) (while node-id - (when-let* ((node (gethash node-id node-table))) + (when-let* ((node (ebox-runtime-index-get node-id node-table))) (when signature-cache (remhash node signature-cache)) (when height-cache (remhash node height-cache))) - (setq node-id (gethash node-id parent-table))))) + (setq node-id (ebox-runtime-index-get node-id parent-table))))) (defun ebox--runtime-ancestor-id-p (buffer ancestor-id node-id) "Return non-nil when ANCESTOR-ID is NODE-ID's ancestor in BUFFER." @@ -4184,7 +4192,7 @@ during `ebox-incremental-flush'." (ebox-incremental--candidate-range-items-retainable-p candidate next-items ebox-incremental--source-base-index reuse-map))) - (parent (gethash (plist-get record :parent-node-id) + (parent (ebox-runtime-index-get (plist-get record :parent-node-id) (plist-get base-state :node-table))) (sequence (and parent (plist-get parent :ebox-child-sequence))) (segment (and sequence (ebox-child-range--lookup-ref sequence ref))) @@ -4285,12 +4293,12 @@ during `ebox-incremental-flush'." "Return non-nil when ANCESTOR-ID contains DESCENDANT-ID in CANDIDATE's base." (let* ((state (ebox-candidate--base-state candidate)) (parent-table (plist-get state :parent-table)) - (current (gethash descendant-id parent-table)) + (current (ebox-runtime-index-get descendant-id parent-table)) found) (while (and current (not found)) (if (equal current ancestor-id) (setq found t) - (setq current (gethash current parent-table)))) + (setq current (ebox-runtime-index-get current parent-table)))) found)) (defun ebox-incremental-candidate-replace @@ -4311,7 +4319,7 @@ detached semantic variants of this stable anchor for bounded identity reuse." (let* ((state (ebox-candidate--base-state candidate)) (node-table (plist-get state :node-table)) retained) - (unless (gethash node-id node-table) + (unless (ebox-runtime-index-get node-id node-table) (error "Ebox candidate anchor does not exist: %S" node-id)) (unless (and (listp next-subtree) (not (stringp next-subtree))) (error "Ebox candidate replacement must be an Ebox node")) @@ -4428,7 +4436,7 @@ the patched Host's ancestor chain." (when (eq node-id missing) (error "Ebox candidate host ref does not exist: %S" host-ref)) (catch 'unsupported - (let* ((base-node (gethash node-id (plist-get state :node-table))) + (let* ((base-node (ebox-runtime-index-get node-id (plist-get state :node-table))) (previous-node (ebox-incremental--find-host-ref-node previous-subtree host-ref)) @@ -4565,9 +4573,9 @@ logical tree crosses that boundary." "Return ROOT path-copied with NODE-ID replaced by REPLACEMENT." (let* ((node-table (plist-get index :node-table)) (parent-table (plist-get index :parent-table)) - (old-child (gethash node-id node-table)) + (old-child (ebox-runtime-index-get node-id node-table)) (new-child replacement) - (parent-id (gethash node-id parent-table))) + (parent-id (ebox-runtime-index-get node-id parent-table))) (unless old-child (error "Ebox candidate anchor is absent after an ancestor replacement: %S" node-id)) @@ -4577,12 +4585,12 @@ logical tree crosses that boundary." (plist-get replacement :node-id)))) (while parent-id (let* ((current-parent-id parent-id) - (old-parent (gethash current-parent-id node-table)) + (old-parent (ebox-runtime-index-get current-parent-id node-table)) (new-parent (ebox-tree-copy-with-direct-child-replacements old-parent (list (cons old-child new-child)))) (grandparent-id - (gethash current-parent-id parent-table))) + (ebox-runtime-index-get current-parent-id parent-table))) (ebox-incremental--candidate-record-path-copy current-parent-id new-parent grandparent-id nil (not (equal (plist-get old-child :node-id) @@ -4607,16 +4615,16 @@ copies the owner's descendants before mutation while retaining their ids." (node-table (plist-get state :node-table)) (parent-table (plist-get state :parent-table)) (region-node-table (plist-get state :region-node-table)) - (owner-id (and (hash-table-p region-node-table) - (gethash region-id region-node-table))) - (owner (and (hash-table-p node-table) - (gethash owner-id node-table))) + (owner-id (and (ebox-runtime-index-like-p region-node-table) + (ebox-runtime-index-get region-id region-node-table))) + (owner (and (ebox-runtime-index-like-p node-table) + (ebox-runtime-index-get owner-id node-table))) (box (pcase (and owner (plist-get owner :ebox-type)) ('box owner) ('flex (plist-get owner :box))))) (when (and root owner box - (hash-table-p parent-table) + (ebox-runtime-index-like-p parent-table) (equal (plist-get box :region-id) region-id)) (let* ((candidate-box (if isolate-owner-subtree-p @@ -4647,14 +4655,14 @@ shared with the published generation." affected-ids) (maphash (lambda (node-id _replacement) - (unless (gethash node-id node-table) + (unless (ebox-runtime-index-get node-id node-table) (error "Ebox candidate path-copy node is absent: %S" node-id)) (let ((current node-id)) (while current (unless (gethash current affected) (puthash current t affected) (push current affected-ids)) - (setq current (gethash current parent-table))))) + (setq current (ebox-runtime-index-get current parent-table))))) replacements) (let ((depths (make-hash-table :test 'equal))) (cl-labels @@ -4664,7 +4672,7 @@ shared with the published generation." (let ((current node-id) path (result -1)) (while (and current (not (gethash current depths))) (push current path) - (setq current (gethash current parent-table))) + (setq current (ebox-runtime-index-get current parent-table))) (when current (setq result (gethash current depths))) (dolist (path-id path) @@ -4676,7 +4684,7 @@ shared with the published generation." (> (depth left) (depth right))))))) (let (result) (dolist (node-id affected-ids) - (let* ((old-node (gethash node-id node-table)) + (let* ((old-node (ebox-runtime-index-get node-id node-table)) (local (or (gethash node-id replacements) old-node)) (child-replacements (gethash node-id children)) (effective-child-replacements @@ -4704,7 +4712,7 @@ shared with the published generation." (ebox-tree-copy-with-direct-child-replacements local effective-child-replacements) local)) - (parent-id (gethash node-id parent-table))) + (parent-id (ebox-runtime-index-get node-id parent-table))) (ebox-incremental--candidate-record-path-copy node-id candidate parent-id (gethash node-id replacements) nil) (if parent-id @@ -4729,15 +4737,15 @@ be resolved through the retained runtime indexes." (region-node-table (plist-get state :region-node-table)) (replacements (make-hash-table :test 'equal)) valid) - (setq valid (and root (hash-table-p node-table) - (hash-table-p parent-table) - (hash-table-p region-node-table))) + (setq valid (and root (ebox-runtime-index-like-p node-table) + (ebox-runtime-index-like-p parent-table) + (ebox-runtime-index-like-p region-node-table))) (dolist (entry region-offsets) (when valid (let* ((region-id (car entry)) (offset (cdr entry)) - (owner-id (gethash region-id region-node-table)) - (owner (and owner-id (gethash owner-id node-table))) + (owner-id (ebox-runtime-index-get region-id region-node-table)) + (owner (and owner-id (ebox-runtime-index-get owner-id node-table))) (box (pcase (and owner (plist-get owner :ebox-type)) ('box owner) @@ -4772,8 +4780,8 @@ be resolved through the retained runtime indexes." affected-ids) (maphash (lambda (node-id replacement) - (let ((old-node (gethash node-id node-table)) - (parent-id (gethash node-id parent-table))) + (let ((old-node (ebox-runtime-index-get node-id node-table)) + (parent-id (ebox-runtime-index-get node-id parent-table))) (unless old-node (error "Ebox candidate path-copy node is absent: %S" node-id)) (ebox-incremental--candidate-record-path-copy @@ -4790,7 +4798,7 @@ be resolved through the retained runtime indexes." (unless (gethash parent-id affected-table) (puthash parent-id t affected-table) (push parent-id affected-ids)) - (setq parent-id (gethash parent-id parent-table))))) + (setq parent-id (ebox-runtime-index-get parent-id parent-table))))) replacements) (let ((depth-table (make-hash-table :test 'equal))) (cl-labels @@ -4803,7 +4811,7 @@ be resolved through the retained runtime indexes." (while (and current (not (gethash current depth-table))) (push current path) - (setq current (gethash current parent-table))) + (setq current (ebox-runtime-index-get current parent-table))) (when current (setq result (gethash current depth-table))) (dolist (path-id path) @@ -4816,11 +4824,11 @@ be resolved through the retained runtime indexes." (let ((result (gethash (plist-get root :node-id) replacements))) (dolist (parent-id affected-ids) (let* ((child-replacements (gethash parent-id direct)) - (old-parent (gethash parent-id node-table)) + (old-parent (ebox-runtime-index-get parent-id node-table)) (copy (ebox-tree-copy-with-direct-child-replacements old-parent child-replacements)) - (grandparent-id (gethash parent-id parent-table))) + (grandparent-id (ebox-runtime-index-get parent-id parent-table))) (ebox-incremental--candidate-record-path-copy parent-id copy grandparent-id nil (cl-some @@ -4846,12 +4854,12 @@ building an index for an intermediate path-copied root." (cl-labels ((ancestor-p (ancestor-id descendant-id) - (let ((current (gethash descendant-id parent-table)) + (let ((current (ebox-runtime-index-get descendant-id parent-table)) found) (while (and current (not found)) (if (equal current ancestor-id) (setq found t) - (setq current (gethash current parent-table)))) + (setq current (ebox-runtime-index-get current parent-table)))) found))) (cl-loop for tail on replacements @@ -4900,12 +4908,12 @@ Cost is proportional to the replacements, never to the page." (let ((walk descendant) found) (while (and walk (not found)) (setq found (equal walk ancestor) - walk (and (not found) (gethash walk parent-table)))) + walk (and (not found) (ebox-runtime-index-get walk parent-table)))) found)) (inside-replaced-anchor-p (node-id) (let ((walk node-id)) (while (and walk (not (member walk anchor-ids))) - (setq walk (gethash walk parent-table))) + (setq walk (ebox-runtime-index-get walk parent-table))) walk)) (collect-host-refs (node acc) (if (or (not (listp node)) (stringp node)) @@ -4929,8 +4937,8 @@ Cost is proportional to the replacements, never to the page." (replacement-key (ebox-tree-node-author-key replacement-source-index replacement)) - (parent-id (gethash node-id parent-table)) - (parent (and parent-id (gethash parent-id node-table)))) + (parent-id (ebox-runtime-index-get node-id parent-table)) + (parent (and parent-id (ebox-runtime-index-get parent-id node-table)))) ;; Sibling keys stay unique under the anchor's parent. (when (and replacement-key parent) (dolist (sibling (ebox-tree--children-raw parent)) @@ -5033,7 +5041,7 @@ CURRENT-SNAPSHOT identifies the subtree identities being replaced." conflict) (maphash (lambda (node-id _present) - (when (and (gethash node-id node-table) + (when (and (ebox-runtime-index-get node-id node-table) (not (gethash node-id current-node-ids))) (setq conflict t))) (plist-get historical-snapshot :node-id-set)) @@ -5118,7 +5126,7 @@ DETACHED-HISTORY receives identity snapshots for semantic replacements." (let* ((node-id (ebox-incremental--candidate-replacement-anchor-id entry)) (old-anchor - (gethash node-id (plist-get base-index :node-table)))) + (ebox-runtime-index-get node-id (plist-get base-index :node-table)))) (unless old-anchor (error "Ebox candidate anchor is absent: %S" node-id)) ;; Replacements were validated when they were recorded @@ -5147,7 +5155,7 @@ DETACHED-HISTORY receives identity snapshots for semantic replacements." (let* ((node-id (ebox-incremental--candidate-replacement-anchor-id entry)) (old-anchor - (gethash node-id (plist-get base-index :node-table)))) + (ebox-runtime-index-get node-id (plist-get base-index :node-table)))) (unless old-anchor (error "Ebox candidate anchor is absent: %S" @@ -5251,7 +5259,7 @@ but must re-enter the candidate indexes as a new object." "Return current candidate NODE-ID from path trace or base STATE." (or (plist-get (gethash node-id ebox-incremental--candidate-path-copy-trace) :node) - (gethash node-id (plist-get state :node-table)))) + (ebox-runtime-index-get node-id (plist-get state :node-table)))) (defun ebox-incremental--candidate-range-ref-overlay (old-state path-trace) "Return Range ref table updated only for node anchors in PATH-TRACE." @@ -5268,10 +5276,10 @@ but must re-enter the candidate indexes as a new object." (setq anchors (cl-delete-if (lambda (anchor) - (let ((walk (gethash (car anchor) parent-table)) found) + (let ((walk (ebox-runtime-index-get (car anchor) parent-table)) found) (while (and walk (not found)) (setq found (gethash walk anchor-set) - walk (and (not found) (gethash walk parent-table)))) + walk (and (not found) (ebox-runtime-index-get walk parent-table)))) found)) anchors))) (if (null anchors) @@ -5285,7 +5293,7 @@ but must re-enter the candidate indexes as a new object." (dolist (child (ebox-tree--children-raw node)) (walk child function)))) (dolist (anchor anchors) - (when-let* ((old (gethash (car anchor) + (when-let* ((old (ebox-runtime-index-get (car anchor) (plist-get old-state :node-table)))) (walk old (lambda (_parent record) @@ -5309,7 +5317,7 @@ but must re-enter the candidate indexes as a new object." (let* ((parent-table (plist-get state :parent-table)) (old-child (ebox-incremental--candidate-current-node state node-id)) (new-child replacement) - (parent-id (gethash node-id parent-table))) + (parent-id (ebox-runtime-index-get node-id parent-table))) (ebox-incremental--candidate-record-path-copy node-id replacement parent-id t structure-p) (while parent-id @@ -5348,7 +5356,7 @@ but must re-enter the candidate indexes as a new object." copy) (ebox-tree-copy-with-direct-child-replacements old-parent (list (cons old-child new-child))))) - (grandparent-id (gethash current-parent-id parent-table))) + (grandparent-id (ebox-runtime-index-get current-parent-id parent-table))) (ebox-incremental--candidate-record-path-copy current-parent-id new-parent grandparent-id nil (not (equal (plist-get old-child :node-id) @@ -5391,7 +5399,7 @@ but must re-enter the candidate indexes as a new object." (dolist (parent-id (nreverse order)) (let* ((parent (ebox-incremental--candidate-current-node state parent-id)) - (base-parent (gethash parent-id (plist-get state :node-table))) + (base-parent (ebox-runtime-index-get parent-id (plist-get state :node-table))) (base-sequence (and base-parent (plist-get base-parent :ebox-child-sequence))) @@ -5561,10 +5569,10 @@ but must re-enter the candidate indexes as a new object." "Return PATCH-IDS without a patched ancestor in STATE." (let ((parents (plist-get state :parent-table)) result) (dolist (node-id patch-ids (nreverse result)) - (let ((parent-id (gethash node-id parents)) found) + (let ((parent-id (ebox-runtime-index-get node-id parents)) found) (while (and parent-id (not found)) (setq found (gethash parent-id patch-table) - parent-id (and (not found) (gethash parent-id parents)))) + parent-id (and (not found) (ebox-runtime-index-get parent-id parents)))) (unless found (push node-id result)))))) (defun ebox-incremental--candidate-apply-paint-patches (candidate root) @@ -5587,7 +5595,7 @@ style consumers; the Style Adapter computes their values exactly once later." (let ((current node-id)) (while current (puthash current t patch-paths) - (setq current (gethash current parents))))) + (setq current (ebox-runtime-index-get current parents))))) (cl-labels ((rewrite (node inherited-input-changed-p) @@ -5691,7 +5699,7 @@ style consumers; the Style Adapter computes their values exactly once later." (gethash (ebox-incremental--candidate-range-replacement-ref range) (plist-get (ebox-candidate--base-state candidate) :range-ref-table))) - (parent (gethash (plist-get record :parent-node-id) + (parent (ebox-runtime-index-get (plist-get record :parent-node-id) (plist-get (ebox-candidate--base-state candidate) :node-table))) (segment (and parent @@ -5762,10 +5770,11 @@ Return the same pure surface-input shape as buffer old-state next-root)))) (defun ebox-incremental--hash-keys (table) - "Return the keys currently present in hash TABLE." + "Return the keys currently present in supported TABLE." (let (keys) - (when (hash-table-p table) - (maphash (lambda (key _value) (push key keys)) table)) + (when (ebox-runtime-index-like-p table) + (ebox-runtime-index-map + (lambda (key _value) (push key keys)) table)) (nreverse keys))) (defun ebox-incremental--hash-snapshot (table keys) @@ -5812,7 +5821,7 @@ Return the same pure surface-input shape as (candidate-node-table (plist-get candidate-index :node-table)) (snapshots (make-hash-table :test 'equal))) (when (hash-table-p old-snapshots) - (maphash + (ebox-runtime-index-map (lambda (node-id _node) (when-let* ((snapshot (gethash node-id old-snapshots))) (puthash node-id (copy-sequence snapshot) snapshots))) @@ -6009,14 +6018,17 @@ Return the same pure surface-input shape as (plist-get state :runtime-type-count-table) :selector-index-stale-p (plist-get state :selector-index-stale-p) - :native-node-postorder (plist-get state :native-node-postorder))) + :native-node-postorder (plist-get state :native-node-postorder) + :native-node-postorder-ids + (plist-get state :native-node-postorder-ids))) (defun ebox-incremental--candidate-copy-index-table (state key test) - "Return a materialized copy of STATE's hash table under KEY." + "Return STATE's persistent index under KEY or a fresh legacy table." (let ((table (plist-get state key))) - (if (hash-table-p table) - (copy-hash-table table) - (make-hash-table :test test)))) + (cond + ((ebox-runtime-index-p table) table) + ((hash-table-p table) (copy-hash-table table)) + (t (make-hash-table :test test))))) (defun ebox-incremental--candidate-node-region-record (node) "Return NODE's `(REGION-ID OWNER-ID BOX)' record, or nil." @@ -6064,7 +6076,8 @@ complete preorder first-owner and postorder last-box semantics." (maphash (lambda (region-id _present) (remhash region-id region-id-set) - (remhash region-id region-node-table) + (setq region-node-table + (ebox-runtime-index-delete region-id region-node-table)) (remhash region-id region-box-table)) region-ids) (cl-labels @@ -6075,8 +6088,10 @@ complete preorder first-owner and postorder last-box semantics." (pcase-let ((`(,region-id ,owner-id ,_box) record)) (when (gethash region-id region-ids) (puthash region-id t region-id-set) - (unless (gethash region-id region-node-table) - (puthash region-id owner-id region-node-table))))) + (unless (ebox-runtime-index-get region-id region-node-table) + (setq region-node-table + (ebox-runtime-index-put + region-id owner-id region-node-table)))))) (dolist (child (ebox--node-children node)) (visit child)) (when-let* ((record @@ -6084,7 +6099,8 @@ complete preorder first-owner and postorder last-box semantics." (pcase-let ((`(,region-id ,_owner-id ,box) record)) (when (gethash region-id region-ids) (puthash region-id box region-box-table))))))) - (visit root))) + (visit root)) + (list region-id-set region-node-table region-box-table)) (defun ebox-incremental--candidate-native-postorder (root) "Return ROOT's native compiler postorder without building other indexes." @@ -6092,7 +6108,8 @@ complete preorder first-owner and postorder last-box semantics." (cl-labels ((visit (node native-layout-p) (when (and (listp node) (not (stringp node))) - (let ((type (plist-get node :ebox-type))) + (let ((type (plist-get node :ebox-type)) + (node-id (plist-get node :node-id))) (ebox-tree-for-each-direct-child node (lambda (child) @@ -6102,23 +6119,33 @@ complete preorder first-owner and postorder last-box semantics." (eq child (plist-get node :box)))))))) (when native-layout-p - (push node postorder)))))) + (push node-id postorder)))))) (visit root t)) (vconcat (nreverse postorder)))) -(defun ebox-incremental--candidate-map-native-postorder +(defun ebox-incremental--candidate-retain-native-postorder (old-state candidate-node-table) - "Re-key OLD-STATE's structurally stable native postorder to candidate nodes." + "Return OLD-STATE's stable node-id postorder for CANDIDATE-NODE-TABLE. +The `:native-node-postorder-ids' slot binds the exact read-only vector built +from indexed node IDs. Reuse that vector without enumeration. Unmarked +vectors may contain legacy node plists or IDs; resolve every entry against +the candidate index before converting them. This format marker supplies no +topology proof, and neither producers nor consumers may mutate its vector." (let ((old-postorder (plist-get old-state :native-node-postorder))) - (if (vectorp old-postorder) + (when (vectorp old-postorder) + (if (eq old-postorder (plist-get old-state :native-node-postorder-ids)) + old-postorder (vconcat (mapcar - (lambda (old-node) - (or (gethash (plist-get old-node :node-id) - candidate-node-table) - (error "Ebox candidate lost a structurally retained native node"))) - (append old-postorder nil))) - nil))) + (lambda (entry) + (let ((node-id (if (and (listp entry) + (plist-member entry :node-id)) + (plist-get entry :node-id) + entry))) + (unless (ebox-runtime-index-get node-id candidate-node-table) + (error "Ebox candidate lost a structurally retained native node")) + node-id)) + old-postorder)))))) (defun ebox-incremental--candidate-range-created-node-ids (range-delta-table) "Return new node ids covered by structural RANGE-DELTA-TABLE entries." @@ -6236,7 +6263,7 @@ complete preorder first-owner and postorder last-box semantics." (defun ebox-incremental--candidate-full-index-p (old-state touched) "Return non-nil when TOUCHED justifies rebuilding OLD-STATE's index." (let ((touched-count (length touched)) - (old-count (hash-table-count (plist-get old-state :node-table)))) + (old-count (ebox-runtime-index-size (plist-get old-state :node-table)))) (and (>= touched-count ebox-incremental--candidate-full-index-min-touched) (>= (* 2 touched-count) old-count)))) @@ -6286,9 +6313,9 @@ complete preorder first-owner and postorder last-box semantics." Traversal stops at every node that is `eq' to OLD-STATE's indexed node. The work is therefore bounded by copied ancestor paths plus added, removed, and -replacement subtrees. Core tables are materialized in this stage so existing -scalar `gethash' consumers remain unchanged; publication journaling can later -replace those O(n) table copies without changing this delta contract." +replacement subtrees. Node, parent, and region-owner changes path-copy their +immutable radix indexes; unrelated runtime side tables retain their existing +candidate isolation contracts." (let ((old-root (plist-get old-state :root-node))) (if (eq candidate-root old-root) (list :index (ebox-incremental--candidate-base-index old-state) @@ -6317,7 +6344,7 @@ replace those O(n) table copies without changing this delta contract." touched))) (visit-new (node parent-id) (let* ((node-id (ebox--ensure-node-id node)) - (old-node (gethash node-id old-node-table missing))) + (old-node (ebox-runtime-index-get node-id old-node-table missing))) (unless (or (gethash node-id visited-new) (eq node old-node)) (puthash node-id t visited-new) @@ -6347,7 +6374,7 @@ replace those O(n) table copies without changing this delta contract." (parent-id (plist-get delta :parent-id))) (puthash parent-id t visited-new) (record-touched old-parent new-parent - (gethash parent-id + (ebox-runtime-index-get parent-id (plist-get old-state :parent-table)) (plist-get delta :children-changed)) (dolist (payload (plist-get delta :old-payloads)) @@ -6362,7 +6389,7 @@ replace those O(n) table copies without changing this delta contract." (maphash (lambda (base-node-id trace) (when (plist-get trace :anchor-p) - (let* ((old-node (gethash base-node-id old-node-table)) + (let* ((old-node (ebox-runtime-index-get base-node-id old-node-table)) (new-node (plist-get trace :node)) (parent-id (plist-get trace :parent-id))) (when (and old-node @@ -6381,7 +6408,7 @@ replace those O(n) table copies without changing this delta contract." (unless (gethash node-id visited-new) (puthash node-id t visited-new) (record-touched - (gethash base-node-id old-node-table) + (ebox-runtime-index-get base-node-id old-node-table) new-node (plist-get trace :parent-id) (plist-get trace :structure-p)))))) @@ -6418,8 +6445,8 @@ replace those O(n) table copies without changing this delta contract." ((parent-id (node-id) (if (gethash node-id visited-new) - (gethash node-id touched-parent-table) - (gethash node-id old-parent-table))) + (ebox-runtime-index-get node-id touched-parent-table) + (ebox-runtime-index-get node-id old-parent-table))) (depth (node-id) (or (gethash node-id depth-table) @@ -6475,8 +6502,10 @@ replace those O(n) table copies without changing this delta contract." (let ((node-id (plist-get node :node-id))) (ebox-incremental--adjust-runtime-type-count runtime-type-count-table node -1) - (remhash node-id node-table) - (remhash node-id parent-table) + (setq node-table + (ebox-runtime-index-delete node-id node-table)) + (setq parent-table + (ebox-runtime-index-delete node-id parent-table)) (when-let* ((host-ref (ebox-tree-node-source-identity node))) (when (equal (gethash host-ref host-ref-table) node-id) @@ -6531,13 +6560,17 @@ replace those O(n) table copies without changing this delta contract." (when (hash-table-p old-region-box-count-table) (ebox-incremental--candidate-adjust-region-box-count region-box-count-table old-node -1))) - (puthash node-id new-node node-table) + (setq node-table + (ebox-runtime-index-put node-id new-node node-table)) (when (or (null old-node) (not (eq old-type new-type))) (ebox-incremental--adjust-runtime-type-count runtime-type-count-table new-node 1)) (if parent-id - (puthash node-id parent-id parent-table) - (remhash node-id parent-table)) + (setq parent-table + (ebox-runtime-index-put + node-id parent-id parent-table)) + (setq parent-table + (ebox-runtime-index-delete node-id parent-table))) ;; Replacement subtrees were validated before recording and ;; checked against untouched Host refs before path copying. (when-let* ((new-host-ref @@ -6568,7 +6601,9 @@ replace those O(n) table copies without changing this delta contract." (maphash (lambda (region-id _present) (remhash region-id region-id-set) - (remhash region-id region-node-table) + (setq region-node-table + (ebox-runtime-index-delete + region-id region-node-table)) (remhash region-id region-box-table)) affected-region-ids) ;; TOUCHED is preorder, matching `ebox--runtime-index' ownership: @@ -6579,13 +6614,21 @@ replace those O(n) table copies without changing this delta contract." (nth 1 entry)))) (pcase-let ((`(,region-id ,owner-id ,box) record)) (puthash region-id t region-id-set) - (unless (gethash region-id region-node-table) - (puthash region-id owner-id region-node-table)) + (unless (ebox-runtime-index-get region-id region-node-table) + (setq region-node-table + (ebox-runtime-index-put + region-id owner-id region-node-table))) (puthash region-id box region-box-table)))) (when (> (hash-table-count ambiguous-region-ids) 0) - (ebox-incremental--candidate-recompute-regions - candidate-root ambiguous-region-ids - region-id-set region-node-table region-box-table)) + (pcase-let + ((`(,new-region-id-set ,new-region-node-table + ,new-region-box-table) + (ebox-incremental--candidate-recompute-regions + candidate-root ambiguous-region-ids + region-id-set region-node-table region-box-table))) + (setq region-id-set new-region-id-set + region-node-table new-region-node-table + region-box-table new-region-box-table))) (let* ((range-delta-table (make-hash-table :test #'equal)) (_range-deltas (dolist (delta range-index-deltas) @@ -6613,7 +6656,7 @@ replace those O(n) table copies without changing this delta contract." (if (or range-index-deltas structure-p) (ebox-incremental--candidate-native-postorder candidate-root) - (ebox-incremental--candidate-map-native-postorder + (ebox-incremental--candidate-retain-native-postorder old-state node-table))) (index (list :node-table node-table @@ -6637,7 +6680,8 @@ replace those O(n) table copies without changing this delta contract." candidate-source-index 'type) :runtime-type-count-table runtime-type-count-table :selector-index-stale-p t - :native-node-postorder native-node-postorder))) + :native-node-postorder native-node-postorder + :native-node-postorder-ids native-node-postorder))) (list :index index :dirty-set dirty-set :participation-node-ids @@ -6651,6 +6695,8 @@ replace those O(n) table copies without changing this delta contract." (mapcar (lambda (node) (plist-get node :node-id)) removed) + :touched-region-ids + (ebox-incremental--hash-keys affected-region-ids) :touched-count (length touched) :removed-count (length removed)))))))))) @@ -6661,7 +6707,7 @@ The search stops at the first shared node, so a copy-on-write candidate is recognized from its first untouched sibling without walking the whole tree." (let ((old-root (plist-get old-state :root-node)) (old-node-table (plist-get old-state :node-table))) - (and (hash-table-p old-node-table) + (and (ebox-runtime-index-like-p old-node-table) (catch 'shared (cl-labels ((visit (node) @@ -6669,7 +6715,7 @@ recognized from its first untouched sibling without walking the whole tree." (let ((node-id (plist-get node :node-id))) (when (and node-id (not (eq node old-root)) - (eq node (gethash node-id old-node-table))) + (eq node (ebox-runtime-index-get node-id old-node-table))) (throw 'shared t))) (dolist (child (ebox-tree--children-raw node)) (visit child))))) @@ -6795,9 +6841,10 @@ CANDIDATE-INDEX is the prepared runtime index for CANDIDATE-ROOT." :new-region-ids (ebox--node-all-region-ids candidate-root) :old old-root :new candidate-root) dirty) - (maphash - (lambda (node-id new-node) - (when-let* ((old-node (gethash node-id old-table))) + (dolist (node-id (ebox--runtime-node-ids candidate-root)) + (let ((new-node (ebox-runtime-index-get node-id new-table))) + (when-let* ((old-node + (ebox-runtime-index-get node-id old-table))) (let* ((changed-keys (ebox-incremental--candidate-local-changed-keys old-node (plist-get old-state :source-index) @@ -6832,8 +6879,7 @@ CANDIDATE-INDEX is the prepared runtime index for CANDIDATE-ROOT." (ebox-tree-node-local-source-signature new-node)) (ebox-incremental--child-change-props old-node new-node children-changed)) - dirty))))) - new-table)) + dirty))))))) (nreverse dirty))) (defun ebox-incremental--isolate-candidate-caches (state) @@ -6933,7 +6979,9 @@ candidate therefore owns its cache tables until TP commits the generation." (:selector-index-stale-p ,(plist-get candidate-index :selector-index-stale-p)) (:native-node-postorder - ,(plist-get candidate-index :native-node-postorder)))) + ,(plist-get candidate-index :native-node-postorder)) + (:native-node-postorder-ids + ,(plist-get candidate-index :native-node-postorder-ids)))) (setq state (plist-put state (car entry) (cadr entry)))) (ebox-incremental--isolate-candidate-caches state))) @@ -6944,11 +6992,11 @@ candidate therefore owns its cache tables until TP commits the generation." (let ((candidate-cache (make-hash-table :test 'eq)) (missing (make-symbol "ebox-candidate-cache-missing"))) (when (and (hash-table-p old-cache) - (hash-table-p old-node-table) - (hash-table-p candidate-node-table)) - (maphash + (ebox-runtime-index-like-p old-node-table) + (ebox-runtime-index-like-p candidate-node-table)) + (ebox-runtime-index-map (lambda (node-id candidate-node) - (when-let* ((old-node (gethash node-id old-node-table))) + (when-let* ((old-node (ebox-runtime-index-get node-id old-node-table))) (let ((cached (gethash old-node old-cache missing))) (unless (eq cached missing) (puthash candidate-node cached candidate-cache))))) @@ -6972,7 +7020,7 @@ must be recomputed in the next publication." old-node-table old-parent-table candidate-cache nil)) (dolist (node-id (append touched-node-ids removed-node-ids)) - (when-let* ((old-node (gethash node-id old-node-table))) + (when-let* ((old-node (ebox-runtime-index-get node-id old-node-table))) (remhash old-node candidate-cache))) candidate-cache)) @@ -7026,8 +7074,8 @@ derive render changes from the computed fragment style signatures." (mapcar (lambda (entry) (let* ((node-id (plist-get entry :node-id)) - (old-node (and node-id (gethash node-id old-nodes))) - (new-node (and node-id (gethash node-id new-nodes)))) + (old-node (and node-id (ebox-runtime-index-get node-id old-nodes))) + (new-node (and node-id (ebox-runtime-index-get node-id new-nodes)))) (if (or (eq (plist-get entry :dirty-kind) 'structure) (not old-node) (not new-node) @@ -7156,7 +7204,7 @@ certificate." (with-current-buffer buffer (dolist (entry dirty-set) (when (memq (plist-get entry :dirty-kind) '(geometry structure)) - (when (gethash (plist-get entry :node-id) + (when (ebox-runtime-index-get (plist-get entry :node-id) (plist-get old-state :node-table)) (ebox--ensure-layout-snapshot-spans buffer (plist-get entry :node-id))))) @@ -7454,7 +7502,7 @@ the expensive parent-context mount and role scan first." (node-id candidate-id) eligible) (while (and node-id (not (equal node-id root-id)) (not eligible)) - (let* ((node (gethash node-id (plist-get old-state :node-table))) + (let* ((node (ebox-runtime-index-get node-id (plist-get old-state :node-table))) (formatting-p (and node (ebox-incremental--formatting-context-reflow-node-p node))) @@ -7475,7 +7523,7 @@ the expensive parent-context mount and role scan first." (ebox--line-spans-cover-whole-lines-p spans))) (setq eligible t)) (unless eligible - (setq node-id (gethash node-id parents))))) + (setq node-id (ebox-runtime-index-get node-id parents))))) ) eligible)) @@ -7521,11 +7569,11 @@ line count; its outside siblings are shifted by the one scoped replacement." dirty-set)) (while (and candidate-id (not proof) (not (equal candidate-id root-id))) - (let* ((old-node (gethash candidate-id + (let* ((old-node (ebox-runtime-index-get candidate-id (plist-get old-state :node-table))) - (new-node (gethash candidate-id + (new-node (ebox-runtime-index-get candidate-id (plist-get candidate-state :node-table))) - (parent-id (gethash candidate-id parents)) + (parent-id (ebox-runtime-index-get candidate-id parents)) (snapshot (and old-node (ebox--ensure-layout-snapshot-details @@ -7544,25 +7592,25 @@ line count; its outside siblings are shifted by the one scoped replacement." (plist-get mount :end)) mounts))))) (parent (and parent-id - (gethash parent-id + (ebox-runtime-index-get parent-id (plist-get old-state :node-table)))) (parent-new (and parent-id - (gethash parent-id + (ebox-runtime-index-get parent-id (plist-get candidate-state :node-table)))) (ancestors-stable-p t) (walk parent-id)) (while (and walk ancestors-stable-p (not (equal walk root-id))) - (let ((ancestor (gethash walk (plist-get old-state :node-table)))) + (let ((ancestor (ebox-runtime-index-get walk (plist-get old-state :node-table)))) (when (or (not ancestor) (memq (ebox-tree-display-inner ancestor) '(row flex grid)) (ebox-tree-node-visible-overflow-p ancestor)) (setq ancestors-stable-p nil))) - (setq walk (gethash walk parents))) + (setq walk (ebox-runtime-index-get walk parents))) (when (and old-node new-node parent parent-new (ebox-incremental--formatting-context-reflow-node-p old-node) - (equal (gethash candidate-id parents) - (gethash candidate-id candidate-parents)) + (equal (ebox-runtime-index-get candidate-id parents) + (ebox-runtime-index-get candidate-id candidate-parents)) (ebox-incremental--node-child-ids-equal-p old-node new-node) (equal (ebox-incremental--layout-slot-style-signature @@ -7598,7 +7646,7 @@ line count; its outside siblings are shifted by the one scoped replacement." (plist-get old-state :layout-snapshot-detail-generation)))) (unless proof - (setq candidate-id (gethash candidate-id parents)))))) + (setq candidate-id (ebox-runtime-index-get candidate-id parents)))))) proof)) (defun ebox-incremental--two-owner-allocation-proof @@ -7852,14 +7900,14 @@ suppresses the ordinary fallback when any fact is missing." :border-bottom-color))) (plist-get entry :changed-keys)) (let* ((node-id (plist-get entry :node-id)) - (old-node (gethash node-id old-nodes)) - (new-node (gethash node-id new-nodes))) + (old-node (ebox-runtime-index-get node-id old-nodes)) + (new-node (ebox-runtime-index-get node-id new-nodes))) (and old-node new-node (ebox-incremental--node-child-ids-equal-p old-node new-node) - (equal (gethash node-id + (equal (ebox-runtime-index-get node-id (plist-get old-state :parent-table)) - (gethash node-id + (ebox-runtime-index-get node-id (plist-get candidate-state :parent-table))) (equal (ebox-incremental--layout-slot-style-signature @@ -7907,7 +7955,7 @@ suppresses the ordinary fallback when any fact is missing." (not (plist-get prepared :scroll-patch-fast-p)) root-id (not (equal geometry-owner-id root-id)) - (> (hash-table-count old-nodes) + (> (ebox-runtime-index-size old-nodes) (length (delete-dups (append geometry-owner-ids paint-owner-ids))))) (list :geometry-proof geometry-proof @@ -7919,7 +7967,7 @@ suppresses the ordinary fallback when any fact is missing." :dirty-set (copy-sequence dirty-set) :owner-plan (copy-tree owner-plan) :outside-complement-count - (- (hash-table-count old-nodes) + (- (ebox-runtime-index-size old-nodes) (length (delete-dups (append geometry-owner-ids paint-owner-ids)))))))) @@ -7938,7 +7986,7 @@ because it could address a run whose topology the closure just replaced." (let ((walk node-id)) (while (and valid walk) (when (gethash walk geometry-set) (setq valid nil)) - (setq walk (and valid (gethash walk parents)))))) + (setq walk (and valid (ebox-runtime-index-get walk parents)))))) valid)) (defun ebox-incremental--allocation-closure-ancestor-stable-p @@ -7951,12 +7999,12 @@ because it could address a run whose topology the closure just replaced." (node-id owner-id) (valid t)) (while (and valid node-id) - (let* ((old-node (gethash node-id old-nodes)) - (new-node (gethash node-id new-nodes)) - (old-parent (gethash node-id old-parents))) + (let* ((old-node (ebox-runtime-index-get node-id old-nodes)) + (new-node (ebox-runtime-index-get node-id new-nodes)) + (old-parent (ebox-runtime-index-get node-id old-parents))) (setq valid (and old-node new-node - (equal old-parent (gethash node-id new-parents)) + (equal old-parent (ebox-runtime-index-get node-id new-parents)) (ebox-incremental--node-child-ids-equal-p old-node new-node) (equal @@ -8016,15 +8064,15 @@ because it could address a run whose topology the closure just replaced." old-state candidate-state dirty-set))) (while (and candidate-id path-valid-p (not proof) (not (equal candidate-id root-id))) - (let* ((old-node (gethash candidate-id old-nodes)) - (new-node (gethash candidate-id new-nodes)) - (parent-id (gethash candidate-id parents)) + (let* ((old-node (ebox-runtime-index-get candidate-id old-nodes)) + (new-node (ebox-runtime-index-get candidate-id new-nodes)) + (parent-id (ebox-runtime-index-get candidate-id parents)) (display (and old-node (ebox-tree-display-inner old-node)))) (setq saw-flex-p (or saw-flex-p (eq display 'flex))) (setq path-valid-p (and old-node new-node (equal parent-id - (gethash candidate-id candidate-parents)) + (ebox-runtime-index-get candidate-id candidate-parents)) (ebox-incremental--node-child-ids-equal-p old-node new-node) (equal @@ -8288,7 +8336,7 @@ role, overflow, and outside-complement validation." (parent-table (plist-get (ebox--buffer-render-state buffer) :parent-table)) ops) - (unless (hash-table-p parent-table) + (unless (ebox-runtime-index-like-p parent-table) (signal 'ebox-patch-plan-error (list :missing-parent-table buffer))) (ebox--with-layout-snapshot-detail-context buffer @@ -8468,7 +8516,7 @@ closure may still plan one for a later owner-scoped surface projection." (mapcar (lambda (op) (plist-get op :op)) owner-plan) :owner-ids owner-ids :owner-type - (plist-get (gethash single-owner node-table) :ebox-type) + (plist-get (ebox-runtime-index-get single-owner node-table) :ebox-type) :span-count (cl-loop for owner-id in owner-ids for snapshot = (and snapshots @@ -8492,18 +8540,8 @@ closure may still plan one for a later owner-scoped surface projection." dirty-set))) (defun ebox-incremental--hash-key-set-equal-p (left right) - "Return non-nil when hash tables LEFT and RIGHT have the same keys." - (and (hash-table-p left) - (hash-table-p right) - (= (hash-table-count left) (hash-table-count right)) - (let ((equal-p t) - (missing (make-symbol "ebox-missing-key"))) - (maphash - (lambda (key _value) - (when (eq (gethash key right missing) missing) - (setq equal-p nil))) - left) - equal-p))) + "Return non-nil when supported indexes LEFT and RIGHT have the same keys." + (ebox-runtime-index-key-set-equal-p left right)) (defun ebox-incremental--span-patch-identity-stable-p (buffer old-state candidate-state prepared owner-id) @@ -8524,7 +8562,7 @@ the surface is allowed to reuse retained TP objects." (plist-get prepared :dirty-set)))) (while walk (push walk ids) - (setq walk (gethash walk old-parent-table))) + (setq walk (ebox-runtime-index-get walk old-parent-table))) (delete-dups ids)) (delete-dups (append (plist-get prepared :touched-node-ids) @@ -8538,8 +8576,8 @@ the surface is allowed to reuse retained TP objects." (plist-get candidate-state :region-id-set))) (cl-every (lambda (node-id) - (let ((old-node (gethash node-id old-node-table)) - (new-node (gethash node-id new-node-table))) + (let ((old-node (ebox-runtime-index-get node-id old-node-table)) + (new-node (ebox-runtime-index-get node-id new-node-table))) (and old-node new-node (eq (plist-get old-node :ebox-type) (plist-get new-node :ebox-type)) @@ -8548,8 +8586,8 @@ the surface is allowed to reuse retained TP objects." (plist-get old-state :source-index) old-node) (ebox-tree-node-author-key (plist-get candidate-state :source-index) new-node)) - (equal (gethash node-id old-parent-table) - (gethash node-id new-parent-table))))) + (equal (ebox-runtime-index-get node-id old-parent-table) + (ebox-runtime-index-get node-id new-parent-table))))) (delq nil touched))))) (defun ebox-incremental--path-span-line-count-stable-p @@ -8710,10 +8748,10 @@ the complete proof." (node-id (plist-get entry :node-id)) (next-id (and (cdr entries) (plist-get (cadr entries) :node-id))) - (old-node (and node-id (gethash node-id old-nodes))) - (new-node (and node-id (gethash node-id new-nodes))) - (old-parent (and node-id (gethash node-id old-parents))) - (new-parent (and node-id (gethash node-id new-parents))) + (old-node (and node-id (ebox-runtime-index-get node-id old-nodes))) + (new-node (and node-id (ebox-runtime-index-get node-id new-nodes))) + (old-parent (and node-id (ebox-runtime-index-get node-id old-parents))) + (new-parent (and node-id (ebox-runtime-index-get node-id new-parents))) (allocated-style (plist-get entry :allocated-style))) (setq valid (and old-node new-node allocated-style @@ -8762,10 +8800,10 @@ candidate's unrendered caches." signature valid) (setq valid t) (while (and valid node-id) - (let* ((old-node (gethash node-id old-nodes)) - (new-node (gethash node-id new-nodes)) - (old-parent (gethash node-id old-parents)) - (new-parent (gethash node-id new-parents)) + (let* ((old-node (ebox-runtime-index-get node-id old-nodes)) + (new-node (ebox-runtime-index-get node-id new-nodes)) + (old-parent (ebox-runtime-index-get node-id old-parents)) + (new-parent (ebox-runtime-index-get node-id new-parents)) (snapshot (and old-node (ebox-incremental--cached-allocation-snapshot buffer node-id))) @@ -8859,28 +8897,28 @@ complete published ancestor slot chain remains identical." (let* ((old-state (ebox-incremental--proof-base-state buffer)) (node-id (plist-get entry :node-id)) (old-node (and node-id - (gethash node-id (plist-get old-state :node-table)))) + (ebox-runtime-index-get node-id (plist-get old-state :node-table)))) (new-node (and node-id - (gethash node-id + (ebox-runtime-index-get node-id (plist-get candidate-state :node-table)))) (parent-table (plist-get old-state :parent-table)) (candidate-parents (plist-get candidate-state :parent-table)) - (ancestor-id (and node-id (gethash node-id parent-table))) + (ancestor-id (and node-id (ebox-runtime-index-get node-id parent-table))) grid-id old-grid new-grid) (while (and ancestor-id (not grid-id)) (let ((old-ancestor - (gethash ancestor-id (plist-get old-state :node-table))) + (ebox-runtime-index-get ancestor-id (plist-get old-state :node-table))) (new-ancestor - (gethash ancestor-id (plist-get candidate-state :node-table)))) + (ebox-runtime-index-get ancestor-id (plist-get candidate-state :node-table)))) (if (and old-ancestor new-ancestor - (equal (gethash ancestor-id parent-table) - (gethash ancestor-id candidate-parents)) + (equal (ebox-runtime-index-get ancestor-id parent-table) + (ebox-runtime-index-get ancestor-id candidate-parents)) (eq (ebox-tree-display-inner old-ancestor) 'grid) (eq (ebox-tree-display-inner new-ancestor) 'grid)) (setq grid-id ancestor-id old-grid old-ancestor new-grid new-ancestor) - (setq ancestor-id (gethash ancestor-id parent-table))))) + (setq ancestor-id (ebox-runtime-index-get ancestor-id parent-table))))) (let* ((old-props (and old-grid (ebox-tree-layout-props old-grid))) (new-props (and new-grid (ebox-tree-layout-props new-grid))) (tracks (and old-props @@ -8922,16 +8960,16 @@ following sibling. The surface footprint validator remains the final gate." (let* ((old-state (ebox-incremental--proof-base-state buffer)) (node-id (plist-get entry :node-id)) (old-node (and node-id - (gethash node-id (plist-get old-state :node-table)))) + (ebox-runtime-index-get node-id (plist-get old-state :node-table)))) (new-node (and node-id - (gethash node-id (plist-get candidate-state :node-table)))) + (ebox-runtime-index-get node-id (plist-get candidate-state :node-table)))) (parents (plist-get old-state :parent-table)) (candidate-parents (plist-get candidate-state :parent-table)) - (parent-id (and node-id (gethash node-id parents))) + (parent-id (and node-id (ebox-runtime-index-get node-id parents))) (old-parent (and parent-id - (gethash parent-id (plist-get old-state :node-table)))) + (ebox-runtime-index-get parent-id (plist-get old-state :node-table)))) (new-parent (and parent-id - (gethash parent-id (plist-get candidate-state :node-table)))) + (ebox-runtime-index-get parent-id (plist-get candidate-state :node-table)))) (old-children (and old-parent (ebox-tree--children-raw old-parent))) (position (and old-children (cl-position-if @@ -8949,8 +8987,8 @@ following sibling. The surface footprint validator remains the final gate." (ebox-incremental--ancestor-slot-signature buffer old-state candidate-state node-id)))) (and old-state old-node new-node old-parent new-parent - (equal (gethash parent-id parents) - (gethash parent-id candidate-parents)) + (equal (ebox-runtime-index-get parent-id parents) + (ebox-runtime-index-get parent-id candidate-parents)) (ebox-incremental--node-child-ids-equal-p old-parent new-parent) (equal (ebox-incremental--layout-slot-style-signature old-parent) (ebox-incremental--layout-slot-style-signature new-parent)) @@ -9007,17 +9045,17 @@ proving that changed descendants still fit this slot exactly." (new-nodes (plist-get candidate-state :node-table)) (old-parents (plist-get old-state :parent-table)) (new-parents (plist-get candidate-state :parent-table)) - (old-node (and owner-id (gethash owner-id old-nodes))) - (new-node (and owner-id (gethash owner-id new-nodes))) - (parent-id (and owner-id (gethash owner-id old-parents))) - (old-parent (and parent-id (gethash parent-id old-nodes))) - (new-parent (and parent-id (gethash parent-id new-nodes))) + (old-node (and owner-id (ebox-runtime-index-get owner-id old-nodes))) + (new-node (and owner-id (ebox-runtime-index-get owner-id new-nodes))) + (parent-id (and owner-id (ebox-runtime-index-get owner-id old-parents))) + (old-parent (and parent-id (ebox-runtime-index-get parent-id old-nodes))) + (new-parent (and parent-id (ebox-runtime-index-get parent-id new-nodes))) (snapshot (and old-node (ebox-incremental--cached-allocation-snapshot buffer owner-id)))) (and old-node new-node old-parent new-parent snapshot - (equal parent-id (gethash owner-id new-parents)) + (equal parent-id (ebox-runtime-index-get owner-id new-parents)) (eq (ebox-tree-display-inner old-parent) 'column) (eq (ebox-tree-display-inner new-parent) 'column) (ebox-incremental--node-child-ids-equal-p old-node new-node) @@ -9058,17 +9096,17 @@ style, and the published ancestor slot chain remain mandatory." (let* ((old-state (ebox-incremental--proof-base-state buffer)) (node-id (plist-get entry :node-id)) (old-node (and node-id - (gethash node-id (plist-get old-state :node-table)))) + (ebox-runtime-index-get node-id (plist-get old-state :node-table)))) (new-node (and node-id - (gethash node-id (plist-get candidate-state + (ebox-runtime-index-get node-id (plist-get candidate-state :node-table)))) (parents (plist-get old-state :parent-table)) (candidate-parents (plist-get candidate-state :parent-table)) - (parent-id (and node-id (gethash node-id parents))) + (parent-id (and node-id (ebox-runtime-index-get node-id parents))) (old-parent (and parent-id - (gethash parent-id (plist-get old-state :node-table)))) + (ebox-runtime-index-get parent-id (plist-get old-state :node-table)))) (new-parent (and parent-id - (gethash parent-id + (ebox-runtime-index-get parent-id (plist-get candidate-state :node-table)))) (snapshot (and old-node (ebox-incremental--cached-allocation-snapshot @@ -9079,8 +9117,8 @@ style, and the published ancestor slot chain remain mandatory." (eq (ebox-tree-display-inner old-parent) (ebox-tree-display-inner new-parent)) (memq (ebox-tree-display-inner old-parent) '(row flex)) - (equal (gethash parent-id parents) - (gethash parent-id candidate-parents)) + (equal (ebox-runtime-index-get parent-id parents) + (ebox-runtime-index-get parent-id candidate-parents)) (ebox-incremental--node-child-ids-equal-p old-parent new-parent) (equal (ebox-incremental--layout-slot-style-signature old-parent) (ebox-incremental--layout-slot-style-signature new-parent)) @@ -9113,17 +9151,17 @@ the complete snapshot proof." (new-nodes (plist-get candidate-state :node-table)) (old-parents (plist-get old-state :parent-table)) (new-parents (plist-get candidate-state :parent-table)) - (node-id (gethash grid-id old-parents)) + (node-id (ebox-runtime-index-get grid-id old-parents)) (seen nil) (valid (and node-id t))) (while (and valid node-id) (if (memq node-id seen) (setq valid nil) (push node-id seen) - (let* ((old-node (gethash node-id old-nodes)) - (new-node (gethash node-id new-nodes)) - (old-parent (gethash node-id old-parents)) - (new-parent (gethash node-id new-parents)) + (let* ((old-node (ebox-runtime-index-get node-id old-nodes)) + (new-node (ebox-runtime-index-get node-id new-nodes)) + (old-parent (ebox-runtime-index-get node-id old-parents)) + (new-parent (ebox-runtime-index-get node-id new-parents)) (display (and old-node (ebox-tree-display-inner old-node))) (old-width (and old-node (ebox-get old-node :width))) @@ -9156,22 +9194,22 @@ projection kinds and the surface output validator remains authoritative." (let* ((old-state (ebox-incremental--proof-base-state buffer)) (node-id (plist-get entry :node-id)) (old-node (and node-id - (gethash node-id (plist-get old-state :node-table)))) + (ebox-runtime-index-get node-id (plist-get old-state :node-table)))) (new-node (and node-id - (gethash node-id + (ebox-runtime-index-get node-id (plist-get candidate-state :node-table)))) (parents (plist-get old-state :parent-table)) (candidate-parents (plist-get candidate-state :parent-table)) - (ancestor-id (and node-id (gethash node-id parents))) + (ancestor-id (and node-id (ebox-runtime-index-get node-id parents))) (child-id node-id) grid-id old-grid new-grid) (while (and ancestor-id (not grid-id)) - (let* ((old-ancestor (gethash ancestor-id + (let* ((old-ancestor (ebox-runtime-index-get ancestor-id (plist-get old-state :node-table))) - (new-ancestor (gethash ancestor-id + (new-ancestor (ebox-runtime-index-get ancestor-id (plist-get candidate-state :node-table))) - (parent-id (gethash ancestor-id parents)) - (new-parent-id (gethash ancestor-id candidate-parents))) + (parent-id (ebox-runtime-index-get ancestor-id parents)) + (new-parent-id (ebox-runtime-index-get ancestor-id candidate-parents))) (if (and old-ancestor new-ancestor (equal parent-id new-parent-id) (not (ebox-tree-node-visible-overflow-p old-ancestor))) @@ -9295,8 +9333,8 @@ metadata; this predicate only authorizes the local attempt." (dirty (car (plist-get prepared :dirty-set))) (owner-id (plist-get operation :owner-id)) (changed-keys (plist-get dirty :changed-keys)) - (old-node (gethash owner-id (plist-get old-state :node-table))) - (new-node (gethash owner-id (plist-get candidate-state :node-table))) + (old-node (ebox-runtime-index-get owner-id (plist-get old-state :node-table))) + (new-node (ebox-runtime-index-get owner-id (plist-get candidate-state :node-table))) (slot-dirty (plist-put (copy-sequence dirty) :node-id owner-id)) (snapshot @@ -9549,8 +9587,8 @@ role, and outside-complement compatibility before publication." (owner-id (plist-get operation :owner-id)) (dirty-set (plist-get prepared :dirty-set)) (root-id (ebox--buffer-root-node-id buffer)) - (old-node (gethash owner-id (plist-get old-state :node-table))) - (new-node (gethash owner-id + (old-node (ebox-runtime-index-get owner-id (plist-get old-state :node-table))) + (new-node (ebox-runtime-index-get owner-id (plist-get candidate-state :node-table))) (fixed-flex-slot-p (ebox--flex-item-fixed-basis-content-allocation-stable-p @@ -9662,9 +9700,9 @@ role, and outside-complement compatibility before publication." ;; a second proof render here would only duplicate work. (or owner-slot-proof fixed-flex-ancestor-proof content-slot-proof) - (equal (gethash owner-id + (equal (ebox-runtime-index-get owner-id (plist-get old-state :parent-table)) - (gethash owner-id + (ebox-runtime-index-get owner-id (plist-get candidate-state :parent-table))) (ebox-incremental--node-child-ids-equal-p old-node new-node) @@ -9719,8 +9757,8 @@ role, and outside-complement compatibility before publication." (dirty (car (plist-get prepared :dirty-set))) (delta (plist-get dirty :range-delta)) (owner-id (plist-get operation :owner-id)) - (old-node (gethash owner-id (plist-get old-state :node-table))) - (new-node (gethash owner-id + (old-node (ebox-runtime-index-get owner-id (plist-get old-state :node-table))) + (new-node (ebox-runtime-index-get owner-id (plist-get candidate-state :node-table))) (old-items (and delta (apply #'append (plist-get delta :old-payloads)))) @@ -9751,8 +9789,8 @@ role, and outside-complement compatibility before publication." (cl-every (lambda (item) (eq (plist-get item :ebox-kind) 'box)) new-items) - (equal (gethash owner-id (plist-get old-state :parent-table)) - (gethash owner-id + (equal (ebox-runtime-index-get owner-id (plist-get old-state :parent-table)) + (ebox-runtime-index-get owner-id (plist-get candidate-state :parent-table))) (equal (ebox-incremental--layout-slot-style-signature old-node) (ebox-incremental--layout-slot-style-signature new-node)) @@ -10046,6 +10084,8 @@ role, and outside-complement compatibility before publication." (plist-get candidate-state :selector-index-stale-p) :native-node-postorder (plist-get candidate-state :native-node-postorder) + :native-node-postorder-ids + (plist-get candidate-state :native-node-postorder-ids) :surface-node-object-table (and (memq projection-kind '(span-patch owner-scoped scroll-patch @@ -10627,6 +10667,7 @@ When RETAIN-NATIVE-P is non-nil, preserve its committed native frame bases." :selector-type-table (plist-get state :selector-type-table) :runtime-type-count-table (plist-get state :runtime-type-count-table) :native-node-postorder (plist-get state :native-node-postorder) + :native-node-postorder-ids (plist-get state :native-node-postorder-ids) :runtime-index-prepared-p t :scroll-state-table (plist-get state :scroll-state-table) :detached-identity-history @@ -10695,7 +10736,7 @@ topology changes; those continue to use the conservative full projection." (region-id (and (= (length scroll-ids) 1) (car scroll-ids))) (region-node-id (and region-id region-node-table - (gethash region-id region-node-table))) + (ebox-runtime-index-get region-id region-node-table))) (candidate-scroll-table (plist-get candidate-state :scroll-state-table))) (and old-root candidate-root @@ -10734,13 +10775,13 @@ the producer's content geometry is the retained boundary." (node-table (plist-get state :node-table)) (scroll-state-table (plist-get state :scroll-state-table))) (or (null scroll-ids) - (and (hash-table-p region-node-table) - (hash-table-p node-table) + (and (ebox-runtime-index-like-p region-node-table) + (ebox-runtime-index-like-p node-table) (hash-table-p scroll-state-table) (cl-every (lambda (region-id) - (let* ((node-id (gethash region-id region-node-table)) - (node (and node-id (gethash node-id node-table)))) + (let* ((node-id (ebox-runtime-index-get region-id region-node-table)) + (node (and node-id (ebox-runtime-index-get node-id node-table)))) (and node (gethash region-id scroll-state-table) (not (ebox--viewport-dependent-subtree-p node)) @@ -10758,8 +10799,8 @@ fresh by the mixed projection." (scroll-state-table (plist-get state :scroll-state-table)) (stable nil) (affected nil)) - (when (and (hash-table-p region-node-table) - (hash-table-p node-table) + (when (and (ebox-runtime-index-like-p region-node-table) + (ebox-runtime-index-like-p node-table) (hash-table-p scroll-state-table)) (let ((ebox--viewport-dependent-node-ids-cache (make-hash-table :test #'eq)) @@ -10768,8 +10809,8 @@ fresh by the mixed projection." (ebox--viewport-height-dependent-subtree-cache (make-hash-table :test #'eq))) (dolist (region-id (plist-get state :scroll-region-ids)) - (let* ((node-id (gethash region-id region-node-table)) - (node (and node-id (gethash node-id node-table))) + (let* ((node-id (ebox-runtime-index-get region-id region-node-table)) + (node (and node-id (ebox-runtime-index-get node-id node-table))) (scroll-state (gethash region-id scroll-state-table)) (stable-p (and node scroll-state @@ -10821,6 +10862,8 @@ fresh by the mixed projection." (plist-get candidate-state :selector-index-stale-p) :native-node-postorder (plist-get candidate-state :native-node-postorder) + :native-node-postorder-ids + (plist-get candidate-state :native-node-postorder-ids) :runtime-index-prepared-p t :surface-node-object-table (plist-get old-state :surface-node-object-table)))) diff --git a/ebox-layout.el b/ebox-layout.el index ee85509..553f601 100644 --- a/ebox-layout.el +++ b/ebox-layout.el @@ -15,6 +15,7 @@ (require 'ebox-fragment) (require 'ebox-render-context) (require 'ebox-buffer-backend) +(require 'ebox-runtime-index) (declare-function ebox--render-grid "ebox-grid" (node)) (declare-function ebox--render-grid-box-children @@ -1982,8 +1983,9 @@ also pass a pre-rendered string while an active surface owns runtime facts." (cl-loop for state in ebox--render-source-states for table = (plist-get state :node-table) - when (and (hash-table-p table) - (eq node (gethash node-id table))) + when (and (ebox-runtime-index-like-p table) + (eq node + (ebox-runtime-index-get node-id table))) return (plist-get state :source-index))) (and handle (cl-find-if diff --git a/ebox-native-commit.el b/ebox-native-commit.el index 92dd302..f1e76c4 100644 --- a/ebox-native-commit.el +++ b/ebox-native-commit.el @@ -11,6 +11,7 @@ ;;; Code: (require 'cl-lib) +(require 'ebox-runtime-index) (declare-function ebox-native-reflow-layout-ready-p "ebox-native-reflow" ()) (declare-function ebox-native-reflow-execute-sync @@ -45,7 +46,7 @@ "ebox-incremental" (node)) (defun ebox-native-commit-topology-stable-p - (old-state candidate-state prepared) + (old-state candidate-state prepared) "Return non-nil when PREPARED preserves OLD-STATE's object topology." (let ((old-nodes (plist-get old-state :node-table)) (new-nodes (plist-get candidate-state :node-table)) @@ -62,22 +63,22 @@ (lambda (entry) (eq (plist-get entry :dirty-kind) 'structure)) (plist-get prepared :dirty-set))) - (hash-table-p old-nodes) (hash-table-p new-nodes) - (hash-table-p old-parents) (hash-table-p new-parents) - (hash-table-p old-regions) (hash-table-p new-regions) + (ebox-runtime-index-like-p old-nodes) (ebox-runtime-index-like-p new-nodes) + (ebox-runtime-index-like-p old-parents) (ebox-runtime-index-like-p new-parents) + (ebox-runtime-index-like-p old-regions) (ebox-runtime-index-like-p new-regions) (hash-table-p old-objects) - (= (hash-table-count old-nodes) - (hash-table-count new-nodes)) - (= (hash-table-count old-regions) - (hash-table-count new-regions)))) + (= (ebox-runtime-index-size old-nodes) + (ebox-runtime-index-size new-nodes)) + (= (ebox-runtime-index-size old-regions) + (ebox-runtime-index-size new-regions)))) (when stable - (maphash + (ebox-runtime-index-map (lambda (node-id node) - (let ((old-node (gethash node-id old-nodes missing))) + (let ((old-node (ebox-runtime-index-get node-id old-nodes missing))) (unless (and (not (eq old-node missing)) (gethash node-id old-objects) - (equal (gethash node-id old-parents missing) - (gethash node-id new-parents missing)) + (equal (ebox-runtime-index-get node-id old-parents missing) + (ebox-runtime-index-get node-id new-parents missing)) (eq (plist-get old-node :ebox-type) (plist-get node :ebox-type)) (equal @@ -88,9 +89,9 @@ (setq stable nil)))) new-nodes)) (when stable - (maphash + (ebox-runtime-index-map (lambda (region-id node-id) - (unless (equal (gethash region-id old-regions missing) node-id) + (unless (equal (ebox-runtime-index-get region-id old-regions missing) node-id) (setq stable nil))) new-regions)) stable)) @@ -105,7 +106,7 @@ (while (and node-id (not (gethash node-id seen))) (puthash node-id t seen) (push node-id result) - (setq node-id (and parents (gethash node-id parents)))))) + (setq node-id (and parents (ebox-runtime-index-get node-id parents)))))) (nreverse result))) (defun ebox-native-commit-inherited-dirty-node-ids (prepared) @@ -123,8 +124,8 @@ "Return NODE-ID to `(PARENT-ID . POSITION)' table for STATE." (let ((nodes (plist-get state :node-table)) (table (make-hash-table :test 'equal))) - (when (hash-table-p nodes) - (maphash + (when (ebox-runtime-index-like-p nodes) + (ebox-runtime-index-map (lambda (parent-id node) (cl-loop for child in (ebox-tree--children-raw node) for position from 0 @@ -166,22 +167,22 @@ type, key, parent, and sibling position are identical." (plist-get old-state :source-index) old-root) (ebox-tree-node-key (plist-get candidate-state :source-index) new-root)) - (hash-table-p old-nodes) (hash-table-p new-nodes) + (ebox-runtime-index-like-p old-nodes) (ebox-runtime-index-like-p new-nodes) (hash-table-p old-objects))) result) (dolist (node-id (plist-get prepared :touched-node-ids)) - (if (gethash node-id new-nodes) + (if (ebox-runtime-index-get node-id new-nodes) (puthash node-id t requested) (setq valid nil))) (dolist (node-id (plist-get prepared :removed-node-ids)) - (if (and (gethash node-id old-nodes) - (not (gethash node-id new-nodes))) + (if (and (ebox-runtime-index-get node-id old-nodes) + (not (ebox-runtime-index-get node-id new-nodes))) (puthash node-id t removed) (setq valid nil))) (when valid - (maphash + (ebox-runtime-index-map (lambda (node-id _node) - (when (and (not (gethash node-id new-nodes)) + (when (and (not (ebox-runtime-index-get node-id new-nodes)) (not (gethash node-id removed))) (setq valid nil))) old-nodes)) @@ -189,7 +190,7 @@ type, key, parent, and sibling position are identical." (cl-labels ((visit (node) (let* ((node-id (plist-get node :node-id)) - (old-node (gethash node-id old-nodes missing)) + (old-node (ebox-runtime-index-get node-id old-nodes missing)) (old-object (gethash node-id old-objects)) (position (gethash node-id new-positions)) (parent-id (car-safe position)) @@ -229,12 +230,12 @@ type, key, parent, and sibling position are identical." (setq stable (and (plist-get old-state :viewport-dependent-node-ids-ready) (plist-member old-state :viewport-dependent-node-id-axes) - (hash-table-p old-nodes) - (hash-table-p new-nodes))) + (ebox-runtime-index-like-p old-nodes) + (ebox-runtime-index-like-p new-nodes))) (dolist (entry (plist-get prepared :dirty-set) stable) (let* ((node-id (plist-get entry :node-id)) - (old (and stable (gethash node-id old-nodes))) - (new (and stable (gethash node-id new-nodes)))) + (old (and stable (ebox-runtime-index-get node-id old-nodes))) + (new (and stable (ebox-runtime-index-get node-id new-nodes)))) (unless (and old new (eq (ebox--node-direct-viewport-width-dependent-p old) (ebox--node-direct-viewport-width-dependent-p new)) @@ -398,9 +399,9 @@ new session with the same bounded configuration used by retained frames." (defun ebox-native-commit--region-box (state region-id) "Resolve REGION-ID's candidate box directly from STATE indexes." - (when-let* ((node-id (gethash region-id + (when-let* ((node-id (ebox-runtime-index-get region-id (plist-get state :region-node-table))) - (node (gethash node-id (plist-get state :node-table)))) + (node (ebox-runtime-index-get node-id (plist-get state :node-table)))) (pcase (plist-get node :ebox-type) ('box node) ('flex (plist-get node :box))))) @@ -429,8 +430,8 @@ new session with the same bounded configuration used by retained frames." "Return non-nil when STATE contains only executable native node types." (let ((nodes (plist-get state :node-table)) (supported t)) - (when (hash-table-p nodes) - (maphash + (when (ebox-runtime-index-like-p nodes) + (ebox-runtime-index-map (lambda (_node-id node) (unless (memq (plist-get node :ebox-type) '(box concat stack flex)) diff --git a/ebox-native-reflow.el b/ebox-native-reflow.el index 5e73466..ee441be 100644 --- a/ebox-native-reflow.el +++ b/ebox-native-reflow.el @@ -15,6 +15,7 @@ (require 'subr-x) (require 'ebox-measure) (require 'ebox-render-context) +(require 'ebox-runtime-index) (declare-function ebox-native--module-version "ebox_native_reflow" ()) (declare-function ebox-native--module-layout-ready-p "ebox_native_reflow" ()) @@ -2411,7 +2412,13 @@ Otherwise return CANDIDATE with the next document revision." (root (and state (plist-get state :root-node))) (postorder (and state (plist-get state :native-node-postorder)))) (unless (and root (vectorp postorder) (> (length postorder) 0) - (eq root (aref postorder (1- (length postorder))))) + (equal (plist-get root :node-id) + (let ((entry (aref postorder + (1- (length postorder))))) + (if (and (listp entry) + (plist-member entry :node-id)) + (plist-get entry :node-id) + entry)))) (error "Native layout scene is absent from the render transaction")) (with-current-buffer buffer (ebox-native-reflow--make-layout-scene @@ -2496,7 +2503,12 @@ per call, and no call recursively visits the captured Ebox tree." (< (- (float-time) started) budget-seconds))) (let* ((index (ebox-native-reflow-layout-builder-index builder)) - (node (aref nodes index)) + (node + (ebox-native-reflow--postorder-node + (aref nodes index) + (plist-get + (ebox-native-reflow-layout-scene-render-state scene) + :node-table))) (fragment (ebox-native-reflow--compile-scene-node node fragments @@ -2645,10 +2657,21 @@ per call, and no call recursively visits the captured Ebox tree." (plist-get node :content-min-width))) local))) -(defun ebox-native-reflow--retained-edge-index (postorder cache) - "Build exact child-edge facts from full POSTORDER fragments in CACHE." +(defun ebox-native-reflow--postorder-node (entry node-table) + "Resolve POSTORDER ENTRY through NODE-TABLE when it is a node id." + (if (and (listp entry) (plist-member entry :ebox-type)) + entry + (or (ebox-runtime-index-get entry node-table) + (error "Native postorder references an absent node: %S" entry)))) + +(defun ebox-native-reflow--retained-edge-index + (postorder cache &optional node-table) + "Build exact child-edge facts from full POSTORDER fragments in CACHE. +Resolve node-ID entries through NODE-TABLE; legacy node plists need no index." (let (index) - (cl-loop for parent across postorder + (cl-loop for entry across postorder + for parent = (ebox-native-reflow--postorder-node + entry node-table) for parent-id = (plist-get parent :node-id) for rule = (ebox-native-reflow--retained-edge-rule parent) when rule do @@ -2685,7 +2708,8 @@ per call, and no call recursively visits the captured Ebox tree." (defun ebox-native-reflow--compile-retained-layout-package-full (session state node) "Compile a complete retained package for NODE in SESSION from STATE." - (let ((postorder (plist-get state :native-node-postorder))) + (let ((postorder (plist-get state :native-node-postorder)) + (node-table (plist-get state :node-table))) (unless (and (vectorp postorder) (> (length postorder) 0)) (setq postorder (ebox-native-reflow--retained-layout-postorder node))) @@ -2728,7 +2752,11 @@ per call, and no call recursively visits the captured Ebox tree." (postorder-id-set (let ((set (make-hash-table :test 'equal))) (dotimes (index (length postorder)) - (puthash (plist-get (aref postorder index) :node-id) + (puthash + (plist-get + (ebox-native-reflow--postorder-node + (aref postorder index) node-table) + :node-id) t set)) set)) (ebox-native-reflow--compile-styles @@ -2832,7 +2860,9 @@ per call, and no call recursively visits the captured Ebox tree." (puthash current fragment fragments) (puthash node-id entry new-cache)))) (dotimes (index (length postorder)) - (let* ((current (aref postorder index)) + (let* ((current + (ebox-native-reflow--postorder-node + (aref postorder index) node-table)) (node-id (plist-get current :node-id))) (if (and retained-fast-p (not (gethash node-id touched-set))) @@ -2871,7 +2901,7 @@ per call, and no call recursively visits the captured Ebox tree." (plist-get package :property-templates)) (ebox-native-reflow-session-layout-edge-index session) (ebox-native-reflow--retained-edge-index - postorder new-cache)) + postorder new-cache node-table)) package))))) (defconst ebox-native-reflow--delta-edge-fields @@ -2924,9 +2954,9 @@ RULE identifies the compiler lowering, and RETAINED supplies its stable slot." (owner-id (and nodes parents (ebox-native-reflow--delta-owner-id state dirty-id index))) - (owner (and owner-id (gethash owner-id nodes))) - (parent-id (and owner-id (gethash owner-id parents))) - (parent (and parent-id (gethash parent-id nodes))) + (owner (and owner-id (ebox-runtime-index-get owner-id nodes))) + (parent-id (and owner-id (ebox-runtime-index-get owner-id parents))) + (parent (and parent-id (ebox-runtime-index-get parent-id nodes))) (current-parent-rule (and parent (ebox-native-reflow--retained-edge-rule parent))) (retained-parent-rule @@ -2994,9 +3024,9 @@ RULE identifies the compiler lowering, and RETAINED supplies its stable slot." (let ((parents (plist-get state :parent-table)) (nodes (plist-get state :node-table)) (current node-id)) - (when-let* ((source (and nodes (gethash current nodes))) - (parent-id (and parents (gethash current parents))) - (parent (and nodes (gethash parent-id nodes)))) + (when-let* ((source (and nodes (ebox-runtime-index-get current nodes))) + (parent-id (and parents (ebox-runtime-index-get current parents))) + (parent (and nodes (ebox-runtime-index-get parent-id nodes)))) (when (and (eq (plist-get source :ebox-kind) 'text) (eq (plist-get parent :ebox-kind) 'box) (eq (ebox-layout-config-kind @@ -3006,7 +3036,7 @@ RULE identifies the compiler lowering, and RETAINED supplies its stable slot." (while (and current (null (ebox-native-reflow--persistent-index-get index current))) - (setq current (and parents (gethash current parents)))) + (setq current (and parents (ebox-runtime-index-get current parents)))) current)) (defun ebox-native-reflow--compile-delta-slots (node old-fragment) @@ -3073,7 +3103,7 @@ RULE identifies the compiler lowering, and RETAINED supplies its stable slot." (old-index (ebox-native-reflow-session-layout-fragment-index session)) (node-table (plist-get state :node-table)) (touched (plist-get state :native-touched-node-ids))) - (when (and old-package old-index (hash-table-p node-table) + (when (and old-package old-index (ebox-runtime-index-like-p node-table) (plist-get state :native-topology-stable-p) (plist-member state :native-local-dirty-entries) (not (ebox-native-reflow--delta-edge-change-p @@ -3126,7 +3156,7 @@ RULE identifies the compiler lowering, and RETAINED supplies its stable slot." (let* ((cached (ebox-native-reflow--persistent-index-get old-index owner-id)) (old-fragment (plist-get cached :fragment)) - (source (gethash owner-id node-table)) + (source (ebox-runtime-index-get owner-id node-table)) (expected (plist-get cached :revision)) patches new-fragment) (unless (and cached source (integerp expected)) diff --git a/ebox-patch-plan.el b/ebox-patch-plan.el index a5df05a..fe00411 100644 --- a/ebox-patch-plan.el +++ b/ebox-patch-plan.el @@ -12,6 +12,7 @@ (require 'cl-lib) (require 'subr-x) +(require 'ebox-runtime-index) (define-error 'ebox-patch-plan-error "Invalid Ebox patch plan input") @@ -115,14 +116,14 @@ "Return OWNER-ID's strict ancestors from immutable PARENT-TABLE." (let ((ancestors (make-hash-table :test #'equal)) (seen (make-hash-table :test #'equal)) - (walk (gethash owner-id parent-table))) + (walk (ebox-runtime-index-get owner-id parent-table))) (while walk (when (gethash walk seen) (signal 'ebox-patch-plan-error (list :cyclic-parent-table :node-id walk))) (puthash walk t seen) (puthash walk t ancestors) - (setq walk (gethash walk parent-table))) + (setq walk (ebox-runtime-index-get walk parent-table))) ancestors)) (defun ebox-patch-plan--make-index () @@ -216,7 +217,7 @@ PARENT-TABLE maps a node id to its parent id and is treated as immutable. OPERATIONS are patch artifact plists. This function mutates neither input and has no publication capability." - (unless (hash-table-p parent-table) + (unless (ebox-runtime-index-like-p parent-table) (signal 'ebox-patch-plan-error (list :malformed-parent-table parent-table))) (unless (proper-list-p operations) diff --git a/ebox-runtime-index.el b/ebox-runtime-index.el new file mode 100644 index 0000000..b09302c --- /dev/null +++ b/ebox-runtime-index.el @@ -0,0 +1,237 @@ +;;; ebox-runtime-index.el --- Immutable runtime indexes for Ebox -*- lexical-binding: t; -*- + +;; SPDX-License-Identifier: GPL-3.0-or-later + +;;; Commentary: + +;; Compressed persistent radix maps used by runtime state. Branches retain +;; only nibble positions where codes diverge; leaves hold exact equal-key +;; collision buckets. Adapters continue to accept legacy hash tables. + +;;; Code: + +(require 'cl-lib) + +(cl-defstruct (ebox-runtime-index + (:constructor ebox-runtime-index--create + (root count key-set-token))) + "Immutable compressed radix index." + root + count + key-set-token) + +(defun ebox-runtime-index-empty () + "Return an empty immutable runtime index." + (ebox-runtime-index--create nil 0 nil)) + +(defun ebox-runtime-index-like-p (object) + "Return non-nil when OBJECT is a supported runtime index." + (or (ebox-runtime-index-p object) (hash-table-p object))) + +(defun ebox-runtime-index--code (key) + "Return a stable 64-bit radix code for KEY." + (logand (if (and (integerp key) (>= key 0)) key (sxhash-equal key)) + #xffffffffffffffff)) + +(defun ebox-runtime-index--branch (shift code &optional original) + "Return a fresh branch for nibble SHIFT and representative CODE. +Copy ORIGINAL when supplied; its metadata already carries SHIFT and CODE. +All branch allocations, including path copies, pass through this function." + (if original + (copy-sequence original) + (let ((branch (make-vector 18 nil))) + (aset branch 0 shift) + (aset branch 1 code) + branch))) + +(defun ebox-runtime-index--join (left left-code right right-code) + "Join LEFT and RIGHT above the first differing nibble of their codes. +LEFT-CODE and RIGHT-CODE are distinct 64-bit representative codes." + (let* ((difference (logxor left-code right-code)) + (shift (logand (logcount (1- (logand difference (- difference)))) + -4)) + (branch (ebox-runtime-index--branch shift left-code))) + (aset branch (+ 2 (logand (ash left-code (- shift)) 15)) left) + (aset branch (+ 2 (logand (ash right-code (- shift)) 15)) right) + branch)) + +(defun ebox-runtime-index--root-get (root key missing) + "Return KEY from persistent ROOT, or MISSING." + (let ((node root) + (code (ebox-runtime-index--code key))) + (while (vectorp node) + (setq node (aref node (+ 2 (logand (ash code (- (aref node 0))) 15))))) + (if (and node (= code (car node))) + (let ((pair (assoc key (cdr node)))) + (if pair (cdr pair) missing)) + missing))) + +(defun ebox-runtime-index-get (key index &optional missing) + "Return KEY's value from INDEX, or MISSING. +INDEX may be an immutable runtime index or a legacy hash table." + (cond + ((ebox-runtime-index-p index) + (ebox-runtime-index--root-get (ebox-runtime-index-root index) key missing)) + ((hash-table-p index) (gethash key index missing)) + (t missing))) + +(defun ebox-runtime-index-contains-p (key index) + "Return non-nil when INDEX contains KEY, including a nil value." + (let ((missing (make-symbol "ebox-runtime-index-missing"))) + (not (eq (ebox-runtime-index-get key index missing) missing)))) + +(defun ebox-runtime-index--root-put (root key value code) + "Return `(ROOT . ADDED)' after mapping KEY to VALUE using CODE." + (cond + ((null root) (cons (cons code (list (cons key value))) t)) + ((vectorp root) + (let ((shift (aref root 0)) + (representative (aref root 1))) + (if (/= (logand (logxor code representative) (1- (ash 1 shift))) 0) + (cons (ebox-runtime-index--join + root representative (cons code (list (cons key value))) code) + t) + (let* ((slot (+ 2 (logand (ash code (- shift)) 15))) + (result (ebox-runtime-index--root-put + (aref root slot) key value code)) + (copy (ebox-runtime-index--branch shift representative root))) + (aset copy slot (car result)) + (cons copy (cdr result)))))) + ((= code (car root)) + (let* ((bucket (cdr root)) + (present (assoc key bucket))) + (cons (cons code + (cons (cons key value) + (if present + (cl-remove key bucket :key #'car :test #'equal) + bucket))) + (not present)))) + (t (cons (ebox-runtime-index--join + root (car root) (cons code (list (cons key value))) code) + t)))) + +(defun ebox-runtime-index-put (key value index) + "Return INDEX with KEY mapped to VALUE. +Immutable indexes return a new value. Legacy hash tables are mutated and +returned for compatibility." + (cond + ((ebox-runtime-index-p index) + (let ((result + (ebox-runtime-index--root-put + (ebox-runtime-index-root index) key value + (ebox-runtime-index--code key)))) + (ebox-runtime-index--create + (car result) + (+ (ebox-runtime-index-count index) (if (cdr result) 1 0)) + (if (cdr result) + (make-symbol "ebox-runtime-index-key-set") + (ebox-runtime-index-key-set-token index))))) + ((hash-table-p index) (puthash key value index) index) + (t (signal 'wrong-type-argument (list 'ebox-runtime-index-like-p index))))) + +(defun ebox-runtime-index--root-delete (root key code) + "Return `(ROOT . REMOVED)' after deleting KEY using CODE." + (cond + ((null root) (cons nil nil)) + ((vectorp root) + (let* ((shift (aref root 0)) + (slot (+ 2 (logand (ash code (- shift)) 15))) + (result (ebox-runtime-index--root-delete (aref root slot) key code))) + (if (not (cdr result)) + (cons root nil) + (let ((children 0) survivor) + (unless (car result) + (dotimes (offset 16) + (let ((position (+ offset 2))) + (when (and (/= position slot) (aref root position)) + (cl-incf children) + (setq survivor (aref root position)))))) + (if (= children 1) + (cons survivor t) + (let ((copy (ebox-runtime-index--branch shift (aref root 1) root))) + (aset copy slot (car result)) + (cons copy t))))))) + ((and (= code (car root)) (assoc key (cdr root))) + (let ((bucket (cl-remove key (cdr root) :key #'car :test #'equal))) + (cons (and bucket (cons code bucket)) t))) + (t (cons root nil)))) + +(defun ebox-runtime-index-delete (key index) + "Return INDEX without KEY. +Immutable indexes return a new value. Legacy hash tables are mutated and +returned for compatibility." + (cond + ((ebox-runtime-index-p index) + (let ((result + (ebox-runtime-index--root-delete + (ebox-runtime-index-root index) key + (ebox-runtime-index--code key)))) + (if (cdr result) + (let ((count (1- (ebox-runtime-index-count index)))) + (ebox-runtime-index--create + (car result) count + (and (> count 0) + (make-symbol "ebox-runtime-index-key-set")))) + index))) + ((hash-table-p index) (remhash key index) index) + (t (signal 'wrong-type-argument (list 'ebox-runtime-index-like-p index))))) + +(defun ebox-runtime-index-size (index) + "Return the number of entries in INDEX." + (cond + ((ebox-runtime-index-p index) (ebox-runtime-index-count index)) + ((hash-table-p index) (hash-table-count index)) + (t 0))) + +(defun ebox-runtime-index-key-set-equal-p (left right) + "Return non-nil when supported indexes LEFT and RIGHT have equal keys." + (let ((persistent-shared-keys-p + (and (ebox-runtime-index-p left) + (ebox-runtime-index-p right) + (= (ebox-runtime-index-count left) + (ebox-runtime-index-count right)) + (eq (ebox-runtime-index-key-set-token left) + (ebox-runtime-index-key-set-token right))))) + (cond + (persistent-shared-keys-p t) + ((and (ebox-runtime-index-like-p left) + (ebox-runtime-index-like-p right) + (= (ebox-runtime-index-size left) (ebox-runtime-index-size right))) + (let ((equal-p t) + (missing (make-symbol "ebox-runtime-index-missing"))) + (ebox-runtime-index-map + (lambda (key _value) + (when (eq (ebox-runtime-index-get key right missing) missing) + (setq equal-p nil))) + left) + equal-p)) + (t nil)))) + +(defun ebox-runtime-index--map-root (function root) + "Call FUNCTION for entries below ROOT, visiting only live trie nodes." + (when root + (if (vectorp root) + (dotimes (branch 16) + (let ((child (aref root (+ 2 branch)))) + (when child (ebox-runtime-index--map-root function child)))) + (dolist (pair (cdr root)) (funcall function (car pair) (cdr pair)))))) + +(defun ebox-runtime-index-map (function index) + "Call FUNCTION with every key and value in INDEX." + (cond + ((ebox-runtime-index-p index) + (ebox-runtime-index--map-root + function (ebox-runtime-index-root index))) + ((hash-table-p index) (maphash function index)) + (t (signal 'wrong-type-argument (list 'ebox-runtime-index-like-p index)))) + nil) + +(defun ebox-runtime-index-keys (index) + "Return a list of keys in INDEX." + (let (keys) + (ebox-runtime-index-map (lambda (key _value) (push key keys)) index) + keys)) + +(provide 'ebox-runtime-index) + +;;; ebox-runtime-index.el ends here diff --git a/ebox-surface.el b/ebox-surface.el index 0da7d3f..63b34d2 100644 --- a/ebox-surface.el +++ b/ebox-surface.el @@ -18,6 +18,7 @@ (require 'ebox-tree) (require 'ebox-layout) (require 'ebox-incremental) +(require 'ebox-runtime-index) (require 'tp-reactive) (require 'tp-surface) (require 'tp) @@ -1521,7 +1522,7 @@ PREVIOUS-STATE's node-object table." (context nodes node-root old-objects objects-by-node) "Reuse native NODES whose object topology is proven unchanged." (tp-object-reuse-subtree context node-root) - (maphash + (ebox-runtime-index-map (lambda (node-id node) (let ((object (gethash node-id old-objects))) (unless object @@ -1544,7 +1545,7 @@ PREVIOUS-STATE's node-object table." (old-objects (plist-get previous-state :surface-node-object-table)) (touched (make-hash-table :test 'equal)) (objects-by-id (make-hash-table :test 'equal))) - (unless (and (hash-table-p nodes) (hash-table-p parents) + (unless (and (ebox-runtime-index-like-p nodes) (ebox-runtime-index-like-p parents) (hash-table-p old-objects) (or (plist-get state-overrides :native-topology-stable-p) touched-ids)) @@ -1554,7 +1555,7 @@ PREVIOUS-STATE's node-object table." context nodes node-root old-objects objects-by-node) (tp-object-retain context node-root) (dolist (node-id touched-ids) (puthash node-id t touched)) - (maphash + (ebox-runtime-index-map (lambda (node-id node) (when subject-table (when-let* ((subject @@ -1574,10 +1575,10 @@ PREVIOUS-STATE's node-object table." (puthash node-id object objects-by-id)))) nodes) (dolist (node-id touched-ids) - (let* ((node (gethash node-id nodes)) - (parent-id (and node (gethash node-id parents))) + (let* ((node (ebox-runtime-index-get node-id nodes)) + (parent-id (and node (ebox-runtime-index-get node-id parents))) (old-object (and node (gethash node-id old-objects))) - (parent-node (and parent-id (gethash parent-id nodes))) + (parent-node (and parent-id (ebox-runtime-index-get parent-id nodes))) (position (if parent-node (or (cl-position @@ -2009,7 +2010,7 @@ batch without asking TP to rediscover the same diff from a full plan." (progn (setq object (and objects (gethash node-id objects))) (or (not object) (not (tp-object-mounted-p object))))) - (setq node-id (and parents (gethash node-id parents)))) + (setq node-id (and parents (ebox-runtime-index-get node-id parents)))) (unless (and object (tp-object-live-p object) (tp-object-mounted-p object)) (error "Ebox scoped owner has no mounted TP object")) @@ -2457,19 +2458,19 @@ source-handle-to-subject table when the requested local preparation completed." (when (gethash node-id visiting) (error "Ebox candidate style parent cycle at %S" node-id)) (puthash node-id t visiting) - (let* ((node (gethash node-id new-nodes)) - (old-node (gethash node-id old-nodes)) - (parent-id (gethash node-id parents)) + (let* ((node (ebox-runtime-index-get node-id new-nodes)) + (old-node (ebox-runtime-index-get node-id old-nodes)) + (parent-id (ebox-runtime-index-get node-id parents)) (parent-style (and parent-id (resolve parent-id))) (parent-subject (and parent-id (gethash parent-id subjects-by-id))) (old-parent-id (and old-node - (gethash node-id + (ebox-runtime-index-get node-id (plist-get old-state :parent-table)))) (old-parent (and old-parent-id - (gethash old-parent-id old-nodes))) + (ebox-runtime-index-get old-parent-id old-nodes))) (old-parent-style (and old-parent (plist-get old-parent :ebox-computed-style))) @@ -2534,7 +2535,7 @@ source-handle-to-subject table when the requested local preparation completed." (ebox-surface--project-font node style (and parent-id - (plist-get (gethash parent-id new-nodes) + (plist-get (ebox-runtime-index-get parent-id new-nodes) :ebox-font-fact)) snapshot) (cond @@ -2556,7 +2557,7 @@ source-handle-to-subject table when the requested local preparation completed." (puthash node-id style resolved) style)))))) (dolist (node-id node-ids) - (when (gethash node-id new-nodes) + (when (ebox-runtime-index-get node-id new-nodes) (resolve node-id)))) subjects-by-handle)))) @@ -2671,7 +2672,7 @@ dependencies, or any malformed index conservatively preserves root planning." signature))))) (and (hash-table-p objects) (hash-table-p style-states) - (hash-table-p candidate-nodes) + (ebox-runtime-index-like-p candidate-nodes) dirty-set (cl-every (lambda (entry) @@ -2681,7 +2682,8 @@ dependencies, or any malformed index conservatively preserves root planning." (object (and node-id (gethash node-id objects))) (style-state (and object (gethash object style-states))) (candidate-node (and node-id - (gethash node-id candidate-nodes))) + (ebox-runtime-index-get + node-id candidate-nodes))) (subject (and style-state (plist-get style-state :subject))) (candidate-declarations @@ -2790,7 +2792,7 @@ dependencies, or any malformed index conservatively preserves root planning." (defun ebox-surface--region-object-table (state node-objects) "Return region-to-object table from candidate STATE and NODE-OBJECTS." (let ((table (make-hash-table :test 'equal))) - (maphash + (ebox-runtime-index-map (lambda (region-id node-id) (let ((object (gethash node-id node-objects))) (unless object @@ -2975,10 +2977,10 @@ valid because their declarative layout and viewport did not change." "Attach retained TP objects to candidate nodes in STATE." (let ((nodes (plist-get state :node-table)) (objects (plist-get state :surface-node-object-table))) - (when (and (hash-table-p nodes) (hash-table-p objects)) + (when (and (ebox-runtime-index-like-p nodes) (hash-table-p objects)) (maphash (lambda (node-id object) - (when-let* ((node (gethash node-id nodes))) + (when-let* ((node (ebox-runtime-index-get node-id nodes))) (plist-put node :surface-object object))) objects)) state)) @@ -2994,7 +2996,7 @@ valid because their declarative layout and viewport did not change." (objects (plist-get state :surface-node-object-table)) (style-states (plist-get state :style-binding-states))) (dolist (owner-id owner-ids) - (when-let* ((node (and nodes (gethash owner-id nodes))) + (when-let* ((node (and nodes (ebox-runtime-index-get owner-id nodes))) (object (and objects (gethash owner-id objects))) (style-state (and style-states (gethash object style-states))) @@ -3255,11 +3257,11 @@ old allocated width while the owner changes its natural content length." (old-snapshot (and owner-id (ebox--ensure-layout-snapshot-details buffer owner-id))) (spans (and old-snapshot (plist-get old-snapshot :buffer-spans))) - (node (and owner-id (gethash owner-id (plist-get state :node-table)))) + (node (and owner-id (ebox-runtime-index-get owner-id (plist-get state :node-table)))) (parent-id (and owner-id - (gethash owner-id (plist-get state :parent-table)))) + (ebox-runtime-index-get owner-id (plist-get state :parent-table)))) (parent (and parent-id - (gethash parent-id (plist-get state :node-table)))) + (ebox-runtime-index-get parent-id (plist-get state :node-table)))) (slot-sized-render-p (and role-owned-lines-p parent (eq (ebox-tree-display-inner parent) 'flex))) @@ -3343,7 +3345,7 @@ old allocated width while the owner changes its natural content length." (let* ((owner-id (plist-get proof :owner-id)) (old-snapshot (plist-get proof :snapshot)) (spans (plist-get old-snapshot :buffer-spans)) - (node (gethash owner-id (plist-get state :node-table))) + (node (ebox-runtime-index-get owner-id (plist-get state :node-table))) (render-node (if (and node (numberp (plist-get proof :allocated-width))) (plist-put (copy-sequence node) @@ -3566,7 +3568,7 @@ untouched." (snapshot (and proof (plist-get proof :snapshot))) (old-spans (and proof (plist-get proof :old-spans))) (node (and owner-id - (gethash owner-id (plist-get state :node-table)))) + (ebox-runtime-index-get owner-id (plist-get state :node-table)))) (allocated-width (and proof (plist-get proof :allocated-width)))) (when (and proof snapshot old-spans node @@ -3577,16 +3579,16 @@ untouched." (parent-table (plist-get state :parent-table)) (ancestor-owner-ids (let ((walk (and parent-table - (gethash owner-id parent-table))) + (ebox-runtime-index-get owner-id parent-table))) owners) (while walk (when-let* ((ancestor - (gethash walk (plist-get state :node-table))) + (ebox-runtime-index-get walk (plist-get state :node-table))) (region-id (ebox-surface--node-editable-region-id ancestor))) (setq owners (append owners (list region-id)))) - (setq walk (gethash walk parent-table))) + (setq walk (ebox-runtime-index-get walk parent-table))) owners)) (rendered (ebox--maplines @@ -3965,11 +3967,11 @@ box with wrapper chrome falls back to the retained wrapper renderer." (nodes (plist-get state :node-table))) (and (plist-get state :runtime-index-prepared-p) root-id - (hash-table-p nodes) - (eq root (gethash root-id nodes)) - (hash-table-p (plist-get state :parent-table)) + (ebox-runtime-index-like-p nodes) + (eq root (ebox-runtime-index-get root-id nodes)) + (ebox-runtime-index-like-p (plist-get state :parent-table)) (hash-table-p (plist-get state :region-id-set)) - (hash-table-p (plist-get state :region-node-table)) + (ebox-runtime-index-like-p (plist-get state :region-node-table)) (hash-table-p (plist-get state :region-box-count-table)) (hash-table-p (plist-get state :region-box-table)) (hash-table-p @@ -4075,7 +4077,7 @@ Each owner appears at most once for one rendered fragment." owners) (dolist (region-id (delete-dups (mapcar #'cdr role-ids))) (let ((object (gethash region-id region-objects)) - (node-id (gethash region-id region-node-table))) + (node-id (ebox-runtime-index-get region-id region-node-table))) (unless (and object node-id) (error "Ebox output references unknown region %S" region-id)) (unless (gethash object attached) @@ -4083,7 +4085,7 @@ Each owner appears at most once for one rendered fragment." (ebox-surface--region-role-tags region-id role-ids)) owners) (puthash object t attached)) - (setq node-id (gethash node-id parent-table)) + (setq node-id (ebox-runtime-index-get node-id parent-table)) (while node-id (let ((ancestor (gethash node-id node-objects))) (unless ancestor @@ -4091,7 +4093,7 @@ Each owner appears at most once for one rendered fragment." (unless (gethash ancestor attached) (push (list ancestor (list :ebox/descendant-output t)) owners) (puthash ancestor t attached))) - (setq node-id (gethash node-id parent-table))))) + (setq node-id (ebox-runtime-index-get node-id parent-table))))) (nreverse owners))) (defun ebox-surface--rendered-fragments (rendered) @@ -4514,7 +4516,7 @@ all descendant ranges remain unchanged." (let* ((proof (plist-get state :formatting-context-reflow-proof)) (owner-id (plist-get proof :owner-id)) (owner (and owner-id (gethash owner-id node-objects))) - (node (and owner-id (gethash owner-id (plist-get state :node-table)))) + (node (and owner-id (ebox-runtime-index-get owner-id (plist-get state :node-table)))) (region-id (and node (plist-get node :region-id))) (block-start (max 0 @@ -4720,7 +4722,7 @@ NODE-OBJECTS and REGION-OBJECTS supply retained ownership ranges." (let ((key node-id) (parents (plist-get state :parent-table)) (depth 0)) - (while (setq node-id (and node-id (gethash node-id parents))) + (while (setq node-id (and node-id (ebox-runtime-index-get node-id parents))) (cl-incf depth)) (when ebox-surface--paint-node-depth-cache (puthash key depth ebox-surface--paint-node-depth-cache)) @@ -4737,14 +4739,16 @@ NODE-OBJECTS and REGION-OBJECTS supply retained ownership ranges." (let ((region-nodes (plist-get state :region-node-table)) (parents (plist-get state :parent-table)) deepest deepest-depth) (dolist (entry role-ids) - (when-let* ((node-id (gethash (cdr entry) region-nodes))) + (when-let* ((node-id + (ebox-runtime-index-get + (cdr entry) region-nodes))) (let ((depth (ebox-surface--node-depth state node-id))) (when (or (null deepest-depth) (> depth deepest-depth)) (setq deepest node-id deepest-depth depth))))) (let (chain) (while deepest (push deepest chain) - (setq deepest (gethash deepest parents))) + (setq deepest (ebox-runtime-index-get deepest parents))) (setq chain (nreverse chain)) (when ebox-surface--paint-node-chain-cache (puthash role-ids chain ebox-surface--paint-node-chain-cache)) @@ -4816,7 +4820,7 @@ ANCESTOR-P means BOX wraps a more specific rendered surface." (let ((nodes (plist-get state :node-table)) result first) (setq first t) (dolist (node-id (ebox-surface--paint-node-chain state role-ids)) - (when-let* ((node (gethash node-id nodes)) + (when-let* ((node (ebox-runtime-index-get node-id nodes)) (box (ebox-fragment-style-source-node node))) (let* ((region-id (plist-get box :region-id)) (roles @@ -5003,7 +5007,7 @@ relative face contributions and performs the final merge during prepare." "Return FRAGMENTS with horizontal border roles updated from STATE." (let ((nodes (plist-get state :node-table))) (dolist (dirty dirty-set fragments) - (when-let* ((node (gethash (plist-get dirty :node-id) nodes)) + (when-let* ((node (ebox-runtime-index-get (plist-get dirty :node-id) nodes)) (box (ebox-fragment-style-source-node node)) (region-id (plist-get box :region-id))) (dolist (entry '(((:border-top-width :border-top-style) @@ -5084,7 +5088,7 @@ OLD-TEXT is a defensive snapshot of the published TP content." (when (hash-table-p snapshots) (dolist (dirty dirty-set) (when-let* ((node-id (plist-get dirty :node-id)) - (node (gethash node-id nodes)) + (node (ebox-runtime-index-get node-id nodes)) (snapshot (gethash node-id snapshots))) (plist-put snapshot :style-signature (ebox-fragment-node-style-signature node))))) diff --git a/ebox-tree.el b/ebox-tree.el index 28bd633..684b695 100644 --- a/ebox-tree.el +++ b/ebox-tree.el @@ -15,6 +15,7 @@ (require 'ebox-style) (require 'ebox-node-factory) (require 'ebox-child-range) +(require 'ebox-runtime-index) (defvar ebox-tree--incoming-source-indexes nil "Transaction-local handle to incoming source-index map for one delta.") @@ -739,8 +740,8 @@ handles, reused node objects, and cycles without consulting node metadata." (defun ebox-tree--indexed-parent-context (node-id node-table parent-table) "Return the direct parent layout kind for NODE-ID in indexed tree." - (let* ((parent-id (gethash node-id parent-table)) - (parent (and parent-id (gethash parent-id node-table)))) + (let* ((parent-id (ebox-runtime-index-get node-id parent-table)) + (parent (and parent-id (ebox-runtime-index-get parent-id node-table)))) (and parent (ebox-tree--child-layout-kind parent nil)))) (defun ebox-tree-validate-indexed-participation @@ -748,7 +749,7 @@ handles, reused node objects, and cycles without consulting node metadata." "Validate NODE-IDS in indexed final tree using AUTHOR or COMPUTED SOURCE." (setq source (or source 'author)) (dolist (node-id (delete-dups (copy-sequence node-ids))) - (when-let* ((node (gethash node-id node-table))) + (when-let* ((node (ebox-runtime-index-get node-id node-table))) (ebox-tree--validate-node-parent-participation node (ebox-tree--indexed-parent-context node-id node-table parent-table) @@ -1379,12 +1380,12 @@ stale until a query or full projection materializes them." (parent-id (nth 2 entry)) parent-subject) (while (and parent-id (null parent-subject)) - (when-let* ((parent (gethash parent-id node-table))) + (when-let* ((parent (ebox-runtime-index-get parent-id node-table))) (when-let* ((parent-handle (ebox-tree-node-source-handle parent))) (setq parent-subject (node-subject parent-handle)))) (unless parent-subject - (setq parent-id (gethash parent-id parent-table)))) + (setq parent-id (ebox-runtime-index-get parent-id parent-table)))) (setf (ecss-subject-parent subject) parent-subject) (puthash (ebox-source--handle-id-view handle) subject added-subjects) diff --git a/ebox.el b/ebox.el index a0eff1f..b368212 100644 --- a/ebox.el +++ b/ebox.el @@ -23,7 +23,8 @@ "Directory containing the active Ebox Lisp sources.") (defconst ebox--compile-sources - '("ebox-cache.el" "ebox-source.el" "ebox-state-contract.el" + '("ebox-cache.el" "ebox-source.el" "ebox-runtime-index.el" + "ebox-state-contract.el" "ebox-font.el" "ebox-style.el" "ebox-layout-config.el" "ebox-node-factory.el" @@ -44,6 +45,7 @@ (require 'subr-x) (require 'ebox-cache) (require 'ebox-source) +(require 'ebox-runtime-index) (require 'ebox-state-contract) (require 'ebox-font) (require 'ebox-style) @@ -1141,7 +1143,7 @@ remain transaction-local." (let* ((template-box (plist-get template :box)) (node-id (and template-box (ebox--ensure-node-id template-box))) (node-table (ebox--buffer-node-table (current-buffer)))) - (or (and node-table node-id (gethash node-id node-table)) + (or (and node-table node-id (ebox-runtime-index-get node-id node-table)) (plist-get (ebox--scroll-get-state region-id) :box) (gethash region-id ebox--region-box-table)))) @@ -1164,7 +1166,7 @@ remain transaction-local." (or (ebox-get effective-box :scroll-offset) 0)) (or portable (null node-table) - (eq box (gethash node-id node-table))) + (eq box (ebox-runtime-index-get node-id node-table))) (or (null live-state) (eq effective-box (plist-get live-state :box))) (not (plist-get live-state :lazy-scroll-prefix-dirty)) @@ -2931,7 +2933,9 @@ Scroll routing uses semantic ancestry rather than mount span length because nested owners may render equal-sized clipped spans." (let* ((state (ebox--buffer-render-state (current-buffer))) (region-nodes (plist-get state :region-node-table)) - (node-id (and region-nodes (gethash region-id region-nodes)))) + (node-id + (and region-nodes + (ebox-runtime-index-get region-id region-nodes)))) (if node-id (ebox-surface--node-depth state node-id) -1))) (defun ebox--scroll-region-ids-in-buffer-outer-first () @@ -4110,7 +4114,7 @@ cannot be changed through the unpublished batch root." entries) (let ((parents (ebox--buffer-parent-table buffer)) ancestors) (dolist (node-id node-ids) - (while (setq node-id (gethash node-id parents)) + (while (setq node-id (ebox-runtime-index-get node-id parents)) (push node-id ancestors))) (delete-dups ancestors)))) @@ -4122,7 +4126,7 @@ cannot be changed through the unpublished batch root." (let ((parents (ebox--buffer-parent-table buffer))) (delete-dups (delq nil - (mapcar (lambda (node-id) (gethash node-id parents)) + (mapcar (lambda (node-id) (ebox-runtime-index-get node-id parents)) node-ids)))))) (defun ebox--publish-scoped-region-candidate diff --git a/tests/ebox-child-range-tests.el b/tests/ebox-child-range-tests.el index 313c052..e118fa4 100644 --- a/tests/ebox-child-range-tests.el +++ b/tests/ebox-child-range-tests.el @@ -355,8 +355,8 @@ (should-not (plist-member empty-record :sequence)) (should (= (plist-get empty-resolved :rank) 1)) (should (= (plist-get items-resolved :rank) 1)) - (should (= (hash-table-count (plist-get state :node-table)) - (hash-table-count (plist-get direct-state :node-table)))) + (should (= (ebox-runtime-index-size (plist-get state :node-table)) + (ebox-runtime-index-size (plist-get direct-state :node-table)))) (should (= (hash-table-count (plist-get state :region-id-set)) (hash-table-count (plist-get direct-state :region-id-set)))) @@ -482,7 +482,9 @@ (node-id (gethash ref (ebox-child-range-test--host-ref-table state))) - (node (gethash node-id (plist-get state :node-table)))) + (node + (ebox-runtime-index-get + node-id (plist-get state :node-table)))) (should node-id) (should (equal (ebox-child-range-test--node-key @@ -668,7 +670,7 @@ (ebox-commit buffer candidate)) (let* ((state (ebox--buffer-render-state buffer)) (record (gethash 'items (plist-get state :range-ref-table))) - (parent (gethash (plist-get record :parent-node-id) + (parent (ebox-runtime-index-get (plist-get record :parent-node-id) (plist-get state :node-table))) (segment (ebox-child-range--lookup-ref @@ -742,7 +744,7 @@ (ebox-commit buffer candidate)) (let* ((state (ebox--buffer-render-state buffer)) (record (gethash 'items (plist-get state :range-ref-table))) - (parent (gethash (plist-get record :parent-node-id) + (parent (ebox-runtime-index-get (plist-get record :parent-node-id) (plist-get state :node-table))) (segment (ebox-child-range--lookup-ref @@ -1208,7 +1210,7 @@ node-id (plist-get state :surface-node-object-table)))) - (should (eq node (gethash node-id + (should (eq node (ebox-runtime-index-get node-id (plist-get state :node-table)))) (should object) (should (eq object (plist-get node :surface-object)))))))) @@ -1301,7 +1303,9 @@ (current-id (gethash 'child (ebox-child-range-test--host-ref-table state))) - (node (gethash current-id (plist-get state :node-table)))) + (node + (ebox-runtime-index-get + current-id (plist-get state :node-table)))) (when node-id (should (equal node-id current-id))) (setq node-id current-id) (should (plist-get node :ebox-sequence-location)))) diff --git a/tests/ebox-commit-tests.el b/tests/ebox-commit-tests.el index 991d806..ae6bfb5 100644 --- a/tests/ebox-commit-tests.el +++ b/tests/ebox-commit-tests.el @@ -43,9 +43,10 @@ (defun ebox-commit-test--hash-facts (table &optional values) "Return sorted TABLE keys, or key/value pairs when VALUES is non-nil." (let (facts) - (maphash (lambda (key value) - (push (if values (cons key value) key) facts)) - table) + (ebox-runtime-index-map + (lambda (key value) + (push (if values (cons key value) key) facts)) + table) (sort facts (lambda (left right) (string< (prin1-to-string left) @@ -653,7 +654,7 @@ new-root new-source-index)) (new-index (ebox--runtime-index new-root t new-source-index)) (root-id (plist-get old-root :node-id))) - (maphash (lambda (node-id _node) + (ebox-runtime-index-map (lambda (node-id _node) (puthash node-id (list 'object node-id) old-objects)) (plist-get old-index :node-table)) (let* ((old-state (append (list :root-node old-root @@ -700,7 +701,7 @@ new-root new-source-index)) (new-index (ebox--runtime-index new-root t new-source-index)) (root-id (plist-get old-root :node-id))) - (maphash (lambda (node-id _node) + (ebox-runtime-index-map (lambda (node-id _node) (puthash node-id (list 'object node-id) old-objects)) (plist-get old-index :node-table)) (let ((old-state (append (list :root-node old-root @@ -1342,20 +1343,24 @@ remain retained identities." (node-table (plist-get state :node-table)) (parent-table (plist-get state :parent-table)) (node-id (and region-node-table - (gethash value region-node-table))) + (ebox-runtime-index-get + value region-node-table))) (source-node-id node-id) (root-node-id (plist-get (plist-get state :root-node) :node-id)) key) (while (and node-id (not key)) - (when-let* ((node (gethash node-id node-table))) + (when-let* ((node + (ebox-runtime-index-get + node-id node-table))) (setq key (ebox-tree-node-key (plist-get state :source-index) node))) (setq node-id (and (not key) - (gethash node-id parent-table)))) + (ebox-runtime-index-get + node-id parent-table)))) (or key (and (equal source-node-id root-node-id) 'root) value)) @@ -2587,6 +2592,187 @@ remain retained identities." (ebox-native-reflow--persistent-index-get next 17))) (should-not (eq base next)))) +(ert-deftest ebox-logical-candidate-path-copies-runtime-indexes-only () + "A local logical commit shares its base and never copies a complete core map." + (let* ((input + (ebox-test-column + (ebox-test-box :key 'message :source-identity 'message + (ebox-test-text "old")) + (ebox-test-box :key 'tail (ebox-test-text "tail")))) + (buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-runtime-index-local*") input)) + (old-state (ebox--buffer-render-state buffer)) + (old-nodes (plist-get old-state :node-table)) + (old-parents (plist-get old-state :parent-table)) + (old-regions (plist-get old-state :region-node-table)) + (old-postorder (plist-get old-state :native-node-postorder)) + (old-message (ebox--host-ref-node buffer 'message)) + (original-copy (symbol-function 'copy-hash-table)) + copied-core) + (unwind-protect + (let ((candidate (ebox-candidate-begin buffer))) + (ebox-candidate-replace-host-ref + candidate 'message + (ebox-test-box :key 'message :source-identity 'message + (ebox-test-text "new"))) + (cl-letf (((symbol-function 'copy-hash-table) + (lambda (table) + (when (memq table + (list old-nodes old-parents old-regions)) + (setq copied-core t)) + (funcall original-copy table)))) + (ebox-commit buffer candidate)) + (let* ((state (ebox--buffer-render-state buffer)) + (nodes (plist-get state :node-table)) + (parents (plist-get state :parent-table)) + (regions (plist-get state :region-node-table))) + (should-not copied-core) + (should (ebox-runtime-index-p nodes)) + (should (ebox-runtime-index-p parents)) + (should (ebox-runtime-index-p regions)) + (should-not (eq nodes old-nodes)) + (should (eq regions old-regions)) + (should (eq (plist-get state :native-node-postorder) + old-postorder)) + (should (eq (plist-get state :native-node-postorder-ids) + old-postorder)) + (should (eq (ebox-runtime-index-get + (plist-get old-message :node-id) old-nodes) + old-message)) + (should + (cl-every + (lambda (node-id) + (ebox-runtime-index-get node-id nodes)) + old-postorder)))) + (when (buffer-live-p buffer) (kill-buffer buffer))))) + +(ert-deftest ebox-logical-candidate-converts-legacy-native-postorder () + "Legacy and mixed vectors resolve every entry against candidate nodes." + (let* ((old-leaf '(:ebox-type text :node-id 1 :content "old")) + (old-root (list :ebox-type 'column :node-id 2 + :children (list old-leaf))) + (new-leaf '(:ebox-type text :node-id 1 :content "new")) + (new-root (list :ebox-type 'column :node-id 2 + :children (list new-leaf)))) + (dolist (table (list (ebox-runtime-index-empty) + (make-hash-table :test 'equal))) + (setq table (ebox-runtime-index-put 1 new-leaf table) + table (ebox-runtime-index-put 2 new-root table)) + (dolist (legacy (list (vector old-leaf old-root) + (vector 1 old-root) + (vector old-leaf 2))) + (let* ((before (copy-sequence legacy)) + (state (list :native-node-postorder legacy + :native-node-postorder-ids (vector 1 2))) + (postorder + (ebox-incremental--candidate-retain-native-postorder + state table))) + (should (equal postorder [1 2])) + (should-not (eq postorder legacy)) + (should (equal legacy before)) + (should (eq (ebox-native-reflow--postorder-node + (aref postorder 0) table) + new-leaf)) + (should (eq (ebox-native-reflow--postorder-node + (aref postorder 1) table) + new-root))))))) + +(ert-deftest ebox-logical-candidate-native-postorder-validates-legacy-membership () + "Unmarked retained entries reject missing candidate nodes; empty is valid." + (let* ((node '(:ebox-type text :node-id 1 :content "old")) + (table (ebox-runtime-index-empty))) + (should-error + (ebox-incremental--candidate-retain-native-postorder + (list :native-node-postorder (vector node)) table) + :type 'error) + (should (equal + (ebox-incremental--candidate-retain-native-postorder + (list :native-node-postorder []) table) + [])) + (should-not + (ebox-incremental--candidate-retain-native-postorder nil table)))) + +(ert-deftest ebox-logical-candidate-retains-native-id-postorder-without-lookups () + "Generated ID vectors retain their exact identity without walking nodes." + (let* ((input (ebox-test-column (ebox-test-text "retained"))) + (root (ebox-test-root input)) + (index (ebox--runtime-index root nil (ebox-test-source-index input))) + (postorder (plist-get index :native-node-postorder))) + (should (eq postorder (plist-get index :native-node-postorder-ids))) + (cl-letf (((symbol-function 'ebox-runtime-index-get) + (lambda (&rest _arguments) + (ert-fail "Generated native ID postorder was remapped")))) + (should (eq postorder + (ebox-incremental--candidate-retain-native-postorder + index (plist-get index :node-table))))))) + +(defun ebox-test--native-topology-full-gate-fixture () + "Return `(BUFFER OLD-STATE CANDIDATE-STATE PREPARED TAIL)' for a paint edit." + (let* ((previous-message + (ebox-test-box :key 'message :source-identity 'message + :background-color "#111111" + (ebox-test-text "message"))) + (previous + (ebox-test-column + previous-message + (ebox-test-box :key 'tail :source-identity 'tail + (ebox-test-text "tail")))) + (next + (ebox-test-box :key 'message :source-identity 'message + :background-color "#222222" + (ebox-test-text "message"))) + (buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-topology-authority*") previous)) + (old-state (ebox--buffer-render-state buffer)) + (candidate (ebox-candidate-begin buffer))) + (unless (ebox-candidate-patch-host-paint + candidate 'message + previous-message next) + (error "Topology authority fixture paint patch was rejected")) + (let* ((prepared + (ebox-incremental--prepare-logical-candidate + buffer old-state candidate)) + (candidate-state + (ebox-incremental--candidate-state + old-state (plist-get prepared :root) + (plist-get prepared :index) prepared))) + (list buffer old-state candidate-state prepared + (ebox--host-ref-node buffer 'tail))))) + +(ert-deftest ebox-native-topology-full-gate-rejects-untouched-object-loss () + "The full topology gate rejects a missing untouched surface object." + (pcase-let ((`(,buffer ,old-state ,candidate-state ,prepared ,tail) + (ebox-test--native-topology-full-gate-fixture))) + (unwind-protect + (progn + (should (ebox-native-commit-topology-stable-p + old-state candidate-state prepared)) + (remhash (plist-get tail :node-id) + (plist-get old-state :surface-node-object-table)) + (should-not (ebox-native-commit-topology-stable-p + old-state candidate-state prepared))) + (when (buffer-live-p buffer) (kill-buffer buffer))))) + +(ert-deftest ebox-native-topology-full-gate-rejects-untouched-source-key-change () + "The full topology gate rejects an untouched source key change." + (pcase-let ((`(,buffer ,old-state ,candidate-state ,prepared ,tail) + (ebox-test--native-topology-full-gate-fixture))) + (unwind-protect + (progn + (should (ebox-native-commit-topology-stable-p + old-state candidate-state prepared)) + (let* ((source-index (plist-get candidate-state :source-index)) + (binding + (ebox-source-index-rebind + source-index (ebox-tree-node-source-handle tail) + :key 'tampered-tail))) + (plist-put candidate-state :source-index (car binding))) + (should-not (ebox-native-commit-topology-stable-p + old-state candidate-state prepared))) + (when (buffer-live-p buffer) (kill-buffer buffer))))) + (ert-deftest ebox-native-session-fork-shares-immutable-compiler-roots () "Forking does not clone the retained fragment map or persistent indexes." (require 'ebox-native-reflow) diff --git a/tests/ebox-core-render-tests.el b/tests/ebox-core-render-tests.el index a815933..9ab6f0a 100644 --- a/tests/ebox-core-render-tests.el +++ b/tests/ebox-core-render-tests.el @@ -875,6 +875,31 @@ (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) +(ert-deftest ebox-runtime-bootstrap-uses-persistent-core-indexes-and-id-postorder () + "Runtime bootstrap stores persistent core indexes and stable node ids." + (ebox-test--reset-runtime-state) + (let* ((input + (ebox-test-column + (ebox-test-box :key 'left (ebox-test-text "Left")) + (ebox-test-box :key 'right (ebox-test-text "Right")))) + (root (ebox-test-root input)) + (index (ebox--runtime-index root t (ebox-test-source-index input))) + (nodes (plist-get index :node-table)) + (postorder (plist-get index :native-node-postorder))) + (should (ebox-runtime-index-p nodes)) + (should (ebox-runtime-index-p (plist-get index :parent-table))) + (should (ebox-runtime-index-p (plist-get index :region-node-table))) + (should (vectorp postorder)) + (should (> (length postorder) 0)) + (should + (cl-every + (lambda (node-id) + (and (not (listp node-id)) + (ebox-runtime-index-get node-id nodes))) + postorder)) + (should (equal (aref postorder (1- (length postorder))) + (plist-get root :node-id))))) + (ert-deftest ebox-region-render-owner-index-keeps-earlier-duplicate-box () "A later flex wrapper should not replace an earlier duplicate region owner." (ebox-test--reset-runtime-state) @@ -894,7 +919,7 @@ (expected (car (ebox--node-path-to-region layout-node region-id)))) (should (eq expected plain-node)) - (should (equal (gethash region-id table) + (should (equal (ebox-runtime-index-get region-id table) (ebox--ensure-node-id expected)))))) (ert-deftest ebox-region-render-owner-index-falls-back-for-legacy-state () @@ -2440,7 +2465,7 @@ (should (eq (plist-get report :constraint-owner-type) 'box)) (should (equal (plist-get report :constraint-owner-id) - (gethash left-id + (ebox-runtime-index-get left-id (ebox--buffer-region-node-table (current-buffer))))) (should (equal (plist-get report :dirty-kinds) diff --git a/tests/ebox-docs-contract-tests.el b/tests/ebox-docs-contract-tests.el index 1b90c38..d0d0cf3 100644 --- a/tests/ebox-docs-contract-tests.el +++ b/tests/ebox-docs-contract-tests.el @@ -24,14 +24,16 @@ (defconst ebox-docs-test--active-files '("Makefile" ".github/workflows/ci.yml" - "ebox.el" "ebox-cache.el" "ebox-state-contract.el" "ebox-style.el" + "ebox.el" "ebox-cache.el" "ebox-runtime-index.el" + "ebox-state-contract.el" "ebox-style.el" "ebox-child-range.el" "ebox-tree.el" "ebox-measure.el" "ebox-fragment.el" "ebox-render-context.el" "ebox-layout.el" "ebox-flex.el" "ebox-grid.el" "ebox-buffer-backend.el" "ebox-patch-plan.el" "ebox-incremental.el" "ebox-surface.el" "ebox-dsl.el" "ebox-spi.el" "ebox-selector.el" "ebox-native-reflow.el" - "tests/ebox-core-render-tests.el" "tests/ebox-state-contract-tests.el" + "tests/ebox-core-render-tests.el" "tests/ebox-runtime-index-tests.el" + "tests/ebox-state-contract-tests.el" "tests/ebox-layout-boundary-tests.el" "tests/ebox-patch-plan-tests.el" "tests/ebox-style-schema-tests.el" @@ -50,7 +52,8 @@ "Active Ebox files that the maintainer map must cover.") (defconst ebox-docs-test--targets - '("check" "ci" "load" "compile" "core-tests" "child-range-tests" "grid-tests" + '("check" "ci" "load" "compile" "core-tests" "runtime-index-tests" + "child-range-tests" "grid-tests" "ebox-commit-tests" "surface-tests" "visual-check-tests" "package-tests" "selector-tests" "dsl-tests" "flex-tests" "state-contract-tests" "layout-boundary-tests" "layout-boundary-performance" diff --git a/tests/ebox-package-tests.el b/tests/ebox-package-tests.el index 016c30b..e4dca9b 100644 --- a/tests/ebox-package-tests.el +++ b/tests/ebox-package-tests.el @@ -156,7 +156,8 @@ (should (commandp 'ebox-byte-compile)) (should (equal ebox--compile-sources - '("ebox-cache.el" "ebox-source.el" "ebox-state-contract.el" + '("ebox-cache.el" "ebox-source.el" "ebox-runtime-index.el" + "ebox-state-contract.el" "ebox-font.el" "ebox-style.el" "ebox-layout-config.el" "ebox-node-factory.el" diff --git a/tests/ebox-runtime-index-tests.el b/tests/ebox-runtime-index-tests.el new file mode 100644 index 0000000..7753e8a --- /dev/null +++ b/tests/ebox-runtime-index-tests.el @@ -0,0 +1,295 @@ +;;; ebox-runtime-index-tests.el --- Runtime index tests -*- lexical-binding: t; -*- + +;; SPDX-License-Identifier: GPL-3.0-or-later + +;;; Code: + +(require 'ert) +(require 'cl-lib) +(require 'ebox-runtime-index) + +(defun ebox-runtime-index-test--shape (index) + "Validate compressed INDEX and return `(BRANCHES LEAVES MAX-DEPTH)'." + (let ((branches 0) (leaves 0) (max-depth 0)) + (cl-labels + ((visit (node depth parent-shift mask prefix) + (setq max-depth (max max-depth depth)) + (cond + ((vectorp node) + (should (= (length node) 18)) + (let ((shift (aref node 0)) + (code (aref node 1)) + (children 0)) + (should (and (integerp shift) (<= 0 shift 60) + (= (% shift 4) 0) (> shift parent-shift))) + (should (= (logand code mask) prefix)) + (cl-incf branches) + (dotimes (slot 16) + (when-let* ((child (aref node (+ slot 2)))) + (cl-incf children) + (visit child (1+ depth) shift + (1- (ash 1 (+ shift 4))) + (logior (logand code (1- (ash 1 shift))) + (ash slot shift))))) + (should (>= children 2)))) + (node + (should (and (consp node) (integerp (car node)) (cdr node))) + (should (= (logand (car node) mask) prefix)) + (dolist (entry (cdr node)) + (should (= (ebox-runtime-index--code (car entry)) + (car node)))) + (cl-incf leaves))))) + (visit (ebox-runtime-index-root index) 0 -1 0 0)) + (list branches leaves max-depth))) + +(ert-deftest ebox-runtime-index-persistent-update-isolates-base () + (let* ((base (ebox-runtime-index-put 1 'old (ebox-runtime-index-empty))) + (changed (ebox-runtime-index-put 1 'new base)) + (added (ebox-runtime-index-put 17 nil changed)) + (deleted (ebox-runtime-index-delete 1 added))) + (should (eq (ebox-runtime-index-get 1 base) 'old)) + (should (eq (ebox-runtime-index-get 1 changed) 'new)) + (should (ebox-runtime-index-contains-p 17 added)) + (should-not (ebox-runtime-index-get 17 added 'missing)) + (should-not (ebox-runtime-index-contains-p 1 deleted)) + (should (ebox-runtime-index-key-set-equal-p base changed)) + (should-not (ebox-runtime-index-key-set-equal-p base added)) + (should-not (ebox-runtime-index-key-set-equal-p base deleted)) + (should (= (ebox-runtime-index-size base) 1)) + (should (= (ebox-runtime-index-size added) 2)) + (should (= (ebox-runtime-index-size deleted) 1)))) + +(ert-deftest ebox-runtime-index-key-set-proof-does-not-enumerate-persistent-indexes () + (let* ((base (ebox-runtime-index-put 1 'old (ebox-runtime-index-empty))) + (changed (ebox-runtime-index-put 1 'new base))) + (cl-letf (((symbol-function 'ebox-runtime-index-map) + (lambda (&rest _args) + (ert-fail "persistent key-set proof enumerated its input")))) + (should (ebox-runtime-index-key-set-equal-p base changed))))) + +(ert-deftest ebox-runtime-index-key-set-equality-supports-independent-indexes () + (let ((left (ebox-runtime-index-empty)) + (right (ebox-runtime-index-empty))) + (dolist (key '(1 17 33)) + (setq left (ebox-runtime-index-put key key left))) + (dolist (key '(33 17 1)) + (setq right (ebox-runtime-index-put key nil right))) + (should (ebox-runtime-index-key-set-equal-p left right)))) + +(ert-deftest ebox-runtime-index-preserves-equal-key-collisions () + (cl-letf (((symbol-function 'ebox-runtime-index--code) + (lambda (_key) 0))) + (let* ((base (ebox-runtime-index-empty)) + (one (ebox-runtime-index-put '(same code one) 1 base)) + (two (ebox-runtime-index-put '(same code two) 2 one))) + (should (= (ebox-runtime-index-get '(same code one) two) 1)) + (should (= (ebox-runtime-index-get '(same code two) two) 2)) + (should (= (ebox-runtime-index-size two) 2))))) + +(ert-deftest ebox-runtime-index-map-and-keys-cover-exact-entries () + (let ((index (ebox-runtime-index-empty)) + entries) + (dolist (pair '((5 . five) (1 . one) (9 . nine))) + (setq index (ebox-runtime-index-put (car pair) (cdr pair) index))) + (ebox-runtime-index-map + (lambda (key value) (push (cons key value) entries)) index) + (should (equal (sort entries (lambda (a b) (< (car a) (car b)))) + '((1 . one) (5 . five) (9 . nine)))) + (should (equal (sort (ebox-runtime-index-keys index) #'<) '(1 5 9))))) + +(ert-deftest ebox-runtime-index-adapters-accept-legacy-hash-tables () + (let ((table (make-hash-table :test #'equal))) + (should (eq (ebox-runtime-index-put 'key nil table) table)) + (should (ebox-runtime-index-contains-p 'key table)) + (should (= (ebox-runtime-index-size table) 1)) + (should (equal (ebox-runtime-index-keys table) '(key))) + (should (eq (ebox-runtime-index-delete 'key table) table)) + (should (= (ebox-runtime-index-size table) 0)))) + +(ert-deftest ebox-runtime-index-update-work-is-bounded-by-changed-keys () + "Path-copy work grows with changed keys rather than retained index size." + (dolist (case '((32 . 2) (128 . 2) (512 . 3))) + (let ((size (car case)) + (max-depth (cdr case)) + (base (ebox-runtime-index-empty))) + (dotimes (id size) + (setq base (ebox-runtime-index-put id id base))) + (dolist (changed-count '(1 4 16)) + (let ((next base) + (vector-copies 0) + (original-copy (symbol-function 'copy-sequence))) + (cl-letf (((symbol-function 'copy-sequence) + (lambda (sequence) + (when (vectorp sequence) + (cl-incf vector-copies)) + (funcall original-copy sequence)))) + (dotimes (id changed-count) + (setq next (ebox-runtime-index-put id (- id) next)))) + (should (<= vector-copies (* changed-count max-depth))) + (should (= (ebox-runtime-index-size next) size)) + (should (= (ebox-runtime-index-get (1- size) base) (1- size))) + (should (= (ebox-runtime-index-get 0 base) 0))))))) + +(ert-deftest ebox-runtime-index-compresses-prefixes-for-generic-keys () + "Noninteger keys with long shared codes keep only real branching levels." + (cl-letf (((symbol-function 'ebox-runtime-index--code) + (lambda (key) (logior #x123456789a (ash (cadr key) 40))))) + (let ((index (ebox-runtime-index-empty))) + (dotimes (id 128) + (setq index (ebox-runtime-index-put (list 'key id) id index))) + (pcase-let ((`(,branches ,leaves ,depth) + (ebox-runtime-index-test--shape index))) + (should (= branches 17)) + (should (= leaves 128)) + (should (= depth 2))) + (dotimes (id 128) + (should (= (ebox-runtime-index-get (list 'key id) index) id)))))) + +(ert-deftest ebox-runtime-index-splits-prefixes-and-collapses-deleted-branches () + "Splitting above retained branches and deleting representatives isolate forks." + (let ((index (ebox-runtime-index-empty)) snapshots) + (dolist (key '(#x1100 #x2100 #x0101 #x1110 #x3110)) + (push index snapshots) + (setq index (ebox-runtime-index-put key key index)) + (ebox-runtime-index-test--shape index)) + (let ((base index)) + ;; The first key supplied representative codes to several branch nodes. + (setq index (ebox-runtime-index-delete #x1100 index)) + (ebox-runtime-index-test--shape index) + (setq index (ebox-runtime-index-put #x4110 'later index)) + (ebox-runtime-index-test--shape index) + (should (= (ebox-runtime-index-get #x1100 base) #x1100)) + (should-not (ebox-runtime-index-contains-p #x4110 base)) + (should (eq (ebox-runtime-index-get #x4110 index) 'later)) + (dolist (key '(#x2100 #x0101 #x1110 #x3110)) + (setq index (ebox-runtime-index-delete key index)) + (ebox-runtime-index-test--shape index)) + (should (equal (ebox-runtime-index-test--shape index) '(0 1 0))) + (should (eq index (ebox-runtime-index-delete 'absent index))) + (setq index (ebox-runtime-index-delete #x4110 index)) + (should (= (ebox-runtime-index-size index) 0)) + (should-not (ebox-runtime-index-root index))) + (cl-loop for snapshot in snapshots + for count downfrom 4 + do (should (= (ebox-runtime-index-size snapshot) count)) + do (ebox-runtime-index-test--shape snapshot)))) + +(ert-deftest ebox-runtime-index-preserves-truncated-large-integer-collisions () + "Keys differing above bit 63 retain independent exact values and membership." + (let* ((small 7) + (large (+ 7 (ash 1 80))) + (larger (+ 7 (ash 1 100))) + (base (ebox-runtime-index-put small 'small (ebox-runtime-index-empty))) + (base (ebox-runtime-index-put large nil base)) + (base (ebox-runtime-index-put larger 'larger base)) + (changed (ebox-runtime-index-put large 'large base)) + (deleted (ebox-runtime-index-delete small changed))) + (should (= (ebox-runtime-index--code small) + (ebox-runtime-index--code larger))) + (should (= (ebox-runtime-index-size base) 3)) + (should (ebox-runtime-index-contains-p large base)) + (should-not (ebox-runtime-index-get large base 'missing)) + (should (eq (ebox-runtime-index-get large deleted) 'large)) + (should (eq (ebox-runtime-index-get larger deleted) 'larger)) + (should-not (ebox-runtime-index-contains-p small deleted)) + (should (ebox-runtime-index-key-set-equal-p base changed)) + (should (equal (ebox-runtime-index-test--shape deleted) '(0 1 0))))) + +(ert-deftest ebox-runtime-index-branches-at-the-highest-code-nibble () + "The compressed prefix arithmetic covers all 64 code bits." + (let ((index (ebox-runtime-index-empty))) + (dolist (key (list 0 (ash 1 60) (ash 1 63))) + (setq index (ebox-runtime-index-put key key index))) + (should (equal (ebox-runtime-index-test--shape index) '(1 3 1))) + (should (= (aref (ebox-runtime-index-root index) 0) 60)) + (setq index (ebox-runtime-index-delete 0 index)) + (setq index (ebox-runtime-index-put (ash 1 62) 'later index)) + (ebox-runtime-index-test--shape index) + (should (= (ebox-runtime-index-get (ash 1 63) index) (ash 1 63))) + (should (eq (ebox-runtime-index-get (ash 1 62) index) 'later)))) + +(ert-deftest ebox-runtime-index-put-discovers-membership-in-one-traversal () + "Insertion and replacement do not perform a separate membership lookup." + (let ((base (ebox-runtime-index-put 'one nil (ebox-runtime-index-empty)))) + (cl-letf (((symbol-function 'ebox-runtime-index--root-get) + (lambda (&rest _arguments) + (ert-fail "Put performed a preliminary membership lookup")))) + (should (= (ebox-runtime-index-size + (ebox-runtime-index-put 'one 'changed base)) + 1)) + (should (= (ebox-runtime-index-size + (ebox-runtime-index-put 'two nil base)) + 2))))) + +(ert-deftest ebox-runtime-index-collision-updates-and-deletes-preserve-equal-keys () + "Colliding noninteger keys compare with equal across immutable forks." + (cl-letf (((symbol-function 'ebox-runtime-index--code) + (lambda (_key) 23))) + (let* ((base (ebox-runtime-index-put + (list "key" 1) nil (ebox-runtime-index-empty))) + (base (ebox-runtime-index-put ["key" 2] 'two base)) + (changed (ebox-runtime-index-put (list "key" 1) 'one base)) + (deleted (ebox-runtime-index-delete (vector "key" 2) changed))) + (should (= (ebox-runtime-index-size changed) 2)) + (should (ebox-runtime-index-contains-p (list "key" 1) base)) + (should-not (ebox-runtime-index-get (list "key" 1) base 'missing)) + (should (eq (ebox-runtime-index-get (list "key" 1) deleted) 'one)) + (should-not (ebox-runtime-index-contains-p ["key" 2] deleted)) + (should (eq (ebox-runtime-index-get ["key" 2] base) 'two)) + (ebox-runtime-index-test--shape deleted)))) + +(ert-deftest ebox-runtime-index-map-visits-only-live-compressed-nodes () + "Enumeration visits at most one leaf and one branch per distinct code." + (let ((index (ebox-runtime-index-empty)) + (visits 0) + (entries 0) + (original (symbol-function 'ebox-runtime-index--map-root))) + (dotimes (id 512) + (setq index (ebox-runtime-index-put id id index))) + (cl-letf (((symbol-function 'ebox-runtime-index--map-root) + (lambda (&rest arguments) + (cl-incf visits) + (apply original arguments)))) + (ebox-runtime-index-map + (lambda (_key _value) (cl-incf entries)) index)) + (should (= entries 512)) + (should (<= visits (1- (* entries 2)))))) + +(ert-deftest ebox-runtime-index-mixed-key-updates-match-hash-table () + "Generic insertion/deletion sequences preserve exact facts and old snapshots." + (let ((keys (vector nil t -1 0 1 17 256 (ash 1 80) (1+ (ash 1 80)) + "key" '(key . 1) '[key 2])) + (index (ebox-runtime-index-empty)) + (expected (make-hash-table :test 'equal)) + (random-state 1729) + snapshots) + (dotimes (step 256) + (setq random-state (mod (+ (* random-state 1103515245) 12345) + 2147483648)) + (let ((key (aref keys (% (/ random-state 16) (length keys)))) + (value (and (/= (% step 7) 0) step))) + (if (= (% random-state 3) 0) + (progn + (setq index (ebox-runtime-index-delete key index)) + (remhash key expected)) + (setq index (ebox-runtime-index-put key value index)) + (puthash key value expected))) + (should (= (ebox-runtime-index-size index) (hash-table-count expected))) + (ebox-runtime-index-test--shape index) + (cl-loop for key across keys do + (should (equal (ebox-runtime-index-get key index 'missing) + (gethash key expected 'missing)))) + (when (= (% step 16) 0) + (push (cons index (copy-hash-table expected)) snapshots))) + (dolist (snapshot snapshots) + (ebox-runtime-index-test--shape (car snapshot)) + (should (= (ebox-runtime-index-size (car snapshot)) + (hash-table-count (cdr snapshot)))) + (cl-loop for key across keys do + (should (equal + (ebox-runtime-index-get key (car snapshot) 'missing) + (gethash key (cdr snapshot) 'missing))))))) + +(provide 'ebox-runtime-index-tests) + +;;; ebox-runtime-index-tests.el ends here diff --git a/tests/ebox-source-tests.el b/tests/ebox-source-tests.el index 4edfe14..f3c5aa3 100644 --- a/tests/ebox-source-tests.el +++ b/tests/ebox-source-tests.el @@ -396,7 +396,7 @@ (ebox-source-record-classes (ebox-source-index-record old-index old-handle)))) - (maphash + (ebox-runtime-index-map (lambda (_node-id node) (when (ebox-node-kind node) (should (ebox-source-index-record diff --git a/tests/ebox-surface-tests.el b/tests/ebox-surface-tests.el index 40eb1a8..3989dd9 100644 --- a/tests/ebox-surface-tests.el +++ b/tests/ebox-surface-tests.el @@ -501,7 +501,7 @@ candidate cannot hide mutations by restoring the old hash-table pointer." (defun ebox-surface-test--object-by-key (state key) "Return the candidate surface object for Ebox node KEY in STATE." (let (object) - (maphash + (ebox-runtime-index-map (lambda (_node-id node) (when (equal (plist-get node :key) key) (setq object (plist-get node :surface-object)))) @@ -511,7 +511,7 @@ candidate cannot hide mutations by restoring the old hash-table pointer." (defun ebox-surface-test--node-by-key (state key) "Return the runtime Ebox node for KEY in STATE." (let (match) - (maphash + (ebox-runtime-index-map (lambda (_node-id node) (when (equal (plist-get node :key) key) (setq match node))) @@ -2099,7 +2099,7 @@ candidate cannot hide mutations by restoring the old hash-table pointer." (style-states (plist-get state :style-binding-states)) (source-index (plist-get state :source-index)) text-id text-node) - (maphash + (ebox-runtime-index-map (lambda (node-id node) (when (and (eq (plist-get node :ebox-kind) 'text) (equal (plist-get node :content) "Inherited")) @@ -2107,13 +2107,14 @@ candidate cannot hide mutations by restoring the old hash-table pointer." nodes) (should text-id) (let* ((parent-id - (gethash text-id (plist-get state :parent-table))) + (ebox-runtime-index-get + text-id (plist-get state :parent-table))) (object (gethash text-id objects)) (parent-object (gethash parent-id objects)) (style-state (gethash object style-states)) (parent-state (gethash parent-object style-states)) (candidate-node (copy-sequence text-node)) - (candidate-nodes (copy-hash-table nodes)) + (candidate-nodes nodes) (candidate-state (copy-sequence state))) (should style-state) (should parent-state) @@ -2126,7 +2127,9 @@ candidate cannot hide mutations by restoring the old hash-table pointer." (ecss-subject-id (plist-get style-state :subject)))) (should (ebox-surface--static-style-state-p style-state)) (plist-put candidate-node :ebox-candidate-computed-style-p t) - (puthash text-id candidate-node candidate-nodes) + (setq candidate-nodes + (ebox-runtime-index-put + text-id candidate-node candidate-nodes)) (plist-put candidate-state :node-table candidate-nodes) (let ((ebox-incremental--allocated-slot-proof-cache (make-hash-table :test 'equal))) @@ -3207,7 +3210,7 @@ changes. Geometry and non-inherited computed values must remain identical." (maphash (lambda (id node) (should (eq node - (gethash (gethash id + (ebox-runtime-index-get (ebox-runtime-index-get id (plist-get state :region-node-table)) (plist-get state :node-table))))) From 220f27803ecbde928bcece331f08fe66de5e2e92 Mon Sep 17 00:00:00 2001 From: Kinneyzhang Date: Sat, 5 Sep 2026 12:56:20 +0800 Subject: [PATCH 08/12] Retain exact source changes and immutable use topology --- native/src/layout.rs | 1441 +++++++++++++++++++++------ native/src/lib.rs | 104 +- native/src/source_topology.rs | 214 ++++ native/src/source_topology_tests.rs | 162 +++ 4 files changed, 1628 insertions(+), 293 deletions(-) create mode 100644 native/src/source_topology.rs create mode 100644 native/src/source_topology_tests.rs diff --git a/native/src/layout.rs b/native/src/layout.rs index e646d9f..cecad8b 100644 --- a/native/src/layout.rs +++ b/native/src/layout.rs @@ -6,6 +6,10 @@ use std::collections::BTreeMap; use std::collections::HashSet; use std::sync::Arc; +#[path = "source_topology.rs"] +mod source_topology; +use source_topology::{LocalStep, SourceTopology, TopologyBuilder}; + #[path = "atom_plan.rs"] mod atom_plan; use atom_plan::AtomPlan; @@ -149,6 +153,173 @@ struct LocalSlotPatch { local: JsonMap, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum LocalField { + RegionId, + Content, + ContentRegionId, + ContentTypographyStyle, + ContentForegroundStyle, + ContentSurfaceTemplateId, + ContentWidthExact, + ContentMinWidth, + Width, + MinWidth, + MaxWidth, + Height, + MinHeight, + MaxHeight, + BoxSizing, + PaddingLeft, + PaddingRight, + PaddingTop, + PaddingBottom, + MarginLeft, + MarginRight, + MarginTop, + MarginBottom, + BorderLeft, + BorderRight, + TypographyStyle, + ForegroundStyle, + BackgroundStyle, + BorderLeftStyle, + BorderRightStyle, + BorderTopStyle, + BorderBottomStyle, + SurfaceTemplateId, + TextAlign, + VerticalAlign, + Overflow, + WrapMode, + ScrollOffset, + Direction, + Wrap, + Justify, + AlignItems, + AlignContent, + RowGap, + ColumnGap, +} + +#[derive(Debug)] +struct SlotChange { + slot: u8, + fields: Vec, +} + +#[derive(Debug)] +struct OwnerChange { + node_id: u64, + expected_revision: u64, + target_revision: u64, + slots: Vec, +} + +/// Transient source facts for one exact pair of documents, never retained in a +/// document or confirmed baseline. The current renderer does not cache by these facts. +#[derive(Debug)] +pub(crate) struct SourceChanges { + base: Arc, + target: Arc, + owners: Vec, + styles: std::ops::Range, + property_templates: std::ops::Range, +} + +impl SourceChanges { + pub(crate) fn applies_to( + &self, + base: &Arc, + target: &Arc, + ) -> bool { + Arc::ptr_eq(&self.base, base) && Arc::ptr_eq(&self.target, target) + } + + pub(crate) fn visit_changed_slots( + &self, + mut visit: impl FnMut(u64, u64, u64, u8, &[LocalField]), + ) { + for owner in &self.owners { + for slot in &owner.slots { + visit( + owner.node_id, + owner.expected_revision, + owner.target_revision, + slot.slot, + &slot.fields, + ); + } + } + } + + pub(crate) fn registry_ranges(&self) -> (std::ops::Range, std::ops::Range) { + (self.styles.clone(), self.property_templates.clone()) + } +} + +/// Counts concrete comparisons/copies at delta preparation and emitted source +/// changes at input integration. These are not layout-cache or topology counters. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "kebab-case")] +pub(crate) struct SourceChangeWork { + pub(crate) fields_compared: u64, + pub(crate) size_nodes_compared: u64, + pub(crate) text_lines_compared: u64, + pub(crate) text_clusters_compared: u64, + pub(crate) text_bytes_compared: u64, + pub(crate) local_nodes_copied: u64, + pub(crate) owners_changed: u64, + pub(crate) slots_changed: u64, + pub(crate) fields_changed: u64, + pub(crate) styles_appended: u64, + pub(crate) property_templates_added: u64, +} + +impl SourceChangeWork { + pub(crate) fn accumulate(&mut self, other: Self) { + self.fields_compared += other.fields_compared; + self.size_nodes_compared += other.size_nodes_compared; + self.text_lines_compared += other.text_lines_compared; + self.text_clusters_compared += other.text_clusters_compared; + self.text_bytes_compared += other.text_bytes_compared; + self.local_nodes_copied += other.local_nodes_copied; + self.owners_changed += other.owners_changed; + self.slots_changed += other.slots_changed; + self.fields_changed += other.fields_changed; + self.styles_appended += other.styles_appended; + self.property_templates_added += other.property_templates_added; + } +} + +#[derive(Debug, Default)] +pub(crate) struct DeltaStats { + pub(crate) entries_parsed: u64, + pub(crate) trie_path_nodes_copied: u64, + pub(crate) source_work: SourceChangeWork, +} + +#[derive(Debug)] +pub(crate) struct AppliedDelta { + pub(crate) document: Arc, + pub(crate) changes: SourceChanges, + pub(crate) stats: DeltaStats, +} + +struct PatchedOwner { + node: Arc, + slot_work: [Arc; 2], + work_delta: LocalWorkDelta, + slots: Vec, +} + +struct PatchedLocal<'a> { + node: Option, + // Names come from the same dispatch as the typed identifiers, for the + // existing local-work summary; no second field classification is needed. + fields: Vec<(LocalField, &'a str)>, +} + #[derive(Debug)] struct RetainedEntry { revision: u64, @@ -159,7 +330,7 @@ struct RetainedEntry { #[derive(Debug, Clone, Default)] struct LocalWorkSummary(BTreeMap<&'static str, LocalFieldWork>); -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq)] struct LocalFieldWork { units: usize, viewport_heights: usize, @@ -233,6 +404,7 @@ fn radix_insert( pub(crate) struct RetainedDocument { root_id: u64, entries: Arc, + topology: Arc, styles: Arc, style_count: u32, property_template_count: u32, @@ -352,130 +524,112 @@ fn visit_layout_children(node: &LayoutNode, mut visit: impl FnMut(&LayoutNode)) impl RetainedDocument { pub(crate) fn bootstrap(document: LayoutDocument) -> Result<(Arc, u64), String> { let (node_count, work_units) = document.validate_metrics()?; - fn install_owner( - mut node: LayoutNode, - entries: &mut Arc, - seen: &mut HashSet, - count: &mut u64, - context_viewport_heights: &mut usize, - ) -> Result { - fn localize( - node: &mut LayoutNode, - owner_id: u64, - entries: &mut Arc, - seen: &mut HashSet, - count: &mut u64, - context_viewport_heights: &mut usize, - ) -> Result<(), String> { - fn localize_child( - child: LayoutNode, - owner_id: u64, - entries: &mut Arc, - seen: &mut HashSet, - count: &mut u64, - context_viewport_heights: &mut usize, - ) -> Result { - if child.node_id().is_some() { - let installed = - install_owner(child, entries, seen, count, context_viewport_heights)?; - return Ok(LayoutNode::NodeRef { node_id: installed }); - } - let mut child = child; - localize( - &mut child, - owner_id, - entries, - seen, - count, - context_viewport_heights, - )?; - Ok(child) - } - // Localization visits every input node once, including anonymous - // descendants that cannot be named by a later topology-preserving delta. - visit_context_height_fields(node, |_, size| { - *context_viewport_heights = context_viewport_heights - .checked_add(context_size_occurrences(size)?) - .ok_or_else(|| { - "Native retained context summary invariant failed".to_owned() - })?; - Ok(()) - })?; - match node { - LayoutNode::Box { child, .. } => { - if let Some(old) = child.take() { - let old = Arc::try_unwrap(old).unwrap_or_else(|value| (*value).clone()); - *child = Some(Arc::new(localize_child( - old, - owner_id, - entries, - seen, - count, - context_viewport_heights, - )?)); - } - } - LayoutNode::Row { children, .. } | LayoutNode::Column { children, .. } => { - let old = std::mem::take(children); - let old = Arc::try_unwrap(old).unwrap_or_else(|value| (*value).clone()); - let mut localized = Vec::with_capacity(old.len()); - for child in old { - localized.push(localize_child( - child, - owner_id, - entries, - seen, - count, - context_viewport_heights, - )?); - } - *children = Arc::new(localized); - } - LayoutNode::Flex { items, .. } => { - let old = std::mem::take(items); - let old = Arc::try_unwrap(old).unwrap_or_else(|value| (*value).clone()); - let mut localized = Vec::with_capacity(old.len()); - for mut item in old { - item.node = localize_child( - item.node, - owner_id, - entries, - seen, - count, - context_viewport_heights, - )?; - localized.push(item); - } - *items = Arc::new(localized); - } - LayoutNode::Text { .. } => {} - LayoutNode::NodeRef { .. } => { - return Err( - "Native retained bootstrap received a node reference".to_owned() - ); - } - } - Ok(()) + struct BootstrapState { + entries: Arc, + seen: HashSet, + owner_count: u64, + context_viewport_heights: usize, + topology: TopologyBuilder, + } + + fn localize_child( + mut child: LayoutNode, + owner_id: u64, + path: &mut Vec, + slot: LocalStep, + state: &mut BootstrapState, + ) -> Result { + if child.node_id().is_some() { + let installed = install_owner(child, state)?; + state.topology.add_use(owner_id, path, slot, installed)?; + return Ok(LayoutNode::NodeRef { node_id: installed }); } + path.push(slot); + let result = localize(&mut child, owner_id, path, state); + path.pop(); + result?; + Ok(child) + } + fn localize( + node: &mut LayoutNode, + owner_id: u64, + path: &mut Vec, + state: &mut BootstrapState, + ) -> Result<(), String> { + // This is the existing one-time traversal, including anonymous nodes. + visit_context_height_fields(node, |_, size| { + state.context_viewport_heights = state + .context_viewport_heights + .checked_add(context_size_occurrences(size)?) + .ok_or_else(|| "Native retained context summary invariant failed".to_owned())?; + Ok(()) + })?; + let column = matches!(node, LayoutNode::Column { .. }); + match node { + LayoutNode::Box { child, .. } => { + if let Some(old) = child.take() { + let old = Arc::try_unwrap(old).unwrap_or_else(|value| (*value).clone()); + *child = Some(Arc::new(localize_child( + old, + owner_id, + path, + LocalStep::BoxChild, + state, + )?)); + } + } + LayoutNode::Row { children, .. } | LayoutNode::Column { children, .. } => { + let old = std::mem::take(children); + let old = Arc::try_unwrap(old).unwrap_or_else(|value| (*value).clone()); + let mut localized = Vec::with_capacity(old.len()); + for (index, child) in old.into_iter().enumerate() { + let slot = if column { + LocalStep::ColumnChild(index) + } else { + LocalStep::RowChild(index) + }; + localized.push(localize_child(child, owner_id, path, slot, state)?); + } + *children = Arc::new(localized); + } + LayoutNode::Flex { items, .. } => { + let old = std::mem::take(items); + let old = Arc::try_unwrap(old).unwrap_or_else(|value| (*value).clone()); + let mut localized = Vec::with_capacity(old.len()); + for (index, mut item) in old.into_iter().enumerate() { + item.node = localize_child( + item.node, + owner_id, + path, + LocalStep::FlexItem(index), + state, + )?; + localized.push(item); + } + *items = Arc::new(localized); + } + LayoutNode::Text { .. } => {} + LayoutNode::NodeRef { .. } => { + return Err("Native retained bootstrap received a node reference".to_owned()); + } + } + Ok(()) + } + + fn install_owner(mut node: LayoutNode, state: &mut BootstrapState) -> Result { let node_id = node.node_id().ok_or_else(|| { "Native retained document owner is missing its node id".to_owned() })?; let revision = node.node_revision().ok_or_else(|| { "Native retained document node is missing its revision".to_owned() })?; - if node_id == 0 || !seen.insert(node_id) { + if node_id == 0 || !state.seen.insert(node_id) { return Err("Native retained document has invalid duplicate node id".to_owned()); } - localize( - &mut node, - node_id, - entries, - seen, - count, - context_viewport_heights, - )?; + // Each owner starts a fresh local address; paths never cross NodeRefs. + localize(&mut node, node_id, &mut Vec::new(), state)?; let slot_one = match &node { LayoutNode::Box { child: Some(child), .. @@ -487,24 +641,21 @@ impl RetainedDocument { slot_work: [Arc::new(local_work_summary(&node)?), Arc::new(slot_one)], node: Arc::new(node), }); - let (updated, _) = radix_insert(entries, node_id, entry); - *entries = updated; - *count += 1; + let (updated, _) = radix_insert(&state.entries, node_id, entry); + state.entries = updated; + state.owner_count += 1; Ok(node_id) } - let mut entries = Arc::new(RadixNode::default()); - let mut seen = HashSet::new(); - let mut owner_count = 0; - let mut context_viewport_heights = 0; - let root_id = install_owner( - document.root, - &mut entries, - &mut seen, - &mut owner_count, - &mut context_viewport_heights, - )?; - debug_assert!(usize::try_from(owner_count).is_ok_and(|count| count <= node_count)); + let mut state = BootstrapState { + entries: Arc::new(RadixNode::default()), + seen: HashSet::new(), + owner_count: 0, + context_viewport_heights: 0, + topology: TopologyBuilder::default(), + }; + let root_id = install_owner(document.root, &mut state)?; + debug_assert!(usize::try_from(state.owner_count).is_ok_and(|count| count <= node_count)); let mut styles = Arc::new(StyleRadixNode::default()); if document.styles.len() != document.style_count as usize { return Err("Native retained style table count mismatch".to_owned()); @@ -513,21 +664,29 @@ impl RetainedDocument { let (updated, _) = style_insert(&styles, index as u64, Arc::new(style)); styles = updated; } + let topology = state.topology.finish(root_id); + debug_assert!(topology.incoming_uses(root_id).is_empty()); + debug_assert_eq!( + topology.build_work().occurrences_written, + state.owner_count - 1 + ); Ok(( Arc::new(Self { root_id, - entries, + entries: state.entries, + topology, styles, style_count: document.style_count, property_template_count: document.property_template_count, work_units, - context_viewport_heights, + context_viewport_heights: state.context_viewport_heights, }), node_count as u64, )) } fn effective_node(&self, node_id: u64) -> Option<&LayoutNode> { + debug_assert_eq!(self.root_id, self.topology.root_owner()); radix_lookup(&self.entries, node_id).map(|entry| entry.node.as_ref()) } @@ -554,9 +713,9 @@ impl RetainedDocument { } pub(crate) fn apply_delta( - &self, + self: &Arc, delta: DocumentDelta, - ) -> Result<(Arc, u64, u64), String> { + ) -> Result { if delta.style_base_count != self.style_count || delta.property_template_base_count != self.property_template_count || delta.property_template_target_count < delta.property_template_base_count @@ -576,6 +735,8 @@ impl RetainedDocument { } let mut seen = HashSet::with_capacity(delta.entries.len()); let mut prepared = Vec::with_capacity(delta.entries.len()); + let mut owners = Vec::new(); + let mut stats = DeltaStats::default(); let mut work_delta_total = 0_i128; let mut context_delta_total = 0_i128; for update in delta.entries { @@ -589,38 +750,34 @@ impl RetainedDocument { { return Err("Native retained delta node revision mismatch".to_owned()); } - let node = if update.slot_patches.is_empty() { - Arc::clone(&old.node) - } else { - let (patched, slot_work, work_delta) = patch_owner_slots( - &old.node, - &old.slot_work, - &update.slot_patches, - target_style_count, - delta.property_template_target_count, - )?; - work_delta_total = work_delta_total - .checked_add(work_delta.units as i128) - .ok_or_else(|| "Native layout work estimate overflowed".to_owned())?; - context_delta_total = context_delta_total - .checked_add(work_delta.viewport_heights) - .ok_or_else(|| "Native retained context summary invariant failed".to_owned())?; - prepared.push(( - update.node_id, - Arc::new(RetainedEntry { - revision: update.target_revision, - node: Arc::new(patched), - slot_work, - }), - )); - continue; - }; + let patched = patch_owner_slots( + &old.node, + &old.slot_work, + &update.slot_patches, + target_style_count, + delta.property_template_target_count, + &mut stats.source_work, + )?; + work_delta_total = work_delta_total + .checked_add(patched.work_delta.units as i128) + .ok_or_else(|| "Native layout work estimate overflowed".to_owned())?; + context_delta_total = context_delta_total + .checked_add(patched.work_delta.viewport_heights) + .ok_or_else(|| "Native retained context summary invariant failed".to_owned())?; + if !patched.slots.is_empty() { + owners.push(OwnerChange { + node_id: update.node_id, + expected_revision: update.expected_revision, + target_revision: update.target_revision, + slots: patched.slots, + }); + } prepared.push(( update.node_id, Arc::new(RetainedEntry { revision: update.target_revision, - node, - slot_work: old.slot_work.clone(), + node: patched.node, + slot_work: patched.slot_work, }), )); } @@ -661,51 +818,75 @@ impl RetainedDocument { styles = updated; copied += count; } - Ok(( - Arc::new(Self { - root_id: self.root_id, - entries, - styles, - style_count: target_style_count, - property_template_count: delta.property_template_target_count, - work_units, - context_viewport_heights, - }), - parsed, - copied, - )) + let document = Arc::new(Self { + root_id: self.root_id, + topology: Arc::clone(&self.topology), + entries, + styles, + style_count: target_style_count, + property_template_count: delta.property_template_target_count, + work_units, + context_viewport_heights, + }); + stats.entries_parsed = parsed; + stats.trie_path_nodes_copied = copied; + let changes = SourceChanges { + base: Arc::clone(self), + target: Arc::clone(&document), + owners, + styles: self.style_count..target_style_count, + property_templates: self.property_template_count..document.property_template_count, + }; + Ok(AppliedDelta { + document, + changes, + stats, + }) } } fn patch_owner_slots( - base: &LayoutNode, + base: &Arc, base_work: &[Arc; 2], patches: &[LocalSlotPatch], style_count: u32, property_template_count: u32, -) -> Result<(LayoutNode, [Arc; 2], LocalWorkDelta), String> { - let mut result = base.clone(); + source_work: &mut SourceChangeWork, +) -> Result { + let mut result = Arc::clone(base); let mut slot_work = base_work.clone(); let mut work_delta = 0_isize; let mut context_delta = 0_i128; let mut seen = [false; 2]; + let mut slots = Vec::new(); for patch in patches { let index = patch.slot as usize; if index >= seen.len() || std::mem::replace(&mut seen[index], true) { return Err("Native retained delta has an invalid duplicate slot".to_owned()); } if patch.slot == 0 { - let old_work = slot_work[0].changed_work(&patch.local); - result = patch_local_node(&result, &patch.local)?; - validate_changed_fields(&result, &patch.local, style_count, property_template_count)?; - let updated = slot_work[0].updated(&result, &patch.local)?; - context_delta += updated.changed_context_occurrences(&patch.local) - - slot_work[0].changed_context_occurrences(&patch.local); - let new_work = updated.changed_work(&patch.local); + let patched = patch_local_node(&result, &patch.local, source_work)?; + validate_changed_fields( + patched.node.as_ref().unwrap_or(&result), + &patch.local, + style_count, + property_template_count, + )?; + let Some(node) = patched.node else { continue }; + let old_work = slot_work[0].changed_work(&patched.fields); + let updated = slot_work[0].updated(&node, &patched.fields)?; + context_delta += updated.changed_context_occurrences(&patched.fields) + - slot_work[0].changed_context_occurrences(&patched.fields); + let new_work = updated.changed_work(&patched.fields); work_delta += new_work as isize - old_work as isize; - slot_work[0] = Arc::new(updated); + slot_work[0] = updated; + result = Arc::new(node); + slots.push(SlotChange { + slot: 0, + fields: patched.fields.into_iter().map(|(field, _)| field).collect(), + }); } else { - let LayoutNode::Box { child, .. } = &mut result else { + let LayoutNode::Box { child, .. } = result.as_ref() else { return Err("Native retained delta slot one requires a box owner".to_owned()); }; let old_child = child @@ -714,44 +895,83 @@ fn patch_owner_slots( if old_child.node_id().is_some() { return Err("Native retained delta slot one must be anonymous".to_owned()); } - let old_work = slot_work[1].changed_work(&patch.local); - let new_child = patch_local_node(old_child, &patch.local)?; + let patched = patch_local_node(old_child, &patch.local, source_work)?; validate_changed_fields( - &new_child, + patched.node.as_ref().unwrap_or(old_child), &patch.local, style_count, property_template_count, )?; - let updated = slot_work[1].updated(&new_child, &patch.local)?; - context_delta += updated.changed_context_occurrences(&patch.local) - - slot_work[1].changed_context_occurrences(&patch.local); - let new_work = updated.changed_work(&patch.local); + let Some(new_child) = patched.node else { + continue; + }; + let old_work = slot_work[1].changed_work(&patched.fields); + let updated = slot_work[1].updated(&new_child, &patched.fields)?; + context_delta += updated.changed_context_occurrences(&patched.fields) + - slot_work[1].changed_context_occurrences(&patched.fields); + let new_work = updated.changed_work(&patched.fields); work_delta += new_work as isize - old_work as isize; - slot_work[1] = Arc::new(updated); + slot_work[1] = updated; + if Arc::strong_count(&result) > 1 { + source_work.local_nodes_copied += 1; + } + let LayoutNode::Box { child, .. } = Arc::make_mut(&mut result) else { + unreachable!() + }; *child = Some(Arc::new(new_child)); + slots.push(SlotChange { + slot: 1, + fields: patched.fields.into_iter().map(|(field, _)| field).collect(), + }); } } - Ok(( - result, + Ok(PatchedOwner { + node: result, slot_work, - LocalWorkDelta { + work_delta: LocalWorkDelta { units: work_delta, viewport_heights: context_delta, }, - )) + slots, + }) } -fn patch_local_node( +fn patch_local_node<'a>( base: &LayoutNode, - fields: &JsonMap, -) -> Result { + fields: &'a JsonMap, + source_work: &mut SourceChangeWork, +) -> Result, String> { fn parsed(value: &JsonValue) -> Result { serde_json::from_value(value.clone()) .map_err(|error| format!("Invalid native retained local field: {error}")) } - let mut output = base.clone(); + let mut output = None; + let mut changed_fields = Vec::new(); for (name, value) in fields { - match &mut output { + macro_rules! assign { + ($variant:ident, $field:ident, $id:ident, $same:ident, $parsed:expr) => {{ + let next = $parsed; + source_work.fields_compared += 1; + if !$same($field, &next, source_work) { + let node = output.get_or_insert_with(|| { + source_work.local_nodes_copied += 1; + base.clone() + }); + let LayoutNode::$variant { $field, .. } = node else { + unreachable!() + }; + *$field = next; + changed_fields.push((LocalField::$id, name.as_str())); + } + }}; + ($variant:ident, $field:ident, $id:ident) => { + assign!($variant, $field, $id, same_scalar_source, parsed(value)?) + }; + ($variant:ident, $field:ident, $id:ident, $same:ident) => { + assign!($variant, $field, $id, $same, parsed(value)?) + }; + } + match output.as_ref().unwrap_or(base) { LayoutNode::Box { region_id, content, @@ -793,44 +1013,56 @@ fn patch_local_node( scroll_offset, .. } => match name.as_str() { - "region-id" => *region_id = parsed(value)?, - "content" => *content = parsed::>(value)?.map(Arc::new), - "content-region-id" => *content_region_id = parsed(value)?, - "content-typography-style" => *content_typography_style = parsed(value)?, - "content-foreground-style" => *content_foreground_style = parsed(value)?, - "content-surface-template-id" => *content_surface_template_id = parsed(value)?, - "content-width-exact" => *content_width_exact = parsed(value)?, - "content-min-width" => *content_min_width = parsed(value)?, - "width" => *width = parsed(value)?, - "min-width" => *min_width = parsed(value)?, - "max-width" => *max_width = parsed(value)?, - "height" => *height = parsed(value)?, - "min-height" => *min_height = parsed(value)?, - "max-height" => *max_height = parsed(value)?, - "box-sizing" => *box_sizing = parsed(value)?, - "padding-left" => *padding_left = parsed(value)?, - "padding-right" => *padding_right = parsed(value)?, - "padding-top" => *padding_top = parsed(value)?, - "padding-bottom" => *padding_bottom = parsed(value)?, - "margin-left" => *margin_left = parsed(value)?, - "margin-right" => *margin_right = parsed(value)?, - "margin-top" => *margin_top = parsed(value)?, - "margin-bottom" => *margin_bottom = parsed(value)?, - "border-left" => *border_left = parsed(value)?, - "border-right" => *border_right = parsed(value)?, - "typography-style" => *typography_style = parsed(value)?, - "foreground-style" => *foreground_style = parsed(value)?, - "background-style" => *background_style = parsed(value)?, - "border-left-style" => *border_left_style = parsed(value)?, - "border-right-style" => *border_right_style = parsed(value)?, - "border-top-style" => *border_top_style = parsed(value)?, - "border-bottom-style" => *border_bottom_style = parsed(value)?, - "surface-template-id" => *surface_template_id = parsed(value)?, - "text-align" => *text_align = parsed(value)?, - "vertical-align" => *vertical_align = parsed(value)?, - "overflow" => *overflow = parsed(value)?, - "wrap-mode" => *wrap_mode = parsed(value)?, - "scroll-offset" => *scroll_offset = parsed(value)?, + "region-id" => assign!(Box, region_id, RegionId), + "content" => assign!( + Box, + content, + Content, + same_optional_content_source, + parsed::>(value)?.map(Arc::new) + ), + "content-region-id" => assign!(Box, content_region_id, ContentRegionId), + "content-typography-style" => { + assign!(Box, content_typography_style, ContentTypographyStyle) + } + "content-foreground-style" => { + assign!(Box, content_foreground_style, ContentForegroundStyle) + } + "content-surface-template-id" => { + assign!(Box, content_surface_template_id, ContentSurfaceTemplateId) + } + "content-width-exact" => assign!(Box, content_width_exact, ContentWidthExact), + "content-min-width" => assign!(Box, content_min_width, ContentMinWidth), + "width" => assign!(Box, width, Width, same_size_source), + "min-width" => assign!(Box, min_width, MinWidth, same_size_source), + "max-width" => assign!(Box, max_width, MaxWidth, same_size_source), + "height" => assign!(Box, height, Height, same_size_source), + "min-height" => assign!(Box, min_height, MinHeight, same_size_source), + "max-height" => assign!(Box, max_height, MaxHeight, same_size_source), + "box-sizing" => assign!(Box, box_sizing, BoxSizing), + "padding-left" => assign!(Box, padding_left, PaddingLeft), + "padding-right" => assign!(Box, padding_right, PaddingRight), + "padding-top" => assign!(Box, padding_top, PaddingTop), + "padding-bottom" => assign!(Box, padding_bottom, PaddingBottom), + "margin-left" => assign!(Box, margin_left, MarginLeft), + "margin-right" => assign!(Box, margin_right, MarginRight), + "margin-top" => assign!(Box, margin_top, MarginTop), + "margin-bottom" => assign!(Box, margin_bottom, MarginBottom), + "border-left" => assign!(Box, border_left, BorderLeft), + "border-right" => assign!(Box, border_right, BorderRight), + "typography-style" => assign!(Box, typography_style, TypographyStyle), + "foreground-style" => assign!(Box, foreground_style, ForegroundStyle), + "background-style" => assign!(Box, background_style, BackgroundStyle), + "border-left-style" => assign!(Box, border_left_style, BorderLeftStyle), + "border-right-style" => assign!(Box, border_right_style, BorderRightStyle), + "border-top-style" => assign!(Box, border_top_style, BorderTopStyle), + "border-bottom-style" => assign!(Box, border_bottom_style, BorderBottomStyle), + "surface-template-id" => assign!(Box, surface_template_id, SurfaceTemplateId), + "text-align" => assign!(Box, text_align, TextAlign), + "vertical-align" => assign!(Box, vertical_align, VerticalAlign), + "overflow" => assign!(Box, overflow, Overflow), + "wrap-mode" => assign!(Box, wrap_mode, WrapMode), + "scroll-offset" => assign!(Box, scroll_offset, ScrollOffset), _ => return Err(format!("Unsupported native retained box field {name}")), }, LayoutNode::Text { @@ -842,12 +1074,18 @@ fn patch_local_node( wrap_mode, .. } => match name.as_str() { - "region-id" => *region_id = parsed(value)?, - "content" => *content = Arc::new(parsed(value)?), - "typography-style" => *typography_style = parsed(value)?, - "foreground-style" => *foreground_style = parsed(value)?, - "surface-template-id" => *surface_template_id = parsed(value)?, - "wrap-mode" => *wrap_mode = parsed(value)?, + "region-id" => assign!(Text, region_id, RegionId), + "content" => assign!( + Text, + content, + Content, + same_text_source, + Arc::new(parsed(value)?) + ), + "typography-style" => assign!(Text, typography_style, TypographyStyle), + "foreground-style" => assign!(Text, foreground_style, ForegroundStyle), + "surface-template-id" => assign!(Text, surface_template_id, SurfaceTemplateId), + "wrap-mode" => assign!(Text, wrap_mode, WrapMode), _ => return Err(format!("Unsupported native retained text field {name}")), }, LayoutNode::Flex { @@ -862,15 +1100,15 @@ fn patch_local_node( column_gap, .. } => match name.as_str() { - "direction" => *direction = parsed(value)?, - "wrap" => *wrap = parsed(value)?, - "justify" => *justify = parsed(value)?, - "align-items" => *align_items = parsed(value)?, - "align-content" => *align_content = parsed(value)?, - "width" => *width = parsed(value)?, - "height" => *height = parsed(value)?, - "row-gap" => *row_gap = parsed(value)?, - "column-gap" => *column_gap = parsed(value)?, + "direction" => assign!(Flex, direction, Direction), + "wrap" => assign!(Flex, wrap, Wrap), + "justify" => assign!(Flex, justify, Justify), + "align-items" => assign!(Flex, align_items, AlignItems), + "align-content" => assign!(Flex, align_content, AlignContent), + "width" => assign!(Flex, width, Width, same_size_source), + "height" => assign!(Flex, height, Height, same_size_source), + "row-gap" => assign!(Flex, row_gap, RowGap), + "column-gap" => assign!(Flex, column_gap, ColumnGap), _ => return Err(format!("Unsupported native retained flex field {name}")), }, LayoutNode::Row { .. } | LayoutNode::Column { .. } => { @@ -881,7 +1119,93 @@ fn patch_local_node( } } } - Ok(output) + Ok(PatchedLocal { + node: output, + fields: changed_fields, + }) +} + +fn same_scalar_source(base: &T, target: &T, _: &mut SourceChangeWork) -> bool { + base == target +} + +fn same_size_source(base: &Size, target: &Size, work: &mut SourceChangeWork) -> bool { + work.size_nodes_compared += 1; + match (base, target) { + (Size::Pixels { value: left }, Size::Pixels { value: right }) + | (Size::Lines { value: left }, Size::Lines { value: right }) => left == right, + (Size::FitContent { limit: left }, Size::FitContent { limit: right }) => { + match (left, right) { + (None, None) => true, + (Some(left), Some(right)) => same_size_source(left, right, work), + _ => false, + } + } + (Size::Add { values: left }, Size::Add { values: right }) + | (Size::Subtract { values: left }, Size::Subtract { values: right }) => { + left.0.len() == right.0.len() + && left + .0 + .iter() + .zip(right.0.iter()) + .all(|(left, right)| same_size_source(left, right, work)) + } + (Size::Auto, Size::Auto) + | (Size::Content, Size::Content) + | (Size::None, Size::None) + | (Size::Viewport, Size::Viewport) + | (Size::ViewportHeight, Size::ViewportHeight) + | (Size::MinContent, Size::MinContent) + | (Size::MaxContent, Size::MaxContent) + | (Size::Stretch, Size::Stretch) + | (Size::Contain, Size::Contain) => true, + _ => false, + } +} + +fn same_optional_content_source( + base: &Option>, + target: &Option>, + work: &mut SourceChangeWork, +) -> bool { + match (base, target) { + (None, None) => true, + (Some(base), Some(target)) => same_text_source(base, target, work), + _ => false, + } +} + +fn same_text_source( + base: &MeasuredText, + target: &MeasuredText, + work: &mut SourceChangeWork, +) -> bool { + base.lines.len() == target.lines.len() + && base.lines.iter().zip(&target.lines).all(|(left, right)| { + work.text_lines_compared += 1; + left.clusters.len() == right.clusters.len() + && left + .clusters + .iter() + .zip(&right.clusters) + .all(|(left, right)| { + work.text_clusters_compared += 1; + left.width == right.width + && left.cjk == right.cjk + && left.space == right.space + && left.pixel_space == right.pixel_space + && left.source_template_id == right.source_template_id + && left.text.len() == right.text.len() + && left + .text + .bytes() + .zip(right.text.bytes()) + .all(|(left, right)| { + work.text_bytes_compared += 1; + left == right + }) + }) + }) } fn measured_text_work(text: &MeasuredText, property_template_count: u32) -> Result { @@ -1003,32 +1327,39 @@ fn local_work_summary(node: &LayoutNode) -> Result { } impl LocalWorkSummary { - fn changed_work(&self, fields: &JsonMap) -> usize { + fn changed_work(&self, fields: &[(LocalField, &str)]) -> usize { fields - .keys() - .filter_map(|name| self.0.get(name.as_str())) + .iter() + .filter_map(|(_, name)| self.0.get(name)) .map(|work| work.units) .sum() } - fn changed_context_occurrences(&self, fields: &JsonMap) -> i128 { + fn changed_context_occurrences(&self, fields: &[(LocalField, &str)]) -> i128 { fields - .keys() - .filter_map(|name| self.0.get(name.as_str())) + .iter() + .filter_map(|(_, name)| self.0.get(name)) .map(|work| work.viewport_heights as i128) .sum() } fn updated( - &self, + self: &Arc, node: &LayoutNode, - fields: &JsonMap, - ) -> Result { - let mut output = self.clone(); - for name in fields.keys() { + fields: &[(LocalField, &str)], + ) -> Result, String> { + let mut output = Arc::clone(self); + for (_, name) in fields { if let Some(value) = field_work(node, name)? { - output.0.insert( - match name.as_str() { + let value = LocalFieldWork { + units: value, + viewport_heights: context_field_occurrences(node, name)?, + }; + if output.0.get(name) == Some(&value) { + continue; + } + Arc::make_mut(&mut output).0.insert( + match *name { "content" => "content", "height" => "height", "min-height" => "min-height", @@ -1040,10 +1371,7 @@ impl LocalWorkSummary { "row-gap" => "row-gap", _ => continue, }, - LocalFieldWork { - units: value, - viewport_heights: context_field_occurrences(node, name)?, - }, + value, ); } } @@ -7190,6 +7518,8 @@ fn column_leaves(children: &[LayoutNode]) -> impl Iterator { mod tests { use super::*; + include!("source_topology_tests.rs"); + #[test] fn line_composition_preserves_mixed_ownership_and_decoration_order() { let mut child = Line::from_clusters(&[cluster("x", 2, Some(7))]); @@ -9455,11 +9785,11 @@ mod tests { slot_one.clone(), ])])) .unwrap() - .0; + .document; let reverse = base .apply_delta(context_test_delta(vec![update(vec![slot_one, slot_zero])])) .unwrap() - .0; + .document; assert_retained_context_oracle(&base, 1); assert_retained_context_oracle(&forward, 5); assert_retained_context_oracle(&reverse, 5); @@ -9469,7 +9799,7 @@ mod tests { let no_op = forward .apply_delta(context_test_delta(vec![revision])) .unwrap() - .0; + .document; assert_retained_context_oracle(&no_op, 5); assert!(Arc::ptr_eq( &radix_lookup(&forward.entries, 1).unwrap().node, @@ -9482,7 +9812,7 @@ mod tests { let other_fork = base .apply_delta(context_test_delta(vec![remove])) .unwrap() - .0; + .document; assert_retained_context_oracle(&other_fork, 0); assert_retained_context_oracle(&base, 1); assert_retained_context_oracle(&forward, 5); @@ -9557,22 +9887,22 @@ mod tests { let flex_only = base .apply_delta(context_test_delta(vec![flex_update.clone()])) .unwrap() - .0; + .document; let box_only = base .apply_delta(context_test_delta(vec![box_update.clone()])) .unwrap() - .0; + .document; let forward = base .apply_delta(context_test_delta(vec![ flex_update.clone(), box_update.clone(), ])) .unwrap() - .0; + .document; let reverse = base .apply_delta(context_test_delta(vec![box_update, flex_update.clone()])) .unwrap() - .0; + .document; assert_retained_context_oracle(&base, 4); assert_retained_context_oracle(&flex_only, 5); assert_retained_context_oracle(&box_only, 3); @@ -9586,7 +9916,7 @@ mod tests { "slot-patches": [{"slot": 1, "local": {"max-height": {"kind": "none"}}}]}), ])) .unwrap() - .0; + .document; assert_retained_context_oracle(&removed, 0); assert_retained_context_oracle(&forward, 4); @@ -9630,6 +9960,536 @@ mod tests { ); } + fn source_test_entry( + node_id: u64, + expected: u64, + target: u64, + patches: Vec, + ) -> JsonValue { + serde_json::json!({"node-id": node_id, "expected-revision": expected, + "target-revision": target, "slot-patches": patches}) + } + + fn source_test_content(text: &str) -> JsonValue { + serde_json::json!({"lines": [{"clusters": [{"text": text, "width": text.len(), "cjk": false, "space": false}]}]}) + } + + fn source_test_base() -> Arc { + RetainedDocument::bootstrap(retained_document(identified( + text_box(1, measured_text(vec![vec![cluster("abc", 3, None)]]), None), + 1, + 7, + ))) + .unwrap() + .0 + } + + fn source_test_seeds(changes: &SourceChanges) -> Vec<(u64, u64, u64, u8, Vec)> { + let mut seeds = Vec::new(); + changes.visit_changed_slots(|owner, expected, target, slot, fields| { + seeds.push((owner, expected, target, slot, fields.to_vec())); + }); + seeds + } + + #[test] + fn retained_source_noops_preserve_body_summary_identity_and_advance_cas() { + let base = source_test_base(); + let old = radix_lookup(&base.entries, 1).unwrap(); + for (patches, compared) in [ + (vec![], 0), + (vec![serde_json::json!({"slot": 0, "local": {}})], 0), + ( + vec![serde_json::json!({"slot": 0, "local": {"padding-left": 0}})], + 1, + ), + ( + vec![ + serde_json::json!({"slot": 0, "local": {"content": source_test_content("abc")}}), + ], + 1, + ), + ] { + let applied = base + .apply_delta(context_test_delta(vec![source_test_entry( + 1, 7, 8, patches, + )])) + .unwrap(); + let target = radix_lookup(&applied.document.entries, 1).unwrap(); + assert!(Arc::ptr_eq(&old.node, &target.node)); + for slot in 0..2 { + assert!(Arc::ptr_eq(&old.slot_work[slot], &target.slot_work[slot])); + } + assert_eq!(target.revision, 8); + assert_eq!(old.revision, 7); + assert!(source_test_seeds(&applied.changes).is_empty()); + assert!(applied.changes.applies_to(&base, &applied.document)); + assert_eq!(applied.stats.source_work.fields_compared, compared); + assert_eq!(applied.stats.source_work.local_nodes_copied, 0); + assert!(applied + .document + .apply_delta(context_test_delta(vec![source_test_entry(1, 7, 9, vec![])])) + .is_err()); + } + } + + #[test] + fn retained_source_mixed_content_compares_payload_and_preserves_equal_arc() { + let base = source_test_base(); + let old = radix_lookup(&base.entries, 1).unwrap(); + let applied = base.apply_delta(context_test_delta(vec![source_test_entry(1, 7, 8, vec![ + serde_json::json!({"slot": 0, "local": {"padding-left": 2, "content": source_test_content("abc")}}) + ])])).unwrap(); + let target = radix_lookup(&applied.document.entries, 1).unwrap(); + let LayoutNode::Box { + content: Some(old_content), + .. + } = old.node.as_ref() + else { + unreachable!() + }; + let LayoutNode::Box { + content: Some(target_content), + .. + } = target.node.as_ref() + else { + unreachable!() + }; + assert!(Arc::ptr_eq(old_content, target_content)); + assert!(Arc::ptr_eq(&old.slot_work[0], &target.slot_work[0])); + assert_eq!( + source_test_seeds(&applied.changes), + vec![(1, 7, 8, 0, vec![LocalField::PaddingLeft])] + ); + let work = applied.stats.source_work; + assert_eq!( + ( + work.fields_compared, + work.text_lines_compared, + work.text_clusters_compared, + work.text_bytes_compared, + work.local_nodes_copied + ), + (2, 1, 1, 3, 1) + ); + + let different = base.apply_delta(context_test_delta(vec![source_test_entry(1, 7, 8, vec![ + serde_json::json!({"slot": 0, "local": {"content": source_test_content("axc")}}) + ])])).unwrap(); + assert_eq!( + different.stats.source_work.text_bytes_compared, 2, + "byte comparison must stop at the actual mismatch" + ); + assert_eq!( + source_test_seeds(&different.changes), + vec![(1, 7, 8, 0, vec![LocalField::Content])] + ); + } + + #[test] + fn retained_source_text_payload_compares_every_cluster_field() { + let root = identified(nonuniform_text(1, &[1]), 1, 7); + let mut full = retained_document(root); + full.property_template_count = 1; + let (base, _) = RetainedDocument::bootstrap(full).unwrap(); + let original_content = source_test_content("x"); + for (field, value) in [ + ("text", serde_json::json!("y")), + ("width", serde_json::json!(2)), + ("cjk", serde_json::json!(true)), + ("space", serde_json::json!(true)), + ("pixel-space", serde_json::json!(true)), + ("source-template-id", serde_json::json!(0)), + ] { + let mut content = original_content.clone(); + content["lines"][0]["clusters"][0][field] = value; + let mut delta = context_test_delta(vec![source_test_entry( + 1, + 7, + 8, + vec![serde_json::json!({"slot": 0, "local": {"content": content}})], + )]); + delta.property_template_base_count = 1; + delta.property_template_target_count = 1; + let applied = base.apply_delta(delta).unwrap(); + assert_eq!( + source_test_seeds(&applied.changes), + vec![(1, 7, 8, 0, vec![LocalField::Content])], + "{field}" + ); + } + let mut delta = context_test_delta(vec![source_test_entry( + 1, + 7, + 8, + vec![serde_json::json!({"slot": 0, "local": {"content": original_content}})], + )]); + delta.property_template_base_count = 1; + delta.property_template_target_count = 1; + let equal = base.apply_delta(delta).unwrap(); + assert!(Arc::ptr_eq( + &radix_lookup(&base.entries, 1).unwrap().node, + &radix_lookup(&equal.document.entries, 1).unwrap().node + )); + assert!(equal.changes.owners.is_empty()); + } + + #[test] + fn retained_source_slot_seeds_are_exact_in_both_orders_and_slot_one_only() { + let leaf = identified( + text_box(2, measured_text(vec![vec![cluster("x", 1, None)]]), None), + 2, + 1, + ); + let root = identified( + child_box(1, context_test_flex(Size::Auto, vec![leaf]), None), + 1, + 7, + ); + let (base, _) = RetainedDocument::bootstrap(retained_document(root)).unwrap(); + let old = radix_lookup(&base.entries, 1).unwrap(); + let LayoutNode::Box { + child: Some(old_child), + .. + } = old.node.as_ref() + else { + unreachable!() + }; + let LayoutNode::Flex { + items: old_items, .. + } = old_child.as_ref() + else { + unreachable!() + }; + let slot_zero = serde_json::json!({"slot": 0, "local": {"padding-left": 1, "height": {"kind": "auto"}}}); + let slot_one = serde_json::json!({"slot": 1, "local": {"height": {"kind": "lines", "value": 1}, "width": {"kind": "viewport-height"}}}); + for patches in [ + vec![slot_zero.clone()], + vec![slot_one.clone()], + vec![slot_zero.clone(), slot_one.clone()], + vec![slot_one.clone(), slot_zero.clone()], + ] { + let expected: Vec<_> = patches + .iter() + .map(|patch| { + let slot = patch["slot"].as_u64().unwrap() as u8; + ( + 1, + 7, + 8, + slot, + vec![if slot == 0 { + LocalField::PaddingLeft + } else { + LocalField::Height + }], + ) + }) + .collect(); + let applied = base + .apply_delta(context_test_delta(vec![source_test_entry( + 1, 7, 8, patches, + )])) + .unwrap(); + assert_eq!(source_test_seeds(&applied.changes), expected); + let target = radix_lookup(&applied.document.entries, 1).unwrap(); + let LayoutNode::Box { + child: Some(target_child), + .. + } = target.node.as_ref() + else { + unreachable!() + }; + let LayoutNode::Flex { + items: target_items, + .. + } = target_child.as_ref() + else { + unreachable!() + }; + assert!(Arc::ptr_eq(old_items, target_items)); + if expected.iter().all(|seed| seed.3 == 0) { + assert!(Arc::ptr_eq(old_child, target_child)); + } + if expected.iter().all(|seed| seed.3 == 1) { + assert!(Arc::ptr_eq(&old.slot_work[0], &target.slot_work[0])); + } + assert!(Arc::ptr_eq( + &radix_lookup(&base.entries, 2).unwrap().node, + &radix_lookup(&applied.document.entries, 2).unwrap().node + )); + } + } + + #[test] + fn retained_source_size_expression_identity_is_independent_of_resolved_geometry() { + let mut root = identified( + text_box(1, measured_text(vec![vec![cluster("x", 1, None)]]), None), + 1, + 7, + ); + let LayoutNode::Box { height, .. } = &mut root else { + unreachable!() + }; + *height = Size::Lines { value: 10 }; + let (base, _) = RetainedDocument::bootstrap(retained_document(root)).unwrap(); + let expression = serde_json::json!({"kind": "add", "values": [{"kind": "lines", "value": 4}, {"kind": "lines", "value": 6}]}); + let applied = base + .apply_delta(context_test_delta(vec![source_test_entry( + 1, + 7, + 8, + vec![serde_json::json!({"slot": 0, "local": {"height": expression}})], + )])) + .unwrap(); + assert_eq!( + source_test_seeds(&applied.changes), + vec![(1, 7, 8, 0, vec![LocalField::Height])] + ); + assert_eq!( + base.layout_tape(test_context(), None).unwrap(), + applied.document.layout_tape(test_context(), None).unwrap() + ); + assert_eq!(applied.stats.source_work.size_nodes_compared, 1); + let equal = applied + .document + .apply_delta(context_test_delta(vec![source_test_entry( + 1, + 8, + 9, + vec![serde_json::json!({"slot": 0, "local": {"height": expression}})], + )])) + .unwrap(); + assert_eq!(equal.stats.source_work.size_nodes_compared, 3); + assert_eq!(equal.stats.source_work.local_nodes_copied, 0); + assert!(equal.changes.owners.is_empty()); + } + + #[test] + fn retained_source_registry_only_updates_and_forks_bind_exact_document_identity() { + let base = source_test_base(); + let mut delta = context_test_delta(vec![]); + delta.styles_append = vec![serde_json::from_value( + serde_json::json!({"mode": "add", "face": {"foreground": "red"}}), + ) + .unwrap()]; + delta.property_template_target_count = 2; + let registry = base.apply_delta(delta).unwrap(); + assert_eq!(registry.changes.registry_ranges(), (0..1, 0..2)); + assert!(registry.changes.owners.is_empty()); + assert_eq!(registry.stats.entries_parsed, 0); + assert!(Arc::ptr_eq(&base.entries, ®istry.document.entries)); + assert_eq!(base.style_count, 0); + + let entry = source_test_entry( + 1, + 7, + 8, + vec![serde_json::json!({"slot": 0, "local": {"padding-left": 2}})], + ); + let left = base + .apply_delta(context_test_delta(vec![entry.clone()])) + .unwrap(); + let right = base.apply_delta(context_test_delta(vec![entry])).unwrap(); + assert!(left.changes.applies_to(&base, &left.document)); + assert!(!left.changes.applies_to(&base, &right.document)); + assert!(!left.changes.applies_to(&right.document, &left.document)); + assert_eq!( + source_test_seeds(&left.changes), + source_test_seeds(&right.changes) + ); + } + + #[test] + fn retained_source_transient_changes_do_not_retain_document_history() { + let base = source_test_base(); + let weak_base = Arc::downgrade(&base); + let applied = base + .apply_delta(context_test_delta(vec![source_test_entry(1, 7, 8, vec![])])) + .unwrap(); + let AppliedDelta { + document: target, + changes, + .. + } = applied; + drop(base); + assert!( + weak_base.upgrade().is_some(), + "the transient descriptor owns its exact base" + ); + drop(changes); + assert!( + weak_base.upgrade().is_none(), + "a target document must not retain its historical base" + ); + let weak_target = Arc::downgrade(&target); + let next = target + .apply_delta(context_test_delta(vec![source_test_entry(1, 8, 9, vec![])])) + .unwrap(); + drop(target); + let AppliedDelta { + document: next, + changes, + .. + } = next; + drop(changes); + assert!(weak_target.upgrade().is_none()); + assert_eq!(radix_lookup(&next.entries, 1).unwrap().revision, 9); + } + + #[test] + fn retained_source_local_comparison_work_does_not_grow_with_owner_count() { + for count in [32, 128, 512] { + let children = (2..count + 2) + .map(|id| { + identified( + text_box(id, measured_text(vec![vec![cluster("abc", 3, None)]]), None), + id as u64, + 7, + ) + }) + .collect(); + let root = identified( + child_box(1, context_test_flex(Size::Auto, children), None), + 1, + 7, + ); + let (base, _) = RetainedDocument::bootstrap(retained_document(root)).unwrap(); + let applied = base.apply_delta(context_test_delta(vec![source_test_entry(2, 7, 8, vec![serde_json::json!({"slot": 0, "local": {"content": source_test_content("abc"), "padding-left": 2}})])])).unwrap(); + let work = applied.stats.source_work; + assert_eq!( + ( + work.fields_compared, + work.text_lines_compared, + work.text_clusters_compared, + work.text_bytes_compared, + work.local_nodes_copied + ), + (2, 1, 1, 3, 1) + ); + assert_eq!(applied.stats.trie_path_nodes_copied, 17); + assert!(Arc::ptr_eq(&base.topology, &applied.document.topology)); + } + } + + #[test] + fn retained_source_rejection_contract_preserves_parent_and_render() { + let root = identified( + text_box(1, measured_text(vec![vec![cluster("x", 1, None)]]), None), + 1, + 7, + ); + let (base, _) = RetainedDocument::bootstrap(retained_document(root)).unwrap(); + let before = base.layout_tape(test_context(), None).unwrap(); + let entry = |id, expected, target, patches: Vec| { + serde_json::json!({ + "node-id": id, "expected-revision": expected, "target-revision": target, + "slot-patches": patches + }) + }; + let same = serde_json::json!({"slot": 0, "local": {"padding-left": 0}}); + for (entries, message) in [ + ( + vec![entry(1, 6, 8, vec![same.clone()])], + "node revision mismatch", + ), + ( + vec![entry(1, 7, 7, vec![same.clone()])], + "node revision mismatch", + ), + (vec![entry(99, 7, 8, vec![same.clone()])], "unknown node id"), + ( + vec![entry(1, 7, 8, vec![same.clone()]), entry(1, 7, 9, vec![])], + "duplicate node id", + ), + ( + vec![entry(1, 7, 8, vec![same.clone(), same.clone()])], + "duplicate slot", + ), + ( + vec![entry( + 1, + 7, + 8, + vec![ + serde_json::json!({"slot": 0, "local": {"padding-left": 0, "unknown-field": 0}}), + ], + )], + "Unsupported native retained box field", + ), + ( + vec![entry( + 1, + 7, + 8, + vec![ + serde_json::json!({"slot": 0, "local": {"padding-left": 0, "height": {"kind": "lines", "value": -1}}}), + ], + )], + "cannot be negative", + ), + ( + vec![entry( + 1, + 7, + 8, + vec![ + serde_json::json!({"slot": 0, "local": {"padding-left": 0, "content": null}}), + ], + )], + "exactly one text or child", + ), + ( + vec![entry(1, 7, 8, vec![same.clone()]), entry(99, 7, 8, vec![])], + "unknown node id", + ), + ] { + let error = base.apply_delta(context_test_delta(entries)).unwrap_err(); + assert!(error.contains(message), "{error}"); + assert_eq!(radix_lookup(&base.entries, 1).unwrap().revision, 7); + assert_eq!(base.layout_tape(test_context(), None).unwrap(), before); + } + } + + #[test] + fn retained_source_equal_and_mixed_fields_preserve_render_contract() { + let root = identified( + text_box(1, measured_text(vec![vec![cluster("x", 1, None)]]), None), + 1, + 7, + ); + let (base, _) = RetainedDocument::bootstrap(retained_document(root.clone())).unwrap(); + let content = serde_json::json!({"lines": [{"clusters": [{"text": "x", "width": 1, "cjk": false, "space": false}]}]}); + for local in [ + serde_json::json!({}), + serde_json::json!({"padding-left": 0, "content": content.clone()}), + serde_json::json!({"padding-left": 2, "content": content}), + ] { + let target = base + .apply_delta(context_test_delta(vec![serde_json::json!({ + "node-id": 1, "expected-revision": 7, "target-revision": 8, + "slot-patches": [{"slot": 0, "local": local}] + })])) + .unwrap() + .document; + let mut expected = root.clone(); + if let LayoutNode::Box { padding_left, .. } = &mut expected { + *padding_left = local + .get("padding-left") + .and_then(JsonValue::as_i64) + .unwrap_or(0); + } + assert_eq!( + target.layout_tape(test_context(), None).unwrap(), + retained_document(expected) + .layout_tape(test_context(), None) + .unwrap() + ); + assert_eq!(radix_lookup(&target.entries, 1).unwrap().revision, 8); + } + assert_eq!(radix_lookup(&base.entries, 1).unwrap().revision, 7); + } + #[test] fn retained_scalar_patch_path_copies_only_radix_and_shares_wide_edges() { let children = Arc::new( @@ -9683,7 +10543,10 @@ mod tests { }] })) .unwrap(); - let (target, validated, copied) = base.apply_delta(delta).unwrap(); + let applied = base.apply_delta(delta).unwrap(); + let target = applied.document; + let validated = applied.stats.entries_parsed; + let copied = applied.stats.trie_path_nodes_copied; assert_eq!(validated, 1); assert_eq!(copied, 17); let target_root = radix_lookup(&target.entries, 1).unwrap(); @@ -9725,8 +10588,10 @@ mod tests { })) .unwrap() }; - let (left, _, copied) = base.apply_delta(revision_only(3, 20)).unwrap(); - let (right, _, _) = base.apply_delta(revision_only(3, 30)).unwrap(); + let applied = base.apply_delta(revision_only(3, 20)).unwrap(); + let left = applied.document; + let copied = applied.stats.trie_path_nodes_copied; + let right = base.apply_delta(revision_only(3, 30)).unwrap().document; assert_eq!(copied, 17); assert!(Arc::ptr_eq( &radix_lookup(&base.entries, 1).unwrap().node, @@ -9834,11 +10699,11 @@ mod tests { let forward = base .apply_delta(delta(vec![entry(2, 10), entry(3, high)])) .unwrap() - .0; + .document; let reverse = base .apply_delta(delta(vec![entry(3, high), entry(2, 10)])) .unwrap() - .0; + .document; assert_eq!(forward.work_units, MAX_LAYOUT_WORK_UNITS); assert_eq!(reverse.work_units, MAX_LAYOUT_WORK_UNITS); } diff --git a/native/src/lib.rs b/native/src/lib.rs index 7f3769f..6fc482f 100644 --- a/native/src/lib.rs +++ b/native/src/lib.rs @@ -484,6 +484,7 @@ struct DocumentInputStats { delta_entries_parsed: u64, delta_entries_validated: u64, trie_path_nodes_copied: u64, + source_change_work: layout::SourceChangeWork, } #[derive(Debug)] @@ -523,6 +524,7 @@ struct RuntimeState { document_delta_entries_validated: u64, document_trie_path_nodes_copied: u64, document_resolver_lookups: u64, + source_change_work: layout::SourceChangeWork, atom_plan_work: layout::AtomPlanWork, line_plan_work: layout::LinePlanWork, confirmed_baseline: Option>, @@ -602,6 +604,7 @@ struct SessionStats { document_delta_entries_validated: u64, document_trie_path_nodes_copied: u64, document_resolver_lookups: u64, + source_change_work: layout::SourceChangeWork, atom_plan_work: layout::AtomPlanWork, line_plan_work: layout::LinePlanWork, pending_baselines: usize, @@ -676,6 +679,7 @@ impl Session { document_delta_entries_validated: 0, document_trie_path_nodes_copied: 0, document_resolver_lookups: 0, + source_change_work: layout::SourceChangeWork::default(), atom_plan_work: layout::AtomPlanWork::default(), line_plan_work: layout::LinePlanWork::default(), confirmed_baseline, @@ -1019,14 +1023,38 @@ impl Session { .to_owned(), ); }; - let (document, parsed, copied) = document.apply_delta(delta)?; + let applied = document.apply_delta(delta)?; + if !applied.changes.applies_to(document, &applied.document) { + return Err( + "Native retained source changes document identity mismatch".to_owned() + ); + } + // Consume exact source facts for input accounting. The renderer + // still evaluates the target normally; no history-bearing change + // object is stored in a job, document or confirmed baseline. + let mut source_change_work = applied.stats.source_work; + let mut previous_owner = None; + applied + .changes + .visit_changed_slots(|owner, _, _, _, fields| { + if previous_owner != Some(owner) { + source_change_work.owners_changed += 1; + previous_owner = Some(owner); + } + source_change_work.slots_changed += 1; + source_change_work.fields_changed += fields.len() as u64; + }); + let (styles, property_templates) = applied.changes.registry_ranges(); + source_change_work.styles_appended = styles.len() as u64; + source_change_work.property_templates_added = property_templates.len() as u64; ( - LayoutSource::Retained(document), + LayoutSource::Retained(applied.document), DocumentInputStats { delta_input_bytes: payload.len() as u64, - delta_entries_parsed: parsed, - delta_entries_validated: parsed, - trie_path_nodes_copied: copied, + delta_entries_parsed: applied.stats.entries_parsed, + delta_entries_validated: applied.stats.entries_parsed, + trie_path_nodes_copied: applied.stats.trie_path_nodes_copied, + source_change_work, ..DocumentInputStats::default() }, ) @@ -1113,6 +1141,9 @@ impl Session { state.document_delta_entries_parsed += input_stats.delta_entries_parsed; state.document_delta_entries_validated += input_stats.delta_entries_validated; state.document_trie_path_nodes_copied += input_stats.trie_path_nodes_copied; + state + .source_change_work + .accumulate(input_stats.source_change_work); state.pending_baselines.clear(); if let Some(pending) = output.pending { state @@ -1252,6 +1283,7 @@ impl Session { document_delta_entries_validated: state.document_delta_entries_validated, document_trie_path_nodes_copied: state.document_trie_path_nodes_copied, document_resolver_lookups: state.document_resolver_lookups, + source_change_work: state.source_change_work, atom_plan_work: state.atom_plan_work, line_plan_work: state.line_plan_work, pending_baselines, @@ -2677,6 +2709,14 @@ mod tests { assert_eq!(stats.document_delta_entries_validated, 1); assert_eq!(stats.document_trie_path_nodes_copied, 17); assert_eq!(stats.document_delta_input_bytes, delta.len() as u64); + assert_eq!(stats.source_change_work.fields_compared, 1); + assert_eq!(stats.source_change_work.text_lines_compared, 1); + assert_eq!(stats.source_change_work.text_clusters_compared, 1); + assert_eq!(stats.source_change_work.text_bytes_compared, 1); + assert_eq!(stats.source_change_work.local_nodes_copied, 1); + assert_eq!(stats.source_change_work.owners_changed, 1); + assert_eq!(stats.source_change_work.slots_changed, 1); + assert_eq!(stats.source_change_work.fields_changed, 1); assert_eq!( stats.document_resolver_lookups, stats.target_renders, "each single-owner render resolves its root; retained context validation resolves no nodes" @@ -2693,6 +2733,60 @@ mod tests { child.stop(true); } + #[test] + fn retained_source_input_counts_noops_and_registries_without_publishing_failed_work() { + let session = Session::new(1, 4, 4, 64 * 1024).unwrap(); + let first = identified_proof_layout_payload( + r#"[{"key":1,"viewport-width":80,"viewport-height":10,"root-width":80,"runtime-revision":0,"context-hash":77}]"#, + ); + session.render_sync(1, &first).unwrap(); + assert!(session.confirm(1, 1, 1).unwrap()); + let document: serde_json::Value = serde_json::from_slice(&first).unwrap(); + let content = document["document"]["root"]["content"].clone(); + let payload = |padding| { + serde_json::to_vec(&serde_json::json!({ + "version": 1, "document-base-revision": 1, "document-target-revision": 2, + "document-delta": { + "style-base-count": 0, "styles-append": [{"mode": "add", "face": {"foreground": "red"}}], + "property-template-base-count": 0, "property-template-target-count": 2, + "entries": [{"node-id": 1, "expected-revision": 7, "target-revision": 8, + "slot-patches": [{"slot": 0, "local": {"content": content, "padding-left": padding}}]}] + }, + "frames": [{"key": 2, "viewport-width": 80, "viewport-height": 10, + "root-width": 80, "runtime-revision": 1, "context-hash": 77}] + })).unwrap() + }; + let before = session.stats().source_change_work; + assert!(session.render_sync(2, &payload(-1)).is_err()); + assert_eq!(session.stats().source_change_work, before); + session.render_sync(3, &payload(0)).unwrap(); + let work = session.stats().source_change_work; + assert_eq!( + ( + work.fields_compared, + work.text_lines_compared, + work.text_clusters_compared, + work.text_bytes_compared + ), + (2, 1, 1, 1) + ); + assert_eq!( + ( + work.local_nodes_copied, + work.owners_changed, + work.slots_changed, + work.fields_changed + ), + (0, 0, 0, 0) + ); + assert_eq!( + (work.styles_appended, work.property_templates_added), + (1, 2) + ); + assert!(session.confirm(3, 2, 2).unwrap()); + session.stop(true); + } + #[test] fn retained_document_reuse_rejects_wrong_or_ambiguous_revision() { let parent = Session::new(1, 4, 4, 64 * 1024).unwrap(); diff --git a/native/src/source_topology.rs b/native/src/source_topology.rs new file mode 100644 index 0000000..7e7748a --- /dev/null +++ b/native/src/source_topology.rs @@ -0,0 +1,214 @@ +//! Immutable source-reference occurrences, independent of layout evaluation phases. + +use std::collections::BTreeMap; +use std::sync::Arc; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum LocalStep { + BoxChild, + RowChild(usize), + ColumnChild(usize), + FlexItem(usize), +} + +/// A local structural address stops before crossing an identified node reference. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) struct SourceAddress { + pub(super) owner_id: u64, + pub(super) path: Arc<[LocalStep]>, +} + +/// One structural occurrence can have several distinct layout evaluation uses. +#[derive(Debug, PartialEq, Eq)] +pub(super) struct ParentUse { + pub(super) use_id: u64, + pub(super) parent: SourceAddress, + pub(super) child_slot: LocalStep, + pub(super) target_owner: u64, +} + +/// Explicit bootstrap work; excludes allocator/map internals and layout work/K. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(super) struct TopologyBuildWork { + pub(super) occurrences_written: u64, + pub(super) path_steps_copied: u64, + pub(super) incoming_lists_frozen: u64, +} + +#[derive(Debug)] +pub(super) struct SourceTopology { + root_owner: u64, + incoming: BTreeMap>, + build_work: TopologyBuildWork, +} + +impl SourceTopology { + pub(super) fn root_owner(&self) -> u64 { + self.root_owner + } + + /// Borrows the complete occurrence list; no owner-pair deduplication. + pub(super) fn incoming_uses(&self, owner_id: u64) -> &[ParentUse] { + self.incoming.get(&owner_id).map_or(&[], Box::as_ref) + } + + pub(super) fn build_work(&self) -> TopologyBuildWork { + self.build_work + } +} + +/// Only the bootstrap traversal can create a topology. Scalar deltas share it. +#[derive(Default)] +pub(super) struct TopologyBuilder { + incoming: BTreeMap>, + work: TopologyBuildWork, +} + +impl TopologyBuilder { + pub(super) fn add_use( + &mut self, + parent_owner: u64, + parent_path: &[LocalStep], + child_slot: LocalStep, + target_owner: u64, + ) -> Result<(), String> { + let use_id = self.work.occurrences_written; + let occurrences_written = use_id + .checked_add(1) + .ok_or_else(|| "Native source topology occurrence count overflow".to_owned())?; + let path_steps_copied = self + .work + .path_steps_copied + .checked_add(parent_path.len() as u64) + .ok_or_else(|| "Native source topology path count overflow".to_owned())?; + self.incoming + .entry(target_owner) + .or_default() + .push(ParentUse { + use_id, + parent: SourceAddress { + owner_id: parent_owner, + path: Arc::from(parent_path), + }, + child_slot, + target_owner, + }); + self.work.occurrences_written = occurrences_written; + self.work.path_steps_copied = path_steps_copied; + Ok(()) + } + + /// The caller has already validated owner uniqueness and rejected NodeRef input. + pub(super) fn finish(self, root_owner: u64) -> Arc { + let mut work = self.work; + let incoming = self + .incoming + .into_iter() + .map(|(owner, uses)| { + work.incoming_lists_frozen += 1; + (owner, uses.into_boxed_slice()) + }) + .collect(); + Arc::new(SourceTopology { + root_owner, + incoming, + build_work: work, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn anonymous_paths_and_structural_slots_remain_distinct() { + let mut builder = TopologyBuilder::default(); + builder + .add_use(11, &[LocalStep::BoxChild], LocalStep::FlexItem(3), 29) + .unwrap(); + builder + .add_use( + 29, + &[LocalStep::BoxChild, LocalStep::RowChild(2)], + LocalStep::ColumnChild(1), + 41, + ) + .unwrap(); + let topology = builder.finish(11); + assert_eq!(topology.root_owner(), 11); + assert!(topology.incoming_uses(11).is_empty()); + assert!(topology.incoming_uses(999).is_empty()); + let first = &topology.incoming_uses(29)[0]; + assert_eq!(first.use_id, 0); + assert_eq!(first.parent.owner_id, 11); + assert_eq!(first.parent.path.as_ref(), &[LocalStep::BoxChild]); + assert_eq!(first.child_slot, LocalStep::FlexItem(3)); + assert_eq!(first.target_owner, 29); + let second = &topology.incoming_uses(41)[0]; + assert_eq!(second.parent.owner_id, 29); + assert_eq!(second.use_id, 1); + assert_eq!( + second.parent.path.as_ref(), + &[LocalStep::BoxChild, LocalStep::RowChild(2)] + ); + assert_eq!(second.child_slot, LocalStep::ColumnChild(1)); + assert_eq!( + topology.build_work(), + TopologyBuildWork { + occurrences_written: 2, + path_steps_copied: 3, + incoming_lists_frozen: 2, + } + ); + } + + #[test] + fn every_occurrence_is_retained_even_for_the_same_owner_pair() { + // The storage must not silently deduplicate occurrences. This does not + // authorize duplicate identified owners in the public bootstrap input. + let mut builder = TopologyBuilder::default(); + for slot in [2, 7, 2] { + builder + .add_use(1, &[], LocalStep::RowChild(slot), 9) + .unwrap(); + } + let topology = builder.finish(1); + let uses = topology.incoming_uses(9); + assert_eq!(uses.len(), 3); + assert_eq!( + uses.iter().map(|usage| usage.use_id).collect::>(), + [0, 1, 2] + ); + assert_eq!(uses[0].child_slot, uses[2].child_slot); + assert_ne!(uses[0].use_id, uses[2].use_id); + assert_eq!(topology.build_work().incoming_lists_frozen, 1); + } + + #[test] + fn forks_borrow_the_same_lists_without_changing_build_work() { + for n in [32, 128, 512] { + let mut builder = TopologyBuilder::default(); + for index in 0..n { + builder + .add_use(1, &[], LocalStep::ColumnChild(index), index as u64 + 2) + .unwrap(); + } + let base = builder.finish(1); + let original_work = base.build_work(); + let left = Arc::clone(&base); + let right = Arc::clone(&base); + let target = (n / 2 + 2) as u64; + assert!(Arc::ptr_eq(&base, &left)); + assert!(Arc::ptr_eq(&left, &right)); + assert!(std::ptr::eq( + base.incoming_uses(target), + right.incoming_uses(target) + )); + assert_eq!(base.build_work(), original_work); + assert_eq!(original_work.occurrences_written, n as u64); + assert_eq!(original_work.path_steps_copied, 0); + assert_eq!(original_work.incoming_lists_frozen, n as u64); + } + } +} diff --git a/native/src/source_topology_tests.rs b/native/src/source_topology_tests.rs new file mode 100644 index 0000000..a251422 --- /dev/null +++ b/native/src/source_topology_tests.rs @@ -0,0 +1,162 @@ +// Included in layout::tests after the production bootstrap topology is attached. + +#[test] +fn retained_topology_bootstrap_records_exact_anonymous_use_paths() { + use source_topology::LocalStep; + let leaf = |id| { + identified( + text_box( + id as i64, + measured_text(vec![vec![cluster("x", 1, None)]]), + None, + ), + id, + 1, + ) + }; + let nested_owner = identified(child_box(3, child_box(30, leaf(4), None), None), 3, 1); + let root = identified( + child_box( + 1, + LayoutNode::Column { + node_id: None, + node_revision: None, + children: Arc::new(vec![ + LayoutNode::Row { + node_id: None, + node_revision: None, + children: Arc::new(vec![ + text_box(20, measured_text(vec![vec![cluster("a", 1, None)]]), None), + leaf(2), + ]), + }, + context_test_flex(Size::Auto, vec![nested_owner]), + ]), + }, + None, + ), + 1, + 1, + ); + let full = retained_document(root); + let (retained, _) = RetainedDocument::bootstrap(full.clone()).unwrap(); + let topology = &retained.topology; + assert_eq!(topology.root_owner(), 1); + assert!(topology.incoming_uses(1).is_empty()); + let cases = [ + ( + 2, + 1, + vec![LocalStep::BoxChild, LocalStep::ColumnChild(0)], + LocalStep::RowChild(1), + ), + ( + 3, + 1, + vec![LocalStep::BoxChild, LocalStep::ColumnChild(1)], + LocalStep::FlexItem(0), + ), + (4, 3, vec![LocalStep::BoxChild], LocalStep::BoxChild), + ]; + let mut use_ids = HashSet::new(); + for (target, parent, path, slot) in cases { + let uses = topology.incoming_uses(target); + assert_eq!(uses.len(), 1); + assert_eq!(uses[0].target_owner, target); + assert_eq!(uses[0].parent.owner_id, parent); + assert_eq!(uses[0].parent.path.as_ref(), path.as_slice()); + assert_eq!(uses[0].child_slot, slot); + assert!(use_ids.insert(uses[0].use_id)); + } + assert_eq!(topology.build_work().occurrences_written, 3); + assert_eq!(topology.build_work().path_steps_copied, 5); + assert_eq!( + retained.layout_tape(test_context(), None).unwrap(), + full.layout_tape(test_context(), None).unwrap() + ); +} + +#[test] +fn retained_topology_real_deltas_share_lists_across_forks_and_failures() { + for n in [32, 128, 512] { + let children = (0..n) + .map(|i| { + identified( + text_box( + i + 2, + measured_text(vec![vec![cluster("x", 1, None)]]), + None, + ), + (i + 2) as u64, + 1, + ) + }) + .collect(); + let root = identified( + LayoutNode::Column { + node_id: None, + node_revision: None, + children: Arc::new(children), + }, + 1, + 1, + ); + let (base, _) = RetainedDocument::bootstrap(retained_document(root)).unwrap(); + let target_id = (n / 2 + 2) as u64; + let original = Arc::clone(&base.topology); + let original_list = original.incoming_uses(target_id); + let patch = |region| { + context_test_delta(vec![serde_json::json!({ + "node-id": target_id, "expected-revision": 1, "target-revision": 2, + "slot-patches": [{"slot": 0, "local": {"region-id": region}}] + })]) + }; + let left = base.apply_delta(patch(100_000)).unwrap().document; + let right = base.apply_delta(patch(100_001)).unwrap().document; + for target in [&base, &left, &right] { + assert!(Arc::ptr_eq(&original, &target.topology)); + assert!(std::ptr::eq( + original_list, + target.topology.incoming_uses(target_id) + )); + assert_eq!(target.topology.root_owner(), 1); + } + assert_eq!(original.incoming_uses(target_id)[0].target_owner, target_id); + assert_eq!(original.build_work().occurrences_written, n as u64); + // Later-entry rejection cannot install a partially updated topology. + let failed = base.apply_delta(context_test_delta(vec![ + serde_json::json!({"node-id": target_id, "expected-revision": 1, "target-revision": 2}), + serde_json::json!({"node-id": 999_999, "expected-revision": 1, "target-revision": 2}), + ])); + assert!(failed.is_err()); + assert!(Arc::ptr_eq(&base.topology, &original)); + assert_eq!(radix_lookup(&base.entries, target_id).unwrap().revision, 1); + } +} + +#[test] +fn retained_topology_identity_is_local_to_one_validated_bootstrap() { + let leaf = identified( + text_box(1, measured_text(vec![vec![cluster("x", 1, None)]]), None), + 1, + 1, + ); + let full = retained_document(leaf.clone()); + let (first, _) = RetainedDocument::bootstrap(full.clone()).unwrap(); + let (second, _) = RetainedDocument::bootstrap(full).unwrap(); + assert!(!Arc::ptr_eq(&first.topology, &second.topology)); + assert!(first.topology.incoming_uses(1).is_empty()); + assert_eq!(first.topology.build_work().occurrences_written, 0); + let duplicate = identified( + LayoutNode::Row { + node_id: None, + node_revision: None, + children: Arc::new(vec![leaf.clone(), leaf]), + }, + 2, + 1, + ); + assert!(RetainedDocument::bootstrap(retained_document(duplicate)).is_err()); + let external = identified(child_box(2, LayoutNode::NodeRef { node_id: 1 }, None), 2, 1); + assert!(RetainedDocument::bootstrap(retained_document(external)).is_err()); +} From 14c1698c41bf32ac665be1be3f00143ce911afd1 Mon Sep 17 00:00:00 2001 From: Kinneyzhang Date: Sat, 5 Sep 2026 13:39:28 +0800 Subject: [PATCH 09/12] Reuse retained layout subtrees across confirmed document updates --- native/src/evaluation.rs | 551 +++++++++++++++++ native/src/evaluation_tests.rs | 1028 ++++++++++++++++++++++++++++++++ native/src/layout.rs | 272 +++++---- native/src/lib.rs | 302 +++++++++- native/src/source_topology.rs | 4 +- 5 files changed, 2018 insertions(+), 139 deletions(-) create mode 100644 native/src/evaluation.rs create mode 100644 native/src/evaluation_tests.rs diff --git a/native/src/evaluation.rs b/native/src/evaluation.rs new file mode 100644 index 0000000..a360971 --- /dev/null +++ b/native/src/evaluation.rs @@ -0,0 +1,551 @@ +//! One immutable tree of current evaluation uses, scoped to an exact document pair. + +use std::cell::{Cell, RefCell}; +use std::collections::{BTreeMap, BTreeSet}; +use std::sync::Arc; + +use super::source_topology::SourceAddress; +use super::{ + BoxOverride, LayoutContext, LayoutNode, LayoutTape, LocalStep, Rendered, RetainedDocument, + SourceChanges, +}; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "kebab-case")] +pub(crate) struct EvalWork { + pub(crate) lookups: u64, + pub(crate) hits: u64, + pub(crate) body_runs: u64, + pub(crate) records_created: u64, + pub(crate) caller_edges_written: u64, + pub(crate) occurrence_slots_written: u64, + pub(crate) child_slots_visited: u64, + pub(crate) source_path_steps_copied: u64, + pub(crate) use_path_steps_copied: u64, + pub(crate) dirty_seeds: u64, + pub(crate) dirty_prefixes_visited: u64, + pub(crate) dirty_addresses_inserted: u64, + pub(crate) topology_lookups: u64, + pub(crate) incoming_uses_visited: u64, + pub(crate) height_queries: u64, +} + +thread_local! { + static WORK: Cell = Cell::new(EvalWork::default()); +} + +impl EvalWork { + pub(crate) fn accumulate(&mut self, other: Self) { + macro_rules! add { + ($($field:ident),+ $(,)?) => { $(self.$field = self.$field.checked_add(other.$field) + .expect("native evaluation work counter overflow");)+ }; + } + add!( + lookups, + hits, + body_runs, + records_created, + caller_edges_written, + occurrence_slots_written, + child_slots_visited, + source_path_steps_copied, + use_path_steps_copied, + dirty_seeds, + dirty_prefixes_visited, + dirty_addresses_inserted, + topology_lookups, + incoming_uses_visited, + height_queries + ); + } +} + +pub(crate) fn reset_work() { + WORK.set(EvalWork::default()); +} +pub(crate) fn work() -> EvalWork { + WORK.get() +} + +fn record(delta: EvalWork) { + let mut work = WORK.get(); + work.accumulate(delta); + WORK.set(work); +} + +pub(super) fn record_height_query() { + record(EvalWork { + height_queries: 1, + ..EvalWork::default() + }); +} + +#[derive(Clone, Debug)] +pub(super) struct SourceView<'a> { + pub(super) node: &'a LayoutNode, + pub(super) address: SourceAddress, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub(super) enum Phase { + FlexMeasure, + FlexMinWidth, + FlexAutoMinContent, + FlexMaxWidth, + FlexBasisContent, + FlexBasisWidth, + ContentIntrinsic, + FlexCrossProbe, + FlexFinal, + WindowFull, + WindowFallback, + WindowChildFull, + WindowChildPartial, +} + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +enum UseStep { + Child(LocalStep), + Phase(Phase), +} + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +struct UseSlot { + path: Vec, + occurrence: usize, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct EvalRequest { + context: LayoutContext, + intrinsic: bool, + size_override: Option, +} + +#[derive(Debug)] +struct EvalRecord { + source: SourceAddress, + request: EvalRequest, + rendered: Rendered, + children: BTreeMap>, +} + +/// Owns the current document and current evaluation tree, never a previous frame. +#[derive(Debug)] +pub(crate) struct RetainedFrame { + document: Arc, + root: Arc, +} + +impl RetainedFrame { + pub(crate) fn materialize_tape(&self) -> LayoutTape { + self.root + .rendered + .clone() + .into_tape(self.document.style_count) + } +} + +#[derive(Debug)] +struct BuildingRecord { + slot: UseSlot, + source: SourceAddress, + request: EvalRequest, + previous: Option>, + children: BTreeMap>, + occurrences: BTreeMap, usize>, +} + +#[derive(Debug)] +struct RenderTxn { + previous: Option>, + dirty: BTreeSet, + stack: Vec, + root: Option>, +} + +impl RenderTxn { + fn begin( + &mut self, + source: &SourceAddress, + path: &[UseStep], + request: EvalRequest, + ) -> Option { + record(EvalWork { + lookups: 1, + ..EvalWork::default() + }); + let (slot, previous) = if let Some(parent) = self.stack.last_mut() { + let occurrence = parent.occurrences.entry(path.to_vec()).or_default(); + let slot = UseSlot { + path: path.to_vec(), + occurrence: *occurrence, + }; + *occurrence += 1; + record(EvalWork { + occurrence_slots_written: 1, + use_path_steps_copied: (path.len() * 2) as u64, + ..EvalWork::default() + }); + let previous = parent + .previous + .as_ref() + .and_then(|old| old.children.get(&slot)) + .cloned(); + (slot, previous) + } else { + ( + UseSlot { + path: Vec::new(), + occurrence: 0, + }, + self.previous.clone(), + ) + }; + if let Some(old) = previous.as_ref().filter(|old| { + old.source == *source && old.request == request && !self.dirty.contains(source) + }) { + record(EvalWork { + hits: 1, + ..EvalWork::default() + }); + let rendered = old.rendered.clone(); + self.attach(slot, Arc::clone(old)); + return Some(rendered); + } + record(EvalWork { + body_runs: 1, + ..EvalWork::default() + }); + self.stack.push(BuildingRecord { + slot, + source: source.clone(), + request, + previous, + children: BTreeMap::new(), + occurrences: BTreeMap::new(), + }); + None + } + + fn finish(&mut self, rendered: &Result) { + let current = self.stack.pop().expect("native evaluation builder stack"); + if let Ok(rendered) = rendered { + record(EvalWork { + records_created: 1, + ..EvalWork::default() + }); + self.attach( + current.slot, + Arc::new(EvalRecord { + source: current.source, + request: current.request, + rendered: rendered.clone(), + children: current.children, + }), + ); + } + } + + fn attach(&mut self, slot: UseSlot, record: Arc) { + if let Some(parent) = self.stack.last_mut() { + record_work_edge(); + assert!( + parent.children.insert(slot, record).is_none(), + "unique caller use slot" + ); + } else { + self.root = Some(record); + } + } +} + +fn record_work_edge() { + record(EvalWork { + caller_edges_written: 1, + ..EvalWork::default() + }); +} + +/// Explicit source/callsite context. Interior mutability is transaction-local; +/// no borrow of the builder stack spans a recursive renderer call. +#[derive(Debug)] +pub(super) struct RenderScope<'a> { + pub(super) view: SourceView<'a>, + pub(super) resolver: Option<&'a RetainedDocument>, + transaction: Option<&'a RefCell>, + path: Vec, +} + +impl Clone for RenderScope<'_> { + fn clone(&self) -> Self { + if self.transaction.is_some() { + record(EvalWork { + use_path_steps_copied: self.path.len() as u64, + ..EvalWork::default() + }); + } + Self { + view: self.view.clone(), + resolver: self.resolver, + transaction: self.transaction, + path: self.path.clone(), + } + } +} + +impl<'a> RenderScope<'a> { + pub(super) fn uncached(node: &'a LayoutNode, resolver: Option<&'a RetainedDocument>) -> Self { + Self { + view: SourceView { + node, + address: SourceAddress { + owner_id: node.node_id().unwrap_or(0), + path: Arc::from([]), + }, + }, + resolver, + transaction: None, + path: Vec::new(), + } + } + + pub(super) fn resolve(&self) -> Result { + let mut resolved = self.clone(); + if let LayoutNode::NodeRef { node_id } = self.view.node { + if let Some(resolver) = self.resolver { + resolved.view = SourceView { + node: resolver.resolve(self.view.node)?, + address: SourceAddress { + owner_id: *node_id, + path: Arc::from([]), + }, + }; + } + } + Ok(resolved) + } + + pub(super) fn child(&self, step: LocalStep, node: &'a LayoutNode) -> Self { + if self.transaction.is_none() { + return Self::uncached(node, self.resolver); + } + let mut address = self.view.address.path.to_vec(); + address.push(step); + let mut path = self.path.clone(); + path.push(UseStep::Child(step)); + if self.transaction.is_some() { + record(EvalWork { + child_slots_visited: 1, + source_path_steps_copied: address.len() as u64, + use_path_steps_copied: path.len() as u64, + ..EvalWork::default() + }); + } + Self { + view: SourceView { + node, + address: SourceAddress { + owner_id: self.view.address.owner_id, + path: address.into(), + }, + }, + resolver: self.resolver, + transaction: self.transaction, + path, + } + } + + pub(super) fn phase(&self, phase: Phase) -> Self { + if self.transaction.is_none() { + return self.clone(); + } + let mut scoped = self.clone(); + scoped.path.push(UseStep::Phase(phase)); + if self.transaction.is_some() { + record(EvalWork { + use_path_steps_copied: 1, + ..EvalWork::default() + }); + } + scoped + } + + pub(super) fn render( + &self, + context: LayoutContext, + intrinsic: bool, + size_override: Option, + ) -> Result { + let source = self.resolve()?; + if let Some(transaction) = source.transaction { + let request = EvalRequest { + context, + intrinsic, + size_override, + }; + if let Some(rendered) = + transaction + .borrow_mut() + .begin(&source.view.address, &source.path, request) + { + return Ok(rendered); + } + } + // Descendant slots are local to this caller, even on a parent request miss. + let body_scope = Self { + path: Vec::new(), + ..source + }; + let rendered = super::render_node_body(&body_scope, context, intrinsic, size_override); + if let Some(transaction) = body_scope.transaction { + transaction.borrow_mut().finish(&rendered); + } + rendered + } +} + +fn dirty_sources(document: &RetainedDocument, changes: &SourceChanges) -> BTreeSet { + fn mark(address: SourceAddress, dirty: &mut BTreeSet, owners: &mut Vec) { + for length in (0..=address.path.len()).rev() { + record(EvalWork { + dirty_prefixes_visited: 1, + source_path_steps_copied: length as u64, + ..EvalWork::default() + }); + let prefix = SourceAddress { + owner_id: address.owner_id, + path: Arc::from(&address.path[..length]), + }; + if dirty.insert(prefix) { + record(EvalWork { + dirty_addresses_inserted: 1, + ..EvalWork::default() + }); + if length == 0 { + owners.push(address.owner_id); + } + } + } + } + let mut dirty = BTreeSet::new(); + let mut owners = Vec::new(); + changes.visit_changed_slots(|owner_id, _, _, slot, _| { + record(EvalWork { + dirty_seeds: 1, + ..EvalWork::default() + }); + let path: Arc<[LocalStep]> = match slot { + 0 => Arc::from([]), + 1 => Arc::from([LocalStep::BoxChild]), + _ => unreachable!("validated native source slot"), + }; + mark(SourceAddress { owner_id, path }, &mut dirty, &mut owners); + }); + while let Some(owner) = owners.pop() { + record(EvalWork { + topology_lookups: 1, + ..EvalWork::default() + }); + for incoming in document.topology.incoming_uses(owner) { + record(EvalWork { + incoming_uses_visited: 1, + ..EvalWork::default() + }); + let mut path = incoming.parent.path.to_vec(); + path.push(incoming.child_slot); + record(EvalWork { + source_path_steps_copied: path.len() as u64, + ..EvalWork::default() + }); + mark( + SourceAddress { + owner_id: incoming.parent.owner_id, + path: path.into(), + }, + &mut dirty, + &mut owners, + ); + } + } + dirty +} + +impl RetainedDocument { + pub(crate) fn render_frame( + self: &Arc, + previous: Option<&RetainedFrame>, + changes: Option<&SourceChanges>, + context: LayoutContext, + root_width_override: Option, + ) -> Result, String> { + let reusable = match (previous, changes) { + (Some(previous), Some(changes)) => { + if !changes.applies_to(&previous.document, self) { + return Err( + "Native retained frame source changes do not match the document pair" + .to_owned(), + ); + } + Some(previous) + } + (Some(previous), None) if Arc::ptr_eq(&previous.document, self) => Some(previous), + (None, Some(changes)) if !Arc::ptr_eq(&changes.target, self) => { + return Err( + "Native retained frame source changes do not match the target document" + .to_owned(), + ); + } + _ => None, + }; + let dirty = match (reusable, changes) { + (Some(_), Some(changes)) => dirty_sources(self, changes), + _ => BTreeSet::new(), + }; + let transaction = RefCell::new(RenderTxn { + previous: reusable.map(|previous| Arc::clone(&previous.root)), + dirty, + stack: Vec::new(), + root: None, + }); + let root = self + .effective_node(self.root_id) + .ok_or_else(|| "Native retained document lost its root".to_owned())?; + super::RESOLVER_LOOKUP_COUNT.with(|count| count.set(count.get().saturating_add(1))); + if root_width_override.is_some() && !matches!(root, LayoutNode::Box { .. }) { + return Err("Native root width override requires a box root".to_owned()); + } + let scope = RenderScope { + view: SourceView { + node: root, + address: SourceAddress { + owner_id: self.root_id, + path: Arc::from([]), + }, + }, + resolver: Some(self), + transaction: Some(&transaction), + path: Vec::new(), + }; + scope.render( + context, + false, + root_width_override.map(|declared_width| BoxOverride { + declared_width: Some(declared_width), + ..BoxOverride::default() + }), + )?; + let root = transaction + .into_inner() + .root + .expect("native retained root evaluation"); + Ok(Arc::new(RetainedFrame { + document: Arc::clone(self), + root, + })) + } +} + +#[cfg(test)] +#[path = "evaluation_tests.rs"] +mod tests; diff --git a/native/src/evaluation_tests.rs b/native/src/evaluation_tests.rs new file mode 100644 index 0000000..9ec2206 --- /dev/null +++ b/native/src/evaluation_tests.rs @@ -0,0 +1,1028 @@ +use super::super::tests::{ + child_box, context_test_delta, identified, nonuniform_text, retained_document, test_context, +}; +use super::super::{FlexAlign, FlexDirection, FlexItem, FlexWrap, Size, WrapMode}; +use super::*; + +fn flex(nodes: Vec, direction: FlexDirection, wrap: FlexWrap) -> LayoutNode { + LayoutNode::Flex { + node_id: None, + node_revision: None, + direction, + wrap, + justify: FlexAlign::FlexStart, + align_items: FlexAlign::FlexStart, + align_content: FlexAlign::FlexStart, + width: Size::Pixels { value: 12 }, + height: Size::Auto, + row_gap: 1, + column_gap: 1, + items: Arc::new( + nodes + .into_iter() + .enumerate() + .map(|(index, node)| FlexItem { + node, + order: (index % 2) as i64, + grow: 0.0, + shrink: 1.0, + basis: Size::Pixels { value: 3 }, + align_self: FlexAlign::Auto, + }) + .collect(), + ), + } +} + +fn row_id(index: usize) -> u64 { + index as u64 * 100 + 10 +} + +fn mixed(size: usize) -> Arc { + let rows = (0..size) + .map(|index| { + let id = row_id(index); + let nodes = (0..3) + .map(|item| { + let item_id = id + 20 + item; + let mut node = child_box( + item_id as i64, + nonuniform_text(item_id as i64 + 100_000, &[1]), + None, + ); + let LayoutNode::Box { wrap_mode, .. } = &mut node else { + unreachable!() + }; + *wrap_mode = WrapMode::Word; + identified(node, item_id, 1) + }) + .collect(); + identified( + LayoutNode::Row { + node_id: None, + node_revision: None, + children: Arc::new(vec![ + identified( + child_box( + id as i64 + 1, + nonuniform_text(id as i64 + 100_001, &[1]), + None, + ), + id + 1, + 1, + ), + identified( + flex(nodes, FlexDirection::RowReverse, FlexWrap::Wrap), + id + 2, + 1, + ), + ]), + }, + id, + 1, + ) + }) + .collect(); + let root = identified( + child_box( + 1, + LayoutNode::Column { + node_id: None, + node_revision: None, + children: Arc::new(rows), + }, + None, + ), + 1, + 1, + ); + RetainedDocument::bootstrap(retained_document(root)) + .unwrap() + .0 +} + +fn text_update(owner: u64, before: u64, slot: u8, text: &str, width: i64) -> serde_json::Value { + serde_json::json!({"node-id": owner, "expected-revision": before, "target-revision": before + 1, + "slot-patches": [{"slot": slot, "local": {"content": {"lines": [{"clusters": [{ + "text": text, "width": width, "cjk": false, "space": false, + }]}]}}}]}) +} + +fn records(root: &Arc) -> Vec<&Arc> { + let mut output = vec![root]; + for child in root.children.values() { + output.extend(records(child)); + } + output +} + +fn owner_record(root: &Arc, owner: u64) -> &Arc { + records(root) + .into_iter() + .find(|record| record.source.owner_id == owner && record.source.path.is_empty()) + .expect("fixture owner has a current evaluation") +} + +fn assert_oracle(frame: &RetainedFrame, context: LayoutContext, root_width: Option) { + assert_eq!( + frame.materialize_tape(), + frame.document.layout_tape(context, root_width).unwrap() + ); +} + +#[test] +fn instrumentation_records_common_entries_without_changing_full_semantics() { + let document = mixed(3); + reset_work(); + let frame = document + .render_frame(None, None, test_context(), None) + .unwrap(); + let measured = work(); + assert_eq!(measured.hits, 0); + assert_eq!(measured.lookups, measured.body_runs); + assert_eq!( + measured.records_created as usize, + records(&frame.root).len() + ); + assert_eq!(measured.caller_edges_written + 1, measured.records_created); + assert_oracle(&frame, test_context(), None); + let all = records(&frame.root); + assert!(all + .iter() + .any(|record| matches!(record.source.path.as_ref(), [LocalStep::BoxChild]))); + let phases = all + .iter() + .flat_map(|record| record.children.keys()) + .flat_map(|slot| slot.path.iter()) + .filter_map(|step| match step { + UseStep::Phase(phase) => Some(*phase), + _ => None, + }) + .collect::>(); + for phase in [ + Phase::FlexMeasure, + Phase::FlexMinWidth, + Phase::FlexAutoMinContent, + Phase::FlexMaxWidth, + Phase::FlexBasisContent, + Phase::FlexBasisWidth, + Phase::ContentIntrinsic, + Phase::FlexCrossProbe, + Phase::FlexFinal, + ] { + assert!(phases.contains(&phase), "missing phase {phase:?}"); + } + let flex_record = owner_record(&frame.root, row_id(0) + 2); + let original_positions = flex_record + .children + .keys() + .filter_map(|slot| { + slot.path.iter().find_map(|step| match step { + UseStep::Child(LocalStep::FlexItem(index)) => Some(*index), + _ => None, + }) + }) + .collect::>(); + assert_eq!(original_positions, BTreeSet::from([0, 1, 2])); + // The cache-disabled oracle builds neither records nor caller/source paths. + reset_work(); + document.layout_tape(test_context(), None).unwrap(); + assert_eq!(work().records_created, 0); + assert_eq!(work().child_slots_visited, 0); + assert_eq!(work().source_path_steps_copied, 0); + assert_eq!(work().use_path_steps_copied, 0); +} + +#[test] +fn instrumentation_preserves_literal_column_paths_and_node_ref_boundaries() { + let nested = LayoutNode::Column { + node_id: None, + node_revision: None, + children: Arc::new(vec![ + nonuniform_text(2, &[2, 9]), + identified( + LayoutNode::Column { + node_id: None, + node_revision: None, + children: Arc::new(vec![nonuniform_text(3, &[5]), nonuniform_text(4, &[1])]), + }, + 2, + 1, + ), + ]), + }; + let root = identified( + child_box( + 1, + LayoutNode::Column { + node_id: None, + node_revision: None, + children: Arc::new(vec![nested, nonuniform_text(5, &[8])]), + }, + None, + ), + 1, + 1, + ); + let document = RetainedDocument::bootstrap(retained_document(root)) + .unwrap() + .0; + let frame = document + .render_frame(None, None, test_context(), None) + .unwrap(); + assert_oracle(&frame, test_context(), None); + let anonymous = records(&frame.root) + .into_iter() + .find(|record| { + record.source.owner_id == 1 + && record.source.path.as_ref() + == [ + LocalStep::BoxChild, + LocalStep::ColumnChild(0), + LocalStep::ColumnChild(0), + ] + }) + .unwrap(); + assert_eq!(anonymous.rendered.height(), 2); + let identified = owner_record(&frame.root, 2); + assert_eq!(identified.children.len(), 2); + assert!(identified + .children + .values() + .all(|record| record.source.owner_id == 2)); +} + +#[test] +fn nonzero_mixed_leaf_delta_reuses_unchanged_subtree_bodies() { + let document = mixed(8); + let baseline = document + .render_frame(None, None, test_context(), None) + .unwrap(); + let update = document + .apply_delta(context_test_delta(vec![text_update( + row_id(3) + 1, + 1, + 1, + "y", + 1, + )])) + .unwrap(); + reset_work(); + super::super::TEST_RENDER_NODE_COUNT.with(|count| count.set(Some(0))); + let target = update + .document + .render_frame(Some(&baseline), Some(&update.changes), test_context(), None) + .unwrap(); + let measured = work(); + let actual_bodies = + super::super::TEST_RENDER_NODE_COUNT.with(|count| count.replace(None).unwrap()); + assert_eq!(actual_bodies as u64, measured.body_runs); + assert_oracle(&target, test_context(), None); + assert!( + Arc::ptr_eq( + owner_record(&baseline.root, row_id(7)), + owner_record(&target.root, row_id(7)) + ), + "unchanged row record must be shared across the nonzero delta: {measured:?}" + ); + assert!(!Arc::ptr_eq( + owner_record(&baseline.root, row_id(3)), + owner_record(&target.root, row_id(3)) + )); + assert_eq!( + measured.body_runs, 5, + "only root Box/Column, changed Row/Box/Text bodies run" + ); + assert_eq!( + measured.hits, 8, + "seven unchanged rows plus the changed row's unchanged Flex" + ); + assert_eq!(measured.records_created, 5); +} + +#[test] +fn fixed_changed_subtree_reuses_bodies_across_sibling_sizes() { + for size in [32, 128, 512] { + let document = mixed(size); + let baseline = document + .render_frame(None, None, test_context(), None) + .unwrap(); + for (offset, text) in [(1, "y"), (20, "longer")] { + let update = document + .apply_delta(context_test_delta(vec![text_update( + row_id(size / 2) + offset, + 1, + 1, + text, + 1, + )])) + .unwrap(); + reset_work(); + super::super::reset_line_plan_work(); + super::super::TEST_RENDER_NODE_COUNT.with(|count| count.set(Some(0))); + let target = update + .document + .render_frame(Some(&baseline), Some(&update.changes), test_context(), None) + .unwrap(); + let actual = + super::super::TEST_RENDER_NODE_COUNT.with(|count| count.replace(None).unwrap()); + let measured = work(); + let line_work = super::super::line_plan_work(); + assert_eq!(measured.body_runs as usize, actual); + assert!( + measured.body_runs <= 20, + "N={size}, offset={offset}: {measured:?}" + ); + assert!(measured.hits >= size as u64 - 1); + assert!( + measured.caller_edges_written >= size as u64, + "parent sibling edge writes remain N" + ); + assert!( + line_work.lines_mapped >= size as u64, + "parent line maps remain explicit N work" + ); + assert_eq!(measured.dirty_seeds, 1); + assert!(measured.dirty_addresses_inserted <= 9); + assert!(Arc::ptr_eq( + owner_record(&baseline.root, row_id(size - 1)), + owner_record(&target.root, row_id(size - 1)) + )); + assert_oracle(&target, test_context(), None); + eprintln!( + "N={size}, changed_offset={offset}, eval={measured:?}, lines_mapped={}", + line_work.lines_mapped + ); + } + } +} + +#[test] +fn parent_and_child_source_updates_share_only_clean_descendants() { + let document = mixed(4); + let baseline = document + .render_frame(None, None, test_context(), None) + .unwrap(); + let update = document + .apply_delta(context_test_delta(vec![ + serde_json::json!({"node-id":1,"expected-revision":1,"target-revision":2, + "slot-patches":[{"slot":0,"local":{"width":{"kind":"pixels","value":80}}}]}), + text_update(row_id(1) + 20, 1, 1, "changed", 2), + ])) + .unwrap(); + let target = update + .document + .render_frame(Some(&baseline), Some(&update.changes), test_context(), None) + .unwrap(); + assert_oracle(&target, test_context(), None); + assert!(Arc::ptr_eq( + owner_record(&baseline.root, row_id(3)), + owner_record(&target.root, row_id(3)) + )); + assert!(!Arc::ptr_eq( + owner_record(&baseline.root, row_id(1) + 20), + owner_record(&target.root, row_id(1) + 20) + )); +} + +#[test] +fn exact_document_pair_authority_rejects_mismatch_and_cold_renders_without_proof() { + let document = mixed(2); + let baseline = document + .render_frame(None, None, test_context(), None) + .unwrap(); + let update = document + .apply_delta(context_test_delta(vec![text_update( + row_id(0) + 1, + 1, + 1, + "y", + 1, + )])) + .unwrap(); + reset_work(); + let cold = update + .document + .render_frame(Some(&baseline), None, test_context(), None) + .unwrap(); + assert_eq!(work().hits, 0); + assert!(!Arc::ptr_eq( + owner_record(&baseline.root, row_id(1)), + owner_record(&cold.root, row_id(1)) + )); + assert_oracle(&cold, test_context(), None); + let foreign = mixed(2); + let foreign_frame = foreign + .render_frame(None, None, test_context(), None) + .unwrap(); + for (target, previous) in [(&document, &baseline), (&update.document, &foreign_frame)] { + assert!(target + .render_frame(Some(previous), Some(&update.changes), test_context(), None) + .is_err()); + } + assert!(foreign + .render_frame(None, Some(&update.changes), test_context(), None) + .is_err()); + assert!(update + .document + .render_frame(None, Some(&update.changes), test_context(), None) + .is_ok()); +} + +#[test] +fn revision_and_registry_only_changes_reuse_output_and_release_old_document() { + let document = mixed(1); + let old_document = Arc::downgrade(&document); + let baseline = document + .render_frame(None, None, test_context(), None) + .unwrap(); + let delta = serde_json::from_value(serde_json::json!({ + "style-base-count":0,"styles-append":[{"mode":"set","face":{"foreground":"red"}}], + "property-template-base-count":0,"property-template-target-count":1, + "entries":[{"node-id":1,"expected-revision":1,"target-revision":2}], + })) + .unwrap(); + let update = document.apply_delta(delta).unwrap(); + reset_work(); + let target = update + .document + .render_frame(Some(&baseline), Some(&update.changes), test_context(), None) + .unwrap(); + assert_eq!( + (work().hits, work().body_runs, work().records_created), + (1, 0, 0) + ); + assert!(Arc::ptr_eq(&baseline.root, &target.root)); + assert_eq!(target.materialize_tape().style_count, 1); + assert_oracle(&target, test_context(), None); + drop(update); + drop(document); + assert!( + old_document.upgrade().is_some(), + "the old frame still owns its document" + ); + drop(baseline); + assert!( + old_document.upgrade().is_none(), + "shared evaluation records must not retain old documents" + ); + assert_eq!(target.document.style_count, 1); +} + +fn render_request( + document: &Arc, + previous: Option<&RetainedFrame>, + context: LayoutContext, + intrinsic: bool, + size_override: Option, +) -> Arc { + let transaction = RefCell::new(RenderTxn { + previous: previous.map(|frame| Arc::clone(&frame.root)), + dirty: BTreeSet::new(), + stack: Vec::new(), + root: None, + }); + let scope = RenderScope { + view: SourceView { + node: document.effective_node(document.root_id).unwrap(), + address: SourceAddress { + owner_id: document.root_id, + path: Arc::from([]), + }, + }, + resolver: Some(document), + transaction: Some(&transaction), + path: Vec::new(), + }; + let rendered = scope.render(context, intrinsic, size_override).unwrap(); + let oracle = super::super::render_node_with_override( + scope.view.node, + Some(document), + context, + intrinsic, + size_override, + ) + .unwrap(); + assert_eq!( + rendered.into_tape(document.style_count), + oracle.into_tape(document.style_count) + ); + Arc::new(RetainedFrame { + document: Arc::clone(document), + root: transaction.into_inner().root.unwrap(), + }) +} + +#[test] +fn requests_preserve_all_context_intrinsic_and_override_fields_exactly() { + let document = mixed(1); + let context = test_context(); + let baseline = render_request(&document, None, context, false, None); + let requests = [ + ( + LayoutContext { + viewport_width: 79, + ..context + }, + false, + None, + ), + ( + LayoutContext { + viewport_width_known: false, + ..context + }, + false, + None, + ), + ( + LayoutContext { + viewport_height: 23, + ..context + }, + false, + None, + ), + ( + LayoutContext { + inline_auto_width_intrinsic: true, + ..context + }, + false, + None, + ), + (context, true, None), + (context, false, Some(BoxOverride::default())), + ( + context, + false, + Some(BoxOverride { + content_width: Some(80), + ..BoxOverride::default() + }), + ), + ( + context, + false, + Some(BoxOverride { + content_height: Some(2), + ..BoxOverride::default() + }), + ), + ( + context, + false, + Some(BoxOverride { + declared_width: Some(80), + ..BoxOverride::default() + }), + ), + ]; + for (context, intrinsic, size_override) in requests { + let changed = render_request( + &document, + Some(&baseline), + context, + intrinsic, + size_override, + ); + assert!(!Arc::ptr_eq(&baseline.root, &changed.root)); + let repeated = render_request(&document, Some(&changed), context, intrinsic, size_override); + assert!(Arc::ptr_eq(&changed.root, &repeated.root)); + } +} + +#[test] +fn viewport_a_b_a_prunes_old_records_and_has_no_history_chain() { + let document = mixed(2); + let context_a = test_context(); + let context_b = LayoutContext { + viewport_height: 42, + ..context_a + }; + let a = document.render_frame(None, None, context_a, None).unwrap(); + let old_root = Arc::downgrade(&a.root); + let old_leaf = Arc::downgrade(owner_record(&a.root, row_id(0) + 20)); + let b = document + .render_frame(Some(&a), None, context_b, None) + .unwrap(); + let count = records(&b.root).len(); + drop(a); + assert!(old_root.upgrade().is_none()); + assert!(old_leaf.upgrade().is_none()); + let weak_b = Arc::downgrade(&b.root); + let current = document + .render_frame(Some(&b), None, context_a, None) + .unwrap(); + drop(b); + assert!(weak_b.upgrade().is_none()); + assert_eq!(records(¤t.root).len(), count); + assert_oracle(¤t, context_a, None); +} + +fn scroll_document() -> Arc { + let children = (2..6) + .map(|id| { + let mut node = child_box(id, nonuniform_text(id + 100, &[1, 1, 1]), None); + let LayoutNode::Box { height, .. } = &mut node else { + unreachable!() + }; + *height = Size::Lines { value: 3 }; + identified(node, id as u64, 1) + }) + .collect(); + let mut root = child_box( + 1, + LayoutNode::Column { + node_id: None, + node_revision: None, + children: Arc::new(children), + }, + None, + ); + let LayoutNode::Box { + width, + height, + content_width_exact, + scroll_offset, + .. + } = &mut root + else { + unreachable!() + }; + *width = Size::Viewport; + *height = Size::Lines { value: 2 }; + *content_width_exact = false; + *scroll_offset = 4; + RetainedDocument::bootstrap(retained_document(identified(root, 1, 1))) + .unwrap() + .0 +} + +#[test] +fn unrendered_height_dependencies_invalidate_scroll_and_windows_cache_only_complete_values() { + let document = scroll_document(); + let baseline = document + .render_frame(None, None, test_context(), None) + .unwrap(); + assert_oracle(&baseline, test_context(), None); + assert!(!records(&baseline.root) + .iter() + .any(|record| record.source.owner_id == 2)); + assert_eq!( + owner_record(&baseline.root, 3).rendered.height(), + 3, + "the partially visible child is retained as a complete render" + ); + 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(); + reset_work(); + let target = update + .document + .render_frame(Some(&baseline), Some(&update.changes), test_context(), None) + .unwrap(); + assert!(work().body_runs > 0); + assert!(work().incoming_uses_visited > 0); + assert!(work().height_queries > 0); + assert_oracle(&target, test_context(), None); + assert_ne!(baseline.materialize_tape(), target.materialize_tape()); + assert!( + Arc::ptr_eq( + owner_record(&baseline.root, 3), + owner_record(&target.root, 3) + ), + "reuse a complete child while selecting a different visible slice" + ); + let expanded = update.document.apply_delta(context_test_delta(vec![serde_json::json!({ + "node-id":1,"expected-revision":1,"target-revision":2, + "slot-patches":[{"slot":0,"local":{"height":{"kind":"lines","value":20},"scroll-offset":0}}] + })])).unwrap(); + let full = expanded + .document + .render_frame(Some(&target), Some(&expanded.changes), test_context(), None) + .unwrap(); + assert_oracle(&full, test_context(), None); + let column = full + .root + .children + .values() + .find(|record| record.source.path.as_ref() == [LocalStep::BoxChild]) + .unwrap(); + assert_eq!(column.rendered.height(), 14); + let old_column = Arc::downgrade(column); + let collapsed = expanded.document.apply_delta(context_test_delta(vec![serde_json::json!({ + "node-id":1,"expected-revision":2,"target-revision":3, + "slot-patches":[{"slot":0,"local":{"height":{"kind":"lines","value":2},"scroll-offset":4}}] + })])).unwrap(); + let window = collapsed + .document + .render_frame(Some(&full), Some(&collapsed.changes), test_context(), None) + .unwrap(); + assert_oracle(&window, test_context(), None); + assert!(!window + .root + .children + .values() + .any(|record| record.source.path.as_ref() == [LocalStep::BoxChild])); + drop(full); + assert!( + old_column.upgrade().is_none(), + "the old full-column use must be pruned" + ); +} + +#[test] +fn nested_reversed_wrapped_flex_preserves_distinct_uses_on_geometry_changes() { + let inner = flex( + (11..14) + .map(|id| { + identified( + child_box(id, nonuniform_text(id + 100, &[1]), None), + id as u64, + 1, + ) + }) + .collect(), + FlexDirection::RowReverse, + FlexWrap::WrapReverse, + ); + let outer = flex( + vec![ + identified(child_box(10, inner, None), 10, 1), + identified(child_box(20, nonuniform_text(120, &[1]), None), 20, 1), + ], + FlexDirection::ColumnReverse, + FlexWrap::WrapReverse, + ); + let document = RetainedDocument::bootstrap(retained_document(identified( + child_box(1, outer, None), + 1, + 1, + ))) + .unwrap() + .0; + let baseline = document + .render_frame(None, None, test_context(), None) + .unwrap(); + let update = document + .apply_delta(context_test_delta(vec![text_update( + 12, 1, 1, "expanded", 7, + )])) + .unwrap(); + reset_work(); + let target = update + .document + .render_frame(Some(&baseline), Some(&update.changes), test_context(), None) + .unwrap(); + assert!(work().hits > 0); + assert_oracle(&target, test_context(), None); + assert!(Arc::ptr_eq( + owner_record(&baseline.root, 20), + owner_record(&target.root, 20) + )); + assert!( + records(&target.root) + .iter() + .filter(|record| record.source.owner_id == 12 && record.source.path.is_empty()) + .count() + > 1, + "a Flex owner has multiple current evaluation uses" + ); +} + +fn repeated_calls( + document: &Arc, + previous: Option<&RetainedFrame>, + widths: &[i64], +) -> Arc { + let source = SourceAddress { + owner_id: document.root_id, + path: Arc::from([]), + }; + let transaction = RefCell::new(RenderTxn { + previous: previous.map(|frame| Arc::clone(&frame.root)), + dirty: BTreeSet::from([source.clone()]), + stack: Vec::new(), + root: None, + }); + let node = document.effective_node(document.root_id).unwrap(); + let scope = RenderScope { + view: SourceView { + node, + address: source.clone(), + }, + resolver: Some(document), + transaction: Some(&transaction), + path: Vec::new(), + }; + let request = EvalRequest { + context: test_context(), + intrinsic: false, + size_override: None, + }; + assert!(transaction + .borrow_mut() + .begin(&source, &[], request) + .is_none()); + let LayoutNode::Box { + child: Some(child), .. + } = node + else { + unreachable!() + }; + let child_scope = scope + .child(LocalStep::BoxChild, child) + .phase(Phase::ContentIntrinsic); + let parts = widths + .iter() + .map(|width| { + child_scope + .render( + LayoutContext { + viewport_width: *width, + ..test_context() + }, + true, + None, + ) + .unwrap() + }) + .collect(); + transaction + .borrow_mut() + .finish(&Ok(super::super::stack_vertical(parts))); + Arc::new(RetainedFrame { + document: Arc::clone(document), + root: transaction.into_inner().root.unwrap(), + }) +} + +#[test] +fn repeated_callsite_uses_have_local_occurrences_and_prune_unused_contexts() { + let document = RetainedDocument::bootstrap(retained_document(identified( + child_box(1, nonuniform_text(2, &[1]), None), + 1, + 1, + ))) + .unwrap() + .0; + let baseline = repeated_calls(&document, None, &[10, 20]); + let keys = baseline.root.children.keys().collect::>(); + assert_eq!(keys[0].path, keys[1].path); + assert_eq!((keys[0].occurrence, keys[1].occurrence), (0, 1)); + let removed = Arc::downgrade(&baseline.root.children[keys[1]]); + reset_work(); + let one = repeated_calls(&document, Some(&baseline), &[10]); + assert_eq!(work().hits, 1); + assert_eq!(one.root.children.len(), 1); + drop(baseline); + assert!(removed.upgrade().is_none()); + reset_work(); + let changed = repeated_calls(&document, Some(&one), &[20]); + assert_eq!( + work().hits, + 0, + "a discarded second occurrence is not a historical context cache" + ); + assert_eq!(changed.root.children.len(), 1); +} + +#[test] +fn independent_nonzero_forks_share_clean_records_without_retaining_old_documents() { + let document = mixed(3); + let old_document = Arc::downgrade(&document); + let baseline = document + .render_frame(None, None, test_context(), None) + .unwrap(); + let left_update = document + .apply_delta(context_test_delta(vec![text_update( + row_id(0) + 1, + 1, + 1, + "left", + 1, + )])) + .unwrap(); + let right_update = document + .apply_delta(context_test_delta(vec![text_update( + row_id(1) + 1, + 1, + 1, + "right", + 1, + )])) + .unwrap(); + let left = left_update + .document + .render_frame( + Some(&baseline), + Some(&left_update.changes), + test_context(), + None, + ) + .unwrap(); + let right = right_update + .document + .render_frame( + Some(&baseline), + Some(&right_update.changes), + test_context(), + None, + ) + .unwrap(); + assert_oracle(&left, test_context(), None); + assert_oracle(&right, test_context(), None); + assert!(Arc::ptr_eq( + owner_record(&left.root, row_id(2)), + owner_record(&right.root, row_id(2)) + )); + assert!(!Arc::ptr_eq( + owner_record(&left.root, row_id(0)), + owner_record(&right.root, row_id(0)) + )); + let old_changed = Arc::downgrade(owner_record(&baseline.root, row_id(0))); + drop(left_update); + drop(right_update); + drop(baseline); + drop(document); + assert!(old_document.upgrade().is_none()); + assert!( + old_changed.upgrade().is_some(), + "the independent right fork still shares this row" + ); + drop(right); + assert!(old_changed.upgrade().is_none()); + assert_oracle(&left, test_context(), None); +} + +#[test] +fn unwinding_discards_current_builder_records_without_mutating_previous_frame() { + let document = mixed(1); + let baseline = document + .render_frame(None, None, test_context(), None) + .unwrap(); + let baseline_tape = baseline.materialize_tape(); + let created = RefCell::new(None); + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let source = baseline.root.source.clone(); + let transaction = RefCell::new(RenderTxn { + previous: Some(Arc::clone(&baseline.root)), + dirty: BTreeSet::from([source.clone()]), + stack: Vec::new(), + root: None, + }); + let node = document.effective_node(document.root_id).unwrap(); + let scope = RenderScope { + view: SourceView { + node, + address: source.clone(), + }, + resolver: Some(&document), + transaction: Some(&transaction), + path: Vec::new(), + }; + transaction + .borrow_mut() + .begin(&source, &[], baseline.root.request); + let LayoutNode::Box { + child: Some(child), .. + } = node + else { + unreachable!() + }; + scope + .child(LocalStep::BoxChild, child) + .render( + LayoutContext { + viewport_height: 99, + ..test_context() + }, + false, + None, + ) + .unwrap(); + *created.borrow_mut() = Some(Arc::downgrade( + transaction + .borrow() + .stack + .last() + .unwrap() + .children + .values() + .next() + .unwrap(), + )); + panic!("injected caller failure after child evaluation"); + })); + assert!(result.is_err()); + assert!(created.into_inner().unwrap().upgrade().is_none()); + assert_eq!(baseline.materialize_tape(), baseline_tape); + let retry = document + .render_frame(Some(&baseline), None, test_context(), None) + .unwrap(); + assert!(Arc::ptr_eq(&retry.root, &baseline.root)); +} diff --git a/native/src/layout.rs b/native/src/layout.rs index cecad8b..6ce1c2c 100644 --- a/native/src/layout.rs +++ b/native/src/layout.rs @@ -36,6 +36,18 @@ pub(crate) fn line_plan_work() -> LinePlanWork { line_plan::work() } +#[path = "evaluation.rs"] +mod evaluation; +pub(crate) use evaluation::{EvalWork, RetainedFrame}; +use evaluation::{Phase, RenderScope}; + +pub(crate) fn reset_eval_work() { + evaluation::reset_work(); +} +pub(crate) fn eval_work() -> EvalWork { + evaluation::work() +} + fn deserialize_arc_vec<'de, D, T>(deserializer: D) -> Result>, D::Error> where D: Deserializer<'de>, @@ -5500,6 +5512,7 @@ enum FlexMode { #[derive(Debug, Clone)] struct FlexRuntimeItem<'a> { source: &'a LayoutNode, + scope: RenderScope<'a>, grow: f64, shrink: f64, align_self: FlexAlign, @@ -5610,7 +5623,7 @@ fn box_vertical_side(node: &LayoutNode) -> Option { fn box_content_intrinsics( node: &LayoutNode, - resolver: Option<&RetainedDocument>, + scope: &RenderScope<'_>, context: LayoutContext, ) -> Result, String> { let LayoutNode::Box { @@ -5633,7 +5646,10 @@ fn box_content_intrinsics( // current inline viewport. Treating that width as unknown makes // responsive descendants collapse to their narrow intrinsic form and // produces a different automatic minimum from the visible renderer. - let rendered = render_node(child, resolver, context, true)?; + let rendered = scope + .child(LocalStep::BoxChild, child) + .phase(Phase::ContentIntrinsic) + .render(context, true, None)?; Ok(Some(( content_min_width.unwrap_or_else(|| rendered.min_content_width(*wrap_mode)), rendered.max_width(), @@ -5647,7 +5663,7 @@ fn flex_box_resolve_width( node: &LayoutNode, size: &Size, fallback: Option, - resolver: Option<&RetainedDocument>, + scope: &RenderScope<'_>, context: LayoutContext, ) -> Result, String> { let LayoutNode::Box { @@ -5662,7 +5678,7 @@ fn flex_box_resolve_width( return Ok(fallback); }; let (min_content, max_content) = - box_content_intrinsics(node, resolver, context)?.unwrap_or((0, 0)); + box_content_intrinsics(node, scope, context)?.unwrap_or((0, 0)); let side = box_horizontal_side(node).unwrap_or(0); let stretch = context .viewport_width_known @@ -5711,7 +5727,7 @@ fn flex_min_main( source: &LayoutNode, rendered: &Rendered, axis: FlexAxis, - resolver: Option<&RetainedDocument>, + scope: &RenderScope<'_>, context: LayoutContext, ) -> Result { let LayoutNode::Box { @@ -5730,17 +5746,27 @@ fn flex_min_main( Ok(match axis { FlexAxis::Row => { let side = box_horizontal_side(source).unwrap_or(0); - let declared = - flex_box_resolve_width(source, min_width, Some(0), resolver, context)?.unwrap_or(0); + let declared = flex_box_resolve_width( + source, + min_width, + Some(0), + &scope.phase(Phase::FlexMinWidth), + context, + )? + .unwrap_or(0); if *wrap_mode == WrapMode::None { rendered.max_width().max(side + declared) } else { let content_min = match content_min_width { Some(content_min_width) => *content_min_width, None => { - box_content_intrinsics(source, resolver, context)? - .unwrap_or((0, 0)) - .0 + box_content_intrinsics( + source, + &scope.phase(Phase::FlexAutoMinContent), + context, + )? + .unwrap_or((0, 0)) + .0 } }; side + declared.max(content_min) @@ -5758,7 +5784,7 @@ fn flex_min_main( fn flex_max_main( source: &LayoutNode, axis: FlexAxis, - resolver: Option<&RetainedDocument>, + scope: &RenderScope<'_>, context: LayoutContext, ) -> Result, String> { let LayoutNode::Box { @@ -5770,8 +5796,14 @@ fn flex_max_main( return Ok(None); }; Ok(match axis { - FlexAxis::Row => flex_box_resolve_width(source, max_width, None, resolver, context)? - .map(|value| value + box_horizontal_side(source).unwrap_or(0)), + FlexAxis::Row => flex_box_resolve_width( + source, + max_width, + None, + &scope.phase(Phase::FlexMaxWidth), + context, + )? + .map(|value| value + box_horizontal_side(source).unwrap_or(0)), FlexAxis::Column => flex_box_resolve_height(source, max_height, None, context) .map(|value| value + box_vertical_side(source).unwrap_or(0)), }) @@ -5798,7 +5830,7 @@ fn flex_basis_main( rendered: &Rendered, axis: FlexAxis, basis: &Size, - resolver: Option<&RetainedDocument>, + scope: &RenderScope<'_>, context: LayoutContext, ) -> Result { let rendered_main = match axis { @@ -5811,15 +5843,24 @@ fn flex_basis_main( if matches!(basis, Size::Content) { if axis == FlexAxis::Row && matches!(source, LayoutNode::Box { .. }) { let (_, content_max) = - box_content_intrinsics(source, resolver, context)?.unwrap_or((0, 0)); + box_content_intrinsics(source, &scope.phase(Phase::FlexBasisContent), context)? + .unwrap_or((0, 0)); return Ok(box_horizontal_side(source).unwrap_or(0) + content_max); } return Ok(rendered_main); } if axis == FlexAxis::Row && matches!(source, LayoutNode::Box { .. }) { - let (_, content_max) = box_content_intrinsics(source, resolver, context)?.unwrap_or((0, 0)); - let content = flex_box_resolve_width(source, basis, Some(content_max), resolver, context)? - .unwrap_or(content_max); + let (_, content_max) = + box_content_intrinsics(source, &scope.phase(Phase::FlexBasisContent), context)? + .unwrap_or((0, 0)); + let content = flex_box_resolve_width( + source, + basis, + Some(content_max), + &scope.phase(Phase::FlexBasisWidth), + context, + )? + .unwrap_or(content_max); return Ok(box_horizontal_side(source).unwrap_or(0) + content); } Ok(match axis { @@ -5852,13 +5893,11 @@ fn measure_flex_item<'a>( item: &'a FlexItem, axis: FlexAxis, inline_viewport: Option, - resolver: Option<&'a RetainedDocument>, + scope: RenderScope<'a>, context: LayoutContext, ) -> Result, String> { - let source = match resolver { - Some(resolver) => resolver.resolve(&item.node)?, - None => &item.node, - }; + let scope = scope.resolve()?; + let source = scope.view.node; let uses_inline_viewport = flex_item_uses_inline_viewport(source); let measurement_context = LayoutContext { viewport_width: if uses_inline_viewport { @@ -5870,13 +5909,17 @@ fn measure_flex_item<'a>( viewport_height: context.viewport_height, inline_auto_width_intrinsic: context.inline_auto_width_intrinsic, }; - let rendered = render_node(source, resolver, measurement_context, uses_inline_viewport)?; - let min_main = flex_min_main(source, &rendered, axis, resolver, context)?; - let max_main = flex_max_main(source, axis, resolver, context)?; - let base = flex_basis_main(source, &rendered, axis, &item.basis, resolver, context)?.max(0); + let rendered = + scope + .phase(Phase::FlexMeasure) + .render(measurement_context, uses_inline_viewport, None)?; + let min_main = flex_min_main(source, &rendered, axis, &scope, context)?; + let max_main = flex_max_main(source, axis, &scope, context)?; + let base = flex_basis_main(source, &rendered, axis, &item.basis, &scope, context)?.max(0); let hypothetical = flex_clamp_main(base, min_main, max_main); Ok(FlexRuntimeItem { source, + scope, grow: item.grow, shrink: item.shrink, align_self: item.align_self, @@ -6221,7 +6264,7 @@ fn render_flex_sized_entry( main: i64, cross: Option, container_align: FlexAlign, - resolver: Option<&RetainedDocument>, + phase: Phase, context: LayoutContext, intrinsic: bool, ) -> Result { @@ -6244,13 +6287,10 @@ fn render_flex_sized_entry( inline_auto_width_intrinsic: context.inline_auto_width_intrinsic, }; let override_size = box_override_for_flex(item.source, axis, main, cross, stretch); - let mut rendered = render_node_with_override( - item.source, - resolver, - render_context, - intrinsic, - override_size, - )?; + let mut rendered = item + .scope + .phase(phase) + .render(render_context, intrinsic, override_size)?; match axis { FlexAxis::Row => { rendered = pad_rendered_width(rendered, main, FlexAlign::FlexStart); @@ -6341,6 +6381,7 @@ fn exact_rendered_height( resolver: Option<&RetainedDocument>, context: LayoutContext, ) -> Option { + evaluation::record_height_query(); let node = resolver .and_then(|value| value.resolve(node).ok()) .unwrap_or(node); @@ -6391,19 +6432,25 @@ fn exact_rendered_height( } fn render_node_window( - node: &LayoutNode, - resolver: Option<&RetainedDocument>, + scope: &RenderScope<'_>, context: LayoutContext, intrinsic: bool, start: i64, height: i64, ) -> Option> { - let node = resolver - .and_then(|value| value.resolve(node).ok()) - .unwrap_or(node); + let scope = match scope.resolve() { + Ok(scope) => scope, + Err(error) => return Some(Err(error)), + }; + let node = scope.view.node; + let resolver = scope.resolver; let total_height = exact_rendered_height(node, resolver, context)?; if start <= 0 && height >= total_height { - return Some(render_node(node, resolver, context, intrinsic)); + return Some( + scope + .phase(Phase::WindowFull) + .render(context, intrinsic, None), + ); } match node { LayoutNode::Column { children, .. } @@ -6412,19 +6459,21 @@ fn render_node_window( && context.viewport_width_known => { Some(render_column_window( - children, resolver, context, start, height, + children, &scope, context, start, height, )) } _ => Some( - render_node(node, resolver, context, intrinsic) + scope + .phase(Phase::WindowFallback) + .render(context, intrinsic, None) .map(|rendered| slice_rendered(rendered, start, height)), ), } } -fn render_column_window( - children: &[LayoutNode], - resolver: Option<&RetainedDocument>, +fn render_column_window<'a>( + children: &'a [LayoutNode], + scope: &RenderScope<'a>, context: LayoutContext, start: i64, height: i64, @@ -6434,8 +6483,8 @@ fn render_column_window( let mut offset = 0_i64; let mut parts = Vec::new(); - for child in column_leaves(children) { - let child_height = exact_rendered_height(child, resolver, context) + for child in column_leaves(scope, children) { + let child_height = exact_rendered_height(child.view.node, child.resolver, context) .ok_or_else(|| "Native layout column window has an unbounded child".to_owned())?; let child_end = offset.saturating_add(child_height); if child_end <= start { @@ -6449,18 +6498,21 @@ fn render_column_window( let child_start = start.saturating_sub(offset); let child_window_height = (child_end.min(end) - (offset + child_start)).max(0); let mut rendered = if child_start == 0 && child_window_height >= child_height { - render_node(child, resolver, context, false)? + child + .phase(Phase::WindowChildFull) + .render(context, false, None)? } else { render_node_window( - child, - resolver, + &child.phase(Phase::WindowChildPartial), context, false, child_start, child_window_height, ) .unwrap_or_else(|| { - render_node(child, resolver, context, false) + child + .phase(Phase::WindowFallback) + .render(context, false, None) .map(|rendered| slice_rendered(rendered, child_start, child_window_height)) })? }; @@ -6488,7 +6540,6 @@ fn flex_line_cross( line: &[FlexRuntimeItem<'_>], axis: FlexAxis, container_align: FlexAlign, - resolver: Option<&RetainedDocument>, context: LayoutContext, intrinsic: bool, ) -> Result { @@ -6501,7 +6552,7 @@ fn flex_line_cross( item.target, None, container_align, - resolver, + Phase::FlexCrossProbe, context, intrinsic, )? @@ -6558,7 +6609,6 @@ fn render_flex_row_line( main_gap: i64, justify: FlexAlign, align: FlexAlign, - resolver: Option<&RetainedDocument>, context: LayoutContext, intrinsic: bool, ) -> Result { @@ -6573,7 +6623,7 @@ fn render_flex_row_line( item.target, Some(line_cross), align, - resolver, + Phase::FlexFinal, context, intrinsic, )?; @@ -6596,7 +6646,6 @@ fn render_flex_column_line( main_gap: i64, justify: FlexAlign, align: FlexAlign, - resolver: Option<&RetainedDocument>, context: LayoutContext, intrinsic: bool, ) -> Result { @@ -6618,7 +6667,7 @@ fn render_flex_column_line( item.target, Some(line_cross), align, - resolver, + Phase::FlexFinal, context, intrinsic, )? @@ -6645,7 +6694,6 @@ fn render_flex_row( align: FlexAlign, align_content: FlexAlign, single_line: bool, - resolver: Option<&RetainedDocument>, context: LayoutContext, intrinsic: bool, ) -> Result { @@ -6664,7 +6712,7 @@ fn render_flex_row( } else { lines .iter() - .map(|line| flex_line_cross(line, FlexAxis::Row, align, resolver, context, intrinsic)) + .map(|line| flex_line_cross(line, FlexAxis::Row, align, context, intrinsic)) .collect::, _>>()? }; let cross_layout = @@ -6690,7 +6738,6 @@ fn render_flex_row( main_gap, justify, align, - resolver, context, intrinsic, )?); @@ -6721,7 +6768,6 @@ fn render_flex_column( align: FlexAlign, align_content: FlexAlign, single_line: bool, - resolver: Option<&RetainedDocument>, context: LayoutContext, intrinsic: bool, ) -> Result { @@ -6733,9 +6779,7 @@ fn render_flex_column( } else { lines .iter() - .map(|line| { - flex_line_cross(line, FlexAxis::Column, align, resolver, context, intrinsic) - }) + .map(|line| flex_line_cross(line, FlexAxis::Column, align, context, intrinsic)) .collect::, _>>()? }; let cross_layout = @@ -6751,7 +6795,7 @@ fn render_flex_column( .enumerate() { let rendered = render_flex_column_line( - line, line_cross, main_size, main_gap, justify, align, resolver, context, intrinsic, + line, line_cross, main_size, main_gap, justify, align, context, intrinsic, )?; parts.push((rendered, line_cross)); if index + 1 < line_count { @@ -6769,7 +6813,7 @@ fn render_flex_column( } #[allow(clippy::too_many_arguments)] -fn render_flex( +fn render_flex<'a>( direction: FlexDirection, wrap: FlexWrap, justify: FlexAlign, @@ -6779,8 +6823,8 @@ fn render_flex( height: &Size, row_gap: i64, column_gap: i64, - source_items: &[FlexItem], - resolver: Option<&RetainedDocument>, + source_items: &'a [FlexItem], + scope: &RenderScope<'a>, context: LayoutContext, intrinsic: bool, ) -> Result { @@ -6801,7 +6845,7 @@ fn render_flex( &source_items[index], axis, inline_viewport, - resolver, + scope.child(LocalStep::FlexItem(index), &source_items[index].node), context, ) }) @@ -6829,7 +6873,6 @@ fn render_flex( align_items, align_content, single_line, - resolver, context, intrinsic, ), @@ -6843,20 +6886,20 @@ fn render_flex( align_items, align_content, single_line, - resolver, context, intrinsic, ), } } -#[derive(Debug, Clone, Copy, Default)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] struct BoxOverride { content_width: Option, content_height: Option, declared_width: Option, } +#[cfg(test)] fn render_node( node: &LayoutNode, resolver: Option<&RetainedDocument>, @@ -6873,10 +6916,17 @@ fn render_node_with_override( intrinsic: bool, size_override: Option, ) -> Result { - let node = match resolver { - Some(resolver) => resolver.resolve(node)?, - None => node, - }; + RenderScope::uncached(node, resolver).render(context, intrinsic, size_override) +} + +fn render_node_body( + scope: &RenderScope<'_>, + context: LayoutContext, + intrinsic: bool, + size_override: Option, +) -> Result { + let node = scope.view.node; + let resolver = scope.resolver; #[cfg(test)] TEST_RENDER_NODE_COUNT.with(|count| { if let Some(value) = count.get() { @@ -7026,6 +7076,7 @@ fn render_node_with_override( let mut windowed_child_start = None; let mut windowed_child_height = None; let mut child_rendered = if let Some(child) = child { + let child_scope = scope.child(LocalStep::BoxChild, child); let child_context = LayoutContext { viewport_width: if intrinsic_child { 0 @@ -7049,8 +7100,7 @@ fn render_node_with_override( windowed_child_height = Some(total_height); Some( render_node_window( - child, - resolver, + &child_scope, child_context, intrinsic || intrinsic_child, start, @@ -7059,20 +7109,14 @@ fn render_node_with_override( .expect("exact height checked above")?, ) } else { - Some(render_node( - child, - resolver, + Some(child_scope.render( child_context, intrinsic || intrinsic_child, + None, )?) } } else { - Some(render_node( - child, - resolver, - child_context, - intrinsic || intrinsic_child, - )?) + Some(child_scope.render(child_context, intrinsic || intrinsic_child, None)?) } } else { None @@ -7425,7 +7469,14 @@ fn render_node_with_override( }; let rendered = children .iter() - .map(|child| render_node(child, resolver, child_context, intrinsic)) + .enumerate() + .map(|(index, child)| { + scope.child(LocalStep::RowChild(index), child).render( + child_context, + intrinsic, + None, + ) + }) .collect::, _>>()?; let parts = rendered .into_iter() @@ -7437,8 +7488,8 @@ fn render_node_with_override( Ok(concat_horizontal_sized(parts, 0)) } LayoutNode::Column { children, .. } => { - let rendered = column_leaves(children) - .map(|child| render_node(child, resolver, context, intrinsic)) + let rendered = column_leaves(scope, children) + .map(|child| child.render(context, intrinsic, None)) .collect::, _>>()?; let maximum = rendered .iter() @@ -7492,7 +7543,7 @@ fn render_node_with_override( *row_gap, *column_gap, items, - resolver, + scope, context, intrinsic, ), @@ -7501,12 +7552,19 @@ fn render_node_with_override( // Flatten only literal Columns. In particular, a retained NodeRef remains a // child evaluation boundary even when it resolves to an identified Column. -fn column_leaves(children: &[LayoutNode]) -> impl Iterator { - let mut stack = vec![children.iter()]; +fn column_leaves<'a>( + scope: &RenderScope<'a>, + children: &'a [LayoutNode], +) -> impl Iterator> { + let mut stack = vec![(scope.clone(), children.iter().enumerate())]; std::iter::from_fn(move || loop { - match stack.last_mut()?.next() { - Some(LayoutNode::Column { children, .. }) => stack.push(children.iter()), - Some(child) => return Some(child), + let (scope, children) = stack.last_mut()?; + match children.next() { + Some((index, child @ LayoutNode::Column { children, .. })) => { + let child_scope = scope.child(LocalStep::ColumnChild(index), child); + stack.push((child_scope, children.iter().enumerate())); + } + Some((index, child)) => return Some(scope.child(LocalStep::ColumnChild(index), child)), None => { stack.pop(); } @@ -7633,7 +7691,11 @@ mod tests { serde_json::from_str(json).unwrap() } - fn cluster(text: &str, width: i64, source_template_id: Option) -> MeasuredCluster { + pub(super) fn cluster( + text: &str, + width: i64, + source_template_id: Option, + ) -> MeasuredCluster { MeasuredCluster { text: text.to_owned(), width, @@ -7644,7 +7706,7 @@ mod tests { } } - fn measured_text(lines: Vec>) -> MeasuredText { + pub(super) fn measured_text(lines: Vec>) -> MeasuredText { MeasuredText { lines: lines .into_iter() @@ -7716,7 +7778,7 @@ mod tests { node } - fn child_box( + pub(super) fn child_box( region_id: i64, child: LayoutNode, surface_template_id: Option, @@ -7766,7 +7828,7 @@ mod tests { } } - fn identified(mut node: LayoutNode, node_id: u64, revision: u64) -> LayoutNode { + pub(super) fn identified(mut node: LayoutNode, node_id: u64, revision: u64) -> LayoutNode { match &mut node { LayoutNode::Box { node_id: id, @@ -7801,7 +7863,7 @@ mod tests { node } - fn retained_document(root: LayoutNode) -> LayoutDocument { + pub(super) fn retained_document(root: LayoutNode) -> LayoutDocument { LayoutDocument { version: LAYOUT_VERSION, space_width: 1, @@ -7863,7 +7925,7 @@ mod tests { } } - fn test_context() -> LayoutContext { + pub(super) fn test_context() -> LayoutContext { LayoutContext { viewport_width: 80, viewport_width_known: true, @@ -8607,7 +8669,7 @@ mod tests { assert_eq!(joined.lines[1].break_after, Some(AtomProperties::default())); } - fn nonuniform_text(region_id: i64, widths: &[i64]) -> LayoutNode { + pub(super) fn nonuniform_text(region_id: i64, widths: &[i64]) -> LayoutNode { LayoutNode::Text { node_id: None, node_revision: None, @@ -9624,7 +9686,7 @@ mod tests { } } - fn context_test_delta(entries: Vec) -> DocumentDelta { + pub(super) fn context_test_delta(entries: Vec) -> DocumentDelta { serde_json::from_value(serde_json::json!({ "style-base-count": 0, "styles-append": [], diff --git a/native/src/lib.rs b/native/src/lib.rs index 6fc482f..44c82ad 100644 --- a/native/src/lib.rs +++ b/native/src/lib.rs @@ -3,7 +3,8 @@ pub mod sequence; use layout::{ encode_error_tape, DocumentDelta, LayoutContext, LayoutDocument, LayoutTape, RetainedDocument, - TapeIdentity, TapeOutputOptions, MAX_LAYOUT_DIMENSION, MIN_TAPE_BYTES, + RetainedFrame, SourceChanges, TapeIdentity, TapeOutputOptions, MAX_LAYOUT_DIMENSION, + MIN_TAPE_BYTES, }; use serde::{Deserialize, Serialize}; use std::collections::{HashMap, HashSet, VecDeque}; @@ -379,6 +380,7 @@ enum JobPayload { Echo(Vec), Layout { document: LayoutSource, + source_changes: Option, context: LayoutContext, root_width: i64, root_width_override: bool, @@ -426,6 +428,27 @@ impl LayoutSource { Self::Retained(document) => document.styles(), } } + + fn render_target( + &self, + context: LayoutContext, + root_width: Option, + previous: Option<&RetainedFrame>, + changes: Option<&SourceChanges>, + ) -> Result<(LayoutTape, Option>), String> { + match self { + Self::Full(document) => { + if changes.is_some() { + return Err("Native source changes require a retained document".to_owned()); + } + Ok((document.layout_tape(context, root_width)?, None)) + } + Self::Retained(document) => { + let frame = document.render_frame(previous, changes, context, root_width)?; + Ok((frame.materialize_tape(), Some(frame))) + } + } + } } #[derive(Clone, Debug)] @@ -444,6 +467,7 @@ struct ConfirmedBaseline { document: LayoutSource, document_revision: u64, tape: LayoutTape, + retained_frame: Option>, styles: Vec, } @@ -453,6 +477,7 @@ struct PendingBaseline { document: LayoutSource, document_revision: u64, tape: LayoutTape, + retained_frame: Option>, styles: Vec, } @@ -471,6 +496,7 @@ struct RenderedJob { resolver_lookups: u64, atom_plan_work: layout::AtomPlanWork, line_plan_work: layout::LinePlanWork, + eval_work: layout::EvalWork, } #[derive(Default)] @@ -527,6 +553,7 @@ struct RuntimeState { source_change_work: layout::SourceChangeWork, atom_plan_work: layout::AtomPlanWork, line_plan_work: layout::LinePlanWork, + eval_work: layout::EvalWork, confirmed_baseline: Option>, } @@ -607,6 +634,7 @@ struct SessionStats { source_change_work: layout::SourceChangeWork, atom_plan_work: layout::AtomPlanWork, line_plan_work: layout::LinePlanWork, + eval_work: layout::EvalWork, pending_baselines: usize, confirmed_baseline: bool, confirmed_baseline_bytes: usize, @@ -682,6 +710,7 @@ impl Session { source_change_work: layout::SourceChangeWork::default(), atom_plan_work: layout::AtomPlanWork::default(), line_plan_work: layout::LinePlanWork::default(), + eval_work: layout::EvalWork::default(), confirmed_baseline, }), readiness_channel: Mutex::new(None), @@ -807,6 +836,7 @@ impl Session { frame, document_base_revision, document_target_revision, + None, )? } else { let payload = frame @@ -955,6 +985,7 @@ impl Session { .unwrap_or_else(|poison| poison.into_inner()) .confirmed_baseline .clone(); + let mut source_changes = None; let (document, input_stats) = match (batch.document, batch.document_delta) { (Some(_), Some(_)) => { return Err( @@ -1029,9 +1060,9 @@ impl Session { "Native retained source changes document identity mismatch".to_owned() ); } - // Consume exact source facts for input accounting. The renderer - // still evaluates the target normally; no history-bearing change - // object is stored in a job, document or confirmed baseline. + // Account for exact input work, then carry these facts only for + // this render job. Documents and baselines never store the + // descriptor's references to both versions. let mut source_change_work = applied.stats.source_work; let mut previous_owner = None; applied @@ -1047,6 +1078,7 @@ impl Session { let (styles, property_templates) = applied.changes.registry_ranges(); source_change_work.styles_appended = styles.len() as u64; source_change_work.property_templates_added = property_templates.len() as u64; + source_changes = Some(applied.changes); ( LayoutSource::Retained(applied.document), DocumentInputStats { @@ -1104,6 +1136,7 @@ impl Session { frame, document_base_revision, document_target_revision, + source_changes, )?; let output = render_layout_payload( prepared.payload, @@ -1132,6 +1165,7 @@ impl Session { state.document_resolver_lookups += output.resolver_lookups; state.atom_plan_work.accumulate(output.atom_plan_work); state.line_plan_work.accumulate(output.line_plan_work); + state.eval_work.accumulate(output.eval_work); state.document_parses += input_stats.parses; state.document_validations += input_stats.validations; state.document_reuses += input_stats.reuses; @@ -1176,6 +1210,7 @@ impl Session { document: pending.document, document_revision: pending.document_revision, tape: pending.tape, + retained_frame: pending.retained_frame, styles: pending.styles, })); Ok(true) @@ -1253,6 +1288,8 @@ impl Session { .lock() .unwrap_or_else(|poison| poison.into_inner()); let pending_baselines = state.pending_baselines.len(); + // Compatibility field: the shallow tape container only, not retained + // plan allocations, shared heap memory or a cache memory bound. let confirmed_baseline_bytes = state .confirmed_baseline .as_ref() @@ -1286,6 +1323,7 @@ impl Session { source_change_work: state.source_change_work, atom_plan_work: state.atom_plan_work, line_plan_work: state.line_plan_work, + eval_work: state.eval_work, pending_baselines, confirmed_baseline: state.confirmed_baseline.is_some(), confirmed_baseline_bytes, @@ -1389,6 +1427,7 @@ fn prepare_layout_job( frame: ControlFrame, document_base_revision: u64, document_target_revision: u64, + source_changes: Option, ) -> Result { layout::reset_resolver_lookups(); if frame.payload.is_some() { @@ -1462,6 +1501,7 @@ fn prepare_layout_job( delay_ms: frame.delay_ms, payload: JobPayload::Layout { document: document.clone(), + source_changes, context, root_width, root_width_override: frame.root_width_override, @@ -1498,9 +1538,11 @@ fn render_layout_payload( resolver_lookups: 0, atom_plan_work: layout::AtomPlanWork::default(), line_plan_work: layout::LinePlanWork::default(), + eval_work: layout::EvalWork::default(), }, JobPayload::Layout { document, + source_changes, context, root_width, root_width_override, @@ -1548,6 +1590,7 @@ fn render_layout_payload( Vec, LayoutTape, Vec, + Option>, bool, u64, u64, @@ -1557,8 +1600,19 @@ fn render_layout_payload( layout::reset_resolver_lookups(); layout::reset_atom_plan_work(); layout::reset_line_plan_work(); + layout::reset_eval_work(); let result = catch_unwind(AssertUnwindSafe(|| -> LayoutRenderOutcome { let target_styles = document.styles()?; + let render_target = || { + document.render_target( + context, + root_width_override.then_some(root_width), + confirmed_baseline + .as_ref() + .and_then(|baseline| baseline.retained_frame.as_deref()), + source_changes.as_ref(), + ) + }; if let Some(base_context) = base_context { let base_identity = BaselineIdentity { context: base_context, @@ -1582,8 +1636,7 @@ fn render_layout_payload( }) .cloned(); if require_confirmed_patch_base && base_hit.is_none() { - let target = document - .layout_tape(context, root_width_override.then_some(root_width))?; + let (target, retained_frame) = render_target()?; let bytes = layout::encode_layout_tape( target.clone(), &target_styles, @@ -1591,7 +1644,7 @@ fn render_layout_payload( output.root_metadata, output.max_bytes, )?; - return Ok((bytes, target, target_styles, false, 0, 1)); + return Ok((bytes, target, target_styles, retained_frame, false, 0, 1)); } let (old, baseline_hit, base_renders) = if let Some(baseline) = base_hit { (baseline.tape.clone(), true, 0) @@ -1605,8 +1658,7 @@ fn render_layout_payload( 1, ) }; - let target = - document.layout_tape(context, root_width_override.then_some(root_width))?; + let (target, retained_frame) = render_target()?; let bytes = layout::encode_layout_patch_tape( old, target.clone(), @@ -1615,10 +1667,17 @@ fn render_layout_payload( output.root_metadata, output.max_bytes, )?; - Ok((bytes, target, target_styles, baseline_hit, base_renders, 1)) + Ok(( + bytes, + target, + target_styles, + retained_frame, + baseline_hit, + base_renders, + 1, + )) } else { - let target = - document.layout_tape(context, root_width_override.then_some(root_width))?; + let (target, retained_frame) = render_target()?; let bytes = layout::encode_layout_tape( target.clone(), &target_styles, @@ -1626,32 +1685,41 @@ fn render_layout_payload( output.root_metadata, output.max_bytes, )?; - Ok((bytes, target, target_styles, false, 0, 1)) + Ok((bytes, target, target_styles, retained_frame, false, 0, 1)) } })); let resolver_lookups = validation_resolver_lookups.saturating_add(layout::resolver_lookups()); let atom_plan_work = layout::atom_plan_work(); let line_plan_work = layout::line_plan_work(); + let eval_work = layout::eval_work(); match result { - Ok(Ok((bytes, tape, styles, baseline_hit, base_renders, target_renders))) => { - RenderedJob { - bytes, - pending: Some(PendingBaseline { - confirmed_identity: pending_identity, - document: document.clone(), - document_revision: document_target_revision, - tape, - styles, - }), - baseline_hit, - base_renders, - target_renders, - resolver_lookups, - atom_plan_work, - line_plan_work, - } - } + Ok(Ok(( + bytes, + tape, + styles, + retained_frame, + baseline_hit, + base_renders, + target_renders, + ))) => RenderedJob { + bytes, + pending: Some(PendingBaseline { + confirmed_identity: pending_identity, + document: document.clone(), + document_revision: document_target_revision, + tape, + retained_frame, + styles, + }), + baseline_hit, + base_renders, + target_renders, + resolver_lookups, + atom_plan_work, + line_plan_work, + eval_work, + }, Ok(Err(error)) => RenderedJob { bytes: encode_error_tape(identity, &error, max_result_bytes), pending: None, @@ -1661,6 +1729,7 @@ fn render_layout_payload( resolver_lookups, atom_plan_work, line_plan_work, + eval_work, }, Err(_) => RenderedJob { bytes: encode_error_tape(identity, "native layout panicked", max_result_bytes), @@ -1671,6 +1740,7 @@ fn render_layout_payload( resolver_lookups, atom_plan_work, line_plan_work, + eval_work, }, } } @@ -1706,7 +1776,7 @@ fn render_proof(payload: &[u8]) -> Result, String> { return Err("Native proof render cannot contain delay-ms".to_owned()); } let document = LayoutSource::Full(Arc::new(document)); - let prepared = prepare_layout_job(&document, frame, 0, 1)?; + let prepared = prepare_layout_job(&document, frame, 0, 1, None)?; let output = render_layout_payload( prepared.payload, SYNC_RENDER_SESSION_ID, @@ -1822,6 +1892,7 @@ fn worker_loop(shared: Arc) { state.document_resolver_lookups += output.resolver_lookups; state.atom_plan_work.accumulate(output.atom_plan_work); state.line_plan_work.accumulate(output.line_plan_work); + state.eval_work.accumulate(output.eval_work); state.results.insert( (job.generation, job.key), ResultEntry { @@ -2569,6 +2640,149 @@ mod tests { session.stop(true); } + #[test] + fn retained_layout_reuse_does_not_bypass_wire_base_proof() { + let session = Session::new(1, 4, 4, 64 * 1024).unwrap(); + let first = identified_proof_layout_payload( + r#"[{"key":1,"viewport-width":80,"viewport-height":10,"root-width":80,"runtime-revision":0,"context-hash":77}]"#, + ); + session.render_sync(1, &first).unwrap(); + assert!(session.confirm(1, 1, 1).unwrap()); + let before = session.stats(); + // The source and layout request still match, but receiver context + // identity does not: a layout hit must still publish a full tape. + let second = retained_layout_payload( + 1, + r#"[{"key":2,"viewport-width":80,"viewport-height":10,"root-width":80,"patch":true,"base-viewport-width":80,"base-viewport-height":10,"base-root-width":80,"runtime-revision":1,"context-hash":78}]"#, + ); + let tape = session.render_sync(2, &second).unwrap(); + let after = session.stats(); + assert_ne!(u16::from_le_bytes(tape[6..8].try_into().unwrap()) & 1, 0); + assert!(!tape_patch_p(&tape)); + assert_eq!(after.baseline_hits, before.baseline_hits); + assert_eq!(after.base_renders, before.base_renders); + assert_eq!(after.eval_work.hits - before.eval_work.hits, 1); + assert_eq!(after.eval_work.body_runs, before.eval_work.body_runs); + assert!(session.confirm(2, 2, 2).unwrap()); + session.stop(true); + } + + #[test] + fn retained_frame_failures_preserve_the_confirmed_fork_and_allow_retry() { + // Failed encoding or confirmation must not promote source/output or + // retain the rejected candidate's evaluation tree. + let parent = Session::new(1, 4, 4, 2048).unwrap(); + let first = identified_proof_layout_payload( + r#"[{"key":1,"viewport-width":80,"viewport-height":10,"root-width":80,"runtime-revision":0,"context-hash":77}]"#, + ); + let first_tape = parent.render_sync(1, &first).unwrap(); + assert_ne!( + u16::from_le_bytes(first_tape[6..8].try_into().unwrap()) & 1, + 0 + ); + assert!(parent.confirm(1, 1, 1).unwrap()); + let child = parent.fork_confirmed().unwrap(); + let original = parent + .shared + .state + .lock() + .unwrap() + .confirmed_baseline + .clone() + .unwrap(); + let original_frame = original.retained_frame.as_ref().unwrap(); + let delta = |key, text: &str| { + serde_json::to_vec(&serde_json::json!({ + "version": 1, "document-base-revision": 1, "document-target-revision": 2, + "document-delta": { + "style-base-count": 0, "styles-append": [], + "property-template-base-count": 0, "property-template-target-count": 0, + "entries": [{"node-id": 1, "expected-revision": 7, "target-revision": 8, + "slot-patches": [{"slot": 0, "local": { + "content": {"lines": [{"clusters": [{"text": text, "width": 8, + "cjk": false, "space": false}]}]} + }}]}] + }, + "frames": [{"key": key, "viewport-width": 80, "viewport-height": 10, + "root-width": 80, "patch": true, "base-viewport-width": 80, + "base-viewport-height": 10, "base-root-width": 80, + "runtime-revision": 1, "context-hash": 77}] + })) + .unwrap() + }; + let oversized = child.render_sync(1, &delta(2, &"x".repeat(4096))).unwrap(); + assert_eq!( + u16::from_le_bytes(oversized[6..8].try_into().unwrap()) & 1, + 0 + ); + assert_eq!(child.stats().pending_baselines, 0); + assert!(!child.confirm(1, 2, 2).unwrap()); + let before = child + .shared + .state + .lock() + .unwrap() + .confirmed_baseline + .clone() + .unwrap(); + assert!(Arc::ptr_eq(&original, &before)); + + let retry = child.render_sync(2, &delta(3, "y")).unwrap(); + assert_ne!(u16::from_le_bytes(retry[6..8].try_into().unwrap()) & 1, 0); + assert_eq!(child.stats().pending_baselines, 1); + let rejected_frame = { + let state = child.shared.state.lock().unwrap(); + let candidate = state.pending_baselines.get(&(2, 3)).unwrap(); + let frame = candidate.retained_frame.as_ref().unwrap(); + assert!(!Arc::ptr_eq(original_frame, frame)); + Arc::downgrade(frame) + }; + assert!(child.confirm(2, 3, 99).is_err()); + assert_eq!(child.stats().pending_baselines, 0); + assert!(rejected_frame.upgrade().is_none()); + let after = child + .shared + .state + .lock() + .unwrap() + .confirmed_baseline + .clone() + .unwrap(); + assert!(Arc::ptr_eq(&original, &after)); + + let retry = child.render_sync(3, &delta(4, "z")).unwrap(); + assert_ne!(u16::from_le_bytes(retry[6..8].try_into().unwrap()) & 1, 0); + assert!(child.confirm(3, 4, 2).unwrap()); + let confirmed = child + .shared + .state + .lock() + .unwrap() + .confirmed_baseline + .clone() + .unwrap(); + assert!(!Arc::ptr_eq(&original, &confirmed)); + assert!(!Arc::ptr_eq( + original_frame, + confirmed.retained_frame.as_ref().unwrap() + )); + assert_eq!(confirmed.document_revision, 2); + assert_eq!(original.document_revision, 1); + assert!(Arc::ptr_eq( + &original, + parent + .shared + .state + .lock() + .unwrap() + .confirmed_baseline + .as_ref() + .unwrap() + )); + parent.stop(true); + child.stop(true); + } + #[test] fn confirmed_fork_shares_only_immutable_baseline() { let parent = Session::new(1, 4, 4, 64 * 1024).unwrap(); @@ -2857,6 +3071,30 @@ mod tests { retry.stop(true); } + #[test] + fn cancellation_releases_retained_candidate_frame() { + let session = Session::new(1, 4, 4, 64 * 1024).unwrap(); + let payload = identified_proof_layout_payload( + r#"[{"key":1,"viewport-width":80,"viewport-height":10,"runtime-revision":0}]"#, + ); + session.render_sync(1, &payload).unwrap(); + let cancelled_frame = { + let state = session.shared.state.lock().unwrap(); + Arc::downgrade( + state.pending_baselines[&(1, 1)] + .retained_frame + .as_ref() + .unwrap(), + ) + }; + session.cancel(1); + assert!(cancelled_frame.upgrade().is_none()); + assert_eq!(session.stats().pending_baselines, 0); + assert!(!session.stats().confirmed_baseline); + assert!(!session.confirm(1, 1, 1).unwrap()); + session.stop(true); + } + #[test] fn async_omit_uses_the_latest_synchronously_confirmed_document() { let parent = Session::new(1, 4, 4, 64 * 1024).unwrap(); diff --git a/native/src/source_topology.rs b/native/src/source_topology.rs index 7e7748a..8b22954 100644 --- a/native/src/source_topology.rs +++ b/native/src/source_topology.rs @@ -3,7 +3,7 @@ use std::collections::BTreeMap; use std::sync::Arc; -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] pub(super) enum LocalStep { BoxChild, RowChild(usize), @@ -12,7 +12,7 @@ pub(super) enum LocalStep { } /// A local structural address stops before crossing an identified node reference. -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] pub(super) struct SourceAddress { pub(super) owner_id: u64, pub(super) path: Arc<[LocalStep]>, From 39d079b2cc17c49eb1792fc0fd53c94371c85678 Mon Sep 17 00:00:00 2001 From: Kinneyzhang Date: Sat, 5 Sep 2026 15:21:10 +0800 Subject: [PATCH 10/12] perf: retain sparse column composition and exact box projections --- native/src/composition.rs | 606 +++++++++++ native/src/composition_tests.rs | 206 ++++ native/src/evaluation.rs | 493 ++++++++- native/src/evaluation_tests.rs | 529 +++++++++- native/src/layout.rs | 514 ++++++---- native/src/line_metrics.rs | 295 ++++++ native/src/line_ops.rs | 169 ++++ native/src/line_plan.rs | 1683 ++++++++++++++++++++++++++++--- 8 files changed, 4126 insertions(+), 369 deletions(-) create mode 100644 native/src/composition.rs create mode 100644 native/src/composition_tests.rs create mode 100644 native/src/line_metrics.rs create mode 100644 native/src/line_ops.rs diff --git a/native/src/composition.rs b/native/src/composition.rs new file mode 100644 index 0000000..c44c1c8 --- /dev/null +++ b/native/src/composition.rs @@ -0,0 +1,606 @@ +//! Current Column slots and their persistent composition. Shape contains only +//! original structural paths; target nodes are read from the current document. + +use std::sync::Arc; + +use super::evaluation::{record, EvalRecord, EvalWork, RenderScope, RenderedChange}; +use super::line_plan::{ChangeKind, LineOp, LinePlan, LineSplice, PlanChange, ProjectionState}; +use super::{LayoutContext, LayoutNode, Line, LocalStep, Rendered}; + +#[derive(Debug, Default)] +struct ShapeRoute { + slot: Option, + next: Box<[(LocalStep, ShapeRoute)]>, +} + +#[derive(Debug)] +struct ColumnShape { + paths: Box<[Arc<[LocalStep]>]>, + routes: ShapeRoute, +} + +impl ColumnShape { + fn new(paths: Vec>) -> Arc { + // column_leaves supplies paths in original lexicographic order. Freeze + // each prefix group once; updates only binary-search these shared arrays. + fn build(paths: &[Arc<[LocalStep]>], first: usize, depth: usize) -> ShapeRoute { + if paths.first().is_some_and(|path| path.len() == depth) { + assert_eq!(paths.len(), 1, "one leaf at each original Column path"); + return ShapeRoute { + slot: Some(first), + next: Box::new([]), + }; + } + let mut next = Vec::new(); + let mut start = 0; + while start < paths.len() { + let step = paths[start][depth]; + let mut end = start + 1; + while end < paths.len() && paths[end][depth] == step { + end += 1; + } + record(EvalWork { + column_shape_steps_visited: (end - start) as u64, + ..EvalWork::default() + }); + next.push((step, build(&paths[start..end], first + start, depth + 1))); + start = end; + } + ShapeRoute { + slot: None, + next: next.into_boxed_slice(), + } + } + let routes = build(&paths, 0, 0); + Arc::new(Self { + paths: paths.into_boxed_slice(), + routes, + }) + } + + fn dirty_slots(&self, scope: &RenderScope<'_>) -> Vec { + fn collect( + route: &ShapeRoute, + scope: &RenderScope<'_>, + path: &mut Vec, + all: bool, + slots: &mut Vec, + ) { + record(EvalWork { + column_shape_steps_visited: 1, + ..EvalWork::default() + }); + if let Some(slot) = route.slot { + slots.push(slot); + return; + } + let dirty = scope.dirty_route(path); + if all || dirty.local { + for (step, child) in &route.next { + path.push(*step); + collect(child, scope, path, true, slots); + path.pop(); + } + } else { + for step in dirty.next { + if let Ok(index) = route.next.binary_search_by_key(&step, |(key, _)| *key) { + path.push(step); + collect(&route.next[index].1, scope, path, false, slots); + path.pop(); + } + } + } + } + let mut slots = Vec::new(); + collect(&self.routes, scope, &mut Vec::new(), false, &mut slots); + slots + } + + fn scope<'a>(&self, scope: &RenderScope<'a>, index: usize) -> Result, String> { + let mut result = scope.clone(); + for step in self.paths[index].iter() { + record(EvalWork { + column_shape_steps_visited: 1, + ..EvalWork::default() + }); + let (LayoutNode::Column { children, .. }, LocalStep::ColumnChild(index)) = + (result.view.node, step) + else { + return Err("Native retained Column shape lost its original path".to_owned()); + }; + let child = children + .get(*index) + .ok_or_else(|| "Native retained Column shape lost its child slot".to_owned())?; + result = result.child(*step, child); + } + Ok(result) + } +} + +#[derive(Debug)] +struct ColumnSlot { + record: Option>, + raw: LinePlan, + normalized: LinePlan, + projection: Option>, +} + +impl ColumnSlot { + fn normalize( + raw: LinePlan, + child: Option>, + proof: Option<&PlanChange>, + target: i64, + previous: Option<&Self>, + ) -> (Arc, Option) { + let extra = (target - raw.first_width()).max(0); + let (normalized, projection, change) = if extra == 0 { + let change = previous.map(|old| { + proof + .filter(|proof| proof.applies_to(&old.normalized, &raw)) + .cloned() + .unwrap_or_else(|| PlanChange::replace_all(&old.normalized, &raw)) + }); + (raw.clone(), None, change) + } else { + let ops = Arc::from([LineOp::AppendSpace(extra), LineOp::ClearBreaks]); + if let Some(old) = previous.and_then(|old| old.projection.as_ref()) { + let update = old.update(raw.clone(), proof, ops); + ( + update.state.plan().clone(), + Some(update.state), + Some(update.change), + ) + } else { + let projection = ProjectionState::new(raw.clone(), ops); + let normalized = projection.plan().clone(); + let change = + previous.map(|old| PlanChange::replace_all(&old.normalized, &normalized)); + (normalized, Some(projection), change) + } + }; + ( + Arc::new(Self { + record: child, + raw, + normalized, + projection, + }), + change, + ) + } +} + +#[derive(Clone, Copy, Debug, Default)] +struct ColumnMeasure { + slots: usize, + lines: usize, + max_first: i64, + max_width: i64, + max_width_minus_first: Option, + first_nonempty: Option, +} + +impl ColumnMeasure { + fn leaf(slot: &ColumnSlot) -> Self { + let first = slot.raw.first_width(); + let width = slot.raw.max_width(); + Self { + slots: 1, + lines: slot.raw.len(), + max_first: first, + max_width: width, + max_width_minus_first: (!slot.raw.is_empty()).then_some(width - first), + first_nonempty: (!slot.raw.is_empty()).then_some(first), + } + } + + fn combine(self, other: Self) -> Self { + Self { + slots: self.slots + other.slots, + lines: self.lines + other.lines, + max_first: self.max_first.max(other.max_first), + max_width: self.max_width.max(other.max_width), + max_width_minus_first: self.max_width_minus_first.max(other.max_width_minus_first), + first_nonempty: self.first_nonempty.or(other.first_nonempty), + } + } + + fn normalized_max(self, target: i64) -> i64 { + self.max_width_minus_first + .map_or(0, |delta| self.max_width.max(target + delta)) + } +} + +#[derive(Debug)] +enum ColumnNode { + Leaf { + slot: Arc, + measure: ColumnMeasure, + }, + Branch { + left: Arc, + right: Arc, + measure: ColumnMeasure, + lines: LinePlan, + }, +} + +impl ColumnNode { + fn leaf(slot: Arc) -> Arc { + record(EvalWork { + column_tree_nodes_created: 1, + ..EvalWork::default() + }); + Arc::new(Self::Leaf { + measure: ColumnMeasure::leaf(&slot), + slot, + }) + } + + fn branch(left: Arc, right: Arc, previous: Option<&Self>) -> Arc { + record(EvalWork { + column_tree_nodes_created: 1, + ..EvalWork::default() + }); + let lines = match previous { + Some(Self::Branch { + left: old_left, + right: old_right, + lines, + .. + }) if left.lines().ptr_eq(old_left.lines()) + && right.lines().ptr_eq(old_right.lines()) => + { + lines.clone() + } + _ => left.lines().concat(right.lines()), + }; + Arc::new(Self::Branch { + measure: left.measure().combine(right.measure()), + left, + right, + lines, + }) + } + + fn build(slots: &[Arc]) -> Option> { + match slots.len() { + 0 => None, + 1 => Some(Self::leaf(Arc::clone(&slots[0]))), + count => { + let mid = count / 2; + Some(Self::branch( + Self::build(&slots[..mid]).unwrap(), + Self::build(&slots[mid..]).unwrap(), + None, + )) + } + } + } + + fn measure(&self) -> ColumnMeasure { + match self { + Self::Leaf { measure, .. } | Self::Branch { measure, .. } => *measure, + } + } + + fn lines(&self) -> &LinePlan { + match self { + Self::Leaf { slot, .. } => &slot.normalized, + Self::Branch { lines, .. } => lines, + } + } + + fn slot(&self, index: usize) -> &Arc { + record(EvalWork { + column_tree_nodes_visited: 1, + ..EvalWork::default() + }); + match self { + Self::Leaf { slot, .. } => { + assert_eq!(index, 0); + slot + } + Self::Branch { left, right, .. } => { + let left_len = left.measure().slots; + if index < left_len { + left.slot(index) + } else { + right.slot(index - left_len) + } + } + } + } + + fn prefix_lines(&self, end: usize) -> usize { + record(EvalWork { + column_tree_nodes_visited: 1, + ..EvalWork::default() + }); + if end == 0 { + return 0; + } + if end == self.measure().slots { + return self.measure().lines; + } + match self { + Self::Leaf { .. } => unreachable!("validated Column prefix"), + Self::Branch { left, right, .. } => { + let left_len = left.measure().slots; + if end <= left_len { + left.prefix_lines(end) + } else { + left.measure().lines + right.prefix_lines(end - left_len) + } + } + } + } + + fn replace(&self, index: usize, slot: Arc) -> Arc { + record(EvalWork { + column_tree_nodes_visited: 1, + ..EvalWork::default() + }); + match self { + Self::Leaf { .. } => { + assert_eq!(index, 0); + Self::leaf(slot) + } + Self::Branch { left, right, .. } => { + let left_len = left.measure().slots; + if index < left_len { + Self::branch(left.replace(index, slot), Arc::clone(right), Some(self)) + } else { + Self::branch( + Arc::clone(left), + right.replace(index - left_len, slot), + Some(self), + ) + } + } + } + } + + fn retarget(&self, target: i64) -> Arc { + record(EvalWork { + column_tree_nodes_visited: 1, + ..EvalWork::default() + }); + match self { + Self::Leaf { slot, .. } => { + record(EvalWork { + column_slots_updated: 1, + ..EvalWork::default() + }); + let same = PlanChange::same(&slot.raw); + Self::leaf( + ColumnSlot::normalize( + slot.raw.clone(), + slot.record.clone(), + Some(&same), + target, + Some(slot), + ) + .0, + ) + } + Self::Branch { left, right, .. } => { + Self::branch(left.retarget(target), right.retarget(target), Some(self)) + } + } + } +} + +#[derive(Debug)] +pub(super) struct ColumnState { + shape: Arc, + root: Option>, + target: i64, + lines: LinePlan, +} + +#[cfg(test)] +impl ColumnState { + pub(super) fn shares_slot(&self, other: &Self, index: usize) -> bool { + Arc::ptr_eq( + self.root.as_ref().unwrap().slot(index), + other.root.as_ref().unwrap().slot(index), + ) + } + + pub(super) fn shares_shape(&self, other: &Self) -> bool { + Arc::ptr_eq(&self.shape, &other.shape) + } +} + +fn target_width(context: LayoutContext, intrinsic: bool, maximum: i64) -> i64 { + if intrinsic || context.inline_auto_width_intrinsic || !context.viewport_width_known { + maximum + } else { + context.viewport_width.max(0) + } +} + +fn output(root: Option<&Arc>, previous: Option<&ColumnState>) -> LinePlan { + if let Some(root) = root.filter(|root| root.measure().lines > 0) { + return root.lines().clone(); + } + if let Some(previous) = previous.filter(|previous| { + previous + .root + .as_ref() + .is_none_or(|root| root.measure().lines == 0) + }) { + return previous.lines.clone(); + } + LinePlan::from_lines([Line::default()]) +} + +// A concatenation owns its joining break, including the former last line when +// an empty boundary moves. Include that predecessor in each changed span. +fn include_join_boundaries(splices: Vec) -> Vec { + let mut result: Vec = Vec::with_capacity(splices.len()); + for mut splice in splices { + if splice.old.start == 0 || splice.new.start == 0 { + splice.old.start = 0; + splice.new.start = 0; + } else { + splice.old.start -= 1; + splice.new.start -= 1; + } + if let Some(previous) = result.last_mut().filter(|previous| { + splice.old.start <= previous.old.end || splice.new.start <= previous.new.end + }) { + previous.old.end = previous.old.end.max(splice.old.end); + previous.new.end = previous.new.end.max(splice.new.end); + } else { + result.push(splice); + } + } + result +} + +pub(super) fn render_column<'a>( + scope: &RenderScope<'a>, + children: &'a [LayoutNode], + context: LayoutContext, + intrinsic: bool, +) -> Result { + let state = if let Some(previous) = scope.previous_column() { + let dirty = previous.shape.dirty_slots(scope); + scope.inherit_column_uses(); + let mut root = previous.root.clone(); + let mut changes = Vec::with_capacity(dirty.len()); + for index in dirty { + let child_scope = previous.shape.scope(scope, index)?; + let result = child_scope.render_with_change(context, intrinsic, None)?; + let old = previous + .root + .as_ref() + .expect("dirty Column has slots") + .slot(index); + let (slot, change) = ColumnSlot::normalize( + result.rendered.lines, + result.record, + result.change.as_ref(), + previous.target, + Some(old), + ); + record(EvalWork { + column_slots_updated: 1, + ..EvalWork::default() + }); + root = Some( + root.as_ref() + .expect("dirty Column root") + .replace(index, slot), + ); + changes.push((index, change.expect("previous normalized child"))); + } + let target = target_width( + context, + intrinsic, + root.as_ref().map_or(0, |root| root.measure().max_first), + ); + if target != previous.target { + record(EvalWork { + column_full_rebuilds: 1, + ..EvalWork::default() + }); + root = root.map(|root| root.retarget(target)); + } + let lines = output(root.as_ref(), Some(&previous)); + let change = if lines.ptr_eq(&previous.lines) { + PlanChange::same(&lines) + } else if target != previous.target + || previous + .root + .as_ref() + .is_none_or(|root| root.measure().lines == 0) + || root.as_ref().is_none_or(|root| root.measure().lines == 0) + { + PlanChange::replace_all(&previous.lines, &lines) + } else { + let old_root = previous.root.as_ref().unwrap(); + let new_root = root.as_ref().unwrap(); + let mut splices = Vec::new(); + for (index, change) in changes { + let old_offset = old_root.prefix_lines(index); + let new_offset = new_root.prefix_lines(index); + match change.kind() { + ChangeKind::Same => {} + ChangeKind::Splices(parts) => { + splices.extend(parts.iter().map(|part| LineSplice { + old: old_offset + part.old.start..old_offset + part.old.end, + new: new_offset + part.new.start..new_offset + part.new.end, + })); + } + ChangeKind::ReplaceAll => splices.push(LineSplice { + old: old_offset..old_offset + old_root.slot(index).raw.len(), + new: new_offset..new_offset + new_root.slot(index).raw.len(), + }), + } + } + PlanChange::splices(&previous.lines, &lines, include_join_boundaries(splices)) + }; + scope.publish_change(change); + Arc::new(ColumnState { + shape: Arc::clone(&previous.shape), + root, + target, + lines, + }) + } else { + record(EvalWork { + column_full_rebuilds: 1, + ..EvalWork::default() + }); + let mut paths = Vec::new(); + let mut results = Vec::new(); + for child in super::column_leaves(scope, children) { + let path = &child.view.address.path[scope.view.address.path.len()..]; + record(EvalWork { + source_path_steps_copied: path.len() as u64, + column_slots_built: 1, + ..EvalWork::default() + }); + paths.push(Arc::from(path)); + results.push(child.render_with_change(context, intrinsic, None)?); + } + let maximum = results + .iter() + .map(|result| result.rendered.first_width()) + .max() + .unwrap_or(0); + let target = target_width(context, intrinsic, maximum); + let slots = results + .into_iter() + .map( + |RenderedChange { + rendered, record, .. + }| { + ColumnSlot::normalize(rendered.lines, record, None, target, None).0 + }, + ) + .collect::>(); + let root = ColumnNode::build(&slots); + let lines = output(root.as_ref(), None); + Arc::new(ColumnState { + shape: ColumnShape::new(paths), + root, + target, + lines, + }) + }; + debug_assert_eq!( + state + .root + .as_ref() + .map_or(0, |root| root.measure().normalized_max(state.target)), + state.lines.max_width() + ); + let rendered = Rendered::from_line_plan(state.lines.clone()); + scope.store_column(state); + Ok(rendered) +} diff --git a/native/src/composition_tests.rs b/native/src/composition_tests.rs new file mode 100644 index 0000000..a47fca8 --- /dev/null +++ b/native/src/composition_tests.rs @@ -0,0 +1,206 @@ +mod composition_regressions { + use super::*; + + fn expected_text_line( + text: &str, + properties: &AtomProperties, + break_after: Option, + ) -> TapeLine { + TapeLine { + width: text.chars().count() as i64, + atoms: text + .chars() + .map(|character| TapeAtom::Text { + text: character.to_string(), + width: 1, + properties: properties.clone(), + }) + .collect(), + break_after, + } + } + + #[test] + fn ownership_before_wrap_and_slice_keeps_its_source_index_anchor() { + let mut line = Line::from_clusters(&[ + cluster("a", 1, Some(3)), + cluster("b", 1, Some(3)), + cluster("c", 1, Some(3)), + cluster("d", 1, Some(3)), + ]); + line.own_content(11, 7); + line.apply_style(Some(2)); + line.apply_property_template(Some(4)); + let source = Rendered::from_lines(vec![line]); + let anchored = AtomProperties { + style_ids: vec![2], + content: Some(11), + content_idx: Some(7), + owner: Some(11), + owners: vec![11], + property_template_ids: vec![3, 4], + ..AtomProperties::default() + }; + assert_eq!( + source.clone().into_tape(6), + LayoutTape { + style_count: 6, + lines: vec![expected_text_line("abcd", &anchored, None)], + } + ); + + let wrapped = wrap_rendered(source, 2, WrapMode::Char); + let expected_wrapped = LayoutTape { + style_count: 6, + lines: vec![ + expected_text_line("ab", &anchored, Some(AtomProperties::default())), + expected_text_line("cd", &anchored, None), + ], + }; + assert_eq!(wrapped.clone().into_tape(6), expected_wrapped); + + let mut selected = slice_rendered(wrapped.clone(), 1, 1); + assert_eq!( + selected.clone().into_tape(6), + LayoutTape { + style_count: 6, + lines: vec![expected_text_line("cd", &anchored, None)], + } + ); + selected.lines = selected.lines.map_lines(|index, mut line| { + line.own_content(22, 99 + index as i64); + line.apply_style(Some(5)); + line.apply_property_template(Some(8)); + line + }); + let outer = AtomProperties { + style_ids: vec![2, 5], + content: Some(11), + content_idx: Some(7), + owner: Some(22), + owners: vec![11, 22], + property_template_ids: vec![3, 4, 8], + ..AtomProperties::default() + }; + assert_eq!( + selected.into_tape(6), + LayoutTape { + style_count: 6, + lines: vec![expected_text_line("cd", &outer, None)], + } + ); + assert_eq!(wrapped.into_tape(6), expected_wrapped); + } + + fn expected_column_with_prefix(prefix_lines: usize) -> LayoutTape { + let parent_only = AtomProperties { + owner: Some(1), + owners: vec![1], + ..AtomProperties::default() + }; + let owned_line = |region_id, content_index| TapeLine { + width: 4, + atoms: vec![ + TapeAtom::Text { + text: "x".to_owned(), + width: 1, + properties: AtomProperties { + content: Some(region_id), + content_idx: Some(content_index), + owner: Some(1), + owners: vec![region_id, 1], + ..AtomProperties::default() + }, + }, + TapeAtom::Space { + width: 3, + properties: parent_only.clone(), + }, + ], + break_after: Some(AtomProperties::default()), + }; + let mut lines = (0..prefix_lines) + .map(|index| owned_line(10, index as i64)) + .collect::>(); + lines.push(TapeLine { + width: 4, + atoms: vec![TapeAtom::Space { + width: 4, + properties: AtomProperties { + content: Some(1), + content_idx: Some(prefix_lines as i64), + owner: Some(1), + owners: vec![1], + ..AtomProperties::default() + }, + }], + break_after: Some(AtomProperties::default()), + }); + let mut suffix = owned_line(30, 0); + suffix.break_after = None; + lines.push(suffix); + LayoutTape { + style_count: 0, + lines, + } + } + + #[test] + fn prefix_line_growth_and_shrink_update_only_the_inherited_suffix_content_index() { + let mut empty_suffix = nonuniform_text(20, &[0]); + let LayoutNode::Text { content, .. } = &mut empty_suffix else { + unreachable!() + }; + *content = Arc::new(measured_text(vec![Vec::new()])); + let root = identified( + child_box( + 1, + LayoutNode::Column { + node_id: None, + node_revision: None, + children: Arc::new(vec![ + identified(nonuniform_text(10, &[1]), 2, 1), + identified(empty_suffix, 3, 1), + identified(nonuniform_text(30, &[1]), 4, 1), + ]), + }, + None, + ), + 1, + 1, + ); + let mut document = RetainedDocument::bootstrap(retained_document(root)) + .unwrap() + .0; + let context = LayoutContext { + viewport_width: 4, + ..test_context() + }; + let mut frame = document.render_frame(None, None, context, None).unwrap(); + assert_eq!(frame.materialize_tape(), expected_column_with_prefix(1)); + + for (revision, prefix_lines) in [(1, 3), (2, 1)] { + let lines = vec![ + serde_json::json!({"clusters":[{ + "text":"x","width":1,"cjk":false,"space":false + }]}); + prefix_lines + ]; + let applied = document + .apply_delta(context_test_delta(vec![serde_json::json!({ + "node-id":2,"expected-revision":revision,"target-revision":revision+1, + "slot-patches":[{"slot":0,"local":{"content":{"lines":lines}}}] + })])) + .unwrap(); + frame = applied + .document + .render_frame(Some(&frame), Some(&applied.changes), context, None) + .unwrap(); + assert_eq!( + frame.materialize_tape(), + expected_column_with_prefix(prefix_lines) + ); + document = applied.document; + } + } +} diff --git a/native/src/evaluation.rs b/native/src/evaluation.rs index a360971..d4f2341 100644 --- a/native/src/evaluation.rs +++ b/native/src/evaluation.rs @@ -4,6 +4,8 @@ use std::cell::{Cell, RefCell}; use std::collections::{BTreeMap, BTreeSet}; use std::sync::Arc; +use super::composition::ColumnState; +use super::line_plan::{BoxProjectionSlot, PlanChange, ProjectionState}; use super::source_topology::SourceAddress; use super::{ BoxOverride, LayoutContext, LayoutNode, LayoutTape, LocalStep, Rendered, RetainedDocument, @@ -28,6 +30,16 @@ pub(crate) struct EvalWork { pub(crate) topology_lookups: u64, pub(crate) incoming_uses_visited: u64, pub(crate) height_queries: u64, + pub(crate) child_use_nodes_created: u64, + pub(crate) child_use_nodes_visited: u64, + pub(crate) dirty_routes_written: u64, + pub(crate) dirty_routes_visited: u64, + pub(crate) column_shape_steps_visited: u64, + pub(crate) column_slots_built: u64, + pub(crate) column_slots_updated: u64, + pub(crate) column_tree_nodes_created: u64, + pub(crate) column_tree_nodes_visited: u64, + pub(crate) column_full_rebuilds: u64, } thread_local! { @@ -55,7 +67,17 @@ impl EvalWork { dirty_addresses_inserted, topology_lookups, incoming_uses_visited, - height_queries + height_queries, + child_use_nodes_created, + child_use_nodes_visited, + dirty_routes_written, + dirty_routes_visited, + column_shape_steps_visited, + column_slots_built, + column_slots_updated, + column_tree_nodes_created, + column_tree_nodes_visited, + column_full_rebuilds ); } } @@ -67,7 +89,7 @@ pub(crate) fn work() -> EvalWork { WORK.get() } -fn record(delta: EvalWork) { +pub(super) fn record(delta: EvalWork) { let mut work = WORK.get(); work.accumulate(delta); WORK.set(work); @@ -123,11 +145,247 @@ struct EvalRequest { } #[derive(Debug)] -struct EvalRecord { +pub(super) struct EvalRecord { source: SourceAddress, request: EvalRequest, rendered: Rendered, - children: BTreeMap>, + children: ChildUses, + column: Option>, + projections: BTreeMap>, +} + +/// Persistent caller-local map. Path copying writes only the search path; keys +/// and current child records remain shared. There is no historical use table. +#[derive(Clone, Debug, Default)] +struct ChildUses(Option>); + +#[derive(Debug)] +struct UseNode { + slot: Arc, + value: Arc, + left: ChildUses, + right: ChildUses, + height: u32, + len: usize, +} + +impl ChildUses { + fn from_fresh(entries: BTreeMap>) -> Self { + fn build( + entries: &mut impl Iterator)>, + count: usize, + ) -> ChildUses { + if count == 0 { + return ChildUses::default(); + } + let middle = count / 2; + let left = build(entries, middle); + let (slot, value) = entries.next().expect("fresh caller-use count"); + let right = build(entries, count - middle - 1); + ChildUses::node(Arc::new(slot), value, left, right) + } + let count = entries.len(); + build(&mut entries.into_iter(), count) + } + + fn height(&self) -> u32 { + self.0.as_ref().map_or(0, |node| node.height) + } + + fn len(&self) -> usize { + self.0.as_ref().map_or(0, |node| node.len) + } + + fn node(slot: Arc, value: Arc, left: Self, right: Self) -> Self { + record(EvalWork { + child_use_nodes_created: 1, + ..EvalWork::default() + }); + Self(Some(Arc::new(UseNode { + slot, + value, + height: 1 + left.height().max(right.height()), + len: 1 + left.len() + right.len(), + left, + right, + }))) + } + + fn balanced(slot: Arc, value: Arc, left: Self, right: Self) -> Self { + if left.height() > right.height() + 1 { + let root = left.0.as_ref().expect("left-heavy caller tree"); + if root.left.height() >= root.right.height() { + return Self::node( + Arc::clone(&root.slot), + Arc::clone(&root.value), + root.left.clone(), + Self::node(slot, value, root.right.clone(), right), + ); + } + let middle = root.right.0.as_ref().expect("left-right caller tree"); + return Self::node( + Arc::clone(&middle.slot), + Arc::clone(&middle.value), + Self::node( + Arc::clone(&root.slot), + Arc::clone(&root.value), + root.left.clone(), + middle.left.clone(), + ), + Self::node(slot, value, middle.right.clone(), right), + ); + } + if right.height() > left.height() + 1 { + let root = right.0.as_ref().expect("right-heavy caller tree"); + if root.right.height() >= root.left.height() { + return Self::node( + Arc::clone(&root.slot), + Arc::clone(&root.value), + Self::node(slot, value, left, root.left.clone()), + root.right.clone(), + ); + } + let middle = root.left.0.as_ref().expect("right-left caller tree"); + return Self::node( + Arc::clone(&middle.slot), + Arc::clone(&middle.value), + Self::node(slot, value, left, middle.left.clone()), + Self::node( + Arc::clone(&root.slot), + Arc::clone(&root.value), + middle.right.clone(), + root.right.clone(), + ), + ); + } + Self::node(slot, value, left, right) + } + + fn get(&self, slot: &UseSlot) -> Option<&Arc> { + let mut current = self.0.as_deref(); + while let Some(node) = current { + record(EvalWork { + child_use_nodes_visited: 1, + ..EvalWork::default() + }); + match slot.cmp(&node.slot) { + std::cmp::Ordering::Less => current = node.left.0.as_deref(), + std::cmp::Ordering::Greater => current = node.right.0.as_deref(), + std::cmp::Ordering::Equal => return Some(&node.value), + } + } + None + } + + fn inserted(&self, slot: Arc, value: Arc) -> Self { + let Some(node) = &self.0 else { + return Self::node(slot, value, Self::default(), Self::default()); + }; + record(EvalWork { + child_use_nodes_visited: 1, + ..EvalWork::default() + }); + match slot.cmp(&node.slot) { + std::cmp::Ordering::Less => Self::balanced( + Arc::clone(&node.slot), + Arc::clone(&node.value), + node.left.inserted(slot, value), + node.right.clone(), + ), + std::cmp::Ordering::Greater => Self::balanced( + Arc::clone(&node.slot), + Arc::clone(&node.value), + node.left.clone(), + node.right.inserted(slot, value), + ), + std::cmp::Ordering::Equal => Self::node( + Arc::clone(&node.slot), + value, + node.left.clone(), + node.right.clone(), + ), + } + } + + #[cfg(test)] + fn iter(&self) -> impl Iterator)> { + let mut stack = Vec::new(); + let mut next = self.0.as_deref(); + std::iter::from_fn(move || { + while let Some(node) = next { + stack.push(node); + next = node.left.0.as_deref(); + } + let node = stack.pop()?; + next = node.right.0.as_deref(); + Some((node.slot.as_ref(), &node.value)) + }) + } + + #[cfg(test)] + fn values(&self) -> impl Iterator> { + self.iter().map(|(_, value)| value) + } + + #[cfg(test)] + fn keys(&self) -> impl Iterator { + self.iter().map(|(slot, _)| slot) + } +} + +/// Full evaluation uses a mutable fresh collector, then freezes exactly N nodes. +/// A proven sparse Column starts from an immutable current root and path-copies. +#[derive(Debug)] +enum BuildingUses { + Fresh(BTreeMap>), + Sparse(ChildUses), +} + +impl Default for BuildingUses { + fn default() -> Self { + Self::Fresh(BTreeMap::new()) + } +} + +impl BuildingUses { + fn len(&self) -> usize { + match self { + Self::Fresh(entries) => entries.len(), + Self::Sparse(entries) => entries.len(), + } + } + + fn insert(&mut self, slot: UseSlot, value: Arc) { + match self { + Self::Fresh(entries) => assert!( + entries.insert(slot, value).is_none(), + "unique caller use slot" + ), + Self::Sparse(entries) => *entries = entries.inserted(Arc::new(slot), value), + } + } + + fn finish(self) -> ChildUses { + match self { + Self::Fresh(entries) => ChildUses::from_fresh(entries), + Self::Sparse(entries) => entries, + } + } + + #[cfg(test)] + fn values(&self) -> Box> + '_> { + match self { + Self::Fresh(entries) => Box::new(entries.values()), + Self::Sparse(entries) => Box::new(entries.values()), + } + } +} + +#[derive(Debug)] +pub(super) struct RenderedChange { + pub(super) rendered: Rendered, + pub(super) change: Option, + pub(super) record: Option>, } /// Owns the current document and current evaluation tree, never a previous frame. @@ -152,14 +410,18 @@ struct BuildingRecord { source: SourceAddress, request: EvalRequest, previous: Option>, - children: BTreeMap>, + children: BuildingUses, occurrences: BTreeMap, usize>, + column: Option>, + projections: BTreeMap>, + pending_change: Option, } #[derive(Debug)] struct RenderTxn { previous: Option>, - dirty: BTreeSet, + dirty: DirtySources, + same_shape: bool, stack: Vec, root: Option>, } @@ -170,7 +432,7 @@ impl RenderTxn { source: &SourceAddress, path: &[UseStep], request: EvalRequest, - ) -> Option { + ) -> Option { record(EvalWork { lookups: 1, ..EvalWork::default() @@ -203,15 +465,19 @@ impl RenderTxn { ) }; if let Some(old) = previous.as_ref().filter(|old| { - old.source == *source && old.request == request && !self.dirty.contains(source) + old.source == *source && old.request == request && !self.dirty.contains_key(source) }) { record(EvalWork { hits: 1, ..EvalWork::default() }); - let rendered = old.rendered.clone(); + let result = RenderedChange { + rendered: old.rendered.clone(), + change: Some(PlanChange::same(&old.rendered.lines)), + record: Some(Arc::clone(old)), + }; self.attach(slot, Arc::clone(old)); - return Some(rendered); + return Some(result); } record(EvalWork { body_runs: 1, @@ -222,38 +488,50 @@ impl RenderTxn { source: source.clone(), request, previous, - children: BTreeMap::new(), + children: BuildingUses::default(), occurrences: BTreeMap::new(), + column: None, + projections: BTreeMap::new(), + pending_change: None, }); None } - fn finish(&mut self, rendered: &Result) { + fn finish(&mut self, rendered: Result) -> Result { let current = self.stack.pop().expect("native evaluation builder stack"); - if let Ok(rendered) = rendered { - record(EvalWork { - records_created: 1, - ..EvalWork::default() - }); - self.attach( - current.slot, - Arc::new(EvalRecord { - source: current.source, - request: current.request, - rendered: rendered.clone(), - children: current.children, - }), - ); - } + let rendered = rendered?; + let change = current.previous.as_ref().map(|previous| { + current + .pending_change + .filter(|change| change.applies_to(&previous.rendered.lines, &rendered.lines)) + .unwrap_or_else(|| { + PlanChange::replace_all(&previous.rendered.lines, &rendered.lines) + }) + }); + record(EvalWork { + records_created: 1, + ..EvalWork::default() + }); + let retained = Arc::new(EvalRecord { + source: current.source, + request: current.request, + rendered: rendered.clone(), + children: current.children.finish(), + column: current.column, + projections: current.projections, + }); + self.attach(current.slot, Arc::clone(&retained)); + Ok(RenderedChange { + rendered, + change, + record: Some(retained), + }) } fn attach(&mut self, slot: UseSlot, record: Arc) { if let Some(parent) = self.stack.last_mut() { record_work_edge(); - assert!( - parent.children.insert(slot, record).is_none(), - "unique caller use slot" - ); + parent.children.insert(slot, record); } else { self.root = Some(record); } @@ -377,6 +655,16 @@ impl<'a> RenderScope<'a> { intrinsic: bool, size_override: Option, ) -> Result { + self.render_with_change(context, intrinsic, size_override) + .map(|result| result.rendered) + } + + pub(super) fn render_with_change( + &self, + context: LayoutContext, + intrinsic: bool, + size_override: Option, + ) -> Result { let source = self.resolve()?; if let Some(transaction) = source.transaction { let request = EvalRequest { @@ -399,14 +687,131 @@ impl<'a> RenderScope<'a> { }; let rendered = super::render_node_body(&body_scope, context, intrinsic, size_override); if let Some(transaction) = body_scope.transaction { - transaction.borrow_mut().finish(&rendered); + return transaction.borrow_mut().finish(rendered); } - rendered + rendered.map(|rendered| RenderedChange { + rendered, + change: None, + record: None, + }) + } + + pub(super) fn is_retained(&self) -> bool { + self.transaction.is_some() + } + + pub(super) fn publish_change(&self, change: PlanChange) { + if let Some(transaction) = self.transaction { + transaction + .borrow_mut() + .stack + .last_mut() + .expect("current evaluation") + .pending_change = Some(change); + } + } + + pub(super) fn previous_projection( + &self, + slot: BoxProjectionSlot, + ) -> Option> { + let transaction = self.transaction?.borrow(); + let current = transaction.stack.last()?; + let previous = current + .previous + .as_ref() + .filter(|old| old.source == current.source)?; + previous.projections.get(&slot).cloned() + } + + pub(super) fn store_projection(&self, slot: BoxProjectionSlot, state: Arc) { + if let Some(transaction) = self.transaction { + transaction + .borrow_mut() + .stack + .last_mut() + .expect("current evaluation") + .projections + .insert(slot, state); + } + } + + pub(super) fn previous_column(&self) -> Option> { + let transaction = self.transaction?.borrow(); + let current = transaction.stack.last()?; + let previous = current.previous.as_ref().filter(|old| { + transaction.same_shape + && old.source == current.source + && old.request == current.request + && !transaction + .dirty + .get(¤t.source) + .is_some_and(|route| route.local) + })?; + previous.column.clone() + } + + pub(super) fn inherit_column_uses(&self) { + let mut transaction = self.transaction.expect("retained Column").borrow_mut(); + let current = transaction.stack.last_mut().expect("current evaluation"); + assert_eq!( + current.children.len(), + 0, + "inherit before evaluating dirty Column slots" + ); + current.children = BuildingUses::Sparse( + current + .previous + .as_ref() + .expect("previous Column") + .children + .clone(), + ); + } + + pub(super) fn store_column(&self, state: Arc) { + self.transaction + .expect("retained Column") + .borrow_mut() + .stack + .last_mut() + .expect("current evaluation") + .column = Some(state); + } + + pub(super) fn dirty_route(&self, relative: &[LocalStep]) -> DirtyRoute { + let Some(transaction) = self.transaction else { + return DirtyRoute::default(); + }; + let mut path = self.view.address.path.to_vec(); + path.extend_from_slice(relative); + record(EvalWork { + source_path_steps_copied: path.len() as u64, + dirty_routes_visited: 1, + ..EvalWork::default() + }); + transaction + .borrow() + .dirty + .get(&SourceAddress { + owner_id: self.view.address.owner_id, + path: path.into(), + }) + .cloned() + .unwrap_or_default() } } -fn dirty_sources(document: &RetainedDocument, changes: &SourceChanges) -> BTreeSet { - fn mark(address: SourceAddress, dirty: &mut BTreeSet, owners: &mut Vec) { +#[derive(Clone, Debug, Default)] +pub(super) struct DirtyRoute { + pub(super) local: bool, + pub(super) next: BTreeSet, +} + +type DirtySources = BTreeMap; + +fn dirty_sources(document: &RetainedDocument, changes: &SourceChanges) -> DirtySources { + fn mark(address: SourceAddress, dirty: &mut DirtySources, owners: &mut Vec) { for length in (0..=address.path.len()).rev() { record(EvalWork { dirty_prefixes_visited: 1, @@ -417,7 +822,17 @@ fn dirty_sources(document: &RetainedDocument, changes: &SourceChanges) -> BTreeS owner_id: address.owner_id, path: Arc::from(&address.path[..length]), }; - if dirty.insert(prefix) { + let is_new = !dirty.contains_key(&prefix); + let route = dirty.entry(prefix).or_default(); + if length == address.path.len() { + route.local = true; + } else if route.next.insert(address.path[length]) { + record(EvalWork { + dirty_routes_written: 1, + ..EvalWork::default() + }); + } + if is_new { record(EvalWork { dirty_addresses_inserted: 1, ..EvalWork::default() @@ -428,7 +843,7 @@ fn dirty_sources(document: &RetainedDocument, changes: &SourceChanges) -> BTreeS } } } - let mut dirty = BTreeSet::new(); + let mut dirty = BTreeMap::new(); let mut owners = Vec::new(); changes.visit_changed_slots(|owner_id, _, _, slot, _| { record(EvalWork { @@ -500,11 +915,13 @@ impl RetainedDocument { }; let dirty = match (reusable, changes) { (Some(_), Some(changes)) => dirty_sources(self, changes), - _ => BTreeSet::new(), + _ => BTreeMap::new(), }; let transaction = RefCell::new(RenderTxn { previous: reusable.map(|previous| Arc::clone(&previous.root)), dirty, + same_shape: reusable + .is_some_and(|previous| Arc::ptr_eq(&previous.document.topology, &self.topology)), stack: Vec::new(), root: None, }); diff --git a/native/src/evaluation_tests.rs b/native/src/evaluation_tests.rs index 9ec2206..27008dd 100644 --- a/native/src/evaluation_tests.rs +++ b/native/src/evaluation_tests.rs @@ -294,8 +294,8 @@ fn nonzero_mixed_leaf_delta_reuses_unchanged_subtree_bodies() { "only root Box/Column, changed Row/Box/Text bodies run" ); assert_eq!( - measured.hits, 8, - "seven unchanged rows plus the changed row's unchanged Flex" + measured.hits, 1, + "the changed row's unchanged Flex is looked up; other rows stay in shared slots" ); assert_eq!(measured.records_created, 5); } @@ -333,14 +333,9 @@ fn fixed_changed_subtree_reuses_bodies_across_sibling_sizes() { measured.body_runs <= 20, "N={size}, offset={offset}: {measured:?}" ); - assert!(measured.hits >= size as u64 - 1); assert!( - measured.caller_edges_written >= size as u64, - "parent sibling edge writes remain N" - ); - assert!( - line_work.lines_mapped >= size as u64, - "parent line maps remain explicit N work" + measured.caller_edges_written <= 64, + "only changed-row calls write caller edges: {measured:?}" ); assert_eq!(measured.dirty_seeds, 1); assert!(measured.dirty_addresses_inserted <= 9); @@ -357,6 +352,496 @@ fn fixed_changed_subtree_reuses_bodies_across_sibling_sizes() { } } +#[test] +fn sparse_column_update_does_not_visit_resolve_or_rewrite_unchanged_siblings() { + for size in [32, 128, 512] { + let document = mixed(size); + let baseline = document + .render_frame(None, None, test_context(), None) + .unwrap(); + let update = document + .apply_delta(context_test_delta(vec![text_update( + row_id(size / 2) + 1, + 1, + 1, + "changed", + 1, + )])) + .unwrap(); + reset_work(); + super::super::reset_resolver_lookups(); + let target = update + .document + .render_frame(Some(&baseline), Some(&update.changes), test_context(), None) + .unwrap(); + let measured = work(); + let resolvers = super::super::resolver_lookups(); + eprintln!("sparse Column N={size}, eval={measured:?}, resolvers={resolvers}"); + assert_oracle(&target, test_context(), None); + assert_eq!(measured.body_runs, 5); + assert!(measured.child_slots_visited <= 12, "N={size}: {measured:?}"); + assert!( + measured.caller_edges_written <= 12, + "N={size}: {measured:?}" + ); + assert!(resolvers <= 12, "N={size}: resolvers={resolvers}"); + assert!(Arc::ptr_eq( + owner_record(&baseline.root, row_id(size - 1)), + owner_record(&target.root, row_id(size - 1)) + )); + } +} + +fn root_column_record(frame: &RetainedFrame) -> &Arc { + frame + .root + .children + .values() + .find(|child| child.column.is_some()) + .expect("fixture root has a complete retained Column") +} + +fn root_column(frame: &RetainedFrame) -> &Arc { + root_column_record(frame).column.as_ref().unwrap() +} + +#[test] +fn front_line_growth_and_shrink_share_column_suffix_slots_and_release_old_state() { + for size in [32, 128, 512] { + let mut document = mixed(size); + let mut frame = document + .render_frame(None, None, test_context(), None) + .unwrap(); + let original_height = owner_record(&frame.root, row_id(0)).rendered.height(); + for (revision, line_count) in [(1, original_height + 3), (2, 1)] { + let lines = (0..line_count) + .map(|_| { + serde_json::json!({"clusters":[{ + "text":"x", "width":1, "cjk":false, "space":false + }]}) + }) + .collect::>(); + let update = document.apply_delta(context_test_delta(vec![serde_json::json!({ + "node-id":row_id(0)+1, "expected-revision":revision, "target-revision":revision+1, + "slot-patches":[{"slot":1,"local":{"content":{"lines":lines}}}] + })])).unwrap(); + let old_document = Arc::downgrade(&document); + let old_column = Arc::downgrade(root_column(&frame)); + reset_work(); + super::super::reset_resolver_lookups(); + super::super::reset_line_plan_work(); + let target = update + .document + .render_frame(Some(&frame), Some(&update.changes), test_context(), None) + .unwrap(); + let measured = work(); + let resolvers = super::super::resolver_lookups(); + let line_work = super::super::line_plan_work(); + assert!(measured.child_slots_visited <= 12, "N={size}: {measured:?}"); + assert!( + measured.caller_edges_written <= 12, + "N={size}: {measured:?}" + ); + assert!(resolvers <= 12, "N={size}: {resolvers}"); + assert_eq!(measured.column_slots_updated, 1); + assert_eq!(measured.column_slots_built, 0); + assert_eq!(measured.column_full_rebuilds, 0); + assert!( + measured.child_use_nodes_created <= 32, + "N={size}: {measured:?}" + ); + assert!( + measured.column_tree_nodes_created <= 12, + "N={size}: {measured:?}" + ); + assert!(root_column(&frame).shares_shape(root_column(&target))); + assert!(root_column(&frame).shares_slot(root_column(&target), size - 1)); + assert!(!root_column(&frame).shares_slot(root_column(&target), 0)); + assert!( + Arc::ptr_eq( + root_column_record(&frame) + .children + .0 + .as_ref() + .unwrap() + .right + .0 + .as_ref() + .unwrap(), + root_column_record(&target) + .children + .0 + .as_ref() + .unwrap() + .right + .0 + .as_ref() + .unwrap(), + ), + "the unchanged right half of the caller-use map must be shared" + ); + assert_oracle(&target, test_context(), None); + assert_ne!(frame.materialize_tape(), target.materialize_tape()); + eprintln!("front Column N={size}, lines={line_count}, eval={measured:?}, line_work={line_work:?}"); + document = Arc::clone(&update.document); + drop(update); + frame = target; + assert!( + old_document.upgrade().is_none(), + "current slots must not retain the previous source document" + ); + assert!( + old_column.upgrade().is_none(), + "current slots/projections must not retain old parent state" + ); + } + } +} + +#[test] +fn padded_box_same_parameter_updates_bound_metric_work_and_release_previous_frames() { + use super::super::VerticalAlign; + + let mut excessive_work = Vec::new(); + for size in [32, 128, 512] { + for align in [ + VerticalAlign::Top, + VerticalAlign::Center, + VerticalAlign::Bottom, + ] { + let children = (0..size) + .map(|index| { + let id = index as u64 + 2; + identified(nonuniform_text(id as i64, &[1]), id, 1) + }) + .collect(); + let mut root = child_box( + 1, + LayoutNode::Column { + node_id: None, + node_revision: None, + children: Arc::new(children), + }, + None, + ); + let LayoutNode::Box { + width, + height, + padding_top, + padding_bottom, + padding_left, + padding_right, + margin_top, + margin_bottom, + margin_left, + margin_right, + foreground_style, + vertical_align, + .. + } = &mut root + else { + unreachable!() + }; + *width = Size::Pixels { value: 80 }; + *height = if align == VerticalAlign::Top { + Size::Auto + } else { + Size::Lines { + value: size as i64 + 12, + } + }; + *padding_top = 2; + *padding_bottom = 3; + *padding_left = 1; + *padding_right = 2; + *margin_top = 1; + *margin_bottom = 2; + *margin_left = 1; + *margin_right = 1; + *foreground_style = Some(0); + *vertical_align = align; + let mut input = retained_document(identified(root, 1, 1)); + input.style_count = 1; + input.styles.push( + serde_json::from_value(serde_json::json!({ + "mode":"set", "face":{"foreground":"red"} + })) + .unwrap(), + ); + let mut document = RetainedDocument::bootstrap(input).unwrap().0; + let mut frame = document + .render_frame(None, None, test_context(), None) + .unwrap(); + assert_oracle(&frame, test_context(), None); + for (revision, line_count, text) in + [(1, 1, "same-height"), (2, 4, "grow"), (3, 1, "shrink")] + { + let lines = (0..line_count) + .map(|_| { + serde_json::json!({"clusters":[{ + "text":text, "width":1, "cjk":false, "space":false + }]}) + }) + .collect::>(); + let mut delta = context_test_delta(vec![serde_json::json!({ + "node-id":2,"expected-revision":revision,"target-revision":revision+1, + "slot-patches":[{"slot":0,"local":{"content":{"lines":lines}}}] + })]); + delta.style_base_count = 1; + let update = document.apply_delta(delta).unwrap(); + let old_document = Arc::downgrade(&document); + let old_frame = Arc::downgrade(&frame); + let old_column = Arc::downgrade(root_column(&frame)); + let old_projections = frame + .root + .projections + .values() + .map(Arc::downgrade) + .collect::>(); + super::super::reset_line_plan_work(); + reset_work(); + let target = update + .document + .render_frame(Some(&frame), Some(&update.changes), test_context(), None) + .unwrap(); + let line_work = super::super::line_plan_work(); + let evaluated = work(); + assert_eq!(evaluated.column_slots_updated, 1); + assert_eq!(evaluated.column_full_rebuilds, 0); + assert!(root_column(&frame).shares_slot(root_column(&target), size - 1)); + assert_oracle(&target, test_context(), None); + assert_ne!(frame.materialize_tape(), target.materialize_tape()); + eprintln!("padded Box N={size}, align={align:?}, update={text}, eval={evaluated:?}, line_work={line_work:?}"); + if line_work.projection_lines_measured > 48 + || line_work.projection_fallback_lines_measured > 16 + { + excessive_work.push(format!("N={size}, align={align:?}, update={text}: projection_lines_measured={}, fallback_lines_measured={}", + line_work.projection_lines_measured, line_work.projection_fallback_lines_measured)); + } + document = Arc::clone(&update.document); + drop(update); + frame = target; + assert!( + old_document.upgrade().is_none(), + "framing must not retain the old document" + ); + assert!( + old_frame.upgrade().is_none(), + "framing must not retain the old frame" + ); + assert!( + old_column.upgrade().is_none(), + "framing must not retain the old Column state" + ); + assert!( + old_projections + .iter() + .all(|state| state.upgrade().is_none()), + "framing must not retain old projection states" + ); + } + } + } + assert!( + excessive_work.is_empty(), + "same-parameter padded Box work must remain bounded:\n{}", + excessive_work.join("\n") + ); +} + +#[test] +fn cold_column_and_child_uses_freeze_linear_nodes_and_request_changes_rebuild() { + for size in [32, 128, 512] { + let document = mixed(size); + reset_work(); + let baseline = document + .render_frame(None, None, test_context(), None) + .unwrap(); + let cold = work(); + assert_eq!( + cold.child_use_nodes_created + 1, + cold.records_created, + "fresh maps freeze once per current caller edge: {cold:?}" + ); + assert_eq!(cold.column_slots_built, size as u64); + assert_eq!(cold.column_tree_nodes_created, 2 * size as u64 - 1); + let changed_context = LayoutContext { + viewport_width: 79, + ..test_context() + }; + reset_work(); + let target = document + .render_frame(Some(&baseline), None, changed_context, None) + .unwrap(); + let changed = work(); + assert_eq!(changed.column_slots_built, size as u64); + assert_eq!(changed.column_slots_updated, 0); + assert!(!root_column(&baseline).shares_shape(root_column(&target))); + assert_oracle(&target, changed_context, None); + } +} + +#[test] +fn intrinsic_column_target_changes_retarget_constant_child_extra_and_count_full_work() { + let document = RetainedDocument::bootstrap(retained_document(identified( + LayoutNode::Column { + node_id: None, + node_revision: None, + children: Arc::new(vec![ + identified(nonuniform_text(2, &[1, 9]), 2, 1), + identified(nonuniform_text(3, &[5, 1]), 3, 1), + ]), + }, + 1, + 1, + ))) + .unwrap() + .0; + let context = LayoutContext { + viewport_width_known: false, + ..test_context() + }; + let baseline = document.render_frame(None, None, context, None).unwrap(); + assert_eq!( + baseline + .materialize_tape() + .lines + .iter() + .map(|line| line.width) + .collect::>(), + [5, 13, 5, 1] + ); + let lines = [7, 9] + .into_iter() + .map(|width| { + serde_json::json!({"clusters":[{ + "text":"x", "width":width, "cjk":false, "space":false + }]}) + }) + .collect::>(); + 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":{"content":{"lines":lines}}}] + })])) + .unwrap(); + reset_work(); + let target = update + .document + .render_frame(Some(&baseline), Some(&update.changes), context, None) + .unwrap(); + let measured = work(); + assert_eq!( + target + .materialize_tape() + .lines + .iter() + .map(|line| line.width) + .collect::>(), + [7, 9, 7, 3] + ); + assert_eq!(measured.column_full_rebuilds, 1); + assert_eq!( + measured.column_slots_updated, 3, + "one raw replacement and both normalized slots" + ); + assert_eq!(measured.column_slots_built, 0); + assert!(Arc::ptr_eq( + owner_record(&baseline.root, 3), + owner_record(&target.root, 3) + )); + assert_oracle(&target, context, None); +} + +#[test] +fn sparse_routes_keep_nested_literal_paths_zero_width_lines_and_node_ref_boundaries() { + let column = |children| LayoutNode::Column { + node_id: None, + node_revision: None, + children: Arc::new(children), + }; + let document = RetainedDocument::bootstrap(retained_document(identified( + child_box( + 1, + column(vec![ + column(vec![ + identified(child_box(2, nonuniform_text(12, &[1]), None), 2, 1), + column(vec![nonuniform_text(99, &[0])]), + identified(nonuniform_text(3, &[1]), 3, 1), + ]), + identified( + column(vec![identified( + child_box(5, nonuniform_text(15, &[1]), None), + 5, + 1, + )]), + 4, + 1, + ), + ]), + None, + ), + 1, + 1, + ))) + .unwrap() + .0; + let baseline = document + .render_frame(None, None, test_context(), None) + .unwrap(); + assert_oracle(&baseline, test_context(), None); + let update = document + .apply_delta(context_test_delta(vec![ + text_update(2, 1, 1, "left", 2), + text_update(5, 1, 1, "right", 3), + ])) + .unwrap(); + reset_work(); + let target = update + .document + .render_frame(Some(&baseline), Some(&update.changes), test_context(), None) + .unwrap(); + let measured = work(); + assert_eq!( + measured.column_slots_updated, 3, + "two outer slots and one NodeRef Column slot" + ); + assert_eq!(measured.column_slots_built, 0); + assert_eq!(measured.column_full_rebuilds, 0); + assert!(root_column(&baseline).shares_shape(root_column(&target))); + assert!(root_column(&baseline).shares_slot(root_column(&target), 1)); + assert!(root_column(&baseline).shares_slot(root_column(&target), 2)); + let paths = root_column_record(&target) + .children + .keys() + .map(|slot| slot.path.clone()) + .collect::>(); + assert_eq!( + paths, + vec![ + vec![ + UseStep::Child(LocalStep::ColumnChild(0)), + UseStep::Child(LocalStep::ColumnChild(0)) + ], + vec![ + UseStep::Child(LocalStep::ColumnChild(0)), + UseStep::Child(LocalStep::ColumnChild(1)), + UseStep::Child(LocalStep::ColumnChild(0)) + ], + vec![ + UseStep::Child(LocalStep::ColumnChild(0)), + UseStep::Child(LocalStep::ColumnChild(2)) + ], + vec![UseStep::Child(LocalStep::ColumnChild(1))], + ] + ); + assert!( + owner_record(&target.root, 4).column.is_some(), + "NodeRef Column remains its own caller" + ); + assert_oracle(&target, test_context(), None); +} + #[test] fn parent_and_child_source_updates_share_only_clean_descendants() { let document = mixed(4); @@ -478,7 +963,8 @@ fn render_request( ) -> Arc { let transaction = RefCell::new(RenderTxn { previous: previous.map(|frame| Arc::clone(&frame.root)), - dirty: BTreeSet::new(), + dirty: BTreeMap::new(), + same_shape: true, stack: Vec::new(), root: None, }); @@ -805,7 +1291,14 @@ fn repeated_calls( }; let transaction = RefCell::new(RenderTxn { previous: previous.map(|frame| Arc::clone(&frame.root)), - dirty: BTreeSet::from([source.clone()]), + dirty: BTreeMap::from([( + source.clone(), + DirtyRoute { + local: true, + next: BTreeSet::new(), + }, + )]), + same_shape: true, stack: Vec::new(), root: None, }); @@ -854,7 +1347,8 @@ fn repeated_calls( .collect(); transaction .borrow_mut() - .finish(&Ok(super::super::stack_vertical(parts))); + .finish(Ok(super::super::stack_vertical(parts))) + .unwrap(); Arc::new(RetainedFrame { document: Arc::clone(document), root: transaction.into_inner().root.unwrap(), @@ -874,7 +1368,7 @@ fn repeated_callsite_uses_have_local_occurrences_and_prune_unused_contexts() { let keys = baseline.root.children.keys().collect::>(); assert_eq!(keys[0].path, keys[1].path); assert_eq!((keys[0].occurrence, keys[1].occurrence), (0, 1)); - let removed = Arc::downgrade(&baseline.root.children[keys[1]]); + let removed = Arc::downgrade(baseline.root.children.get(keys[1]).unwrap()); reset_work(); let one = repeated_calls(&document, Some(&baseline), &[10]); assert_eq!(work().hits, 1); @@ -971,7 +1465,14 @@ fn unwinding_discards_current_builder_records_without_mutating_previous_frame() let source = baseline.root.source.clone(); let transaction = RefCell::new(RenderTxn { previous: Some(Arc::clone(&baseline.root)), - dirty: BTreeSet::from([source.clone()]), + dirty: BTreeMap::from([( + source.clone(), + DirtyRoute { + local: true, + next: BTreeSet::new(), + }, + )]), + same_shape: true, stack: Vec::new(), root: None, }); diff --git a/native/src/layout.rs b/native/src/layout.rs index 6ce1c2c..211e7c1 100644 --- a/native/src/layout.rs +++ b/native/src/layout.rs @@ -25,8 +25,8 @@ pub(crate) fn atom_plan_work() -> AtomPlanWork { #[path = "line_plan.rs"] mod line_plan; -use line_plan::LinePlan; pub(crate) use line_plan::LinePlanWork; +use line_plan::{BoxProjectionSlot, FrameSpec, LineOp, LinePlan, PlanChange, ProjectionState}; pub(crate) fn reset_line_plan_work() { line_plan::reset_work(); @@ -36,6 +36,8 @@ pub(crate) fn line_plan_work() -> LinePlanWork { line_plan::work() } +#[path = "composition.rs"] +mod composition; #[path = "evaluation.rs"] mod evaluation; pub(crate) use evaluation::{EvalWork, RetainedFrame}; @@ -2203,7 +2205,7 @@ impl Rendered { } fn first_width(&self) -> i64 { - self.lines.first().map_or(0, |line| line.width) + self.lines.first_width() } fn max_width(&self) -> i64 { @@ -2214,27 +2216,21 @@ impl Rendered { if wrap_mode == WrapMode::None { return self.max_width(); } - self.lines - .iter() - .map(|line| line.atoms.min_content_width()) - .max() - .unwrap_or(0) + self.lines.min_content_width() } fn height(&self) -> i64 { self.lines.height() } + #[cfg(test)] fn apply_scroll_window(&mut self, region_id: i64) { - self.lines = self - .lines - .map_lines(|_, mut line| { - line.atoms = line.atoms.apply_scroll_window(region_id); - line - }) - .map_breaks(|properties| { - properties.scroll_window = Some(region_id); - }); + self.lines = ProjectionState::new( + self.lines.clone(), + Arc::from([LineOp::ScrollWindow(region_id)]), + ) + .plan() + .clone(); } fn into_tape(self, style_count: u32) -> LayoutTape { @@ -2244,29 +2240,32 @@ impl Rendered { lines: self .lines .iter_with_breaks() - .map(|(line, break_after)| TapeLine { - width: line.width, - atoms: line - .atoms - .to_vec() - .into_iter() - .map(|atom| match atom { - Atom::Text { - text, - width, - properties, - .. - } => TapeAtom::Text { - text, - width, - properties, - }, - Atom::Space { width, properties } => { - TapeAtom::Space { width, properties } - } - }) - .collect(), - break_after: break_after.cloned(), + .map(|(view, break_after)| { + let line = view.materialize(); + TapeLine { + width: view.width(), + atoms: line + .atoms + .to_vec() + .into_iter() + .map(|atom| match atom { + Atom::Text { + text, + width, + properties, + .. + } => TapeAtom::Text { + text, + width, + properties, + }, + Atom::Space { width, properties } => { + TapeAtom::Space { width, properties } + } + }) + .collect(), + break_after: break_after.map(|view| view.materialize().into_owned()), + } }) .collect(), } @@ -5472,13 +5471,16 @@ fn wrap_rendered(rendered: Rendered, max_width: i64, mode: WrapMode) -> Rendered let mut lines = LinePlan::default(); let mut joining_break = AtomProperties::default(); for (line, break_after) in rendered.lines.iter_with_breaks() { - let pieces = LinePlan::from_lines(wrap_rendered_line(line, max_width, mode)); + let pieces = LinePlan::from_lines(wrap_rendered_line(&line.materialize(), max_width, mode)); lines = lines.concat_with_break(&pieces, joining_break); - joining_break = break_after.cloned().unwrap_or_default(); + joining_break = break_after + .map(|view| view.materialize().into_owned()) + .unwrap_or_default(); } Rendered { lines } } +#[cfg(test)] fn vertical_align(lines: LinePlan, height: usize, align: VerticalAlign, width: i64) -> LinePlan { let lines = lines.slice(0..lines.len().min(height)); if lines.len() >= height { @@ -6338,7 +6340,7 @@ fn concat_horizontal_sized(parts: Vec<(Rendered, i64)>, target_height: i64) -> R let mut line = Line::default(); for (lines, width) in &mut parts { if let Some(part) = lines.next() { - line.append(part); + line.append(&part.materialize()); } else { line.push_space(*width); } @@ -6919,6 +6921,47 @@ fn render_node_with_override( RenderScope::uncached(node, resolver).render(context, intrinsic, size_override) } +fn project_box_lines( + scope: &RenderScope<'_>, + slot: BoxProjectionSlot, + input: LinePlan, + change: &mut Option, + ops: Vec, +) -> LinePlan { + let ops: Arc<[LineOp]> = ops.into(); + let state = if let Some(previous) = scope.previous_projection(slot) { + let update = previous.update(input, change.as_ref(), ops); + *change = Some(update.change); + update.state + } else { + *change = None; + ProjectionState::new(input, ops) + }; + let lines = state.plan().clone(); + scope.store_projection(slot, state); + lines +} + +fn frame_box_lines( + scope: &RenderScope<'_>, + slot: BoxProjectionSlot, + input: LinePlan, + change: &mut Option, + frame: FrameSpec, +) -> LinePlan { + let state = if let Some(previous) = scope.previous_projection(slot) { + let update = previous.update_frame(input, change.as_ref(), frame); + *change = Some(update.change); + update.state + } else { + *change = None; + ProjectionState::new_frame(input, frame) + }; + let lines = state.plan().clone(); + scope.store_projection(slot, state); + lines +} + fn render_node_body( scope: &RenderScope<'_>, context: LayoutContext, @@ -7075,6 +7118,7 @@ fn render_node_body( && *vertical == VerticalAlign::Top; let mut windowed_child_start = None; let mut windowed_child_height = None; + let mut child_change = None; let mut child_rendered = if let Some(child) = child { let child_scope = scope.child(LocalStep::BoxChild, child); let child_context = LayoutContext { @@ -7109,14 +7153,22 @@ fn render_node_body( .expect("exact height checked above")?, ) } else { - Some(child_scope.render( + let evaluated = child_scope.render_with_change( child_context, intrinsic || intrinsic_child, None, - )?) + )?; + child_change = evaluated.change; + Some(evaluated.rendered) } } else { - Some(child_scope.render(child_context, intrinsic || intrinsic_child, None)?) + let evaluated = child_scope.render_with_change( + child_context, + intrinsic || intrinsic_child, + None, + )?; + child_change = evaluated.change; + Some(evaluated.rendered) } } else { None @@ -7209,18 +7261,25 @@ fn render_node_body( && border_top_style.is_none() && border_bottom_style.is_none() && child_rendered.as_ref().is_some_and(|rendered| { - rendered - .lines - .iter() - .all(|line| !line.atoms.is_empty() && line.width == content_width) + rendered.lines.all_nonempty() && rendered.lines.uniform_width(content_width) }); if transparent_preformatted { let mut rendered = child_rendered.take().expect("validated child"); - rendered.lines = rendered.lines.map_lines(|index, mut line| { - line.own_content(*region_id, index as i64); - line.apply_property_template(*surface_template_id); - line - }); + let mut ops = vec![LineOp::OwnContent { + region: *region_id, + start: 0, + }]; + ops.extend(surface_template_id.map(LineOp::Template)); + rendered.lines = project_box_lines( + scope, + BoxProjectionSlot::TransparentContent, + rendered.lines, + &mut child_change, + ops, + ); + if let Some(change) = child_change { + scope.publish_change(change); + } return Ok(rendered); } @@ -7235,30 +7294,35 @@ fn render_node_body( } } LinePlan::from_lines(content_lines) - .map_lines(|_, line| line.padded(content_width, *text_align)) } else { let rendered = child_rendered.expect("validated child"); - let uniform_width = rendered - .lines - .iter() - .all(|line| line.width == content_width); - let preserve_exact_width = *content_width_exact && *wrap_mode == WrapMode::None; + let uniform_width = rendered.lines.uniform_width(content_width); let rendered = if *wrap_mode != WrapMode::None && !uniform_width { + child_change = None; wrap_rendered(rendered, content_width, *wrap_mode) } else { rendered }; - if preserve_exact_width { - rendered.lines - } else { - rendered - .lines - .map_lines(|_, line| line.padded(content_width, *text_align)) - } + rendered.lines }; // Ordinary Box formatting historically rebuilds default breaks; // the transparent preformatted branch above preserves child breaks. - formatted = formatted.clear_breaks(); + let mut change = child_change; + let mut pad_ops = Vec::new(); + if content.is_some() || !(*content_width_exact && *wrap_mode == WrapMode::None) { + pad_ops.push(LineOp::PadTo { + width: content_width, + align: *text_align, + }); + } + pad_ops.push(LineOp::ClearBreaks); + formatted = project_box_lines( + scope, + BoxProjectionSlot::ContentPad, + formatted, + &mut change, + pad_ops, + ); let text_height = windowed_child_height.unwrap_or(formatted.len() as i64); let minimum_height = resolve_height( min_height, @@ -7315,6 +7379,9 @@ fn render_node_body( if let Some(start) = windowed_child_start { content_index_start = start; } else if formatted.len() > content_height as usize { + // Clipping changes operation origins. Until the exact old/new window + // anchors are available, the next stage takes its counted fallback. + change = None; match overflow { Overflow::Scroll => { let max_offset = formatted.len() - content_height as usize; @@ -7333,113 +7400,206 @@ fn render_node_body( } } } - formatted = formatted.map_lines(|index, mut line| { - line.own_content(*region_id, content_index_start + index as i64); - line - }); - let lines = - vertical_align(formatted, content_height as usize, *vertical, content_width) - .map_lines(|_, line| { - if simple_scroll_rendered { - line - } else { - line.collapse_whitespace_content(content_width, *region_id) - } - }); + formatted = project_box_lines( + scope, + BoxProjectionSlot::ContentOwn, + formatted, + &mut change, + vec![LineOp::OwnContent { + region: *region_id, + start: content_index_start, + }], + ); + if formatted.len() > content_height as usize { + change = None; + formatted = formatted.slice(0..content_height as usize); + } + let remaining = content_height as usize - formatted.len(); + let top = match vertical { + VerticalAlign::Top => 0, + VerticalAlign::Bottom => remaining, + VerticalAlign::Center => remaining / 2, + }; + let lines = frame_box_lines( + scope, + BoxProjectionSlot::VerticalFrame, + formatted, + &mut change, + FrameSpec { + prefix: top, + suffix: remaining - top, + width: content_width, + prefix_properties: Arc::new(AtomProperties::default()), + suffix_properties: Arc::new(AtomProperties::default()), + }, + ); + let collapse_ops = if simple_scroll_rendered { + Vec::new() + } else { + vec![LineOp::CollapseWhitespace { + width: content_width, + region: *region_id, + }] + }; + let lines = project_box_lines( + scope, + BoxProjectionSlot::ContentCollapse, + lines, + &mut change, + collapse_ops, + ); - let mut padded = LinePlan::from_lines((0..*padding_top).map(|_| { - Line::blank_with_properties( - content_width, - region_properties(RegionRole::PaddingTop, *region_id, None), + let mut padded = frame_box_lines( + scope, + BoxProjectionSlot::PaddingFrame, + lines, + &mut change, + FrameSpec { + prefix: *padding_top as usize, + suffix: *padding_bottom as usize, + width: content_width, + prefix_properties: region_properties(RegionRole::PaddingTop, *region_id, None) + .into(), + suffix_properties: region_properties( + RegionRole::PaddingBottom, + *region_id, + None, + ) + .into(), + }, + ); + let mut edge_ops = vec![LineOp::EdgeSpaces { + left: *padding_left, + left_properties: region_properties(RegionRole::PaddingLeft, *region_id, None) + .into(), + right: *padding_right, + right_properties: region_properties(RegionRole::PaddingRight, *region_id, None) + .into(), + }]; + edge_ops.extend( + [*typography_style, *foreground_style, *background_style] + .into_iter() + .flatten() + .map(LineOp::Style), + ); + edge_ops.push(LineOp::EdgeSpaces { + left: *border_left, + left_properties: region_properties( + RegionRole::BorderLeft, + *region_id, + *border_left_style, ) + .into(), + right: *border_right, + right_properties: region_properties( + RegionRole::BorderRight, + *region_id, + *border_right_style, + ) + .into(), + }); + padded = project_box_lines( + scope, + BoxProjectionSlot::PaddingEdges, + padded, + &mut change, + edge_ops, + ); + let mut boundary_ops = Vec::new(); + boundary_ops.extend(border_top_style.map(|style| LineOp::FirstStyleRole { + style, + region: *region_id, })); - padded = padded.concat(&lines); - padded = padded.concat(&LinePlan::from_lines((0..*padding_bottom).map(|_| { - Line::blank_with_properties( - content_width, - region_properties(RegionRole::PaddingBottom, *region_id, None), - ) - }))); - - padded = padded.map_lines(|_, mut line| { - line.prepend_space_with_properties( - *padding_left, - region_properties(RegionRole::PaddingLeft, *region_id, None), - ); - line.push_space_with_properties( - *padding_right, - region_properties(RegionRole::PaddingRight, *region_id, None), - ); - line.apply_style(*typography_style); - line.apply_style(*foreground_style); - line.apply_style(*background_style); - line.prepend_space_with_properties( - *border_left, - region_properties(RegionRole::BorderLeft, *region_id, *border_left_style), - ); - line.push_space_with_properties( - *border_right, - region_properties(RegionRole::BorderRight, *region_id, *border_right_style), - ); - line - }); - if border_top_style.is_some() && !padded.is_empty() { - padded = padded.update_line(0, |mut line| { - line.apply_style(*border_top_style); - line.apply_role(RegionRole::BorderTop, *region_id); - line - }); - } - if border_bottom_style.is_some() && !padded.is_empty() { - padded = padded.update_line(padded.len() - 1, |mut line| { - line.apply_style(*border_bottom_style); - line.apply_role(RegionRole::BorderBottom, *region_id); - line - }); - } - padded = padded.map_lines(|_, mut line| { - line.apply_property_template(*surface_template_id); - line - }); - padded = padded.map_lines(|_, mut line| { - line.prepend_space_with_properties( - *margin_left, - region_properties(RegionRole::MarginLeft, *region_id, None), - ); - line.push_space_with_properties( - *margin_right, - region_properties(RegionRole::MarginRight, *region_id, None), - ); - line - }); + boundary_ops.extend(border_bottom_style.map(|style| LineOp::LastStyleRole { + style, + region: *region_id, + })); + padded = project_box_lines( + scope, + BoxProjectionSlot::BoundaryBorders, + padded, + &mut change, + boundary_ops, + ); + padded = project_box_lines( + scope, + BoxProjectionSlot::SurfaceTemplate, + padded, + &mut change, + surface_template_id + .map(LineOp::Template) + .into_iter() + .collect(), + ); + padded = project_box_lines( + scope, + BoxProjectionSlot::MarginEdges, + padded, + &mut change, + vec![LineOp::EdgeSpaces { + left: *margin_left, + left_properties: region_properties(RegionRole::MarginLeft, *region_id, None) + .into(), + right: *margin_right, + right_properties: region_properties(RegionRole::MarginRight, *region_id, None) + .into(), + }], + ); let total_width = content_width + side_width; - let mut output = LinePlan::from_lines((0..*margin_top).map(|_| { - Line::blank_with_properties( - total_width, - region_properties(RegionRole::MarginTop, *region_id, None), - ) - })); - output = output.concat(&padded); - output = output.concat(&LinePlan::from_lines((0..*margin_bottom).map(|_| { - Line::blank_with_properties( - total_width, - region_properties(RegionRole::MarginBottom, *region_id, None), - ) - }))); + let output = frame_box_lines( + scope, + BoxProjectionSlot::MarginFrame, + padded, + &mut change, + FrameSpec { + prefix: *margin_top as usize, + suffix: *margin_bottom as usize, + width: total_width, + prefix_properties: region_properties(RegionRole::MarginTop, *region_id, None) + .into(), + suffix_properties: region_properties( + RegionRole::MarginBottom, + *region_id, + None, + ) + .into(), + }, + ); let mut rendered = Rendered::from_line_plan(output); if !overflow_lines.is_empty() { let left_space = margin_left + border_left + padding_left; let right_space = padding_right + border_right + margin_right; - overflow_lines = overflow_lines.map_lines(|_, mut line| { - line.apply_style(*foreground_style); - line.prepend_space(left_space); - line.push_space(right_space); - line + let mut ops = foreground_style + .map(LineOp::Style) + .into_iter() + .collect::>(); + ops.push(LineOp::EdgeSpaces { + left: left_space, + left_properties: Arc::new(AtomProperties::default()), + right: right_space, + right_properties: Arc::new(AtomProperties::default()), }); + overflow_lines = project_box_lines( + scope, + BoxProjectionSlot::OverflowEdges, + overflow_lines, + &mut None, + ops, + ); rendered = stack_vertical(vec![rendered, Rendered::from_line_plan(overflow_lines)]); + change = None; } if *overflow == Overflow::Scroll && text_height > content_height { - rendered.apply_scroll_window(*region_id); + rendered.lines = project_box_lines( + scope, + BoxProjectionSlot::ScrollWindow, + rendered.lines, + &mut change, + vec![LineOp::ScrollWindow(*region_id)], + ); + } + if let Some(change) = change { + scope.publish_change(change); } Ok(rendered) } @@ -7488,6 +7648,9 @@ fn render_node_body( Ok(concat_horizontal_sized(parts, 0)) } LayoutNode::Column { children, .. } => { + if scope.is_retained() { + return composition::render_column(scope, children, context, intrinsic); + } let rendered = column_leaves(scope, children) .map(|child| child.render(context, intrinsic, None)) .collect::, _>>()?; @@ -7576,6 +7739,8 @@ fn column_leaves<'a>( mod tests { use super::*; + include!("composition_tests.rs"); + include!("source_topology_tests.rs"); #[test] @@ -8085,14 +8250,14 @@ mod tests { }), ) .expect("typed column render"); - assert_eq!(rendered.lines.get(0).unwrap().width, 100); + assert_eq!(rendered.lines.get(0).unwrap().width(), 100); assert!( matches!( - rendered.lines.get(0).unwrap().atoms.first(), + rendered.lines.get(0).unwrap().materialize().atoms.first(), Some(Atom::Space { width: 45, .. }) ), "{:?}", - rendered.lines.get(0).unwrap().atoms + rendered.lines.get(0).unwrap().materialize().atoms ); } @@ -8595,10 +8760,16 @@ mod tests { let mut stacked = stack_vertical(vec![first, second]); assert_eq!(stacked.lines.len() - 1, 2); - assert_eq!(stacked.lines.break_after(0).unwrap().owner, Some(11)); assert_eq!( - stacked.lines.break_after(1), - Some(&AtomProperties::default()) + stacked.lines.break_after(0).unwrap().materialize().owner, + Some(11) + ); + assert_eq!( + stacked + .lines + .break_after(1) + .map(|view| view.materialize().into_owned()), + Some(AtomProperties::default()) ); stacked.apply_scroll_window(9); @@ -8606,9 +8777,10 @@ mod tests { .lines .iter_with_breaks() .filter_map(|(_, properties)| properties) - .all(|properties| { properties.scroll_window == Some(9) })); + .all(|properties| { properties.materialize().scroll_window == Some(9) })); assert!(stacked.lines.iter().all(|line| { - line.atoms + line.materialize() + .atoms .to_vec() .iter() .all(|atom| atom.properties().scroll_window == Some(9)) diff --git a/native/src/line_metrics.rs b/native/src/line_metrics.rs new file mode 100644 index 0000000..9f9351b --- /dev/null +++ b/native/src/line_metrics.rs @@ -0,0 +1,295 @@ +//! Persistent numeric summaries. No node retains a line, document, or old projection. +use std::ops::Range; +use std::sync::Arc; + +use super::{record, Line, LinePlanWork}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) struct LineMetric { + pub width: i64, + pub chars: u64, + pub min_content: i64, + pub nonempty: bool, + pub whitespace: bool, + pub noncontent: bool, +} + +impl LineMetric { + pub fn line(line: &Line) -> Self { + Self { + width: line.width, + chars: line.atoms.char_count(), + min_content: line.atoms.min_content_width(), + nonempty: !line.atoms.is_empty(), + whitespace: line.whitespace_only(), + noncontent: line.has_noncontent_properties(), + } + } +} + +#[derive(Clone, Copy, Debug, Default)] +pub(super) struct Summary { + pub count: usize, + pub chars: u64, + pub width_sum: i64, + pub first_width: i64, + pub min_width: i64, + pub max_width: i64, + pub min_content: i64, + pub all_nonempty: bool, +} + +impl Summary { + fn line(line: LineMetric) -> Self { + Self { + count: 1, + chars: line.chars, + width_sum: line.width, + first_width: line.width, + min_width: line.width, + max_width: line.width, + min_content: line.min_content, + all_nonempty: line.nonempty, + } + } + + fn combine(self, other: Self) -> Self { + if self.count == 0 { + return other; + } + if other.count == 0 { + return self; + } + Self { + count: self + .count + .checked_add(other.count) + .expect("validated line count"), + chars: self + .chars + .checked_add(other.chars) + .expect("validated character count"), + width_sum: self + .width_sum + .checked_add(other.width_sum) + .expect("validated line widths"), + first_width: self.first_width, + min_width: self.min_width.min(other.min_width), + max_width: self.max_width.max(other.max_width), + min_content: self.min_content.max(other.min_content), + all_nonempty: self.all_nonempty && other.all_nonempty, + } + } +} + +#[derive(Clone, Debug, Default)] +pub(super) struct Metrics(Option>); + +#[derive(Debug)] +struct Node { + summary: Summary, + height: u32, + kind: Kind, +} + +#[derive(Debug)] +enum Kind { + Leaf(Box<[LineMetric]>), + Branch(Metrics, Metrics), +} + +impl Metrics { + pub fn from_iter(lines: impl IntoIterator) -> Self { + let mut lines = lines.into_iter(); + let mut blocks = Vec::new(); + loop { + let block = lines.by_ref().take(16).collect::>(); + if block.is_empty() { + break; + } + blocks.push(Self::leaf(block.into_boxed_slice())); + } + fn build(lines: &[Metrics]) -> Metrics { + match lines { + [] => Metrics::default(), + [line] => line.clone(), + _ => Metrics::branch( + build(&lines[..lines.len() / 2]), + build(&lines[lines.len() / 2..]), + ), + } + } + build(&blocks) + } + + fn line(line: LineMetric) -> Self { + Self::leaf(Box::new([line])) + } + + fn leaf(lines: Box<[LineMetric]>) -> Self { + record(LinePlanWork { + metric_nodes_created: 1, + metric_entries_written: lines.len() as u64, + ..LinePlanWork::default() + }); + let summary = lines + .iter() + .copied() + .fold(Summary::default(), |summary, line| { + summary.combine(Summary::line(line)) + }); + Self(Some(Arc::new(Node { + summary, + height: 1, + kind: Kind::Leaf(lines), + }))) + } + + fn height(&self) -> u32 { + self.0.as_ref().map_or(0, |node| node.height) + } + pub fn summary(&self) -> Summary { + self.0 + .as_ref() + .map_or(Summary::default(), |node| node.summary) + } + + fn branch(left: Self, right: Self) -> Self { + if left.0.is_none() { + return right; + } + if right.0.is_none() { + return left; + } + record(LinePlanWork { + metric_nodes_created: 1, + ..LinePlanWork::default() + }); + Self(Some(Arc::new(Node { + summary: left.summary().combine(right.summary()), + height: left.height().max(right.height()) + 1, + kind: Kind::Branch(left, right), + }))) + } + + fn children(&self) -> (&Self, &Self) { + let Kind::Branch(left, right) = &self.0.as_ref().expect("nonempty metrics").kind else { + unreachable!("metric balance branch") + }; + (left, right) + } + + fn balanced(left: Self, right: Self) -> Self { + if left.height() > right.height() + 1 { + let (ll, lr) = left.children(); + if ll.height() >= lr.height() { + Self::branch(ll.clone(), Self::branch(lr.clone(), right)) + } else { + let (lrl, lrr) = lr.children(); + Self::branch( + Self::branch(ll.clone(), lrl.clone()), + Self::branch(lrr.clone(), right), + ) + } + } else if right.height() > left.height() + 1 { + let (rl, rr) = right.children(); + if rr.height() >= rl.height() { + Self::branch(Self::branch(left, rl.clone()), rr.clone()) + } else { + let (rll, rlr) = rl.children(); + Self::branch( + Self::branch(left, rll.clone()), + Self::branch(rlr.clone(), rr.clone()), + ) + } + } else { + Self::branch(left, right) + } + } + + pub fn concat(&self, other: &Self) -> Self { + if self.height() > other.height() + 1 { + let (left, right) = self.children(); + Self::balanced(left.clone(), right.concat(other)) + } else if other.height() > self.height() + 1 { + let (left, right) = other.children(); + Self::balanced(self.concat(left), right.clone()) + } else { + Self::branch(self.clone(), other.clone()) + } + } + + #[cfg(test)] + pub fn get(&self, index: usize) -> Option { + let node = self.0.as_ref()?; + record(LinePlanWork { + metric_nodes_visited: 1, + ..LinePlanWork::default() + }); + match &node.kind { + Kind::Leaf(lines) => lines.get(index).copied(), + Kind::Branch(left, right) => { + if index < left.summary().count { + left.get(index) + } else { + right.get(index - left.summary().count) + } + } + } + } + + pub fn slice(&self, range: Range) -> Self { + assert!(range.start <= range.end && range.end <= self.summary().count); + if range.is_empty() { + return Self::default(); + } + if range.start == 0 && range.end == self.summary().count { + return self.clone(); + } + record(LinePlanWork { + metric_nodes_visited: 1, + ..LinePlanWork::default() + }); + if let Kind::Leaf(lines) = &self.0.as_ref().expect("nonempty metrics").kind { + return Self::leaf(lines[range].into()); + } + let (left, right) = self.children(); + let middle = left.summary().count; + if range.end <= middle { + left.slice(range) + } else if range.start >= middle { + right.slice(range.start - middle..range.end - middle) + } else { + left.slice(range.start..middle) + .concat(&right.slice(0..range.end - middle)) + } + } + + pub fn replace(&self, index: usize, line: LineMetric) -> Self { + self.slice(0..index) + .concat(&Self::line(line)) + .concat(&self.slice(index + 1..self.summary().count)) + } + + pub fn iter(&self) -> impl Iterator + '_ { + let mut stack = self.0.as_deref().into_iter().collect::>(); + let mut leaf = [].iter(); + std::iter::from_fn(move || loop { + if let Some(line) = leaf.next() { + return Some(*line); + } + let node = stack.pop()?; + record(LinePlanWork { + metric_nodes_visited: 1, + ..LinePlanWork::default() + }); + match &node.kind { + Kind::Leaf(lines) => leaf = lines.iter(), + Kind::Branch(left, right) => { + stack.push(right.0.as_deref().expect("nonempty metric branch")); + stack.push(left.0.as_deref().expect("nonempty metric branch")); + } + } + }) + } +} diff --git a/native/src/line_ops.rs b/native/src/line_ops.rs new file mode 100644 index 0000000..23e6bde --- /dev/null +++ b/native/src/line_ops.rs @@ -0,0 +1,169 @@ +use super::super::{AtomProperties, HorizontalAlign, Line, RegionRole}; +use super::line_metrics::LineMetric; +use std::sync::Arc; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub(in crate::layout) enum BoxProjectionSlot { + ContentPad, + ContentOwn, + ContentCollapse, + VerticalFrame, + PaddingFrame, + MarginFrame, + PaddingEdges, + BoundaryBorders, + SurfaceTemplate, + MarginEdges, + OverflowEdges, + ScrollWindow, + TransparentContent, +} + +/// Ordered, cardinality-preserving operations. Each index is in this stage's input. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(in crate::layout) enum LineOp { + PadTo { + width: i64, + align: HorizontalAlign, + }, + AppendSpace(i64), + OwnContent { + region: i64, + start: i64, + }, + CollapseWhitespace { + width: i64, + region: i64, + }, + EdgeSpaces { + left: i64, + left_properties: Arc, + right: i64, + right_properties: Arc, + }, + Style(u32), + Template(u32), + ClearBreaks, + ScrollWindow(i64), + FirstStyleRole { + style: u32, + region: i64, + }, + LastStyleRole { + style: u32, + region: i64, + }, +} + +impl LineOp { + pub fn apply(&self, mut line: Line, index: usize, length: usize) -> Line { + match self { + Self::PadTo { width, align } => line = line.padded(*width, *align), + Self::AppendSpace(width) => line.push_space(*width), + Self::OwnContent { region, start } => line.own_content(*region, *start + index as i64), + Self::CollapseWhitespace { width, region } => { + line = line.collapse_whitespace_content(*width, *region) + } + Self::EdgeSpaces { + left, + left_properties, + right, + right_properties, + } => { + line.prepend_space_with_properties(*left, left_properties.as_ref().clone()); + line.push_space_with_properties(*right, right_properties.as_ref().clone()); + } + Self::Style(style) => line.apply_style(Some(*style)), + Self::Template(template) => line.apply_property_template(Some(*template)), + Self::ScrollWindow(region) => line.atoms = line.atoms.apply_scroll_window(*region), + Self::FirstStyleRole { style, region } if index == 0 => { + line.apply_style(Some(*style)); + line.apply_role(RegionRole::BorderTop, *region); + } + Self::LastStyleRole { style, region } if index + 1 == length => { + line.apply_style(Some(*style)); + line.apply_role(RegionRole::BorderBottom, *region); + } + Self::FirstStyleRole { .. } | Self::LastStyleRole { .. } | Self::ClearBreaks => {} + } + line + } + + pub(super) fn metric(&self, mut line: LineMetric, index: usize, length: usize) -> LineMetric { + fn space(line: &mut LineMetric, width: i64, properties: Option<&AtomProperties>) { + if width <= 0 { + return; + } + line.width += width; + line.chars += 1; + if !line.nonempty { + line.whitespace = true; + } + line.nonempty = true; + line.noncontent |= properties.is_some_and(|p| { + !p.style_ids.is_empty() + || !p.roles.is_empty() + || !p.property_template_ids.is_empty() + }); + } + match self { + Self::PadTo { width, align } => { + let remaining = (*width - line.width).max(0); + let left = match align { + HorizontalAlign::Left => 0, + HorizontalAlign::Right => remaining, + HorizontalAlign::Center => remaining / 2, + }; + space(&mut line, left, None); + space(&mut line, remaining - left, None); + } + Self::AppendSpace(width) => space(&mut line, *width, None), + Self::EdgeSpaces { + left, + left_properties, + right, + right_properties, + } => { + space(&mut line, *left, Some(left_properties)); + space(&mut line, *right, Some(right_properties)); + } + Self::CollapseWhitespace { width, .. } if line.whitespace && !line.noncontent => { + line = LineMetric { + width: (*width).max(0), + chars: u64::from(*width > 0), + min_content: 0, + nonempty: *width > 0, + whitespace: *width > 0, + noncontent: false, + }; + } + Self::Style(_) | Self::Template(_) => line.noncontent |= line.nonempty, + Self::FirstStyleRole { .. } if index == 0 => line.noncontent |= line.nonempty, + Self::LastStyleRole { .. } if index + 1 == length => line.noncontent |= line.nonempty, + _ => {} + } + line + } + + pub fn apply_break(&self, properties: &mut AtomProperties) { + match self { + Self::ClearBreaks => *properties = AtomProperties::default(), + Self::ScrollWindow(region) => properties.scroll_window = Some(*region), + _ => {} + } + } + + pub fn boundary_sensitive(&self) -> bool { + matches!( + self, + Self::FirstStyleRole { .. } | Self::LastStyleRole { .. } + ) + } + + pub fn preserves_metrics(&self) -> bool { + matches!( + self, + Self::OwnContent { .. } | Self::ClearBreaks | Self::ScrollWindow(_) + ) + } +} diff --git a/native/src/line_plan.rs b/native/src/line_plan.rs index 2ae4598..d1a4b26 100644 --- a/native/src/line_plan.rs +++ b/native/src/line_plan.rs @@ -1,11 +1,19 @@ +use std::borrow::Cow; use std::cell::Cell; use std::ops::Range; use std::sync::Arc; -use crate::sequence::{Entry, Iter, Measure, Sequence, Work}; +use crate::sequence::{Entry, Measure, Sequence, Work}; use super::{AtomProperties, Line}; +#[path = "line_metrics.rs"] +mod line_metrics; +#[path = "line_ops.rs"] +mod line_ops; +use line_metrics::{LineMetric, Metrics}; +pub(super) use line_ops::{BoxProjectionSlot, LineOp}; + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize)] #[serde(rename_all = "kebab-case")] pub(crate) struct LinePlanWork { @@ -21,6 +29,16 @@ pub(crate) struct LinePlanWork { pub(crate) materialized_lines: u64, pub(crate) materialized_breaks: u64, pub(crate) materialized_chars: u64, + pub(crate) metric_nodes_created: u64, + pub(crate) metric_entries_written: u64, + pub(crate) metric_nodes_visited: u64, + pub(crate) projection_lines_measured: u64, + pub(crate) projection_nodes_created: u64, + pub(crate) projection_fallbacks: u64, + pub(crate) projection_initial_lines_measured: u64, + pub(crate) projection_fallback_lines_measured: u64, + pub(crate) projection_splice_lines_measured: u64, + pub(crate) frame_blank_lines_created: u64, } thread_local! { @@ -55,7 +73,17 @@ impl LinePlanWork { line_clones, materialized_lines, materialized_breaks, - materialized_chars + materialized_chars, + metric_nodes_created, + metric_entries_written, + metric_nodes_visited, + projection_lines_measured, + projection_nodes_created, + projection_fallbacks, + projection_initial_lines_measured, + projection_fallback_lines_measured, + projection_splice_lines_measured, + frame_blank_lines_created ); } } @@ -115,65 +143,146 @@ fn entry(line: Line, break_after: Arc) -> Entry { #[derive(Clone, Debug, Default)] pub(super) struct LinePlan { + // Raw plans retain the existing packed sequence. Extended plans keep this empty. entries: Sequence, + extension: Option>, + metrics: Metrics, + identity: Arc<()>, +} + +#[derive(Debug)] +enum Extension { + Projection { + input: LinePlan, + ops: Arc<[LineOp]>, + origin: usize, + length: usize, + }, + Concat { + left: LinePlan, + right: LinePlan, + height: u32, + }, + Break { + input: LinePlan, + properties: Arc, + }, +} + +#[derive(Clone, Copy)] +struct Stage<'a> { + ops: &'a [LineOp], + index: usize, + length: usize, +} + +pub(super) struct LineView<'a> { + entry: &'a Arc, + stages: Vec>, + break_override: Option<&'a AtomProperties>, + break_stage_start: usize, +} + +impl<'a> LineView<'a> { + fn metric(&self) -> LineMetric { + let mut metric = LineMetric::line(&self.entry.line); + for stage in &self.stages { + for op in stage.ops { + metric = op.metric(metric, stage.index, stage.length); + } + } + metric + } + + pub(super) fn width(&self) -> i64 { + self.metric().width + } + + /// Raw lines remain borrowed. Typed programs are realized only at an explicit boundary. + pub(super) fn materialize(&self) -> Cow<'a, Line> { + if self.stages.is_empty() { + return Cow::Borrowed(&self.entry.line); + } + let mut line = clone_line(&self.entry.line); + for stage in &self.stages { + for op in stage.ops { + line = op.apply(line, stage.index, stage.length); + } + } + Cow::Owned(line) + } + + fn break_view(&self) -> BreakView<'a> { + BreakView { + original: self.break_override.unwrap_or(&self.entry.break_after), + stages: self.stages[self.break_stage_start..].to_vec(), + } + } +} + +pub(super) struct BreakView<'a> { + original: &'a AtomProperties, + stages: Vec>, +} + +impl<'a> BreakView<'a> { + pub(super) fn materialize(&self) -> Cow<'a, AtomProperties> { + if self.stages.is_empty() { + return Cow::Borrowed(self.original); + } + let mut properties = self.original.clone(); + for stage in &self.stages { + if stage.index + 1 < stage.length { + for op in stage.ops { + op.apply_break(&mut properties); + } + } + } + Cow::Owned(properties) + } } -/// Borrowed traversal; counts both sequence-node visits and yielded lines. pub(super) struct Lines<'a> { - entries: Iter<'a, LineEntry>, + inner: Box> + 'a>, + remaining: usize, } impl<'a> Iterator for Lines<'a> { - type Item = &'a Line; - + type Item = LineView<'a>; fn next(&mut self) -> Option { - let before = self.entries.nodes_visited(); - let value = self.entries.next(); - record(LinePlanWork { - iterator_nodes_visited: self.entries.nodes_visited() - before, - lines_visited: u64::from(value.is_some()), - ..LinePlanWork::default() - }); - value.map(|entry| &entry.value().line) + let value = self.inner.next(); + if value.is_some() { + self.remaining -= 1; + } + value } - fn size_hint(&self) -> (usize, Option) { - self.entries.size_hint() + (self.remaining, Some(self.remaining)) } } - impl ExactSizeIterator for Lines<'_> {} pub(super) struct LinesAndBreaks<'a> { lines: Lines<'a>, } - impl<'a> Iterator for LinesAndBreaks<'a> { - type Item = (&'a Line, Option<&'a AtomProperties>); - + type Item = (LineView<'a>, Option>); fn next(&mut self) -> Option { - let before = self.lines.entries.nodes_visited(); - let value = self.lines.entries.next(); - record(LinePlanWork { - iterator_nodes_visited: self.lines.entries.nodes_visited() - before, - lines_visited: u64::from(value.is_some()), - ..LinePlanWork::default() - }); - let entry = value?.value(); - Some(( - &entry.line, - (self.lines.entries.len() > 0).then_some(entry.break_after.as_ref()), - )) + let line = self.lines.next()?; + let properties = (self.lines.len() > 0).then(|| line.break_view()); + Some((line, properties)) } - fn size_hint(&self) -> (usize, Option) { self.lines.size_hint() } } - impl ExactSizeIterator for LinesAndBreaks<'_> {} impl LinePlan { + pub(super) fn ptr_eq(&self, other: &Self) -> bool { + Arc::ptr_eq(&self.identity, &other.identity) + } + pub(super) fn from_lines(lines: impl IntoIterator) -> Self { let default_break = Arc::new(AtomProperties::default()); Self::from_entries( @@ -188,20 +297,116 @@ impl LinePlan { let entries = Sequence::from_entries(entries, &mut work) .expect("validated native line sequence aggregates fit limits"); record_sequence(work); - Self { entries } + let metrics = Metrics::from_iter( + entries + .iter() + .map(|entry| LineMetric::line(&entry.value().line)), + ); + Self { + entries, + metrics, + ..Self::default() + } + } + + fn extended(extension: Extension, metrics: Metrics) -> Self { + record(LinePlanWork { + projection_nodes_created: 1, + ..LinePlanWork::default() + }); + Self { + extension: Some(Arc::new(extension)), + metrics, + ..Self::default() + } } pub(super) fn len(&self) -> usize { - self.entries.len() + self.metrics.summary().count } - pub(super) fn is_empty(&self) -> bool { - self.entries.is_empty() + self.len() == 0 + } + pub(super) fn first_width(&self) -> i64 { + self.metrics.summary().first_width + } + pub(super) fn max_width(&self) -> i64 { + self.metrics.summary().max_width + } + pub(super) fn min_content_width(&self) -> i64 { + self.metrics.summary().min_content + } + pub(super) fn uniform_width(&self, width: i64) -> bool { + self.is_empty() || (self.metrics.summary().min_width == width && self.max_width() == width) + } + pub(super) fn all_nonempty(&self) -> bool { + self.is_empty() || self.metrics.summary().all_nonempty + } + pub(super) fn height(&self) -> i64 { + i64::try_from(self.len()).expect("validated line height") } pub(super) fn iter(&self) -> Lines<'_> { + let inner: Box>> = match self.extension.as_deref() { + None => { + let mut iter = self.entries.iter(); + Box::new(std::iter::from_fn(move || { + let before = iter.nodes_visited(); + let value = iter.next(); + record(LinePlanWork { + iterator_nodes_visited: iter.nodes_visited() - before, + lines_visited: u64::from(value.is_some()), + ..LinePlanWork::default() + }); + value.map(|entry| LineView { + entry: entry.value(), + stages: Vec::new(), + break_override: None, + break_stage_start: 0, + }) + })) + } + Some(Extension::Projection { + input, + ops, + origin, + length, + }) => { + record(LinePlanWork { + iterator_nodes_visited: 1, + ..LinePlanWork::default() + }); + Box::new(input.iter().enumerate().map(move |(index, mut line)| { + line.stages.push(Stage { + ops, + index: origin + index, + length: *length, + }); + line + })) + } + Some(Extension::Concat { left, right, .. }) => { + record(LinePlanWork { + iterator_nodes_visited: 1, + ..LinePlanWork::default() + }); + Box::new(left.iter().chain(right.iter())) + } + Some(Extension::Break { input, properties }) => { + record(LinePlanWork { + iterator_nodes_visited: 1, + ..LinePlanWork::default() + }); + Box::new(input.iter().map(move |mut line| { + line.break_override = Some(properties); + line.break_stage_start = line.stages.len(); + line + })) + } + }; Lines { - entries: self.entries.iter(), + inner, + remaining: self.len(), } } @@ -209,118 +414,303 @@ impl LinePlan { LinesAndBreaks { lines: self.iter() } } - fn get_entry(&self, index: usize) -> Option<&LineEntry> { + pub(super) fn get(&self, index: usize) -> Option> { record(LinePlanWork { line_lookups: 1, ..LinePlanWork::default() }); - self.entries.get(index).map(|entry| entry.value().as_ref()) + if index >= self.len() { + return None; + } + match self.extension.as_deref() { + None => self.entries.get(index).map(|entry| LineView { + entry: entry.value(), + stages: Vec::new(), + break_override: None, + break_stage_start: 0, + }), + Some(Extension::Projection { + input, + ops, + origin, + length, + }) => { + let mut line = input.get(index)?; + line.stages.push(Stage { + ops, + index: origin + index, + length: *length, + }); + Some(line) + } + Some(Extension::Concat { left, right, .. }) => { + if index < left.len() { + left.get(index) + } else { + right.get(index - left.len()) + } + } + Some(Extension::Break { input, properties }) => { + let mut line = input.get(index)?; + line.break_override = Some(properties); + line.break_stage_start = line.stages.len(); + Some(line) + } + } } - pub(super) fn get(&self, index: usize) -> Option<&Line> { - self.get_entry(index).map(|entry| &entry.line) - } - - pub(super) fn first(&self) -> Option<&Line> { + #[cfg(test)] + pub(super) fn first(&self) -> Option> { self.get(0) } - pub(super) fn max_width(&self) -> i64 { - self.entries.measure().width_max + pub(super) fn break_after(&self, index: usize) -> Option> { + (index < self.len().saturating_sub(1)) + .then(|| self.get(index).expect("checked break index").break_view()) } - pub(super) fn height(&self) -> i64 { - i64::try_from(self.entries.measure().lines).expect("validated native line height fits i64") - } - - pub(super) fn break_after(&self, index: usize) -> Option<&AtomProperties> { - (index < self.len().saturating_sub(1)).then(|| { - self.get_entry(index) - .expect("checked line index") - .break_after - .as_ref() - }) - } - - /// Character offset of a line start, or the complete output length at len(). pub(super) fn prefix_chars(&self, end: usize) -> u64 { let chars = self.range_measure(0..end).chars; chars .checked_add(u64::from(end > 0 && end < self.len())) - .expect("validated native line prefix fits u64") + .expect("validated line prefix") } - /// Exact standalone range extent: height, widths, and chars excluding its final newline. pub(super) fn range_measure(&self, range: Range) -> Measure { record(LinePlanWork { range_queries: 1, ..LinePlanWork::default() }); - let mut measure = self - .entries - .range_measure(range) - .expect("validated native line range"); - measure.chars = measure.chars.saturating_sub(u64::from(measure.lines > 0)); - measure + let summary = self.metrics.slice(range).summary(); + Measure::new( + summary.chars + summary.count.saturating_sub(1) as u64, + summary.count as u64, + summary.width_sum, + summary.max_width, + ) + .expect("validated line range") } pub(super) fn slice(&self, range: Range) -> Self { - let mut work = Work::default(); - let entries = self - .entries - .slice(range, &mut work) - .expect("validated native line slice"); - record_sequence(work); - Self { entries } + assert!(range.start <= range.end && range.end <= self.len()); + if range.start == 0 && range.end == self.len() { + return self.clone(); + } + if range.is_empty() { + return Self::default(); + } + let metrics = self.metrics.slice(range.clone()); + match self.extension.as_deref() { + None => { + let mut work = Work::default(); + let entries = self + .entries + .slice(range, &mut work) + .expect("validated line slice"); + record_sequence(work); + Self { + entries, + metrics, + ..Self::default() + } + } + Some(Extension::Projection { + input, + ops, + origin, + length, + }) => Self::extended( + Extension::Projection { + input: input.slice(range.clone()), + ops: Arc::clone(ops), + origin: origin + range.start, + length: *length, + }, + metrics, + ), + Some(Extension::Concat { left, right, .. }) => { + if range.end <= left.len() { + left.slice(range) + } else if range.start >= left.len() { + right.slice(range.start - left.len()..range.end - left.len()) + } else { + left.slice(range.start..left.len()) + .join_stored(&right.slice(0..range.end - left.len())) + } + } + Some(Extension::Break { input, properties }) => Self::extended( + Extension::Break { + input: input.slice(range), + properties: Arc::clone(properties), + }, + metrics, + ), + } } + #[cfg(test)] pub(super) fn replace_line(&self, index: usize, line: Line) -> Self { let old = self - .get_entry(index) - .expect("validated native line replacement"); - self.replace_entry(index, entry(line, Arc::clone(&old.break_after))) - } - - pub(super) fn update_line(&self, index: usize, update: impl FnOnce(Line) -> Line) -> Self { - let line = clone_line(self.get(index).expect("validated native line update")); - record(LinePlanWork { - lines_mapped: 1, - ..LinePlanWork::default() - }); - self.replace_line(index, update(line)) + .get(index) + .expect("validated line replacement") + .break_view() + .materialize() + .into_owned(); + if self.extension.is_none() { + self.replace_entry(index, entry(line, Arc::new(old))) + } else { + let replacement = Self::from_entries([entry(line, Arc::new(old))]); + self.slice(0..index) + .join_stored(&replacement) + .join_stored(&self.slice(index + 1..self.len())) + } } pub(super) fn with_break(&self, index: usize, properties: AtomProperties) -> Self { - let old = self - .break_after(index) - .expect("validated native break index"); - if *old == properties { + let old = self.break_after(index).expect("validated break index"); + if *old.materialize() == properties { return self.clone(); } self.set_stored_break(index, Arc::new(properties)) } fn set_stored_break(&self, index: usize, properties: Arc) -> Self { - let old = self - .get_entry(index) - .expect("validated native break replacement"); - self.replace_entry(index, entry(clone_line(&old.line), properties)) + if self.extension.is_none() { + let old = self + .entries + .get(index) + .expect("validated break replacement"); + self.replace_entry(index, entry(clone_line(&old.value().line), properties)) + } else { + let selected = self.slice(index..index + 1); + // The new override replaces every property of the old one. Keep + // its current line program, without retaining the shadowed root. + let input = match selected.extension.as_deref() { + Some(Extension::Break { input, .. }) => input.clone(), + _ => selected.clone(), + }; + let replacement = + Self::extended(Extension::Break { input, properties }, selected.metrics); + self.slice(0..index) + .join_stored(&replacement) + .join_stored(&self.slice(index + 1..self.len())) + } } fn replace_entry(&self, index: usize, entry: Entry) -> Self { + let metric = LineMetric::line(&entry.value().line); let mut work = Work::default(); let entries = self .entries .replace(index, entry, &mut work) - .expect("validated native line replacement aggregates fit limits"); + .expect("validated line replacement"); record_sequence(work); - Self { entries } + Self { + entries, + metrics: self.metrics.replace(index, metric), + ..Self::default() + } + } + + fn tree_height(&self) -> u32 { + if self.is_empty() { + return 0; + } + match self.extension.as_deref() { + Some(Extension::Concat { height, .. }) => *height, + _ => 1, + } + } + + fn branch(left: Self, right: Self) -> Self { + if left.is_empty() { + return right; + } + if right.is_empty() { + return left; + } + let metrics = left.metrics.concat(&right.metrics); + let height = left.tree_height().max(right.tree_height()) + 1; + Self::extended( + Extension::Concat { + left, + right, + height, + }, + metrics, + ) + } + + fn children(&self) -> (&Self, &Self) { + let Some(Extension::Concat { left, right, .. }) = self.extension.as_deref() else { + unreachable!("balanced line branch") + }; + (left, right) + } + + fn balanced(left: Self, right: Self) -> Self { + if left.tree_height() > right.tree_height() + 1 { + let (ll, lr) = left.children(); + if ll.tree_height() >= lr.tree_height() { + Self::branch(ll.clone(), Self::branch(lr.clone(), right)) + } else { + let (lrl, lrr) = lr.children(); + Self::branch( + Self::branch(ll.clone(), lrl.clone()), + Self::branch(lrr.clone(), right), + ) + } + } else if right.tree_height() > left.tree_height() + 1 { + let (rl, rr) = right.children(); + if rr.tree_height() >= rl.tree_height() { + Self::branch(Self::branch(left, rl.clone()), rr.clone()) + } else { + let (rll, rlr) = rl.children(); + Self::branch( + Self::branch(left, rll.clone()), + Self::branch(rlr.clone(), rr.clone()), + ) + } + } else { + Self::branch(left, right) + } + } + + /// Concatenate already-authorized stored breaks without introducing a new seam. + fn join_stored(&self, other: &Self) -> Self { + if self.is_empty() { + return other.clone(); + } + if other.is_empty() { + return self.clone(); + } + if self.extension.is_none() && other.extension.is_none() { + let mut work = Work::default(); + let entries = self + .entries + .concat(&other.entries, &mut work) + .expect("validated line concatenation"); + record_sequence(work); + return Self { + entries, + metrics: self.metrics.concat(&other.metrics), + ..Self::default() + }; + } + if self.tree_height() > other.tree_height() + 1 { + let (left, right) = self.children(); + Self::balanced(left.clone(), right.join_stored(other)) + } else if other.tree_height() > self.tree_height() + 1 { + let (left, right) = other.children(); + Self::balanced(self.join_stored(left), right.clone()) + } else { + Self::branch(self.clone(), other.clone()) + } } pub(super) fn concat(&self, other: &Self) -> Self { self.concat_with_break(other, AtomProperties::default()) } - pub(super) fn concat_with_break(&self, other: &Self, properties: AtomProperties) -> Self { if self.is_empty() { return other.clone(); @@ -328,68 +718,490 @@ impl LinePlan { if other.is_empty() { return self.clone(); } - let mut work = Work::default(); - let entries = self - .entries - .concat(&other.entries, &mut work) - .expect("validated native line concatenation aggregates fit limits"); - record_sequence(work); - Self { entries }.with_break(self.len() - 1, properties) + self.join_stored(other) + .with_break(self.len() - 1, properties) } - /// Full O(N) line transform. Atom payloads remain shared until changed by the callback. + /// Full fallback/oracle transform; every visited line and copy is counted. pub(super) fn map_lines(&self, mut map: impl FnMut(usize, Line) -> Line) -> Self { - let mut iter = self.entries.iter(); - let result = Self::from_entries(iter.by_ref().enumerate().map(|(index, old)| { + Self::from_entries(self.iter().enumerate().map(|(index, old)| { record(LinePlanWork { - lines_visited: 1, lines_mapped: 1, ..LinePlanWork::default() }); entry( - map(index, clone_line(&old.value().line)), - Arc::clone(&old.value().break_after), + map(index, clone_line(&old.materialize())), + Arc::new(old.break_view().materialize().into_owned()), ) - })); - record(LinePlanWork { - iterator_nodes_visited: iter.nodes_visited(), - ..LinePlanWork::default() - }); - result + })) } - /// Full O(N) break transform. Its traversal and rewritten sequence entries are counted. pub(super) fn map_breaks(&self, mut map: impl FnMut(&mut AtomProperties)) -> Self { - let mut iter = self.entries.iter(); let last = self.len().saturating_sub(1); - let result = Self::from_entries(iter.by_ref().enumerate().map(|(index, old)| { - record(LinePlanWork { - lines_visited: 1, - ..LinePlanWork::default() - }); - if index == last { - return old.clone(); + Self::from_entries(self.iter().enumerate().map(|(index, old)| { + if index == last && old.stages.is_empty() && old.break_override.is_none() { + // Keep the raw terminal entry exactly as before. + return Entry::new( + Arc::clone(old.entry), + Measure::new( + old.entry.line.atoms.char_count() + 1, + 1, + old.entry.line.width, + old.entry.line.width, + ) + .expect("line measure"), + ) + .expect("line entry"); } - let mut properties = old.value().break_after.as_ref().clone(); - map(&mut properties); - record(LinePlanWork { - breaks_mapped: 1, - ..LinePlanWork::default() - }); - entry(clone_line(&old.value().line), Arc::new(properties)) - })); - record(LinePlanWork { - iterator_nodes_visited: iter.nodes_visited(), - ..LinePlanWork::default() - }); - result + let mut properties = old.break_view().materialize().into_owned(); + if index != last { + map(&mut properties); + record(LinePlanWork { + breaks_mapped: 1, + ..LinePlanWork::default() + }); + } + entry(clone_line(&old.materialize()), Arc::new(properties)) + })) } - pub(super) fn clear_breaks(&self) -> Self { self.map_breaks(|properties| *properties = AtomProperties::default()) } } +#[derive(Clone, Debug)] +pub(super) struct LineSplice { + pub(super) old: Range, + pub(super) new: Range, +} +#[derive(Clone, Debug)] +pub(super) enum ChangeKind { + Same, + Splices(Box<[LineSplice]>), + ReplaceAll, +} + +/// Transaction-local authorization for this exact pair. Splices describe line programs, +/// not final properties: relative coordinates may move within otherwise shared suffixes. +#[derive(Clone, Debug)] +pub(super) struct PlanChange { + base: LinePlan, + target: LinePlan, + kind: ChangeKind, +} +impl PlanChange { + pub(super) fn same(plan: &LinePlan) -> Self { + Self { + base: plan.clone(), + target: plan.clone(), + kind: ChangeKind::Same, + } + } + pub(super) fn replace_all(base: &LinePlan, target: &LinePlan) -> Self { + if base.ptr_eq(target) { + return Self::same(base); + } + Self { + base: base.clone(), + target: target.clone(), + kind: ChangeKind::ReplaceAll, + } + } + pub(super) fn splices(base: &LinePlan, target: &LinePlan, splices: Vec) -> Self { + let (mut old_end, mut new_end) = (0, 0); + for splice in &splices { + if splice.old.start < old_end + || splice.new.start < new_end + || splice.old.start > splice.old.end + || splice.new.start > splice.new.end + || splice.old.end > base.len() + || splice.new.end > target.len() + || splice.old.start - old_end != splice.new.start - new_end + { + return Self::replace_all(base, target); + } + old_end = splice.old.end; + new_end = splice.new.end; + } + if base.len() - old_end != target.len() - new_end { + return Self::replace_all(base, target); + } + if base.ptr_eq(target) { + return Self::same(base); + } + Self { + base: base.clone(), + target: target.clone(), + kind: ChangeKind::Splices(splices.into_boxed_slice()), + } + } + pub(super) fn applies_to(&self, base: &LinePlan, target: &LinePlan) -> bool { + self.base.ptr_eq(base) && self.target.ptr_eq(target) + } + pub(super) fn kind(&self) -> &ChangeKind { + &self.kind + } + + fn boundary_splices(&self) -> Vec { + let ChangeKind::Splices(splices) = &self.kind else { + return Vec::new(); + }; + let mut result = splices.to_vec(); + let (mut old_start, mut new_start) = (0, 0); + let terminal = LineSplice { + old: self.base.len()..self.base.len(), + new: self.target.len()..self.target.len(), + }; + for splice in splices.iter().chain(std::iter::once(&terminal)) { + // Only unchanged-gap endpoints need adding; changed ranges already cover the rest. + for old in [0, self.base.len().saturating_sub(1)] { + if old >= old_start && old < splice.old.start { + let new = new_start + old - old_start; + result.push(LineSplice { + old: old..old + 1, + new: new..new + 1, + }); + } + } + for new in [0, self.target.len().saturating_sub(1)] { + if new >= new_start && new < splice.new.start { + let old = old_start + new - new_start; + result.push(LineSplice { + old: old..old + 1, + new: new..new + 1, + }); + } + } + old_start = splice.old.end; + new_start = splice.new.end; + } + result.sort_by_key(|splice| (splice.old.start, splice.new.start)); + let mut merged: Vec = Vec::new(); + for splice in result { + if let Some(previous) = merged.last_mut() { + if splice.old.start <= previous.old.end && splice.new.start <= previous.new.end { + previous.old.end = previous.old.end.max(splice.old.end); + previous.new.end = previous.new.end.max(splice.new.end); + continue; + } + } + merged.push(splice); + } + merged + } +} + +#[derive(Debug)] +pub(super) struct ProjectionState { + input: LinePlan, + ops: Arc<[LineOp]>, + plan: LinePlan, + frame: Option, +} + +/// Current blank-row framing parameters; counts can change with child height. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) struct FrameSpec { + pub(super) prefix: usize, + pub(super) suffix: usize, + pub(super) width: i64, + pub(super) prefix_properties: Arc, + pub(super) suffix_properties: Arc, +} + +#[derive(Debug)] +struct FrameState { + spec: FrameSpec, + prefix: LinePlan, + suffix: LinePlan, +} + +impl FrameSpec { + fn same_parameters(&self, other: &Self) -> bool { + self.width == other.width + && self.prefix_properties == other.prefix_properties + && self.suffix_properties == other.suffix_properties + } +} +pub(super) struct ProjectionUpdate { + pub(super) state: Arc, + pub(super) change: PlanChange, +} +impl ProjectionState { + fn measure(input: &LinePlan, ops: &[LineOp], range: Range) -> Metrics { + if ops.iter().all(LineOp::preserves_metrics) { + return input.metrics.slice(range); + } + let selected = input.metrics.slice(range.clone()); + Metrics::from_iter(selected.iter().enumerate().map(|(offset, mut metric)| { + let index = range.start + offset; + record(LinePlanWork { + projection_lines_measured: 1, + ..LinePlanWork::default() + }); + for op in ops { + metric = op.metric(metric, index, input.len()); + } + metric + })) + } + + fn build(input: LinePlan, ops: Arc<[LineOp]>, metrics: Metrics) -> Arc { + let plan = if ops.is_empty() { + input.clone() + } else { + LinePlan::extended( + Extension::Projection { + input: input.clone(), + ops: Arc::clone(&ops), + origin: 0, + length: input.len(), + }, + metrics, + ) + }; + Arc::new(Self { + input, + ops, + plan, + frame: None, + }) + } + + pub(super) fn new(input: LinePlan, ops: Arc<[LineOp]>) -> Arc { + let before = work().projection_lines_measured; + let metrics = Self::measure(&input, &ops, 0..input.len()); + record(LinePlanWork { + projection_initial_lines_measured: work().projection_lines_measured - before, + ..LinePlanWork::default() + }); + Self::build(input, ops, metrics) + } + + pub(super) fn plan(&self) -> &LinePlan { + &self.plan + } + + pub(super) fn update( + self: &Arc, + input: LinePlan, + proof: Option<&PlanChange>, + ops: Arc<[LineOp]>, + ) -> ProjectionUpdate { + let valid = proof.filter(|proof| proof.applies_to(&self.input, &input)); + if self.frame.is_none() && ops == self.ops { + if input.ptr_eq(&self.input) { + return ProjectionUpdate { + state: Arc::clone(self), + change: PlanChange::same(&self.plan), + }; + } + if let Some(PlanChange { + kind: ChangeKind::Splices(splices), + .. + }) = valid + { + let before = work().projection_lines_measured; + // New and former endpoints are explicit changed ranges; all other + // numeric subtrees remain shared even when their coordinates shift. + let output_splices = if ops.iter().any(LineOp::boundary_sensitive) { + valid.expect("validated splice proof").boundary_splices() + } else { + splices.to_vec() + }; + let metrics = if ops.iter().all(LineOp::preserves_metrics) { + input.metrics.clone() + } else { + let mut metrics = Metrics::default(); + let mut old_end = 0; + for splice in &output_splices { + metrics = + metrics.concat(&self.plan.metrics.slice(old_end..splice.old.start)); + metrics = metrics.concat(&Self::measure(&input, &ops, splice.new.clone())); + old_end = splice.old.end; + } + metrics.concat(&self.plan.metrics.slice(old_end..self.input.len())) + }; + debug_assert_eq!(metrics.summary().count, input.len()); + record(LinePlanWork { + projection_splice_lines_measured: work().projection_lines_measured - before, + ..LinePlanWork::default() + }); + let state = Self::build(input, ops, metrics); + let change = PlanChange::splices(&self.plan, &state.plan, output_splices); + return ProjectionUpdate { state, change }; + } + } + record(LinePlanWork { + projection_fallbacks: 1, + ..LinePlanWork::default() + }); + let before = work().projection_lines_measured; + let metrics = Self::measure(&input, &ops, 0..input.len()); + record(LinePlanWork { + projection_fallback_lines_measured: work().projection_lines_measured - before, + ..LinePlanWork::default() + }); + let state = Self::build(input, ops, metrics); + let change = PlanChange::replace_all(&self.plan, &state.plan); + ProjectionUpdate { state, change } + } + + fn blanks(count: usize, width: i64, properties: &Arc) -> LinePlan { + record(LinePlanWork { + frame_blank_lines_created: count as u64, + ..LinePlanWork::default() + }); + LinePlan::from_lines( + (0..count).map(|_| Line::blank_with_properties(width, properties.as_ref().clone())), + ) + } + + fn build_frame( + input: LinePlan, + spec: FrameSpec, + prefix: LinePlan, + suffix: LinePlan, + ) -> Arc { + let plan = if spec.prefix == 0 && spec.suffix == 0 { + // Zero framing forwards input-bound proofs, including empty roots. + input.clone() + } else { + prefix.concat(&input).concat(&suffix) + }; + Arc::new(Self { + input, + ops: Arc::from([]), + plan, + frame: Some(FrameState { + spec, + prefix, + suffix, + }), + }) + } + + pub(super) fn new_frame(input: LinePlan, spec: FrameSpec) -> Arc { + let prefix = Self::blanks(spec.prefix, spec.width, &spec.prefix_properties); + let suffix = Self::blanks(spec.suffix, spec.width, &spec.suffix_properties); + Self::build_frame(input, spec, prefix, suffix) + } + + pub(super) fn update_frame( + self: &Arc, + input: LinePlan, + proof: Option<&PlanChange>, + spec: FrameSpec, + ) -> ProjectionUpdate { + let Some(previous) = self + .frame + .as_ref() + .filter(|previous| previous.spec.same_parameters(&spec)) + else { + record(LinePlanWork { + projection_fallbacks: 1, + ..LinePlanWork::default() + }); + let state = Self::new_frame(input, spec); + let change = PlanChange::replace_all(&self.plan, &state.plan); + return ProjectionUpdate { state, change }; + }; + if input.ptr_eq(&self.input) && previous.spec == spec { + return ProjectionUpdate { + state: Arc::clone(self), + change: PlanChange::same(&self.plan), + }; + } + let resize = |old: &LinePlan, count: usize, properties: &Arc| { + if count <= old.len() { + old.slice(0..count) + } else { + old.concat(&Self::blanks(count - old.len(), spec.width, properties)) + } + }; + let prefix = resize(&previous.prefix, spec.prefix, &spec.prefix_properties); + let suffix = resize(&previous.suffix, spec.suffix, &spec.suffix_properties); + let valid = proof.filter(|proof| proof.applies_to(&self.input, &input)); + let child_splices = if input.ptr_eq(&self.input) { + Some(Vec::new()) + } else { + valid.map(|proof| match proof.kind() { + ChangeKind::Splices(splices) => splices.to_vec(), + ChangeKind::ReplaceAll => vec![LineSplice { + old: 0..self.input.len(), + new: 0..input.len(), + }], + ChangeKind::Same => Vec::new(), + }) + }; + let state = Self::build_frame(input, spec.clone(), prefix, suffix); + let Some(child_splices) = child_splices else { + record(LinePlanWork { + projection_fallbacks: 1, + ..LinePlanWork::default() + }); + let change = PlanChange::replace_all(&self.plan, &state.plan); + return ProjectionUpdate { state, change }; + }; + if previous.spec.prefix == 0 + && previous.spec.suffix == 0 + && spec.prefix == 0 + && spec.suffix == 0 + { + let change = valid + .cloned() + .unwrap_or_else(|| PlanChange::replace_all(&self.plan, &state.plan)); + return ProjectionUpdate { state, change }; + } + let old_prefix = previous.spec.prefix; + let new_prefix = spec.prefix; + let mut splices = Vec::new(); + if old_prefix != new_prefix { + let shared = old_prefix.min(new_prefix); + splices.push(LineSplice { + old: shared..old_prefix, + new: shared..new_prefix, + }); + } + splices.extend(child_splices.into_iter().map(|splice| LineSplice { + old: old_prefix + splice.old.start..old_prefix + splice.old.end, + new: new_prefix + splice.new.start..new_prefix + splice.new.end, + })); + if previous.spec.suffix != spec.suffix { + let shared = previous.spec.suffix.min(spec.suffix); + splices.push(LineSplice { + old: old_prefix + self.input.len() + shared..self.plan.len(), + new: new_prefix + state.input.len() + shared..state.plan.len(), + }); + } + // Default joins replace the stored predecessor break. Include that row, + // including the terminal-row case when a child or blank tail disappears. + for splice in &mut splices { + if splice.old.start > 0 && splice.new.start > 0 { + splice.old.start -= 1; + splice.new.start -= 1; + } else { + splice.old.start = 0; + splice.new.start = 0; + } + } + splices.sort_by_key(|splice| (splice.old.start, splice.new.start)); + let mut merged: Vec = Vec::new(); + for splice in splices { + if let Some(previous) = merged.last_mut() { + if splice.old.start <= previous.old.end && splice.new.start <= previous.new.end { + previous.old.end = previous.old.end.max(splice.old.end); + previous.new.end = previous.new.end.max(splice.new.end); + continue; + } + } + merged.push(splice); + } + let change = PlanChange::splices(&self.plan, &state.plan, merged); + ProjectionUpdate { state, change } + } +} + #[cfg(test)] mod tests { use super::super::{MeasuredCluster, Rendered}; @@ -406,6 +1218,571 @@ mod tests { }]) } + #[test] + fn typed_projection_keeps_exact_spaces_and_relative_content_without_line_maps() { + let input = LinePlan::from_lines([ + text("界a", 3), + Line::default(), + Line::blank(2), + text("z", 4), + ]); + reset_work(); + let state = ProjectionState::new( + input, + Arc::from([ + LineOp::PadTo { + width: 5, + align: super::super::HorizontalAlign::Center, + }, + LineOp::OwnContent { + region: 42, + start: 7, + }, + ]), + ); + let projected = state.plan().clone(); + let projection_work = work(); + let tape = Rendered::from_line_plan(projected.clone()).into_tape(0); + assert_eq!(projected.prefix_chars(4), 14); + assert_eq!( + tape.lines.iter().map(|line| line.width).collect::>(), + [5; 4] + ); + for (index, line) in tape.lines.iter().enumerate() { + for atom in &line.atoms { + let properties = match atom { + super::super::TapeAtom::Text { properties, .. } + | super::super::TapeAtom::Space { properties, .. } => properties, + }; + assert_eq!(properties.content_idx, Some(7 + index as i64)); + } + } + assert_eq!(projection_work.lines_mapped, 0); + } + + fn assert_exact_metrics(plan: &LinePlan) { + let mut chars = plan.len().saturating_sub(1) as u64; + let mut max_width = 0; + let mut min_content = 0; + for (index, view) in plan.iter().enumerate() { + let line = view.materialize(); + let expected = LineMetric::line(&line); + assert_eq!(plan.metrics.get(index), Some(expected), "line {index}"); + chars += expected.chars; + max_width = max_width.max(expected.width); + min_content = min_content.max(expected.min_content); + } + assert_eq!(plan.prefix_chars(plan.len()), chars); + assert_eq!(plan.max_width(), max_width); + assert_eq!(plan.min_content_width(), min_content); + assert_eq!( + plan.first_width(), + plan.first().map_or(0, |line| line.width()) + ); + } + + #[test] + fn projection_cold_build_is_packed_and_same_parameter_splices_are_bounded() { + for size in [32_usize, 128, 512, 8192] { + let input = LinePlan::from_lines((0..size).map(|_| text("界", 2))); + let ops: Arc<[LineOp]> = Arc::from([ + LineOp::PadTo { + width: 7, + align: super::super::HorizontalAlign::Center, + }, + LineOp::OwnContent { + region: 9, + start: 0, + }, + ]); + reset_work(); + let original = ProjectionState::new(input.clone(), Arc::clone(&ops)); + let cold = work(); + assert_eq!(cold.projection_initial_lines_measured, size as u64); + assert_eq!(cold.metric_entries_written, size as u64); + assert!(cold.metric_nodes_created <= size as u64 / 8); + assert!(cold.metric_nodes_visited <= size as u64 / 8); + assert_eq!((cold.lines_mapped, cold.lines_visited), (0, 0)); + + let replacement = input.replace_line(1, text("wide", 8)); + let proof = PlanChange::splices( + &input, + &replacement, + vec![LineSplice { + old: 1..2, + new: 1..2, + }], + ); + reset_work(); + let changed = original.update(replacement.clone(), Some(&proof), Arc::clone(&ops)); + let steady = work(); + assert_eq!(steady.projection_splice_lines_measured, 1); + assert_eq!(steady.projection_fallbacks, 0); + assert_eq!( + ( + steady.lines_mapped, + steady.lines_visited, + steady.materialized_lines + ), + (0, 0, 0) + ); + assert!(steady.metric_nodes_created <= 12 * u64::from(size.ilog2()) + 8); + assert!(changed + .change + .applies_to(original.plan(), changed.state.plan())); + assert_exact_metrics(changed.state.plan()); + let oracle = replacement.map_lines(|index, line| { + let mut line = line.padded(7, super::super::HorizontalAlign::Center); + line.own_content(9, index as i64); + line + }); + assert_eq!( + Rendered::from_line_plan(changed.state.plan().clone()).into_tape(0), + Rendered::from_line_plan(oracle).into_tape(0) + ); + + let changed_ops: Arc<[LineOp]> = Arc::from([LineOp::AppendSpace(2)]); + reset_work(); + let fallback = changed.state.update(replacement, None, changed_ops); + assert_eq!(work().projection_fallback_lines_measured, size as u64); + assert_eq!(work().projection_initial_lines_measured, 0); + assert_eq!(work().projection_fallbacks, 1); + assert!(matches!(fallback.change.kind(), ChangeKind::ReplaceAll)); + assert_exact_metrics(fallback.state.plan()); + } + } + + #[test] + fn projection_coordinates_shift_shared_suffixes_and_slices_keep_stage_origins() { + let mut owned = text("owned", 5); + owned.own_content(20, 0); + let input = LinePlan::from_lines([Line::blank(2), Line::blank(2), owned]); + let ops: Arc<[LineOp]> = Arc::from([LineOp::OwnContent { + region: 9, + start: 10, + }]); + let original = ProjectionState::new(input.clone(), Arc::clone(&ops)); + let grown = LinePlan::from_lines([Line::blank(2), Line::blank(2), Line::blank(2)]) + .concat(&input.slice(1..3)); + let proof = PlanChange::splices( + &input, + &grown, + vec![LineSplice { + old: 0..1, + new: 0..3, + }], + ); + reset_work(); + let changed = original.update(grown, Some(&proof), Arc::clone(&ops)); + assert_eq!(work().projection_lines_measured, 0); + let tape = Rendered::from_line_plan(changed.state.plan().clone()).into_tape(0); + let properties = |atom: &super::super::TapeAtom| match atom { + super::super::TapeAtom::Text { properties, .. } + | super::super::TapeAtom::Space { properties, .. } => properties.content_idx, + }; + assert_eq!(properties(&tape.lines[3].atoms[0]), Some(13)); + assert_eq!(properties(&tape.lines[4].atoms[0]), Some(0)); + let selected = changed.state.plan().slice(2..4); + assert_eq!( + selected + .first() + .unwrap() + .materialize() + .atoms + .first() + .unwrap() + .properties() + .content_idx, + Some(12) + ); + let sliced_first = ProjectionState::new(input.slice(1..2), ops); + assert_eq!( + sliced_first + .plan() + .first() + .unwrap() + .materialize() + .atoms + .first() + .unwrap() + .properties() + .content_idx, + Some(10) + ); + assert_exact_metrics(&selected); + } + + #[test] + fn projection_empty_zero_width_and_ordered_decorations_match_eager_oracle() { + let mut mixed = Line::blank(2); + mixed.append(&text("a b", 3)); + let input = LinePlan::from_lines([ + Line::default(), + text("", 0), + text(" \t", 2), + Line::blank(0), + mixed, + ]); + for width in [0, 1, 6] { + let left_properties = + super::super::region_properties(super::super::RegionRole::PaddingLeft, 44, None); + let ops = Arc::from([ + LineOp::OwnContent { + region: 12, + start: 4, + }, + LineOp::CollapseWhitespace { width, region: 12 }, + LineOp::Style(3), + LineOp::EdgeSpaces { + left: 2, + left_properties: Arc::new(left_properties.clone()), + right: 0, + right_properties: Arc::new(AtomProperties::default()), + }, + LineOp::Template(7), + LineOp::ClearBreaks, + LineOp::ScrollWindow(88), + ]); + let state = ProjectionState::new(input.clone(), ops); + let oracle = input + .map_lines(|index, mut line| { + line.own_content(12, 4 + index as i64); + line = line.collapse_whitespace_content(width, 12); + line.apply_style(Some(3)); + line.prepend_space_with_properties(2, left_properties.clone()); + line.push_space(0); + line.apply_property_template(Some(7)); + line.atoms = line.atoms.apply_scroll_window(88); + line + }) + .map_breaks(|properties| { + *properties = AtomProperties { + scroll_window: Some(88), + ..AtomProperties::default() + } + }); + assert_exact_metrics(state.plan()); + assert_eq!( + Rendered::from_line_plan(state.plan().clone()).into_tape(0), + Rendered::from_line_plan(oracle).into_tape(0) + ); + } + } + + #[test] + fn projection_endpoints_repair_metrics_and_propagate_precise_change() { + let input = LinePlan::from_lines([Line::blank(2), Line::blank(2), Line::blank(2)]); + let ops: Arc<[LineOp]> = Arc::from([ + LineOp::FirstStyleRole { + style: 3, + region: 9, + }, + LineOp::LastStyleRole { + style: 4, + region: 9, + }, + ]); + let original = ProjectionState::new(input.clone(), Arc::clone(&ops)); + let grown = LinePlan::from_lines([Line::blank(2)]).concat(&input); + let proof = PlanChange::splices( + &input, + &grown, + vec![LineSplice { + old: 0..0, + new: 0..1, + }], + ); + let changed = original.update(grown.clone(), Some(&proof), ops); + assert!(matches!(changed.change.kind(), ChangeKind::Splices(_))); + assert_exact_metrics(changed.state.plan()); + let oracle = grown.map_lines(|index, mut line| { + if index == 0 { + line.apply_style(Some(3)); + line.apply_role(super::super::RegionRole::BorderTop, 9); + } + if index + 1 == grown.len() { + line.apply_style(Some(4)); + line.apply_role(super::super::RegionRole::BorderBottom, 9); + } + line + }); + assert_eq!( + Rendered::from_line_plan(changed.state.plan().clone()).into_tape(0), + Rendered::from_line_plan(oracle).into_tape(0) + ); + } + + #[test] + fn projection_hints_require_exact_roots_and_replaced_history_is_released() { + let input = LinePlan::from_lines([text("old", 3), text("kept", 4)]); + let removed = Arc::downgrade(input.entries.get(0).unwrap().value()); + let ops: Arc<[LineOp]> = Arc::from([LineOp::AppendSpace(1)]); + let original = ProjectionState::new(input.clone(), Arc::clone(&ops)); + let old_state = Arc::downgrade(&original); + let old_root = Arc::downgrade(&original.plan.identity); + let replacement = input.replace_line(0, text("new", 3)); + let unrelated = input.replace_line(0, text("new", 3)); + let wrong = PlanChange::splices( + &input, + &unrelated, + vec![LineSplice { + old: 0..1, + new: 0..1, + }], + ); + reset_work(); + let fallback = original.update(replacement.clone(), Some(&wrong), Arc::clone(&ops)); + assert_eq!(work().projection_fallback_lines_measured, 2); + assert!(matches!(fallback.change.kind(), ChangeKind::ReplaceAll)); + let proof = PlanChange::splices( + &input, + &replacement, + vec![LineSplice { + old: 0..1, + new: 0..1, + }], + ); + let updated = original.update(replacement.clone(), Some(&proof), Arc::clone(&ops)); + let same = updated.state.update( + replacement, + Some(&PlanChange::same(&updated.state.input)), + ops, + ); + assert!(Arc::ptr_eq(&same.state, &updated.state)); + assert!(matches!(same.change.kind(), ChangeKind::Same)); + let retained = updated.state.plan().clone(); + drop(( + input, original, proof, wrong, unrelated, fallback, updated, same, + )); + assert!(old_state.upgrade().is_none()); + assert!(old_root.upgrade().is_none()); + assert!(removed.upgrade().is_none()); + assert_exact_metrics(&retained); + assert_eq!( + retained + .first() + .unwrap() + .materialize() + .atoms + .first() + .unwrap() + .width(), + 3 + ); + } + + #[test] + fn projection_slice_prunes_old_inputs_and_breaks_follow_their_stage() { + let input = LinePlan::from_lines((0..4).map(|_| text("x", 1))); + let removed = Arc::downgrade(input.entries.get(3).unwrap().value()); + let state = ProjectionState::new(input.clone(), Arc::from([LineOp::ScrollWindow(8)])); + let old_root = Arc::downgrade(&state.plan.identity); + let selected = state.plan().slice(0..2); + let joined = selected.concat(&LinePlan::from_lines([text("y", 1)])); + assert_eq!( + joined.break_after(0).unwrap().materialize().scroll_window, + Some(8) + ); + assert_eq!( + joined.break_after(1).unwrap().materialize().scroll_window, + None + ); + let outer = ProjectionState::new(joined, Arc::from([LineOp::ScrollWindow(9)])); + assert_eq!( + outer + .plan() + .break_after(1) + .unwrap() + .materialize() + .scroll_window, + Some(9) + ); + assert!(outer.plan().break_after(2).is_none()); + drop((input, state, selected)); + assert!(old_root.upgrade().is_none()); + assert!(removed.upgrade().is_none()); + assert_exact_metrics(outer.plan()); + } + + #[test] + fn repeated_projected_break_override_releases_shadowed_roots_and_properties() { + let mut plan = ProjectionState::new( + LinePlan::from_lines([text("a", 1), text("b", 1)]), + Arc::from([LineOp::Style(3)]), + ) + .plan() + .clone() + .with_break( + 0, + AtomProperties { + owner: Some(10), + ..AtomProperties::default() + }, + ); + for owner in 11..75 { + let old_line = plan.slice(0..1); + let old_root = + Arc::downgrade(old_line.extension.as_ref().expect("projected break root")); + let Some(Extension::Break { properties, .. }) = old_line.extension.as_deref() else { + panic!("break override") + }; + let old_properties = Arc::downgrade(properties); + plan = plan.with_break( + 0, + AtomProperties { + owner: Some(owner), + ..AtomProperties::default() + }, + ); + drop(old_line); + assert!( + old_root.upgrade().is_none(), + "old override root retained at {owner}" + ); + assert!( + old_properties.upgrade().is_none(), + "old override properties retained at {owner}" + ); + let line = plan.slice(0..1); + let Some(Extension::Break { input, .. }) = line.extension.as_deref() else { + panic!("current break override") + }; + assert!(!matches!( + input.extension.as_deref(), + Some(Extension::Break { .. }) + )); + assert_eq!( + plan.break_after(0).unwrap().materialize().owner, + Some(owner) + ); + } + } + + #[test] + fn zero_blank_frame_binds_forwarded_changes_across_empty_transitions() { + let mut bindings = Vec::new(); + for starts_empty in [true, false] { + let empty = LinePlan::default(); + let nonempty = LinePlan::from_lines([text("x", 1)]); + let (input, target) = if starts_empty { + (empty, nonempty) + } else { + (nonempty, empty) + }; + let spec = FrameSpec { + prefix: 0, + suffix: 0, + width: 3, + prefix_properties: Arc::new(AtomProperties::default()), + suffix_properties: Arc::new(AtomProperties::default()), + }; + let state = ProjectionState::new_frame(input.clone(), spec.clone()); + let proof = PlanChange::splices( + &input, + &target, + vec![LineSplice { + old: 0..input.len(), + new: 0..target.len(), + }], + ); + let updated = state.update_frame(target.clone(), Some(&proof), spec); + bindings.push(( + updated + .change + .applies_to(state.plan(), updated.state.plan()), + updated.state.plan().ptr_eq(&target), + )); + } + assert_eq!(bindings, [(true, true), (true, true)]); + } + + #[test] + fn retained_blank_frame_matches_vertical_oracle_and_keeps_only_current_state() { + let input = LinePlan::from_lines((0..5).map(|_| text("x", 2))); + let spec = FrameSpec { + prefix: 2, + suffix: 3, + width: 3, + prefix_properties: Arc::new(AtomProperties::default()), + suffix_properties: Arc::new(AtomProperties::default()), + }; + let state = ProjectionState::new_frame(input.clone(), spec.clone()); + let old_state = Arc::downgrade(&state); + let old_root = Arc::downgrade(&state.plan.identity); + let same = state.update_frame(input.clone(), Some(&PlanChange::same(&input)), spec.clone()); + assert!(Arc::ptr_eq(&state, &same.state)); + assert!(matches!(same.change.kind(), ChangeKind::Same)); + let target = LinePlan::from_lines((0..4).map(|_| text("y", 2))).concat(&input.slice(1..5)); + let proof = PlanChange::splices( + &input, + &target, + vec![LineSplice { + old: 0..1, + new: 0..4, + }], + ); + reset_work(); + let updated = state.update_frame( + target.clone(), + Some(&proof), + FrameSpec { + prefix: 1, + suffix: 1, + ..spec.clone() + }, + ); + assert_eq!(work().frame_blank_lines_created, 0); + assert!(matches!(updated.change.kind(), ChangeKind::Splices(_))); + let oracle = super::super::vertical_align( + target.clone(), + 10, + super::super::VerticalAlign::Center, + 3, + ); + assert_eq!( + Rendered::from_line_plan(updated.state.plan().clone()).into_tape(0), + Rendered::from_line_plan(oracle).into_tape(0) + ); + assert_exact_metrics(updated.state.plan()); + // The transaction-local result deliberately owns its exact old root; + // only the retained state must release history after that proof drops. + let ProjectionUpdate { + state: updated, + change, + } = updated; + drop((same, state, input, proof)); + assert!(old_state.upgrade().is_none()); + assert_eq!( + old_root.strong_count(), + 1, + "only the transient change owns the old root" + ); + assert!(Arc::ptr_eq( + &change.base.identity, + &old_root.upgrade().unwrap() + )); + drop(change); + assert!(old_root.upgrade().is_none()); + reset_work(); + let changed_parameters = updated.update_frame( + target, + None, + FrameSpec { + prefix: 1, + suffix: 1, + width: 7, + ..spec + }, + ); + assert_eq!(work().projection_fallbacks, 1); + assert_eq!(work().frame_blank_lines_created, 2); + assert!(matches!( + changed_parameters.change.kind(), + ChangeKind::ReplaceAll + )); + assert_exact_metrics(changed_parameters.state.plan()); + } + #[test] fn prefix_and_range_summaries_exclude_exactly_one_terminal_newline() { let empty = LinePlan::default(); @@ -450,9 +1827,20 @@ mod tests { let prefix = original.slice(0..1); assert!(prefix.break_after(0).is_none()); let joined = prefix.concat(&original.slice(1..3)); - assert_eq!(joined.break_after(0), Some(&AtomProperties::default())); - assert_eq!(joined.break_after(1).unwrap().scroll_window, Some(12)); - assert_eq!(original.break_after(0).unwrap().owner, Some(11)); + assert_eq!( + joined + .break_after(0) + .map(|view| view.materialize().into_owned()), + Some(AtomProperties::default()) + ); + assert_eq!( + joined.break_after(1).unwrap().materialize().scroll_window, + Some(12) + ); + assert_eq!( + original.break_after(0).unwrap().materialize().owner, + Some(11) + ); let explicit = prefix.concat_with_break( &original.slice(1..3), AtomProperties { @@ -460,7 +1848,10 @@ mod tests { ..AtomProperties::default() }, ); - assert_eq!(explicit.break_after(0).unwrap().owner, Some(13)); + assert_eq!( + explicit.break_after(0).unwrap().materialize().owner, + Some(13) + ); assert_eq!(prefix.concat(&LinePlan::default()).prefix_chars(1), 1); } From 73357dee94d66e37813da1014e09b3b65a3a6de8 Mon Sep 17 00:00:00 2001 From: Kinneyzhang Date: Sat, 5 Sep 2026 16:09:24 +0800 Subject: [PATCH 11/12] fix: wait a fresh idle interval before predictive reflow --- ebox.el | 6 ++- tests/ebox-core-render-tests.el | 82 ++++++++++++++++++++++++++++++--- 2 files changed, 80 insertions(+), 8 deletions(-) diff --git a/ebox.el b/ebox.el index b368212..0a4bb47 100644 --- a/ebox.el +++ b/ebox.el @@ -2463,7 +2463,8 @@ publishes the prepared rendered-body entry plus its derived root templates." (puthash buffer (run-with-idle-timer - (max 0 ebox-runtime-idle-reflow-cache-prewarm-delay) + (ebox--idle-continuation-delay + ebox-runtime-idle-reflow-cache-prewarm-delay) nil #'ebox--run-root-width-cache-prewarm buffer state revision region-id source-width target-width) ebox--reflow-cache-prewarm-timers))))) @@ -2505,7 +2506,8 @@ predictions are skipped." (puthash buffer (run-with-idle-timer - (max 0 ebox-runtime-idle-reflow-cache-prewarm-delay) + (ebox--idle-continuation-delay + ebox-runtime-idle-reflow-cache-prewarm-delay) nil #'ebox--run-reflow-cache-prewarm buffer state revision viewport-width target-width viewport-height) ebox--reflow-cache-prewarm-timers)))))) diff --git a/tests/ebox-core-render-tests.el b/tests/ebox-core-render-tests.el index 9ab6f0a..fa755be 100644 --- a/tests/ebox-core-render-tests.el +++ b/tests/ebox-core-render-tests.el @@ -1652,7 +1652,7 @@ (kill-buffer buffer))))) (ert-deftest ebox-root-width-cache-prewarm-yields-to-user-input () - "Ordinary root prediction should run only after an idle interval." + "Ordinary root prediction should wait a fresh interval even while idle." (ebox-test--reset-runtime-state) (let* ((layout (ebox-test-box :id "root" :width '(240) @@ -1674,7 +1674,9 @@ (generate-new-buffer-name " *ebox-root-schedule*") layout)) (let ((root-id (ebox-test--selector-region-id buffer "#root"))) - (cl-letf (((symbol-function 'run-at-time) + (cl-letf (((symbol-function 'current-idle-time) + (lambda () (seconds-to-time 0.4))) + ((symbol-function 'run-at-time) (lambda (&rest _) (ert-fail "Resize prediction must yield to input"))) ((symbol-function 'run-with-idle-timer) @@ -1687,7 +1689,7 @@ (ebox--schedule-buffer-root-width-cache-prewarm buffer root-id 240 260))) (should (= scheduled-delay - ebox-runtime-idle-reflow-cache-prewarm-delay)) + (+ 0.4 ebox-runtime-idle-reflow-cache-prewarm-delay))) (should-not scheduled-repeat) (should (eq scheduled-function #'ebox--run-root-width-cache-prewarm)) @@ -1697,6 +1699,72 @@ (when (buffer-live-p buffer) (kill-buffer buffer))))) +(ert-deftest ebox-reflow-prewarm-replacement-and-one-shot-completion () + "Fresh and zero-delay predictions replace old jobs and complete once." + (ebox-test--reset-runtime-state) + (let ((ebox-viewport-width 900) + (ebox-viewport-height 4) + (ebox-runtime-idle-prewarm nil) + (ebox-runtime-idle-reflow-cache-prewarm t) + (ebox--runtime-prewarm-allow-noninteractive t) + buffer) + (unwind-protect + (progn + (setq buffer + (ebox-render-to-buffer + (generate-new-buffer-name " *ebox-prewarm-one-shot*") + (ebox-test-box :id "root" :width '(240) :height 1 + (ebox-test-text "prediction")))) + (let ((state (ebox--buffer-render-state buffer)) + (root-id (ebox-test--selector-region-id buffer "#root"))) + (dolist (kind '(viewport root-width)) + (dolist (case '((nil 0.15 0.15) (0.4 0 0.4))) + (pcase-let* ((`(,idle-age ,delay ,threshold) case) + (ebox-runtime-idle-reflow-cache-prewarm-delay delay) + (scratch (plist-get state :reflow-prewarm-scratch)) + (cancel (symbol-function 'cancel-timer)) + (registrations 0) + (scheduled nil) + (cancelled nil)) + (cl-letf + (((symbol-function 'current-idle-time) + (lambda () (and idle-age (seconds-to-time idle-age)))) + ((symbol-function 'cancel-timer) + (lambda (timer) + (push timer cancelled) + (funcall cancel timer))) + ((symbol-function 'run-with-idle-timer) + (lambda (seconds repeat function &rest arguments) + (cl-incf registrations) + (setq scheduled (list seconds repeat function arguments)) + (timer-create)))) + (dotimes (attempt 2) + (let ((old (gethash buffer ebox--reflow-cache-prewarm-timers))) + (pcase kind + ('viewport + (ebox--schedule-buffer-reflow-cache-prewarm + buffer 880 900)) + ('root-width + (ebox--schedule-buffer-root-width-cache-prewarm + buffer root-id 240 260))) + (when (= attempt 1) + (should (memq old cancelled)) + (should-not + (eq old (gethash buffer ebox--reflow-cache-prewarm-timers)))))) + (should (= registrations 2)) + (should (= (car scheduled) threshold)) + (should-not (cadr scheduled)) + (should (eq scratch (plist-get state :reflow-prewarm-scratch))) + ;; Advance the same idle period beyond the fixed threshold. + ;; Run the real callback, including its state/revision guards. + (setq idle-age (+ threshold 1)) + (should (apply (nth 2 scheduled) (nth 3 scheduled))) + (should (plist-get state :reflow-prewarm-scratch)) + (should-not (gethash buffer ebox--reflow-cache-prewarm-timers)) + (should (= registrations 2)))))))) + (when (buffer-live-p buffer) + (kill-buffer buffer))))) + (ert-deftest ebox-scheduled-reflow-prewarms-use-offscreen-gc-pressure () "Both predicted reflow paths should move GC pressure off visible frames." (let ((prewarm-count 0) @@ -10656,7 +10724,7 @@ :rendered-region-line-span-index-deferred)))))) (ert-deftest ebox-reflow-cache-prewarm-uses-first-valid-width-delta () - "Predictive warming should start from the first valid resize velocity." + "First resize prediction should wait a fresh interval even while idle." (ebox-test--reset-runtime-state) (let* ((layout (ebox-test-box :id "root" :width '(viewport) :height 2 @@ -10676,7 +10744,9 @@ (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") layout)) - (cl-letf (((symbol-function 'run-at-time) + (cl-letf (((symbol-function 'current-idle-time) + (lambda () (seconds-to-time 0.4))) + ((symbol-function 'run-at-time) (lambda (&rest _) (ert-fail "Resize prediction must yield to input"))) ((symbol-function 'run-with-idle-timer) @@ -10688,7 +10758,7 @@ 'scheduled-reflow-timer))) (ebox-rerender-buffer-with-context buffer 260 4)) (should (= scheduled-delay - ebox-runtime-idle-reflow-cache-prewarm-delay)) + (+ 0.4 ebox-runtime-idle-reflow-cache-prewarm-delay))) (should-not scheduled-repeat) (should (eq scheduled-function #'ebox--run-reflow-cache-prewarm)) (should (equal (car scheduled-arguments) buffer)) From 727aba2ec77a31486ef86a1dc93645ca67c0888f Mon Sep 17 00:00:00 2001 From: Kinneyzhang Date: Sat, 5 Sep 2026 17:26:13 +0800 Subject: [PATCH 12/12] 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))