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)