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}