Skip to main content

lattice_ui_tui/
render.rs

1//! Frame rendering. Pure where it can be (line composition is testable);
2//! IO-bound where ratatui needs it (`draw_frame` accepting a `Frame`).
3//!
4//! Layout:
5//!
6//! +----------------------------------------------------------------+
7//! | gutter | buffer text                                           |
8//! | gutter | buffer text                                           |
9//! | ...                                                            |
10//! +----------------------------------------------------------------+
11//! | mode line: NOR  path                       line:col   lang     |
12//! +----------------------------------------------------------------+
13
14use std::sync::Arc;
15
16use ratatui::Frame;
17use ratatui::layout::{Constraint, Direction, Layout, Rect};
18use ratatui::style::{Color, Modifier, Style as TuiStyle};
19use ratatui::text::{Line, Span};
20use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap};
21
22use lattice_grammar::{ModalState, SearchDirection};
23use lattice_lsp::{Diagnostic as LspDiagnostic, DiagnosticSeverity};
24use lattice_protocol::position::Range as ProtoRange;
25// 5.8.P: `VisualMode` reads now live host-side via
26// `Editor::visual_selection_range`; this peer no longer references
27// the variants directly.
28use lattice_runtime::DocumentSnapshot;
29
30use crate::app::{App, EchoLevel, Fold};
31
32/// Per-render-chain snapshot of App state the renderer reads.
33///
34/// Audit slice 7 / M2. The renderer is one of multiple peer
35/// renderer implementations (TUI today, GPUI as part of 1.0,
36/// future WebRenderer). The architecture is renderer-agnostic:
37/// no render path may depend on single-threaded discipline as
38/// its safety mechanism, because GPUI runs on a separate
39/// thread from the App's input loop.
40///
41/// `FrameView` is taken once at entry to each render chain
42/// (`compose_visible_lines`, `draw_inactive_document`) and
43/// threaded into the chain's helpers. Internal reads then go
44/// through immutable Arc-shared / by-value snapshots; an async
45/// mutator that writes the underlying App fields can no longer
46/// produce a torn mid-render view, regardless of which thread
47/// the renderer runs on.
48///
49/// Fields:
50/// - `app: &App` -- stable App fields (cursor, modal,
51///   command_line, picker, ...) that don't mutate during a
52///   render pass even under multi-thread rendering. Read
53///   directly through this borrowed reference.
54/// - `folds: Arc<[Fold]>` -- frozen snapshot. Replaces direct
55///   `app.editor.folds.iter()` reads.
56/// - `show_line_numbers: bool` -- cached typed-options value.
57///   The typed-options ArcSwap read is wait-free per call, but
58///   caching once per chain keeps gutter computation
59///   deterministic if the option flips mid-chain under
60///   multi-thread input.
61///
62/// `app.editor.lsp_diagnostics` is left as a borrowed
63/// `&DiagnosticsLayer` because that layer is already wait-free
64/// behind its own `ArcSwap` (audit slice 2); the existing
65/// `line_severity` / `diagnostics_on_line` API is structurally
66/// safe to call from any thread.
67pub struct FrameView<'a> {
68    pub app: &'a App,
69    pub folds: Arc<[Fold]>,
70    // display-line B4.2: the `visible_rows` field (worker-published
71    // pre-paint rows) was deleted. The TUI document body migrated to
72    // the canonical `DisplayMatrix` (B2.4); markdown / help / messages
73    // bodies read their own sources. Nothing in the compose chain read
74    // this field any more — it was constructed but never consumed.
75    pub show_line_numbers: bool,
76    /// M.4: resolved per-pane in `for_buffer`; tracks the active
77    /// buffer's setting in `from_app`. Reading this through the
78    /// view lets per-pane render paths route consistently.
79    pub relative_line_numbers: bool,
80    /// Slice 3c.extension.fold-rs: cache `foldenable` at view
81    /// construction. Prior to this, `view.line_inside_closed_fold`
82    /// and friends called `self.app.foldenable()` per line, each
83    /// triggering an actor mailbox round-trip (~µs–tens-of-µs).
84    /// One pre-cached bool per frame replaces 120+ RPCs in the
85    /// compose loop.
86    pub foldenable: bool,
87    /// Perf plan C: O(log folds) lookup index built once per frame
88    /// from `folds + foldenable`. The compose loop's per-line
89    /// `line_inside_closed_fold` check used to walk every fold per
90    /// row (`iter().any(...)` — O(rows × folds)); via this index it
91    /// becomes a partition-point binary search with a constant-time
92    /// fast path for the common non-overlapping case.
93    pub fold_index: lattice_host::folds::FoldIndex,
94    /// Fold-bleed follow-up (2026-06-30): this pane's OWN byte-baked
95    /// inlay-hint rows, resolved via `RenderState::inlay_hints_for_buffer`
96    /// (active → baked `syntax.inlay_hints`; inactive → published
97    /// `cells.panes` entry). One source for both focus states so the
98    /// compose loop splices hints through a single path — no
99    /// active-vs-inactive divergence to drift.
100    pub inlay_hints: Arc<[lattice_host::render_state::InlayHintRow]>,
101    /// Slice 3c.extension.fold-rs: per-frame LSP-mode gates,
102    /// cached once at `FrameView::from_app` so the compose loop's
103    /// per-line decoration checks don't pay actor-RPC cost. Each
104    /// is one `read_editor` at frame entry; a 120-row paint that
105    /// previously triggered 120× per-line RPC for
106    /// `app.lsp_semantic_tokens_mode_enabled_for(...)` now reads
107    /// `view.lsp_semantic_tokens_enabled` directly.
108    pub lsp_mode_enabled: bool,
109    pub lsp_diagnostics_enabled: bool,
110    pub lsp_semantic_tokens_enabled: bool,
111    pub lsp_document_highlight_enabled: bool,
112    /// Slice A.2b.2: inlay-hint mode gate moved to publish-time
113    /// (`Editor::build_active_inlay_hints` returns an empty list
114    /// when the mode is off). The compose loop no longer reads
115    /// this field; `rs.syntax.inlay_hints` is already gated, so
116    /// `is_empty()` on the published list is the cheap fast-path
117    /// check.
118    pub lsp_progress_enabled: bool,
119    /// Whether soft-wrap is enabled for this pane's buffer. Global
120    /// option today (`option_cache.wrap_lines`); the seam for
121    /// per-buffer options: when buffer-local options land,
122    /// `for_buffer` resolves from that buffer's local value with
123    /// the global default as fallback (emacs buffer-local pattern).
124    /// Compose paths read `view.wrap_lines` instead of touching
125    /// `app.ad().option_cache` directly so the resolver is always
126    /// in one place.
127    pub wrap_lines: bool,
128    /// PU.1b-1a (`signcolumn`): whether to reserve this pane's gutter
129    /// sign columns (diagnostics severity + diff sign). Resolved
130    /// per-buffer like every other view option — `from_app` reads the
131    /// active buffer's `option_cache.sign_column`; `for_buffer` reads
132    /// `app.sign_column_for(buffer_id)`. The compose path reserves the
133    /// two sign cells iff this is `true`, so a buffer with
134    /// `signcolumn=no` (help-mode, synthetic buffers) renders
135    /// gutterless without the renderer knowing it's help.
136    pub sign_column: bool,
137    /// DB.4: extra leading gutter cells to horizontally centre the buffer's
138    /// content (dashboard). Added to the computed gutter width for both
139    /// document lines and virtual rows, so content + cursor shift right. `0`
140    /// for every non-centred buffer.
141    pub content_left_pad: u32,
142}
143
144impl<'a> FrameView<'a> {
145    /// Snapshot the App's per-render-chain state once.
146    ///
147    /// `Arc::from(Vec<T>)` is one alloc + a memcpy of the
148    /// slice metadata; the underlying span / fold data already
149    /// lives in heap-allocated vecs, so the snapshot cost is
150    /// O(folds.len() + viewport_height) -- negligible at
151    /// terminal sizes. GPUI-era multi-thread rendering can call
152    /// this from the render thread without taking any App
153    /// lock; the App's main loop owns the underlying vecs and
154    /// the snapshot is consistent at the moment `from_app` runs.
155    pub fn from_app(app: &'a App) -> Self {
156        // display-line B4.2: the worker-published pre-paint rows the
157        // `FrameView` used to snapshot here were deleted with the
158        // overlay worker's span/row cache. The TUI document body reads
159        // the canonical `DisplayMatrix` (B2.4) directly in the compose
160        // loop; nothing on `FrameView` consumed the prepaint rows.
161        let rs = app.render_state.load_full();
162        // Slice 3c.extension.fold-rs: pre-cache per-frame option +
163        // mode-gate reads. One `read_editor` each at frame entry
164        // (~7 RPCs total per frame) replaces N actor RPCs in the
165        // per-line compose loop. The active document id is needed
166        // for the mode-gate checks.
167        let doc_id = rs.active_document.load().document_buffer_id;
168        // Perf plan C: build the index once per frame from the same
169        // snapshot the renderer reads. Both peers go through this
170        // path now; build cost is O(folds) (<1 µs for typical files).
171        // Fold-bleed fix (2026-06-30): route through `folds_for_buffer`
172        // (the active doc returns its live `self.folds`) so the active
173        // and inactive (`for_buffer`) pane paths share ONE per-buffer
174        // fold source — no drift between them or the GPUI peer.
175        let (folds, foldenable) = rs.folds_for_buffer(doc_id);
176        let fold_index = lattice_host::folds::FoldIndex::from_folds(&folds, foldenable);
177        let inlay_hints = rs.inlay_hints_for_buffer(doc_id);
178        // Snapshot ad() once so wrap_lines and sign_column come from
179        // the same atomic snapshot as the outer RenderState.
180        let ad = rs.active_document.load();
181        Self {
182            app,
183            folds,
184            inlay_hints,
185            show_line_numbers: app.show_line_numbers(),
186            relative_line_numbers: app.relative_line_numbers(),
187            foldenable,
188            fold_index,
189            lsp_mode_enabled: app.lsp_mode_enabled_for(doc_id),
190            lsp_diagnostics_enabled: app.lsp_diagnostics_mode_enabled_for(doc_id),
191            lsp_semantic_tokens_enabled: app.lsp_semantic_tokens_mode_enabled_for(doc_id),
192            lsp_document_highlight_enabled: app.lsp_document_highlight_mode_enabled_for(doc_id),
193            lsp_progress_enabled: app.lsp_progress_mode_enabled_for(doc_id),
194            wrap_lines: ad.option_cache.wrap_lines,
195            sign_column: ad.option_cache.sign_column,
196            content_left_pad: ad.option_cache.content_left_pad,
197        }
198    }
199
200    /// M.4: per-pane FrameView -- resolves options for `buffer_id`
201    /// instead of capturing the active buffer's settings. Used by
202    /// inactive-pane render paths so each pane's mode stack drives
203    /// its own gutter independently.
204    /// DR.2: inactive panes now source decorations from their own
205    /// per-pane `DisplayMatrix`; `pane_highlights` is retired.
206    pub fn for_buffer(app: &'a App, buffer_id: crate::buffers::BufferId) -> Self {
207        // display-line B4.2: same as `from_app` — the deleted
208        // prepaint-rows cell is no longer snapshotted here.
209        let rs = app.render_state.load_full();
210        // Fold-bleed fix (2026-06-30): resolve folds from THIS pane's
211        // buffer, not the active doc. Previously this snapshot was
212        // doc-scoped (`active_document.folds`), so an inactive pane
213        // showing a different buffer rendered with the active buffer's
214        // folds and dropped its own. `folds_for_buffer` returns the
215        // per-buffer list (+ its `foldenable`). Shared with the GPUI peer.
216        let (folds, foldenable) = rs.folds_for_buffer(buffer_id);
217        let fold_index = lattice_host::folds::FoldIndex::from_folds(&folds, foldenable);
218        let inlay_hints = rs.inlay_hints_for_buffer(buffer_id);
219        // PI.0: content-centring pad follows the *rendered* buffer's
220        // `CenterContentWidth` local + its pane's width, not the
221        // active-buffer identity — so a pane keeps its centring even when
222        // `document_buffer_id` points elsewhere (e.g. during a picker
223        // preview that swapped the active buffer to the previewed file).
224        let content_left_pad = rs.content_left_pad_for(buffer_id);
225        Self {
226            app,
227            folds,
228            inlay_hints,
229            content_left_pad,
230            // PI.4: resolved from the PUBLISHED per-buffer snapshot, not
231            // through `App::resolved_option` — that routes via `read_editor`,
232            // so the four option reads here were four actor-seam calls per
233            // pane per frame. The active path never paid them (it read the
234            // hot-path `option_cache`), which is exactly why the guard on
235            // `compose_visible_lines` never caught it: it only watched the
236            // path that was already clean. Same seam the GPUI peer uses.
237            show_line_numbers: *rs.resolved_option_for::<lattice_config::Number>(buffer_id),
238            relative_line_numbers: *rs
239                .resolved_option_for::<lattice_config::RelativeNumber>(buffer_id),
240            // Slice 3c.extension.fold-rs: per-buffer cache. The
241            // mode gates resolve against `buffer_id` (the pane's
242            // buffer, possibly different from the active doc).
243            foldenable,
244            fold_index,
245            lsp_mode_enabled: app.lsp_mode_enabled_for(buffer_id),
246            lsp_diagnostics_enabled: app.lsp_diagnostics_mode_enabled_for(buffer_id),
247            lsp_semantic_tokens_enabled: app.lsp_semantic_tokens_mode_enabled_for(buffer_id),
248            lsp_document_highlight_enabled: app.lsp_document_highlight_mode_enabled_for(buffer_id),
249            lsp_progress_enabled: app.lsp_progress_mode_enabled_for(buffer_id),
250            // PU.1b-1a: wrap + signcolumn resolve per-buffer (the
251            // emacs buffer-local pattern) so an inactive pane reflects
252            // ITS mode stack — e.g. help-mode's `Wrap = true` /
253            // `signcolumn = no` — not the active buffer's settings.
254            wrap_lines: *rs.resolved_option_for::<lattice_config::Wrap>(buffer_id),
255            sign_column: rs
256                .resolved_option_for::<lattice_config::SignColumnOption>(buffer_id)
257                .reserved(),
258        }
259    }
260
261    /// Mirror of [`App::fold_start_at_any`] but reads from the
262    /// frozen `view.folds` snapshot instead of `app.editor.folds`.
263    /// Used by the gutter glyph provider so the renderer's view
264    /// of folds can't go out of sync with the snapshot it took
265    /// at chain entry.
266    pub fn fold_start_at_any(&self, line: u32) -> Option<&Fold> {
267        if !self.foldenable {
268            return None;
269        }
270        self.folds.iter().find(|f| f.start_line == line)
271    }
272
273    /// Mirror of [`App::fold_start_at`] -- only matches CLOSED
274    /// folds at `line`. Reads from the frozen `view.folds`
275    /// snapshot.
276    pub fn fold_start_at(&self, line: u32) -> Option<&Fold> {
277        if !self.foldenable {
278            return None;
279        }
280        self.folds.iter().find(|f| f.closed && f.start_line == line)
281    }
282
283    /// Mirror of [`App::line_inside_closed_fold`] reading from
284    /// the snapshot.
285    ///
286    /// Perf plan C: routes through the per-frame
287    /// [`lattice_host::folds::FoldIndex`] so the per-line cost in
288    /// compose loops drops from O(folds) to O(log folds) amortized
289    /// constant. The `foldenable` short-circuit is baked into the
290    /// index at construction time — no extra branch here.
291    pub fn line_inside_closed_fold(&self, line: u32) -> bool {
292        self.fold_index.line_inside_closed_fold(line)
293    }
294}
295
296// DR.2 (decoration-retention): `source_spans_from_runs` (the
297// inactive-pane fallback that derived `StyledSpan`s from the worker's
298// `RowPrepaint.runs`) was retired with the `pane_highlights` path.
299// Inactive panes now read the per-pane `DisplayMatrix` like the active
300// pane.
301
302/// Render one terminal frame.
303///
304/// `snap` is the active document's snapshot, loaded once per frame
305/// by the runtime via `app.editor.snapshot_cache.load_arc()` (DESIGN.md
306/// §5.6.8). All active-pane render paths read through this single
307/// snapshot -- inactive panes (different documents) still go
308/// through `entry.handle.snapshot()` since the cache is per-cell.
309///
310/// ## Per-render-chain stability (audit slice 7 / M2)
311///
312/// The renderer is one of multiple peer renderer implementations
313/// (TUI today; GPUI as part of 1.0; future WebRenderer). The
314/// architecture is renderer-agnostic: render paths must NOT depend
315/// on single-threaded discipline as their safety mechanism,
316/// because GPUI runs on a separate thread from the App's input
317/// loop. Each render chain (`compose_visible_lines`,
318/// `draw_inactive_document`) takes a [`FrameView`] at entry and
319/// threads it into its helpers; reads of `folds`,
320/// `visible_highlights`, and `show_line_numbers` go through that
321/// snapshot rather than the live App fields. `lsp_diagnostics`
322/// stays wait-free behind its own `ArcSwap` (audit slice 2).
323/// WK.12: how many rows the minibuffer band claims — its content, capped at
324/// half the body so an advisory hint can never swallow the buffer it describes
325/// (the same cap the pane-bottom popup carried). `0` when no band is open.
326pub(crate) fn band_row_count(app: &crate::app::App, body_height: u16) -> u16 {
327    let Some(id) = app.band().buffer_id else {
328        return 0;
329    };
330    let Some(handle) = app.buffers().registry.document_handle(id) else {
331        return 0;
332    };
333    let lines = handle.snapshot().buffer.content_line_count() as u16;
334    lines.min((body_height / 2).max(1))
335}
336
337/// WK.12: the band's inner `(rows, cols)` for the host's cells worker — the
338/// peer of [`popup_feedback_inner_dims`]. Without this hand-off
339/// `build_cells_panes` never sizes `PaneId::MINIBUFFER_BAND` and the band
340/// paints nothing at all.
341pub fn band_feedback_inner_dims(
342    app: &crate::app::App,
343    terminal_width: u16,
344    buffer_height: u32,
345) -> Option<(u32, u32)> {
346    let rows = band_row_count(app, buffer_height.min(u16::MAX as u32) as u16);
347    (rows > 0).then(|| (rows as u32, terminal_width.max(1) as u32))
348}
349
350/// WK.12: paint the minibuffer band — which-key's grid today. Borderless and
351/// full width: it is a strip of the frame, not a floating box, and it never
352/// takes focus, so no cursor is placed inside it and no interaction overlay
353/// (hlsearch, current line) applies.
354fn draw_minibuffer_band(
355    frame: &mut ratatui::Frame,
356    area: ratatui::layout::Rect,
357    app: &crate::app::App,
358) {
359    let Some(band_id) = app.band().buffer_id else {
360        return;
361    };
362    let Some(handle) = app.buffers().registry.document_handle(band_id) else {
363        return;
364    };
365    let content_snap = handle.snapshot();
366    let view = FrameView::for_buffer(app, band_id);
367    let ctx = PaneComposeCtx {
368        is_active: false,
369        pane_id: lattice_core::ui::pane::PaneId::MINIBUFFER_BAND,
370        buffer_id: band_id,
371        cursor_line: 0,
372        cursor_line_highlight: false,
373        scroll: 0,
374        leftcol: 0,
375        // A band shows a grid, never a file: no gutter.
376        display_line_numbers: None,
377    };
378    let lines = compose_pane_lines(
379        &view,
380        &content_snap,
381        area.height as u32,
382        area.width as u32,
383        &ctx,
384    );
385    frame.render_widget(ratatui::widgets::Paragraph::new(lines), area);
386}
387
388/// Returns the completion-docs side popup's inner `(rows, cols)` when it is
389/// shown this frame (PU.5c) — `None` otherwise. The runtime loop feeds this
390/// back via `App::set_completion_docs_viewport` so `build_cells_panes` sizes
391/// the `PaneId::COMPLETION_DOCS` matrix. Computed at the exact draw site
392/// (with the real `chunks[1]` buffer area) because the docs popup is
393/// cursor-anchored — position-dependent, unlike the floating popup whose
394/// geometry the runtime reconstructs (`popup_feedback_inner_dims`).
395#[must_use]
396pub fn draw_frame(frame: &mut Frame, app: &App, snap: &DocumentSnapshot) -> Option<(u32, u32)> {
397    // ML.4: last frame's click regions describe a layout that is about
398    // to be overwritten. Clearing here rather than in the modeline
399    // painter is what makes a pane that stops painting a modeline
400    // (closed split, full-screen popup) stop being clickable too.
401    app.modeline_hits.borrow_mut().clear();
402    // MO.2: same contract as the modeline map above — last frame's pane
403    // rects describe a layout about to be overwritten, and clearing here
404    // is what makes a pane that stops painting (closed split) stop taking
405    // clicks.
406    app.pane_hits.borrow_mut().clear();
407    // Vertico-style layout (DESIGN.md §5.11.3, §5.9.7): when the
408    // cmdline completion popup OR the picker is open in
409    // minibuffer mode, an extra row band sits below the cmdline
410    // holding the candidate list. The selected candidate sits
411    // visually adjacent to the prompt (above for completion,
412    // below for picker), alternatives fanning away. Without
413    // either open the layout is the standard
414    // buffer / mode-line / cmdline three.
415    //
416    // Picker takes precedence over completion when both are open
417    // (only one is reachable interactively at a time, but the
418    // ordering matters for layout sizing).
419    //
420    // `picker.display` (config) selects whether the picker uses
421    // this minibuffer-anchored layout or a centred popup overlay
422    // floating over the buffer. Default `"minibuffer"`. In
423    // `"popup"` mode the picker does NOT claim the cmdline row
424    // and does NOT allocate an extra band -- the centered
425    // overlay is drawn on top of the buffer area instead.
426    let picker_is_minibuffer = picker_display_is_minibuffer(app);
427    // `chrome_rows` is the single source of truth for tabline + candidate-
428    // band rows, shared with the runtime loop's viewport/pane-rect push
429    // (see its doc comment) — this and that push can no longer diverge.
430    let chrome = chrome_rows(app);
431    let extra_rows = chrome.extra();
432
433    // Issue #29 (2026-05-22): tabline row at the top. Visibility
434    // is resolved by the publisher (`build_tabs_render_state`)
435    // based on `tabline.show` × tabs.len().
436    let tabline_rows: u16 = chrome.tabline;
437    let tabline_visible = tabline_rows > 0;
438
439    // MO.4.b / Option-A: global modeline removed. Each pane owns its
440    // own 1-row status footer (drawn by draw_panes / draw_pane_status_line).
441    // MB.2: the `:` line grows into a multi-row mini-buffer band when
442    // expanded (`<C-x><C-e>`), pushing the panes (and their mode-lines) up.
443    // MB.2e: the `command-line.expand-height` option sizes it (`half`
444    // default / `full` / fixed rows), resolved against the live frame
445    // height. The band claims rows from the `Min(1)` pane area above it.
446    let cmdline_rows: u16 = if app.command_line_expanded() {
447        let base = app.command_line_expand_height().rows(frame.area().height);
448        // When the completion popup is open inside the expanded band,
449        // deduct its rows from the band height so candidates sit flush
450        // against the cursor instead of at the very bottom of the frame.
451        let completion_rows = app
452            .completion()
453            .state
454            .as_deref()
455            .map(|s| popup_height(s.candidates.len()))
456            .unwrap_or(0) as u16;
457        base.saturating_sub(completion_rows).max(1)
458    } else {
459        1
460    };
461    // Layout: tabline (0/1) | panes (Min) | cmdline (1..=band) | candidates (opt).
462    let constraints: Vec<Constraint> = if extra_rows > 0 {
463        vec![
464            Constraint::Length(tabline_rows),      // tabline (0 or 1)
465            Constraint::Min(1),                    // panes (each carries its own status row)
466            Constraint::Length(cmdline_rows),      // cmdline / picker query / expanded band
467            Constraint::Length(extra_rows as u16), // candidate list (bottom)
468        ]
469    } else {
470        vec![
471            Constraint::Length(tabline_rows),
472            Constraint::Min(1),
473            Constraint::Length(cmdline_rows),
474        ]
475    };
476    let chunks = Layout::default()
477        .direction(Direction::Vertical)
478        .constraints(constraints)
479        .split(frame.area());
480
481    // chunks[0] = tabline (0-height when hidden),
482    // chunks[1] = pane area, chunks[2] = cmdline, chunks[3] = candidates.
483    if tabline_visible {
484        draw_tabline(frame, chunks[0], app);
485    }
486    // WK.12: the minibuffer band claims rows from the BOTTOM of the pane area —
487    // below every pane, directly above the `:` line. Carved out here rather
488    // than added as a layout constraint so the `chunks[N]` indices below keep
489    // their meaning; everything that paints over the panes gets `body`, so the
490    // band is never overdrawn and never overdraws a popup.
491    let band_rows = band_row_count(app, chunks[1].height);
492    let body = ratatui::layout::Rect {
493        height: chunks[1].height.saturating_sub(band_rows),
494        ..chunks[1]
495    };
496    draw_panes(frame, body, app, snap);
497    if band_rows > 0 {
498        let band_area = ratatui::layout::Rect {
499            y: chunks[1].y + body.height,
500            height: band_rows,
501            ..chunks[1]
502        };
503        draw_minibuffer_band(frame, band_area, app);
504    }
505    // Picker query claims the cmdline row only in minibuffer
506    // mode. In popup mode the cmdline / echo content stays
507    // visible and the picker query renders inside the overlay
508    // instead. Fold audit fix: a transient in minibuffer mode gets
509    // its title here (the same row a regular picker's prompt uses),
510    // exactly like every other picker surface — it used to force
511    // the popup overlay unconditionally, ignoring `picker.display`.
512    if app.picker_state().state.is_some() && picker_is_minibuffer {
513        let picker = app.picker_state();
514        let is_transient = picker
515            .state
516            .as_deref()
517            .is_some_and(|p| p.transient.is_some());
518        if is_transient {
519            draw_transient_minibuffer_prompt(frame, chunks[2], app);
520        } else {
521            draw_picker_prompt(frame, chunks[2], app);
522        }
523    } else {
524        draw_command_or_echo(frame, chunks[2], app);
525    }
526    // Help popup overlay -- painted whenever a popup_buffer is
527    // set AND the active pane isn't already showing it as an
528    // in-pane buffer (the in-pane case is handled by the Help
529    // arm of `draw_panes`). Two scenarios trigger this:
530    // - **State A** (active = Document, popup_buffer = Some):
531    //   first `K` shown the popup, focus is still on the doc;
532    //   doc paints normally below, popup floats on top, no
533    //   cursor inside the popup.
534    // - **State B** (active = Help via popup mode, pane.buffer =
535    //   Document): second `K` moved focus into the popup; popup
536    //   paints with a visible cursor at `app.editor.cursor`; doc paints
537    //   as inactive (frozen at `pane.cursor`) below.
538    // Slice 3c.final.B (group 1): pane-tree reads route through
539    // `app.panes()` instead of `app.editor.pane_tree.X()`.
540    let active_pane_kind = app.panes().tree.active().buffer;
541    if app.popup().is_open() && active_pane_kind != crate::buffers::BufferKind::Help {
542        draw_help_overlay(frame, body, app, snap);
543    }
544    // Picker candidate list (precedence over completion popup --
545    // only one is interactive at a time). Only the minibuffer
546    // display mode uses the bottom band; the popup mode draws
547    // its own self-contained overlay below. Fold audit fix: a
548    // transient's item list now uses this same band in minibuffer
549    // mode (`draw_transient_minibuffer_candidates`), instead of
550    // being excluded from it entirely and forced into the popup box.
551    let is_transient = app
552        .picker_state()
553        .state
554        .as_deref()
555        .is_some_and(|p| p.transient.is_some());
556    if chrome.picker > 0 && is_transient {
557        draw_transient_minibuffer_candidates(frame, chunks[3], app);
558    } else if chrome.picker > 0 {
559        draw_picker_candidates(frame, chunks[3], app);
560    } else if chrome.completion > 0 {
561        draw_completion_popup(frame, chunks[3], app);
562    }
563    // Picker popup overlay -- only drawn when `picker.display` is
564    // `"popup"` and a picker is open. Floats centered over the
565    // buffer area (chunks[0]) so the user still sees the mode line
566    // and any echo / cmdline content underneath. Fold audit fix:
567    // this used to draw the transient overlay unconditionally
568    // (`|| ...transient.is_some()`) regardless of `picker_is_minibuffer`
569    // — a transient in minibuffer mode got BOTH the minibuffer strip
570    // above AND this popup, or effectively always the popup since the
571    // strip path never ran. Now it follows the exact same
572    // `!picker_is_minibuffer` gate every other picker surface uses.
573    let picker_state = app.picker_state();
574    if picker_state.state.is_some() && !picker_is_minibuffer {
575        let p = picker_state.state.as_deref().unwrap();
576        if p.transient.is_some() {
577            draw_transient_overlay(frame, body, app);
578        } else {
579            draw_picker_overlay(frame, body, app);
580        }
581    }
582    // NOTIF.1b: painted after every other overlay so a picker or a
583    // transient cannot cover it — a notification hidden exactly when
584    // the user is busy is the case it exists for.
585    draw_notifications(frame, body, app);
586    // Slice 3c.gpui-cmdline-completion: cmdline-completion popup
587    // overlay. Mutually exclusive with the picker (picker doesn't
588    // open during `:` typing).
589    if !picker_is_minibuffer
590        && app
591            .completion()
592            .state
593            .as_deref()
594            .is_some_and(|s| !s.candidates.is_empty())
595    {
596        draw_completion_overlay(frame, body, app);
597    }
598    // Insert-mode completion popup overlay (Phase 4.2.g.1).
599    // Anchored at the cursor; floats over the buffer; doesn't
600    // claim the cmdline row (so echoes can still appear).
601    // Painted last so it sits on top of any pane-area widgets.
602    let mut completion_docs_dims: Option<(u32, u32)> = None;
603    if app.completion_popup_active() {
604        draw_insert_completion_popup(frame, body, app, snap);
605        // Side documentation popup (Phase 4.2.g.3) -- only
606        // rendered when the user has flipped it on with
607        // `<C-d>`. Anchored right of the candidate popup
608        // when there's room; below otherwise.
609        if let Some(state) = app.completion().insert.as_deref()
610            && state.doc_popup.is_some()
611        {
612            draw_insert_completion_docs_popup(frame, body, app, snap);
613            // PU.5c: feed the docs popup's inner geometry back to the host
614            // (computed here with the exact `chunks[1]` it painted into).
615            completion_docs_dims = completion_docs_feedback_inner_dims(app, snap, chunks[1]);
616        }
617    }
618    completion_docs_dims
619}
620
621/// Issue #29 (2026-05-22): paint the tabline row. Reads the
622/// published `TabsRenderState` (labels + active idx) and
623/// renders ` [N] {label} ` per tab, brightening the active
624/// one with `cursor_background` / `cursor_foreground`. Lines
625/// truncate horizontally at the area's right edge — overflow
626/// disappears (real vim does the same).
627fn draw_tabline(frame: &mut Frame, area: Rect, app: &App) {
628    use ratatui::text::{Line, Span};
629    use ratatui::widgets::Paragraph;
630    let tabs = app.render_state.load().tabs.clone();
631    let active_style = app.theme.pane_status_active;
632    let inactive_style = app.theme.pane_status_inactive;
633    let mut spans: Vec<Span<'static>> = Vec::with_capacity(tabs.items.len() * 2);
634    for (idx, item) in tabs.items.iter().enumerate() {
635        let label = item.tabline_text(idx);
636        let style = if idx == tabs.active {
637            active_style
638        } else {
639            inactive_style
640        };
641        spans.push(Span::styled(label, style));
642        // Separator between tabs (only between, not trailing).
643        if idx + 1 < tabs.items.len() {
644            spans.push(Span::styled(" ".to_string(), inactive_style));
645        }
646    }
647    let line = Line::from(spans);
648    frame.render_widget(Paragraph::new(line), area);
649}
650
651/// Total rows the popup occupies (no borders -- vertico-style;
652/// matches the picker's candidate-list shape) capped so it
653/// never dominates the screen.
654/// The picker / completion candidate-band budget.
655///
656/// A picker is *filtered* — you type to narrow — so ten rows is
657/// plenty. Transients are different (see [`transient_popup_height`]).
658const PICKER_MAX_ROWS: usize = 10;
659
660fn popup_height(candidate_count: usize) -> usize {
661    popup_height_capped(candidate_count, PICKER_MAX_ROWS)
662}
663
664/// MG.41b: the same clamp with a caller-supplied maximum.
665fn popup_height_capped(candidate_count: usize, max_rows: usize) -> usize {
666    candidate_count.min(max_rows.max(1)).max(1)
667}
668
669/// MG.41b: a transient's row budget, from `ui.transient.max-rows`.
670///
671/// Transients are *browsed*, not filtered: you read the menu to find
672/// the key, so the picker's ten-row budget showed under half of the
673/// 25-row magit dispatch. Falls back to the compiled default when the
674/// registry is unpopulated (boot-before-linkme and test fixtures,
675/// mirroring `command_line_expand_height`).
676fn transient_max_rows(app: &App) -> usize {
677    // Published options sub-state — a wait-free Arc clone, no actor
678    // round-trip, same as `picker_display_is_minibuffer`. This runs in
679    // the per-frame layout path, so an actor hop here would be a
680    // paramount-#1 violation.
681    app.options()
682        .config
683        .get_typed::<lattice_config::TransientMaxRows>()
684        .map(|arc| *arc)
685        .unwrap_or(20)
686        .max(1) as usize
687}
688
689/// Rows outside the buffer area: the tabline (0/1) and the picker/
690/// completion candidate band (0 when neither is open, or when
691/// `picker.display = "popup"` floats it over the buffer instead of
692/// claiming a strip).
693pub(crate) struct ChromeRows {
694    pub tabline: u16,
695    pub picker: u16,
696    pub completion: u16,
697}
698
699impl ChromeRows {
700    /// The candidate-band height: picker takes precedence when both are
701    /// open (only one is interactively reachable at a time).
702    pub fn extra(&self) -> u16 {
703        self.picker.max(self.completion)
704    }
705}
706
707/// SINGLE source of truth for [`ChromeRows`]. Before this existed,
708/// `draw_frame`'s paint layout and the runtime loop's viewport / pane-rect
709/// push each computed these rows independently (the runtime loop even had
710/// its own hand-synced copy of `popup_height`, `popup_height_for`,
711/// explicitly commented "kept in sync by hand for now"). The tabline term
712/// was missing from the runtime's copy entirely: the scroll/viewport logic
713/// believed it had one more row than `draw_frame` actually painted, so the
714/// last visible line (by the viewport's reckoning) was never drawn once the
715/// tabline showed. `popup_feedback_inner_dims` no longer needs its own
716/// local tabline recomputation either — callers pass a `buffer_height`
717/// that already excludes it via this function.
718pub(crate) fn chrome_rows(app: &App) -> ChromeRows {
719    let tabline = if app.render_state.load().tabs.visible {
720        1
721    } else {
722        0
723    };
724    let picker_is_minibuffer = picker_display_is_minibuffer(app);
725    let picker_rows = if picker_is_minibuffer {
726        app.picker_state()
727            .state
728            .as_deref()
729            .map(|p| match p.transient.as_deref() {
730                // Fold audit fix: a transient's row budget is its
731                // group/item count, not `candidates.len()` (always 0
732                // for a transient — it has no candidate list at all).
733                // Using the candidate count here meant the minibuffer
734                // band claimed only 1 row for a transient, regardless
735                // of how many items it actually had.
736                Some(spec) => {
737                    popup_height_capped(transient_row_count(spec), transient_max_rows(app))
738                }
739                None => popup_height(p.candidates.len().max(1)),
740            })
741            .unwrap_or(0)
742    } else {
743        0
744    };
745    let completion_rows = if picker_is_minibuffer {
746        app.completion()
747            .state
748            .as_deref()
749            .map(|s| popup_height(s.candidates.len()))
750            .unwrap_or(0)
751    } else {
752        0
753    };
754    ChromeRows {
755        tabline,
756        picker: picker_rows as u16,
757        completion: completion_rows as u16,
758    }
759}
760
761/// Vertico-style cmdline completion popup (DESIGN.md §5.11.3,
762/// **Insert-mode completion popup** (Phase 4.2.g.1, design
763/// in `docs/dev/architecture/insert-completion.md` §5). Multi-column layout:
764/// `[kind glyph] [label]   [detail]   [src]`. Anchored below
765/// the cursor at the popup's `anchor` position; falls back to
766/// above when there's no room below. Selected row reverse-
767/// videoed; matched byte ranges in the label are painted with
768/// the match face.
769///
770/// Width capped at 60 cells; height capped at 12 rows. Doc-
771/// popup side panel + width-aware column dropping land in
772/// 4.2.g.3 / 4.2.g.5.
773fn draw_insert_completion_popup(
774    frame: &mut Frame,
775    buffer_area: Rect,
776    app: &App,
777    snap: &DocumentSnapshot,
778) {
779    // Slice 3c.final.B (group 3): bind the substate Arc so the
780    // `as_deref()` borrow lives for the function body.
781    let completion = app.completion();
782    let Some(state) = completion.insert.as_deref() else {
783        return;
784    };
785    if state.rendered.is_empty() {
786        return;
787    }
788    // Width: cap at 60 cells, fits at least 30.
789    let width: u16 = 60u16.min(buffer_area.width.saturating_sub(2)).max(30);
790    // Height: cap at 12, but never more than the candidate
791    // count + the selected row's surrounding band.
792    let max_h: u16 = 12;
793    let want_h = (state.rendered.len() as u16).min(max_h).max(1);
794    // Anchor screen position: the cursor's screen position
795    // is what we want, since the popup sits at the user's
796    // typing point. Active pane content rect computed via
797    // the helper from the hover popup path.
798    let pane_rect = active_pane_content_rect(app, buffer_area).unwrap_or(buffer_area);
799    let view = FrameView::from_app(app);
800    let anchor_screen = cursor_screen_position_at(
801        &view,
802        snap,
803        pane_rect,
804        app.ad().cursor,
805        app.ad().scroll,
806        app.panes().tree.active().id,
807    );
808    let (anchor_x, anchor_y) = anchor_screen.unwrap_or((buffer_area.x, buffer_area.y));
809    // Below if there's room, else above.
810    let area_bottom = buffer_area.y + buffer_area.height;
811    let space_below = area_bottom.saturating_sub(anchor_y + 1);
812    let space_above = anchor_y.saturating_sub(buffer_area.y);
813    let height = want_h.min(space_below.max(space_above));
814    if height == 0 {
815        return;
816    }
817    let y = if space_below >= height {
818        anchor_y + 1
819    } else {
820        anchor_y.saturating_sub(height)
821    };
822    let max_x = (buffer_area.x + buffer_area.width).saturating_sub(width);
823    let x = anchor_x.min(max_x).max(buffer_area.x);
824    let popup = Rect {
825        x,
826        y,
827        width,
828        height,
829    };
830    frame.render_widget(Clear, popup);
831    // CSM.K2: reserve the bottom row for a filter-chord hint
832    // whenever the popup has at least 2 rows. The hint surfaces
833    // the per-source filter chords (unfiltered) or the active
834    // source + `<C-Space>` clear (filtered).
835    let footer_rows: u16 = if popup.height >= 2 { 1 } else { 0 };
836    let candidate_rows = popup.height.saturating_sub(footer_rows) as usize;
837    // Window the visible slice so the selected row stays on
838    // screen. Selected sticks at the top band when reachable;
839    // scrolls down when the selection passes the visible-row
840    // count.
841    let scroll = if state.selected < candidate_rows {
842        0
843    } else {
844        state.selected + 1 - candidate_rows
845    };
846    let display_col_chars = state
847        .rendered
848        .iter()
849        .skip(scroll)
850        .take(candidate_rows)
851        .map(|c| c.raw.display.chars().count())
852        .max()
853        .unwrap_or(0);
854    let lines: Vec<Line> = state
855        .rendered
856        .iter()
857        .enumerate()
858        .skip(scroll)
859        .take(candidate_rows)
860        .map(|(i, c)| insert_candidate_line(c, i == state.selected, display_col_chars))
861        .collect();
862    let candidate_area = Rect {
863        x: popup.x,
864        y: popup.y,
865        width: popup.width,
866        height: candidate_rows as u16,
867    };
868    let para = Paragraph::new(lines);
869    frame.render_widget(para, candidate_area);
870    if footer_rows > 0 {
871        let footer_area = Rect {
872            x: popup.x,
873            y: popup.y + candidate_rows as u16,
874            width: popup.width,
875            height: footer_rows,
876        };
877        let footer = insert_completion_footer_line(state, footer_area.width);
878        frame.render_widget(Paragraph::new(footer), footer_area);
879    }
880}
881
882/// CSM.K2: filter-chord hint rendered as the popup's bottom
883/// row. Unfiltered: a compact chord menu pruned to the chords
884/// that actually have candidates in `state.raw`. Filtered:
885/// `source: <id>  [<C-Space> all]` so the user can tell which
886/// source is active and how to clear it.
887///
888/// 2026-05-27: switched from `  ` separator to `│` and added
889/// a compact fallback. Width adaption order:
890///   1. Full form: `<C-b> buf │ <C-o> lsp │ ...`
891///   2. Compact: `[b]uf │ [o]lsp │ ...` (drops the `<C-` chord
892///      prefix since users know the convention from the popup-
893///      layer keymap docs).
894///   3. Prune chords from the right (least common source last)
895///      until what's left fits.
896fn insert_completion_footer_line(
897    state: &lattice_completion::InsertCompletionState,
898    width: u16,
899) -> Line<'static> {
900    use ratatui::style::{Modifier, Style};
901    use ratatui::text::Span;
902    if let Some(active) = state.source_filter.as_ref() {
903        let label = source_display_label(active.as_str());
904        let text = format!(" source: {label} │ <C-Space> all ");
905        let text = clip_to_width(&text, width);
906        return Line::from(Span::styled(
907            text,
908            Style::default().add_modifier(Modifier::DIM),
909        ));
910    }
911    let sources_present: std::collections::BTreeSet<&str> = state
912        .raw
913        .iter()
914        .filter_map(|r| r.source.as_ref().map(|s| s.as_str()))
915        .collect();
916    let entries = filter_chord_entries(&sources_present);
917    if entries.is_empty() {
918        return Line::from(Span::raw(""));
919    }
920    let text = render_filter_chord_footer(&entries, width);
921    Line::from(Span::styled(
922        text,
923        Style::default().add_modifier(Modifier::DIM),
924    ))
925}
926
927/// One filter chord entry. `key` is the bare letter (e.g.
928/// `"b"`); `label` is the source's short name. Shared between
929/// the full and compact renderers.
930pub(crate) struct FilterChordEntry {
931    pub key: &'static str,
932    pub label: &'static str,
933}
934
935/// 2026-05-27: build the ordered chord list from the sources
936/// actually present in this batch. Pruning order matches the
937/// popup keymap (CSM.K2): buffer → lsp → path → tree-sitter →
938/// snippet (most-common to least-common in normal coding use).
939pub(crate) fn filter_chord_entries(
940    sources_present: &std::collections::BTreeSet<&str>,
941) -> Vec<FilterChordEntry> {
942    let mut out: Vec<FilterChordEntry> = Vec::new();
943    if sources_present.contains(lattice_completion::insert::BufferWordsSource::ID) {
944        out.push(FilterChordEntry {
945            key: "b",
946            label: "buf",
947        });
948    }
949    if sources_present.contains(lattice_completion::insert::LSP_COMPLETION_SOURCE_ID) {
950        out.push(FilterChordEntry {
951            key: "o",
952            label: "lsp",
953        });
954    }
955    if sources_present.contains(lattice_completion::insert::PATH_SOURCE_ID) {
956        out.push(FilterChordEntry {
957            key: "f",
958            label: "path",
959        });
960    }
961    if sources_present.contains(lattice_completion::insert::TREE_SITTER_SYMBOL_SOURCE_ID) {
962        out.push(FilterChordEntry {
963            key: "t",
964            label: "ts",
965        });
966    }
967    if sources_present.contains(lattice_completion::insert::SNIPPET_SOURCE_ID) {
968        out.push(FilterChordEntry {
969            key: "s",
970            label: "snip",
971        });
972    }
973    out
974}
975
976/// 2026-05-27: render the filter-chord footer string with
977/// width adaption — full form if it fits, compact otherwise,
978/// then prune from the right.
979pub(crate) fn render_filter_chord_footer(entries: &[FilterChordEntry], width: u16) -> String {
980    if entries.is_empty() {
981        return String::new();
982    }
983    let w = width as usize;
984    let render = |form_full: bool, take: usize| -> String {
985        let parts: Vec<String> = entries
986            .iter()
987            .take(take)
988            .map(|e| {
989                if form_full {
990                    format!("<C-{}> {}", e.key, e.label)
991                } else {
992                    // `^` is the standard Ctrl indicator (e.g. `^C` =
993                    // Ctrl-C). `[^b]uf` reads as "Ctrl-b → buf" in
994                    // half the chars of the full form.
995                    format!("[^{}]{}", e.key, e.label)
996                }
997            })
998            .collect();
999        format!(" {} ", parts.join(" │ "))
1000    };
1001    // Try full form, all entries.
1002    let full = render(true, entries.len());
1003    if full.chars().count() <= w {
1004        return full;
1005    }
1006    // Try compact form, all entries.
1007    let compact = render(false, entries.len());
1008    if compact.chars().count() <= w {
1009        return compact;
1010    }
1011    // Prune from the right in compact form.
1012    for take in (1..entries.len()).rev() {
1013        let pruned = render(false, take);
1014        if pruned.chars().count() <= w {
1015            return pruned;
1016        }
1017    }
1018    // Single chord still doesn't fit — hard truncate.
1019    clip_to_width(&compact, width)
1020}
1021
1022fn source_display_label(id: &str) -> &'static str {
1023    match id {
1024        "gen:buffer-words" => "buffer-words",
1025        "gen:lsp-completion" => "lsp",
1026        "gen:path" => "path",
1027        "gen:tree-sitter-symbol" => "tree-sitter",
1028        "gen:snippet" => "snippet",
1029        _ => "source",
1030    }
1031}
1032
1033fn clip_to_width(s: &str, width: u16) -> String {
1034    let w = width as usize;
1035    if s.chars().count() <= w {
1036        let mut out = s.to_string();
1037        while out.chars().count() < w {
1038            out.push(' ');
1039        }
1040        out
1041    } else {
1042        s.chars().take(w).collect()
1043    }
1044}
1045
1046/// **Insert-mode completion docs side popup** (Phase
1047/// 4.2.g.3). Anchored right of the candidate popup when
1048/// there's room (typical wide terminals); falls back to
1049/// below the candidate popup when narrow. Renders the
1050/// focused item's `documentation` (lazy-resolved via
1051/// `completionItem/resolve`) wrapped to the popup width.
1052/// Title bar shows "docs" + the focused candidate's label.
1053///
1054/// `<C-f>` / `<C-b>` (inside the completion-popup minor
1055/// mode) page through the body via `state.doc_popup.scroll`.
1056/// PU.5c: the OUTER rect the completion-docs side popup paints into, or
1057/// `None` when no docs popup is shown / there's no room. Anchored to the
1058/// right of (or below) the candidate popup. Shared by the renderer (paint)
1059/// and the runtime geometry feedback (`completion_docs_feedback_inner_dims`
1060/// → `App::set_completion_docs_viewport`) so the `PaneId::COMPLETION_DOCS`
1061/// matrix width and the painted box agree — the peer of
1062/// `popup_feedback_inner_dims` / `draw_help_overlay`.
1063fn completion_docs_popup_rect(
1064    app: &App,
1065    snap: &DocumentSnapshot,
1066    buffer_area: Rect,
1067) -> Option<Rect> {
1068    let completion = app.completion();
1069    let state = completion.insert.as_deref()?;
1070    let doc_popup = state.doc_popup.as_ref()?;
1071    // Only shown when the focused candidate resolved a non-empty body.
1072    if doc_popup.body.as_ref().is_none_or(|b| b.is_empty()) {
1073        return None;
1074    }
1075    // Anchor: same anchor as the candidate popup.
1076    let pane_rect = active_pane_content_rect(app, buffer_area).unwrap_or(buffer_area);
1077    let view = FrameView::from_app(app);
1078    let anchor_screen = cursor_screen_position_at(
1079        &view,
1080        snap,
1081        pane_rect,
1082        app.ad().cursor,
1083        app.ad().scroll,
1084        app.panes().tree.active().id,
1085    );
1086    let (anchor_x, anchor_y) = anchor_screen.unwrap_or((buffer_area.x, buffer_area.y));
1087    // Candidate popup geometry (mirrors `draw_insert_completion_popup`).
1088    let cand_width: u16 = 60u16.min(buffer_area.width.saturating_sub(2)).max(30);
1089    let cand_height: u16 = 12u16.min(state.rendered.len() as u16).max(1);
1090    let area_bottom = buffer_area.y + buffer_area.height;
1091    let space_below = area_bottom.saturating_sub(anchor_y + 1);
1092    let cand_y = if space_below >= cand_height {
1093        anchor_y + 1
1094    } else {
1095        anchor_y.saturating_sub(cand_height)
1096    };
1097    let cand_max_x = (buffer_area.x + buffer_area.width).saturating_sub(cand_width);
1098    let cand_x = anchor_x.min(cand_max_x).max(buffer_area.x);
1099    // Docs popup: try to fit right of the candidate popup; else below it.
1100    let cand_right = cand_x + cand_width;
1101    let space_right = (buffer_area.x + buffer_area.width).saturating_sub(cand_right + 1);
1102    let docs_width: u16 = 60u16.min(space_right);
1103    let (x, y, width, height) = if docs_width >= 30 {
1104        (cand_right + 1, cand_y, docs_width, cand_height)
1105    } else {
1106        let below_y = cand_y + cand_height;
1107        let below_h = area_bottom.saturating_sub(below_y).min(8);
1108        if below_h < 3 {
1109            return None;
1110        }
1111        (cand_x, below_y, cand_width, below_h)
1112    };
1113    if width < 20 || height < 3 {
1114        return None;
1115    }
1116    Some(Rect {
1117        x,
1118        y,
1119        width,
1120        height,
1121    })
1122}
1123
1124/// PU.5c: inner `(rows, cols)` of the completion-docs popup for the host
1125/// geometry hand-off (`App::set_completion_docs_viewport`). Inner = outer −
1126/// 2 (the `Borders::ALL` block). `None` when no docs popup is shown — the
1127/// runtime then pushes nothing (the synthetic pane simply isn't built).
1128pub(crate) fn completion_docs_feedback_inner_dims(
1129    app: &App,
1130    snap: &DocumentSnapshot,
1131    buffer_area: Rect,
1132) -> Option<(u32, u32)> {
1133    let rect = completion_docs_popup_rect(app, snap, buffer_area)?;
1134    let inner_w = u32::from(rect.width.saturating_sub(2)).max(1);
1135    let inner_h = u32::from(rect.height.saturating_sub(2)).max(1);
1136    Some((inner_h, inner_w))
1137}
1138
1139fn draw_insert_completion_docs_popup(
1140    frame: &mut Frame,
1141    buffer_area: Rect,
1142    app: &App,
1143    snap: &DocumentSnapshot,
1144) {
1145    let Some(popup) = completion_docs_popup_rect(app, snap, buffer_area) else {
1146        return;
1147    };
1148    frame.render_widget(Clear, popup);
1149    let block = Block::default()
1150        .borders(Borders::ALL)
1151        .title(" docs (<C-d>) ");
1152    let inner = block.inner(popup);
1153    frame.render_widget(block, popup);
1154    if inner.width == 0 || inner.height == 0 {
1155        return;
1156    }
1157    let completion = app.completion();
1158    let doc_scroll = completion
1159        .insert
1160        .as_deref()
1161        .and_then(|s| s.doc_popup.as_ref())
1162        .map(|d| d.scroll)
1163        .unwrap_or(0);
1164    // PU.5c: render the docs CONTENT through the shared compose seam reading
1165    // the `PaneId::COMPLETION_DOCS` `DisplayMatrix` (markdown colour + link
1166    // styling + soft-wrap), the SAME path the help / hover popup uses — the
1167    // docs buffer is a help-flavoured ephemeral Document. Before the
1168    // ephemeral buffer exists (the one tick between `doc_popup.body`
1169    // appearing and `reconcile_completion_docs_buffer` creating it) OR
1170    // before the renderer has fed geometry back (matrix not built yet),
1171    // compose falls back to plain-text from the snapshot — correct text,
1172    // colour catches up within a frame (UX-acceptable eventual consistency).
1173    if let Some(docs_id) = completion.docs_buffer_id
1174        && let Some(handle) = app.buffers().registry.document_handle(docs_id)
1175    {
1176        let content_snap = handle.snapshot();
1177        let view = FrameView::for_buffer(app, docs_id);
1178        let ctx = PaneComposeCtx {
1179            // The docs popup is never focused (State A only) — no cursor,
1180            // no interaction overlays.
1181            is_active: false,
1182            pane_id: lattice_core::ui::pane::PaneId::COMPLETION_DOCS,
1183            buffer_id: docs_id,
1184            cursor_line: 0,
1185            cursor_line_highlight: false,
1186            scroll: doc_scroll,
1187            leftcol: 0,
1188            display_line_numbers: handle.display_line_numbers(),
1189        };
1190        let lines = compose_pane_lines(
1191            &view,
1192            &content_snap,
1193            inner.height as u32,
1194            inner.width as u32,
1195            &ctx,
1196        );
1197        frame.render_widget(Paragraph::new(lines), inner);
1198    } else {
1199        // Pre-buffer fallback: plain-text body so the user sees docs
1200        // immediately on the first frame.
1201        let body_text: String = completion
1202            .insert
1203            .as_deref()
1204            .and_then(|s| s.doc_popup.as_ref())
1205            .and_then(|d| d.body.clone())
1206            .unwrap_or_else(|| "(loading…)".to_string());
1207        let visible_body: String = body_text
1208            .lines()
1209            .skip(doc_scroll as usize)
1210            .collect::<Vec<_>>()
1211            .join("\n");
1212        let para = Paragraph::new(visible_body)
1213            .wrap(Wrap { trim: false })
1214            .style(TuiStyle::default().fg(Color::Gray));
1215        frame.render_widget(para, inner);
1216    }
1217}
1218
1219/// Render one Insert-mode-completion candidate row. Three
1220/// columns: kind glyph (3 cells) / label with match-face
1221/// highlighting (≤ 30 cells) / source tag right-aligned
1222/// (3-4 cells). Detail column lands in 4.2.g.3 once LSP
1223/// items carry signatures; for buffer-words there's no
1224/// detail to show.
1225/// 2026-05-27: `display_col_chars` is the widest visible
1226/// candidate's display width in chars. Caller computes once
1227/// per render so every row's annotation column starts at the
1228/// same x. Replaces the previous `width: u16` (popup width)
1229/// padding that made the annotation drift to the right edge
1230/// of wide popups — bounded by content, not container.
1231fn insert_candidate_line<'a>(
1232    c: &'a lattice_completion::RenderedCandidate,
1233    selected: bool,
1234    display_col_chars: usize,
1235) -> Line<'a> {
1236    let row_style = if selected {
1237        TuiStyle::default()
1238            .bg(Color::DarkGray)
1239            .add_modifier(Modifier::BOLD)
1240    } else {
1241        TuiStyle::default()
1242    };
1243    let match_style = TuiStyle::default()
1244        .fg(Color::Cyan)
1245        .add_modifier(Modifier::BOLD);
1246    let glyph = candidate_kind_glyph(c.raw.kind);
1247    // 3-cell kind column (selected/unselected) + leading space.
1248    let mut spans: Vec<Span<'a>> = vec![Span::styled(format!(" {glyph}  "), row_style)];
1249    // Label with match-face spans on `c.match_ranges`.
1250    let label = &c.raw.display;
1251    let mut cursor = 0usize;
1252    let mut sorted: Vec<_> = c.match_ranges.clone();
1253    sorted.sort_by_key(|r| r.start);
1254    for range in sorted {
1255        if range.start >= label.len() || range.end > label.len() || range.start >= range.end {
1256            continue;
1257        }
1258        if range.start > cursor {
1259            spans.push(Span::styled(
1260                label[cursor..range.start].to_string(),
1261                row_style,
1262            ));
1263        }
1264        spans.push(Span::styled(
1265            label[range.start..range.end].to_string(),
1266            if selected {
1267                match_style.bg(Color::DarkGray).add_modifier(Modifier::BOLD)
1268            } else {
1269                match_style
1270            },
1271        ));
1272        cursor = range.end;
1273    }
1274    if cursor < label.len() {
1275        spans.push(Span::styled(label[cursor..].to_string(), row_style));
1276    }
1277    // Source tag, column-aligned. Inferred from kind for v1 --
1278    // CandidateData::Plain doesn't carry a source id today.
1279    // 4.2.g.5 will plumb the SourceId into RenderedCandidate
1280    // (typed routing payload work) and this falls out.
1281    let source_tag = source_tag_for_kind(c.raw.kind);
1282    // Pad so the tag column starts `display_col_chars + 2`
1283    // chars into the row — same x as every other row in the
1284    // visible window.
1285    let label_len: usize = spans.iter().map(|s| s.content.chars().count()).sum();
1286    let target_col = display_col_chars
1287        .saturating_add(spans[0].content.chars().count()) // kind prefix width
1288        .saturating_add(2);
1289    let target_pad = target_col.saturating_sub(label_len);
1290    if target_pad > 0 {
1291        spans.push(Span::styled(" ".repeat(target_pad), row_style));
1292    }
1293    spans.push(Span::styled(
1294        format!(" {source_tag}"),
1295        if selected {
1296            row_style.fg(Color::DarkGray)
1297        } else {
1298            TuiStyle::default().fg(Color::DarkGray)
1299        },
1300    ));
1301    Line::from(spans)
1302}
1303
1304/// Single-glyph icon for a candidate's `CandidateKind`.
1305/// Mirrors `symbol_kind_glyph` / `completion_kind_glyph` in
1306/// the LSP path -- once the LSP source plugs into the popup
1307/// (4.2.g.2) those map straight through.
1308fn candidate_kind_glyph(kind: lattice_completion::CandidateKind) -> &'static str {
1309    use lattice_completion::CandidateKind as K;
1310    match kind {
1311        K::Command => ":",
1312        K::Option => "⚙",
1313        K::File => "📄",
1314        K::Directory => "📁",
1315        K::Pattern => "/",
1316        K::Buffer => "▤",
1317        K::Register => "\"",
1318        K::Mark => "'",
1319        K::Chord => "⌘",
1320        K::Plain => "·",
1321        K::Extension(_) => "+",
1322    }
1323}
1324
1325/// Source tag rendered right-aligned in the popup row. Today
1326/// inferred from kind; 4.2.g.5 plumbs the `SourceId` directly
1327/// onto the candidate so the tag matches the actual source.
1328fn source_tag_for_kind(kind: lattice_completion::CandidateKind) -> &'static str {
1329    use lattice_completion::CandidateKind as K;
1330    match kind {
1331        K::File | K::Directory => "path",
1332        K::Buffer => "buf",
1333        K::Plain => "buf",
1334        _ => "",
1335    }
1336}
1337
1338/// §5.9.7). Sits BELOW the `:` prompt; the selected candidate is
1339/// the FIRST visible row (closest to the prompt above), alternatives
1340/// fan downward. Same visual shape as
1341/// [`draw_picker_candidates`] -- no border, no title bar, just the
1342/// candidate list. The candidate-count hint is appended to the
1343/// cmdline itself by [`draw_command_or_echo`] when completion is
1344/// open, matching the picker's prompt-inline `(n/m)` style.
1345fn draw_completion_popup(frame: &mut Frame, popup_area: Rect, app: &App) {
1346    // Slice 3c.final.B (group 3): bind substate Arc.
1347    let completion = app.completion();
1348    let Some(state) = completion.state.as_deref() else {
1349        return;
1350    };
1351    if state.candidates.is_empty() {
1352        return;
1353    }
1354
1355    frame.render_widget(Clear, popup_area);
1356    let inner = popup_area;
1357
1358    // Visible window. Selected stays in view as the user advances
1359    // with Tab; once it would scroll off the bottom, we shift the
1360    // window so the selected sits at the bottom row.
1361    let visible_count = inner.height as usize;
1362    if visible_count == 0 {
1363        return;
1364    }
1365    let scroll = if state.selected < visible_count {
1366        0
1367    } else {
1368        state.selected + 1 - visible_count
1369    };
1370    let display_col_chars = state
1371        .candidates
1372        .iter()
1373        .skip(scroll)
1374        .take(visible_count)
1375        .map(|c| c.raw.display.chars().count())
1376        .max()
1377        .unwrap_or(0);
1378    // MARG.5 (2026-06-03): per-category column layout across
1379    // the visible candidate set. Each row's annotation cells
1380    // render against this so columns align vertically even
1381    // when some rows have keybindings and others don't.
1382    let columns = lattice_completion::AnnotationColumns::from_visible(
1383        state.candidates.iter().skip(scroll).take(visible_count),
1384    );
1385    // MR.1: resolved theme + ids so annotation colors read the
1386    // registered `completion.annotation.*` slots (TUI/GPUI parity).
1387    let cells_rs = app.render_state.load().cells.load_full();
1388    let visible: Vec<Line> = state
1389        .candidates
1390        .iter()
1391        .enumerate()
1392        .skip(scroll)
1393        .take(visible_count)
1394        .map(|(i, c)| {
1395            candidate_to_line(
1396                c,
1397                i == state.selected,
1398                display_col_chars,
1399                &columns,
1400                &cells_rs.resolved_theme,
1401                &cells_rs.theme_ids,
1402            )
1403        })
1404        .collect();
1405    let para = Paragraph::new(visible);
1406    frame.render_widget(para, inner);
1407}
1408
1409/// Render one candidate as a single styled line. Matched byte
1410/// ranges are painted with a distinct style; annotations
1411/// column-aligned at `display_col_chars + 2` characters in
1412/// (caller passes the widest visible display so every row's
1413/// annotation starts at the same x). 2026-05-27: previously
1414/// took `width: u16` and right-justified to it; replaced for
1415/// content-bound annotation column so wide popups don't push
1416/// the marginalia to the far-right edge.
1417fn candidate_to_line<'a>(
1418    c: &'a lattice_completion::RenderedCandidate,
1419    selected: bool,
1420    display_col_chars: usize,
1421    columns: &lattice_completion::AnnotationColumns,
1422    resolved: &lattice_host::ui::theme::ResolvedTheme,
1423    ids: &lattice_host::ui::theme::BuiltinElementIds,
1424) -> Line<'a> {
1425    // 2026-06-03: the `▶ ` selection-glyph prefix was
1426    // redundant — the row-background colour on the selected
1427    // row already conveys focus. Dropping it both halves the
1428    // left-margin width and matches the GPUI peer's shape
1429    // (which never carried a selection-glyph). Empty prefix:
1430    // candidate text starts at the kind glyph.
1431    let prefix = "";
1432    let row_style = if selected {
1433        TuiStyle::default()
1434            .bg(Color::DarkGray)
1435            .add_modifier(Modifier::BOLD)
1436    } else {
1437        TuiStyle::default()
1438    };
1439    // Issue #35 (2026-05-22): match highlight stays cyan+bold.
1440    // TUI's hardcoded palette is the 16-color baseline (works
1441    // even on Linux ttys without true-color). Theme-driven
1442    // override queued — bringing the GPUI peer's
1443    // `picker_match_highlight` to ratatui needs the host
1444    // Theme abstraction wired through here (separate slice).
1445    let match_style = TuiStyle::default()
1446        .fg(Color::Cyan)
1447        .add_modifier(Modifier::BOLD);
1448    // Kind glyph style — dim grey so it doesn't compete with
1449    // the candidate text. On the selected row we lift it
1450    // slightly to stay legible.
1451    let kind_style = if selected {
1452        row_style.fg(Color::Gray)
1453    } else {
1454        row_style.fg(Color::DarkGray)
1455    };
1456
1457    // Build spans: kind glyph (issue #35), text with match-range
1458    // highlighting, then padding, then annotations on the right.
1459    let text = &c.raw.display;
1460    let mut spans: Vec<Span<'a>> = Vec::new();
1461    spans.push(Span::styled(prefix, row_style));
1462    // Issue #35: left-margin kind glyph + space separator.
1463    spans.push(Span::styled(format!("{} ", c.raw.kind.glyph()), kind_style));
1464
1465    // Walk text + match_ranges to paint runs.
1466    let mut cursor = 0usize;
1467    let mut sorted_ranges: Vec<_> = c
1468        .match_ranges
1469        .iter()
1470        .filter(|r| r.start < r.end && r.end <= text.len())
1471        .cloned()
1472        .collect();
1473    sorted_ranges.sort_by_key(|r| r.start);
1474    for range in sorted_ranges {
1475        if range.start > cursor {
1476            // PH.1: the non-match gap is syntax-highlighted by
1477            // `display_spans` when present (match runs below keep
1478            // the match style — composition rule: match wins on
1479            // overlap).
1480            push_preview_run(
1481                &mut spans,
1482                text,
1483                cursor,
1484                range.start,
1485                &c.raw.display_spans,
1486                row_style,
1487                resolved,
1488                ids,
1489            );
1490        }
1491        spans.push(Span::styled(
1492            text[range.start..range.end].to_string(),
1493            match_style,
1494        ));
1495        cursor = range.end;
1496    }
1497    if cursor < text.len() {
1498        push_preview_run(
1499            &mut spans,
1500            text,
1501            cursor,
1502            text.len(),
1503            &c.raw.display_spans,
1504            row_style,
1505            resolved,
1506            ids,
1507        );
1508    }
1509
1510    // MARG.5 (2026-06-03): per-category column-aligned
1511    // annotations. Walk `columns` (pre-computed per-visible-
1512    // set max widths) in display order; for each column,
1513    // either render this candidate's matching annotation
1514    // (padded to column width) or render a blank cell of the
1515    // same column width. Keeps every row's annotation cells
1516    // lined up vertically even when some rows have a
1517    // keybinding and others don't.
1518    //
1519    // The pad-to-display_col_chars run before the first
1520    // column stays — it's what lifts the annotation column
1521    // off the variable-width candidate text. Per-variant
1522    // colours from `annotation_color` (MARG.1) still apply
1523    // per-cell; blank cells inherit the row style only.
1524    if !columns.is_empty() {
1525        let kind_prefix_len = prefix.len() + 2; // glyph + " "
1526        let target_col = display_col_chars
1527            .saturating_add(kind_prefix_len)
1528            .saturating_add(2);
1529        let used = prefix.len() + 2 + text.len(); // kind prefix + display
1530        let pad = target_col.saturating_sub(used);
1531        if pad > 0 {
1532            spans.push(Span::styled(" ".repeat(pad), row_style));
1533        }
1534        for (i, (category, col_width)) in columns.iter().enumerate() {
1535            if i > 0 {
1536                spans.push(Span::styled("  ", row_style));
1537            }
1538            // Find this row's annotation for the column's
1539            // category. None ⇒ blank cell of the full width.
1540            let ann_for_col = c.annotations.iter().find(|a| a.category() == category);
1541            match ann_for_col {
1542                Some(ann) => {
1543                    let text_chars = ann.display_text().chars().count();
1544                    let cell_pad = col_width.saturating_sub(text_chars);
1545                    match ann {
1546                        // MR.2: a styled cell paints one span per segment,
1547                        // each resolved from its own theme slot (the per-bit
1548                        // permission coloring). Selection is conveyed by the
1549                        // row background, so segments read the slot fg in both
1550                        // states (no per-segment brightening).
1551                        lattice_completion::Annotation::Styled { segments, .. } => {
1552                            for seg in segments {
1553                                let fg = resolved
1554                                    .get(ids.annotation_slot(&seg.slot))
1555                                    .fg
1556                                    .map(crate::theme::host_color_to_ratatui)
1557                                    .unwrap_or(Color::DarkGray);
1558                                spans.push(Span::styled(seg.text.to_string(), row_style.fg(fg)));
1559                            }
1560                        }
1561                        _ => {
1562                            let fg = annotation_color(ann, selected, resolved, ids);
1563                            spans.push(Span::styled(
1564                                ann.display_text().into_owned(),
1565                                row_style.fg(fg),
1566                            ));
1567                        }
1568                    }
1569                    if cell_pad > 0 {
1570                        spans.push(Span::styled(" ".repeat(cell_pad), row_style));
1571                    }
1572                }
1573                None => {
1574                    // Blank cell — keeps downstream columns
1575                    // aligned vertically across rows.
1576                    if col_width > 0 {
1577                        spans.push(Span::styled(" ".repeat(col_width), row_style));
1578                    }
1579                }
1580            }
1581        }
1582    }
1583    Line::from(spans)
1584}
1585
1586/// PH.1: paint the `[lo, hi)` byte slice of a candidate's
1587/// display run, subdividing it by `display_spans` (the
1588/// syntax-highlight overlay) when present. Each covered sub-run
1589/// resolves its semantic `Style` to a theme fg via
1590/// `resolve_syntax_style` — so `:colorscheme` recolors picker
1591/// previews live, exactly like the main editor path; uncovered
1592/// bytes paint in `base` (today's plain preview, so no new
1593/// "plain" theme slot is needed). Called only for the
1594/// non-match gaps; fuzzy-match runs keep the match style
1595/// (composition: match wins on overlap, picker-preview-
1596/// highlight.md §5).
1597///
1598/// `display_spans` are assumed sorted by `range.start` and
1599/// non-overlapping (producer contract, PH.2). Ranges that don't
1600/// land on a char boundary are skipped rather than panicking on
1601/// the slice — graceful degradation over a hot-path panic.
1602#[allow(clippy::too_many_arguments)]
1603fn push_preview_run<'a>(
1604    spans: &mut Vec<Span<'a>>,
1605    text: &str,
1606    lo: usize,
1607    hi: usize,
1608    display_spans: &[lattice_completion::DisplaySpan],
1609    base: TuiStyle,
1610    resolved: &lattice_host::ui::theme::ResolvedTheme,
1611    ids: &lattice_host::ui::theme::BuiltinElementIds,
1612) {
1613    if lo >= hi {
1614        return;
1615    }
1616    // Fast path: no overlay → one plain run, byte-identical to
1617    // the pre-PH.1 output.
1618    if display_spans.is_empty() {
1619        spans.push(Span::styled(text[lo..hi].to_string(), base));
1620        return;
1621    }
1622    let mut pos = lo;
1623    for ds in display_spans {
1624        let s = ds.range.start.max(lo);
1625        let e = ds.range.end.min(hi);
1626        if s >= e {
1627            continue; // span doesn't overlap this gap
1628        }
1629        if !text.is_char_boundary(s) || !text.is_char_boundary(e) {
1630            continue; // malformed range — skip, never panic
1631        }
1632        if s > pos && text.is_char_boundary(pos) {
1633            spans.push(Span::styled(text[pos..s].to_string(), base));
1634        }
1635        let run_style = match lattice_host::ui::theme::resolve_syntax_style(resolved, ids, ds.style)
1636            .fg
1637            .map(crate::theme::host_color_to_ratatui)
1638        {
1639            Some(c) => base.fg(c),
1640            None => base,
1641        };
1642        spans.push(Span::styled(text[s..e].to_string(), run_style));
1643        pos = e;
1644    }
1645    if pos < hi && text.is_char_boundary(pos) {
1646        spans.push(Span::styled(text[pos..hi].to_string(), base));
1647    }
1648}
1649
1650/// MARG.1 (2026-06-03): pick a foreground colour per
1651/// annotation variant. `selected` flips between the dim
1652/// (unselected row bg) and bright (selected row's
1653/// `DarkGray` bg) shade so contrast holds in both states.
1654/// Defaults documented in
1655/// `docs/dev/architecture/marginalia.md` §5; will move to
1656/// theme-slot reads when the queued theme-through-renderer
1657/// slice lands.
1658/// Pack a `0xRRGGBB` literal into a ratatui `Color::Rgb`. Used for the
1659/// selected-row brightened annotation literals and the unset-slot
1660/// fallbacks (both shared verbatim with the GPUI peer).
1661fn rgb_u32_to_ratatui(v: u32) -> Color {
1662    Color::Rgb((v >> 16) as u8, (v >> 8) as u8, v as u8)
1663}
1664
1665/// MR.1 (2026-06-30): resolve a completion / picker annotation's
1666/// foreground from the theme. Mirrors the GPUI peer's
1667/// `annotation_color_rgb` exactly so both renderers agree:
1668///
1669/// - the **unselected** base reads the registered
1670///   `completion.annotation.{kind,doc,keybinding,source,custom}`
1671///   element (fall back to the legacy literal if the slot is unset);
1672/// - the **selected-row brightening** stays renderer logic — a selected
1673///   row returns the brightened literal so contrast against the
1674///   selection background holds (the brightening is NOT a separate theme
1675///   element). The two-tuple literals are identical to GPUI's.
1676///
1677/// Before MR.1 this matched on the variant and returned a hardcoded
1678/// `Color::*` regardless of theme (the §5 "queued" note only closed on
1679/// the GPUI side via T.6). Now `:colorscheme` recolors marginalia on
1680/// both peers.
1681fn annotation_color(
1682    ann: &lattice_completion::Annotation,
1683    selected: bool,
1684    resolved: &lattice_host::ui::theme::ResolvedTheme,
1685    ids: &lattice_host::ui::theme::BuiltinElementIds,
1686) -> Color {
1687    use lattice_completion::Annotation;
1688    let (id, base_fallback, brightened): (_, u32, u32) = match ann {
1689        Annotation::Kind(_) => (ids.completion_annotation_kind, 0x9399b2, 0xcdd6f4),
1690        Annotation::DocSnippet(_) => (ids.completion_annotation_doc, 0x89dceb, 0xbfeaf5),
1691        Annotation::Keybinding(_) => (ids.completion_annotation_keybinding, 0xf9e2af, 0xfff0b8),
1692        Annotation::Source(_) => (ids.completion_annotation_source, 0xcba6f7, 0xe2cfff),
1693        // A plugin's own slot, RESOLVED rather than discarded. This arm was
1694        // `{ .. }`, so every `Custom` annotation painted one fixed blue however
1695        // the producer had labelled it — org-roam marks aliases and tags with
1696        // different slots and both came out identical, which reads as "the
1697        // picker has no highlighting at all". `Styled` segments already resolve
1698        // per-segment through this same call; `Custom` carrying a slot and
1699        // ignoring it was the inconsistency.
1700        //
1701        // Unknown slots still fall back to `completion.annotation.custom`
1702        // inside `annotation_slot`, so nothing that worked before moves.
1703        Annotation::Custom { slot, .. } => (ids.annotation_slot(slot), 0x89b4fa, 0xb6d3ff),
1704        // `Styled` cells are painted per-segment by the caller (each
1705        // segment resolves its own slot via `ids.annotation_slot`), so
1706        // this whole-cell color path is never taken for them; map to the
1707        // custom fallback for exhaustiveness.
1708        Annotation::Styled { .. } => (ids.completion_annotation_custom, 0x89b4fa, 0xb6d3ff),
1709    };
1710    if selected {
1711        rgb_u32_to_ratatui(brightened)
1712    } else {
1713        resolved
1714            .get(id)
1715            .fg
1716            .map(crate::theme::host_color_to_ratatui)
1717            .unwrap_or_else(|| rgb_u32_to_ratatui(base_fallback))
1718    }
1719}
1720
1721/// Draw the help buffer (DESIGN.md §5.11) as a centred popup. Popup
1722/// is the v1 display strategy; multi-buffer support brings split /
1723/// Vertico-style picker prompt (DESIGN.md §5.9.7) drawn in the
1724/// cmdline row when a [`lattice_picker::Picker`] is open. Format:
1725/// `<title>[ <root>]> <query>` -- the title stands in for the `:`
1726/// prompt so the user knows what they're picking, and `query` is
1727/// the live filter they're typing. Sits at the screen bottom; the
1728/// candidate list is rendered below by
1729/// [`draw_picker_candidates`].
1730///
1731/// PP.2: `root` appears only for a picker whose results are scoped
1732/// to a project (`Picker::root_label`), because when several
1733/// checkouts are open the rows cannot say which one answered and
1734/// memory is not a reliable substitute. Most pickers have no root
1735/// and their prompt is byte-identical to what it was.
1736fn draw_picker_prompt(frame: &mut Frame, area: Rect, app: &App) {
1737    // Slice 3c.final.B (group 3): bind picker substate Arc.
1738    let picker = app.picker_state();
1739    let Some(p) = picker.state.as_deref() else {
1740        return;
1741    };
1742    let count = if p.candidates.is_empty() {
1743        "(0/0) ".to_string()
1744    } else {
1745        format!("({}/{}) ", p.selected + 1, p.candidates.len())
1746    };
1747    // PH/grep UX: an in-flight async fetch (initial `:picker grep
1748    // <pat>` or a live re-query) shows `searching…` so a slow grep
1749    // reads as "working" rather than "nothing happened".
1750    let trailing = if p.loading {
1751        format!("  {count}searching… ")
1752    } else {
1753        format!("  {count}")
1754    };
1755    // PP.2: a rooted picker names the root it is operating on, between the
1756    // source and the `>`. Dimmer than the title and brighter than the count:
1757    // it is context, read once on open, not the thing you came to read.
1758    //
1759    // `None` for every picker whose results are not root-scoped, which is most
1760    // of them — the span simply is not emitted, so nothing about those prompts
1761    // moves.
1762    // PP.2c: every colour on this line resolves through the theme. It
1763    // was the last wholly un-themeable surface in the editor — `Cyan`
1764    // and `DarkGray` written straight into the spans, so `:colorscheme`
1765    // could not reach the picker prompt at all. Resolved together here
1766    // rather than per span, which is also what makes a collision
1767    // visible: the root shared the count's tone for a release because
1768    // the two were written ten lines apart.
1769    let cells_rs = app.render_state.load().cells.load_full();
1770    let ids = &cells_rs.theme_ids;
1771    let fg = |id, fallback| {
1772        cells_rs
1773            .resolved_theme
1774            .get(id)
1775            .fg
1776            .map(crate::theme::host_color_to_ratatui)
1777            .unwrap_or(fallback)
1778    };
1779    let title_fg = fg(ids.picker_title, Color::Cyan);
1780    let prompt_fg = fg(ids.picker_prompt, Color::Cyan);
1781    let count_fg = fg(ids.picker_count, Color::DarkGray);
1782    let root_fg = fg(ids.picker_root, Color::Blue);
1783
1784    let mut spans = vec![Span::styled(
1785        p.title.clone(),
1786        TuiStyle::default()
1787            .fg(title_fg)
1788            .add_modifier(Modifier::BOLD),
1789    )];
1790    if let Some(root) = p.root_label.as_deref() {
1791        // PP.2b: two spaces, not one — the root used to abut the `>`
1792        // (`project-files ~/src/lattice> `), which read as one run of
1793        // punctuation and made the prompt hard to find at a glance.
1794        // Padded on BOTH sides so the root is a field rather than a
1795        // suffix of the title.
1796        //
1797        // Resolved through `picker.root` rather than a hardcoded
1798        // `DarkGray`: PP.2 shipped it at the same dimness as the
1799        // `(n/m)` count, so the one piece of context the prompt carries
1800        // read as chrome. It is also what makes the line themeable at
1801        // all — the title and count beside it are still hardcoded, and
1802        // are the next thing to migrate.
1803        spans.push(Span::styled(
1804            format!("  {root}  "),
1805            TuiStyle::default().fg(root_fg),
1806        ));
1807    }
1808    spans.push(Span::styled(
1809        "> ",
1810        TuiStyle::default()
1811            .fg(prompt_fg)
1812            .add_modifier(Modifier::BOLD),
1813    ));
1814    // The query stays at the default foreground: it is the user's own
1815    // text, and an accent there would make what they typed compete with
1816    // the chrome around it.
1817    spans.push(Span::raw(p.query.clone()));
1818    spans.push(Span::styled(trailing, TuiStyle::default().fg(count_fg)));
1819    frame.render_widget(Paragraph::new(Line::from(spans)), area);
1820}
1821
1822/// Vertico-style candidate list (DESIGN.md §5.9.7) drawn in the
1823/// row band below the picker prompt. Selected row sits at the
1824/// TOP of the band (closest to the prompt below), alternatives
1825/// fan upward in match-rank order. Reuses [`candidate_to_line`]
1826/// for per-row rendering so match highlights + marginalia stay
1827/// consistent with the cmdline completion popup.
1828fn draw_picker_candidates(frame: &mut Frame, area: Rect, app: &App) {
1829    // Slice 3c.final.B (group 3): bind picker substate Arc.
1830    let picker = app.picker_state();
1831    let Some(p) = picker.state.as_deref() else {
1832        return;
1833    };
1834    frame.render_widget(Clear, area);
1835    if p.candidates.is_empty() {
1836        let empty = Paragraph::new(Line::from(Span::styled(
1837            "  (no matches)",
1838            TuiStyle::default().fg(Color::DarkGray),
1839        )));
1840        frame.render_widget(empty, area);
1841        return;
1842    }
1843    // Window the visible slice around the selected candidate so
1844    // it's always on screen. With the prompt ABOVE the list
1845    // (vertico's flipped variant), the selected candidate sits at
1846    // the TOP of the band (closest to the prompt) so the eye
1847    // tracks naturally from query to selection.
1848    let visible_count = area.height as usize;
1849    if visible_count == 0 {
1850        return;
1851    }
1852    let scroll = if p.selected < visible_count {
1853        0
1854    } else {
1855        p.selected + 1 - visible_count
1856    };
1857    let display_col_chars = p
1858        .candidates
1859        .iter()
1860        .skip(scroll)
1861        .take(visible_count)
1862        .map(|c| c.raw.display.chars().count())
1863        .max()
1864        .unwrap_or(0);
1865    // MARG.5 (2026-06-03): per-category column layout across
1866    // the visible candidate set. Each row's annotation cells
1867    // render against this so columns align vertically even
1868    // when some rows have keybindings and others don't.
1869    let columns = lattice_completion::AnnotationColumns::from_visible(
1870        p.candidates.iter().skip(scroll).take(visible_count),
1871    );
1872    // MR.1: resolved theme + ids so annotation colors read the
1873    // registered `completion.annotation.*` slots (TUI/GPUI parity).
1874    let cells_rs = app.render_state.load().cells.load_full();
1875    let visible: Vec<Line> = p
1876        .candidates
1877        .iter()
1878        .enumerate()
1879        .skip(scroll)
1880        .take(visible_count)
1881        .map(|(i, c)| {
1882            candidate_to_line(
1883                c,
1884                i == p.selected,
1885                display_col_chars,
1886                &columns,
1887                &cells_rs.resolved_theme,
1888                &cells_rs.theme_ids,
1889            )
1890        })
1891        .collect();
1892    let para = Paragraph::new(visible);
1893    frame.render_widget(para, area);
1894}
1895
1896/// Reads `picker.display` from the typed-options registry and
1897/// returns `true` iff the user wants vertico-style (default).
1898/// Unknown / missing values fall back to the design default
1899/// rather than panicking -- consistency with the validator's
1900/// behaviour at parse time.
1901fn picker_display_is_minibuffer(app: &App) -> bool {
1902    // Slice 3c.final.B.10: typed-options registry via published
1903    // `options()` sub-state — wait-free Arc clone, no actor
1904    // round-trip.
1905    app.options()
1906        .config
1907        .get_typed::<lattice_config::core_options::PickerDisplay>()
1908        .map(|s| s.as_str() != "popup")
1909        .unwrap_or(true)
1910}
1911
1912// Centered overlay rendering of the picker for the
1913// `picker.display = "popup"` mode. Layout inside the overlay:
1914//
1915//   line 1: title (`p.title`) + count `(n / m)`
1916//   line 2: prompt `> <query>`
1917//   line 3: separator
1918//   line 4..: candidate list (vertico-style, selected row
1919//            at the top so the eye tracks from prompt to
1920//            selection identically to minibuffer mode)
1921//
1922// Sizing mirrors the help-popup convention so the picker
1923// feels like part of the same overlay family. The overlay is
1924// painted on top of [`Clear`] so buffer content underneath
1925// doesn't bleed through.
1926
1927/// Rows a transient's group+item list occupies — per group, one
1928/// header row, one row per item and one blank separator row;
1929/// EXCLUDING preview/footer (those only apply to the popup layout;
1930/// the minibuffer candidate band has no room for them, mirroring how
1931/// the regular picker's minibuffer strip only ever shows the
1932/// candidate list, not a preview pane).
1933///
1934/// **The separator counts.** It is a line `transient_group_item_lines`
1935/// actually emits, and this number sets the popup's height and bounds
1936/// the scroll. Undercounting by one row per group made the box that
1937/// many rows too short AND capped the scroll before the bottom rows,
1938/// so the last items of a multi-group menu were both off-screen and
1939/// unreachable — reported against magit's file dispatch, whose last
1940/// group ends in the blame row.
1941///
1942/// The count itself now lives on `TransientSpec` so both renderers and
1943/// the host share one copy; this stays as the local name
1944/// `chrome_rows`' row-budget accounting already uses.
1945fn transient_row_count(spec: &lattice_picker::TransientSpec) -> usize {
1946    spec.row_count()
1947}
1948
1949/// The scroll-windowed group/item lines a transient renders — the
1950/// ONE place that walks `spec.groups`, applies `scroll`, and stops
1951/// once `visible` lines are produced. Deciding *where* these lines
1952/// go (a bordered popup box vs. a bottom strip) is `picker.display`'s
1953/// call, made once by the caller in `draw_frame` — a transient's own
1954/// job stops at "what are the visible lines", so both
1955/// `draw_transient_overlay` (popup) and
1956/// `draw_transient_minibuffer_candidates` (minibuffer band) call
1957/// this and differ only in where they paint the result. Before this
1958/// extraction each had its own copy of the windowing loop. Returns
1959/// the lines plus the running line-index they ended at, so a caller
1960/// that keeps counting past the group/item section (the popup's
1961/// preview/footer rows) continues from the right number.
1962/// The transient menu's colours, resolved once per frame.
1963///
1964/// A transient row is three columns that mean different things — the
1965/// key you press, what it does, and (for a flag or variable) its
1966/// current value — and each needs its own colour or the row reads as
1967/// undifferentiated text. This used to be four hard-coded ANSI
1968/// constants here, which looked deliberate and meant `:colorscheme`
1969/// could not reach the menu at all.
1970#[derive(Clone, Copy)]
1971struct TransientPalette {
1972    title: TuiStyle,
1973    group: TuiStyle,
1974    key: TuiStyle,
1975    key_inactive: TuiStyle,
1976    description: TuiStyle,
1977    value: TuiStyle,
1978    border: TuiStyle,
1979}
1980
1981impl TransientPalette {
1982    fn resolve(app: &App) -> Self {
1983        let cells = app.render_state.load().cells.load_full();
1984        let resolved = &cells.resolved_theme;
1985        let ids = &cells.theme_ids;
1986        let st = |id| crate::theme::host_style_to_ratatui(resolved.get(id));
1987        Self {
1988            title: st(ids.transient_title),
1989            group: st(ids.transient_group),
1990            key: st(ids.transient_key),
1991            key_inactive: st(ids.transient_key_inactive),
1992            description: st(ids.transient_description),
1993            value: st(ids.transient_value),
1994            border: st(ids.transient_border),
1995        }
1996    }
1997}
1998
1999fn transient_group_item_lines(
2000    spec: &lattice_picker::TransientSpec,
2001    transient_state: &lattice_picker::TransientState,
2002    scroll: usize,
2003    visible: usize,
2004    selected: usize,
2005    prefix: &str,
2006    palette: &TransientPalette,
2007) -> (Vec<Line<'static>>, usize) {
2008    let mut lines: Vec<Line> = Vec::new();
2009    let mut ln: usize = 0;
2010    // Which item we are on, counted across groups the same way
2011    // `TransientSpec::selectable_count` counts — the highlight and the
2012    // host's selection index have to mean the same thing or `<CR>`
2013    // fires a row other than the one that looks chosen.
2014    let mut item_index: usize = 0;
2015
2016    for group in &spec.groups {
2017        if ln >= scroll && lines.len() < visible {
2018            lines.push(Line::from(Span::styled(
2019                format!("  ▸ {}", group.label),
2020                palette.group,
2021            )));
2022        }
2023        ln += 1;
2024
2025        for item in &group.items {
2026            if ln >= scroll && lines.len() < visible {
2027                let is_selected = item_index == selected;
2028                let key = format!("[{}]", item.key.join("/"));
2029                let flag = match &item.kind {
2030                    lattice_picker::TransientItemKind::Flag { name, .. } => {
2031                        let v = transient_state
2032                            .get(name)
2033                            .and_then(|v| match v {
2034                                lattice_picker::TransientValue::Bool(b) => Some(*b),
2035                                _ => None,
2036                            })
2037                            .unwrap_or(false);
2038                        if v {
2039                            "[x]".to_string()
2040                        } else {
2041                            "[ ]".to_string()
2042                        }
2043                    }
2044                    // MG.43g: a variable reports the CURRENT value
2045                    // inline — that is the whole point of the row.
2046                    // Three states, not two: `…` means "not read yet",
2047                    // which is not the same claim as `unset`.
2048                    lattice_picker::TransientItemKind::Variable { key, value, .. } => {
2049                        let shown = match value.as_deref() {
2050                            None => "…".to_string(),
2051                            Some("") => "unset".to_string(),
2052                            Some(v) => v.to_string(),
2053                        };
2054                        format!("{key} = {shown}")
2055                    }
2056                    _ => String::new(),
2057                };
2058                // The selection is marked with a leading caret and a
2059                // bold label rather than a reversed row: a transient is
2060                // primarily key-driven, so the marker has to say
2061                // "this is where <CR> would land" without competing
2062                // with the key column for attention.
2063                let (marker, label_style) = if is_selected {
2064                    ("  ❯ ", TuiStyle::default().add_modifier(Modifier::BOLD))
2065                } else {
2066                    ("    ", TuiStyle::default())
2067                };
2068                let _ = &palette;
2069                // With a multi-key row part-way typed, rows that can no
2070                // longer match go dull — magit's own behaviour, and the
2071                // only thing that says "`,` was received, keep going"
2072                // rather than leaving the menu looking inert.
2073                let reachable = lattice_picker::TransientSpec::item_matches_prefix(item, prefix);
2074                let (key_style, label_style, desc_style) = if reachable {
2075                    (palette.key, label_style, palette.description)
2076                } else {
2077                    // One tone for the whole row: a row the typed prefix
2078                    // has ruled out should read as "not this one", not as
2079                    // a row whose key is merely a different colour.
2080                    (
2081                        palette.key_inactive,
2082                        palette.key_inactive,
2083                        palette.key_inactive,
2084                    )
2085                };
2086                lines.push(Line::from(vec![
2087                    Span::styled(format!("{marker}{:<6}", key), key_style),
2088                    Span::styled(format!("{:<20}", item.label), label_style),
2089                    Span::styled(item.description.clone(), desc_style),
2090                    Span::styled(format!("  {}", flag), palette.value),
2091                ]));
2092            }
2093            ln += 1;
2094            item_index += 1;
2095        }
2096
2097        if ln >= scroll && lines.len() < visible {
2098            lines.push(Line::from(""));
2099        }
2100        ln += 1;
2101    }
2102    (lines, ln)
2103}
2104
2105/// NOTIF.1b: the notification stack, anchored bottom-right.
2106///
2107/// §5.9.9's corner and order: they stack in a corner, newest **below**
2108/// older ones here because the stack grows upward from the bottom edge
2109/// — `visible` arrives oldest-first, so painting it in order down the
2110/// block puts the newest nearest the corner, which is where the eye
2111/// lands.
2112///
2113/// **Drawn last, over everything.** A notification that a picker or a
2114/// transient could cover would be invisible exactly when the user is
2115/// busy, which is when it matters most.
2116///
2117/// Nothing here is timer-driven: expiry happens in the store and
2118/// arrives as an ordinary repaint. This function only ever paints what
2119/// it is given.
2120fn draw_notifications(frame: &mut Frame, area: Rect, app: &App) {
2121    let rs = app.render_state.load_full();
2122    let n = &rs.notifications;
2123    if n.visible.is_empty() {
2124        return;
2125    }
2126
2127    // One row per notification, plus the "+N more" line when some are
2128    // queued. Width is a third of the screen, clamped so it stays
2129    // readable on a narrow split and does not swallow a wide one.
2130    let extra = usize::from(n.queued > 0);
2131    let rows = (n.visible.len() + extra) as u16;
2132    let width = (area.width / 3).clamp(24, 60).min(area.width);
2133    // Every pane reserves its BOTTOM row for the per-pane status line
2134    // (see `draw_panes`), so the usable area stops one row short of
2135    // `area`. Anchoring to `area` itself covers the modeline — which is
2136    // the row telling you which buffer and mode you are in, and the one
2137    // surface that must never be occluded, since a notification is
2138    // transient and the modeline is how you stay oriented while it is
2139    // up.
2140    let usable_h = area.height.saturating_sub(1);
2141    let height = (rows + 2).min(usable_h);
2142    if height == 0 {
2143        return;
2144    }
2145    let block_area = Rect {
2146        x: area.x + area.width.saturating_sub(width),
2147        y: area.y + usable_h.saturating_sub(height),
2148        width,
2149        height,
2150    };
2151
2152    frame.render_widget(Clear, block_area);
2153    let block = Block::default()
2154        .borders(Borders::ALL)
2155        .border_style(TuiStyle::default().fg(Color::DarkGray));
2156    let inner = block.inner(block_area);
2157    frame.render_widget(block, block_area);
2158
2159    let theme = &app.theme;
2160    let mut lines: Vec<Line> = n
2161        .visible
2162        .iter()
2163        .map(|item| notification_line(item, theme, inner.width))
2164        .collect();
2165    if n.queued > 0 {
2166        // Named rather than dropped: a burst that silently discarded
2167        // the tail would be the invisible-work bug again.
2168        lines.push(Line::from(vec![Span::styled(
2169            format!("+{} more", n.queued),
2170            TuiStyle::default().fg(Color::DarkGray),
2171        )]));
2172    }
2173    frame.render_widget(Paragraph::new(lines), inner);
2174}
2175
2176/// One notification row: the level's icon, then the text, both in the
2177/// level's colour.
2178///
2179/// Colours come from the theme rather than fixed terminal colours, and
2180/// from the same elements the GPUI peer reads — `diagnostic.*` for the
2181/// severities and `diff.add.sign` for success — so `:colorscheme`
2182/// recolours both, identically.
2183fn notification_line(
2184    item: &lattice_notify::Notification,
2185    theme: &crate::theme::Theme,
2186    width: u16,
2187) -> Line<'static> {
2188    use lattice_notify::NotificationLevel as L;
2189    let style = match item.level {
2190        L::Info => theme.diagnostic_info_style,
2191        L::Success => theme.diff_add_sign_style,
2192        L::Warn => theme.diagnostic_warning_style,
2193        L::Error => theme.diagnostic_error_style,
2194    };
2195    let icon = format!("{} ", item.level.glyph(theme.nerd_fonts));
2196    // NC.2: the scope is its own bold span, so two repositories'
2197    // notifications differ in one place the eye can find.
2198    // Capped at a third of the row: a long repository name must not
2199    // leave no room for what happened in it.
2200    let scope = item
2201        .scope
2202        .as_deref()
2203        .map(|s| {
2204            let s = clip_to_width(s, width / 3);
2205            format!("{} {} ", s.trim_end(), lattice_notify::SCOPE_SEPARATOR)
2206        })
2207        .unwrap_or_default();
2208    let used = (icon.chars().count() + scope.chars().count()) as u16;
2209    let rest = width.saturating_sub(used);
2210    let mut spans = vec![Span::styled(icon, style)];
2211    if !scope.is_empty() {
2212        spans.push(Span::styled(
2213            scope,
2214            style.add_modifier(ratatui::style::Modifier::BOLD),
2215        ));
2216    }
2217    spans.push(Span::styled(clip_to_width(&item.text, rest), style));
2218    Line::from(spans)
2219}
2220
2221fn draw_transient_overlay(frame: &mut Frame, buffer_area: Rect, app: &App) {
2222    let picker = app.picker_state();
2223    let Some(p) = picker.state.as_deref() else {
2224        return;
2225    };
2226    let Some(ref spec) = p.transient else { return };
2227
2228    let preview_rows = if spec.preview.is_some() { 2 } else { 0 };
2229    let footer_rows = if spec.footer.is_some() { 1 } else { 0 };
2230    let row_count = transient_row_count(spec) + preview_rows + footer_rows;
2231    // Clamped to the area as well as to 35: a box taller than the
2232    // terminal is clipped by whoever paints last, which loses rows
2233    // with no scroll position that can bring them back.
2234    let height = (row_count as u16 + 3)
2235        .clamp(8, 35)
2236        .min(buffer_area.height.max(3));
2237    let width = buffer_area.width.saturating_sub(4).clamp(40, 80);
2238    let x = buffer_area.x + buffer_area.width.saturating_sub(width) / 2;
2239    let y = buffer_area.y + buffer_area.height.saturating_sub(height) / 3;
2240    let area = Rect {
2241        x,
2242        y,
2243        width,
2244        height,
2245    };
2246
2247    let palette = TransientPalette::resolve(app);
2248    frame.render_widget(Clear, area);
2249    let block = Block::default()
2250        .borders(Borders::ALL)
2251        .border_style(palette.border);
2252    let title_line = Line::from(vec![Span::styled(
2253        format!(" {} ", spec.title),
2254        palette.title,
2255    )]);
2256    let block = block.title(title_line);
2257    let inner = block.inner(area);
2258    frame.render_widget(block, area);
2259
2260    let visible = inner.height as usize;
2261    // Derived from the selection every frame rather than stored: there
2262    // is no scroll state left to drift past the end, which is what made
2263    // `<C-n>` overshoot and `<C-p>` feel stuck.
2264    let scroll = spec.scroll_for(p.transient_selected, visible);
2265
2266    let (mut lines, mut ln) = transient_group_item_lines(
2267        spec,
2268        &p.transient_state,
2269        scroll,
2270        visible,
2271        p.transient_selected,
2272        &p.transient_prefix,
2273        &palette,
2274    );
2275
2276    if let Some(ref preview_fn) = spec.preview {
2277        if ln >= scroll && lines.len() < visible {
2278            let sep = "─".repeat(inner.width as usize);
2279            lines.push(Line::from(Span::styled(sep, palette.border)));
2280        }
2281        ln += 1;
2282        if ln >= scroll && lines.len() < visible {
2283            let text = (preview_fn)(&p.transient_state);
2284            lines.push(Line::from(Span::styled(
2285                format!("  {}", text),
2286                palette.description.add_modifier(Modifier::ITALIC),
2287            )));
2288        }
2289        ln += 1;
2290    }
2291
2292    if let Some(ref footer) = spec.footer
2293        && ln >= scroll
2294        && lines.len() < visible
2295    {
2296        lines.push(Line::from(Span::styled(
2297            format!("  {}", footer),
2298            palette.description,
2299        )));
2300    }
2301
2302    frame.render_widget(Paragraph::new(lines), inner);
2303}
2304
2305/// Fold audit fix: transients used to hard-code the popup overlay
2306/// regardless of `picker.display` — a `"minibuffer"` preference
2307/// controlled every OTHER picker surface but was silently ignored
2308/// for transient menus. This is the minibuffer-mode title row,
2309/// mirroring `draw_picker_prompt`'s cmdline-row shape.
2310fn draw_transient_minibuffer_prompt(frame: &mut Frame, area: Rect, app: &App) {
2311    let picker = app.picker_state();
2312    let Some(p) = picker.state.as_deref() else {
2313        return;
2314    };
2315    let Some(ref spec) = p.transient else { return };
2316    // The same `transient.title` element the popup's title uses. It was
2317    // `Color::Cyan` + bold here, which meant the two display modes of
2318    // the SAME menu disagreed about its colour and neither followed the
2319    // theme.
2320    let palette = TransientPalette::resolve(app);
2321    let para = Paragraph::new(Line::from(vec![Span::styled(
2322        format!(" {} ", spec.title),
2323        palette.title,
2324    )]));
2325    frame.render_widget(para, area);
2326}
2327
2328/// Minibuffer-mode candidate band for a transient — the group/item
2329/// list, windowed to `area`'s height around `transient_selected` (the
2330/// SAME field + the same "skip until scroll, stop once the band is
2331/// full" shape `draw_transient_overlay` uses for its popup box, just
2332/// flowed into a bottom strip instead of a bordered floating area;
2333/// see `transient_row_count`'s doc comment for why preview/footer
2334/// are dropped here). Flag indicators render the same way; preview
2335/// and footer don't fit the band and are omitted (matching the
2336/// regular picker's minibuffer candidate band, which never shows a
2337/// preview pane either).
2338fn draw_transient_minibuffer_candidates(frame: &mut Frame, area: Rect, app: &App) {
2339    let picker = app.picker_state();
2340    let Some(p) = picker.state.as_deref() else {
2341        return;
2342    };
2343    let Some(ref spec) = p.transient else { return };
2344    frame.render_widget(Clear, area);
2345
2346    let visible = area.height as usize;
2347    if visible == 0 {
2348        return;
2349    }
2350    let scroll = spec.scroll_for(p.transient_selected, visible);
2351
2352    let palette = TransientPalette::resolve(app);
2353    let (lines, _) = transient_group_item_lines(
2354        spec,
2355        &p.transient_state,
2356        scroll,
2357        visible,
2358        p.transient_selected,
2359        &p.transient_prefix,
2360        &palette,
2361    );
2362    frame.render_widget(Paragraph::new(lines), area);
2363}
2364
2365fn draw_picker_overlay(frame: &mut Frame, buffer_area: Rect, app: &App) {
2366    // Slice 3c.final.B (group 3): bind picker substate Arc.
2367    let picker = app.picker_state();
2368    let Some(p) = picker.state.as_deref() else {
2369        return;
2370    };
2371    // Cap width at 80 cells / 70% of the buffer area; cap
2372    // height at 18 rows including the title + prompt + sep.
2373    let cand_count = p.candidates.len().max(1) as u16;
2374    let max_w = buffer_area.width.saturating_sub(4).min(80);
2375    let max_h = buffer_area.height.saturating_sub(4).min(18);
2376    let height = (cand_count + 3).min(max_h).max(5);
2377    let width = max_w.max(40).min(buffer_area.width.saturating_sub(2));
2378    let x = buffer_area.x + buffer_area.width.saturating_sub(width) / 2;
2379    let y = buffer_area.y + buffer_area.height.saturating_sub(height) / 3;
2380    let area = Rect {
2381        x,
2382        y,
2383        width,
2384        height,
2385    };
2386
2387    frame.render_widget(Clear, area);
2388    let block = Block::default().borders(Borders::ALL).title(format!(
2389        " {}  ({} / {}) ",
2390        p.title,
2391        if p.candidates.is_empty() {
2392            0
2393        } else {
2394            p.selected + 1
2395        },
2396        p.candidates.len(),
2397    ));
2398    let inner = block.inner(area);
2399    frame.render_widget(block, area);
2400
2401    // Slice inner into prompt (row 0) + separator (row 1) +
2402    // candidate band (remaining). Constraints rather than
2403    // hand-arithmetic so terminal resizes degrade cleanly.
2404    let inner_chunks = Layout::default()
2405        .direction(Direction::Vertical)
2406        .constraints(vec![
2407            Constraint::Length(1), // prompt
2408            Constraint::Length(1), // separator
2409            Constraint::Min(1),    // candidate list
2410        ])
2411        .split(inner);
2412
2413    let prompt = Paragraph::new(Line::from(vec![
2414        Span::styled(
2415            "> ",
2416            TuiStyle::default()
2417                .fg(Color::Cyan)
2418                .add_modifier(Modifier::BOLD),
2419        ),
2420        Span::raw(p.query.clone()),
2421    ]));
2422    frame.render_widget(prompt, inner_chunks[0]);
2423
2424    let sep_text = "─".repeat(inner_chunks[1].width as usize);
2425    let sep = Paragraph::new(Line::from(Span::styled(
2426        sep_text,
2427        TuiStyle::default().fg(Color::DarkGray),
2428    )));
2429    frame.render_widget(sep, inner_chunks[1]);
2430
2431    let cand_area = inner_chunks[2];
2432    if p.candidates.is_empty() {
2433        let empty = Paragraph::new(Line::from(Span::styled(
2434            "  (no matches)",
2435            TuiStyle::default().fg(Color::DarkGray),
2436        )));
2437        frame.render_widget(empty, cand_area);
2438        return;
2439    }
2440    let visible_count = cand_area.height as usize;
2441    if visible_count == 0 {
2442        return;
2443    }
2444    // Selected row stays at the TOP of the band -- same eye
2445    // path as the minibuffer mode (prompt above, selection
2446    // adjacent below).
2447    let scroll = if p.selected < visible_count {
2448        0
2449    } else {
2450        p.selected + 1 - visible_count
2451    };
2452    let display_col_chars = p
2453        .candidates
2454        .iter()
2455        .skip(scroll)
2456        .take(visible_count)
2457        .map(|c| c.raw.display.chars().count())
2458        .max()
2459        .unwrap_or(0);
2460    // MARG.5 (2026-06-03): per-category column layout across
2461    // the visible candidate set. Each row's annotation cells
2462    // render against this so columns align vertically even
2463    // when some rows have keybindings and others don't.
2464    let columns = lattice_completion::AnnotationColumns::from_visible(
2465        p.candidates.iter().skip(scroll).take(visible_count),
2466    );
2467    // MR.1: resolved theme + ids so annotation colors read the
2468    // registered `completion.annotation.*` slots (TUI/GPUI parity).
2469    let cells_rs = app.render_state.load().cells.load_full();
2470    let visible: Vec<Line> = p
2471        .candidates
2472        .iter()
2473        .enumerate()
2474        .skip(scroll)
2475        .take(visible_count)
2476        .map(|(i, c)| {
2477            candidate_to_line(
2478                c,
2479                i == p.selected,
2480                display_col_chars,
2481                &columns,
2482                &cells_rs.resolved_theme,
2483                &cells_rs.theme_ids,
2484            )
2485        })
2486        .collect();
2487    let para = Paragraph::new(visible);
2488    frame.render_widget(para, cand_area);
2489}
2490
2491/// Slice 3c.gpui-cmdline-completion: centered-overlay variant of
2492/// the cmdline-completion popup. Activates when `picker.display
2493/// = "popup"` and a `:` line completion is open. Mirrors
2494/// `draw_picker_overlay` minus the title + separator + prompt
2495/// rows — the cmdline at the bottom of the screen IS the prompt,
2496/// so the overlay is candidate-band-only.
2497fn draw_completion_overlay(frame: &mut Frame, buffer_area: Rect, app: &App) {
2498    let completion = app.completion();
2499    let Some(state) = completion.state.as_deref() else {
2500        return;
2501    };
2502    if state.candidates.is_empty() {
2503        return;
2504    }
2505    let cand_count = state.candidates.len() as u16;
2506    let max_w = buffer_area.width.saturating_sub(4).min(80);
2507    let max_h = buffer_area.height.saturating_sub(4).min(18);
2508    let height = (cand_count + 2).min(max_h).max(5);
2509    let width = max_w.max(40).min(buffer_area.width.saturating_sub(2));
2510    let x = buffer_area.x + buffer_area.width.saturating_sub(width) / 2;
2511    let y = buffer_area.y + buffer_area.height.saturating_sub(height) / 3;
2512    let area = Rect {
2513        x,
2514        y,
2515        width,
2516        height,
2517    };
2518    frame.render_widget(Clear, area);
2519    let block = Block::default().borders(Borders::ALL).title(format!(
2520        " completion  ({} / {}) ",
2521        state.selected + 1,
2522        state.candidates.len(),
2523    ));
2524    let inner = block.inner(area);
2525    frame.render_widget(block, area);
2526    let visible_count = inner.height as usize;
2527    if visible_count == 0 {
2528        return;
2529    }
2530    let scroll = if state.selected < visible_count {
2531        0
2532    } else {
2533        state.selected + 1 - visible_count
2534    };
2535    let display_col_chars = state
2536        .candidates
2537        .iter()
2538        .skip(scroll)
2539        .take(visible_count)
2540        .map(|c| c.raw.display.chars().count())
2541        .max()
2542        .unwrap_or(0);
2543    // MARG.5 (2026-06-03): per-category column layout across
2544    // the visible candidate set. Each row's annotation cells
2545    // render against this so columns align vertically even
2546    // when some rows have keybindings and others don't.
2547    let columns = lattice_completion::AnnotationColumns::from_visible(
2548        state.candidates.iter().skip(scroll).take(visible_count),
2549    );
2550    // MR.1: resolved theme + ids so annotation colors read the
2551    // registered `completion.annotation.*` slots (TUI/GPUI parity).
2552    let cells_rs = app.render_state.load().cells.load_full();
2553    let visible: Vec<Line> = state
2554        .candidates
2555        .iter()
2556        .enumerate()
2557        .skip(scroll)
2558        .take(visible_count)
2559        .map(|(i, c)| {
2560            candidate_to_line(
2561                c,
2562                i == state.selected,
2563                display_col_chars,
2564                &columns,
2565                &cells_rs.resolved_theme,
2566                &cells_rs.theme_ids,
2567            )
2568        })
2569        .collect();
2570    let para = Paragraph::new(visible);
2571    frame.render_widget(para, inner);
2572}
2573
2574/// PU.1b-3: recompute the floating help popup's inner rect `(rows,
2575/// cols)` for the host geometry hand-off (`App::set_popup_viewport`).
2576/// Returns `None` when no FLOATING popup is open (no popup, or help is
2577/// shown as an in-pane leaf — that case is a real pane). The buffer
2578/// area is reconstructed from the terminal width + the runtime's
2579/// already-resolved `buffer_height`, so the `popup_outer_size` inputs
2580/// match exactly what `draw_help_overlay` paints into — the synthetic
2581/// popup-pane matrix and the painted box agree on width. `buffer_height`
2582/// already excludes the tabline row (the caller computes it via
2583/// `chrome_rows`, the single source of truth — no local recomputation
2584/// here). Inner = outer − 2 (the `Borders::ALL` block).
2585pub(crate) fn popup_feedback_inner_dims(
2586    app: &App,
2587    terminal_width: u16,
2588    buffer_height: u32,
2589) -> Option<(u32, u32)> {
2590    if !app.popup().is_open() {
2591        return None;
2592    }
2593    // In-pane help is a real pane leaf — `build_cells_panes` already
2594    // covers it; only the floating overlay needs the synthetic pane.
2595    if app.panes().tree.active().buffer == crate::buffers::BufferKind::Help {
2596        return None;
2597    }
2598    let help = app.popup_help()?;
2599    let line_count = u16::try_from(help.line_count().max(1)).unwrap_or(u16::MAX);
2600    let (outer_w, outer_h) = lattice_core::ui::popup::popup_outer_size(
2601        terminal_width,
2602        u16::try_from(buffer_height).unwrap_or(u16::MAX),
2603        line_count,
2604        app.popup().placement,
2605    );
2606    let inner_w = u32::from(outer_w.saturating_sub(2)).max(1);
2607    let inner_h = u32::from(outer_h.saturating_sub(2)).max(1);
2608    Some((inner_h, inner_w))
2609}
2610
2611fn draw_help_overlay(frame: &mut Frame, buffer_area: Rect, app: &App, snap: &DocumentSnapshot) {
2612    let Some(help) = app.popup_help() else {
2613        return;
2614    };
2615    // Slice 3c.final.B (group 3): popup buffer id via published
2616    // substate.
2617    let popup_id = app.popup().buffer_id.expect("popup_help is Some");
2618    // Sizing routes through `lattice_core::ui::popup::popup_outer_size`
2619    // so the renderer + the App's `help_popup_inner_height`
2620    // (motion / scroll / ensure_cursor_visible) agree on the
2621    // viewport bounds. Centred popups (reading surfaces:
2622    // `:help`, `:options`, `:describe-*`, `:apropos`,
2623    // `:customize`) get a larger cap (120 wide, 40 tall);
2624    // cursor-anchored popups (hover, signature help) keep
2625    // tight tooltip caps (80 wide, 20 tall).
2626    let line_count = help.line_count().max(1) as u16;
2627    let (width, height) = lattice_core::ui::popup::popup_outer_size(
2628        buffer_area.width,
2629        buffer_area.height,
2630        line_count,
2631        app.popup().placement,
2632    );
2633    let popup = position_help_popup(app, snap, buffer_area, width, height);
2634
2635    frame.render_widget(Clear, popup);
2636
2637    // Header: bold accent title + dim hint, both from the themeable
2638    // `ui.popup.title` / `ui.popup.hint` elements (resolved into
2639    // `app.theme.popup_{title,hint}`) — same source the GPUI peer uses, so
2640    // the accent is themeable and identical across peers. GPUI additionally
2641    // bumps the title font size; no separator rule on either peer.
2642    // PU-A.4: title = the popup buffer's registry name (the synthetic
2643    // Document name slot), not `help.title` — de-Help-ifies the chrome to
2644    // match the GPUI peer, which sources it from `name_of` since PU.1b-4b.
2645    // `help.title` == this name today (`register_help_document` sets
2646    // `name: Some(buffer.title)`), so the swap is invisible to the user.
2647    let title = app.buffers().registry.name_of(popup_id).unwrap_or_default();
2648    let block = Block::default()
2649        .borders(Borders::ALL)
2650        .title(Line::from(vec![
2651            Span::styled(format!(" {} ", title), app.theme.popup_title),
2652            Span::styled("Esc to dismiss ", app.theme.popup_hint),
2653        ]));
2654    let inner = block.inner(popup);
2655    frame.render_widget(block, popup);
2656
2657    // PU.1b-3: the popup CONTENT renders through the shared document
2658    // compose path (`compose_pane_lines`) reading the synthetic
2659    // popup-pane `DisplayMatrix` (`PaneId::POPUP`, built off-thread by
2660    // the cells worker — markdown colour from PU.1b-1, link styling
2661    // from the PU.1b-2a `ExtraHighlights` merge, hlsearch / fold /
2662    // soft-wrap / horizontal-scroll all for free). Only the box
2663    // (border + title) above is popup-specific chrome. A help popup is
2664    // now pixel-equivalent to a `:set nonu signcolumn=no wrap` document
2665    // in a box — the K.4 / `feedback_render_is_option_derived` endgame.
2666    //
2667    // Options resolve PER-BUFFER via `for_buffer(popup_id)`, so
2668    // help-mode's `nonu` / `signcolumn=no` / `wrap = true` drive the
2669    // layout regardless of which buffer is active under the popup
2670    // (State A: the doc is active; State B: focus moved into help).
2671    let Some(handle) = app.buffers().registry.document_handle(popup_id) else {
2672        return;
2673    };
2674    let content_snap = handle.snapshot();
2675    let view = FrameView::for_buffer(app, popup_id);
2676    // State B (focus moved into the popup): the popup buffer IS the
2677    // active buffer, so its live cursor / scroll / leftcol are on
2678    // `app.ad()`. State A (popup shown, doc focused): the popup's
2679    // persisted scroll is `popup_help().scroll` (the `popup_scroll`
2680    // stash), no cursor inside the popup, leftcol pinned (help wraps).
2681    let popup_focused = app.ad().popup_focused;
2682    let (scroll, cursor_line, leftcol) = if popup_focused {
2683        (app.ad().scroll, app.ad().cursor.line, app.ad().leftcol)
2684    } else {
2685        (help.scroll as u32, 0, 0)
2686    };
2687    let ctx = PaneComposeCtx {
2688        // State B feeds the interaction overlays (cursor line, hlsearch,
2689        // current match) the same way the active pane does; State A
2690        // suppresses them (popup shown but not focused).
2691        is_active: popup_focused,
2692        pane_id: lattice_core::ui::pane::PaneId::POPUP,
2693        buffer_id: popup_id,
2694        cursor_line,
2695        // Preserve prior behaviour: State B (focused popup) paints the
2696        // cursor line iff cursorline is on; State A suppresses it.
2697        cursor_line_highlight: popup_focused && app.ad().option_cache.current_line_highlight,
2698        scroll,
2699        leftcol,
2700        display_line_numbers: handle.display_line_numbers(),
2701    };
2702    let lines = compose_pane_lines(
2703        &view,
2704        &content_snap,
2705        inner.height as u32,
2706        inner.width as u32,
2707        &ctx,
2708    );
2709    frame.render_widget(Paragraph::new(lines), inner);
2710
2711    // Place the terminal cursor INSIDE the popup only in State B —
2712    // focus has moved into it and vim grammar acts on the popup's
2713    // content. In State A the cursor stays where the doc renderer put
2714    // it (on the symbol the user K'd). `cursor_screen_position_at` is
2715    // the compose-aware placement (matches `split_body_into_segments`
2716    // wrap + the nonu gutter), so it replaces the bespoke
2717    // `wrap_aware_cursor_offset` that tracked the deleted
2718    // `manually_wrap_lines`.
2719    // …unless a minibuffer owns the caret. The `:` / `/` / prompt lines each
2720    // draw their own at their own buffer's cursor, and the terminal has ONE
2721    // hardware caret — so without this guard the last writer won and the caret
2722    // stayed parked in the popup, merely changing shape to the Bar that
2723    // `ModalState::Search` implies. Reported in use as "the cursor changes
2724    // from block to an insert-mode line cursor" inside a read-only popup.
2725    //
2726    // The pane path below has always asked this question (its local
2727    // `prompt_owns_cursor`); the popup path never did. It is one question, so
2728    // it now has one answer, in `lattice_host::cursor_shape`.
2729    if popup_focused
2730        && !lattice_host::cursor_shape::minibuffer_owns_caret(app.ad().modal)
2731        && inner.height > 0
2732        && inner.width > 0
2733        && let Some((screen_x, screen_y)) = cursor_screen_position_at(
2734            &view,
2735            &content_snap,
2736            inner,
2737            app.ad().cursor,
2738            app.ad().scroll,
2739            // PIC.1: the popup caret walks the POPUP pane's matrices —
2740            // the same source `compose_pane_lines` uses for the body +
2741            // cursorline above (`ctx.pane_id`). Reading the active
2742            // document pane instead drifts the caret past the cursorline.
2743            lattice_core::ui::pane::PaneId::POPUP,
2744        )
2745    {
2746        frame.set_cursor_position((screen_x, screen_y));
2747    }
2748}
2749
2750/// Soft-wrap (W.4): gutter continuation marker for wrapped-line
2751/// segments after the first. `↩`-style left arrow (U+21AA) is a
2752/// plain BMP glyph that renders in every terminal font, so unlike
2753/// nerd-font icons it needs no palette fallback
2754/// (`feedback_icon_palette` governs nerd-font surfaces).
2755const WRAP_CONT_MARKER: &str = "↪";
2756
2757/// Soft-wrap (W.4): split a fully-overlaid body row into display
2758/// segments of `width` columns each, preserving every span's style.
2759/// One column == one char (the cell-grid's ASCII design target,
2760/// where char == byte == display column; wide chars are an explicit
2761/// approximation shared with the cells layer). `width == 0` (wrap
2762/// off) or a body that fits returns a single segment, so the caller
2763/// can treat the result uniformly.
2764///
2765/// Segment boundaries match the host's
2766/// `CellMatrix::segment_count(line) = ⌈col_count / width⌉`, so the
2767/// scroll model and the renderer agree on how many display rows a
2768/// source line occupies. The continuation marker lives in the
2769/// gutter (see the compose loop), not in the body, so every segment
2770/// uses the full content width.
2771///
2772/// `wrap_cols` is how many leading columns of `body` sit on the
2773/// SOURCE axis — the width the `DisplayMatrix` measured, and the
2774/// only width the host's `segment_count` knows about. Columns past
2775/// it are trailing decoration the compose loop appended (the
2776/// closed-fold ` ⋯ N lines` summary, completion ghost text, the
2777/// cursorline's right-edge pad). Those ride the final segment and
2778/// are clipped at the pane edge; they must never break a new one,
2779/// or the row would exist in the painted body but not in the
2780/// segment count the scroll model and the caret walk
2781/// ([`buffer_line_to_visible_row_with`]) share.
2782fn split_body_into_segments(
2783    body: Vec<Span<'static>>,
2784    width: usize,
2785    wrap_cols: usize,
2786) -> Vec<Vec<Span<'static>>> {
2787    if width == 0 {
2788        return vec![body];
2789    }
2790    if wrap_cols <= width {
2791        return vec![body];
2792    }
2793    let mut segments: Vec<Vec<Span<'static>>> = Vec::new();
2794    let mut current: Vec<Span<'static>> = Vec::new();
2795    let mut used: usize = 0; // columns filled in the current segment
2796    let mut col: usize = 0; // columns consumed from the whole body
2797    for span in body {
2798        let style = span.style;
2799        let mut chunk = String::new();
2800        for ch in span.content.chars() {
2801            // Break only while still inside the source-axis run;
2802            // once `col` reaches `wrap_cols` the rest is decoration
2803            // and stays on the segment it started.
2804            if used == width && col < wrap_cols {
2805                if !chunk.is_empty() {
2806                    current.push(Span::styled(std::mem::take(&mut chunk), style));
2807                }
2808                segments.push(std::mem::take(&mut current));
2809                used = 0;
2810            }
2811            chunk.push(ch);
2812            used += 1;
2813            col += 1;
2814        }
2815        if !chunk.is_empty() {
2816            current.push(Span::styled(chunk, style));
2817        }
2818    }
2819    if !current.is_empty() || segments.is_empty() {
2820        segments.push(current);
2821    }
2822    segments
2823}
2824
2825/// W.4.t.1: char-count column of source `byte` within the
2826/// tab-expanded cell body — the column model
2827/// [`split_body_into_segments`] slices by: one column per char, with
2828/// a `\t` filling to the next `tabstop` multiple (mirroring the cells
2829/// builder's `cells.len()`-based expansion). Identity for ASCII
2830/// without tabs (`byte == col`), so overlays on plain code are
2831/// unchanged. Char-count (not display-width) is deliberate — it
2832/// matches how the body is segmented and how the cells were laid out.
2833fn source_byte_to_body_col(line: &str, byte: usize, tabstop: u32) -> usize {
2834    let ts = (tabstop.max(1)) as usize;
2835    let mut col = 0usize;
2836    let mut b = 0usize;
2837    for ch in line.chars() {
2838        if b >= byte {
2839            break;
2840        }
2841        if ch == '\t' {
2842            col += ts - (col % ts);
2843        } else {
2844            col += 1;
2845        }
2846        b += ch.len_utf8();
2847    }
2848    col
2849}
2850
2851/// W.4.t.1: byte offset of the `col`-th char (0-based char index) in
2852/// `s`, clamped to `s.len()`. Maps a body column (from
2853/// [`source_byte_to_body_col`]) back onto the expanded cell-body's
2854/// byte index so the byte-slicing overlay appliers cut at the right
2855/// cell. `col` past the end clamps to the body end (EOL overlays).
2856fn nth_char_byte(s: &str, col: usize) -> usize {
2857    s.char_indices().nth(col).map(|(b, _)| b).unwrap_or(s.len())
2858}
2859
2860/// Placement for the help popup overlay.
2861///
2862/// Honors the popup's [`crate::popup::PopupPlacement`]:
2863/// - `Centered` (default for command-launched popups like
2864///   `:lsp-status`, `:describe-*`, `:apropos`, `:help`, `:keymap`,
2865///   `:options`, `:ls`) sits at the centre of the buffer area.
2866/// - `CursorAnchored` (hover, signature help) anchors next to the
2867///   document cursor: below when there's room, above otherwise,
2868///   horizontally aligned with the cursor column. Falls back to
2869///   centred if the cursor isn't visible.
2870///
2871/// In State A (active = Document) the doc cursor is `app.editor.cursor`
2872/// / `app.editor.scroll`; in State B (active = Help) it lives in the
2873/// active pane's stash.
2874fn position_help_popup(
2875    app: &App,
2876    snap: &DocumentSnapshot,
2877    buffer_area: Rect,
2878    width: u16,
2879    height: u16,
2880) -> Rect {
2881    let centered = || {
2882        let cx = buffer_area.x + buffer_area.width.saturating_sub(width) / 2;
2883        let cy = buffer_area.y + buffer_area.height.saturating_sub(height) / 2;
2884        Rect {
2885            x: cx,
2886            y: cy,
2887            width,
2888            height,
2889        }
2890    };
2891    if matches!(
2892        app.popup().placement,
2893        crate::popup::PopupPlacement::Centered
2894    ) {
2895        return centered();
2896    }
2897    let pane_area = match active_pane_content_rect(app, buffer_area) {
2898        Some(r) => r,
2899        None => return centered(),
2900    };
2901    // WK.5: which-key's placement — the active pane's full width, flush to
2902    // its bottom edge. `height` already carries the half-pane cap from
2903    // `popup_outer_size`, so the only work here is the anchor.
2904    if matches!(
2905        app.popup().placement,
2906        crate::popup::PopupPlacement::MinibufferBand
2907    ) {
2908        let h = height.min(pane_area.height);
2909        return Rect {
2910            x: pane_area.x,
2911            y: pane_area.y + pane_area.height.saturating_sub(h),
2912            width: pane_area.width,
2913            height: h,
2914        };
2915    }
2916    // Active pane must be a Document for the anchor to make sense
2917    // (the popup is only painted when active_pane.buffer != Help,
2918    // so this is the State A / B case where the active pane shows
2919    // a doc).
2920    //
2921    // K.4.9 (2026-06-02): explicit enumeration of which kinds hit
2922    // each branch (per `feedback_buffers_no_special_case`):
2923    //
2924    // - `Document` arm: reads cursor + scroll from
2925    //   `app.ad()` (the active document's published render
2926    //   state). Used when the active pane shows a regular
2927    //   document buffer.
2928    // - `_` fallback: hit by Messages / Multibuffer / FileTree /
2929    //   Oil / Terminal / Help. Reads from `panes.tree.active()`'s
2930    //   PaneState — the per-pane cursor/scroll the pane carries
2931    //   in its non-Document state. Same anchoring math; just a
2932    //   different source for the cursor coords. Help typically
2933    //   doesn't render its own popup anchor (the popup
2934    //   placement function isn't reached for Help-active panes
2935    //   per the comment above) but is defensively included in
2936    //   the fallback since the function may be called from
2937    //   future paths.
2938    // …and NOT `ad()` while a minibuffer holds focus. The `*search-line*`,
2939    // `*command-line*` and prompt buffers are Documents, so the `Document`
2940    // arm below would take the MINIBUFFER's cursor — (0, 0) on a fresh
2941    // search line — and anchor the popup to the top of the pane. Reported in
2942    // use: "hitting `/` jumps the popup up near the top".
2943    //
2944    // The pane's stashed cursor / scroll is the document's own: focusing a
2945    // minibuffer calls `snapshot_active_pane()` before it swaps, precisely so
2946    // the unfocused path reads live state (`focus_editing_buffer`). That is
2947    // the same source the non-Document arm already uses.
2948    //
2949    // Third instance of one mistake — `BufferKind::Document` standing in for
2950    // a question about focus. See `popup-unification.md` §9.
2951    //
2952    // FS.2 generalised it: the question is not "is a minibuffer focused" but
2953    // **"is the active document the pane's buffer"**. Since a focused popup
2954    // became the active document too, `ad()` diverges from the pane for two
2955    // reasons now, and an anchor read from `ad()` is wrong for both — it is
2956    // the popup's own caret when the popup has focus, and the search line's
2957    // (0, 0) when a `/` is open inside it.
2958    // The published state carries BOTH identities by name, which is the
2959    // distinction this whole initiative is about — use them rather than
2960    // inferring one from a `BufferKind`.
2961    let pane_buffer = app.ad().active_pane_buffer_id;
2962    let active_is_pane = app.ad().document_buffer_id == pane_buffer;
2963    let (cursor, scroll) = if active_is_pane {
2964        (app.ad().cursor, app.ad().scroll)
2965    } else {
2966        // The pane's stash IS the document's live state: every path that
2967        // takes focus calls `snapshot_active_pane()` before swapping,
2968        // precisely so the unfocused reader sees the truth.
2969        let panes = app.panes();
2970        let pane = panes.tree.active();
2971        (pane.cursor, pane.scroll)
2972    };
2973    // A cursor-anchored popup is anchored to where the caret WAS when it
2974    // opened, which is what `popup().anchor` records — the live caret is only
2975    // a fallback for popups that never captured one. Preferring the anchor
2976    // keeps the popup still while the caret moves inside it.
2977    let cursor = app.popup().anchor.unwrap_or(cursor);
2978    // …and the anchor is a line in the PANE's buffer, so it has to be mapped
2979    // through that buffer's snapshot. `snap` is the ACTIVE document's, which
2980    // is the popup itself or a one-line `*search-line*` — mapping a file line
2981    // through either lands the popup somewhere unrelated to it. That was the
2982    // other half of "hitting `/` jumps the popup".
2983    let pane_snap = if active_is_pane {
2984        None
2985    } else {
2986        app.buffers()
2987            .registry
2988            .document_handle(pane_buffer)
2989            .map(|h| h.snapshot())
2990    };
2991    let snap: &DocumentSnapshot = pane_snap.as_deref().unwrap_or(snap);
2992    let view = FrameView::from_app(app);
2993    let Some((cx, cy)) = cursor_screen_position_at(
2994        &view,
2995        snap,
2996        pane_area,
2997        cursor,
2998        scroll,
2999        app.panes().tree.active().id,
3000    ) else {
3001        return centered();
3002    };
3003    // Vertical: prefer below the cursor row; if the popup wouldn't
3004    // fit, place above. Pin to buffer_area bounds.
3005    let area_bottom = buffer_area.y + buffer_area.height;
3006    let space_below = area_bottom.saturating_sub(cy + 1);
3007    let space_above = cy.saturating_sub(buffer_area.y);
3008    let y = if space_below >= height {
3009        cy + 1
3010    } else if space_above >= height {
3011        cy.saturating_sub(height)
3012    } else if space_below >= space_above {
3013        // Not enough room either side -- pick the larger gap and
3014        // clamp the popup so it stays on-screen.
3015        area_bottom.saturating_sub(height).max(buffer_area.y)
3016    } else {
3017        buffer_area.y
3018    };
3019    // Horizontal: align to cursor column; shift left if it would
3020    // overflow the buffer area's right edge. Clamp to area.x.
3021    let max_x = (buffer_area.x + buffer_area.width).saturating_sub(width);
3022    let x = cx.min(max_x).max(buffer_area.x);
3023    Rect {
3024        x,
3025        y,
3026        width,
3027        height,
3028    }
3029}
3030
3031/// Compute the *content* rect (status row excluded) of the active
3032/// pane within `buffer_area`. Replicates the layout
3033/// [`draw_panes`] computes per pane. Returns `None` if the pane
3034/// tree has no active leaf (shouldn't happen in practice).
3035fn active_pane_content_rect(app: &App, buffer_area: Rect) -> Option<Rect> {
3036    let pane_area = crate::pane::PaneRect {
3037        x: buffer_area.x,
3038        y: buffer_area.y,
3039        width: buffer_area.width,
3040        height: buffer_area.height,
3041    };
3042    // Slice 3c.final.B (group 1): pane geometry through `app.panes()`.
3043    let panes = app.panes();
3044    let rects = panes.tree.compute_rects(pane_area);
3045    let active_idx = panes.tree.active_index();
3046    let multi = rects.len() > 1;
3047    let prect = rects
3048        .iter()
3049        .find(|(idx, _)| *idx == active_idx)
3050        .map(|(_, r)| *r)?;
3051    let rect = Rect {
3052        x: prect.x,
3053        y: prect.y,
3054        width: prect.width,
3055        height: prect.height,
3056    };
3057    if multi && rect.height >= 2 {
3058        Some(Rect {
3059            x: rect.x,
3060            y: rect.y,
3061            width: rect.width,
3062            height: rect.height - 1,
3063        })
3064    } else {
3065        Some(rect)
3066    }
3067}
3068
3069/// True iff the active pane's buffer kind is the same kind as
3070/// `app.ad().buffer_kind`. When mismatched, the active pane is
3071/// painted as visually inactive (frozen at `pane.cursor`) -- the
3072/// scenario that matters is help-popup-overlay (State B) where
3073/// the active pane shows a Document but motions go to the help
3074/// popup's buffer.
3075fn pane_buffer_matches_active(app: &App, idx: usize) -> bool {
3076    // Slice 3c.final.B (group 1): pane leaves via `app.panes()`.
3077    let panes = app.panes();
3078    panes
3079        .tree
3080        .leaves()
3081        .get(idx)
3082        .map(|p| p.buffer == app.ad().buffer_kind)
3083        .unwrap_or(false)
3084}
3085
3086/// Lay the pane tree out across `area` and draw each pane
3087/// (DESIGN.md §5.9). Each pane renders its actual buffer content
3088/// (vim-style: no decorative borders) plus a one-row status line
3089/// at its bottom edge. The active pane's status line is reverse-
3090/// videoed so focus is unambiguous; inactive status lines are
3091/// dim. With a single pane we skip the status line so the buffer
3092/// area looks identical to the pre-split rendering.
3093fn draw_panes(frame: &mut Frame, area: Rect, app: &App, snap: &DocumentSnapshot) {
3094    let pane_area = crate::pane::PaneRect {
3095        x: area.x,
3096        y: area.y,
3097        width: area.width,
3098        height: area.height,
3099    };
3100    // Slice 3c.final.B (group 1): pane geometry through `app.panes()`.
3101    // Bind the Arc once so subsequent `panes.tree.X()` reads share
3102    // the same snapshot for the duration of `draw_panes`.
3103    let panes_state = app.panes();
3104    let rects = panes_state.tree.compute_rects(pane_area);
3105    let active = panes_state.tree.active_index();
3106    let multi = rects.len() > 1;
3107    for (idx, prect) in rects.iter().copied() {
3108        let rect = Rect {
3109            x: prect.x,
3110            y: prect.y,
3111            width: prect.width,
3112            height: prect.height,
3113        };
3114        // A pane is *active for input* iff it's the focused pane
3115        // AND the active buffer kind matches the pane's buffer.
3116        // The mismatch case is the help-popup-overlay scenario:
3117        // active pane shows a Document, but `app.ad().buffer_kind ==
3118        // Help` because the popup is focused (State B). The doc
3119        // must paint with its own (frozen) `pane.cursor`, not
3120        // `app.editor.cursor` (which is help's). draw_inactive_document
3121        // already reads pane state, so we route there.
3122        // PI.3: a pane that is *previewing* another buffer must render
3123        // through the isolated projection path (`draw_inactive_document`
3124        // reads the displayed buffer), NOT the active path (`draw_buffer`
3125        // reads `app.ad()` = the committed buffer A). So the focused pane
3126        // is "active for input" only when it is showing its committed
3127        // buffer, not while a preview override is seated on it.
3128        let previewing = panes_state
3129            .tree
3130            .leaves()
3131            .get(idx)
3132            .map(|p| p.is_previewing())
3133            .unwrap_or(false);
3134        // MB.1: while the `:` line is open, `self.document` is the
3135        // synthetic `*command-line*` buffer. The active pane must keep
3136        // rendering ITS OWN buffer (via the registry-keyed inactive
3137        // path), not the command-line text — the Help-popup pattern.
3138        // MB.5: same for the `/`·`?` search line.
3139        let is_active = idx == active
3140            && pane_buffer_matches_active(app, idx)
3141            && !previewing
3142            && !app.ad().command_line_active
3143            && !app.ad().search_line_active;
3144        // Every pane reserves its bottom row for the per-pane status
3145        // line (Option A: global modeline removed).
3146        let (content_rect, status_rect) = if rect.height >= 2 {
3147            let content_h = rect.height - 1;
3148            (
3149                Rect {
3150                    x: rect.x,
3151                    y: rect.y,
3152                    width: rect.width,
3153                    height: content_h,
3154                },
3155                Some(Rect {
3156                    x: rect.x,
3157                    y: rect.y + content_h,
3158                    width: rect.width,
3159                    height: 1,
3160                }),
3161            )
3162        } else {
3163            (rect, None)
3164        };
3165        // Slice 3c.final.B (group 1): reuse the `panes_state` Arc
3166        // bound at the top of `draw_panes` so the slice borrow is
3167        // safe for the duration of the iteration.
3168        let pane_leaves = panes_state.tree.leaves();
3169        let Some(pane) = pane_leaves.get(idx) else {
3170            continue;
3171        };
3172        let pane = *pane;
3173        // M.4: per-kind dispatch consolidated into
3174        // `draw_pane_content`. The match still lives inside that
3175        // helper; from `draw_panes`'s POV the call is uniform.
3176        // Mode-driven dispatch (each major mode contributes its
3177        // own draw fn) replaces the helper-side match in a
3178        // follow-up.
3179        // MO.2: record this pane's painted body for mouse hit-testing,
3180        // beside the paint rather than derived from the layout later —
3181        // the `ModelineHitMap` rule, and for the same reason: a second
3182        // computation of the layout is free to disagree with the one on
3183        // screen, and the symptom is a click landing in the wrong pane.
3184        // `content_rect` excludes the status footer, so a click there is
3185        // not a click in the buffer.
3186        app.pane_hits
3187            .borrow_mut()
3188            .push(lattice_host::mouse::PaneHitZone {
3189                pane_id: pane.id,
3190                buffer_id: pane.buffer_id,
3191                x: content_rect.x,
3192                y: content_rect.y,
3193                width: content_rect.width,
3194                height: content_rect.height,
3195                text_left: pane_text_left(app, &pane, is_active, content_rect),
3196                scroll: pane.scroll,
3197                leftcol: pane.leftcol,
3198            });
3199        draw_pane_content(frame, content_rect, app, snap, &pane, is_active, idx);
3200        if let Some(sr) = status_rect {
3201            draw_pane_status_line(frame, sr, app, &pane, is_active);
3202        }
3203    }
3204    // Draw vertical separators in the column gaps between
3205    // side-by-side panes. The separator overlays the boundary
3206    // column of the right-side pane; horizontal splits don't get
3207    // an explicit separator -- the per-pane status line at the
3208    // bottom of the upper pane already provides one.
3209    if multi {
3210        draw_pane_separators(frame, &rects, app);
3211    }
3212}
3213
3214/// M.4: pane-content dispatch. Looks up the active buffer's mode
3215/// in `App::pane_render_registry` (walks minors then major) so
3216/// each major / minor mode owns its render path; falls back to
3217/// the document path when no provider matches. Replaces the
3218/// helper-side `match buffer.kind` so a plugin-installed mode can
3219/// register its own renderer without touching the dispatcher.
3220fn draw_pane_content(
3221    frame: &mut Frame,
3222    content_rect: Rect,
3223    app: &App,
3224    snap: &DocumentSnapshot,
3225    pane: &crate::pane::PaneState,
3226    is_active: bool,
3227    idx: usize,
3228) {
3229    // DL.4: a provider may own only its status label. A `None` render
3230    // means "paint me through the shared document path" — which is
3231    // where every converged kind ends up.
3232    if let Some(provider) = app.pane_render_provider(pane.buffer_id)
3233        && let Some(render) = provider.render
3234    {
3235        render(frame, content_rect, app, snap, pane, is_active, idx);
3236        return;
3237    }
3238    // Issue #40 / Terminal-mode T1: paint the terminal cell
3239    // grid when the pane's buffer is a Terminal. T2/T3 will
3240    // promote this to a pane-render provider registered by
3241    // `terminal-mode` (the major mode); T1 keeps it inline
3242    // since terminal-mode doesn't exist yet.
3243    if matches!(pane.buffer, crate::buffers::BufferKind::Terminal) {
3244        draw_terminal_pane(frame, content_rect, app, pane, is_active);
3245        return;
3246    }
3247    // Default path: document buffer. The active branch reads the
3248    // live `app.editor.cursor` / `app.editor.scroll`; the inactive one reads the
3249    // pane's stashed cursor + scroll.
3250    draw_pane_document(frame, content_rect, app, snap, pane, is_active);
3251}
3252
3253/// Map a terminal-substrate colour to ratatui's `Color`. `Default`
3254/// stays `Reset` so the cell renders with the terminal's own fg/bg
3255/// defaults (honouring the user's terminal theme).
3256fn term_to_tui(c: lattice_terminal::TerminalColor) -> ratatui::style::Color {
3257    use lattice_terminal::{NamedColor as TermNamed, TerminalColor};
3258    use ratatui::style::Color as TuiColor;
3259    match c {
3260        TerminalColor::Default => TuiColor::Reset,
3261        TerminalColor::Named(n) => match n {
3262            TermNamed::Black => TuiColor::Black,
3263            TermNamed::Red => TuiColor::Red,
3264            TermNamed::Green => TuiColor::Green,
3265            TermNamed::Yellow => TuiColor::Yellow,
3266            TermNamed::Blue => TuiColor::Blue,
3267            TermNamed::Magenta => TuiColor::Magenta,
3268            TermNamed::Cyan => TuiColor::Cyan,
3269            TermNamed::White => TuiColor::Gray,
3270            TermNamed::BrightBlack => TuiColor::DarkGray,
3271            TermNamed::BrightRed => TuiColor::LightRed,
3272            TermNamed::BrightGreen => TuiColor::LightGreen,
3273            TermNamed::BrightYellow => TuiColor::LightYellow,
3274            TermNamed::BrightBlue => TuiColor::LightBlue,
3275            TermNamed::BrightMagenta => TuiColor::LightMagenta,
3276            TermNamed::BrightCyan => TuiColor::LightCyan,
3277            TermNamed::BrightWhite => TuiColor::White,
3278        },
3279        TerminalColor::Indexed(i) => TuiColor::Indexed(i),
3280        TerminalColor::Rgb(r, g, b) => TuiColor::Rgb(r, g, b),
3281    }
3282}
3283
3284/// Build the ratatui `Style` for a terminal cell's SGR fg/bg/attrs.
3285fn terminal_cell_style(
3286    fg: lattice_terminal::TerminalColor,
3287    bg: lattice_terminal::TerminalColor,
3288    attrs: lattice_terminal::CellAttrs,
3289) -> ratatui::style::Style {
3290    use ratatui::style::{Modifier, Style};
3291    let mut s = Style::default().fg(term_to_tui(fg)).bg(term_to_tui(bg));
3292    let mut m = Modifier::empty();
3293    if attrs.bold {
3294        m |= Modifier::BOLD;
3295    }
3296    if attrs.italic {
3297        m |= Modifier::ITALIC;
3298    }
3299    if attrs.underline {
3300        m |= Modifier::UNDERLINED;
3301    }
3302    if attrs.reverse {
3303        m |= Modifier::REVERSED;
3304    }
3305    if attrs.dim {
3306        m |= Modifier::DIM;
3307    }
3308    if attrs.strikethrough {
3309        m |= Modifier::CROSSED_OUT;
3310    }
3311    if !m.is_empty() {
3312        s = s.add_modifier(m);
3313    }
3314    s
3315}
3316
3317/// Build the styled spans for one terminal grid row. Adjacent cells
3318/// with identical resolved style coalesce into one `Span`.
3319///
3320/// **Width contract** (see `docs/dev/audit/terminal-wide-char-ghosting.md`):
3321/// a width-2 glyph occupies two grid cells — the glyph plus a
3322/// `wide_spacer` placeholder. Spacer cells are SKIPPED so the row's
3323/// total display width equals `cols_to_paint`; the wide glyph owns
3324/// its second display column via ratatui's own shaping. Emitting the
3325/// spacer as a space would make the row one column wide per glyph and
3326/// corrupt ratatui's width-based cell diff (the auto-scroll ghosting
3327/// bug).
3328#[allow(clippy::too_many_arguments)]
3329fn terminal_row_spans(
3330    snap: &lattice_terminal::TerminalSnapshot,
3331    row: u16,
3332    cols_to_paint: u16,
3333    paint_cursor_cell: bool,
3334    cursor_row: u16,
3335    cursor_col: u16,
3336    match_overlay: Option<(u16, u16, u16)>,
3337    all_matches: &[lattice_terminal::GridSearchHit],
3338    visual: Option<lattice_terminal::TerminalVisualState>,
3339) -> Vec<ratatui::text::Span<'static>> {
3340    use ratatui::style::{Modifier, Style};
3341    use ratatui::text::Span;
3342    let mut spans: Vec<Span> = Vec::new();
3343    let mut run_text = String::with_capacity(cols_to_paint as usize);
3344    let mut run_style: Option<Style> = None;
3345    for col in 0..cols_to_paint {
3346        let cell = snap.cell_at(row, col);
3347        // Width contract: a wide glyph owns its two display columns via
3348        // ratatui's own shaping; its trailing `wide_spacer` cell must NOT
3349        // be emitted as a stray space (that pushes the row one column wide
3350        // per glyph and corrupts ratatui's width-based cell diff — the
3351        // auto-scroll ghosting bug). Skip it so grid col stays 1:1 with
3352        // display col. See docs/dev/audit/terminal-wide-char-ghosting.md.
3353        if cell.wide_spacer {
3354            continue;
3355        }
3356        let mut style = terminal_cell_style(cell.fg, cell.bg, cell.attrs);
3357        // Splice the cursor cell on inactive panes (the active pane
3358        // uses the hardware cursor).
3359        if paint_cursor_cell && row == cursor_row && col == cursor_col {
3360            style = style.add_modifier(Modifier::REVERSED);
3361        }
3362        // Hlsearch-style soft underline for every all_matches hit.
3363        if !all_matches.is_empty() {
3364            let off = snap.scroll_offset as i32;
3365            let cell_line = row as i32 - off;
3366            for h in all_matches {
3367                if h.line == cell_line {
3368                    let c_start = h.column;
3369                    let c_end = h.column.saturating_add(h.len.min(u16::MAX as u32) as u16);
3370                    if col >= c_start && col < c_end {
3371                        style = style.add_modifier(Modifier::UNDERLINED);
3372                        break;
3373                    }
3374                }
3375            }
3376        }
3377        // Current match wins style precedence on its row.
3378        if let Some((m_row, c_start, c_end)) = match_overlay
3379            && row == m_row
3380            && col >= c_start
3381            && col < c_end
3382        {
3383            style = style.add_modifier(Modifier::REVERSED);
3384        }
3385        // Visual selection cells paint REVERSED (per-kind predicate).
3386        if let Some(v) = visual {
3387            use lattice_terminal::VisualKind as Vk;
3388            let off = snap.scroll_offset as i32;
3389            let cell_line = row as i32 - off;
3390            let in_sel = match v.kind {
3391                Vk::Line => {
3392                    let (lo, hi) = v.line_range();
3393                    cell_line >= lo && cell_line <= hi
3394                }
3395                Vk::Block => {
3396                    let (lo, hi) = v.line_range();
3397                    let (lo_c, hi_c) = v.block_col_range();
3398                    cell_line >= lo && cell_line <= hi && col >= lo_c && col <= hi_c
3399                }
3400                Vk::Char => {
3401                    let ((sl, sc), (el, ec)) = v.char_endpoints();
3402                    if sl == el {
3403                        cell_line == sl && col >= sc && col <= ec
3404                    } else if cell_line == sl {
3405                        col >= sc
3406                    } else if cell_line == el {
3407                        col <= ec
3408                    } else {
3409                        cell_line > sl && cell_line < el
3410                    }
3411                }
3412            };
3413            if in_sel {
3414                style = style.add_modifier(Modifier::REVERSED);
3415            }
3416        }
3417        match run_style {
3418            Some(prev) if prev == style => {
3419                run_text.push(cell.ch);
3420            }
3421            _ => {
3422                if !run_text.is_empty() {
3423                    spans.push(Span::styled(
3424                        std::mem::take(&mut run_text),
3425                        run_style.unwrap_or_default(),
3426                    ));
3427                }
3428                run_text.push(cell.ch);
3429                run_style = Some(style);
3430            }
3431        }
3432    }
3433    if !run_text.is_empty() {
3434        spans.push(Span::styled(run_text, run_style.unwrap_or_default()));
3435    }
3436    spans
3437}
3438
3439/// Issue #40 / Terminal-mode T1: paint the terminal cell grid
3440/// from the published `TerminalSnapshot`. T1 ignores the
3441/// per-cell fg/bg/attrs and renders monochrome — T2 wires
3442/// alacritty_terminal's real SGR colors via the same path.
3443fn draw_terminal_pane(
3444    frame: &mut Frame,
3445    area: Rect,
3446    app: &App,
3447    pane: &crate::pane::PaneState,
3448    is_active: bool,
3449) {
3450    use ratatui::text::{Line, Span};
3451    use ratatui::widgets::Paragraph;
3452    let rs = app.render_state.load();
3453    let (snap_arc, current_match, mut visual, all_matches, mut nav_cursor) =
3454        match rs.buffers.registry.with_terminal(pane.buffer_id, |t| {
3455            (
3456                t.snapshot.load_full(),
3457                t.current_match,
3458                t.visual,
3459                t.all_matches.clone(),
3460                t.nav_cursor,
3461            )
3462        }) {
3463            Some(p) => p,
3464            None => {
3465                let p = Paragraph::new(Line::from(Span::raw("(terminal buffer unavailable)")));
3466                frame.render_widget(p, area);
3467                return;
3468            }
3469        };
3470    // T-clean-1 Phase A.2 (2026-05-28): for the active pane,
3471    // prefer the publisher's derived values from
3472    // `rs.active_document.load().terminal_nav_cursor` /
3473    // `terminal_visual` (computed from `self.cursor` (doc-space)
3474    // + `synthetic.origin_top_line`). This is the path that
3475    // will retire `t.nav_cursor` / `t.visual` direct reads
3476    // entirely once per-pane publishing lands. Inactive panes
3477    // keep using `with_terminal` (their stashed values are
3478    // intentionally last-known-good across pane switches).
3479    if is_active {
3480        if rs.active_document.load().terminal_nav_cursor.is_some() {
3481            nav_cursor = rs.active_document.load().terminal_nav_cursor;
3482        }
3483        if rs.active_document.load().terminal_visual.is_some() {
3484            visual = rs.active_document.load().terminal_visual;
3485        }
3486    }
3487    let rows_to_paint = area.height.min(snap_arc.rows);
3488    let cols_to_paint = area.width.min(snap_arc.cols);
3489    // Diagnostic probe (opt-in via RUST_LOG=lattice_ui_tui::terminal_clip=debug):
3490    // fires whenever the PTY's own row/col count (what alacritty and the
3491    // child process believe their screen size is) exceeds this frame's real
3492    // paint area. That gap is exactly what makes `rows_to_paint` cut the
3493    // BOTTOM of the terminal's content (the last N rows of the PTY grid —
3494    // often where a full-screen program like `claude` puts its prompt /
3495    // status — are never painted). If the "last line clipped" bug persists
3496    // after the `empty_sized` placeholder fix (spawner.rs), enable this to
3497    // capture the exact PTY-vs-pane numbers at the moment it happens.
3498    if snap_arc.rows > area.height || snap_arc.cols > area.width {
3499        tracing::debug!(
3500            target: "lattice_ui_tui::terminal_clip",
3501            pty_rows = snap_arc.rows,
3502            pty_cols = snap_arc.cols,
3503            pane_area_rows = area.height,
3504            pane_area_cols = area.width,
3505            rows_to_paint,
3506            cols_to_paint,
3507            "draw_terminal_pane: PTY believes it is larger than the paint area — \
3508             the bottom/right of its content is not being painted",
3509        );
3510    }
3511    // 2026-05-25: nav_cursor overrides the PTY cursor in
3512    // Normal-in-terminal so the user sees a "you are here"
3513    // marker that j / k / etc. moves. When nav_cursor is None
3514    // we fall back to the live PTY cursor (the snapshot's
3515    // cursor_row/col).
3516    let (cursor_row, cursor_col, cursor_visible) = if let Some((nav_l, nav_c)) = nav_cursor {
3517        let off = snap_arc.scroll_offset as i32;
3518        let row = nav_l + off;
3519        if (0..rows_to_paint as i32).contains(&row) && nav_c < cols_to_paint {
3520            (row as u16, nav_c, true)
3521        } else {
3522            (0, 0, false)
3523        }
3524    } else {
3525        let r = snap_arc.cursor_row;
3526        let c = snap_arc.cursor_col;
3527        let v = snap_arc.cursor_visible && r < rows_to_paint && c < cols_to_paint;
3528        (r, c, v)
3529    };
3530    // Per-cell SGR colour + style mapping and the row-span builder
3531    // live as module-level fns (`term_to_tui`, `terminal_cell_style`,
3532    // `terminal_row_spans`) so the wide-char width contract is unit-
3533    // testable. See `docs/dev/audit/terminal-wide-char-ghosting.md`.
3534    // Terminal-mode T2.b (2026-05-25): the active pane drives the
3535    // ratatui hardware cursor at the terminal's grid position, so
3536    // the user sees a real terminal cursor with the right shape
3537    // (block in Normal-in-terminal, bar in Terminal-Insert — set
3538    // by `runtime::cursor_style_for`). The cell-reverse splice
3539    // stays on inactive panes since the hardware cursor can only
3540    // be in one place at a time.
3541    let paint_cursor_cell = cursor_visible && !is_active;
3542    // T3.b.3: translate the current_match's alacritty grid
3543    // line into the snapshot's visible-window row coordinates.
3544    // `snap.scroll_offset` is the number of rows scrolled back
3545    // from the live edge; the topmost visible row corresponds
3546    // to alacritty `Line(-scroll_offset)`. A match at grid
3547    // line `L` therefore lands at visible row `L + scroll_offset`.
3548    let match_overlay = current_match.and_then(|h| {
3549        let row = h.line + snap_arc.scroll_offset as i32;
3550        if (0..rows_to_paint as i32).contains(&row) {
3551            let col_start = h.column;
3552            let col_end = h
3553                .column
3554                .saturating_add(h.len.min(u16::MAX as u32) as u16)
3555                .min(cols_to_paint);
3556            Some((row as u16, col_start, col_end))
3557        } else {
3558            None
3559        }
3560    });
3561    // T3.b.2 / T3.b.2.b: Visual selection predicate. Walks
3562    // each cell and asks "is this in the selection?" based on
3563    // kind. Char + block need col-precision so the row-range
3564    // shortcut isn't enough.
3565    let visual_state = visual;
3566    // Row spans (with cursor / match / visual overlays and the
3567    // wide-char width contract) are built by `terminal_row_spans`.
3568    let lines: Vec<Line> = (0..rows_to_paint)
3569        .map(|row| {
3570            Line::from(terminal_row_spans(
3571                &snap_arc,
3572                row,
3573                cols_to_paint,
3574                paint_cursor_cell,
3575                cursor_row,
3576                cursor_col,
3577                match_overlay,
3578                &all_matches,
3579                visual_state,
3580            ))
3581        })
3582        .collect();
3583    let para = Paragraph::new(lines);
3584    frame.render_widget(para, area);
3585    // Place the hardware cursor on the active pane only —
3586    // ratatui's frame.set_cursor_position drives the terminal's
3587    // single hardware cursor, so multi-pane setups would race
3588    // without the active gate. Out-of-area positions are clamped
3589    // so a stale snapshot can't park the cursor outside the pane.
3590    if is_active && cursor_visible {
3591        let screen_x = area
3592            .x
3593            .saturating_add(cursor_col)
3594            .min(area.x + area.width.saturating_sub(1));
3595        let screen_y = area
3596            .y
3597            .saturating_add(cursor_row)
3598            .min(area.y + area.height.saturating_sub(1));
3599        frame.set_cursor_position((screen_x, screen_y));
3600    }
3601}
3602
3603// M.4: per-mode pane-render adapters. Each adapter has the
3604// uniform [`crate::pane_render::PaneRenderFn`] signature; the
3605// existing per-kind draw fns retain their original signatures and
3606// the adapter forwards the relevant subset.
3607
3608fn help_pane_render(
3609    frame: &mut Frame,
3610    area: Rect,
3611    app: &App,
3612    snap: &DocumentSnapshot,
3613    pane: &crate::pane::PaneState,
3614    is_active: bool,
3615    _idx: usize,
3616) {
3617    // PU.1b-2b: in-pane help is a Document — its markdown `SyntaxHandle`
3618    // + link `ExtraHighlights` are baked into the cells-worker
3619    // `DisplayMatrix` — so it renders through the SAME compose path as
3620    // any document, with NO bespoke help painter. help-mode's options
3621    // (`nonu`, `signcolumn=no`, `wrap`) give it the clean gutterless
3622    // wrapped look the old `draw_help_in_pane` hand-rolled. This provider
3623    // stays registered only for `help_pane_status` (the status-line
3624    // contribution); the render arm forwards to the generic path, so a
3625    // help pane is pixel-equivalent to a `:set nonu signcolumn=no wrap`
3626    // document (K.4 / `feedback_render_is_option_derived`).
3627    draw_pane_document(frame, area, app, snap, pane, is_active);
3628}
3629
3630fn help_pane_status(app: &App, _pane: &crate::pane::PaneState) -> String {
3631    app.popup_help()
3632        .map(|h| format!("[help] {}", h.title))
3633        .unwrap_or_else(|| "[help]".to_string())
3634}
3635
3636fn file_tree_pane_status(app: &App, pane: &crate::pane::PaneState) -> String {
3637    // Slice 3c.final.B.9: buffer_locals via published map.
3638    let locals_map = app.buffer_locals();
3639    let root = locals_map
3640        .map
3641        .get(&pane.buffer_id)
3642        .and_then(|locals| locals.get::<crate::modes::FileTreeRoot>())
3643        .map(|r| r.0.clone());
3644    root.map(|p| format!("[tree] {}", p.display()))
3645        .unwrap_or_else(|| "[tree]".to_string())
3646}
3647
3648fn oil_pane_status(app: &App, pane: &crate::pane::PaneState) -> String {
3649    // Slice 3c.final.E.5j: `with_oil` via published `buffers()` sub-
3650    // state; OilDir buffer-local via `read_editor`.
3651    let dirty_opt = app
3652        .buffers()
3653        .registry
3654        .document_handle(pane.buffer_id)
3655        .map(|h| h.snapshot().buffer.as_string())
3656        .and_then(|text| {
3657            app.buffer_locals()
3658                .map
3659                .get(&pane.buffer_id)
3660                .and_then(|l| l.get::<lattice_listing::oil::modes::OilSnapshotLocal>())
3661                .map(|s| s.0.is_dirty(&text))
3662        });
3663    let Some(is_dirty) = dirty_opt else {
3664        return "[oil]".to_string();
3665    };
3666    let dirty = if is_dirty { " [+]" } else { "" };
3667    // Slice 3c.final.B.9: OilDir via published map.
3668    let locals_map = app.buffer_locals();
3669    let dir: String = locals_map
3670        .map
3671        .get(&pane.buffer_id)
3672        .and_then(|locals| locals.get::<crate::modes::OilDir>())
3673        .map(|d| d.0.display().to_string())
3674        .unwrap_or_default();
3675    format!("[oil] {dir}{dirty}")
3676}
3677
3678/// Boot-time registration of the renderer-side providers for the
3679/// built-in modes. Plugin-installed modes (post-1.0) extend this
3680/// registry through the same interface.
3681pub fn build_pane_render_registry() -> crate::pane_render::PaneRenderRegistry {
3682    use crate::pane_render::{PaneRenderProvider, PaneRenderRegistry};
3683    use lattice_mode::Mode;
3684    let mut registry = PaneRenderRegistry::new();
3685    // Help-mode is a *minor* mode layered onto markdown-mode; the
3686    // pane-render dispatcher walks minors first, so this entry
3687    // wins over markdown's default (document) path when the
3688    // help-mode minor is active.
3689    registry.register(
3690        lattice_mode::modes::HelpMode.id(),
3691        PaneRenderProvider {
3692            render: Some(help_pane_render),
3693            status: help_pane_status,
3694        },
3695    );
3696    // DL.4: the file tree has NO `render` provider — it is a Document
3697    // now and paints through the shared compose path like every other
3698    // buffer. Only the status label stays mode-owned.
3699    registry.register_status_only(
3700        lattice_listing::file_tree::FileTreeMode.id(),
3701        file_tree_pane_status,
3702    );
3703    // DL.5: oil has NO `render` provider either — the last bespoke
3704    // listing painter is gone, so both listing kinds paint through the
3705    // shared compose path. Only the status label is mode-owned.
3706    registry.register_status_only(lattice_listing::oil::OilMode.id(), oil_pane_status);
3707    registry
3708}
3709
3710/// Walk the pane rects and draw a vertical separator wherever two
3711/// rects share a vertical seam (same y range, A's right edge ==
3712/// B's left edge). Uses [`Theme::pane_separator_vertical`] for the
3713/// glyph and [`Theme::pane_separator`] for the style.
3714fn draw_pane_separators(frame: &mut Frame, rects: &[(usize, crate::pane::PaneRect)], app: &App) {
3715    let glyph = app.theme.pane_separator_vertical;
3716    let style = app.theme.pane_separator;
3717    for (col, y_start, y_end) in separator_segments(rects) {
3718        for row in y_start..y_end {
3719            let r = Rect {
3720                x: col,
3721                y: row,
3722                width: 1,
3723                height: 1,
3724            };
3725            let para = Paragraph::new(Line::from(Span::styled(glyph.to_string(), style)));
3726            frame.render_widget(para, r);
3727        }
3728    }
3729}
3730
3731/// Compute the vertical separator segments between horizontally
3732/// adjacent panes. Each segment is `(col, y_start, y_end)` (half-open
3733/// row range) painted on the boundary column shared by a left pane's
3734/// right edge and a right pane's left edge.
3735///
3736/// The separator is drawn over the *vertical overlap* of the two
3737/// panes, NOT the full height of either. This is what lets a column
3738/// that has itself been sub-split (e.g. `:vsplit` then `:split` the
3739/// left pane) still get a continuous divider against the neighbour:
3740/// each stacked sub-pane contributes the segment covering its own
3741/// rows. The earlier "same band" gate (`a.y == b.y && a.height ==
3742/// b.height`) required identical rects and silently dropped the
3743/// divider whenever one side was sub-split.
3744fn separator_segments(rects: &[(usize, crate::pane::PaneRect)]) -> Vec<(u16, u16, u16)> {
3745    let mut segments = Vec::new();
3746    for (i, (_, a)) in rects.iter().enumerate() {
3747        for (_, b) in rects.iter().skip(i + 1) {
3748            // Identify which pane sits directly to the left of the
3749            // other (order in `rects` is tree-walk order, not sorted
3750            // by x), so both `a|b` and `b|a` adjacencies are caught.
3751            let (left, right) = if a.x + a.width == b.x {
3752                (a, b)
3753            } else if b.x + b.width == a.x {
3754                (b, a)
3755            } else {
3756                continue;
3757            };
3758            let y_start = left.y.max(right.y);
3759            let y_end = (left.y + left.height).min(right.y + right.height);
3760            if y_start < y_end {
3761                segments.push((left.x + left.width - 1, y_start, y_end));
3762            }
3763        }
3764    }
3765    segments
3766}
3767
3768/// One-row status line at the bottom of a pane (vim's "statusline"
3769/// per-window). ML.1a-render: the row is laid out from the registered
3770/// modeline elements (`RenderState.modeline_elements`) in three zones —
3771/// Left (flush-left), Right (flush-right, the block right-aligned),
3772/// Center (centered in the gap). Built-in (`core.*`) content is resolved
3773/// per pane *host-side* (`lattice_host::modeline`), so the TUI and GPUI
3774/// peers paint identical content; only this layout/paint is
3775/// renderer-specific. The Center zone is fed by the temporary mode-items
3776/// pull until ML.3 migrates LSP/diff to registered elements.
3777///
3778/// Active pane is reverse-videoed; inactive panes are dim — one
3779/// `pane_status_*` style for the whole row until ML.1b's per-role
3780/// theming resolves each span's `ModelineRole` through `ResolvedTheme`.
3781fn draw_pane_status_line(
3782    frame: &mut Frame,
3783    area: Rect,
3784    app: &App,
3785    pane: &crate::pane::PaneState,
3786    is_active: bool,
3787) {
3788    let width = area.width as usize;
3789    if width == 0 {
3790        return;
3791    }
3792    let segments = modeline_segments(app, pane, is_active, width);
3793    // ML.4: record before painting, from the same run list the painter
3794    // consumes, so the click map and the pixels come from one source.
3795    record_modeline_hits(&mut app.modeline_hits.borrow_mut(), area, &segments);
3796    let spans = segments_to_spans(app, &segments, is_active);
3797    frame.render_widget(Paragraph::new(Line::from(spans)), area);
3798}
3799
3800/// One styled run within the modeline: text, its theme role (`None` =
3801/// neutral padding / separator, painted as the bar base only), and
3802/// (ML.4) the command a click on it dispatches.
3803///
3804/// This was a bare `(String, Option<ModelineRole>)` tuple until ML.4.
3805/// It had to grow identity because a terminal has no element tree to
3806/// hang a listener on: the only way to know what the user clicked is to
3807/// remember which columns each element painted into, which means the
3808/// run has to carry its element's action all the way through layout
3809/// and truncation.
3810#[derive(Debug, Clone, PartialEq, Eq)]
3811struct ModelineSeg {
3812    text: String,
3813    role: Option<lattice_mode::ModelineRole>,
3814    /// `None` for padding, separators, and elements that declared no
3815    /// `on_click` — i.e. almost everything.
3816    click: Option<lattice_protocol::CommandId>,
3817}
3818
3819impl ModelineSeg {
3820    /// A neutral run: padding, separator, or filler. Never clickable.
3821    fn filler(text: String) -> Self {
3822        Self {
3823            text,
3824            role: None,
3825            click: None,
3826        }
3827    }
3828
3829    fn width(&self) -> usize {
3830        self.text.chars().count()
3831    }
3832}
3833
3834/// Flatten an element's content into role-tagged runs, dropping empty
3835/// spans.
3836///
3837/// ML.4: every run of one element carries that element's `on_click`, so
3838/// clicking anywhere on a multi-span element (an icon plus its label,
3839/// say) fires the same command — which is what a user means by
3840/// "clicking the element".
3841fn content_to_runs(
3842    content: lattice_mode::ElementContent,
3843    click: Option<lattice_protocol::CommandId>,
3844) -> Vec<ModelineSeg> {
3845    content
3846        .spans
3847        .into_iter()
3848        .filter(|s| !s.text.is_empty())
3849        .map(|s| ModelineSeg {
3850            text: s.text,
3851            role: Some(s.role),
3852            click,
3853        })
3854        .collect()
3855}
3856
3857/// Build the per-Span modeline line for `pane` (ML.1b). Resolves each
3858/// zone's content into role-tagged runs via the shared host resolver,
3859/// lays them out into `width` columns, then maps each role → a ratatui
3860/// style through the theme cache ([`crate::theme::Theme::modeline_style`]).
3861/// Extracted from [`draw_pane_status_line`] so tests can assert span text +
3862/// style without a frame. The whole row sits on the active/inactive
3863/// bar; per-role foregrounds compose over it.
3864fn modeline_segments(
3865    app: &App,
3866    pane: &crate::pane::PaneState,
3867    is_active: bool,
3868    width: usize,
3869) -> Vec<ModelineSeg> {
3870    let rs = app.render_state.load();
3871    let snap = &rs.modeline_elements;
3872    // The file-tree / oil / help custom label (M.4 provider mechanism) is
3873    // the one renderer-resolved content input; it overrides `core.path`.
3874    // Resolved once and threaded into the shared host resolver so the
3875    // assembly stays common across peers.
3876    // PI.3: the per-pane status line reports the pane's COMMITTED buffer,
3877    // not the previewed one (design §5) — the modeline shows what you'd
3878    // save / switch back to. `core.path` already comes from the active
3879    // document's `modeline_elements`; the provider label resolves against
3880    // the committed buffer too.
3881    let provider_label = app
3882        .pane_render_provider(pane.committed_id())
3883        .map(|p| (p.status)(app, pane));
3884    let provider_ref = provider_label.as_deref();
3885
3886    // ML.5: the `ui.modeline.{left,center,right}` config drives zone
3887    // membership + order; `resolve_layout` returns the per-zone
3888    // descriptor lists (Auto = descriptor placement) + the configured
3889    // separator. The renderer still resolves each descriptor's content
3890    // itself (built-ins host-side, pushed from the snapshot).
3891    let layout = lattice_host::modeline::resolve_layout(&snap.registry, &rs.options.config);
3892    let sep = layout.separator.clone();
3893    let resolve_zone = |els: &[&lattice_mode::ModelineElement]| -> Vec<ModelineSeg> {
3894        let mut runs: Vec<ModelineSeg> = Vec::new();
3895        for el in els {
3896            // §7: Global-scope elements (e.g. the diff summary) render
3897            // only on the active pane; PaneLocal is the default.
3898            if matches!(el.scope, lattice_mode::Scope::Global) && !is_active {
3899                continue;
3900            }
3901            let id = el.id.as_str();
3902            let content = if id.starts_with("core.") {
3903                lattice_host::modeline::resolve_builtin_content(
3904                    id,
3905                    pane,
3906                    is_active,
3907                    &rs,
3908                    provider_ref,
3909                )
3910            } else {
3911                // Pushed elements (modes / plugins, ML.3): content from
3912                // the published store, resolved per the descriptor's
3913                // scope against this pane's buffer (PaneLocal) or the
3914                // global slot.
3915                snap.resolve(el, pane.buffer_id)
3916                    .cloned()
3917                    .unwrap_or_default()
3918            };
3919            if content.is_empty() {
3920                continue;
3921            }
3922            // Configured separator between elements within a zone
3923            // (`ui.modeline.separator`, default a single space).
3924            if !runs.is_empty() && !sep.is_empty() {
3925                runs.push(ModelineSeg::filler(sep.clone()));
3926            }
3927            // ML.4: the descriptor's declared click target rides along
3928            // with its runs. The separator above deliberately does not
3929            // get one — the gap between two elements belongs to
3930            // neither.
3931            let click = el.interaction.as_ref().and_then(|i| i.on_click);
3932            runs.extend(content_to_runs(content, click));
3933        }
3934        runs
3935    };
3936
3937    let left = resolve_zone(&layout.left);
3938    let center = resolve_zone(&layout.center);
3939    let right = resolve_zone(&layout.right);
3940
3941    compose_modeline_segments(width, layout.padding, left, center, right)
3942}
3943
3944/// Paint a composed run list as ratatui spans.
3945fn segments_to_spans(app: &App, segments: &[ModelineSeg], is_active: bool) -> Vec<Span<'static>> {
3946    segments
3947        .iter()
3948        .map(|seg| {
3949            let style = app
3950                .theme
3951                .modeline_style(seg.role.as_ref().map(|r| r.as_str()), is_active);
3952            Span::styled(seg.text.clone(), style)
3953        })
3954        .collect()
3955}
3956
3957/// ML.4: record every clickable run's absolute cell span into `hits`.
3958///
3959/// Walks the composed runs left to right accumulating columns, which is
3960/// exactly how the painter lays them down — so the recorded regions
3961/// cannot disagree with what the user sees unless the painter itself
3962/// changes. `area` is the modeline's own `Rect`, so this handles splits
3963/// (each pane's modeline records its own column range on its own row)
3964/// with no extra work.
3965fn record_modeline_hits(
3966    hits: &mut lattice_host::modeline::ModelineHitMap,
3967    area: Rect,
3968    segments: &[ModelineSeg],
3969) {
3970    let mut col = area.x;
3971    for seg in segments {
3972        let w = seg.width() as u16;
3973        if let Some(action) = seg.click {
3974            hits.push(lattice_host::modeline::ModelineHitZone {
3975                row: area.y,
3976                col_start: col,
3977                // Clip to the pane's own width: a run can only be
3978                // clicked where it was actually painted.
3979                col_end: col.saturating_add(w).min(area.x + area.width),
3980                action,
3981            });
3982        }
3983        col = col.saturating_add(w);
3984    }
3985}
3986
3987/// Truncate `s` to at most `max` display columns (char count), adding a
3988/// `…` ellipsis when it overflows. `max == 0` ⇒ empty; `max == 1` ⇒ just
3989/// the ellipsis. Never panics.
3990fn truncate_to(s: &str, max: usize) -> String {
3991    if s.chars().count() <= max {
3992        return s.to_string();
3993    }
3994    match max {
3995        0 => String::new(),
3996        1 => "…".to_string(),
3997        _ => {
3998            let mut out: String = s.chars().take(max - 1).collect();
3999            out.push('…');
4000            out
4001        }
4002    }
4003}
4004
4005/// Total display width (char count) of a run list.
4006fn runs_width(runs: &[ModelineSeg]) -> usize {
4007    runs.iter().map(|s| s.width()).sum()
4008}
4009
4010/// Truncate a run list to at most `max` columns, ellipsising the run that
4011/// straddles the boundary (preserving its role and its click target).
4012/// Never panics.
4013///
4014/// ML.4: the ellipsised remnant keeps its `click`. A half-visible
4015/// element is still that element — refusing the click on the grounds
4016/// that it got shortened would make clickability depend on pane width,
4017/// which the user experiences as the modeline randomly not working.
4018fn truncate_runs(runs: Vec<ModelineSeg>, max: usize) -> Vec<ModelineSeg> {
4019    let mut out: Vec<ModelineSeg> = Vec::new();
4020    let mut used = 0usize;
4021    for seg in runs {
4022        let w = seg.width();
4023        if used + w <= max {
4024            used += w;
4025            out.push(seg);
4026        } else {
4027            let remaining = max - used;
4028            if remaining > 0 {
4029                out.push(ModelineSeg {
4030                    text: truncate_to(&seg.text, remaining),
4031                    ..seg
4032                });
4033            }
4034            break;
4035        }
4036    }
4037    out
4038}
4039
4040/// Lay three role-tagged run lists into a `width`-wide row of styled
4041/// segments: Left flush-left, Right flush-right (block right-aligned),
4042/// Center centered in the gap. On overflow, sacrifice **Center first,
4043/// then Right, then Left** (design §7) via greedy keep-priority
4044/// Left > Right > Center, each block ellipsised to its remaining budget.
4045/// Padding / separator segments carry no role (`None`) → the bar base.
4046/// The returned segments sum to exactly `width` columns (empty when
4047/// `width == 0`). Saturating throughout: never panics, blocks never
4048/// overlap (≥1 column between any two non-empty blocks).
4049fn compose_modeline_segments(
4050    width: usize,
4051    padding: usize,
4052    left: Vec<ModelineSeg>,
4053    center: Vec<ModelineSeg>,
4054    right: Vec<ModelineSeg>,
4055) -> Vec<ModelineSeg> {
4056    if width == 0 {
4057        return Vec::new();
4058    }
4059    // `ui.modeline.padding`: reserve a blank margin on each edge, lay the
4060    // zones out into the inner width, then bookend with the margins so the
4061    // total is still exactly `width`. Capped at half-width so a huge
4062    // padding on a narrow pane can't underflow.
4063    let pad = padding.min(width / 2);
4064    if pad > 0 {
4065        let inner = compose_modeline_segments(width - 2 * pad, 0, left, center, right);
4066        let mut out: Vec<ModelineSeg> = Vec::with_capacity(inner.len() + 2);
4067        out.push(ModelineSeg::filler(" ".repeat(pad)));
4068        out.extend(inner);
4069        out.push(ModelineSeg::filler(" ".repeat(pad)));
4070        return out;
4071    }
4072    // Left is highest-priority: keep as much as fits.
4073    let left = truncate_runs(left, width);
4074    let used_l = runs_width(&left);
4075    // Right gets the remainder after Left + a 1-col separator.
4076    let sep_lr = if used_l > 0 { 1 } else { 0 };
4077    let right = truncate_runs(right, width.saturating_sub(used_l + sep_lr));
4078    let used_r = runs_width(&right);
4079    // Center gets the gap minus a 1-col pad on each abutting side.
4080    let pad_l = if used_l > 0 { 1 } else { 0 };
4081    let pad_r = if used_r > 0 { 1 } else { 0 };
4082    let center_budget = width.saturating_sub(used_l + used_r + pad_l + pad_r);
4083    let center = truncate_runs(center, center_budget);
4084    let used_c = runs_width(&center);
4085
4086    let mut out: Vec<ModelineSeg> = Vec::new();
4087    out.extend(left);
4088    // Region between the left block and the right block (≥ 0 by budget).
4089    let region_w = width - used_l - used_r;
4090    if used_c > 0 {
4091        let offset = (region_w - used_c) / 2;
4092        if offset > 0 {
4093            out.push(ModelineSeg::filler(" ".repeat(offset)));
4094        }
4095        out.extend(center);
4096        let after = region_w - used_c - offset;
4097        if after > 0 {
4098            out.push(ModelineSeg::filler(" ".repeat(after)));
4099        }
4100    } else if region_w > 0 {
4101        out.push(ModelineSeg::filler(" ".repeat(region_w)));
4102    }
4103    out.extend(right);
4104    out
4105}
4106
4107/// DR.3 (decoration-retention): render a Document pane that isn't
4108/// focused. This is now a thin entry point — it builds the per-pane
4109/// [`PaneComposeCtx`] (`is_active: false`, the pane's OWN buffer /
4110/// cursor / scroll / display-line-number map) and a `for_buffer`
4111/// [`FrameView`], then defers to the SAME [`compose_pane_lines`] the
4112/// active pane uses via [`draw_buffer`]. The old parallel inactive
4113/// compose body — a smaller, drifting decoration set keyed on focus —
4114/// is retired; the only inactive-specific behaviour now lives in the
4115/// ctx flags (interaction overlays off, dim opacity on) interpreted
4116/// inside the one shared path. Inactive panes therefore carry the
4117/// FULL buffer-intrinsic decoration set (syntax, semantic tokens,
4118/// inlay hints, diagnostics) dimmed, per the design's §Render
4119/// contract.
4120/// Assemble the compose inputs for ONE pane.
4121///
4122/// The single place a pane's `FrameView` and `PaneComposeCtx` are built, for
4123/// both the focused pane and every other one. There used to be two — an
4124/// `is_active` builder inside `compose_visible_lines` and a second inside
4125/// `draw_inactive_document` — and they disagreed about where a pane's options
4126/// come from: the active one read `ad().option_cache` (the active DOCUMENT),
4127/// the inactive one resolved against the pane's own buffer. That is how a
4128/// focused magit pane came to paint with the file's `number` and lose it again
4129/// on blur.
4130///
4131/// `is_active` survives, but only as what it should have been: a flag for
4132/// INTERACTION state — whose cursor and scroll to read, and whether this pane
4133/// paints a cursorline. Everything that is a property of the buffer being
4134/// painted is resolved from `pane.buffer_id` either way.
4135fn pane_compose_inputs<'a>(
4136    app: &'a App,
4137    pane: &crate::pane::PaneState,
4138    is_active: bool,
4139) -> (FrameView<'a>, PaneComposeCtx) {
4140    // M.4: options for THIS pane's buffer — its own mode stack drives its
4141    // gutter and LSP gates — whether or not it is the focused one.
4142    let view = FrameView::for_buffer(app, pane.buffer_id);
4143    // Per-pane composed→source row map, pulled from the pane's OWN handle: a
4144    // multibuffer pane shows source line numbers whoever is focused, and a
4145    // regular Document returns None (identity numbering). The GPUI peer takes
4146    // it from the same handle for the same reason.
4147    let display_line_numbers = app
4148        .buffers()
4149        .registry
4150        .document_handle(pane.buffer_id)
4151        .and_then(|h| h.display_line_numbers());
4152    // PI.3: a focused pane that is PREVIEWING renders through the unfocused
4153    // path (routed in `draw_panes`) but keeps its cursorline, so the target
4154    // line of an LSP-reference or grep preview stays highlighted. Ordinary
4155    // unfocused panes paint none.
4156    let focused_preview = pane.is_previewing() && app.panes().tree.active().id == pane.id;
4157    // The OPTION is a property of the buffer; whether this pane paints one at
4158    // all is interaction state. Splitting the two is what lets one expression
4159    // serve both paths.
4160    let cursor_line_highlight = (is_active || focused_preview)
4161        && app
4162            .render_state
4163            .load()
4164            .current_line_highlight_for(pane.buffer_id);
4165
4166    // The one genuine difference: the focused pane's cursor and scroll are
4167    // LIVE in `ad()`, while a pane's stashed copy is only written when it
4168    // loses focus — so reading the stash for the active pane would render it
4169    // one focus-change stale.
4170    let ctx = if is_active {
4171        let ad = app.ad();
4172        PaneComposeCtx {
4173            is_active,
4174            pane_id: pane.id,
4175            buffer_id: pane.buffer_id,
4176            cursor_line: ad.cursor.line,
4177            cursor_line_highlight,
4178            scroll: ad.scroll,
4179            leftcol: ad.leftcol,
4180            display_line_numbers,
4181        }
4182    } else {
4183        PaneComposeCtx {
4184            is_active,
4185            pane_id: pane.id,
4186            buffer_id: pane.buffer_id,
4187            cursor_line: pane.cursor.line,
4188            cursor_line_highlight,
4189            scroll: pane.scroll,
4190            leftcol: pane.leftcol,
4191            display_line_numbers,
4192        }
4193    };
4194    (view, ctx)
4195}
4196
4197/// Paint one document pane — focused or not.
4198///
4199/// The unification: `draw_buffer` and `draw_inactive_document` were two
4200/// functions that opened differently, built their compose inputs differently,
4201/// and then called the same `compose_pane_lines`. The duplicated half is the
4202/// half that drifted, and dimming — the thing that actually distinguishes an
4203/// unfocused pane — was never in either of them: it lives inside
4204/// `compose_pane_lines`, keyed on `ctx.is_active`, exactly where a decoration
4205/// belongs.
4206///
4207/// So what is left of `is_active` here is the terminal caret, which only the
4208/// focused pane owns.
4209fn draw_pane_document(
4210    frame: &mut Frame,
4211    area: Rect,
4212    app: &App,
4213    snap: &DocumentSnapshot,
4214    pane: &crate::pane::PaneState,
4215    is_active: bool,
4216) {
4217    // The focused pane paints the frame's already-taken snapshot, so its text
4218    // cannot tear against the cursor and scroll read beside it. Another pane
4219    // has no such snapshot and reads its own handle's.
4220    let owned;
4221    let snap = if is_active {
4222        snap
4223    } else {
4224        let Some(handle) = app.buffers().registry.document_handle(pane.buffer_id) else {
4225            return;
4226        };
4227        owned = handle.snapshot();
4228        &owned
4229    };
4230    let (view, ctx) = pane_compose_inputs(app, pane, is_active);
4231    let lines = compose_pane_lines(&view, snap, area.height as u32, area.width as u32, &ctx);
4232    frame.render_widget(Paragraph::new(lines), area);
4233
4234    if !is_active {
4235        return;
4236    }
4237    // Place the buffer-area cursor only when a minibuffer isn't claiming it.
4238    // In Command (`:`), Search (`/`, `?`) and Prompt the caret lives in the
4239    // bottom row -- handled by `draw_command_or_echo`.
4240    //
4241    // Shared with the popup path above through
4242    // `lattice_host::cursor_shape::minibuffer_owns_caret`, which also adds
4243    // `Prompt` — the local copy this replaces listed only two of the three
4244    // readline surfaces, so a prompt and the pane both placed the caret and
4245    // whichever drew last won.
4246    let ad = app.ad();
4247    let prompt_owns_cursor = lattice_host::cursor_shape::minibuffer_owns_caret(ad.modal);
4248    if !prompt_owns_cursor
4249        && let Some((screen_x, screen_y)) = cursor_screen_position_at(
4250            &view,
4251            snap,
4252            area,
4253            ad.cursor,
4254            ad.scroll,
4255            app.panes().tree.active().id,
4256        )
4257    {
4258        frame.set_cursor_position((screen_x, screen_y));
4259    }
4260}
4261
4262fn draw_command_or_echo(frame: &mut Frame, area: Rect, app: &App) {
4263    // MB.2: the expanded tier-2 mini-buffer band — draw the multi-line
4264    // `*command-line*` buffer across the grown area (`:` prompt on line 0),
4265    // with the caret at the buffer's cursor. Full modal editing (the edit
4266    // path) is unchanged; this is the presentation grown in place.
4267    if app.command_line_expanded() {
4268        let modeline = app.modeline();
4269        let prompt: char = if modeline.search_direction.is_some() {
4270            match modeline.search_direction {
4271                Some(lattice_grammar::SearchDirection::Forward) => '/',
4272                Some(lattice_grammar::SearchDirection::Backward) => '?',
4273                _ => '/',
4274            }
4275        } else {
4276            ':'
4277        };
4278        let raw = modeline.cmdline_full_text.to_string();
4279        let deco = modeline.cmdline_decorations.as_ref();
4280        let cells = app.render_state.load().cells.load_full();
4281        let resolved = &cells.resolved_theme;
4282        let ids = &cells.theme_ids;
4283        let base = TuiStyle::default();
4284        let lines: Vec<Line<'static>> = raw
4285            .split('\n')
4286            .enumerate()
4287            .map(|(i, l)| {
4288                let line_str = if i == 0 {
4289                    format!("{prompt}{l}")
4290                } else {
4291                    l.to_string()
4292                };
4293                if i == 0
4294                    && let Some(d) = deco
4295                    && !d.spans.is_empty()
4296                {
4297                    let mut spans: Vec<Span<'_>> = Vec::with_capacity(d.spans.len() + 2);
4298                    let prompt_span = Span::raw(prompt.to_string());
4299                    let full_first = line_str.clone();
4300                    let first_line = l;
4301                    let prefix_len = prompt.len_utf8();
4302                    let mut pos = 0usize;
4303                    for sp in &d.spans {
4304                        let s = sp.range.start.min(first_line.len());
4305                        let e = sp.range.end.min(first_line.len());
4306                        if s < pos || e < s {
4307                            continue;
4308                        }
4309                        if s > pos {
4310                            spans.push(Span::styled(
4311                                full_first[pos + prefix_len..s + prefix_len].to_string(),
4312                                base,
4313                            ));
4314                        }
4315                        let styled = match lattice_host::ui::theme::resolve_syntax_style(
4316                            resolved, ids, sp.style,
4317                        )
4318                        .fg
4319                        .map(crate::theme::host_color_to_ratatui)
4320                        {
4321                            Some(c) => base.fg(c),
4322                            None => base,
4323                        };
4324                        spans.push(Span::styled(
4325                            full_first[s + prefix_len..e + prefix_len].to_string(),
4326                            styled,
4327                        ));
4328                        pos = e;
4329                    }
4330                    if pos < first_line.len() {
4331                        spans.push(Span::styled(
4332                            full_first[pos + prefix_len..].to_string(),
4333                            base,
4334                        ));
4335                    }
4336                    // Prepend the prompt character (":" / "/" / "?") as
4337                    // the first span, BEFORE the decorated content. The
4338                    // decoration spans are byte ranges into the raw line
4339                    // text and start at prefix_len, so they never include
4340                    // the prompt. Without this, the command line ":comp"
4341                    // renders as "comp" when decorations are present.
4342                    spans.insert(0, prompt_span);
4343                    return Line::from(spans);
4344                }
4345                Line::from(line_str)
4346            })
4347            .collect();
4348        frame.render_widget(Paragraph::new(lines), area);
4349        // Caret: buffer (line, byte) → band cell; the `:` prompt adds one
4350        // column on line 0. Clamp inside the band.
4351        let cur = app.ad().cursor;
4352        let lead: u16 = if cur.line == 0 { 1 } else { 0 };
4353        let cy = area.y.saturating_add(cur.line as u16);
4354        if cy < area.y.saturating_add(area.height) {
4355            let cx = area
4356                .x
4357                .saturating_add((cur.byte as u16).saturating_add(lead))
4358                .min(area.x.saturating_add(area.width.saturating_sub(1)));
4359            frame.set_cursor_position((cx, cy));
4360        }
4361        return;
4362    }
4363    if matches!(app.ad().modal, ModalState::Command) {
4364        // MB.1: ":<typed>" with the caret at the `*command-line*` buffer's
4365        // cursor (byte offset into the single line) so mid-line editing
4366        // shows where the user is typing — not pinned to the end.
4367        let line = app.command_line();
4368        let caret_byte = (app.ad().cursor.byte as usize).min(line.len());
4369        let cursor_col = area
4370            .x
4371            .saturating_add((1 + caret_byte).min(area.width as usize) as u16);
4372
4373        // Visual hints. Two non-mutually-exclusive layers show
4374        // after the cursor in a dim style:
4375        //   1. `auto_submit_after_chord` (missing-arg prompt
4376        //      armed by `:describe-key<CR>`): show a clear
4377        //      "press a chord" cue so the user knows the next
4378        //      keypress runs the lookup.
4379        //   2. Otherwise, if chord-capture is just active
4380        //      (cursor in a `Chord` arg slot), show a softer
4381        //      `(chord)` tag so the user knows the cmdline is
4382        //      consuming raw key events as chord tokens.
4383        // Slice 3c.final.B.7: auto_submit hint via published
4384        // `modeline()` sub-state (wait-free Arc clone, no actor
4385        // round-trip).
4386        let hint: Option<&'static str> = if app.modeline().auto_submit_hint {
4387            Some("press a chord")
4388        } else if app.chord_capture_active() {
4389            Some("(chord)")
4390        } else {
4391            None
4392        };
4393
4394        // MB.4: draw the `:` prompt then the line, coloured per the
4395        // published decoration spans (syntax highlight). The spans are
4396        // produced off the render thread; here we only map each token's
4397        // `Style` to its themed colour and slice the line.
4398        let cells = app.render_state.load().cells.load_full();
4399        let resolved = &cells.resolved_theme;
4400        let ids = &cells.theme_ids;
4401        let modeline = app.modeline();
4402        let deco = modeline.cmdline_decorations.as_ref();
4403        let base = TuiStyle::default();
4404        let mut spans: Vec<Span<'_>> = vec![Span::raw(":".to_string())];
4405        match deco {
4406            Some(d) if !d.spans.is_empty() => {
4407                let mut pos = 0usize;
4408                for sp in &d.spans {
4409                    let s = sp.range.start.min(line.len());
4410                    let e = sp.range.end.min(line.len());
4411                    if s < pos || e < s || !line.is_char_boundary(s) || !line.is_char_boundary(e) {
4412                        continue;
4413                    }
4414                    if s > pos {
4415                        spans.push(Span::styled(line[pos..s].to_string(), base));
4416                    }
4417                    let styled = match lattice_host::ui::theme::resolve_syntax_style(
4418                        resolved, ids, sp.style,
4419                    )
4420                    .fg
4421                    .map(crate::theme::host_color_to_ratatui)
4422                    {
4423                        Some(c) => base.fg(c),
4424                        None => base,
4425                    };
4426                    spans.push(Span::styled(line[s..e].to_string(), styled));
4427                    pos = e;
4428                }
4429                if pos < line.len() {
4430                    spans.push(Span::styled(line[pos..].to_string(), base));
4431                }
4432            }
4433            _ => spans.push(Span::raw(line.clone())),
4434        }
4435        if let Some(text) = hint {
4436            spans.push(Span::styled(
4437                text,
4438                TuiStyle::default()
4439                    .fg(Color::DarkGray)
4440                    .add_modifier(Modifier::ITALIC),
4441            ));
4442        }
4443        // Vertico-style count hint when the completion popup is
4444        // open: `(selected/total)` faintly trailing the cmdline.
4445        // Mirrors the picker prompt's `(n/m)` so both surfaces
4446        // read the same.
4447        if let Some(state) = app.completion().state.as_deref()
4448            && !state.candidates.is_empty()
4449        {
4450            spans.push(Span::styled(
4451                format!("  ({}/{})", state.selected + 1, state.candidates.len()),
4452                TuiStyle::default().fg(Color::DarkGray),
4453            ));
4454        }
4455        // MB.4: a live error indicator (unknown command / bad args) wins
4456        // the trailing slot; otherwise a dim parameter hint from the
4457        // command's ArgSpec — suppressed while the completion popup is up
4458        // to avoid clutter.
4459        if let Some(d) = deco {
4460            if let Some(err) = &d.error {
4461                let err_color = lattice_host::ui::theme::resolve_syntax_style(
4462                    resolved,
4463                    ids,
4464                    lattice_cells::style::Style::DiagnosticError,
4465                )
4466                .fg
4467                .map(crate::theme::host_color_to_ratatui)
4468                .unwrap_or(Color::Red);
4469                spans.push(Span::styled(
4470                    format!("  {err}"),
4471                    TuiStyle::default()
4472                        .fg(err_color)
4473                        .add_modifier(Modifier::ITALIC),
4474                ));
4475            } else if let Some(ph) = &d.param_hint {
4476                let popup_open = app
4477                    .completion()
4478                    .state
4479                    .as_deref()
4480                    .is_some_and(|s| !s.candidates.is_empty());
4481                if !popup_open {
4482                    spans.push(Span::styled(
4483                        format!("  {ph}"),
4484                        TuiStyle::default()
4485                            .fg(Color::DarkGray)
4486                            .add_modifier(Modifier::ITALIC),
4487                    ));
4488                }
4489            }
4490        }
4491        let para = Paragraph::new(Line::from(spans));
4492        frame.render_widget(para, area);
4493        frame.set_cursor_position((cursor_col, area.y));
4494        return;
4495    }
4496
4497    if let ModalState::Search(direction) = app.ad().modal {
4498        let lead = match direction {
4499            SearchDirection::Forward => '/',
4500            SearchDirection::Backward => '?',
4501        };
4502        // Slice 3c.final.B.7: search pattern via published
4503        // `modeline()` sub-state (Arc<str> clone, wait-free).
4504        let modeline = app.modeline();
4505        let pattern: &str = modeline.search_pattern.as_deref().unwrap_or("");
4506        let prompt = format!("{lead}{pattern}");
4507        let para = Paragraph::new(Line::from(prompt.clone()));
4508        frame.render_widget(para, area);
4509        let col = area
4510            .x
4511            .saturating_add(prompt.len().min(area.width as usize) as u16);
4512        frame.set_cursor_position((col, area.y));
4513        return;
4514    }
4515
4516    // MG.51: the prompt line (`Effect::OpenPrompt` — magit's branch
4517    // checkout, rename, tag name, …).
4518    //
4519    // Without this arm the row fell through to the echo below, which
4520    // draws the LABEL and nothing else: `open_prompt_line` puts the
4521    // label in the echo (`set_message`) and the typed text in the
4522    // `*prompt-line*` buffer, and only the label was ever drawn. Keys
4523    // reached the buffer — submitting worked — so the prompt read as a
4524    // dead input that mysteriously did the right thing.
4525    //
4526    // The prompt buffer IS the focused editing document (see
4527    // `focus_editing_buffer`), so its first line is the typed text and
4528    // `ad().cursor` is the caret inside it, exactly as the `:` line
4529    // reads its own buffer above.
4530    if matches!(app.ad().modal, ModalState::Prompt) {
4531        let messages = app.messages();
4532        let label: String = messages
4533            .last
4534            .as_deref()
4535            .map(|m| m.text.clone())
4536            .unwrap_or_default();
4537        let typed = app.modeline().prompt_text.to_string();
4538        let caret_byte = (app.ad().cursor.byte as usize).min(typed.len());
4539        let para = Paragraph::new(Line::from(format!("{label}{typed}")));
4540        frame.render_widget(para, area);
4541        // Columns, not bytes: the label is user-facing text and may hold
4542        // multi-byte characters.
4543        let col_of = |s: &str, upto: usize| s[..upto].chars().count();
4544        let col = area.x.saturating_add(
4545            (label.chars().count() + col_of(&typed, caret_byte)).min(area.width as usize) as u16,
4546        );
4547        frame.set_cursor_position((col, area.y));
4548        return;
4549    }
4550
4551    // Slice 3c.final.B.7: last message via published `messages()`
4552    // sub-state — wait-free Arc clone.
4553    let messages = app.messages();
4554    let Some(msg) = messages.last.as_deref() else {
4555        // Nothing to show -- render nothing (the row stays blank).
4556        return;
4557    };
4558    let level = msg.level;
4559    let style = match level {
4560        // Trace + Debug are below the default messages.filter
4561        // threshold and don't normally surface to the echo
4562        // area; if a record at one of these levels does reach
4563        // the echo, render dim to match `*messages*` convention.
4564        EchoLevel::Trace | EchoLevel::Debug => TuiStyle::default().add_modifier(Modifier::DIM),
4565        EchoLevel::Info => TuiStyle::default(),
4566        EchoLevel::Warn => TuiStyle::default().fg(Color::Yellow),
4567        EchoLevel::Error => TuiStyle::default()
4568            .fg(Color::Red)
4569            .add_modifier(Modifier::BOLD),
4570    };
4571    let para = Paragraph::new(Line::from(vec![Span::styled(msg.text.clone(), style)]));
4572    frame.render_widget(para, area);
4573}
4574
4575/// Produce the visible buffer lines as `ratatui::text::Line`s, including
4576/// gutter (line numbers), tab expansion, and styled spans pulled from
4577/// the canonical `DisplayMatrix` (rebuilt off the UI thread by the
4578/// cells worker). display-line B4.2: the old `app.editor.visible_highlights`
4579/// span cache + `App::refresh_highlights` prime were deleted.
4580///
4581/// Spans are owned (`Cow::Owned`) so the returned `Line`s outlive the
4582/// document text we slice out of for this frame. One alloc per visible line
4583/// per frame -- negligible at terminal sizes (typically 50-100 lines).
4584/// DR.3 (decoration-retention): the per-pane inputs that vary
4585/// between the focused pane and inactive panes, so ONE compose path
4586/// ([`compose_pane_lines`]) serves both. Buffer-intrinsic
4587/// decorations are sourced by `buffer_id` — syntax via the per-pane
4588/// `DisplayMatrix` keyed by `pane_id`, plus semantic tokens, inlay
4589/// hints, and diagnostic underlines/severity from their per-buffer
4590/// caches — so they paint on inactive panes too (dimmed). The state
4591/// gated on `is_active` is exactly *interaction* (cursor-line,
4592/// visual selection, hlsearch, current match, ghost text, substitute
4593/// preview) plus the layout that still reads active-doc-scoped state
4594/// (closed-fold skipping/summary, soft-wrap) — documented seams that
4595/// lift to per-pane when per-buffer fold + option state lands. See
4596/// docs/dev/architecture/decoration-retention.md §Render contract.
4597pub(crate) struct PaneComposeCtx {
4598    pub is_active: bool,
4599    pub pane_id: crate::pane::PaneId,
4600    pub buffer_id: crate::buffers::BufferId,
4601    pub cursor_line: u32,
4602    /// PI.3: whether to paint the cursor-line background on
4603    /// [`Self::cursor_line`] for THIS pane. Decouples cursorline from
4604    /// "is this the active document": the active pane sets it from
4605    /// `option_cache.current_line_highlight`; a focused *preview* pane sets
4606    /// it from the displayed buffer's `CursorLine` (so an LSP-reference /
4607    /// grep preview keeps its target line highlighted); every other
4608    /// inactive pane leaves it `false`.
4609    pub cursor_line_highlight: bool,
4610    pub scroll: u32,
4611    /// Horizontal scroll: first visible display column. Drives the
4612    /// body's left clip when `wrap` is off; ignored under wrap.
4613    pub leftcol: u32,
4614    pub display_line_numbers: Option<Arc<[u32]>>,
4615}
4616
4617pub fn compose_visible_lines(
4618    app: &App,
4619    snap: &DocumentSnapshot,
4620    height: u32,
4621    width: u32,
4622) -> Vec<Line<'static>> {
4623    // Audit slice 7 / M2: snapshot the App's render-relevant
4624    // state once at chain entry. Helpers below read through
4625    // `view` rather than `app` for `folds` / `visible_highlights`
4626    // / `show_line_numbers` so a multi-thread renderer (GPUI,
4627    // future Web) can't see a torn mid-render view if a
4628    // concurrent input event mutates the underlying App fields.
4629    // Through the SAME builder the painter uses, with the active pane as its
4630    // subject. This function is now a named shortcut for "compose the focused
4631    // pane" rather than a second construction path — which is what it was, and
4632    // what let it drift onto the active DOCUMENT's options while its inactive
4633    // counterpart resolved per buffer.
4634    let pane = *app.panes().tree.active();
4635    let (view, ctx) = pane_compose_inputs(app, &pane, true);
4636    compose_pane_lines(&view, snap, height, width, &ctx)
4637}
4638
4639/// DR.3: the single compose path for every Document pane. The active
4640/// pane calls it via [`compose_visible_lines`] with `is_active =
4641/// true` + a `from_app` view; inactive panes call it from
4642/// [`draw_pane_content`] with `is_active = false` + a `for_buffer`
4643/// view. The body the active pane produces is byte-identical to the
4644/// pre-merge path (pinned by `dr3_active_pane_compose_characterization`):
4645/// for the active pane `ctx.buffer_id == app.ad().document_buffer_id`,
4646/// so the per-buffer decoration sourcing resolves the same id.
4647/// Columns the gutter occupies before the first text cell.
4648///
4649/// Extracted so the compose loop, the caret walk and MO.2's mouse hit
4650/// map all read ONE expression. Four inputs feed it (line count,
4651/// `number`, the trailing-pad rule, the centring pad) and each extra
4652/// copy is a chance for the body, the caret and a click to land in three
4653/// different columns — which is the bug class the `2`-vs-`GUTTER_TRAILING_PAD`
4654/// comment below records, from back when there were two copies.
4655fn gutter_cols(view: &FrameView<'_>, total_lines: u32) -> u32 {
4656    (if view.show_line_numbers {
4657        gutter_width(total_lines)
4658    } else {
4659        // `:set nonumber` still paints a gutter — `format_gutter_cell`
4660        // ALWAYS emits its three trailing cells (separator + fold-glyph
4661        // slot + gap), so the width must cover them. `2` here made
4662        // `saturating_sub(label_cols + 3)` clamp to zero and the cell
4663        // came out 3 cells wide against a declared width of 2: the body
4664        // painted one column right of where every arithmetic consumer
4665        // thought it was, and the caret landed a cell to its LEFT — on
4666        // the last character instead of after it. Same class as the `↪`
4667        // fix in `format_gutter_cell`, opposite direction.
4668        GUTTER_TRAILING_PAD
4669    })
4670    // DB.4: horizontal centring widens the gutter (content + cursor shift
4671    // right); 0 for non-centred buffers.
4672    + view.content_left_pad
4673}
4674
4675/// MO.2: the column a pane's text starts at, for the mouse hit map.
4676///
4677/// The gutter plus the sign columns — the same two terms the compose
4678/// loop subtracts from the pane width to get its body width, read
4679/// through the same helpers, so a click and a glyph agree on where
4680/// column 0 is.
4681fn pane_text_left(app: &App, pane: &crate::pane::PaneState, is_active: bool, rect: Rect) -> u16 {
4682    let Some(handle) = app.buffers().registry.document_handle(pane.buffer_id) else {
4683        return 0;
4684    };
4685    let total_lines = handle.snapshot().buffer.content_line_count();
4686    let (view, _ctx) = pane_compose_inputs(app, pane, is_active);
4687    let cols = gutter_cols(&view, total_lines) + sign_columns_width(&view);
4688    // Clamped into the pane: a gutter wider than the pane cannot leave a
4689    // text origin outside it, and an out-of-range `text_left` would make
4690    // every cell in the pane read as gutter.
4691    cols.min(rect.width as u32) as u16
4692}
4693
4694pub(crate) fn compose_pane_lines(
4695    view: &FrameView<'_>,
4696    snap: &DocumentSnapshot,
4697    height: u32,
4698    width: u32,
4699    ctx: &PaneComposeCtx,
4700) -> Vec<Line<'static>> {
4701    let app = view.app;
4702    // msg-mode.3: when the active pane's major mode is
4703    // `messages-mode`, every visible line is rendered through
4704    // a level-aware path instead of the normal
4705    // syntax-highlight pipeline. The legacy spans path is
4706    // bypassed because the level styles aren't expressible as
4707    // `lattice_syntax::Style` enum variants (which is the
4708    // unit the spans pipeline carries).
4709    // DR.3: key the messages-mode body path on THIS pane's buffer,
4710    // not the globally-active one, so an inactive messages pane still
4711    // renders level-aware. For the active pane `ctx.buffer_id` IS the
4712    // active buffer (byte-identical).
4713    // Slice 3c.final.B.11: active_modes via published `modes()`
4714    // sub-state — wait-free Arc-bump lookup, no actor round-trip.
4715    // §5.6.8 contract: one snapshot per frame, used for everything.
4716    // The snapshot was loaded by the runtime via
4717    // `app.editor.snapshot_cache.load_arc()` and threaded through.
4718    // §8.2 hot path: never materialise the whole buffer -- iterate
4719    // ropey's line API and pull only the visible window. A 100MB
4720    // log file should cost the same per-frame as a 100-line file.
4721    let total_lines = snap.buffer.content_line_count();
4722    let gutter_w = gutter_cols(view, total_lines);
4723    // Severity column is prepended (Phase 4.1.d.iii); reserve
4724    // one cell so buffer width stays correct. D.3.d.1: diff
4725    // sign column sits between severity and gutter, costs one
4726    // more cell — reserved unconditionally so the layout
4727    // doesn't shift on `:diff` / `:diffoff`.
4728    let buffer_w = width
4729        .saturating_sub(gutter_w)
4730        .saturating_sub(sign_columns_width(view));
4731
4732    // Compute visual selection range once (instead of per line).
4733    // DR.3: visual selection is interaction state — present only on
4734    // the focused pane.
4735    let visual_range = if ctx.is_active {
4736        visual_selection_range(app)
4737    } else {
4738        None
4739    };
4740    let block = if ctx.is_active {
4741        visual_block_extents(app)
4742    } else {
4743        None
4744    };
4745
4746    // Perf plan B.2 slice B.2.b: load the worker's per-row pre-
4747    // bucketed static-overlay quads once for the whole frame
4748    // instead of walking `app.ad().all_matches` and
4749    // `substitute_preview.matches` per row × per match. Active
4750    // pane only — when the bucket is empty (boot before first
4751    // recompute or non-active pane), per-row code falls back to
4752    // the legacy walk. DocHighlight stays on the per-row walk
4753    // because the TUI's per-quad style is keyed off
4754    // `DocumentHighlightKind` which the bucket doesn't carry.
4755    // DR.3: the per-row hlsearch/substitute bucket is the ACTIVE
4756    // pane's (published from `app.ad()`), and both layers it feeds are
4757    // interaction state — only loaded for the focused pane. Inactive
4758    // panes get an empty bucket and skip the hlsearch/substitute
4759    // blocks below.
4760    let active_overlay_quads_for_frame = if ctx.is_active {
4761        let rs = app.render_state.load();
4762        rs.syntax.static_overlay_quads.load_full()
4763    } else {
4764        std::sync::Arc::new(lattice_host::render_state::StaticOverlayQuads::default())
4765    };
4766    let frame_scroll = ctx.scroll;
4767
4768    // Build the visible-buffer-line ordering: starting from `scroll`,
4769    // skip lines inside closed folds, taking up to `height` entries.
4770    // Shared with the GPUI peer (`lattice_host::folds::
4771    // visible_source_lines`) — this walk used to live inline here and
4772    // GPUI grew its own source-line-windowed version that under-filled
4773    // the pane whenever a fold closed. One implementation, one
4774    // behaviour.
4775    //
4776    // CV.2: bounded in CONTENT space. `total_lines` above is ropey's
4777    // raw count and stays that way — it sizes the gutter, which must
4778    // keep matching the host's `cells_worker::gutter_cols` — but a
4779    // file's terminating `\n` must not earn a painted row.
4780    let visible: Vec<u32> = lattice_host::folds::visible_source_lines(
4781        &view.fold_index,
4782        ctx.scroll,
4783        height,
4784        snap.buffer.content_line_count(),
4785    );
4786
4787    // D.3.b.1 (2026-05-29): snapshot the virtual-row matrix
4788    // so we can interleave Above / Below rows around document
4789    // lines. Lock-free `Arc` clone; the matrix lives on the
4790    // RenderState snapshot already loaded by callers.
4791    //
4792    // K.4.6 c.ii (2026-06-02, FIXED 2026-06-02): each pane reads ITS
4793    // OWN virtual-rows matrix. DR.3 keys it on `ctx.pane_id` (was
4794    // `active_pane_id`) so the one compose path serves both the
4795    // focused pane and inactive panes correctly — for the active pane
4796    // `ctx.pane_id` IS the active pane (byte-identical). Drop the
4797    // boot-seeded fallback: falling back to `rs.virtual_rows.matrix`
4798    // leaks the last-active-pane's headers into panes (e.g. a file
4799    // pane) that have no providers.
4800    let virtual_rows_matrix = {
4801        let rs = view.app.render_state.load();
4802        rs.virtual_rows
4803            .matrix_for_pane(ctx.pane_id)
4804            .map(|cell| cell.load_full())
4805            .unwrap_or_else(|| std::sync::Arc::new(lattice_cells::VirtualRowMatrix::empty()))
4806    };
4807    // Sticky rows occupy the top of the pane, but they are NOT paid for
4808    // here: the host reserves them once in the scroll budget
4809    // (`ensure_cursor_visible`'s `effective_height`), and the fill loop
4810    // below counts the sticky pre-pass rows against `height`. Shrinking
4811    // `visible` as well would reserve the row twice and silently delete
4812    // the last document line — which, for a buffer whose final line is an
4813    // editable prompt (the ACP conversation buffer), is always the prompt.
4814    let body_col_width = buffer_w;
4815    // W.4 (soft-wrap): `:set wrap`. When on, the per-line body is
4816    // kept at full width (truncation below is skipped) so
4817    // `split_body_into_segments` can wrap it into `body_col_width`
4818    // columns; when off, the body is clipped to the viewport
4819    // (horizontal-scroll behaviour, unchanged).
4820    // Soft-wrap is a global option today; `view.wrap_lines` is the
4821    // resolver seam — `FrameView::from_app` and `::for_buffer` both
4822    // read the global `option_cache.wrap_lines` for now. When
4823    // buffer-local options land, `for_buffer` resolves the
4824    // buffer-local override with the global as default (emacs
4825    // buffer-local pattern) and this site stays unchanged.
4826    let wrap_on = view.wrap_lines;
4827    let body_trunc_w = if wrap_on { u32::MAX } else { buffer_w };
4828    // Horizontal scroll (HS.1): with wrap off, drop the first
4829    // `leftcol` display columns of each body line before clipping to
4830    // the body width — the cursor-follow clamp (`leftcol`) keeps the
4831    // cursor inside `[leftcol, leftcol + buffer_w)`. Under wrap the
4832    // host pins `leftcol = 0`, so this is a no-op there regardless.
4833    let leftcol_off = if wrap_on { 0 } else { ctx.leftcol };
4834    // DR.3: composed→source row map + cursor line come from the pane
4835    // ctx — the active pane passes `app.ad()`'s values, inactive panes
4836    // pass their own handle's mapping + stashed cursor. Cloned once
4837    // per frame (Arc bump).
4838    let active_display_line_numbers = ctx.display_line_numbers.clone();
4839    let active_cursor_line = ctx.cursor_line;
4840    // DR.3: load THIS pane's retained `DisplayMatrix` ONCE per frame,
4841    // keyed by `pane_id`, instead of the top-level
4842    // `cells.display_matrix` per line. For the active pane this entry
4843    // is published from the same `p.display_matrix.clone()` that backs
4844    // the top-level, so the body is byte-identical; inactive panes get
4845    // their own buffer's matrix (no focus-keyed source branch — the
4846    // DR.3 "one producer, one path" payoff). Empty fallback (boot /
4847    // transient publish gap / a pane with no matrix entry) → the
4848    // per-line plain-text fallback. Hoisting the lookup out of the
4849    // loop keeps the body O(viewport) with no per-line HashMap probe
4850    // (paramount #1). `cells_rs` is held for the whole function so
4851    // `display_theme` can borrow its `theme`.
4852    let cells_rs = view.app.render_state.load().cells.load_full();
4853    let display_matrix = cells_rs
4854        .display_matrix_for_pane(ctx.pane_id)
4855        .map(|cell| cell.load_full())
4856        .unwrap_or_else(|| {
4857            std::sync::Arc::new(lattice_host::display_matrix::DisplayMatrix::empty())
4858        });
4859    // IG.3: this pane's indentation guides, loaded once per frame beside
4860    // its `DisplayMatrix` and for the same reason — one HashMap probe
4861    // per pane, not one per line (paramount #1). The layer is published
4862    // in the same worker pass as the matrix above, so it needs no
4863    // staleness check of its own: if the matrix is current, so is this.
4864    let indent_guides = cells_rs
4865        .indent_guides_for_pane(ctx.pane_id)
4866        .map(|cell| cell.load_full())
4867        .unwrap_or_else(|| std::sync::Arc::new(lattice_host::indent_guides::IndentGuides::empty()));
4868    // TC.3b: this pane's pinned context strip, loaded once per frame for the
4869    // same reason as the guides above — one map probe per pane. Published in
4870    // the same worker pass as the matrix, so it needs no staleness check.
4871    let sticky_context = cells_rs
4872        .sticky_context_for_pane(ctx.pane_id)
4873        .map(|cell| cell.load_full())
4874        .unwrap_or_else(|| {
4875            std::sync::Arc::new(lattice_host::sticky_context::StickyContext::empty())
4876        });
4877    // The guide glyph and the two styles, resolved once. The active
4878    // block is picked per frame from the cursor row the pane already
4879    // holds — that is what keeps a cursor move off the worker entirely
4880    // and the highlight free of lag.
4881    let indent_guide_style = IndentGuideStyle::resolve(
4882        view.app,
4883        &indent_guides,
4884        ctx.cursor_line,
4885        &cells_rs.resolved_theme,
4886        &cells_rs.theme_ids,
4887    );
4888    // T.5.b: the body-compose path resolves syntax styles through
4889    // `cells_rs.resolved_theme` + `cells_rs.theme_ids` (the resolved
4890    // table) rather than the retired `Theme::syntax_style`.
4891    // T.6: the search / selection / doc-highlight / substitute / inlay
4892    // overlay stylers read the same resolved table. Bind once here
4893    // (`cells_rs` is held for the whole fn) so each call is an O(1)
4894    // array index, not a per-overlay name lookup (paramount #1).
4895    let overlay_resolved = &cells_rs.resolved_theme;
4896    let overlay_ids = &cells_rs.theme_ids;
4897    // W.4.t.2: the whitespace stamp a matrix built RIGHT NOW would
4898    // carry. The per-line body compose below compares the retained
4899    // matrix's stamp against it — see the `display_stale` comment. One
4900    // lookup per pane per frame (O(#panes)), hoisted out of the line
4901    // loop like every other per-frame read here (paramount #1).
4902    let current_whitespace_version = cells_rs.whitespace_version_for_pane(ctx.pane_id);
4903    // Fold-marker colours, resolved once per pane from the theme
4904    // (`gutter.fold.open` / `gutter.fold.closed`). Muted by cross-editor
4905    // convention; the GPUI peer resolves the same two elements. `DarkGray`
4906    // (the historical gutter tone) is the fallback when a theme leaves the
4907    // element unset, so the marker never vanishes.
4908    let fold_colors = FoldColors {
4909        open: overlay_resolved
4910            .get(overlay_ids.gutter_fold_open)
4911            .fg
4912            .map(crate::theme::host_color_to_ratatui)
4913            .unwrap_or(Color::DarkGray),
4914        closed: overlay_resolved
4915            .get(overlay_ids.gutter_fold_closed)
4916            .fg
4917            .map(crate::theme::host_color_to_ratatui)
4918            .unwrap_or(Color::DarkGray),
4919    };
4920    // The ` ⋯ N lines` trailer's tone, from `gutter.fold.summary`. Was a
4921    // literal `Color::DarkGray` here — registering it is what lets the
4922    // GPUI peer paint the SAME trailer instead of inventing its own.
4923    let fold_summary_color = overlay_resolved
4924        .get(overlay_ids.gutter_fold_summary)
4925        .fg
4926        .map(crate::theme::host_color_to_ratatui)
4927        .unwrap_or(Color::DarkGray);
4928    // MO.4.a: gutter-decoration pre-loop. Walk active modes for this
4929    // pane's buffer once per frame; accumulate GutterDecoration
4930    // contributions into per-line maps. Replaces per-line RenderState
4931    // reads from render_diff_sign_cell / render_diagnostic_severity_cell
4932    // — both now read the maps, not the render-state directly.
4933    // SG.4b: ONE map per gutter column, indexed by column position. Nothing
4934    // here knows what a diagnostic or a hunk mark is any more — they are signs
4935    // whose definitions name a column, exactly like a plugin's.
4936    let sign_gutter: Vec<std::collections::HashMap<u32, lattice_mode::SignId>> = {
4937        use lattice_mode::{DecorationCtx, GutterDecoration, ServiceRegistry, SignId};
4938        let mut services = ServiceRegistry::new();
4939        let rs_deco = app.render_state.load();
4940        // D-fix.3b: per-pane gutter signs — register THIS pane's buffer's
4941        // sign map (proposed→current-side, baseline→baseline-side), so both
4942        // panes of a side-by-side diff show gutter signs, not just the active
4943        // one. `None` (no session for this buffer) → no signs.
4944        if let Some(sign_map) = rs_deco.diff.sign_maps.get(&ctx.buffer_id) {
4945            services.register(lattice_host::diff::mode::DiffDecorationData {
4946                sign_map: sign_map.clone(),
4947            });
4948        }
4949        // LspDiagnosticsData: gated on lsp_diagnostics_enabled (M.5.6).
4950        if view.lsp_diagnostics_enabled {
4951            let diagnostics = app
4952                .buffer_uri(ctx.buffer_id)
4953                .and_then(|u| rs_deco.diagnostics.layer.diagnostics_arc(&u));
4954            services.register(lattice_lsp::modes::LspDiagnosticsData { diagnostics });
4955        }
4956        // CM.3c: inject the `*compilation*` buffer's severity index. The
4957        // producer scans off-thread (in the compilation drain) and the host
4958        // snapshots the per-buffer index into `render_state.compilation_severity`;
4959        // here the renderer only reads the slot for this pane's buffer and
4960        // registers the carrier — no `lattice-compilation` dependency, no
4961        // paint-time scan. `CompilationMode::gutter_decorations` reads it and
4962        // emits `Severity` marks through the SAME gutter column as LSP.
4963        if let Some(entries) = rs_deco.compilation_severity.get(&ctx.buffer_id) {
4964            services.register(lattice_mode::CompilationSeverityData {
4965                entries: entries.clone(),
4966            });
4967        }
4968        // SG.4b: the interned built-in sign ids, so a producer emitting a
4969        // mark per visible line names it by field rather than by string.
4970        services.register(rs_deco.signs.builtin);
4971        let deco_ctx = DecorationCtx::new(ctx.buffer_id, &services);
4972        let modes_rs = rs_deco.modes.clone();
4973        // SG.2b: one cell holds one sign, so contention is resolved HERE,
4974        // as placements arrive, rather than by whoever paints last.
4975        // `winning_sign` breaks equal priorities on name, so the glyph a
4976        // line shows does not depend on which producer the mode walk
4977        // reached first — or on a `HashMap` reseeding between runs.
4978        let signs_rs = rs_deco.signs.clone();
4979        let mut columns: Vec<std::collections::HashMap<u32, SignId>> =
4980            vec![Default::default(); lattice_mode::BUILTIN_SIGN_COLUMNS.len()];
4981        fn place_sign(
4982            registry: &lattice_mode::SignRegistry,
4983            columns: &mut [std::collections::HashMap<u32, SignId>],
4984            line: u32,
4985            sign: SignId,
4986        ) {
4987            // A retired id resolves to nothing and must not displace a live
4988            // sign on the same line — SG.1 retires rather than reuses
4989            // precisely so a stale placement paints nothing.
4990            let Some(incoming) = registry.get(sign) else {
4991                return;
4992            };
4993            // A definition naming a column the host does not paint lands in
4994            // the leftmost one rather than vanishing — the `gutter.sign`
4995            // principle, where the failure that loses the information
4996            // entirely is the worst one available.
4997            let col = lattice_mode::BUILTIN_SIGN_COLUMNS
4998                .iter()
4999                .position(|c| *c == incoming.column)
5000                .unwrap_or(0);
5001            let map = &mut columns[col];
5002            let held = map.get(&line).and_then(|id| registry.get(*id));
5003            let takes_it = match held {
5004                Some(held) => {
5005                    std::sync::Arc::ptr_eq(lattice_mode::winning_sign(held, incoming), incoming)
5006                }
5007                None => true,
5008            };
5009            if takes_it {
5010                map.insert(line, sign);
5011            }
5012        }
5013        if let Some(active) = modes_rs.map.get(&ctx.buffer_id) {
5014            let registry = &modes_rs.mode_registry;
5015            let mut all_ids: Vec<lattice_mode::ModeId> = Vec::new();
5016            if let Some(major) = active.major() {
5017                all_ids.push(major);
5018            }
5019            all_ids.extend_from_slice(active.minors());
5020            for id in all_ids {
5021                if let Some(mode) = registry.get(id) {
5022                    for deco in mode.gutter_decorations(&deco_ctx) {
5023                        match deco {
5024                            GutterDecoration::Sign { line, sign } => {
5025                                place_sign(&signs_rs.registry, &mut columns, line, sign);
5026                            }
5027                        }
5028                    }
5029                }
5030            }
5031        }
5032        // PL8.E: merge WASM plugin gutter decorations (host-cached, read
5033        // wait-free) into the SAME partition. Never runs WASM at paint time —
5034        // the producer wrote this cache off the render path; here we only read
5035        // it. Identical partition as the native mode walk above, so plugin marks
5036        // paint through the identical glyph/style mapping downstream.
5037        {
5038            use lattice_host::per_buffer_cache::PerBufferCacheExt;
5039            if let Some(cache) = rs_deco.wasm_gutter_decorations.get_for(ctx.buffer_id) {
5040                for deco in &cache.decorations {
5041                    match deco {
5042                        GutterDecoration::Sign { line, sign } => {
5043                            place_sign(&signs_rs.registry, &mut columns, *line, *sign);
5044                        }
5045                    }
5046                }
5047            }
5048        }
5049        columns
5050    };
5051    let mut out: Vec<Line<'static>> = Vec::with_capacity(height as usize);
5052    // MO.2: what each painted row came from, recorded in lockstep with
5053    // `out` so the mouse can invert the compose loop rather than
5054    // re-implement it. `None` is a row mirroring no source line — a
5055    // virtual row, or the `~` filler past the end of the buffer. Pushed
5056    // beside EVERY `out.push` below; the two must stay the same length
5057    // or every click past the divergence lands on the wrong line, which
5058    // is why the tail asserts it.
5059    let mut row_src: Vec<Option<lattice_host::mouse::RowOrigin>> =
5060        Vec::with_capacity(height as usize);
5061    // Sticky pre-pass: render fixed-top rows before the scrollable content.
5062    // These are excluded from virtual_rows_at so they don't double-paint.
5063    for vrow in virtual_rows_matrix.sticky_rows() {
5064        if (out.len() as u32) >= height {
5065            break;
5066        }
5067        out.push(render_virtual_row(view, vrow, gutter_w, body_col_width));
5068        row_src.push(None);
5069    }
5070    // TC.3b: the pinned context strip, painted AFTER the matrix's sticky rows
5071    // and never instead of them. That ordering is the whole contract — the
5072    // headerline keeps row 0 whatever it is showing, and context reaches the
5073    // top of the pane only when there is no headerline to displace. It falls
5074    // out of appending rather than needing a rank field or any negotiation
5075    // between the two producers.
5076    //
5077    // Rows are adapted to `VirtualRow` and painted by `render_virtual_row`
5078    // rather than given their own painter: two implementations of "paint a
5079    // pinned row of cells" would drift, and the cells here are already
5080    // worker-built from the same builder as the document rows.
5081    // TC.12: a trailing separator row is not a scope, so it must not take
5082    // the `active` styling meant for the scope the cursor is in.
5083    let innermost = sticky_context.innermost_scope();
5084    for (idx, row) in sticky_context.rows.iter().enumerate() {
5085        if (out.len() as u32) >= height {
5086            break;
5087        }
5088        let is_innermost = Some(idx) == innermost;
5089        let vrow = lattice_cells::VirtualRow {
5090            media: None,
5091            anchor_line: row.source_line,
5092            position: lattice_cells::AnchorPosition::Above,
5093            cells: row.cells.clone(),
5094            height: 1,
5095            kind: lattice_cells::VirtualRowKind::Sticky,
5096            // TC.11: the LAST row is the innermost scope — the one the cursor
5097            // is actually in, and the line the reader is looking for. Falls
5098            // back to the shared backdrop when the theme leaves `active`
5099            // unset, so a theme that does not distinguish them still works.
5100            bg: if is_innermost {
5101                sticky_context.active_bg.or(sticky_context.bg)
5102            } else {
5103                sticky_context.bg
5104            },
5105            scales: None,
5106            // TC.8: the header's real place in the file. The decision was made
5107            // host-side (`context.line-numbers`), so the renderer never reads
5108            // a plugin option — it only paints what the layer carries.
5109            // ANDed with the pane's own `number`: `context.line-numbers` asks
5110            // for numbers, `:set nonumber` says this pane shows none, and the
5111            // pane wins — a strip numbered above unnumbered code reads as a
5112            // stray column.
5113            // A separator mirrors no source line, so it shows no number.
5114            gutter_line: (sticky_context.line_numbers
5115                && view.show_line_numbers
5116                && !(sticky_context.has_separator && idx + 1 == sticky_context.rows.len()))
5117            .then_some(row.source_line),
5118            gutter_fg: sticky_context.line_number_fg,
5119        };
5120        out.push(render_virtual_row(view, &vrow, gutter_w, body_col_width));
5121        row_src.push(None);
5122    }
5123    let mut visible_idx: usize = 0;
5124    while (out.len() as u32) < height {
5125        let line_idx = match visible.get(visible_idx) {
5126            Some(&l) => {
5127                visible_idx += 1;
5128                l
5129            }
5130            None => {
5131                out.push(empty_marker_line(gutter_w));
5132                row_src.push(None);
5133                continue;
5134            }
5135        };
5136        // D.3.b.1: emit Above-anchored virtual rows for this
5137        // document line first.
5138        for vrow in virtual_rows_at(
5139            &virtual_rows_matrix,
5140            line_idx,
5141            lattice_cells::AnchorPosition::Above,
5142        ) {
5143            if (out.len() as u32) >= height {
5144                break;
5145            }
5146            out.push(render_virtual_row(view, vrow, gutter_w, body_col_width));
5147            row_src.push(None);
5148        }
5149        if (out.len() as u32) >= height {
5150            break;
5151        }
5152        // Pull just this line's text (O(log n) lookup +
5153        // O(line_len) materialisation).
5154        let line_text = snap.buffer.line(line_idx).unwrap_or_default();
5155        let gutter = render_gutter_for(
5156            view,
5157            line_idx,
5158            gutter_w,
5159            active_cursor_line,
5160            active_display_line_numbers.as_deref(),
5161            fold_colors,
5162        );
5163        // Highlight slot is keyed by buffer-line offset from
5164        // `scroll`, NOT by viewport row -- once closed folds skip
5165        // interior lines, viewport row `i` no longer corresponds
5166        // to buffer line `scroll + i`, and using the row index
5167        // would paint a post-fold line with stale spans for the
5168        // hidden interior.
5169        // msg-mode.3: messages-mode buffers bypass the
5170        // syntax-spans pipeline entirely -- the level token
5171        // styling isn't expressible as a `lattice_syntax::Style`
5172        // variant. `messages_line_spans` scans the fixed
5173        // `HH:MM:SS.mmm LEVEL text` format and returns a
5174        // ratatui `Vec<Span<'static>>` directly. Lines that
5175        // don't match the format render plain (e.g. blank
5176        // lines at the end of the rope).
5177        // W.4.t: tracks whether `body` came from the cells matrix
5178        // (already whitespace-decorated + tab-expanded by the
5179        // builder) vs the raw-text fallback. The compose-loop
5180        // whitespace pass below must NOT re-decorate a cell-derived
5181        // body — doing so re-classifies the tab's expanded fill
5182        // spaces and desyncs (cell spans no longer align 1:1 with
5183        // source bytes once tabs/markers expand).
5184        let mut body_from_cells = false;
5185        // IG.3: whether this row's body came from a display row at all.
5186        //
5187        // Distinct from `body_from_cells`, which is `!spans.is_empty()` and
5188        // therefore ALSO false for a blank line whose display row was built
5189        // perfectly well. Guides need the wider predicate: a blank line
5190        // inside a block is exactly the row a guide has to carry through,
5191        // and gating on emptiness would drop it.
5192        let mut body_is_display_row = false;
5193        let mut body = {
5194            // S3.c.final (2026-05-26): cell-derived spans are the
5195            // ONLY source for document-buffer bodies. The
5196            // `cell_row_to_source_spans` converter (S3.b) filters
5197            // INLAY-flagged cells so the resulting spans cover
5198            // source-byte positions one-to-one with `line_text`,
5199            // preserving overlay byte-coordinate semantics for
5200            // every downstream layer (whitespace, semantic
5201            // tokens, hlsearch, visual, diagnostics, fold suffix,
5202            // post-overlay inlay splice — all validated against
5203            // cell-derived bodies in S3.c.1–4).
5204            //
5205            // The RowPrepaint fallback that lived here through
5206            // S3.c.0–4 has been retired. Empty-matrix windows
5207            // (boot frames before the first cell-builder publish,
5208            // or the brief gap during a buffer switch) emit
5209            // plain-text `Span::raw(line_text)` instead — exactly
5210            // what the legacy fallback degraded to once the prepaint
5211            // rows ran out. Semantically equivalent for the user; one
5212            // source-of-truth from the code's perspective.
5213            //
5214            // display-line B4.2: the worker-published prepaint-rows
5215            // cell (`view.visible_rows`) was deleted — it had no live
5216            // readers left (the document body, markdown, help, and
5217            // messages bodies all read their own sources). Only the
5218            // overlay worker's `static_overlay_quads` survives as a
5219            // worker output. The document-body branch reads the
5220            // canonical `DisplayMatrix` below.
5221            // B2.4 (2026-06-04): consume the canonical `DisplayMatrix`
5222            // directly — its style-tagged runs resolve to ratatui via the
5223            // host theme at paint (`display_line_to_source_spans`). Pre-B2.4
5224            // the TUI read the projected cell grid here; the projection (and
5225            // the whole cell path) is deleted in B4.
5226            // DR.3: `display_matrix` + `display_theme` are this pane's
5227            // retained matrix, loaded once above (keyed by `ctx.pane_id`),
5228            // not a per-line top-level cell read.
5229            // Three paths fall through to plain `line_text`:
5230            //   1. matrix has no row at `line_idx` (boot frames before the
5231            //      first build, doc-switch gap, or off-window on a large
5232            //      file — the windowed build covers only the viewport),
5233            //   2. matrix has a row but it is entirely INLAY runs —
5234            //      `display_line_to_source_spans` drops those, so the Vec is
5235            //      empty even though `line_text` has content,
5236            //   3. matrix text lags the snapshot
5237            //      (`version.text != snap.text_version`). B2.3 rebuilds the
5238            //      edited region's `DisplayMatrix` SYNCHRONOUSLY in the
5239            //      publish tail, so a single-keystroke edit is already
5240            //      text-current here and this guard does NOT fire — that is
5241            //      what retired the per-keystroke whole-viewport flicker
5242            //      (user-reported 2026-06-02: "after adding one character …
5243            //      I suddenly see changes" was the old async-lag window).
5244            //      It still fires for the rare publish the sync path skips
5245            //      (multi-edit batch, doc switch); painting current
5246            //      plain-text for a frame beats painting PRE-edit content,
5247            //      and the async worker recolours within a frame or two.
5248            //   4. W.4.t.2: matrix whitespace config lags the options
5249            //      (`version.whitespace` differs from what a matrix built
5250            //      now would carry). The builder bakes whitespace markers
5251            //      into `Cell.ch` at emission, and the compose-loop
5252            //      pre-pass below deliberately skips cell-derived bodies
5253            //      (it would double-decorate) — so a matrix built before
5254            //      `:set list` paints UNDECORATED text and the pre-pass
5255            //      declines to fix it. Toggling whitespace would then do
5256            //      nothing visible until the async worker rebuilt. Same
5257            //      trade-off as (3): fall back to raw text for a frame so
5258            //      the pre-pass runs and the glyphs appear on the very
5259            //      next paint; syntax colour catches up when the worker
5260            //      republishes. Whitespace toggles are a rare, deliberate
5261            //      gesture, so the momentary uncoloured frame costs
5262            //      nothing on the hot path.
5263            let display_stale = display_matrix.version.text != snap.text_version
5264                || display_matrix.version.whitespace != current_whitespace_version;
5265            let spans = if display_stale {
5266                Vec::new()
5267            } else {
5268                match display_matrix.row_at_source_line(line_idx) {
5269                    Some(line) => crate::cells_render::display_line_to_source_spans(
5270                        line,
5271                        &cells_rs.resolved_theme,
5272                        &cells_rs.theme_ids,
5273                    ),
5274                    None => Vec::new(),
5275                }
5276            };
5277            if spans.is_empty() && !line_text.is_empty() {
5278                clip_spans_horizontally(
5279                    vec![Span::raw(line_text.clone())],
5280                    leftcol_off,
5281                    body_trunc_w,
5282                )
5283            } else {
5284                body_from_cells = !spans.is_empty();
5285                body_is_display_row =
5286                    !display_stale && display_matrix.row_at_source_line(line_idx).is_some();
5287                clip_spans_horizontally(spans, leftcol_off, body_trunc_w)
5288            }
5289        };
5290        // M.7.3.b: whitespace decoration pre-pass. Cheap when
5291        // `show_whitespace` is off (single bool check); when
5292        // on, walks each rendered span and substitutes glyphs
5293        // for tab / trailing / leading / space / EOL per the
5294        // typed `display.whitespace.*` options.
5295        //
5296        // W.4.t: skip the cell-derived path — the cells builder
5297        // already decorated whitespace (and expanded tabs to their
5298        // display width), so re-running here would double-decorate
5299        // and desync against source bytes. Only the raw-text
5300        // fallback (cells stale / absent) needs decoration here.
5301        if app.ad().option_cache.show_whitespace && !body_from_cells {
5302            let decoration = WhitespaceDecoration::from_app(app);
5303            body = apply_whitespace_decoration(body, &line_text, &decoration);
5304        }
5305        // IG.3: indentation guides, AFTER the horizontal clip.
5306        //
5307        // After, not before, because `clip_spans_horizontally` and
5308        // `truncate_spans_to_width` measure in BYTES (their own comment
5309        // calls the display-width model a punt). A guide glyph is three
5310        // bytes, so substituting it ahead of the clip would shorten every
5311        // indented line by two columns per guide — a visible regression
5312        // bought for nothing, since the glyph occupies one column either
5313        // way. Running after means the pass sees the same columns the
5314        // user sees, and `leftcol` pans the guides with the text.
5315        //
5316        // Still before the overlay remap below: that maps source byte →
5317        // body COLUMN → body byte, and guides change no column, exactly
5318        // as whitespace markers change none.
5319        //
5320        // Cell-derived bodies only. The plain-text fallback has not
5321        // expanded tabs, so a display column does not index it and a
5322        // guide would land wrong on every tab-indented line. A fallback
5323        // frame therefore shows no guides — the same degradation it
5324        // already makes for syntax colour, lasting exactly as long.
5325        if body_is_display_row {
5326            body = apply_indent_guides(
5327                body,
5328                indent_guides.marks_for_line(line_idx),
5329                &indent_guide_style,
5330                leftcol_off,
5331                body_trunc_w,
5332            );
5333        }
5334        let line_len = line_text.len();
5335        // W.4.t.1: the overlay ranges below carry SOURCE-byte offsets,
5336        // but the cell-derived `body` has tabs expanded to their display
5337        // width (and may carry multi-byte whitespace markers), so source
5338        // bytes no longer index it — selection / search / diagnostic /
5339        // semantic / document-highlight overlays drift on tab-indented
5340        // lines. Map each overlay endpoint: source byte → body column
5341        // (the char-count model `split_body_into_segments` slices by) →
5342        // body byte. Identity on the plain-text fallback
5343        // (`!body_from_cells`), where body bytes already equal source
5344        // bytes, so plain ASCII code is unchanged. Inlays splice AFTER
5345        // the overlays, so the body carries no inlay runs here — the
5346        // column model is tab-only (empty-inlay equivalent).
5347        let overlay_tabstop = app.ad().option_cache.tabstop;
5348        let body_concat: String = if body_from_cells {
5349            body.iter().map(|s| s.content.as_ref()).collect()
5350        } else {
5351            String::new()
5352        };
5353        let map_ob = |src: usize| -> usize {
5354            if body_from_cells {
5355                nth_char_byte(
5356                    &body_concat,
5357                    source_byte_to_body_col(&line_text, src, overlay_tabstop),
5358                )
5359            } else {
5360                src
5361            }
5362        };
5363        // Whether this line begins a closed fold. Used to append the
5364        // ` ⋯ N lines` suffix AFTER overlay processing, so
5365        // visual selection / hlsearch / current_match still paint
5366        // the heading correctly.
5367        // Fold-bleed fix (2026-06-30): computed for every pane (matches
5368        // the now-ungated visible-line walk above). `view.fold_start_at`
5369        // reads the pane's own per-buffer folds, so an inactive pane
5370        // shows its fold summary identically to when it was focused.
5371        let closed_fold_at_start = view.fold_start_at(line_idx).filter(|f| f.closed).map(|f| {
5372            // The "⋯ N lines" suffix should reflect the
5373            // user's perception of how much content collapsed
5374            // onto this single visible row -- including any
5375            // sibling / nested closed folds whose headings are
5376            // themselves hidden by this fold and whose ranges
5377            // chain past `f.end_line`. Without this walk, two
5378            // touching folds (1..=3 then 3..=5, both closed)
5379            // visually hide 5 lines but report only the first
5380            // fold's own 3 lines, which doesn't match what the
5381            // user just collapsed.
5382            closed_fold_display_span(view, snap, f)
5383        });
5384        // 4.4.h: LSP semantic-tokens overlay. Replaces the
5385        // foreground color (folding in modifier bits) for
5386        // each token's byte range. Painted BEFORE visual /
5387        // hlsearch / diagnostic passes so those still layer
5388        // their bg / underline on top of the LSP-driven fg
5389        // -- the user's selection and search highlight stay
5390        // visible over semantic-colored text.
5391        // 5.8.AF.5 / Slice 3b.2: read semantic-tokens through
5392        // `RenderState.lsp.semantic_tokens`. The spawned request
5393        // task writes via `insert_for` into the same underlying
5394        // `PerBufferCache` -- this read sees fresh data without
5395        // any UI-thread drain.
5396        // DR.3: semantic tokens are a buffer-intrinsic decoration —
5397        // source from THIS pane's per-buffer cache (`ctx.buffer_id`)
5398        // and the per-pane `view` gate, so they paint on inactive
5399        // panes too (dimmed). For the active pane `ctx.buffer_id` IS
5400        // the active doc → byte-identical.
5401        use lattice_host::per_buffer_cache::PerBufferCacheExt;
5402        let rs_st = app.render_state.load();
5403        if let Some(cache) = rs_st.lsp.semantic_tokens.get_for(ctx.buffer_id)
5404            && view.lsp_semantic_tokens_enabled
5405        {
5406            for tok in cache.tokens.iter().filter(|t| t.line == line_idx) {
5407                let start =
5408                    lattice_lsp::position::utf16_column_to_utf8_byte(&line_text, tok.start_char)
5409                        as usize;
5410                let end = lattice_lsp::position::utf16_column_to_utf8_byte(
5411                    &line_text,
5412                    tok.start_char + tok.length,
5413                ) as usize;
5414                let start = start.min(line_len);
5415                let end = end.min(line_len);
5416                if start >= end {
5417                    continue;
5418                }
5419                let mut mods = Modifier::empty();
5420                let style_with_mods =
5421                    apply_semantic_token_modifiers(TuiStyle::default(), &tok.modifiers);
5422                mods.insert(style_with_mods.add_modifier);
5423                body = apply_semantic_token_overlay(
5424                    body,
5425                    map_ob(start),
5426                    map_ob(end),
5427                    semantic_token_color(&tok.token_type),
5428                    mods,
5429                );
5430            }
5431        }
5432        // Blockwise visual: per-line column band [min_col, max_col].
5433        // Charwise / Linewise visual go through `visual_range` instead.
5434        if let Some(b) = block
5435            && line_idx >= b.start_line
5436            && line_idx <= b.end_line
5437        {
5438            let start = (b.start_col as usize).min(line_len);
5439            let end = ((b.end_col as usize) + 1).min(line_len);
5440            if start < end {
5441                body = apply_match_overlay(
5442                    body,
5443                    map_ob(start),
5444                    map_ob(end),
5445                    visual_style(overlay_resolved, overlay_ids),
5446                );
5447            }
5448        } else if let Some(range) = visual_range
5449            && let Some((overlay_start, overlay_end)) =
5450                match_overlay_range(range, line_idx, line_len)
5451        {
5452            body = apply_match_overlay(
5453                body,
5454                map_ob(overlay_start),
5455                map_ob(overlay_end),
5456                visual_style(overlay_resolved, overlay_ids),
5457            );
5458        }
5459        // Perf plan B.2 slice B.2.b: hlsearch (`all_matches`) overlay
5460        // now reads from the worker's per-row bucket. The bucket is
5461        // indexed by visible-row offset from `scroll` (= worker's
5462        // recompute `start`), so for buffer line `line_idx` the row
5463        // index is `line_idx - frame_scroll`. Bucket is empty
5464        // pre-first-recompute or for non-active panes; in those cases
5465        // we fall back to the legacy per-frame walk so search hits
5466        // still paint correctly through the warm-up window.
5467        // DR.3: hlsearch + current-match are interaction state — the
5468        // `all_matches` walk reads the ACTIVE doc's matches (no
5469        // per-buffer search store yet), so both run only on the
5470        // focused pane. The bucket is empty when inactive anyway; the
5471        // gate also stops the `else` walk from painting the active
5472        // buffer's matches onto an inactive pane.
5473        let bucket_row: Option<&Vec<lattice_host::render_state::RowOverlayQuad>> = (line_idx
5474            >= frame_scroll)
5475            .then(|| (line_idx - frame_scroll) as usize)
5476            .and_then(|idx| active_overlay_quads_for_frame.quads.get(idx));
5477        // MB.5: while the `/`·`?` search line is open, the document
5478        // pane renders via the inactive path (registry-keyed buffer),
5479        // but hlsearch / current-match overlays must still paint — the
5480        // user sees live match highlighting as they type.
5481        if ctx.is_active || app.ad().search_line_active {
5482            if let Some(row_quads) = bucket_row {
5483                for q in row_quads {
5484                    if matches!(
5485                        q.layer,
5486                        lattice_host::render_state::OverlayLayer::AllMatches
5487                    ) {
5488                        let start = (q.source_byte_start as usize).min(line_len);
5489                        let end = (q.source_byte_end as usize).min(line_len);
5490                        if start < end {
5491                            body = apply_match_overlay(
5492                                body,
5493                                map_ob(start),
5494                                map_ob(end),
5495                                hlsearch_style(overlay_resolved, overlay_ids),
5496                            );
5497                        }
5498                    }
5499                }
5500            } else {
5501                for &range in app.ad().all_matches.iter() {
5502                    if let Some((overlay_start, overlay_end)) =
5503                        match_overlay_range(range, line_idx, line_len)
5504                    {
5505                        body = apply_match_overlay(
5506                            body,
5507                            map_ob(overlay_start),
5508                            map_ob(overlay_end),
5509                            hlsearch_style(overlay_resolved, overlay_ids),
5510                        );
5511                    }
5512                }
5513            }
5514            if let Some(range) = app.ad().current_match
5515                && let Some((overlay_start, overlay_end)) =
5516                    match_overlay_range(range, line_idx, line_len)
5517            {
5518                body = apply_match_overlay(
5519                    body,
5520                    map_ob(overlay_start),
5521                    map_ob(overlay_end),
5522                    match_style(overlay_resolved, overlay_ids),
5523                );
5524            }
5525        }
5526        // LSP diagnostic underline overlay (Phase 4.1.d.iii):
5527        // for each diagnostic touching this line, underline the
5528        // affected range with the severity colour. Underline
5529        // modifier composes with any prior bg / fg overlays
5530        // (visual / hlsearch / current_match) -- all four can
5531        // co-exist on a single span without conflict.
5532        // DR.3: diagnostics are a buffer-intrinsic decoration —
5533        // sourced by `ctx.buffer_id` (the layer is per-URI) so they
5534        // underline on inactive panes too (dimmed). Active pane's
5535        // `ctx.buffer_id` is the active doc → byte-identical.
5536        for d in diagnostics_on_line(view, ctx.buffer_id, line_idx) {
5537            let start = if d.range.start.line == line_idx {
5538                (d.range.start.character as usize).min(line_len)
5539            } else {
5540                0
5541            };
5542            let end = if d.range.end.line == line_idx {
5543                (d.range.end.character as usize).min(line_len)
5544            } else {
5545                line_len
5546            };
5547            if start >= end {
5548                continue;
5549            }
5550            let color = match d.severity {
5551                Some(DiagnosticSeverity::ERROR) => Color::Red,
5552                Some(DiagnosticSeverity::WARNING) => Color::Yellow,
5553                Some(DiagnosticSeverity::INFORMATION) => Color::Blue,
5554                Some(DiagnosticSeverity::HINT) => Color::DarkGray,
5555                _ => Color::Blue,
5556            };
5557            body = apply_underline_overlay(body, map_ob(start), map_ob(end), color);
5558        }
5559        // 4.4.e: `documentHighlight` soft overlay. Reads from
5560        // the App's per-buffer cache (populated by the per-tick
5561        // pump). The overlay walks each highlight; the range
5562        // intersected with this row gets a background tint with
5563        // hue keyed off the `kind` field (Read = green-ish,
5564        // Write = red-ish, Text/None = blue-ish). The styling
5565        // composes with diagnostics + hlsearch + visual so a
5566        // symbol caught by all four still reads correctly.
5567        // Phase 5.8.AF.5 / Slice 3b.0: read through the
5568        // `RenderState.lsp.document_highlights` ArcSwap. The
5569        // spawned LSP request task `.store()`s directly into
5570        // the same underlying slot, so this `load_full()` sees
5571        // the latest result without any tick-driven drain on
5572        // the renderer thread.
5573        // DR.3: document-highlight is a buffer-intrinsic decoration —
5574        // the highlight ranges are positions in `cache.buffer_id`, so
5575        // paint them on ANY pane showing that buffer (active or
5576        // inactive, dimmed), matching the GPUI peer
5577        // (`feedback_tui_gpui_parity`). Keyed on `ctx.buffer_id` (was
5578        // `app.ad().document_buffer_id`): the active pane's id is the
5579        // active doc → byte-identical; an inactive pane showing a
5580        // DIFFERENT buffer no longer mis-claims the active buffer's
5581        // highlights.
5582        let rs = app.render_state.load();
5583        let dh_guard = rs.lsp.document_highlights.load_full();
5584        if let Some(cache) = dh_guard.as_deref()
5585            && cache.buffer_id == ctx.buffer_id
5586            && view.lsp_document_highlight_enabled
5587        {
5588            for h in &cache.highlights {
5589                let start_line = h.range.start.line;
5590                let end_line = h.range.end.line;
5591                if line_idx < start_line || line_idx > end_line {
5592                    continue;
5593                }
5594                let start = if line_idx == start_line {
5595                    (h.range.start.character as usize).min(line_len)
5596                } else {
5597                    0
5598                };
5599                let end = if line_idx == end_line {
5600                    (h.range.end.character as usize).min(line_len)
5601                } else {
5602                    line_len
5603                };
5604                if start >= end {
5605                    continue;
5606                }
5607                body = apply_match_overlay(
5608                    body,
5609                    map_ob(start),
5610                    map_ob(end),
5611                    document_highlight_style(h.kind, overlay_resolved, overlay_ids),
5612                );
5613            }
5614        }
5615        // Perf plan A.2 slice A.2b.2: `inlayHint` virtual-text
5616        // overlay reads from `rs.syntax.inlay_hints` — the
5617        // publish-time gated + flattened list with
5618        // `padding_left/right` already baked in and the utf-16
5619        // column already converted to utf-8 bytes. The mode gate,
5620        // per-buffer-cache lookup, label flatten, and column
5621        // conversion all moved off the per-line hot loop onto
5622        // dispatch (once per publish). Filters per row by `line`;
5623        // splices in reverse byte order so earlier splices don't
5624        // shift later ones.
5625        //
5626        // display-line B-series: the active-pane document body reads
5627        // the canonical `DisplayMatrix` (which already weaves inlay
5628        // text into its runs); this post-hoc inlay splice covers the
5629        // markdown / help / messages bodies that still build spans
5630        // from raw line text. (The old prepaint-rows cell that would
5631        // have woven it was deleted in B4.2.)
5632        //
5633        // Fold-bleed follow-up (2026-06-30): inlay hints are a
5634        // buffer-intrinsic decoration shown on EVERY pane through ONE
5635        // path. `view.inlay_hints` is this pane's own byte-baked list
5636        // (`RenderState::inlay_hints_for_buffer`: active → baked
5637        // `syntax.inlay_hints`; inactive → its `cells.panes` entry).
5638        // This replaces the prior active-vs-inactive split where the
5639        // inactive branch re-derived hints from the LSP cache with its
5640        // own utf-16→utf-8 conversion — two sources that could drift.
5641        // Splices through `map_ob` so W.4.t tab expansion lands them on
5642        // the right cell; reverse byte order keeps earlier splices from
5643        // shifting later ones.
5644        let rs = app.render_state.load();
5645        if !view.inlay_hints.is_empty() {
5646            let mut on_line: Vec<&lattice_host::render_state::InlayHintRow> = view
5647                .inlay_hints
5648                .iter()
5649                .filter(|h| h.line == line_idx)
5650                .collect();
5651            on_line.sort_by(|a, b| b.byte.cmp(&a.byte));
5652            for h in on_line {
5653                body = splice_virtual_text_into_spans(
5654                    body,
5655                    map_ob((h.byte as usize).min(line_len)),
5656                    h.text.clone(),
5657                    inlay_row_style(overlay_resolved, overlay_ids, h.style),
5658                );
5659            }
5660        }
5661        // L4a.3 (lsp-architecture.md §15): inline cursor-line diagnostic
5662        // summary. The host idle gate (L4a.2) publishes
5663        // `rs.diagnostics.inline_summary = Some((line, summary))` for the
5664        // ACTIVE buffer's cursor line; render it as trailing eol virtual
5665        // text, severity-themed to match the gutter glyph. Active pane
5666        // only — unlike the buffer-intrinsic inlay/underline decorations
5667        // above, the cursor-line summary is interaction state tied to the
5668        // focused cursor (Helix/Zed show it only in the focused view).
5669        // Spliced at `usize::MAX` so it appends at the very end of the
5670        // rendered line — AFTER any inlay-hint virtual text spliced
5671        // above — rather than at the source-text end (which would land
5672        // it before trailing inlays, mid-line). The leading gap
5673        // separates it from the code.
5674        if ctx.is_active
5675            && view.lsp_diagnostics_enabled
5676            && let Some((sum_line, summary)) = rs.diagnostics.inline_summary.as_ref()
5677            && *sum_line == line_idx
5678        {
5679            body = splice_virtual_text_into_spans(
5680                body,
5681                usize::MAX,
5682                format!("    {}", summary.text),
5683                diagnostic_summary_style(summary.severity_rank, view),
5684            );
5685        }
5686        // Substitute live preview overlay (DESIGN.md §5.9.10): paint
5687        // the about-to-be-replaced ranges in a strike-through-ish
5688        // style so the user sees what will change before they hit
5689        // Enter. Distinct from hlsearch's plain match highlight.
5690        //
5691        // Perf plan B.2 slice B.2.b: consume the worker bucket's
5692        // Substitute layer when available; legacy walk as fallback.
5693        // DR.3: the substitute preview is driven by the command line
5694        // (`:s///`) typed in the focused pane — interaction state, so
5695        // it paints only on the active pane.
5696        if ctx.is_active {
5697            if let Some(row_quads) = bucket_row {
5698                let mut found_any = false;
5699                for q in row_quads {
5700                    if matches!(
5701                        q.layer,
5702                        lattice_host::render_state::OverlayLayer::Substitute
5703                    ) {
5704                        let start = (q.source_byte_start as usize).min(line_len);
5705                        let end = (q.source_byte_end as usize).min(line_len);
5706                        if start < end {
5707                            body = apply_match_overlay(
5708                                body,
5709                                map_ob(start),
5710                                map_ob(end),
5711                                substitute_preview_style(overlay_resolved, overlay_ids),
5712                            );
5713                            found_any = true;
5714                        }
5715                    }
5716                }
5717                // If the worker bucket existed but had no substitute
5718                // quads, that's accurate state — no substitute preview is
5719                // active. Don't fall back to per-frame walk.
5720                let _ = found_any;
5721            } else if let Some(preview) = app.ad().substitute_preview.as_ref() {
5722                for &range in preview.matches.iter() {
5723                    if let Some((overlay_start, overlay_end)) =
5724                        match_overlay_range(range, line_idx, line_len)
5725                    {
5726                        body = apply_match_overlay(
5727                            body,
5728                            map_ob(overlay_start),
5729                            map_ob(overlay_end),
5730                            substitute_preview_style(overlay_resolved, overlay_ids),
5731                        );
5732                    }
5733                }
5734            }
5735        }
5736        // W.4.t: the columns of this row that live on the SOURCE axis,
5737        // measured before any trailing decoration is appended. This is
5738        // what `split_body_into_segments` is allowed to wrap at — see
5739        // its `wrap_cols` doc. Everything pushed below (fold summary,
5740        // ghost text, cursorline pad) is decoration outside the
5741        // source-byte axis; letting it wrap would give the line more
5742        // display rows than `wrap_segments(col_count, …)`, the count
5743        // the host's scroll model and the caret walk both use.
5744        let wrap_cols: usize = body.iter().map(|s| s.content.chars().count()).sum();
5745        // Heading-preserved fold render (`docs/user/folding.md`):
5746        // append the ` ⋯ N lines` suffix AFTER all overlays so the
5747        // heading's syntax / visual / search styling is preserved, with
5748        // the dim summary trailing off the right.
5749        if let Some(n) = closed_fold_at_start {
5750            body.push(Span::styled(
5751                lattice_host::folds::fold_summary_text(n),
5752                TuiStyle::default().fg(fold_summary_color),
5753            ));
5754        }
5755        // Ghost text (Phase 4.2.g.7 polish). When the cursor
5756        // sits at end-of-line on this row AND the popup's
5757        // top-ranked candidate has a suffix to preview, paint
5758        // it as a dimmed inline overlay so the user sees the
5759        // most-likely accept inline. Cursor block visually
5760        // overlays the first ghost char (the typed prefix
5761        // ends right before it).
5762        // DR.3: ghost text previews the active completion at the
5763        // active cursor — interaction state, focused pane only.
5764        if ctx.is_active
5765            && line_idx == app.ad().cursor.line
5766            && (app.ad().cursor.byte as usize) == line_text.len()
5767            && let Some(suffix) = app.completion_ghost_text_suffix()
5768        {
5769            body.push(Span::styled(
5770                suffix,
5771                TuiStyle::default()
5772                    .fg(Color::DarkGray)
5773                    .add_modifier(Modifier::ITALIC),
5774            ));
5775        }
5776        // M.7.3.c: current-line highlight. When
5777        // `current-line-highlight-mode` is active (M.7.2 minor
5778        // / `:set cursorline`) and this row is the cursor's,
5779        // OR `theme.cursor_line_bg` into each body span's style
5780        // where the span's bg is unset. Selection wins
5781        // per-cell -- spans with bg already set (visual /
5782        // hlsearch / current_match overlays) keep their bg.
5783        // Pads to buffer width so the highlight extends to the
5784        // pane's right edge.
5785        //
5786        // DR.3: the cursor line is interaction state — `is_active`
5787        // gates it so inactive panes (no cursor) never paint a
5788        // cursor-line bg.
5789        //
5790        // Gutter + severity cell are intentionally not
5791        // highlighted -- they're their own visual column. vim
5792        // does highlight the line-number column; lattice can
5793        // add that as a follow-up if users want it.
5794        // PI.3: cursorline is now a per-pane decision on `ctx`, not gated on
5795        // "is this the active document". For the active pane this resolves
5796        // identically (`cursor_line` = `app.ad().cursor.line`,
5797        // `cursor_line_highlight` = the active option_cache value); a focused
5798        // preview pane paints its displayed buffer's cursorline at the
5799        // preview cursor.
5800        if ctx.cursor_line_highlight && line_idx == ctx.cursor_line {
5801            let bg = app.theme.cursor_line_bg;
5802            for span in body.iter_mut() {
5803                if span.style.bg.is_none() {
5804                    span.style = span.style.bg(bg);
5805                }
5806            }
5807            let used: usize = body.iter().map(|s| s.content.len()).sum();
5808            let pad_width = (buffer_w as usize).saturating_sub(used);
5809            if pad_width > 0 {
5810                body.push(Span::styled(
5811                    " ".repeat(pad_width),
5812                    TuiStyle::default().bg(bg),
5813                ));
5814            }
5815        }
5816        // LSP severity cell (Phase 4.1.d.iii). One cell pre-
5817        // pended to the gutter; severity glyph + colour when a
5818        // diagnostic touches the line, blank otherwise. Costs
5819        // one cell of gutter width on every frame -- visible
5820        // even when no diagnostics exist so the layout doesn't
5821        // shift when one arrives.
5822        // SG.4b: one cell per gutter column, left to right, each showing that
5823        // column's winning sign. Diagnostics land in `mark` and hunk marks in
5824        // `diff` because their DEFINITIONS say so, not because this loop knows
5825        // the difference — which is the whole point of the unification.
5826        //
5827        // The column order is the host's (a gutter whose columns moved per
5828        // buffer would be unreadable); what goes in each one is entirely the
5829        // registry's answer. Adjacent dedicated columns rather than one
5830        // contended cell is the Helix / Zed shape, and it is what stops a
5831        // diagnostic hiding a hunk mark on the lines a user is most likely to
5832        // be looking at.
5833        //
5834        // DR.3: sourced by `ctx.buffer_id`, so an inactive pane shows ITS
5835        // buffer's marks. Active pane's id is the active doc → byte-identical.
5836        let sign_cells: Vec<Span<'static>> = sign_gutter
5837            .iter()
5838            .map(|col| render_sign_cell(col.get(&line_idx).copied(), view))
5839            .collect();
5840        // D.3.e: line-background tint. Applied AFTER all other
5841        // body overlays (whitespace decoration, hlsearch,
5842        // visual selection, etc.) — the tint sits BEHIND the
5843        // already-styled content, so we layer bg last and
5844        // preserve every fg / modifier the upstream overlays
5845        // set. Conditional on the active-document sign map;
5846        // no-session / no-hunk-on-line → no allocation.
5847        let body = match diff_tint_bg(view, ctx.buffer_id, line_idx) {
5848            Some(bg) => apply_diff_tint(body, bg),
5849            None => body,
5850        };
5851        // CM.3d (2026-07-22): compilation location line tint —
5852        // background tint on the whole line + link foreground on
5853        // the file-path portion.
5854        let body = match compilation_location_tint(view, ctx.buffer_id, line_idx) {
5855            Some((start, end, bg, fg)) => apply_compilation_location_tint(body, start, end, bg, fg),
5856            None => body,
5857        };
5858        // MC.3: fenced/indented code-block background — the weakest, widest
5859        // tint, applied LAST so any narrower background already set (diff line,
5860        // compilation location, search match, visual selection) wins on
5861        // overlap. `apply_diff_tint` fills spans without their own bg (the
5862        // text), then a trailing pad fills the rest of the row to `buffer_w`
5863        // so the block reads as a solid rectangle including blank lines and
5864        // trailing space (mirrors the cursorline pad above; matches the GPUI
5865        // peer's full-row code-block quad). The pad only fills the gap, so a
5866        // row already padded to full width by a stronger tint (e.g. the
5867        // cursorline on a code line) is left untouched.
5868        let body = match code_block_tint_bg(view, ctx.buffer_id, line_idx) {
5869            Some(bg) => {
5870                let mut body = apply_diff_tint(body, bg);
5871                let used: usize = body.iter().map(|s| s.content.len()).sum();
5872                let pad_width = (buffer_w as usize).saturating_sub(used);
5873                if pad_width > 0 {
5874                    body.push(Span::styled(
5875                        " ".repeat(pad_width),
5876                        TuiStyle::default().bg(bg),
5877                    ));
5878                }
5879                body
5880            }
5881            None => body,
5882        };
5883        // DR.3: inactive panes are a paint-time opacity (the design's
5884        // §Render contract). Dim the fully-decorated body (syntax +
5885        // semantic + diagnostics + inlays + diff tint) uniformly AFTER
5886        // every decoration overlay and BEFORE wrap-segmenting, so the
5887        // pane reads as unfocused without dropping any decoration cue
5888        // (`feedback_decorations_update_in_place`). The focused pane
5889        // skips this entirely (opacity 1.0 → byte-identical). The
5890        // gutter/severity/diff-sign prefix keeps its own styling, as
5891        // it did on the pre-merge inactive path.
5892        let body = if ctx.is_active || !view.app.theme.dim_inactive_panes {
5893            body
5894        } else {
5895            let overlay = view.app.theme.inactive_pane_overlay;
5896            body.into_iter()
5897                .map(|mut s| {
5898                    s.style = s.style.patch(overlay);
5899                    s
5900                })
5901                .collect()
5902        };
5903        // W.4 (soft-wrap): split the fully-overlaid body into
5904        // display segments of `body_col_width` columns when `:set
5905        // wrap` is on. Segment 0 carries the real prefix + gutter
5906        // (line number / fold / diagnostic); continuation segments
5907        // get a blank severity/diff prefix and a `↪` gutter. Wrap
5908        // off ⇒ one segment ⇒ byte-identical to the prior single
5909        // push. The height cap stops mid-line if the viewport
5910        // fills (matches the pre-wrap truncation behaviour).
5911        let segments = if wrap_on {
5912            split_body_into_segments(body, body_col_width as usize, wrap_cols)
5913        } else {
5914            vec![body]
5915        };
5916        let mut seg_iter = segments.into_iter();
5917        if let Some(seg0) = seg_iter.next() {
5918            // PU.1b-1a: drop the severity + diff sign cells entirely
5919            // when `signcolumn=no` so content abuts the gutter — the
5920            // exact geometry `buffer_w` reserved via
5921            // `sign_columns_width`. Reserved (default) → byte-identical.
5922            let sign_prefix = if view.sign_column {
5923                sign_cells
5924            } else {
5925                Vec::new()
5926            };
5927            out.push(combine_prefixed(sign_prefix, gutter, seg0));
5928            row_src.push(Some(lattice_host::mouse::RowOrigin {
5929                source_line: line_idx,
5930                segment: 0,
5931            }));
5932        }
5933        // MO.2: `cont_idx + 1` — continuation rows are segments 1, 2, …
5934        // of the same logical line, and that index is what shifts a
5935        // click's column into the right part of it. Without it the tail
5936        // of a wrapped paragraph would resolve to its head.
5937        for (cont_idx, seg) in seg_iter.enumerate() {
5938            if (out.len() as u32) >= height {
5939                break;
5940            }
5941            // Continuation rows carry no fold marker (the blank glyph
5942            // slot is part of `format_gutter_cell`'s layout), so the
5943            // whole cell is one dim span.
5944            let cont_gutter = vec![Span::styled(
5945                format_gutter_cell(WRAP_CONT_MARKER, gutter_w, None),
5946                TuiStyle::default()
5947                    .fg(Color::DarkGray)
5948                    .add_modifier(Modifier::DIM),
5949            )];
5950            // PU.1b-1a: continuation rows mirror seg0's sign-column
5951            // geometry — two blank cells when reserved, none when
5952            // `signcolumn=no`.
5953            let cont_sign_prefix = if view.sign_column {
5954                vec![Span::raw(" "); lattice_mode::BUILTIN_SIGN_COLUMNS.len()]
5955            } else {
5956                Vec::new()
5957            };
5958            out.push(combine_prefixed(cont_sign_prefix, cont_gutter, seg));
5959            row_src.push(Some(lattice_host::mouse::RowOrigin {
5960                source_line: line_idx,
5961                segment: cont_idx as u32 + 1,
5962            }));
5963        }
5964        // D.3.b.1: emit Below-anchored virtual rows for this
5965        // document line, then continue to the next visible
5966        // document line.
5967        for vrow in virtual_rows_at(
5968            &virtual_rows_matrix,
5969            line_idx,
5970            lattice_cells::AnchorPosition::Below,
5971        ) {
5972            if (out.len() as u32) >= height {
5973                break;
5974            }
5975            out.push(render_virtual_row(view, vrow, gutter_w, body_col_width));
5976            row_src.push(None);
5977        }
5978    }
5979    debug_assert_eq!(
5980        out.len(),
5981        row_src.len(),
5982        "MO.2: every painted row records its origin"
5983    );
5984    app.pane_hits.borrow_mut().set_rows(ctx.pane_id, row_src);
5985    out
5986}
5987
5988fn hlsearch_style(
5989    resolved: &lattice_host::ui::theme::ResolvedTheme,
5990    ids: &lattice_host::ui::theme::BuiltinElementIds,
5991) -> TuiStyle {
5992    // T.6: hlsearch is now a BACKGROUND tint resolved from
5993    // `search.match` (both renderers agree; the legacy cyan-fg recolor
5994    // is retired). Softer than the current match so it reads as
5995    // "another instance of what you're searching for".
5996    let bg = resolved
5997        .get(ids.search_match)
5998        .bg
5999        .map(crate::theme::host_color_to_ratatui)
6000        .unwrap_or(Color::Cyan);
6001    TuiStyle::default().bg(bg)
6002}
6003
6004/// 4.4.e: `documentHighlight` overlay style. Soft tint that
6005/// reads as "same symbol, related to the one your cursor is
6006/// on". Distinct hue per kind so the user can spot reads-vs-
6007/// writes-vs-other without consulting the spec:
6008///
6009/// - `Read` (default) — dim green; "this is being consulted"
6010/// - `Write` — dim red; "this site mutates the symbol"
6011/// - `Text` / `None` — dim blue; "this is an occurrence"
6012///
6013/// All three use a dark background tint + the original fg so
6014/// the text stays readable; composes with the other overlays
6015/// (diagnostics underline, visual selection bg, hlsearch).
6016fn document_highlight_style(
6017    kind: Option<lattice_lsp::lsp_types::DocumentHighlightKind>,
6018    resolved: &lattice_host::ui::theme::ResolvedTheme,
6019    ids: &lattice_host::ui::theme::BuiltinElementIds,
6020) -> TuiStyle {
6021    use lattice_lsp::lsp_types::DocumentHighlightKind;
6022    // T.6: the 3 distinct kinds resolve from the registered
6023    // `doc_highlight.{read,write,text}` elements (both renderers honor
6024    // all three). Fallbacks mirror the legacy literals.
6025    let (id, fallback) = match kind {
6026        Some(DocumentHighlightKind::READ) => (ids.doc_highlight_read, Color::Rgb(20, 50, 25)),
6027        Some(DocumentHighlightKind::WRITE) => (ids.doc_highlight_write, Color::Rgb(60, 20, 20)),
6028        _ => (ids.doc_highlight_text, Color::Rgb(20, 30, 60)),
6029    };
6030    let bg = resolved
6031        .get(id)
6032        .bg
6033        .map(crate::theme::host_color_to_ratatui)
6034        .unwrap_or(fallback);
6035    TuiStyle::default().bg(bg)
6036}
6037
6038/// Style for substitute live-preview matches. Magenta-bg with a
6039/// strike-through reads as "this is going to be replaced if you
6040/// hit Enter" -- distinct from hlsearch's "this is what your
6041/// search is finding" cyan, and distinct from the current-match
6042/// yellow.
6043fn substitute_preview_style(
6044    resolved: &lattice_host::ui::theme::ResolvedTheme,
6045    ids: &lattice_host::ui::theme::BuiltinElementIds,
6046) -> TuiStyle {
6047    // T.6: bg resolves from `substitute.preview` (shared with GPUI).
6048    // The CROSSED_OUT strikethrough stays TUI-only ("this is going to
6049    // be replaced if you hit Enter").
6050    let bg = resolved
6051        .get(ids.substitute_preview)
6052        .bg
6053        .map(crate::theme::host_color_to_ratatui)
6054        .unwrap_or(Color::Magenta);
6055    TuiStyle::default()
6056        .bg(bg)
6057        .add_modifier(Modifier::CROSSED_OUT)
6058}
6059
6060/// For blockwise Visual: the rectangle defined by the selection's
6061/// `(anchor, head)` positions. Returns `None` if not in blockwise
6062/// mode.
6063///
6064/// 2026-05-27: migrated to
6065/// [`lattice_host::visual::BlockExtents`] / `Editor::
6066/// visual_block_extents` — renderer-neutral so the GPUI peer can
6067/// consume the same data. Thin wrapper kept so this peer's call
6068/// sites resolve unchanged.
6069fn visual_block_extents(app: &App) -> Option<BlockExtents> {
6070    app.ad().visual_block_extents
6071}
6072
6073type BlockExtents = lattice_host::visual::BlockExtents;
6074
6075/// Compute the rendered range of the visual selection. Returns `None` if
6076/// not in Visual mode. For Linewise visual the byte extents on the first
6077/// and last lines are normalized to cover the full lines (mirrored from
6078/// the dispatcher's `Range::Selection` resolution).
6079// 5.8.P: `visual_selection_range` migrated to
6080// `lattice_host::editor::Editor::visual_selection_range` — renderer-
6081// neutral logic shared between TUI and GPUI peers. Thin wrapper
6082// kept here so this peer's call sites resolve unchanged.
6083fn visual_selection_range(app: &App) -> Option<ProtoRange> {
6084    app.ad().visual_range
6085}
6086
6087fn visual_style(
6088    resolved: &lattice_host::ui::theme::ResolvedTheme,
6089    ids: &lattice_host::ui::theme::BuiltinElementIds,
6090) -> TuiStyle {
6091    // T.6: visual selection bg resolves from `selection` (shared with
6092    // GPUI's selection quad). Distinct from the search-match style.
6093    let bg = resolved
6094        .get(ids.selection)
6095        .bg
6096        .map(crate::theme::host_color_to_ratatui)
6097        .unwrap_or(Color::Blue);
6098    TuiStyle::default().bg(bg)
6099}
6100
6101/// If `range` covers any bytes on `line_idx`, return the within-line
6102/// half-open byte interval `[start, end)`. `line_len` is the line's
6103/// content length excluding the trailing newline.
6104fn match_overlay_range(
6105    range: ProtoRange,
6106    line_idx: u32,
6107    line_len: usize,
6108) -> Option<(usize, usize)> {
6109    if line_idx < range.start.line || line_idx > range.end.line {
6110        return None;
6111    }
6112    let start = if line_idx == range.start.line {
6113        range.start.byte as usize
6114    } else {
6115        0
6116    };
6117    let end = if line_idx == range.end.line {
6118        range.end.byte as usize
6119    } else {
6120        line_len
6121    };
6122    if start >= end || start >= line_len {
6123        return None;
6124    }
6125    Some((start, end.min(line_len)))
6126}
6127
6128/// S3.c.3 (2026-05-26): visibility bumped to `pub(crate)` so
6129/// `cells_render::tests` can validate the overlay walks cell-
6130/// derived spans correctly. Matches the precedent set by
6131/// `apply_whitespace_decoration` / `apply_semantic_token_overlay`.
6132pub(crate) fn apply_match_overlay(
6133    spans: Vec<Span<'static>>,
6134    overlay_start: usize,
6135    overlay_end: usize,
6136    overlay_style: TuiStyle,
6137) -> Vec<Span<'static>> {
6138    let mut out: Vec<Span<'static>> = Vec::with_capacity(spans.len() + 2);
6139    let mut cursor = 0usize;
6140    for span in spans {
6141        let s = span.content.as_ref().to_string();
6142        let span_start = cursor;
6143        let span_end = cursor + s.len();
6144        let overlap_start = span_start.max(overlay_start);
6145        let overlap_end = span_end.min(overlay_end);
6146        if overlap_start >= overlap_end {
6147            out.push(Span::styled(s, span.style));
6148        } else {
6149            if overlap_start > span_start {
6150                let pre = s[..overlap_start - span_start].to_string();
6151                out.push(Span::styled(pre, span.style));
6152            }
6153            let mid = s[overlap_start - span_start..overlap_end - span_start].to_string();
6154            out.push(Span::styled(mid, overlay_style));
6155            if overlap_end < span_end {
6156                let post = s[overlap_end - span_start..].to_string();
6157                out.push(Span::styled(post, span.style));
6158            }
6159        }
6160        cursor = span_end;
6161    }
6162    out
6163}
6164
6165fn match_style(
6166    resolved: &lattice_host::ui::theme::ResolvedTheme,
6167    ids: &lattice_host::ui::theme::BuiltinElementIds,
6168) -> TuiStyle {
6169    // T.6: the current search match resolves from `search.current` as a
6170    // BACKGROUND tint (both renderers agree; the legacy yellow-fg
6171    // recolor is retired).
6172    let bg = resolved
6173        .get(ids.search_current)
6174        .bg
6175        .map(crate::theme::host_color_to_ratatui)
6176        .unwrap_or(Color::Yellow);
6177    TuiStyle::default().bg(bg)
6178}
6179
6180/// 4.4.g: splice `virtual_text` into `spans` at `byte_offset`
6181/// (utf-8 byte index within the *concatenated* span text, i.e.
6182/// the original line). When `byte_offset` lands strictly inside
6183/// a span, the span is split on the byte boundary; when it
6184/// lands at a span boundary (or past the end), the virtual
6185/// text inserts cleanly between spans without splitting.
6186///
6187/// `byte_offset` past the end of all spans appends -- the
6188/// caller's responsibility to convert LSP utf-16 columns to
6189/// utf-8 bytes before passing in.
6190/// S3.c.4 (2026-05-26): visibility bumped to `pub(crate)` so
6191/// `cells_render::tests` can validate the splice walks cell-
6192/// derived spans correctly. Matches the precedent set by the
6193/// other overlay-engine fns.
6194pub(crate) fn splice_virtual_text_into_spans(
6195    spans: Vec<Span<'static>>,
6196    byte_offset: usize,
6197    virtual_text: String,
6198    virtual_style: TuiStyle,
6199) -> Vec<Span<'static>> {
6200    if virtual_text.is_empty() {
6201        return spans;
6202    }
6203    let mut out: Vec<Span<'static>> = Vec::with_capacity(spans.len() + 2);
6204    let mut cursor = 0usize;
6205    let mut spliced = false;
6206    for span in spans {
6207        let s = span.content.as_ref().to_string();
6208        let span_start = cursor;
6209        let span_end = cursor + s.len();
6210        if !spliced && byte_offset >= span_start && byte_offset <= span_end {
6211            if byte_offset == span_start {
6212                // Inject before this span.
6213                out.push(Span::styled(virtual_text.clone(), virtual_style));
6214                out.push(Span::styled(s, span.style));
6215            } else if byte_offset == span_end {
6216                // Inject after this span -- push the span first,
6217                // then the virtual text, then continue.
6218                out.push(Span::styled(s, span.style));
6219                out.push(Span::styled(virtual_text.clone(), virtual_style));
6220            } else {
6221                // Split inside this span on the byte boundary.
6222                let prefix = s[..byte_offset - span_start].to_string();
6223                let suffix = s[byte_offset - span_start..].to_string();
6224                out.push(Span::styled(prefix, span.style));
6225                out.push(Span::styled(virtual_text.clone(), virtual_style));
6226                out.push(Span::styled(suffix, span.style));
6227            }
6228            spliced = true;
6229        } else {
6230            out.push(Span::styled(s, span.style));
6231        }
6232        cursor = span_end;
6233    }
6234    if !spliced {
6235        // Offset past every span -- append at the line end.
6236        out.push(Span::styled(virtual_text, virtual_style));
6237    }
6238    out
6239}
6240
6241/// 4.4.g: style for inlay-hint virtual text. Dimmed inline so
6242/// the user can spot it as "annotation, not actual buffer
6243/// content" -- italic + dim gray on default bg. Kind-specific
6244/// hue could differentiate type vs parameter hints in a
6245/// follow-up; v1 keeps a single style for simplicity.
6246fn inlay_hint_style(
6247    resolved: &lattice_host::ui::theme::ResolvedTheme,
6248    ids: &lattice_host::ui::theme::BuiltinElementIds,
6249) -> TuiStyle {
6250    // T.6: inlay-hint fg resolves from `inlay.hint` (shared with GPUI's
6251    // `inlay_color`). The italic modifier reads as "annotation, not
6252    // actual buffer content".
6253    let fg = resolved
6254        .get(ids.inlay_hint)
6255        .fg
6256        .map(crate::theme::host_color_to_ratatui)
6257        .unwrap_or(Color::DarkGray);
6258    TuiStyle::default().fg(fg).add_modifier(Modifier::ITALIC)
6259}
6260
6261/// Style for ONE spliced inlay row — the row's own element, not a
6262/// blanket `inlay.hint`.
6263///
6264/// The splice above re-inserts virtual text that
6265/// `display_line_to_source_spans` deliberately dropped, so the run's
6266/// style does not survive the round trip and has to be resolved again
6267/// here. It was resolved as [`inlay_hint_style`] unconditionally, which
6268/// is right for an LSP hint and wrong for every other producer: DL.3a
6269/// gave `InlayHintRow` a real style precisely so a producer with its own
6270/// vocabulary paints in it, and the cells worker and GPUI's
6271/// `display_run_to_synthetic_cell` both honour it. This peer did not, so
6272/// `directory-listing-mode`'s per-language icons all painted one grey —
6273/// the published data was correct and only the TUI's paint discarded it.
6274///
6275/// Italic stays exclusive to [`lattice_syntax::Style::InlayHint`]. It is
6276/// the "annotation, not buffer content" cue for an LSP hint; a listing's
6277/// icon glyph IS the row's content, and slanting a devicon just smears it.
6278fn inlay_row_style(
6279    resolved: &lattice_host::ui::theme::ResolvedTheme,
6280    ids: &lattice_host::ui::theme::BuiltinElementIds,
6281    style: lattice_syntax::Style,
6282) -> TuiStyle {
6283    if matches!(style, lattice_syntax::Style::InlayHint) {
6284        return inlay_hint_style(resolved, ids);
6285    }
6286    let s = lattice_host::ui::theme::resolve_syntax_style(resolved, ids, style);
6287    let mut out = TuiStyle::default();
6288    if let Some(fg) = s.fg {
6289        out = out.fg(crate::theme::host_color_to_ratatui(fg));
6290    }
6291    // An element may carry weight/emphasis of its own (`listing.dir` is
6292    // bold), and dropping those would make a themed element half-applied.
6293    if s.modifiers.bold {
6294        out = out.add_modifier(Modifier::BOLD);
6295    }
6296    if s.modifiers.italic {
6297        out = out.add_modifier(Modifier::ITALIC);
6298    }
6299    if s.modifiers.dim {
6300        out = out.add_modifier(Modifier::DIM);
6301    }
6302    if s.modifiers.underline {
6303        out = out.add_modifier(Modifier::UNDERLINED);
6304    }
6305    out
6306}
6307
6308/// L4a.3 (lsp-architecture.md §15): style for the inline end-of-line
6309/// diagnostic summary. Reuses the gutter's per-severity themed style
6310/// (so the eol text colour matches the gutter glyph exactly) and adds
6311/// italic to read as virtual, non-document text — the same cue inlay
6312/// hints use. `severity_rank` is Error = 0 … Hint = 3 (matching
6313/// `lattice_lsp`'s `severity_rank` / the published summary).
6314fn diagnostic_summary_style(severity_rank: u8, view: &FrameView<'_>) -> TuiStyle {
6315    let theme = &view.app.theme;
6316    let base = match severity_rank {
6317        0 => theme.diagnostic_error_style,
6318        1 => theme.diagnostic_warning_style,
6319        2 => theme.diagnostic_info_style,
6320        _ => theme.diagnostic_hint_style,
6321    };
6322    base.add_modifier(Modifier::ITALIC)
6323}
6324
6325/// 4.4.h: apply LSP semantic styling to the spans intersecting
6326/// `[overlay_start, overlay_end)`. Replaces fg + folds in
6327/// modifiers WITHOUT clobbering existing bg / underline /
6328/// reverse from earlier passes (tree-sitter set bg = None
6329/// commonly; visual / hlsearch / diagnostics overlays may
6330/// have set bg). Same span-splitting machinery as
6331/// `apply_match_overlay`.
6332///
6333/// S3.c.2 (2026-05-26): visibility bumped to `pub(crate)` so
6334/// `cells_render::tests` can validate the overlay's behaviour
6335/// against cell-derived bodies. Matches the existing precedent
6336/// set by `apply_whitespace_decoration`.
6337pub(crate) fn apply_semantic_token_overlay(
6338    spans: Vec<Span<'static>>,
6339    overlay_start: usize,
6340    overlay_end: usize,
6341    fg: Color,
6342    modifiers: Modifier,
6343) -> Vec<Span<'static>> {
6344    let mut out: Vec<Span<'static>> = Vec::with_capacity(spans.len() + 2);
6345    let mut cursor = 0usize;
6346    for span in spans {
6347        let s = span.content.as_ref().to_string();
6348        let span_start = cursor;
6349        let span_end = cursor + s.len();
6350        let overlap_start = span_start.max(overlay_start);
6351        let overlap_end = span_end.min(overlay_end);
6352        if overlap_start >= overlap_end {
6353            out.push(Span::styled(s, span.style));
6354        } else {
6355            if overlap_start > span_start {
6356                let pre = s[..overlap_start - span_start].to_string();
6357                out.push(Span::styled(pre, span.style));
6358            }
6359            let mid = s[overlap_start - span_start..overlap_end - span_start].to_string();
6360            // Merge: keep prior bg / underline / reverse;
6361            // override fg + add modifier bits.
6362            let merged = span.style.fg(fg).add_modifier(modifiers);
6363            out.push(Span::styled(mid, merged));
6364            if overlap_end < span_end {
6365                let post = s[overlap_end - span_start..].to_string();
6366                out.push(Span::styled(post, span.style));
6367            }
6368        }
6369        cursor = span_end;
6370    }
6371    out
6372}
6373
6374/// 4.4.h: pick a foreground color for a semantic-token kind.
6375/// Names are the LSP-standard token-type strings (see
6376/// `SemanticTokenType` consts in `lsp-types`). Servers may
6377/// declare custom token types beyond the standard set; those
6378/// fall through to the default magenta so they're at least
6379/// distinguishable from un-styled text.
6380///
6381/// Modifiers are folded into the style via
6382/// `apply_semantic_token_modifiers` (italic / bold / etc.);
6383/// this fn only chooses the hue.
6384fn semantic_token_color(kind: &str) -> Color {
6385    match kind {
6386        "keyword" | "controlKeyword" => Color::Magenta,
6387        "type" | "class" | "struct" | "interface" | "enum" | "typeParameter" => Color::Cyan,
6388        "function" | "method" | "macro" => Color::Yellow,
6389        "string" => Color::Green,
6390        "number" => Color::LightYellow,
6391        "comment" => Color::DarkGray,
6392        "operator" => Color::LightCyan,
6393        "variable" | "parameter" | "property" | "enumMember" => Color::White,
6394        "namespace" | "modifier" => Color::LightMagenta,
6395        _ => Color::Magenta,
6396    }
6397}
6398
6399/// 4.4.h: apply LSP modifier bits to a base style. `static`,
6400/// `readonly`, `deprecated` etc. carry visual cues
6401/// (italic / strike-through). Idempotent; missing modifiers
6402/// leave the style untouched.
6403fn apply_semantic_token_modifiers(mut style: TuiStyle, modifiers: &[String]) -> TuiStyle {
6404    for m in modifiers {
6405        match m.as_str() {
6406            "deprecated" => style = style.add_modifier(Modifier::CROSSED_OUT),
6407            "readonly" | "static" => style = style.add_modifier(Modifier::ITALIC),
6408            "defaultLibrary" => style = style.add_modifier(Modifier::DIM),
6409            _ => {}
6410        }
6411    }
6412    style
6413}
6414
6415// Slice A.2b.2: `inlay_hint_label_text` is no longer imported here
6416// — the per-line splice block previously called it to flatten LSP
6417// inlay-hint labels, but that flattening now happens once per
6418// publish on the host (`Editor::build_active_inlay_hints`) and the
6419// renderer reads `rs.syntax.inlay_hints` with the text already
6420// flattened. The function still exists at `lattice_lsp::
6421// inlay_hint_label_text` for any other callers.
6422
6423/// Trailing-side padding cells between the gutter's content and the
6424/// buffer column. Layout of the three cells: one separator space, the
6425/// fold-glyph slot, then one more space so the glyph doesn't run flush
6426/// against the code (`_99_▸_code`). The extra gap makes the fold marker
6427/// read as a distinct affordance rather than part of the text.
6428const GUTTER_TRAILING_PAD: u32 = 3;
6429
6430fn gutter_width(line_count: u32) -> u32 {
6431    // Layout: 1 cell leading pad + N digits + GUTTER_TRAILING_PAD
6432    // (separator space + fold-glyph slot + trailing gap). For
6433    // line_count = 99 and pad = 3 that's "_99_▸_" => 6 cells.
6434    let digits = line_count.max(1).ilog10() + 1;
6435    digits + 1 + GUTTER_TRAILING_PAD
6436}
6437
6438/// Resolved fold-marker colours for a pane, one per marker state.
6439/// Themed via `gutter.fold.open` / `gutter.fold.closed`; the GPUI peer
6440/// resolves the same two elements. `Copy` so it threads cheaply through
6441/// the per-line gutter path.
6442#[derive(Debug, Clone, Copy)]
6443struct FoldColors {
6444    open: Color,
6445    closed: Color,
6446}
6447
6448/// Pick the gutter fold marker for a buffer line: `▸` + the closed
6449/// colour when the line begins a closed fold, `▾` + the open colour when
6450/// it begins an open fold, or `None` when the line is unaffiliated with
6451/// any fold start (`docs/user/folding.md`). Shows a marker on every
6452/// foldable head — open or closed — matching the GPUI peer. Even on a
6453/// gutterless / centred buffer (help, the dashboard) the marker renders
6454/// just before the text: the centring pad is folded into `gutter_w`, so
6455/// `format_gutter_cell` right-aligns the glyph against the content.
6456fn fold_glyph_for(
6457    view: &FrameView<'_>,
6458    line_idx: u32,
6459    colors: FoldColors,
6460) -> Option<(char, Color)> {
6461    let f = view.fold_start_at_any(line_idx)?;
6462    Some(if f.closed {
6463        ('▸', colors.closed)
6464    } else {
6465        ('▾', colors.open)
6466    })
6467}
6468
6469/// Build the gutter cell as styled spans. With no fold marker the whole
6470/// cell is one dim-gutter span; with a marker the glyph is split into its
6471/// own themed span (the `… 99 ▸ ` layout puts the glyph second-from-last,
6472/// with a separator space before and a trailing gap after), so the fold
6473/// colour applies to the glyph alone and the line number keeps the
6474/// gutter tone.
6475fn gutter_spans(label: &str, width: u32, marker: Option<(char, Color)>) -> Vec<Span<'static>> {
6476    let num_style = TuiStyle::default().fg(Color::DarkGray);
6477    let cell = format_gutter_cell(label, width, marker.map(|(g, _)| g));
6478    let Some((_, color)) = marker else {
6479        return vec![Span::styled(cell, num_style)];
6480    };
6481    // `cell` ends `…{sep}{glyph}{trailing}` — split on chars so the
6482    // multi-byte glyph (`▸`/`▾`, 3 bytes) is handled cleanly.
6483    let chars: Vec<char> = cell.chars().collect();
6484    let n = chars.len();
6485    let prefix: String = chars[..n - 2].iter().collect();
6486    let glyph: String = chars[n - 2..n - 1].iter().collect();
6487    let trailing: String = chars[n - 1..].iter().collect();
6488    vec![
6489        Span::styled(prefix, num_style),
6490        Span::styled(glyph, TuiStyle::default().fg(color)),
6491        Span::styled(trailing, num_style),
6492    ]
6493}
6494
6495/// Format the gutter cell text for a numbered line.
6496/// Layout: `[leading_pad][label][separator][glyph_or_space]`.
6497/// The separator is one plain space sitting between the line
6498/// number and the rightmost cell so digits don't run flush against
6499/// the fold glyph; the glyph (or a plain space when no fold starts
6500/// on this line) occupies the rightmost cell, immediately
6501/// adjacent to the buffer column. This mirrors vim's
6502/// `signcolumn`-on-the-right convention -- e.g. ` 99 ▸` for a
6503/// closed fold's heading.
6504fn format_gutter_cell(label: &str, width: u32, glyph: Option<char>) -> String {
6505    use unicode_width::UnicodeWidthStr;
6506    // Rightmost cell is the glyph; one separator space sits before
6507    // the label. Leading pad fills the rest.
6508    //
6509    // Pad by the label's DISPLAY WIDTH, not its byte length: the wrap
6510    // continuation marker `↪` (U+21AA) is 3 bytes but occupies a single
6511    // terminal cell, so `label.len()` under-counts it by 2 and the
6512    // gutter comes out one cell short (the extra byte-vs-cell gap gets
6513    // clamped by `saturating_sub`). That made wrapped continuation rows
6514    // paint one column left of segment 0's body and, because the cursor
6515    // math uses segment 0's `gutter_w`, put the cursor one cell right of
6516    // the glyph on every wrapped line. For ASCII numeric labels
6517    // display-width == byte-len, so the numbered gutter is unchanged.
6518    let label_cols = UnicodeWidthStr::width(label);
6519    // 3 trailing cells: separator space + glyph + trailing gap.
6520    let leading = (width as usize).saturating_sub(label_cols + 3);
6521    let g = glyph.unwrap_or(' ');
6522    format!("{:lead$}{label} {g} ", "", lead = leading)
6523}
6524
6525fn render_gutter(line_idx: u32, width: u32, marker: Option<(char, Color)>) -> Vec<Span<'static>> {
6526    let n = (line_idx + 1).to_string();
6527    gutter_spans(&n, width, marker)
6528}
6529
6530fn render_gutter_for(
6531    view: &FrameView<'_>,
6532    line_idx: u32,
6533    width: u32,
6534    cursor_line: u32,
6535    display_line_numbers: Option<&[u32]>,
6536    fold_colors: FoldColors,
6537) -> Vec<Span<'static>> {
6538    let marker = fold_glyph_for(view, line_idx, fold_colors);
6539    if !view.show_line_numbers {
6540        // No-numbers gutter: glyph (or empty) at the inner edge,
6541        // GUTTER_TRAILING_PAD - 1 trailing spaces, the rest leading
6542        // padding. The layout still aligns with the numbered case
6543        // so toggling `:set number` doesn't shift content.
6544        return gutter_spans("", width, marker);
6545    }
6546    // K.4.6 follow-up (2026-06-02): consult the substrate's
6547    // composed→source row map (`display_line_numbers`) when
6548    // present. Multibuffer publishes it; regular Documents
6549    // return None (their composed row IS their source line).
6550    // Substrate-aligned per [[feedback_buffers_no_special_case]]
6551    // — the renderer checks the published mapping, not BufferKind.
6552    //
6553    // 2026-06-02 follow-up: `cursor_line` + `display_line_numbers`
6554    // are now passed by the caller (per-pane state) so this fn
6555    // works correctly for inactive panes too. Active path passes
6556    // `app.ad().cursor.line` + `app.ad().display_line_numbers`;
6557    // inactive path passes `pane.cursor.line` + the inactive
6558    // handle's own `display_line_numbers()`. No more `app.ad()`
6559    // reads — the gutter stays per-pane consistent.
6560    let display_line_idx: u32 = if let Some(map) = display_line_numbers {
6561        // Multibuffer: composed_row → source line. Out-of-bounds
6562        // is theoretically possible during transient renders
6563        // mid-recompose; fall back to identity rather than
6564        // panic.
6565        *map.get(line_idx as usize).unwrap_or(&line_idx)
6566    } else {
6567        line_idx
6568    };
6569    // K.4.6 follow-up (2026-06-02): suppress relativenumber on
6570    // multibuffer views — relative distance across non-
6571    // contiguous source rows is meaningless. Matches Zed.
6572    let multibuffer_mode = display_line_numbers.is_some();
6573    if !view.relative_line_numbers || line_idx == cursor_line || multibuffer_mode {
6574        return render_gutter(display_line_idx, width, marker);
6575    }
6576    let dist = line_idx.abs_diff(cursor_line);
6577    let n = dist.to_string();
6578    gutter_spans(&n, width, marker)
6579}
6580
6581/// Width of the diagnostic-severity column prepended to the
6582/// PU.1b-1a (`signcolumn`): total width of the gutter sign columns for `view`,
6583/// or `0` when the pane's resolved `signcolumn=no`.
6584///
6585/// SG.4b: ONE cell per registered gutter column — the two hardcoded widths
6586/// (`DIAG_GUTTER_WIDTH` + `DIFF_SIGN_GUTTER_WIDTH`) are gone with the two
6587/// hardcoded columns. The count is `BUILTIN_SIGN_COLUMNS.len()`, which is 2,
6588/// so the geometry is byte-identical to what it replaced; the difference is
6589/// that a third column would now widen the gutter by construction rather than
6590/// by someone remembering to add a constant here.
6591///
6592/// The single gate every compose site reads, so the width math (`buffer_w`,
6593/// cursor `wrap_width` / `col`) and the per-line prefix cells stay in lockstep
6594/// — a buffer with `signcolumn=no` (help-mode, synthetic buffers) drops every
6595/// sign cell and renders gutterless, with the renderer never branching on
6596/// buffer kind.
6597fn sign_columns_width(view: &FrameView<'_>) -> u32 {
6598    if view.sign_column {
6599        lattice_mode::BUILTIN_SIGN_COLUMNS.len() as u32
6600    } else {
6601        0
6602    }
6603}
6604
6605/// D.3.b.1 (2026-05-29): iterate `VirtualRowMatrix` rows
6606/// anchored at `line` with the given `position`. The matrix
6607/// keeps rows sorted by `(anchor_line, position)` with
6608/// `Above < Below`, so this is a single contiguous slice we
6609/// just `take_while` over.
6610fn virtual_rows_at<'a>(
6611    matrix: &'a lattice_cells::VirtualRowMatrix,
6612    line: u32,
6613    position: lattice_cells::AnchorPosition,
6614) -> impl Iterator<Item = &'a lattice_cells::VirtualRow> + 'a {
6615    let start = matrix.first_row_at_or_after(line) as usize;
6616    matrix.rows[start..]
6617        .iter()
6618        .take_while(move |r| r.anchor_line == line)
6619        .filter(move |r| r.position == position)
6620        // Pinned rows (sticky headerlines + the branding masthead) are
6621        // rendered at the pane top in the pre-pass; skip them here so they
6622        // don't double-paint in the content loop.
6623        .filter(|r| !r.kind.is_pinned())
6624}
6625
6626/// D.3.b.1: render a virtual row as a ratatui `Line`.
6627/// Emits the same blank severity + diff-sign + gutter prefix
6628/// as a document row so the body column lines up exactly,
6629/// then renders the row's cells as a single styled span with
6630/// a dim-red background (the "this content existed in
6631/// baseline but is gone / replaced in current" convention
6632/// borrowed from Vim's `:diff` and GitHub-style diffs).
6633/// Empty `cells` (D.3.a's empty placeholder) still emit a
6634/// row of the correct width so the deletion appears as a
6635/// visible gap.
6636/// TC.8: a virtual row's gutter — the document gutter when the row carries a
6637/// source line, the blank one otherwise.
6638///
6639/// Built by `render_gutter` rather than formatted here so the two cannot
6640/// drift: a context row whose digits sit one column off from the code beneath
6641/// it reads as a layout bug, and the only way to be sure they agree is to run
6642/// the same formatter. The blank branch is the same total width by
6643/// construction (`format_gutter_cell` pads to `width`), so toggling
6644/// `line-numbers` never shifts the strip.
6645fn virtual_row_gutter_spans(
6646    gutter_line: Option<u32>,
6647    gutter_w: u32,
6648    gutter_fg: Option<u32>,
6649) -> Vec<Span<'static>> {
6650    match gutter_line {
6651        Some(line) => {
6652            let mut spans = render_gutter(line, gutter_w, None);
6653            // TC.11: recolour rather than re-format. `render_gutter` owns the
6654            // LAYOUT (which is the thing that must match the document gutter
6655            // exactly); the theme owns only the colour, so the two concerns
6656            // cannot drift into each other.
6657            if let Some(rgb) = gutter_fg {
6658                let c = Color::Rgb(
6659                    ((rgb >> 16) & 0xff) as u8,
6660                    ((rgb >> 8) & 0xff) as u8,
6661                    (rgb & 0xff) as u8,
6662                );
6663                for span in &mut spans {
6664                    span.style = span.style.fg(c);
6665                }
6666            }
6667            spans
6668        }
6669        None => vec![Span::styled(
6670            " ".repeat(gutter_w as usize),
6671            TuiStyle::default().fg(Color::DarkGray),
6672        )],
6673    }
6674}
6675
6676fn render_virtual_row(
6677    view: &FrameView<'_>,
6678    vrow: &lattice_cells::VirtualRow,
6679    gutter_w: u32,
6680    body_width: u32,
6681) -> Line<'static> {
6682    let severity_blank = Span::styled(" ".to_string(), TuiStyle::default());
6683    let diff_sign_blank = Span::styled(" ".to_string(), TuiStyle::default());
6684    let gutter = virtual_row_gutter_spans(vrow.gutter_line, gutter_w, vrow.gutter_fg);
6685    // D.3.b.2 (2026-05-29): emit per-cell spans with run
6686    // coalescing — adjacent cells sharing the same `fg`
6687    // merge into a single styled Span so an 80-char
6688    // baseline line produces ~5 spans, not 80.
6689    //
6690    // D.6.i (2026-05-31): backdrop selection by `vrow.kind`.
6691    // `vrow.bg` (0xRRGGBB u32) overrides the kind-based default
6692    // when `Some` — sticky rows supply their own retro HUD palette.
6693    // Deletion blocks / generic rows fall back to the theme's
6694    // diff_deletion_block_bg; filler and sticky rows without an
6695    // explicit bg have no backdrop.
6696    //
6697    // `cell.fg = 0` means "use terminal default" (renderer
6698    // leaves fg unset).
6699    let bg: Option<Color> = vrow
6700        .bg
6701        .map(|rgb| {
6702            Color::Rgb(
6703                ((rgb >> 16) & 0xff) as u8,
6704                ((rgb >> 8) & 0xff) as u8,
6705                (rgb & 0xff) as u8,
6706            )
6707        })
6708        .or(match vrow.kind {
6709            lattice_cells::VirtualRowKind::DeletionBlock
6710            | lattice_cells::VirtualRowKind::Generic => Some(view.app.theme.diff_deletion_block_bg),
6711            // Filler/Sticky/BrandingBlock: no kind backdrop. The TUI paints
6712            // the branding cells as its terminal-art treatment (half-block
6713            // mark); no full-row backdrop behind them.
6714            lattice_cells::VirtualRowKind::Filler
6715            | lattice_cells::VirtualRowKind::Sticky
6716            // MG.26b: an annotation carries no backdrop of its own —
6717            // a blame heading tinted like a deletion would read as one.
6718            | lattice_cells::VirtualRowKind::Annotation
6719            // IM.3: a media block's rows carry their alt text as ordinary
6720            // cells, which is the TUI's whole rendering of it. A deletion
6721            // backdrop behind them would read as removed lines; the block
6722            // supplies its own `bg` when it wants a box.
6723            | lattice_cells::VirtualRowKind::MediaBlock
6724            | lattice_cells::VirtualRowKind::BrandingBlock => None,
6725        });
6726    let mut spans: Vec<Span<'static>> = Vec::new();
6727    let mut run_text = String::new();
6728    let mut run_fg: Option<u32> = None;
6729    let flush = |text: &mut String, fg: Option<u32>, spans: &mut Vec<Span<'static>>| {
6730        if text.is_empty() {
6731            return;
6732        }
6733        let mut style = TuiStyle::default();
6734        if let Some(c) = bg {
6735            style = style.bg(c);
6736        }
6737        if let Some(rgb) = fg
6738            && rgb != 0
6739        {
6740            let r = ((rgb >> 16) & 0xff) as u8;
6741            let g = ((rgb >> 8) & 0xff) as u8;
6742            let b = (rgb & 0xff) as u8;
6743            style = style.fg(Color::Rgb(r, g, b));
6744        }
6745        spans.push(Span::styled(std::mem::take(text), style));
6746    };
6747    for cell in vrow.cells.iter() {
6748        let Some(ch) = char::from_u32(cell.codepoint) else {
6749            continue;
6750        };
6751        let cell_fg = Some(cell.fg);
6752        if run_fg.is_none() {
6753            run_fg = cell_fg;
6754        }
6755        if run_fg != cell_fg {
6756            flush(&mut run_text, run_fg, &mut spans);
6757            run_fg = cell_fg;
6758        }
6759        run_text.push(ch);
6760    }
6761    flush(&mut run_text, run_fg, &mut spans);
6762    // Pad the row out to body_width so the backdrop (when present — deletion
6763    // blocks; not filler rows) covers the full body column. Horizontal
6764    // centring is handled upstream by the gutter (content_left_pad), not here.
6765    let used: u32 = spans.iter().map(|s| s.content.chars().count() as u32).sum();
6766    if used < body_width {
6767        let mut pad_style = TuiStyle::default();
6768        if let Some(c) = bg {
6769            pad_style = pad_style.bg(c);
6770        }
6771        spans.push(Span::styled(
6772            " ".repeat((body_width - used) as usize),
6773            pad_style,
6774        ));
6775    }
6776    let mut out: Vec<Span<'static>> = Vec::with_capacity(3 + spans.len());
6777    out.push(severity_blank);
6778    out.push(diff_sign_blank);
6779    out.extend(gutter);
6780    out.extend(spans);
6781    Line::from(out)
6782}
6783
6784/// D.3.e (2026-05-29): line background tint colour for
6785/// `line_idx`, if any. Reuses the same `DiffSignMap` data
6786/// path as `render_diff_sign_cell` — one classification
6787/// source for both decorations. Returns `None` when no
6788/// session, no hunk on this row, or the hunk is `Remove`
6789/// (which has no current-side row to tint; the deletion
6790/// block from D.3.b is the visible surface for removes).
6791///
6792/// Tint colours are intentionally dim — the tint sits BEHIND
6793/// source text, and a saturated background would crush
6794/// foreground colours. Hardcoded for v1; future theme
6795/// integration routes through `DiffAdd` / `DiffChange` /
6796/// `DiffRemove` theme entries (deferred to the theme
6797/// expansion slice).
6798fn diff_tint_bg(
6799    view: &FrameView<'_>,
6800    buffer_id: crate::buffers::BufferId,
6801    line_idx: u32,
6802) -> Option<Color> {
6803    use lattice_host::diff::overlay::DiffSignKind;
6804    let rs = view.app.render_state.load();
6805    // D-fix.3b: tint each pane from ITS buffer's sign map (per-pane), so a
6806    // side-by-side diff colours BOTH sides — baseline (left) shows
6807    // removed/changed, proposed (right) shows added/changed. Colours resolve
6808    // from the active theme.
6809    let sign_map = rs.diff.sign_maps.get(&buffer_id)?;
6810    match sign_map.sign_at(line_idx)? {
6811        // D.3.b.3: read tint colours from the theme.
6812        DiffSignKind::Add => Some(view.app.theme.diff_add_line_bg),
6813        DiffSignKind::Change => Some(view.app.theme.diff_change_line_bg),
6814        // D-fix.3b: the baseline pane's removed lines tint red (was None —
6815        // only the current side was ever shown before pane-group tints).
6816        DiffSignKind::Remove => Some(view.app.theme.diff_remove_line_bg),
6817        // D.6.f (2026-05-31): three-way Conflict gets its
6818        // own tint so a 30-row file with one conflict block
6819        // is readable at a glance.
6820        DiffSignKind::Conflict => Some(view.app.theme.diff_conflict_line_bg),
6821    }
6822}
6823
6824/// MC.3: full-width background for a line inside a fenced/indented code block.
6825/// Reads the per-buffer code-block line set from the render-state snapshot
6826/// (sorted, so `binary_search` is O(log n)) and returns the resolved
6827/// `syntax.code_block` background for `line_idx`, or `None` when the line is
6828/// not in a code block. Painted via [`apply_diff_tint`] like the diff tint.
6829fn code_block_tint_bg(
6830    view: &FrameView<'_>,
6831    buffer_id: crate::buffers::BufferId,
6832    line_idx: u32,
6833) -> Option<Color> {
6834    let rs = view.app.render_state.load();
6835    let lines = rs.code_block_lines.get(&buffer_id)?;
6836    lines.binary_search(&line_idx).ok()?;
6837    Some(view.app.theme.code_block_bg)
6838}
6839
6840/// D.3.e: layer a background tint over every span in
6841/// `spans`, preserving each span's foreground colour and
6842/// modifiers. Used to apply diff-line backgrounds on top of
6843/// syntax-highlighted source content.
6844fn apply_diff_tint(spans: Vec<Span<'static>>, bg: Color) -> Vec<Span<'static>> {
6845    spans
6846        .into_iter()
6847        .map(|s| {
6848            // A span that already carries its OWN background keeps it.
6849            //
6850            // This function used to set `bg` unconditionally, and the
6851            // row tint is applied last — so it erased every narrower
6852            // background underneath it. Intra-line diff refinement was
6853            // the visible casualty: it computed correctly, reached the
6854            // run, and was painted onto the style, and then this
6855            // overwrote it one step later. `cells_render`'s
6856            // `display_run_to_style` states the contract this broke —
6857            // "a refined run overrides its row's diff tint with a
6858            // stronger one".
6859            //
6860            // The rule generalises rather than special-casing
6861            // refinement: the row tint is the WIDER, weaker statement
6862            // ("this line changed"); anything that painted a narrower
6863            // background — refinement, a search match, the visual
6864            // selection — is the more specific one and wins. Every
6865            // comparable editor resolves it that way, and a selection
6866            // that vanishes because it happens to fall on a diff line
6867            // is the same bug wearing different clothes.
6868            let new_style = if s.style.bg.is_some() {
6869                s.style
6870            } else {
6871                s.style.bg(bg)
6872            };
6873            Span::styled(s.content.into_owned(), new_style)
6874        })
6875        .collect()
6876}
6877
6878/// T.7 (2026-07-22): compilation location tint. Reads the
6879/// per-buffer location-line index from the render-state snapshot and
6880/// returns `(path_byte_start, path_byte_end, bg, fg)` for `line_idx`
6881/// if it carries a file-location link.
6882fn compilation_location_tint(
6883    view: &FrameView<'_>,
6884    buffer_id: crate::buffers::BufferId,
6885    line_idx: u32,
6886) -> Option<(u32, u32, Color, Color)> {
6887    let rs = view.app.render_state.load();
6888    let entries = rs.compilation_location_lines.get(&buffer_id)?;
6889    let entry = entries.iter().find(|(l, _, _)| *l == line_idx)?;
6890    let (bg, fg) = *rs.compilation_theme_colors;
6891    let bg = Color::Rgb(
6892        (bg >> 16) as u8,
6893        ((bg >> 8) & 0xff) as u8,
6894        (bg & 0xff) as u8,
6895    );
6896    let fg = Color::Rgb(
6897        (fg >> 16) as u8,
6898        ((fg >> 8) & 0xff) as u8,
6899        (fg & 0xff) as u8,
6900    );
6901    Some((entry.1, entry.2, bg, fg))
6902}
6903
6904/// Apply compilation location link tint: set the background tint on
6905/// every span, and set the link foreground on spans whose byte range
6906/// overlaps `[path_byte_start, path_byte_end)`.
6907fn apply_compilation_location_tint(
6908    spans: Vec<Span<'static>>,
6909    path_byte_start: u32,
6910    path_byte_end: u32,
6911    bg: Color,
6912    fg: Color,
6913) -> Vec<Span<'static>> {
6914    let mut byte_pos: usize = 0;
6915    spans
6916        .into_iter()
6917        .map(|s| {
6918            let span_len = s.content.len();
6919            let span_end = byte_pos + span_len;
6920            let inside_link =
6921                byte_pos < path_byte_end as usize && span_end > path_byte_start as usize;
6922            let style = s.style.bg(bg);
6923            let style = if inside_link { style.fg(fg) } else { style };
6924            byte_pos = span_end;
6925            Span::styled(s.content.into_owned(), style)
6926        })
6927        .collect()
6928}
6929
6930/// SG.4b — the cell for one gutter column on one line.
6931///
6932/// `sign` is that column's winning placement from the decoration pre-loop, or
6933/// `None` for a line nothing marked. There is exactly ONE of these now: the
6934/// separate `render_diagnostic_severity_cell` and `render_diff_sign_cell`
6935/// paths are gone, because a diagnostic and a hunk mark are signs whose
6936/// definitions carry their glyph, their theme element and their column. This
6937/// function cannot tell them apart and does not need to.
6938///
6939/// A retired id paints a BLANK: SG.1 retires ids rather than reusing them so
6940/// that a placement produced before an `undefine` paints nothing, where a
6941/// reused slot would have painted some later sign's glyph. A blank cell is a
6942/// visible absence; the wrong glyph is a lie.
6943fn render_sign_cell(sign: Option<lattice_mode::SignId>, view: &FrameView<'_>) -> Span<'static> {
6944    let blank = Span::styled(" ".to_string(), TuiStyle::default());
6945    let Some(sign) = sign else {
6946        return blank;
6947    };
6948    let rs = view.app.render_state.load();
6949    let Some(def) = rs.signs.registry.get(sign) else {
6950        return blank;
6951    };
6952    // The theme decides the COLOUR, the font capability decides the GLYPH.
6953    // Both palettes are the same cell width by the icon-degradation rule, so
6954    // toggling `ui.nerd_fonts` cannot shift the gutter's geometry.
6955    let glyph = def.glyph_char(view.app.theme.nerd_fonts).to_string();
6956    // An unregistered element falls back to `gutter.sign` rather than to no
6957    // style: a sign was placed to tell the user something, and painting it
6958    // invisibly is the one outcome that loses the information entirely
6959    // rather than merely showing it in the wrong tone.
6960    let element = rs
6961        .signs
6962        .elements
6963        .get(&sign)
6964        .copied()
6965        .unwrap_or(rs.theme_ids.gutter_sign);
6966    let style = rs
6967        .resolved_theme
6968        .get(element)
6969        .fg
6970        .map(crate::theme::host_color_to_ratatui)
6971        .map(|c| TuiStyle::default().fg(c))
6972        .unwrap_or_default();
6973    Span::styled(glyph, style)
6974}
6975
6976/// Diagnostics that overlap `line_idx` of `buffer_id`. Used by the
6977/// inline-underline overlay. Gated on `lsp-mode` (M.5.6); the
6978/// diagnostics layer keeps storing data when the mode is off, but
6979/// the renderer pretends none exist.
6980///
6981/// DR.3: takes `buffer_id` (was the active `_snap`) so inactive panes
6982/// underline their OWN buffer's diagnostics; the active pane passes
6983/// its own id (byte-identical).
6984pub(crate) fn diagnostics_on_line(
6985    view: &FrameView<'_>,
6986    buffer_id: crate::buffers::BufferId,
6987    line_idx: u32,
6988) -> Vec<LspDiagnostic> {
6989    // Slice 3c.extension.fold-rs: gate on the cached
6990    // `view.lsp_diagnostics_enabled` instead of the prior
6991    // per-line `app.lsp_diagnostics_mode_enabled_for(...)`
6992    // actor RPC.
6993    if !view.lsp_diagnostics_enabled {
6994        return Vec::new();
6995    }
6996    let app = view.app;
6997    let Some(uri) = app.buffer_uri(buffer_id) else {
6998        return Vec::new();
6999    };
7000    // Slice 3c.final.B.8: read via the already-published
7001    // `diagnostics.layer` sub-state — wait-free against the
7002    // supervisor's `ArcSwap`-backed snapshot. No actor round-trip.
7003    app.render_state
7004        .load()
7005        .diagnostics
7006        .layer
7007        .diagnostics_on_line(&uri, line_idx)
7008}
7009
7010/// M.7.3.b parameter bundle for the whitespace-decoration
7011/// pre-pass. Per-glyph `Option<char>` -- `None` ⇒ category
7012/// disabled. `style_normal` covers tab / leading / mid-text
7013/// space / EOL; `style_trailing` covers trailing whitespace
7014/// (separated because trailing is a lint signal where the
7015/// others are structural).
7016#[derive(Debug, Clone, Copy)]
7017pub(crate) struct WhitespaceDecoration {
7018    pub tab: Option<char>,
7019    pub trailing: Option<char>,
7020    pub leading: Option<char>,
7021    pub space: Option<char>,
7022    pub eol: Option<char>,
7023    pub style_normal: TuiStyle,
7024    pub style_trailing: TuiStyle,
7025}
7026
7027impl WhitespaceDecoration {
7028    /// Build from app + theme. Used at every render-line call
7029    /// site that wants whitespace decoration applied -- gating
7030    /// on `app.editor.option_cache.show_whitespace` is the caller's
7031    /// responsibility.
7032    fn from_app(app: &App) -> Self {
7033        // Slice 3c.final.B (group 2): read whitespace glyphs via
7034        // `app.ad().option_cache` (published mirror of
7035        // `editor.option_cache`).
7036        let oc = app.ad().option_cache;
7037        Self {
7038            tab: oc.whitespace_tab,
7039            trailing: oc.whitespace_trailing,
7040            leading: oc.whitespace_leading,
7041            space: oc.whitespace_space,
7042            eol: oc.whitespace_eol,
7043            style_normal: app.theme.whitespace_style,
7044            style_trailing: app.theme.whitespace_trailing_style,
7045        }
7046    }
7047
7048    /// Quick-test path: every glyph disabled ⇒ no work to do.
7049    /// Lets callers skip the post-pass walk when the user has
7050    /// turned `whitespace-show-mode` on but configured every
7051    /// category to empty (degenerate, but free to handle).
7052    fn is_noop(&self) -> bool {
7053        self.tab.is_none()
7054            && self.trailing.is_none()
7055            && self.leading.is_none()
7056            && self.space.is_none()
7057            && self.eol.is_none()
7058    }
7059}
7060
7061/// Classify a single character + its byte-offset within the
7062/// line, returning the `(glyph, style)` substitution if any
7063/// category fires. Precedence: trailing > tab > leading > space.
7064/// Returns `None` to leave the character unchanged.
7065fn classify_whitespace(
7066    ch: char,
7067    pos: usize,
7068    first_non_ws: usize,
7069    trailing_start: usize,
7070    d: &WhitespaceDecoration,
7071) -> Option<(char, TuiStyle)> {
7072    // Trailing wins: every whitespace byte in `[trailing_start,
7073    // line.len())` becomes trailing-marked.
7074    if pos >= trailing_start
7075        && (ch == ' ' || ch == '\t')
7076        && let Some(g) = d.trailing
7077    {
7078        return Some((g, d.style_trailing));
7079    }
7080    if ch == '\t' {
7081        // Tabs anywhere except in the trailing zone (handled
7082        // above) get the tab glyph.
7083        return d.tab.map(|g| (g, d.style_normal));
7084    }
7085    if ch == ' ' {
7086        if pos < first_non_ws {
7087            // Leading non-tab whitespace; emacs's `indentation`.
7088            if let Some(g) = d.leading {
7089                return Some((g, d.style_normal));
7090            }
7091        } else if pos < trailing_start {
7092            // Mid-text space; emacs's `space-mark`.
7093            if let Some(g) = d.space {
7094                return Some((g, d.style_normal));
7095            }
7096        }
7097    }
7098    None
7099}
7100
7101/// Apply whitespace-glyph substitution to a vector of styled
7102/// spans. Walks every char, classifies it via
7103/// [`classify_whitespace`], emits glyph-substituted spans where
7104/// categories fire and keeps original content otherwise. The
7105/// EOL glyph (if configured) appends as a final span after all
7106/// content. Output spans are width-equivalent to input spans
7107/// (one char in, one char out for substitutions).
7108///
7109/// The caller passes the original line text (unsubstituted)
7110/// for whitespace position classification -- spans hold byte
7111/// substrings of `line`, so byte-position tracking across spans
7112/// stays consistent with the original.
7113pub(crate) fn apply_whitespace_decoration(
7114    spans: Vec<Span<'static>>,
7115    line: &str,
7116    d: &WhitespaceDecoration,
7117) -> Vec<Span<'static>> {
7118    if d.is_noop() {
7119        return spans;
7120    }
7121    let bytes = line.as_bytes();
7122    let first_non_ws = bytes
7123        .iter()
7124        .position(|b| !b.is_ascii_whitespace())
7125        .unwrap_or(bytes.len());
7126    let trailing_start = bytes
7127        .iter()
7128        .rposition(|b| !b.is_ascii_whitespace())
7129        .map(|i| i + 1)
7130        .unwrap_or(0);
7131
7132    let mut out: Vec<Span<'static>> = Vec::with_capacity(spans.len());
7133    let mut pos = 0usize;
7134    for span in spans {
7135        let span_style = span.style;
7136        let content = span.content.into_owned();
7137        let mut accum = String::new();
7138        for ch in content.chars() {
7139            let ch_len = ch.len_utf8();
7140            match classify_whitespace(ch, pos, first_non_ws, trailing_start, d) {
7141                Some((glyph, style)) => {
7142                    if !accum.is_empty() {
7143                        out.push(Span::styled(std::mem::take(&mut accum), span_style));
7144                    }
7145                    let mut g = String::new();
7146                    g.push(glyph);
7147                    out.push(Span::styled(g, style));
7148                }
7149                None => accum.push(ch),
7150            }
7151            pos += ch_len;
7152        }
7153        if !accum.is_empty() {
7154            out.push(Span::styled(accum, span_style));
7155        }
7156    }
7157    if let Some(eol_glyph) = d.eol {
7158        let mut g = String::new();
7159        g.push(eol_glyph);
7160        out.push(Span::styled(g, d.style_normal));
7161    }
7162    out
7163}
7164
7165/// IG.3: the resolved indentation-guide paint state for one pane, for one
7166/// frame.
7167///
7168/// Built once per pane per frame — the glyph and both styles are constant
7169/// across the viewport, and the active block is picked from the cursor row
7170/// the pane already holds. That pick is the reason the worker publishes block
7171/// *extents* rather than a precomputed "is active" flag: moving the cursor
7172/// restyles one column here, and costs the worker nothing.
7173pub(crate) struct IndentGuideStyle {
7174    /// `None` ⇒ paint nothing (guides off, or the glyph set to empty).
7175    glyph: Option<char>,
7176    normal: TuiStyle,
7177    active: TuiStyle,
7178    /// Index into the layer's `blocks`, or `None` when the cursor is at
7179    /// top level or `display.indent-guides.active` is off.
7180    active_block: Option<u16>,
7181}
7182
7183impl IndentGuideStyle {
7184    pub(crate) fn resolve(
7185        app: &crate::app::App,
7186        guides: &lattice_host::indent_guides::IndentGuides,
7187        cursor_line: u32,
7188        resolved: &lattice_host::ui::theme::ResolvedTheme,
7189        ids: &lattice_host::ui::theme::BuiltinElementIds,
7190    ) -> Self {
7191        let oc = &app.ad().option_cache;
7192        let to_style = |e: lattice_host::ui::theme::ElementId| {
7193            let st = resolved.get(e);
7194            let mut out = TuiStyle::default();
7195            if let Some(fg) = st.fg {
7196                out = out.fg(crate::cells_render::rgb_u32_to_color(fg.to_rgb_u32(0)));
7197            }
7198            if st.modifiers.dim {
7199                out = out.add_modifier(Modifier::DIM);
7200            }
7201            out
7202        };
7203        Self {
7204            glyph: oc.indent_guide_char,
7205            normal: to_style(ids.indent_guide),
7206            active: to_style(ids.indent_guide_active),
7207            active_block: if oc.indent_guide_active {
7208                guides.active_block(cursor_line)
7209            } else {
7210                None
7211            },
7212        }
7213    }
7214
7215    fn style_for(&self, block: u16) -> TuiStyle {
7216        if self.active_block == Some(block) {
7217            self.active
7218        } else {
7219            self.normal
7220        }
7221    }
7222}
7223
7224/// Substitute the guide glyph into every column the worker marked.
7225///
7226/// Runs AFTER the horizontal clip, so `marks` (absolute display columns) are
7227/// translated by `leftcol` and dropped outside `[leftcol, leftcol + width)`.
7228/// The clip measures in bytes — see the call site — so substituting a
7229/// three-byte glyph before it would shorten the line; doing it after means the
7230/// pass and the user see the same columns.
7231///
7232/// No "is this column blank" check: the worker applies
7233/// [`lattice_core::indent_blocks::IndentBlock::paints_on`] before publishing,
7234/// so a mark is only ever emitted for a column that holds a space.
7235/// Re-deriving that here would be a second implementation of the rule, and
7236/// the one that disagreed would be the one painting over code.
7237///
7238/// Where a guide and a `:set list` leading-whitespace marker want the same
7239/// cell, the guide wins — it carries structure, the marker only says "space".
7240pub(crate) fn apply_indent_guides(
7241    spans: Vec<Span<'static>>,
7242    marks: &[lattice_host::indent_guides::GuideMark],
7243    style: &IndentGuideStyle,
7244    leftcol: u32,
7245    width: u32,
7246) -> Vec<Span<'static>> {
7247    let Some(glyph) = style.glyph else {
7248        return spans;
7249    };
7250    if marks.is_empty() {
7251        return spans;
7252    }
7253    // Body-local columns. `width` is `u32::MAX` under soft wrap, where the
7254    // body is deliberately left unclipped for the segmenter.
7255    let visible = |m: &lattice_host::indent_guides::GuideMark| -> Option<u32> {
7256        let col = (m.col as u32).checked_sub(leftcol)?;
7257        (col < width).then_some(col)
7258    };
7259    let mut out: Vec<Span<'static>> = Vec::with_capacity(spans.len() + marks.len() * 2);
7260    let mut col: u32 = 0;
7261    let mut next = 0usize;
7262    for span in spans {
7263        let span_style = span.style;
7264        let content = span.content.into_owned();
7265        let mut accum = String::new();
7266        for ch in content.chars() {
7267            // Marks arrive in ascending column order per row, so one forward
7268            // cursor covers the walk. Marks scrolled off the left are skipped
7269            // by the same step.
7270            while next < marks.len() && visible(&marks[next]).is_none_or(|c| c < col) {
7271                next += 1;
7272            }
7273            if next < marks.len() && visible(&marks[next]) == Some(col) {
7274                if !accum.is_empty() {
7275                    out.push(Span::styled(std::mem::take(&mut accum), span_style));
7276                }
7277                out.push(Span::styled(
7278                    glyph.to_string(),
7279                    style.style_for(marks[next].block),
7280                ));
7281                next += 1;
7282            } else {
7283                accum.push(ch);
7284            }
7285            col = col.saturating_add(1);
7286        }
7287        if !accum.is_empty() {
7288            out.push(Span::styled(accum, span_style));
7289        }
7290    }
7291    // Guides past the end of the rendered body. Only a blank row can reach
7292    // here: `paints_on` requires the column to be left of where the line's
7293    // text starts, so a line with content is always long enough to hold its
7294    // own guides. Padding a blank row is what carries a guide through the
7295    // blank lines inside a block.
7296    for mark in &marks[next..] {
7297        let Some(target) = visible(mark) else {
7298            continue;
7299        };
7300        if target < col {
7301            continue;
7302        }
7303        if target > col {
7304            out.push(Span::raw(" ".repeat((target - col) as usize)));
7305            col = target;
7306        }
7307        out.push(Span::styled(glyph.to_string(), style.style_for(mark.block)));
7308        col = col.saturating_add(1);
7309    }
7310    out
7311}
7312
7313// DR.2 (decoration-retention): `render_styled_line` (the legacy
7314// `StyledSpan` → ratatui renderer for the inactive-pane span path) was
7315// retired. Both panes now render bodies from the `DisplayMatrix` via
7316// `cells_render::display_line_to_source_spans`; `truncate_spans_to_width`
7317// (below) is still shared by both paths.
7318
7319/// Horizontal scroll (HS.1): drop the first `skip` display columns
7320/// from the body spans, then clip the remainder to `max_width`.
7321/// Byte-based, mirroring [`truncate_spans_to_width`]'s ASCII-width
7322/// model (the cells path feeds tab-/inlay-expanded text, so byte ≈
7323/// column for the common case; non-ASCII width is punted there too).
7324/// `skip == 0` is exactly `truncate_spans_to_width`.
7325fn clip_spans_horizontally(
7326    spans: Vec<Span<'static>>,
7327    skip: u32,
7328    max_width: u32,
7329) -> Vec<Span<'static>> {
7330    if skip == 0 {
7331        return truncate_spans_to_width(spans, max_width);
7332    }
7333    let mut remaining = skip as usize;
7334    let mut kept: Vec<Span<'static>> = Vec::with_capacity(spans.len());
7335    for span in spans {
7336        if remaining == 0 {
7337            kept.push(span);
7338            continue;
7339        }
7340        let s = span.content.as_ref();
7341        let len = s.len();
7342        if len <= remaining {
7343            remaining -= len;
7344        } else {
7345            // Drop `remaining` bytes from the front; land on a char
7346            // boundary so the retained tail is valid UTF-8.
7347            let mut cut = remaining;
7348            while cut < len && !s.is_char_boundary(cut) {
7349                cut += 1;
7350            }
7351            kept.push(Span::styled(s[cut..].to_string(), span.style));
7352            remaining = 0;
7353        }
7354    }
7355    truncate_spans_to_width(kept, max_width)
7356}
7357
7358fn truncate_spans_to_width(spans: Vec<Span<'static>>, max_width: u32) -> Vec<Span<'static>> {
7359    // Naive byte-based truncation. Adequate for ASCII; non-ASCII display
7360    // width is a real problem we punt on until we own a width-aware shaping
7361    // path (Phase 9 / rich-buffer).
7362    let mut out = Vec::with_capacity(spans.len());
7363    let mut budget = max_width as usize;
7364    for span in spans {
7365        if budget == 0 {
7366            break;
7367        }
7368        let s = span.content.as_ref().to_string();
7369        if s.len() <= budget {
7370            budget -= s.len();
7371            out.push(Span::styled(s, span.style));
7372        } else {
7373            let cut = s
7374                .char_indices()
7375                .map(|(i, _)| i)
7376                .take_while(|i| *i <= budget)
7377                .last()
7378                .unwrap_or(0);
7379            out.push(Span::styled(s[..cut].to_string(), span.style));
7380            break;
7381        }
7382    }
7383    out
7384}
7385
7386fn empty_marker_line(gutter_w: u32) -> Line<'static> {
7387    // Treat the `~` like a pseudo line-number label so its column
7388    // alignment matches `render_gutter`'s numbered output: leading
7389    // pad + `~` + GUTTER_TRAILING_PAD.
7390    // Prepended one-cell severity column blank (Phase 4.1.d.iii)
7391    // so the `~` lines align with body lines below the document.
7392    let cell = format_gutter_cell("~", gutter_w, None);
7393    Line::from(vec![
7394        Span::styled(" ".to_string(), TuiStyle::default()),
7395        Span::styled(cell, TuiStyle::default().fg(Color::DarkGray)),
7396    ])
7397}
7398
7399/// Like [`combine_prefixed`] but accepts a multi-span prefix -- used by
7400/// the LSP diagnostic gutter where the leading severity cell
7401/// has its own per-severity style and can't share a span with
7402/// the line-number gutter (which is always dim-darkgray).
7403fn combine_prefixed(
7404    prefix: Vec<Span<'static>>,
7405    gutter: Vec<Span<'static>>,
7406    mut body: Vec<Span<'static>>,
7407) -> Line<'static> {
7408    let mut all = Vec::with_capacity(prefix.len() + gutter.len() + body.len());
7409    all.extend(prefix);
7410    all.extend(gutter);
7411    all.append(&mut body);
7412    Line::from(all)
7413}
7414
7415/// Apply an underline overlay over a byte range of a line's
7416/// existing styled spans. Unlike [`apply_match_overlay`], this
7417/// PRESERVES the underlying span's foreground / background and
7418/// only ADDs the `UNDERLINED` modifier. Used for inline LSP
7419/// diagnostic decoration.
7420///
7421/// Why no underline-colour: setting an explicit underline colour
7422/// emits the SGR 58 / 59 extension codes (`\x1b[58:5:Nm` /
7423/// `\x1b[59m`). They're widely supported but not universally;
7424/// terminals that don't recognise them have produced
7425/// reproducible visual breakage where text on lines following
7426/// the diagnostic line rendered as if `fg = Color::Black` --
7427/// the parameters of the unrecognised sequence get swallowed
7428/// into subsequent SGR state and pin the foreground to a value
7429/// the user perceives as "the next several lines went black"
7430/// (the severity colour belongs in the gutter glyph; the body
7431/// underline is enough signal). Symptom cleared as soon as the
7432/// flagged line scrolled past the viewport. The severity-cell
7433/// gutter still carries the per-severity colour, so the user
7434/// sees which kind of diagnostic is on the line.
7435/// S3.c.3 (2026-05-26): visibility bumped to `pub(crate)` so
7436/// `cells_render::tests` can validate the overlay walks cell-
7437/// derived spans correctly.
7438pub(crate) fn apply_underline_overlay(
7439    spans: Vec<Span<'static>>,
7440    overlay_start: usize,
7441    overlay_end: usize,
7442    _severity_color: Color,
7443) -> Vec<Span<'static>> {
7444    let mut out: Vec<Span<'static>> = Vec::with_capacity(spans.len() + 2);
7445    let mut cursor = 0usize;
7446    for span in spans {
7447        let s = span.content.as_ref().to_string();
7448        let span_start = cursor;
7449        let span_end = cursor + s.len();
7450        let overlap_start = span_start.max(overlay_start);
7451        let overlap_end = span_end.min(overlay_end);
7452        if overlap_start >= overlap_end {
7453            out.push(Span::styled(s, span.style));
7454        } else {
7455            if overlap_start > span_start {
7456                let pre = s[..overlap_start - span_start].to_string();
7457                out.push(Span::styled(pre, span.style));
7458            }
7459            let mid = s[overlap_start - span_start..overlap_end - span_start].to_string();
7460            let mid_style = span.style.add_modifier(Modifier::UNDERLINED);
7461            out.push(Span::styled(mid, mid_style));
7462            if overlap_end < span_end {
7463                let post = s[overlap_end - span_start..].to_string();
7464                out.push(Span::styled(post, span.style));
7465            }
7466        }
7467        cursor = span_end;
7468    }
7469    out
7470}
7471
7472/// Number of buffer lines collapsed onto the visible head row of
7473/// `fold`. Thin wrapper over the shared [`lattice_host::folds::
7474/// folded_line_span`] so the TUI and GPUI renderers report identical
7475/// counts (see that function for the abut-vs-overlap chaining rule).
7476fn closed_fold_display_span(
7477    view: &FrameView<'_>,
7478    snap: &DocumentSnapshot,
7479    fold: &crate::app::Fold,
7480) -> u32 {
7481    lattice_host::folds::folded_line_span(
7482        view.folds.as_ref(),
7483        fold.start_line,
7484        fold.end_line,
7485        snap.buffer.content_line_count(),
7486    )
7487}
7488
7489/// Translate a buffer line into the corresponding visible row index
7490/// in the active pane, accounting for closed folds. Walks the same
7491/// "skip closed-fold interior" algorithm `compose_visible_lines`
7492/// uses to build the visible-line list.
7493///
7494/// If `target` is hidden by a closed fold, the result is the row
7495/// where that fold's heading renders -- so the cursor projection
7496/// always lands on a line the user can see.
7497///
7498/// Returns `None` when the resulting row is past `viewport_height`
7499/// (the cursor is below the visible window) or before scroll.
7500/// Map a buffer line to the visible row inside a pane viewport,
7501/// taking closed folds into account. `scroll` is the pane's
7502/// top-of-viewport buffer line -- usually `app.editor.scroll`, but the
7503/// popup-anchor path passes the active pane's stashed doc scroll
7504/// (State B) where the doc isn't the active buffer.
7505fn buffer_line_to_visible_row_with(
7506    view: &FrameView<'_>,
7507    snap: &DocumentSnapshot,
7508    target: u32,
7509    viewport_height: u32,
7510    scroll: u32,
7511    // W.4.t cursor-fix: columns a wrapped line is broken at, or `0`
7512    // when `:set wrap` is off. Each doc line then contributes
7513    // `segment_count` display rows (not 1) to the walk, so the
7514    // cursor row stays correct when a wrapped line sits above it.
7515    // Composes with the existing virtual-row + fold accounting:
7516    // every kind of virtual textual height is summed here, matching
7517    // what `compose_visible_lines_inner` paints.
7518    wrap_width: u32,
7519    // PIC.1: the pane whose matrices the walk must read — the caret
7520    // must sum wrap-segment / virtual-row heights from the SAME pane
7521    // the body + cursorline compose from (`compose_pane_lines`'s
7522    // `ctx.pane_id`). For the active document pane this is
7523    // `tree.active().id` (byte-identical to the top-level matrix); for
7524    // a focused popup it is `PaneId::POPUP`, whose help-buffer line
7525    // widths differ from the background document's — reading the wrong
7526    // pane compounds a per-line wrap error and drifts the caret.
7527    pane_id: lattice_core::ui::pane::PaneId,
7528) -> Option<u32> {
7529    if target < scroll {
7530        return None;
7531    }
7532    // B2.4: per-line display width from the canonical `DisplayMatrix`
7533    // (tab-expanded col_count == what the renderer paints). Stale /
7534    // missing rows fall back to the rope line's char count.
7535    // PIC.1: read THIS pane's matrix (keyed by `pane_id`), matching
7536    // `compose_pane_lines`'s body/cursorline source. For the active
7537    // document pane this entry is byte-identical to the top-level
7538    // `cells.display_matrix`; for `PaneId::POPUP` it is the popup
7539    // buffer's matrix, so the caret walks the same wrap widths the
7540    // popup body paints.
7541    let cells_rs = view.app.render_state.load().cells.load_full();
7542    let display_matrix = cells_rs
7543        .display_matrix_for_pane(pane_id)
7544        .map(|cell| cell.load_full())
7545        .unwrap_or_else(|| {
7546            std::sync::Arc::new(lattice_host::display_matrix::DisplayMatrix::empty())
7547        });
7548    let segment_rows = |line: u32| -> u32 {
7549        if wrap_width == 0 {
7550            return 1;
7551        }
7552        let width = display_matrix
7553            .row_at_source_line(line)
7554            .map(|r| r.col_count)
7555            .unwrap_or_else(|| {
7556                snap.buffer
7557                    .line(line)
7558                    .map(|s| s.chars().count() as u32)
7559                    .unwrap_or(0)
7560            });
7561        lattice_cells::wrap_segments(width, wrap_width)
7562    };
7563    // D.3.b.1.cursor-fix (2026-05-29): account for virtual
7564    // rows the renderer interleaves between document rows.
7565    // Without this, the cursor was painted at the doc-row
7566    // count past scroll — which is the position the line
7567    // WOULD occupy if there were no virtual rows in between.
7568    // After D.3.b.1's interleaver added Above- and Below-
7569    // anchored virtual rows around each doc row, the cursor
7570    // visually landed on the wrong row whenever a deletion
7571    // block sat between the scroll and the cursor.
7572    //
7573    // K.4.6 c.ii (2026-06-02, FIXED 2026-06-02): per-pane matrix
7574    // lookup, no boot-seeded fallback. Same fix as
7575    // compose_visible_lines_inner.
7576    let virtual_rows_matrix = {
7577        let rs = view.app.render_state.load();
7578        rs.virtual_rows
7579            .matrix_for_pane(pane_id)
7580            .map(|cell| cell.load_full())
7581            .unwrap_or_else(|| std::sync::Arc::new(lattice_cells::VirtualRowMatrix::empty()))
7582    };
7583    let total_lines = snap.buffer.content_line_count();
7584    let mut buf_line = scroll;
7585    let mut row: u32 = 0;
7586    while row < viewport_height && buf_line < total_lines {
7587        // Above-anchored virtual rows at buf_line render
7588        // BEFORE the doc row, so they shift `row` by their
7589        // count when walking past this position.
7590        let above_count = virtual_rows_at(
7591            &virtual_rows_matrix,
7592            buf_line,
7593            lattice_cells::AnchorPosition::Above,
7594        )
7595        .count() as u32;
7596        row += above_count;
7597        if row >= viewport_height {
7598            return None;
7599        }
7600        // If a closed fold starts at buf_line, the fold's whole
7601        // range collapses onto this single visible row. The cursor
7602        // resolves to this row whether it's at the fold heading or
7603        // anywhere in the hidden body.
7604        let fold_at = view.fold_start_at(buf_line);
7605        let next_buf_line = match fold_at {
7606            Some(fold) => fold.end_line + 1,
7607            None => buf_line + 1,
7608        };
7609        let covers_target = match fold_at {
7610            Some(fold) => target >= fold.start_line && target <= fold.end_line,
7611            None => target == buf_line,
7612        };
7613        if covers_target {
7614            return Some(row);
7615        }
7616        if buf_line == target {
7617            // Defensive: the line wasn't claimed above (no fold,
7618            // not equal); should be unreachable, but return the
7619            // current row rather than None so the cursor still
7620            // shows somewhere sensible.
7621            return Some(row);
7622        }
7623        if view.line_inside_closed_fold(buf_line) {
7624            // Hidden interior line -- not the start of any fold but
7625            // still part of one (the renderer skips it). Don't
7626            // increment row; just advance buf_line.
7627            buf_line += 1;
7628            continue;
7629        }
7630        // Below-anchored virtual rows of buf_line render
7631        // AFTER the doc row, before the next doc row.
7632        let below_count = virtual_rows_at(
7633            &virtual_rows_matrix,
7634            buf_line,
7635            lattice_cells::AnchorPosition::Below,
7636        )
7637        .count() as u32;
7638        row += below_count;
7639        // W.4.t: the doc line occupies `segment_count` display rows
7640        // under wrap (1 when wrap is off), not a flat 1.
7641        row += segment_rows(buf_line);
7642        buf_line = next_buf_line;
7643    }
7644    None
7645}
7646
7647#[allow(dead_code)]
7648fn cursor_screen_position(
7649    view: &FrameView<'_>,
7650    snap: &DocumentSnapshot,
7651    area: Rect,
7652) -> Option<(u16, u16)> {
7653    cursor_screen_position_at(
7654        view,
7655        snap,
7656        area,
7657        view.app.ad().cursor,
7658        view.app.ad().scroll,
7659        view.app.panes().tree.active().id,
7660    )
7661}
7662
7663/// Same as [`cursor_screen_position`] but with explicit `cursor`
7664/// and `scroll`. Used by the help-popup tooltip-anchor path where
7665/// the document's cursor / scroll live in the active pane's stash
7666/// (State B), not on `app.editor.cursor` / `app.editor.scroll` (which hold the
7667/// help buffer's). Folds are document-state and read straight off
7668/// `app`, which is correct for both states.
7669fn cursor_screen_position_at(
7670    view: &FrameView<'_>,
7671    snap: &DocumentSnapshot,
7672    area: Rect,
7673    cursor: lattice_protocol::Position,
7674    scroll: u32,
7675    // PIC.1: the pane whose matrices back the caret walk — see
7676    // `buffer_line_to_visible_row_with`. `tree.active().id` for the
7677    // active document caret; `PaneId::POPUP` for a focused popup.
7678    pane_id: lattice_core::ui::pane::PaneId,
7679) -> Option<(u16, u16)> {
7680    // Snapshot ad() once so wrap_lines, leftcol, and tabstop come
7681    // from the same atomic snapshot — without this the cursor
7682    // screen position and cursorline highlight can disagree when
7683    // the actor publishes between two independent app.ad() calls.
7684    let ad = view.app.ad();
7685    if cursor.line < scroll {
7686        return None;
7687    }
7688    // Map the buffer cursor line to the visible row taking closed
7689    // folds into account. If the cursor sits inside a closed fold's
7690    // hidden body, project it onto the fold's heading row -- the
7691    // user always sees the cursor on a real visible line, never
7692    // adrift inside collapsed content. This is the safety net for
7693    // any code path that sets `app.editor.cursor` without first running
7694    // `snap_cursor_past_closed_folds` (e.g. edits that shift line
7695    // numbers underneath an unchanged cursor).
7696    let total_lines = snap.buffer.content_line_count().max(1);
7697    let gutter_w = (if view.show_line_numbers {
7698        gutter_width(total_lines)
7699    } else {
7700        // Must match `compose_visible_lines_inner`'s `gutter_w` exactly —
7701        // see the note there. `2` put the caret one cell left of the
7702        // glyph on every `nonumber` buffer.
7703        GUTTER_TRAILING_PAD
7704    }) + view.content_left_pad; // DB.4: keep cursor col aligned with centring.
7705    // W.4.t: body content width = the columns a wrapped line is
7706    // broken at. Must match `compose_visible_lines_inner`'s
7707    // `buffer_w` exactly so the cursor row/col agree with what is
7708    // painted. `0` when `:set wrap` is off (no wrapping).
7709    //
7710    // Resolve wrap through `view.wrap_lines` — the SAME per-buffer
7711    // resolver (`App::wrap_lines_for`) the compose loop reads at
7712    // `wrap_on`. Reading the global `option_cache.wrap_lines` here
7713    // instead diverges the moment a buffer-local override exists:
7714    // help / popup buffers carry `wrap=off` from their mode (HP.3),
7715    // so with `:set wrap` on globally the body composed unwrapped
7716    // while the caret walk still split every line into segments,
7717    // drifting the caret off the cursorline. For the active document
7718    // pane the two resolve identically.
7719    let wrap_width = if view.wrap_lines {
7720        (area.width as u32)
7721            .saturating_sub(gutter_w)
7722            .saturating_sub(sign_columns_width(view))
7723            .max(1)
7724    } else {
7725        0
7726    };
7727    // Map the buffer cursor line to the visible row, accounting for
7728    // virtual rows + folds (existing) AND wrap-segment height
7729    // (W.4.t) of every line above it. This is the first display row
7730    // of the cursor's line; the cursor's own wrap segment is added
7731    // below from its display column.
7732    let row_in_view = buffer_line_to_visible_row_with(
7733        view,
7734        snap,
7735        cursor.line,
7736        area.height as u32,
7737        scroll,
7738        wrap_width,
7739        pane_id,
7740    )?;
7741    // `cursor.byte` is a UTF-8 byte offset into the line; the
7742    // terminal places glyphs by display width, not byte count. A
7743    // line containing `§` (2 bytes / 1 cell) or a CJK glyph (3
7744    // bytes / 2 cells) puts the cursor at the wrong column if we
7745    // use the byte offset directly. Compute the display width of
7746    // the prefix `line[..cursor.byte]` -- handles ASCII (1:1),
7747    // Latin-1 / Greek / Cyrillic (multi-byte but 1 cell), CJK and
7748    // emoji (1-4 bytes, 2 cells).
7749    //
7750    // 2026-05-26: LSP inlay hints render inline via
7751    // `splice_virtual_text_into_spans` (compose loop) but live
7752    // outside the source-byte axis. Cursor column has to add the
7753    // cumulative display width of every inlay on this line whose
7754    // anchor byte sits at-or-before `cursor.byte`, mirroring the
7755    // GPUI peer's `byte_to_combined_col` shift. Without it, `$`
7756    // (and every motion past an inlay) lands cells short of the
7757    // glyph it logically points at.
7758    let rs_st = view.app.render_state.load();
7759    let inlay_hints = &rs_st.syntax.inlay_hints;
7760    // Display column of the cursor within its (unwrapped) line —
7761    // tab-expanded + inlay-shifted.
7762    let display_col =
7763        display_col_for_byte(&snap.buffer, cursor, inlay_hints, ad.option_cache.tabstop);
7764    // W.4.t: split that column across wrap segments. The cursor's
7765    // own segment index (`display_col / wrap_width`) adds to the
7766    // row; the remainder (`display_col % wrap_width`) is the column
7767    // within the segment. This is the exact inverse of
7768    // `split_body_into_segments` (which breaks the body every
7769    // `wrap_width` columns), so it stays correct for a line that
7770    // wraps into any number of visual rows, not just two. Wrap off
7771    // (`wrap_width == 0`) ⇒ the whole column, no extra row.
7772    let (own_segment, body_col) = if wrap_width > 0 {
7773        (display_col / wrap_width, display_col % wrap_width)
7774    } else {
7775        (0, display_col)
7776    };
7777    // Sticky rows are emitted in a pre-pass before the scrollable
7778    // content (compose_pane_lines), so they occupy the first N screen
7779    // rows of the pane regardless of scroll. virtual_rows_at /
7780    // buffer_line_to_visible_row_with exclude sticky rows from their
7781    // counts (to avoid double-painting), so row_in_view is relative
7782    // to the *scrollable* region starting at sticky_count, not to the
7783    // pane top. Add sticky_count here so the terminal cursor lands on
7784    // the correct physical row.
7785    let sticky_count = {
7786        let rs = view.app.render_state.load();
7787        let matrix_rows = rs
7788            .virtual_rows
7789            .matrix_for_pane(pane_id)
7790            .map(|cell| cell.load_full().sticky_rows().count() as u32)
7791            .unwrap_or(0);
7792        // TC.3b: the context strip is emitted in the SAME pre-pass, after the
7793        // matrix's sticky rows, so it occupies physical rows too. Counting only
7794        // the matrix rows here drew the caret N rows above the cursorline —
7795        // the cursorline is painted as part of the composed row list, which
7796        // does include these, so the two disagreed by exactly the strip height.
7797        let context_rows = rs
7798            .cells
7799            .load()
7800            .sticky_context_for_pane(pane_id)
7801            .map(|cell| cell.load_full().rows.len() as u32)
7802            .unwrap_or(0);
7803        matrix_rows + context_rows
7804    };
7805    // Horizontal scroll (HS.1): shift the body column left by
7806    // `leftcol` so the cursor tracks the scrolled view. Wrap off
7807    // only — under wrap the host pins `leftcol = 0`. `saturating_sub`
7808    // guards the (transient) case where the cursor sits left of the
7809    // anchor before the next `ensure_cursor_horizontally_visible`.
7810    // Same per-buffer resolver as the compose loop's `leftcol_off`.
7811    let leftcol = if view.wrap_lines { 0 } else { ad.leftcol };
7812    let col = sign_columns_width(view) + gutter_w + body_col.saturating_sub(leftcol);
7813    let row = row_in_view
7814        .saturating_add(own_segment)
7815        .saturating_add(sticky_count);
7816    Some((
7817        area.x.saturating_add(col.try_into().unwrap_or(u16::MAX)),
7818        area.y.saturating_add(row.try_into().unwrap_or(u16::MAX)),
7819    ))
7820}
7821
7822/// Display column (terminal cells) of `pos.byte` within
7823/// `pos.line`. Falls back to `pos.byte` when the line is missing
7824/// or the byte index lands past the line end (so the cursor still
7825/// renders at a sensible position rather than disappearing).
7826///
7827/// 2026-05-26: `inlay_hints` is the publish-time
7828/// `rs.syntax.inlay_hints` slice. Every hint on `pos.line` whose
7829/// anchor byte is `<= pos.byte` shifts the cursor by its label's
7830/// display width — the same accounting the compose loop applies
7831/// when it splices the inlay text into the row. Mirrors the GPUI
7832/// peer's `byte_to_combined_col` (the `inlay_offsets <= byte`
7833/// filter there). Pass an empty slice to skip the shift (the
7834/// pre-inlay behaviour).
7835fn display_col_for_byte(
7836    buffer: &lattice_core::Buffer,
7837    pos: lattice_protocol::Position,
7838    inlay_hints: &[lattice_host::render_state::InlayHintRow],
7839    tabstop: u32,
7840) -> u32 {
7841    use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
7842
7843    let line = match buffer.line(pos.line) {
7844        Some(s) => s,
7845        None => return pos.byte,
7846    };
7847    let byte = (pos.byte as usize).min(line.len());
7848    // Truncate to the prefix at a UTF-8 boundary. `is_char_boundary`
7849    // is true at index 0 and at every codepoint start; if the
7850    // caller happened to point inside a multi-byte char (motions
7851    // shouldn't, but guard anyway), step back to the previous
7852    // boundary so `&line[..byte]` is a valid str slice.
7853    let mut byte = byte;
7854    while byte > 0 && !line.is_char_boundary(byte) {
7855        byte -= 1;
7856    }
7857    // W.4.t: tab-aware prefix width. The cells builder expands each
7858    // `\t` to the next multiple of `tabstop` columns, so the cursor
7859    // must advance the same way (plain `UnicodeWidthStr` treats a
7860    // tab as ~0 and would land the cursor inside the expanded tab).
7861    let ts = tabstop.max(1);
7862    let mut base = 0u32;
7863    for ch in line[..byte].chars() {
7864        if ch == '\t' {
7865            base += ts - (base % ts);
7866        } else {
7867            base += UnicodeWidthChar::width(ch).unwrap_or(0) as u32;
7868        }
7869    }
7870    // Inlay shift: cumulative display width of every hint on
7871    // `pos.line` with `hint.byte <= cursor.byte`. The `<=`
7872    // matches the splice site (`splice_virtual_text_into_spans`
7873    // inserts BEFORE the char at `hint.byte`, so the cursor at
7874    // that same byte sits AFTER the inlay).
7875    let inlay_shift: u32 = inlay_hints
7876        .iter()
7877        .filter(|h| h.line == pos.line && (h.byte as usize) <= byte)
7878        .map(|h| UnicodeWidthStr::width(h.text.as_str()) as u32)
7879        .sum();
7880    base + inlay_shift
7881}
7882
7883#[cfg(test)]
7884mod tests {
7885    #![allow(clippy::unwrap_used, clippy::panic)]
7886    use super::*;
7887    use crate::app::App;
7888    use lattice_core::Document;
7889
7890    // ── the row tint must not erase narrower backgrounds ──────────
7891    //
7892    // Live-reported: intra-line diff refinement was invisible in
7893    // magit-status. It computed correctly, survived the splice, reached
7894    // the run, and was painted onto the span style — and then the
7895    // row-level diff tint, applied last, overwrote every span's
7896    // background including that one. GPUI never had the bug because it
7897    // paints the tint as a quad BEHIND the text runs; the TUI flattens
7898    // both into one ratatui `Style`, so ordering decides it.
7899
7900    use ratatui::style::{Color as RColor, Style as RStyle};
7901    use ratatui::text::Span as RSpan;
7902
7903    /// TC.8: a virtual row that carries a source line shows it in the gutter,
7904    /// formatted EXACTLY like a document row's.
7905    ///
7906    /// Asserted against `render_gutter` rather than against a literal, because
7907    /// the property that matters is not "shows a number" but "occupies the
7908    /// same columns as the rows beneath it". A gutter one cell narrower puts
7909    /// the whole context strip out of alignment with the code it heads, which
7910    /// reads as a rendering bug rather than an off-by-one.
7911    #[test]
7912    fn a_virtual_row_with_a_source_line_paints_the_document_gutter() {
7913        for width in [4u32, 6, 9] {
7914            let mine = virtual_row_gutter_spans(Some(41), width, None);
7915            let theirs = render_gutter(41, width, None);
7916            let text = |v: &[RSpan<'static>]| -> String {
7917                v.iter().map(|s| s.content.to_string()).collect()
7918            };
7919            assert_eq!(
7920                text(&mine),
7921                text(&theirs),
7922                "width {width}: the context gutter must be the document gutter"
7923            );
7924            assert!(text(&mine).contains("42"), "1-based, like every gutter");
7925        }
7926    }
7927
7928    /// Without a source line the gutter is blank — and blank at exactly the
7929    /// document width, which is what keeps deletion blocks and filler rows
7930    /// aligned today.
7931    #[test]
7932    fn a_virtual_row_without_a_source_line_paints_a_blank_gutter() {
7933        let spans = virtual_row_gutter_spans(None, 6, None);
7934        let text: String = spans.iter().map(|s| s.content.to_string()).collect();
7935        assert_eq!(text, " ".repeat(6));
7936        assert_eq!(
7937            text.chars().count(),
7938            render_gutter(0, 6, None)
7939                .iter()
7940                .map(|s| s.content.chars().count())
7941                .sum::<usize>(),
7942            "blank and numbered gutters are the same width, so toggling \
7943             `line-numbers` never shifts the strip"
7944        );
7945    }
7946
7947    /// TC.11: the theme's `sticky.context.line_number` recolours the digits
7948    /// without changing their LAYOUT.
7949    ///
7950    /// Layout and colour are separated deliberately: `render_gutter` owns the
7951    /// columns (which must match the document gutter exactly, or the strip
7952    /// sits off from the code it heads) and the theme owns only the paint. A
7953    /// recolour that also reflowed would reintroduce the alignment bug the
7954    /// blank/numbered width tests exist to prevent.
7955    #[test]
7956    fn a_themed_gutter_colour_changes_paint_but_not_columns() {
7957        let plain = virtual_row_gutter_spans(Some(41), 6, None);
7958        let themed = virtual_row_gutter_spans(Some(41), 6, Some(0x00_ff_00));
7959        let text =
7960            |v: &[RSpan<'static>]| -> String { v.iter().map(|s| s.content.to_string()).collect() };
7961        assert_eq!(text(&plain), text(&themed), "same columns, same digits");
7962        assert!(
7963            themed
7964                .iter()
7965                .all(|s| s.style.fg == Some(RColor::Rgb(0, 0xff, 0))),
7966            "every span takes the themed colour"
7967        );
7968        assert!(
7969            plain
7970                .iter()
7971                .all(|s| s.style.fg != Some(RColor::Rgb(0, 0xff, 0))),
7972            "and without a theme colour it keeps the default"
7973        );
7974    }
7975
7976    /// A span with no background of its own takes the row tint — the
7977    /// ordinary case, and the behaviour that must not change.
7978    #[test]
7979    fn the_row_tint_applies_where_there_is_no_background() {
7980        let body = vec![RSpan::styled(
7981            "let x = 1;",
7982            RStyle::default().fg(RColor::White),
7983        )];
7984        let tinted = apply_diff_tint(body, RColor::Rgb(60, 0, 0));
7985        assert_eq!(tinted[0].style.bg, Some(RColor::Rgb(60, 0, 0)));
7986        assert_eq!(
7987            tinted[0].style.fg,
7988            Some(RColor::White),
7989            "the tint never touches foreground"
7990        );
7991    }
7992
7993    /// A span that already carries a background KEEPS it. This is the
7994    /// regression: refinement paints the narrower, stronger background,
7995    /// and the wider row tint must not win over it.
7996    #[test]
7997    fn the_row_tint_does_not_erase_an_existing_background() {
7998        let refine_bg = RColor::Rgb(0x6f, 0x00, 0x00);
7999        let body = vec![
8000            RSpan::styled("let ", RStyle::default().fg(RColor::White)),
8001            RSpan::styled("old", RStyle::default().fg(RColor::White).bg(refine_bg)),
8002            RSpan::styled(" = 1;", RStyle::default().fg(RColor::White)),
8003        ];
8004        let tinted = apply_diff_tint(body, RColor::Rgb(60, 0, 0));
8005        assert_eq!(
8006            tinted[1].style.bg,
8007            Some(refine_bg),
8008            "the refined span keeps its own background"
8009        );
8010        assert_eq!(
8011            tinted[0].style.bg,
8012            Some(RColor::Rgb(60, 0, 0)),
8013            "its unrefined neighbours still take the row tint"
8014        );
8015        assert_eq!(tinted[2].style.bg, Some(RColor::Rgb(60, 0, 0)));
8016    }
8017
8018    /// The same rule protects a selection or search match that happens
8019    /// to fall on a diff line — previously the tint erased those too,
8020    /// which is the same defect wearing different clothes.
8021    #[test]
8022    fn a_selection_background_survives_the_row_tint() {
8023        let selection_bg = RColor::Rgb(0x30, 0x30, 0x60);
8024        let body = vec![RSpan::styled(
8025            "selected",
8026            RStyle::default().bg(selection_bg),
8027        )];
8028        let tinted = apply_diff_tint(body, RColor::Rgb(0, 50, 0));
8029        assert_eq!(tinted[0].style.bg, Some(selection_bg));
8030    }
8031
8032    /// The row budget must equal the rows actually emitted.
8033    ///
8034    /// Live-reported against magit's file dispatch: the last group's
8035    /// items were off-screen and `<C-n>` would not bring them back.
8036    /// `transient_row_count` counted a header and the items per group
8037    /// but not the blank separator the walker emits, and BOTH the popup
8038    /// height and the scroll bound come off that number — so the box
8039    /// was short by one row per group and the scroll stopped before the
8040    /// bottom.
8041    ///
8042    /// Pinned as an identity rather than an arithmetic expectation:
8043    /// whatever the walker emits, the budget must say so, including
8044    /// when either side gains a row.
8045    /// A palette for the row-walking tests.
8046    ///
8047    /// These assert row COUNTS and which row carries the selection
8048    /// marker, not colours, so any palette works — but the walker takes
8049    /// one now, and a shared fixture keeps that from being restated at
8050    /// each call site.
8051    fn test_palette() -> TransientPalette {
8052        let plain = TuiStyle::default();
8053        TransientPalette {
8054            title: plain,
8055            group: plain,
8056            key: plain,
8057            key_inactive: plain,
8058            description: plain,
8059            value: plain,
8060            border: plain,
8061        }
8062    }
8063
8064    #[test]
8065    fn a_transients_row_budget_matches_the_rows_it_emits() {
8066        use lattice_picker::{TransientGroup, TransientItem, TransientItemKind, TransientSpec};
8067
8068        fn item(key: &str) -> TransientItem {
8069            TransientItem {
8070                key: vec![key.to_string()],
8071                label: key.to_string(),
8072                description: String::new(),
8073                kind: TransientItemKind::Dismiss,
8074            }
8075        }
8076        let spec = TransientSpec {
8077            title: "t".into(),
8078            groups: vec![
8079                TransientGroup {
8080                    label: "one".into(),
8081                    items: vec![item("a"), item("b")],
8082                },
8083                TransientGroup {
8084                    label: "two".into(),
8085                    items: vec![item("c")],
8086                },
8087                TransientGroup {
8088                    label: "three".into(),
8089                    items: vec![item("d"), item("e"), item("f")],
8090                },
8091            ],
8092            preview: None,
8093            footer: None,
8094        };
8095
8096        let state = lattice_picker::TransientState::default();
8097        // A visible budget far past the end, so the walker emits
8098        // everything it has.
8099        let (lines, _) = transient_group_item_lines(&spec, &state, 0, 1000, 0, "", &test_palette());
8100        assert_eq!(
8101            transient_row_count(&spec),
8102            lines.len(),
8103            "the budget and the walker must agree, or the popup is sized \
8104             and scrolled against a number of rows that do not exist"
8105        );
8106
8107        // And the consequence the user actually sees: with a window
8108        // shorter than the menu, the clamp must still reach the last
8109        // row.
8110        let visible = 5;
8111        let max_scroll = transient_row_count(&spec).saturating_sub(visible);
8112        let (tail, _) =
8113            transient_group_item_lines(&spec, &state, max_scroll, visible, 0, "", &test_palette());
8114        assert_eq!(
8115            tail.len(),
8116            visible,
8117            "scrolled to the clamp, the window must still be full — a \
8118             short count leaves rows below it unreachable"
8119        );
8120    }
8121
8122    /// The selection must always be on screen, at every position and
8123    /// every window size — including the last item, which is what the
8124    /// overshoot bug made unreachable in practice.
8125    ///
8126    /// Asserted through the walker rather than against `scroll_for`'s
8127    /// arithmetic: what matters is that the marked row is among the
8128    /// emitted lines, which is what the user sees.
8129    #[test]
8130    fn the_selected_transient_row_is_always_inside_the_window() {
8131        use lattice_picker::{TransientGroup, TransientItem, TransientItemKind, TransientSpec};
8132
8133        fn item(key: &str) -> TransientItem {
8134            TransientItem {
8135                key: vec![key.to_string()],
8136                label: format!("label-{key}"),
8137                description: String::new(),
8138                kind: TransientItemKind::Dismiss,
8139            }
8140        }
8141        let spec = TransientSpec {
8142            title: "t".into(),
8143            groups: vec![
8144                TransientGroup {
8145                    label: "one".into(),
8146                    items: vec![item("a"), item("b"), item("c")],
8147                },
8148                TransientGroup {
8149                    label: "two".into(),
8150                    items: vec![item("d"), item("e")],
8151                },
8152                TransientGroup {
8153                    label: "three".into(),
8154                    items: vec![item("f"), item("g")],
8155                },
8156            ],
8157            preview: None,
8158            footer: None,
8159        };
8160        let state = lattice_picker::TransientState::default();
8161        let labels = ["a", "b", "c", "d", "e", "f", "g"];
8162
8163        for visible in [3usize, 5, 8, 40] {
8164            for (selected, key) in labels.iter().enumerate() {
8165                let scroll = spec.scroll_for(selected, visible);
8166                let (lines, _) = transient_group_item_lines(
8167                    &spec,
8168                    &state,
8169                    scroll,
8170                    visible,
8171                    selected,
8172                    "",
8173                    &test_palette(),
8174                );
8175                let rendered: Vec<String> = lines
8176                    .iter()
8177                    .map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
8178                    .collect();
8179                let marked: Vec<&String> = rendered.iter().filter(|r| r.contains('❯')).collect();
8180                assert_eq!(
8181                    marked.len(),
8182                    1,
8183                    "exactly one row must be marked at selection {selected} \
8184                     with {visible} visible rows, got {rendered:?}"
8185                );
8186                assert!(
8187                    marked[0].contains(&format!("label-{key}")),
8188                    "the marked row must be the selected item ({key}): {marked:?}"
8189                );
8190            }
8191        }
8192    }
8193
8194    /// PU.1b-3 regression (link styling): the FLOATING help popup must
8195    /// render its markdown/link styling end-to-end. `:describe-command`
8196    /// emits a source link and opens a focused (State B) popup. The popup
8197    /// content lives in the registry (help is never `activate_document`'d
8198    /// as `self.document`), so the synthetic `PaneId::POPUP` pane must
8199    /// source its snapshot from the registry handle — NOT `self.document`,
8200    /// which still points at the underlying buffer. We drive the cells
8201    /// worker for the popup pane, compose the popup interior the way
8202    /// `draw_help_overlay` does, and assert a span carries the resolved
8203    /// `Style::Link` style (not the unstyled plain-text fallback).
8204    #[test]
8205    fn floating_popup_composes_link_styling_end_to_end() {
8206        use lattice_core::ui::pane::PaneId;
8207        let mut a = App::new(Document::from_text("xx\n"));
8208        a.set_viewport_height(20);
8209        a.editor.set_command_line_text("describe-command ex:write");
8210        a.editor.modal = ModalState::Command;
8211        a.apply(crate::app::Action::CommandLineSubmit);
8212        let popup_id = a.editor.popup_buffer.expect("floating popup open");
8213        // Renderer normally feeds this from the runtime loop.
8214        a.editor.popup_viewport_height = 30;
8215        a.editor.popup_viewport_width = 100;
8216        a.editor.publish_render_state();
8217        // Drive the cells worker for the synthetic popup pane (sim async).
8218        let cells = a.editor.render_state.load().cells.load_full();
8219        let pop = cells
8220            .panes
8221            .iter()
8222            .find(|p| p.pane_id == PaneId::POPUP)
8223            .expect("synthetic popup pane present");
8224        let ct = lattice_host::cells_worker::CellTheme {
8225            resolved: &cells.resolved_theme,
8226            ids: &cells.theme_ids,
8227        };
8228        let _ = lattice_host::cells_worker::recompute_pane(pop, ct, &cells.whitespace);
8229        // Compose the popup interior the way draw_help_overlay does.
8230        let handle = a
8231            .buffers()
8232            .registry
8233            .document_handle(popup_id)
8234            .expect("popup buffer in registry");
8235        let snap = handle.snapshot();
8236        let view = FrameView::for_buffer(&a, popup_id);
8237        let ctx = PaneComposeCtx {
8238            is_active: true,
8239            pane_id: PaneId::POPUP,
8240            buffer_id: popup_id,
8241            cursor_line: a.ad().cursor.line,
8242            cursor_line_highlight: a.ad().option_cache.current_line_highlight,
8243            scroll: a.ad().scroll,
8244            leftcol: a.ad().leftcol,
8245            display_line_numbers: handle.display_line_numbers(),
8246        };
8247        let lines = compose_pane_lines(&view, &snap, 30, 100, &ctx);
8248        let link_style =
8249            crate::theme::host_style_to_ratatui(lattice_host::ui::theme::resolve_syntax_style(
8250                &cells.resolved_theme,
8251                &cells.theme_ids,
8252                lattice_syntax::Style::Link,
8253            ));
8254        let styled_link = lines
8255            .iter()
8256            .flat_map(|l| l.spans.iter())
8257            .any(|sp| sp.style == link_style && !sp.content.trim().is_empty());
8258        assert!(
8259            styled_link,
8260            "floating popup must render a span with the resolved Style::Link style \
8261             (got the unstyled plain-text fallback — link styling regressed)"
8262        );
8263    }
8264
8265    /// PIC.1 regression: in a FOCUSED popup (State B) the terminal caret
8266    /// must land on the cursorline row. The popup body + cursorline
8267    /// compose from the `PaneId::POPUP` matrix, but the caret used to
8268    /// walk the background document's top-level matrix — so a wrapping
8269    /// line above the cursor drifted the caret below the cursorline
8270    /// (they coincide on line 0, diverge going down). Here popup line 0
8271    /// wraps into several segments while the background document's line 0
8272    /// is a single segment: the caret for popup line 1 must sit on the
8273    /// popup's cursorline row, NOT the (higher) row the document matrix
8274    /// would put it on.
8275    #[test]
8276    fn focused_popup_caret_row_matches_cursorline_row() {
8277        use lattice_core::ui::pane::PaneId;
8278        // Background document: line 0 is a single wrap segment.
8279        let mut a = App::new(Document::from_text("a\nb\nc\n"));
8280        a.set_viewport_height(20);
8281        // Focused popup whose line 0 is very wide (wraps into several
8282        // segments), followed by two short lines.
8283        let content = lattice_help::parse_help_lines(
8284            "t",
8285            vec!["x".repeat(200), "second".to_string(), "third".to_string()],
8286        );
8287        a.editor
8288            .open_popup(content, lattice_host::popup::PopupPlacement::Centered);
8289        let popup_id = a.editor.popup_buffer.expect("popup open");
8290        // Wrap on so the caret's wrap-width (from `ad().option_cache`)
8291        // matches the popup body's wrapping — isolates the matrix-source
8292        // fix (PIC.1) from the separate wrap-flag source question.
8293        a.editor.option_cache.wrap_lines = true;
8294        // Cursor onto popup line 1, below the wrap of line 0.
8295        a.editor.cursor = lattice_protocol::Position::new(1, 0);
8296        a.editor.popup_viewport_height = 20;
8297        a.editor.popup_viewport_width = 40;
8298        a.editor.publish_render_state();
8299        assert_eq!(
8300            a.ad().buffer_kind,
8301            crate::buffers::BufferKind::Help,
8302            "open_popup is State B (focused)"
8303        );
8304        // Drive the cells worker for the synthetic popup pane so its
8305        // DisplayMatrix is populated (sim async).
8306        let cells = a.editor.render_state.load().cells.load_full();
8307        let pop = cells
8308            .panes
8309            .iter()
8310            .find(|p| p.pane_id == PaneId::POPUP)
8311            .expect("synthetic popup pane present");
8312        let ct = lattice_host::cells_worker::CellTheme {
8313            resolved: &cells.resolved_theme,
8314            ids: &cells.theme_ids,
8315        };
8316        let _ = lattice_host::cells_worker::recompute_pane(pop, ct, &cells.whitespace);
8317
8318        let handle = a
8319            .buffers()
8320            .registry
8321            .document_handle(popup_id)
8322            .expect("popup buffer in registry");
8323        let snap = handle.snapshot();
8324        let inner = Rect::new(0, 0, 40, 18);
8325        let view = FrameView::for_buffer(&a, popup_id);
8326
8327        // Cursorline row: the cursor-line background is applied to every
8328        // wrap segment of line 1, so its FIRST row is line 1 segment 0 —
8329        // exactly where the caret (col 0) should land.
8330        let ctx = PaneComposeCtx {
8331            is_active: true,
8332            pane_id: PaneId::POPUP,
8333            buffer_id: popup_id,
8334            cursor_line: 1,
8335            cursor_line_highlight: true,
8336            scroll: 0,
8337            leftcol: 0,
8338            display_line_numbers: handle.display_line_numbers(),
8339        };
8340        let lines = compose_pane_lines(&view, &snap, inner.height as u32, inner.width as u32, &ctx);
8341        let cursor_line_bg = a.theme.cursor_line_bg;
8342        let cursorline_row = lines
8343            .iter()
8344            .position(|l| l.spans.iter().any(|s| s.style.bg == Some(cursor_line_bg)))
8345            .expect("cursorline row present");
8346        // HP.3 (`bb84a517`) gave help buffers `wrap=off` via their mode,
8347        // so the popup body composes line 0 as a SINGLE row even though
8348        // `:set wrap` is on globally — hence row 1, not row 6.
8349        assert_eq!(
8350            cursorline_row, 1,
8351            "help popups do not wrap (HP.3), so popup line 1 composes on row 1"
8352        );
8353
8354        // ...and the caret must agree. This is the live bug HP.3 opened:
8355        // compose resolves wrap per-buffer (`view.wrap_lines` →
8356        // `App::wrap_lines_for`, which help-mode overrides to `off`) while
8357        // `cursor_screen_position_at` used to read the GLOBAL
8358        // `option_cache.wrap_lines`. With `:set wrap` on, the caret walk
8359        // split the 200-column line 0 into ~6 segments the composed body
8360        // never painted and placed the caret ~5 rows below the cursorline.
8361        // Both sides now read the one per-buffer resolver.
8362        let (_, caret_y) = cursor_screen_position_at(
8363            &view,
8364            &snap,
8365            inner,
8366            lattice_protocol::Position::new(1, 0),
8367            0,
8368            PaneId::POPUP,
8369        )
8370        .expect("caret placed");
8371        assert_eq!(
8372            caret_y as usize, cursorline_row,
8373            "focused-popup caret must land on the cursorline row"
8374        );
8375
8376        // NOTE (PIC.1 coverage): this test used to also prove the caret
8377        // walks the POPUP pane's matrix rather than the background
8378        // document's, by making popup line 0 wrap while the document's
8379        // line 0 did not. HP.3 removed wrap from help popups, so that
8380        // construction is no longer reachable — with wrap off every line
8381        // is one row and the matrix source cannot change the row math.
8382        // If popup buffers ever wrap again, restore the wrapping-line
8383        // arm here; `buffer_line_to_visible_row_with`'s `pane_id`
8384        // argument is the thing under test.
8385    }
8386
8387    /// A sticky headerline must not cost a document line. The host already
8388    /// reserves the sticky row in the scroll budget (`ensure_cursor_visible`'s
8389    /// `effective_height`), and the fill loop below the sticky pre-pass already
8390    /// caps at `height` — so shrinking `visible` on top of that deletes a real
8391    /// line. For a buffer whose last line is an editable prompt (the ACP
8392    /// conversation buffer) the deleted line is always the prompt.
8393    #[test]
8394    fn sticky_headerline_does_not_clip_the_last_document_line() {
8395        let mut a = app_with("alpha\nbravo\n> ", 6);
8396        a.editor.publish_render_state();
8397        let buffer_id = a.ad().document_buffer_id;
8398
8399        let handle = lattice_cells::SimpleHeaderlineHandle::new((), |_: &()| {
8400            let cells: std::sync::Arc<[lattice_cells::Cell]> =
8401                vec![lattice_cells::Cell::new('H' as u32, 0xff_ff_ff, 0, 0)].into();
8402            Some(lattice_cells::HeaderlineRow { cells, bg: None })
8403        });
8404        a.editor
8405            .virtual_row_providers
8406            .register(buffer_id, std::sync::Arc::new(handle.provider(7)));
8407
8408        let mut state = lattice_host::virtual_rows_worker::VirtualRowsWorkerState::new();
8409        lattice_host::virtual_rows_worker::recompute(
8410            &mut state,
8411            &a.editor.render_state,
8412            &a.editor.virtual_row_providers,
8413        );
8414
8415        let snap = a.ad().snapshot.clone();
8416        let lines = compose_visible_lines(&a, &snap, 6, 40);
8417        let rendered: Vec<String> = lines
8418            .iter()
8419            .map(|l| {
8420                l.spans
8421                    .iter()
8422                    .map(|s| s.content.as_ref())
8423                    .collect::<String>()
8424            })
8425            .collect();
8426
8427        assert!(
8428            rendered.iter().any(|l| l.contains('H')),
8429            "sticky headerline should paint: {rendered:#?}"
8430        );
8431        assert!(
8432            rendered.iter().any(|l| l.contains("bravo")),
8433            "last transcript line should paint: {rendered:#?}"
8434        );
8435        assert!(
8436            rendered.iter().any(|l| l.trim_end().ends_with('>')),
8437            "the prompt (last document line) must still be painted: {rendered:#?}"
8438        );
8439    }
8440
8441    fn app_with(text: &str, viewport: u32) -> App {
8442        let mut a = App::new(Document::from_text(text));
8443        a.set_viewport_height(viewport);
8444        // display-line B4.2: `App::refresh_highlights` was deleted with
8445        // the overlay worker's dead span/row cache. Syntax now flows
8446        // through the cells / `DisplayMatrix` substrate (rebuilt on
8447        // publish); no explicit prime is needed for these render tests.
8448        a
8449    }
8450
8451    /// Regression (2026-09-29): describe/help opened in a horizontal split
8452    /// (`help.describe-display=split-h`) must not leak the underlying
8453    /// document into the help pane's rows below the (short) help content.
8454    ///
8455    /// `PaneTree::split_active` clones the source leaf, so the new pane's
8456    /// region already belongs to the original document; `draw_pane_document`
8457    /// paints a full-height `Paragraph`, but a composed line shorter than the
8458    /// pane width leaves its trailing cells untouched and the `~`-filler rows
8459    /// past EOF paint only one glyph — so without a per-pane `Clear` those
8460    /// cells read as the cloned document. The user's report: "after the
8461    /// content of describe ends, I see the contents of the previous buffer
8462    /// where the help was triggered."
8463    #[tokio::test]
8464    async fn describe_in_split_does_not_leak_underlying_buffer_below_help() {
8465        use ratatui::Terminal;
8466        use ratatui::backend::TestBackend;
8467
8468        let (tw, th): (u16, u16) = (40, 24);
8469        // A tall, visually-distinct underlying document — every content column
8470        // is `Z`, so any leak into the help pane is unmistakable.
8471        let underlying: String = "ZZZZZZZZZZZZ\n".repeat(60);
8472        let mut a = app_with(&underlying, th as u32);
8473
8474        // Describe content is deliberately SHORT so the help pane has many
8475        // rows below it where a leak would show.
8476        let content = crate::help::HelpContent::from_lines(
8477            "describe-test",
8478            vec!["describe line one".into(), "describe line two".into()],
8479        );
8480        let _id = a.open_help_in_split(content, crate::pane::SplitOrientation::Horizontal);
8481        // Drive the app the way production does between the split and its next
8482        // frame: the actor republishes `ad()` keyed on the freshly-activated
8483        // buffer. Without settling, `ad().snapshot` (which `draw_frame` hands
8484        // the active pane) stays on the underlying document.
8485        for _ in 0..50 {
8486            a.mutate_editor(|e: &mut lattice_host::editor::Editor| {
8487                e.publish_render_state();
8488            });
8489            let sigs =
8490                a.mutate_editor_with(|e: &mut lattice_host::editor::Editor| e.run_tick_pending());
8491            for s in sigs {
8492                a.handle_renderer_signal(s);
8493            }
8494            if a.ad().snapshot.buffer.as_string().contains("describe line") {
8495                break;
8496            }
8497            tokio::time::sleep(std::time::Duration::from_millis(5)).await;
8498        }
8499
8500        let snap = a.ad().snapshot.clone();
8501        let mut terminal = Terminal::new(TestBackend::new(tw, th)).unwrap();
8502        terminal
8503            .draw(|f| {
8504                let _ = draw_frame(f, &a, &snap);
8505            })
8506            .unwrap();
8507        let buf = terminal.backend().buffer().clone();
8508
8509        let rows: Vec<String> = (0..th)
8510            .map(|y| {
8511                (0..tw)
8512                    .map(|x| buf[(x, y)].symbol().to_string())
8513                    .collect::<String>()
8514            })
8515            .collect();
8516
8517        // The last help-content row.
8518        let describe_row = rows
8519            .iter()
8520            .rposition(|r| r.contains("describe line two"))
8521            .unwrap_or_else(|| panic!("describe content must paint:\n{rows:#?}"));
8522
8523        // Rows between the help content and the help pane's status line must be
8524        // help-pane filler (blank / `~`), NEVER the underlying `Z`s. The status
8525        // line ("[help] …") ends the help pane; the underlying buffer resumes
8526        // only in the SECOND (bottom) pane, below that status line.
8527        for (i, row) in rows.iter().enumerate().skip(describe_row + 1) {
8528            if row.contains("help") || row.contains("describe-test") {
8529                break; // reached the help pane's status line
8530            }
8531            assert!(
8532                !row.contains("ZZZ"),
8533                "row {i} sits between the help content and the help pane's \
8534                 status line but shows the underlying buffer (`Z`s) — the \
8535                 describe-in-split leak:\n{rows:#?}"
8536            );
8537        }
8538    }
8539
8540    /// **CV.5: in a pane-scoped listing the cursor highlight and the
8541    /// caret must land on the same screen row, at any scroll.**
8542    ///
8543    /// Reported against oil in a horizontal split: scrolling the
8544    /// cursor past the bottom of the pane made the highlight fall
8545    /// behind while the caret stayed on the right line. The split is
8546    /// not incidental — a full-height pane shows a short listing
8547    /// without ever scrolling, and at `scroll == 0` the two agree.
8548    ///
8549    /// Oil and the file tree are checked together on purpose. They are
8550    /// separate hand-written paint paths that were copied from each
8551    /// other, so they carried the same defect; the report named only
8552    /// oil, and testing only oil would have left the twin broken with
8553    /// nothing to announce it.
8554    #[tokio::test]
8555    async fn pane_listing_cursor_highlight_and_caret_share_a_row_when_scrolled() {
8556        use ratatui::Terminal;
8557        use ratatui::backend::TestBackend;
8558
8559        fn tempdir(tag: &str) -> std::path::PathBuf {
8560            use std::sync::atomic::{AtomicU64, Ordering};
8561            static COUNTER: AtomicU64 = AtomicU64::new(0);
8562            let nanos = std::time::SystemTime::now()
8563                .duration_since(std::time::UNIX_EPOCH)
8564                .map(|d| d.as_nanos())
8565                .unwrap_or(0);
8566            let n = COUNTER.fetch_add(1, Ordering::Relaxed);
8567            let d = std::env::temp_dir().join(format!("lattice-{tag}-cursorline-{nanos}-{n}"));
8568            std::fs::create_dir_all(&d).unwrap();
8569            d
8570        }
8571
8572        for kind in ["oil", "file-tree"] {
8573            let dir = tempdir(kind);
8574            for i in 0..40 {
8575                std::fs::write(dir.join(format!("file{i:03}.txt")), "x").unwrap();
8576            }
8577
8578            let (tw, th): (u16, u16) = (60, 24);
8579            let mut a = app_with("scratch\n", 1);
8580            // A horizontal split, as reported: the pane is short enough
8581            // that walking down the listing has to scroll.
8582            a.editor
8583                .pane_tree
8584                .split_active(crate::pane::SplitOrientation::Horizontal);
8585            a.editor.publish_render_state();
8586            // The bodies `App::do_open_oil` / `do_open_file_tree` run —
8587            // both are `pub(super)` to the `app` module, so drive the
8588            // host call plus the signal fan-out directly.
8589            let target = dir.clone();
8590            let signals = a.mutate_editor_with(move |e| match kind {
8591                "oil" => e.do_open_oil(Some(target)),
8592                _ => e.do_open_file_tree(Some(target)),
8593            });
8594            for s in signals {
8595                a.handle_renderer_signal(s);
8596            }
8597
8598            // DL.4: the converged tree marks its cursor row with the
8599            // cursorline, which `directory-listing-mode` contributes —
8600            // so the mode has to be ACTIVE before the frame means
8601            // anything. Mode activation is async; without settling, the
8602            // first frame has no mark and the assertion below would be
8603            // measuring the gap rather than the invariant.
8604            {
8605                let mode = lattice_listing::listing_mode::DirectoryListingMode::mode_id();
8606                let bid = a.editor.active_pane_buffer_id();
8607                let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
8608                while std::time::Instant::now() < deadline {
8609                    let active = a
8610                        .editor
8611                        .active_modes
8612                        .get(&bid)
8613                        .map(|m| m.is_active(mode))
8614                        .unwrap_or(false);
8615                    if active {
8616                        break;
8617                    }
8618                    tokio::time::sleep(std::time::Duration::from_millis(10)).await;
8619                    let signals = a.editor.run_tick_pending();
8620                    for sig in signals {
8621                        a.handle_renderer_signal(sig);
8622                    }
8623                }
8624            }
8625
8626            let chrome = chrome_rows(&a);
8627            let buffer_height = th
8628                .saturating_sub(1)
8629                .saturating_sub(chrome.tabline)
8630                .saturating_sub(chrome.extra()) as u32;
8631            let vh = a.active_pane_content_height(buffer_height);
8632            a.set_viewport_height(vh);
8633
8634            // Walk down far enough that the pane must scroll.
8635            let down = a.editor.builtins.line_down;
8636            for _ in 0..(vh + 6) {
8637                a.apply(crate::app::Action::Invoke(
8638                    lattice_grammar::CommandInvocation::of(down.0),
8639                ));
8640            }
8641            a.mutate_editor(|e| {
8642                e.publish_render_state();
8643            });
8644            assert!(
8645                a.editor.scroll > 0,
8646                "{kind}: precondition — the split pane must have scrolled \
8647                 (scroll={}, vh={vh})",
8648                a.editor.scroll,
8649            );
8650
8651            let mut terminal = Terminal::new(TestBackend::new(tw, th)).unwrap();
8652            let snap = a.ad().snapshot.clone();
8653            terminal
8654                .draw(|f| {
8655                    let _ = draw_frame(f, &a, &snap);
8656                })
8657                .unwrap();
8658
8659            let caret_row = terminal.get_cursor_position().unwrap().y;
8660            let buf = terminal.backend().buffer().clone();
8661            let screen: Vec<String> = (0..th)
8662                .map(|y| {
8663                    (0..tw)
8664                        .map(|x| buf[(x, y)].symbol().to_string())
8665                        .collect::<String>()
8666                        .trim_end()
8667                        .to_string()
8668                })
8669                .collect();
8670
8671            // The cursor's own row, identified by its TEXT rather than
8672            // by how the pane marks it.
8673            //
8674            // DL.4 moved the file tree between paint paths — the
8675            // bespoke painter reverse-videoed the cursor row, the
8676            // shared compose path tints it with the cursorline — so an
8677            // assertion on either mechanism would have had to be
8678            // rewritten and would only ever guard one of them. What the
8679            // report was actually about survives both: the caret must
8680            // sit on the row showing the cursor's entry. Oil moves to
8681            // the shared path in DL.5 and this needs no change.
8682            // `active_text` resolves per kind, so this reads the pane's
8683            // OWN buffer for both — `ad()` is the background document
8684            // for oil, which is not converged until DL.5.
8685            //
8686            // Matched on the entry name rather than the whole row: the
8687            // tree's row carries an indent and an expand marker, and
8688            // both kinds now splice a leading icon that is virtual text
8689            // and therefore not in the rope. The filenames in the
8690            // fixture are unique, so one row matches.
8691            let cursor_text = a
8692                .editor
8693                .active_text()
8694                .line(a.editor.cursor.line)
8695                .unwrap_or_default()
8696                .trim()
8697                .to_string();
8698            assert!(
8699                !cursor_text.is_empty(),
8700                "{kind}: precondition — the cursor's line has text to find"
8701            );
8702            let rows_with_cursor_text: Vec<u16> = (0..th)
8703                .filter(|&y| screen[y as usize].contains(&cursor_text))
8704                .collect();
8705
8706            let (scroll, cursor_line) = (a.editor.scroll, a.editor.cursor.line);
8707            let _ = std::fs::remove_dir_all(&dir);
8708
8709            assert_eq!(
8710                rows_with_cursor_text,
8711                vec![caret_row],
8712                "{kind}: the caret must sit on the row showing the cursor's own \
8713                 entry ({cursor_text:?}) — caret_row={caret_row}, scroll={scroll}, \
8714                 cursor.line={cursor_line}",
8715            );
8716        }
8717    }
8718
8719    /// **A listing icon paints in its OWN theme element, not one blanket
8720    /// `inlay.hint` grey.**
8721    ///
8722    /// `directory-listing-mode` publishes one icon per row carrying
8723    /// `Style::Element(listing.file.rust)` / `…python` / `listing.dir`
8724    /// (DL.3b), and the cells worker and GPUI both resolve that style.
8725    /// The TUI's post-hoc splice did not: it re-inserted the virtual text
8726    /// that `display_line_to_source_spans` drops and re-styled every row
8727    /// with `inlay_hint_style`, so a `.rs`, a `.py` and a directory all
8728    /// painted `#7f849c`. The published data was correct the whole time —
8729    /// only this paint discarded it, which is why the host-side
8730    /// `listing_icons_resolve_their_element_before_the_mode_cascade_runs`
8731    /// test passed while the screen showed one colour.
8732    ///
8733    /// Asserted on the PAINTED frame, and against the resolved elements
8734    /// rather than literals: "the icons differ from each other" alone
8735    /// would pass on any palette, and hardcoded RGB would re-break the
8736    /// moment a theme retunes `listing.*` — which is the entire point of
8737    /// having rooted them in the theme.
8738    #[tokio::test]
8739    async fn a_listing_icon_paints_in_its_own_element_not_one_inlay_grey() {
8740        use lattice_host::ui::theme::{ElementName, ThemeRegistryHandle};
8741        use ratatui::Terminal;
8742        use ratatui::backend::TestBackend;
8743
8744        let dir = {
8745            use std::sync::atomic::{AtomicU64, Ordering};
8746            static N: AtomicU64 = AtomicU64::new(0);
8747            let nanos = std::time::SystemTime::now()
8748                .duration_since(std::time::UNIX_EPOCH)
8749                .map(|d| d.as_nanos())
8750                .unwrap_or(0);
8751            let d = std::env::temp_dir().join(format!(
8752                "lattice-listing-icon-colour-{nanos}-{}",
8753                N.fetch_add(1, Ordering::Relaxed)
8754            ));
8755            std::fs::create_dir_all(&d).unwrap();
8756            d
8757        };
8758        std::fs::create_dir_all(dir.join("subdir")).unwrap();
8759        std::fs::write(dir.join("main.rs"), "x").unwrap();
8760        std::fs::write(dir.join("script.py"), "x").unwrap();
8761
8762        let (tw, th): (u16, u16) = (60, 24);
8763        let mut a = app_with("scratch\n", 20);
8764        let target = dir.clone();
8765        let signals = a.mutate_editor_with(move |e: &mut lattice_host::editor::Editor| {
8766            e.do_open_file_tree(Some(target))
8767        });
8768        for s in signals {
8769            a.handle_renderer_signal(s);
8770        }
8771        // Icons reach the frame through `PendingInlays` → `ExtraInlays`,
8772        // drained on a tick — not on the open call. Settling on the mode
8773        // is the same wait the CV.5 test above makes.
8774        {
8775            let mode = lattice_listing::listing_mode::DirectoryListingMode::mode_id();
8776            let bid = a.editor.active_pane_buffer_id();
8777            let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
8778            while std::time::Instant::now() < deadline {
8779                if a.editor
8780                    .active_modes
8781                    .get(&bid)
8782                    .map(|m| m.is_active(mode))
8783                    .unwrap_or(false)
8784                {
8785                    break;
8786                }
8787                tokio::time::sleep(std::time::Duration::from_millis(10)).await;
8788                let signals = a.editor.run_tick_pending();
8789                for sig in signals {
8790                    a.handle_renderer_signal(sig);
8791                }
8792            }
8793        }
8794        a.mutate_editor(|e: &mut lattice_host::editor::Editor| {
8795            e.publish_render_state();
8796        });
8797
8798        // What the theme says each element should paint as.
8799        let theme = a
8800            .editor
8801            .services
8802            .get::<ThemeRegistryHandle>()
8803            .expect("the theme registry is a boot service");
8804        let resolved = theme.resolved();
8805        let want = |name: &'static str| -> Color {
8806            let id = theme
8807                .id(&ElementName::from_static(name))
8808                .unwrap_or_else(|| panic!("{name} must be registered by the listing mode"));
8809            crate::theme::host_color_to_ratatui(
8810                resolved
8811                    .get(id)
8812                    .fg
8813                    .unwrap_or_else(|| panic!("{name} must resolve a foreground")),
8814            )
8815        };
8816        let want_dir = want(lattice_listing::listing_mode::ELEM_LISTING_DIR);
8817        let want_rust = want("listing.file.rust");
8818        let want_python = want("listing.file.python");
8819        assert!(
8820            want_rust != want_python && want_rust != want_dir,
8821            "precondition: the three elements resolve to different colours \
8822             ({want_rust:?} / {want_python:?} / {want_dir:?}) — if the palette \
8823             collapsed them this test could not tell a fix from the bug"
8824        );
8825
8826        let mut terminal = Terminal::new(TestBackend::new(tw, th)).unwrap();
8827        let snap = a.ad().snapshot.clone();
8828        terminal
8829            .draw(|f| {
8830                let _ = draw_frame(f, &a, &snap);
8831            })
8832            .unwrap();
8833        let buf = terminal.backend().buffer().clone();
8834
8835        // The painted fg of the icon on the row whose text contains
8836        // `name`. The icon is virtual text, so it is not in the rope and
8837        // cannot be located by byte — find the row by its name, then take
8838        // the cell just left of where the name starts.
8839        let icon_fg = |name: &str| -> Color {
8840            for y in 0..th {
8841                // Searched per COLUMN, not per byte: the icon glyph is
8842                // multi-byte, so a byte offset into the row's text is not
8843                // the column the name starts at — and the cell two to its
8844                // left would be some interior byte of the name.
8845                let cols: Vec<String> = (0..tw).map(|x| buf[(x, y)].symbol().to_string()).collect();
8846                for c in 0..cols.len() {
8847                    if cols[c..].concat().starts_with(name) {
8848                        // Back over the icon's trailing space onto the glyph.
8849                        return buf[(c.saturating_sub(2) as u16, y)].fg;
8850                    }
8851                }
8852            }
8853            panic!("no painted row contains {name:?}");
8854        };
8855
8856        let (rust, python, subdir) = (icon_fg("main.rs"), icon_fg("script.py"), icon_fg("subdir"));
8857        let _ = std::fs::remove_dir_all(&dir);
8858
8859        assert_eq!(
8860            rust, want_rust,
8861            "the .rs icon must paint `listing.file.rust`; painting the \
8862             blanket `inlay.hint` grey here is the reported bug"
8863        );
8864        assert_eq!(
8865            python, want_python,
8866            "the .py icon must paint `listing.file.python`"
8867        );
8868        assert_eq!(
8869            subdir, want_dir,
8870            "a directory icon must paint `listing.dir`"
8871        );
8872    }
8873
8874    /// **DL.8b: a listing row's NAME paints in the row's element too, and
8875    /// the tree's indent + expand marker do not.**
8876    ///
8877    /// The icon alone carried colour until DL.8b, so every filename —
8878    /// directories included — painted plain `text`, which is what the
8879    /// report "no colour coding" was mostly about. Names publish through
8880    /// the generic `PendingSyntheticHighlights` channel, so this asserts
8881    /// the whole path end to end: entries → spans → `ExtraHighlights` →
8882    /// the cells build → painted cells.
8883    ///
8884    /// The marker assertion is the other half and is the one that can
8885    /// regress quietly: spanning from byte 0 instead of `icon_byte`
8886    /// would tint a directory's `▾` with the directory colour and make
8887    /// the tree's structure read as content. It looks fine on an oil
8888    /// buffer (`icon_byte == 0`), which is why the tree is what is
8889    /// checked here.
8890    #[tokio::test]
8891    async fn a_listing_row_name_paints_its_element_and_the_tree_marker_does_not() {
8892        use lattice_host::ui::theme::{ElementName, ThemeRegistryHandle};
8893        use ratatui::Terminal;
8894        use ratatui::backend::TestBackend;
8895
8896        let dir = {
8897            use std::sync::atomic::{AtomicU64, Ordering};
8898            static N: AtomicU64 = AtomicU64::new(0);
8899            let nanos = std::time::SystemTime::now()
8900                .duration_since(std::time::UNIX_EPOCH)
8901                .map(|d| d.as_nanos())
8902                .unwrap_or(0);
8903            let d = std::env::temp_dir().join(format!(
8904                "lattice-listing-name-colour-{nanos}-{}",
8905                N.fetch_add(1, Ordering::Relaxed)
8906            ));
8907            std::fs::create_dir_all(&d).unwrap();
8908            d
8909        };
8910        std::fs::create_dir_all(dir.join("subdir")).unwrap();
8911        std::fs::write(dir.join("main.rs"), "x").unwrap();
8912        std::fs::write(dir.join("script.py"), "x").unwrap();
8913
8914        let (tw, th): (u16, u16) = (60, 24);
8915        let mut a = app_with("scratch\n", 20);
8916        let target = dir.clone();
8917        let signals = a.mutate_editor_with(move |e: &mut lattice_host::editor::Editor| {
8918            e.do_open_file_tree(Some(target))
8919        });
8920        for s in signals {
8921            a.handle_renderer_signal(s);
8922        }
8923        {
8924            let mode = lattice_listing::listing_mode::DirectoryListingMode::mode_id();
8925            let bid = a.editor.active_pane_buffer_id();
8926            let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
8927            while std::time::Instant::now() < deadline {
8928                if a.editor
8929                    .active_modes
8930                    .get(&bid)
8931                    .map(|m| m.is_active(mode))
8932                    .unwrap_or(false)
8933                {
8934                    break;
8935                }
8936                tokio::time::sleep(std::time::Duration::from_millis(10)).await;
8937                let signals = a.editor.run_tick_pending();
8938                for sig in signals {
8939                    a.handle_renderer_signal(sig);
8940                }
8941            }
8942        }
8943        a.mutate_editor(|e: &mut lattice_host::editor::Editor| {
8944            e.publish_render_state();
8945        });
8946
8947        let theme = a
8948            .editor
8949            .services
8950            .get::<ThemeRegistryHandle>()
8951            .expect("the theme registry is a boot service");
8952        let resolved = theme.resolved();
8953        let want = |name: &'static str| -> Color {
8954            let id = theme
8955                .id(&ElementName::from_static(name))
8956                .unwrap_or_else(|| panic!("{name} must be registered"));
8957            crate::theme::host_color_to_ratatui(
8958                resolved.get(id).fg.expect("element resolves a foreground"),
8959            )
8960        };
8961        let want_dir = want(lattice_listing::listing_mode::ELEM_LISTING_DIR);
8962        let want_rust = want("listing.file.rust");
8963        let want_python = want("listing.file.python");
8964        let plain = want(lattice_listing::listing_mode::ELEM_LISTING_FILE);
8965
8966        let mut terminal = Terminal::new(TestBackend::new(tw, th)).unwrap();
8967
8968        // `(fg of the name's first cell, fg of the expand marker on that
8969        // row)`. Located per column, not per byte — the icon glyph is
8970        // multi-byte.
8971        let row_colours = |buf: &ratatui::buffer::Buffer, name: &str| -> (Color, Option<Color>) {
8972            for y in 0..th {
8973                let cols: Vec<String> = (0..tw).map(|x| buf[(x, y)].symbol().to_string()).collect();
8974                for c in 0..cols.len() {
8975                    if cols[c..].concat().starts_with(name) {
8976                        let marker = (0..c)
8977                            .find(|&x| cols[x] == "▾" || cols[x] == "▸")
8978                            .map(|x| buf[(x as u16, y)].fg);
8979                        return (buf[(c as u16, y)].fg, marker);
8980                    }
8981                }
8982            }
8983            panic!("no painted row contains {name:?}");
8984        };
8985
8986        // Names, unlike icons, need the cells / `DisplayMatrix` REBUILD:
8987        // they colour source spans that the matrix bakes in, where an icon
8988        // is spliced post-hoc onto whatever body exists. That build is the
8989        // async worker's, so the colour lands a frame or two after the
8990        // publish — the eventual consistency the keystroke UX contract
8991        // allows for syntax colour, and the same window `display_stale`
8992        // paints plain text through.
8993        //
8994        // So this settles on the PAINTED result rather than on the mode. A
8995        // single draw passes on an idle machine and fails under a loaded
8996        // one, which is a flake that would get argued with instead of
8997        // obeyed; and if the colour never arrives, this still fails — on
8998        // the assertions below, with the deadline spent.
8999        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
9000        let buf = loop {
9001            let snap = a.ad().snapshot.clone();
9002            terminal
9003                .draw(|f| {
9004                    let _ = draw_frame(f, &a, &snap);
9005                })
9006                .unwrap();
9007            let buf = terminal.backend().buffer().clone();
9008            if row_colours(&buf, "main.rs").0 == want_rust || std::time::Instant::now() >= deadline
9009            {
9010                break buf;
9011            }
9012            tokio::time::sleep(std::time::Duration::from_millis(10)).await;
9013            let signals = a.editor.run_tick_pending();
9014            for sig in signals {
9015                a.handle_renderer_signal(sig);
9016            }
9017            a.mutate_editor(|e: &mut lattice_host::editor::Editor| {
9018                e.publish_render_state();
9019            });
9020        };
9021
9022        let (rust_name, _) = row_colours(&buf, "main.rs");
9023        let (python_name, _) = row_colours(&buf, "script.py");
9024        let (dir_name, dir_marker) = row_colours(&buf, "subdir");
9025        let _ = std::fs::remove_dir_all(&dir);
9026
9027        assert_eq!(
9028            rust_name, want_rust,
9029            "a .rs row's NAME must paint `listing.file.rust` — painting it \
9030             {plain:?} (plain text) is the state DL.8b fixes"
9031        );
9032        assert_eq!(
9033            python_name, want_python,
9034            "a .py row's name must paint `listing.file.python`"
9035        );
9036        assert_eq!(
9037            dir_name, want_dir,
9038            "a directory's name must paint `listing.dir`"
9039        );
9040        assert_ne!(
9041            rust_name, python_name,
9042            "two languages must not collapse to one colour — that is what \
9043             'the colour coding is gone' looks like from the user's side"
9044        );
9045        assert_eq!(
9046            dir_marker,
9047            Some(plain),
9048            "the tree's expand marker is STRUCTURE and must keep the default \
9049             text colour — a span anchored at byte 0 instead of `icon_byte` \
9050             would tint it with the directory's colour and make the tree's \
9051             shape read as content"
9052        );
9053    }
9054
9055    /// **CV.2: a file that ends in a newline must not paint an extra
9056    /// empty row.**
9057    ///
9058    /// ropey reports one more line than the file has for any rope
9059    /// ending in `\n` — the convention `Buffer::line_count` surfaces
9060    /// verbatim. Feeding that raw count to the matrix build made the
9061    /// phantom logical line into a phantom *display row*, so a
9062    /// 219-line `todo.org` painted a numbered, empty line 220 that no
9063    /// other editor shows. Below the real last line the frame must
9064    /// show the no-such-line filler, not a numbered row.
9065    #[test]
9066    fn a_file_ending_in_a_newline_paints_no_extra_row() {
9067        use ratatui::Terminal;
9068        use ratatui::backend::TestBackend;
9069
9070        // (label, text, lines the file actually has)
9071        for (label, text, want_lines) in [
9072            ("terminated", "alpha\nbravo\ncharlie\n", 3u32),
9073            ("unterminated", "alpha\nbravo\ncharlie", 3),
9074            // A genuinely blank final line IS content — the file ends
9075            // with an empty line and then its terminator. Trimming it
9076            // too would be the same bug in the other direction.
9077            ("blank last line", "alpha\nbravo\ncharlie\n\n", 4),
9078            ("single line", "alpha\n", 1),
9079            ("empty", "", 1),
9080        ] {
9081            let (tw, th): (u16, u16) = (40, 12);
9082            let mut a = app_with(text, 10);
9083            a.set_pane_viewport(0, 10, tw as u32);
9084            a.mutate_editor(|e| {
9085                e.publish_render_state();
9086            });
9087            let mut terminal = Terminal::new(TestBackend::new(tw, th)).unwrap();
9088            let snap = a.ad().snapshot.clone();
9089            terminal
9090                .draw(|f| {
9091                    let _ = draw_frame(f, &a, &snap);
9092                })
9093                .unwrap();
9094            let buf = terminal.backend().buffer().clone();
9095            let numbered: Vec<u32> = (0..th)
9096                .filter_map(|y| {
9097                    let row: String = (0..tw).map(|x| buf[(x, y)].symbol().to_string()).collect();
9098                    row.trim_start().split(' ').next()?.parse::<u32>().ok()
9099                })
9100                .collect();
9101
9102            assert_eq!(
9103                numbered,
9104                (1..=want_lines).collect::<Vec<_>>(),
9105                "{label}: the frame must number exactly the {want_lines} line(s) the \
9106                 file has — a trailing newline terminates the last line, it does not \
9107                 start another one"
9108            );
9109        }
9110    }
9111
9112    /// **CV.1: after `G`, the cursor's line must be a line the frame
9113    /// actually painted.**
9114    ///
9115    /// Reported against a 219-line `todo.org` with `wrap = true`: `G`
9116    /// put the cursor on line 219 while the last row drawn was 218, so
9117    /// the caret sat one row below the visible area. The cause was in
9118    /// the host's bottom clamp (`bottom_anchored_scroll`), which read
9119    /// soft-wrap geometry from the cells matrix — published
9120    /// asynchronously by the worker and windowed to the viewport. On
9121    /// the keystroke that runs the motion that cache routinely holds
9122    /// neither the pane's wrap width nor a row for the jump target,
9123    /// and `segment_count` answers `1` for both, so the clamp budgeted
9124    /// one row per line for content the renderer wrapped into two or
9125    /// three.
9126    ///
9127    /// Asserted as the invariant rather than the instance, against the
9128    /// painted frame rather than against `editor.scroll`: whatever the
9129    /// geometry, the cursor's source line is among the line numbers in
9130    /// the gutter. The app is deliberately **not** settled first — a
9131    /// test that lets the worker publish before pressing `G` passes on
9132    /// the broken build, which is exactly how this survived the
9133    /// existing wrap tests (they pre-seed the matrix with
9134    /// `seed_wrap_matrix`, handing the clamp the answer it is supposed
9135    /// to derive).
9136    #[test]
9137    fn cursor_lands_on_a_painted_row_after_goto_last_line() {
9138        use ratatui::Terminal;
9139        use ratatui::backend::TestBackend;
9140
9141        // Prose-shaped: a mix of short lines and lines long enough to
9142        // wrap into two or three rows at every width under test.
9143        let text: String = (0..219)
9144            .map(|i: u32| match i % 4 {
9145                0 => format!("line {i}\n"),
9146                1 => format!("{} {i}\n", "medium length body text".repeat(2)),
9147                2 => String::from("\n"),
9148                _ => format!("{} {i}\n", "a much longer paragraph of prose".repeat(4)),
9149            })
9150            .collect();
9151
9152        let mut failures: Vec<String> = Vec::new();
9153        for wrap in [false, true] {
9154            for split in [false, true] {
9155                for &(tw, th) in &[(120u16, 30u16), (80, 30), (80, 24), (60, 20), (100, 45)] {
9156                    let mut a = app_with(&text, 1);
9157                    if wrap {
9158                        a.editor
9159                            .config
9160                            .parse_and_set_command("wrap")
9161                            .expect("`wrap` is settable");
9162                        a.mutate_editor(|e| {
9163                            e.rebuild_option_cache();
9164                        });
9165                    }
9166                    if split {
9167                        a.editor
9168                            .pane_tree
9169                            .split_active(crate::pane::SplitOrientation::Horizontal);
9170                        a.editor.publish_render_state();
9171                    }
9172
9173                    // Mirrors runtime.rs's per-frame viewport push.
9174                    let chrome = chrome_rows(&a);
9175                    let buffer_height =
9176                        th.saturating_sub(1)
9177                            .saturating_sub(chrome.tabline)
9178                            .saturating_sub(chrome.extra()) as u32;
9179                    let vh = a.active_pane_content_height(buffer_height);
9180                    a.set_viewport_height(vh);
9181                    a.set_pane_viewport(0, vh, tw as u32);
9182
9183                    let id = a.editor.builtins.goto_last_line;
9184                    a.apply(crate::app::Action::Invoke(
9185                        lattice_grammar::CommandInvocation::of(id.0),
9186                    ));
9187                    a.mutate_editor(|e| {
9188                        e.publish_render_state();
9189                    });
9190
9191                    let mut terminal = Terminal::new(TestBackend::new(tw, th)).unwrap();
9192                    let snap = a.ad().snapshot.clone();
9193                    terminal
9194                        .draw(|f| {
9195                            let _ = draw_frame(f, &a, &snap);
9196                        })
9197                        .unwrap();
9198                    let buf = terminal.backend().buffer().clone();
9199                    // Painted source lines = the leading gutter numbers.
9200                    // Wrap-continuation rows have a blank gutter and so
9201                    // contribute nothing, which is what we want: the
9202                    // cursor's line must have a numbered row of its own.
9203                    let painted: Vec<u32> = (0..th)
9204                        .filter_map(|y| {
9205                            let row: String =
9206                                (0..tw).map(|x| buf[(x, y)].symbol().to_string()).collect();
9207                            row.trim_start().split(' ').next()?.parse::<u32>().ok()
9208                        })
9209                        .collect();
9210
9211                    let want = a.editor.cursor.line + 1;
9212                    if !painted.contains(&want) {
9213                        failures.push(format!(
9214                            "wrap={wrap} split={split} {tw}x{th}: cursor on line {want} but the \
9215                             frame painted lines {:?}..={:?} (scroll={})",
9216                            painted.first(),
9217                            painted.last(),
9218                            a.editor.scroll,
9219                        ));
9220                    }
9221                }
9222            }
9223        }
9224        assert!(
9225            failures.is_empty(),
9226            "`G` left the cursor off the painted area in {} of 20 geometries:\n  {}",
9227            failures.len(),
9228            failures.join("\n  "),
9229        );
9230    }
9231
9232    /// `chrome_rows` is the single source of truth the runtime loop and
9233    /// `draw_frame` both read; verify it actually reflects tabline
9234    /// MG.41b: a transient's band is bounded by
9235    /// `ui.transient.max-rows`, not the picker's 10.
9236    ///
9237    /// The magit dispatch is 25 rows plus group headers, so the shared
9238    /// picker cap showed under half of it — the reported complaint.
9239    #[test]
9240    fn transient_band_uses_its_own_row_budget() {
9241        let a = app_with("x\n", 10);
9242        // Default: 20, not the picker's 10.
9243        assert_eq!(transient_max_rows(&a), 20);
9244        // The cap is a MAXIMUM — a short menu still claims only its own
9245        // rows, so a two-item transient does not paint an 18-row hole.
9246        assert_eq!(popup_height_capped(3, 20), 3);
9247        assert_eq!(popup_height_capped(25, 20), 20);
9248        // Zero/negative clamp to 1 rather than producing an empty band.
9249        assert_eq!(popup_height_capped(25, 0), 1);
9250    }
9251
9252    /// The picker's own budget is untouched by the transient option —
9253    /// they were sharing one constant before this slice.
9254    #[test]
9255    fn picker_band_is_unchanged_at_ten() {
9256        assert_eq!(popup_height(25), PICKER_MAX_ROWS);
9257        assert_eq!(popup_height(4), 4);
9258    }
9259
9260    /// `:set ui.transient.max-rows` is honoured live.
9261    #[test]
9262    fn transient_row_budget_is_configurable() {
9263        let mut a = app_with("x\n", 10);
9264        a.editor
9265            .config
9266            .parse_and_set_command("ui.transient.max-rows=40")
9267            .expect("option is settable");
9268        a.editor.publish_render_state();
9269        assert_eq!(transient_max_rows(&a), 40);
9270    }
9271
9272    /// visibility (Auto mode: visible iff more than one tab is open).
9273    #[test]
9274    fn chrome_rows_reflects_tabline_visibility() {
9275        let mut a = app_with("one\ntwo\nthree\n", 10);
9276        assert_eq!(chrome_rows(&a).tabline, 0, "single tab: no tabline row");
9277        a.editor.do_new_tab();
9278        a.editor.publish_render_state();
9279        assert_eq!(
9280            chrome_rows(&a).tabline,
9281            1,
9282            "two tabs: tabline claims one row"
9283        );
9284    }
9285
9286    /// Regression guard for the "last line off-screen once the tabline is
9287    /// visible" bug: the runtime loop's `buffer_height` formula (terminal
9288    /// height minus cmdline, tabline, and candidate-band rows) must always
9289    /// equal the pane area `draw_frame` actually lays out via the ratatui
9290    /// `Layout`. Before this fix the runtime's copy never subtracted the
9291    /// tabline row at all, so the viewport/scroll logic believed it had one
9292    /// more row to show than `draw_frame` ever painted — the "last" line by
9293    /// its reckoning was never physically drawn. Both sides now read
9294    /// `chrome_rows`, so this test also guards against a future edit
9295    /// reintroducing a hand-duplicated, divergent copy of either formula.
9296    #[test]
9297    fn runtime_buffer_height_matches_draw_frame_pane_area_with_tabline() {
9298        let mut a = app_with("one\ntwo\nthree\n", 10);
9299        a.editor.do_new_tab();
9300        a.editor.publish_render_state();
9301        let chrome = chrome_rows(&a);
9302        assert_eq!(chrome.tabline, 1, "precondition: tabline visible");
9303
9304        let terminal_width: u16 = 80;
9305        let terminal_height: u16 = 40;
9306        // Mirrors `runtime.rs`'s `buffer_height` formula exactly.
9307        let runtime_buffer_height = terminal_height
9308            .saturating_sub(1)
9309            .saturating_sub(chrome.tabline)
9310            .saturating_sub(chrome.extra());
9311
9312        // Mirrors `draw_frame`'s own Layout constraints (no candidate band
9313        // open in this scenario).
9314        let chunks = Layout::default()
9315            .direction(Direction::Vertical)
9316            .constraints([
9317                Constraint::Length(chrome.tabline),
9318                Constraint::Min(1),
9319                Constraint::Length(1),
9320            ])
9321            .split(Rect::new(0, 0, terminal_width, terminal_height));
9322
9323        assert_eq!(
9324            runtime_buffer_height, chunks[1].height,
9325            "runtime's buffer_height must equal draw_frame's actual pane-area \
9326             height — a mismatch means the viewport/scroll logic and the paint \
9327             layout disagree on how many rows are visible"
9328        );
9329    }
9330
9331    /// Same invariant as above, tabline hidden (single tab) — the two
9332    /// computations must still agree when there's nothing extra to reserve.
9333    #[test]
9334    fn runtime_buffer_height_matches_draw_frame_pane_area_without_tabline() {
9335        let a = app_with("one\ntwo\nthree\n", 10);
9336        let chrome = chrome_rows(&a);
9337        assert_eq!(chrome.tabline, 0, "precondition: tabline hidden");
9338
9339        let terminal_width: u16 = 80;
9340        let terminal_height: u16 = 40;
9341        let runtime_buffer_height = terminal_height
9342            .saturating_sub(1)
9343            .saturating_sub(chrome.tabline)
9344            .saturating_sub(chrome.extra());
9345
9346        let chunks = Layout::default()
9347            .direction(Direction::Vertical)
9348            .constraints([
9349                Constraint::Length(chrome.tabline),
9350                Constraint::Min(1),
9351                Constraint::Length(1),
9352            ])
9353            .split(Rect::new(0, 0, terminal_width, terminal_height));
9354
9355        assert_eq!(runtime_buffer_height, chunks[1].height);
9356    }
9357
9358    /// Generalises the two invariant tests above from "one pane" to
9359    /// ARBITRARY split trees and ARBITRARY window sizes — Dhruva's
9360    /// concern that the fix must hold "regardless of these constraints"
9361    /// (2-way, 3-way, 4-way splits; any terminal size, i.e. any resize),
9362    /// not just the shapes happened to be spot-checked.
9363    ///
9364    /// Both `compute_rects` (lattice-core) and the split-geometry loops in
9365    /// `runtime.rs` / `draw_panes` are PURE recursive functions of
9366    /// `(pane tree, total area)` — the tabline/cmdline/candidate-band
9367    /// reservation happens exactly once, one layer ABOVE the split tree,
9368    /// via `chrome_rows`. So proving the invariant composes with splits
9369    /// and resizes reduces to: for every split depth and every terminal
9370    /// size, does `compute_rects` over the runtime's `area_for_panes`
9371    /// (built from `chrome_rows`-corrected `buffer_height`) produce THE
9372    /// SAME per-leaf rects as `compute_rects` over `draw_frame`'s actual
9373    /// `chunks[1]`? If a future edit ever special-cased tabline handling
9374    /// PER-PANE instead of once at the top, this test would catch the
9375    /// divergence immediately at any split count.
9376    #[test]
9377    fn chrome_rows_composes_with_arbitrary_splits_and_terminal_sizes() {
9378        use crate::pane::{PaneRect, SplitOrientation};
9379
9380        // 0, 1, 2, 3 splits => 1, 2, 3, 4-way pane trees. Mix of
9381        // orientations so both HorizontalSplit and VerticalSplit recursion
9382        // arms are exercised, not just one.
9383        let split_plans: &[&[SplitOrientation]] = &[
9384            &[],
9385            &[SplitOrientation::Horizontal],
9386            &[SplitOrientation::Vertical],
9387            &[SplitOrientation::Horizontal, SplitOrientation::Vertical],
9388            &[
9389                SplitOrientation::Horizontal,
9390                SplitOrientation::Vertical,
9391                SplitOrientation::Horizontal,
9392            ],
9393        ];
9394
9395        // A spread of terminal sizes standing in for "the user resized the
9396        // window" — including sizes too small to hold every split cleanly,
9397        // where `saturating_sub`/`.max(1)` clamping must still agree
9398        // between both sides.
9399        let terminal_sizes: &[(u16, u16)] =
9400            &[(80, 24), (80, 40), (200, 60), (40, 10), (120, 8), (30, 6)];
9401
9402        for splits in split_plans {
9403            for tabline_visible in [false, true] {
9404                for &(terminal_width, terminal_height) in terminal_sizes {
9405                    let mut a = app_with("one\ntwo\nthree\n", 10);
9406                    for orientation in *splits {
9407                        a.editor.pane_tree.split_active(*orientation);
9408                    }
9409                    if tabline_visible {
9410                        a.editor
9411                            .pane_tree
9412                            .split_active(SplitOrientation::Horizontal);
9413                        // Second tab makes `tabs.visible` true in Auto mode
9414                        // (`self.tabs.len() > 1`) without depending on any
9415                        // particular `tabline.show` config default.
9416                        a.editor.do_new_tab();
9417                    }
9418                    a.editor.publish_render_state();
9419
9420                    let chrome = chrome_rows(&a);
9421                    assert_eq!(
9422                        chrome.tabline > 0,
9423                        tabline_visible,
9424                        "tabline visibility precondition failed for splits={splits:?} \
9425                         size=({terminal_width}x{terminal_height})"
9426                    );
9427
9428                    // Mirrors runtime.rs: buffer_height, then compute_rects
9429                    // over the FULL terminal area (tabline/cmdline/candidate
9430                    // rows already excluded by buffer_height).
9431                    let runtime_buffer_height = terminal_height
9432                        .saturating_sub(1)
9433                        .saturating_sub(chrome.tabline)
9434                        .saturating_sub(chrome.extra());
9435                    let panes_arc = a.panes();
9436                    let runtime_area = PaneRect {
9437                        x: 0,
9438                        y: 0,
9439                        width: terminal_width,
9440                        height: runtime_buffer_height,
9441                    };
9442                    let mut runtime_rects = panes_arc.tree.compute_rects(runtime_area);
9443                    runtime_rects.sort_by_key(|(idx, _)| *idx);
9444
9445                    // Mirrors draw_frame: the real ratatui Layout split,
9446                    // then draw_panes's own compute_rects over chunks[1].
9447                    let constraints: Vec<Constraint> = if chrome.extra() > 0 {
9448                        vec![
9449                            Constraint::Length(chrome.tabline),
9450                            Constraint::Min(1),
9451                            Constraint::Length(1),
9452                            Constraint::Length(chrome.extra()),
9453                        ]
9454                    } else {
9455                        vec![
9456                            Constraint::Length(chrome.tabline),
9457                            Constraint::Min(1),
9458                            Constraint::Length(1),
9459                        ]
9460                    };
9461                    let chunks = Layout::default()
9462                        .direction(Direction::Vertical)
9463                        .constraints(constraints)
9464                        .split(Rect::new(0, 0, terminal_width, terminal_height));
9465                    let draw_area = PaneRect {
9466                        x: chunks[1].x,
9467                        y: chunks[1].y,
9468                        width: chunks[1].width,
9469                        height: chunks[1].height,
9470                    };
9471                    let mut draw_rects = panes_arc.tree.compute_rects(draw_area);
9472                    draw_rects.sort_by_key(|(idx, _)| *idx);
9473
9474                    // Compare (width, height) per leaf, NOT the full rect:
9475                    // `runtime.rs`'s `area_for_panes` is deliberately
9476                    // anchored at `y:0` (it only sizes the PTY / viewport
9477                    // row-col COUNTS, never paints), while `draw_frame`'s
9478                    // real `chunks[1]` starts at `y:1` once the tabline
9479                    // claims the first screen row. Different absolute
9480                    // position, same size — that's expected, not a bug.
9481                    // The invariant that actually matters (does the
9482                    // runtime compute the SAME row/col count per pane that
9483                    // gets painted?) is the size, which this checks.
9484                    let runtime_sizes: Vec<(usize, u16, u16)> = runtime_rects
9485                        .iter()
9486                        .map(|(idx, r)| (*idx, r.width, r.height))
9487                        .collect();
9488                    let draw_sizes: Vec<(usize, u16, u16)> = draw_rects
9489                        .iter()
9490                        .map(|(idx, r)| (*idx, r.width, r.height))
9491                        .collect();
9492                    assert_eq!(
9493                        runtime_sizes, draw_sizes,
9494                        "runtime-pushed vs. actually-painted per-pane sizes diverged \
9495                         for splits={splits:?}, tabline_visible={tabline_visible}, \
9496                         terminal_size=({terminal_width}x{terminal_height})"
9497                    );
9498
9499                    // Every leaf's content height (status row reserved) must
9500                    // also agree — the same `rect.height - 1` formula both
9501                    // `runtime.rs`'s per-leaf loop and `draw_panes` apply,
9502                    // checked here across the SAME split/size matrix rather
9503                    // than just the whole-buffer area.
9504                    for (idx, rect) in &draw_rects {
9505                        let content_h = if rect.height >= 2 {
9506                            rect.height - 1
9507                        } else {
9508                            rect.height
9509                        };
9510                        assert!(
9511                            content_h <= rect.height,
9512                            "leaf {idx} content height must not exceed its pane rect \
9513                             for splits={splits:?} size=({terminal_width}x{terminal_height})"
9514                        );
9515                    }
9516                }
9517            }
9518        }
9519    }
9520
9521    fn line_text(line: &Line<'_>) -> String {
9522        line.spans.iter().map(|s| s.content.as_ref()).collect()
9523    }
9524
9525    /// Regression for the auto-scroll ghosting bug
9526    /// (`docs/dev/audit/terminal-wide-char-ghosting.md`): a width-2
9527    /// glyph occupies two grid cells (the glyph + a `wide_spacer`
9528    /// placeholder). The emitted row must have the SAME total display
9529    /// width as the grid column count — the wide glyph owns its two
9530    /// columns and the spacer must NOT be emitted as a stray space.
9531    /// Emitting the spacer pushed the row one column wide per glyph,
9532    /// desyncing ratatui's width-based cell diff and stranding stale
9533    /// glyphs on scroll.
9534    #[test]
9535    fn terminal_row_with_wide_glyph_keeps_grid_column_count() {
9536        use lattice_terminal::{Cell, TerminalSnapshot};
9537
9538        // Grid row: 🚀 (wide) + its spacer + "abc" → 5 grid columns.
9539        let cells = vec![
9540            Cell {
9541                ch: '🚀',
9542                wide_spacer: false,
9543                ..Cell::default()
9544            },
9545            Cell {
9546                ch: ' ',
9547                wide_spacer: true,
9548                ..Cell::default()
9549            },
9550            Cell {
9551                ch: 'a',
9552                ..Cell::default()
9553            },
9554            Cell {
9555                ch: 'b',
9556                ..Cell::default()
9557            },
9558            Cell {
9559                ch: 'c',
9560                ..Cell::default()
9561            },
9562        ];
9563        let cols = cells.len() as u16;
9564        let snap = TerminalSnapshot {
9565            cells: cells.into(),
9566            ..TerminalSnapshot::empty_sized(1, cols)
9567        };
9568
9569        let spans = super::terminal_row_spans(&snap, 0, cols, false, 0, 0, None, &[], None);
9570        let text: String = spans.iter().map(|s| s.content.as_ref()).collect();
9571        let display_width: usize = spans.iter().map(|s| s.width()).sum();
9572        assert_eq!(
9573            display_width, cols as usize,
9574            "emitted row display width ({display_width}) must equal the grid column \
9575             count ({cols}); the wide glyph owns 2 columns and the spacer must be \
9576             skipped — got text {text:?}",
9577        );
9578        assert_eq!(
9579            text, "🚀abc",
9580            "the wide-char spacer cell must be skipped, not emitted as a space",
9581        );
9582    }
9583
9584    /// MG.47: **a synthetic buffer keeps rendering its own text while the
9585    /// `:` line is open.**
9586    ///
9587    /// Reported against magit: pressing `:` in a magit buffer made the pane
9588    /// paint the file magit was opened *from*. `draw_panes` drops `is_active`
9589    /// while `command_line_active`, routing the focused pane to
9590    /// `draw_inactive_document` — which resolves the pane's OWN buffer from
9591    /// the registry. So the content must not change when the line opens.
9592    ///
9593    /// `*plugins*` stands in for a magit buffer: same
9594    /// `Effect::OpenSyntheticBuffer` open onto a provider-registered mode,
9595    /// and this crate cannot depend on `lattice-magit`.
9596    #[test]
9597    fn a_synthetic_buffer_keeps_its_content_while_the_command_line_is_open() {
9598        use ratatui::Terminal;
9599        use ratatui::backend::TestBackend;
9600
9601        let mut app = app_with("ORIGINFILECONTENT\n", 24);
9602        let mut terminal = Terminal::new(TestBackend::new(80, 26)).unwrap();
9603        let screen = |t: &Terminal<TestBackend>| -> String {
9604            let buf = t.backend().buffer().clone();
9605            (0..26u16)
9606                .map(|y| {
9607                    (0..80u16)
9608                        .map(|x| buf[(x, y)].symbol().to_string())
9609                        .collect::<String>()
9610                })
9611                .collect::<Vec<_>>()
9612                .join("\n")
9613        };
9614
9615        app.mutate_editor(|e| {
9616            e.open_synthetic_buffer("*plugins*", "plugins-mode");
9617            e.publish_render_state();
9618        });
9619        let snap = app.ad().snapshot.clone();
9620        terminal
9621            .draw(|f| {
9622                let _ = draw_frame(f, &app, &snap);
9623            })
9624            .unwrap();
9625        let before = screen(&terminal);
9626        assert!(
9627            !before.contains("ORIGINFILECONTENT"),
9628            "sanity: after opening the synthetic buffer the origin file's text \
9629             is gone from the pane; got:\n{before}",
9630        );
9631
9632        // Now open the `:` line. The pane must keep painting ITS buffer.
9633        app.mutate_editor(|e| {
9634            e.open_command_line("");
9635            e.publish_render_state();
9636        });
9637        let snap = app.ad().snapshot.clone();
9638        terminal
9639            .draw(|f| {
9640                let _ = draw_frame(f, &app, &snap);
9641            })
9642            .unwrap();
9643        let during = screen(&terminal);
9644        assert!(
9645            !during.contains("ORIGINFILECONTENT"),
9646            "opening `:` must not make the pane fall back to the buffer the \
9647             synthetic one was opened from; got:\n{during}",
9648        );
9649    }
9650
9651    /// MG.47, on the buffer it was actually reported against.
9652    ///
9653    /// The `*plugins*` case above covers the generic mechanism; magit differs
9654    /// in ways that could plausibly matter to the render path — a headerline
9655    /// virtual row, async content, and a `prev_pane_for_popup` stash taken at
9656    /// open. This renders a real magit buffer across the `:` transition.
9657    #[test]
9658    fn a_magit_buffer_keeps_its_content_while_the_command_line_is_open() {
9659        use ratatui::Terminal;
9660        use ratatui::backend::TestBackend;
9661
9662        let mut app = app_with("ORIGINFILECONTENT\n", 24);
9663        let mut terminal = Terminal::new(TestBackend::new(80, 26)).unwrap();
9664        let screen = |t: &Terminal<TestBackend>| -> String {
9665            let buf = t.backend().buffer().clone();
9666            (0..26u16)
9667                .map(|y| {
9668                    (0..80u16)
9669                        .map(|x| buf[(x, y)].symbol().to_string())
9670                        .collect::<String>()
9671                })
9672                .collect::<Vec<_>>()
9673                .join("\n")
9674        };
9675
9676        // Seed the magit buffer with known text. Without this the buffer is
9677        // empty in a test (no git repo, no async refresh), and every
9678        // "origin text is absent" assertion below would pass vacuously —
9679        // which is exactly how this bug hid from an earlier version of this
9680        // test.
9681        app.mutate_editor(|e| {
9682            e.open_synthetic_buffer("*magit:status*", "magit-status-mode");
9683            let id = e.buffers.by_name("*magit:status*").unwrap();
9684            e.append_to_owned_buffer(id, "MAGITSTATUSCONTENT\n");
9685            e.publish_render_state();
9686        });
9687        let snap = app.ad().snapshot.clone();
9688        terminal
9689            .draw(|f| {
9690                let _ = draw_frame(f, &app, &snap);
9691            })
9692            .unwrap();
9693        let before = screen(&terminal);
9694        assert!(
9695            before.contains("MAGITSTATUSCONTENT"),
9696            "sanity: the magit buffer's own text must be on screen before `:` \
9697             — otherwise the assertions below prove nothing; got:\n{before}",
9698        );
9699        assert!(
9700            !before.contains("ORIGINFILECONTENT"),
9701            "sanity: the magit buffer replaced the origin file in the pane",
9702        );
9703
9704        app.mutate_editor(|e| {
9705            e.open_command_line("");
9706            e.publish_render_state();
9707        });
9708        let snap = app.ad().snapshot.clone();
9709        terminal
9710            .draw(|f| {
9711                let _ = draw_frame(f, &app, &snap);
9712            })
9713            .unwrap();
9714        let during = screen(&terminal);
9715        assert!(
9716            !during.contains("ORIGINFILECONTENT"),
9717            "opening `:` in a magit buffer must not repaint the pane with the \
9718             file magit was opened from; got:\n{during}",
9719        );
9720        assert!(
9721            during.contains("MAGITSTATUSCONTENT"),
9722            "the pane must keep painting the magit buffer's own text while \
9723             the `:` line is open; got:\n{during}",
9724        );
9725    }
9726
9727    /// MG.51: **the prompt line shows what you type.**
9728    ///
9729    /// `Effect::OpenPrompt` (magit's branch checkout, tag name, …) puts
9730    /// the LABEL in the echo and the typed text in the `*prompt-line*`
9731    /// buffer. `draw_command_or_echo` had no `ModalState::Prompt` arm,
9732    /// so the row fell through to the echo and drew the label alone —
9733    /// keys reached the buffer and submitting worked, so the prompt read
9734    /// as a dead input that mysteriously did the right thing.
9735    ///
9736    /// Seeded through `initial` rather than keypresses: the defect is in
9737    /// what gets DRAWN, and `initial` puts text in the same buffer the
9738    /// dispatcher writes to.
9739    #[test]
9740    fn the_prompt_line_renders_the_text_being_typed() {
9741        use ratatui::Terminal;
9742        use ratatui::backend::TestBackend;
9743
9744        let mut app = app_with("x\n", 10);
9745        let mut terminal = Terminal::new(TestBackend::new(60, 12)).unwrap();
9746
9747        app.mutate_editor(|e| {
9748            e.open_prompt_line(
9749                "Checkout branch/revision: ".to_string(),
9750                "feature/x".to_string(),
9751                "action:magit-global-branch-checkout".to_string(),
9752                None,
9753            );
9754            e.publish_render_state();
9755        });
9756
9757        let snap = app.ad().snapshot.clone();
9758        terminal
9759            .draw(|f| {
9760                let _ = draw_frame(f, &app, &snap);
9761            })
9762            .unwrap();
9763        let buf = terminal.backend().buffer().clone();
9764        let out: String = (0..12u16)
9765            .map(|y| {
9766                (0..60u16)
9767                    .map(|x| buf[(x, y)].symbol().to_string())
9768                    .collect::<String>()
9769            })
9770            .collect::<Vec<_>>()
9771            .join("\n");
9772
9773        assert!(
9774            out.contains("Checkout branch/revision:"),
9775            "the label must still show; got:\n{out}"
9776        );
9777        assert!(
9778            out.contains("feature/x"),
9779            "the TYPED TEXT must show — this is the bug: the label \
9780             rendered and the input did not; got:\n{out}"
9781        );
9782    }
9783
9784    /// Evidence test for the preview-switch "bleeding" report: render a LONG
9785    /// active document, then a SHORT one (the binary-placeholder case), to
9786    /// the same TestBackend terminal. After the switch, NO row may still
9787    /// show the long document's content. If this FAILS, the per-frame render
9788    /// isn't clearing vacated rows (a real render bug). If it PASSES, the
9789    /// on-screen bleed is an out-of-band terminal write, not the renderer.
9790    #[test]
9791    fn shrinking_active_document_clears_vacated_rows() {
9792        use ratatui::Terminal;
9793        use ratatui::backend::TestBackend;
9794
9795        let dir = std::env::temp_dir().join(format!("lattice-bleed-{}", std::process::id()));
9796        std::fs::create_dir_all(&dir).unwrap();
9797        let long = dir.join("long.txt");
9798        let short = dir.join("short.txt");
9799        let body: String = (0..30)
9800            .map(|i| format!("members entry line {i} XXXXXXXXXXXXXXXXXXXX\n"))
9801            .collect();
9802        std::fs::write(&long, &body).unwrap();
9803        std::fs::write(&short, "only one line\n").unwrap();
9804
9805        let mut app = app_with("origin\n", 24);
9806        let mut terminal = Terminal::new(TestBackend::new(80, 26)).unwrap();
9807
9808        let row_text = |buf: &ratatui::buffer::Buffer, y: u16| -> String {
9809            (0..80).map(|x| buf[(x, y)].symbol().to_string()).collect()
9810        };
9811
9812        // Frame 1: preview the long file.
9813        let long_c = long.clone();
9814        app.mutate_editor(move |e| {
9815            let _ = e.do_preview(long_c, None);
9816            e.publish_render_state();
9817        });
9818        let snap = app.ad().snapshot.clone();
9819        terminal
9820            .draw(|f| {
9821                let _ = draw_frame(f, &app, &snap);
9822            })
9823            .unwrap();
9824        let buf1 = terminal.backend().buffer().clone();
9825        let frame1_has_long = (0..26u16).any(|y| row_text(&buf1, y).contains("members entry"));
9826        assert!(
9827            frame1_has_long,
9828            "sanity: the long preview must actually render its content"
9829        );
9830
9831        // Frame 2: preview the short file (active doc shrinks 30 → 1 line).
9832        let short_c = short.clone();
9833        app.mutate_editor(move |e| {
9834            let _ = e.do_preview(short_c, None);
9835            e.publish_render_state();
9836        });
9837        let snap = app.ad().snapshot.clone();
9838        terminal
9839            .draw(|f| {
9840                let _ = draw_frame(f, &app, &snap);
9841            })
9842            .unwrap();
9843        let buf2 = terminal.backend().buffer().clone();
9844        // Check BOTH the line START ("members entry") AND the line TAIL
9845        // ("XXXX…") — the reported bleed is the trailing chars, which a
9846        // start-only check would miss.
9847        let stale: Vec<(u16, String)> = (0..26u16)
9848            .filter_map(|y| {
9849                let r = row_text(&buf2, y);
9850                if r.contains("members entry") || r.contains("XXXX") {
9851                    Some((y, r.trim_end().to_string()))
9852                } else {
9853                    None
9854                }
9855            })
9856            .collect();
9857
9858        std::fs::remove_dir_all(&dir).ok();
9859        assert!(
9860            stale.is_empty(),
9861            "stale long-file content after switching to a short preview: {stale:?}"
9862        );
9863    }
9864
9865    // ---- W.4: body wrap segment splitting ----
9866
9867    #[test]
9868    fn split_body_wrap_off_or_fits_is_single_segment() {
9869        let body = vec![Span::raw("hello world")];
9870        // width 0 ⇒ wrap off.
9871        assert_eq!(split_body_into_segments(body.clone(), 0, 11).len(), 1);
9872        // fits within width ⇒ single segment.
9873        assert_eq!(split_body_into_segments(body, 80, 11).len(), 1);
9874    }
9875
9876    #[test]
9877    fn split_body_trailing_decoration_never_breaks_a_segment() {
9878        // Regression (2026-08-08): the closed-fold ` ⋯ N lines`
9879        // summary is appended to the body AFTER the source spans, so
9880        // it used to push a heading that fits on one row onto two —
9881        // one more row than `wrap_segments(col_count, width)`, which
9882        // is what the host's scroll model and the caret walk count.
9883        // Trailing decoration rides the final segment instead.
9884        let src = Span::raw("0123456789"); // 10 source columns
9885        let deco = Span::raw(" ⋯ 4 lines"); // 10 decoration columns
9886        let body = vec![src.clone(), deco.clone()];
9887        let segs = split_body_into_segments(body, 12, 10);
9888        assert_eq!(
9889            segs.len(),
9890            lattice_cells::wrap_segments(10, 12) as usize,
9891            "decoration past `wrap_cols` must not create a display row"
9892        );
9893
9894        // The source axis still wraps normally, and the decoration
9895        // lands on the LAST segment.
9896        let body = vec![src, deco];
9897        let segs = split_body_into_segments(body, 4, 10);
9898        assert_eq!(segs.len(), lattice_cells::wrap_segments(10, 4) as usize);
9899        let text =
9900            |s: &[Span<'static>]| -> String { s.iter().map(|sp| sp.content.as_ref()).collect() };
9901        assert_eq!(text(&segs[0]), "0123");
9902        assert_eq!(text(&segs[1]), "4567");
9903        assert_eq!(text(&segs[2]), "89 ⋯ 4 lines");
9904    }
9905
9906    #[test]
9907    fn w4t1_source_byte_maps_to_expanded_body_position() {
9908        // W.4.t.1: overlays carry SOURCE bytes, but the cell body has
9909        // tabs expanded; the mapping must land them on the right cell.
9910        // tabstop 4. "\tfoo": tab fills col 0→4, then "foo".
9911        let line = "\tfoo";
9912        assert_eq!(source_byte_to_body_col(line, 0, 4), 0); // before the tab
9913        assert_eq!(source_byte_to_body_col(line, 1, 4), 4); // after tab → col 4 ('f')
9914        assert_eq!(source_byte_to_body_col(line, 4, 4), 7); // after "foo" → col 7 (EOL)
9915        // Mid-line tab "a\tb": 'a' at 0, tab fills 1→4, 'b' at col 4.
9916        let line2 = "a\tb";
9917        assert_eq!(source_byte_to_body_col(line2, 1, 4), 1); // after 'a'
9918        assert_eq!(source_byte_to_body_col(line2, 2, 4), 4); // after tab
9919        assert_eq!(source_byte_to_body_col(line2, 3, 4), 5); // after 'b'
9920        // ASCII without tabs: identity (col == byte) ⇒ plain code unchanged.
9921        assert_eq!(source_byte_to_body_col("hello", 3, 4), 3);
9922        // nth_char_byte walks the expanded body "    foo" by char index.
9923        let body = "    foo";
9924        assert_eq!(nth_char_byte(body, 4), 4); // 4th char = 'f'
9925        assert_eq!(nth_char_byte(body, 7), body.len()); // past end clamps to EOL
9926        // Composed: source byte 1 of "\tfoo" → body byte 4 (start of "foo").
9927        assert_eq!(nth_char_byte(body, source_byte_to_body_col(line, 1, 4)), 4);
9928    }
9929
9930    #[test]
9931    fn split_body_breaks_at_width_preserving_styles() {
9932        // Two styled spans: "abcd" + "efghij" = 10 cols, width 4 ⇒
9933        // segments [abcd][efgh][ij]. Styles must survive the split.
9934        let red = TuiStyle::default().fg(Color::Red);
9935        let blue = TuiStyle::default().fg(Color::Blue);
9936        let body = vec![Span::styled("abcd", red), Span::styled("efghij", blue)];
9937        let segs = split_body_into_segments(body, 4, 10);
9938        assert_eq!(segs.len(), 3);
9939        let text =
9940            |s: &[Span<'static>]| -> String { s.iter().map(|sp| sp.content.as_ref()).collect() };
9941        assert_eq!(text(&segs[0]), "abcd");
9942        assert_eq!(text(&segs[1]), "efgh");
9943        assert_eq!(text(&segs[2]), "ij");
9944        // Segment 0 is fully red; segment 1 spans the red→blue
9945        // boundary ("e" was blue, but so is the rest of seg1).
9946        assert_eq!(segs[0][0].style.fg, Some(Color::Red));
9947        assert_eq!(segs[1][0].style.fg, Some(Color::Blue));
9948        // Total column count matches ⌈10/4⌉ = 3 segments — the
9949        // host's `CellMatrix::segment_count` agrees.
9950        assert_eq!(segs.len(), lattice_cells::wrap_segments(10, 4) as usize);
9951    }
9952
9953    // ---- IG.3: indentation-guide pre-pass ----
9954
9955    fn guide_style() -> IndentGuideStyle {
9956        IndentGuideStyle {
9957            glyph: Some('\u{2502}'),
9958            normal: TuiStyle::default().fg(Color::DarkGray),
9959            active: TuiStyle::default().fg(Color::White),
9960            active_block: None,
9961        }
9962    }
9963
9964    fn marks(cols: &[u16]) -> Vec<lattice_host::indent_guides::GuideMark> {
9965        cols.iter()
9966            .enumerate()
9967            .map(|(i, c)| lattice_host::indent_guides::GuideMark {
9968                col: *c,
9969                block: i as u16,
9970            })
9971            .collect()
9972    }
9973
9974    /// Apply guides the way the compose loop does with no horizontal scroll.
9975    fn guides_on(body: &str, cols: &[u16], style: &IndentGuideStyle) -> Vec<Span<'static>> {
9976        let spans = if body.is_empty() {
9977            Vec::new()
9978        } else {
9979            vec![Span::raw(body.to_string())]
9980        };
9981        apply_indent_guides(spans, &marks(cols), style, 0, u32::MAX)
9982    }
9983
9984    #[test]
9985    fn indent_guides_noop_without_marks_or_glyph() {
9986        let input = vec![Span::raw("        work();".to_string())];
9987        let out = apply_indent_guides(input.clone(), &[], &guide_style(), 0, u32::MAX);
9988        assert_eq!(spans_text(&out), spans_text(&input));
9989
9990        let mut off = guide_style();
9991        off.glyph = None;
9992        let out = apply_indent_guides(input.clone(), &marks(&[0, 4]), &off, 0, u32::MAX);
9993        assert_eq!(spans_text(&out), spans_text(&input), "empty glyph disables");
9994    }
9995
9996    #[test]
9997    fn indent_guides_substitute_at_marked_columns() {
9998        let out = guides_on("        work();", &[0, 4], &guide_style());
9999        let rendered = spans_text(&out);
10000        assert_eq!(rendered, "\u{2502}   \u{2502}   work();");
10001        // Width is preserved: one char in, one char out. A guide that
10002        // widened the row would shift every column right of it, and the
10003        // overlay remap downstream indexes by column.
10004        assert_eq!(rendered.chars().count(), "        work();".chars().count());
10005    }
10006
10007    #[test]
10008    fn indent_guides_never_replace_a_non_blank_character() {
10009        // The producer only marks blank columns, so this asserts the
10010        // renderer honours that rather than re-deriving it: every char the
10011        // pass replaced must have been a space.
10012        let line = "        work();";
10013        let out = guides_on(line, &[0, 4], &guide_style());
10014        for (i, ch) in spans_text(&out).chars().enumerate() {
10015            if ch == '\u{2502}' {
10016                assert_eq!(
10017                    line.chars().nth(i),
10018                    Some(' '),
10019                    "guide at col {i} replaced a non-blank"
10020                );
10021            }
10022        }
10023    }
10024
10025    #[test]
10026    fn indent_guides_pad_a_blank_row() {
10027        // The blank-line-inside-a-block case: nothing to substitute into,
10028        // so the pass pads out to each marked column.
10029        let out = guides_on("", &[0, 4], &guide_style());
10030        assert_eq!(spans_text(&out), "\u{2502}   \u{2502}");
10031    }
10032
10033    #[test]
10034    fn indent_guides_pad_from_a_short_body() {
10035        // A body shorter than its marks: substitute what exists, pad the
10036        // rest out to the absolute column each guide belongs at.
10037        let out = guides_on("  ", &[0, 4], &guide_style());
10038        assert_eq!(spans_text(&out), "\u{2502}   \u{2502}");
10039    }
10040
10041    #[test]
10042    fn indent_guides_style_the_active_block_apart() {
10043        let mut style = guide_style();
10044        style.active_block = Some(1); // marks(&[0, 4]) gives block 1 col 4
10045        let out = guides_on("        work();", &[0, 4], &style);
10046        let guides: Vec<TuiStyle> = out
10047            .iter()
10048            .filter(|s| s.content.as_ref() == "\u{2502}")
10049            .map(|s| s.style)
10050            .collect();
10051        assert_eq!(guides.len(), 2);
10052        assert_eq!(guides[0], style.normal, "outer guide stays normal");
10053        assert_eq!(guides[1], style.active, "enclosing block is highlighted");
10054    }
10055
10056    #[test]
10057    fn indent_guides_survive_a_multi_span_body() {
10058        // Syntax colouring splits the leading whitespace across runs; the
10059        // walk is by column, not by span, so the guide still lands.
10060        let body = vec![
10061            Span::styled("    ".to_string(), TuiStyle::default()),
10062            Span::styled("    ".to_string(), TuiStyle::default().fg(Color::Blue)),
10063            Span::styled("work();".to_string(), TuiStyle::default().fg(Color::Red)),
10064        ];
10065        let out = apply_indent_guides(body, &marks(&[0, 4]), &guide_style(), 0, u32::MAX);
10066        assert_eq!(spans_text(&out), "\u{2502}   \u{2502}   work();");
10067    }
10068
10069    #[test]
10070    fn indent_guides_replace_whitespace_markers_at_their_column() {
10071        // `:set list` has already decorated the indentation. A guide and a
10072        // leading-whitespace marker want the same cell; the guide wins,
10073        // because it carries structure and the marker only says "space".
10074        let out = guides_on("\u{b7}\u{b7}\u{b7}\u{b7}work();", &[0], &guide_style());
10075        assert_eq!(spans_text(&out), "\u{2502}\u{b7}\u{b7}\u{b7}work();");
10076    }
10077
10078    #[test]
10079    fn indent_guides_pan_with_leftcol() {
10080        // Guides run after the clip, so a horizontally scrolled body gets
10081        // its marks translated: the column-4 guide lands at body column 0
10082        // and the column-0 guide has scrolled off.
10083        let clipped =
10084            clip_spans_horizontally(vec![Span::raw("        work();".to_string())], 4, 40);
10085        let out = apply_indent_guides(clipped, &marks(&[0, 4]), &guide_style(), 4, 40);
10086        assert_eq!(spans_text(&out), "\u{2502}   work();");
10087    }
10088
10089    #[test]
10090    fn indent_guides_do_not_shorten_the_clipped_body() {
10091        // The regression this ordering exists to prevent: the clip measures
10092        // in BYTES, so substituting three-byte glyphs ahead of it would eat
10093        // two columns of real text per guide.
10094        let line = "        work();  tail";
10095        let clipped = clip_spans_horizontally(vec![Span::raw(line.to_string())], 0, 21);
10096        let out = apply_indent_guides(clipped, &marks(&[0, 4]), &guide_style(), 0, 21);
10097        assert_eq!(
10098            spans_text(&out).chars().count(),
10099            21,
10100            "every column the clip kept survives the guide pass"
10101        );
10102        assert!(spans_text(&out).ends_with("tail"));
10103    }
10104
10105    #[test]
10106    fn indent_guides_outside_the_viewport_are_dropped() {
10107        // A guide past the right edge must not pad the row out past it.
10108        let out = apply_indent_guides(Vec::new(), &marks(&[0, 40]), &guide_style(), 0, 8);
10109        assert_eq!(spans_text(&out), "\u{2502}");
10110    }
10111
10112    // ---- M.7.3.b: whitespace decoration pre-pass ----
10113
10114    fn ws_decoration_default() -> WhitespaceDecoration {
10115        // Mirrors the emacs-default option set: tab, trailing,
10116        // leading on; space + EOL off.
10117        WhitespaceDecoration {
10118            tab: Some('→'),
10119            trailing: Some('·'),
10120            leading: Some('·'),
10121            space: None,
10122            eol: None,
10123            style_normal: TuiStyle::default().fg(Color::DarkGray),
10124            style_trailing: TuiStyle::default().fg(Color::Red),
10125        }
10126    }
10127
10128    fn spans_text(spans: &[Span<'static>]) -> String {
10129        spans.iter().map(|s| s.content.as_ref()).collect()
10130    }
10131
10132    #[test]
10133    fn whitespace_decoration_noop_when_all_disabled() {
10134        let mut d = ws_decoration_default();
10135        d.tab = None;
10136        d.trailing = None;
10137        d.leading = None;
10138        let line = "  hello \t  ";
10139        let input: Vec<Span<'static>> = vec![Span::raw(line.to_string())];
10140        let out = apply_whitespace_decoration(input.clone(), line, &d);
10141        assert_eq!(spans_text(&out), spans_text(&input));
10142    }
10143
10144    #[test]
10145    fn whitespace_decoration_substitutes_tab_glyph() {
10146        let d = ws_decoration_default();
10147        let line = "abc\tdef";
10148        let input = vec![Span::raw(line.to_string())];
10149        let out = apply_whitespace_decoration(input, line, &d);
10150        let rendered = spans_text(&out);
10151        assert!(rendered.contains('→'), "tab glyph missing: {rendered:?}");
10152        assert!(!rendered.contains('\t'), "raw tab leaked: {rendered:?}");
10153    }
10154
10155    #[test]
10156    fn whitespace_decoration_marks_trailing_in_red() {
10157        let d = ws_decoration_default();
10158        let line = "hello   "; // three trailing spaces
10159        let input = vec![Span::raw(line.to_string())];
10160        let out = apply_whitespace_decoration(input, line, &d);
10161        // Trailing dots present.
10162        let rendered = spans_text(&out);
10163        let dot_count = rendered.chars().filter(|c| *c == '·').count();
10164        assert_eq!(dot_count, 3, "expected 3 trailing dots, got {rendered:?}");
10165        // Each trailing-glyph span carries the trailing style.
10166        let trailing_spans: Vec<_> = out.iter().filter(|s| s.content.as_ref() == "·").collect();
10167        assert_eq!(trailing_spans.len(), 3);
10168        for s in trailing_spans {
10169            assert_eq!(s.style.fg, Some(Color::Red), "trailing should be red");
10170        }
10171    }
10172
10173    #[test]
10174    fn whitespace_decoration_marks_leading_with_normal_style() {
10175        let d = ws_decoration_default();
10176        let line = "  hello";
10177        let input = vec![Span::raw(line.to_string())];
10178        let out = apply_whitespace_decoration(input, line, &d);
10179        // Two leading dots.
10180        let dot_spans: Vec<_> = out.iter().filter(|s| s.content.as_ref() == "·").collect();
10181        assert_eq!(dot_spans.len(), 2);
10182        // Leading uses style_normal (DarkGray), not trailing's red.
10183        for s in dot_spans {
10184            assert_eq!(s.style.fg, Some(Color::DarkGray));
10185        }
10186    }
10187
10188    #[test]
10189    fn whitespace_decoration_trailing_wins_over_leading_for_pure_ws_line() {
10190        // A line that's nothing but whitespace: trailing
10191        // covers the whole range (last_non_ws = 0) and trailing
10192        // has higher precedence than leading.
10193        let d = ws_decoration_default();
10194        let line = "   ";
10195        let input = vec![Span::raw(line.to_string())];
10196        let out = apply_whitespace_decoration(input, line, &d);
10197        let dots: Vec<_> = out.iter().filter(|s| s.content.as_ref() == "·").collect();
10198        assert_eq!(dots.len(), 3);
10199        for s in dots {
10200            assert_eq!(
10201                s.style.fg,
10202                Some(Color::Red),
10203                "pure-ws line should be all trailing-marked",
10204            );
10205        }
10206    }
10207
10208    #[test]
10209    fn whitespace_decoration_does_not_mark_mid_text_space_when_disabled() {
10210        // `space: None` (default): mid-text spaces stay bare.
10211        let d = ws_decoration_default();
10212        let line = "a b c";
10213        let input = vec![Span::raw(line.to_string())];
10214        let out = apply_whitespace_decoration(input, line, &d);
10215        let rendered = spans_text(&out);
10216        // No dots (no leading / trailing in this line).
10217        assert!(!rendered.contains('·'), "should be bare: {rendered:?}");
10218        // The bare spaces are preserved.
10219        assert!(rendered.contains("a b c"), "got: {rendered:?}");
10220    }
10221
10222    #[test]
10223    fn whitespace_decoration_marks_mid_text_space_when_enabled() {
10224        let mut d = ws_decoration_default();
10225        d.space = Some('·');
10226        let line = "a b c";
10227        let input = vec![Span::raw(line.to_string())];
10228        let out = apply_whitespace_decoration(input, line, &d);
10229        let dots = spans_text(&out).chars().filter(|c| *c == '·').count();
10230        assert_eq!(dots, 2);
10231    }
10232
10233    // ---- M.7.3.c: current-line highlight ----
10234
10235    fn span_with_bg<'a>(line: &'a Line<'_>, expected_bg: Color) -> Option<&'a Span<'a>> {
10236        line.spans.iter().find(|s| s.style.bg == Some(expected_bg))
10237    }
10238
10239    #[test]
10240    fn current_line_highlight_off_emits_no_special_bg() {
10241        // Default state: the option is off ⇒ cursor row has no
10242        // bg from the highlight pass.
10243        let app = app_with("hello\nworld\n", 5);
10244        let lines = compose_visible_lines(&app, &app.ad().snapshot.clone(), 5, 80);
10245        for line in &lines {
10246            assert!(
10247                span_with_bg(line, Color::Indexed(236)).is_none(),
10248                "no cursor-line bg expected when off",
10249            );
10250        }
10251    }
10252
10253    #[test]
10254    fn current_line_highlight_on_paints_cursor_row_bg() {
10255        // Activate `current-line-highlight-mode`; the cursor's
10256        // row should pick up the theme's cursor_line_bg.
10257        let mut app = app_with("hello\nworld\n", 5);
10258        app.toggle_mode_by_name("current-line-highlight-mode");
10259        // Cursor starts on line 0; verify its row has the bg.
10260        let lines = compose_visible_lines(&app, &app.ad().snapshot.clone(), 5, 80);
10261        let cursor_row = &lines[0];
10262        assert!(
10263            span_with_bg(cursor_row, Color::Indexed(236)).is_some(),
10264            "cursor row should have cursor_line_bg: {cursor_row:?}",
10265        );
10266        // Non-cursor row stays clean.
10267        let other_row = &lines[1];
10268        assert!(
10269            span_with_bg(other_row, Color::Indexed(236)).is_none(),
10270            "other rows should not have cursor_line_bg: {other_row:?}",
10271        );
10272    }
10273
10274    #[test]
10275    fn current_line_highlight_pads_to_pane_width() {
10276        // Even on a short line, the highlight should reach
10277        // the right edge -- the renderer appends a pad-span
10278        // with bg-only style.
10279        let mut app = app_with("hi\n", 5);
10280        app.toggle_mode_by_name("current-line-highlight-mode");
10281        let lines = compose_visible_lines(&app, &app.ad().snapshot.clone(), 5, 80);
10282        let cursor_row = &lines[0];
10283        // Find any pad span: bg = cursor_line_bg, content all
10284        // spaces.
10285        let pad = cursor_row.spans.iter().find(|s| {
10286            s.style.bg == Some(Color::Indexed(236))
10287                && s.content.chars().all(|c| c == ' ')
10288                && s.content.len() > 1
10289        });
10290        assert!(pad.is_some(), "expected a pad span: {cursor_row:?}",);
10291    }
10292
10293    #[test]
10294    fn whitespace_show_mode_off_produces_no_decoration_in_pipeline() {
10295        // Default state: whitespace-show-mode is inactive ⇒
10296        // `option_cache.show_whitespace == false` ⇒ pre-pass
10297        // is skipped ⇒ rendered body shows raw text.
10298        let app = app_with("hello   \n", 5);
10299        let lines = compose_visible_lines(&app, &app.ad().snapshot.clone(), 5, 80);
10300        let row0 = line_text(&lines[0]);
10301        assert!(!row0.contains('·'), "no dots when ws-mode off: {row0:?}");
10302    }
10303
10304    #[test]
10305    fn whitespace_show_mode_on_produces_trailing_dots_in_pipeline() {
10306        // Activate `whitespace-show-mode` (cascade flips
10307        // `Whitespace=true`); the renderer's pipeline wires
10308        // through the cache and pre-pass kicks in.
10309        let mut app = app_with("hello   \n", 5);
10310        app.toggle_mode_by_name("whitespace-show-mode");
10311        let lines = compose_visible_lines(&app, &app.ad().snapshot.clone(), 5, 80);
10312        let row0 = line_text(&lines[0]);
10313        assert!(
10314            row0.contains("hello") && row0.contains('·'),
10315            "ws-mode on should show content + trailing dots: {row0:?}",
10316        );
10317    }
10318
10319    /// W.4.t.2: the same assertion as the sibling above, but with a
10320    /// **late worker write** — a rebuild that was already in flight when
10321    /// the toggle landed and finishes afterwards, storing cells built
10322    /// under the PRE-toggle whitespace config into the pane's (shared,
10323    /// stable-identity) matrix cell. Nothing re-runs the worker after
10324    /// that, so this is the state a frame paints from.
10325    ///
10326    /// The builder bakes whitespace markers into `Cell.ch` at emission
10327    /// and the compose-loop pre-pass deliberately skips cell-derived
10328    /// bodies (re-decorating would desync source bytes), so without the
10329    /// whitespace axis in the staleness guard the frame paints
10330    /// undecorated text and `:set list` looks like it did nothing.
10331    ///
10332    /// This is the deterministic form of a flake: pre-fix, the sibling
10333    /// test failed whenever the real worker lost this race (~50% of
10334    /// full-suite runs under `--features system-clipboard`, green in
10335    /// isolation).
10336    #[test]
10337    fn whitespace_shows_dots_when_a_late_worker_write_predates_the_toggle() {
10338        let mut app = app_with("hello   \n", 5);
10339        app.editor.publish_render_state();
10340        let pane_id = app.panes().tree.active().id;
10341        // Capture the PRE-toggle worker inputs: version stamp + whitespace
10342        // config as they were when the in-flight rebuild started.
10343        let stale_inputs = app.editor.render_state.load().cells.load_full();
10344        assert!(
10345            !stale_inputs.whitespace.show,
10346            "precondition: the in-flight rebuild started with whitespace off"
10347        );
10348        // Toggle whitespace on. The option cascade republishes (and
10349        // rebuilds) with the new config.
10350        app.toggle_mode_by_name("whitespace-show-mode");
10351
10352        // The painted matrix's whitespace stamp, and the stamp a matrix
10353        // built now would carry.
10354        let stamps = || {
10355            let live = app.editor.render_state.load().cells.load_full();
10356            let painted = live
10357                .display_matrix_for_pane(pane_id)
10358                .expect("pane matrix")
10359                .load()
10360                .version
10361                .whitespace;
10362            (painted, live.whitespace_version_for_pane(pane_id))
10363        };
10364        // The toggle also woke the LIVE cells worker, which rebuilds on its
10365        // own thread. If that rebuild lands after the replay below, it
10366        // overwrites the stale stamp and the precondition fails: a race in
10367        // this test's SETUP, lost more often the busier the suite is (it
10368        // failed 3 of 4 full runs on 2026-09-15 and never alone). So wait for
10369        // the worker to catch up with the toggle first. The budget is only
10370        // spent if the worker never rebuilds, in which case there is no race.
10371        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
10372        while {
10373            let (painted, current) = stamps();
10374            painted != current
10375        } && std::time::Instant::now() < deadline
10376        {
10377            std::thread::sleep(std::time::Duration::from_millis(10));
10378        }
10379
10380        // Now the in-flight rebuild lands, stamping the pane's matrix cell
10381        // with the pre-toggle version + undecorated cells.
10382        let pane = stale_inputs
10383            .panes
10384            .iter()
10385            .find(|p| p.pane_id == pane_id)
10386            .expect("active document pane present in cells inputs");
10387        let replay_stale_write = || {
10388            let _ = lattice_host::cells_worker::recompute_pane(
10389                pane,
10390                lattice_host::cells_worker::CellTheme {
10391                    resolved: &stale_inputs.resolved_theme,
10392                    ids: &stale_inputs.theme_ids,
10393                },
10394                &stale_inputs.whitespace,
10395            );
10396        };
10397        replay_stale_write();
10398        // A coalesced tail rebuild can still land once more. Only the setup is
10399        // retried here; the assertions below run once, against a matrix that
10400        // is stale when they start.
10401        while {
10402            let (painted, current) = stamps();
10403            painted == current
10404        } && std::time::Instant::now() < deadline
10405        {
10406            std::thread::sleep(std::time::Duration::from_millis(10));
10407            replay_stale_write();
10408        }
10409        let (painted, current) = stamps();
10410        assert_ne!(
10411            painted, current,
10412            "precondition: the painted matrix carries the pre-toggle \
10413             whitespace stamp"
10414        );
10415        let lines = compose_visible_lines(&app, &app.ad().snapshot.clone(), 5, 80);
10416        let row0 = line_text(&lines[0]);
10417        assert!(
10418            row0.contains("hello") && row0.contains('·'),
10419            "a stale-whitespace matrix must fall back to the raw-text \
10420             pre-pass so the glyphs appear on this frame: {row0:?}",
10421        );
10422    }
10423
10424    #[test]
10425    fn whitespace_decoration_appends_eol_glyph_when_enabled() {
10426        let mut d = ws_decoration_default();
10427        d.eol = Some('¬');
10428        let line = "hello";
10429        let input = vec![Span::raw(line.to_string())];
10430        let out = apply_whitespace_decoration(input, line, &d);
10431        let rendered = spans_text(&out);
10432        assert!(rendered.ends_with('¬'), "got: {rendered:?}");
10433    }
10434
10435    #[test]
10436    fn whitespace_decoration_preserves_syntax_highlight_around_substitutions() {
10437        // Two-span input simulating syntax highlight: keyword
10438        // span + raw rest. The whitespace pre-pass should keep
10439        // the keyword's style on its non-whitespace content
10440        // and split out a separate trailing-styled span for the
10441        // trailing dots.
10442        let kw_style = TuiStyle::default().fg(Color::Yellow);
10443        let line = "fn main()  ";
10444        let input = vec![
10445            Span::styled("fn".to_string(), kw_style),
10446            Span::raw(" main()  ".to_string()),
10447        ];
10448        let d = ws_decoration_default();
10449        let out = apply_whitespace_decoration(input, line, &d);
10450        // Keyword span survives unchanged.
10451        assert!(
10452            out.iter()
10453                .any(|s| s.content.as_ref() == "fn" && s.style.fg == Some(Color::Yellow)),
10454            "keyword span lost: {out:?}",
10455        );
10456        // Two trailing dots present + red.
10457        let trailing: Vec<_> = out
10458            .iter()
10459            .filter(|s| s.content.as_ref() == "·" && s.style.fg == Some(Color::Red))
10460            .collect();
10461        assert_eq!(trailing.len(), 2);
10462    }
10463
10464    /// The host's gutter reservation must equal what this renderer
10465    /// actually paints, for every combination of the two options that
10466    /// change it.
10467    ///
10468    /// 2026-08-16: they differed by one column. `gutter_cols` accounted
10469    /// for the severity column but not the diff-sign column added later,
10470    /// so the host's soft-wrap width was one wider than the painted one.
10471    /// A line whose length fell exactly between the two wrapped on screen
10472    /// but not in the scroll budget, and the extra rendered row pushed
10473    /// the cursor below the last painted line — `G` on a 219-line
10474    /// todo.org with a 173-character line, host width 173, renderer 172.
10475    ///
10476    /// A previous fix for the same symptom made the host's two clamps
10477    /// share `gutter_cols` with each other. They agreed, and both
10478    /// disagreed with what was painted — which is why this test asserts
10479    /// across the crate boundary rather than within the host.
10480    #[test]
10481    fn gutter_cols_matches_the_tui_gutter() {
10482        for lines in [1u32, 9, 10, 99, 100, 219, 1000, 12345] {
10483            for numbers in [true, false] {
10484                for signs in [true, false] {
10485                    let painted = (if numbers {
10486                        gutter_width(lines)
10487                    } else {
10488                        GUTTER_TRAILING_PAD
10489                    }) + if signs {
10490                        lattice_mode::BUILTIN_SIGN_COLUMNS.len() as u32
10491                    } else {
10492                        0
10493                    };
10494                    let reserved = lattice_host::cells_worker::gutter_cols(lines, numbers, signs);
10495                    assert_eq!(
10496                        reserved, painted,
10497                        "lines={lines} number={numbers} signcolumn={signs}: the host \
10498                         reserves {reserved} columns, the renderer paints {painted}"
10499                    );
10500                }
10501            }
10502        }
10503    }
10504
10505    #[test]
10506    fn gutter_width_for_small_buffers() {
10507        // Layout: 1 leading pad + N digits + GUTTER_TRAILING_PAD (3)
10508        // = N + 4 cells. 1-digit numbers => 5 cells (" 1   "),
10509        // 2-digit => 6 (" 99   "), 3-digit => 7 ("100   ").
10510        assert_eq!(gutter_width(1), 5);
10511        assert_eq!(gutter_width(9), 5);
10512        assert_eq!(gutter_width(10), 6);
10513        assert_eq!(gutter_width(99), 6);
10514        assert_eq!(gutter_width(100), 7);
10515    }
10516
10517    /// Concatenate a gutter's span contents back into one string.
10518    fn gutter_text(spans: &[Span<'static>]) -> String {
10519        spans.iter().map(|s| s.content.as_ref()).collect()
10520    }
10521
10522    #[test]
10523    fn render_gutter_separates_number_from_buffer_with_two_cells() {
10524        // Layout: `[lead][digits][space][glyph_or_space]`. With no
10525        // fold the rightmost cell is a plain space, so output ends
10526        // in two spaces -- one separator between digits and glyph
10527        // slot, one empty glyph slot. No fold ⇒ a single span.
10528        let spans = render_gutter(0, gutter_width(1), None);
10529        assert_eq!(spans.len(), 1, "no-fold gutter is one span: {spans:?}");
10530        let s = gutter_text(&spans);
10531        assert!(s.ends_with("  "), "expected two trailing spaces, got {s:?}");
10532        assert!(s.contains('1'), "line number missing: {s:?}");
10533    }
10534
10535    #[test]
10536    fn render_gutter_places_glyph_near_buffer_with_a_gap() {
10537        // Closed fold ▸ sits near the buffer column, with a separator
10538        // space before it and a one-cell gap after it so the glyph
10539        // doesn't run flush against code -- the `[ 1 ▸ ]` layout. The
10540        // glyph rides its own themed span so its colour is independent
10541        // of the dim line-number tone.
10542        let spans = render_gutter(0, gutter_width(1), Some(('▸', Color::Yellow)));
10543        let s = gutter_text(&spans);
10544        assert!(s.contains(" 1 ▸ "), "expected ' 1 ▸ ' shape, got {s:?}");
10545        assert!(s.ends_with("▸ "), "glyph must have a trailing gap: {s:?}");
10546        // Exactly one span carries the glyph, in the themed colour.
10547        let glyph_spans: Vec<_> = spans
10548            .iter()
10549            .filter(|sp| sp.content.as_ref() == "▸")
10550            .collect();
10551        assert_eq!(glyph_spans.len(), 1, "glyph is its own span: {spans:?}");
10552        assert_eq!(glyph_spans[0].style.fg, Some(Color::Yellow));
10553    }
10554
10555    #[test]
10556    fn compose_visible_lines_returns_height_lines_padded_with_marker() {
10557        let app = app_with("a\nb", 5);
10558        let lines = compose_visible_lines(&app, &app.ad().snapshot.clone(), 5, 80);
10559        assert_eq!(lines.len(), 5);
10560        // Past EOF lines start with the `~` marker.
10561        let past_eof = format!("{:?}", lines[3]);
10562        assert!(past_eof.contains('~'), "expected ~ marker, got {past_eof}");
10563    }
10564
10565    /// Set an option the way `:set` does, then republish.
10566    ///
10567    /// Poking `editor.option_cache.<field>` directly used to work because the
10568    /// active pane's `FrameView` read that cache. It reads the PUBLISHED
10569    /// per-buffer resolved options now (PI.4, shared with the GPUI peer), so a
10570    /// cache poke sets a field nothing consults. Going through the real
10571    /// option path is what these tests meant all along — they are about what
10572    /// `:set signcolumn=no` renders, not about a struct field.
10573    fn set_opt(app: &mut App, spec: &str) {
10574        let spec = spec.to_string();
10575        app.mutate_editor(move |e| {
10576            e.handle_effect(lattice_grammar::Effect::SetOption { spec });
10577            e.publish_render_state();
10578        });
10579    }
10580
10581    #[test]
10582    fn compose_wraps_long_line_when_wrap_on() {
10583        // 36-char single line; narrow total width forces wrapping.
10584        let long = "abcdefghijklmnopqrstuvwxyz0123456789";
10585        let mut app = app_with(long, 10);
10586        set_opt(&mut app, "wrap=true");
10587        let lines = compose_visible_lines(&app, &app.ad().snapshot.clone(), 10, 20);
10588        let texts: Vec<String> = lines.iter().map(line_text).collect();
10589        // Wrapping produces ↪ continuation gutters …
10590        assert!(
10591            texts.iter().any(|t| t.contains('↪')),
10592            "expected a ↪ continuation row, got {texts:?}"
10593        );
10594        // … and the tail of the long line still renders (not clipped
10595        // away as it was with horizontal-scroll truncation). The
10596        // final segment carries the end of the line.
10597        assert!(
10598            texts.iter().any(|t| t.contains("23456789")),
10599            "wrapped tail must render in a continuation segment, got {texts:?}"
10600        );
10601    }
10602
10603    #[test]
10604    fn compose_does_not_wrap_when_wrap_off() {
10605        let long = "abcdefghijklmnopqrstuvwxyz0123456789";
10606        let app = app_with(long, 10); // wrap defaults off
10607        let lines = compose_visible_lines(&app, &app.ad().snapshot.clone(), 10, 20);
10608        let texts: Vec<String> = lines.iter().map(line_text).collect();
10609        assert!(
10610            !texts.iter().any(|t| t.contains('↪')),
10611            "wrap off ⇒ no continuation rows, got {texts:?}"
10612        );
10613    }
10614
10615    #[test]
10616    fn signcolumn_no_and_nonumber_drops_sign_and_number_columns() {
10617        // PU.1b-1a: gutter geometry is option-derived, never
10618        // kind-derived. With `signcolumn=no` + `nonu` a document line
10619        // abuts the 2-cell no-number margin — no severity/diff sign
10620        // cells, no line-number gutter. This is exactly the geometry
10621        // help-mode gets via its option overrides; the renderer does
10622        // not know (or care) which buffer kind it is painting.
10623        let mut app = app_with("hello\nworld\n", 5);
10624
10625        // Default (`signcolumn=yes` + `number=yes`) reserves the sign
10626        // cells + the line-number gutter, so content sits further in.
10627        let default0 =
10628            line_text(&compose_visible_lines(&app, &app.ad().snapshot.clone(), 5, 40)[0]);
10629        let default_indent = default0.find("hello").expect("hello rendered");
10630        assert!(
10631            default_indent >= 3,
10632            "default reserves 2 sign cells + a line-number gutter; got indent \
10633             {default_indent} in {default0:?}"
10634        );
10635
10636        // `signcolumn=no` + `nonu`: only the fold-marker gutter remains
10637        // (separator + fold slot + trailing gap = 3 cells, so a fold `▸`
10638        // still shows with numbers off), and the body starts at column 3.
10639        set_opt(&mut app, "signcolumn=no");
10640        set_opt(&mut app, "number=false");
10641        let lean0 = line_text(&compose_visible_lines(&app, &app.ad().snapshot.clone(), 5, 40)[0]);
10642        assert_eq!(
10643            lean0.find("hello"),
10644            Some(3),
10645            "signcolumn=no + nonu ⇒ content at the 3-cell fold gutter, got {lean0:?}"
10646        );
10647    }
10648
10649    /// Reported 2026-08-09: typing in the magit commit buffer drew the
10650    /// caret one cell to the LEFT of the insertion point — on the last
10651    /// character typed rather than after it.
10652    ///
10653    /// Not commit-specific: `magit-commit-mode` sets `Number = false`,
10654    /// and that is the whole trigger. `format_gutter_cell` ALWAYS emits
10655    /// three trailing cells (separator + fold-glyph slot + gap), but the
10656    /// `nonumber` gutter width was declared as `2`, so its
10657    /// `saturating_sub(label_cols + 3)` clamped to zero and the cell came
10658    /// out 3 wide. The body painted at column 3 while `buffer_w` and the
10659    /// caret's `sign + gutter_w + body_col` both assumed 2.
10660    ///
10661    /// Asserts the invariant that actually matters — the caret column is
10662    /// the column the NEXT glyph will occupy — with numbers off and on,
10663    /// so a future change to either gutter branch cannot drift them
10664    /// apart again.
10665    #[test]
10666    fn caret_lands_after_the_last_glyph_with_numbers_off() {
10667        for numbers in [false, true] {
10668            let mut app = app_with("abc\n", 5);
10669            set_opt(
10670                &mut app,
10671                if numbers {
10672                    "number=true"
10673                } else {
10674                    "number=false"
10675                },
10676            );
10677            // Insertion point: end of "abc".
10678            app.editor
10679                .set_cursor(lattice_protocol::position::Position::new(0, 3));
10680            app.editor.publish_render_state();
10681            let snap = app.ad().snapshot.clone();
10682            let composed = compose_visible_lines(&app, &snap, 5, 40);
10683            let painted: String = composed[0]
10684                .spans
10685                .iter()
10686                .map(|sp| sp.content.as_ref())
10687                .collect();
10688            let body_col = painted.find('a').expect("body painted");
10689            let area = Rect::new(0, 0, 40, 5);
10690            let pos = cursor_screen_position_at(
10691                &FrameView::from_app(&app),
10692                &snap,
10693                area,
10694                app.ad().cursor,
10695                0,
10696                app.panes().tree.active().id,
10697            )
10698            .expect("caret visible");
10699            assert_eq!(
10700                pos.0 as usize,
10701                body_col + 3,
10702                "number={numbers}: caret must sit AFTER the last glyph \
10703                 (body starts at {body_col} in {painted:?})"
10704            );
10705        }
10706    }
10707
10708    #[test]
10709    fn clip_spans_horizontally_skips_then_truncates() {
10710        // "abcdefghij", skip 3, width 4 ⇒ "defg".
10711        let spans = vec![Span::raw("abcdefghij".to_string())];
10712        let out = clip_spans_horizontally(spans, 3, 4);
10713        let joined: String = out.iter().map(|s| s.content.as_ref()).collect();
10714        assert_eq!(joined, "defg");
10715    }
10716
10717    #[test]
10718    fn clip_spans_horizontally_skip_zero_is_truncate() {
10719        let spans = vec![Span::raw("abcdef".to_string())];
10720        let out = clip_spans_horizontally(spans, 0, 3);
10721        let joined: String = out.iter().map(|s| s.content.as_ref()).collect();
10722        assert_eq!(joined, "abc");
10723    }
10724
10725    #[test]
10726    fn clip_spans_horizontally_skip_spans_whole_first_span() {
10727        // Skip crosses a span boundary: ["ab","cdef"], skip 3 ⇒ drop
10728        // "ab" entirely + 1 byte of "cdef" ⇒ "def" (width 8).
10729        let spans = vec![Span::raw("ab".to_string()), Span::raw("cdef".to_string())];
10730        let out = clip_spans_horizontally(spans, 3, 8);
10731        let joined: String = out.iter().map(|s| s.content.as_ref()).collect();
10732        assert_eq!(joined, "def");
10733    }
10734
10735    #[test]
10736    fn compose_visible_lines_starts_at_scroll_offset() {
10737        let mut app = app_with("0\n1\n2\n3\n4", 2);
10738        app.editor.set_scroll(2);
10739        let lines = compose_visible_lines(&app, &app.ad().snapshot.clone(), 2, 80);
10740        // Line index 2 is "2"; expect that text in the rendered first line.
10741        let l0 = format!("{:?}", lines[0]);
10742        assert!(
10743            l0.contains('2'),
10744            "first visible line should be '2', got {l0}"
10745        );
10746    }
10747
10748    #[test]
10749    fn cursor_position_advances_for_byte_offset() {
10750        let mut app = app_with("hello", 5);
10751        app.editor.set_cursor_byte(3);
10752        let area = Rect::new(0, 0, 80, 5);
10753        let pos =
10754            cursor_screen_position(&FrameView::from_app(&app), &app.ad().snapshot.clone(), area)
10755                .unwrap();
10756        // severity_cell (1) + diff_sign_cell (1) + gutter_width(1)=5 + 3 = 10.
10757        assert_eq!(pos.0, 10);
10758        assert_eq!(pos.1, 0);
10759    }
10760
10761    #[test]
10762    fn display_col_for_byte_shifts_by_inlay_widths_at_or_before_cursor() {
10763        // 2026-05-26 regression guard. Inlay hints render inline
10764        // via `splice_virtual_text_into_spans`, but
10765        // `display_col_for_byte` historically returned the source-
10766        // prefix width only — so the cursor lagged the rendered
10767        // line by the cumulative inlay width once any inlay sat
10768        // at or before `cursor.byte`. Mirrors GPUI's
10769        // `byte_to_combined_col` semantics.
10770        let app = app_with("let x = 42\n", 5);
10771        let inlays = [lattice_host::render_state::InlayHintRow::hint(
10772            0,
10773            5, // after `let x`, before ` =`
10774            ": i32".to_string(),
10775        )];
10776        // Cursor before the inlay anchor: no shift.
10777        let before = display_col_for_byte(
10778            &app.ad().snapshot.buffer,
10779            lattice_protocol::Position::new(0, 4),
10780            &inlays,
10781            4,
10782        );
10783        assert_eq!(before, 4, "shift must not apply before inlay anchor");
10784        // Cursor at the inlay anchor: shift applies (splice is
10785        // BEFORE the source char at the anchor).
10786        let at = display_col_for_byte(
10787            &app.ad().snapshot.buffer,
10788            lattice_protocol::Position::new(0, 5),
10789            &inlays,
10790            4,
10791        );
10792        assert_eq!(at, 5 + 5, "shift applies at the inlay anchor");
10793        // Cursor past the inlay anchor (e.g. `$` to last byte):
10794        // same shift.
10795        let eol = display_col_for_byte(
10796            &app.ad().snapshot.buffer,
10797            lattice_protocol::Position::new(0, 9),
10798            &inlays,
10799            4,
10800        );
10801        assert_eq!(eol, 9 + 5, "shift carries through to EOL");
10802        // Empty inlay slice: behaviour matches the pre-inlay path.
10803        let no_inlay = display_col_for_byte(
10804            &app.ad().snapshot.buffer,
10805            lattice_protocol::Position::new(0, 9),
10806            &[],
10807            4,
10808        );
10809        assert_eq!(no_inlay, 9);
10810    }
10811
10812    #[test]
10813    fn cursor_row_accounts_for_multi_segment_wrap_above_and_within() {
10814        // W.4.t: a line wrapping into 3 visual rows must shift the
10815        // cursor row of lines below by 3 (not 1), and the cursor on
10816        // that wrapped line must land on its own segment. This is
10817        // the `dd`-deletes-wrong-line bug + the N-segment
10818        // generalisation.
10819        //
10820        // Widths: gutter_width(n) = digits + 3 (lead+sep+glyph);
10821        // DIAG + DIFF = 2. With width 40 and ≤9 lines ⇒ gutter 4 ⇒
10822        // body_w = 40 - 4 - 2 = 34. line 1 is 69 chars ⇒ ⌈69/34⌉ = 3
10823        // segments.
10824        let body_w = 34usize;
10825        let long = "x".repeat(2 * body_w + 1); // 69 ⇒ 3 segments
10826        let text = format!("a\n{long}\nb\n");
10827        let mut app = app_with(&text, 20);
10828        app.editor.option_cache.wrap_lines = true;
10829        app.editor.publish_render_state();
10830        let area = Rect::new(0, 0, 40, 20);
10831        let snap = app.ad().snapshot.clone();
10832        let view = FrameView::from_app(&app);
10833
10834        // Cursor on line 2 ("b"), below the 3-row wrap of line 1.
10835        // Display rows: line0 seg0 = 0; line1 segs = 1,2,3; line2 = 4.
10836        let below = cursor_screen_position_at(
10837            &view,
10838            &snap,
10839            area,
10840            lattice_protocol::Position::new(2, 0),
10841            0,
10842            app.panes().tree.active().id,
10843        )
10844        .unwrap();
10845        assert_eq!(below.1, 4, "line below a 3-row wrap sits at display row 4");
10846
10847        // Cursor within the wrapped line, in its 3rd segment (byte
10848        // 68 ⇒ 68/34 = segment 2). Rows: line0=0; line1 seg0=1,
10849        // seg1=2, seg2=3.
10850        let within = cursor_screen_position_at(
10851            &view,
10852            &snap,
10853            area,
10854            lattice_protocol::Position::new(1, 68),
10855            0,
10856            app.panes().tree.active().id,
10857        )
10858        .unwrap();
10859        assert_eq!(
10860            within.1, 3,
10861            "cursor in the 3rd wrap segment sits at display row 3"
10862        );
10863    }
10864
10865    /// Screen column (char index) where the first non-gutter body char
10866    /// appears on `composed[row]`. The gutter/marker prefix is all
10867    /// spaces + at most one glyph; the body starts at the first char
10868    /// that isn't part of that prefix. We find it by counting the
10869    /// leading run of prefix cells the compose loop emits, which equals
10870    /// the display width of the gutter.
10871    fn body_start_col(line: &Line<'static>, body_first_char: char) -> usize {
10872        let s: String = line.spans.iter().map(|sp| sp.content.as_ref()).collect();
10873        s.chars().position(|c| c == body_first_char).unwrap()
10874    }
10875
10876    #[test]
10877    fn wrapped_continuation_gutter_aligns_body_and_cursor() {
10878        // Regression (2026-07-03): the wrap continuation marker `↪`
10879        // (U+21AA, 3 bytes / 1 display column) made `format_gutter_cell`
10880        // under-pad the continuation gutter, because it computed the
10881        // leading pad from `label.len()` (BYTES) instead of display
10882        // width. The wrapped body then painted one column LEFT of
10883        // segment 0's body, while `cursor_screen_position_at` kept using
10884        // the segment-0 `gutter_w` — so the cursor sat one cell RIGHT of
10885        // the glyph on every wrapped continuation segment.
10886        //
10887        // 56-char ASCII line, viewport 40, line numbers on (default):
10888        // the gutter is 7 cells (2 sign + 5 = ` 1   ` under the 3-cell
10889        // trailing pad), so the body is 33 columns; segment 0 body is 33
10890        // chars and segment 1 starts at source char 33 (the digit '7').
10891        let long = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRST";
10892        assert_eq!(long.len(), 56);
10893        let text = format!("{long}\n");
10894        let mut app = app_with(&text, 20);
10895        app.editor.option_cache.wrap_lines = true;
10896        app.editor.publish_render_state();
10897        let area = Rect::new(0, 0, 40, 20);
10898        let snap = app.ad().snapshot.clone();
10899        let view = FrameView::from_app(&app);
10900
10901        let composed = compose_pane_lines(
10902            &view,
10903            &snap,
10904            area.height as u32,
10905            area.width as u32,
10906            &PaneComposeCtx {
10907                buffer_id: app.ad().document_buffer_id,
10908                pane_id: app.panes().tree.active().id,
10909                is_active: true,
10910                scroll: 0,
10911                cursor_line: app.ad().cursor.line,
10912                cursor_line_highlight: app.ad().option_cache.current_line_highlight,
10913                leftcol: 0,
10914                display_line_numbers: app.ad().display_line_numbers.clone(),
10915            },
10916        );
10917
10918        // Segment 0 body ('a') and segment 1 body ('7') must start at the
10919        // SAME screen column — the continuation gutter is the same width
10920        // as the numbered gutter, so wrapped text aligns vertically.
10921        let seg0_col = body_start_col(&composed[0], 'a');
10922        let seg1_col = body_start_col(&composed[1], '7');
10923        assert_eq!(
10924            seg0_col, seg1_col,
10925            "wrapped continuation body must align with segment 0 body \
10926             (seg0 at {seg0_col}, continuation at {seg1_col})"
10927        );
10928
10929        // And the cursor on a continuation-segment byte must land on the
10930        // exact screen column where that glyph is painted. Byte 37 is
10931        // 'B' — the 5th char of segment 1 (source chars 33='7', 34='8',
10932        // 35='9', 36='A', 37='B'), so painted at `seg1_col + 4`.
10933        let pos = cursor_screen_position_at(
10934            &view,
10935            &snap,
10936            area,
10937            lattice_protocol::Position::new(0, 37),
10938            0,
10939            app.panes().tree.active().id,
10940        )
10941        .unwrap();
10942        assert_eq!(pos.1, 1, "byte 37 sits on the first wrap continuation row");
10943        assert_eq!(
10944            pos.0 as usize,
10945            seg1_col + 4,
10946            "cursor column must match the painted glyph column on the \
10947             continuation segment"
10948        );
10949    }
10950
10951    #[test]
10952    fn display_col_for_byte_expands_tabs_to_tabstop() {
10953        // W.4.t: a leading tab advances the cursor to the next
10954        // tab-stop, matching the cells builder's expansion.
10955        let app = app_with("\tab", 5);
10956        let buf = &app.ad().snapshot.buffer;
10957        let col =
10958            |byte: u32| display_col_for_byte(buf, lattice_protocol::Position::new(0, byte), &[], 4);
10959        assert_eq!(col(0), 0, "cursor on the tab sits at its start column");
10960        assert_eq!(col(1), 4, "'a' after a tab lands at column tabstop");
10961        assert_eq!(col(2), 5, "'b' follows at column tabstop+1");
10962    }
10963
10964    #[test]
10965    fn cursor_position_uses_display_width_for_multibyte_chars() {
10966        // `§` is 2 bytes / 1 cell in a terminal. With cursor.byte = 6
10967        // (the `P` of "Performance" on the line below), the rendered
10968        // column must be 5 cells in (`-`, ` `, `§`, `8`, ` `, `P`),
10969        // not 6 -- which is what the byte offset would give us if
10970        // we used it as the column.
10971        let mut app = app_with("- §8 Performance commitments", 5);
10972        app.editor.set_cursor_byte(6);
10973        let area = Rect::new(0, 0, 80, 5);
10974        let pos =
10975            cursor_screen_position(&FrameView::from_app(&app), &app.ad().snapshot.clone(), area)
10976                .unwrap();
10977        // severity_cell (1) + diff_sign_cell (1) + gutter_w (5) + 5 = 12.
10978        assert_eq!(pos.0, 12);
10979    }
10980
10981    #[test]
10982    fn cursor_position_handles_cjk_double_width() {
10983        // CJK chars are 3 bytes / 2 cells. After "abc中" the cursor
10984        // at byte 6 (the space after the CJK char) should land at
10985        // display col 5 (a, b, c, 中=2 cells = total 5 cells).
10986        let mut app = app_with("abc中 def", 5);
10987        app.editor.set_cursor_byte(6); // past the 3-byte CJK char
10988        let area = Rect::new(0, 0, 80, 5);
10989        let pos =
10990            cursor_screen_position(&FrameView::from_app(&app), &app.ad().snapshot.clone(), area)
10991                .unwrap();
10992        // severity_cell (1) + diff_sign_cell (1) + gutter_w (5) + 5 = 12.
10993        assert_eq!(pos.0, 12);
10994    }
10995
10996    #[test]
10997    fn cursor_position_is_none_when_out_of_view() {
10998        let mut app = app_with("a\nb\nc\nd\ne", 2);
10999        app.editor.set_scroll(0);
11000        app.editor.set_cursor_line(4); // not in viewport [0,1]
11001        let area = Rect::new(0, 0, 80, 2);
11002        assert!(
11003            cursor_screen_position(&FrameView::from_app(&app), &app.ad().snapshot.clone(), area)
11004                .is_none()
11005        );
11006    }
11007
11008    #[test]
11009    fn cursor_inside_closed_fold_renders_at_fold_heading_row() {
11010        // Buffer: lines 0..6. Closed fold spans lines 2..=4. The
11011        // cursor sitting on hidden line 3 must render at the
11012        // heading row (= row 2 in the visible-line list, since
11013        // scroll=0). Without the fold-aware projection, the
11014        // cursor would draw at row 3, which doesn't correspond to
11015        // any drawn buffer line.
11016        let mut app = app_with("a\nb\nh\nx\ny\nz\nq", 7);
11017        app.editor
11018            .set_cursor(lattice_protocol::position::Position::new(3, 0)); // hidden by fold
11019        // Push a closed fold over lines 2..=4.
11020        app.editor.folds.push(crate::app::Fold {
11021            start_line: 2,
11022            end_line: 4,
11023            closed: true,
11024            identity: None,
11025        });
11026        // Slice 3c.final.B (group 2): direct fold mutation needs
11027        // a publish — the renderer reads folds via the published
11028        // `rs.active_document.load().folds` snapshot now.
11029        app.editor.publish_render_state();
11030        let area = Rect::new(0, 0, 80, 7);
11031        let pos =
11032            cursor_screen_position(&FrameView::from_app(&app), &app.ad().snapshot.clone(), area)
11033                .expect("cursor visible");
11034        // Visible rows: 0=line0, 1=line1, 2=line2 (heading + summary),
11035        // 3=line5, 4=line6. Cursor at hidden line 3 → screen row 2
11036        // (area.y + 2 since area.y is 0).
11037        assert_eq!(
11038            pos.1,
11039            area.y + 2,
11040            "cursor must render on the fold heading row, got row {}",
11041            pos.1
11042        );
11043    }
11044
11045    /// Row index of the first composed line whose text contains
11046    /// `needle`, or `None`.
11047    fn composed_row_containing(lines: &[Line<'static>], needle: &str) -> Option<usize> {
11048        lines.iter().position(|l| {
11049            let s: String = l.spans.iter().map(|sp| sp.content.as_ref()).collect();
11050            s.contains(needle)
11051        })
11052    }
11053
11054    #[test]
11055    fn closed_fold_summary_does_not_add_a_wrap_row() {
11056        // Regression (2026-08-08): under `:set wrap` in a narrow pane
11057        // (a vertical split), the ` ⋯ N lines` summary appended to a
11058        // closed fold's heading pushed the composed body past the wrap
11059        // width, so the heading painted on TWO display rows. The caret
11060        // walk (`buffer_line_to_visible_row_with`) sizes every source
11061        // line by `wrap_segments(col_count, …)` — the SOURCE width,
11062        // which knows nothing about the summary — so it counted ONE
11063        // row. Every line below a closed fold then drew its caret one
11064        // row ABOVE the cursorline compose painted: cursorline right,
11065        // cursor a line behind.
11066        //
11067        // The summary is decoration, not source text. It must ride the
11068        // final segment (clipped at the pane edge, as the GPUI peer's
11069        // end-of-row overlay does) and never create a display row —
11070        // that is the invariant `split_body_into_segments` documents
11071        // and the host's scroll model shares.
11072        let heading = "## a markdown heading of 30 ch";
11073        assert_eq!(heading.chars().count(), 30);
11074        let text = format!("{heading}\nh1\nh2\nh3\nTARGET\ntail\n");
11075        let mut app = app_with(&text, 10);
11076        app.editor.option_cache.wrap_lines = true;
11077        app.editor
11078            .set_cursor(lattice_protocol::position::Position::new(4, 0));
11079        // Closed fold over lines 0..=3 — summary reads ` ⋯ 4 lines`
11080        // (10 columns).
11081        app.editor.folds.push(crate::app::Fold {
11082            start_line: 0,
11083            end_line: 3,
11084            closed: true,
11085            identity: None,
11086        });
11087        app.editor.publish_render_state();
11088
11089        // width 40 → gutter 5 + sign columns 2 → body width 33.
11090        // Heading alone (30) fits on one segment; heading + summary
11091        // (40) does not.
11092        let area = Rect::new(0, 0, 40, 10);
11093        let snap = app.ad().snapshot.clone();
11094        let composed = compose_visible_lines(&app, &snap, area.height as u32, area.width as u32);
11095        let target_row =
11096            composed_row_containing(&composed, "TARGET").expect("TARGET line must be composed");
11097
11098        let pos = cursor_screen_position_at(
11099            &FrameView::from_app(&app),
11100            &snap,
11101            area,
11102            app.ad().cursor,
11103            app.ad().scroll,
11104            app.panes().tree.active().id,
11105        )
11106        .expect("cursor visible");
11107
11108        assert_eq!(
11109            target_row, 1,
11110            "the folded heading must occupy exactly one display row \
11111             (the ` ⋯ N lines` summary is decoration, not a wrap row)"
11112        );
11113        assert_eq!(
11114            pos.1 as usize, target_row,
11115            "caret row must match the composed row of the cursor line"
11116        );
11117    }
11118
11119    // DR.2 (decoration-retention): the `render_styled_line_*` tests were
11120    // retired with the function. Body styling now comes from the
11121    // `DisplayMatrix` via `cells_render::display_line_to_source_spans`
11122    // (covered in `cells_render` tests); truncation is covered by
11123    // `truncation_does_not_overrun_max_width` below.
11124
11125    /// msg-mode.3: a well-formed messages record produces a
11126    /// styled level token. Order: timestamp (dim), space,
11127    /// LEVEL (themed), space, body.
11128
11129    #[test]
11130    fn truncation_does_not_overrun_max_width() {
11131        // DR.2: `render_styled_line` retired; exercise the surviving
11132        // shared `truncate_spans_to_width` directly.
11133        let spans = truncate_spans_to_width(
11134            vec![Span::raw("this is a long line of text".to_string())],
11135            6,
11136        );
11137        let total: usize = spans.iter().map(|s| s.content.len()).sum();
11138        assert!(total <= 6, "rendered length {total} exceeded max width 6");
11139    }
11140
11141    // ---- Match overlay ----
11142
11143    use lattice_protocol::position::{Position, Range as ProtoRange};
11144
11145    fn pos(l: u32, b: u32) -> Position {
11146        Position::new(l, b)
11147    }
11148
11149    #[test]
11150    fn match_overlay_range_returns_within_line_interval_when_match_is_local() {
11151        // Match: (0,4)-(0,7) on a 11-char line.
11152        let r = ProtoRange::new(pos(0, 4), pos(0, 7));
11153        assert_eq!(match_overlay_range(r, 0, 11), Some((4, 7)));
11154    }
11155
11156    #[test]
11157    fn match_overlay_range_returns_none_when_line_outside_match_band() {
11158        let r = ProtoRange::new(pos(1, 0), pos(1, 3));
11159        assert_eq!(match_overlay_range(r, 0, 10), None);
11160        assert_eq!(match_overlay_range(r, 2, 10), None);
11161    }
11162
11163    #[test]
11164    fn match_overlay_range_extends_to_eol_for_first_line_of_multiline_match() {
11165        // Match starts on line 0 byte 5 and ends on line 1 byte 2.
11166        let r = ProtoRange::new(pos(0, 5), pos(1, 2));
11167        assert_eq!(match_overlay_range(r, 0, 10), Some((5, 10)));
11168        assert_eq!(match_overlay_range(r, 1, 8), Some((0, 2)));
11169    }
11170
11171    #[test]
11172    fn match_overlay_range_returns_none_when_match_starts_past_line_end() {
11173        let r = ProtoRange::new(pos(0, 12), pos(0, 15));
11174        // Line is shorter than the match's start byte -- nothing to overlay.
11175        assert_eq!(match_overlay_range(r, 0, 10), None);
11176    }
11177
11178    #[test]
11179    fn apply_match_overlay_splits_a_single_span() {
11180        let spans = vec![Span::raw("hello world".to_string())];
11181        let style = TuiStyle::default().bg(Color::Yellow);
11182        let out = apply_match_overlay(spans, 6, 11, style);
11183        // Expect three spans: "hello ", "world", and (none after, since 11 == len).
11184        assert_eq!(out.len(), 2);
11185        assert_eq!(out[0].content.as_ref(), "hello ");
11186        assert_eq!(out[1].content.as_ref(), "world");
11187        assert_eq!(out[1].style, style);
11188    }
11189
11190    #[test]
11191    fn apply_match_overlay_clips_when_match_partially_overlaps_styled_span() {
11192        // "fn main" with "fn" already styled as keyword; overlay covers "n m".
11193        let spans = vec![
11194            Span::styled("fn".to_string(), TuiStyle::default().fg(Color::Magenta)),
11195            Span::raw(" main".to_string()),
11196        ];
11197        let style = TuiStyle::default().bg(Color::Yellow);
11198        let out = apply_match_overlay(spans, 1, 4, style);
11199        // Pieces: "f" (kw), "n" (overlay), " m" (overlay), "ain" (raw)
11200        let texts: Vec<&str> = out.iter().map(|s| s.content.as_ref()).collect();
11201        assert_eq!(texts, vec!["f", "n", " m", "ain"]);
11202    }
11203
11204    #[test]
11205    fn apply_match_overlay_passes_through_when_no_overlap() {
11206        let spans = vec![Span::raw("untouched".to_string())];
11207        let style = TuiStyle::default().bg(Color::Yellow);
11208        let out = apply_match_overlay(spans, 100, 110, style);
11209        assert_eq!(out.len(), 1);
11210        assert_eq!(out[0].content.as_ref(), "untouched");
11211    }
11212
11213    /// 4.4.g: splice virtual text inside a single span.
11214    #[test]
11215    fn splice_virtual_text_inside_a_single_span() {
11216        let spans = vec![Span::raw("let x = 1".to_string())];
11217        let style = TuiStyle::default().fg(Color::DarkGray);
11218        let out = splice_virtual_text_into_spans(spans, 5, ": i32".into(), style);
11219        let texts: Vec<&str> = out.iter().map(|s| s.content.as_ref()).collect();
11220        assert_eq!(texts, vec!["let x", ": i32", " = 1"]);
11221        assert_eq!(out[1].style.fg, Some(Color::DarkGray));
11222    }
11223
11224    /// 4.4.g: splice at a span boundary inserts without
11225    /// splitting; preserves the adjacent spans' styles.
11226    #[test]
11227    fn splice_virtual_text_at_a_span_boundary() {
11228        let spans = vec![
11229            Span::styled("fn".to_string(), TuiStyle::default().fg(Color::Magenta)),
11230            Span::raw(" main()".to_string()),
11231        ];
11232        let style = TuiStyle::default().fg(Color::DarkGray);
11233        // Boundary at byte 2 (end of "fn").
11234        let out = splice_virtual_text_into_spans(spans, 2, "[hint]".into(), style);
11235        let texts: Vec<&str> = out.iter().map(|s| s.content.as_ref()).collect();
11236        assert_eq!(texts, vec!["fn", "[hint]", " main()"]);
11237        // Original spans' styles preserved.
11238        assert_eq!(out[0].style.fg, Some(Color::Magenta));
11239        assert_eq!(out[2].style.fg, None);
11240    }
11241
11242    /// 4.4.g: empty virtual text is a no-op.
11243    #[test]
11244    fn splice_virtual_text_empty_is_noop() {
11245        let spans = vec![Span::raw("hi".to_string())];
11246        let style = TuiStyle::default();
11247        let out = splice_virtual_text_into_spans(spans.clone(), 1, String::new(), style);
11248        assert_eq!(out.len(), 1);
11249        assert_eq!(out[0].content.as_ref(), "hi");
11250    }
11251
11252    /// 4.4.g: offset past the end appends at the line end.
11253    #[test]
11254    fn splice_virtual_text_past_end_appends() {
11255        let spans = vec![Span::raw("abc".to_string())];
11256        let style = TuiStyle::default();
11257        let out = splice_virtual_text_into_spans(spans, 999, " // EOL".into(), style);
11258        let texts: Vec<&str> = out.iter().map(|s| s.content.as_ref()).collect();
11259        assert_eq!(texts, vec!["abc", " // EOL"]);
11260    }
11261
11262    // 5.8.N: `inlay_hint_label_text` test migrated to
11263    // `lattice_lsp::inlay_hint_label_tests`. This peer's tests
11264    // exercise the call-site (label flattening + paint splicing)
11265    // via `inlay_hint_overlay_splices_virtual_text` below.
11266
11267    #[test]
11268    fn compose_visible_lines_appends_ghost_text_at_eol_when_enabled() {
11269        // With completion.ghost_text on AND popup open with a
11270        // prefix-matching top candidate, the cursor's line ends
11271        // with a dimmed span carrying the suffix.
11272        let mut app = app_with("foo", 5);
11273        app.editor.set_modal(lattice_grammar::ModalState::Insert);
11274        app.editor.set_cursor(pos(0, 3));
11275        app.editor
11276            .config
11277            .set_typed::<lattice_config::CompletionGhostText>(true)
11278            .expect("set ghost_text");
11279        // Install a popup with `foobar` as the top candidate
11280        // and `foo` as the typed query.
11281        let mut state = lattice_completion::InsertCompletionState::open(
11282            lattice_completion::CompletionTrigger::Manual,
11283            app.editor.cursor,
11284            app.editor.cursor,
11285            "foo".into(),
11286        );
11287        let raw = lattice_completion::RawCandidate::plain(
11288            "foobar",
11289            lattice_completion::CandidateKind::Plain,
11290        );
11291        state.raw.push(raw.clone());
11292        state
11293            .rendered
11294            .push(lattice_completion::RenderedCandidate::from_scored(
11295                lattice_completion::ScoredCandidate {
11296                    raw,
11297                    score: lattice_completion::MatchScore(800),
11298                    match_ranges: Vec::new(),
11299                },
11300            ));
11301        app.editor.insert_completion = Some(state);
11302
11303        let lines = compose_visible_lines(&app, &app.ad().snapshot.clone(), 1, 80);
11304        let composed = line_text(&lines[0]);
11305        // The line should contain BOTH the buffer text `foo`
11306        // AND the ghost suffix `bar`.
11307        assert!(
11308            composed.contains("foo") && composed.contains("bar"),
11309            "expected ghost suffix appended; got `{composed}`",
11310        );
11311        // The LAST span on the line is the ghost — confirm it's
11312        // dim-styled (DarkGray) so it renders subtler than the
11313        // buffer text.
11314        let last = lines[0]
11315            .spans
11316            .last()
11317            .expect("at least one span on the rendered line");
11318        assert_eq!(last.content.as_ref(), "bar");
11319        assert_eq!(last.style.fg, Some(Color::DarkGray));
11320    }
11321
11322    #[test]
11323    fn compose_visible_lines_no_ghost_when_cursor_not_at_eol() {
11324        // Cursor mid-line -> ghost would visually clash with
11325        // existing buffer content; producer suppresses.
11326        let mut app = app_with("foobaz", 5);
11327        app.editor.set_modal(lattice_grammar::ModalState::Insert);
11328        app.editor.set_cursor(pos(0, 3)); // between `foo` and `baz`
11329        app.editor
11330            .config
11331            .set_typed::<lattice_config::CompletionGhostText>(true)
11332            .expect("set ghost_text");
11333        let mut state = lattice_completion::InsertCompletionState::open(
11334            lattice_completion::CompletionTrigger::Manual,
11335            app.editor.cursor,
11336            app.editor.cursor,
11337            "foo".into(),
11338        );
11339        let raw = lattice_completion::RawCandidate::plain(
11340            "foobar",
11341            lattice_completion::CandidateKind::Plain,
11342        );
11343        state.raw.push(raw.clone());
11344        state
11345            .rendered
11346            .push(lattice_completion::RenderedCandidate::from_scored(
11347                lattice_completion::ScoredCandidate {
11348                    raw,
11349                    score: lattice_completion::MatchScore(800),
11350                    match_ranges: Vec::new(),
11351                },
11352            ));
11353        app.editor.insert_completion = Some(state);
11354        let lines = compose_visible_lines(&app, &app.ad().snapshot.clone(), 1, 80);
11355        let composed = line_text(&lines[0]);
11356        // `foobaz` from the buffer is fine; `foobar` (ghost)
11357        // mustn't sneak in.
11358        assert!(composed.contains("foobaz"));
11359        assert!(
11360            !composed.contains("foobar"),
11361            "ghost suppressed mid-line; got `{composed}`",
11362        );
11363    }
11364
11365    #[test]
11366    fn compose_visible_lines_applies_match_overlay() {
11367        let mut app = app_with("hello world", 1);
11368        app.editor.current_match = Some(ProtoRange::new(pos(0, 6), pos(0, 11)));
11369        let lines = compose_visible_lines(&app, &app.ad().snapshot.clone(), 1, 80);
11370        let dump = format!("{:?}", lines[0]);
11371        // Spans should be split so "world" is its own span; we look for the
11372        // match style's signature in the debug dump.
11373        assert!(dump.contains("world"), "rendered: {dump}");
11374    }
11375
11376    // DR.2 (decoration-retention): the `source_spans_from_runs_*` tests
11377    // were retired with the function (the inactive-pane span fallback).
11378
11379    // ---- Visual selection rendering ----
11380
11381    use lattice_grammar::VisualKind;
11382    use lattice_protocol::selection::{Selection, SelectionSet, VisualMode};
11383
11384    #[test]
11385    fn visual_selection_range_is_none_when_not_in_visual() {
11386        let app = app_with("hello", 5);
11387        assert!(visual_selection_range(&app).is_none());
11388    }
11389
11390    #[test]
11391    fn visual_selection_range_charwise_includes_head_byte() {
11392        let mut app = app_with("hello", 5);
11393        app.apply(crate::app::Action::EnterVisual(VisualKind::Charwise));
11394        // Move cursor to byte 2 -- selection extends from 0 to 2 inclusive.
11395        let sel = Selection {
11396            anchor: pos(0, 0),
11397            head: pos(0, 2),
11398            visual: Some(VisualMode::Charwise),
11399        };
11400        app.editor
11401            .set_selections_blocking(SelectionSet::single(sel));
11402        let r = visual_selection_range(&app).expect("range");
11403        assert_eq!(r.start, pos(0, 0));
11404        // Charwise includes head: end byte = head.byte + 1.
11405        assert_eq!(r.end, pos(0, 3));
11406    }
11407
11408    #[test]
11409    fn visual_selection_range_linewise_covers_full_lines() {
11410        let mut app = app_with("aaa\nbbb\nccc", 5);
11411        app.apply(crate::app::Action::EnterVisual(VisualKind::Linewise));
11412        let sel = Selection {
11413            anchor: pos(0, 1),
11414            head: pos(2, 1),
11415            visual: Some(VisualMode::Linewise),
11416        };
11417        app.editor
11418            .set_selections_blocking(SelectionSet::single(sel));
11419        let r = visual_selection_range(&app).expect("range");
11420        assert_eq!(r.start, pos(0, 0));
11421        // Linewise end byte is u32::MAX so per-line clamping picks line_len.
11422        assert_eq!(r.end.line, 2);
11423    }
11424
11425    #[test]
11426    fn visual_selection_range_normalises_reversed_anchor_head() {
11427        let mut app = app_with("hello", 5);
11428        app.apply(crate::app::Action::EnterVisual(VisualKind::Charwise));
11429        // anchor > head (the user moved leftward in Visual).
11430        let sel = Selection {
11431            anchor: pos(0, 4),
11432            head: pos(0, 1),
11433            visual: Some(VisualMode::Charwise),
11434        };
11435        app.editor
11436            .set_selections_blocking(SelectionSet::single(sel));
11437        let r = visual_selection_range(&app).expect("range");
11438        assert_eq!(r.start, pos(0, 1));
11439        assert_eq!(r.end, pos(0, 5));
11440    }
11441
11442    #[test]
11443    fn visual_block_extents_returns_none_when_not_blockwise() {
11444        let app = app_with("hello", 5);
11445        assert!(visual_block_extents(&app).is_none());
11446    }
11447
11448    #[test]
11449    fn visual_block_extents_normalises_anchor_and_head() {
11450        let mut app = app_with("aaa\nbbb\nccc", 10);
11451        app.apply(crate::app::Action::EnterVisual(VisualKind::Blockwise));
11452        let sel = Selection {
11453            anchor: pos(2, 1),
11454            head: pos(0, 2),
11455            visual: Some(VisualMode::Blockwise),
11456        };
11457        app.editor
11458            .set_selections_blocking(SelectionSet::single(sel));
11459        let b = visual_block_extents(&app).unwrap();
11460        assert_eq!(b.start_line, 0);
11461        assert_eq!(b.end_line, 2);
11462        assert_eq!(b.start_col, 1);
11463        assert_eq!(b.end_col, 2);
11464    }
11465
11466    #[test]
11467    fn compose_visible_lines_overlays_visual_selection() {
11468        let mut app = app_with("hello world", 1);
11469        app.apply(crate::app::Action::EnterVisual(VisualKind::Charwise));
11470        let sel = Selection {
11471            anchor: pos(0, 0),
11472            head: pos(0, 4),
11473            visual: Some(VisualMode::Charwise),
11474        };
11475        app.editor
11476            .set_selections_blocking(SelectionSet::single(sel));
11477        let lines = compose_visible_lines(&app, &app.ad().snapshot.clone(), 1, 80);
11478        let dump = format!("{:?}", lines[0]);
11479        // The selected "hello" should appear as its own span(s); we just
11480        // verify the line still contains the original text after overlay.
11481        assert!(dump.contains("hello"));
11482        assert!(dump.contains("world"));
11483    }
11484
11485    // --- DR.3 characterization: active-pane compose byte-identity ---
11486
11487    /// Serialise every span of every line to a stable
11488    /// `text/fg/bg/modifier` fingerprint. Used to pin the active-pane
11489    /// compose output across the DR.3 render-path merge: the merge
11490    /// parameterizes one path over `(buffer, interaction|None,
11491    /// opacity)`, and the active pane must come out pixel-identical.
11492    fn compose_fingerprint(lines: &[Line<'static>]) -> String {
11493        lines
11494            .iter()
11495            .map(|l| {
11496                l.spans
11497                    .iter()
11498                    .map(|s| {
11499                        format!(
11500                            "{:?}/{:?}/{:?}/{:?}",
11501                            s.content.as_ref(),
11502                            s.style.fg,
11503                            s.style.bg,
11504                            s.style.add_modifier
11505                        )
11506                    })
11507                    .collect::<Vec<_>>()
11508                    .join("|")
11509            })
11510            .collect::<Vec<_>>()
11511            .join("\n")
11512    }
11513
11514    #[test]
11515    fn dr3_active_pane_compose_characterization() {
11516        // DR.3: pins the active-pane overlay stack (syntax spans,
11517        // gutter, line-number column, cursor-line bg, visual
11518        // selection, hlsearch, current-match) byte-identical through
11519        // the render-path merge. LSP-decoration sourcing (semantic,
11520        // diagnostics) is provably id-equivalent for the active pane
11521        // — `ctx.buffer_id == app.ad().document_buffer_id` — so it is
11522        // not injected here; the existing LSP overlay tests cover it.
11523        // T.6: visual selection + current-match are now BACKGROUND
11524        // tints resolved from the `selection` / `search.current`
11525        // elements (no fg recolor / bold), matching the GPUI peer — the
11526        // expected fingerprint reflects that converged styling.
11527        // CV.2: the scene is a 4-line file, and the fingerprint used to
11528        // carry a fifth NUMBERED row — the phantom line ropey reports
11529        // for the terminating `\n`. It is now the second `~` filler,
11530        // which is what vim shows and what the buffer actually has.
11531        // IG.3: lines 2 and 3 now open with an indentation guide — the
11532        // `\u{2502}` span in `indent.guide`'s resolved fg, followed by the
11533        // remaining three columns of their indent. This is the pin doing
11534        // its job: guides are on by default, so every indented line in
11535        // every buffer gains one, and that change had to be looked at
11536        // rather than absorbed.
11537        let mut app = app_with("fn main() {\n    let x = 1;\n    foo();\n}\n", 6);
11538        app.toggle_mode_by_name("current-line-highlight-mode");
11539        // Visual selection on line 1 (cols 4..=6), hlsearch + current
11540        // match on line 2 (cols 4..7) — exercises every active-only
11541        // overlay branch in one scene.
11542        app.apply(crate::app::Action::EnterVisual(VisualKind::Charwise));
11543        let sel = Selection {
11544            anchor: pos(1, 4),
11545            head: pos(1, 6),
11546            visual: Some(VisualMode::Charwise),
11547        };
11548        app.editor
11549            .set_selections_blocking(SelectionSet::single(sel));
11550        app.editor.current_match = Some(ProtoRange::new(pos(2, 4), pos(2, 7)));
11551        app.editor.all_matches = vec![ProtoRange::new(pos(2, 4), pos(2, 7))];
11552        app.editor.publish_render_state();
11553        let lines = compose_visible_lines(&app, &app.ad().snapshot.clone(), 6, 40);
11554        let fp = compose_fingerprint(&lines);
11555        let expected = "\" \"/None/None/NONE|\" \"/None/None/NONE|\" 1   \"/Some(DarkGray)/None/NONE|\"fn main() {\"/Some(Rgb(205, 214, 244))/Some(Indexed(236))/NONE|\"                      \"/None/Some(Indexed(236))/NONE\n\" \"/None/None/NONE|\" \"/None/None/NONE|\" 2   \"/Some(DarkGray)/None/NONE|\"\u{2502}\"/Some(Rgb(108, 112, 134))/None/NONE|\"   \"/Some(Rgb(205, 214, 244))/None/NONE|\"let\"/None/Some(Rgb(69, 71, 90))/NONE|\" x = 1;\"/Some(Rgb(205, 214, 244))/None/NONE\n\" \"/None/None/NONE|\" \"/None/None/NONE|\" 3   \"/Some(DarkGray)/None/NONE|\"\u{2502}\"/Some(Rgb(108, 112, 134))/None/NONE|\"   \"/Some(Rgb(205, 214, 244))/None/NONE|\"foo\"/None/Some(Rgb(108, 90, 30))/NONE|\"();\"/Some(Rgb(205, 214, 244))/None/NONE\n\" \"/None/None/NONE|\" \"/None/None/NONE|\" 4   \"/Some(DarkGray)/None/NONE|\"}\"/Some(Rgb(205, 214, 244))/None/NONE\n\" \"/None/None/NONE|\" ~   \"/Some(DarkGray)/None/NONE\n\" \"/None/None/NONE|\" ~   \"/Some(DarkGray)/None/NONE";
11556        assert_eq!(fp, expected, "active-pane compose output changed");
11557    }
11558
11559    /// PP.2: the root reaches the PAINTED prompt row, and an unrooted picker's
11560    /// prompt does not move.
11561    ///
11562    /// Asserted on the frame rather than on `Picker::root_label`, because the
11563    /// host-side test already pins the field and the thing that can still go
11564    /// missing is this render. A prompt that carries the root in its model and
11565    /// never paints it is indistinguishable, to the user, from the feature not
11566    /// existing.
11567    #[test]
11568    fn pp2_a_rooted_picker_paints_its_root_in_the_prompt() {
11569        use ratatui::Terminal;
11570        use ratatui::backend::TestBackend;
11571
11572        let row_text = |app: &App| -> String {
11573            let (tw, th): (u16, u16) = (80, 10);
11574            let mut terminal = Terminal::new(TestBackend::new(tw, th)).unwrap();
11575            let snap = app.ad().snapshot.clone();
11576            terminal
11577                .draw(|f| {
11578                    draw_picker_prompt(
11579                        f,
11580                        Rect {
11581                            x: 0,
11582                            y: 0,
11583                            width: tw,
11584                            height: 1,
11585                        },
11586                        app,
11587                    );
11588                    let _ = &snap;
11589                })
11590                .unwrap();
11591            let buf = terminal.backend().buffer().clone();
11592            (0..tw)
11593                .map(|x| buf[(x, 0)].symbol().to_string())
11594                .collect::<String>()
11595                .trim_end()
11596                .to_string()
11597        };
11598
11599        let mut a = app_with("scratch\n", 20);
11600        let _ = a.mutate_editor_with(|e: &mut lattice_host::editor::Editor| {
11601            e.open_picker("buffers".to_string(), Vec::new())
11602        });
11603        let unrooted = row_text(&a);
11604        assert!(
11605            unrooted.starts_with("buffers> "),
11606            "an unrooted picker's prompt is unchanged: {unrooted:?}"
11607        );
11608
11609        a.mutate_editor(|e: &mut lattice_host::editor::Editor| {
11610            if let Some(p) = e.picker.as_mut() {
11611                p.root_label = Some("~/src/lattice".to_string());
11612            }
11613        });
11614        let rooted = row_text(&a);
11615        // PP.2b: padded on BOTH sides. The root used to abut the `>`
11616        // (`buffers ~/src/lattice> `), which read as one run of
11617        // punctuation with the prompt lost inside it — the gap is what
11618        // makes the `>` findable at a glance.
11619        assert!(
11620            rooted.starts_with("buffers  ~/src/lattice  > "),
11621            "the root sits between the source and the `>`, with a gap on \
11622             each side: {rooted:?}"
11623        );
11624    }
11625
11626    /// PP.2b: the root is painted in its own colour, distinct from the
11627    /// `(n/m)` count beside it.
11628    ///
11629    /// The point of the slot is that the root stopped being the dimmest
11630    /// thing on the line — it shared `DarkGray` with the count, so the
11631    /// one piece of context the prompt carries read as chrome. Asserted
11632    /// as "different from the count's colour" rather than against a
11633    /// concrete colour, so a theme is free to choose one without
11634    /// breaking the test that exists to keep them distinguishable.
11635    #[test]
11636    fn pp2b_the_root_is_painted_distinctly_from_the_count() {
11637        use ratatui::Terminal;
11638        use ratatui::backend::TestBackend;
11639
11640        let mut a = app_with("scratch\n", 20);
11641        let _ = a.mutate_editor_with(|e: &mut lattice_host::editor::Editor| {
11642            e.open_picker("buffers".to_string(), Vec::new())
11643        });
11644        a.mutate_editor(|e: &mut lattice_host::editor::Editor| {
11645            if let Some(p) = e.picker.as_mut() {
11646                p.root_label = Some("~/src/lattice".to_string());
11647            }
11648        });
11649
11650        let (tw, th): (u16, u16) = (80, 10);
11651        let mut terminal = Terminal::new(TestBackend::new(tw, th)).unwrap();
11652        terminal
11653            .draw(|f| {
11654                draw_picker_prompt(
11655                    f,
11656                    Rect {
11657                        x: 0,
11658                        y: 0,
11659                        width: tw,
11660                        height: 1,
11661                    },
11662                    &a,
11663                );
11664            })
11665            .unwrap();
11666        let buf = terminal.backend().buffer().clone();
11667        let row: String = (0..tw).map(|x| buf[(x, 0)].symbol().to_string()).collect();
11668
11669        let fg_at = |col: u16| -> Color { buf[(col, 0)].style().fg.expect("a foreground") };
11670        let root_fg = fg_at(row.find("~/src/lattice").expect("the root is painted") as u16);
11671        // The count is `(n/m)`, and `n` depends on how many buffers the
11672        // picker seated — so find it by its opening paren rather than by
11673        // a literal that a different fixture would change.
11674        let count_fg = fg_at(row.rfind('(').expect("the count is painted") as u16);
11675        assert_ne!(
11676            root_fg, count_fg,
11677            "the root must not share the count's colour — that is what made \
11678             it read as chrome"
11679        );
11680        assert_ne!(
11681            root_fg,
11682            Color::DarkGray,
11683            "…and specifically must not still be the dim grey PP.2 shipped"
11684        );
11685    }
11686
11687    /// PP.2c: every span on the prompt takes its colour from the theme,
11688    /// so `:colorscheme` reaches the picker at all.
11689    ///
11690    /// Driven by actually retuning the four elements and re-rendering,
11691    /// rather than by comparing against the defaults. A line that
11692    /// resolves through the theme and a line that hardcodes four colours
11693    /// are indistinguishable until the theme changes — which is exactly
11694    /// the bug: the prompt looked fine and no colorscheme could touch it.
11695    #[test]
11696    fn pp2c_the_whole_prompt_line_follows_the_theme() {
11697        use lattice_host::ui::theme::{ElementName, StyleSpec, ThemeRegistryHandle};
11698        use ratatui::Terminal;
11699        use ratatui::backend::TestBackend;
11700
11701        let mut a = app_with("scratch\n", 20);
11702        let _ = a.mutate_editor_with(|e: &mut lattice_host::editor::Editor| {
11703            e.open_picker("buffers".to_string(), Vec::new())
11704        });
11705        a.mutate_editor(|e: &mut lattice_host::editor::Editor| {
11706            if let Some(p) = e.picker.as_mut() {
11707                p.root_label = Some("~/src/lattice".to_string());
11708            }
11709        });
11710
11711        // Four unmistakable, mutually distinct colours — a span that is
11712        // still hardcoded cannot land on one of these.
11713        a.mutate_editor(|e: &mut lattice_host::editor::Editor| {
11714            let reg = e
11715                .services
11716                .get::<ThemeRegistryHandle>()
11717                .expect("the theme registry is a boot service");
11718            for (name, colour) in [
11719                ("picker.title", "red"),
11720                ("picker.prompt", "green"),
11721                ("picker.count", "blue"),
11722                ("picker.root", "yellow"),
11723            ] {
11724                reg.set_override(ElementName::from_static(name), StyleSpec::new().fg(colour));
11725            }
11726        });
11727        // The prompt reads its colours from the published table, so the
11728        // override has to reach a publish before it reaches a frame —
11729        // the same hop `RendererSignal::ThemeChanged` drives in
11730        // production.
11731        a.mutate_editor(|e: &mut lattice_host::editor::Editor| {
11732            e.publish_render_state();
11733        });
11734        lattice_host::cells_worker::recompute(&a.editor.render_state);
11735
11736        let (tw, th): (u16, u16) = (80, 10);
11737        let mut terminal = Terminal::new(TestBackend::new(tw, th)).unwrap();
11738        terminal
11739            .draw(|f| {
11740                draw_picker_prompt(
11741                    f,
11742                    Rect {
11743                        x: 0,
11744                        y: 0,
11745                        width: tw,
11746                        height: 1,
11747                    },
11748                    &a,
11749                );
11750            })
11751            .unwrap();
11752        let buf = terminal.backend().buffer().clone();
11753        let row: String = (0..tw).map(|x| buf[(x, 0)].symbol().to_string()).collect();
11754        let fg_at = |col: u16| -> Color { buf[(col, 0)].style().fg.expect("a foreground") };
11755
11756        let title_fg = fg_at(0);
11757        let root_fg = fg_at(row.find("~/src/lattice").expect("root") as u16);
11758        let prompt_fg = fg_at(row.find('>').expect("prompt marker") as u16);
11759        let count_fg = fg_at(row.rfind('(').expect("count") as u16);
11760
11761        // Four overrides, four distinct palette roles → four distinct
11762        // painted colours. A span still holding `Color::Cyan` would
11763        // collide with another or keep the old literal, and both show up
11764        // here.
11765        let all = [title_fg, root_fg, prompt_fg, count_fg];
11766        let distinct: std::collections::HashSet<String> =
11767            all.iter().map(|c| format!("{c:?}")).collect();
11768        assert_eq!(
11769            distinct.len(),
11770            4,
11771            "every span must follow its own element; got {all:?}"
11772        );
11773        for (what, c) in [
11774            ("title", title_fg),
11775            ("prompt", prompt_fg),
11776            ("count", count_fg),
11777            ("root", root_fg),
11778        ] {
11779            assert!(
11780                !matches!(c, Color::Cyan | Color::DarkGray),
11781                "{what} is still painting the hardcoded literal: {c:?}"
11782            );
11783        }
11784    }
11785
11786    #[test]
11787    fn dr3_inactive_pane_drops_interaction_and_dims_decorations() {
11788        // DR.3: the SAME scene as the active characterization, but
11789        // composed through `compose_pane_lines` with `is_active:
11790        // false`. Interaction overlays (visual selection, current
11791        // match, cursor-line bg) must be GONE; buffer-intrinsic
11792        // decorations (syntax here) must REMAIN, dimmed by the theme's
11793        // `inactive_pane_overlay` (default DIM). This is the
11794        // decoration-vs-interaction split the merge encodes.
11795        let mut app = app_with("fn main() {\n    let x = 1;\n    foo();\n}\n", 6);
11796        app.toggle_mode_by_name("current-line-highlight-mode");
11797        app.apply(crate::app::Action::EnterVisual(VisualKind::Charwise));
11798        let sel = Selection {
11799            anchor: pos(1, 4),
11800            head: pos(1, 6),
11801            visual: Some(VisualMode::Charwise),
11802        };
11803        app.editor
11804            .set_selections_blocking(SelectionSet::single(sel));
11805        app.editor.current_match = Some(ProtoRange::new(pos(2, 4), pos(2, 7)));
11806        app.editor.all_matches = vec![ProtoRange::new(pos(2, 4), pos(2, 7))];
11807        app.editor.publish_render_state();
11808        // Build the styled cells NOW. The worker does this off-thread, so
11809        // without it the compose below races the build and, losing, sees
11810        // text with no syntax colour — which this test then reports as the
11811        // inactive pane having dropped its decorations.
11812        lattice_host::cells_worker::recompute(&app.editor.render_state);
11813
11814        let view = FrameView::for_buffer(&app, app.ad().document_buffer_id);
11815        let ctx = PaneComposeCtx {
11816            is_active: false,
11817            pane_id: app.panes().tree.active().id,
11818            buffer_id: app.ad().document_buffer_id,
11819            cursor_line: app.ad().cursor.line,
11820            cursor_line_highlight: false,
11821            scroll: app.ad().scroll,
11822            leftcol: app.ad().leftcol,
11823            display_line_numbers: app.ad().display_line_numbers.clone(),
11824        };
11825        let lines = compose_pane_lines(&view, &app.ad().snapshot.clone(), 6, 40, &ctx);
11826        let spans: Vec<&Span<'static>> = lines.iter().flat_map(|l| l.spans.iter()).collect();
11827
11828        // Interaction state is gone on the inactive pane.
11829        assert!(
11830            spans.iter().all(|s| s.style.bg != Some(Color::Blue)),
11831            "visual selection leaked onto inactive pane",
11832        );
11833        assert!(
11834            spans.iter().all(|s| s.style.bg != Some(Color::Yellow)),
11835            "current-match leaked onto inactive pane",
11836        );
11837        assert!(
11838            spans
11839                .iter()
11840                .all(|s| s.style.bg != Some(Color::Indexed(236))),
11841            "cursor-line leaked onto inactive pane",
11842        );
11843        // Decorations retained + dimmed: the syntax-coloured body span
11844        // keeps its fg and gains the DIM overlay.
11845        assert!(
11846            spans
11847                .iter()
11848                .any(|s| s.style.fg == Some(Color::Rgb(205, 214, 244))
11849                    && s.style.add_modifier.contains(Modifier::DIM)),
11850            "inactive pane lost its dimmed syntax decoration: {lines:?}",
11851        );
11852    }
11853
11854    // --- Heading-preserved fold render -------------------------
11855
11856    #[test]
11857    fn closed_fold_preserves_heading_and_appends_summary() {
11858        let mut app = app_with("# Heading\nbody one\nbody two\nafter\n", 5);
11859        app.set_foldmethod_for_test(crate::app::FoldMethod::Markdown);
11860        app.recompute_folds();
11861        // Close the heading fold.
11862        let idx = app
11863            .editor
11864            .folds
11865            .iter()
11866            .position(|f| f.start_line == 0)
11867            .expect("heading fold");
11868        app.editor.folds[idx].closed = true;
11869        app.editor.publish_render_state();
11870        let lines = compose_visible_lines(&app, &app.ad().snapshot.clone(), 5, 80);
11871        let row0 = line_text(&lines[0]);
11872        // Heading text is preserved.
11873        assert!(row0.contains("# Heading"), "row0 = {row0:?}");
11874        // Summary suffix appended.
11875        assert!(row0.contains("⋯"), "row0 = {row0:?}");
11876    }
11877
11878    #[test]
11879    fn closed_fold_hides_interior_lines() {
11880        let mut app = app_with("# H\nhidden1\nhidden2\nshown\n", 5);
11881        app.set_foldmethod_for_test(crate::app::FoldMethod::Markdown);
11882        app.recompute_folds();
11883        let idx = app
11884            .editor
11885            .folds
11886            .iter()
11887            .position(|f| f.start_line == 0)
11888            .expect("heading fold");
11889        app.editor.folds[idx].closed = true;
11890        app.editor.publish_render_state();
11891        let lines = compose_visible_lines(&app, &app.ad().snapshot.clone(), 5, 80);
11892        let blob: String = lines.iter().map(line_text).collect::<Vec<_>>().join("\n");
11893        assert!(!blob.contains("hidden1"), "interior leaked: {blob}");
11894        assert!(!blob.contains("hidden2"), "interior leaked: {blob}");
11895    }
11896
11897    /// Fold-bleed fix (2026-06-30): an INACTIVE pane must elide its
11898    /// closed folds and show the summary exactly like the active pane —
11899    /// the user's repro was a folded buffer un-folding the instant focus
11900    /// moved to another split. Previously `compose_pane_lines` gated all
11901    /// fold handling on `ctx.is_active`, so inactive panes walked lines
11902    /// 1:1. With per-buffer fold state (`folds_for_buffer`) the gate is
11903    /// gone; this composes the buffer with `is_active: false` and asserts
11904    /// the interior is still hidden and the summary still appears.
11905    #[test]
11906    fn inactive_pane_elides_closed_folds_like_active() {
11907        let mut app = app_with("# H\nhidden1\nhidden2\nshown\n", 5);
11908        app.set_foldmethod_for_test(crate::app::FoldMethod::Markdown);
11909        app.recompute_folds();
11910        let idx = app
11911            .editor
11912            .folds
11913            .iter()
11914            .position(|f| f.start_line == 0)
11915            .expect("heading fold");
11916        app.editor.folds[idx].closed = true;
11917        app.editor.publish_render_state();
11918
11919        let view = FrameView::for_buffer(&app, app.ad().document_buffer_id);
11920        let ctx = PaneComposeCtx {
11921            is_active: false,
11922            pane_id: app.panes().tree.active().id,
11923            buffer_id: app.ad().document_buffer_id,
11924            cursor_line: app.ad().cursor.line,
11925            cursor_line_highlight: false,
11926            scroll: app.ad().scroll,
11927            leftcol: app.ad().leftcol,
11928            display_line_numbers: app.ad().display_line_numbers.clone(),
11929        };
11930        let lines = compose_pane_lines(&view, &app.ad().snapshot.clone(), 5, 80, &ctx);
11931        let blob: String = lines.iter().map(line_text).collect::<Vec<_>>().join("\n");
11932        assert!(
11933            !blob.contains("hidden1"),
11934            "inactive interior leaked: {blob}"
11935        );
11936        assert!(
11937            !blob.contains("hidden2"),
11938            "inactive interior leaked: {blob}"
11939        );
11940        assert!(blob.contains("⋯"), "inactive summary missing: {blob}");
11941    }
11942
11943    #[test]
11944    fn closed_fold_summary_includes_chained_closed_folds() {
11945        // Reproduces the user's "fold both branches of an if/else
11946        // under foldmethod=indent" case: two closed folds touch at
11947        // line 3 -- the outer (1, 3) hides 2..=3, the sibling
11948        // (3, 5) hides 4..=5 (its heading at 3 is itself hidden by
11949        // the first fold). Visually the user collapses 5 buffer
11950        // lines onto one row; the summary should report 5, not 3.
11951        let mut app = app_with("a\nb\nc\nd\ne\nf\ng\n", 7);
11952        app.editor.folds.push(crate::app::Fold {
11953            start_line: 1,
11954            end_line: 3,
11955            closed: true,
11956            identity: None,
11957        });
11958        app.editor.folds.push(crate::app::Fold {
11959            start_line: 3,
11960            end_line: 5,
11961            closed: true,
11962            identity: None,
11963        });
11964        // Slice 3c.final.B (group 2): publish after direct fold
11965        // mutations so `rs.active_document.load().folds` reflects them.
11966        app.editor.publish_render_state();
11967        let lines = compose_visible_lines(&app, &app.ad().snapshot.clone(), 7, 80);
11968        // Find the row that summarises the chained folds (line 1's
11969        // heading row).
11970        let row1_text = line_text(&lines[1]);
11971        assert!(
11972            row1_text.contains("⋯ 5 lines"),
11973            "expected '⋯ 5 lines' for chained folds, got: {row1_text:?}"
11974        );
11975    }
11976
11977    #[test]
11978    fn adjacent_folds_count_only_their_own_lines() {
11979        // Two back-to-back but NON-overlapping closed folds (foldmethod=
11980        // indent on two sibling blocks): (0, 2) then (3, 5). The second
11981        // fold's heading at line 3 is the first VISIBLE row after fold
11982        // one, so each fold summarises only its own 3 lines. Regression:
11983        // the display-span walk used to chain a fold starting at `end+1`,
11984        // making the first fold report all 6 lines.
11985        let mut app = app_with("a\nb\nc\nd\ne\nf\n", 7);
11986        app.editor.folds.push(crate::app::Fold {
11987            start_line: 0,
11988            end_line: 2,
11989            closed: true,
11990            identity: None,
11991        });
11992        app.editor.folds.push(crate::app::Fold {
11993            start_line: 3,
11994            end_line: 5,
11995            closed: true,
11996            identity: None,
11997        });
11998        app.editor.publish_render_state();
11999        let lines = compose_visible_lines(&app, &app.ad().snapshot.clone(), 7, 80);
12000        // Row 0 = fold one's heading; row 1 = fold two's heading (visible).
12001        let row0_text = line_text(&lines[0]);
12002        let row1_text = line_text(&lines[1]);
12003        assert!(
12004            row0_text.contains("⋯ 3 lines"),
12005            "first fold should count only its own 3 lines, got: {row0_text:?}"
12006        );
12007        assert!(
12008            row1_text.contains("⋯ 3 lines"),
12009            "second fold should count only its own 3 lines, got: {row1_text:?}"
12010        );
12011    }
12012
12013    #[test]
12014    fn open_fold_renders_lines_normally_without_summary() {
12015        let mut app = app_with("# H\nbody\n", 5);
12016        app.set_foldmethod_for_test(crate::app::FoldMethod::Markdown);
12017        app.recompute_folds();
12018        // Leave the fold open (default).
12019        let lines = compose_visible_lines(&app, &app.ad().snapshot.clone(), 5, 80);
12020        let row0 = line_text(&lines[0]);
12021        assert!(row0.contains("# H"), "row0 = {row0:?}");
12022        assert!(
12023            !row0.contains("⋯"),
12024            "summary should only appear on closed folds: {row0:?}"
12025        );
12026    }
12027
12028    // --- Fold gutter glyphs ------------------------------------
12029
12030    #[test]
12031    fn open_fold_gutter_shows_down_glyph() {
12032        let mut app = app_with("# H\nbody\n", 5);
12033        app.set_foldmethod_for_test(crate::app::FoldMethod::Markdown);
12034        app.recompute_folds();
12035        // Slice 3c.final.B (group 2): recompute_folds mutates
12036        // editor.folds outside dispatch; publish so the renderer's
12037        // `rs.active_document.load().folds` reflects the new set.
12038        app.editor.publish_render_state();
12039        let lines = compose_visible_lines(&app, &app.ad().snapshot.clone(), 5, 80);
12040        let row0 = line_text(&lines[0]);
12041        assert!(
12042            row0.contains('▾'),
12043            "expected ▾ glyph on open fold: {row0:?}"
12044        );
12045        assert!(!row0.contains('▸'), "did not expect ▸ glyph: {row0:?}");
12046    }
12047
12048    #[test]
12049    fn closed_fold_gutter_shows_right_glyph() {
12050        let mut app = app_with("# H\nbody\n", 5);
12051        app.set_foldmethod_for_test(crate::app::FoldMethod::Markdown);
12052        app.recompute_folds();
12053        let idx = app
12054            .editor
12055            .folds
12056            .iter()
12057            .position(|f| f.start_line == 0)
12058            .expect("heading fold");
12059        app.editor.folds[idx].closed = true;
12060        app.editor.publish_render_state();
12061        // Slice 3c.final.B (group 2): publish after direct
12062        // `editor.folds[idx].closed` mutation.
12063        app.editor.publish_render_state();
12064        let lines = compose_visible_lines(&app, &app.ad().snapshot.clone(), 5, 80);
12065        let row0 = line_text(&lines[0]);
12066        assert!(
12067            row0.contains('▸'),
12068            "expected ▸ glyph on closed fold: {row0:?}"
12069        );
12070        assert!(!row0.contains('▾'), "did not expect ▾ glyph: {row0:?}");
12071    }
12072
12073    #[test]
12074    fn gutterless_buffer_still_shows_fold_marker_before_text() {
12075        // Folding is useful on gutterless buffers too (help / the
12076        // dashboard have many markdown sections), so a foldable head must
12077        // STILL paint its `▾`/`▸` — positioned immediately before the row
12078        // text, not stranded at the pane's left edge. The centring pad is
12079        // folded into `gutter_w`, so `format_gutter_cell` right-aligns the
12080        // glyph against the content.
12081        let mut app = app_with("# H\nbody\n", 5);
12082        app.set_foldmethod_for_test(crate::app::FoldMethod::Markdown);
12083        app.recompute_folds();
12084        app.editor.option_cache.sign_column = false;
12085        app.editor.option_cache.show_line_numbers = false;
12086        app.editor.publish_render_state();
12087        let lines = compose_visible_lines(&app, &app.ad().snapshot.clone(), 5, 80);
12088        let row0 = line_text(&lines[0]);
12089        assert!(
12090            row0.contains('▾'),
12091            "gutterless head still shows ▾: {row0:?}"
12092        );
12093        // The glyph sits just before the heading text (a single trailing
12094        // gap), not at column 0 with the text far to the right.
12095        let chars: Vec<char> = row0.chars().collect();
12096        let glyph_ci = chars.iter().position(|&c| c == '▾').expect("glyph");
12097        let text_ci = chars.iter().position(|&c| c == '#').expect("heading");
12098        assert!(
12099            text_ci > glyph_ci && text_ci - glyph_ci <= 2,
12100            "▾ should sit just before the text (glyph@{glyph_ci}, text@{text_ci}): {row0:?}"
12101        );
12102    }
12103
12104    #[test]
12105    fn line_after_closed_fold_keeps_correct_syntax_highlighting() {
12106        // Reproduces a user-reported regression: with a closed fold
12107        // hiding interior lines, the next visible line was being
12108        // styled with stale spans from `visible_highlights[viewport_row]`
12109        // because the row index assumed `visible[i] == scroll + i`.
12110        // The fix indexes into `visible_highlights` by buffer-line
12111        // delta instead of viewport row.
12112        //
12113        // The struct fold now also swallows the trailing `}` (closer
12114        // inclusion), so the "next visible line" is the trailing
12115        // statement, not the brace.
12116        let src = "pub struct Buffer {\n    rope: Rope,\n}\nlet trailing = 1;\n";
12117        let mut app = app_with(src, 10);
12118        app.set_foldmethod_for_test(crate::app::FoldMethod::Indent);
12119        app.recompute_folds();
12120        let idx = app
12121            .editor
12122            .folds
12123            .iter()
12124            .position(|f| f.start_line == 0)
12125            .expect("struct fold");
12126        app.editor.folds[idx].closed = true;
12127        app.editor.publish_render_state();
12128        let lines = compose_visible_lines(&app, &app.ad().snapshot.clone(), 4, 80);
12129        // Row 0: heading + " ⋯ N lines".
12130        // Row 1: the post-fold statement -- correct content, not
12131        //        leaking interior spans.
12132        let row1 = line_text(&lines[1]);
12133        assert!(
12134            row1.contains("let trailing"),
12135            "row1 should be the post-fold statement: {row1:?}"
12136        );
12137        assert!(!row1.contains("rope"), "interior leaked: {row1:?}");
12138        assert!(
12139            !row1.contains('}'),
12140            "closer should be inside the fold: {row1:?}"
12141        );
12142    }
12143
12144    #[test]
12145    fn closed_indent_fold_swallows_trailing_close_brace() {
12146        // Vim's `foldmethod=indent` strictly excludes lines whose
12147        // indent isn't > start. We extend that with closer-line
12148        // inclusion: a `}` / `]` / `)` line at the same indent as
12149        // the fold start gets pulled in, so the user doesn't see an
12150        // orphan brace below `... ⋯ N lines`.
12151        let src = "pub struct Buffer {\n    rope: Rope,\n}\n";
12152        let mut app = app_with(src, 5);
12153        app.set_foldmethod_for_test(crate::app::FoldMethod::Indent);
12154        app.recompute_folds();
12155        let f = app
12156            .editor
12157            .folds
12158            .iter()
12159            .find(|f| f.start_line == 0)
12160            .expect("fold");
12161        assert_eq!(f.end_line, 2, "expected `}}` swallowed: {f:?}");
12162    }
12163
12164    /// **Closing a fold must not change the heading row's syntax colours.**
12165    ///
12166    /// Reported against a real org file: with `#+STARTUP: overview` most
12167    /// sections open collapsed, and every `▶` heading rendered its TITLE
12168    /// unstyled while its keyword and tags still coloured. The `▼` headings
12169    /// right beside them — same level, same query — were coloured. So it is
12170    /// the fold state, not the language or the level.
12171    ///
12172    /// The suspicion this pins: a capture whose node SPANS the folded subtree
12173    /// (org's `(section)` is "a headline plus everything beneath it") produces
12174    /// a span reaching past the one visible row a closed fold renders, and the
12175    /// composer drops or mis-clips it — keeping the short spans that sit
12176    /// entirely within the line (keyword, tags) and losing the long one.
12177    ///
12178    /// Asserted as an INVARIANT rather than against fixed colours: whatever
12179    /// the heading looked like open, it must look the same closed, minus the
12180    /// summary suffix and the fold marker. That holds for any grammar and
12181    /// cannot rot into asserting a theme.
12182    ///
12183    /// **This fixture does NOT reproduce the org report, and that is the
12184    /// finding.** It passes: the composer looks its spans up by buffer line
12185    /// and a closed fold's heading keeps them. Which means the org failure is
12186    /// not here — `visible_highlights` is already per-LINE (the syntax layer
12187    /// decomposes a multi-line capture before the renderer sees it), so there
12188    /// is no long span for a fold to clip. The remaining suspect is the
12189    /// highlight RANGE: with everything collapsed, ~60 visible rows span ~2500
12190    /// buffer lines, and whatever band actually gets computed is what colours.
12191    /// Kept as a guard for the composer, which is now known-good.
12192    #[test]
12193    fn closing_a_fold_preserves_the_heading_rows_syntax_spans() {
12194        let src = "pub struct Buffer {\n    rope: Rope,\n}\nlet trailing = 1;\n";
12195        let mut app = app_with(src, 10);
12196        app.set_foldmethod_for_test(crate::app::FoldMethod::Indent);
12197        app.recompute_folds();
12198        let idx = app
12199            .editor
12200            .folds
12201            .iter()
12202            .position(|f| f.start_line == 0)
12203            .expect("struct fold");
12204
12205        // Open: the heading row as the user sees it uncollapsed.
12206        app.editor.folds[idx].closed = false;
12207        app.editor.publish_render_state();
12208        let open = compose_visible_lines(&app, &app.ad().snapshot.clone(), 4, 80);
12209        let open_styles: Vec<_> = open[0]
12210            .spans
12211            .iter()
12212            .map(|s| (s.content.to_string(), s.style.fg))
12213            .collect();
12214
12215        // Closed: same row, plus a summary suffix.
12216        app.editor.folds[idx].closed = true;
12217        app.editor.publish_render_state();
12218        let closed = compose_visible_lines(&app, &app.ad().snapshot.clone(), 4, 80);
12219        let closed_styles: Vec<_> = closed[0]
12220            .spans
12221            .iter()
12222            .map(|s| (s.content.to_string(), s.style.fg))
12223            // The `⋯ N lines` suffix is decoration the open row has no peer
12224            // for; everything before it must match.
12225            .filter(|(text, _)| !text.contains('\u{22ef}'))
12226            .collect();
12227        // The fold MARKER legitimately differs (`▾` open, `▸` closed) — that
12228        // is the feature, not the regression.
12229        fn drop_marker<C>(v: Vec<(String, Option<C>)>) -> Vec<(String, Option<C>)> {
12230            v.into_iter()
12231                .filter(|(t, _)| !t.contains('\u{25be}') && !t.contains('\u{25b8}'))
12232                .collect()
12233        }
12234        let open_styles = drop_marker(open_styles);
12235        let closed_styles = drop_marker(closed_styles);
12236
12237        // Compare the STYLED runs only — trailing padding differs by the
12238        // suffix's width and carries no colour either way.
12239        fn styled<C: Clone>(v: &[(String, Option<C>)]) -> Vec<(String, Option<C>)> {
12240            v.iter()
12241                .filter(|(t, c)| c.is_some() && !t.trim().is_empty())
12242                .cloned()
12243                .collect()
12244        }
12245        assert_eq!(
12246            styled(&closed_styles),
12247            styled(&open_styles),
12248            "a closed fold's heading lost syntax spans the open one had"
12249        );
12250    }
12251
12252    #[test]
12253    fn linewise_visual_highlights_closed_fold_heading() {
12254        // Regression: previously the closed-fold heading branch in
12255        // compose_visible_lines emitted the summary suffix and
12256        // `continue`'d before the visual overlay ran -- so V on a
12257        // closed-fold heading appeared unhighlighted. The summary
12258        // suffix is now appended AFTER overlay processing.
12259        let src = "pub struct Buffer {\n    rope: Rope,\n}\n";
12260        let mut app = app_with(src, 5);
12261        app.set_foldmethod_for_test(crate::app::FoldMethod::Indent);
12262        app.recompute_folds();
12263        let idx = app
12264            .editor
12265            .folds
12266            .iter()
12267            .position(|f| f.start_line == 0)
12268            .expect("fold");
12269        app.editor.folds[idx].closed = true;
12270        app.editor.publish_render_state();
12271        app.editor.cursor = lattice_protocol::position::Position::new(0, 0);
12272        app.apply(crate::app::Action::EnterVisual(VisualKind::Linewise));
12273        let sel = Selection {
12274            anchor: pos(0, 0),
12275            head: pos(0, 0),
12276            visual: Some(VisualMode::Linewise),
12277        };
12278        app.editor
12279            .set_selections_blocking(SelectionSet::single(sel));
12280        let lines = compose_visible_lines(&app, &app.ad().snapshot.clone(), 5, 80);
12281        // T.6: visual_style now resolves through the app's published
12282        // resolved table — derive the expected bg from the same source.
12283        let cells_rs = app.render_state.load().cells.load_full();
12284        let visual_bg = visual_style(&cells_rs.resolved_theme, &cells_rs.theme_ids).bg;
12285        let row0 = &lines[0];
12286        let has_visual_span = row0.spans.iter().any(|s| s.style.bg == visual_bg);
12287        assert!(
12288            has_visual_span,
12289            "linewise visual on a closed-fold heading must still overlay: {row0:?}"
12290        );
12291        // Summary suffix is still present.
12292        let row0_text = line_text(row0);
12293        assert!(
12294            row0_text.contains("⋯"),
12295            "summary suffix lost: {row0_text:?}"
12296        );
12297    }
12298
12299    #[test]
12300    fn linewise_visual_overlays_full_line_after_fold_change() {
12301        // After the v-line key, a line outside any fold should still
12302        // overlay correctly. This is a guard against the fold work
12303        // accidentally breaking line-visual on plain documents.
12304        let mut app = app_with("alpha\nbeta\ngamma\n", 5);
12305        app.editor.cursor = lattice_protocol::position::Position::new(1, 0);
12306        app.apply(crate::app::Action::EnterVisual(VisualKind::Linewise));
12307        let sel = Selection {
12308            anchor: pos(1, 0),
12309            head: pos(1, 0),
12310            visual: Some(VisualMode::Linewise),
12311        };
12312        app.editor
12313            .set_selections_blocking(SelectionSet::single(sel));
12314        let lines = compose_visible_lines(&app, &app.ad().snapshot.clone(), 5, 80);
12315        // Verify the second visible line ("beta") has at least one
12316        // span styled with the visual color.
12317        // T.6: derive expected bg from the app's published resolved table.
12318        let cells_rs = app.render_state.load().cells.load_full();
12319        let visual_bg = visual_style(&cells_rs.resolved_theme, &cells_rs.theme_ids).bg;
12320        let row1 = &lines[1];
12321        let has_visual_span = row1.spans.iter().any(|s| s.style.bg == visual_bg);
12322        assert!(
12323            has_visual_span,
12324            "linewise visual should overlay the selected line: {row1:?}"
12325        );
12326    }
12327
12328    #[test]
12329    fn lines_without_fold_start_have_no_glyph() {
12330        let mut app = app_with("# H\nbody one\nbody two\nafter\n", 5);
12331        app.set_foldmethod_for_test(crate::app::FoldMethod::Markdown);
12332        app.recompute_folds();
12333        let lines = compose_visible_lines(&app, &app.ad().snapshot.clone(), 5, 80);
12334        // Row 1 (body one) is inside the fold, not a fold start.
12335        let row1 = line_text(&lines[1]);
12336        assert!(!row1.contains('▸'), "row1: {row1:?}");
12337        assert!(!row1.contains('▾'), "row1: {row1:?}");
12338    }
12339
12340    // ---- LSP diagnostic rendering tests (Phase 4.1.d.iii) ----
12341
12342    /// Helper: seed a diagnostic into the App's LSP layer for
12343    /// the given line range + severity, mapping the App's
12344    /// active buffer to a fake URI.
12345    ///
12346    /// M.5.6: also activates `lsp-mode` on the buffer so the
12347    /// renderer's gate (`severity_for_line` /
12348    /// `diagnostics_on_line`) lets the diagnostic through. Tests
12349    /// were written before the gate; activating here keeps them
12350    /// probing the rendering path they were originally probing.
12351    fn seed_diagnostic(
12352        app: &mut App,
12353        line: u32,
12354        start_col: u32,
12355        end_col: u32,
12356        severity: lattice_lsp::DiagnosticSeverity,
12357        message: &str,
12358    ) {
12359        use std::str::FromStr;
12360        let uri = lattice_lsp::Uri::from_str("file:///tmp/x.rs").unwrap();
12361        let doc_id = app.ad().document_buffer_id;
12362        app.editor.buffer_uris.insert(doc_id, uri.clone());
12363        // Activate lsp-mode so the M.5.6 render gate doesn't
12364        // suppress what we're about to paint. Idempotent: tests
12365        // that have already toggled it on no-op here.
12366        if !app.lsp_mode_enabled_for(app.ad().document_buffer_id) {
12367            app.toggle_mode_by_name("lsp-mode");
12368        }
12369        let diag = lattice_lsp::Diagnostic {
12370            range: lattice_lsp::LspRange {
12371                start: lattice_lsp::LspPosition {
12372                    line,
12373                    character: start_col,
12374                },
12375                end: lattice_lsp::LspPosition {
12376                    line,
12377                    character: end_col,
12378                },
12379            },
12380            severity: Some(severity),
12381            code: None,
12382            code_description: None,
12383            source: None,
12384            message: message.into(),
12385            related_information: None,
12386            tags: None,
12387            data: None,
12388        };
12389        app.editor
12390            .lsp_diagnostics
12391            .apply(lattice_lsp::DiagnosticEvent {
12392                server_id: std::sync::Arc::from("rust"),
12393                uri,
12394                version: None,
12395                diagnostics: std::sync::Arc::from(vec![diag].into_boxed_slice()),
12396            });
12397        // Phase 5.8.AF.5 / Slice 3a: republish the renderer's
12398        // `RenderState` so the diagnostic the test just wrote
12399        // appears in `render_state.diagnostics.layer`. In prod
12400        // this fires automatically at the end of every
12401        // `Editor::dispatch`; tests that mutate `Editor` state
12402        // directly must publish manually.
12403        app.editor.publish_render_state();
12404    }
12405
12406    #[test]
12407    fn lsp_mode_off_suppresses_diagnostic_glyphs() {
12408        // M.5.6: the render-side gate hides diagnostics when
12409        // `lsp-mode` is off, even if the diagnostics layer holds
12410        // data and a URI is mapped. Mirrors the supervisor-side
12411        // gates in M.5.4 / M.5.5: the user's "off" setting
12412        // suppresses every visible LSP signal for that buffer.
12413        let mut app = app_with("fn main() {}\n", 5);
12414        // Seed the diagnostic the same way other tests do (this
12415        // also auto-activates lsp-mode via the helper).
12416        seed_diagnostic(
12417            &mut app,
12418            0,
12419            0,
12420            7,
12421            lattice_lsp::DiagnosticSeverity::ERROR,
12422            "boom",
12423        );
12424        // Toggle lsp-mode OFF; the diagnostic glyph should
12425        // disappear from the rendered gutter.
12426        app.toggle_mode_by_name("lsp-mode");
12427        let lines = compose_visible_lines(&app, &app.ad().snapshot.clone(), 5, 80);
12428        let row0 = line_text(&lines[0]);
12429        assert!(
12430            !row0.contains('■'),
12431            "lsp-mode off should suppress the error glyph; got {row0:?}"
12432        );
12433        // (LSP segment was in the old global modeline; now covered by pane_status_label.)
12434    }
12435
12436    #[test]
12437    fn lsp_diagnostics_mode_off_suppresses_glyphs_independently() {
12438        // M.6.3: the render-side gate moves from `lsp-mode`
12439        // (umbrella) to `lsp-diagnostics-mode` (sub-mode). User
12440        // can disable just the diagnostic visual surface while
12441        // keeping other LSP features (hover / completion / nav)
12442        // active.
12443        let mut app = app_with("fn main() {}\n", 5);
12444        seed_diagnostic(
12445            &mut app,
12446            0,
12447            0,
12448            7,
12449            lattice_lsp::DiagnosticSeverity::ERROR,
12450            "boom",
12451        );
12452        // Helper auto-activated lsp-mode (and via cascade,
12453        // lsp-diagnostics-mode). Toggle just diagnostics-mode
12454        // off; lsp-mode stays on.
12455        assert!(app.lsp_mode_enabled_for(app.ad().document_buffer_id));
12456        assert!(app.lsp_diagnostics_mode_enabled_for(app.ad().document_buffer_id));
12457        app.toggle_mode_by_name("lsp-diagnostics-mode");
12458        assert!(app.lsp_mode_enabled_for(app.ad().document_buffer_id));
12459        assert!(!app.lsp_diagnostics_mode_enabled_for(app.ad().document_buffer_id));
12460        // Glyph suppressed.
12461        let lines = compose_visible_lines(&app, &app.ad().snapshot.clone(), 5, 80);
12462        let row0 = line_text(&lines[0]);
12463        assert!(
12464            !row0.contains('■'),
12465            "lsp-diagnostics-mode off should suppress glyph; got {row0:?}",
12466        );
12467    }
12468
12469    /// 4.4.e: a seeded `documentHighlight` cache produces a
12470    /// background-tinted run on the matching row when
12471    /// `lsp-document-highlight-mode` is on.
12472    #[test]
12473    fn document_highlight_overlay_tints_matched_range() {
12474        use std::str::FromStr;
12475        let mut app = app_with("let x = x + 1;\n", 5);
12476        // M.5.6: the overlay gate also requires lsp-mode.
12477        // Seed a URI so the mode gate's URI check passes (the
12478        // overlay also checks lsp_document_highlight_mode).
12479        let uri = lattice_lsp::Uri::from_str("file:///tmp/x.rs").unwrap();
12480        let doc_id = app.ad().document_buffer_id;
12481        app.editor.buffer_uris.insert(doc_id, uri);
12482        if !app.lsp_mode_enabled_for(app.ad().document_buffer_id) {
12483            app.toggle_mode_by_name("lsp-mode");
12484        }
12485        // `lsp-document-highlight-mode` should have cascaded
12486        // on with lsp-mode (capability cascade is per-mode);
12487        // if not, force it.
12488        if !app.lsp_document_highlight_mode_enabled_for(app.ad().document_buffer_id) {
12489            app.toggle_mode_by_name("lsp-document-highlight-mode");
12490        }
12491        // 5.8.AF.5 / Slice 3b.0: `lsp_document_highlights` is
12492        // now `Arc<ArcSwapOption<...>>`. Tests `.store()` the
12493        // cache and `publish_render_state()` so renderer reads
12494        // via `RenderState` reflect the seeded value (mirrors
12495        // the prod path where the spawned task stores + the
12496        // ArcSwap is shared with the render-state snapshot).
12497        app.editor
12498            .lsp_document_highlights
12499            .store(Some(std::sync::Arc::new(
12500                crate::app::DocumentHighlightCache {
12501                    buffer_id: app.ad().document_buffer_id,
12502                    cursor: lattice_protocol::Position::new(0, 4),
12503                    highlights: vec![
12504                        lattice_lsp::lsp_types::DocumentHighlight {
12505                            range: lattice_lsp::lsp_types::Range {
12506                                start: lattice_lsp::lsp_types::Position {
12507                                    line: 0,
12508                                    character: 4,
12509                                },
12510                                end: lattice_lsp::lsp_types::Position {
12511                                    line: 0,
12512                                    character: 5,
12513                                },
12514                            },
12515                            kind: Some(lattice_lsp::lsp_types::DocumentHighlightKind::WRITE),
12516                        },
12517                        lattice_lsp::lsp_types::DocumentHighlight {
12518                            range: lattice_lsp::lsp_types::Range {
12519                                start: lattice_lsp::lsp_types::Position {
12520                                    line: 0,
12521                                    character: 8,
12522                                },
12523                                end: lattice_lsp::lsp_types::Position {
12524                                    line: 0,
12525                                    character: 9,
12526                                },
12527                            },
12528                            kind: Some(lattice_lsp::lsp_types::DocumentHighlightKind::READ),
12529                        },
12530                    ],
12531                },
12532            )));
12533        app.editor.publish_render_state();
12534        let lines = compose_visible_lines(&app, &app.ad().snapshot.clone(), 5, 80);
12535        // Walk the spans on row 0; expect at least one span
12536        // with the read tint (rgb(20,50,25)) and one with the
12537        // write tint (rgb(60,20,20)). Span splitting depends on
12538        // overlay composition, so we tolerate any number of
12539        // them as long as both tints appear at least once.
12540        let row0 = &lines[0];
12541        let mut saw_read = false;
12542        let mut saw_write = false;
12543        for span in &row0.spans {
12544            match span.style.bg {
12545                Some(Color::Rgb(20, 50, 25)) => saw_read = true,
12546                Some(Color::Rgb(60, 20, 20)) => saw_write = true,
12547                _ => {}
12548            }
12549        }
12550        assert!(saw_read, "expected READ tint span; got {row0:?}");
12551        assert!(saw_write, "expected WRITE tint span; got {row0:?}");
12552    }
12553
12554    /// 4.4.g: a seeded inlay-hint cache produces a virtual
12555    /// span at the hint's character position, styled with the
12556    /// inlay-hint italic+dim color.
12557    #[test]
12558    fn inlay_hint_overlay_splices_virtual_text() {
12559        use std::str::FromStr;
12560        let mut app = app_with("let x = 1;\n", 5);
12561        let uri = lattice_lsp::Uri::from_str("file:///tmp/x.rs").unwrap();
12562        let doc_id = app.ad().document_buffer_id;
12563        app.editor.buffer_uris.insert(doc_id, uri);
12564        if !app.lsp_mode_enabled_for(app.ad().document_buffer_id) {
12565            app.toggle_mode_by_name("lsp-mode");
12566        }
12567        if !app.lsp_inlay_hint_mode_enabled_for(app.ad().document_buffer_id) {
12568            app.toggle_mode_by_name("lsp-inlay-hint-mode");
12569        }
12570        // Hint at column 5 (end of "let x") with label ": i32".
12571        // 5.8.AF.5 / Slice 3b.1: use `insert_for` + publish.
12572        {
12573            use lattice_host::per_buffer_cache::PerBufferCacheExt;
12574            app.editor.lsp_inlay_hints_cache.insert_for(
12575                app.ad().document_buffer_id,
12576                crate::app::LspInlayHintCache {
12577                    document_version: app.editor.document.snapshot().version,
12578                    hints: vec![lattice_lsp::lsp_types::InlayHint {
12579                        position: lattice_lsp::lsp_types::Position {
12580                            line: 0,
12581                            character: 5,
12582                        },
12583                        label: lattice_lsp::lsp_types::InlayHintLabel::String(": i32".into()),
12584                        kind: Some(lattice_lsp::lsp_types::InlayHintKind::TYPE),
12585                        text_edits: None,
12586                        tooltip: None,
12587                        padding_left: Some(false),
12588                        padding_right: Some(false),
12589                        data: None,
12590                    }],
12591                    requested_first_line: 0,
12592                    requested_last_line: u32::MAX,
12593                },
12594            );
12595        }
12596        app.editor.publish_render_state();
12597        let lines = compose_visible_lines(&app, &app.ad().snapshot.clone(), 5, 80);
12598        // T.6: inlay-hint fg now resolves from the `inlay.hint` element
12599        // (shared with GPUI). Derive the expected fg from the same
12600        // resolved table the renderer reads, so this stays a parity pin.
12601        let cells_rs = app.render_state.load().cells.load_full();
12602        let expected_fg = inlay_hint_style(&cells_rs.resolved_theme, &cells_rs.theme_ids).fg;
12603        let row0 = &lines[0];
12604        let mut found = false;
12605        for span in &row0.spans {
12606            if span.content.as_ref().contains(": i32") {
12607                assert_eq!(span.style.fg, expected_fg);
12608                found = true;
12609            }
12610        }
12611        assert!(found, "expected `: i32` inlay-hint span; got {row0:?}");
12612    }
12613
12614    /// Inlay-hint de-duplication (2026-06-30): an INACTIVE pane renders
12615    /// inlay hints through the SAME single path as the active pane —
12616    /// `view.inlay_hints` (per-buffer). Guards the unified
12617    /// `inlay_hints_for_buffer` source against a regression back to the
12618    /// active-only / inactive-LSP-cache split.
12619    #[test]
12620    fn inactive_pane_renders_inlay_hints_like_active() {
12621        use std::str::FromStr;
12622        let mut app = app_with("let x = 1;\n", 5);
12623        let uri = lattice_lsp::Uri::from_str("file:///tmp/x.rs").unwrap();
12624        let doc_id = app.ad().document_buffer_id;
12625        app.editor.buffer_uris.insert(doc_id, uri);
12626        if !app.lsp_mode_enabled_for(doc_id) {
12627            app.toggle_mode_by_name("lsp-mode");
12628        }
12629        if !app.lsp_inlay_hint_mode_enabled_for(doc_id) {
12630            app.toggle_mode_by_name("lsp-inlay-hint-mode");
12631        }
12632        {
12633            use lattice_host::per_buffer_cache::PerBufferCacheExt;
12634            app.editor.lsp_inlay_hints_cache.insert_for(
12635                doc_id,
12636                crate::app::LspInlayHintCache {
12637                    document_version: app.editor.document.snapshot().version,
12638                    hints: vec![lattice_lsp::lsp_types::InlayHint {
12639                        position: lattice_lsp::lsp_types::Position {
12640                            line: 0,
12641                            character: 5,
12642                        },
12643                        label: lattice_lsp::lsp_types::InlayHintLabel::String(": i32".into()),
12644                        kind: Some(lattice_lsp::lsp_types::InlayHintKind::TYPE),
12645                        text_edits: None,
12646                        tooltip: None,
12647                        padding_left: Some(false),
12648                        padding_right: Some(false),
12649                        data: None,
12650                    }],
12651                    requested_first_line: 0,
12652                    requested_last_line: u32::MAX,
12653                },
12654            );
12655        }
12656        app.editor.publish_render_state();
12657
12658        // Compose the buffer as an INACTIVE pane.
12659        let view = FrameView::for_buffer(&app, doc_id);
12660        let ctx = PaneComposeCtx {
12661            is_active: false,
12662            pane_id: app.panes().tree.active().id,
12663            buffer_id: doc_id,
12664            cursor_line: app.ad().cursor.line,
12665            cursor_line_highlight: false,
12666            scroll: app.ad().scroll,
12667            leftcol: app.ad().leftcol,
12668            display_line_numbers: app.ad().display_line_numbers.clone(),
12669        };
12670        let lines = compose_pane_lines(&view, &app.ad().snapshot.clone(), 5, 80, &ctx);
12671        let blob: String = lines.iter().map(line_text).collect::<Vec<_>>().join("");
12672        assert!(
12673            blob.contains(": i32"),
12674            "inactive pane lost its inlay hint via the unified path: {blob}"
12675        );
12676    }
12677
12678    /// MR.1: an unselected annotation's color now reads the registered
12679    /// `completion.annotation.*` theme slot (was a hardcoded `Color::*`
12680    /// before; GPUI already did this via T.6). Closes the TUI theme gap.
12681    #[test]
12682    fn annotation_color_reads_theme_for_unselected_rows() {
12683        let app = app_with("x\n", 5);
12684        let cells = app.render_state.load().cells.load_full();
12685        let resolved = &cells.resolved_theme;
12686        let ids = &cells.theme_ids;
12687        let kind = lattice_completion::Annotation::Kind("motion".into());
12688        let got = super::annotation_color(&kind, false, resolved, ids);
12689        let want = resolved
12690            .get(ids.completion_annotation_kind)
12691            .fg
12692            .map(crate::theme::host_color_to_ratatui)
12693            .expect("completion.annotation.kind has a default fg");
12694        assert_eq!(
12695            got, want,
12696            "unselected annotation fg must resolve from completion.annotation.kind"
12697        );
12698    }
12699
12700    /// MR.1: the selected-row brightening stays renderer logic (a
12701    /// brightened literal), NOT a theme read — mirrors the GPUI peer's
12702    /// `annotation_color_rgb` exactly so the two peers agree.
12703    #[test]
12704    fn annotation_color_brightens_selected_independent_of_theme() {
12705        let app = app_with("x\n", 5);
12706        let cells = app.render_state.load().cells.load_full();
12707        let kind = lattice_completion::Annotation::Kind("motion".into());
12708        let got = super::annotation_color(&kind, true, &cells.resolved_theme, &cells.theme_ids);
12709        // Same brightened literal GPUI returns for a selected Kind row.
12710        assert_eq!(got, Color::Rgb(0xcd, 0xd6, 0xf4));
12711    }
12712
12713    /// MR.2: a `Styled` annotation paints one span per segment, each
12714    /// colored by its own theme slot — the per-bit permission coloring.
12715    #[test]
12716    fn styled_annotation_paints_each_segment_with_its_slot_color() {
12717        let app = app_with("x\n", 5);
12718        let cells = app.render_state.load().cells.load_full();
12719        let resolved = &cells.resolved_theme;
12720        let ids = &cells.theme_ids;
12721        let perm = lattice_completion::Annotation::Styled {
12722            category: "perm".into(),
12723            segments: vec![
12724                lattice_completion::AnnotationSegment {
12725                    text: "w".into(),
12726                    slot: "completion.annotation.perm.write".into(),
12727                },
12728                lattice_completion::AnnotationSegment {
12729                    text: "x".into(),
12730                    slot: "completion.annotation.perm.exec".into(),
12731                },
12732            ],
12733        };
12734        let scored = lattice_completion::ScoredCandidate {
12735            raw: lattice_completion::RawCandidate::plain(
12736                "f",
12737                lattice_completion::CandidateKind::Plain,
12738            ),
12739            score: lattice_completion::MatchScore::PERFECT,
12740            match_ranges: vec![],
12741        };
12742        let mut c = lattice_completion::RenderedCandidate::from_scored(scored);
12743        c.annotations = vec![perm];
12744        let cols = lattice_completion::AnnotationColumns::from_visible(std::iter::once(&c));
12745        let line = super::candidate_to_line(&c, false, 1, &cols, resolved, ids);
12746
12747        let want = |id| {
12748            resolved
12749                .get(id)
12750                .fg
12751                .map(crate::theme::host_color_to_ratatui)
12752                .unwrap()
12753        };
12754        let w = line
12755            .spans
12756            .iter()
12757            .find(|s| s.content.as_ref() == "w")
12758            .expect("w span");
12759        let x = line
12760            .spans
12761            .iter()
12762            .find(|s| s.content.as_ref() == "x")
12763            .expect("x span");
12764        assert_eq!(w.style.fg, Some(want(ids.completion_annotation_perm_write)));
12765        assert_eq!(x.style.fg, Some(want(ids.completion_annotation_perm_exec)));
12766    }
12767
12768    /// MR.4: a theme change recolors styled marginalia on the TUI peer —
12769    /// overriding the `perm.write` element changes the rendered `w`
12770    /// segment's fg through `candidate_to_line`.
12771    #[test]
12772    fn styled_segment_recolors_on_theme_change_tui() {
12773        use lattice_host::ui::theme::{
12774            BuiltinElementIds, ElementName, InMemoryThemeRegistry, StyleSpec, ThemeRegistry,
12775        };
12776        let reg = InMemoryThemeRegistry::with_defaults();
12777        let ids = BuiltinElementIds::capture(&reg);
12778        let perm = lattice_completion::Annotation::Styled {
12779            category: "perm".into(),
12780            segments: vec![lattice_completion::AnnotationSegment {
12781                text: "w".into(),
12782                slot: "completion.annotation.perm.write".into(),
12783            }],
12784        };
12785        let scored = lattice_completion::ScoredCandidate {
12786            raw: lattice_completion::RawCandidate::plain(
12787                "f",
12788                lattice_completion::CandidateKind::Plain,
12789            ),
12790            score: lattice_completion::MatchScore::PERFECT,
12791            match_ranges: vec![],
12792        };
12793        let mut c = lattice_completion::RenderedCandidate::from_scored(scored);
12794        c.annotations = vec![perm];
12795        let cols = lattice_completion::AnnotationColumns::from_visible(std::iter::once(&c));
12796        let w_fg = |reg: &InMemoryThemeRegistry| {
12797            let r = reg.resolved();
12798            let line = super::candidate_to_line(&c, false, 1, &cols, &r, &ids);
12799            line.spans
12800                .iter()
12801                .find(|s| s.content.as_ref() == "w")
12802                .map(|s| s.style.fg)
12803        };
12804        let before = w_fg(&reg);
12805        reg.set_override(
12806            ElementName::from_static("completion.annotation.perm.write"),
12807            StyleSpec::new().fg("green"),
12808        );
12809        let after = w_fg(&reg);
12810        assert_ne!(
12811            before, after,
12812            "styled marginalia tracks the active theme on TUI"
12813        );
12814    }
12815
12816    /// PH.1: a candidate's `display_spans` syntax-color the
12817    /// preview run, composing with fuzzy match-highlight so the
12818    /// match style wins on overlap (picker-preview-highlight.md
12819    /// §5). The matched prefix paints cyan+bold (match), the
12820    /// non-matched suffix keeps the keyword syntax color.
12821    #[test]
12822    fn display_spans_compose_with_match_highlight_tui() {
12823        let app = app_with("x\n", 5);
12824        let cells = app.render_state.load().cells.load_full();
12825        let resolved = &cells.resolved_theme;
12826        let ids = &cells.theme_ids;
12827
12828        // "test": whole word colored as Keyword; first half "te"
12829        // also a fuzzy match.
12830        let scored = lattice_completion::ScoredCandidate {
12831            raw: lattice_completion::RawCandidate::plain(
12832                "test",
12833                lattice_completion::CandidateKind::Plain,
12834            ),
12835            score: lattice_completion::MatchScore::PERFECT,
12836            match_ranges: Vec::from([0..2]),
12837        };
12838        let mut c = lattice_completion::RenderedCandidate::from_scored(scored);
12839        c.raw.display_spans = vec![lattice_completion::DisplaySpan {
12840            range: 0..4,
12841            style: lattice_cells::style::Style::Keyword,
12842        }];
12843        let cols = lattice_completion::AnnotationColumns::from_visible(std::iter::once(&c));
12844        let line = super::candidate_to_line(&c, false, 1, &cols, resolved, ids);
12845
12846        let keyword_fg = resolved
12847            .get(ids.syntax_keyword)
12848            .fg
12849            .map(crate::theme::host_color_to_ratatui);
12850
12851        // Matched prefix "te" paints the match style, NOT keyword.
12852        let te = line
12853            .spans
12854            .iter()
12855            .find(|s| s.content.as_ref() == "te")
12856            .expect("matched run");
12857        assert_eq!(te.style.fg, Some(Color::Cyan), "match wins on overlap");
12858        assert!(te.style.add_modifier.contains(Modifier::BOLD));
12859        // Non-matched suffix "st" keeps the keyword syntax color.
12860        let st = line
12861            .spans
12862            .iter()
12863            .find(|s| s.content.as_ref() == "st")
12864            .expect("syntax run");
12865        assert_eq!(st.style.fg, keyword_fg);
12866        assert_ne!(st.style.fg, Some(Color::Cyan));
12867    }
12868
12869    /// PH.1: a `:colorscheme` swap recolors preview syntax spans
12870    /// live on the TUI peer — overriding `syntax.keyword` changes
12871    /// the rendered keyword-span fg (semantic `Style` resolved at
12872    /// the seam, never baked).
12873    #[test]
12874    fn display_spans_recolor_on_theme_change_tui() {
12875        use lattice_host::ui::theme::{
12876            BuiltinElementIds, ElementName, InMemoryThemeRegistry, StyleSpec, ThemeRegistry,
12877        };
12878        let reg = InMemoryThemeRegistry::with_defaults();
12879        let ids = BuiltinElementIds::capture(&reg);
12880        let scored = lattice_completion::ScoredCandidate {
12881            raw: lattice_completion::RawCandidate::plain(
12882                "kw",
12883                lattice_completion::CandidateKind::Plain,
12884            ),
12885            score: lattice_completion::MatchScore::PERFECT,
12886            match_ranges: vec![],
12887        };
12888        let mut c = lattice_completion::RenderedCandidate::from_scored(scored);
12889        c.raw.display_spans = vec![lattice_completion::DisplaySpan {
12890            range: 0..2,
12891            style: lattice_cells::style::Style::Keyword,
12892        }];
12893        let cols = lattice_completion::AnnotationColumns::from_visible(std::iter::once(&c));
12894        let kw_fg = |reg: &InMemoryThemeRegistry| {
12895            let r = reg.resolved();
12896            let line = super::candidate_to_line(&c, false, 1, &cols, &r, &ids);
12897            line.spans
12898                .iter()
12899                .find(|s| s.content.as_ref() == "kw")
12900                .map(|s| s.style.fg)
12901        };
12902        let before = kw_fg(&reg);
12903        reg.set_override(
12904            ElementName::from_static("syntax.keyword"),
12905            StyleSpec::new().fg("green"),
12906        );
12907        let after = kw_fg(&reg);
12908        assert_ne!(
12909            before, after,
12910            "preview syntax color tracks the active theme on TUI"
12911        );
12912    }
12913
12914    /// 4.4.h: a seeded semantic-tokens cache repaints the
12915    /// foreground color within each token's byte range.
12916    #[test]
12917    fn semantic_tokens_overlay_repaints_fg_within_token_range() {
12918        use std::str::FromStr;
12919        let mut app = app_with("fn main() {}\n", 5);
12920        let uri = lattice_lsp::Uri::from_str("file:///tmp/x.rs").unwrap();
12921        let doc_id = app.ad().document_buffer_id;
12922        app.editor.buffer_uris.insert(doc_id, uri);
12923        if !app.lsp_mode_enabled_for(app.ad().document_buffer_id) {
12924            app.toggle_mode_by_name("lsp-mode");
12925        }
12926        if !app.lsp_semantic_tokens_mode_enabled_for(app.ad().document_buffer_id) {
12927            app.toggle_mode_by_name("lsp-semantic-tokens-mode");
12928        }
12929        // Seed: "fn" as keyword (chars 0..=1), "main" as function
12930        // (chars 3..=6).
12931        // 5.8.AF.5 / Slice 3b.2: `lsp_semantic_tokens_cache` is
12932        // now a `PerBufferCache<...>`; use `insert_for` + publish.
12933        {
12934            use lattice_host::per_buffer_cache::PerBufferCacheExt;
12935            app.editor.lsp_semantic_tokens_cache.insert_for(
12936                app.ad().document_buffer_id,
12937                crate::app::LspSemanticTokensCache {
12938                    document_version: app.editor.document.snapshot().version,
12939                    result_id: None,
12940                    raw_data: Vec::new(),
12941                    tokens: vec![
12942                        crate::app::DecodedSemanticToken {
12943                            line: 0,
12944                            start_char: 0,
12945                            length: 2,
12946                            token_type: "keyword".into(),
12947                            modifiers: Vec::new(),
12948                        },
12949                        crate::app::DecodedSemanticToken {
12950                            line: 0,
12951                            start_char: 3,
12952                            length: 4,
12953                            token_type: "function".into(),
12954                            modifiers: Vec::new(),
12955                        },
12956                    ],
12957                },
12958            );
12959        }
12960        app.editor.publish_render_state();
12961        let lines = compose_visible_lines(&app, &app.ad().snapshot.clone(), 5, 80);
12962        let row0 = &lines[0];
12963        // Magenta = keyword, Yellow = function. Find at least
12964        // one span of each in the row.
12965        let mut saw_keyword = false;
12966        let mut saw_function = false;
12967        for span in &row0.spans {
12968            match span.style.fg {
12969                Some(Color::Magenta) if span.content.as_ref().contains("fn") => {
12970                    saw_keyword = true;
12971                }
12972                Some(Color::Yellow) if span.content.as_ref().contains("main") => {
12973                    saw_function = true;
12974                }
12975                _ => {}
12976            }
12977        }
12978        assert!(saw_keyword, "expected keyword fg on `fn`; got {row0:?}");
12979        assert!(saw_function, "expected function fg on `main`; got {row0:?}");
12980    }
12981
12982    /// 4.4.h: with the mode off, the cache is ignored.
12983    #[test]
12984    fn semantic_tokens_overlay_suppressed_when_mode_off() {
12985        use std::str::FromStr;
12986        let mut app = app_with("fn main() {}\n", 5);
12987        let uri = lattice_lsp::Uri::from_str("file:///tmp/x.rs").unwrap();
12988        let doc_id = app.ad().document_buffer_id;
12989        app.editor.buffer_uris.insert(doc_id, uri);
12990        if !app.lsp_mode_enabled_for(app.ad().document_buffer_id) {
12991            app.toggle_mode_by_name("lsp-mode");
12992        }
12993        if app.lsp_semantic_tokens_mode_enabled_for(app.ad().document_buffer_id) {
12994            app.toggle_mode_by_name("lsp-semantic-tokens-mode");
12995        }
12996        // 5.8.AF.5 / Slice 3b.2: see seed pattern note above.
12997        {
12998            use lattice_host::per_buffer_cache::PerBufferCacheExt;
12999            app.editor.lsp_semantic_tokens_cache.insert_for(
13000                app.ad().document_buffer_id,
13001                crate::app::LspSemanticTokensCache {
13002                    document_version: app.editor.document.snapshot().version,
13003                    result_id: None,
13004                    raw_data: Vec::new(),
13005                    tokens: vec![crate::app::DecodedSemanticToken {
13006                        line: 0,
13007                        start_char: 0,
13008                        length: 2,
13009                        token_type: "keyword".into(),
13010                        modifiers: Vec::new(),
13011                    }],
13012                },
13013            );
13014        }
13015        app.editor.publish_render_state();
13016        let lines = compose_visible_lines(&app, &app.ad().snapshot.clone(), 5, 80);
13017        let row0 = &lines[0];
13018        // No magenta fg should appear on the "fn" span.
13019        for span in &row0.spans {
13020            if span.content.as_ref().contains("fn") {
13021                assert_ne!(
13022                    span.style.fg,
13023                    Some(Color::Magenta),
13024                    "mode-off should suppress semantic-tokens overlay; got {span:?}"
13025                );
13026            }
13027        }
13028    }
13029
13030    /// 4.4.g: with the mode off, the cache content is ignored
13031    /// and the overlay does not paint.
13032    #[test]
13033    fn inlay_hint_overlay_suppressed_when_mode_off() {
13034        use std::str::FromStr;
13035        let mut app = app_with("let x = 1;\n", 5);
13036        let uri = lattice_lsp::Uri::from_str("file:///tmp/x.rs").unwrap();
13037        let doc_id = app.ad().document_buffer_id;
13038        app.editor.buffer_uris.insert(doc_id, uri);
13039        if !app.lsp_mode_enabled_for(app.ad().document_buffer_id) {
13040            app.toggle_mode_by_name("lsp-mode");
13041        }
13042        // Force mode OFF.
13043        if app.lsp_inlay_hint_mode_enabled_for(app.ad().document_buffer_id) {
13044            app.toggle_mode_by_name("lsp-inlay-hint-mode");
13045        }
13046        // 5.8.AF.5 / Slice 3b.1: use `insert_for` + publish.
13047        {
13048            use lattice_host::per_buffer_cache::PerBufferCacheExt;
13049            app.editor.lsp_inlay_hints_cache.insert_for(
13050                app.ad().document_buffer_id,
13051                crate::app::LspInlayHintCache {
13052                    document_version: app.editor.document.snapshot().version,
13053                    hints: vec![lattice_lsp::lsp_types::InlayHint {
13054                        position: lattice_lsp::lsp_types::Position {
13055                            line: 0,
13056                            character: 5,
13057                        },
13058                        label: lattice_lsp::lsp_types::InlayHintLabel::String(": i32".into()),
13059                        kind: None,
13060                        text_edits: None,
13061                        tooltip: None,
13062                        padding_left: None,
13063                        padding_right: None,
13064                        data: None,
13065                    }],
13066                    requested_first_line: 0,
13067                    requested_last_line: u32::MAX,
13068                },
13069            );
13070        }
13071        app.editor.publish_render_state();
13072        let lines = compose_visible_lines(&app, &app.ad().snapshot.clone(), 5, 80);
13073        let row0 = &lines[0];
13074        for span in &row0.spans {
13075            assert!(
13076                !span.content.as_ref().contains(": i32"),
13077                "mode-off should suppress hint span; got {span:?}"
13078            );
13079        }
13080    }
13081
13082    /// 4.4.e: with the mode off, the overlay must NOT paint --
13083    /// even if the cache still holds entries (mode disable is
13084    /// a render-side gate).
13085    #[test]
13086    fn document_highlight_overlay_suppressed_when_mode_off() {
13087        use std::str::FromStr;
13088        let mut app = app_with("let x = x;\n", 5);
13089        let uri = lattice_lsp::Uri::from_str("file:///tmp/x.rs").unwrap();
13090        let doc_id = app.ad().document_buffer_id;
13091        app.editor.buffer_uris.insert(doc_id, uri);
13092        if !app.lsp_mode_enabled_for(app.ad().document_buffer_id) {
13093            app.toggle_mode_by_name("lsp-mode");
13094        }
13095        // Force the sub-mode OFF (lsp-mode cascade may have
13096        // turned it on by default).
13097        if app.lsp_document_highlight_mode_enabled_for(app.ad().document_buffer_id) {
13098            app.toggle_mode_by_name("lsp-document-highlight-mode");
13099        }
13100        // 5.8.AF.5 / Slice 3b.0: see seed pattern note above.
13101        app.editor
13102            .lsp_document_highlights
13103            .store(Some(std::sync::Arc::new(
13104                crate::app::DocumentHighlightCache {
13105                    buffer_id: app.ad().document_buffer_id,
13106                    cursor: lattice_protocol::Position::new(0, 4),
13107                    highlights: vec![lattice_lsp::lsp_types::DocumentHighlight {
13108                        range: lattice_lsp::lsp_types::Range {
13109                            start: lattice_lsp::lsp_types::Position {
13110                                line: 0,
13111                                character: 4,
13112                            },
13113                            end: lattice_lsp::lsp_types::Position {
13114                                line: 0,
13115                                character: 5,
13116                            },
13117                        },
13118                        kind: None,
13119                    }],
13120                },
13121            )));
13122        app.editor.publish_render_state();
13123        let lines = compose_visible_lines(&app, &app.ad().snapshot.clone(), 5, 80);
13124        let row0 = &lines[0];
13125        for span in &row0.spans {
13126            // No DH-tinted span should appear.
13127            assert!(
13128                !matches!(span.style.bg, Some(Color::Rgb(20, 30, 60))),
13129                "expected suppressed; got tinted span: {span:?}"
13130            );
13131        }
13132    }
13133
13134    /// SG.2b: define a sign and return its id. Goes through the registered
13135    /// `SignRegistryHandle` service — the same handle a provider's install
13136    /// or a plugin's load writes — so a test that passes here proves the
13137    /// boot wiring, not just the paint code.
13138    fn define_sign(app: &App, name: &str, glyph: char, priority: i32) -> lattice_mode::SignId {
13139        let handle = app
13140            .editor
13141            .services
13142            .get::<lattice_mode::SignRegistryHandle>()
13143            .expect("SG.2b: boot registers the sign registry service");
13144        let mut reg = (**handle.load()).clone();
13145        let id = reg.define(lattice_mode::SignDefinition {
13146            name: name.to_string(),
13147            text: glyph.to_string(),
13148            fallback: glyph.to_string(),
13149            theme_element: format!("gutter.sign.{name}"),
13150            priority,
13151            column: lattice_mode::SIGN_COLUMN_MARK.to_string(),
13152        });
13153        handle.store(std::sync::Arc::new(reg));
13154        id
13155    }
13156
13157    /// SG.2b: place signs on lines through the WASM decoration cache — the
13158    /// path a plugin's producer writes, read wait-free by the renderer.
13159    fn place_signs(app: &mut App, placements: &[(u32, lattice_mode::SignId)]) {
13160        use lattice_host::per_buffer_cache::PerBufferCacheExt;
13161        let buffer_id = app.ad().document_buffer_id;
13162        let cache = &app.editor.wasm_decorations.cache;
13163        cache.insert_for(
13164            buffer_id,
13165            lattice_host::wasm_decorations::WasmGutterDecorationCache {
13166                document_version: 0,
13167                decorations: placements
13168                    .iter()
13169                    .map(|(line, sign)| lattice_mode::GutterDecoration::Sign {
13170                        line: *line,
13171                        sign: *sign,
13172                    })
13173                    .collect(),
13174            },
13175        );
13176        app.editor.publish_render_state();
13177    }
13178
13179    #[test]
13180    fn a_placed_sign_paints_in_the_gutter_mark_cell() {
13181        let mut app = app_with("fn main() {}\nlet x = 1;\n", 5);
13182        let id = define_sign(&app, "test-mark", '◆', 5);
13183        place_signs(&mut app, &[(0, id)]);
13184        let lines = compose_visible_lines(&app, &app.ad().snapshot.clone(), 5, 80);
13185        let row0 = line_text(&lines[0]);
13186        assert!(row0.contains('◆'), "expected the sign glyph; got {row0:?}");
13187        let row1 = line_text(&lines[1]);
13188        assert!(
13189            !row1.contains('◆'),
13190            "an unmarked line must stay clean: {row1:?}"
13191        );
13192    }
13193
13194    #[test]
13195    fn a_default_priority_sign_yields_the_cell_to_a_diagnostic() {
13196        // The two share one cell, so one of them loses it. A sign at vim's
13197        // default priority (10, level with the LOWEST diagnostic) loses to an
13198        // error outright — an error is a state of the user's code they did not
13199        // ask for, and it must not be hidden by a mark somebody chose to show.
13200        let mut app = app_with("fn main() {}\n", 5);
13201        seed_diagnostic(
13202            &mut app,
13203            0,
13204            0,
13205            7,
13206            lattice_lsp::DiagnosticSeverity::ERROR,
13207            "boom",
13208        );
13209        let id = define_sign(&app, "tie", '◆', lattice_mode::DIAGNOSTIC_HINT_PRIORITY);
13210        place_signs(&mut app, &[(0, id)]);
13211        let lines = compose_visible_lines(&app, &app.ad().snapshot.clone(), 5, 80);
13212        let row0 = line_text(&lines[0]);
13213        assert!(row0.contains('■'), "the error must hold the cell: {row0:?}");
13214        assert!(!row0.contains('◆'), "the sign must yield: {row0:?}");
13215    }
13216
13217    #[test]
13218    fn a_higher_priority_sign_takes_the_cell_from_a_diagnostic() {
13219        // The escape hatch for a producer that genuinely outranks an error —
13220        // a debugger stopped on this very line. It says so by exceeding
13221        // `DIAGNOSTIC_ERROR_PRIORITY`, not by being placed later.
13222        //
13223        // SG.4b raised that bar. A diagnostic used to hold the cell at one
13224        // priority (`SEVERITY_SIGN_PRIORITY`, 10) whatever its severity, so
13225        // any sign above 10 displaced an ERROR. Now the severities span
13226        // 10..40 — which is what carries "most severe wins" through the
13227        // unification — and displacing an error means beating 40. The
13228        // stricter reading is the right one: a sign that hides a compiler
13229        // error had better mean it.
13230        let mut app = app_with("fn main() {}\n", 5);
13231        seed_diagnostic(
13232            &mut app,
13233            0,
13234            0,
13235            7,
13236            lattice_lsp::DiagnosticSeverity::ERROR,
13237            "boom",
13238        );
13239        let id = define_sign(
13240            &app,
13241            "stop",
13242            '◆',
13243            lattice_mode::DIAGNOSTIC_ERROR_PRIORITY + 1,
13244        );
13245        place_signs(&mut app, &[(0, id)]);
13246        let lines = compose_visible_lines(&app, &app.ad().snapshot.clone(), 5, 80);
13247        let row0 = line_text(&lines[0]);
13248        assert!(row0.contains('◆'), "the sign must hold the cell: {row0:?}");
13249        assert!(!row0.contains('■'), "the error must yield: {row0:?}");
13250    }
13251
13252    #[test]
13253    fn the_higher_priority_sign_wins_the_cell() {
13254        // Two producers, one line. The winner is the higher priority, not
13255        // whichever the decoration walk reached last.
13256        let mut app = app_with("fn main() {}\n", 5);
13257        let low = define_sign(&app, "low", '◆', 1);
13258        let high = define_sign(&app, "high", '■', 9);
13259        place_signs(&mut app, &[(0, high), (0, low)]);
13260        let lines = compose_visible_lines(&app, &app.ad().snapshot.clone(), 5, 80);
13261        let row0 = line_text(&lines[0]);
13262        assert!(row0.contains('■'), "higher priority must win: {row0:?}");
13263        assert!(!row0.contains('◆'), "lower priority must lose: {row0:?}");
13264    }
13265
13266    #[test]
13267    fn a_retired_sign_id_paints_nothing_rather_than_a_later_sign() {
13268        // SG.1 retires ids instead of reusing them so that a placement
13269        // produced before an `undefine` paints NOTHING. A reused slot would
13270        // paint some later sign's glyph, and a wrong answer in place of the
13271        // right one is worse than a blank — it is also the silent one.
13272        let mut app = app_with("fn main() {}\n", 5);
13273        let doomed = define_sign(&app, "doomed", '◆', 5);
13274        {
13275            let handle = app
13276                .editor
13277                .services
13278                .get::<lattice_mode::SignRegistryHandle>()
13279                .unwrap();
13280            let mut reg = (**handle.load()).clone();
13281            reg.undefine("doomed");
13282            // A later definition must not inherit the retired slot.
13283            reg.define(lattice_mode::SignDefinition {
13284                name: "successor".into(),
13285                text: "■".into(),
13286                fallback: "■".into(),
13287                theme_element: "gutter.sign.successor".into(),
13288                priority: 5,
13289                column: lattice_mode::SIGN_COLUMN_MARK.into(),
13290            });
13291            handle.store(std::sync::Arc::new(reg));
13292        }
13293        place_signs(&mut app, &[(0, doomed)]);
13294        let lines = compose_visible_lines(&app, &app.ad().snapshot.clone(), 5, 80);
13295        let row0 = line_text(&lines[0]);
13296        assert!(
13297            !row0.contains('◆'),
13298            "the retired sign must not paint: {row0:?}"
13299        );
13300        assert!(
13301            !row0.contains('■'),
13302            "and must not paint its successor's glyph either: {row0:?}"
13303        );
13304    }
13305
13306    #[test]
13307    fn a_sign_does_not_shift_the_content_column() {
13308        // The reason signs share the mark cell rather than getting their own
13309        // column: a gutter that widens when a sign arrives is a pixel change
13310        // to content the user did not edit. Same row, with and without.
13311        let mut app = app_with("fn main() {}\n", 5);
13312        let before = line_text(&compose_visible_lines(&app, &app.ad().snapshot.clone(), 5, 80)[0]);
13313        let id = define_sign(&app, "shift", '◆', 5);
13314        place_signs(&mut app, &[(0, id)]);
13315        let after = line_text(&compose_visible_lines(&app, &app.ad().snapshot.clone(), 5, 80)[0]);
13316        // Compare the CHARACTER column, not the byte offset — the glyph is
13317        // multi-byte, and a byte-offset comparison would report a shift the
13318        // terminal never renders.
13319        let col = |row: &str| {
13320            let idx = row.find("fn main").expect("content row");
13321            row[..idx].chars().count()
13322        };
13323        assert_eq!(
13324            col(&before),
13325            col(&after),
13326            "the sign must not move the content: {before:?} vs {after:?}"
13327        );
13328    }
13329
13330    #[test]
13331    fn diagnostic_severity_glyph_appears_in_gutter_for_error() {
13332        let mut app = app_with("fn main() {}\nlet x = 1;\n", 5);
13333        seed_diagnostic(
13334            &mut app,
13335            0,
13336            0,
13337            7,
13338            lattice_lsp::DiagnosticSeverity::ERROR,
13339            "boom",
13340        );
13341        let lines = compose_visible_lines(&app, &app.ad().snapshot.clone(), 5, 80);
13342        // Row 0 has the error; expect the ■ glyph somewhere in
13343        // the rendered first span (the severity cell).
13344        let row0 = line_text(&lines[0]);
13345        assert!(
13346            row0.contains('■'),
13347            "expected error glyph on diag line; got {row0:?}"
13348        );
13349        // Row 1 has no diagnostic; should NOT have any
13350        // severity glyph.
13351        let row1 = line_text(&lines[1]);
13352        assert!(!row1.contains('■'), "row 1 should be clean: {row1:?}");
13353        assert!(!row1.contains('▲'), "row 1 should be clean: {row1:?}");
13354    }
13355
13356    #[test]
13357    fn diagnostic_warning_uses_triangle_glyph() {
13358        let mut app = app_with("hello\n", 3);
13359        seed_diagnostic(
13360            &mut app,
13361            0,
13362            0,
13363            5,
13364            lattice_lsp::DiagnosticSeverity::WARNING,
13365            "warn",
13366        );
13367        let lines = compose_visible_lines(&app, &app.ad().snapshot.clone(), 3, 80);
13368        let row0 = line_text(&lines[0]);
13369        assert!(row0.contains('▲'), "expected warning glyph; got {row0:?}");
13370    }
13371
13372    #[test]
13373    fn diagnostic_hint_uses_dot_glyph() {
13374        let mut app = app_with("hello\n", 3);
13375        seed_diagnostic(
13376            &mut app,
13377            0,
13378            0,
13379            1,
13380            lattice_lsp::DiagnosticSeverity::HINT,
13381            "hint",
13382        );
13383        let lines = compose_visible_lines(&app, &app.ad().snapshot.clone(), 3, 80);
13384        let row0 = line_text(&lines[0]);
13385        assert!(row0.contains('·'), "expected hint glyph; got {row0:?}");
13386    }
13387
13388    #[test]
13389    fn most_severe_wins_per_line() {
13390        let mut app = app_with("hello\n", 3);
13391        seed_diagnostic(
13392            &mut app,
13393            0,
13394            0,
13395            3,
13396            lattice_lsp::DiagnosticSeverity::WARNING,
13397            "warn",
13398        );
13399        seed_diagnostic(
13400            &mut app,
13401            0,
13402            2,
13403            5,
13404            lattice_lsp::DiagnosticSeverity::ERROR,
13405            "err",
13406        );
13407        let lines = compose_visible_lines(&app, &app.ad().snapshot.clone(), 3, 80);
13408        let row0 = line_text(&lines[0]);
13409        // Error wins over warning on the same line for the
13410        // gutter glyph (most-severe semantics).
13411        assert!(row0.contains('■'), "row0 expected ■: {row0:?}");
13412    }
13413
13414    #[test]
13415    fn no_lsp_attachment_no_severity_glyph() {
13416        let app = app_with("hello\n", 3);
13417        // No buffer_uri mapping -> no diagnostics queryable.
13418        let lines = compose_visible_lines(&app, &app.ad().snapshot.clone(), 3, 80);
13419        let row0 = line_text(&lines[0]);
13420        assert!(!row0.contains('■'), "no LSP -> no error glyph: {row0:?}");
13421        assert!(!row0.contains('▲'), "no LSP -> no warn glyph: {row0:?}");
13422    }
13423
13424    /// L4a.3: the inline cursor-line diagnostic summary renders as
13425    /// trailing eol text on the cursor's line once the idle gate fires.
13426    #[test]
13427    fn inline_diagnostic_summary_renders_at_eol_on_cursor_line() {
13428        let mut app = app_with("let x = 1;\n", 3);
13429        seed_diagnostic(
13430            &mut app,
13431            0,
13432            0,
13433            5,
13434            lattice_lsp::DiagnosticSeverity::ERROR,
13435            "boom error",
13436        );
13437        // seed_diagnostic enables lsp-mode (cascading lsp-diagnostics-
13438        // mode on); ensure the render-side diagnostics gate is active.
13439        if !app.lsp_diagnostics_mode_enabled_for(app.ad().document_buffer_id) {
13440            app.toggle_mode_by_name("lsp-diagnostics-mode");
13441        }
13442        // Cursor is on line 0 by default. Arm the idle gate, then fire
13443        // it (simulating the ~300 ms settle) so the summary is visible.
13444        app.editor.publish_render_state();
13445        app.editor.fire_inline_diag_gate();
13446        app.editor.publish_render_state();
13447        let lines = compose_visible_lines(&app, &app.ad().snapshot.clone(), 3, 80);
13448        let row0 = line_text(&lines[0]);
13449        assert!(
13450            row0.contains("boom error"),
13451            "eol summary expected on the cursor line: {row0:?}"
13452        );
13453    }
13454
13455    /// L4a.3: the summary respects the render-side lsp-diagnostics-mode
13456    /// gate — with the mode off it is suppressed even though the host
13457    /// gate published it (matching the gutter / underline gate).
13458    #[test]
13459    fn inline_diagnostic_summary_suppressed_when_diagnostics_mode_off() {
13460        let mut app = app_with("let x = 1;\n", 3);
13461        seed_diagnostic(
13462            &mut app,
13463            0,
13464            0,
13465            5,
13466            lattice_lsp::DiagnosticSeverity::ERROR,
13467            "boom error",
13468        );
13469        // Force lsp-diagnostics-mode OFF (the lsp-mode cascade in
13470        // seed_diagnostic may have enabled it).
13471        if app.lsp_diagnostics_mode_enabled_for(app.ad().document_buffer_id) {
13472            app.toggle_mode_by_name("lsp-diagnostics-mode");
13473        }
13474        app.editor.publish_render_state();
13475        app.editor.fire_inline_diag_gate();
13476        app.editor.publish_render_state();
13477        let lines = compose_visible_lines(&app, &app.ad().snapshot.clone(), 3, 80);
13478        let row0 = line_text(&lines[0]);
13479        assert!(
13480            !row0.contains("boom error"),
13481            "mode-off must suppress the eol summary: {row0:?}"
13482        );
13483    }
13484
13485    #[test]
13486    fn diagnostic_underline_modifier_applied_to_overlap_range() {
13487        let mut app = app_with("hello world\n", 3);
13488        // Underline cols 6..11 ("world") with an error.
13489        seed_diagnostic(
13490            &mut app,
13491            0,
13492            6,
13493            11,
13494            lattice_lsp::DiagnosticSeverity::ERROR,
13495            "err",
13496        );
13497        let lines = compose_visible_lines(&app, &app.ad().snapshot.clone(), 3, 80);
13498        // Walk every span on row 0; at least one span covering
13499        // bytes 6..11 must have UNDERLINED set.
13500        let mut found_underline = false;
13501        for span in &lines[0].spans {
13502            if span.style.add_modifier.contains(Modifier::UNDERLINED)
13503                || span.style.sub_modifier.is_empty()
13504                    && span.style.add_modifier.contains(Modifier::UNDERLINED)
13505            {
13506                found_underline = true;
13507                break;
13508            }
13509        }
13510        assert!(
13511            found_underline,
13512            "expected an UNDERLINED modifier somewhere in the row's spans: {:?}",
13513            lines[0]
13514        );
13515    }
13516
13517    /// Pins the rendering-breakage fix: when a diagnostic
13518    /// underlines a range on the diagnostic's line, no span on
13519    /// that line OR on subsequent lines may carry an explicit
13520    /// `underline_color`. Setting `underline_color` emits the
13521    /// SGR 58/59 extension codes; in terminals that don't
13522    /// recognise them, the parameters bleed into following
13523    /// SGR state and pin the foreground colour on subsequent
13524    /// lines (visible as "the next several lines went black").
13525    /// See `apply_underline_overlay`'s docstring for the full
13526    /// trail of evidence.
13527    #[test]
13528    fn diagnostic_underline_does_not_set_underline_color() {
13529        let mut app = app_with("first line\nsecond line\nthird line\n", 5);
13530        seed_diagnostic(
13531            &mut app,
13532            0,
13533            0,
13534            "first line".len() as u32,
13535            lattice_lsp::DiagnosticSeverity::WARNING,
13536            "unused",
13537        );
13538        let lines = compose_visible_lines(&app, &app.ad().snapshot.clone(), 5, 80);
13539        for (row, line) in lines.iter().enumerate() {
13540            for (i, span) in line.spans.iter().enumerate() {
13541                assert!(
13542                    span.style.underline_color.is_none(),
13543                    "row {row} span {i} ({:?}) carries underline_color {:?}; \
13544                     this leaks SGR 58/59 into terminals that don't support \
13545                     it and breaks rendering on subsequent lines",
13546                    span.content,
13547                    span.style.underline_color,
13548                );
13549            }
13550        }
13551    }
13552
13553    /// Slice 3c.extension.fold-rs.test: regression scaffold.
13554    ///
13555    /// Catches the class of bug that fold-rs fixed: per-frame paint
13556    /// paths reaching the actor mailbox via `read_editor` /
13557    /// `mutate_editor`. The previous regression
13558    /// (`frame_120_lines/200` going from 90µs to 43.73ms because
13559    /// 120 per-line `read_editor` calls crept into
13560    /// `compose_visible_lines_inner`) would have been caught here.
13561    ///
13562    /// **Why the bound is 0**: every per-frame paint read must go
13563    /// through wait-free RS accessors (`ad()`, `panes()`, `popup()`,
13564    /// `modes()`, `buffer_locals()`, `render_state.load().X`). The
13565    /// `App::{read,mutate,mutate_with}_editor` seam is for cold-
13566    /// path App helpers (LSP autopilots, picker accept tails, ex-
13567    /// command bodies), never the paint loop.
13568    ///
13569    /// If a future change adds a `read_editor` call inside
13570    /// `compose_visible_lines` or any of its callees, this test
13571    /// flips red — the fix is to either lift the read to RS or
13572    /// route through a FrameView-cached value.
13573    #[test]
13574    fn compose_visible_lines_makes_zero_actor_calls() {
13575        let app = app_with("a\nb\nc\nd\ne\nf\ng\nh\ni\nj\n", 10);
13576        let snap = app.ad().snapshot.clone();
13577        // Warm any one-time setup the test fixture itself does
13578        // (theme construction, FrameView's first publish read,
13579        // tree-sitter seeding) so the snapshot we take just
13580        // before `compose_visible_lines` reflects steady state.
13581        let _warmup = compose_visible_lines(&app, &snap, 10, 80);
13582        let before = crate::actor_call_counter::snapshot();
13583        let _lines = compose_visible_lines(&app, &snap, 10, 80);
13584        let after = crate::actor_call_counter::snapshot();
13585        let delta = after - before;
13586        assert_eq!(
13587            delta, 0,
13588            "compose_visible_lines made {delta} actor-seam calls; \
13589             paint paths must read RS, not route through \
13590             read_editor / mutate_editor. See slice \
13591             3c.extension.fold-rs for the migration recipe.",
13592        );
13593    }
13594
13595    /// The unfocused paint path must be as actor-free as the focused one.
13596    ///
13597    /// The guard above only ever watched `compose_visible_lines` — the path
13598    /// that was already clean, because it read the hot-path `option_cache`.
13599    /// Every OTHER pane went through `FrameView::for_buffer`, whose four
13600    /// option reads called `App::resolved_option` → `read_editor`: four
13601    /// actor-seam calls per unfocused pane per frame, in a split, forever,
13602    /// with nothing watching. Unifying the two painters is what surfaced it,
13603    /// and this is what stops it coming back on the side nobody was looking
13604    /// at.
13605    #[test]
13606    fn an_unfocused_panes_compose_inputs_make_zero_actor_calls() {
13607        let app = app_with("a\nb\nc\nd\ne\nf\ng\nh\ni\nj\n", 10);
13608        let pane = *app.panes().tree.active();
13609        let _warmup = pane_compose_inputs(&app, &pane, false);
13610        let before = crate::actor_call_counter::snapshot();
13611        let _inputs = pane_compose_inputs(&app, &pane, false);
13612        let after = crate::actor_call_counter::snapshot();
13613        let delta = after - before;
13614        assert_eq!(
13615            delta, 0,
13616            "an unfocused pane's compose inputs made {delta} actor-seam calls; \
13617             per-frame paint reads resolve through the published RS snapshot \
13618             (`RenderState::resolved_option_for`), never `read_editor`.",
13619        );
13620    }
13621
13622    /// A pane's VIEW options do not depend on whether it is focused.
13623    ///
13624    /// `is_active` may change interaction state — whose cursor and scroll are
13625    /// read, whether a cursorline paints. It must not change the gutter, the
13626    /// wrap, the sign columns or the fold gates, which are properties of the
13627    /// buffer being painted.
13628    ///
13629    /// **What this does NOT prove**, stated because the name would otherwise
13630    /// promise it: the fixture's pane IS the active document, so the two
13631    /// option sources agree here and this test still passes against the old
13632    /// two-painter split (checked, by reintroducing it). Making them disagree
13633    /// needs a buffer whose MODE overrides an option — magit's
13634    /// `Number = false` — and that reproduction lives where it belongs, in
13635    /// `magit_bindings::navigating_to_a_magit_pane_does_not_give_it_the_files_gutter`.
13636    ///
13637    /// What this one guards is the shape: one builder, agreeing with itself,
13638    /// so a future option added to only one branch of `is_active` fails here
13639    /// rather than in a split three months later.
13640    #[test]
13641    fn a_panes_view_options_do_not_depend_on_whether_it_is_focused() {
13642        let mut app = app_with("hello\nworld\n", 5);
13643        // Non-vacuous: pick values that differ from the defaults, so an
13644        // implementation that ignored the buffer entirely would still have to
13645        // agree with itself on something wrong — and the assertions below
13646        // check the VALUES, not just that the two paths match.
13647        set_opt(&mut app, "number=false");
13648        set_opt(&mut app, "signcolumn=no");
13649        set_opt(&mut app, "wrap=true");
13650        let pane = *app.panes().tree.active();
13651
13652        let (focused, focused_ctx) = pane_compose_inputs(&app, &pane, true);
13653        let (blurred, blurred_ctx) = pane_compose_inputs(&app, &pane, false);
13654
13655        assert!(!focused.show_line_numbers, "the option actually took");
13656        assert_eq!(focused.show_line_numbers, blurred.show_line_numbers);
13657        assert_eq!(focused.sign_column, blurred.sign_column);
13658        assert!(!focused.sign_column);
13659        assert_eq!(focused.wrap_lines, blurred.wrap_lines);
13660        assert!(focused.wrap_lines);
13661        assert_eq!(focused.relative_line_numbers, blurred.relative_line_numbers);
13662        assert_eq!(focused.foldenable, blurred.foldenable);
13663        // …and both describe the pane's own buffer, not the active document.
13664        assert_eq!(focused_ctx.buffer_id, pane.buffer_id);
13665        assert_eq!(blurred_ctx.buffer_id, pane.buffer_id);
13666        // The one thing `is_active` is still allowed to decide.
13667        assert!(focused_ctx.is_active && !blurred_ctx.is_active);
13668    }
13669
13670    // Slice 3c.final.X.cleanup: modeline-text builder must read
13671
13672    /// Slice 3c.final.X.cleanup: pane-status text builder also
13673    /// counts as a per-frame paint path (drawn once per pane per
13674    /// frame in horizontal-split layouts).
13675    #[test]
13676    fn pane_status_label_makes_zero_actor_calls() {
13677        let app = app_with("a\nb\nc\nd\ne\n", 10);
13678        let pane = *app.panes().tree.active();
13679        let _warmup = app.pane_status_label(&pane);
13680        let before = crate::actor_call_counter::snapshot();
13681        let _label = app.pane_status_label(&pane);
13682        let after = crate::actor_call_counter::snapshot();
13683        let delta = after - before;
13684        assert_eq!(
13685            delta, 0,
13686            "pane_status_label made {delta} actor-seam calls; \
13687             status-line paths must read RS, not route through \
13688             read_editor.",
13689        );
13690    }
13691
13692    /// Slice 3c.final.X.cleanup → I.7: `App::apply(Action::None)` is the
13693    /// keystroke entry point's minimum-work path. It MUST go
13694    /// through the actor seam — the dispatch itself is a mutation —
13695    /// but extra RPCs there stack up at typing rate. This test
13696    /// caps the count so future additions surface as a red bar.
13697    ///
13698    /// Pre-I.7 baseline was 5 RPCs per `Action::None` keystroke
13699    /// (`dispatch` + a four-op tail each its own `mutate_editor*`
13700    /// crossing). Slice I.7 fused the deterministic tail into the
13701    /// dispatch round-trip via [`Editor::dispatch_fused`]: when the
13702    /// dispatch produces no renderer-coupled work and no popup is up
13703    /// (the hot typing path — `Action::None` qualifies),
13704    /// `ensure_cursor_visible` / `maybe_reparse_syntax` /
13705    /// `sync_keymap_overlays` / `run_tick_pending` all run IN-actor in
13706    /// the same crossing.
13707    ///
13708    /// Post-I.7 baseline: **1** RPC per `Action::None` keystroke —
13709    /// `mutate_editor_with(|e| e.dispatch_fused(..))`, the single
13710    /// unavoidable command-dispatch into the actor. Everything else on
13711    /// the apply path (`ad()`, `cursor()`, `popup()`, the tick-signal
13712    /// fan-out) is a wait-free published read. On WSL2 ~0.5ms/crossing,
13713    /// so this is the ~6× felt-latency drop I.7 set out to deliver.
13714    ///
13715    /// Bound: ≤ 2 gives one slot of headroom. If a change pushes it
13716    /// past 2 the right move is to lift the new read to RS (or fold the
13717    /// new mutation into `dispatch_fused`'s in-actor tail), NOT to raise
13718    /// the bound. See docs/dev/operations/slice-plans/input-latency.md
13719    /// § I.7.
13720    #[test]
13721    fn apply_noop_action_makes_bounded_actor_calls() {
13722        let mut app = app_with("a\nb\n", 10);
13723        // Warmup — first apply may pay one-time setup.
13724        app.apply(lattice_host::action::Action::None);
13725        let before = crate::actor_call_counter::snapshot();
13726        app.apply(lattice_host::action::Action::None);
13727        let after = crate::actor_call_counter::snapshot();
13728        let delta = after - before;
13729        assert!(
13730            delta <= 2,
13731            "App::apply(Action::None) made {delta} actor-seam calls; \
13732             keystroke entry path must stay <= 2 (baseline 1 since I.7). \
13733             If you need to raise this you've added a per-keystroke \
13734             RPC — consider RS-lifting first, or fold the mutation into \
13735             Editor::dispatch_fused's in-actor tail. See slice I.7 \
13736             (docs/dev/operations/slice-plans/input-latency.md).",
13737        );
13738    }
13739
13740    // --- ML.1a-render: zone layout + truncation + built-in parity -----
13741
13742    #[test]
13743    fn truncate_to_adds_ellipsis_on_overflow() {
13744        assert_eq!(truncate_to("hello", 10), "hello");
13745        assert_eq!(truncate_to("hello", 5), "hello");
13746        assert_eq!(truncate_to("hello", 4), "hel…");
13747        assert_eq!(truncate_to("hello", 1), "…");
13748        assert_eq!(truncate_to("hello", 0), "");
13749    }
13750
13751    /// A role-less run, for layout tests where the role is irrelevant.
13752    fn run(text: &str) -> ModelineSeg {
13753        ModelineSeg::filler(text.to_string())
13754    }
13755    /// ML.4: a run carrying a click target.
13756    fn clickable(text: &str, action: u64) -> ModelineSeg {
13757        ModelineSeg {
13758            text: text.to_string(),
13759            role: None,
13760            click: Some(lattice_protocol::CommandId(action)),
13761        }
13762    }
13763    /// Concatenated text of a composed segment list.
13764    fn seg_text(segs: &[ModelineSeg]) -> String {
13765        segs.iter().map(|s| s.text.as_str()).collect()
13766    }
13767
13768    #[test]
13769    fn compose_row_places_left_and_right_at_edges() {
13770        let row = seg_text(&compose_modeline_segments(
13771            20,
13772            0,
13773            vec![run("L")],
13774            vec![],
13775            vec![run("R")],
13776        ));
13777        assert_eq!(row.chars().count(), 20, "row fills width");
13778        assert!(row.starts_with('L'), "left flush-left: {row:?}");
13779        assert!(row.ends_with('R'), "right flush-right: {row:?}");
13780    }
13781
13782    #[test]
13783    fn compose_row_applies_edge_padding() {
13784        // padding 2 ⇒ a 2-col blank margin at each edge; content fills
13785        // the inner width; total still exactly `width`.
13786        let row = seg_text(&compose_modeline_segments(
13787            20,
13788            2,
13789            vec![run("L")],
13790            vec![],
13791            vec![run("R")],
13792        ));
13793        assert_eq!(row.chars().count(), 20, "row still fills width");
13794        assert!(row.starts_with("  L"), "2-col left margin: {row:?}");
13795        assert!(row.ends_with("R  "), "2-col right margin: {row:?}");
13796    }
13797
13798    // --- ML.4: click regions ------------------------------------------
13799    //
13800    // The property these pin is that the recorded regions agree with
13801    // where the runs were painted. They are asserted against the SAME
13802    // composed list the painter consumes, so a layout change that moved
13803    // an element without moving its click target would fail here.
13804
13805    use lattice_host::modeline::ModelineHitMap;
13806
13807    fn hits_for(area: Rect, segs: &[ModelineSeg]) -> ModelineHitMap {
13808        let mut map = ModelineHitMap::new();
13809        record_modeline_hits(&mut map, area, segs);
13810        map
13811    }
13812
13813    #[test]
13814    fn a_clickable_run_records_exactly_the_columns_it_paints() {
13815        let area = Rect::new(0, 23, 20, 1);
13816        let segs = compose_modeline_segments(
13817            20,
13818            0,
13819            vec![run("ab"), clickable("CLICK", 7)],
13820            vec![],
13821            vec![],
13822        );
13823        let hits = hits_for(area, &segs);
13824        // "ab" occupies 0..2, so the clickable run is 2..7.
13825        assert_eq!(hits.hit(1, 23), None, "the run before it is not clickable");
13826        assert_eq!(hits.hit(2, 23), Some(lattice_protocol::CommandId(7)));
13827        assert_eq!(hits.hit(6, 23), Some(lattice_protocol::CommandId(7)));
13828        assert_eq!(hits.hit(7, 23), None, "one past the last painted column");
13829    }
13830
13831    #[test]
13832    fn padding_shifts_the_recorded_region_with_the_paint() {
13833        // The regression this guards: recording from the zone lists
13834        // rather than the composed row would put the region two
13835        // columns left of the glyphs under `ui.modeline.padding=2`.
13836        let area = Rect::new(0, 5, 20, 1);
13837        let segs = compose_modeline_segments(20, 2, vec![clickable("X", 3)], vec![], vec![]);
13838        let hits = hits_for(area, &segs);
13839        assert_eq!(hits.hit(1, 5), None, "inside the left margin");
13840        assert_eq!(hits.hit(2, 5), Some(lattice_protocol::CommandId(3)));
13841        assert_eq!(hits.hit(3, 5), None);
13842    }
13843
13844    #[test]
13845    fn a_pane_offset_by_a_split_records_absolute_columns() {
13846        // A right-hand pane in a vertical split: its modeline starts at
13847        // column 40, so its regions must too.
13848        let area = Rect::new(40, 23, 20, 1);
13849        let segs = compose_modeline_segments(20, 0, vec![clickable("X", 9)], vec![], vec![]);
13850        let hits = hits_for(area, &segs);
13851        assert_eq!(hits.hit(0, 23), None, "the other pane's columns");
13852        assert_eq!(hits.hit(40, 23), Some(lattice_protocol::CommandId(9)));
13853    }
13854
13855    #[test]
13856    fn an_ellipsised_run_stays_clickable() {
13857        // Half-visible is still that element. If clickability depended
13858        // on pane width the modeline would appear to randomly stop
13859        // working as the user resizes.
13860        let segs = truncate_runs(vec![clickable("verylongelement", 4)], 5);
13861        assert_eq!(segs.len(), 1);
13862        assert_eq!(segs[0].text, "very…");
13863        assert_eq!(segs[0].click, Some(lattice_protocol::CommandId(4)));
13864
13865        let hits = hits_for(Rect::new(0, 1, 10, 1), &segs);
13866        assert_eq!(hits.hit(4, 1), Some(lattice_protocol::CommandId(4)));
13867    }
13868
13869    #[test]
13870    fn separators_and_padding_are_not_clickable() {
13871        let segs = compose_modeline_segments(
13872            12,
13873            0,
13874            vec![clickable("A", 1)],
13875            vec![],
13876            vec![clickable("B", 2)],
13877        );
13878        let hits = hits_for(Rect::new(0, 1, 12, 1), &segs);
13879        assert_eq!(hits.hit(0, 1), Some(lattice_protocol::CommandId(1)));
13880        assert_eq!(hits.hit(11, 1), Some(lattice_protocol::CommandId(2)));
13881        for col in 1..11 {
13882            assert_eq!(hits.hit(col, 1), None, "filler at column {col} is dead");
13883        }
13884    }
13885
13886    #[test]
13887    fn an_element_without_on_click_records_nothing() {
13888        let segs = compose_modeline_segments(10, 0, vec![run("plain")], vec![], vec![]);
13889        let hits = hits_for(Rect::new(0, 1, 10, 1), &segs);
13890        assert!(
13891            hits.is_empty(),
13892            "the overwhelmingly common case must cost no regions"
13893        );
13894    }
13895
13896    #[test]
13897    fn every_run_of_a_multi_span_element_shares_one_click_target() {
13898        // An icon plus its label is one element to the user; clicking
13899        // either half must fire the same command.
13900        let content = lattice_mode::ElementContent {
13901            spans: vec![
13902                lattice_mode::Span {
13903                    text: "\u{f05a} ".into(),
13904                    role: lattice_mode::ModelineRole::new("x"),
13905                },
13906                lattice_mode::Span {
13907                    text: "label".into(),
13908                    role: lattice_mode::ModelineRole::new("x"),
13909                },
13910            ],
13911        };
13912        let runs = content_to_runs(content, Some(lattice_protocol::CommandId(5)));
13913        assert_eq!(runs.len(), 2);
13914        assert!(
13915            runs.iter()
13916                .all(|r| r.click == Some(lattice_protocol::CommandId(5)))
13917        );
13918    }
13919
13920    #[test]
13921    fn compose_row_centers_center_block() {
13922        // width 11, "mid" (3) ⇒ offset (11-3)/2 = 4.
13923        let row = seg_text(&compose_modeline_segments(
13924            11,
13925            0,
13926            vec![],
13927            vec![run("mid")],
13928            vec![],
13929        ));
13930        assert_eq!(row, "    mid    ");
13931    }
13932
13933    #[test]
13934    fn compose_row_width_zero_is_empty() {
13935        assert!(
13936            compose_modeline_segments(0, 0, vec![run("L")], vec![run("C")], vec![run("R")])
13937                .is_empty()
13938        );
13939    }
13940
13941    #[test]
13942    fn compose_row_overflow_sacrifices_center_then_right_then_left() {
13943        // width 12: left(8) kept; sep(1); right budget 3 ⇒ "RI…";
13944        // center budget saturates to 0 ⇒ dropped first.
13945        let row = seg_text(&compose_modeline_segments(
13946            12,
13947            0,
13948            vec![run("LEFTLEFT")],
13949            vec![run("CENTER")],
13950            vec![run("RIGHTRIGHT")],
13951        ));
13952        assert_eq!(row.chars().count(), 12, "row fills width");
13953        assert!(row.starts_with("LEFTLEFT"), "left preserved last: {row:?}");
13954        assert!(!row.contains("CENTER"), "center sacrificed first: {row:?}");
13955        assert!(row.contains('…'), "right truncated with ellipsis: {row:?}");
13956        // Extreme narrow width must not panic.
13957        let _ = compose_modeline_segments(
13958            1,
13959            0,
13960            vec![run("LEFTLEFT")],
13961            vec![run("CENTER")],
13962            vec![run("RIGHTRIGHT")],
13963        );
13964    }
13965
13966    /// ML.1b: active-pane spans carry per-role foregrounds composed over
13967    /// the active bar background (the `NOR` mode span is blue + bold
13968    /// on the surface1 bar in the default theme).
13969    #[test]
13970    fn modeline_active_spans_carry_per_role_styles() {
13971        use ratatui::style::{Color, Modifier};
13972        let app = app_with("hello", 10);
13973        let pane = *app.panes().tree.active();
13974        let spans = segments_to_spans(&app, &modeline_segments(&app, &pane, true, 60), true);
13975
13976        // ML.5d: lean 3-letter tag (no brackets).
13977        let mode = spans
13978            .iter()
13979            .find(|s| s.content.contains("NOR"))
13980            .expect("mode span present");
13981        assert_eq!(
13982            mode.style.fg,
13983            Some(Color::Rgb(0x89, 0xb4, 0xfa)),
13984            "mode fg = blue"
13985        );
13986        assert!(
13987            mode.style.add_modifier.contains(Modifier::BOLD),
13988            "mode is bold"
13989        );
13990        // Every span (segments + padding) sits on the active bar bg.
13991        assert!(
13992            spans
13993                .iter()
13994                .all(|s| s.style.bg == Some(Color::Rgb(0x45, 0x47, 0x5a))),
13995            "whole active row sits on the surface1 bar"
13996        );
13997    }
13998
13999    /// ML.1b: inactive-pane spans are uniformly muted (the single
14000    /// `modeline.inactive` bar style — no per-role colour).
14001    #[test]
14002    fn modeline_inactive_spans_are_uniformly_muted() {
14003        let app = app_with("hello", 10);
14004        let pane = *app.panes().tree.active();
14005        let spans = segments_to_spans(&app, &modeline_segments(&app, &pane, false, 60), false);
14006        let muted = app.theme.modeline_inactive;
14007        assert!(!spans.is_empty());
14008        assert!(
14009            spans.iter().all(|s| s.style == muted),
14010            "inactive row is uniformly muted (no per-role fg)"
14011        );
14012    }
14013
14014    /// Render `draw_pane_status_line` to a `TestBackend` and read the
14015    /// one-row footer as a string.
14016    fn render_status_row(
14017        app: &App,
14018        pane: &crate::pane::PaneState,
14019        is_active: bool,
14020        width: u16,
14021    ) -> String {
14022        use ratatui::Terminal;
14023        use ratatui::backend::TestBackend;
14024        let mut terminal = Terminal::new(TestBackend::new(width, 1)).unwrap();
14025        terminal
14026            .draw(|f| {
14027                let area = Rect {
14028                    x: 0,
14029                    y: 0,
14030                    width,
14031                    height: 1,
14032                };
14033                draw_pane_status_line(f, area, app, pane, is_active);
14034            })
14035            .unwrap();
14036        let buf = terminal.backend().buffer().clone();
14037        (0..width)
14038            .map(|x| buf[(x, 0)].symbol().to_string())
14039            .collect()
14040    }
14041
14042    /// Active pane: `core.mode` (`NOR`) + `core.path` in the Left
14043    /// zone, `core.position` right-aligned in the Right zone — the same
14044    /// content the legacy single-string footer showed.
14045    #[test]
14046    fn modeline_active_pane_lays_out_mode_path_position() {
14047        let app = app_with("hello", 10);
14048        let pane = *app.panes().tree.active();
14049        let row = render_status_row(&app, &pane, true, 40);
14050        assert_eq!(row.chars().count(), 40, "row fills pane width: {row:?}");
14051        // ML.5d: lean 3-letter tag (no brackets).
14052        assert!(
14053            row.trim_start().starts_with("NOR"),
14054            "Left zone leads with the modal label: {row:?}"
14055        );
14056        assert!(row.contains("[no name]"), "core.path present: {row:?}");
14057        assert!(
14058            row.trim_end().ends_with("1:0"),
14059            "core.position right-aligned: {row:?}"
14060        );
14061    }
14062
14063    /// Inactive pane: the modal label is omitted (it is a single
14064    /// active-document read), but the path + position still render.
14065    #[test]
14066    fn modeline_inactive_pane_omits_modal_label() {
14067        let app = app_with("hello", 10);
14068        let pane = *app.panes().tree.active();
14069        let row = render_status_row(&app, &pane, false, 40);
14070        assert!(!row.contains("NOR"), "no modal on inactive: {row:?}");
14071        assert!(row.contains("[no name]"), "path still shows: {row:?}");
14072        assert!(
14073            row.trim_end().ends_with("1:0"),
14074            "position still shows: {row:?}"
14075        );
14076    }
14077
14078    /// The footer never panics at a tiny pane width (overflow path).
14079    #[test]
14080    fn modeline_narrow_pane_does_not_panic() {
14081        let app = app_with("hello", 10);
14082        let pane = *app.panes().tree.active();
14083        for w in [1u16, 2, 3, 8] {
14084            let row = render_status_row(&app, &pane, true, w);
14085            assert_eq!(row.chars().count(), w as usize, "row fills width {w}");
14086        }
14087    }
14088
14089    // --- pane separator geometry (draw_pane_separators) ---
14090
14091    use crate::pane::PaneRect;
14092
14093    /// Collect the individual `(col, row)` separator cells that
14094    /// `separator_segments` would paint, sorted for stable asserts.
14095    fn separator_cells(rects: &[(usize, PaneRect)]) -> Vec<(u16, u16)> {
14096        let mut cells: Vec<(u16, u16)> = separator_segments(rects)
14097            .into_iter()
14098            .flat_map(|(col, y0, y1)| (y0..y1).map(move |row| (col, row)))
14099            .collect();
14100        cells.sort_unstable();
14101        cells
14102    }
14103
14104    /// A plain `:vsplit` (two equal side-by-side panes) draws a
14105    /// full-height divider on the left pane's right edge.
14106    #[test]
14107    fn separator_simple_vsplit_full_height() {
14108        // Left: (0,0,5,4)   Right: (5,0,5,4)
14109        let rects = vec![
14110            (
14111                0,
14112                PaneRect {
14113                    x: 0,
14114                    y: 0,
14115                    width: 5,
14116                    height: 4,
14117                },
14118            ),
14119            (
14120                1,
14121                PaneRect {
14122                    x: 5,
14123                    y: 0,
14124                    width: 5,
14125                    height: 4,
14126                },
14127            ),
14128        ];
14129        let cells = separator_cells(&rects);
14130        assert_eq!(cells, vec![(4, 0), (4, 1), (4, 2), (4, 3)]);
14131    }
14132
14133    /// Regression: `:vsplit` then `:split` the LEFT pane. The left
14134    /// column now holds two half-height stacked panes while the right
14135    /// pane is full height. The divider must still be continuous over
14136    /// the full height — the earlier "same band" gate dropped it
14137    /// entirely because no pair shared identical (y, height).
14138    #[test]
14139    fn separator_subsplit_left_column_stays_continuous() {
14140        // Left-top:    (0,0,5,2)
14141        // Left-bottom: (0,2,5,2)
14142        // Right:       (5,0,5,4)
14143        let rects = vec![
14144            (
14145                0,
14146                PaneRect {
14147                    x: 0,
14148                    y: 0,
14149                    width: 5,
14150                    height: 2,
14151                },
14152            ),
14153            (
14154                2,
14155                PaneRect {
14156                    x: 0,
14157                    y: 2,
14158                    width: 5,
14159                    height: 2,
14160                },
14161            ),
14162            (
14163                1,
14164                PaneRect {
14165                    x: 5,
14166                    y: 0,
14167                    width: 5,
14168                    height: 4,
14169                },
14170            ),
14171        ];
14172        let cells = separator_cells(&rects);
14173        // Continuous column at x=4 over every row 0..4.
14174        assert_eq!(cells, vec![(4, 0), (4, 1), (4, 2), (4, 3)]);
14175    }
14176
14177    /// Symmetric case: sub-split the RIGHT column instead. Still one
14178    /// continuous divider on the boundary column.
14179    #[test]
14180    fn separator_subsplit_right_column_stays_continuous() {
14181        // Left:         (0,0,5,4)
14182        // Right-top:    (5,0,5,2)
14183        // Right-bottom: (5,2,5,2)
14184        let rects = vec![
14185            (
14186                0,
14187                PaneRect {
14188                    x: 0,
14189                    y: 0,
14190                    width: 5,
14191                    height: 4,
14192                },
14193            ),
14194            (
14195                1,
14196                PaneRect {
14197                    x: 5,
14198                    y: 0,
14199                    width: 5,
14200                    height: 2,
14201                },
14202            ),
14203            (
14204                2,
14205                PaneRect {
14206                    x: 5,
14207                    y: 2,
14208                    width: 5,
14209                    height: 2,
14210                },
14211            ),
14212        ];
14213        let cells = separator_cells(&rects);
14214        assert_eq!(cells, vec![(4, 0), (4, 1), (4, 2), (4, 3)]);
14215    }
14216
14217    /// A pure horizontal split (stacked panes, same column) has no
14218    /// vertical separator — the per-pane status line divides them.
14219    #[test]
14220    fn separator_horizontal_split_has_no_vertical_divider() {
14221        let rects = vec![
14222            (
14223                0,
14224                PaneRect {
14225                    x: 0,
14226                    y: 0,
14227                    width: 10,
14228                    height: 2,
14229                },
14230            ),
14231            (
14232                1,
14233                PaneRect {
14234                    x: 0,
14235                    y: 2,
14236                    width: 10,
14237                    height: 2,
14238                },
14239            ),
14240        ];
14241        assert!(separator_cells(&rects).is_empty());
14242    }
14243}
14244
14245#[cfg(test)]
14246mod notification_line_tests {
14247    use super::notification_line;
14248    use lattice_notify::{Notification, NotificationId, NotificationLevel};
14249
14250    fn item(level: NotificationLevel, text: &str) -> Notification {
14251        Notification {
14252            id: NotificationId(1),
14253            level,
14254            scope: None,
14255            text: text.into(),
14256            timeout: None,
14257            actions: Vec::new(),
14258        }
14259    }
14260
14261    fn line_text(line: &ratatui::text::Line<'_>) -> String {
14262        line.spans.iter().map(|s| s.content.as_ref()).collect()
14263    }
14264
14265    #[test]
14266    fn a_row_leads_with_its_levels_icon() {
14267        let mut theme = crate::theme::Theme::default();
14268        for nerd in [false, true] {
14269            theme.nerd_fonts = nerd;
14270            let line = notification_line(&item(NotificationLevel::Success, "pushed"), &theme, 40);
14271            let text = line_text(&line);
14272            assert!(
14273                text.starts_with(NotificationLevel::Success.glyph(nerd)),
14274                "nerd_fonts={nerd}: {text:?}"
14275            );
14276            assert!(text.trim_end().ends_with("pushed"), "{text:?}");
14277        }
14278    }
14279
14280    /// Success must not look like info — that is why the level exists.
14281    #[test]
14282    fn success_is_coloured_apart_from_info() {
14283        let theme = crate::theme::Theme::default();
14284        let style = |level| notification_line(&item(level, "x"), &theme, 40).spans[0].style;
14285        assert_ne!(
14286            style(NotificationLevel::Success),
14287            style(NotificationLevel::Info)
14288        );
14289        assert_eq!(
14290            style(NotificationLevel::Success),
14291            theme.diff_add_sign_style,
14292            "success reads the same element GPUI does"
14293        );
14294    }
14295
14296    /// NC.2: the scope leads the text, bold, so two repositories'
14297    /// notifications cannot read the same.
14298    #[test]
14299    fn the_scope_is_its_own_bold_span_before_the_text() {
14300        let theme = crate::theme::Theme::default();
14301        let mut n = item(NotificationLevel::Success, "push main");
14302        n.scope = Some("lattice".into());
14303        let line = notification_line(&n, &theme, 60);
14304        let text = line_text(&line);
14305        let scope_at = text.find("lattice").unwrap();
14306        assert!(scope_at < text.find("push main").unwrap(), "{text:?}");
14307        let scope_span = line
14308            .spans
14309            .iter()
14310            .find(|s| s.content.contains("lattice"))
14311            .unwrap();
14312        assert!(
14313            scope_span
14314                .style
14315                .add_modifier
14316                .contains(ratatui::style::Modifier::BOLD)
14317        );
14318    }
14319
14320    /// The icon costs width; the text is clipped to what is left, so a
14321    /// long message cannot push past the box.
14322    #[test]
14323    fn the_text_is_clipped_to_the_width_left_after_the_icon() {
14324        let theme = crate::theme::Theme::default();
14325        let line = notification_line(&item(NotificationLevel::Info, &"a".repeat(80)), &theme, 20);
14326        assert!(line_text(&line).chars().count() <= 20);
14327    }
14328}
14329
14330#[cfg(test)]
14331mod transient_palette_tests {
14332    /// **Neither renderer may hard-code a transient colour.**
14333    ///
14334    /// The TUI used fixed ANSI constants (`Cyan` border, `Yellow` keys,
14335    /// `DarkGray` descriptions, `Green` flags) and GPUI borrowed
14336    /// `popup_border` / `cursor_background` for five roles — which left
14337    /// GPUI painting **keys and flags in the same colour** and
14338    /// descriptions in the border tone, so a row read as
14339    /// undifferentiated text where the TUI showed three columns.
14340    ///
14341    /// The two symptoms had one cause: no role was NAMED, so each peer
14342    /// invented its own answer and they drifted. Asserted against the
14343    /// source of both files because the defect is a literal in a paint
14344    /// call — visible in the text, and not reachable by any test that
14345    /// can run without a terminal or a GPU window.
14346    #[test]
14347    fn neither_renderer_hard_codes_a_transient_colour() {
14348        // (file, source, the span that holds its transient painting)
14349        let tui = include_str!("render.rs");
14350        let gpui_src = include_str!("../../lattice-ui-gpui/src/window.rs");
14351
14352        // Each transient function's OWN body, not one span covering
14353        // all of them: `draw_notifications` sits between them in this
14354        // file and legitimately maps severities to fixed colours, so a
14355        // span-based check reports it and teaches the reader to ignore
14356        // the guard.
14357        //
14358        // Comments are stripped first. A comment recording what a
14359        // colour USED to be is exactly the context worth keeping —
14360        // "it was `Color::Cyan` here" explains the fix to the next
14361        // reader — and a guard that reads prose as code punishes
14362        // writing it down, which is the wrong incentive for a rule
14363        // whose whole purpose is that the reasoning survives.
14364        fn body(src: &str, name: &str) -> String {
14365            let start = src
14366                .find(&format!("fn {name}"))
14367                .unwrap_or_else(|| panic!("`{name}` not found — renamed?"));
14368            let end = src[start + 4..]
14369                .find("\nfn ")
14370                .map(|o| start + 4 + o)
14371                .unwrap_or(src.len());
14372            src[start..end]
14373                .lines()
14374                .map(|l| match l.find("//") {
14375                    Some(i) => &l[..i],
14376                    None => l,
14377                })
14378                .collect::<Vec<_>>()
14379                .join("\n")
14380        }
14381        for name in [
14382            "transient_group_item_lines",
14383            "draw_transient_overlay",
14384            "draw_transient_minibuffer_prompt",
14385            "draw_transient_minibuffer_candidates",
14386        ] {
14387            let span = body(tui, name);
14388            let span = span.as_str();
14389            for banned in [
14390                "Color::Yellow",
14391                "Color::Green",
14392                "Color::Cyan",
14393                "Color::DarkGray",
14394            ] {
14395                assert!(
14396                    !span.contains(banned),
14397                    "`{name}` still hard-codes `{banned}` — `:colorscheme` \
14398                     cannot reach a fixed ANSI constant, so the transient \
14399                     menu stays the one surface a theme does not govern",
14400                );
14401            }
14402        }
14403
14404        let gpui_span = {
14405            let a = gpui_src
14406                .find("fn transient_rows_gpui")
14407                .expect("GPUI row builder");
14408            let b = gpui_src[a..]
14409                .find("fn build_transient_minibuffer_gpui")
14410                .map(|o| a + o)
14411                .expect("end of the transient builders");
14412            &gpui_src[a..b]
14413        };
14414        for borrowed in ["theme.popup_border", "theme.cursor_background"] {
14415            assert!(
14416                !gpui_span.contains(borrowed),
14417                "the GPUI transient painter still borrows `{borrowed}`. That \
14418                 is what made keys and flags the same colour: one field \
14419                 standing in for several roles cannot distinguish them.",
14420            );
14421        }
14422    }
14423}
14424
14425#[cfg(test)]
14426mod searching_from_a_focused_popup_tests {
14427    //! The reported sequence, at the pixel level: focus a cursor-anchored
14428    //! popup, press `/`, and the popup must neither move nor take the caret.
14429    //!
14430    //! These assert on a rendered frame rather than on state, because both
14431    //! symptoms ARE the frame: "the popup jumps up near the top" is its
14432    //! `Rect`, and "the cursor changes shape" is which surface owns the
14433    //! terminal's one caret. A state-level test would have passed on both.
14434
14435    use super::*;
14436    use crate::app::App;
14437    use lattice_core::Document;
14438    use ratatui::Terminal;
14439    use ratatui::backend::TestBackend;
14440
14441    /// A document long enough that "anchored at the caret" and "anchored at
14442    /// the top" are different rows — with the caret on line 0 the bug is
14443    /// invisible.
14444    fn app_with_popup_at_line(line: u32) -> App {
14445        let text = (0..20)
14446            .map(|n| format!("line {n} of the document\n"))
14447            .collect::<String>();
14448        let mut a = App::new(Document::from_text(&text));
14449        a.set_viewport_height(20);
14450        a.mutate_editor(move |e| {
14451            e.cursor.line = line;
14452            let content = lattice_help::parse_help_lines(
14453                "hover",
14454                vec!["fn thing() -> u32".to_string(), "the docs".to_string()],
14455            );
14456            let _ = e.open_floating_popup(content, crate::popup::PopupPlacement::CursorAnchored);
14457            e.focus_help_popup();
14458            e.publish_render_state();
14459        });
14460        a
14461    }
14462
14463    fn popup_rect(a: &App, w: u16, h: u16) -> Rect {
14464        let snap = a.ad().snapshot.clone();
14465        // The same area the frame gives the buffer region: everything above
14466        // the one-row minibuffer.
14467        let buffer_area = Rect {
14468            x: 0,
14469            y: 0,
14470            width: w,
14471            height: h.saturating_sub(1),
14472        };
14473        position_help_popup(a, &snap, buffer_area, 30, 4)
14474    }
14475
14476    /// `/` must not move the popup. Before the fix the anchor came from
14477    /// `ad()`, which a focused search line replaces — cursor `(0, 0)` — so
14478    /// the popup jumped to the top of the pane.
14479    #[test]
14480    fn opening_the_search_line_does_not_move_a_focused_popup() {
14481        let (w, h) = (80u16, 24u16);
14482        let mut a = app_with_popup_at_line(10);
14483        let before = popup_rect(&a, w, h);
14484        assert!(
14485            before.y > 4,
14486            "the fixture must anchor the popup well down the pane, or this \
14487             test cannot tell 'moved to the top' from 'already there': {before:?}"
14488        );
14489
14490        a.mutate_editor(|e| {
14491            e.open_search_line(lattice_grammar::SearchDirection::Forward);
14492            e.publish_render_state();
14493        });
14494        assert_eq!(
14495            popup_rect(&a, w, h),
14496            before,
14497            "the popup is anchored where it was opened; opening a search line \
14498             is not the document's caret moving"
14499        );
14500    }
14501
14502    /// …and the caret belongs to the search line, not the popup.
14503    ///
14504    /// Asserted through the rendered frame's cursor position: the terminal
14505    /// has ONE hardware caret, so "the popup kept it and merely changed
14506    /// shape" and "the search line has it" are the same question.
14507    #[test]
14508    fn the_search_line_owns_the_caret_not_the_popup() {
14509        let (w, h) = (80u16, 24u16);
14510        let mut a = app_with_popup_at_line(10);
14511        a.mutate_editor(|e| {
14512            e.open_search_line(lattice_grammar::SearchDirection::Forward);
14513            e.publish_render_state();
14514        });
14515
14516        let snap = a.ad().snapshot.clone();
14517        let mut terminal = Terminal::new(TestBackend::new(w, h)).unwrap();
14518        // Read back off the terminal after the draw: `Frame::cursor_position`
14519        // is private, and the backend is where the placement actually lands.
14520        terminal
14521            .draw(|f| {
14522                let _ = draw_frame(f, &a, &snap);
14523            })
14524            .unwrap();
14525        let y = terminal
14526            .get_cursor_position()
14527            .expect("something places the caret")
14528            .y;
14529        assert_eq!(
14530            y,
14531            h - 1,
14532            "the caret sits on the minibuffer row, where `/` is drawn — if it \
14533             is anywhere else the popup claimed it, and wearing the Bar shape \
14534             `ModalState::Search` implies it reads as a read-only popup that \
14535             entered Insert"
14536        );
14537    }
14538}