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(¢er);
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(®);
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(®);
12805 reg.set_override(
12806 ElementName::from_static("completion.annotation.perm.write"),
12807 StyleSpec::new().fg("green"),
12808 );
12809 let after = w_fg(®);
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(®);
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(®);
12903 reg.set_override(
12904 ElementName::from_static("syntax.keyword"),
12905 StyleSpec::new().fg("green"),
12906 );
12907 let after = kw_fg(®);
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}