Skip to main content

lattice_host/
display_matrix.rs

1//! Per-line display cache — the substrate that retires the
2//! per-character [`lattice_cells::CellMatrix`].
3//!
4//! See `docs/dev/architecture/display-line.md` (design) and
5//! `docs/dev/operations/slice-plans/display-line.md` (slices).
6//!
7//! ## What this is
8//!
9//! A [`DisplayLine`] is the renderer-agnostic, fully-resolved display
10//! form of one source line: the final display `text` (inlay hints
11//! spliced in, tabs expanded to display width, whitespace markers
12//! substituted), the style `runs` over it ([`RowRun`], style *tags*
13//! resolved to colour by each renderer at paint), a `col_map` from
14//! source bytes to inserted display columns (cursor / selection /
15//! overlay coordinate translation), the display `col_count` (for
16//! soft-wrap segment geometry), and an optional [`FoldHead`] when the
17//! line heads a closed fold.
18//!
19//! [`DisplayMatrix`] is the chunked, viewport-windowed,
20//! incrementally-rebuilt cache of `DisplayLine`s — the exact machinery
21//! of `CellMatrix` (chunking, windowing, `MatrixVersion`, row reuse via
22//! `Arc`) with the payload swapped from `Vec<Cell>` to `DisplayLine`.
23//! Both renderers consume it directly: TUI maps `text` + `runs` to
24//! ratatui cells; GPU shapes `text` once (`shape_line`, LineLayoutCache)
25//! with per-run colours — no per-char intermediate, no un-bake.
26//!
27//! ## B1 scope
28//!
29//! Types + machinery only (`empty` / `whole_doc` / `chunked`,
30//! `row_at_source_line`, coverage, `segment_count`, `shifted_by`).
31//! The worker build path, the shared incremental-reuse, the
32//! always-current synchronous rebuild, and the renderer cutovers land
33//! in B2–B4. Not consumed by any renderer yet.
34
35use std::sync::Arc;
36
37use lattice_cells::{CHUNK_SIZE_WHOLE_DOC, MatrixVersion, wrap_segments};
38use lattice_syntax::Style;
39
40/// A style-tagged run within a [`DisplayLine`]'s `text` — the per-line
41/// analogue of a `Cell`, one per contiguous run instead of per char.
42/// The renderer resolves `style` → foreground colour + modifiers via
43/// the per-frame theme; `flags` carries the non-style bits the cell
44/// model baked: [`lattice_cells::cell_flags::INLAY`] for spliced inlay
45/// text, `WS_MARKER` for a whitespace-marker glyph. Run lengths
46/// (`len`, utf-8 bytes) sum to `text.len()`.
47#[derive(Clone, Copy, Debug, PartialEq, Eq)]
48pub struct DisplayRun {
49    pub len: u32,
50    pub style: Style,
51    pub flags: u16,
52    /// DR.2 (2026-08-12): intra-line diff refinement — when `Some`,
53    /// this run's **background** overrides its row's diff tint.
54    ///
55    /// The second axis of `span-layering.md`, narrowed from per-row to
56    /// per-range. Runs already split wherever appearance changes, so
57    /// carrying it here costs one field and no new splitting concept.
58    /// Foreground is untouched, which is what keeps the syntax colour
59    /// DS.1–DS.5 added visible under the refinement.
60    pub refine: Option<lattice_cells::RefineKind>,
61}
62
63/// Closed-fold head marker carried by the first visible line of a
64/// folded region. `folded_lines` is how many source lines the fold
65/// collapses (for the ` ┄ N lines folded` gutter / inline suffix).
66#[derive(Clone, Copy, Debug, PartialEq, Eq)]
67pub struct FoldHead {
68    pub folded_lines: u32,
69}
70
71/// The fully-resolved display form of one source line. Fields are
72/// `Arc`-shared so [`Self::with_source_line`] (the incremental-reuse
73/// shift) is a refcount bump, not a copy — mirroring `CellRow`.
74#[derive(Clone, Debug)]
75pub struct DisplayLine {
76    /// Logical (pre-fold) source line this row renders.
77    pub source_line: u32,
78    /// Final display string: inlays spliced, tabs expanded to display
79    /// width, whitespace markers substituted.
80    pub text: Arc<str>,
81    /// Style-tagged byte runs partitioning `text` left-to-right.
82    /// Run lengths sum to `text.len()`. See [`DisplayRun`].
83    pub runs: Arc<[DisplayRun]>,
84    /// `(source_byte, extra_display_cols)` breakpoints: at each source
85    /// byte, how many extra display columns were inserted ahead of it
86    /// (inlay text + tab expansion). Drives source-byte ↔ display-col
87    /// translation. Same shape as `CellRow::inlay_offsets`.
88    pub col_map: Arc<[(u32, u32)]>,
89    /// H.1: source-byte ranges this line hides — `[start, end)`,
90    /// sorted ascending and non-overlapping (the builder coalesces
91    /// before storing; two overlapping ranges would have their
92    /// shared width subtracted twice and every column past them
93    /// would be wrong).
94    ///
95    /// A hidden range occupies zero display columns and its bytes
96    /// are absent from [`Self::text`], so this is what lets a
97    /// source position still be located: see
98    /// [`lattice_cells::source_byte_to_display_col`]. Empty for
99    /// every line of a buffer whose language declares no conceal
100    /// rules, which is the path that must stay free.
101    ///
102    /// Deliberately NOT folded into [`Self::col_map`] as a signed
103    /// delta. `col_map`'s columns are already char-resolved, so a
104    /// hidden range removes exactly `end - start` of them — the
105    /// width is derivable from the range and a second encoding of
106    /// it could only ever disagree with the first.
107    pub conceals: Arc<[lattice_cells::ConcealRange]>,
108    /// Display width in columns (char count of `text`). Soft-wrap
109    /// geometry reads this via [`DisplayMatrix::segment_count`].
110    pub col_count: u32,
111    /// `Some` when this line heads a closed fold.
112    pub fold: Option<FoldHead>,
113}
114
115impl DisplayLine {
116    /// Clone with a new `source_line`; all payload `Arc`s are shared
117    /// (refcount bump only). Used by the incremental-rebuild shift for
118    /// lines past an edit whose content is unchanged.
119    pub fn with_source_line(&self, source_line: u32) -> Self {
120        Self {
121            source_line,
122            text: self.text.clone(),
123            runs: self.runs.clone(),
124            col_map: self.col_map.clone(),
125            conceals: self.conceals.clone(),
126            col_count: self.col_count,
127            fold: self.fold,
128        }
129    }
130
131    /// Map a source byte (already char-resolved) → combined display
132    /// column for this line. Returns the column *after* any inlay /
133    /// tab-expansion columns inserted at or before `byte`. The
134    /// `DisplayLine` analogue of `CellRow::byte_to_combined_col`; both
135    /// walk the same `(orig_byte, extra_cols)` breakpoint list
136    /// (`col_map` here, `inlay_offsets` there), so overlay / cursor
137    /// positioning is identical across the cell and display substrates.
138    /// `col_map` is sorted ascending by `orig_byte` (build invariant),
139    /// so the walk can stop at the first breakpoint past `byte`.
140    ///
141    /// H.1: also subtracts [`Self::conceals`], and a byte falling
142    /// *inside* a hidden range resolves to that range's start column.
143    /// The arithmetic lives in [`lattice_cells::source_byte_to_display_col`]
144    /// rather than here because three carriers ask this question and an
145    /// elision the cursor agrees with and the search highlight does not
146    /// is a caret sitting off its own match.
147    pub fn byte_to_combined_col(&self, byte: u32) -> u32 {
148        lattice_cells::source_byte_to_display_col(byte, &self.col_map, &self.conceals)
149    }
150}
151
152/// Contiguous range of display rows covering a slice of the buffer.
153/// Same invariants as `CellChunk`: `rows` sorted ascending by
154/// `source_line`, folded lines absent (row count is post-fold),
155/// `start_source_line` is the first source line the chunk's
156/// `[start, start + chunk_size)` logical range *could* contain.
157#[derive(Clone, Debug)]
158pub struct DisplayChunk {
159    pub start_source_line: u32,
160    pub rows: Arc<[DisplayLine]>,
161    pub version: MatrixVersion,
162}
163
164impl DisplayChunk {
165    pub fn new(
166        start_source_line: u32,
167        rows: impl Into<Arc<[DisplayLine]>>,
168        version: MatrixVersion,
169    ) -> Self {
170        Self {
171            start_source_line,
172            rows: rows.into(),
173            version,
174        }
175    }
176
177    pub fn empty(start_source_line: u32, version: MatrixVersion) -> Self {
178        Self::new(
179            start_source_line,
180            Arc::from([] as [DisplayLine; 0]),
181            version,
182        )
183    }
184
185    pub fn row_count(&self) -> u32 {
186        self.rows.len() as u32
187    }
188
189    pub fn is_empty(&self) -> bool {
190        self.rows.is_empty()
191    }
192
193    /// Row whose `source_line == target`, or `None` if folded /
194    /// outside the chunk. Binary search (rows are sorted).
195    pub fn row_at_source_line(&self, target: u32) -> Option<&DisplayLine> {
196        match self.rows.binary_search_by_key(&target, |r| r.source_line) {
197            Ok(idx) => self.rows.get(idx),
198            Err(_) => None,
199        }
200    }
201
202    /// Clone-with-shifted-line: `start_source_line` and every row's
203    /// `source_line` shift by `line_delta` (saturating at 0); payload
204    /// `Arc`s shared. `new_version` stamps the result.
205    pub fn shifted_by(&self, line_delta: i32, new_version: MatrixVersion) -> Self {
206        let shifted_rows: Vec<DisplayLine> = self
207            .rows
208            .iter()
209            .map(|r| {
210                let new_line = (r.source_line as i64 + line_delta as i64).max(0) as u32;
211                r.with_source_line(new_line)
212            })
213            .collect();
214        let new_start = (self.start_source_line as i64 + line_delta as i64).max(0) as u32;
215        Self {
216            start_source_line: new_start,
217            rows: Arc::from(shifted_rows.into_boxed_slice()),
218            version: new_version,
219        }
220    }
221}
222
223/// Chunked, viewport-windowed cache of [`DisplayLine`]s. Mirrors
224/// `CellMatrix` exactly; only the row payload differs.
225#[derive(Clone, Debug)]
226pub struct DisplayMatrix {
227    /// Chunks ordered by `start_source_line`. Whole-doc mode has one.
228    pub chunks: Arc<[Arc<DisplayChunk>]>,
229    /// Logical lines per chunk, or [`CHUNK_SIZE_WHOLE_DOC`] for
230    /// whole-doc mode.
231    pub chunk_size: u32,
232    /// Total logical lines in the source buffer (pre-fold).
233    pub source_line_count: u32,
234    /// Total display rows across all chunks (post-fold).
235    pub visible_line_count: u32,
236    /// Component-wise version captured at build time.
237    pub version: MatrixVersion,
238    /// Soft-wrap column width, or `0` when wrapping is off (one display
239    /// row per source line). Stamped by the worker from the pane width.
240    pub wrap_width: u32,
241    /// CL.1: the line this matrix was built with its conceals suppressed on.
242    ///
243    /// Carried on the matrix rather than folded into `MatrixVersion` on
244    /// purpose. The version is the cache-hit key, and the reveal line moves
245    /// with the CURSOR — folding it in would invalidate the whole matrix on
246    /// every `j`, turning a 47 ns cache hit into a ~1.5 ms window rebuild.
247    /// Kept beside the version instead, so the worker can see the reveal moved
248    /// and rebuild exactly the two rows that changed.
249    pub reveal_line: Option<u32>,
250}
251
252impl Default for DisplayMatrix {
253    fn default() -> Self {
254        Self::empty()
255    }
256}
257
258impl DisplayMatrix {
259    pub fn empty() -> Self {
260        Self {
261            chunks: Arc::from([] as [Arc<DisplayChunk>; 0]),
262            chunk_size: CHUNK_SIZE_WHOLE_DOC,
263            source_line_count: 0,
264            visible_line_count: 0,
265            version: MatrixVersion::ZERO,
266            wrap_width: 0,
267            reveal_line: None,
268        }
269    }
270
271    pub fn chunked(
272        chunks: impl Into<Arc<[Arc<DisplayChunk>]>>,
273        chunk_size: u32,
274        source_line_count: u32,
275        version: MatrixVersion,
276    ) -> Self {
277        assert!(chunk_size > 0, "chunked mode requires chunk_size > 0");
278        let chunks: Arc<[Arc<DisplayChunk>]> = chunks.into();
279        let visible_line_count = chunks.iter().map(|c| c.row_count()).sum::<u32>();
280        Self {
281            chunks,
282            chunk_size,
283            source_line_count,
284            visible_line_count,
285            version,
286            wrap_width: 0,
287            reveal_line: None,
288        }
289    }
290
291    pub fn whole_doc(chunk: Arc<DisplayChunk>, source_line_count: u32) -> Self {
292        let visible_line_count = chunk.row_count();
293        let version = chunk.version;
294        Self {
295            chunks: Arc::from(vec![chunk]),
296            chunk_size: CHUNK_SIZE_WHOLE_DOC,
297            source_line_count,
298            visible_line_count,
299            version,
300            wrap_width: 0,
301            reveal_line: None,
302        }
303    }
304
305    pub fn is_whole_doc(&self) -> bool {
306        self.chunk_size == CHUNK_SIZE_WHOLE_DOC
307    }
308
309    pub fn is_empty(&self) -> bool {
310        self.visible_line_count == 0
311    }
312
313    /// How many display rows source line `target` occupies under
314    /// soft-wrap (`1` when wrapping off / line missing / folded).
315    pub fn segment_count(&self, target: u32) -> u32 {
316        if self.wrap_width == 0 {
317            return 1;
318        }
319        match self.row_at_source_line(target) {
320            Some(row) => wrap_segments(row.col_count, self.wrap_width),
321            None => 1,
322        }
323    }
324
325    /// First source line the chunks were built to cover (`0` for
326    /// whole-doc / full-coverage chunked; the window lower bound when
327    /// windowed). H.3 coverage semantics, ported.
328    pub fn covered_start_line(&self) -> u32 {
329        self.chunks
330            .first()
331            .map(|c| c.start_source_line)
332            .unwrap_or(0)
333    }
334
335    /// Exclusive upper bound of the covered source-line range.
336    pub fn covered_end_line(&self) -> u32 {
337        if self.is_whole_doc() {
338            return self.source_line_count;
339        }
340        self.chunks
341            .last()
342            .map(|c| {
343                c.start_source_line
344                    .saturating_add(self.chunk_size)
345                    .min(self.source_line_count)
346            })
347            .unwrap_or(0)
348    }
349
350    /// Does the matrix cover all of `[lo, hi)`? Empty matrix covers
351    /// nothing. Drives the worker cache-hit / window-extend gate.
352    pub fn covers(&self, lo: u32, hi: u32) -> bool {
353        if self.chunks.is_empty() {
354            return false;
355        }
356        self.covered_start_line() <= lo && hi <= self.covered_end_line()
357    }
358
359    /// Row whose `source_line == target`, walking chunks in order.
360    /// `None` when folded or outside coverage (the renderer falls back
361    /// to its rope/plain path only transiently, off-window).
362    pub fn row_at_source_line(&self, target: u32) -> Option<&DisplayLine> {
363        for chunk in self.chunks.iter() {
364            let start = chunk.start_source_line;
365            let end = if self.chunk_size == CHUNK_SIZE_WHOLE_DOC {
366                self.source_line_count
367            } else {
368                start.saturating_add(self.chunk_size)
369            };
370            if target < start {
371                return None;
372            }
373            if target < end {
374                return chunk.row_at_source_line(target);
375            }
376        }
377        None
378    }
379}
380
381#[cfg(test)]
382mod tests {
383    use super::*;
384
385    fn line(source_line: u32, text: &str) -> DisplayLine {
386        let col_count = text.chars().count() as u32;
387        DisplayLine {
388            source_line,
389            text: Arc::from(text),
390            runs: Arc::from(
391                vec![DisplayRun {
392                    len: text.len() as u32,
393                    style: Style::Default,
394                    flags: 0,
395                    refine: None,
396                }]
397                .into_boxed_slice(),
398            ),
399            col_map: Arc::from([] as [(u32, u32); 0]),
400            conceals: Arc::from([] as [lattice_cells::ConcealRange; 0]),
401            col_count,
402            fold: None,
403        }
404    }
405
406    /// A line carrying both tables, for the H.1 coordinate tests.
407    fn line_with(
408        text: &str,
409        col_map: &[(u32, u32)],
410        conceals: &[lattice_cells::ConcealRange],
411    ) -> DisplayLine {
412        let mut l = line(0, text);
413        l.col_map = Arc::from(col_map.to_vec().into_boxed_slice());
414        l.conceals = Arc::from(conceals.to_vec().into_boxed_slice());
415        l
416    }
417
418    #[test]
419    fn h1_no_conceals_is_the_pre_h1_behaviour() {
420        // The regression guard for every buffer in the editor: with an
421        // empty conceal list the delegating implementation must agree
422        // with the inlay-only walk it replaced, entry for entry.
423        let l = line_with("hello world", &[(1, 2), (3, 1)], &[]);
424        assert_eq!(l.byte_to_combined_col(0), 0);
425        assert_eq!(l.byte_to_combined_col(1), 3);
426        assert_eq!(l.byte_to_combined_col(2), 4);
427        assert_eq!(l.byte_to_combined_col(3), 6);
428        assert_eq!(l.byte_to_combined_col(5), 8);
429    }
430
431    #[test]
432    fn h1_a_byte_inside_a_concealed_range_clamps_to_its_start() {
433        // `[[a][hi]]` in miniature: hide [0,4), show 4..6, hide [6,9).
434        let l = line_with("[[a][hi]]", &[], &[(0, 4), (6, 9)]);
435        assert_eq!(l.byte_to_combined_col(0), 0, "before anything visible");
436        assert_eq!(l.byte_to_combined_col(2), 0, "inside the first hidden run");
437        assert_eq!(l.byte_to_combined_col(4), 0, "first visible byte");
438        assert_eq!(l.byte_to_combined_col(5), 1);
439        assert_eq!(l.byte_to_combined_col(7), 2, "inside the second hidden run");
440        assert_eq!(l.byte_to_combined_col(9), 2, "past both");
441    }
442
443    #[test]
444    fn h1_with_source_line_shares_the_conceal_arc() {
445        // The incremental-rebuild shift reuses payload Arcs so unedited
446        // lines stay byte-identical and therefore pixel-stable. A new
447        // field that got cloned instead of shared would not fail any
448        // behaviour test — only this one.
449        let l = line_with("x", &[], &[(0, 1)]);
450        let shifted = l.with_source_line(7);
451        assert_eq!(shifted.source_line, 7);
452        assert!(
453            Arc::ptr_eq(&l.conceals, &shifted.conceals),
454            "conceals must be shared, not re-allocated"
455        );
456    }
457
458    #[test]
459    fn whole_doc_basic_lookup() {
460        let chunk = Arc::new(DisplayChunk::new(
461            0,
462            vec![line(0, "a"), line(1, "bb"), line(2, "ccc")],
463            MatrixVersion::ZERO,
464        ));
465        let m = DisplayMatrix::whole_doc(chunk, 3);
466        assert!(m.is_whole_doc());
467        assert_eq!(m.visible_line_count, 3);
468        assert_eq!(m.row_at_source_line(1).unwrap().text.as_ref(), "bb");
469        assert!(m.row_at_source_line(3).is_none());
470        assert!(m.covers(0, 3));
471        assert_eq!(m.covered_start_line(), 0);
472        assert_eq!(m.covered_end_line(), 3);
473    }
474
475    #[test]
476    fn chunked_lookup_and_coverage() {
477        // One chunk of size 16 over a 25-line doc, windowed to [16,25).
478        let c1 = Arc::new(DisplayChunk::new(
479            16,
480            (16u32..25).map(|i| line(i, "x")).collect::<Vec<_>>(),
481            MatrixVersion::ZERO,
482        ));
483        let m = DisplayMatrix::chunked(vec![c1], 16, 25, MatrixVersion::ZERO);
484        assert!(!m.is_whole_doc());
485        assert_eq!(m.visible_line_count, 9);
486        assert!(m.row_at_source_line(20).is_some());
487        assert!(m.row_at_source_line(5).is_none(), "off-window below");
488        assert_eq!(m.covered_start_line(), 16);
489        assert_eq!(m.covered_end_line(), 25, "16+16 clamped to line count");
490        assert!(m.covers(18, 22));
491        assert!(!m.covers(5, 22), "window does not cover line 5");
492    }
493
494    #[test]
495    fn empty_covers_nothing() {
496        let m = DisplayMatrix::empty();
497        assert!(m.is_empty());
498        assert!(!m.covers(0, 1));
499        assert!(m.row_at_source_line(0).is_none());
500    }
501
502    #[test]
503    fn segment_count_wraps_on_width() {
504        let chunk = Arc::new(DisplayChunk::new(
505            0,
506            vec![line(0, "0123456789")], // 10 cols
507            MatrixVersion::ZERO,
508        ));
509        let mut m = DisplayMatrix::whole_doc(chunk, 1);
510        assert_eq!(m.segment_count(0), 1, "wrap off");
511        m.wrap_width = 4;
512        assert_eq!(m.segment_count(0), 3, "ceil(10/4) = 3");
513        assert_eq!(m.segment_count(99), 1, "missing line → 1");
514    }
515
516    #[test]
517    fn with_source_line_shares_payload() {
518        let l = line(5, "hello");
519        let shifted = l.with_source_line(8);
520        assert_eq!(shifted.source_line, 8);
521        assert_eq!(shifted.text.as_ref(), "hello");
522        assert!(Arc::ptr_eq(&l.text, &shifted.text));
523        assert!(Arc::ptr_eq(&l.runs, &shifted.runs));
524    }
525
526    #[test]
527    fn byte_to_combined_col_shifts_by_colmap_widths_at_or_before_byte() {
528        // Two breakpoints: an inlay of width 3 at byte 2, a tab
529        // expansion of +3 cols at byte 5. Mirrors `CellRow`'s test.
530        let mut l = line(0, "ignored");
531        l.col_map = Arc::from(vec![(2u32, 3u32), (5u32, 3u32)].into_boxed_slice());
532        // No breakpoint at/before byte 1 → col == byte.
533        assert_eq!(l.byte_to_combined_col(1), 1);
534        // Breakpoint at byte 2 (orig_byte <= byte) shifts by +3.
535        assert_eq!(l.byte_to_combined_col(2), 5);
536        // Both breakpoints (2 and 5) apply at byte 6 → +6.
537        assert_eq!(l.byte_to_combined_col(6), 12);
538        // Empty col_map → identity.
539        let plain = line(0, "abc");
540        assert_eq!(plain.byte_to_combined_col(3), 3);
541    }
542
543    #[test]
544    fn shifted_by_advances_lines_sharing_payload() {
545        let chunk = DisplayChunk::new(10, vec![line(10, "a"), line(12, "c")], MatrixVersion::ZERO);
546        let s = chunk.shifted_by(3, MatrixVersion::ZERO);
547        assert_eq!(s.start_source_line, 13);
548        let lines: Vec<u32> = s.rows.iter().map(|r| r.source_line).collect();
549        assert_eq!(lines, vec![13, 15]);
550        for (o, n) in chunk.rows.iter().zip(s.rows.iter()) {
551            assert!(Arc::ptr_eq(&o.text, &n.text));
552        }
553    }
554}