lattice_host/indent_guides.rs
1//! Indentation guides — the per-pane layer both renderers paint from.
2//!
3//! See `docs/dev/architecture/indent-guides.md` (design) and
4//! `docs/dev/operations/slice-plans/indent-guides.md` (slices).
5//!
6//! ## What this is
7//!
8//! [`IndentGuides`] is the resolved answer to "which columns carry a guide on
9//! which rows, and which block is the cursor in". It is built by
10//! `cells_worker` in the pass that builds the pane's
11//! [`DisplayMatrix`](crate::display_matrix::DisplayMatrix), from the same
12//! snapshot and stamped with the same [`MatrixVersion`] — so the two cannot
13//! disagree and guides need no staleness axis of their own.
14//!
15//! ## Why the paint predicate is resolved here
16//!
17//! [`IndentBlock::paints_on`] decides whether a guide may occupy a column, and
18//! getting it wrong means painting over text. Applying it in the producer
19//! rather than in each renderer means there is exactly one implementation: a
20//! bug surfaces as a failing test here rather than as corrupted text in one
21//! peer and not the other. What each renderer still owns is the *mechanism* —
22//! the TUI substitutes a glyph into a cell, the GPU peer paints a hairline
23//! quad — because a terminal cell cannot hold a one-pixel rule.
24//!
25//! ## Why blocks are published alongside the per-row marks
26//!
27//! The *active* guide is the innermost block containing the cursor, and the
28//! cursor moves at keystroke rate. Publishing extents rather than a
29//! precomputed "is active" flag lets each renderer pick the active block
30//! per frame from the cursor row it already holds — an integer scan over the
31//! blocks in the window — so cursor motion costs the worker nothing and the
32//! highlight has zero lag.
33
34use std::sync::Arc;
35
36use lattice_cells::MatrixVersion;
37use lattice_core::indent::IndentUnit;
38use lattice_core::indent_blocks::{IndentBlock, LineIndent, indent_blocks};
39
40/// One guide occupying one column of one row.
41///
42/// `block` indexes [`IndentGuides::blocks`], which is what lets a renderer
43/// style the active guide differently without a second lookup.
44#[derive(Clone, Copy, Debug, PartialEq, Eq)]
45pub struct GuideMark {
46 /// Display column the guide occupies.
47 pub col: u16,
48 /// Index into [`IndentGuides::blocks`].
49 pub block: u16,
50}
51
52/// How far above the covered window the block walk starts looking for the
53/// blocks that enclose it.
54///
55/// A block cannot span a non-blank line at column 0, so starting the walk at
56/// the nearest such line above the window is *exact*, not approximate — it
57/// finds every block that reaches into the window. The cap bounds the scan on
58/// a file that is indented for thousands of consecutive lines (minified JSON,
59/// generated code); past it the outermost guides may be missing from a
60/// scrolled-into window, which is a missing hairline rather than a wrong one.
61const MAX_LOOKBACK: u32 = 2000;
62
63/// The per-buffer inputs a guide layer is built from, resolved once per
64/// publish and carried on [`crate::render_state::PaneCellsInputs`].
65#[derive(Clone, Copy, Debug, PartialEq, Eq)]
66pub struct IndentGuideInputs {
67 /// The buffer's resolved indent level. `step()` is the guide spacing;
68 /// `tab_width()` is what makes a tab-indented line's depth comparable
69 /// to a space-indented one's.
70 pub unit: IndentUnit,
71 /// `display.indent-guides`.
72 pub enabled: bool,
73 /// The `MatrixVersion::indent` stamp these inputs produce.
74 pub version: u64,
75}
76
77/// Hash the inputs that change the guides' geometry.
78///
79/// Only `shiftwidth` and `enabled`: `expandtab` never affects an existing
80/// line's rendered indentation, and `tabstop` already bumps the `whitespace`
81/// axis, which invalidates the same matrix. Carrying an input on two axes is
82/// carrying an input that can drift.
83pub fn indent_axis_version(unit: &IndentUnit, enabled: bool) -> u64 {
84 use std::hash::{Hash, Hasher};
85 let mut h = std::collections::hash_map::DefaultHasher::new();
86 unit.step().hash(&mut h);
87 enabled.hash(&mut h);
88 h.finish()
89}
90
91/// The per-pane guide layer.
92#[derive(Clone, Debug)]
93pub struct IndentGuides {
94 /// Every block reaching into the covered window, ordered by opener then
95 /// column. Retained alongside [`Self::rows`] because the active-block pick
96 /// needs extents, not just painted columns.
97 pub blocks: Arc<[IndentBlock]>,
98 /// Painted marks per covered source line: `rows[line - covered_start]`.
99 /// Already filtered through [`IndentBlock::paints_on`], so every mark
100 /// lands on a blank cell.
101 pub rows: Arc<[Arc<[GuideMark]>]>,
102 /// Source line `rows[0]` describes.
103 pub covered_start: u32,
104 /// The stamp of the display matrix built alongside this layer.
105 pub version: MatrixVersion,
106}
107
108impl Default for IndentGuides {
109 fn default() -> Self {
110 Self::empty()
111 }
112}
113
114impl IndentGuides {
115 pub fn empty() -> Self {
116 Self {
117 blocks: Arc::from([] as [IndentBlock; 0]),
118 rows: Arc::from([] as [Arc<[GuideMark]>; 0]),
119 covered_start: 0,
120 version: MatrixVersion::ZERO,
121 }
122 }
123
124 pub fn is_empty(&self) -> bool {
125 self.rows.is_empty()
126 }
127
128 /// Marks painted on `source_line`, or `&[]` when the line is outside the
129 /// covered window. Outside-the-window is the normal answer during a
130 /// scroll that has outrun the worker, not an error.
131 pub fn marks_for_line(&self, source_line: u32) -> &[GuideMark] {
132 let Some(idx) = source_line.checked_sub(self.covered_start) else {
133 return &[];
134 };
135 self.rows.get(idx as usize).map(|r| &r[..]).unwrap_or(&[])
136 }
137
138 /// Index of the innermost block containing `cursor_line`, or `None` when
139 /// the cursor is not inside any block (top-level code).
140 ///
141 /// Innermost is the greatest column: nesting deeper always means a guide
142 /// further right, whatever the indent widths involved.
143 pub fn active_block(&self, cursor_line: u32) -> Option<u16> {
144 self.blocks
145 .iter()
146 .enumerate()
147 .filter(|(_, b)| b.contains(cursor_line))
148 .max_by_key(|(_, b)| b.col)
149 .map(|(i, _)| i as u16)
150 }
151}
152
153/// Build the guide layer for the source lines `[lo, hi)`.
154///
155/// `shapes_from(i)` yields source line `i` and every line after it; the caller
156/// supplies it so this stays testable without a rope and so the worker can
157/// reuse whatever line access it already holds. `hi` is clamped by the caller
158/// to the buffer's content line count.
159///
160/// **Why a stream and not `line(i)`.** The walk covers `[walk_start, hi)`,
161/// which below `cells_worker`'s window cap is the whole document, and it runs
162/// on every publish — every keystroke. Reading that range one index at a time
163/// costs one `O(log n)` rope descent per line; reading it from a single stream
164/// costs one descent plus a linear walk. The only random access left is the
165/// look-back probe, which is bounded by [`MAX_LOOKBACK`] and in practice stops
166/// within a few lines.
167///
168/// The walk starts above `lo` (see [`MAX_LOOKBACK`]) so a block opened off the
169/// top of the window still paints inside it. It does *not* extend below `hi`:
170/// a block still open at the end of the walk is closed at the last content
171/// line, which is at or below the last visible row, so painting and the
172/// cursor-membership test are both unaffected.
173pub fn build_indent_guides<F, I>(
174 shapes_from: F,
175 line_count: u32,
176 unit: &IndentUnit,
177 lo: u32,
178 hi: u32,
179 version: MatrixVersion,
180) -> IndentGuides
181where
182 F: Fn(u32) -> I,
183 I: Iterator<Item = lattice_core::LineShape>,
184{
185 let hi = hi.min(line_count);
186 let lo = lo.min(hi);
187 if lo == hi {
188 return IndentGuides::empty();
189 }
190
191 let walk_start = scan_back_to_top_level(&shapes_from, lo);
192 // A stream that runs dry before `hi` reads as blank lines — the same
193 // answer the per-index read gave for a line past the end.
194 let indents: Vec<LineIndent> = shapes_from(walk_start)
195 .chain(std::iter::repeat(lattice_core::LineShape {
196 blank: true,
197 columns: 0,
198 closer: false,
199 unindented: false,
200 }))
201 .take((hi - walk_start) as usize)
202 .map(|shape| LineIndent {
203 depth: (!shape.blank).then_some(shape.columns),
204 closer: shape.closer,
205 })
206 .collect();
207
208 let blocks: Vec<IndentBlock> = indent_blocks(&indents, unit.step())
209 .into_iter()
210 .map(|b| IndentBlock {
211 col: b.col,
212 start_line: b.start_line + walk_start,
213 end_line: b.end_line + walk_start,
214 })
215 .collect();
216
217 // One shared empty row, cloned by refcount for every line that
218 // paints no guide. This is the common case by a wide margin — every
219 // top-level line, every blank line, every line in an unnested file —
220 // and the layer is rebuilt over its whole covered range on each
221 // keystroke, so allocating a fresh `Arc<[GuideMark]>` per line was
222 // thousands of allocations per keystroke to represent nothing.
223 let empty_row: Arc<[GuideMark]> = Arc::from([] as [GuideMark; 0]);
224 let mut marks: Vec<GuideMark> = Vec::new();
225 // Sweep, not scan. Testing every block on every row is
226 // `O(covered lines × blocks)`, and both factors grow with the file:
227 // a 3 000-line source has ~800 blocks, so the naive form is ~2.4 M
228 // predicate calls per keystroke and its per-line cost rises with
229 // file size — the shape a keystroke path must never have.
230 //
231 // `blocks` is sorted by opener, so one forward cursor admits each
232 // block exactly once and `active` holds only those still open. Its
233 // length is the *nesting depth* at this row, single digits in real
234 // code, which is what makes the pass linear in covered lines.
235 //
236 // `active` inherits `blocks`' `(start_line, col)` order and both
237 // `push` and `retain` preserve it, so a row's marks come out in the
238 // same order the scan produced — outermost guide first.
239 let mut active: Vec<usize> = Vec::new();
240 let mut admitted = 0usize;
241 let rows: Vec<Arc<[GuideMark]>> = (lo..hi)
242 .map(|line| {
243 while let Some(b) = blocks.get(admitted).filter(|b| b.start_line <= line) {
244 // A block that both opened and closed above the window
245 // is admitted and dropped in the same step.
246 if b.end_line >= line {
247 active.push(admitted);
248 }
249 admitted += 1;
250 }
251 active.retain(|&i| blocks[i].end_line >= line);
252
253 let depth = indents[(line - walk_start) as usize].depth;
254 // Reused across iterations: `Arc::from(&marks[..])` copies
255 // out, so the buffer's capacity survives to the next line
256 // instead of being reallocated per row.
257 marks.clear();
258 marks.extend(
259 active
260 .iter()
261 .map(|&i| (i, blocks[i]))
262 // Still the shared predicate rather than an inlined
263 // depth test: `paints_on` is what guarantees a mark
264 // never lands on a column holding text, and one
265 // implementation of that is worth more than the
266 // redundant range check it re-does here.
267 .filter(|(_, b)| b.paints_on(line, depth))
268 .map(|(i, b)| GuideMark {
269 col: b.col,
270 block: i as u16,
271 }),
272 );
273 if marks.is_empty() {
274 Arc::clone(&empty_row)
275 } else {
276 Arc::from(&marks[..])
277 }
278 })
279 .collect();
280
281 IndentGuides {
282 blocks: Arc::from(blocks.into_boxed_slice()),
283 rows: Arc::from(rows.into_boxed_slice()),
284 covered_start: lo,
285 version,
286 }
287}
288
289/// Nearest non-blank line at column 0 at or above `lo`, bounded by
290/// [`MAX_LOOKBACK`]. No block can span such a line, so the walk starting there
291/// sees every block that reaches into the window.
292///
293/// This is the one caller that reads lines out of order, and it pays a fresh
294/// descent per probe (`shapes_from(i).next()`). That is the right trade here
295/// and the wrong one for the forward walk: the search runs *backward* and
296/// almost always stops within a handful of lines, so a stream would be built
297/// and thrown away, whereas the forward walk reads thousands of lines in order.
298fn scan_back_to_top_level<F, I>(shapes_from: &F, lo: u32) -> u32
299where
300 F: Fn(u32) -> I,
301 I: Iterator<Item = lattice_core::LineShape>,
302{
303 let floor = lo.saturating_sub(MAX_LOOKBACK);
304 let mut i = lo;
305 while i > floor {
306 i -= 1;
307 if shapes_from(i).next().is_some_and(|s| s.unindented) {
308 return i;
309 }
310 }
311 floor
312}
313
314#[cfg(test)]
315mod tests {
316 use super::*;
317
318 fn unit() -> IndentUnit {
319 IndentUnit::new(4, true, 4)
320 }
321
322 /// A `shapes_from` over a line vector: the same contract the rope's
323 /// `Buffer::line_shapes_from` fulfils, without the rope.
324 fn shapes_of(lines: &[String]) -> impl Fn(u32) -> std::vec::IntoIter<lattice_core::LineShape> {
325 let shapes: Vec<lattice_core::LineShape> = lines
326 .iter()
327 .map(|l| lattice_core::LineShape::from_line(l, &unit()))
328 .collect();
329 move |start: u32| {
330 shapes
331 .get((start as usize).min(shapes.len())..)
332 .unwrap_or(&[])
333 .to_vec()
334 .into_iter()
335 }
336 }
337
338 fn build(src: &str) -> IndentGuides {
339 let lines: Vec<String> = src.split('\n').map(|s| s.to_string()).collect();
340 let n = lines.len() as u32;
341 build_indent_guides(shapes_of(&lines), n, &unit(), 0, n, MatrixVersion::ZERO)
342 }
343
344 fn cols(g: &IndentGuides, line: u32) -> Vec<u16> {
345 g.marks_for_line(line).iter().map(|m| m.col).collect()
346 }
347
348 const NESTED: &str = "fn f() {\n\n if c {\n work();\n\n }\n}\n\nfn g() {";
349
350 #[test]
351 fn marks_match_the_paint_predicate() {
352 let g = build(NESTED);
353 assert_eq!(cols(&g, 0), Vec::<u16>::new(), "opener column holds text");
354 assert_eq!(cols(&g, 1), vec![0], "blank inside the outer block");
355 assert_eq!(cols(&g, 2), vec![0]);
356 assert_eq!(cols(&g, 3), vec![0, 4]);
357 assert_eq!(cols(&g, 4), vec![0, 4], "blank inside both blocks");
358 assert_eq!(cols(&g, 5), vec![0], "inner closer column holds a brace");
359 assert_eq!(cols(&g, 6), Vec::<u16>::new());
360 assert_eq!(cols(&g, 7), Vec::<u16>::new(), "between blocks");
361 }
362
363 #[test]
364 fn active_block_is_the_innermost_containing_the_cursor() {
365 let g = build(NESTED);
366 let col_of = |line: u32| g.active_block(line).map(|i| g.blocks[i as usize].col);
367 assert_eq!(col_of(3), Some(4), "inside the inner block");
368 assert_eq!(col_of(2), Some(4), "on the inner block's opener");
369 assert_eq!(col_of(5), Some(4), "on the inner block's closer");
370 assert_eq!(col_of(1), Some(0), "outer block only");
371 assert_eq!(col_of(6), Some(0), "on the outer closer");
372 assert_eq!(col_of(7), None, "between blocks");
373 assert_eq!(col_of(8), None, "top level");
374 }
375
376 #[test]
377 fn every_mark_lands_on_a_blank_column() {
378 // The invariant both renderers rely on: no mark may occupy a column
379 // that holds a character.
380 let src = "fn f() {\n if c {\n work();\n }\n}\n\tmixed\n";
381 let lines: Vec<&str> = src.split('\n').collect();
382 let g = build(src);
383 for (i, line) in lines.iter().enumerate() {
384 let chars: Vec<char> = line.chars().collect();
385 for mark in g.marks_for_line(i as u32) {
386 let at = chars.get(mark.col as usize).copied();
387 assert!(
388 matches!(at, None | Some(' ') | Some('\t')),
389 "line {i} col {} holds {at:?}",
390 mark.col
391 );
392 }
393 }
394 }
395
396 #[test]
397 fn windowed_build_indexes_from_covered_start() {
398 let src = "fn f() {\n a\n b\n c\n d\n}";
399 let lines: Vec<String> = src.split('\n').map(|s| s.to_string()).collect();
400 let n = lines.len() as u32;
401 let g = build_indent_guides(shapes_of(&lines), n, &unit(), 2, 5, MatrixVersion::ZERO);
402 assert_eq!(g.covered_start, 2);
403 assert_eq!(g.rows.len(), 3);
404 // The block opened at line 0 — above the window — still paints inside
405 // it, which is the whole point of the look-back.
406 assert_eq!(cols(&g, 2), vec![0]);
407 assert_eq!(cols(&g, 4), vec![0]);
408 assert_eq!(cols(&g, 1), Vec::<u16>::new(), "below covered_start");
409 assert_eq!(cols(&g, 9), Vec::<u16>::new(), "past the window");
410 }
411
412 #[test]
413 fn look_back_stops_at_the_nearest_top_level_line() {
414 // Two sibling blocks; a window inside the second must not inherit the
415 // first one's extent.
416 let src = "fn f() {\n a\n}\nfn g() {\n b\n}";
417 let lines: Vec<String> = src.split('\n').map(|s| s.to_string()).collect();
418 let g = build_indent_guides(shapes_of(&lines), 6, &unit(), 4, 6, MatrixVersion::ZERO);
419 assert_eq!(cols(&g, 4), vec![0]);
420 assert_eq!(g.blocks.len(), 1, "only the enclosing block is walked");
421 assert_eq!(g.blocks[0].start_line, 3);
422 }
423
424 #[test]
425 fn empty_window_yields_the_empty_layer() {
426 let g = build_indent_guides(
427 |_| std::iter::empty::<lattice_core::LineShape>(),
428 0,
429 &unit(),
430 0,
431 0,
432 MatrixVersion::ZERO,
433 );
434 assert!(g.is_empty());
435 assert_eq!(g.marks_for_line(0), &[]);
436 assert_eq!(g.active_block(0), None);
437 }
438
439 #[test]
440 fn the_forward_walk_opens_exactly_one_stream() {
441 // The perf property, pinned as behaviour. Every `shapes_from`
442 // call is a rope descent, so the covered range must be read from
443 // ONE stream — a build that opens one per line is `O(n log n)`
444 // where a walk is `O(n)`, and it regresses silently because the
445 // output is identical. Only the bounded look-back may probe.
446 let lines: Vec<String> = (0..500)
447 .map(|i| {
448 if i % 5 == 0 {
449 format!("fn f{i}() {{")
450 } else {
451 " body".into()
452 }
453 })
454 .collect();
455 let inner = shapes_of(&lines);
456 let opened = std::cell::Cell::new(0u32);
457 let counting = |start: u32| {
458 opened.set(opened.get() + 1);
459 inner(start)
460 };
461
462 // `lo` sits one line below a top-level line, so the look-back
463 // stops on its first probe and the count is unambiguous.
464 let g = build_indent_guides(counting, 500, &unit(), 401, 500, MatrixVersion::ZERO);
465 assert_eq!(g.rows.len(), 99);
466 assert_eq!(
467 opened.get(),
468 2,
469 "one stream for the walk plus a look-back that stops within a \
470 line or two, got {} streams",
471 opened.get()
472 );
473 }
474
475 #[test]
476 fn the_row_sweep_agrees_with_testing_every_block() {
477 // The sweep's whole justification is that it visits fewer blocks
478 // per row while producing the same rows — including their ORDER,
479 // which renderers see. So the reference implementation is the
480 // definition: apply `paints_on` to every published block, in
481 // published order, on every covered line.
482 //
483 // The corpus nests three deep, closes siblings at every level and
484 // carries blank lines inside blocks, because those are the rows
485 // where "still open" and "opens here" disagree.
486 let mut src: Vec<String> = Vec::new();
487 for i in 0..40 {
488 src.push(format!("fn f{i}() {{"));
489 src.push(" let mut n = 0;".into());
490 src.push(String::new());
491 src.push(" if n > 0 {".into());
492 src.push(" while n > 0 {".into());
493 src.push(" n -= 1;".into());
494 src.push(String::new());
495 src.push(" }".into());
496 src.push(" }".into());
497 src.push("}".into());
498 src.push(String::new());
499 }
500 let n = src.len() as u32;
501
502 for (lo, hi) in [(0, n), (0, 25), (137, n), (150, 260), (n - 1, n)] {
503 let g = build_indent_guides(shapes_of(&src), n, &unit(), lo, hi, MatrixVersion::ZERO);
504 let shapes = shapes_of(&src);
505 for line in lo..hi {
506 let depth = shapes(line)
507 .next()
508 .and_then(|s| (!s.blank).then_some(s.columns));
509 let expected: Vec<GuideMark> = g
510 .blocks
511 .iter()
512 .enumerate()
513 .filter(|(_, b)| b.paints_on(line, depth))
514 .map(|(i, b)| GuideMark {
515 col: b.col,
516 block: i as u16,
517 })
518 .collect();
519 assert_eq!(
520 g.marks_for_line(line),
521 &expected[..],
522 "window {lo}..{hi}, line {line}"
523 );
524 }
525 }
526 }
527
528 #[test]
529 fn tab_indented_file_matches_its_space_indented_twin() {
530 let tabbed = build("fn f() {\n\tif c {\n\t\twork();\n\t}\n}");
531 let spaced = build("fn f() {\n if c {\n work();\n }\n}");
532 for line in 0..5 {
533 assert_eq!(cols(&tabbed, line), cols(&spaced, line), "line {line}");
534 }
535 }
536
537 #[test]
538 fn version_is_carried_through_unchanged() {
539 let v = MatrixVersion {
540 text: 7,
541 indent: 3,
542 ..MatrixVersion::ZERO
543 };
544 let lines: Vec<String> = vec!["a:".into(), " b".into()];
545 let g = build_indent_guides(shapes_of(&lines), 2, &unit(), 0, 2, v);
546 assert_eq!(g.version, v);
547 }
548}