Skip to main content

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(&reg),
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}