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