lattice_host/cells_worker.rs
1//! Background cell-builder worker — replaces per-frame `shape_line`
2//! with an off-thread cell matrix build.
3//!
4//! S2.2 (2026-05-26).
5//!
6//! ## Why this exists
7//!
8//! Per paramount goal #1 in `CLAUDE.md`:
9//!
10//! > **Performance.** UI thread does no I/O, no parsing, no shaping.
11//!
12//! The cell-grid renderer (see
13//! `docs/dev/architecture/cell-grid-renderer.md`) replaces the per-
14//! frame `shape_line` path for code-class buffers. The matrix
15//! producer must run off the UI thread; this module owns it.
16//!
17//! ## S2.2 scope — minimal
18//!
19//! S2.2 lands the worker shell with the **simplest possible build**:
20//!
21//! - one whole-doc [`lattice_cells::CellMatrix`] per published
22//! document,
23//! - rows materialised from `snapshot.buffer.line(i)` line-by-line,
24//! - cells carry the raw codepoint only (no syntax fg, no bg, no
25//! flags),
26//! - no inlay splicing, no fold elision,
27//! - no chunking (S2.4 lands that).
28//!
29//! S2.3 will fold in syntax colour + inlays + folds; S2.4 will
30//! switch to chunked mode once the input is above `4 × viewport_height`
31//! lines.
32//!
33//! ## Design (mirrors `overlay_worker`)
34//!
35//! - Dispatch's `publish_render_state` populates
36//! [`crate::render_state::CellsRenderState`] inputs (`snapshot`,
37//! `version`, …) and fires [`crate::editor::CellsWake`]'s
38//! `Notify`.
39//! - The worker `notified().await`s the wake signal. `Notify` is
40//! permit-style: a burst of publishes wakes the worker exactly
41//! once, after which the worker re-reads the *latest* snapshot.
42//! - On wake the worker reads `render_state.load_full().cells`,
43//! compares its `version` against the currently-published
44//! [`lattice_cells::CellMatrix::version`], and short-circuits on
45//! cache-hit. On miss it builds a fresh matrix and stores it via
46//! the shared `cells_matrix_cell: Arc<ArcSwap<CellMatrix>>`.
47//!
48//! ## Renderer contract (S2.2 — not consumed yet)
49//!
50//! Renderers will read with:
51//!
52//! ```text
53//! let rs = editor.render_state.load_full();
54//! let matrix = rs.cells.matrix.load();
55//! // matrix.slice(scroll, viewport_height) → CellSlice over &CellRow
56//! ```
57//!
58//! S3 (TUI) and S4 (GPU) are the cutover slices that begin
59//! consuming the matrix. S2.2 keeps the producer in place so the
60//! consumer slices land against a populated cell, not a stub.
61
62use std::sync::Arc;
63
64use arc_swap::ArcSwap;
65use tracing::{debug, info};
66
67use crate::editor::CellsWake;
68use crate::render_state::RenderState;
69use lattice_cells::{CHUNK_SIZE_WHOLE_DOC, Cell, CellChunk, CellMatrix, CellRow, MatrixVersion};
70
71/// 2026-05-27: `display.whitespace.*` snapshot consumed by the
72/// cell-builder when emitting cells. Mirrors the
73/// `option_cache.whitespace_*` shape but lives here so the worker
74/// can be written without a config-crate import cycle.
75///
76/// `show` is the master gate (`display.show_whitespace`). When
77/// false, the builder emits whitespace bytes verbatim. When true:
78/// - `tab`: glyph substituted for `\t` (None → leave as-is).
79/// - `trailing`: glyph for spaces between the last non-space
80/// byte of a line and EOL.
81/// - `leading`: glyph for spaces between BOL and the first
82/// non-space byte.
83/// - `space`: glyph for middle (non-leading, non-trailing)
84/// spaces. None → keep middle spaces as ' '.
85/// - `eol`: glyph appended at the visual end of each line.
86/// Reserved for a follow-up (extending past the last char
87/// complicates byte↔col remap).
88#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
89pub struct WhitespaceConfig {
90 pub show: bool,
91 pub tab: Option<char>,
92 pub trailing: Option<char>,
93 pub leading: Option<char>,
94 pub space: Option<char>,
95 pub eol: Option<char>,
96 /// W.4.t: columns a hard tab expands to. The builder advances
97 /// each `\t` to the next multiple of this width, emitting that
98 /// many cells (a marker glyph + space fill when `show`, plain
99 /// spaces otherwise) so the cell grid models a tab at its true
100 /// display width — one width model the host scroll model and
101 /// both renderers share. `0` (the `Default`) is treated as `1`
102 /// (legacy one-cell tabs) so test fixtures need no change.
103 pub tabstop: u32,
104}
105
106/// Recompute decision the worker takes on a wake. Visible for
107/// testing; the production loop calls [`recompute`] directly.
108#[derive(Debug, PartialEq, Eq)]
109pub enum WorkerDecision {
110 /// `snapshot` is `None` — no active document. Worker clears the
111 /// published matrix (so a previous document's cells don't
112 /// linger after a close).
113 Clear,
114 /// Current inputs' `version` matches the already-published
115 /// matrix's `version`. Worker does nothing.
116 CacheHit,
117 /// `version` differs from the published matrix; worker built a
118 /// fresh `CellMatrix` from the snapshot and stored it. The
119 /// full-rebuild path — every chunk's rows were materialised
120 /// from rope text + syntax + inlays + theme.
121 Recomputed,
122 /// S2.4.b: incremental rebuild — exactly one text edit happened
123 /// since the last publish AND all other version axes are
124 /// unchanged. Chunks before the edit's affected range reuse
125 /// their `Arc<CellChunk>` verbatim; chunks at the affected
126 /// range rebuild from scratch; chunks past the affected range
127 /// shift by `lines_added - lines_removed` (cell payloads
128 /// shared, only `source_line` advances).
129 RecomputedIncremental,
130}
131
132/// Worker entry point spawned at boot. Loops forever, awaiting
133/// the wake `Notify`. Each wake re-reads the latest
134/// `RenderState.cells` inputs and calls [`recompute`].
135///
136/// Spawn from `editor_boot` once `Editor` is constructed. Pass
137/// clones of `Editor::render_state` and `Editor::cells_matrix_cell`
138/// plus the wake `Notify` and the `paint_request` notifier.
139///
140/// ## Coalescing contract (S2.5)
141///
142/// `tokio::sync::Notify` is *permit-style*: any `notify_one()`
143/// calls that arrive while no `notified().await` is pending
144/// store a single permit. The next `notified().await` consumes
145/// the permit and resolves immediately. This gives us optimal
146/// burst coalescing for free:
147///
148/// - **Quiescent state.** Worker is blocked on `notified().await`.
149/// No CPU cost; no permit held.
150/// - **Single wake.** Publisher calls `notify_one()` while the
151/// worker is parked. Worker resolves, builds, publishes, loops
152/// back, parks again.
153/// - **Burst during build.** Multiple `notify_one()` calls arrive
154/// while the worker is mid-build. They collapse to exactly one
155/// stored permit (Notify drops subsequent calls when a permit
156/// is already present). After the current build publishes and
157/// the worker loops back, the next `notified().await` consumes
158/// that single permit; one additional build runs against the
159/// LATEST `render_state.load_full()` (which captures all the
160/// bursts' inputs because `RenderState` is published atomically
161/// via `ArcSwap`).
162/// - **Net behaviour for a burst of N publishes during one
163/// build.** Exactly 2 builds run: the original and one tail
164/// build that catches up to the latest state. No queue, no
165/// intermediate states processed; the cumulative `MatrixVersion`
166/// diff drives the rebuild decision.
167///
168/// No explicit debounce is needed. Adding a sleep before
169/// processing would *add* latency without reducing useful work —
170/// `Notify`'s natural permit semantics already drop intermediate
171/// states.
172///
173/// ## `paint_request` semantics
174///
175/// `paint_request` is a shared `Notify` consumed by the renderer
176/// peer. Both this worker and `overlay_worker` fire
177/// `notify_one()` on content-changing decisions. The renderer
178/// observes one wake per coalesced burst across both workers and
179/// schedules a single paint — matrix + spans are read together
180/// from the next `render_state.load_full()`.
181///
182/// `WorkerDecision::CacheHit` leaves the matrix bit-identical so
183/// no paint wake fires. `Clear` / `Recomputed` /
184/// `RecomputedIncremental` all signal content change.
185pub async fn run(
186 render_state: Arc<ArcSwap<RenderState>>,
187 wake: CellsWake,
188 paint_request: Arc<tokio::sync::Notify>,
189) {
190 info!(
191 target: "lattice_host::cells_worker",
192 "cells worker spawned"
193 );
194 let mut tick_count: u64 = 0;
195 // 2026-08-16: per-tick logging is SELF-FEEDING and must be coalesced.
196 //
197 // `*messages*` is a rendered buffer fed by the log pipeline, so a log
198 // emitted once per tick closes a cycle: tick → log → `*messages*` edit
199 // → text_version bump → publish → `AsyncRenderStatePublished` → this
200 // worker wakes → tick. Measured at ~110 rebuilds/second — exactly this
201 // worker's own 8.8 ms rebuild time, because the loop runs as fast as
202 // one rebuild takes — and it never stops.
203 //
204 // The rule this encodes: **the render cycle must not emit a log per
205 // render.** Not a level question (a `trace!` would loop just as hard
206 // when trace is enabled); a frequency one. So the tick log is
207 // aggregated to at most one line per second, carrying the counts that
208 // make a storm visible in a single line rather than 500. One line per
209 // second still costs one cycle per second, which is self-limiting.
210 let mut window_start = std::time::Instant::now();
211 let mut window_ticks: u64 = 0;
212 let mut window_changed: u64 = 0;
213 let mut window_us: u128 = 0;
214 loop {
215 wake.0.notified().await;
216 let t0 = std::time::Instant::now();
217 let decision = recompute(&render_state);
218 let elapsed_us = t0.elapsed().as_micros();
219 tick_count += 1;
220 // Wake the renderer on content changes only. CacheHit
221 // leaves the matrix bit-identical so waking the peer would
222 // be a wasted frame.
223 if matches!(
224 decision,
225 WorkerDecision::Recomputed
226 | WorkerDecision::RecomputedIncremental
227 | WorkerDecision::Clear
228 ) {
229 paint_request.notify_one();
230 }
231 window_ticks += 1;
232 window_us += elapsed_us;
233 if !matches!(decision, WorkerDecision::CacheHit) {
234 window_changed += 1;
235 }
236 if window_start.elapsed() >= std::time::Duration::from_secs(1) {
237 debug!(
238 target: "lattice_host::cells_worker",
239 tick = tick_count,
240 ticks_per_sec = window_ticks,
241 rebuilds = window_changed,
242 busy_us = window_us,
243 last = ?decision,
244 "cells worker (1s summary)"
245 );
246 window_start = std::time::Instant::now();
247 window_ticks = 0;
248 window_changed = 0;
249 window_us = 0;
250 }
251 }
252}
253
254/// Pure synchronous recompute. Reads the current published
255/// `CellsRenderState`, iterates over every visible Document
256/// pane (`cells.panes`), and updates each pane's `matrix`
257/// independently. Returns the aggregate decision used to
258/// gate the renderer `paint_request` wake:
259///
260/// - `Clear`: at least one pane saw a `Clear` (and no pane
261/// saw a content-producing rebuild).
262/// - `Recomputed` / `RecomputedIncremental`: at least one
263/// pane rebuilt content. `Recomputed` wins when both kinds
264/// of rebuild fire in one tick, since the renderer just
265/// needs to know "something changed" — the exact mix
266/// matters per-pane only.
267/// - `CacheHit`: every pane was idle (or `panes` was empty).
268/// No paint wake.
269///
270/// D.4.d.1.b (2026-05-29): pre-d.1.b, the worker wrote into
271/// a single top-level `matrix_cell` corresponding to the
272/// active document. Now each pane's own
273/// `Arc<ArcSwap<CellMatrix>>` is the write target — the
274/// renderer reads them per pane in D.4.d.1.c.
275pub fn recompute(render_state: &ArcSwap<RenderState>) -> WorkerDecision {
276 let rs = render_state.load_full();
277 // I.5.2: `cells` is an inner `ArcSwap` so the keystroke fast path
278 // can republish it without reswapping the monolith. Load the
279 // current cells snapshot once for this recompute pass.
280 let cells = rs.cells.load();
281 if cells.panes.is_empty() {
282 return WorkerDecision::CacheHit;
283 }
284 let mut any_recomputed = false;
285 let mut any_incremental = false;
286 let mut any_cleared = false;
287 let ct = CellTheme {
288 resolved: &cells.resolved_theme,
289 ids: &cells.theme_ids,
290 };
291 for pane in cells.panes.iter() {
292 match recompute_pane(pane, ct, &cells.whitespace) {
293 WorkerDecision::CacheHit => {}
294 WorkerDecision::Clear => any_cleared = true,
295 WorkerDecision::Recomputed => any_recomputed = true,
296 WorkerDecision::RecomputedIncremental => any_incremental = true,
297 }
298 }
299 if any_recomputed {
300 WorkerDecision::Recomputed
301 } else if any_incremental {
302 WorkerDecision::RecomputedIncremental
303 } else if any_cleared {
304 WorkerDecision::Clear
305 } else {
306 WorkerDecision::CacheHit
307 }
308}
309
310/// K.4.7 (2026-06-07): assemble per-excerpt tree-sitter highlights for
311/// a multibuffer composed row range `[lo, hi)` (exclusive `hi`).
312///
313/// Returns a `Vec` indexed relative to `lo` (row 0 = composed row `lo`).
314/// Each inner `Vec` is the `StyledSpan` list for that row — empty when
315/// no excerpt covers it or the excerpt's parse hasn't landed yet.
316/// Returns `None` only when the `excerpt_syntax` slice is empty (caller
317/// falls back to the standard single-document path).
318fn highlight_range_multibuffer(
319 excerpt_syntax: &[crate::render_state::ExcerptSyntax],
320 lo: u32,
321 hi: u32,
322) -> Option<Vec<Vec<lattice_syntax::StyledSpan>>> {
323 if excerpt_syntax.is_empty() {
324 return None;
325 }
326 if hi <= lo {
327 return Some(Vec::new());
328 }
329 let row_count = (hi - lo) as usize;
330 let mut result: Vec<Vec<lattice_syntax::StyledSpan>> = vec![Vec::new(); row_count];
331 for ex in excerpt_syntax {
332 if ex.composed_start >= hi || ex.composed_end < lo {
333 continue;
334 }
335 let composed_lo = ex.composed_start.max(lo);
336 let composed_hi = (ex.composed_end + 1).min(hi);
337 let src_lo = ex.source_start + (composed_lo - ex.composed_start);
338 let src_hi = src_lo + (composed_hi - composed_lo);
339 let Some(spans) = ex.handle.highlight_lines(src_lo, src_hi) else {
340 continue;
341 };
342 for (i, span_row) in spans.into_iter().enumerate() {
343 let dest = (composed_lo - lo) as usize + i;
344 if dest < row_count {
345 result[dest] = span_row;
346 }
347 }
348 }
349 Some(result)
350}
351
352/// Non-body columns a *document* pane reserves for its gutter:
353/// line-number column + diagnostic + diff-sign cells. Mirrors the
354/// TUI renderer's `gutter_width + DIAG + DIFF` (`lattice-ui-tui::
355/// render`) and the GPUI peer's gutter, reducing to `digits + 5`
356/// with line numbers on (leading pad 1 + digits + trailing pad 2 +
357/// DIAG 1 + DIFF 1) or a bare `4` with them off (2-cell margin +
358/// DIAG 1 + DIFF 1).
359///
360/// SINGLE SOURCE OF TRUTH shared with [`crate::editor::Editor::
361/// body_text_width`]. The soft-wrap width the worker stamps on the
362/// matrix (`wrap_width`, read by `segment_count` — the vertical
363/// scroll clamp's display-row model — AND by both renderers' paint
364/// paths) MUST equal the width the renderer wraps body text at, or
365/// `G`/`bottom_anchored_scroll` under-counts wrapped rows and clips
366/// the document tail (the recurring "last lines off-screen" bug).
367/// Because the horizontal clamp already derives its body width the
368/// same way, keeping both on this one function is what prevents the
369/// two clamps from drifting again.
370pub fn gutter_cols(line_count: u32, show_line_numbers: bool, sign_column: bool) -> u32 {
371 let numbers = if show_line_numbers {
372 // 1 leading pad + digits + trailing pad (separator, fold slot, gap).
373 let digits = line_count.max(1).ilog10() + 1;
374 digits + 1 + GUTTER_TRAILING_COLS
375 } else {
376 // `:set nonumber` still paints the trailing cells.
377 GUTTER_TRAILING_COLS
378 };
379 numbers + if sign_column { SIGN_COLS } else { 0 }
380}
381
382/// Cells the gutter always paints after the line number: separator,
383/// fold-glyph slot, trailing gap. Mirrors the TUI's
384/// `GUTTER_TRAILING_PAD`; `gutter_cols_matches_the_tui_gutter` pins them
385/// together from the renderer side, where both are visible.
386pub(crate) const GUTTER_TRAILING_COLS: u32 = 3;
387
388/// Cells the diagnostic-severity and diff-sign columns reserve when
389/// `signcolumn` is on. TWO, not one.
390///
391/// 2026-08-16: this is the whole of the `G`-tail-clip bug. `gutter_cols`
392/// hardcoded `digits + 5`, which accounted for the severity column but
393/// not the diff-sign column added later (D.3.d.1, "reserved
394/// unconditionally so the layout doesn't shift on `:diff`"). The host
395/// therefore computed a soft-wrap width ONE COLUMN WIDER than the
396/// renderer's, so a line whose length fell exactly between the two
397/// widths wrapped on screen but not in the scroll budget. One extra
398/// rendered row pushed the cursor below the last painted line — `G` on a
399/// 219-line file with a 173-char line at a 173-column host width, 172 in
400/// the renderer.
401///
402/// The bug survived a previous fix for the same symptom because that one
403/// made the host's two clamps share `gutter_cols` with each other. They
404/// agreed, and both disagreed with what was painted.
405pub(crate) const SIGN_COLS: u32 = 2;
406
407/// D.4.d.1.b (2026-05-29): per-pane recompute. Same algorithm
408/// the pre-d.1.b `recompute` ran against the top-level
409/// active-doc fields, now keyed off a single
410/// [`crate::render_state::PaneCellsInputs`]. Visible for tests
411/// that want to assert per-pane decisions without driving the
412/// aggregate.
413///
414/// Writes via `pane.matrix` (the per-buffer registry cell), so
415/// two panes showing the same buffer share a single output cell
416/// and the second pane sees a `CacheHit` against the rebuild
417/// the first one already published.
418/// IG.2 (2026-08-16): publish this pane's indentation-guide layer for the
419/// range `matrix` covers.
420///
421/// Called from every site that stores `pane.display_matrix`, so the guide
422/// layer and the matrix are never a publish apart. That coupling is the point:
423/// both are projections of one snapshot under one version stamp, and a
424/// renderer that reads a matrix always finds guides built from the same text.
425///
426/// Guides disabled ⇒ the *empty* layer is published rather than the write
427/// skipped. Skipping would leave the previous build painted until something
428/// else forced a rebuild, which is how a `:set noindent-guides` appears not to
429/// work.
430fn publish_indent_guides(
431 pane: &crate::render_state::PaneCellsInputs,
432 snapshot: &lattice_runtime::DocumentSnapshot,
433 matrix: &crate::display_matrix::DisplayMatrix,
434) {
435 if !pane.indent_guides_enabled {
436 let already_empty = pane.indent_guides.load().is_empty();
437 if !already_empty {
438 pane.indent_guides
439 .store(Arc::new(crate::indent_guides::IndentGuides::empty()));
440 }
441 return;
442 }
443 let guides = crate::indent_guides::build_indent_guides(
444 |line| snapshot.buffer.line_shapes_from(line, &pane.indent_unit),
445 snapshot.buffer.content_line_count(),
446 &pane.indent_unit,
447 matrix.covered_start_line(),
448 matrix.covered_end_line(),
449 matrix.version,
450 );
451 pane.indent_guides.store(Arc::new(guides));
452}
453
454/// TC.3b: build the pane's pinned context rows.
455///
456/// One row per line in `pane.sticky_context_lines` (already resolved host-side
457/// against the cached scopes, so the reservation the scroll model made and the
458/// rows painted here cannot disagree — the host produced the list it reserved
459/// for).
460///
461/// Highlights are fetched PER LINE rather than over one enclosing range: the
462/// context lines are typically far apart (an `impl` at line 10 and a `fn` at
463/// line 3000), so a single spanning range would highlight the whole file to
464/// paint three rows. The call count is bounded by `context.max-lines`.
465///
466/// Publishes the empty layer rather than skipping when there is nothing to
467/// show — skipping would leave the previous strip painted until something else
468/// forced a rebuild, which is how "the context did not go away" happens.
469fn publish_sticky_context(
470 pane: &crate::render_state::PaneCellsInputs,
471 snapshot: &lattice_runtime::DocumentSnapshot,
472 ct: CellTheme<'_>,
473 whitespace: &WhitespaceConfig,
474 version: lattice_cells::MatrixVersion,
475) {
476 use crate::sticky_context::{StickyContext, StickyContextRow};
477
478 if pane.sticky_context_lines.is_empty() {
479 if !pane.sticky_context.load().is_empty() {
480 pane.sticky_context.store(Arc::new(StickyContext::empty()));
481 }
482 return;
483 }
484
485 // Already current: same lines, same build. This runs at cursor rate, so
486 // the common case must not rebuild rows — it is a comparison of at most
487 // `max-lines` line numbers.
488 {
489 let current = pane.sticky_context.load();
490 if current.version == version
491 && current.line_numbers == pane.sticky_context_line_numbers
492 && current.has_separator == pane.sticky_context_separator.is_some()
493 && current.rows.len() == pane.sticky_context_lines.len()
494 && current
495 .rows
496 .iter()
497 .zip(pane.sticky_context_lines.iter())
498 .all(|(row, line)| row.source_line == *line)
499 {
500 return;
501 }
502 }
503
504 let (default_fg, default_flags) = resolve_style(ct, lattice_syntax::Style::Default);
505 let syntax = pane.syntax_handle.as_deref().map(|h| h.snapshot());
506 let conceal_rules = conceal_rules_for(pane.syntax_handle.as_deref());
507 let mut rows: Vec<StickyContextRow> = Vec::with_capacity(pane.sticky_context_lines.len());
508
509 for &line in pane.sticky_context_lines.iter() {
510 let text = snapshot.buffer.line(line).unwrap_or_default();
511 // A header outside every built chunk still gets real colour: the
512 // snapshot can highlight any range, which is the whole reason this
513 // build lives in the worker rather than in the renderers.
514 let spans: Vec<lattice_syntax::StyledSpan> = syntax
515 .as_ref()
516 .and_then(|s| s.highlight_lines(line, line + 1).ok())
517 .and_then(|mut v| (!v.is_empty()).then(|| v.swap_remove(0)))
518 .unwrap_or_default();
519 // Sticky context reuses the buffer's rules so a concealed link
520 // in a pinned header reads the same as it does in the body —
521 // two renderings of one line that disagreed would be worse
522 // than either.
523 let (dtext, runs, col_map, conceals, col_count) =
524 build_display_row(&text, &spans, &[], whitespace, &[], &conceal_rules);
525 let dl = crate::display_matrix::DisplayLine {
526 source_line: line,
527 text: Arc::from(dtext),
528 runs: Arc::from(runs.into_boxed_slice()),
529 col_map: Arc::from(col_map.into_boxed_slice()),
530 conceals: Arc::from(conceals.into_boxed_slice()),
531 col_count,
532 fold: None,
533 };
534 let row = display_line_to_cell_row(&dl, ct, default_fg, default_flags);
535 rows.push(StickyContextRow {
536 source_line: line,
537 cells: row.cells.clone(),
538 });
539 }
540
541 // Resolve the backdrop once per build. `to_rgb_u32` needs a fallback for
542 // a palette entry the theme leaves unset; `None` there means "no
543 // backdrop", which is a legitimate theme choice, so the absence is
544 // carried rather than substituted.
545 let bg = ct
546 .resolved
547 .get(ct.ids.sticky_context_background)
548 .bg
549 .map(|c| c.to_rgb_u32(0));
550 // TC.11: resolved here with the backdrop, for the same reason — the strip
551 // is host chrome, so the host owns its styling and neither renderer reads
552 // a theme (or a plugin option) of its own.
553 let line_number_fg = ct
554 .resolved
555 .get(ct.ids.sticky_context_line_number)
556 .fg
557 .map(|c| c.to_rgb_u32(0));
558 let active_bg = ct
559 .resolved
560 .get(ct.ids.sticky_context_active)
561 .bg
562 .map(|c| c.to_rgb_u32(0));
563
564 // TC.12: the rule, as a REAL row so `len()` counts it and the scroll model
565 // reserves for it. Width is the pane's, because a rule that stops short of
566 // the edge reads as a dashed underline on the last scope rather than as a
567 // boundary.
568 let has_separator = pane.sticky_context_separator.is_some();
569 if let Some(glyph) = pane.sticky_context_separator {
570 let fg = ct
571 .resolved
572 .get(ct.ids.sticky_context_separator)
573 .fg
574 .map(|c| c.to_rgb_u32(0))
575 .unwrap_or(default_fg);
576 let width = pane.viewport_width.max(1) as usize;
577 let cells: Vec<lattice_cells::Cell> = (0..width)
578 .map(|_| lattice_cells::Cell::new(glyph as u32, fg, 0, 0))
579 .collect();
580 rows.push(StickyContextRow {
581 // The rule mirrors no source line, so it shows no line number.
582 // `u32::MAX` would be a sentinel a renderer could mistake for one;
583 // the `has_separator` flag is what renderers branch on instead.
584 source_line: 0,
585 cells: Arc::from(cells.into_boxed_slice()),
586 });
587 }
588 pane.sticky_context.store(Arc::new(StickyContext {
589 rows,
590 version,
591 bg,
592 line_numbers: pane.sticky_context_line_numbers,
593 line_number_fg,
594 active_bg,
595 has_separator,
596 }));
597}
598
599pub fn recompute_pane(
600 pane: &crate::render_state::PaneCellsInputs,
601 ct: CellTheme<'_>,
602 whitespace: &WhitespaceConfig,
603) -> WorkerDecision {
604 let Some(snapshot) = pane.snapshot.as_ref() else {
605 // No snapshot — buffer closed mid-publish, or no
606 // active document for this pane. Clear this pane's
607 // matrices if not already empty; idempotent on repeat
608 // clears so the second call doesn't churn the Arcs.
609 // B2.2: the `DisplayMatrix` is canonical; clear it and the
610 // projected cell grid together. Idempotent only when BOTH are
611 // already empty (a fresh display cell with a stale projected
612 // cell, or vice versa, must still clear the non-empty one).
613 let display_empty = {
614 let e = pane.display_matrix.load();
615 e.is_empty() && e.version == MatrixVersion::ZERO
616 };
617 let cell_empty = {
618 let e = pane.matrix.load();
619 e.is_empty() && e.version == MatrixVersion::ZERO
620 };
621 if display_empty && cell_empty {
622 return WorkerDecision::Clear;
623 }
624 pane.display_matrix
625 .store(Arc::new(crate::display_matrix::DisplayMatrix::empty()));
626 pane.matrix.store(Arc::new(CellMatrix::empty()));
627 pane.indent_guides
628 .store(Arc::new(crate::indent_guides::IndentGuides::empty()));
629 return WorkerDecision::Clear;
630 };
631
632 // W.2 (A2): effective wrap width. `0` ⇒ wrapping off (one
633 // display row per source line). Stamped onto every matrix this
634 // function publishes so consumers (host scroll model + both
635 // renderers) derive display geometry from it. Wrap geometry is
636 // versioned directly here rather than through `MatrixVersion`:
637 // a wrap toggle / pane-width change leaves the content version
638 // unchanged (A2 keeps identical rows) but must still re-stamp
639 // the matrix, so the cache-hit check below compares
640 // `wrap_width` alongside the version.
641 //
642 // Wrap at the BODY width (pane minus gutter), not the full pane
643 // width: `segment_count` (the scroll clamp) and both renderers'
644 // paint paths read this, and the renderer wraps text into
645 // `viewport_width - gutter`. `wrap_reserved_cols` is the gutter
646 // reservation the builder computed via `gutter_cols` (0 for
647 // gutterless floating popups). `.max(1)` mirrors the renderer's
648 // own `.max(1)` so a pane narrower than its gutter still wraps
649 // (rather than the `0` sentinel silently turning wrapping off).
650 let effective_wrap = if pane.wrap {
651 pane.viewport_width
652 .saturating_sub(pane.wrap_reserved_cols)
653 .max(1)
654 } else {
655 0
656 };
657
658 // H.3 (2026-06-04): the source-line range the renderer needs
659 // covered this tick. For a windowed large-file matrix the
660 // cache-hit gate must also confirm the published matrix still
661 // covers this range — otherwise a pure scroll past the window
662 // edge (no version change) would wrongly short-circuit and leave
663 // the new viewport painting plain-text fallback forever.
664 //
665 // Clamp the upper bound to the doc's line count: a viewport taller
666 // than the file (short buffers, big terminals) needs coverage only
667 // through EOF, not past it — otherwise `covers` would spuriously
668 // fail against a whole-doc matrix whose `covered_end_line` is the
669 // (smaller) line count and reject valid cache hits / incremental
670 // results.
671 //
672 // CV.2: **content** space, not ropey's raw `line_count()`. The
673 // raw count reports one extra line for any rope ending in `\n`,
674 // and every line-count in this file bounds either row production
675 // or the coverage range those rows are checked against — so the
676 // phantom logical line becomes a phantom display row, the
677 // numbered empty line 220 of a 219-line file.
678 //
679 // FW.1 (2026-08-31): the upper bound is **fold-aware**.
680 // `viewport_height` counts screen ROWS; this range is buffer LINES,
681 // and a closed fold is where the two diverge — 60 rows of a
682 // collapsed org file reach ~2500 lines in. Asking for
683 // `scroll + viewport_height` asked the build to cover the first
684 // screenful of lines and left every row past the first fold with no
685 // chunk, hence no spans, hence the renderer's plain-text fallback.
686 // This gate and `window_bounds` must derive the bound the same way
687 // or `covers` fails forever and the worker never cache-hits.
688 let coverage_line_count = snapshot.buffer.content_line_count();
689 let visible_lo = pane.scroll.min(coverage_line_count);
690 let visible_hi = crate::folds::fold_aware_visible_end_of(
691 &pane.folds,
692 pane.foldenable,
693 pane.scroll,
694 pane.viewport_height,
695 coverage_line_count,
696 );
697
698 // B2.2 (2026-06-04): the `DisplayMatrix` is now the canonical
699 // build; the `CellMatrix` is a projection of it
700 // (`display_matrix_to_cell_matrix`) feeding the not-yet-cut-over
701 // renderers (GPU until B3) until B4 deletes the cell path. Cache
702 // hit / incremental / full all gate on the canonical matrix; the
703 // projection runs only when the canonical matrix is rebuilt.
704 //
705 // Cache hit: the published canonical matrix already reflects the
706 // inputs AND still covers the viewport (H.3). The cell projection
707 // from the prior tick is still valid (nothing changed), so we
708 // touch neither cell.
709 let existing = pane.display_matrix.load_full();
710 // TC.3b fix (2026-08-17): the strip must be republished BEFORE the
711 // matrix cache-hit returns below. Its input is the resolved
712 // `sticky_context_lines`, which changes with the CURSOR — and a cursor
713 // move does not move `MatrixVersion` (text / syntax / folds / theme /
714 // whitespace / indent), so the matrix cache-hits and the strip froze at
715 // whatever it showed when the text last changed. Walking `[u` moved the
716 // cursor out of a scope and the row for it stayed on screen.
717 //
718 // Cheap at cursor rate: `publish_sticky_context` no-ops when the resolved
719 // lines and the version both match what is already published, so the
720 // common case is a slice comparison of at most `max-lines` u32s.
721 publish_sticky_context(pane, snapshot.as_ref(), ct, whitespace, existing.version);
722 // CL.1: the reveal line joins the cache-hit key, but NOT `MatrixVersion`.
723 // It moves with the cursor, so folding it into the version would rebuild
724 // the window on every `j` — a 47 ns hit becoming ~1.5 ms. Compared here
725 // instead, and a difference takes the two-row incremental path below
726 // rather than a full rebuild.
727 // Stale-render instrumentation. The report is always the same shape — an
728 // edit renders with the wrong colours until `<C-l>` — and the question it
729 // asks is which INVALIDATION AXIS failed to move. `MatrixVersion` carries
730 // one per input (text / syntax / folds / theme / …), so naming the axes
731 // that differ says immediately whether the reparse reached this layer at
732 // all.
733 //
734 // Read it against `syntax_reparse_published`. The expected sequence for one
735 // edit is: a rebuild with axes `["text"]` (the sync edit path builds
736 // UNCOLOURED on purpose — see `sync_rebuild_pane_on_edit` — so the glyphs
737 // land in the same frame as the keystroke), then `syntax_reparse_published`,
738 // then a second rebuild with axes `["syntax"]` that puts the colour on.
739 //
740 // A missing SECOND rebuild is the bug: the reparse landed and this layer
741 // never noticed, which is exactly the state `<C-l>` clears by dropping the
742 // published matrix entirely. A second rebuild that IS logged and still
743 // renders stale moves the question on to the renderer.
744 //
745 // One line per pane per rebuild — the same cadence as
746 // `syntax_reparse_requested`, not per frame; the cache-hit path (every
747 // idle tick) logs nothing.
748 let axes = pane.version.differing_axes(&existing.version);
749 if !axes.is_empty() {
750 debug!(
751 target: "lattice_host::cells_worker",
752 ?axes,
753 want_text = pane.version.text,
754 have_text = existing.version.text,
755 want_syntax = pane.version.syntax,
756 have_syntax = existing.version.syntax,
757 "cells_matrix_invalidated"
758 );
759 }
760 if !pane.version.differs_from(&existing.version)
761 && existing.wrap_width == effective_wrap
762 && existing.reveal_line == pane.conceal_reveal_line
763 && existing.covers(visible_lo, visible_hi)
764 {
765 // B2.3 (2026-06-04): the canonical `DisplayMatrix` is already
766 // current for these inputs — typically because the actor rebuilt
767 // it synchronously in the publish tail
768 // ([`sync_rebuild_pane_on_edit`]) so `version.text` never lags the
769 // snapshot. That sync path deliberately does NOT project to the
770 // cell grid (the O(window) projection stays off the edit-critical
771 // actor thread per the B2 threading guarantee), so the projected
772 // cells may still be a frame behind. Reconcile them here, off the
773 // actor thread, for the not-yet-cut-over cell renderers (GPU until
774 // B3). When the projection already matches, it is a true cache hit
775 // and no paint wake fires.
776 let cells_current = {
777 let cm = pane.matrix.load();
778 cm.version == existing.version
779 && cm.wrap_width == existing.wrap_width
780 && cm.covers(visible_lo, visible_hi)
781 };
782 if cells_current {
783 return WorkerDecision::CacheHit;
784 }
785 let cells = display_matrix_to_cell_matrix(&existing, ct);
786 pane.matrix.store(Arc::new(cells));
787 return WorkerDecision::Recomputed;
788 }
789
790 // Incremental rebuild (S2.4.b semantics, DisplayLine payload).
791 // Eligibility is checked inside `try_incremental_display_build`;
792 // `None` falls through to a full windowed rebuild. The worker path
793 // keeps full syntax colour (`allow_highlight: true`); the sync edit
794 // path forces it off.
795 //
796 // PU.1b-2a: buffers carrying static extra spans always take the full
797 // rebuild — the incremental path reuses prefix/suffix `DisplayLine`s
798 // verbatim and does not re-run the `merge_extra_spans` step, so a
799 // re-seed (help-content swap) would otherwise keep stale link
800 // styling. The gate is free for ordinary panes (`extra_spans`
801 // empty), which dominate.
802 // DR.3: refinement gates the incremental path for the same reason
803 // `extra_spans` does — the incremental build reuses rows verbatim
804 // and would keep stale refinement after a diff refresh shifted the
805 // text under it.
806 // CL.1: the reveal moved and nothing else did — rebuild the two rows whose
807 // conceal state changed and `Arc`-reuse every other one. This is the whole
808 // reason the reveal line is a line rather than a buffer-wide flag: it makes
809 // the per-cursor-move cost two rows instead of a window.
810 if let Some(dm) = try_incremental_reveal_build(&existing, snapshot.as_ref(), pane, whitespace) {
811 let cells = display_matrix_to_cell_matrix(&dm, ct);
812 pane.matrix.store(Arc::new(cells));
813 publish_indent_guides(pane, snapshot.as_ref(), &dm);
814 publish_sticky_context(pane, snapshot.as_ref(), ct, whitespace, dm.version);
815 pane.display_matrix.store(Arc::new(dm));
816 return WorkerDecision::RecomputedIncremental;
817 }
818
819 let rebuilt = if pane.extra_spans.is_empty() && pane.extra_refine.is_empty() {
820 try_incremental_display_build(&existing, snapshot.as_ref(), pane, ct, whitespace, true)
821 .and_then(|mut dm| {
822 dm.wrap_width = effective_wrap;
823 // H.3: accept the incremental result only if it still
824 // covers the viewport; a same-tick scroll past the window
825 // edge falls through to a recentred full rebuild.
826 dm.covers(visible_lo, visible_hi)
827 .then_some((dm, WorkerDecision::RecomputedIncremental))
828 })
829 } else {
830 None
831 };
832
833 let (matrix, decision) = match rebuilt {
834 Some((dm, decision)) => (dm, decision),
835 None => {
836 // Full rebuild fallback — windowed around `pane.scroll`
837 // in chunked mode (H.3).
838 let mut dm = build_display_matrix(
839 snapshot.as_ref(),
840 pane.syntax_handle.as_deref(),
841 &pane.excerpt_syntax,
842 &pane.extra_spans,
843 &pane.extra_refine,
844 ct,
845 &pane.inlay_hints,
846 &pane.folds,
847 pane.foldenable,
848 pane.viewport_height,
849 pane.scroll,
850 pane.version,
851 whitespace,
852 pane.conceal_reveal_line,
853 );
854 dm.wrap_width = effective_wrap;
855 (dm, WorkerDecision::Recomputed)
856 }
857 };
858
859 // Project the canonical matrix to the cell grid (the transient
860 // B2→B4 bridge), then publish both. Store the cell projection
861 // first so a renderer reading `pane.matrix` after seeing the new
862 // `pane.display_matrix` never observes a stale cell grid.
863 let cells = display_matrix_to_cell_matrix(&matrix, ct);
864 pane.matrix.store(Arc::new(cells));
865 publish_indent_guides(pane, snapshot.as_ref(), &matrix);
866 publish_sticky_context(pane, snapshot.as_ref(), ct, whitespace, matrix.version);
867 pane.display_matrix.store(Arc::new(matrix));
868 decision
869}
870
871/// B2.3 (2026-06-04): the synchronous, edit-path-only `DisplayMatrix`
872/// rebuild the actor runs in the publish tail
873/// ([`crate::dispatch`]'s `publish_render_state`) **before** the render
874/// state is stored, so the published `display_matrix` is text-current the
875/// instant the renderer paints — `version.text` never lags the snapshot,
876/// which is what retires the per-keystroke whole-viewport stale-guard
877/// flicker.
878///
879/// Honours the B2 threading guarantee (CLAUDE.md / the slice plan): the
880/// edit-critical thread does ONLY O(window) text + structure work —
881/// prefix/suffix `DisplayLine` `Arc`-reuse plus the edited line(s)' text
882/// rebuild — and never a `highlight_lines` call, reparse, or full O(file)
883/// build. Concretely it attempts ONLY [`try_incremental_display_build`]
884/// with `allow_highlight: false`:
885///
886/// - **Eligible** (single text edit, unchanged inlay/fold/theme axes,
887/// line-count consistent, stable chunk shape, result still covers the
888/// viewport) → store the rebuilt matrix into `pane.display_matrix` and
889/// return `true`. The edited line shows default fg until the async
890/// worker recolours it; unchanged lines keep their colour via `Arc`
891/// reuse.
892/// - **Ineligible** (non-edit publish, doc switch, chunk-shape change,
893/// same-tick window miss) → leave `pane.display_matrix` untouched and
894/// return `false`. The async worker performs the full / highlighted
895/// build off-thread.
896///
897/// Deliberately does NOT project to the cell grid: that O(window)
898/// projection stays on the async worker ([`recompute_pane`] reconciles the
899/// lagging cells on its next wake). Cells therefore trail the display
900/// matrix by one worker tick until the renderers cut over to
901/// `DisplayMatrix` (TUI B2.4, GPU B3) and the cell path is deleted (B4).
902pub fn sync_rebuild_pane_on_edit(
903 pane: &crate::render_state::PaneCellsInputs,
904 ct: CellTheme<'_>,
905 whitespace: &WhitespaceConfig,
906) -> bool {
907 let Some(snapshot) = pane.snapshot.as_ref() else {
908 return false;
909 };
910 // Match `recompute_pane`'s wrap + coverage model so an accepted sync
911 // result is one the worker treats as a cache hit (no redundant
912 // rebuild) on its following wake. Body-width wrap (pane minus the
913 // builder-supplied gutter reservation) — see `recompute_pane`.
914 let effective_wrap = if pane.wrap {
915 pane.viewport_width
916 .saturating_sub(pane.wrap_reserved_cols)
917 .max(1)
918 } else {
919 0
920 };
921 // FW.1: fold-aware, for the reason spelled out in `recompute_pane`.
922 let coverage_line_count = snapshot.buffer.content_line_count();
923 let visible_lo = pane.scroll.min(coverage_line_count);
924 let visible_hi = crate::folds::fold_aware_visible_end_of(
925 &pane.folds,
926 pane.foldenable,
927 pane.scroll,
928 pane.viewport_height,
929 coverage_line_count,
930 );
931
932 let existing = pane.display_matrix.load_full();
933 let result =
934 try_incremental_display_build(&existing, snapshot.as_ref(), pane, ct, whitespace, false);
935 let Some(mut matrix) = result else {
936 return false;
937 };
938 matrix.wrap_width = effective_wrap;
939 if !matrix.covers(visible_lo, visible_hi) {
940 // Same-tick scroll past the window edge — let the async worker do
941 // the recentred (windowed) full rebuild off-thread.
942 return false;
943 }
944 publish_indent_guides(pane, snapshot.as_ref(), &matrix);
945 // The UNCOLOURED half of the pair. This path runs on the edit-critical
946 // actor thread and deliberately builds with `allow_highlight: false`, so
947 // the typed glyph lands in the same frame and the colour follows when the
948 // reparse does. Logging it gives the stale-render sequence a beginning:
949 //
950 // cells_sync_rebuild_uncoloured text=N <- the keystroke's frame
951 // syntax_reparse_published version=N <- the tree caught up
952 // cells_matrix_invalidated axes=[syntax] <- the colour arrives
953 //
954 // If the third line never appears for that text version, the reparse never
955 // reached this layer and the buffer stays at default colours until `<C-l>`
956 // drops the published matrix — which is the reported bug.
957 debug!(
958 target: "lattice_host::cells_worker",
959 text = matrix.version.text,
960 syntax = matrix.version.syntax,
961 "cells_sync_rebuild_uncoloured"
962 );
963 pane.display_matrix.store(Arc::new(matrix));
964 true
965}
966
967/// S2.4.b: attempt an incremental rebuild from the previously-
968/// published `matrix` and the current `cells` substate. Returns
969/// `Some(new_matrix)` when the fast path is eligible; `None`
970/// otherwise. The caller falls back to a full rebuild on `None`.
971///
972/// Eligibility requires *all* of:
973/// - `cells.last_edit` is `Some(delta)` (single text edit since
974/// the last publish, set by `publish_document_changed` and
975/// `take()`n at `build_render_state` time);
976/// - the published matrix has at least one chunk (no prior
977/// matrix → nothing to reuse);
978/// - exactly the `text` and `syntax` axes of `MatrixVersion`
979/// differ (other axes — `inlay_hints`, `folds`, `theme` —
980/// are unchanged; any other axis would invalidate parts of
981/// the cached cell content);
982/// - the post-edit line count consistency check holds
983/// (`published.source_line_count + (added - removed) ==
984/// snapshot.line_count()`) — guards against silent corruption
985/// when the matrix is for a different document;
986/// - the chunked-mode shape doesn't change between pre and post
987/// edit (same whole-doc vs chunked decision, same `chunk_size`).
988///
989/// On success the new matrix is composed by walking the published
990/// matrix's chunks and:
991/// - **Prefix chunks** (entirely below `edit.start_line`) are
992/// cloned by `Arc` (zero work, refcount bump only).
993/// - **Affected chunks** (those whose covered range intersects
994/// the edit's affected range) rebuild via [`build_chunk_rows`].
995/// - **Suffix chunks** (entirely past `edit.pre_edit_end_line()`)
996/// are shifted via [`CellChunk::shifted_by`] — cell payloads
997/// shared, `source_line` advances by `edit.net_delta()`.
998///
999/// Chunks at the chunked-mode boundary that contain the affected
1000/// range may need merging with adjacent rebuilt-zone content;
1001/// this implementation rebuilds any chunk whose covered range
1002/// touches the affected range to keep the logic simple and
1003/// correct.
1004// B2.2 (2026-06-04): superseded by `try_incremental_display_build`;
1005// kept as the cell-path parity oracle, deleted in B4.
1006#[allow(dead_code)]
1007fn try_incremental_build(
1008 published: &CellMatrix,
1009 snapshot: &lattice_runtime::DocumentSnapshot,
1010 pane: &crate::render_state::PaneCellsInputs,
1011 ct: CellTheme<'_>,
1012 whitespace: &WhitespaceConfig,
1013) -> Option<CellMatrix> {
1014 let edit = pane.last_edit?;
1015 if published.chunks.is_empty() {
1016 return None;
1017 }
1018
1019 let new_version = pane.version;
1020 let pub_v = published.version;
1021
1022 // Only text / syntax axes may differ here. The text axis is the
1023 // document version; the syntax axis is the syntax-snapshot version
1024 // (2026-06-03) — they bump independently, because the async reparse
1025 // can land after the edit and must invalidate the cache on its own.
1026 if new_version.inlay_hints != pub_v.inlay_hints
1027 || new_version.folds != pub_v.folds
1028 || new_version.theme != pub_v.theme
1029 {
1030 return None;
1031 }
1032 if new_version.text == pub_v.text && new_version.syntax == pub_v.syntax {
1033 // No actual content delta — would be cache-hit territory.
1034 return None;
1035 }
1036
1037 // Line-count consistency check guards against doc switches
1038 // where versions coincidentally line up.
1039 //
1040 // CV.2: content space, matching `source_line_count` (which the
1041 // build below stamps in content space). An edit that adds or
1042 // removes the file's *terminating* newline moves the rope count
1043 // without moving the content count, so `net_delta` disagrees and
1044 // the check falls back to a full rebuild — conservative, and the
1045 // safe direction.
1046 let new_line_count = snapshot.buffer.content_line_count();
1047 let pre_count = published.source_line_count as i64;
1048 let expected_new = pre_count + edit.net_delta() as i64;
1049 if expected_new < 0 || expected_new as u32 != new_line_count {
1050 return None;
1051 }
1052
1053 // Chunked-mode shape must be unchanged. Whole-doc → whole-doc
1054 // and chunked(n) → chunked(n) both qualify; any cross-shape
1055 // transition forces a full rebuild.
1056 let new_mode = pick_chunk_size(pane.viewport_height, new_line_count);
1057 let new_chunk_size = match new_mode {
1058 ChunkMode::WholeDoc => CHUNK_SIZE_WHOLE_DOC,
1059 ChunkMode::Chunked(n) => n,
1060 };
1061 if new_chunk_size != published.chunk_size {
1062 return None;
1063 }
1064
1065 // Edit-affected ranges in pre- and post-edit line space.
1066 // `post_hi = edit.post_edit_end_line()` is unused in the
1067 // current partitioning (rebuild_hi is derived from the first
1068 // suffix chunk's post-edit start); kept conceptually here for
1069 // readers tracing the design doc but not bound.
1070 let edit_lo = edit.start_line;
1071 let pre_hi = edit.pre_edit_end_line();
1072 let net = edit.net_delta();
1073
1074 let (default_fg, default_flags) = resolve_style(ct, lattice_syntax::Style::Default);
1075 let inlays_by_line = bucket_inlays_by_line(&pane.inlay_hints, new_line_count);
1076 let fold_index = crate::folds::FoldIndex::from_folds(&pane.folds, pane.foldenable);
1077 // H.1 (2026-06-04): highlight only the line range a rebuild actually
1078 // touches, not the whole file. Returns spans indexed RELATIVE to `lo`
1079 // (so the `ChunkInputs.spans_base` for the build is `lo`). `None` ⇒
1080 // syntax stale/absent → rows fall back to default fg. This is what keeps
1081 // per-keystroke highlight O(edit) instead of O(file) — and collapses the
1082 // compose `cells_stale` plain-text window that read as a flicker.
1083 let highlight_range = |lo: u32, hi: u32| -> Option<Vec<Vec<lattice_syntax::StyledSpan>>> {
1084 // K.4.7: multibuffer panes carry per-excerpt handles; use them
1085 // when present. Falls back to the single-doc path when empty.
1086 if let Some(spans) = highlight_range_multibuffer(&pane.excerpt_syntax, lo, hi) {
1087 return Some(spans);
1088 }
1089 if hi <= lo {
1090 return Some(Vec::new());
1091 }
1092 pane.syntax_handle.as_deref().and_then(|h| {
1093 let snap = h.snapshot();
1094 if snap.text_version() < snapshot.text_version {
1095 return None;
1096 }
1097 snap.highlight_lines(lo, hi).ok()
1098 })
1099 };
1100
1101 if new_chunk_size == CHUNK_SIZE_WHOLE_DOC {
1102 // Whole-doc mode is one chunk. 2026-06-04: reuse the prior
1103 // chunk's ROWS for unchanged lines (prefix + suffix), rebuilding
1104 // only the affected range — the row-level analogue of the
1105 // chunked prefix/suffix reuse below. A wholesale
1106 // `build_chunk_rows(0, count)` here recoloured EVERY line from
1107 // `per_line_spans`, which lags right after an edit (the async
1108 // reparse hasn't landed → stale gate returns `None` → colourless
1109 // cells). So a small file (whole-doc mode) blanked its whole
1110 // viewport on each keystroke until syntax caught up — invisible
1111 // for fast single-layer grammars (Rust) but a visible stutter
1112 // for markdown's slower injection reparse. Reusing prior rows
1113 // keeps their colours through the window; only the edited
1114 // line(s) recolour. See `feedback_decorations_update_in_place`.
1115 let Some(prior) = published.chunks.first() else {
1116 let spans = highlight_range(0, new_line_count);
1117 let inputs = ChunkInputs {
1118 snapshot,
1119 conceal_reveal_line: pane.conceal_reveal_line,
1120 per_line_spans: spans.as_ref(),
1121 spans_base: 0,
1122 inlays_by_line: &inlays_by_line,
1123 fold_index: &fold_index,
1124 ct,
1125 default_fg,
1126 default_flags,
1127 whitespace,
1128 refine_by_line: &[],
1129 // The cell path is the legacy parity oracle (deleted at B4)
1130 // and builds via `build_chunk_rows`, which never reads these.
1131 conceal_rules: &[],
1132 excerpt_conceal: &[],
1133 };
1134 let rows = build_chunk_rows(&inputs, 0, new_line_count);
1135 let chunk = Arc::new(CellChunk::new(0, rows, new_version));
1136 return Some(CellMatrix::whole_doc(chunk, new_line_count));
1137 };
1138 let prior_rows = &prior.rows;
1139 // Affected upper bound = the first suffix row's POST-edit start
1140 // (mirrors the chunked `rebuild_hi`). Computed from the actual
1141 // first prior row at/after `pre_hi` so folds (non-contiguous
1142 // source lines) don't throw off the boundary; `None` ⇒ the edit
1143 // reached EOF and the rebuild zone runs to `new_line_count`.
1144 let affected_hi = prior_rows
1145 .iter()
1146 .map(|r| r.source_line)
1147 .find(|&l| l >= pre_hi)
1148 .map(|l| (l as i64 + net as i64).max(edit_lo as i64) as u32)
1149 .unwrap_or(new_line_count)
1150 .min(new_line_count);
1151 let mut rows: Vec<CellRow> = Vec::with_capacity(prior_rows.len() + 2);
1152 // Prefix: unchanged lines before the edit — reuse verbatim.
1153 rows.extend(
1154 prior_rows
1155 .iter()
1156 .filter(|r| r.source_line < edit_lo)
1157 .cloned(),
1158 );
1159 // Affected zone: rebuilt (the only lines that recolour) — highlight
1160 // scoped to exactly this range (H.1).
1161 let spans = highlight_range(edit_lo, affected_hi);
1162 let inputs = ChunkInputs {
1163 snapshot,
1164 per_line_spans: spans.as_ref(),
1165 spans_base: edit_lo,
1166 inlays_by_line: &inlays_by_line,
1167 fold_index: &fold_index,
1168 ct,
1169 default_fg,
1170 default_flags,
1171 whitespace,
1172 refine_by_line: &[],
1173 conceal_rules: &[],
1174 excerpt_conceal: &[], // legacy cell path; build_chunk_rows ignores these
1175 conceal_reveal_line: None,
1176 };
1177 rows.extend(build_chunk_rows(&inputs, edit_lo, affected_hi));
1178 // Suffix: lines past the edit — reuse with shifted source line.
1179 rows.extend(
1180 prior_rows
1181 .iter()
1182 .filter(|r| r.source_line >= pre_hi)
1183 .map(|r| r.with_source_line((r.source_line as i64 + net as i64).max(0) as u32)),
1184 );
1185 let chunk = Arc::new(CellChunk::new(0, rows, new_version));
1186 return Some(CellMatrix::whole_doc(chunk, new_line_count));
1187 }
1188
1189 // Chunked-mode incremental rebuild. Partition the published
1190 // chunks into three regions whose post-edit projections are
1191 // mutually exclusive and contiguous:
1192 //
1193 // 1. Prefix-reuse: chunks fully before the edit
1194 // (chunk_end <= edit_lo). Their cells *and* logical-line
1195 // positions are unchanged; clone the `Arc<CellChunk>`
1196 // verbatim (one refcount bump).
1197 // 2. Rebuild zone: post-edit lines from the prefix's high-
1198 // water-mark up to (but not including) the first suffix
1199 // chunk's post-edit start. Materialised via
1200 // `build_chunk_rows` in `chunk_size`-aligned buckets.
1201 // 3. Suffix-shift: chunks fully past the edit
1202 // (chunk_start >= pre_hi). Their `start_source_line` and
1203 // every row's `source_line` shift by `net`; cell payloads
1204 // are shared via row-level `Arc` refcount bumps.
1205 //
1206 // Chunks straddling the affected range fall into the rebuild
1207 // zone implicitly — they are not picked up by either prefix
1208 // or suffix and the rebuild loop covers their post-edit
1209 // footprint.
1210 let chunk_size = new_chunk_size;
1211 let mut new_chunks: Vec<Arc<CellChunk>> = Vec::with_capacity(published.chunks.len() + 2);
1212
1213 // --- Step 1: prefix-reuse ---
1214 let mut rebuild_lo: u32 = 0;
1215 for chunk in published.chunks.iter() {
1216 let chunk_end = chunk.start_source_line.saturating_add(published.chunk_size);
1217 if chunk_end <= edit_lo {
1218 new_chunks.push(Arc::clone(chunk));
1219 rebuild_lo = chunk_end;
1220 } else {
1221 // chunks are sorted by start_source_line; the rest
1222 // overlap or are past the edit.
1223 break;
1224 }
1225 }
1226
1227 // --- Step 2: suffix-shift ---
1228 let mut suffix_chunks: Vec<Arc<CellChunk>> = Vec::new();
1229 for chunk in published.chunks.iter() {
1230 if chunk.start_source_line >= pre_hi {
1231 suffix_chunks.push(Arc::new(chunk.shifted_by(net, new_version)));
1232 }
1233 }
1234 // First suffix chunk's post-edit start anchors the upper bound of the
1235 // rebuild zone. When no suffix chunks remain (the edit fell in the last
1236 // covered chunk) the zone stops at the published window's covered end
1237 // shifted by `net`, NOT `new_line_count` — otherwise a windowed
1238 // large-file matrix rebuilds O(file) rows on an edit near the top. See
1239 // the matching fix + rationale in `try_incremental_display_build`. (Dead
1240 // parity oracle; mirrored to stay honest until B4 deletes the cell path.)
1241 let rebuild_hi = suffix_chunks
1242 .first()
1243 .map(|c| c.start_source_line)
1244 .unwrap_or_else(|| {
1245 ((published.covered_end_line() as i64 + net as i64).max(rebuild_lo as i64) as u32)
1246 .min(new_line_count)
1247 });
1248
1249 // --- Step 3: rebuild zone ---
1250 // Carve [rebuild_lo, rebuild_hi) into `chunk_size`-aligned
1251 // chunks. The final chunk may be ragged if `rebuild_hi` falls
1252 // mid-chunk — that's expected when suffix-shift produces a
1253 // chunk at a non-aligned start (e.g. an insert shifts an old
1254 // chunk-aligned start by `net != 0`). The matrix invariant is
1255 // contiguous ordered chunks, not uniform sizing — the renderer
1256 // walks via `chunk.rows.iter()` so any ragged tail is fine.
1257 // H.1: highlight scoped to the rebuild zone [rebuild_lo, rebuild_hi),
1258 // indexed relative to `rebuild_lo` — not the whole file.
1259 let spans = highlight_range(rebuild_lo, rebuild_hi);
1260 let inputs = ChunkInputs {
1261 snapshot,
1262 per_line_spans: spans.as_ref(),
1263 spans_base: rebuild_lo,
1264 inlays_by_line: &inlays_by_line,
1265 fold_index: &fold_index,
1266 ct,
1267 default_fg,
1268 default_flags,
1269 whitespace,
1270 refine_by_line: &[],
1271 conceal_rules: &[], // legacy cell path; build_chunk_rows ignores these
1272 excerpt_conceal: &[],
1273 conceal_reveal_line: None,
1274 };
1275 let mut cur = rebuild_lo;
1276 while cur < rebuild_hi {
1277 let end = cur.saturating_add(chunk_size).min(rebuild_hi);
1278 let rows = build_chunk_rows(&inputs, cur, end);
1279 new_chunks.push(Arc::new(CellChunk::new(cur, rows, new_version)));
1280 cur = end;
1281 }
1282
1283 // --- Append suffix chunks ---
1284 new_chunks.extend(suffix_chunks);
1285
1286 Some(CellMatrix::chunked(
1287 new_chunks,
1288 chunk_size,
1289 new_line_count,
1290 new_version,
1291 ))
1292}
1293
1294/// Build a [`CellMatrix`] from `snapshot` + optional syntax
1295/// handle + theme + inlay-hint payload + folds. Picks whole-doc
1296/// vs chunked mode based on `viewport_height` and the snapshot's
1297/// line count.
1298///
1299/// One [`CellRow`] per source line that survives fold elision. Cell
1300/// codepoints come from the document snapshot's rope. `cell.fg` is
1301/// the theme-resolved RGB for the syntax span covering each byte;
1302/// bytes outside any span (or every byte when no syntax handle is
1303/// attached) take the theme's `Style::Default` fg. Inlay hints
1304/// whose `(line, byte)` falls inside the visible range splice
1305/// virtual cells (one per inlay char) at that position with
1306/// `flags::INLAY` set, and record `(orig_byte, char_width)` on
1307/// `CellRow::inlay_offsets`. Source lines that fall *strictly
1308/// inside* a closed fold (`start_line < line <= end_line`) produce
1309/// no row — the fold's `start_line` is the only visible row for the
1310/// folded section, matching the existing `line_inside_closed_fold`
1311/// semantics.
1312///
1313/// **Mode selection (S2.4.a)**: when `viewport_height == 0` or
1314/// `line_count <= 4 × viewport_height`, the matrix is a single
1315/// whole-doc chunk. Otherwise it splits into
1316/// `next_pow2(2 × viewport_height)`-line chunks (clamped to a
1317/// 16-line floor). Every chunk carries the same publisher
1318/// `MatrixVersion` for now — S2.4.b will reuse unaffected chunks
1319/// by per-chunk version comparison once edit-range plumbing
1320/// arrives.
1321///
1322/// Stale-syntax behaviour: if the syntax snapshot's
1323/// `text_version` is behind the document's `text_version`, the
1324/// snapshot's byte offsets no longer align with the current rope
1325/// and re-styling against them would mis-colour edits. The worker
1326/// falls back to the default fg for the whole document in that
1327/// case. The matrix rebuilds again when the syntax catches up
1328/// (the cascade bumps `MatrixVersion::syntax`, which equals the
1329/// document's `text_version` at publish time).
1330// B2.2 (2026-06-04): superseded in production by `build_display_matrix`
1331// + `display_matrix_to_cell_matrix`. Retained as the parity oracle for
1332// the projection tests; deleted with the cell path in B4.
1333#[allow(dead_code)]
1334#[allow(clippy::too_many_arguments)]
1335fn build_matrix(
1336 snapshot: &lattice_runtime::DocumentSnapshot,
1337 syntax_handle: Option<&lattice_syntax::SyntaxHandle>,
1338 ct: CellTheme<'_>,
1339 inlay_hints: &[crate::render_state::InlayHintRow],
1340 folds: &[lattice_core::Fold],
1341 foldenable: bool,
1342 viewport_height: u32,
1343 scroll: u32,
1344 version: MatrixVersion,
1345 whitespace: &WhitespaceConfig,
1346) -> CellMatrix {
1347 let line_count = snapshot.buffer.content_line_count();
1348 if line_count == 0 {
1349 return CellMatrix::empty();
1350 }
1351
1352 let (default_fg, default_flags) = resolve_style(ct, lattice_syntax::Style::Default);
1353
1354 let inlays_by_line = bucket_inlays_by_line(inlay_hints, line_count);
1355 let fold_index = crate::folds::FoldIndex::from_folds(folds, foldenable);
1356
1357 // H.3 (2026-06-04): highlight only the line range a build actually
1358 // covers — the whole file in whole-doc mode, the viewport window in
1359 // chunked mode — not the whole file unconditionally. Mirrors the
1360 // `highlight_range` closure in `try_incremental_build`. Returns spans
1361 // indexed RELATIVE to `lo` (so the build's `spans_base` is `lo`).
1362 // `None` ⇒ syntax stale/absent → rows fall back to default fg, and the
1363 // matrix rebuilds when the syntax catches up (the cascade bumps
1364 // `MatrixVersion::syntax`).
1365 let highlight_range = |lo: u32, hi: u32| -> Option<Vec<Vec<lattice_syntax::StyledSpan>>> {
1366 if hi <= lo {
1367 return Some(Vec::new());
1368 }
1369 syntax_handle.and_then(|h| {
1370 let snap = h.snapshot();
1371 // Stale snapshot — don't paint with mismatched offsets.
1372 if snap.text_version() < snapshot.text_version {
1373 return None;
1374 }
1375 snap.highlight_lines(lo, hi).ok()
1376 })
1377 };
1378
1379 match pick_chunk_size(viewport_height, line_count) {
1380 ChunkMode::WholeDoc => {
1381 let spans = highlight_range(0, line_count);
1382 let inputs = ChunkInputs {
1383 snapshot,
1384 per_line_spans: spans.as_ref(),
1385 spans_base: 0,
1386 inlays_by_line: &inlays_by_line,
1387 fold_index: &fold_index,
1388 ct,
1389 default_fg,
1390 default_flags,
1391 whitespace,
1392 refine_by_line: &[],
1393 conceal_rules: &[],
1394 excerpt_conceal: &[], // legacy cell path; build_chunk_rows ignores these
1395 conceal_reveal_line: None,
1396 };
1397 let rows = build_chunk_rows(&inputs, 0, line_count);
1398 let chunk = Arc::new(CellChunk::new(0, rows, version));
1399 CellMatrix::whole_doc(chunk, line_count)
1400 }
1401 ChunkMode::Chunked(chunk_size) => {
1402 // H.3: window the chunked build around the viewport (above
1403 // `WINDOW_CAP_LINES`; full coverage at/below it). Highlight +
1404 // row materialisation are scoped to `[win_lo, win_hi)`, so the
1405 // build is O(window) on large files. `source_line_count` stays
1406 // the true doc count so `row_at_source_line` bounds + scroll
1407 // geometry are unchanged; off-window lines simply have no chunk
1408 // → `None` → the renderers' existing plain-text/legacy-span
1409 // fallback.
1410 //
1411 // FW.1: sized by the fold-aware SPAN, not the row count.
1412 let (win_lo, win_hi) = window_bounds(
1413 scroll,
1414 fold_aware_span(&fold_index, scroll, viewport_height, line_count),
1415 line_count,
1416 chunk_size,
1417 );
1418 let spans = highlight_range(win_lo, win_hi);
1419 let inputs = ChunkInputs {
1420 snapshot,
1421 per_line_spans: spans.as_ref(),
1422 spans_base: win_lo,
1423 inlays_by_line: &inlays_by_line,
1424 fold_index: &fold_index,
1425 ct,
1426 default_fg,
1427 default_flags,
1428 whitespace,
1429 refine_by_line: &[],
1430 conceal_rules: &[],
1431 excerpt_conceal: &[], // legacy cell path; build_chunk_rows ignores these
1432 conceal_reveal_line: None,
1433 };
1434 let mut chunks: Vec<Arc<CellChunk>> =
1435 Vec::with_capacity(((win_hi - win_lo) / chunk_size + 1) as usize);
1436 let mut start = win_lo;
1437 while start < win_hi {
1438 let end = start.saturating_add(chunk_size).min(win_hi);
1439 let rows = build_chunk_rows(&inputs, start, end);
1440 chunks.push(Arc::new(CellChunk::new(start, rows, version)));
1441 start = end;
1442 }
1443 CellMatrix::chunked(chunks, chunk_size, line_count, version)
1444 }
1445 }
1446}
1447
1448/// Mode selection for [`build_matrix`]. `WholeDoc` collapses to a
1449/// single chunk covering the entire document; `Chunked(n)` builds
1450/// `n`-line chunks. Switching point and chunk size match the
1451/// design doc (`docs/dev/architecture/cell-grid-renderer.md` §
1452/// Chunking policy).
1453#[derive(Debug, PartialEq, Eq)]
1454enum ChunkMode {
1455 WholeDoc,
1456 Chunked(u32),
1457}
1458
1459/// Pick the chunk mode. Whole-doc when:
1460/// - `viewport_height == 0` (boot / no layout yet), or
1461/// - `line_count <= 4 × viewport_height` (small-doc threshold).
1462///
1463/// Chunked otherwise. `chunk_size = next_pow2(2 × viewport_height)`,
1464/// clamped to a 16-line floor so tiny viewports don't produce
1465/// per-line chunks. The power-of-two snap is intentional — it
1466/// keeps the chunk-table cache-friendly and makes the eventual
1467/// LRU eviction policy in the design doc trivial to reason about.
1468fn pick_chunk_size(viewport_height: u32, line_count: u32) -> ChunkMode {
1469 if viewport_height == 0 || line_count <= viewport_height.saturating_mul(4) {
1470 return ChunkMode::WholeDoc;
1471 }
1472 let target = viewport_height.saturating_mul(2).max(16);
1473 ChunkMode::Chunked(next_power_of_two(target))
1474}
1475
1476/// Smallest `u32` power of two `≥ n`. `n == 0` returns 1 (the
1477/// minimum non-zero power of two); inputs near `u32::MAX` saturate
1478/// at `1 << 31` to avoid overflow.
1479fn next_power_of_two(n: u32) -> u32 {
1480 if n <= 1 {
1481 return 1;
1482 }
1483 let leading = (n - 1).leading_zeros();
1484 if leading == 0 {
1485 1u32 << 31
1486 } else {
1487 1u32 << (32 - leading)
1488 }
1489}
1490
1491/// H.3 (2026-06-04): line-count cap above which a chunked-mode
1492/// matrix is *windowed* around the viewport rather than covering
1493/// the whole document.
1494///
1495/// Below the cap a full-residency chunked build is cheap (~2K lines
1496/// of cells is a couple of MB) and — crucially — avoids paying a
1497/// window rebuild every time the user scrolls past the covered
1498/// range, which on a small doc is pure waste. Above the cap, the
1499/// matrix tracks `[scroll − overscan, scroll + viewport + overscan)`
1500/// so build, rebuild, and memory all stay O(viewport) on large
1501/// files (paramount goal #1) — the headline large-file win.
1502///
1503/// The cap sits well above any realistic "small file" yet below the
1504/// 10k+-line scale where an O(file) cell build visibly stutters. All
1505/// pre-H.3 chunked-mode tests use docs far below it, so they keep
1506/// the full-coverage behaviour they assert.
1507const WINDOW_CAP_LINES: u32 = 2048;
1508
1509/// FW.2 (2026-08-31): the maximal ranges of `[lo, hi)` NOT hidden inside a
1510/// closed fold — the lines a build will actually materialise a row for.
1511///
1512/// **Bounded by the viewport, not by the span.** Every boundary between two
1513/// runs is a closed fold, and every closed fold in the window spends one of
1514/// the viewport's rows on its head, so there are at most
1515/// `viewport_height + 1` runs however many lines the folds hide. That bound
1516/// is what makes querying per-run affordable rather than trading one large
1517/// cost for an unbounded number of small ones.
1518///
1519/// No closed folds ⇒ exactly one run, `[lo, hi)`.
1520fn visible_runs(fold_index: &crate::folds::FoldIndex, lo: u32, hi: u32) -> Vec<(u32, u32)> {
1521 let mut runs: Vec<(u32, u32)> = Vec::new();
1522 let mut line = lo;
1523 let mut start: Option<u32> = None;
1524 while line < hi {
1525 if fold_index.line_inside_closed_fold(line) {
1526 if let Some(s) = start.take() {
1527 runs.push((s, line));
1528 }
1529 // Hop the whole hidden body rather than testing each of its
1530 // lines: a closed fold's interior is contiguous by definition,
1531 // and a 40 000-line fold must not cost 40 000 binary searches.
1532 match fold_index.enclosing_closed_fold(line) {
1533 Some((_, end)) => line = end.saturating_add(1),
1534 None => line += 1,
1535 }
1536 continue;
1537 }
1538 if start.is_none() {
1539 start = Some(line);
1540 }
1541 line += 1;
1542 }
1543 if let Some(s) = start {
1544 runs.push((s, hi));
1545 }
1546 runs
1547}
1548
1549/// FW.2: highlight spans for `[lo, hi)`, querying only the VISIBLE runs.
1550///
1551/// The measurement this exists for, on a 5 000-line Rust fixture:
1552///
1553/// | one contiguous 5 000-line query | 60 single-line queries |
1554/// |---|---|
1555/// | 52.2 ms | 828 µs |
1556///
1557/// FW.1 made the window fold-aware so a collapsed screen paints correctly.
1558/// That made the window span every line the folds hide, and `highlight_lines`
1559/// costs what its RANGE costs whether or not those lines are ever drawn — a
1560/// 63× tax for spans that are then thrown away, since `build_display_rows`
1561/// skips folded interiors and never asks for them.
1562///
1563/// The result stays a DENSE `Vec` indexed from `lo`, so `spans_base` lookups,
1564/// `ChunkInputs` and `build_display_rows` are untouched: a hidden line keeps
1565/// an empty span list, which is what it would have got anyway and what
1566/// nothing reads. That density is bounded by the fold-aware span, and costs
1567/// one empty `Vec` per hidden line against a tree-sitter query over it.
1568///
1569/// `None` when the syntax snapshot is stale or absent, matching the
1570/// single-range primitive — staleness is a property of the snapshot, so every
1571/// run agrees and the first `None` settles it for all of them.
1572fn highlight_visible_runs<F>(
1573 highlight_range: &F,
1574 fold_index: &crate::folds::FoldIndex,
1575 lo: u32,
1576 hi: u32,
1577) -> Option<Vec<Vec<lattice_syntax::StyledSpan>>>
1578where
1579 F: Fn(u32, u32) -> Option<Vec<Vec<lattice_syntax::StyledSpan>>>,
1580{
1581 let runs = visible_runs(fold_index, lo, hi);
1582 // Nothing folded — the overwhelmingly common case. One run covering the
1583 // whole window, so this is the pre-FW.2 call with no stitching at all and
1584 // an ordinary buffer cannot pay for a feature it does not use.
1585 if matches!(runs[..], [(a, b)] if a == lo && b == hi) {
1586 return highlight_range(lo, hi);
1587 }
1588 let len = hi.saturating_sub(lo) as usize;
1589 // Every line hidden: legal, no row will be built, and an empty dense
1590 // window is the honest answer at the cost of no query at all.
1591 if runs.is_empty() {
1592 return Some(vec![Vec::new(); len]);
1593 }
1594 let mut out: Vec<Vec<lattice_syntax::StyledSpan>> = vec![Vec::new(); len];
1595 for (run_lo, run_hi) in runs {
1596 let run = highlight_range(run_lo, run_hi)?;
1597 for (i, spans) in run.into_iter().enumerate() {
1598 let abs = run_lo as usize + i;
1599 let Some(slot) = abs.checked_sub(lo as usize).and_then(|r| out.get_mut(r)) else {
1600 continue;
1601 };
1602 *slot = spans;
1603 }
1604 }
1605 Some(out)
1606}
1607
1608/// FW.1 (2026-08-31): how many BUFFER LINES a `viewport_height`-row
1609/// viewport starting at `scroll` reaches — the unit [`window_bounds`]
1610/// has to be sized in.
1611///
1612/// Equal to `viewport_height` for every buffer with nothing collapsed,
1613/// which is why the conflation survived so long: `viewport_height` was
1614/// the right answer until a fold made a row stand for fifty lines.
1615/// Floored at `viewport_height` so a viewport running past EOF asks for
1616/// the range it always did, keeping fold-free builds bit-identical.
1617fn fold_aware_span(
1618 fold_index: &crate::folds::FoldIndex,
1619 scroll: u32,
1620 viewport_height: u32,
1621 line_count: u32,
1622) -> u32 {
1623 crate::folds::fold_aware_visible_end(fold_index, scroll, viewport_height, line_count)
1624 .saturating_sub(scroll)
1625 .max(viewport_height)
1626}
1627
1628/// H.3: the source-line range a chunked build covers for a given
1629/// `scroll` / visible `span`. Returns the whole document
1630/// `(0, line_count)` at or below [`WINDOW_CAP_LINES`] (full
1631/// residency); above it, a `chunk_size`-aligned window
1632/// `[scroll − overscan, scroll + span + overscan)` clamped to
1633/// `[0, line_count)`.
1634///
1635/// `span` is the count of BUFFER LINES the viewport reaches
1636/// ([`fold_aware_span`]), NOT its height in rows. The two agree
1637/// whenever nothing is folded; when something is, only the former
1638/// describes the range the renderer will ask for rows over — sizing
1639/// this window in rows left everything past the first closed fold
1640/// outside coverage, so it painted uncoloured (FW.1).
1641///
1642/// Overscan is one `span` on each side: combined with the chunk-aligned
1643/// bounds, line-by-line scrolling stays inside the covered range (worker
1644/// returns `CacheHit`); only a jump that crosses the window edge
1645/// triggers a rebuild, and that rebuild is O(window). Scaling the
1646/// overscan with the span rather than pinning it to the row count is
1647/// what keeps that property under folds: one row of scroll can advance
1648/// `scroll` by a whole fold body, so a row-sized overscan would be
1649/// crossed immediately and rebuild on every scroll tick.
1650///
1651/// The cost this admits is real and worth naming: a heavily-collapsed
1652/// region makes the window cover the lines the folds hide, so the
1653/// highlight query runs over them. Rows are not built for them
1654/// (`build_display_rows` skips folded interiors), so the row count stays
1655/// O(viewport) — it is the query that scales with the collapsed span.
1656/// That span tracks the folds *intersecting the viewport*, not the file
1657/// size, so it stays bounded on large files. `chunk_size` is `> 0` here
1658/// (chunked mode only; whole-doc mode never calls this).
1659fn window_bounds(scroll: u32, span: u32, line_count: u32, chunk_size: u32) -> (u32, u32) {
1660 if line_count <= WINDOW_CAP_LINES {
1661 return (0, line_count);
1662 }
1663 let overscan = span;
1664 let raw_lo = scroll.saturating_sub(overscan);
1665 let raw_hi = scroll
1666 .saturating_add(span)
1667 .saturating_add(overscan)
1668 .min(line_count);
1669 // Align lo down and hi up to chunk boundaries so chunks stay
1670 // chunk-aligned (matching the full-coverage build) and a small
1671 // scroll lands inside the already-built window.
1672 let lo = (raw_lo / chunk_size) * chunk_size;
1673 let hi = raw_hi
1674 .saturating_add(chunk_size - 1)
1675 .saturating_div(chunk_size)
1676 .saturating_mul(chunk_size)
1677 .min(line_count);
1678 (lo, hi)
1679}
1680
1681/// Inputs shared across all chunks of a single matrix build. Held
1682/// by reference so the orchestrator can call `build_chunk_rows`
1683/// repeatedly without cloning.
1684struct ChunkInputs<'a> {
1685 snapshot: &'a lattice_runtime::DocumentSnapshot,
1686 /// Per-line highlight spans for the range `[spans_base, spans_base + len)`,
1687 /// indexed RELATIVE to `spans_base` (H.1, 2026-06-04). The highlight is
1688 /// scoped to exactly the range a rebuild touches — whole-file no longer —
1689 /// so `build_chunk_rows` looks up `per_line_spans[line_idx - spans_base]`.
1690 /// `None` ⇒ syntax unavailable/stale; every row falls back to default fg.
1691 per_line_spans: Option<&'a Vec<Vec<lattice_syntax::StyledSpan>>>,
1692 /// Absolute source line that `per_line_spans[0]` corresponds to.
1693 spans_base: u32,
1694 inlays_by_line: &'a std::collections::HashMap<u32, Vec<(u32, &'a str, lattice_syntax::Style)>>,
1695 fold_index: &'a crate::folds::FoldIndex,
1696 ct: CellTheme<'a>,
1697 default_fg: u32,
1698 /// S3.a: modifier flags from `theme.syntax_style(Default)` so
1699 /// cells outside any styled span pick up the theme's default
1700 /// modifiers (typically none, but cheaply propagated).
1701 default_flags: u16,
1702 /// 2026-05-27: passed through to `build_row_cells` for the
1703 /// whitespace-marker substitution. Empty (`show: false`) when
1704 /// the user hasn't enabled `display.show_whitespace`.
1705 whitespace: &'a WhitespaceConfig,
1706 /// DR.3: per-line intra-line refinement, indexed by ABSOLUTE source
1707 /// line (unlike `per_line_spans`, which is relative to
1708 /// `spans_base`). Empty for every buffer that publishes none.
1709 refine_by_line: &'a [Vec<lattice_cells::RefineSpan>],
1710 /// H.3: the buffer language's compiled conceal rules
1711 /// (`docs/dev/architecture/conceal.md`). Empty for every language
1712 /// that declares none, and the emptiness is what makes the conceal
1713 /// path cost a Rust buffer nothing.
1714 conceal_rules: &'a [lattice_syntax::conceal::ConcealRule],
1715 /// OA.7b: per-excerpt conceal rules for a multibuffer, in composed-row
1716 /// order. Empty for an ordinary buffer, which resolves once from
1717 /// `conceal_rules` above — a multibuffer has no single language to
1718 /// resolve from, which is why conceal did nothing in the agenda.
1719 excerpt_conceal: &'a [ExcerptConceal],
1720 /// CL.1: the one line whose conceals are suppressed, or `None`.
1721 conceal_reveal_line: Option<u32>,
1722}
1723
1724/// Build the row vector for one chunk covering source lines
1725/// `[start_line, end_line)`. Folded interior lines are skipped
1726/// (see `line_inside_closed_fold`); surviving rows keep their
1727/// logical `source_line`.
1728// B2.2: cell-path row builder; superseded by `build_display_rows`
1729// (+ projection). Kept as a parity oracle; deleted in B4.
1730#[allow(dead_code)]
1731fn build_chunk_rows(inputs: &ChunkInputs, start_line: u32, end_line: u32) -> Vec<CellRow> {
1732 // `saturating_sub`: see `build_display_rows`.
1733 let mut rows: Vec<CellRow> = Vec::with_capacity(end_line.saturating_sub(start_line) as usize);
1734 for line_idx in start_line..end_line {
1735 if inputs.fold_index.line_inside_closed_fold(line_idx) {
1736 continue;
1737 }
1738 let text = inputs.snapshot.buffer.line(line_idx).unwrap_or_default();
1739 let line_spans: &[lattice_syntax::StyledSpan] = inputs
1740 .per_line_spans
1741 .and_then(|v| {
1742 let rel = line_idx.checked_sub(inputs.spans_base)?;
1743 v.get(rel as usize)
1744 })
1745 .map(Vec::as_slice)
1746 .unwrap_or(&[]);
1747 let line_inlays = inputs
1748 .inlays_by_line
1749 .get(&line_idx)
1750 .map(Vec::as_slice)
1751 .unwrap_or(&[]);
1752 let (cells, inlay_offsets) = build_row_cells(
1753 &text,
1754 line_spans,
1755 line_inlays,
1756 inputs.ct,
1757 inputs.default_fg,
1758 inputs.default_flags,
1759 inputs.whitespace,
1760 );
1761 rows.push(CellRow::new(cells, line_idx, inlay_offsets));
1762 }
1763 rows
1764}
1765
1766/// Per-row build: walks `text` char-by-char, splices inlay text at
1767/// each `(orig_byte, text)` position, emits source cells with
1768/// theme-resolved fg and inlay cells with `flags::INLAY`. Returns
1769/// `(cells, inlay_offsets)` ready for `CellRow::new`.
1770///
1771/// Splice points are inclusive at byte position (inlays whose
1772/// `orig_byte <= char_byte_start` splice in *before* that char).
1773/// Trailing inlays at or past EOL splice at end-of-line — matches
1774/// the historical inlay-weave contract (originally in the deleted
1775/// `highlights_worker::weave_row`) so S3 renderers can switch
1776/// substrates without semantic drift.
1777// B2.2: cell-path per-row builder; superseded by `build_display_row`
1778// (+ `display_line_to_cell_row`). Kept as the parity oracle the
1779// `display_build_parity_*` tests compare against; deleted in B4.
1780#[allow(dead_code)]
1781fn build_row_cells(
1782 text: &str,
1783 line_spans: &[lattice_syntax::StyledSpan],
1784 line_inlays: &[(u32, &str, lattice_syntax::Style)],
1785 ct: CellTheme<'_>,
1786 default_fg: u32,
1787 default_flags: u16,
1788 ws: &WhitespaceConfig,
1789) -> (Vec<Cell>, Vec<lattice_cells::row::InlayOffset>) {
1790 // Capacity: source chars + sum of inlay char widths. Slight
1791 // over-estimate is fine.
1792 let inlay_total_chars: usize = line_inlays.iter().map(|(_, t, _)| t.chars().count()).sum();
1793 let mut cells: Vec<Cell> = Vec::with_capacity(text.len() + inlay_total_chars);
1794 let mut inlay_offsets: Vec<lattice_cells::row::InlayOffset> =
1795 Vec::with_capacity(line_inlays.len());
1796
1797 // Resolve `Style → (fg, flags)`. `Style::Default` returns the
1798 // pre-resolved defaults so callers can avoid the per-cell theme
1799 // lookup on the hot path.
1800 let resolve = |style: lattice_syntax::Style| -> (u32, u16) {
1801 if matches!(style, lattice_syntax::Style::Default) {
1802 (default_fg, default_flags)
1803 } else {
1804 resolve_style(ct, style)
1805 }
1806 };
1807
1808 // 2026-05-27: pre-scan the line to find leading-end and
1809 // trailing-start byte positions. `leading_end_byte` is the
1810 // byte of the first NON-space char (== text.len() for blank
1811 // lines = all chars trailing). `trailing_start_byte` is the
1812 // byte AFTER the last NON-space char.
1813 let mut leading_end_byte = text.len();
1814 let mut trailing_start_byte = 0;
1815 if ws.show {
1816 for (b, c) in text.char_indices() {
1817 if c != ' ' && c != '\t' {
1818 if leading_end_byte == text.len() {
1819 leading_end_byte = b;
1820 }
1821 trailing_start_byte = b + c.len_utf8();
1822 }
1823 }
1824 if leading_end_byte == text.len() {
1825 // No non-space char on the line — treat every cell as
1826 // trailing.
1827 trailing_start_byte = 0;
1828 }
1829 }
1830 // Trailing-style fg (red) when emitting trailing-space markers.
1831 // Matches TUI's theme.whitespace_trailing_style.
1832 let trailing_fg = ct
1833 .resolved
1834 .get(ct.ids.whitespace_trailing)
1835 .fg
1836 .map(|c| c.to_rgb_u32(default_fg))
1837 .unwrap_or(default_fg);
1838
1839 let mut inlay_idx = 0usize;
1840 for (byte, ch) in text.char_indices() {
1841 // Splice every inlay whose `orig_byte` is at or before this
1842 // char position. Order-of-arrival ties at the same byte
1843 // resolve in input order.
1844 while inlay_idx < line_inlays.len() && (line_inlays[inlay_idx].0 as usize) <= byte {
1845 let (orig_byte, t, istyle) = line_inlays[inlay_idx];
1846 let char_width = t.chars().count() as u32;
1847 inlay_offsets.push((orig_byte, char_width));
1848 // DL.3a: the inlay's OWN style, resolved through the theme.
1849 // This was `inlay_fg` — one hardcoded DarkGray for every
1850 // inlay in the editor, past the registered `inlay.hint`
1851 // element.
1852 let (ifg, _) = resolve(istyle);
1853 for ic in t.chars() {
1854 cells.push(Cell::new(
1855 ic as u32,
1856 ifg,
1857 0,
1858 lattice_cells::cell_flags::INLAY,
1859 ));
1860 }
1861 inlay_idx += 1;
1862 }
1863 let style = style_at_byte(line_spans, byte);
1864 let (fg, mods) = resolve(style);
1865 // 2026-05-27 whitespace decoration. When `ws.show` is true
1866 // and the source char is whitespace, substitute the
1867 // configured marker glyph and set `WS_MARKER`. Falls
1868 // through to the verbatim emit when ws is off or no glyph
1869 // is configured for the position.
1870 let mut emitted = false;
1871 // W.4.t: a hard tab ALWAYS expands to its display width (the
1872 // next multiple of `tabstop`), so no literal `\t` ever
1873 // reaches the renderers and the cell grid models the tab at
1874 // full width — one width model the host scroll model and
1875 // both renderers share. Respects `display.whitespace.tab`:
1876 // when whitespace is shown and a tab glyph is configured the
1877 // first column is that marker (WS_MARKER) and the remaining
1878 // columns are space fill; otherwise the whole run is plain
1879 // spaces. `tabstop == 0` (the `WhitespaceConfig::default`)
1880 // falls back to `1`, i.e. the legacy one-cell tab.
1881 if ch == '\t' {
1882 let tabstop = ws.tabstop.max(1);
1883 let col = cells.len() as u32;
1884 let fill = tabstop - (col % tabstop); // 1..=tabstop
1885 let is_trailing = byte >= trailing_start_byte;
1886 let marker = ws.show && ws.tab.is_some();
1887 let cell_fg = if marker && is_trailing {
1888 trailing_fg
1889 } else {
1890 fg
1891 };
1892 let flags = if marker {
1893 mods | lattice_cells::cell_flags::WS_MARKER
1894 } else {
1895 mods
1896 };
1897 let first = if marker { ws.tab.unwrap_or(' ') } else { ' ' };
1898 cells.push(Cell::new(first as u32, cell_fg, 0, flags));
1899 for _ in 1..fill {
1900 cells.push(Cell::new(' ' as u32, cell_fg, 0, flags));
1901 }
1902 // Record the 1-source-byte → `fill`-cell expansion so
1903 // byte↔column mapping (`byte_to_combined_col`, used by
1904 // overlays + the GPUI cursor) shifts bytes AFTER the tab
1905 // by the `fill - 1` extra columns.
1906 if fill > 1 {
1907 inlay_offsets.push((byte as u32 + 1, fill - 1));
1908 }
1909 emitted = true;
1910 } else if ws.show {
1911 let is_trailing = byte >= trailing_start_byte;
1912 let is_leading = byte < leading_end_byte;
1913 if ch == ' ' {
1914 let glyph = if is_trailing {
1915 ws.trailing
1916 } else if is_leading {
1917 ws.leading.or(ws.space)
1918 } else {
1919 ws.space
1920 };
1921 if let Some(g) = glyph {
1922 let cell_fg = if is_trailing { trailing_fg } else { fg };
1923 cells.push(Cell::new(
1924 g as u32,
1925 cell_fg,
1926 0,
1927 mods | lattice_cells::cell_flags::WS_MARKER,
1928 ));
1929 emitted = true;
1930 }
1931 }
1932 }
1933 if !emitted {
1934 cells.push(Cell::new(ch as u32, fg, 0, mods));
1935 }
1936 }
1937 // Trailing inlays at/past EOL.
1938 while inlay_idx < line_inlays.len() {
1939 let (orig_byte, t, istyle) = line_inlays[inlay_idx];
1940 let char_width = t.chars().count() as u32;
1941 inlay_offsets.push((orig_byte, char_width));
1942 let (ifg, _) = resolve(istyle);
1943 for ic in t.chars() {
1944 cells.push(Cell::new(
1945 ic as u32,
1946 ifg,
1947 0,
1948 lattice_cells::cell_flags::INLAY,
1949 ));
1950 }
1951 inlay_idx += 1;
1952 }
1953
1954 (cells, inlay_offsets)
1955}
1956
1957/// B1 (2026-06-04): the canonical per-line builder for the
1958/// [`crate::display_matrix::DisplayMatrix`] — the substrate that
1959/// retires the per-character cell grid. Same display resolution as
1960/// [`build_row_cells`] (inlay splice, tab expansion to display width,
1961/// whitespace markers; fold elision is the caller's), but emits
1962/// style-*tagged* runs over a display string instead of theme-resolved
1963/// per-character cells. The renderer resolves each run's `style` +
1964/// `flags` → colour at paint (GPU shapes the string once; no un-bake).
1965///
1966/// Returns `(display_text, runs, col_map, col_count)`. `build_row_cells`
1967/// (the cell path) is deleted in B4; until then both exist and the
1968/// `display_build_parity_*` tests guard their equivalence so they can't
1969/// drift. `flags` carries only the non-style bits (`INLAY` / `WS_MARKER`)
1970/// — modifiers (bold/italic/…) are derived from `style` by the renderer,
1971/// exactly as the cell projection does.
1972// B2.2: live — the canonical per-row builder behind
1973// `build_display_rows` → `build_display_matrix` → `recompute_pane`.
1974/// DR.2: which refinement (if any) covers `byte`. First match wins,
1975/// matching the foreground axis's rule; refine spans do not overlap in
1976/// practice, and if a producer ever emits overlapping ones the earlier
1977/// simply claims the byte rather than the result being undefined.
1978fn refine_at_byte(
1979 refine: &[lattice_cells::RefineSpan],
1980 byte: usize,
1981) -> Option<lattice_cells::RefineKind> {
1982 refine
1983 .iter()
1984 .find(|r| byte >= r.start && byte < r.end)
1985 .map(|r| r.kind)
1986}
1987
1988fn build_display_row(
1989 text: &str,
1990 line_spans: &[lattice_syntax::StyledSpan],
1991 line_inlays: &[(u32, &str, lattice_syntax::Style)],
1992 ws: &WhitespaceConfig,
1993 // DR.2: byte ranges whose background differs from the row's. Empty
1994 // for every buffer that publishes none, which is all of them until
1995 // DR.3 — hence byte-identical output in that case.
1996 line_refine: &[lattice_cells::RefineSpan],
1997 // H.3: this line's compiled conceal rules. Empty for every language
1998 // that declares none — which is every language but org today — and
1999 // the emptiness is checked before any matching, so those buffers
2000 // pay nothing at all.
2001 conceal_rules: &[lattice_syntax::conceal::ConcealRule],
2002) -> (
2003 Box<str>,
2004 Vec<crate::display_matrix::DisplayRun>,
2005 Vec<(u32, u32)>,
2006 Vec<lattice_cells::ConcealRange>,
2007 u32,
2008) {
2009 use crate::display_matrix::DisplayRun;
2010 use lattice_cells::cell_flags;
2011
2012 /// Append `s` under one `(style, flags)`; merge with the last run.
2013 fn push(
2014 out: &mut String,
2015 runs: &mut Vec<DisplayRun>,
2016 s: &str,
2017 style: lattice_syntax::Style,
2018 flags: u16,
2019 ) {
2020 push_refined(out, runs, s, style, flags, None)
2021 }
2022
2023 /// DR.2: as `push`, with an explicit refinement. A run merges only
2024 /// when the refinement matches too, so a refine boundary splits
2025 /// runs exactly as a style change does — no new splitting concept,
2026 /// just one more field in the equality.
2027 fn push_refined(
2028 out: &mut String,
2029 runs: &mut Vec<DisplayRun>,
2030 s: &str,
2031 style: lattice_syntax::Style,
2032 flags: u16,
2033 refine: Option<lattice_cells::RefineKind>,
2034 ) {
2035 if s.is_empty() {
2036 return;
2037 }
2038 let len = s.len() as u32;
2039 out.push_str(s);
2040 match runs.last_mut() {
2041 Some(last) if last.style == style && last.flags == flags && last.refine == refine => {
2042 last.len += len
2043 }
2044 _ => runs.push(DisplayRun {
2045 len,
2046 style,
2047 flags,
2048 refine,
2049 }),
2050 }
2051 }
2052
2053 let inlay_total: usize = line_inlays.iter().map(|(_, t, _)| t.len()).sum();
2054 let mut out = String::with_capacity(text.len() + inlay_total);
2055 let mut runs: Vec<DisplayRun> = Vec::new();
2056 let mut col_map: Vec<(u32, u32)> = Vec::with_capacity(line_inlays.len());
2057 // Display columns emitted so far (== char count == cell count in the
2058 // old grid). Drives tab fill the same way `cells.len()` did.
2059 let mut col: u32 = 0;
2060 let mut tmp = [0u8; 4];
2061
2062 // Leading/trailing prescan for whitespace markers (mirrors
2063 // `build_row_cells`).
2064 let mut leading_end_byte = text.len();
2065 let mut trailing_start_byte = 0;
2066 if ws.show {
2067 for (b, c) in text.char_indices() {
2068 if c != ' ' && c != '\t' {
2069 if leading_end_byte == text.len() {
2070 leading_end_byte = b;
2071 }
2072 trailing_start_byte = b + c.len_utf8();
2073 }
2074 }
2075 if leading_end_byte == text.len() {
2076 trailing_start_byte = 0;
2077 }
2078 }
2079
2080 // H.3: byte ranges to elide, sorted and coalesced. `conceal_spans`
2081 // returns immediately on an empty rule list, so a Rust buffer never
2082 // reaches the matcher.
2083 let conceal_bytes = lattice_syntax::conceal::conceal_spans(conceal_rules, text);
2084 // OL.1: the styled remainder of each concealed match — an org link's
2085 // description, once its brackets and target are hidden. Returns
2086 // immediately unless some rule declared a style, so a language whose
2087 // rules only elide (and every language but org, which declares none)
2088 // pays one `any()` over a short list.
2089 let conceal_styles = lattice_syntax::conceal::conceal_style_spans(conceal_rules, text);
2090 // The stored `conceals` list is in COLUMN space, not byte space,
2091 // because that is the space `byte_to_combined_col` works in — its
2092 // baseline is `col = byte`, i.e. callers hand it an already
2093 // char-resolved position (the byte-vs-char note on
2094 // `CellRow::byte_to_combined_col`). Converting here, once, as the
2095 // walk crosses each boundary keeps both tables in one coordinate
2096 // system; storing byte ranges would be correct only for ASCII, and
2097 // wrong in exactly the buffers where it is hardest to notice.
2098 let mut conceal_cols: Vec<lattice_cells::ConcealRange> =
2099 Vec::with_capacity(conceal_bytes.len());
2100 let mut conceal_open: Option<u32> = None;
2101 let mut conceal_idx = 0usize;
2102 let mut char_idx: u32 = 0;
2103
2104 let mut inlay_idx = 0usize;
2105 for (byte, ch) in text.char_indices() {
2106 // Close a range this char has passed the end of, then open one
2107 // it has reached the start of. Recorded during the walk because
2108 // this is the only point the byte↔column correspondence is
2109 // known without a second pass over the line.
2110 while conceal_idx < conceal_bytes.len() && conceal_bytes[conceal_idx].1 as usize <= byte {
2111 if let Some(start_col) = conceal_open.take() {
2112 conceal_cols.push((start_col, char_idx));
2113 }
2114 conceal_idx += 1;
2115 }
2116 let concealed = conceal_idx < conceal_bytes.len()
2117 && byte >= conceal_bytes[conceal_idx].0 as usize
2118 && byte < conceal_bytes[conceal_idx].1 as usize;
2119 if concealed {
2120 if conceal_open.is_none() {
2121 conceal_open = Some(char_idx);
2122 }
2123 // Emitted nowhere and counted in no display column.
2124 // `char_idx` still advances: it indexes the SOURCE line,
2125 // which is what a conceal range has to be expressed in.
2126 char_idx += 1;
2127 continue;
2128 }
2129 while inlay_idx < line_inlays.len() && (line_inlays[inlay_idx].0 as usize) <= byte {
2130 let (orig_byte, t, istyle) = line_inlays[inlay_idx];
2131 col_map.push((orig_byte, t.chars().count() as u32));
2132 // DL.3a: the run carries the inlay's real style, so the
2133 // projection below can resolve it instead of forcing one
2134 // hardcoded colour on every inlay.
2135 push(&mut out, &mut runs, t, istyle, cell_flags::INLAY);
2136 col += t.chars().count() as u32;
2137 inlay_idx += 1;
2138 }
2139 // OL.1: a conceal rule's style WINS over the grammar's.
2140 //
2141 // It is the more specific claim — the rule matched a whole concrete
2142 // construct, where a grammar capture may be covering the line. That
2143 // matters most inside a headline, whose `@text.title.N` spans the
2144 // entire item: without the override a link there would be painted as
2145 // heading text and stay invisible as a link, which is exactly the
2146 // report. Emacs shows `org-link` in headlines too.
2147 let style = conceal_style_at_byte(&conceal_styles, byte)
2148 .unwrap_or_else(|| style_at_byte(line_spans, byte));
2149 let mut emitted = false;
2150 if ch == '\t' {
2151 let tabstop = ws.tabstop.max(1);
2152 let fill = tabstop - (col % tabstop);
2153 let marker = ws.show && ws.tab.is_some();
2154 // WS_TRAILING mirrors the cell path's `marker && is_trailing`
2155 // condition for trailing-fg resolution (build_row_cells).
2156 let is_trailing = byte >= trailing_start_byte;
2157 let flags = if marker {
2158 cell_flags::WS_MARKER
2159 | if is_trailing {
2160 cell_flags::WS_TRAILING
2161 } else {
2162 0
2163 }
2164 } else {
2165 0
2166 };
2167 let first = if marker { ws.tab.unwrap_or(' ') } else { ' ' };
2168 push(
2169 &mut out,
2170 &mut runs,
2171 first.encode_utf8(&mut tmp),
2172 style,
2173 flags,
2174 );
2175 for _ in 1..fill {
2176 push(&mut out, &mut runs, " ", style, flags);
2177 }
2178 if fill > 1 {
2179 col_map.push((byte as u32 + 1, fill - 1));
2180 }
2181 col += fill;
2182 emitted = true;
2183 } else if ws.show && ch == ' ' {
2184 let is_trailing = byte >= trailing_start_byte;
2185 let is_leading = byte < leading_end_byte;
2186 let glyph = if is_trailing {
2187 ws.trailing
2188 } else if is_leading {
2189 ws.leading.or(ws.space)
2190 } else {
2191 ws.space
2192 };
2193 if let Some(g) = glyph {
2194 // Space markers take trailing-fg iff trailing (the cell
2195 // path keys trailing-fg on `is_trailing` for spaces).
2196 let flags = cell_flags::WS_MARKER
2197 | if is_trailing {
2198 cell_flags::WS_TRAILING
2199 } else {
2200 0
2201 };
2202 push(&mut out, &mut runs, g.encode_utf8(&mut tmp), style, flags);
2203 col += 1;
2204 emitted = true;
2205 }
2206 }
2207 if !emitted {
2208 push_refined(
2209 &mut out,
2210 &mut runs,
2211 ch.encode_utf8(&mut tmp),
2212 style,
2213 0,
2214 refine_at_byte(line_refine, byte),
2215 );
2216 col += 1;
2217 }
2218 char_idx += 1;
2219 }
2220 // A range running to end-of-line never meets a char past its end,
2221 // so it is closed here rather than in the loop.
2222 if let Some(start_col) = conceal_open.take() {
2223 conceal_cols.push((start_col, char_idx));
2224 }
2225 while inlay_idx < line_inlays.len() {
2226 let (orig_byte, t, istyle) = line_inlays[inlay_idx];
2227 col_map.push((orig_byte, t.chars().count() as u32));
2228 push(&mut out, &mut runs, t, istyle, cell_flags::INLAY);
2229 col += t.chars().count() as u32;
2230 inlay_idx += 1;
2231 }
2232
2233 (out.into_boxed_str(), runs, col_map, conceal_cols, col)
2234}
2235
2236/// B1: per-chunk display-row build — the `DisplayLine` analogue of
2237/// [`build_chunk_rows`]. Folded interior lines are skipped (same
2238/// `line_inside_closed_fold` semantics); surviving rows keep their
2239/// logical `source_line`. Highlight spans are looked up relative to
2240/// `inputs.spans_base` exactly as the cell path does (H.1).
2241// B2.2: live — called by `build_display_matrix` /
2242// `try_incremental_display_build`.
2243fn build_display_rows(
2244 inputs: &ChunkInputs,
2245 start_line: u32,
2246 end_line: u32,
2247) -> Vec<crate::display_matrix::DisplayLine> {
2248 use crate::display_matrix::DisplayLine;
2249 // An inverted range is EMPTY, and must stay cheap. The incremental build
2250 // hands one over whenever an edit lands below the window a large file's
2251 // matrix covers — nothing in the window changed, so the zone to rebuild
2252 // ends (`affected_hi`, clamped to the window) before it starts
2253 // (`edit_lo`). The loop below already does nothing for it; a bare
2254 // `end_line - start_line` wrapped to ~4 billion and asked for 320 GB,
2255 // which aborts the editor on an edit far from the viewport.
2256 let mut rows: Vec<DisplayLine> =
2257 Vec::with_capacity(end_line.saturating_sub(start_line) as usize);
2258 for line_idx in start_line..end_line {
2259 if inputs.fold_index.line_inside_closed_fold(line_idx) {
2260 continue;
2261 }
2262 let text = inputs.snapshot.buffer.line(line_idx).unwrap_or_default();
2263 let line_spans: &[lattice_syntax::StyledSpan] = inputs
2264 .per_line_spans
2265 .and_then(|v| {
2266 let rel = line_idx.checked_sub(inputs.spans_base)?;
2267 v.get(rel as usize)
2268 })
2269 .map(Vec::as_slice)
2270 .unwrap_or(&[]);
2271 let line_inlays = inputs
2272 .inlays_by_line
2273 .get(&line_idx)
2274 .map(Vec::as_slice)
2275 .unwrap_or(&[]);
2276 let (text, runs, col_map, conceals, col_count) = build_display_row(
2277 &text,
2278 line_spans,
2279 line_inlays,
2280 inputs.whitespace,
2281 inputs
2282 .refine_by_line
2283 .get(line_idx as usize)
2284 .map(Vec::as_slice)
2285 .unwrap_or(&[]),
2286 // CL.1: this line's rules — empty for the revealed line, so it
2287 // shows its raw `[[id:…][…]]` while every other line stays
2288 // concealed. Scoped to a line rather than the buffer because the
2289 // reason to reveal is to edit what is under the cursor.
2290 if inputs.conceal_reveal_line == Some(line_idx) {
2291 &[]
2292 } else {
2293 // OA.7b: a multibuffer row's rules come from the EXCERPT it
2294 // belongs to, not from the pane — the pane has no one
2295 // language. Falls through to the pane's rules for every
2296 // ordinary buffer, where `excerpt_conceal` is empty.
2297 rules_for_row(inputs.excerpt_conceal, line_idx).unwrap_or(inputs.conceal_rules)
2298 },
2299 );
2300 rows.push(DisplayLine {
2301 source_line: line_idx,
2302 text: Arc::from(text),
2303 runs: Arc::from(runs.into_boxed_slice()),
2304 col_map: Arc::from(col_map.into_boxed_slice()),
2305 conceals: Arc::from(conceals.into_boxed_slice()),
2306 col_count,
2307 // Fold-head metadata is wired when the renderers consume
2308 // `DisplayMatrix` (B2); the cell path carried no fold info
2309 // on the row either (the renderer computes the fold suffix).
2310 fold: None,
2311 });
2312 }
2313 rows
2314}
2315
2316/// PU.1b-2a: merge per-buffer static `extra` spans into the grammar
2317/// `spans` (both indexed by source line; `spans` is the window relative
2318/// to `base`, `extra` is absolute), PREPENDING the extra spans so they
2319/// win [`style_at_byte`]'s first-match precedence (a help-link label
2320/// overrides whatever grammar scope sits under it). No-op when `extra`
2321/// is empty — every buffer without the `ExtraHighlights` local — so the
2322/// grammar spans pass through untouched and ordinary panes are
2323/// byte-identical.
2324fn merge_extra_spans(
2325 spans: &mut [Vec<lattice_syntax::StyledSpan>],
2326 extra: &[Vec<lattice_syntax::StyledSpan>],
2327 base: u32,
2328) {
2329 if extra.is_empty() {
2330 return;
2331 }
2332 for (i, line_spans) in spans.iter_mut().enumerate() {
2333 let src = base as usize + i;
2334 if let Some(ex) = extra.get(src)
2335 && !ex.is_empty()
2336 {
2337 let mut merged = Vec::with_capacity(ex.len() + line_spans.len());
2338 merged.extend_from_slice(ex);
2339 merged.append(line_spans);
2340 *line_spans = merged;
2341 }
2342 }
2343}
2344
2345/// PU.1b-2a: a cheap content fingerprint of the static extra-highlight
2346/// spans, folded into [`MatrixVersion`]'s `syntax` axis so a re-seed
2347/// (e.g. a help-content swap) invalidates the matrix cache even when no
2348/// other axis moved. Empty ⇒ `0` — a constant for every buffer without
2349/// the local, so it never perturbs their version.
2350pub(crate) fn extra_spans_version(spans: &[Vec<lattice_syntax::StyledSpan>]) -> u64 {
2351 let mut acc = 0u64;
2352 for (line, line_spans) in spans.iter().enumerate() {
2353 for s in line_spans {
2354 acc = acc
2355 .wrapping_mul(1099511628211) // FNV-1a prime
2356 .wrapping_add(line as u64)
2357 .wrapping_add((s.start as u64) << 20)
2358 .wrapping_add((s.end as u64) << 4)
2359 .wrapping_add(s.style.fingerprint());
2360 }
2361 }
2362 acc
2363}
2364
2365/// B2.2 (2026-06-04): build a [`crate::display_matrix::DisplayMatrix`]
2366/// — the `DisplayLine` analogue of [`build_matrix`]. Identical mode
2367/// selection ([`pick_chunk_size`]), windowing ([`window_bounds`]), and
2368/// highlight scoping (H.1/H.3); only the per-row payload differs
2369/// ([`build_display_rows`] instead of [`build_chunk_rows`]). This is the
2370/// *canonical* build once B2.2b flips `recompute_pane` over; the
2371/// `CellMatrix` is then a projection ([`display_matrix_to_cell_matrix`]).
2372#[allow(clippy::too_many_arguments)]
2373/// H.3: the buffer language's conceal rules, or an empty set.
2374///
2375/// Resolved once per matrix build. Three ways to get nothing, and all
2376/// three are ordinary rather than exceptional: a buffer with no syntax
2377/// handle (a synthetic view, a plain-text file), a registry that is not
2378/// live yet (the boot frame), and — for every language but org today —
2379/// a language that simply declares no rules.
2380///
2381/// The empty `Arc` costs one allocation-free clone, and
2382/// `conceal_spans` returns immediately on it, so a Rust buffer never
2383/// reaches the matcher at all.
2384/// OA.7b: one excerpt's composed row range and the conceal rules its grammar
2385/// declares.
2386pub(crate) struct ExcerptConceal {
2387 composed_start: u32,
2388 composed_end: u32,
2389 rules: std::sync::Arc<[lattice_syntax::conceal::ConcealRule]>,
2390}
2391
2392/// The rules covering `row`, or `None` when this is not a multibuffer (or the
2393/// row belongs to no excerpt, which a header's virtual row does).
2394fn rules_for_row(
2395 excerpts: &[ExcerptConceal],
2396 row: u32,
2397) -> Option<&[lattice_syntax::conceal::ConcealRule]> {
2398 excerpts
2399 .iter()
2400 .find(|e| row >= e.composed_start && row <= e.composed_end)
2401 .map(|e| e.rules.as_ref())
2402}
2403
2404/// Resolve conceal rules once per DISTINCT language in the view.
2405///
2406/// An agenda over one org corpus has one language and a thousand excerpts, so
2407/// resolving per excerpt would take the registry lock a thousand times for one
2408/// answer. Empty when nothing in the view declares rules — which is every
2409/// view but org's — so the per-row lookup above finds nothing and costs a
2410/// pointer compare.
2411fn excerpt_conceal_for(
2412 excerpt_syntax: &[crate::render_state::ExcerptSyntax],
2413) -> Vec<ExcerptConceal> {
2414 if excerpt_syntax.is_empty() {
2415 return Vec::new();
2416 }
2417 let Ok(reg) = lattice_syntax::registry::live() else {
2418 return Vec::new();
2419 };
2420 let mut by_lang: std::collections::HashMap<
2421 &'static str,
2422 std::sync::Arc<[lattice_syntax::conceal::ConcealRule]>,
2423 > = std::collections::HashMap::new();
2424 let mut out = Vec::new();
2425 for ex in excerpt_syntax {
2426 let Some(lang) = ex.lang else { continue };
2427 let rules = by_lang
2428 .entry(lang)
2429 .or_insert_with(|| reg.conceal_rules(lang))
2430 .clone();
2431 if rules.is_empty() {
2432 continue;
2433 }
2434 out.push(ExcerptConceal {
2435 composed_start: ex.composed_start,
2436 composed_end: ex.composed_end,
2437 rules,
2438 });
2439 }
2440 out
2441}
2442
2443fn conceal_rules_for(
2444 syntax_handle: Option<&lattice_syntax::SyntaxHandle>,
2445) -> std::sync::Arc<[lattice_syntax::conceal::ConcealRule]> {
2446 let empty = || std::sync::Arc::from([] as [lattice_syntax::conceal::ConcealRule; 0]);
2447 // CL.1: no reveal parameter. H.4 expressed "reveal" as an empty rule set
2448 // for the whole buffer, which made the version axis and the rows agree by
2449 // construction — but the unit was wrong: reveal belongs to ONE LINE, and a
2450 // buffer-wide empty set cannot say that. Suppression now happens per row
2451 // in `build_display_rows`, and the axis below no longer moves with it.
2452 let Some(h) = syntax_handle else {
2453 return empty();
2454 };
2455 match lattice_syntax::registry::live() {
2456 Ok(reg) => reg.conceal_rules(h.lang().name()),
2457 Err(_) => empty(),
2458 }
2459}
2460
2461/// H.3: the `conceal` axis value for a buffer with this syntax handle.
2462///
2463/// Zero when there are no rules, so the axis is a constant for every
2464/// language but org and cannot cost those buffers a rebuild.
2465/// CL.1: the RULES' version, and deliberately not the reveal line's.
2466///
2467/// The reveal line moves with the cursor. Folding it in here would invalidate
2468/// the matrix on every `j` — a 47 ns cache hit becoming a ~1.5 ms window
2469/// rebuild. The worker compares `DisplayMatrix::reveal_line` separately and
2470/// rebuilds the two rows that changed.
2471pub(crate) fn conceal_version_for(syntax_handle: Option<&lattice_syntax::SyntaxHandle>) -> u64 {
2472 lattice_syntax::conceal::rules_version(&conceal_rules_for(syntax_handle))
2473}
2474
2475fn build_display_matrix(
2476 snapshot: &lattice_runtime::DocumentSnapshot,
2477 syntax_handle: Option<&lattice_syntax::SyntaxHandle>,
2478 excerpt_syntax: &[crate::render_state::ExcerptSyntax],
2479 extra_spans: &[Vec<lattice_syntax::StyledSpan>],
2480 // DR.3: per-line intra-line refinement; empty for every buffer
2481 // that publishes none.
2482 extra_refine: &[Vec<lattice_cells::RefineSpan>],
2483 ct: CellTheme<'_>,
2484 inlay_hints: &[crate::render_state::InlayHintRow],
2485 folds: &[lattice_core::Fold],
2486 foldenable: bool,
2487 viewport_height: u32,
2488 scroll: u32,
2489 version: MatrixVersion,
2490 whitespace: &WhitespaceConfig,
2491 // H.4: Insert/Replace on THIS pane's buffer renders raw.
2492 // CL.1: the one line whose conceals are suppressed.
2493 reveal_line: Option<u32>,
2494) -> crate::display_matrix::DisplayMatrix {
2495 use crate::display_matrix::{DisplayChunk, DisplayMatrix};
2496 // CV.2: the row range, in **content** space. This is where the
2497 // phantom trailing row was born: ropey's raw `line_count()` counts
2498 // the empty line after a file's terminating `\n`, and building a
2499 // row for it paints a numbered blank line no other editor shows.
2500 // Also stamped as the matrix's `source_line_count`, so coverage
2501 // and the incremental guard stay in the same space as the rows.
2502 let line_count = snapshot.buffer.content_line_count();
2503 if line_count == 0 {
2504 return DisplayMatrix::empty();
2505 }
2506
2507 // `default_*` is required to construct `ChunkInputs`
2508 // (shared with the cell path) even though `build_display_rows` reads
2509 // only the snapshot / spans / inlays / folds / whitespace fields.
2510 let (default_fg, default_flags) = resolve_style(ct, lattice_syntax::Style::Default);
2511 let inlays_by_line = bucket_inlays_by_line(inlay_hints, line_count);
2512 let fold_index = crate::folds::FoldIndex::from_folds(folds, foldenable);
2513 // H.3: resolved ONCE per matrix build, not per line. Empty for a
2514 // buffer with no syntax handle, an unavailable registry, or — the
2515 // overwhelmingly common case — a language that declares no rules.
2516 let conceal_rules = conceal_rules_for(syntax_handle);
2517 // OA.7b: and the per-excerpt rules for a multibuffer. Empty for every
2518 // ordinary pane, so the row loop's lookup finds nothing.
2519 let excerpt_conceal = excerpt_conceal_for(excerpt_syntax);
2520
2521 let highlight_range = |lo: u32, hi: u32| -> Option<Vec<Vec<lattice_syntax::StyledSpan>>> {
2522 // K.4.7: multibuffer panes use per-excerpt handles.
2523 if let Some(mut spans) = highlight_range_multibuffer(excerpt_syntax, lo, hi) {
2524 merge_extra_spans(&mut spans, extra_spans, lo);
2525 return Some(spans);
2526 }
2527 if hi <= lo {
2528 return Some(Vec::new());
2529 }
2530 // PU.1b-2a: prepend the buffer's static extra spans onto the
2531 // grammar spans so they win `style_at_byte`'s first-match
2532 // precedence. No-op when `extra_spans` is empty (every buffer
2533 // without the local), so non-help panes are byte-identical.
2534 let grammar = syntax_handle.and_then(|h| {
2535 let snap = h.snapshot();
2536 if snap.text_version() < snapshot.text_version {
2537 return None;
2538 }
2539 snap.highlight_lines(lo, hi).ok()
2540 });
2541 match grammar {
2542 Some(mut spans) => {
2543 merge_extra_spans(&mut spans, extra_spans, lo);
2544 Some(spans)
2545 }
2546 // No (or stale) grammar but static extra spans exist (e.g. a
2547 // plain-text buffer carrying link styling): synthesise an
2548 // empty per-line window and merge the extra spans in, so the
2549 // static styling still reaches the matrix. Plain buffers with
2550 // no extra spans keep the historical `None` (all-default).
2551 None if !extra_spans.is_empty() => {
2552 let mut spans = vec![Vec::new(); (hi - lo) as usize];
2553 merge_extra_spans(&mut spans, extra_spans, lo);
2554 Some(spans)
2555 }
2556 None => None,
2557 }
2558 };
2559
2560 match pick_chunk_size(viewport_height, line_count) {
2561 ChunkMode::WholeDoc => {
2562 let spans = highlight_range(0, line_count);
2563 let inputs = ChunkInputs {
2564 snapshot,
2565 per_line_spans: spans.as_ref(),
2566 spans_base: 0,
2567 inlays_by_line: &inlays_by_line,
2568 fold_index: &fold_index,
2569 ct,
2570 default_fg,
2571 default_flags,
2572 whitespace,
2573 refine_by_line: extra_refine,
2574 conceal_rules: &conceal_rules,
2575 excerpt_conceal: &excerpt_conceal,
2576 conceal_reveal_line: reveal_line,
2577 };
2578 let rows = build_display_rows(&inputs, 0, line_count);
2579 let chunk = Arc::new(DisplayChunk::new(0, rows, version));
2580 DisplayMatrix::whole_doc(chunk, line_count)
2581 }
2582 ChunkMode::Chunked(chunk_size) => {
2583 // FW.1: sized by the fold-aware SPAN, not the row count.
2584 let (win_lo, win_hi) = window_bounds(
2585 scroll,
2586 fold_aware_span(&fold_index, scroll, viewport_height, line_count),
2587 line_count,
2588 chunk_size,
2589 );
2590 // FW.2: query per VISIBLE RUN, not once across the window.
2591 let spans = highlight_visible_runs(&highlight_range, &fold_index, win_lo, win_hi);
2592 let inputs = ChunkInputs {
2593 snapshot,
2594 per_line_spans: spans.as_ref(),
2595 spans_base: win_lo,
2596 inlays_by_line: &inlays_by_line,
2597 fold_index: &fold_index,
2598 ct,
2599 default_fg,
2600 default_flags,
2601 whitespace,
2602 refine_by_line: extra_refine,
2603 conceal_rules: &conceal_rules,
2604 excerpt_conceal: &excerpt_conceal,
2605 conceal_reveal_line: reveal_line,
2606 };
2607 let mut chunks: Vec<Arc<DisplayChunk>> =
2608 Vec::with_capacity(((win_hi - win_lo) / chunk_size + 1) as usize);
2609 let mut start = win_lo;
2610 while start < win_hi {
2611 let end = start.saturating_add(chunk_size).min(win_hi);
2612 let rows = build_display_rows(&inputs, start, end);
2613 chunks.push(Arc::new(DisplayChunk::new(start, rows, version)));
2614 start = end;
2615 }
2616 DisplayMatrix::chunked(chunks, chunk_size, line_count, version)
2617 }
2618 }
2619}
2620
2621/// CL.1: rebuild only the rows whose CONCEAL REVEAL state changed.
2622///
2623/// The cursor moved off one line and onto another; at most those two rows
2624/// differ, and every other row in the matrix is byte-identical. So they are
2625/// `Arc`-reused and two rows are rebuilt — which is what makes revealing the
2626/// cursor line affordable at cursor rate. Folding the reveal line into
2627/// `MatrixVersion` instead would rebuild the whole window on every `j`
2628/// (~1.5 ms against a 47 ns cache hit), and revealing the whole BUFFER — the
2629/// pre-CL.1 behaviour — avoided the cost only by doing the wrong thing.
2630///
2631/// `None` (fall through to a full build) whenever anything else moved: a
2632/// version difference means the rows would have had to be rebuilt anyway, and
2633/// this path's whole claim is that they did not.
2634fn try_incremental_reveal_build(
2635 published: &crate::display_matrix::DisplayMatrix,
2636 snapshot: &lattice_runtime::DocumentSnapshot,
2637 pane: &crate::render_state::PaneCellsInputs,
2638 whitespace: &WhitespaceConfig,
2639) -> Option<crate::display_matrix::DisplayMatrix> {
2640 use crate::display_matrix::{DisplayChunk, DisplayLine, DisplayMatrix};
2641 if published.chunks.is_empty() {
2642 return None;
2643 }
2644 // Only the reveal moved. Anything else and the caller's ordinary paths
2645 // are already correct — and cheaper than doing both.
2646 if pane.version.differs_from(&published.version) {
2647 return None;
2648 }
2649 if published.reveal_line == pane.conceal_reveal_line {
2650 return None;
2651 }
2652 // Nothing to rebuild if this buffer conceals nothing: the two rows would
2653 // come out byte-identical, and every org-less buffer takes this exit.
2654 let conceal_rules = conceal_rules_for(pane.syntax_handle.as_deref());
2655 if conceal_rules.is_empty() {
2656 // Still record the move, or the gate above re-fires every tick.
2657 let mut dm = published.clone();
2658 dm.reveal_line = pane.conceal_reveal_line;
2659 return Some(dm);
2660 }
2661
2662 let touched: Vec<u32> = [published.reveal_line, pane.conceal_reveal_line]
2663 .into_iter()
2664 .flatten()
2665 .collect();
2666 let mut chunks: Vec<Arc<DisplayChunk>> = Vec::with_capacity(published.chunks.len());
2667 for chunk in published.chunks.iter() {
2668 // Chunks holding none of the touched lines are shared, not copied —
2669 // the `Arc` clone IS the reuse.
2670 if !chunk.rows.iter().any(|r| touched.contains(&r.source_line)) {
2671 chunks.push(Arc::clone(chunk));
2672 continue;
2673 }
2674 let mut rows: Vec<DisplayLine> = chunk.rows.to_vec();
2675 for row in rows.iter_mut() {
2676 if !touched.contains(&row.source_line) {
2677 continue;
2678 }
2679 let line = row.source_line;
2680 let text = snapshot.buffer.line(line).unwrap_or_default();
2681 let spans = pane
2682 .syntax_handle
2683 .as_deref()
2684 .and_then(|h| {
2685 let snap = h.snapshot();
2686 (snap.text_version() >= snapshot.text_version)
2687 .then(|| snap.highlight_lines(line, line + 1).ok())
2688 .flatten()
2689 })
2690 .and_then(|v| v.into_iter().next())
2691 .unwrap_or_default();
2692 let (text, runs, col_map, conceals, col_count) = build_display_row(
2693 &text,
2694 &spans,
2695 &[],
2696 whitespace,
2697 &[],
2698 if pane.conceal_reveal_line == Some(line) {
2699 &[]
2700 } else {
2701 &conceal_rules
2702 },
2703 );
2704 *row = DisplayLine {
2705 source_line: line,
2706 text: text.into(),
2707 runs: Arc::from(runs.into_boxed_slice()),
2708 col_map: Arc::from(col_map.into_boxed_slice()),
2709 conceals: Arc::from(conceals.into_boxed_slice()),
2710 col_count,
2711 fold: row.fold,
2712 };
2713 }
2714 chunks.push(Arc::new(DisplayChunk::new(
2715 chunk.start_source_line,
2716 rows,
2717 chunk.version,
2718 )));
2719 }
2720
2721 let mut dm = if published.is_whole_doc() {
2722 DisplayMatrix::whole_doc(chunks.remove(0), published.source_line_count)
2723 } else {
2724 DisplayMatrix::chunked(
2725 chunks,
2726 published.chunk_size,
2727 published.source_line_count,
2728 published.version,
2729 )
2730 };
2731 dm.wrap_width = published.wrap_width;
2732 dm.reveal_line = pane.conceal_reveal_line;
2733 Some(dm)
2734}
2735
2736/// B2.2 (2026-06-04): incremental `DisplayMatrix` rebuild — the
2737/// `DisplayLine` analogue of [`try_incremental_build`]. Same eligibility
2738/// gates (single edit, only text/syntax axes differ, line-count
2739/// consistency, unchanged chunked-mode shape) and the same
2740/// prefix-reuse / rebuild-zone / suffix-shift partition; only the
2741/// payload + matrix/chunk types differ. Unchanged `DisplayLine`s are
2742/// `Arc`-reused byte-identical (pixel-stable; only the edited line
2743/// recolours), exactly as the cell path does.
2744///
2745/// B2.3 (2026-06-04): `allow_highlight` gates whether the rebuild zone
2746/// is syntax-highlighted. The async worker passes `true` (full colour).
2747/// The synchronous actor path ([`sync_rebuild_pane_on_edit`]) passes
2748/// `false` so the rebuild does ZERO `highlight_lines` work on the
2749/// edit-critical thread — the B2 threading guarantee, enforced rather
2750/// than relying on the syntax snapshot happening to be stale. The
2751/// edited line keeps default fg until the async worker recolours it a
2752/// frame or two later (eventual consistency, within the keystroke UX
2753/// contract); unchanged lines `Arc`-reuse their prior colour.
2754fn try_incremental_display_build(
2755 published: &crate::display_matrix::DisplayMatrix,
2756 snapshot: &lattice_runtime::DocumentSnapshot,
2757 pane: &crate::render_state::PaneCellsInputs,
2758 ct: CellTheme<'_>,
2759 whitespace: &WhitespaceConfig,
2760 allow_highlight: bool,
2761) -> Option<crate::display_matrix::DisplayMatrix> {
2762 use crate::display_matrix::{DisplayChunk, DisplayLine, DisplayMatrix};
2763 // H.3: the incremental path rebuilds only the edited zone, but the
2764 // rows it rebuilds must conceal exactly as a full build would — a
2765 // link that renders raw only on the line you just touched would be
2766 // the most visible possible version of this bug.
2767 let conceal_rules = conceal_rules_for(pane.syntax_handle.as_deref());
2768 // OA.7b: and per excerpt, for the same reason as the comment above — a
2769 // multibuffer row concealing only until you touch it would be worse than
2770 // not concealing at all.
2771 let excerpt_conceal = excerpt_conceal_for(&pane.excerpt_syntax);
2772 let edit = pane.last_edit?;
2773 if published.chunks.is_empty() {
2774 return None;
2775 }
2776
2777 let new_version = pane.version;
2778 let pub_v = published.version;
2779 if new_version.inlay_hints != pub_v.inlay_hints
2780 || new_version.folds != pub_v.folds
2781 || new_version.theme != pub_v.theme
2782 {
2783 return None;
2784 }
2785 if new_version.text == pub_v.text && new_version.syntax == pub_v.syntax {
2786 return None;
2787 }
2788
2789 let new_line_count = snapshot.buffer.content_line_count();
2790 let pre_count = published.source_line_count as i64;
2791 let expected_new = pre_count + edit.net_delta() as i64;
2792 if expected_new < 0 || expected_new as u32 != new_line_count {
2793 return None;
2794 }
2795
2796 let new_mode = pick_chunk_size(pane.viewport_height, new_line_count);
2797 let new_chunk_size = match new_mode {
2798 ChunkMode::WholeDoc => CHUNK_SIZE_WHOLE_DOC,
2799 ChunkMode::Chunked(n) => n,
2800 };
2801 if new_chunk_size != published.chunk_size {
2802 return None;
2803 }
2804
2805 let edit_lo = edit.start_line;
2806 // B2.3 intra-line staleness fix (2026-06-05) + open-line dup fix
2807 // (2026-07-03) + boundary-mid-line-replace fix (2026-09-06): the
2808 // row-reuse partition treats lines `>= suffix_lo` as the unchanged
2809 // SUFFIX (reused, shifted by `net`). But `EditDelta` counts only FULL
2810 // lines added/removed, so raw `pre_edit_end_line()` cannot by itself
2811 // tell three shapes apart:
2812 //
2813 // - **Intra-line insert** (the COMMON typing case, `removed == added
2814 // == 0`): `pre_edit_end_line() == start_line`, which would
2815 // reuse-and-shift the START line itself. The edited line is reused
2816 // stale while the matrix version is stamped current, so the typed
2817 // glyph lags the cursor by a frame (`|word` → `w|ord` → ` |word`;
2818 // felt as "one key behind").
2819 // - **Line-opening / mid-line-split insert** (`removed == 0, added >
2820 // 0`, e.g. vim `o`/`O`): `\n` inserted at EOL leaves the start
2821 // line's content unchanged but creates a new blank line after it.
2822 // Reusing the start row and shifting it into the new line's slot
2823 // DUPLICATES the start line's content (the "`o` duplicates the
2824 // line" bug).
2825 // - **Boundary line partially or wholly replaced** (`removed > 0`
2826 // and the edit's OLD range ends partway into its last affected
2827 // line rather than at BOL of the line after —
2828 // `old_end_at_bol == false`): `pre_edit_end_line()` lands ON the
2829 // line the edit's old range actually ends inside, so that line's
2830 // content changed and is NOT a safe suffix start.
2831 // Table-mode's `rewrite()` and org's `replace_lines` both build
2832 // edits shaped exactly this way on EVERY invocation (range end =
2833 // EOL of the last affected line, never BOL of the line after) —
2834 // this was previously dismissed here as "rarer, self-healing"; it
2835 // is neither. It reproduces on every table `<Tab>` align, and the
2836 // reused row is stamped with the CURRENT `MatrixVersion`, so
2837 // nothing revisits it without a full (`<C-l>`-forced) rebuild.
2838 //
2839 // A pure insert always modifies or splits the start line and/or
2840 // creates new lines at/after it, so the start line MUST be rebuilt —
2841 // the reusable suffix starts one past `start_line`, covering
2842 // mid-line splits too regardless of insert column. A partially- or
2843 // wholly-replaced boundary line MUST also be rebuilt, for the same
2844 // reason. Only a genuinely clean line-boundary edit (`removed > 0`
2845 // and `old_end_at_bol == true`, e.g. `dd`-style whole-line deletion
2846 // including trailing newlines) leaves `pre_edit_end_line()` itself
2847 // untouched and safe to shift wholesale (preserving its syntax
2848 // colour — extending the boundary needlessly would recolour it, a
2849 // flicker the async worker would have to repaint —
2850 // feedback_decorations_update_in_place).
2851 //
2852 // `EditDelta::suffix_start_line()` centralises this three-way
2853 // decision; see its doc comment for the exact boundary math.
2854 let suffix_lo = edit.suffix_start_line();
2855 let net = edit.net_delta();
2856
2857 let (default_fg, default_flags) = resolve_style(ct, lattice_syntax::Style::Default);
2858 let inlays_by_line = bucket_inlays_by_line(&pane.inlay_hints, new_line_count);
2859 let fold_index = crate::folds::FoldIndex::from_folds(&pane.folds, pane.foldenable);
2860 let highlight_range = |lo: u32, hi: u32| -> Option<Vec<Vec<lattice_syntax::StyledSpan>>> {
2861 // B2.3: sync edit path forces highlight off — no `highlight_lines`
2862 // call ever lands on the edit-critical actor thread.
2863 if !allow_highlight {
2864 return None;
2865 }
2866 // K.4.7: multibuffer panes use per-excerpt handles.
2867 if let Some(spans) = highlight_range_multibuffer(&pane.excerpt_syntax, lo, hi) {
2868 return Some(spans);
2869 }
2870 if hi <= lo {
2871 return Some(Vec::new());
2872 }
2873 pane.syntax_handle.as_deref().and_then(|h| {
2874 let snap = h.snapshot();
2875 if snap.text_version() < snapshot.text_version {
2876 return None;
2877 }
2878 snap.highlight_lines(lo, hi).ok()
2879 })
2880 };
2881
2882 if new_chunk_size == CHUNK_SIZE_WHOLE_DOC {
2883 let Some(prior) = published.chunks.first() else {
2884 let spans = highlight_range(0, new_line_count);
2885 let inputs = ChunkInputs {
2886 snapshot,
2887 per_line_spans: spans.as_ref(),
2888 spans_base: 0,
2889 inlays_by_line: &inlays_by_line,
2890 fold_index: &fold_index,
2891 ct,
2892 default_fg,
2893 default_flags,
2894 whitespace,
2895 refine_by_line: &[],
2896 conceal_rules: &conceal_rules,
2897 excerpt_conceal: &excerpt_conceal,
2898 conceal_reveal_line: pane.conceal_reveal_line,
2899 };
2900 let rows = build_display_rows(&inputs, 0, new_line_count);
2901 let chunk = Arc::new(DisplayChunk::new(0, rows, new_version));
2902 return Some(DisplayMatrix::whole_doc(chunk, new_line_count));
2903 };
2904 let prior_rows = &prior.rows;
2905 let affected_hi = prior_rows
2906 .iter()
2907 .map(|r| r.source_line)
2908 .find(|&l| l >= suffix_lo)
2909 .map(|l| (l as i64 + net as i64).max(edit_lo as i64) as u32)
2910 .unwrap_or(new_line_count)
2911 .min(new_line_count);
2912 let mut rows: Vec<DisplayLine> = Vec::with_capacity(prior_rows.len() + 2);
2913 rows.extend(
2914 prior_rows
2915 .iter()
2916 .filter(|r| r.source_line < edit_lo)
2917 .cloned(),
2918 );
2919 let spans = highlight_range(edit_lo, affected_hi);
2920 let inputs = ChunkInputs {
2921 snapshot,
2922 per_line_spans: spans.as_ref(),
2923 spans_base: edit_lo,
2924 inlays_by_line: &inlays_by_line,
2925 fold_index: &fold_index,
2926 ct,
2927 default_fg,
2928 default_flags,
2929 whitespace,
2930 refine_by_line: &[],
2931 conceal_rules: &conceal_rules,
2932 excerpt_conceal: &excerpt_conceal,
2933 // CL.1: a sticky-context header is never the cursor line.
2934 conceal_reveal_line: None,
2935 };
2936 rows.extend(build_display_rows(&inputs, edit_lo, affected_hi));
2937 rows.extend(
2938 prior_rows
2939 .iter()
2940 .filter(|r| r.source_line >= suffix_lo)
2941 .map(|r| r.with_source_line((r.source_line as i64 + net as i64).max(0) as u32)),
2942 );
2943 let chunk = Arc::new(DisplayChunk::new(0, rows, new_version));
2944 return Some(DisplayMatrix::whole_doc(chunk, new_line_count));
2945 }
2946
2947 let chunk_size = new_chunk_size;
2948 let mut new_chunks: Vec<Arc<DisplayChunk>> = Vec::with_capacity(published.chunks.len() + 2);
2949
2950 // Step 1: prefix-reuse.
2951 let mut rebuild_lo: u32 = 0;
2952 for chunk in published.chunks.iter() {
2953 let chunk_end = chunk.start_source_line.saturating_add(published.chunk_size);
2954 if chunk_end <= edit_lo {
2955 new_chunks.push(Arc::clone(chunk));
2956 rebuild_lo = chunk_end;
2957 } else {
2958 break;
2959 }
2960 }
2961
2962 // Step 2: suffix-shift.
2963 let mut suffix_chunks: Vec<Arc<DisplayChunk>> = Vec::new();
2964 for chunk in published.chunks.iter() {
2965 if chunk.start_source_line >= suffix_lo {
2966 suffix_chunks.push(Arc::new(chunk.shifted_by(net, new_version)));
2967 }
2968 }
2969 // H.3 windowing (2026-06-04, found by the B2.3 `display_edit_path`
2970 // bench): when no suffix chunk remains — the edit fell in the LAST
2971 // covered chunk of a *windowed* large-file matrix — the rebuild zone
2972 // must stop at the published window's covered end (shifted by `net`),
2973 // NOT `new_line_count`. The full build is windowed to O(viewport)
2974 // (H.3); without this bound the incremental rebuild instead
2975 // materialised every row from the edit to EOF — O(file) — so typing
2976 // near the top of a 100k-line file cost ~57ms per keystroke, blowing
2977 // the sync edit path's O(window) guarantee. The matrix preserves its
2978 // windowed coverage here; a scroll past the window then fails the
2979 // `covers` gate in `recompute_pane` and triggers a recentred (windowed)
2980 // full rebuild.
2981 let rebuild_hi = suffix_chunks
2982 .first()
2983 .map(|c| c.start_source_line)
2984 .unwrap_or_else(|| {
2985 ((published.covered_end_line() as i64 + net as i64).max(rebuild_lo as i64) as u32)
2986 .min(new_line_count)
2987 });
2988
2989 // Step 3: rebuild zone — ROW-LEVEL reuse (mirrors the whole-doc
2990 // branch above). Only the actually-edited line range
2991 // `[edit_lo, affected_hi)` is rebuilt; every unchanged line WITHIN the
2992 // zone `Arc`-reuses its prior `DisplayLine` (and thus its syntax
2993 // colour). Load-bearing for the keystroke UX contract: the sync edit
2994 // path ([`sync_rebuild_pane_on_edit`]) runs with `allow_highlight:
2995 // false`, so the prior *wholesale* rebuild of the zone blanked
2996 // ~`chunk_size` lines of colour on EVERY keystroke — a whole-viewport
2997 // syntax-highlight flicker on any chunked-mode file (e.g. README at 500
2998 // lines: the viewport sits inside one 64-line chunk, so the entire
2999 // screen lost colour per char, recoloured a frame later by the async
3000 // worker). Reusing prior rows confines the transient colour loss to the
3001 // single edited line. Whole-doc mode already did this; chunked mode
3002 // rebuilt the whole zone — that asymmetry WAS the flicker bug.
3003 // (feedback_decorations_update_in_place)
3004 //
3005 // Prior rows for the zone come from the STRADDLING chunks: those
3006 // neither fully prefix-reused (`chunk_end <= edit_lo`) nor fully
3007 // suffix-shifted (`chunk_start >= suffix_lo`).
3008 let zone_prior_rows: Vec<DisplayLine> = published
3009 .chunks
3010 .iter()
3011 .filter(|c| {
3012 let start = c.start_source_line;
3013 let end = start.saturating_add(published.chunk_size);
3014 end > edit_lo && start < suffix_lo
3015 })
3016 .flat_map(|c| c.rows.iter().cloned())
3017 .collect();
3018
3019 // First unchanged line at/after the edit, mapped into post-edit space
3020 // (mirrors the whole-doc `affected_hi`); bounded by the zone end so a
3021 // suffix row never collides with the suffix-shifted chunks.
3022 let affected_hi = zone_prior_rows
3023 .iter()
3024 .map(|r| r.source_line)
3025 .find(|&l| l >= suffix_lo)
3026 .map(|l| (l as i64 + net as i64).max(edit_lo as i64) as u32)
3027 .unwrap_or(rebuild_hi)
3028 .min(rebuild_hi);
3029
3030 let spans = highlight_range(edit_lo, affected_hi);
3031 let inputs = ChunkInputs {
3032 snapshot,
3033 conceal_reveal_line: pane.conceal_reveal_line,
3034 per_line_spans: spans.as_ref(),
3035 spans_base: edit_lo,
3036 inlays_by_line: &inlays_by_line,
3037 fold_index: &fold_index,
3038 ct,
3039 default_fg,
3040 default_flags,
3041 whitespace,
3042 refine_by_line: &[],
3043 conceal_rules: &conceal_rules,
3044 excerpt_conceal: &excerpt_conceal,
3045 };
3046
3047 // Assemble the zone rows in source-line order:
3048 // prefix-reuse (source_line < edit_lo, unshifted)
3049 // ++ rebuilt edit range [edit_lo, affected_hi)
3050 // ++ suffix-reuse (source_line >= suffix_lo, shifted by net).
3051 let mut zone_rows: Vec<DisplayLine> = Vec::with_capacity(zone_prior_rows.len() + 2);
3052 zone_rows.extend(
3053 zone_prior_rows
3054 .iter()
3055 .filter(|r| r.source_line < edit_lo)
3056 .cloned(),
3057 );
3058 zone_rows.extend(build_display_rows(&inputs, edit_lo, affected_hi));
3059 zone_rows.extend(
3060 zone_prior_rows
3061 .iter()
3062 .filter(|r| r.source_line >= suffix_lo)
3063 .map(|r| r.with_source_line((r.source_line as i64 + net as i64).max(0) as u32)),
3064 );
3065
3066 // Re-bucket into `chunk_size`-aligned chunks — identical chunk starts
3067 // to the prior wholesale loop (`cur` stepping by `chunk_size`), so the
3068 // chunked-matrix shape and the `chunk_end = start + chunk_size`
3069 // prefix/suffix detection on the NEXT edit are preserved; only the ROWS
3070 // differ (reused vs rebuilt).
3071 let mut cur = rebuild_lo;
3072 let mut idx = 0usize;
3073 while cur < rebuild_hi {
3074 let end = cur.saturating_add(chunk_size).min(rebuild_hi);
3075 let mut bucket: Vec<DisplayLine> = Vec::new();
3076 while idx < zone_rows.len() && zone_rows[idx].source_line < end {
3077 bucket.push(zone_rows[idx].clone());
3078 idx += 1;
3079 }
3080 new_chunks.push(Arc::new(DisplayChunk::new(cur, bucket, new_version)));
3081 cur = end;
3082 }
3083
3084 new_chunks.extend(suffix_chunks);
3085
3086 Some(DisplayMatrix::chunked(
3087 new_chunks,
3088 chunk_size,
3089 new_line_count,
3090 new_version,
3091 ))
3092}
3093
3094/// B2.2 (2026-06-04): project one [`crate::display_matrix::DisplayLine`]
3095/// to a [`CellRow`] — the temporary bridge that keeps the not-yet-cut-
3096/// over renderers (GPU until B3) painting off the cell grid while the
3097/// `DisplayMatrix` is canonical. Reproduces [`build_row_cells`]
3098/// byte-for-byte: per run, resolve `style → (fg, mods)` (inlay runs
3099/// take their own resolved fg + `INLAY`; trailing markers take the theme's
3100/// trailing-whitespace fg via the `WS_TRAILING` provenance bit); emit
3101/// one cell per char. `col_map` IS the cell path's `inlay_offsets`
3102/// (the B1 parity test pins this), so it transfers verbatim. The
3103/// `WS_TRAILING` bit is DisplayRun-only provenance — it is stripped
3104/// from the projected cell's flags so the result matches the cell
3105/// path, which bakes trailing-fg into `fg` instead. Deleted with the
3106/// cell path in B4.
3107fn display_line_to_cell_row(
3108 line: &crate::display_matrix::DisplayLine,
3109 ct: CellTheme<'_>,
3110 default_fg: u32,
3111 default_flags: u16,
3112) -> CellRow {
3113 use lattice_cells::cell_flags;
3114 let trailing_fg = ct
3115 .resolved
3116 .get(ct.ids.whitespace_trailing)
3117 .fg
3118 .map(|c| c.to_rgb_u32(default_fg))
3119 .unwrap_or(default_fg);
3120 let mut cells: Vec<Cell> = Vec::with_capacity(line.col_count as usize);
3121 let mut byte_off = 0usize;
3122 for run in line.runs.iter() {
3123 let run_len = run.len as usize;
3124 let slice = &line.text[byte_off..byte_off + run_len];
3125 byte_off += run_len;
3126 let is_inlay = run.flags & cell_flags::INLAY != 0;
3127 let is_ws_marker = run.flags & cell_flags::WS_MARKER != 0;
3128 let is_trailing = run.flags & cell_flags::WS_TRAILING != 0;
3129 let (style_fg, mods) = if matches!(run.style, lattice_syntax::Style::Default) {
3130 (default_fg, default_flags)
3131 } else {
3132 resolve_style(ct, run.style)
3133 };
3134 let (fg, flags) = if is_inlay {
3135 // DL.3a: `style_fg` here is the inlay's own resolved
3136 // colour; it used to be a hardcoded `inlay_fg`.
3137 (style_fg, cell_flags::INLAY)
3138 } else if is_ws_marker {
3139 let f = if is_trailing { trailing_fg } else { style_fg };
3140 (f, mods | cell_flags::WS_MARKER)
3141 } else {
3142 (style_fg, mods)
3143 };
3144 for ch in slice.chars() {
3145 cells.push(Cell::new(ch as u32, fg, 0, flags));
3146 }
3147 }
3148 CellRow::new(cells, line.source_line, line.col_map.to_vec())
3149}
3150
3151/// B2.2 (2026-06-04): project a whole [`crate::display_matrix::DisplayMatrix`]
3152/// to a [`CellMatrix`], preserving chunk structure, per-chunk versions,
3153/// mode (whole-doc vs chunked), and `wrap_width`. The bridge feeding the
3154/// cell renderers until their cutover (B2.4 TUI, B3 GPU); deleted in B4.
3155fn display_matrix_to_cell_matrix(
3156 dm: &crate::display_matrix::DisplayMatrix,
3157 ct: CellTheme<'_>,
3158) -> CellMatrix {
3159 let (default_fg, default_flags) = resolve_style(ct, lattice_syntax::Style::Default);
3160 if dm.chunks.is_empty() {
3161 return CellMatrix::empty();
3162 }
3163 let chunks: Vec<Arc<CellChunk>> = dm
3164 .chunks
3165 .iter()
3166 .map(|dc| {
3167 let rows: Vec<CellRow> = dc
3168 .rows
3169 .iter()
3170 .map(|dl| display_line_to_cell_row(dl, ct, default_fg, default_flags))
3171 .collect();
3172 Arc::new(CellChunk::new(dc.start_source_line, rows, dc.version))
3173 })
3174 .collect();
3175 let mut cm = if dm.is_whole_doc() {
3176 // Whole-doc mode is exactly one chunk (guaranteed non-empty here).
3177 CellMatrix::whole_doc(chunks.into_iter().next().unwrap(), dm.source_line_count)
3178 } else {
3179 CellMatrix::chunked(chunks, dm.chunk_size, dm.source_line_count, dm.version)
3180 };
3181 cm.wrap_width = dm.wrap_width;
3182 cm
3183}
3184
3185/// Bucket a flat inlay-hints list by line into per-line slices of
3186/// `(orig_byte, text)`, each bucket sorted ascending by `orig_byte`.
3187/// Output length is `line_count` so callers can index by line
3188/// without bounds-checking. Hints whose `line` is past `line_count`
3189/// are dropped — out-of-range payloads do not feed the build.
3190fn bucket_inlays_by_line<'a>(
3191 inlay_hints: &'a [crate::render_state::InlayHintRow],
3192 line_count: u32,
3193) -> std::collections::HashMap<u32, Vec<(u32, &'a str, lattice_syntax::Style)>> {
3194 // H.3 (2026-06-04): keyed by absolute source line rather than a
3195 // dense `Vec<Vec<_>>` of length `line_count`. The dense form was
3196 // O(file) — it allocated and zeroed one slot per *document* line
3197 // even when the build only touches a viewport window (and even
3198 // when there are no inlays at all), which the
3199 // `cells_worker_windowed_build` bench exposed as build cost
3200 // scaling with `line_count`. A `HashMap` is O(inlays): empty when
3201 // inlay mode is off, and base-agnostic so the whole-doc, windowed,
3202 // and incremental builds all share one lookup without threading a
3203 // window offset.
3204 let mut buckets: std::collections::HashMap<u32, Vec<(u32, &'a str, lattice_syntax::Style)>> =
3205 std::collections::HashMap::new();
3206 for h in inlay_hints {
3207 if h.line < line_count {
3208 buckets
3209 .entry(h.line)
3210 .or_default()
3211 .push((h.byte, h.text.as_str(), h.style));
3212 }
3213 }
3214 for b in buckets.values_mut() {
3215 b.sort_by_key(|(off, _, _)| *off);
3216 }
3217 buckets
3218}
3219
3220/// Resolve a syntax style to its `0xRRGGBB` foreground colour via
3221/// the host theme. `Style::Default` and styles whose theme entry
3222/// has no explicit fg return `0` — the renderer maps that to "use
3223/// the pane's default text colour" at paint time.
3224///
3225/// S3.a (2026-05-26): kept under `#[cfg(test)]` after the worker
3226/// switched to [`resolve_style`] (returns `(fg, flags)` together).
3227/// Tests that only assert fg keep the simpler one-value helper.
3228#[cfg(test)]
3229fn resolve_fg(ct: CellTheme<'_>, style: lattice_syntax::Style) -> u32 {
3230 resolve_style(ct, style).0
3231}
3232
3233/// T.5 (theme-system): the resolved theme read table + builtin ids the
3234/// cell builder threads in place of the old `&Theme`. `Copy` (two
3235/// refs), so it passes through the build chain for free. The builder
3236/// uses it only for syntax-category + whitespace-marker styling.
3237#[derive(Clone, Copy)]
3238pub struct CellTheme<'a> {
3239 pub resolved: &'a crate::ui::theme::ResolvedTheme,
3240 pub ids: &'a crate::ui::theme::BuiltinElementIds,
3241}
3242
3243/// S3.a / T.5: resolve a syntax style to `(fg, flags)` via the shared
3244/// host `resolve_syntax_style` (resolved table). The returned `flags`
3245/// is the OR of `Cell::flags` modifier bits ([`flags::BOLD`] etc.)
3246/// matching the resolved style's [`crate::ui::theme::Modifiers`].
3247/// Splice-flags like `INLAY` / `WS_MARKER` are NOT set here — callers
3248/// OR those in separately when emitting an inlay or whitespace cell.
3249fn resolve_style(ct: CellTheme<'_>, style: lattice_syntax::Style) -> (u32, u16) {
3250 let s = crate::ui::theme::resolve_syntax_style(ct.resolved, ct.ids, style);
3251 let fg = s.fg.map(|c| c.to_rgb_u32(0)).unwrap_or(0);
3252 let flags = modifiers_to_flags(&s.modifiers);
3253 (fg, flags)
3254}
3255
3256/// S3.a: pack the host theme's [`crate::ui::theme::Modifiers`]
3257/// (bold / italic / underline / dim / reverse) into the
3258/// `Cell::flags` bit layout declared in
3259/// [`lattice_cells::cell_flags`]. Keeps the
3260/// `host::Theme → Cell` mapping centralised so adding a new
3261/// modifier is a one-line change here + a one-line flag-bit
3262/// declaration in `lattice-cells`.
3263fn modifiers_to_flags(m: &crate::ui::theme::Modifiers) -> u16 {
3264 use lattice_cells::cell_flags;
3265 let mut f: u16 = 0;
3266 if m.bold {
3267 f |= cell_flags::BOLD;
3268 }
3269 if m.italic {
3270 f |= cell_flags::ITALIC;
3271 }
3272 if m.underline {
3273 f |= cell_flags::UNDERLINE;
3274 }
3275 if m.dim {
3276 f |= cell_flags::DIM;
3277 }
3278 if m.reverse {
3279 f |= cell_flags::REVERSE;
3280 }
3281 f
3282}
3283
3284/// Resolve the highlight style at a given utf-8 byte offset inside
3285/// `line_spans`. Mirrors the historical `style_at_byte` contract
3286/// (originally in the deleted `highlights_worker`) — bytes outside
3287/// every span fall through to `Style::Default`.
3288/// OL.1: the conceal-declared style covering `byte`, if any.
3289///
3290/// Linear over a list that holds one entry per link on the line — a handful
3291/// at most, and empty for every line without one. Not a binary search
3292/// deliberately: the caller walks bytes in order and the list is short enough
3293/// that a cursor would cost more in complexity than it saves in comparisons.
3294fn conceal_style_at_byte(
3295 spans: &[(u32, u32, lattice_syntax::Style)],
3296 byte: usize,
3297) -> Option<lattice_syntax::Style> {
3298 if spans.is_empty() {
3299 return None;
3300 }
3301 let b = byte as u32;
3302 spans
3303 .iter()
3304 .find(|(s, e, _)| b >= *s && b < *e)
3305 .map(|(_, _, st)| *st)
3306}
3307
3308fn style_at_byte(line_spans: &[lattice_syntax::StyledSpan], byte: usize) -> lattice_syntax::Style {
3309 for s in line_spans {
3310 if byte >= s.start && byte < s.end {
3311 return s.style;
3312 }
3313 }
3314 lattice_syntax::Style::Default
3315}
3316
3317#[cfg(test)]
3318mod tests {
3319 // ── DR.2: intra-line refinement rides DisplayRun ─────────────────
3320
3321 #[test]
3322 fn no_refine_spans_leaves_every_run_unrefined() {
3323 let (_t, runs, _cm, _c, _cc) = build_display_row(
3324 "let x = 1;",
3325 &[],
3326 &[],
3327 &WhitespaceConfig::default(),
3328 &[],
3329 &[],
3330 );
3331 assert!(
3332 runs.iter().all(|r| r.refine.is_none()),
3333 "a buffer publishing no refinement must be byte-identical to before DR.2"
3334 );
3335 }
3336
3337 #[test]
3338 fn a_refine_span_splits_runs_and_marks_only_its_bytes() {
3339 use lattice_cells::{RefineKind, RefineSpan};
3340 // Refine "x" only (byte 4..5) in `let x = 1;`.
3341 let (_t, runs, _cm, _c, _cc) = build_display_row(
3342 "let x = 1;",
3343 &[],
3344 &[],
3345 &WhitespaceConfig::default(),
3346 &[RefineSpan {
3347 start: 4,
3348 end: 5,
3349 kind: RefineKind::Added,
3350 }],
3351 &[],
3352 );
3353 let refined: u32 = runs
3354 .iter()
3355 .filter(|r| r.refine == Some(RefineKind::Added))
3356 .map(|r| r.len)
3357 .sum();
3358 assert_eq!(refined, 1, "exactly the refined byte is marked");
3359 let total: u32 = runs.iter().map(|r| r.len).sum();
3360 assert_eq!(total, 10, "the row still covers every byte");
3361 }
3362
3363 /// A tab expands to several display columns, so a renderer-side
3364 /// walk over RENDERED bytes would drift. Refinement is resolved in
3365 /// the worker against SOURCE bytes precisely to avoid that.
3366 #[test]
3367 fn refinement_survives_tab_expansion() {
3368 use lattice_cells::{RefineKind, RefineSpan};
3369 let ws = WhitespaceConfig {
3370 tabstop: 4,
3371 ..WhitespaceConfig::default()
3372 };
3373 // "\tab" — refine "ab" at source bytes 1..3.
3374 let (_t, runs, _cm, _c, _cc) = build_display_row(
3375 "\tab",
3376 &[],
3377 &[],
3378 &ws,
3379 &[RefineSpan {
3380 start: 1,
3381 end: 3,
3382 kind: RefineKind::Removed,
3383 }],
3384 &[],
3385 );
3386 let refined: u32 = runs
3387 .iter()
3388 .filter(|r| r.refine == Some(RefineKind::Removed))
3389 .map(|r| r.len)
3390 .sum();
3391 assert_eq!(refined, 2, "the two source bytes, not tab-expanded columns");
3392 }
3393
3394 /// A span past the end of the line must be ignored, not panic — a
3395 /// stale refinement racing a shorter re-render is a real case.
3396 #[test]
3397 fn an_out_of_range_refine_span_is_ignored() {
3398 use lattice_cells::{RefineKind, RefineSpan};
3399 let (_t, runs, _cm, _c, _cc) = build_display_row(
3400 "ab",
3401 &[],
3402 &[],
3403 &WhitespaceConfig::default(),
3404 &[RefineSpan {
3405 start: 50,
3406 end: 60,
3407 kind: RefineKind::Added,
3408 }],
3409 &[],
3410 );
3411 assert!(runs.iter().all(|r| r.refine.is_none()));
3412 }
3413
3414 use super::*;
3415 use crate::render_state::{CellsRenderState, RenderState};
3416 use lattice_core::Document;
3417 use lattice_runtime::DocumentSnapshot;
3418
3419 /// T.5: a real resolved theme read-table + builtin ids for tests
3420 /// that thread a [`CellTheme`] into the builder. The default
3421 /// registry reproduces the legacy `Theme::default()` syntax colours
3422 /// exactly, so colour-asserting tests keep their expected values.
3423 fn test_cell_theme() -> (
3424 std::sync::Arc<crate::ui::theme::ResolvedTheme>,
3425 crate::ui::theme::BuiltinElementIds,
3426 ) {
3427 use crate::ui::theme::ThemeRegistry as _;
3428 let reg = crate::ui::theme::InMemoryThemeRegistry::with_defaults();
3429 (
3430 reg.resolved(),
3431 crate::ui::theme::BuiltinElementIds::capture(®),
3432 )
3433 }
3434
3435 /// Helper: build a `RenderState` whose `cells` substate carries
3436 /// `snapshot` + `version` and shares `matrix_cell` with the
3437 /// caller.
3438 fn rs_with_snapshot(
3439 snapshot: Option<Arc<DocumentSnapshot>>,
3440 version: MatrixVersion,
3441 matrix_cell: Arc<ArcSwap<CellMatrix>>,
3442 ) -> ArcSwap<RenderState> {
3443 rs_with_snapshot_themed(snapshot, version, matrix_cell, None)
3444 }
3445
3446 /// Themed variant used by S2.3.a tests that need a non-default
3447 /// syntax handle. T.6.t dropped the `theme: Theme` param — the host
3448 /// `Theme` struct is gone; the cell builder resolves styles through
3449 /// the resolved table built by `test_cell_theme()` inside
3450 /// `rs_with_everything`.
3451 fn rs_with_snapshot_themed(
3452 snapshot: Option<Arc<DocumentSnapshot>>,
3453 version: MatrixVersion,
3454 matrix_cell: Arc<ArcSwap<CellMatrix>>,
3455 syntax_handle: Option<Arc<lattice_syntax::SyntaxHandle>>,
3456 ) -> ArcSwap<RenderState> {
3457 rs_with_snapshot_full(
3458 snapshot,
3459 version,
3460 matrix_cell,
3461 syntax_handle,
3462 Vec::<crate::render_state::InlayHintRow>::new(),
3463 )
3464 }
3465
3466 /// Full-input variant used by S2.3.b tests that need to drive
3467 /// the inlay-hint splice path.
3468 fn rs_with_snapshot_full(
3469 snapshot: Option<Arc<DocumentSnapshot>>,
3470 version: MatrixVersion,
3471 matrix_cell: Arc<ArcSwap<CellMatrix>>,
3472 syntax_handle: Option<Arc<lattice_syntax::SyntaxHandle>>,
3473 inlay_hints: Vec<crate::render_state::InlayHintRow>,
3474 ) -> ArcSwap<RenderState> {
3475 rs_with_snapshot_full_folded(
3476 snapshot,
3477 version,
3478 matrix_cell,
3479 syntax_handle,
3480 inlay_hints,
3481 Vec::new(),
3482 true,
3483 )
3484 }
3485
3486 /// Folded variant used by S2.3.c tests that need to drive the
3487 /// fold-elision path.
3488 fn rs_with_snapshot_full_folded(
3489 snapshot: Option<Arc<DocumentSnapshot>>,
3490 version: MatrixVersion,
3491 matrix_cell: Arc<ArcSwap<CellMatrix>>,
3492 syntax_handle: Option<Arc<lattice_syntax::SyntaxHandle>>,
3493 inlay_hints: Vec<crate::render_state::InlayHintRow>,
3494 folds: Vec<lattice_core::Fold>,
3495 foldenable: bool,
3496 ) -> ArcSwap<RenderState> {
3497 rs_with_everything(
3498 snapshot,
3499 version,
3500 matrix_cell,
3501 syntax_handle,
3502 inlay_hints,
3503 folds,
3504 foldenable,
3505 None,
3506 0,
3507 )
3508 }
3509
3510 /// S2.4.b: superset helper exposing `last_edit` and
3511 /// `viewport_height` for incremental-rebuild tests.
3512 ///
3513 /// D.4.d.1.b (2026-05-29): also publishes a single-pane
3514 /// `cells.panes[0]` entry mirroring the top-level inputs
3515 /// and using the same `matrix_cell`, so the worker (which
3516 /// now iterates `cells.panes`) writes into the caller's
3517 /// `matrix_cell`. Without this entry the worker would see
3518 /// an empty `panes` slice and return `CacheHit` without
3519 /// touching the matrix — every pre-d.1.b test expects the
3520 /// matrix to receive a fresh build.
3521 #[allow(clippy::too_many_arguments)]
3522 /// B2.2 test shim: the worker now persists its canonical state in
3523 /// `display_matrix`, so multi-publish tests that thread a shared
3524 /// `matrix_cell` need the matching display cell to persist too.
3525 /// Pair a per-thread display cell with each `matrix_cell` keyed by
3526 /// its Arc pointer, so the same `matrix_cell` always yields the
3527 /// same display cell (mirroring how `matrix_cell` itself persists),
3528 /// while a fresh `Arc::default()` gets a fresh display cell. The
3529 /// `Weak` guard pins the keyed address (a live `Weak` keeps the
3530 /// allocation reserved) so a freed+reused address can never leak a
3531 /// prior test's display cell into a later one.
3532 fn display_cell_for(
3533 matrix_cell: &Arc<ArcSwap<CellMatrix>>,
3534 ) -> Arc<ArcSwap<crate::display_matrix::DisplayMatrix>> {
3535 use std::cell::RefCell;
3536 use std::collections::HashMap;
3537 use std::sync::Weak;
3538 type DisplayCell = Arc<ArcSwap<crate::display_matrix::DisplayMatrix>>;
3539 thread_local! {
3540 static MAP: RefCell<HashMap<usize, (Weak<ArcSwap<CellMatrix>>, DisplayCell)>> =
3541 RefCell::new(HashMap::new());
3542 }
3543 let key = Arc::as_ptr(matrix_cell) as usize;
3544 MAP.with(|m| {
3545 let mut map = m.borrow_mut();
3546 if let Some((weak, disp)) = map.get(&key)
3547 && weak.upgrade().is_some_and(|a| Arc::ptr_eq(&a, matrix_cell))
3548 {
3549 return disp.clone();
3550 }
3551 let disp: DisplayCell = Arc::new(ArcSwap::from_pointee(
3552 crate::display_matrix::DisplayMatrix::empty(),
3553 ));
3554 map.insert(key, (Arc::downgrade(matrix_cell), disp.clone()));
3555 disp
3556 })
3557 }
3558
3559 fn rs_with_everything(
3560 snapshot: Option<Arc<DocumentSnapshot>>,
3561 version: MatrixVersion,
3562 matrix_cell: Arc<ArcSwap<CellMatrix>>,
3563 syntax_handle: Option<Arc<lattice_syntax::SyntaxHandle>>,
3564 inlay_hints: Vec<crate::render_state::InlayHintRow>,
3565 folds: Vec<lattice_core::Fold>,
3566 foldenable: bool,
3567 last_edit: Option<lattice_cells::EditDelta>,
3568 viewport_height: u32,
3569 ) -> ArcSwap<RenderState> {
3570 let inlay_hints_arc: Arc<[crate::render_state::InlayHintRow]> =
3571 Arc::from(inlay_hints.into_boxed_slice());
3572 let folds_arc: Arc<[lattice_core::Fold]> = Arc::from(folds.into_boxed_slice());
3573 // Canonical display cell persisted alongside the shared
3574 // `matrix_cell` (see `display_cell_for`).
3575 let display_cell = display_cell_for(&matrix_cell);
3576 let pane_entry = crate::render_state::PaneCellsInputs {
3577 conceal_reveal_line: None,
3578 pane_id: lattice_core::ui::pane::PaneId::default(),
3579 buffer_id: lattice_core::BufferId::default(),
3580 matrix: matrix_cell.clone(),
3581 display_matrix: display_cell.clone(),
3582 // IG.2: guides on, default indent unit — the shape a test
3583 // pane has unless it is exercising guides specifically.
3584 indent_guides: Arc::new(ArcSwap::from_pointee(
3585 crate::indent_guides::IndentGuides::empty(),
3586 )),
3587 indent_unit: lattice_core::IndentUnit::default(),
3588 indent_guides_enabled: true,
3589 sticky_context_lines: std::sync::Arc::from([] as [u32; 0]),
3590 sticky_context_line_numbers: true,
3591 sticky_context_separator: None,
3592 sticky_context: Default::default(),
3593 virtual_rows_matrix: Arc::new(ArcSwap::from_pointee(
3594 lattice_cells::VirtualRowMatrix::empty(),
3595 )),
3596 version,
3597 snapshot: snapshot.clone(),
3598 syntax_handle: syntax_handle.clone(),
3599 inlay_hints: inlay_hints_arc.clone(),
3600 folds: folds_arc.clone(),
3601 viewport_height,
3602 scroll: 0,
3603 viewport_width: 0,
3604 wrap: false,
3605 wrap_reserved_cols: 0,
3606 foldenable,
3607 last_edit,
3608 excerpt_syntax: Arc::from([]),
3609 extra_spans: Arc::from([]),
3610 extra_refine: Arc::from(Vec::new().into_boxed_slice()),
3611 };
3612 let pane_matrices = {
3613 let mut m = std::collections::HashMap::new();
3614 m.insert(pane_entry.pane_id, pane_entry.matrix.clone());
3615 Arc::new(m)
3616 };
3617 let display_pane_matrices = {
3618 let mut m = std::collections::HashMap::new();
3619 m.insert(pane_entry.pane_id, pane_entry.display_matrix.clone());
3620 Arc::new(m)
3621 };
3622 let (resolved_theme, theme_ids) = test_cell_theme();
3623 let cells = CellsRenderState {
3624 pane_indent_guides: Arc::new(std::collections::HashMap::new()),
3625 pane_sticky_context: Arc::new(std::collections::HashMap::new()),
3626 matrix: matrix_cell,
3627 version,
3628 snapshot,
3629 syntax_handle,
3630 inlay_hints: inlay_hints_arc,
3631 folds: folds_arc,
3632 viewport_height,
3633 foldenable,
3634 last_edit,
3635 resolved_theme,
3636 theme_ids,
3637 whitespace: WhitespaceConfig::default(),
3638 panes: Arc::from(vec![pane_entry].into_boxed_slice()),
3639 pane_matrices,
3640 display_matrix: display_cell,
3641 display_pane_matrices,
3642 };
3643 let rs = RenderState {
3644 cells: Arc::new(ArcSwap::from_pointee(cells)),
3645 ..RenderState::default()
3646 };
3647 ArcSwap::from_pointee(rs)
3648 }
3649
3650 fn snap_of(text: &str) -> Arc<DocumentSnapshot> {
3651 let doc = Document::from_text(text);
3652 Arc::new(DocumentSnapshot::__bench_from_document(&doc))
3653 }
3654
3655 fn v(text: u64) -> MatrixVersion {
3656 MatrixVersion {
3657 text,
3658 syntax: 0,
3659 inlay_hints: 0,
3660 folds: 0,
3661 theme: 0,
3662 whitespace: 0,
3663 indent: 0,
3664 conceal: 0,
3665 }
3666 }
3667
3668 /// `recompute` with `snapshot: None` clears the matrix and
3669 /// short-circuits when the published matrix is already empty.
3670 #[test]
3671 fn recompute_with_no_snapshot_clears_matrix() {
3672 let matrix_cell: Arc<ArcSwap<CellMatrix>> = Arc::default();
3673 // Seed a non-empty matrix so the first call exercises the
3674 // store path.
3675 let pre_chunk = Arc::new(CellChunk::new(
3676 0,
3677 vec![CellRow::new(
3678 vec![Cell::with_codepoint(b'x' as u32)],
3679 0,
3680 Vec::<lattice_cells::row::InlayOffset>::new(),
3681 )],
3682 v(7),
3683 ));
3684 matrix_cell.store(Arc::new(CellMatrix::whole_doc(pre_chunk, 1)));
3685 let rs = rs_with_snapshot(None, v(7), matrix_cell.clone());
3686
3687 let decision = recompute(&rs);
3688 assert_eq!(decision, WorkerDecision::Clear);
3689 assert!(matrix_cell.load().is_empty());
3690
3691 // Second call sees an already-empty matrix at version ZERO;
3692 // the idempotent Clear branch short-circuits without a store.
3693 let before = Arc::as_ptr(&matrix_cell.load_full());
3694 assert_eq!(recompute(&rs), WorkerDecision::Clear);
3695 let after = Arc::as_ptr(&matrix_cell.load_full());
3696 assert_eq!(before, after, "idempotent Clear must not churn the Arc");
3697 }
3698
3699 /// Cache miss: with a fresh snapshot + non-matching version,
3700 /// the worker builds a matrix that reflects every line.
3701 #[test]
3702 fn recompute_publishes_matrix_for_snapshot_text() {
3703 let matrix_cell: Arc<ArcSwap<CellMatrix>> = Arc::default();
3704 let snap = snap_of("ab\ncd\nef");
3705 let rs = rs_with_snapshot(Some(snap), v(1), matrix_cell.clone());
3706
3707 let decision = recompute(&rs);
3708 assert_eq!(decision, WorkerDecision::Recomputed);
3709
3710 let matrix = matrix_cell.load();
3711 assert!(matrix.is_whole_doc());
3712 // ropey counts a trailing implicit line; 3 newline-separated
3713 // lines without trailing `\n` yields exactly 3 lines.
3714 assert_eq!(matrix.visible_line_count, 3);
3715 assert_eq!(matrix.source_line_count, 3);
3716 let slice = matrix.slice(0, 10);
3717 let rows: Vec<&CellRow> = slice.iter().collect();
3718 assert_eq!(rows.len(), 3);
3719 let row_text = |r: &CellRow| -> String {
3720 r.cells
3721 .iter()
3722 .map(|c| char::from_u32(c.codepoint).unwrap_or('?'))
3723 .collect()
3724 };
3725 assert_eq!(row_text(rows[0]), "ab");
3726 assert_eq!(row_text(rows[1]), "cd");
3727 assert_eq!(row_text(rows[2]), "ef");
3728 assert_eq!(matrix.version, v(1));
3729 }
3730
3731 /// Cache hit: a second `recompute` with matching versions sees
3732 /// `published_matrix.version == cells.version` and short-circuits.
3733 /// The stored Arc identity is preserved.
3734 #[test]
3735 fn recompute_with_matching_version_is_cache_hit() {
3736 let matrix_cell: Arc<ArcSwap<CellMatrix>> = Arc::default();
3737 let snap = snap_of("hello");
3738 let rs = rs_with_snapshot(Some(snap), v(4), matrix_cell.clone());
3739
3740 assert_eq!(recompute(&rs), WorkerDecision::Recomputed);
3741 let first_ptr = Arc::as_ptr(&matrix_cell.load_full());
3742 assert_eq!(recompute(&rs), WorkerDecision::CacheHit);
3743 let second_ptr = Arc::as_ptr(&matrix_cell.load_full());
3744 assert_eq!(first_ptr, second_ptr, "cache-hit must not store a new Arc");
3745 }
3746
3747 /// Version bump triggers a fresh build. Earlier matrix is
3748 /// replaced; new matrix carries the new version stamp.
3749 #[test]
3750 fn version_bump_rebuilds_matrix() {
3751 let matrix_cell: Arc<ArcSwap<CellMatrix>> = Arc::default();
3752 let snap1 = snap_of("aaa");
3753 let rs1 = rs_with_snapshot(Some(snap1), v(1), matrix_cell.clone());
3754 assert_eq!(recompute(&rs1), WorkerDecision::Recomputed);
3755 assert_eq!(matrix_cell.load().version, v(1));
3756
3757 // New snapshot + bumped text version.
3758 let snap2 = snap_of("bbbb");
3759 let rs2 = rs_with_snapshot(Some(snap2), v(2), matrix_cell.clone());
3760 assert_eq!(recompute(&rs2), WorkerDecision::Recomputed);
3761 let m = matrix_cell.load();
3762 assert_eq!(m.version, v(2));
3763 assert_eq!(m.visible_line_count, 1);
3764 let first_row = m.slice(0, 1).iter().next().cloned().unwrap();
3765 assert_eq!(first_row.cells.len(), 4);
3766 }
3767
3768 /// Empty text produces a single empty row (ropey reports one
3769 /// line for an empty buffer). Distinct from the no-snapshot
3770 /// `Clear` branch.
3771 #[test]
3772 fn empty_text_produces_one_empty_row() {
3773 let matrix_cell: Arc<ArcSwap<CellMatrix>> = Arc::default();
3774 let snap = snap_of("");
3775 let rs = rs_with_snapshot(Some(snap), v(1), matrix_cell.clone());
3776 assert_eq!(recompute(&rs), WorkerDecision::Recomputed);
3777 let m = matrix_cell.load();
3778 assert_eq!(m.visible_line_count, 1);
3779 let row = m.slice(0, 1).iter().next().cloned().unwrap();
3780 assert!(row.is_empty());
3781 assert_eq!(row.source_line, 0);
3782 }
3783
3784 // ---- S2.3.a — syntax fg + theme palette ----
3785
3786 /// Helper: build a seeded Rust `SyntaxHandle` parsed against
3787 /// `text` at the given text_version.
3788 fn rust_handle(text: &str, text_version: u64) -> Arc<lattice_syntax::SyntaxHandle> {
3789 let mut s = lattice_syntax::Syntax::for_language(lattice_syntax::Lang::Rust)
3790 .unwrap()
3791 .expect("rust grammar available in test build");
3792 s.parse_at(text, text_version);
3793 Arc::new(lattice_syntax::SyntaxHandle::seeded(s))
3794 }
3795
3796 /// Helper: produce a snapshot whose `text_version` matches the
3797 /// caller-supplied value (so syntax / doc text-versions line up
3798 /// in tests without driving the actor).
3799 fn snap_of_versioned(text: &str, text_version: u64) -> Arc<DocumentSnapshot> {
3800 let doc = Document::from_text(text);
3801 let mut s = DocumentSnapshot::__bench_from_document(&doc);
3802 s.text_version = text_version;
3803 Arc::new(s)
3804 }
3805
3806 /// With a syntax handle attached and the default theme, the
3807 /// `fn` keyword on line 0 takes the theme's keyword fg
3808 /// (`0xcba6f7`); a comment line takes the comment fg
3809 /// (`0x6c7086`); plain text takes the default fg (`0xcdd6f4`).
3810 #[test]
3811 fn syntax_handle_resolves_keyword_string_comment_fg() {
3812 // Line 0: `fn` keyword + identifier + paren punctuation.
3813 // Line 1: line comment.
3814 // Line 2: plain whitespace / EOF.
3815 let text = "fn main() {}\n// comment\n";
3816 let handle = rust_handle(text, 1);
3817 let snap = snap_of_versioned(text, 1);
3818 let matrix_cell: Arc<ArcSwap<CellMatrix>> = Arc::default();
3819 let rs = rs_with_snapshot_themed(Some(snap), v(1), matrix_cell.clone(), Some(handle));
3820 assert_eq!(recompute(&rs), WorkerDecision::Recomputed);
3821
3822 let m = matrix_cell.load();
3823 let rows: Vec<&CellRow> = m.slice(0, 10).iter().collect();
3824 assert!(rows.len() >= 2, "expected at least 2 rows for {text:?}");
3825
3826 // Default fg for the theme is Catppuccin Mocha "Text"
3827 // (0xcdd6f4) and the keyword fg is Mauve (0xcba6f7).
3828 let (resolved, ids) = test_cell_theme();
3829 let ct = CellTheme {
3830 resolved: &resolved,
3831 ids: &ids,
3832 };
3833 let expected_default = resolve_fg(ct, lattice_syntax::Style::Default);
3834 let expected_keyword = resolve_fg(ct, lattice_syntax::Style::Keyword);
3835 let expected_comment = resolve_fg(ct, lattice_syntax::Style::LineComment);
3836 assert_eq!(expected_keyword, 0x00cb_a6f7);
3837 assert_ne!(expected_default, expected_keyword);
3838
3839 // First two cells of line 0 are `f` and `n` (the `fn`
3840 // keyword). Both should carry the keyword fg.
3841 let line0 = rows[0];
3842 assert!(line0.cells.len() >= 2, "line 0 has at least `fn`");
3843 assert_eq!(line0.cells[0].codepoint, b'f' as u32);
3844 assert_eq!(line0.cells[0].fg, expected_keyword);
3845 assert_eq!(line0.cells[1].codepoint, b'n' as u32);
3846 assert_eq!(line0.cells[1].fg, expected_keyword);
3847
3848 // Line 1 is a line comment — every byte takes the comment fg.
3849 let line1 = rows[1];
3850 assert!(
3851 line1.cells.iter().all(|c| c.fg == expected_comment),
3852 "every cell on a line-comment row must carry the comment fg; got {:?}",
3853 line1.cells.iter().map(|c| c.fg).collect::<Vec<_>>()
3854 );
3855 }
3856
3857 /// Without a syntax handle, every cell on every line takes the
3858 /// theme's default fg — proves the no-handle fallback path
3859 /// doesn't accidentally use a different colour.
3860 #[test]
3861 fn no_syntax_handle_yields_default_fg_everywhere() {
3862 let snap = snap_of("ab\ncd");
3863 let matrix_cell: Arc<ArcSwap<CellMatrix>> = Arc::default();
3864 let rs = rs_with_snapshot_themed(Some(snap), v(1), matrix_cell.clone(), None);
3865 assert_eq!(recompute(&rs), WorkerDecision::Recomputed);
3866 let (resolved, ids) = test_cell_theme();
3867 let ct = CellTheme {
3868 resolved: &resolved,
3869 ids: &ids,
3870 };
3871 let default_fg = resolve_fg(ct, lattice_syntax::Style::Default);
3872 let m = matrix_cell.load();
3873 for row in m.slice(0, 10).iter() {
3874 for c in row.cells.iter() {
3875 assert_eq!(c.fg, default_fg, "no-handle path must use default fg");
3876 }
3877 }
3878 }
3879
3880 /// A syntax snapshot whose `text_version` lags the document's
3881 /// `text_version` is treated as stale: the worker falls back
3882 /// to default fg rather than painting against mismatched byte
3883 /// offsets. Mirrors `overlay_worker`'s stale-hold contract.
3884 #[test]
3885 fn stale_syntax_falls_back_to_default_fg() {
3886 // Snapshot parsed against version 1; document advanced
3887 // to version 2 (mid-edit, syntax hasn't reparsed yet).
3888 let text = "fn x() {}";
3889 let handle = rust_handle(text, 1);
3890 let snap = snap_of_versioned(text, 2);
3891 let matrix_cell: Arc<ArcSwap<CellMatrix>> = Arc::default();
3892 let rs = rs_with_snapshot_themed(Some(snap), v(1), matrix_cell.clone(), Some(handle));
3893 assert_eq!(recompute(&rs), WorkerDecision::Recomputed);
3894
3895 let (resolved, ids) = test_cell_theme();
3896 let ct = CellTheme {
3897 resolved: &resolved,
3898 ids: &ids,
3899 };
3900 let default_fg = resolve_fg(ct, lattice_syntax::Style::Default);
3901 let m = matrix_cell.load();
3902 for row in m.slice(0, 10).iter() {
3903 for c in row.cells.iter() {
3904 assert_eq!(
3905 c.fg, default_fg,
3906 "stale-syntax fallback must use default fg, got {:#08x}",
3907 c.fg
3908 );
3909 }
3910 }
3911 }
3912
3913 // ---- S2.3.b — inlay-hint splicing ----
3914
3915 fn inlay(line: u32, byte: u32, text: &str) -> crate::render_state::InlayHintRow {
3916 crate::render_state::InlayHintRow::hint(line, byte, text.to_string())
3917 }
3918
3919 fn row_text(r: &CellRow) -> String {
3920 r.cells
3921 .iter()
3922 .map(|c| char::from_u32(c.codepoint).unwrap_or('?'))
3923 .collect()
3924 }
3925
3926 /// DL.3a: an inlay may name its OWN theme element, and the spliced
3927 /// cells take that element's colour.
3928 ///
3929 /// This is what the listing icons need (DL.3b): one leading inlay
3930 /// per row, coloured by the `listing.*` element its path resolves
3931 /// to. It was impossible before — every inlay in the editor was
3932 /// painted with one hardcoded grey and `run.style` was discarded at
3933 /// projection.
3934 #[test]
3935 fn an_inlay_can_carry_its_own_theme_element() {
3936 use lattice_theme::ThemeRegistry as _;
3937 let theme = lattice_theme::InMemoryThemeRegistry::with_defaults();
3938 let element = theme.register(
3939 lattice_theme::ElementName::from_static("listing.file.rust"),
3940 lattice_theme::ElementOwner::Mode("directory-listing-mode".into()),
3941 lattice_theme::StyleSpec::new().fg(lattice_theme::ColorRef::Literal(
3942 lattice_theme::Color::Rgb(0xDE, 0xA5, 0x84),
3943 )),
3944 "test element",
3945 );
3946
3947 let snap = snap_of_versioned("hello", 1);
3948 let matrix_cell: Arc<ArcSwap<CellMatrix>> = Arc::default();
3949 let mut icon = inlay(0, 0, "R ");
3950 icon.style = lattice_syntax::Style::Element(element);
3951 let rs = rs_with_snapshot_full(Some(snap), v(1), matrix_cell.clone(), None, vec![icon]);
3952 assert_eq!(recompute(&rs), WorkerDecision::Recomputed);
3953
3954 let m = matrix_cell.load();
3955 let row = m.slice(0, 1).iter().next().cloned().unwrap();
3956 assert!(row.cells[0].is_inlay(), "the icon must be an inlay cell");
3957
3958 // The worker resolves against its own theme, so compare against
3959 // the same element resolved there rather than the literal — the
3960 // point is that the ELEMENT drives the colour, not that this
3961 // particular RGB survives.
3962 let (resolved, ids) = test_cell_theme();
3963 let hint_fg = resolve_fg(
3964 CellTheme {
3965 resolved: &resolved,
3966 ids: &ids,
3967 },
3968 lattice_syntax::Style::InlayHint,
3969 );
3970 assert_ne!(
3971 row.cells[0].fg, hint_fg,
3972 "an element-styled inlay must NOT fall back to the plain hint \
3973 colour — that fallback is exactly the bug this slice fixes"
3974 );
3975 }
3976
3977 /// Single inlay spliced mid-line: combined text reflects the
3978 /// inlay, the spliced cells carry `flags::INLAY`, and
3979 /// `inlay_offsets` records `(orig_byte, char_width)` so
3980 /// `byte_to_combined_col` returns the post-inlay column for
3981 /// later bytes.
3982 #[test]
3983 fn single_inlay_splices_into_row_and_sets_flags() {
3984 let snap = snap_of_versioned("hello", 1);
3985 let matrix_cell: Arc<ArcSwap<CellMatrix>> = Arc::default();
3986 let hints = vec![inlay(0, 2, ": ")];
3987 let rs = rs_with_snapshot_full(Some(snap), v(1), matrix_cell.clone(), None, hints);
3988 assert_eq!(recompute(&rs), WorkerDecision::Recomputed);
3989 let m = matrix_cell.load();
3990 let row = m.slice(0, 1).iter().next().cloned().unwrap();
3991 // Combined cells: `h e : SPACE l l o`.
3992 assert_eq!(row_text(&row), "he: llo");
3993 // Inlay-spliced cells at index 2, 3 carry the INLAY flag.
3994 assert!(row.cells[2].is_inlay(), "cell 2 (`:`) must be INLAY");
3995 assert!(row.cells[3].is_inlay(), "cell 3 (` `) must be INLAY");
3996 // Source cells stay clean.
3997 assert!(!row.cells[0].is_inlay());
3998 assert!(!row.cells[1].is_inlay());
3999 assert!(!row.cells[4].is_inlay());
4000 // DL.3a: the inlay foreground comes from the registered
4001 // `inlay.hint` theme element, not a hardcoded grey. Asserted
4002 // against the element rather than a literal so a palette change
4003 // moves the expectation with it — pinning the literal is what
4004 // let a hardcoded colour sit past a themed element unnoticed.
4005 let (resolved, ids) = test_cell_theme();
4006 let want = resolve_fg(
4007 CellTheme {
4008 resolved: &resolved,
4009 ids: &ids,
4010 },
4011 lattice_syntax::Style::InlayHint,
4012 );
4013 assert_eq!(row.cells[2].fg, want);
4014 assert_ne!(
4015 want, 0x7f7f7f,
4016 "precondition: the element differs from the grey that was \
4017 hardcoded, so this test would fail on the old path"
4018 );
4019 // Offsets: one entry, (2, 2) for `(orig_byte, char_width)`.
4020 assert_eq!(row.inlay_offsets.as_ref(), &[(2u32, 2u32)] as &[_]);
4021 // byte_to_combined_col round-trip: source byte 2 sits at
4022 // combined col 4 (after the 2-wide inlay).
4023 assert_eq!(row.byte_to_combined_col(0), 0);
4024 assert_eq!(row.byte_to_combined_col(2), 4);
4025 assert_eq!(row.byte_to_combined_col(3), 5);
4026 }
4027
4028 /// Two inlays on the same line, presented out-of-order in the
4029 /// payload, splice in `(byte, sequence-of-arrival)` order after
4030 /// the worker's per-line `sort_by_key`.
4031 #[test]
4032 fn multiple_inlays_splice_in_byte_order() {
4033 let snap = snap_of_versioned("abc", 1);
4034 let matrix_cell: Arc<ArcSwap<CellMatrix>> = Arc::default();
4035 // Insert out of order on purpose.
4036 let hints = vec![inlay(0, 2, "[2]"), inlay(0, 1, "[1]")];
4037 let rs = rs_with_snapshot_full(Some(snap), v(1), matrix_cell.clone(), None, hints);
4038 assert_eq!(recompute(&rs), WorkerDecision::Recomputed);
4039 let row = matrix_cell
4040 .load()
4041 .slice(0, 1)
4042 .iter()
4043 .next()
4044 .cloned()
4045 .unwrap();
4046 assert_eq!(row_text(&row), "a[1]b[2]c");
4047 // Offsets ordered by orig_byte.
4048 assert_eq!(
4049 row.inlay_offsets.as_ref(),
4050 &[(1u32, 3u32), (2u32, 3u32)] as &[_]
4051 );
4052 }
4053
4054 /// An inlay at byte 0 splices *before* the first char of the
4055 /// line — covers the boundary case the byte<=byte splice
4056 /// inequality is meant to handle.
4057 #[test]
4058 fn inlay_at_line_start_splices_before_first_char() {
4059 let snap = snap_of_versioned("xyz", 1);
4060 let matrix_cell: Arc<ArcSwap<CellMatrix>> = Arc::default();
4061 let hints = vec![inlay(0, 0, "?")];
4062 let rs = rs_with_snapshot_full(Some(snap), v(1), matrix_cell.clone(), None, hints);
4063 assert_eq!(recompute(&rs), WorkerDecision::Recomputed);
4064 let row = matrix_cell
4065 .load()
4066 .slice(0, 1)
4067 .iter()
4068 .next()
4069 .cloned()
4070 .unwrap();
4071 assert_eq!(row_text(&row), "?xyz");
4072 assert!(row.cells[0].is_inlay());
4073 assert_eq!(row.inlay_offsets.as_ref(), &[(0u32, 1u32)] as &[_]);
4074 }
4075
4076 /// A trailing inlay (orig_byte == line_len) splices at EOL.
4077 /// Matches the historical inlay-weave contract (from the deleted
4078 /// `highlights_worker`) so future renderer cutovers don't
4079 /// surprise the user with disappearing end-of-line hints.
4080 #[test]
4081 fn trailing_inlay_splices_at_end_of_line() {
4082 let snap = snap_of_versioned("ab", 1);
4083 let matrix_cell: Arc<ArcSwap<CellMatrix>> = Arc::default();
4084 let hints = vec![inlay(0, 2, ";")];
4085 let rs = rs_with_snapshot_full(Some(snap), v(1), matrix_cell.clone(), None, hints);
4086 assert_eq!(recompute(&rs), WorkerDecision::Recomputed);
4087 let row = matrix_cell
4088 .load()
4089 .slice(0, 1)
4090 .iter()
4091 .next()
4092 .cloned()
4093 .unwrap();
4094 assert_eq!(row_text(&row), "ab;");
4095 assert!(row.cells[2].is_inlay());
4096 assert_eq!(row.inlay_offsets.as_ref(), &[(2u32, 1u32)] as &[_]);
4097 }
4098
4099 /// An inlay-version bump (same text + theme, new inlay
4100 /// payload) triggers a recompute. Demonstrates the cells.
4101 /// inlay_hints field participates in the version axes.
4102 #[test]
4103 fn inlay_version_bump_triggers_rebuild() {
4104 let snap = snap_of_versioned("a", 1);
4105 let matrix_cell: Arc<ArcSwap<CellMatrix>> = Arc::default();
4106 let v_a = MatrixVersion {
4107 text: 1,
4108 syntax: 1,
4109 inlay_hints: 0,
4110 folds: 0,
4111 theme: 0,
4112 whitespace: 0,
4113 indent: 0,
4114 conceal: 0,
4115 };
4116 let v_b = MatrixVersion {
4117 inlay_hints: 1,
4118 ..v_a
4119 };
4120
4121 let rs1 = rs_with_snapshot_full(
4122 Some(snap.clone()),
4123 v_a,
4124 matrix_cell.clone(),
4125 None,
4126 Vec::new(),
4127 );
4128 assert_eq!(recompute(&rs1), WorkerDecision::Recomputed);
4129 let first_ptr = Arc::as_ptr(&matrix_cell.load_full());
4130
4131 // Add an inlay + bump the version.
4132 let rs2 = rs_with_snapshot_full(
4133 Some(snap),
4134 v_b,
4135 matrix_cell.clone(),
4136 None,
4137 vec![inlay(0, 0, "!")],
4138 );
4139 assert_eq!(recompute(&rs2), WorkerDecision::Recomputed);
4140 assert_ne!(first_ptr, Arc::as_ptr(&matrix_cell.load_full()));
4141 let row = matrix_cell
4142 .load()
4143 .slice(0, 1)
4144 .iter()
4145 .next()
4146 .cloned()
4147 .unwrap();
4148 assert_eq!(row_text(&row), "!a");
4149 }
4150
4151 // ---- S2.4.a — chunked-mode switch ----
4152
4153 /// `pick_chunk_size` policy: small docs and zero-viewport
4154 /// inputs collapse to whole-doc; everything past the
4155 /// `4 × viewport_height` threshold goes chunked.
4156 #[test]
4157 fn pick_chunk_size_policy() {
4158 // viewport == 0 → whole-doc regardless of line count.
4159 assert_eq!(pick_chunk_size(0, 0), ChunkMode::WholeDoc);
4160 assert_eq!(pick_chunk_size(0, 1_000_000), ChunkMode::WholeDoc);
4161
4162 // Below threshold (line_count <= 4 × viewport): whole-doc.
4163 assert_eq!(pick_chunk_size(50, 200), ChunkMode::WholeDoc);
4164 assert_eq!(pick_chunk_size(50, 199), ChunkMode::WholeDoc);
4165
4166 // Above threshold: chunked with next_pow2(2 × viewport).
4167 // viewport=50 → 2×50=100 → next_pow2 = 128.
4168 assert_eq!(pick_chunk_size(50, 201), ChunkMode::Chunked(128));
4169 // viewport=70 → 2×70=140 → next_pow2 = 256.
4170 assert_eq!(pick_chunk_size(70, 281), ChunkMode::Chunked(256));
4171
4172 // 16-line floor: tiny viewport doesn't produce sub-16
4173 // chunks.
4174 // viewport=3 → 2×3=6, clamped to 16 → next_pow2 = 16.
4175 assert_eq!(pick_chunk_size(3, 13), ChunkMode::Chunked(16));
4176 }
4177
4178 #[test]
4179 fn next_power_of_two_table() {
4180 assert_eq!(next_power_of_two(0), 1);
4181 assert_eq!(next_power_of_two(1), 1);
4182 assert_eq!(next_power_of_two(2), 2);
4183 assert_eq!(next_power_of_two(3), 4);
4184 assert_eq!(next_power_of_two(15), 16);
4185 assert_eq!(next_power_of_two(16), 16);
4186 assert_eq!(next_power_of_two(17), 32);
4187 assert_eq!(next_power_of_two(100), 128);
4188 assert_eq!(next_power_of_two(1_000_000), 1_048_576);
4189 // u32::MAX-class inputs saturate at 1 << 31 (avoid overflow).
4190 assert_eq!(next_power_of_two(u32::MAX), 1u32 << 31);
4191 }
4192
4193 /// Small doc (line_count <= 4 × viewport_height) builds a
4194 /// whole-doc matrix — one chunk covering every line.
4195 #[test]
4196 fn small_doc_stays_whole_doc() {
4197 // 5 lines + viewport 5 → threshold 20; line_count <= 20.
4198 let snap = snap_of_versioned("l0\nl1\nl2\nl3\nl4", 1);
4199 let matrix_cell: Arc<ArcSwap<CellMatrix>> = Arc::default();
4200 let rs = rs_with_everything(
4201 Some(snap),
4202 v(1),
4203 matrix_cell.clone(),
4204 None,
4205 Vec::new(),
4206 Vec::new(),
4207 true,
4208 None,
4209 5,
4210 );
4211 assert_eq!(recompute(&rs), WorkerDecision::Recomputed);
4212 let m = matrix_cell.load();
4213 assert!(m.is_whole_doc(), "small doc must stay whole-doc");
4214 assert_eq!(m.chunks.len(), 1);
4215 assert_eq!(m.visible_line_count, 5);
4216 }
4217
4218 /// Large doc (line_count > 4 × viewport_height) switches to
4219 /// chunked mode. Chunk size = next_pow2(2 × viewport_height),
4220 /// chunks cover the full document, and `matrix.slice(0, all)`
4221 /// walks them in order with row content matching each line.
4222 #[test]
4223 fn large_doc_splits_into_chunks() {
4224 // viewport=5, threshold=20. Build 25 lines so we cross it.
4225 // chunk_size = next_pow2(10) = 16. Expect ceil(25/16) = 2.
4226 let text: String = (0..25)
4227 .map(|i| format!("line{}", i))
4228 .collect::<Vec<_>>()
4229 .join("\n");
4230 let snap = snap_of_versioned(&text, 1);
4231 let matrix_cell: Arc<ArcSwap<CellMatrix>> = Arc::default();
4232 let rs = rs_with_everything(
4233 Some(snap),
4234 v(1),
4235 matrix_cell.clone(),
4236 None,
4237 Vec::new(),
4238 Vec::new(),
4239 true,
4240 None,
4241 5,
4242 );
4243 assert_eq!(recompute(&rs), WorkerDecision::Recomputed);
4244
4245 let m = matrix_cell.load();
4246 assert!(!m.is_whole_doc());
4247 assert_eq!(m.chunk_size, 16);
4248 assert_eq!(m.source_line_count, 25);
4249 assert_eq!(m.visible_line_count, 25);
4250 // Two chunks: 16 + 9 rows.
4251 assert_eq!(m.chunks.len(), 2);
4252 assert_eq!(m.chunks[0].start_source_line, 0);
4253 assert_eq!(m.chunks[0].rows.len(), 16);
4254 assert_eq!(m.chunks[1].start_source_line, 16);
4255 assert_eq!(m.chunks[1].rows.len(), 9);
4256
4257 // Slice iteration walks across chunks transparently and
4258 // preserves logical source_line on each row.
4259 let source_lines: Vec<u32> = m.slice(0, 100).iter().map(|r| r.source_line).collect();
4260 assert_eq!(source_lines, (0u32..25).collect::<Vec<_>>());
4261 }
4262
4263 /// Fold elision works in chunked mode: a closed fold whose
4264 /// interior crosses a chunk boundary still elides the right
4265 /// source lines. The fold's start_line lands in chunk 0; the
4266 /// fold's end_line lands in chunk 1; only the start stays
4267 /// visible.
4268 #[test]
4269 fn chunked_mode_honours_fold_elision_across_chunks() {
4270 let text: String = (0..25)
4271 .map(|i| format!("l{}", i))
4272 .collect::<Vec<_>>()
4273 .join("\n");
4274 let snap = snap_of_versioned(&text, 1);
4275 let matrix_cell: Arc<ArcSwap<CellMatrix>> = Arc::default();
4276 // Close a fold from line 10 through line 20 — interior
4277 // lines 11..=20 are elided, 10 stays.
4278 let folds = vec![closed_fold(10, 20)];
4279 let rs = rs_with_everything(
4280 Some(snap),
4281 v(1),
4282 matrix_cell.clone(),
4283 None,
4284 Vec::new(),
4285 folds,
4286 true,
4287 None,
4288 5,
4289 );
4290 assert_eq!(recompute(&rs), WorkerDecision::Recomputed);
4291
4292 let m = matrix_cell.load();
4293 assert!(!m.is_whole_doc(), "still chunked");
4294 assert_eq!(m.source_line_count, 25);
4295 // 25 source - 10 elided (11..=20) = 15 visible rows.
4296 assert_eq!(m.visible_line_count, 15);
4297 let source_lines: Vec<u32> = m.slice(0, 100).iter().map(|r| r.source_line).collect();
4298 let expected: Vec<u32> = (0u32..=10).chain(21u32..25).collect();
4299 assert_eq!(source_lines, expected);
4300 }
4301
4302 /// Viewport-height change that crosses the threshold flips the
4303 /// matrix between whole-doc and chunked shapes — exercised
4304 /// because `viewport_height` is not in `MatrixVersion` (it
4305 /// only changes via dispatch); the publisher's version axes
4306 /// must still drive the rebuild. We bump `text` to simulate
4307 /// the version cascade that would accompany a viewport
4308 /// resize-induced republish.
4309 #[test]
4310 fn viewport_shrink_can_promote_to_chunked() {
4311 let text: String = (0..25)
4312 .map(|i| format!("l{}", i))
4313 .collect::<Vec<_>>()
4314 .join("\n");
4315 let snap = snap_of_versioned(&text, 1);
4316 let matrix_cell: Arc<ArcSwap<CellMatrix>> = Arc::default();
4317
4318 let mk_rs = |vp: u32, version: MatrixVersion| -> ArcSwap<RenderState> {
4319 rs_with_everything(
4320 Some(snap.clone()),
4321 version,
4322 matrix_cell.clone(),
4323 None,
4324 Vec::new(),
4325 Vec::new(),
4326 true,
4327 None,
4328 vp,
4329 )
4330 };
4331
4332 // Wide viewport (8) → 4×8=32 ≥ 25 → whole-doc.
4333 let rs1 = mk_rs(8, v(1));
4334 assert_eq!(recompute(&rs1), WorkerDecision::Recomputed);
4335 assert!(matrix_cell.load().is_whole_doc());
4336
4337 // Shrink to 5 → 4×5=20 < 25 → chunked. Bump text version
4338 // so the cache key differs and the worker rebuilds.
4339 let rs2 = mk_rs(5, v(2));
4340 assert_eq!(recompute(&rs2), WorkerDecision::Recomputed);
4341 let m = matrix_cell.load();
4342 assert!(!m.is_whole_doc(), "post-shrink must be chunked");
4343 assert_eq!(m.chunk_size, 16);
4344 }
4345
4346 // ---- H.3 — viewport-scoped (windowed) chunked matrix ----
4347
4348 /// Build a snapshot of `line_count` short lines at version 1.
4349 fn big_snap(line_count: u32) -> Arc<DocumentSnapshot> {
4350 let text: String = (0..line_count)
4351 .map(|i| format!("line{i}"))
4352 .collect::<Vec<_>>()
4353 .join("\n");
4354 snap_of_versioned(&text, 1)
4355 }
4356
4357 /// `window_bounds` is full-coverage at/below the cap and a
4358 /// chunk-aligned window above it.
4359 #[test]
4360 fn window_bounds_full_below_cap_windowed_above() {
4361 let cs = 128;
4362 // At/below cap → whole doc regardless of scroll.
4363 assert_eq!(
4364 window_bounds(0, 50, WINDOW_CAP_LINES, cs),
4365 (0, WINDOW_CAP_LINES)
4366 );
4367 assert_eq!(
4368 window_bounds(900, 50, WINDOW_CAP_LINES, cs),
4369 (0, WINDOW_CAP_LINES)
4370 );
4371 // Above cap → window around the viewport, aligned to chunks.
4372 // scroll=2500, vh=50, overscan=50 ⇒ raw [2450, 2600);
4373 // align lo down to 2432 (19·128), hi up to 2688 (21·128).
4374 let (lo, hi) = window_bounds(2500, 50, 5000, cs);
4375 assert_eq!(lo % cs, 0, "lo chunk-aligned");
4376 assert_eq!(hi % cs, 0, "hi chunk-aligned");
4377 assert!(
4378 lo <= 2450 && hi >= 2600,
4379 "window brackets the viewport+overscan"
4380 );
4381 assert!(hi - lo < 5000, "window is a strict subset of the doc");
4382 // Window never exceeds the document.
4383 let (_, hi_eof) = window_bounds(4990, 50, 5000, cs);
4384 assert!(hi_eof <= 5000);
4385 }
4386
4387 /// A large doc (> `WINDOW_CAP_LINES`) builds a matrix that covers
4388 /// only the viewport window, not the whole document. Off-window
4389 /// source lines have no row (the renderers fall back to plain
4390 /// text / legacy spans for those).
4391 #[test]
4392 fn windowed_matrix_covers_viewport_not_whole_doc() {
4393 let matrix_cell: Arc<ArcSwap<CellMatrix>> = Arc::default();
4394 let mut pane = pane_inputs(matrix_cell.clone(), Some(big_snap(5000)), v(1), 50);
4395 pane.scroll = 2500;
4396 let (resolved, ids) = test_cell_theme();
4397 let ct = CellTheme {
4398 resolved: &resolved,
4399 ids: &ids,
4400 };
4401 let ws = WhitespaceConfig::default();
4402
4403 assert_eq!(recompute_pane(&pane, ct, &ws), WorkerDecision::Recomputed);
4404 let m = matrix_cell.load();
4405 assert!(!m.is_whole_doc(), "large doc is chunked");
4406 assert_eq!(m.source_line_count, 5000, "true doc line count preserved");
4407 // Covered range is a small window around the viewport, NOT the
4408 // whole 5000-line document — this is the O(viewport) win.
4409 assert!(m.covered_start_line() <= 2500);
4410 assert!(m.covered_end_line() >= 2550);
4411 assert!(
4412 m.covered_end_line() - m.covered_start_line() < 1000,
4413 "covered span bounded (got {}..{})",
4414 m.covered_start_line(),
4415 m.covered_end_line()
4416 );
4417 assert!(m.covers(2500, 2550), "viewport is covered");
4418 // In-window line has a row; far off-window lines do not.
4419 assert!(m.row_at_source_line(2500).is_some());
4420 assert!(
4421 m.row_at_source_line(10).is_none(),
4422 "line far above the window has no row"
4423 );
4424 assert!(
4425 m.row_at_source_line(4990).is_none(),
4426 "line far below the window has no row"
4427 );
4428 }
4429
4430 /// An edit BELOW the covered window leaves the window alone.
4431 ///
4432 /// The viewport is at the top of a 5000-line file, so the matrix covers
4433 /// a few hundred lines; the edit is at line 2500. Nothing the window
4434 /// shows changed, and the incremental build's rebuild zone comes out
4435 /// inverted — it ends, clamped to the window, before the edit starts.
4436 /// `build_display_rows` sized its `Vec` from `end - start`, which wrapped
4437 /// to four billion rows: 320 GB, and an abort, in a release build
4438 /// (`cells_worker_incremental_build/5000_lines` died exactly so in CI);
4439 /// a subtraction overflow here. `:2500d` from the top of a large file,
4440 /// or an LSP edit somewhere far away, is the same shape.
4441 #[test]
4442 fn an_edit_below_the_covered_window_does_not_touch_it() {
4443 let matrix_cell: Arc<ArcSwap<CellMatrix>> = Arc::default();
4444 let (resolved, ids) = test_cell_theme();
4445 let ct = CellTheme {
4446 resolved: &resolved,
4447 ids: &ids,
4448 };
4449 let ws = WhitespaceConfig::default();
4450
4451 let mut pane = pane_inputs(matrix_cell.clone(), Some(big_snap(5000)), v(1), 50);
4452 assert_eq!(recompute_pane(&pane, ct, &ws), WorkerDecision::Recomputed);
4453 let covered_end = matrix_cell.load().covered_end_line();
4454 assert!(
4455 covered_end < 2500,
4456 "precondition: line 2500 is outside the window (covered to {covered_end})"
4457 );
4458
4459 // Same line count, one line's text changed, far below the window.
4460 let text: String = (0..5000u32)
4461 .map(|i| {
4462 if i == 2500 {
4463 "EDITED".to_string()
4464 } else {
4465 format!("line{i}")
4466 }
4467 })
4468 .collect::<Vec<_>>()
4469 .join("\n");
4470 pane.snapshot = Some(snap_of_versioned(&text, 2));
4471 pane.version = v(2);
4472 pane.last_edit = Some(lattice_cells::EditDelta {
4473 start_line: 2500,
4474 lines_removed: 0,
4475 lines_added: 0,
4476 ..Default::default()
4477 });
4478
4479 let _ = recompute_pane(&pane, ct, &ws);
4480
4481 let m = matrix_cell.load();
4482 assert!(m.covers(0, 50), "the viewport is still covered");
4483 assert!(
4484 m.row_at_source_line(10).is_some(),
4485 "a line in the window still has its row"
4486 );
4487 assert_eq!(m.source_line_count, 5000);
4488 }
4489
4490 /// Scrolling the viewport past the covered window (no version
4491 /// change) forces a rebuild that recentres the window on the new
4492 /// scroll — the old region is no longer covered.
4493 #[test]
4494 fn scroll_past_window_triggers_rebuild() {
4495 let matrix_cell: Arc<ArcSwap<CellMatrix>> = Arc::default();
4496 let (resolved, ids) = test_cell_theme();
4497 let ct = CellTheme {
4498 resolved: &resolved,
4499 ids: &ids,
4500 };
4501 let ws = WhitespaceConfig::default();
4502
4503 // First build at the top.
4504 let mut pane = pane_inputs(matrix_cell.clone(), Some(big_snap(5000)), v(1), 50);
4505 pane.scroll = 0;
4506 assert_eq!(recompute_pane(&pane, ct, &ws), WorkerDecision::Recomputed);
4507 assert!(matrix_cell.load().row_at_source_line(0).is_some());
4508
4509 // Same version, jump far down. Pure-scroll past the window must
4510 // rebuild (not CacheHit) and recentre.
4511 pane.scroll = 3000;
4512 assert_eq!(
4513 recompute_pane(&pane, ct, &ws),
4514 WorkerDecision::Recomputed,
4515 "scroll past window rebuilds despite unchanged version"
4516 );
4517 let m = matrix_cell.load();
4518 assert!(m.covers(3000, 3050), "new viewport covered");
4519 assert!(m.row_at_source_line(3000).is_some());
4520 assert!(
4521 m.row_at_source_line(0).is_none(),
4522 "window moved off the original top"
4523 );
4524 }
4525
4526 /// A closed fold at every 50th line: 50 rows of viewport span 2500
4527 /// buffer lines.
4528 fn every_50th_line_folded(total: u32) -> Arc<[lattice_core::Fold]> {
4529 let folds: Vec<lattice_core::Fold> = (0..total / 50)
4530 .map(|i| lattice_core::Fold {
4531 start_line: i * 50,
4532 end_line: i * 50 + 49,
4533 closed: true,
4534 identity: None,
4535 })
4536 .collect();
4537 Arc::from(folds.into_boxed_slice())
4538 }
4539
4540 /// **Closed folds stretch the buffer-line span a viewport covers,
4541 /// and the matrix window must stretch with it.**
4542 ///
4543 /// `viewport_height` counts SCREEN ROWS; the window it sized was
4544 /// measured in BUFFER LINES. Those are the same quantity only when
4545 /// nothing is folded. Collapse an org file with `#+STARTUP: overview`
4546 /// and ~60 rows reach ~2500 lines in — so the window covered the
4547 /// first screenful of *lines*, every row below it found no chunk, and
4548 /// the renderer fell back to uncoloured plain text for most of the
4549 /// screen. Reported as "only the first few headings are coloured".
4550 ///
4551 /// Asserted at the row the viewport actually ends on, not at a fixed
4552 /// window size, so it pins the coupling (chunk window ↔ fold state)
4553 /// rather than any particular chunking policy.
4554 #[test]
4555 fn fold_stretched_viewport_is_covered_by_the_window() {
4556 let matrix_cell: Arc<ArcSwap<CellMatrix>> = Arc::default();
4557 let (resolved, ids) = test_cell_theme();
4558 let ct = CellTheme {
4559 resolved: &resolved,
4560 ids: &ids,
4561 };
4562 let ws = WhitespaceConfig::default();
4563
4564 let total = 5_000u32;
4565 let vh = 50u32;
4566 let mut pane = pane_inputs(matrix_cell.clone(), Some(big_snap(total)), v(1), vh);
4567 pane.folds = every_50th_line_folded(total);
4568 pane.foldenable = true;
4569 pane.scroll = 0;
4570 let dm_cell = pane.display_matrix.clone();
4571
4572 assert_eq!(recompute_pane(&pane, ct, &ws), WorkerDecision::Recomputed);
4573
4574 // The rows the renderer will paint: one per fold head, 50 of
4575 // them, the last at buffer line 2450.
4576 let fold_idx = crate::folds::FoldIndex::from_folds(&pane.folds, true);
4577 let visible = crate::folds::visible_source_lines(&fold_idx, pane.scroll, vh, total);
4578 assert_eq!(visible.len(), vh as usize, "fixture fills the viewport");
4579 let last_visible = *visible.last().expect("viewport is non-empty");
4580 assert_eq!(last_visible, 2_450, "fixture spans 2500 buffer lines");
4581
4582 let dm = dm_cell.load();
4583 for line in visible {
4584 assert!(
4585 dm.row_at_source_line(line).is_some(),
4586 "visible row at buffer line {line} has no display row \
4587 (matrix covers {}..{})",
4588 dm.covered_start_line(),
4589 dm.covered_end_line(),
4590 );
4591 }
4592
4593 // And the cell projection the GPUI peer still reads agrees.
4594 let cm = matrix_cell.load();
4595 assert!(
4596 cm.row_at_source_line(last_visible).is_some(),
4597 "cell projection stops short of the fold-stretched viewport"
4598 );
4599 }
4600
4601 /// FW.2: `visible_runs` splits a window at closed folds, and nowhere else.
4602 #[test]
4603 fn visible_runs_splits_only_at_closed_folds() {
4604 use lattice_core::Fold;
4605 let fold = |start_line, end_line, closed| Fold {
4606 start_line,
4607 end_line,
4608 closed,
4609 identity: None,
4610 };
4611
4612 // No folds ⇒ ONE run covering the window verbatim. This is the case
4613 // `highlight_visible_runs` short-circuits on, so an ordinary buffer
4614 // cannot pay for a feature it does not use.
4615 let none = crate::folds::FoldIndex::from_folds(&[], true);
4616 assert_eq!(visible_runs(&none, 10, 20), [(10, 20)]);
4617
4618 // A closed fold's HEAD stays visible (vim renders it); only its body
4619 // is hidden. `[5, 9]` closed ⇒ 5 visible, 6..=9 hidden.
4620 let one = crate::folds::FoldIndex::from_folds(&[fold(5, 9, true)], true);
4621 assert_eq!(visible_runs(&one, 0, 15), [(0, 6), (10, 15)]);
4622
4623 // An OPEN fold hides nothing.
4624 let open = crate::folds::FoldIndex::from_folds(&[fold(5, 9, false)], true);
4625 assert_eq!(visible_runs(&open, 0, 15), [(0, 15)]);
4626
4627 // `foldenable` off makes every fold inert, so back to one run.
4628 let off = crate::folds::FoldIndex::from_folds(&[fold(5, 9, true)], false);
4629 assert_eq!(visible_runs(&off, 0, 15), [(0, 15)]);
4630
4631 // Back-to-back folds do not produce empty runs between them.
4632 let two = crate::folds::FoldIndex::from_folds(&[fold(2, 4, true), fold(5, 7, true)], true);
4633 assert_eq!(visible_runs(&two, 0, 10), [(0, 3), (5, 6), (8, 10)]);
4634
4635 // A window entirely inside a fold body has no visible run at all.
4636 let big = crate::folds::FoldIndex::from_folds(&[fold(0, 100, true)], true);
4637 assert!(visible_runs(&big, 10, 20).is_empty());
4638 }
4639
4640 /// **The spans a visible line gets must not depend on how the window was
4641 /// queried.** FW.2 replaced one contiguous `highlight_lines` call with one
4642 /// per visible run; if the stitching were off by a line, every row below
4643 /// the first fold would paint with its neighbour's colours — which reads
4644 /// as a highlighting bug, not a windowing one.
4645 ///
4646 /// Asserted against the contiguous answer rather than against fixed
4647 /// styles, so it cannot rot into asserting a theme or a grammar.
4648 #[test]
4649 fn per_run_highlighting_agrees_with_the_contiguous_query_line_for_line() {
4650 use lattice_core::Fold;
4651 // A stand-in for the real highlighter with the one property that
4652 // matters: what a line gets depends only on the LINE, never on the
4653 // range it was asked for. A stitching bug shows up as a shifted index.
4654 let per_line = |lo: u32, hi: u32| -> Option<Vec<Vec<lattice_syntax::StyledSpan>>> {
4655 Some(
4656 (lo..hi)
4657 .map(|line| {
4658 vec![lattice_syntax::StyledSpan {
4659 start: line as usize,
4660 end: line as usize + 1,
4661 style: lattice_syntax::Style::Default,
4662 }]
4663 })
4664 .collect(),
4665 )
4666 };
4667
4668 let folds = [
4669 Fold {
4670 start_line: 3,
4671 end_line: 8,
4672 closed: true,
4673 identity: None,
4674 },
4675 Fold {
4676 start_line: 12,
4677 end_line: 40,
4678 closed: true,
4679 identity: None,
4680 },
4681 ];
4682 let idx = crate::folds::FoldIndex::from_folds(&folds, true);
4683 let (lo, hi) = (0u32, 50u32);
4684
4685 let contiguous = per_line(lo, hi).expect("some");
4686 let stitched = highlight_visible_runs(&per_line, &idx, lo, hi).expect("some");
4687 assert_eq!(stitched.len(), contiguous.len(), "same dense window length");
4688
4689 for line in lo..hi {
4690 let i = (line - lo) as usize;
4691 if idx.line_inside_closed_fold(line) {
4692 // Hidden: no row is built for it, so an empty slot is right —
4693 // and is what makes the per-run query cheaper at all.
4694 assert!(
4695 stitched[i].is_empty(),
4696 "line {line} is hidden and must not have been queried"
4697 );
4698 } else {
4699 assert_eq!(
4700 stitched[i], contiguous[i],
4701 "visible line {line} must get the spans the contiguous \
4702 query would have given it"
4703 );
4704 }
4705 }
4706 }
4707
4708 /// A `None` from any run propagates, matching the single-range primitive:
4709 /// a stale syntax snapshot must paint default fg everywhere rather than
4710 /// colouring the first run and leaving the rest bare.
4711 #[test]
4712 fn a_stale_snapshot_makes_the_whole_window_none() {
4713 use lattice_core::Fold;
4714 let idx = crate::folds::FoldIndex::from_folds(
4715 &[Fold {
4716 start_line: 3,
4717 end_line: 8,
4718 closed: true,
4719 identity: None,
4720 }],
4721 true,
4722 );
4723 let stale = |_: u32, _: u32| -> Option<Vec<Vec<lattice_syntax::StyledSpan>>> { None };
4724 assert!(highlight_visible_runs(&stale, &idx, 0, 20).is_none());
4725 }
4726
4727 /// The fold-stretched window must also satisfy the worker's own
4728 /// coverage gate: if the gate asks for more than the build produced,
4729 /// `covers` fails every tick and the worker rebuilds forever instead
4730 /// of ever reporting `CacheHit`.
4731 #[test]
4732 fn fold_stretched_window_settles_to_a_cache_hit() {
4733 let matrix_cell: Arc<ArcSwap<CellMatrix>> = Arc::default();
4734 let (resolved, ids) = test_cell_theme();
4735 let ct = CellTheme {
4736 resolved: &resolved,
4737 ids: &ids,
4738 };
4739 let ws = WhitespaceConfig::default();
4740
4741 let total = 20_000u32;
4742 let mut pane = pane_inputs(matrix_cell.clone(), Some(big_snap(total)), v(1), 50);
4743 pane.folds = every_50th_line_folded(total);
4744 pane.foldenable = true;
4745 pane.scroll = 5_000;
4746
4747 assert_eq!(recompute_pane(&pane, ct, &ws), WorkerDecision::Recomputed);
4748 assert_eq!(
4749 recompute_pane(&pane, ct, &ws),
4750 WorkerDecision::CacheHit,
4751 "unchanged inputs must settle; a gate the build cannot \
4752 satisfy rebuilds on every tick"
4753 );
4754 }
4755
4756 /// Scrolling within the covered window (overscan slack) is a
4757 /// cache hit — no rebuild on line-by-line scrolling.
4758 #[test]
4759 fn in_window_scroll_is_cache_hit() {
4760 let matrix_cell: Arc<ArcSwap<CellMatrix>> = Arc::default();
4761 let (resolved, ids) = test_cell_theme();
4762 let ct = CellTheme {
4763 resolved: &resolved,
4764 ids: &ids,
4765 };
4766 let ws = WhitespaceConfig::default();
4767
4768 let mut pane = pane_inputs(matrix_cell.clone(), Some(big_snap(5000)), v(1), 50);
4769 pane.scroll = 2500;
4770 assert_eq!(recompute_pane(&pane, ct, &ws), WorkerDecision::Recomputed);
4771 // A few lines of scroll stays inside the overscan window.
4772 pane.scroll = 2510;
4773 assert_eq!(
4774 recompute_pane(&pane, ct, &ws),
4775 WorkerDecision::CacheHit,
4776 "small in-window scroll does not rebuild"
4777 );
4778 }
4779
4780 // ---- B1: display-builder parity with the cell builder ----
4781
4782 /// The canonical `build_display_row` must reproduce the exact
4783 /// content `build_row_cells` produces (whitespace off): projecting
4784 /// the `DisplayLine` runs back to cells yields the same codepoints +
4785 /// resolved fg + flags, and `col_map` equals `inlay_offsets`. Guards
4786 /// against drift while both builders coexist (B1→B4).
4787 // ---- H.4: mode scoping ----
4788
4789 #[test]
4790 fn h4_revealing_is_the_same_as_having_no_rules() {
4791 // The gate is expressed as an empty rule set rather than as a
4792 // flag threaded beside them, so the version axis and the built
4793 // rows cannot disagree about what is concealed. This asserts
4794 // the equivalence directly.
4795 let r = org_conceal_rules();
4796 let concealed = lattice_syntax::conceal::conceal_spans(&r, "[[id:A][one]]");
4797 let revealed = lattice_syntax::conceal::conceal_spans(&[], "[[id:A][one]]");
4798 assert_eq!(concealed.len(), 2);
4799 assert!(revealed.is_empty());
4800 }
4801
4802 /// **CL.1: only the cursor's line reveals; every other link stays
4803 /// concealed.**
4804 ///
4805 /// Pressing `i` used to reveal every link in the buffer at once, because
4806 /// reveal was a buffer-wide flag expressed as an empty rule set. No editor
4807 /// does that — vim scopes it to the cursor line (`concealcursor`), because
4808 /// the reason to reveal is to edit the thing under the cursor; the other
4809 /// links on screen have no reason to turn back into `[[id:…][…]]`.
4810 #[test]
4811 fn cl1_only_the_cursor_line_reveals() {
4812 let text = "[[id:A][one]]\n[[id:B][two]]\n[[id:C][three]]\n";
4813 let (resolved, ids) = test_cell_theme();
4814 let ct = CellTheme {
4815 resolved: &resolved,
4816 ids: &ids,
4817 };
4818 let rules = org_conceal_rules();
4819 let row = |line: &str, reveal: bool| {
4820 let (out, ..) = build_display_row(
4821 line,
4822 &[],
4823 &[],
4824 &WhitespaceConfig::default(),
4825 &[],
4826 if reveal { &[] } else { &rules },
4827 );
4828 let _ = ct;
4829 out.to_string()
4830 };
4831
4832 // Line 1 is the cursor's: raw. Its neighbours: concealed.
4833 assert_eq!(row("[[id:B][two]]", true), "[[id:B][two]]");
4834 assert_eq!(row("[[id:A][one]]", false), "one");
4835 assert_eq!(row("[[id:C][three]]", false), "three");
4836 let _ = text;
4837 }
4838
4839 /// **CL.1: moving the reveal line is INCREMENTAL, not a rebuild.**
4840 ///
4841 /// This is what makes per-line reveal affordable at cursor rate. The
4842 /// cursor moves on every keystroke; a full window rebuild there would be
4843 /// ~1.5 ms against a 47 ns cache hit, and revealing the whole buffer — the
4844 /// behaviour this replaced — avoided that cost only by doing the wrong
4845 /// thing.
4846 ///
4847 /// Asserted as the worker's own decision rather than by timing, so it
4848 /// cannot pass on a slow machine or flake on a fast one.
4849 #[tokio::test(flavor = "current_thread")]
4850 async fn cl1_moving_the_reveal_line_is_incremental() {
4851 let matrix_cell: Arc<ArcSwap<CellMatrix>> = Arc::default();
4852 let (resolved, ids) = test_cell_theme();
4853 let ct = CellTheme {
4854 resolved: &resolved,
4855 ids: &ids,
4856 };
4857 let ws = WhitespaceConfig::default();
4858 let mut pane = pane_inputs(
4859 matrix_cell.clone(),
4860 Some(snap_of("alpha\nbeta\ngamma\n")),
4861 v(1),
4862 10,
4863 );
4864
4865 // Cold build, no reveal.
4866 assert_eq!(recompute_pane(&pane, ct, &ws), WorkerDecision::Recomputed);
4867 // Same inputs: a cache hit, which is what a cursor move costs today.
4868 assert_eq!(recompute_pane(&pane, ct, &ws), WorkerDecision::CacheHit);
4869
4870 // The cursor moves onto a line: the reveal moved, nothing else did.
4871 pane.conceal_reveal_line = Some(1);
4872 assert_eq!(
4873 recompute_pane(&pane, ct, &ws),
4874 WorkerDecision::RecomputedIncremental,
4875 "a reveal move must not rebuild the window"
4876 );
4877 // …and it settles, or the gate would re-fire every tick forever.
4878 assert_eq!(recompute_pane(&pane, ct, &ws), WorkerDecision::CacheHit);
4879
4880 // Moving it again is incremental too, not just the first transition.
4881 pane.conceal_reveal_line = Some(2);
4882 assert_eq!(
4883 recompute_pane(&pane, ct, &ws),
4884 WorkerDecision::RecomputedIncremental
4885 );
4886 assert_eq!(recompute_pane(&pane, ct, &ws), WorkerDecision::CacheHit);
4887 }
4888
4889 /// **CL.1 inverted H.4's axis rule, and that inversion is the feature.**
4890 ///
4891 /// H.4 made revealing move the `conceal` axis, because revealing was
4892 /// buffer-wide and a buffer-wide change genuinely needs a rebuild. Scoped
4893 /// to the cursor LINE, the axis must NOT move: it is the cache-hit key,
4894 /// the cursor moves on every keystroke, and a moving key would turn a
4895 /// 47 ns hit into a ~1.5 ms window rebuild on every `j`.
4896 ///
4897 /// The reveal line is compared beside the version instead, and a
4898 /// difference takes the two-row incremental path.
4899 #[test]
4900 fn cl1_the_conceal_axis_does_not_move_with_the_cursor() {
4901 let r = org_conceal_rules();
4902 assert_ne!(
4903 lattice_syntax::conceal::rules_version(&r),
4904 0,
4905 "a language WITH rules still has a non-zero axis"
4906 );
4907 // The axis is a property of the RULES, and the rules do not change
4908 // when the cursor does. Asserted through the accessor the worker
4909 // actually calls, so a reveal parameter cannot creep back into it.
4910 assert_eq!(conceal_version_for(None), 0);
4911 }
4912
4913 #[test]
4914 fn h4_a_language_with_no_rules_never_moves_the_axis() {
4915 for reveal in [false, true] {
4916 assert_eq!(
4917 lattice_syntax::conceal::rules_version(&[]),
4918 0,
4919 "no rules, reveal={reveal}: the axis must not move"
4920 );
4921 }
4922 // And the same through the resolver the render path actually
4923 // calls, with no syntax handle — the plain-text / synthetic
4924 // buffer case.
4925 assert_eq!(conceal_version_for(None), 0);
4926 assert_eq!(conceal_version_for(None), 0);
4927 }
4928
4929 #[test]
4930 fn h4_a_revealed_row_is_byte_identical_to_the_source() {
4931 // What Insert mode actually has to produce: the file's own text.
4932 let r = org_conceal_rules();
4933 let line = "* See [[id:6F39][Project Kickoff]] before Friday.";
4934 let (concealed, _, _) = row(line, &r);
4935 assert_ne!(concealed, line);
4936 // Revealing resolves to an empty rule set, and an empty rule set
4937 // is H.3's byte-identical path.
4938 let (revealed, spans, cols) = row(line, &[]);
4939 assert_eq!(revealed, line);
4940 assert!(spans.is_empty());
4941 assert_eq!(cols, line.chars().count() as u32);
4942 }
4943
4944 // ---- H.3: the display row elides ----
4945
4946 /// Org's real two rules, compiled.
4947 /// OA.7b: a multibuffer row conceals by ITS OWN excerpt's grammar.
4948 ///
4949 /// Conceal was resolved once per pane from the buffer's single syntax
4950 /// handle, and a multibuffer has none — so it resolved to no rules and an
4951 /// org link in an agenda row showed its raw `[[id:…][…]]` while the same
4952 /// line concealed correctly in its own file.
4953 #[test]
4954 fn a_multibuffer_row_conceals_by_its_own_excerpts_language() {
4955 let rules: std::sync::Arc<[lattice_syntax::conceal::ConcealRule]> =
4956 std::sync::Arc::from(org_conceal_rules());
4957 // Rows 0..=1 are org; row 2 belongs to no excerpt (a header's virtual
4958 // row sits outside them) and must fall through to the pane's rules.
4959 let excerpts = vec![ExcerptConceal {
4960 composed_start: 0,
4961 composed_end: 1,
4962 rules: rules.clone(),
4963 }];
4964
4965 assert!(
4966 rules_for_row(&excerpts, 0).is_some_and(|r| !r.is_empty()),
4967 "an org row gets org's rules"
4968 );
4969 assert!(rules_for_row(&excerpts, 1).is_some());
4970 assert!(
4971 rules_for_row(&excerpts, 2).is_none(),
4972 "a row outside every excerpt resolves to nothing, so the caller \
4973 falls back to the pane's rules rather than concealing by a \
4974 neighbour's grammar"
4975 );
4976
4977 // And the rules that come back actually conceal.
4978 let line = "* TODO see [[id:abc][the note]]";
4979 let (text, conceals, _) = row(line, rules_for_row(&excerpts, 0).unwrap());
4980 assert!(
4981 !conceals.is_empty() && text.contains("the note") && !text.contains("[[id:"),
4982 "got {text:?} conceals={conceals:?}"
4983 );
4984 }
4985
4986 /// An ordinary buffer has no excerpts, so every row falls through to the
4987 /// pane's own rules and the path is byte-identical to before.
4988 #[test]
4989 fn an_ordinary_buffer_resolves_no_per_excerpt_rules() {
4990 assert!(rules_for_row(&[], 0).is_none());
4991 assert!(rules_for_row(&[], 41).is_none());
4992 }
4993
4994 fn org_conceal_rules() -> Vec<lattice_syntax::conceal::ConcealRule> {
4995 // OL.1: slots carried, so these fixtures exercise the styled path the
4996 // shipped rules take rather than a conceal-only shape org no longer has.
4997 let (ok, errs) = lattice_syntax::conceal::compile_rules(
4998 &[
4999 (
5000 r"(\[\[[^]]+\]\[)[^]]+(\]\])".to_string(),
5001 vec![1, 2],
5002 Some("text.reference".to_string()),
5003 ),
5004 (
5005 r"(\[\[)([^]]+)(\]\])".to_string(),
5006 vec![1, 3],
5007 Some("text.uri".to_string()),
5008 ),
5009 ],
5010 None,
5011 );
5012 assert!(errs.is_empty(), "{errs:?}");
5013 ok
5014 }
5015
5016 fn row(
5017 text: &str,
5018 rules: &[lattice_syntax::conceal::ConcealRule],
5019 ) -> (String, Vec<(u32, u32)>, u32) {
5020 let (t, _runs, _cm, conceals, col_count) =
5021 build_display_row(text, &[], &[], &WhitespaceConfig::default(), &[], rules);
5022 (t.to_string(), conceals, col_count)
5023 }
5024
5025 #[test]
5026 fn h3_a_described_link_collapses_to_its_description() {
5027 let r = org_conceal_rules();
5028 let (text, conceals, cols) = row("* See [[id:6F39][Project Kickoff]] before Friday.", &r);
5029 assert_eq!(text, "* See Project Kickoff before Friday.");
5030 assert_eq!(
5031 cols,
5032 text.chars().count() as u32,
5033 "col_count follows the elision"
5034 );
5035 assert_eq!(conceals.len(), 2);
5036 }
5037
5038 #[test]
5039 fn h3_a_bare_link_keeps_its_target() {
5040 let r = org_conceal_rules();
5041 let (text, _, _) = row("see [[https://example.com]] ok", &r);
5042 assert_eq!(text, "see https://example.com ok");
5043 }
5044
5045 #[test]
5046 fn h3_two_links_on_one_line_both_collapse() {
5047 let r = org_conceal_rules();
5048 let (text, _, _) = row("[[id:A][one]] and [[id:B][two]]", &r);
5049 assert_eq!(text, "one and two");
5050 }
5051
5052 /// The zero-cost path: with no rules the output must be what it was
5053 /// before H.3 existed, byte for byte, and carry no conceal ranges.
5054 /// This is the guard for every buffer in the editor.
5055 #[test]
5056 fn h3_no_rules_leaves_the_row_byte_identical() {
5057 let line = "* See [[id:6F39][Project Kickoff]] before Friday.";
5058 let (text, conceals, cols) = row(line, &[]);
5059 assert_eq!(text, line);
5060 assert!(conceals.is_empty());
5061 assert_eq!(cols, line.chars().count() as u32);
5062 }
5063
5064 #[test]
5065 fn h3_a_malformed_link_is_left_alone() {
5066 let r = org_conceal_rules();
5067 let line = "[[id:6F39][unterminated";
5068 let (text, conceals, _) = row(line, &r);
5069 assert_eq!(text, line);
5070 assert!(conceals.is_empty());
5071 }
5072
5073 /// **Conceal ranges are COLUMN ranges, not byte ranges**, and this
5074 /// is the test that would fail if they were stored in byte space.
5075 ///
5076 /// `é` is two bytes and one column. With a multi-byte char before
5077 /// the link, the link's byte offset and its column diverge — and
5078 /// `byte_to_combined_col`'s baseline is `col = byte`, i.e. it works
5079 /// in columns. Storing bytes would place every conceal range too
5080 /// far right, by exactly the number of extra UTF-8 bytes earlier on
5081 /// the line, in precisely the buffers where nobody looks.
5082 #[test]
5083 fn h3_conceal_ranges_are_in_column_space_not_byte_space() {
5084 let r = org_conceal_rules();
5085 let line = "café [[id:A][x]]";
5086 let (text, conceals, _) = row(line, &r);
5087 assert_eq!(text, "café x");
5088 // "café " is 5 columns and 6 bytes. The first hidden run starts
5089 // at the `[[`, i.e. COLUMN 5 — not byte 6.
5090 assert_eq!(conceals[0].0, 5, "start is a column, not a byte");
5091 assert!(
5092 line.find("[[").unwrap() == 6,
5093 "the fixture must actually have byte != column here"
5094 );
5095 }
5096
5097 /// The whole point of the range list: every consumer that maps a
5098 /// source position to a column agrees, including for a position
5099 /// that has no column of its own.
5100 #[test]
5101 fn h3_a_source_position_inside_a_hidden_run_resolves_to_its_start() {
5102 let r = org_conceal_rules();
5103 let (_t, _runs, col_map, conceals, col_count) = build_display_row(
5104 "[[id:A][one]]",
5105 &[],
5106 &[],
5107 &WhitespaceConfig::default(),
5108 &[],
5109 &r,
5110 );
5111 let dl = crate::display_matrix::DisplayLine {
5112 source_line: 0,
5113 text: Arc::from("one"),
5114 runs: Arc::from([] as [crate::display_matrix::DisplayRun; 0]),
5115 col_map: Arc::from(col_map.into_boxed_slice()),
5116 conceals: Arc::from(conceals.into_boxed_slice()),
5117 col_count,
5118 fold: None,
5119 };
5120 // `[[id:A][` is columns 0..8; every one of them resolves to 0.
5121 for c in 0..=8 {
5122 assert_eq!(dl.byte_to_combined_col(c), 0, "column {c}");
5123 }
5124 // `one` occupies columns 8..11 in the source → 0..3 on screen.
5125 assert_eq!(dl.byte_to_combined_col(9), 1);
5126 assert_eq!(dl.byte_to_combined_col(11), 3);
5127 }
5128
5129 #[test]
5130 fn h3_a_conceal_run_reaching_end_of_line_is_still_closed() {
5131 // The post-loop fixup: a range whose end is EOL never meets a
5132 // char past it, so the in-loop close never fires.
5133 let r = org_conceal_rules();
5134 let (text, conceals, _) = row("x [[id:A][y]]", &r);
5135 assert_eq!(text, "x y");
5136 assert_eq!(conceals.len(), 2);
5137 assert_eq!(conceals[1].1, 13, "the trailing ]] run is closed at EOL");
5138 }
5139
5140 #[test]
5141 fn display_build_parity_with_cells_ws_off() {
5142 use lattice_cells::cell_flags;
5143 let (resolved, ids) = test_cell_theme();
5144 let ct = CellTheme {
5145 resolved: &resolved,
5146 ids: &ids,
5147 };
5148 let (default_fg, default_flags) = resolve_style(ct, lattice_syntax::Style::Default);
5149 let ws = WhitespaceConfig::default(); // show: false
5150
5151 // "let\tx" — `let` styled as Keyword, a tab (expands), and an
5152 // inlay ": T" spliced after byte 5 (EOL).
5153 let text = "let\tx";
5154 let spans = vec![lattice_syntax::StyledSpan {
5155 start: 0,
5156 end: 3,
5157 style: lattice_syntax::Style::Keyword,
5158 }];
5159 let inlays: Vec<(u32, &str, lattice_syntax::Style)> =
5160 vec![(5, ": T", lattice_syntax::Style::InlayHint)];
5161
5162 let (cells, inlay_offsets) =
5163 build_row_cells(text, &spans, &inlays, ct, default_fg, default_flags, &ws);
5164 let (dtext, runs, col_map, _conceals, col_count) =
5165 build_display_row(text, &spans, &inlays, &ws, &[], &[]);
5166
5167 // Project display runs → cells (the ws-off resolution path).
5168 let mut projected: Vec<Cell> = Vec::new();
5169 let mut byte = 0usize;
5170 for run in &runs {
5171 let s = &dtext[byte..byte + run.len as usize];
5172 for ch in s.chars() {
5173 let (fg, flags) = if run.flags & cell_flags::INLAY != 0 {
5174 (resolve_style(ct, run.style).0, cell_flags::INLAY)
5175 } else if matches!(run.style, lattice_syntax::Style::Default) {
5176 (default_fg, default_flags)
5177 } else {
5178 resolve_style(ct, run.style)
5179 };
5180 projected.push(Cell::new(ch as u32, fg, 0, flags));
5181 }
5182 byte += run.len as usize;
5183 }
5184
5185 assert_eq!(
5186 projected, cells,
5187 "display→cells projection must equal build_row_cells output"
5188 );
5189 assert_eq!(col_map, inlay_offsets, "col_map must equal inlay_offsets");
5190 assert_eq!(
5191 col_count,
5192 cells.len() as u32,
5193 "col_count must equal the display cell count"
5194 );
5195 assert!(
5196 runs.iter()
5197 .any(|r| matches!(r.style, lattice_syntax::Style::Keyword)),
5198 "the `let` keyword run must carry the Keyword style tag"
5199 );
5200 assert!(
5201 runs.iter().any(|r| r.flags & cell_flags::INLAY != 0),
5202 "the inlay must produce an INLAY-flagged run"
5203 );
5204 }
5205
5206 /// B2.2: the full `DisplayMatrix → CellMatrix` projection must equal
5207 /// the cell builder byte-for-byte across the hard cases the
5208 /// projection has to reconstruct from runs alone — whitespace ON
5209 /// (leading / interior / trailing markers), tab expansion, AND an
5210 /// inlay splice. The trailing case is the load-bearing one: the
5211 /// default theme's trailing-whitespace fg is red, so a trailing
5212 /// marker's cell fg differs from a non-trailing one — the projection
5213 /// recovers that only via the `WS_TRAILING` run flag. A regression in
5214 /// either builder (or a missing `WS_TRAILING`) makes the projected
5215 /// cells diverge from `build_matrix` and fails here.
5216 /// PU.1b-2a: a buffer carrying static `ExtraHighlights` (here a Link
5217 /// span on line 0, bytes 0..2) gets that style baked into the
5218 /// `DisplayMatrix` runs the renderers read — overriding the grammar
5219 /// (none here; `syntax_handle = None`, exercising the no-grammar
5220 /// merge branch). Line 1 has no extra span, so it stays default:
5221 /// proof the merge is per-line and that empty lines are untouched
5222 /// (the empty-`extra_spans` case is byte-identical, covered by every
5223 /// existing matrix test that passes `&[]`).
5224 #[test]
5225 fn extra_spans_merge_into_display_matrix_runs() {
5226 let (resolved, ids) = test_cell_theme();
5227 let ct = CellTheme {
5228 resolved: &resolved,
5229 ids: &ids,
5230 };
5231 let ws = WhitespaceConfig {
5232 show: false,
5233 tab: None,
5234 trailing: None,
5235 leading: None,
5236 space: None,
5237 eol: None,
5238 tabstop: 4,
5239 };
5240 let snap = snap_of_versioned("hello\nworld", 1);
5241 let extra = vec![
5242 vec![lattice_syntax::StyledSpan {
5243 start: 0,
5244 end: 2,
5245 style: lattice_syntax::Style::Link,
5246 }],
5247 vec![],
5248 ];
5249 let dm = build_display_matrix(
5250 snap.as_ref(),
5251 None,
5252 &[],
5253 &extra,
5254 &[],
5255 ct,
5256 &[],
5257 &[],
5258 true,
5259 5,
5260 0,
5261 v(1),
5262 &ws,
5263 None,
5264 );
5265 let row0 = dm.row_at_source_line(0).expect("line 0 row");
5266 let first = row0.runs.first().expect("at least one run on line 0");
5267 assert_eq!(
5268 first.style,
5269 lattice_syntax::Style::Link,
5270 "the static Link span must reach the matrix run for line 0"
5271 );
5272 assert_eq!(first.len, 2, "the Link run should cover the 2-byte span");
5273 let row1 = dm.row_at_source_line(1).expect("line 1 row");
5274 assert!(
5275 row1.runs
5276 .iter()
5277 .all(|r| r.style != lattice_syntax::Style::Link),
5278 "line 1 carries no extra span, so it must have no Link styling"
5279 );
5280 }
5281
5282 #[test]
5283 fn projection_parity_ws_on_trailing_tab_inlay() {
5284 let (resolved, ids) = test_cell_theme();
5285 let ct = CellTheme {
5286 resolved: &resolved,
5287 ids: &ids,
5288 };
5289 // The default theme paints trailing whitespace red — assert that
5290 // so the parity check below genuinely exercises `WS_TRAILING`.
5291 // T.5: the trailing-whitespace style resolves through the
5292 // `whitespace.trailing` element (the old `Theme` style field is
5293 // gone), mirroring the production read at the `WS_TRAILING` site.
5294 let (default_fg, _) = resolve_style(ct, lattice_syntax::Style::Default);
5295 let trailing_fg = resolved
5296 .get(ids.whitespace_trailing)
5297 .fg
5298 .map(|c| c.to_rgb_u32(default_fg))
5299 .unwrap_or(default_fg);
5300 assert_ne!(
5301 trailing_fg, default_fg,
5302 "test premise: default theme trailing fg must differ from default fg"
5303 );
5304
5305 let ws = WhitespaceConfig {
5306 show: true,
5307 tab: Some('→'),
5308 trailing: Some('·'),
5309 leading: Some('▏'),
5310 space: Some('•'),
5311 eol: None,
5312 tabstop: 4,
5313 };
5314 // line 0: leading tab, "ab", two trailing spaces.
5315 // line 1: all-blank (every cell trailing).
5316 // line 2: "xy" + an inlay spliced after byte 1.
5317 let snap = snap_of_versioned("\tab \n \nxy", 1);
5318 let inlays = vec![inlay(2, 1, ": T")];
5319
5320 let cm = build_matrix(
5321 snap.as_ref(),
5322 None,
5323 ct,
5324 &inlays,
5325 &[],
5326 true,
5327 5, // whole-doc
5328 0,
5329 v(1),
5330 &ws,
5331 );
5332 let dm = build_display_matrix(
5333 snap.as_ref(),
5334 None,
5335 &[],
5336 &[],
5337 &[],
5338 ct,
5339 &inlays,
5340 &[],
5341 true,
5342 5,
5343 0,
5344 v(1),
5345 &ws,
5346 None,
5347 );
5348 let projected = display_matrix_to_cell_matrix(&dm, ct);
5349
5350 assert_eq!(projected.source_line_count, cm.source_line_count);
5351 assert_eq!(projected.is_whole_doc(), cm.is_whole_doc());
5352 let mut saw_trailing = false;
5353 for line in 0..cm.source_line_count {
5354 let a = cm.row_at_source_line(line).expect("cell row");
5355 let b = projected.row_at_source_line(line).expect("projected row");
5356 assert_eq!(
5357 a.cells, b.cells,
5358 "projected cells for line {line} must equal build_matrix"
5359 );
5360 assert_eq!(
5361 a.inlay_offsets, b.inlay_offsets,
5362 "projected inlay_offsets for line {line} must equal build_matrix"
5363 );
5364 saw_trailing |= b.cells.iter().any(|c| c.fg == trailing_fg);
5365 }
5366 assert!(
5367 saw_trailing,
5368 "scenario must produce trailing-fg cells (WS_TRAILING projection path)"
5369 );
5370 }
5371
5372 // ---- S2.3.c — fold elision ----
5373
5374 fn closed_fold(start: u32, end: u32) -> lattice_core::Fold {
5375 lattice_core::Fold {
5376 start_line: start,
5377 end_line: end,
5378 closed: true,
5379 identity: None,
5380 }
5381 }
5382
5383 fn open_fold(start: u32, end: u32) -> lattice_core::Fold {
5384 lattice_core::Fold {
5385 start_line: start,
5386 end_line: end,
5387 closed: false,
5388 identity: None,
5389 }
5390 }
5391
5392 /// A closed fold drops its interior source lines from the
5393 /// matrix. The fold's `start_line` stays visible — vim renders
5394 /// the marker there — and `source_line` on the next surviving
5395 /// row preserves its logical line index (so the renderer maps
5396 /// the click-target back to the source).
5397 #[test]
5398 fn closed_fold_elides_interior_lines() {
5399 let snap = snap_of_versioned("a\nb\nc\nd\ne", 1);
5400 let matrix_cell: Arc<ArcSwap<CellMatrix>> = Arc::default();
5401 // Fold lines 1..3 — interior lines 2, 3 are elided; line
5402 // 1 (start) stays.
5403 let folds = vec![closed_fold(1, 3)];
5404 let rs = rs_with_snapshot_full_folded(
5405 Some(snap),
5406 v(1),
5407 matrix_cell.clone(),
5408 None,
5409 Vec::new(),
5410 folds,
5411 true,
5412 );
5413 assert_eq!(recompute(&rs), WorkerDecision::Recomputed);
5414
5415 let m = matrix_cell.load();
5416 // source_line_count is preserved (pre-fold logical count).
5417 assert_eq!(m.source_line_count, 5);
5418 // visible_line_count post-fold: 5 - 2 elided = 3 rows.
5419 assert_eq!(m.visible_line_count, 3);
5420 let source_lines: Vec<u32> = m.slice(0, 10).iter().map(|r| r.source_line).collect();
5421 assert_eq!(source_lines, vec![0, 1, 4]);
5422 }
5423
5424 /// An OPEN fold does not elide its interior. The presence of a
5425 /// fold range in the list is not enough — only `closed = true`
5426 /// participates.
5427 #[test]
5428 fn open_fold_does_not_elide() {
5429 let snap = snap_of_versioned("a\nb\nc", 1);
5430 let matrix_cell: Arc<ArcSwap<CellMatrix>> = Arc::default();
5431 let folds = vec![open_fold(0, 2)];
5432 let rs = rs_with_snapshot_full_folded(
5433 Some(snap),
5434 v(1),
5435 matrix_cell.clone(),
5436 None,
5437 Vec::new(),
5438 folds,
5439 true,
5440 );
5441 assert_eq!(recompute(&rs), WorkerDecision::Recomputed);
5442 let m = matrix_cell.load();
5443 assert_eq!(m.visible_line_count, 3);
5444 let source_lines: Vec<u32> = m.slice(0, 10).iter().map(|r| r.source_line).collect();
5445 assert_eq!(source_lines, vec![0, 1, 2]);
5446 }
5447
5448 /// `foldenable = false` disables elision even with closed folds
5449 /// in the list — `zi` (toggle) produces the unfolded matrix
5450 /// from the same payload without re-touching the fold list.
5451 #[test]
5452 fn foldenable_off_disables_elision() {
5453 let snap = snap_of_versioned("a\nb\nc", 1);
5454 let matrix_cell: Arc<ArcSwap<CellMatrix>> = Arc::default();
5455 let folds = vec![closed_fold(0, 2)];
5456 let rs = rs_with_snapshot_full_folded(
5457 Some(snap),
5458 v(1),
5459 matrix_cell.clone(),
5460 None,
5461 Vec::new(),
5462 folds,
5463 false,
5464 );
5465 assert_eq!(recompute(&rs), WorkerDecision::Recomputed);
5466 let m = matrix_cell.load();
5467 assert_eq!(m.visible_line_count, 3, "no elision when foldenable=false");
5468 }
5469
5470 /// Two non-overlapping closed folds both elide their interiors.
5471 /// Establishes that the FoldIndex's `partition_point` walk
5472 /// handles multiple folds correctly (the worker just calls
5473 /// `line_inside_closed_fold` per line).
5474 #[test]
5475 fn multiple_closed_folds_elide_independently() {
5476 let snap = snap_of_versioned("a\nb\nc\nd\ne\nf\ng", 1);
5477 let matrix_cell: Arc<ArcSwap<CellMatrix>> = Arc::default();
5478 // Fold lines 0..2 + 4..6. Visible: 0, 3, 4 (start of 2nd
5479 // fold).
5480 let folds = vec![closed_fold(0, 2), closed_fold(4, 6)];
5481 let rs = rs_with_snapshot_full_folded(
5482 Some(snap),
5483 v(1),
5484 matrix_cell.clone(),
5485 None,
5486 Vec::new(),
5487 folds,
5488 true,
5489 );
5490 assert_eq!(recompute(&rs), WorkerDecision::Recomputed);
5491 let m = matrix_cell.load();
5492 let source_lines: Vec<u32> = m.slice(0, 10).iter().map(|r| r.source_line).collect();
5493 assert_eq!(source_lines, vec![0, 3, 4]);
5494 }
5495
5496 /// Theme axis bump rebuilds the matrix even with identical
5497 /// text + syntax. Validates that `MatrixVersion::theme`
5498 /// participates in `differs_from`.
5499 #[test]
5500 fn theme_version_bump_triggers_rebuild() {
5501 let snap = snap_of_versioned("ab", 1);
5502 let matrix_cell: Arc<ArcSwap<CellMatrix>> = Arc::default();
5503 let v_a = MatrixVersion {
5504 text: 1,
5505 syntax: 1,
5506 inlay_hints: 0,
5507 folds: 0,
5508 theme: 0xaa,
5509 whitespace: 0,
5510 indent: 0,
5511 conceal: 0,
5512 };
5513 let v_b = MatrixVersion { theme: 0xbb, ..v_a };
5514
5515 let rs1 = rs_with_snapshot_themed(Some(snap.clone()), v_a, matrix_cell.clone(), None);
5516 assert_eq!(recompute(&rs1), WorkerDecision::Recomputed);
5517 let first_ptr = Arc::as_ptr(&matrix_cell.load_full());
5518
5519 // Repeat with the same version: cache-hit, no store.
5520 assert_eq!(recompute(&rs1), WorkerDecision::CacheHit);
5521 assert_eq!(first_ptr, Arc::as_ptr(&matrix_cell.load_full()));
5522
5523 // Bump only the theme axis: must rebuild.
5524 let rs2 = rs_with_snapshot_themed(Some(snap), v_b, matrix_cell.clone(), None);
5525 assert_eq!(recompute(&rs2), WorkerDecision::Recomputed);
5526 assert_ne!(first_ptr, Arc::as_ptr(&matrix_cell.load_full()));
5527 }
5528
5529 // ---- S2.4.b — incremental rebuild ----
5530
5531 /// `old_end_at_bol: true` — every existing caller of this helper
5532 /// models a CLEAN line-boundary edit (whole-line insert/delete/
5533 /// replace where the old range, when `removed > 0`, ends exactly
5534 /// at BOL of the line after, e.g. `dd`-style deletion including
5535 /// trailing newlines). Table-mode's / org's mid-line-boundary
5536 /// shape (`old_end_at_bol: false`) is exercised by
5537 /// [`edit_delta_mid_line_boundary`], which the last-row rebuild
5538 /// regression tests use.
5539 fn edit_delta(start: u32, removed: u32, added: u32) -> lattice_cells::EditDelta {
5540 lattice_cells::EditDelta {
5541 start_line: start,
5542 lines_removed: removed,
5543 lines_added: added,
5544 old_end_at_bol: true,
5545 }
5546 }
5547
5548 /// Sibling of [`edit_delta`] for edits whose OLD range ends
5549 /// partway into (or at the EOL of) its last affected line —
5550 /// table-mode's `rewrite()` and org's `replace_lines` shape. This
5551 /// is the shape that under-counted `suffix_lo` by one row and
5552 /// left the last replaced row stale until a forced redraw.
5553 fn edit_delta_mid_line_boundary(
5554 start: u32,
5555 removed: u32,
5556 added: u32,
5557 ) -> lattice_cells::EditDelta {
5558 lattice_cells::EditDelta {
5559 start_line: start,
5560 lines_removed: removed,
5561 lines_added: added,
5562 old_end_at_bol: false,
5563 }
5564 }
5565
5566 /// Whole-doc mode + single-text-edit takes the incremental
5567 /// branch (`RecomputedIncremental`), even though whole-doc has
5568 /// nothing to reuse — the eligibility check passes and the
5569 /// branch produces a correct matrix.
5570 #[test]
5571 fn whole_doc_with_edit_takes_incremental_branch() {
5572 let matrix_cell: Arc<ArcSwap<CellMatrix>> = Arc::default();
5573
5574 // First publish: 3-line doc at text_version 1, no edit.
5575 let snap1 = snap_of_versioned("aa\nbb\ncc", 1);
5576 let v1 = MatrixVersion {
5577 text: 1,
5578 syntax: 1,
5579 ..MatrixVersion::ZERO
5580 };
5581 let rs1 = rs_with_everything(
5582 Some(snap1),
5583 v1,
5584 matrix_cell.clone(),
5585 None,
5586 Vec::new(),
5587 Vec::new(),
5588 true,
5589 None,
5590 5, // small viewport ⇒ whole-doc
5591 );
5592 assert_eq!(recompute(&rs1), WorkerDecision::Recomputed);
5593 assert!(matrix_cell.load().is_whole_doc());
5594
5595 // Second publish: insert a line at line 1; text_version → 2.
5596 let snap2 = snap_of_versioned("aa\nNEW\nbb\ncc", 2);
5597 let v2 = MatrixVersion {
5598 text: 2,
5599 syntax: 2,
5600 ..MatrixVersion::ZERO
5601 };
5602 let edit = edit_delta(1, 0, 1);
5603 let rs2 = rs_with_everything(
5604 Some(snap2),
5605 v2,
5606 matrix_cell.clone(),
5607 None,
5608 Vec::new(),
5609 Vec::new(),
5610 true,
5611 Some(edit),
5612 5,
5613 );
5614 assert_eq!(recompute(&rs2), WorkerDecision::RecomputedIncremental);
5615 let m = matrix_cell.load();
5616 assert!(m.is_whole_doc());
5617 assert_eq!(m.source_line_count, 4);
5618 let row_texts: Vec<String> = m
5619 .slice(0, 10)
5620 .iter()
5621 .map(|r| {
5622 r.cells
5623 .iter()
5624 .map(|c| char::from_u32(c.codepoint).unwrap_or('?'))
5625 .collect()
5626 })
5627 .collect();
5628 assert_eq!(row_texts, vec!["aa", "NEW", "bb", "cc"]);
5629 }
5630
5631 /// 2026-06-05 REGRESSION (intra-line text lag). A PURE intra-line edit
5632 /// — inserting a char without crossing a newline, `EditDelta {removed:0,
5633 /// added:0}` — has `pre_edit_end_line() == start_line == the edited
5634 /// line`. The row-reuse partition used to classify that line as the
5635 /// unchanged SUFFIX and reuse its prior row VERBATIM while stamping the
5636 /// matrix version current, so the renderer's per-line staleness fallback
5637 /// never fired and it painted PRE-edit text for a frame (`|word` →
5638 /// `w|ord` → ` |word`; the felt "one key behind" typing lag on every
5639 /// keystroke). The edited line MUST be rebuilt from the current snapshot;
5640 /// unchanged lines still reuse their `text` Arc (colour preserved). The
5641 /// sibling `whole_doc_incremental_reuses_unchanged_rows` covers the
5642 /// STRUCTURAL edit (`{0,1}`, a clean line insert) where the boundary line
5643 /// only SHIFTS and must NOT regress to a rebuild — which is exactly why
5644 /// the fix is gated on `removed == added == 0`.
5645 #[test]
5646 fn whole_doc_incremental_rebuilds_intra_line_edited_row() {
5647 let matrix_cell: Arc<ArcSwap<CellMatrix>> = Arc::default();
5648
5649 let snap1 = snap_of_versioned("word\nbb\ncc", 1);
5650 let v1 = MatrixVersion {
5651 text: 1,
5652 syntax: 1,
5653 ..MatrixVersion::ZERO
5654 };
5655 let rs1 = rs_with_everything(
5656 Some(snap1),
5657 v1,
5658 matrix_cell.clone(),
5659 None,
5660 Vec::new(),
5661 Vec::new(),
5662 true,
5663 None,
5664 5, // whole-doc
5665 );
5666 assert_eq!(recompute(&rs1), WorkerDecision::Recomputed);
5667 let dm_cell = display_cell_for(&matrix_cell);
5668 let d1 = dm_cell.load_full();
5669 assert_eq!(&*d1.row_at_source_line(0).unwrap().text, "word");
5670 let bb_pre = Arc::clone(&d1.row_at_source_line(1).unwrap().text);
5671 let cc_pre = Arc::clone(&d1.row_at_source_line(2).unwrap().text);
5672
5673 // Insert a space at col 0 of line 0 → " word\nbb\ncc". Pure
5674 // intra-line: no newline crossed ⇒ EditDelta {start:0, removed:0,
5675 // added:0} (line count unchanged). This is the keystroke shape.
5676 let snap2 = snap_of_versioned(" word\nbb\ncc", 2);
5677 let v2 = MatrixVersion {
5678 text: 2,
5679 syntax: 2,
5680 ..MatrixVersion::ZERO
5681 };
5682 let edit = edit_delta(0, 0, 0);
5683 let rs2 = rs_with_everything(
5684 Some(snap2),
5685 v2,
5686 matrix_cell.clone(),
5687 None,
5688 Vec::new(),
5689 Vec::new(),
5690 true,
5691 Some(edit),
5692 5,
5693 );
5694 assert_eq!(recompute(&rs2), WorkerDecision::RecomputedIncremental);
5695 let d2 = dm_cell.load_full();
5696 // THE REGRESSION ASSERTION: the edited row is text-CURRENT, not the
5697 // reused-stale "word". Pre-fix this was "word" and the typed space
5698 // lagged a frame behind the cursor.
5699 assert_eq!(
5700 &*d2.row_at_source_line(0).unwrap().text,
5701 " word",
5702 "intra-line edited row must rebuild to the CURRENT text, never reuse the stale pre-edit row"
5703 );
5704 // Unchanged lines still reuse their prior row Arc (colour preserved —
5705 // no whole-viewport recolour flicker).
5706 assert!(
5707 Arc::ptr_eq(&bb_pre, &d2.row_at_source_line(1).unwrap().text),
5708 "unchanged line 1 must reuse its prior DisplayLine text Arc"
5709 );
5710 assert!(
5711 Arc::ptr_eq(&cc_pre, &d2.row_at_source_line(2).unwrap().text),
5712 "unchanged line 2 must reuse its prior DisplayLine text Arc"
5713 );
5714 }
5715
5716 /// Regression (2026-07-03): `o` (open line below) visually duplicated
5717 /// the current line. `o` inserts `\n` at END of line L, producing
5718 /// `EditDelta {start: L, removed: 0, added: 1}` — a PURE insert whose
5719 /// start line's content is unchanged but which creates a new blank
5720 /// line at L+1. The old partition set `suffix_lo = pre_edit_end_line()
5721 /// == L`, so the prior row L ("word") was reused-and-shifted to L+1
5722 /// (where the new blank line belongs) AND rebuilt at L — the line
5723 /// appeared twice. The async worker's full rebuild corrected it a
5724 /// frame later, so it read as an occasional flicker.
5725 #[test]
5726 fn whole_doc_incremental_open_line_below_does_not_duplicate() {
5727 let matrix_cell: Arc<ArcSwap<CellMatrix>> = Arc::default();
5728
5729 let snap1 = snap_of_versioned("word\nbb\ncc", 1);
5730 let v1 = MatrixVersion {
5731 text: 1,
5732 syntax: 1,
5733 ..MatrixVersion::ZERO
5734 };
5735 let rs1 = rs_with_everything(
5736 Some(snap1),
5737 v1,
5738 matrix_cell.clone(),
5739 None,
5740 Vec::new(),
5741 Vec::new(),
5742 true,
5743 None,
5744 5, // whole-doc
5745 );
5746 assert_eq!(recompute(&rs1), WorkerDecision::Recomputed);
5747 let dm_cell = display_cell_for(&matrix_cell);
5748
5749 // `o` on line 0: insert `\n` at EOL of "word" → "word\n\nbb\ncc".
5750 // The new blank line is at source line 1; "bb"/"cc" shift down.
5751 let snap2 = snap_of_versioned("word\n\nbb\ncc", 2);
5752 let v2 = MatrixVersion {
5753 text: 2,
5754 syntax: 2,
5755 ..MatrixVersion::ZERO
5756 };
5757 let edit = edit_delta(0, 0, 1); // start:0, removed:0, added:1
5758 let rs2 = rs_with_everything(
5759 Some(snap2),
5760 v2,
5761 matrix_cell.clone(),
5762 None,
5763 Vec::new(),
5764 Vec::new(),
5765 true,
5766 Some(edit),
5767 5,
5768 );
5769 assert_eq!(recompute(&rs2), WorkerDecision::RecomputedIncremental);
5770 let d2 = dm_cell.load_full();
5771 assert_eq!(
5772 &*d2.row_at_source_line(0).unwrap().text,
5773 "word",
5774 "line 0 is unchanged"
5775 );
5776 assert_eq!(
5777 &*d2.row_at_source_line(1).unwrap().text,
5778 "",
5779 "the newly-opened line must render EMPTY, not a duplicate of line 0"
5780 );
5781 assert_eq!(
5782 &*d2.row_at_source_line(2).unwrap().text,
5783 "bb",
5784 "old line 1 (bb) shifts to source line 2"
5785 );
5786 assert_eq!(
5787 &*d2.row_at_source_line(3).unwrap().text,
5788 "cc",
5789 "old line 2 (cc) shifts to source line 3"
5790 );
5791 }
5792
5793 /// 2026-09-06 REGRESSION (table-align last-row stale-until-`<C-l>`).
5794 /// Table-mode's `<Tab>` align (and org's `replace_lines`, used by
5795 /// every promote/demote/move) issue a whole-line-count-preserving
5796 /// `Replace` whose OLD range ends at EOL of its own last line — NOT
5797 /// at BOL of the line after. For a 3-line whole-doc table that's
5798 /// `EditDelta {start: 0, removed: 2, added: 2, old_end_at_bol:
5799 /// false}`. Before the fix, `suffix_lo` was computed as
5800 /// `pre_edit_end_line() == 2` — the table's LAST row — so the
5801 /// incremental rebuild classified it as an untouched suffix, reused
5802 /// its PRE-edit `DisplayLine` verbatim, and stamped the matrix
5803 /// version current. The stale row then never repainted until a full
5804 /// (`<C-l>`-forced) rebuild. Asserts on the produced `DisplayMatrix`
5805 /// row text — a buffer-text assertion would pass on the broken
5806 /// product, since the alignment itself was always correct.
5807 #[test]
5808 fn whole_doc_incremental_rebuilds_last_row_of_mid_line_boundary_replace() {
5809 let matrix_cell: Arc<ArcSwap<CellMatrix>> = Arc::default();
5810
5811 let snap1 = snap_of_versioned("r0\nr1\nr2", 1);
5812 let v1 = MatrixVersion {
5813 text: 1,
5814 syntax: 1,
5815 ..MatrixVersion::ZERO
5816 };
5817 let rs1 = rs_with_everything(
5818 Some(snap1),
5819 v1,
5820 matrix_cell.clone(),
5821 None,
5822 Vec::new(),
5823 Vec::new(),
5824 true,
5825 None,
5826 5, // whole-doc
5827 );
5828 assert_eq!(recompute(&rs1), WorkerDecision::Recomputed);
5829 let dm_cell = display_cell_for(&matrix_cell);
5830
5831 // Table-align's shape: same row count, every row's content
5832 // changes, OLD range ends at EOL of the last row (r2), not BOL
5833 // of a following line.
5834 let snap2 = snap_of_versioned("R0\nR1\nR2", 2);
5835 let v2 = MatrixVersion {
5836 text: 2,
5837 syntax: 2,
5838 ..MatrixVersion::ZERO
5839 };
5840 let edit = edit_delta_mid_line_boundary(0, 2, 2);
5841 let rs2 = rs_with_everything(
5842 Some(snap2),
5843 v2,
5844 matrix_cell.clone(),
5845 None,
5846 Vec::new(),
5847 Vec::new(),
5848 true,
5849 Some(edit),
5850 5,
5851 );
5852 assert_eq!(recompute(&rs2), WorkerDecision::RecomputedIncremental);
5853 let d2 = dm_cell.load_full();
5854 assert_eq!(&*d2.row_at_source_line(0).unwrap().text, "R0");
5855 assert_eq!(&*d2.row_at_source_line(1).unwrap().text, "R1");
5856 assert_eq!(
5857 &*d2.row_at_source_line(2).unwrap().text,
5858 "R2",
5859 "the LAST row of a whole-line multi-line replace must rebuild from \
5860 the new snapshot, not reuse the stale pre-edit row — this is the \
5861 table-align 'last row does not repaint until <C-l>' bug"
5862 );
5863 }
5864
5865 /// General-case sibling of the table-align regression above: the
5866 /// mid-line-boundary replace need not span the WHOLE document.
5867 /// Org's `replace_lines` helper (used by every promote/demote/move)
5868 /// produces this exact shape on an interior range of a larger
5869 /// buffer, leaving genuine untouched lines both before and after
5870 /// it. Confirms: (1) the boundary line at the end of the replaced
5871 /// range still rebuilds (not just in the whole-doc-with-no-suffix
5872 /// case above); (2) a genuinely untouched prefix line and a
5873 /// genuinely untouched suffix line both still reuse their prior
5874 /// `DisplayLine` `Arc` — the fix must not regress into rebuilding
5875 /// everything.
5876 #[test]
5877 fn whole_doc_incremental_rebuilds_mid_line_boundary_replace_with_real_suffix() {
5878 let matrix_cell: Arc<ArcSwap<CellMatrix>> = Arc::default();
5879
5880 let snap1 = snap_of_versioned("top\nr0\nr1\nr2\nbottom", 1);
5881 let v1 = MatrixVersion {
5882 text: 1,
5883 syntax: 1,
5884 ..MatrixVersion::ZERO
5885 };
5886 let rs1 = rs_with_everything(
5887 Some(snap1),
5888 v1,
5889 matrix_cell.clone(),
5890 None,
5891 Vec::new(),
5892 Vec::new(),
5893 true,
5894 None,
5895 5, // whole-doc
5896 );
5897 assert_eq!(recompute(&rs1), WorkerDecision::Recomputed);
5898 let dm_cell = display_cell_for(&matrix_cell);
5899 let d1 = dm_cell.load_full();
5900 let top_pre = Arc::clone(&d1.row_at_source_line(0).unwrap().text);
5901 let bottom_pre = Arc::clone(&d1.row_at_source_line(4).unwrap().text);
5902
5903 // Replace the interior 3-line range [1, 3] (r0, r1, r2) with
5904 // R0/R1/R2. OLD range ends at EOL of line 3 (r2), not BOL of
5905 // line 4 (bottom) — the mid-line-boundary shape.
5906 let snap2 = snap_of_versioned("top\nR0\nR1\nR2\nbottom", 2);
5907 let v2 = MatrixVersion {
5908 text: 2,
5909 syntax: 2,
5910 ..MatrixVersion::ZERO
5911 };
5912 let edit = edit_delta_mid_line_boundary(1, 2, 2);
5913 let rs2 = rs_with_everything(
5914 Some(snap2),
5915 v2,
5916 matrix_cell.clone(),
5917 None,
5918 Vec::new(),
5919 Vec::new(),
5920 true,
5921 Some(edit),
5922 5,
5923 );
5924 assert_eq!(recompute(&rs2), WorkerDecision::RecomputedIncremental);
5925 let d2 = dm_cell.load_full();
5926 assert!(
5927 Arc::ptr_eq(&top_pre, &d2.row_at_source_line(0).unwrap().text),
5928 "genuinely untouched prefix line must still reuse its prior Arc"
5929 );
5930 assert_eq!(&*d2.row_at_source_line(1).unwrap().text, "R0");
5931 assert_eq!(&*d2.row_at_source_line(2).unwrap().text, "R1");
5932 assert_eq!(
5933 &*d2.row_at_source_line(3).unwrap().text,
5934 "R2",
5935 "the last row of the replaced range must rebuild from the new \
5936 snapshot, not reuse the stale pre-edit row"
5937 );
5938 assert!(
5939 Arc::ptr_eq(&bottom_pre, &d2.row_at_source_line(4).unwrap().text),
5940 "genuinely untouched suffix line must still reuse its prior Arc \
5941 (the fix must not regress into rebuilding everything)"
5942 );
5943 }
5944
5945 /// 2026-06-04: whole-doc incremental rebuild must REUSE the prior
5946 /// chunk's `DisplayLine`s for unchanged lines (same `text` Arc),
5947 /// not rebuild them — that reuse is what keeps their syntax colours
5948 /// through the post-edit window when `per_line_spans` lags (the
5949 /// markdown whole-viewport stutter). Asserts Arc identity of an
5950 /// unchanged prefix row and a shifted suffix row across the edit.
5951 /// B2.2: the reuse guarantee moved from the cell grid (now
5952 /// re-projected each rebuild) to the canonical `DisplayMatrix`.
5953 #[test]
5954 fn whole_doc_incremental_reuses_unchanged_rows() {
5955 let matrix_cell: Arc<ArcSwap<CellMatrix>> = Arc::default();
5956
5957 let snap1 = snap_of_versioned("aa\nbb\ncc", 1);
5958 let v1 = MatrixVersion {
5959 text: 1,
5960 syntax: 1,
5961 ..MatrixVersion::ZERO
5962 };
5963 let rs1 = rs_with_everything(
5964 Some(snap1),
5965 v1,
5966 matrix_cell.clone(),
5967 None,
5968 Vec::new(),
5969 Vec::new(),
5970 true,
5971 None,
5972 5, // whole-doc
5973 );
5974 assert_eq!(recompute(&rs1), WorkerDecision::Recomputed);
5975 // B2.2: the row-reuse guarantee lives on the canonical
5976 // `DisplayMatrix` now (the cell grid is re-projected each
5977 // rebuild, so cell-row Arcs intentionally differ). Assert the
5978 // unchanged `DisplayLine`s reuse their `text` Arc across the
5979 // edit — that reuse is what keeps their colours through the
5980 // post-edit window.
5981 let dm_cell = display_cell_for(&matrix_cell);
5982 let d1 = dm_cell.load_full();
5983 let aa_pre = Arc::clone(&d1.row_at_source_line(0).unwrap().text);
5984 let cc_pre = Arc::clone(&d1.row_at_source_line(2).unwrap().text);
5985
5986 // Insert a line at line 1 → "aa\nNEW\nbb\ncc".
5987 let snap2 = snap_of_versioned("aa\nNEW\nbb\ncc", 2);
5988 let v2 = MatrixVersion {
5989 text: 2,
5990 syntax: 2,
5991 ..MatrixVersion::ZERO
5992 };
5993 let edit = edit_delta(1, 0, 1);
5994 let rs2 = rs_with_everything(
5995 Some(snap2),
5996 v2,
5997 matrix_cell.clone(),
5998 None,
5999 Vec::new(),
6000 Vec::new(),
6001 true,
6002 Some(edit),
6003 5,
6004 );
6005 assert_eq!(recompute(&rs2), WorkerDecision::RecomputedIncremental);
6006 let d2 = dm_cell.load_full();
6007 assert!(
6008 Arc::ptr_eq(&aa_pre, &d2.row_at_source_line(0).unwrap().text),
6009 "unchanged prefix row (line 0) must reuse the prior DisplayLine text Arc — keeps its colours"
6010 );
6011 // The row immediately after the insert start (`start_line == 1`, i.e.
6012 // old line 1 "bb" → new line 2) is now REBUILT, not reused: a pure
6013 // insert's `EditDelta {start:1, removed:0, added:1}` is ambiguous —
6014 // the same delta is produced by "insert a full line at BOL of line 1"
6015 // (old "bb" shifts unchanged) and by `o` on line 1 (old "bb" stays,
6016 // new blank appears after). Since they can't be told apart without
6017 // column info, the boundary line is rebuilt from the new snapshot so
6018 // content is always correct (the open-line-dup fix); its colour catches
6019 // up on the async worker's next pass. Rows STRICTLY past the boundary
6020 // are unambiguously shifted and still Arc-reuse — assert "cc"
6021 // (old line 2 → new line 3) keeps its prior text Arc.
6022 assert!(
6023 Arc::ptr_eq(&cc_pre, &d2.row_at_source_line(3).unwrap().text),
6024 "shifted suffix row (\"cc\": line 2 → 3) must reuse the prior DisplayLine text Arc — keeps its colours"
6025 );
6026 }
6027
6028 /// B2.3 (2026-06-04): the synchronous edit-path rebuild
6029 /// (`sync_rebuild_pane_on_edit`) makes the canonical `DisplayMatrix`
6030 /// text-current (`version.text` == the post-edit snapshot) WITHOUT
6031 /// highlighting — even with a *current* syntax handle attached the
6032 /// rebuilt line's runs are all `Style::Default`, proving the
6033 /// `allow_highlight: false` path keeps `highlight_lines` off the
6034 /// edit-critical actor thread. Unchanged lines `Arc`-reuse their prior
6035 /// `DisplayLine` (keeping whatever colour they had).
6036 #[tokio::test]
6037 async fn sync_rebuild_on_edit_is_text_current_and_unhighlighted() {
6038 let matrix_cell: Arc<ArcSwap<CellMatrix>> = Arc::default();
6039
6040 // v1: plain seed (no handle) → prior whole-doc display matrix.
6041 let snap1 = snap_of_versioned("let a = 1;\nlet b = 2;\nlet c = 3;\n", 1);
6042 let v1 = MatrixVersion {
6043 text: 1,
6044 syntax: 1,
6045 ..MatrixVersion::ZERO
6046 };
6047 let rs1 = rs_with_everything(
6048 Some(snap1),
6049 v1,
6050 matrix_cell.clone(),
6051 None,
6052 Vec::new(),
6053 Vec::new(),
6054 true,
6055 None,
6056 5,
6057 );
6058 assert_eq!(recompute(&rs1), WorkerDecision::Recomputed);
6059 let dm_cell = display_cell_for(&matrix_cell);
6060 let row0_pre = Arc::clone(&dm_cell.load_full().row_at_source_line(0).unwrap().text);
6061
6062 // v2: edit line 1 in place → introduces `fn`. Attach a Rust handle
6063 // parsed + seeded at v2 so syntax IS available and current — the
6064 // sync path must STILL not colour the rebuilt line.
6065 let text2 = "let a = 1;\nfn b() {}\nlet c = 3;\n";
6066 let mut s = lattice_syntax::Syntax::for_language(lattice_syntax::Lang::Rust)
6067 .unwrap()
6068 .unwrap();
6069 s.parse_at(text2, 2);
6070 let handle = Arc::new(lattice_syntax::SyntaxHandle::seeded_with_runtime(
6071 s,
6072 &tokio::runtime::Handle::current(),
6073 None,
6074 ));
6075 let snap2 = snap_of_versioned(text2, 2);
6076 let v2 = MatrixVersion {
6077 text: 2,
6078 syntax: 2,
6079 ..MatrixVersion::ZERO
6080 };
6081 let edit = edit_delta(1, 1, 1);
6082 let rs2 = rs_with_everything(
6083 Some(snap2),
6084 v2,
6085 matrix_cell.clone(),
6086 Some(handle),
6087 Vec::new(),
6088 Vec::new(),
6089 true,
6090 Some(edit),
6091 5,
6092 );
6093 let loaded = rs2.load_full();
6094 let cells = loaded.cells.load();
6095 let pane = &cells.panes[0];
6096 assert!(
6097 sync_rebuild_pane_on_edit(
6098 pane,
6099 CellTheme {
6100 resolved: &cells.resolved_theme,
6101 ids: &cells.theme_ids,
6102 },
6103 &cells.whitespace
6104 ),
6105 "single in-place edit is eligible for the sync rebuild"
6106 );
6107
6108 let dm = dm_cell.load_full();
6109 assert_eq!(
6110 dm.version.text, 2,
6111 "display matrix is text-current after the sync rebuild"
6112 );
6113 let row1 = dm.row_at_source_line(1).unwrap();
6114 assert_eq!(&*row1.text, "fn b() {}");
6115 assert!(
6116 row1.runs
6117 .iter()
6118 .all(|r| matches!(r.style, lattice_syntax::Style::Default)),
6119 "sync rebuild must NOT highlight — all runs default-styled despite a current syntax handle"
6120 );
6121 assert!(
6122 Arc::ptr_eq(&row0_pre, &dm.row_at_source_line(0).unwrap().text),
6123 "unchanged prefix row reuses its prior DisplayLine (keeps its colour)"
6124 );
6125 }
6126
6127 /// B2.3: a non-edit publish (no `last_edit`) is ineligible for the
6128 /// sync rebuild — it returns `false` and leaves `display_matrix`
6129 /// untouched, deferring to the async worker's full/highlighted build.
6130 #[test]
6131 fn sync_rebuild_skips_non_edit_publish() {
6132 let matrix_cell: Arc<ArcSwap<CellMatrix>> = Arc::default();
6133 let snap1 = snap_of_versioned("aa\nbb\ncc", 1);
6134 let v1 = MatrixVersion {
6135 text: 1,
6136 syntax: 1,
6137 ..MatrixVersion::ZERO
6138 };
6139 let rs1 = rs_with_everything(
6140 Some(snap1),
6141 v1,
6142 matrix_cell.clone(),
6143 None,
6144 Vec::new(),
6145 Vec::new(),
6146 true,
6147 None,
6148 5,
6149 );
6150 assert_eq!(recompute(&rs1), WorkerDecision::Recomputed);
6151 let dm_cell = display_cell_for(&matrix_cell);
6152 let before = Arc::as_ptr(&dm_cell.load_full());
6153
6154 // A theme-only republish (no last_edit): bump the theme axis,
6155 // keep text/snapshot identical.
6156 let snap2 = snap_of_versioned("aa\nbb\ncc", 1);
6157 let v2 = MatrixVersion {
6158 text: 1,
6159 syntax: 1,
6160 theme: 99,
6161 ..MatrixVersion::ZERO
6162 };
6163 let rs2 = rs_with_everything(
6164 Some(snap2),
6165 v2,
6166 matrix_cell.clone(),
6167 None,
6168 Vec::new(),
6169 Vec::new(),
6170 true,
6171 None,
6172 5,
6173 );
6174 let loaded = rs2.load_full();
6175 let cells = loaded.cells.load();
6176 let pane = &cells.panes[0];
6177 assert!(
6178 !sync_rebuild_pane_on_edit(
6179 pane,
6180 CellTheme {
6181 resolved: &cells.resolved_theme,
6182 ids: &cells.theme_ids,
6183 },
6184 &cells.whitespace
6185 ),
6186 "non-edit publish is ineligible for the sync rebuild"
6187 );
6188 assert_eq!(
6189 before,
6190 Arc::as_ptr(&dm_cell.load_full()),
6191 "ineligible sync rebuild must not touch display_matrix"
6192 );
6193 }
6194
6195 /// B2.3: after the actor's sync rebuild makes `display_matrix`
6196 /// text-current but leaves the projected cell grid a frame behind (the
6197 /// projection stays off the actor thread per the threading guarantee),
6198 /// the async worker's next `recompute` reconciles the lagging cells —
6199 /// projecting the current display matrix into `pane.matrix` and
6200 /// reporting `Recomputed` so the cell renderers repaint current content.
6201 #[test]
6202 fn worker_projects_lagging_cells_after_sync_rebuild() {
6203 let matrix_cell: Arc<ArcSwap<CellMatrix>> = Arc::default();
6204 let snap1 = snap_of_versioned("aa\nbb\ncc", 1);
6205 let v1 = MatrixVersion {
6206 text: 1,
6207 syntax: 1,
6208 ..MatrixVersion::ZERO
6209 };
6210 let rs1 = rs_with_everything(
6211 Some(snap1),
6212 v1,
6213 matrix_cell.clone(),
6214 None,
6215 Vec::new(),
6216 Vec::new(),
6217 true,
6218 None,
6219 5,
6220 );
6221 assert_eq!(recompute(&rs1), WorkerDecision::Recomputed);
6222 assert_eq!(matrix_cell.load().version.text, 1);
6223
6224 // Edit: insert a line. Build rs2 and run ONLY the sync rebuild
6225 // (mimicking the actor publish tail) — it updates display_matrix
6226 // but deliberately not the cell grid. `syntax: 1` mirrors reality:
6227 // the reparse hasn't landed at edit-publish time.
6228 let snap2 = snap_of_versioned("aa\nNEW\nbb\ncc", 2);
6229 let v2 = MatrixVersion {
6230 text: 2,
6231 syntax: 1,
6232 ..MatrixVersion::ZERO
6233 };
6234 let edit = edit_delta(1, 0, 1);
6235 let rs2 = rs_with_everything(
6236 Some(snap2),
6237 v2,
6238 matrix_cell.clone(),
6239 None,
6240 Vec::new(),
6241 Vec::new(),
6242 true,
6243 Some(edit),
6244 5,
6245 );
6246 {
6247 let loaded = rs2.load_full();
6248 let cells = loaded.cells.load();
6249 let pane = &cells.panes[0];
6250 assert!(sync_rebuild_pane_on_edit(
6251 pane,
6252 CellTheme {
6253 resolved: &cells.resolved_theme,
6254 ids: &cells.theme_ids,
6255 },
6256 &cells.whitespace
6257 ));
6258 }
6259 let dm_cell = display_cell_for(&matrix_cell);
6260 assert_eq!(
6261 dm_cell.load().version.text,
6262 2,
6263 "display matrix is current after sync"
6264 );
6265 assert_eq!(
6266 matrix_cell.load().version.text,
6267 1,
6268 "cell grid still lags (sync deliberately did not project)"
6269 );
6270
6271 // The async worker reconciles the lagging cells on its next wake.
6272 assert_eq!(recompute(&rs2), WorkerDecision::Recomputed);
6273 let cm = matrix_cell.load();
6274 assert_eq!(
6275 cm.version.text, 2,
6276 "worker projected the current display matrix into the cells"
6277 );
6278 let row1: String = cm
6279 .slice(0, 10)
6280 .iter()
6281 .nth(1)
6282 .unwrap()
6283 .cells
6284 .iter()
6285 .map(|c| char::from_u32(c.codepoint).unwrap_or('?'))
6286 .collect();
6287 assert_eq!(row1, "NEW");
6288 }
6289
6290 /// H.1 (2026-06-04): the range-scoped highlight must colour the
6291 /// EDITED line correctly — i.e. `spans_base` relative indexing lands
6292 /// the scoped spans on the right line. Edits line 1 in place to
6293 /// introduce a Rust `fn` keyword, with the syntax snapshot parsed at
6294 /// the post-edit version; the incremental rebuild highlights only
6295 /// `[edit_lo, affected_hi)` (base = edit_lo), so a base/index slip
6296 /// would either miscolour or skip the keyword.
6297 #[tokio::test]
6298 async fn h1_scoped_highlight_colours_the_edited_line() {
6299 let matrix_cell: Arc<ArcSwap<CellMatrix>> = Arc::default();
6300 let (resolved, ids) = test_cell_theme();
6301 let ct = CellTheme {
6302 resolved: &resolved,
6303 ids: &ids,
6304 };
6305 let keyword_fg = resolve_style(ct, lattice_syntax::Style::Keyword).0;
6306
6307 // v1: plain seed (no syntax handle) → prior whole-doc matrix.
6308 let snap1 = snap_of_versioned("let a = 1;\nlet b = 2;\nlet c = 3;\n", 1);
6309 let v1 = MatrixVersion {
6310 text: 1,
6311 syntax: 1,
6312 ..MatrixVersion::ZERO
6313 };
6314 let rs1 = rs_with_everything(
6315 Some(snap1),
6316 v1,
6317 matrix_cell.clone(),
6318 None,
6319 Vec::new(),
6320 Vec::new(),
6321 true,
6322 None,
6323 5,
6324 );
6325 assert_eq!(recompute(&rs1), WorkerDecision::Recomputed);
6326
6327 // v2: edit line 1 in place → introduces `fn`. Rust syntax parsed
6328 // at v2 + seeded so the scoped highlight has fresh spans.
6329 let text2 = "let a = 1;\nfn b() {}\nlet c = 3;\n";
6330 let mut s = lattice_syntax::Syntax::for_language(lattice_syntax::Lang::Rust)
6331 .unwrap()
6332 .unwrap();
6333 s.parse_at(text2, 2);
6334 let handle = Arc::new(lattice_syntax::SyntaxHandle::seeded_with_runtime(
6335 s,
6336 &tokio::runtime::Handle::current(),
6337 None,
6338 ));
6339 let snap2 = snap_of_versioned(text2, 2);
6340 let v2 = MatrixVersion {
6341 text: 2,
6342 syntax: 2,
6343 ..MatrixVersion::ZERO
6344 };
6345 let edit = edit_delta(1, 1, 1); // in-place edit of line 1
6346 let rs2 = rs_with_everything(
6347 Some(snap2),
6348 v2,
6349 matrix_cell.clone(),
6350 Some(handle),
6351 Vec::new(),
6352 Vec::new(),
6353 true,
6354 Some(edit),
6355 5,
6356 );
6357 assert_eq!(recompute(&rs2), WorkerDecision::RecomputedIncremental);
6358
6359 let m = matrix_cell.load_full();
6360 let line1 = m.row_at_source_line(1).expect("line 1 row present");
6361 assert!(
6362 line1.cells.iter().any(|c| c.fg == keyword_fg),
6363 "the scoped highlight (base = edit_lo) must colour the edited line's \
6364 `fn` keyword — guards `spans_base` relative indexing"
6365 );
6366 }
6367
6368 /// Chunked mode + single-edit reuses prefix chunks (by `Arc`
6369 /// identity) and shifts suffix chunks. Concretely: 25-line
6370 /// document, viewport 5 → chunk_size 16, an insert at line 2
6371 /// rebuilds chunk 0 only; chunk 1 (starts at 16) shifts to
6372 /// start at 17.
6373 #[test]
6374 fn chunked_incremental_reuses_prefix_and_shifts_suffix() {
6375 let matrix_cell: Arc<ArcSwap<CellMatrix>> = Arc::default();
6376
6377 // 25 lines: "l0\nl1\n...\nl24".
6378 let text1: String = (0..25)
6379 .map(|i| format!("l{}", i))
6380 .collect::<Vec<_>>()
6381 .join("\n");
6382 let snap1 = snap_of_versioned(&text1, 1);
6383 let v1 = MatrixVersion {
6384 text: 1,
6385 syntax: 1,
6386 ..MatrixVersion::ZERO
6387 };
6388 let rs1 = rs_with_everything(
6389 Some(snap1),
6390 v1,
6391 matrix_cell.clone(),
6392 None,
6393 Vec::new(),
6394 Vec::new(),
6395 true,
6396 None,
6397 5, // 4×5 = 20 < 25 ⇒ chunked, chunk_size = 16
6398 );
6399 assert_eq!(recompute(&rs1), WorkerDecision::Recomputed);
6400 let m1 = matrix_cell.load();
6401 assert_eq!(m1.chunk_size, 16);
6402 assert_eq!(m1.chunks.len(), 2);
6403 // Hold an `Arc` clone of cell chunk 1 for the post-edit
6404 // identity check (the projected cell chunk is rebuilt, so it
6405 // must be a NEW Arc). B2.2: the payload-sharing guarantee now
6406 // lives on the canonical `DisplayMatrix`, so also capture its
6407 // suffix chunk's `DisplayLine` `text` Arcs to assert reuse.
6408 let chunk1_pre: Arc<CellChunk> = Arc::clone(&m1.chunks[1]);
6409 let dm_cell = display_cell_for(&matrix_cell);
6410 let d1 = dm_cell.load_full();
6411 assert_eq!(d1.chunks.len(), 2, "display matrix mirrors cell chunking");
6412 let disp_chunk1_pre: Vec<Arc<str>> = d1.chunks[1]
6413 .rows
6414 .iter()
6415 .map(|r| Arc::clone(&r.text))
6416 .collect();
6417
6418 // Insert one line at line 2.
6419 let text2: String = {
6420 let mut lines: Vec<String> = (0..25).map(|i| format!("l{}", i)).collect();
6421 lines.insert(2, "INS".to_string());
6422 lines.join("\n")
6423 };
6424 let snap2 = snap_of_versioned(&text2, 2);
6425 let v2 = MatrixVersion {
6426 text: 2,
6427 syntax: 2,
6428 ..MatrixVersion::ZERO
6429 };
6430 let edit = edit_delta(2, 0, 1);
6431 let rs2 = rs_with_everything(
6432 Some(snap2),
6433 v2,
6434 matrix_cell.clone(),
6435 None,
6436 Vec::new(),
6437 Vec::new(),
6438 true,
6439 Some(edit),
6440 5,
6441 );
6442 assert_eq!(recompute(&rs2), WorkerDecision::RecomputedIncremental);
6443 let m2 = matrix_cell.load();
6444 // Post-edit shape: chunked, chunk_size 16, 26 source lines.
6445 // Partitioning:
6446 // - prefix-reuse: none (old chunk 0 ends at 16 > edit_lo=2)
6447 // - rebuild zone: [0, 17) (suffix-shift's first chunk lands
6448 // at start=17). Carved into chunks at start=0 (16 rows)
6449 // and start=16 (1 row).
6450 // - suffix-shift: old chunk 1 → start=17 (9 rows).
6451 // ⇒ three chunks at starts [0, 16, 17] totalling 26 rows.
6452 assert!(!m2.is_whole_doc());
6453 assert_eq!(m2.chunk_size, 16);
6454 assert_eq!(m2.source_line_count, 26);
6455 assert_eq!(m2.visible_line_count, 26);
6456 assert_eq!(m2.chunks.len(), 3);
6457 assert_eq!(m2.chunks[0].start_source_line, 0);
6458 assert_eq!(m2.chunks[1].start_source_line, 16);
6459 assert_eq!(m2.chunks[2].start_source_line, 17);
6460
6461 // m2.chunks[2] is the shifted-from-m1.chunks[1] one.
6462 // Rows whose source_line was 16..25 now have source_lines
6463 // 17..26.
6464 let shifted_lines: Vec<u32> = m2.chunks[2].rows.iter().map(|r| r.source_line).collect();
6465 assert_eq!(
6466 shifted_lines,
6467 (17u32..26).collect::<Vec<_>>(),
6468 "suffix chunk rows must be the same as before but shifted by +1"
6469 );
6470
6471 // The projected cell chunk is always a fresh Arc (cells are
6472 // re-projected each rebuild), so it differs from the pre-edit
6473 // one — the payload-sharing guarantee lives on the display
6474 // matrix now, asserted below.
6475 assert!(
6476 !Arc::ptr_eq(&chunk1_pre, &m2.chunks[2]),
6477 "post-shift projected cell chunk is a new Arc<CellChunk>"
6478 );
6479
6480 // B2.2: DisplayLine payload sharing across the shift — the
6481 // canonical guarantee. The suffix chunk's surviving
6482 // `DisplayLine`s reuse their `text` Arc (shifted source_line,
6483 // shared payload), which is what keeps their colours stable.
6484 let d2 = dm_cell.load_full();
6485 let disp_suffix = d2
6486 .chunks
6487 .iter()
6488 .find(|c| c.start_source_line == 17)
6489 .expect("display matrix has the shifted suffix chunk at start 17");
6490 assert_eq!(disp_suffix.rows.len(), disp_chunk1_pre.len());
6491 for (pre_text, post_row) in disp_chunk1_pre.iter().zip(disp_suffix.rows.iter()) {
6492 assert!(
6493 Arc::ptr_eq(pre_text, &post_row.text),
6494 "DisplayLine text Arc must be shared across the suffix shift"
6495 );
6496 }
6497 }
6498
6499 /// 2026-06-04 (B-flicker fix): in CHUNKED mode, a single in-place edit
6500 /// in the MIDDLE of a chunk must rebuild ONLY the edited line; every
6501 /// other line in that chunk reuses its prior `DisplayLine` payload
6502 /// (`text` AND the colour-carrying `runs` Arc). The prior code rebuilt
6503 /// the whole `chunk_size`-aligned zone wholesale, so on the sync edit
6504 /// path (`allow_highlight: false`) ~`chunk_size` lines lost their syntax
6505 /// colour on every keystroke — the whole-viewport markdown flicker,
6506 /// since the viewport sits inside one chunk. Whole-doc mode already
6507 /// reused rows (`whole_doc_incremental_reuses_unchanged_rows`); this is
6508 /// the missing chunked-mode guarantee. (feedback_decorations_update_in_place)
6509 #[test]
6510 fn chunked_incremental_reuses_rows_in_rebuild_zone() {
6511 let matrix_cell: Arc<ArcSwap<CellMatrix>> = Arc::default();
6512
6513 // 25 lines → viewport 5 ⇒ chunked, chunk_size 16, chunks [0,16)+[16,25).
6514 let text1: String = (0..25)
6515 .map(|i| format!("line{}", i))
6516 .collect::<Vec<_>>()
6517 .join("\n");
6518 let snap1 = snap_of_versioned(&text1, 1);
6519 let v1 = MatrixVersion {
6520 text: 1,
6521 syntax: 1,
6522 ..MatrixVersion::ZERO
6523 };
6524 let rs1 = rs_with_everything(
6525 Some(snap1),
6526 v1,
6527 matrix_cell.clone(),
6528 None,
6529 Vec::new(),
6530 Vec::new(),
6531 true,
6532 None,
6533 5,
6534 );
6535 assert_eq!(recompute(&rs1), WorkerDecision::Recomputed);
6536 let dm_cell = display_cell_for(&matrix_cell);
6537 let d1 = dm_cell.load_full();
6538 assert_eq!(d1.chunk_size, 16);
6539 assert_eq!(d1.chunks.len(), 2, "25 lines ⇒ two chunks");
6540
6541 // Capture prior payload Arcs for lines that must survive the edit:
6542 // a prefix line (0), the line just before the edit (4), and a line
6543 // AFTER the edit but still inside the same chunk (10). All three
6544 // were rebuilt fresh by the buggy wholesale-zone path.
6545 let l0 = d1.row_at_source_line(0).unwrap();
6546 let (l0_text, l0_runs) = (Arc::clone(&l0.text), Arc::clone(&l0.runs));
6547 let l4_text = Arc::clone(&d1.row_at_source_line(4).unwrap().text);
6548 let l10 = d1.row_at_source_line(10).unwrap();
6549 let (l10_text, l10_runs) = (Arc::clone(&l10.text), Arc::clone(&l10.runs));
6550 let l5_text_pre = Arc::clone(&d1.row_at_source_line(5).unwrap().text);
6551
6552 // Edit line 5 IN PLACE (removed 1, added 1 → net 0): the edit lands
6553 // inside chunk 0, so the rebuild zone is the whole chunk [0,16).
6554 let text2: String = {
6555 let mut lines: Vec<String> = (0..25).map(|i| format!("line{}", i)).collect();
6556 lines[5] = "EDITED".to_string();
6557 lines.join("\n")
6558 };
6559 let snap2 = snap_of_versioned(&text2, 2);
6560 let v2 = MatrixVersion {
6561 text: 2,
6562 syntax: 2,
6563 ..MatrixVersion::ZERO
6564 };
6565 let edit = edit_delta(5, 1, 1);
6566 let rs2 = rs_with_everything(
6567 Some(snap2),
6568 v2,
6569 matrix_cell.clone(),
6570 None,
6571 Vec::new(),
6572 Vec::new(),
6573 true,
6574 Some(edit),
6575 5,
6576 );
6577 assert_eq!(recompute(&rs2), WorkerDecision::RecomputedIncremental);
6578 let d2 = dm_cell.load_full();
6579 assert_eq!(d2.source_line_count, 25);
6580
6581 // Unchanged lines reuse their prior DisplayLine payload — both the
6582 // text AND the colour-carrying runs Arc. This is the regression: the
6583 // old wholesale-zone rebuild produced fresh Arcs here (blanking colour
6584 // on the sync path), now confined to the single edited line.
6585 let l0_post = d2.row_at_source_line(0).unwrap();
6586 assert!(
6587 Arc::ptr_eq(&l0_text, &l0_post.text) && Arc::ptr_eq(&l0_runs, &l0_post.runs),
6588 "prefix line 0 must reuse its prior text+runs Arcs (keeps colour)"
6589 );
6590 assert!(
6591 Arc::ptr_eq(&l4_text, &d2.row_at_source_line(4).unwrap().text),
6592 "line 4 (just before edit) must reuse its prior text Arc"
6593 );
6594 let l10_post = d2.row_at_source_line(10).unwrap();
6595 assert!(
6596 Arc::ptr_eq(&l10_text, &l10_post.text) && Arc::ptr_eq(&l10_runs, &l10_post.runs),
6597 "line 10 (after edit, same chunk) must reuse its prior text+runs Arcs — \
6598 the buggy wholesale-zone rebuild lost these"
6599 );
6600
6601 // Only the edited line is rebuilt: fresh payload, new content.
6602 let l5_post = d2.row_at_source_line(5).unwrap();
6603 assert!(
6604 !Arc::ptr_eq(&l5_text_pre, &l5_post.text),
6605 "edited line 5 must be rebuilt (fresh text Arc)"
6606 );
6607 assert_eq!(
6608 &*l5_post.text, "EDITED",
6609 "edited line carries the new content"
6610 );
6611 }
6612
6613 /// Eligibility falls back to full rebuild when `last_edit` is
6614 /// `None` (no single-edit since last publish — e.g.
6615 /// undo/redo/multi-edit batch).
6616 #[test]
6617 fn no_last_edit_falls_back_to_full_rebuild() {
6618 let matrix_cell: Arc<ArcSwap<CellMatrix>> = Arc::default();
6619 let snap1 = snap_of_versioned("aa\nbb", 1);
6620 let v1 = MatrixVersion {
6621 text: 1,
6622 syntax: 1,
6623 ..MatrixVersion::ZERO
6624 };
6625 let rs1 = rs_with_everything(
6626 Some(snap1),
6627 v1,
6628 matrix_cell.clone(),
6629 None,
6630 Vec::new(),
6631 Vec::new(),
6632 true,
6633 None,
6634 5,
6635 );
6636 assert_eq!(recompute(&rs1), WorkerDecision::Recomputed);
6637
6638 // text bumps but last_edit stays None (multi-edit batch
6639 // semantics).
6640 let snap2 = snap_of_versioned("AA\nBB", 2);
6641 let v2 = MatrixVersion {
6642 text: 2,
6643 syntax: 2,
6644 ..MatrixVersion::ZERO
6645 };
6646 let rs2 = rs_with_everything(
6647 Some(snap2),
6648 v2,
6649 matrix_cell.clone(),
6650 None,
6651 Vec::new(),
6652 Vec::new(),
6653 true,
6654 None,
6655 5,
6656 );
6657 assert_eq!(recompute(&rs2), WorkerDecision::Recomputed);
6658 }
6659
6660 /// Eligibility falls back to full rebuild when a non-text
6661 /// axis (e.g. theme) also bumped — incremental can't safely
6662 /// reuse cell colours.
6663 #[test]
6664 fn theme_axis_change_disables_incremental() {
6665 let matrix_cell: Arc<ArcSwap<CellMatrix>> = Arc::default();
6666 let snap1 = snap_of_versioned("aa\nbb", 1);
6667 let v1 = MatrixVersion {
6668 text: 1,
6669 syntax: 1,
6670 theme: 100,
6671 ..MatrixVersion::ZERO
6672 };
6673 let rs1 = rs_with_everything(
6674 Some(snap1),
6675 v1,
6676 matrix_cell.clone(),
6677 None,
6678 Vec::new(),
6679 Vec::new(),
6680 true,
6681 None,
6682 5,
6683 );
6684 assert_eq!(recompute(&rs1), WorkerDecision::Recomputed);
6685
6686 // Theme bumps alongside text — incremental must bail.
6687 let snap2 = snap_of_versioned("aa\nNEW\nbb", 2);
6688 let v2 = MatrixVersion {
6689 text: 2,
6690 syntax: 2,
6691 theme: 200,
6692 ..MatrixVersion::ZERO
6693 };
6694 let edit = edit_delta(1, 0, 1);
6695 let rs2 = rs_with_everything(
6696 Some(snap2),
6697 v2,
6698 matrix_cell.clone(),
6699 None,
6700 Vec::new(),
6701 Vec::new(),
6702 true,
6703 Some(edit),
6704 5,
6705 );
6706 // Full rebuild — incremental rejected because theme axis
6707 // differs.
6708 assert_eq!(recompute(&rs2), WorkerDecision::Recomputed);
6709 }
6710
6711 /// Mismatched line-count guard: even with a single-edit
6712 /// delta, if the published matrix's `source_line_count` plus
6713 /// `net_delta` doesn't match the new snapshot's line count,
6714 /// incremental bails (defensive against doc-switches where
6715 /// versions coincidentally line up).
6716 #[test]
6717 fn line_count_mismatch_disables_incremental() {
6718 let matrix_cell: Arc<ArcSwap<CellMatrix>> = Arc::default();
6719 let snap1 = snap_of_versioned("aa\nbb", 1);
6720 let v1 = MatrixVersion {
6721 text: 1,
6722 syntax: 1,
6723 ..MatrixVersion::ZERO
6724 };
6725 let rs1 = rs_with_everything(
6726 Some(snap1),
6727 v1,
6728 matrix_cell.clone(),
6729 None,
6730 Vec::new(),
6731 Vec::new(),
6732 true,
6733 None,
6734 5,
6735 );
6736 assert_eq!(recompute(&rs1), WorkerDecision::Recomputed);
6737
6738 // New snapshot has 5 lines, but the edit says
6739 // lines_added=1 against pre 2 → expects 3, not 5. Bail
6740 // to full rebuild.
6741 let snap2 = snap_of_versioned("a\nb\nc\nd\ne", 2);
6742 let v2 = MatrixVersion {
6743 text: 2,
6744 syntax: 2,
6745 ..MatrixVersion::ZERO
6746 };
6747 let edit = edit_delta(0, 0, 1);
6748 let rs2 = rs_with_everything(
6749 Some(snap2),
6750 v2,
6751 matrix_cell.clone(),
6752 None,
6753 Vec::new(),
6754 Vec::new(),
6755 true,
6756 Some(edit),
6757 5,
6758 );
6759 assert_eq!(recompute(&rs2), WorkerDecision::Recomputed);
6760 }
6761
6762 /// Eligibility falls back when the mode would change between
6763 /// pre and post edit (e.g. small-doc whole-doc → chunked
6764 /// after adding enough lines).
6765 #[test]
6766 fn mode_change_disables_incremental() {
6767 let matrix_cell: Arc<ArcSwap<CellMatrix>> = Arc::default();
6768 // 5 lines + viewport 5 → whole-doc (5 <= 20).
6769 let snap1 = snap_of_versioned("a\nb\nc\nd\ne", 1);
6770 let v1 = MatrixVersion {
6771 text: 1,
6772 syntax: 1,
6773 ..MatrixVersion::ZERO
6774 };
6775 let rs1 = rs_with_everything(
6776 Some(snap1),
6777 v1,
6778 matrix_cell.clone(),
6779 None,
6780 Vec::new(),
6781 Vec::new(),
6782 true,
6783 None,
6784 5,
6785 );
6786 assert_eq!(recompute(&rs1), WorkerDecision::Recomputed);
6787 assert!(matrix_cell.load().is_whole_doc());
6788
6789 // Bump to 30 lines (5 + 25 inserted). Now 30 > 20 → chunked
6790 // mode. Single-edit delta says lines_added=25; that crosses
6791 // the threshold.
6792 let text2: String = (0..30)
6793 .map(|i| format!("l{}", i))
6794 .collect::<Vec<_>>()
6795 .join("\n");
6796 let snap2 = snap_of_versioned(&text2, 2);
6797 let v2 = MatrixVersion {
6798 text: 2,
6799 syntax: 2,
6800 ..MatrixVersion::ZERO
6801 };
6802 let edit = edit_delta(5, 0, 25);
6803 let rs2 = rs_with_everything(
6804 Some(snap2),
6805 v2,
6806 matrix_cell.clone(),
6807 None,
6808 Vec::new(),
6809 Vec::new(),
6810 true,
6811 Some(edit),
6812 5,
6813 );
6814 // Mode flipped; incremental must bail.
6815 assert_eq!(recompute(&rs2), WorkerDecision::Recomputed);
6816 assert!(!matrix_cell.load().is_whole_doc());
6817 }
6818
6819 /// Chunked deletion: removing lines also takes the incremental
6820 /// branch; downstream chunks shift by the negative delta.
6821 #[test]
6822 fn chunked_incremental_handles_deletion() {
6823 let matrix_cell: Arc<ArcSwap<CellMatrix>> = Arc::default();
6824
6825 // 30 lines so we land squarely in chunked mode at viewport
6826 // 5 (chunk_size 16, ceil(30/16) = 2 chunks).
6827 let text1: String = (0..30)
6828 .map(|i| format!("l{}", i))
6829 .collect::<Vec<_>>()
6830 .join("\n");
6831 let snap1 = snap_of_versioned(&text1, 1);
6832 let v1 = MatrixVersion {
6833 text: 1,
6834 syntax: 1,
6835 ..MatrixVersion::ZERO
6836 };
6837 let rs1 = rs_with_everything(
6838 Some(snap1),
6839 v1,
6840 matrix_cell.clone(),
6841 None,
6842 Vec::new(),
6843 Vec::new(),
6844 true,
6845 None,
6846 5,
6847 );
6848 assert_eq!(recompute(&rs1), WorkerDecision::Recomputed);
6849 let m1 = matrix_cell.load();
6850 assert_eq!(m1.chunk_size, 16);
6851 assert_eq!(m1.chunks.len(), 2);
6852
6853 // Delete two lines at line 3.
6854 let text2: String = {
6855 let mut lines: Vec<String> = (0..30).map(|i| format!("l{}", i)).collect();
6856 lines.drain(3..5);
6857 lines.join("\n")
6858 };
6859 let snap2 = snap_of_versioned(&text2, 2);
6860 let v2 = MatrixVersion {
6861 text: 2,
6862 syntax: 2,
6863 ..MatrixVersion::ZERO
6864 };
6865 let edit = edit_delta(3, 2, 0);
6866 let rs2 = rs_with_everything(
6867 Some(snap2),
6868 v2,
6869 matrix_cell.clone(),
6870 None,
6871 Vec::new(),
6872 Vec::new(),
6873 true,
6874 Some(edit),
6875 5,
6876 );
6877 assert_eq!(recompute(&rs2), WorkerDecision::RecomputedIncremental);
6878 let m2 = matrix_cell.load();
6879 assert_eq!(m2.source_line_count, 28);
6880 assert_eq!(m2.visible_line_count, 28);
6881 // Walk source_lines via slice — must be 0..28 contiguous.
6882 let source_lines: Vec<u32> = m2.slice(0, 100).iter().map(|r| r.source_line).collect();
6883 assert_eq!(source_lines, (0u32..28).collect::<Vec<_>>());
6884 }
6885
6886 // ---- S2.5 — coalescing + paint_request + end-to-end ----
6887
6888 /// `recompute` walks the [`WorkerDecision`] state machine
6889 /// monotonically when wakes are interleaved with publishes:
6890 /// first publish ⇒ Recomputed; same-version wake ⇒ CacheHit;
6891 /// new edit ⇒ RecomputedIncremental; multi-axis change ⇒
6892 /// Recomputed (full). This is the synchronous projection of
6893 /// the burst-coalescing contract: each iteration of the async
6894 /// `run` loop reads the *latest* RenderState and never
6895 /// processes stale intermediate states.
6896 #[test]
6897 fn coalescing_walks_decision_state_machine() {
6898 let matrix_cell: Arc<ArcSwap<CellMatrix>> = Arc::default();
6899
6900 // Tick 1: initial publish, no prior matrix → full Recomputed.
6901 let v1 = MatrixVersion {
6902 text: 1,
6903 syntax: 1,
6904 ..MatrixVersion::ZERO
6905 };
6906 let snap1 = snap_of_versioned("aa\nbb\ncc", 1);
6907 let rs1 = rs_with_everything(
6908 Some(snap1.clone()),
6909 v1,
6910 matrix_cell.clone(),
6911 None,
6912 Vec::new(),
6913 Vec::new(),
6914 true,
6915 None,
6916 5,
6917 );
6918 assert_eq!(recompute(&rs1), WorkerDecision::Recomputed);
6919
6920 // Tick 2: same RenderState, redundant wake ⇒ CacheHit.
6921 assert_eq!(recompute(&rs1), WorkerDecision::CacheHit);
6922 assert_eq!(recompute(&rs1), WorkerDecision::CacheHit);
6923
6924 // Tick 3: single edit, all other axes unchanged →
6925 // incremental.
6926 let v2 = MatrixVersion {
6927 text: 2,
6928 syntax: 2,
6929 ..MatrixVersion::ZERO
6930 };
6931 let snap2 = snap_of_versioned("aa\nNEW\nbb\ncc", 2);
6932 let edit = edit_delta(1, 0, 1);
6933 let rs2 = rs_with_everything(
6934 Some(snap2),
6935 v2,
6936 matrix_cell.clone(),
6937 None,
6938 Vec::new(),
6939 Vec::new(),
6940 true,
6941 Some(edit),
6942 5,
6943 );
6944 assert_eq!(recompute(&rs2), WorkerDecision::RecomputedIncremental);
6945
6946 // Tick 4: theme axis bump alongside text — incremental
6947 // bails, full rebuild runs.
6948 let v3 = MatrixVersion {
6949 text: 3,
6950 syntax: 3,
6951 theme: 7,
6952 ..MatrixVersion::ZERO
6953 };
6954 let snap3 = snap_of_versioned("aa\nNEW\nbb\ncc\nDD", 3);
6955 let edit3 = edit_delta(4, 0, 1);
6956 let rs3 = rs_with_everything(
6957 Some(snap3),
6958 v3,
6959 matrix_cell.clone(),
6960 None,
6961 Vec::new(),
6962 Vec::new(),
6963 true,
6964 Some(edit3),
6965 5,
6966 );
6967 assert_eq!(recompute(&rs3), WorkerDecision::Recomputed);
6968 }
6969
6970 /// Cells worker and overlay worker write to independent
6971 /// `ArcSwap` cells. Driving cells `recompute` does NOT touch
6972 /// the overlay quads cell (the overlay worker's output). The
6973 /// shared `RenderState` substrate stays consistent across both.
6974 ///
6975 /// display-line B4.2: the dead `VisibleSpans` / `VisibleRows`
6976 /// sentinel cells this test used were deleted; the surviving
6977 /// sibling cell is `static_overlay_quads`, so the independence
6978 /// invariant is now asserted against it.
6979 #[test]
6980 fn cells_worker_does_not_corrupt_overlay_cell() {
6981 let matrix_cell: Arc<ArcSwap<CellMatrix>> = Arc::default();
6982 let overlay_cell: Arc<ArcSwap<crate::render_state::StaticOverlayQuads>> = Arc::default();
6983
6984 // Seed a sentinel value into the overlay cell so we can
6985 // detect any unintended mutation.
6986 let sentinel_key = crate::render_state::VisibleHighlightsKey {
6987 snapshot_ptr: 0xdead_beef,
6988 ..Default::default()
6989 };
6990 overlay_cell.store(Arc::new(crate::render_state::StaticOverlayQuads {
6991 quads: Arc::from(Vec::new().into_boxed_slice()),
6992 computed_for_key: sentinel_key,
6993 }));
6994 let pre_overlay_ptr = Arc::as_ptr(&overlay_cell.load_full());
6995
6996 // Run a cells recompute against a RenderState that
6997 // happens to share the same `render_state` Arc shape. The
6998 // cells path must not touch overlay_cell.
6999 let snap = snap_of_versioned("aa\nbb", 1);
7000 let v1 = MatrixVersion {
7001 text: 1,
7002 syntax: 1,
7003 ..MatrixVersion::ZERO
7004 };
7005 let rs = rs_with_everything(
7006 Some(snap),
7007 v1,
7008 matrix_cell.clone(),
7009 None,
7010 Vec::new(),
7011 Vec::new(),
7012 true,
7013 None,
7014 5,
7015 );
7016 assert_eq!(recompute(&rs), WorkerDecision::Recomputed);
7017
7018 // Overlay cell remains at the sentinel — Arc identity
7019 // unchanged.
7020 let post_overlay_ptr = Arc::as_ptr(&overlay_cell.load_full());
7021 assert_eq!(
7022 pre_overlay_ptr, post_overlay_ptr,
7023 "cells recompute must not touch the overlay quads cell"
7024 );
7025
7026 // Matrix cell has been updated.
7027 assert!(!matrix_cell.load().is_empty());
7028 }
7029
7030 /// End-to-end smoke test through the actual `run` loop in a
7031 /// tokio runtime. Drives:
7032 /// - publish + wake → matrix populates → paint_request fires;
7033 /// - second publish with new text → matrix updates; second
7034 /// paint_request wake;
7035 /// - same-state wake → matrix unchanged (cache-hit); no
7036 /// additional paint_request beyond the first two.
7037 ///
7038 /// Uses `tokio::time::timeout` so a regression that leaves the
7039 /// worker parked surfaces as a test failure, not a hang.
7040 #[test]
7041 fn end_to_end_through_tokio_run_loop() {
7042 let runtime = tokio::runtime::Builder::new_current_thread()
7043 .enable_time()
7044 .build()
7045 .unwrap();
7046
7047 runtime.block_on(async move {
7048 use tokio::time::{Duration, timeout};
7049
7050 let render_state: Arc<ArcSwap<RenderState>> =
7051 Arc::new(ArcSwap::from_pointee(RenderState::default()));
7052 let matrix_cell: Arc<ArcSwap<CellMatrix>> = Arc::default();
7053 let wake = crate::editor::CellsWake::default();
7054 let paint_request: Arc<tokio::sync::Notify> = Arc::default();
7055
7056 // Spawn the worker.
7057 let handle = tokio::spawn(crate::cells_worker::run(
7058 render_state.clone(),
7059 wake.clone(),
7060 paint_request.clone(),
7061 ));
7062
7063 // Helper: build + atomically publish a fresh
7064 // RenderState (single-pane), then fire the wake.
7065 let publish = |text: &str,
7066 text_version: u64,
7067 last_edit: Option<lattice_cells::EditDelta>| {
7068 let snap = snap_of_versioned(text, text_version);
7069 let v = MatrixVersion {
7070 text: text_version,
7071 syntax: text_version,
7072 ..MatrixVersion::ZERO
7073 };
7074 let inputs = crate::render_state::PaneCellsInputs {
7075 conceal_reveal_line: None,
7076 // IG.2: default guide inputs — enabled with the default indent
7077 // unit, which is the shape a test pane has unless it is
7078 // exercising guides specifically.
7079 indent_guides: Default::default(),
7080 indent_unit: lattice_core::IndentUnit::default(),
7081 indent_guides_enabled: true,
7082 sticky_context_lines: std::sync::Arc::from([] as [u32; 0]),
7083 sticky_context_line_numbers: true,
7084 sticky_context_separator: None,
7085 sticky_context: Default::default(),
7086 pane_id: lattice_core::ui::pane::PaneId::default(),
7087 buffer_id: lattice_core::BufferId::default(),
7088 matrix: matrix_cell.clone(),
7089 display_matrix: Arc::new(ArcSwap::from_pointee(
7090 crate::display_matrix::DisplayMatrix::empty(),
7091 )),
7092 virtual_rows_matrix: Arc::new(ArcSwap::from_pointee(
7093 lattice_cells::VirtualRowMatrix::empty(),
7094 )),
7095 version: v,
7096 snapshot: Some(snap.clone()),
7097 syntax_handle: None,
7098 inlay_hints: Arc::from(
7099 Vec::<crate::render_state::InlayHintRow>::new().into_boxed_slice(),
7100 ),
7101 folds: Arc::from(Vec::<lattice_core::Fold>::new().into_boxed_slice()),
7102 viewport_height: 5,
7103 scroll: 0,
7104 viewport_width: 0,
7105 wrap: false,
7106 wrap_reserved_cols: 0,
7107 foldenable: true,
7108 last_edit,
7109 excerpt_syntax: Arc::from([]),
7110 extra_spans: Arc::from([]),
7111 extra_refine: Arc::from(Vec::new().into_boxed_slice()),
7112 };
7113 let cells = CellsRenderState {
7114 pane_indent_guides: Arc::new(std::collections::HashMap::new()),
7115 pane_sticky_context: Arc::new(std::collections::HashMap::new()),
7116 matrix: matrix_cell.clone(),
7117 version: v,
7118 snapshot: Some(snap),
7119 syntax_handle: None,
7120 inlay_hints: Arc::from(
7121 Vec::<crate::render_state::InlayHintRow>::new().into_boxed_slice(),
7122 ),
7123 folds: Arc::from(Vec::<lattice_core::Fold>::new().into_boxed_slice()),
7124 viewport_height: 5,
7125 foldenable: true,
7126 last_edit,
7127 resolved_theme: std::sync::Arc::new(crate::ui::theme::ResolvedTheme::default()),
7128 theme_ids: crate::ui::theme::BuiltinElementIds::default(),
7129 whitespace: WhitespaceConfig::default(),
7130 panes: Arc::from(vec![inputs.clone()].into_boxed_slice()),
7131 pane_matrices: {
7132 let mut m = std::collections::HashMap::new();
7133 m.insert(inputs.pane_id, inputs.matrix);
7134 Arc::new(m)
7135 },
7136 display_matrix: Arc::new(ArcSwap::from_pointee(
7137 crate::display_matrix::DisplayMatrix::empty(),
7138 )),
7139 display_pane_matrices: Arc::new(std::collections::HashMap::new()),
7140 };
7141 let rs = RenderState {
7142 cells: Arc::new(ArcSwap::from_pointee(cells)),
7143 ..RenderState::default()
7144 };
7145 render_state.store(Arc::new(rs));
7146 wake.0.notify_one();
7147 };
7148
7149 // Publish #1 — fresh document.
7150 publish("aa\nbb\ncc", 1, None);
7151 timeout(Duration::from_secs(2), paint_request.notified())
7152 .await
7153 .expect("paint_request must fire after first publish");
7154 assert_eq!(matrix_cell.load().source_line_count, 3);
7155
7156 // Publish #2 — single-line insert; should take
7157 // incremental path.
7158 publish(
7159 "aa\nNEW\nbb\ncc",
7160 2,
7161 Some(lattice_cells::EditDelta {
7162 start_line: 1,
7163 lines_removed: 0,
7164 lines_added: 1,
7165 ..Default::default()
7166 }),
7167 );
7168 timeout(Duration::from_secs(2), paint_request.notified())
7169 .await
7170 .expect("paint_request must fire after second publish");
7171 let m2 = matrix_cell.load();
7172 assert_eq!(m2.source_line_count, 4);
7173 // Row content reflects post-edit text.
7174 let texts: Vec<String> = m2
7175 .slice(0, 10)
7176 .iter()
7177 .map(|r| {
7178 r.cells
7179 .iter()
7180 .map(|c| char::from_u32(c.codepoint).unwrap_or('?'))
7181 .collect()
7182 })
7183 .collect();
7184 assert_eq!(texts, vec!["aa", "NEW", "bb", "cc"]);
7185
7186 // Redundant wake against the same RenderState — worker
7187 // must hit CacheHit and NOT fire paint_request.
7188 wake.0.notify_one();
7189 // Brief wait window for the worker to process; expect
7190 // timeout (no paint wake fires).
7191 let no_paint = timeout(Duration::from_millis(150), paint_request.notified()).await;
7192 assert!(
7193 no_paint.is_err(),
7194 "redundant wake must produce CacheHit, not a paint signal"
7195 );
7196
7197 handle.abort();
7198 let _ = handle.await;
7199 });
7200 }
7201
7202 /// Burst-coalescing smoke test: fire many wakes in quick
7203 /// succession with the *same* RenderState; the worker must
7204 /// process them as a coalesced batch and produce at most one
7205 /// paint signal (after the initial publish). Mirrors the
7206 /// design comment: a burst of N publishes during one build
7207 /// produces exactly 2 builds (the original + one tail catch-
7208 /// up).
7209 #[test]
7210 fn burst_wakes_coalesce_via_notify_permit() {
7211 let runtime = tokio::runtime::Builder::new_current_thread()
7212 .enable_time()
7213 .build()
7214 .unwrap();
7215
7216 runtime.block_on(async move {
7217 use tokio::time::{Duration, timeout};
7218
7219 let render_state: Arc<ArcSwap<RenderState>> =
7220 Arc::new(ArcSwap::from_pointee(RenderState::default()));
7221 let matrix_cell: Arc<ArcSwap<CellMatrix>> = Arc::default();
7222 let wake = crate::editor::CellsWake::default();
7223 let paint_request: Arc<tokio::sync::Notify> = Arc::default();
7224
7225 let handle = tokio::spawn(crate::cells_worker::run(
7226 render_state.clone(),
7227 wake.clone(),
7228 paint_request.clone(),
7229 ));
7230
7231 // Publish one RenderState and fire ten wakes in a
7232 // row. `Notify` collapses them to at most one
7233 // additional permit beyond the first.
7234 let snap = snap_of_versioned("hello\nworld", 1);
7235 let v = MatrixVersion {
7236 text: 1,
7237 syntax: 1,
7238 ..MatrixVersion::ZERO
7239 };
7240 let inputs = crate::render_state::PaneCellsInputs {
7241 conceal_reveal_line: None,
7242 // IG.2: default guide inputs — enabled with the default indent
7243 // unit, which is the shape a test pane has unless it is
7244 // exercising guides specifically.
7245 indent_guides: Default::default(),
7246 indent_unit: lattice_core::IndentUnit::default(),
7247 indent_guides_enabled: true,
7248 sticky_context_lines: std::sync::Arc::from([] as [u32; 0]),
7249 sticky_context_line_numbers: true,
7250 sticky_context_separator: None,
7251 sticky_context: Default::default(),
7252 pane_id: lattice_core::ui::pane::PaneId::default(),
7253 buffer_id: lattice_core::BufferId::default(),
7254 matrix: matrix_cell.clone(),
7255 display_matrix: Arc::new(ArcSwap::from_pointee(
7256 crate::display_matrix::DisplayMatrix::empty(),
7257 )),
7258 virtual_rows_matrix: Arc::new(ArcSwap::from_pointee(
7259 lattice_cells::VirtualRowMatrix::empty(),
7260 )),
7261 version: v,
7262 snapshot: Some(snap.clone()),
7263 syntax_handle: None,
7264 inlay_hints: Arc::from(
7265 Vec::<crate::render_state::InlayHintRow>::new().into_boxed_slice(),
7266 ),
7267 folds: Arc::from(Vec::<lattice_core::Fold>::new().into_boxed_slice()),
7268 viewport_height: 5,
7269 scroll: 0,
7270 viewport_width: 0,
7271 wrap: false,
7272 wrap_reserved_cols: 0,
7273 foldenable: true,
7274 last_edit: None,
7275 excerpt_syntax: Arc::from([]),
7276 extra_spans: Arc::from([]),
7277 extra_refine: Arc::from(Vec::new().into_boxed_slice()),
7278 };
7279 let cells = CellsRenderState {
7280 pane_indent_guides: Arc::new(std::collections::HashMap::new()),
7281 pane_sticky_context: Arc::new(std::collections::HashMap::new()),
7282 matrix: matrix_cell.clone(),
7283 version: v,
7284 snapshot: Some(snap),
7285 syntax_handle: None,
7286 inlay_hints: Arc::from(
7287 Vec::<crate::render_state::InlayHintRow>::new().into_boxed_slice(),
7288 ),
7289 folds: Arc::from(Vec::<lattice_core::Fold>::new().into_boxed_slice()),
7290 viewport_height: 5,
7291 foldenable: true,
7292 last_edit: None,
7293 resolved_theme: std::sync::Arc::new(crate::ui::theme::ResolvedTheme::default()),
7294 theme_ids: crate::ui::theme::BuiltinElementIds::default(),
7295 whitespace: WhitespaceConfig::default(),
7296 panes: Arc::from(vec![inputs.clone()].into_boxed_slice()),
7297 pane_matrices: {
7298 let mut m = std::collections::HashMap::new();
7299 m.insert(inputs.pane_id, inputs.matrix);
7300 Arc::new(m)
7301 },
7302 display_matrix: Arc::new(ArcSwap::from_pointee(
7303 crate::display_matrix::DisplayMatrix::empty(),
7304 )),
7305 display_pane_matrices: Arc::new(std::collections::HashMap::new()),
7306 };
7307 render_state.store(Arc::new(RenderState {
7308 cells: Arc::new(ArcSwap::from_pointee(cells)),
7309 ..RenderState::default()
7310 }));
7311 for _ in 0..10 {
7312 wake.0.notify_one();
7313 }
7314
7315 // First paint fires for the initial Recomputed.
7316 timeout(Duration::from_secs(2), paint_request.notified())
7317 .await
7318 .expect("paint_request must fire for first build");
7319
7320 // The remaining wakes all see the same RenderState
7321 // → CacheHit → no further paint signals. Wait briefly
7322 // and confirm no extra wake arrives.
7323 let drained = timeout(Duration::from_millis(150), paint_request.notified()).await;
7324 assert!(
7325 drained.is_err(),
7326 "redundant wakes must coalesce to CacheHit, no extra paint signal"
7327 );
7328
7329 handle.abort();
7330 let _ = handle.await;
7331 });
7332 }
7333
7334 // ---- D.4.d.1.b (per-pane iteration) ----
7335
7336 /// Build a single `PaneCellsInputs` with sensible defaults for
7337 /// the multi-pane tests below. Each test bumps the bits it
7338 /// cares about; everything else stays at the default.
7339 fn pane_inputs(
7340 matrix: Arc<ArcSwap<CellMatrix>>,
7341 snapshot: Option<Arc<DocumentSnapshot>>,
7342 version: MatrixVersion,
7343 viewport_height: u32,
7344 ) -> crate::render_state::PaneCellsInputs {
7345 crate::render_state::PaneCellsInputs {
7346 conceal_reveal_line: None,
7347 // IG.2: default guide inputs — enabled with the default indent
7348 // unit, which is the shape a test pane has unless it is
7349 // exercising guides specifically.
7350 indent_guides: Default::default(),
7351 indent_unit: lattice_core::IndentUnit::default(),
7352 indent_guides_enabled: true,
7353 sticky_context_lines: std::sync::Arc::from([] as [u32; 0]),
7354 sticky_context_line_numbers: true,
7355 sticky_context_separator: None,
7356 sticky_context: Default::default(),
7357 pane_id: lattice_core::ui::pane::PaneId::default(),
7358 buffer_id: lattice_core::BufferId::default(),
7359 matrix,
7360 display_matrix: Arc::new(ArcSwap::from_pointee(
7361 crate::display_matrix::DisplayMatrix::empty(),
7362 )),
7363 virtual_rows_matrix: Arc::new(ArcSwap::from_pointee(
7364 lattice_cells::VirtualRowMatrix::empty(),
7365 )),
7366 version,
7367 snapshot,
7368 syntax_handle: None,
7369 inlay_hints: Arc::from(
7370 Vec::<crate::render_state::InlayHintRow>::new().into_boxed_slice(),
7371 ),
7372 folds: Arc::from(Vec::<lattice_core::Fold>::new().into_boxed_slice()),
7373 viewport_height,
7374 scroll: 0,
7375 viewport_width: 0,
7376 wrap: false,
7377 wrap_reserved_cols: 0,
7378 foldenable: true,
7379 last_edit: None,
7380 excerpt_syntax: Arc::from([]),
7381 extra_spans: Arc::from([]),
7382 extra_refine: Arc::from(Vec::new().into_boxed_slice()),
7383 }
7384 }
7385
7386 /// Build an `ArcSwap<RenderState>` whose `cells.panes` carries
7387 /// the caller-supplied entries verbatim. Top-level inputs stay
7388 /// at default — the worker now reads from `panes`.
7389 fn rs_with_panes(entries: Vec<crate::render_state::PaneCellsInputs>) -> ArcSwap<RenderState> {
7390 let cells = CellsRenderState {
7391 panes: Arc::from(entries.into_boxed_slice()),
7392 ..CellsRenderState::default()
7393 };
7394 ArcSwap::from_pointee(RenderState {
7395 cells: Arc::new(ArcSwap::from_pointee(cells)),
7396 ..RenderState::default()
7397 })
7398 }
7399
7400 /// W.2 (A2): the worker stamps `CellMatrix.wrap_width` from the
7401 /// pane's effective wrap width, and a wrap toggle invalidates
7402 /// the cache even when the content version is unchanged.
7403 #[test]
7404 fn recompute_pane_stamps_wrap_width_and_invalidates_on_toggle() {
7405 let snap = snap_of("a line that is clearly wider than the wrap width\n");
7406 let matrix_cell = Arc::new(ArcSwap::from_pointee(CellMatrix::empty()));
7407 let (resolved, ids) = test_cell_theme();
7408 let ct = CellTheme {
7409 resolved: &resolved,
7410 ids: &ids,
7411 };
7412 let ws = WhitespaceConfig::default();
7413
7414 // Wrap off ⇒ stamped width 0 (one display row per line).
7415 let mut p = pane_inputs(matrix_cell.clone(), Some(snap), v(1), 10);
7416 let _ = recompute_pane(&p, ct, &ws);
7417 assert_eq!(matrix_cell.load().wrap_width, 0);
7418 assert_eq!(matrix_cell.load().segment_count(0), 1);
7419
7420 // Wrap on at width 8 — same content version v(1), so only
7421 // the wrap_width differs. The cache-hit guard must still
7422 // force a rebuild that re-stamps the new width, and the
7423 // long line now spans multiple display segments.
7424 p.wrap = true;
7425 p.viewport_width = 8;
7426 let decision = recompute_pane(&p, ct, &ws);
7427 assert!(
7428 matches!(
7429 decision,
7430 WorkerDecision::Recomputed | WorkerDecision::RecomputedIncremental
7431 ),
7432 "wrap toggle at unchanged version must rebuild, got {decision:?}"
7433 );
7434 let m = matrix_cell.load();
7435 assert_eq!(m.wrap_width, 8);
7436 assert!(
7437 m.segment_count(0) > 1,
7438 "long line wraps into multiple segments"
7439 );
7440 }
7441
7442 /// The worker must CONVERGE: repeated ticks with unchanged inputs
7443 /// have to reach `CacheHit`, because a non-CacheHit decision fires
7444 /// `paint_request`, which republishes, which wakes the worker again.
7445 /// A decision that never settles is a spin loop that burns a core for
7446 /// as long as the editor is open.
7447 ///
7448 /// Reported 2026-08-16: `G` to the end of a file put the cells and
7449 /// virtual-rows workers into ~500 mutual ticks/second that never
7450 /// stopped. Scrolled to the END is the interesting position — that is
7451 /// where the windowed build's coverage arithmetic is most likely to
7452 /// disagree with the viewport it is checked against.
7453 #[test]
7454 fn repeated_ticks_at_end_of_buffer_converge_to_cache_hit() {
7455 let snap = big_snap(5000);
7456 let matrix_cell = Arc::new(ArcSwap::from_pointee(CellMatrix::empty()));
7457 let (resolved, ids) = test_cell_theme();
7458 let ct = CellTheme {
7459 resolved: &resolved,
7460 ids: &ids,
7461 };
7462 let ws = WhitespaceConfig::default();
7463
7464 // `G`: cursor + scroll at the last screenful of a 5000-line file.
7465 let mut pane = pane_inputs(matrix_cell, Some(snap), v(1), 50);
7466 pane.scroll = 4950;
7467
7468 let first = recompute_pane(&pane, ct, &ws);
7469 assert!(
7470 !matches!(first, WorkerDecision::CacheHit),
7471 "the first tick builds"
7472 );
7473 for tick in 2..=5 {
7474 let d = recompute_pane(&pane, ct, &ws);
7475 assert!(
7476 matches!(d, WorkerDecision::CacheHit),
7477 "tick {tick} with unchanged inputs must be a CacheHit, got \
7478 {d:?} — a worker that never settles spins forever"
7479 );
7480 }
7481 }
7482
7483 /// The same convergence requirement mid-buffer, as the control: if
7484 /// this one also fails the problem is not end-of-buffer arithmetic.
7485 #[test]
7486 fn repeated_ticks_mid_buffer_converge_to_cache_hit() {
7487 let snap = big_snap(5000);
7488 let matrix_cell = Arc::new(ArcSwap::from_pointee(CellMatrix::empty()));
7489 let (resolved, ids) = test_cell_theme();
7490 let ct = CellTheme {
7491 resolved: &resolved,
7492 ids: &ids,
7493 };
7494 let ws = WhitespaceConfig::default();
7495 let mut pane = pane_inputs(matrix_cell, Some(snap), v(1), 50);
7496 pane.scroll = 2000;
7497
7498 let _ = recompute_pane(&pane, ct, &ws);
7499 for tick in 2..=5 {
7500 let d = recompute_pane(&pane, ct, &ws);
7501 assert!(
7502 matches!(d, WorkerDecision::CacheHit),
7503 "tick {tick} mid-buffer must be a CacheHit, got {d:?}"
7504 );
7505 }
7506 }
7507
7508 // ---- IG.2 indentation guides ----
7509
7510 /// Every guide the worker publishes lands on a blank column of the
7511 /// display line built in the same pass.
7512 ///
7513 /// This is the invariant both renderers are built on: neither carries a
7514 /// don't-overwrite-text guard, so a producer bug would surface as
7515 /// corrupted text on screen rather than as a failure here. Asserting it
7516 /// against the built `DisplayLine` (not against the source rope) is what
7517 /// makes it hold after tab expansion.
7518 #[test]
7519 fn published_guides_land_only_on_blank_columns() {
7520 let snap = snap_of("fn f() {\n if c {\n work();\n }\n}\n");
7521 let matrix_cell = Arc::new(ArcSwap::from_pointee(CellMatrix::empty()));
7522 let (resolved, ids) = test_cell_theme();
7523 let ct = CellTheme {
7524 resolved: &resolved,
7525 ids: &ids,
7526 };
7527 let p = pane_inputs(matrix_cell, Some(snap), v(1), 10);
7528 let _ = recompute_pane(&p, ct, &WhitespaceConfig::default());
7529
7530 let guides = p.indent_guides.load_full();
7531 let matrix = p.display_matrix.load_full();
7532 assert!(!guides.is_empty(), "guides published for a nested buffer");
7533 let mut painted = 0;
7534 for line in 0..5u32 {
7535 let row = matrix.row_at_source_line(line).expect("row built");
7536 let chars: Vec<char> = row.text.chars().collect();
7537 for mark in guides.marks_for_line(line) {
7538 painted += 1;
7539 assert_eq!(
7540 chars.get(mark.col as usize).copied(),
7541 Some(' '),
7542 "line {line} col {} is not blank in {:?}",
7543 mark.col,
7544 row.text
7545 );
7546 }
7547 }
7548 assert!(painted >= 3, "nested fixture paints guides, got {painted}");
7549 }
7550
7551 /// The guide layer carries the stamp of the matrix built beside it.
7552 ///
7553 /// Coherence by construction: a renderer that has decided a matrix is
7554 /// current has, by the same comparison, decided its guides are.
7555 #[test]
7556 fn guides_carry_the_matrix_version() {
7557 let snap = snap_of("fn f() {\n body\n}\n");
7558 let matrix_cell = Arc::new(ArcSwap::from_pointee(CellMatrix::empty()));
7559 let (resolved, ids) = test_cell_theme();
7560 let ct = CellTheme {
7561 resolved: &resolved,
7562 ids: &ids,
7563 };
7564 let p = pane_inputs(matrix_cell, Some(snap), v(1), 10);
7565 let _ = recompute_pane(&p, ct, &WhitespaceConfig::default());
7566 assert_eq!(
7567 p.indent_guides.load().version,
7568 p.display_matrix.load().version
7569 );
7570 }
7571
7572 /// Guides off publishes the EMPTY layer rather than skipping the write.
7573 ///
7574 /// Skipping would leave the previous build painted until some unrelated
7575 /// rebuild cleared it, which is how `:set noindent-guides` appears not to
7576 /// work.
7577 #[test]
7578 fn disabling_guides_clears_the_published_layer() {
7579 let snap = snap_of("fn f() {\n body\n}\n");
7580 let matrix_cell = Arc::new(ArcSwap::from_pointee(CellMatrix::empty()));
7581 let (resolved, ids) = test_cell_theme();
7582 let ct = CellTheme {
7583 resolved: &resolved,
7584 ids: &ids,
7585 };
7586 let ws = WhitespaceConfig::default();
7587 let mut p = pane_inputs(matrix_cell, Some(snap), v(1), 10);
7588 let _ = recompute_pane(&p, ct, &ws);
7589 assert!(!p.indent_guides.load().is_empty());
7590
7591 // The axis moves with the option, which is what makes the worker
7592 // rebuild rather than take the cache-hit branch.
7593 p.indent_guides_enabled = false;
7594 p.version.indent = crate::indent_guides::indent_axis_version(&p.indent_unit, false);
7595 let _ = recompute_pane(&p, ct, &ws);
7596 assert!(p.indent_guides.load().is_empty());
7597 }
7598
7599 /// `shiftwidth` re-spaces the guides, and the axis bump is what gets the
7600 /// worker to rebuild for it.
7601 #[test]
7602 fn shiftwidth_change_respaces_guides() {
7603 // Body at column 8: at shiftwidth 4 that is two levels, at 8 one.
7604 let snap = snap_of("fn f() {\n deep();\n}\n");
7605 let matrix_cell = Arc::new(ArcSwap::from_pointee(CellMatrix::empty()));
7606 let (resolved, ids) = test_cell_theme();
7607 let ct = CellTheme {
7608 resolved: &resolved,
7609 ids: &ids,
7610 };
7611 let ws = WhitespaceConfig::default();
7612 let mut p = pane_inputs(matrix_cell, Some(snap), v(1), 10);
7613 p.indent_unit = lattice_core::IndentUnit::new(4, true, 4);
7614 p.version.indent = crate::indent_guides::indent_axis_version(&p.indent_unit, true);
7615 let _ = recompute_pane(&p, ct, &ws);
7616 let cols: Vec<u16> = p
7617 .indent_guides
7618 .load()
7619 .marks_for_line(1)
7620 .iter()
7621 .map(|m| m.col)
7622 .collect();
7623 assert_eq!(cols, vec![0, 4]);
7624
7625 p.indent_unit = lattice_core::IndentUnit::new(8, true, 4);
7626 p.version.indent = crate::indent_guides::indent_axis_version(&p.indent_unit, true);
7627 let decision = recompute_pane(&p, ct, &ws);
7628 assert!(
7629 !matches!(decision, WorkerDecision::CacheHit),
7630 "the indent axis must invalidate the cache, got {decision:?}"
7631 );
7632 let cols: Vec<u16> = p
7633 .indent_guides
7634 .load()
7635 .marks_for_line(1)
7636 .iter()
7637 .map(|m| m.col)
7638 .collect();
7639 assert_eq!(cols, vec![0], "one level of 8 columns");
7640 }
7641
7642 /// A pane whose snapshot vanishes mid-publish clears its guides with its
7643 /// matrices, rather than leaving the last build painted over whatever
7644 /// replaces the buffer.
7645 #[test]
7646 fn clearing_a_pane_clears_its_guides() {
7647 let snap = snap_of("fn f() {\n body\n}\n");
7648 let matrix_cell = Arc::new(ArcSwap::from_pointee(CellMatrix::empty()));
7649 let (resolved, ids) = test_cell_theme();
7650 let ct = CellTheme {
7651 resolved: &resolved,
7652 ids: &ids,
7653 };
7654 let ws = WhitespaceConfig::default();
7655 let mut p = pane_inputs(matrix_cell, Some(snap), v(1), 10);
7656 let _ = recompute_pane(&p, ct, &ws);
7657 assert!(!p.indent_guides.load().is_empty());
7658
7659 p.snapshot = None;
7660 let _ = recompute_pane(&p, ct, &ws);
7661 assert!(p.indent_guides.load().is_empty());
7662 }
7663
7664 /// W.4.t: a hard tab expands to `tabstop` columns of cells, so
7665 /// `col_count` reflects the true display width (one width model
7666 /// for host scroll + renderers). Whitespace off ⇒ plain spaces;
7667 /// whitespace on with a tab glyph ⇒ the marker leads, spaces
7668 /// fill (respects `display.whitespace.tab`).
7669 #[test]
7670 fn recompute_pane_expands_tabs_to_tabstop_width() {
7671 let snap = snap_of("\tab");
7672 let (resolved, ids) = test_cell_theme();
7673 let ct = CellTheme {
7674 resolved: &resolved,
7675 ids: &ids,
7676 };
7677
7678 // Whitespace off, tabstop 4 ⇒ leading tab → 4 space cells,
7679 // then `ab` ⇒ col_count 6. No WS_MARKER, no literal `\t`.
7680 let plain = WhitespaceConfig {
7681 tabstop: 4,
7682 ..Default::default()
7683 };
7684 let matrix_cell = Arc::new(ArcSwap::from_pointee(CellMatrix::empty()));
7685 let p = pane_inputs(matrix_cell.clone(), Some(snap.clone()), v(1), 10);
7686 let _ = recompute_pane(&p, ct, &plain);
7687 let m = matrix_cell.load();
7688 let row = m.row_at_source_line(0).expect("row 0");
7689 assert_eq!(row.col_count(), 6, "tab(4) + 'ab'(2)");
7690 assert!(
7691 row.cells[..4].iter().all(|c| c.codepoint == ' ' as u32),
7692 "leading tab expands to 4 spaces"
7693 );
7694 assert!(
7695 row.cells[..4].iter().all(|c| !c.is_ws_marker()),
7696 "whitespace off ⇒ no marker flag"
7697 );
7698
7699 // Whitespace on with a tab glyph ⇒ first column is the
7700 // marker, the next 3 are space fill, all WS_MARKER.
7701 let marked = WhitespaceConfig {
7702 show: true,
7703 tab: Some('→'),
7704 tabstop: 4,
7705 ..Default::default()
7706 };
7707 let matrix_cell2 = Arc::new(ArcSwap::from_pointee(CellMatrix::empty()));
7708 let p2 = pane_inputs(matrix_cell2.clone(), Some(snap), v(1), 10);
7709 let _ = recompute_pane(&p2, ct, &marked);
7710 let m2 = matrix_cell2.load();
7711 let row2 = m2.row_at_source_line(0).expect("row 0");
7712 assert_eq!(row2.col_count(), 6);
7713 assert_eq!(row2.cells[0].codepoint, '→' as u32, "marker leads the tab");
7714 assert!(row2.cells[0].is_ws_marker());
7715 assert!(
7716 row2.cells[1..4].iter().all(|c| c.codepoint == ' ' as u32),
7717 "fill columns are spaces"
7718 );
7719 }
7720
7721 /// Two visible Document panes with distinct buffers (distinct
7722 /// matrix cells) both rebuild on a single tick. Each pane's
7723 /// own cell receives a fresh `CellMatrix`; cross-pane writes
7724 /// don't bleed.
7725 #[test]
7726 fn two_panes_with_distinct_buffers_both_rebuild() {
7727 let snap_a = snap_of_versioned("aa\nbb", 1);
7728 let snap_b = snap_of_versioned("xx\nyy\nzz", 1);
7729 let cell_a: Arc<ArcSwap<CellMatrix>> = Arc::default();
7730 let cell_b: Arc<ArcSwap<CellMatrix>> = Arc::default();
7731 let mut a = pane_inputs(cell_a.clone(), Some(snap_a), v(1), 5);
7732 let mut b = pane_inputs(cell_b.clone(), Some(snap_b), v(1), 5);
7733 a.buffer_id = lattice_core::BufferId(1);
7734 b.buffer_id = lattice_core::BufferId(2);
7735 let rs = rs_with_panes(vec![a, b]);
7736 assert_eq!(recompute(&rs), WorkerDecision::Recomputed);
7737 // Pane A: 2 source lines from "aa\nbb".
7738 assert_eq!(cell_a.load().source_line_count, 2);
7739 // Pane B: 3 source lines from "xx\nyy\nzz".
7740 assert_eq!(cell_b.load().source_line_count, 3);
7741 }
7742
7743 /// Cache-hit per pane: one pane sees a version bump and
7744 /// rebuilds; the other pane's inputs match its published
7745 /// matrix and skip work. The aggregate decision is
7746 /// `Recomputed` because at least one pane changed.
7747 #[test]
7748 fn per_pane_cache_hit_skips_unchanged_pane() {
7749 let snap_a = snap_of_versioned("aa\nbb", 1);
7750 let snap_b = snap_of_versioned("xx\nyy", 1);
7751 let cell_a: Arc<ArcSwap<CellMatrix>> = Arc::default();
7752 let cell_b: Arc<ArcSwap<CellMatrix>> = Arc::default();
7753 let mut a = pane_inputs(cell_a.clone(), Some(snap_a.clone()), v(1), 5);
7754 let mut b = pane_inputs(cell_b.clone(), Some(snap_b.clone()), v(1), 5);
7755 a.buffer_id = lattice_core::BufferId(1);
7756 b.buffer_id = lattice_core::BufferId(2);
7757 // First tick: both build.
7758 let rs1 = rs_with_panes(vec![a.clone(), b.clone()]);
7759 assert_eq!(recompute(&rs1), WorkerDecision::Recomputed);
7760 let a_arc_after_tick1 = cell_a.load_full();
7761 let b_arc_after_tick1 = cell_b.load_full();
7762 // Second tick: bump pane A's text version; pane B unchanged.
7763 let snap_a2 = snap_of_versioned("aa\nbb\ncc", 2);
7764 a.version = v(2);
7765 a.snapshot = Some(snap_a2);
7766 let rs2 = rs_with_panes(vec![a, b]);
7767 assert_eq!(recompute(&rs2), WorkerDecision::Recomputed);
7768 // Pane A rebuilt — Arc identity changed.
7769 assert!(
7770 !Arc::ptr_eq(&a_arc_after_tick1, &cell_a.load_full()),
7771 "pane A must rebuild on version bump"
7772 );
7773 assert_eq!(cell_a.load().source_line_count, 3);
7774 // Pane B was a cache hit — Arc identity preserved.
7775 assert!(
7776 Arc::ptr_eq(&b_arc_after_tick1, &cell_b.load_full()),
7777 "pane B was a cache hit; Arc identity must survive"
7778 );
7779 assert_eq!(cell_b.load().source_line_count, 2);
7780 }
7781
7782 /// Two panes showing the same buffer share one matrix cell.
7783 /// The first pane rebuilds; the second pane sees a CacheHit
7784 /// against the freshly-published matrix. Aggregate is
7785 /// `Recomputed` (one entry produced content).
7786 #[test]
7787 fn two_panes_sharing_buffer_share_one_matrix_write() {
7788 let snap = snap_of_versioned("aa\nbb", 1);
7789 let shared_cell: Arc<ArcSwap<CellMatrix>> = Arc::default();
7790 let mut a = pane_inputs(shared_cell.clone(), Some(snap.clone()), v(1), 5);
7791 let mut b = pane_inputs(shared_cell.clone(), Some(snap), v(1), 5);
7792 // Same buffer, distinct pane ids — matches what
7793 // `Editor::cells_matrix_for` returns when two panes show
7794 // the same buffer.
7795 let pid = lattice_core::ui::pane::PaneId::default();
7796 a.pane_id = pid;
7797 b.pane_id = pid; // ids match because PaneId::default() is the same; the test
7798 a.buffer_id = lattice_core::BufferId(42);
7799 b.buffer_id = lattice_core::BufferId(42);
7800 let rs = rs_with_panes(vec![a, b]);
7801 assert_eq!(recompute(&rs), WorkerDecision::Recomputed);
7802 assert_eq!(shared_cell.load().source_line_count, 2);
7803 }
7804
7805 /// Empty `panes` → no work; aggregate is `CacheHit`. Used by
7806 /// editors with no Document leaves visible (eg every pane
7807 /// showing a synthetic buffer).
7808 #[test]
7809 fn empty_panes_is_cache_hit() {
7810 let rs = rs_with_panes(Vec::new());
7811 assert_eq!(recompute(&rs), WorkerDecision::CacheHit);
7812 }
7813
7814 /// A pane with `snapshot: None` clears its matrix. The
7815 /// aggregate decision is `Clear` when no other pane saw a
7816 /// content-producing rebuild.
7817 #[test]
7818 fn pane_without_snapshot_clears_its_matrix() {
7819 let cell: Arc<ArcSwap<CellMatrix>> = Arc::default();
7820 // Seed a non-empty matrix so the Clear branch exercises
7821 // the store path (not the idempotent fast path).
7822 let pre_chunk = Arc::new(CellChunk::new(
7823 0,
7824 vec![CellRow::new(
7825 vec![Cell::with_codepoint(b'x' as u32)],
7826 0,
7827 Vec::<lattice_cells::row::InlayOffset>::new(),
7828 )],
7829 v(7),
7830 ));
7831 cell.store(Arc::new(CellMatrix::whole_doc(pre_chunk, 1)));
7832 let pane = pane_inputs(cell.clone(), None, v(7), 5);
7833 let rs = rs_with_panes(vec![pane]);
7834 assert_eq!(recompute(&rs), WorkerDecision::Clear);
7835 assert!(cell.load().is_empty());
7836 }
7837
7838 // ---- S3.a — cell modifier flag bits ----
7839
7840 /// `modifiers_to_flags` packs each host `Modifiers` field into
7841 /// the corresponding `cell_flags` bit. Standalone helper test
7842 /// — no worker driving needed.
7843 #[test]
7844 fn modifiers_to_flags_packs_each_bit() {
7845 use crate::ui::theme::Modifiers;
7846 use lattice_cells::cell_flags;
7847
7848 let none = Modifiers::default();
7849 assert_eq!(modifiers_to_flags(&none), 0);
7850
7851 let bold = Modifiers {
7852 bold: true,
7853 ..Modifiers::default()
7854 };
7855 assert_eq!(modifiers_to_flags(&bold), cell_flags::BOLD);
7856
7857 let italic = Modifiers {
7858 italic: true,
7859 ..Modifiers::default()
7860 };
7861 assert_eq!(modifiers_to_flags(&italic), cell_flags::ITALIC);
7862
7863 let under = Modifiers {
7864 underline: true,
7865 ..Modifiers::default()
7866 };
7867 assert_eq!(modifiers_to_flags(&under), cell_flags::UNDERLINE);
7868
7869 let dim = Modifiers {
7870 dim: true,
7871 ..Modifiers::default()
7872 };
7873 assert_eq!(modifiers_to_flags(&dim), cell_flags::DIM);
7874
7875 let rev = Modifiers {
7876 reverse: true,
7877 ..Modifiers::default()
7878 };
7879 assert_eq!(modifiers_to_flags(&rev), cell_flags::REVERSE);
7880
7881 let all = Modifiers {
7882 bold: true,
7883 italic: true,
7884 underline: true,
7885 dim: true,
7886 reverse: true,
7887 };
7888 let expected = cell_flags::BOLD
7889 | cell_flags::ITALIC
7890 | cell_flags::UNDERLINE
7891 | cell_flags::DIM
7892 | cell_flags::REVERSE;
7893 assert_eq!(modifiers_to_flags(&all), expected);
7894 }
7895
7896 /// Catppuccin's default theme styles `Keyword` as bold and
7897 /// `LineComment` as italic. After running the cell-builder
7898 /// with a Rust syntax handle attached, the cells for the `fn`
7899 /// keyword carry the BOLD bit and the cells for a `// comment`
7900 /// line carry ITALIC. Plain source bytes (the identifier
7901 /// `main`, the paren punctuation) do NOT carry either bit.
7902 #[test]
7903 fn keyword_cells_carry_bold_comment_cells_carry_italic() {
7904 let text = "fn main() {}\n// comment\n";
7905 let handle = rust_handle(text, 1);
7906 let snap = snap_of_versioned(text, 1);
7907 let matrix_cell: Arc<ArcSwap<CellMatrix>> = Arc::default();
7908 let rs = rs_with_snapshot_themed(Some(snap), v(1), matrix_cell.clone(), Some(handle));
7909 assert_eq!(recompute(&rs), WorkerDecision::Recomputed);
7910
7911 let m = matrix_cell.load();
7912 let rows: Vec<&CellRow> = m.slice(0, 10).iter().collect();
7913 assert!(rows.len() >= 2);
7914
7915 // Line 0: cells 0 and 1 are `f` and `n` (the `fn` keyword).
7916 // Catppuccin's keyword style is `.bold()`.
7917 let line0 = rows[0];
7918 assert_eq!(line0.cells[0].codepoint, b'f' as u32);
7919 assert!(
7920 line0.cells[0].is_bold(),
7921 "`f` of keyword `fn` must carry BOLD flag"
7922 );
7923 assert!(
7924 line0.cells[1].is_bold(),
7925 "`n` of keyword `fn` must carry BOLD flag"
7926 );
7927 // Keyword style has no italic / underline / dim / reverse.
7928 assert!(!line0.cells[0].is_italic());
7929 assert!(!line0.cells[0].is_underline());
7930 assert!(!line0.cells[0].is_dim());
7931 assert!(!line0.cells[0].is_reverse());
7932
7933 // Line 1: `// comment` — comment style is `.italic()`.
7934 let line1 = rows[1];
7935 assert!(
7936 line1.cells.iter().all(|c| c.is_italic()),
7937 "every cell on a comment row must carry ITALIC flag"
7938 );
7939 assert!(
7940 line1.cells.iter().all(|c| !c.is_bold()),
7941 "comment cells must not carry BOLD (Catppuccin comment is italic-only)"
7942 );
7943 }
7944
7945 /// Inlay cells carry only the `INLAY` flag — never any of the
7946 /// syntax-style modifier bits. Inlay fg + flags come from the
7947 /// dedicated inlay path, not from `theme.syntax_style(...)`.
7948 #[test]
7949 fn inlay_cells_do_not_inherit_syntax_modifiers() {
7950 let text = "fn x";
7951 let handle = rust_handle(text, 1);
7952 let snap = snap_of_versioned(text, 1);
7953 let matrix_cell: Arc<ArcSwap<CellMatrix>> = Arc::default();
7954 // Splice an inlay after `fn` (byte 2) — right in keyword
7955 // territory. The cell-builder must NOT pick up BOLD on
7956 // the inlay's spliced cells.
7957 let hints = vec![inlay(0, 2, ":")];
7958 let rs = rs_with_snapshot_full(Some(snap), v(1), matrix_cell.clone(), Some(handle), hints);
7959 assert_eq!(recompute(&rs), WorkerDecision::Recomputed);
7960
7961 let m = matrix_cell.load();
7962 let row = m.slice(0, 1).iter().next().cloned().unwrap();
7963 // combined row: `f n : space x` (`f`, `n`, inlay `:`, ` `, `x`).
7964 // Cell indices: 0=f keyword, 1=n keyword, 2=`:` INLAY,
7965 // 3=` ` source, 4=`x` source.
7966 assert_eq!(row.cells[0].codepoint, b'f' as u32);
7967 assert!(row.cells[0].is_bold(), "keyword `f` should be BOLD");
7968 assert_eq!(row.cells[2].codepoint, b':' as u32);
7969 assert!(row.cells[2].is_inlay(), "inlay cell must carry INLAY");
7970 assert!(
7971 !row.cells[2].is_bold(),
7972 "inlay cell must NOT inherit BOLD from surrounding keyword style"
7973 );
7974 assert!(!row.cells[2].is_italic());
7975 assert!(!row.cells[2].is_underline());
7976 }
7977}