Skip to main content

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}