lattice_cells/chunk.rs
1//! Chunk of contiguous matrix rows.
2//!
3//! A `CellChunk` is the unit of cache + rebuild for the cell-grid
4//! renderer. Edits invalidate one or two chunks (the ones
5//! intersecting the change range); downstream chunks (lines past
6//! the edit) have their `start_source_line` shifted by `Δ`
7//! without rebuild.
8//!
9//! See `docs/dev/architecture/cell-grid-renderer.md` § Chunking
10//! policy for sizing rules (`chunk_size = 2 × viewport_height`,
11//! whole-doc mode below `4 × viewport_height`).
12
13use std::sync::Arc;
14
15use crate::row::CellRow;
16use crate::version::MatrixVersion;
17
18/// Contiguous range of matrix rows covering a slice of the buffer.
19///
20/// Invariants (S2 enforces; S1 documents):
21/// - `rows` is sorted ascending by `source_line`.
22/// - Folded source lines do not appear; row count is post-fold.
23/// - `start_source_line` is the FIRST source line that *could*
24/// appear in this chunk's range. The chunk's range is
25/// `[start_source_line, start_source_line + chunk_size)` in
26/// *logical* (pre-fold) source-line space. Some of those
27/// source lines may be folded and therefore absent from `rows`.
28/// - `version` is the `MatrixVersion` snapshot captured at build
29/// time. Cell-builder compares against current RenderState
30/// version to decide if this chunk needs rebuild.
31#[derive(Clone, Debug)]
32pub struct CellChunk {
33 pub start_source_line: u32,
34 pub rows: Arc<[CellRow]>,
35 pub version: MatrixVersion,
36}
37
38impl CellChunk {
39 /// Construct a chunk. S2 is the production caller.
40 pub fn new(
41 start_source_line: u32,
42 rows: impl Into<Arc<[CellRow]>>,
43 version: MatrixVersion,
44 ) -> Self {
45 Self {
46 start_source_line,
47 rows: rows.into(),
48 version,
49 }
50 }
51
52 /// Empty chunk anchored at `start_source_line`. Used when the
53 /// covered source-line range is entirely folded (no visible
54 /// rows) or as a placeholder during incremental build.
55 pub fn empty(start_source_line: u32, version: MatrixVersion) -> Self {
56 Self::new(start_source_line, Arc::from([] as [CellRow; 0]), version)
57 }
58
59 /// Number of matrix rows this chunk contributes (post-fold).
60 pub fn row_count(&self) -> u32 {
61 self.rows.len() as u32
62 }
63
64 /// `true` when no rows are present (fully-folded range or
65 /// freshly-allocated empty chunk).
66 pub fn is_empty(&self) -> bool {
67 self.rows.is_empty()
68 }
69
70 /// Returns the row whose `source_line` equals `target`, if it
71 /// is present in this chunk (i.e. not folded).
72 ///
73 /// Binary search on `source_line`; O(log N) for the typical
74 /// 128-row chunk. Returns `None` if `target` is folded or
75 /// outside the chunk's range.
76 pub fn row_at_source_line(&self, target: u32) -> Option<&CellRow> {
77 match self.rows.binary_search_by_key(&target, |r| r.source_line) {
78 Ok(idx) => self.rows.get(idx),
79 Err(_) => None,
80 }
81 }
82
83 /// Clone-with-shifted-line. Produces a new chunk whose
84 /// `start_source_line` and every contained row's `source_line`
85 /// are shifted by `line_delta`. Cell payloads are shared (one
86 /// `Arc` refcount bump per row's `cells` + `inlay_offsets`);
87 /// no rope reads, no theme work, no syntax walk.
88 ///
89 /// Used by the cell-builder's incremental rebuild path
90 /// (S2.4.b) for chunks past a single-line edit's affected
91 /// range — their cell content is unchanged, only their
92 /// logical line position shifts. `new_version` stamps the
93 /// chunk with the publisher's current matrix version so the
94 /// renderer can compare against the new matrix's `version`.
95 ///
96 /// `line_delta` may be negative (deletions shift downstream
97 /// lines down). The shifted `source_line` saturates at 0 if
98 /// the caller passes an unreasonably-negative delta; callers
99 /// in S2.4.b only ever invoke this on chunks past the edit's
100 /// affected range, so the saturating path is defensive only.
101 pub fn shifted_by(&self, line_delta: i32, new_version: MatrixVersion) -> Self {
102 let shifted_rows: Vec<CellRow> = self
103 .rows
104 .iter()
105 .map(|r| {
106 let new_line = (r.source_line as i64 + line_delta as i64).max(0) as u32;
107 r.with_source_line(new_line)
108 })
109 .collect();
110 let new_start = (self.start_source_line as i64 + line_delta as i64).max(0) as u32;
111 Self {
112 start_source_line: new_start,
113 rows: Arc::from(shifted_rows.into_boxed_slice()),
114 version: new_version,
115 }
116 }
117}
118
119#[cfg(test)]
120mod tests {
121 use super::*;
122 use crate::cell::Cell;
123
124 fn row(source_line: u32, ch: u8) -> CellRow {
125 CellRow::new(
126 vec![Cell::with_codepoint(ch as u32)],
127 source_line,
128 Vec::<crate::row::InlayOffset>::new(),
129 )
130 }
131
132 #[test]
133 fn empty_chunk_reports_empty() {
134 let c = CellChunk::empty(10, MatrixVersion::ZERO);
135 assert_eq!(c.start_source_line, 10);
136 assert!(c.is_empty());
137 assert_eq!(c.row_count(), 0);
138 assert!(c.row_at_source_line(10).is_none());
139 }
140
141 #[test]
142 fn row_at_source_line_finds_present_row() {
143 let c = CellChunk::new(
144 0,
145 vec![row(0, b'a'), row(1, b'b'), row(2, b'c')],
146 MatrixVersion::ZERO,
147 );
148 assert_eq!(c.row_count(), 3);
149 assert_eq!(c.row_at_source_line(0).unwrap().source_line, 0);
150 assert_eq!(c.row_at_source_line(1).unwrap().source_line, 1);
151 assert_eq!(c.row_at_source_line(2).unwrap().source_line, 2);
152 }
153
154 /// Fold elision: source line 1 is folded so the chunk's
155 /// rows array skips it. `row_at_source_line(1)` returns None.
156 #[test]
157 fn row_at_source_line_returns_none_for_folded() {
158 let c = CellChunk::new(
159 0,
160 vec![row(0, b'a'), row(2, b'c'), row(4, b'e')],
161 MatrixVersion::ZERO,
162 );
163 assert_eq!(c.row_count(), 3);
164 assert!(c.row_at_source_line(0).is_some());
165 assert!(c.row_at_source_line(1).is_none()); // folded
166 assert!(c.row_at_source_line(2).is_some());
167 assert!(c.row_at_source_line(3).is_none()); // folded
168 assert!(c.row_at_source_line(4).is_some());
169 assert!(c.row_at_source_line(5).is_none()); // out of range
170 }
171
172 #[test]
173 fn row_at_source_line_works_on_single_row() {
174 let c = CellChunk::new(7, vec![row(7, b'x')], MatrixVersion::ZERO);
175 assert_eq!(c.row_count(), 1);
176 assert_eq!(c.row_at_source_line(7).unwrap().source_line, 7);
177 assert!(c.row_at_source_line(6).is_none());
178 assert!(c.row_at_source_line(8).is_none());
179 }
180
181 #[test]
182 fn version_round_trip() {
183 let v = MatrixVersion {
184 text: 5,
185 syntax: 2,
186 inlay_hints: 1,
187 folds: 0,
188 theme: 0,
189 whitespace: 0,
190 indent: 0,
191 conceal: 0,
192 };
193 let c = CellChunk::empty(0, v);
194 assert_eq!(c.version, v);
195 }
196
197 /// S2.4.b: `shifted_by` advances `start_source_line` and every
198 /// row's `source_line` while sharing the inner cell payload
199 /// (Arc identity preserved on each row's `cells`).
200 #[test]
201 fn shifted_by_advances_start_and_rows() {
202 let c = CellChunk::new(
203 10,
204 vec![row(10, b'a'), row(11, b'b'), row(13, b'd')],
205 MatrixVersion::ZERO,
206 );
207 let new_v = MatrixVersion {
208 text: 2,
209 syntax: 2,
210 inlay_hints: 0,
211 folds: 0,
212 theme: 0,
213 whitespace: 0,
214 indent: 0,
215 conceal: 0,
216 };
217 let s = c.shifted_by(3, new_v);
218 assert_eq!(s.start_source_line, 13);
219 assert_eq!(s.version, new_v);
220 let lines: Vec<u32> = s.rows.iter().map(|r| r.source_line).collect();
221 assert_eq!(lines, vec![13, 14, 16]);
222 // Cell payloads remain shared (Arc identity).
223 for (orig, shifted_row) in c.rows.iter().zip(s.rows.iter()) {
224 assert!(Arc::ptr_eq(&orig.cells, &shifted_row.cells));
225 }
226 }
227
228 /// Negative delta shifts upwards; saturates at zero rather
229 /// than wrapping.
230 #[test]
231 fn shifted_by_negative_saturates_at_zero() {
232 let c = CellChunk::new(2, vec![row(2, b'a'), row(3, b'b')], MatrixVersion::ZERO);
233 let s = c.shifted_by(-5, MatrixVersion::ZERO);
234 assert_eq!(s.start_source_line, 0);
235 let lines: Vec<u32> = s.rows.iter().map(|r| r.source_line).collect();
236 assert_eq!(lines, vec![0, 0]); // both saturated
237 }
238}