lattice_host/render_state.rs
1//! `RenderState` — the renderer's read contract with the host.
2//!
3//! Phase 5.8.AF.5 / Slice 3a.
4//!
5//! ## Why this exists
6//!
7//! Per paramount goal #4 (CLAUDE.md):
8//!
9//! > Nothing blocks the UI — enforced architecturally, not by
10//! > discipline.
11//!
12//! Renderers must not read `Editor` fields directly during
13//! render — that read path needs the same `&Editor` reference
14//! the dispatcher holds for mutation, which couples render
15//! latency to whatever happens to be mutating `Editor` at the
16//! moment. `RenderState` is the wait-free read seam: dispatch
17//! publishes a fresh snapshot into an `ArcSwap<RenderState>`
18//! at the end of every tick; the renderer loads it once per
19//! frame and reads everything it needs from there.
20//!
21//! This is the substrate for two follow-on slices:
22//!
23//! - **Slice 3b** moves every drain in `run_tick_pending` into
24//! per-subsystem background tasks. Each task writes through
25//! the same publication path; the renderer never sees a
26//! half-built mutation.
27//! - **Slice 3c** moves `Editor` to its own thread. Channels
28//! replace `&mut Editor` references; the renderer becomes a
29//! pure `RenderState` reader.
30//!
31//! Both slices preserve the read contract this slice
32//! establishes — `RenderState` doesn't change shape, just who
33//! produces it and on which thread.
34//!
35//! ## Per-subsystem sub-states
36//!
37//! `RenderState` is split into 11 sub-state structs, one per
38//! UI-visible subsystem. Each is `Arc`-wrapped so a subsystem
39//! whose backing state didn't change between publications can
40//! share its sub-state `Arc` across frames (identity-preserved).
41//! In Slice 3b, subsystem background tasks publish their own
42//! sub-state directly without re-snapshotting unrelated
43//! domains.
44//!
45//! The active-buffer hot-path state (cursor, scroll, viewport,
46//! modal, visual selection, snapshot pointer) lives in its own
47//! [`ActiveDocumentRenderState`] — separate from the buffer registry
48//! ([`BuffersRenderState`]) because the read frequencies differ
49//! by orders of magnitude: the active-buffer state churns on
50//! every motion/edit (per-frame critical), the registry churns
51//! only on `:b` / `:e` / `:bd`. Splitting lets Slice 3b
52//! republish them on independent cadences.
53//!
54//! For Slice 3a only `DiagnosticsRenderState` carries real
55//! data — that's the proof-of-life migration path. The other
56//! sub-states are deliberately empty placeholders so the shape
57//! is in place when their backing fields migrate in later
58//! slices.
59
60use std::sync::Arc;
61
62/// The renderer's read contract with the host. Built fresh by
63/// [`crate::dispatch::Editor::build_render_state`] at the end of
64/// every dispatch tick and stored into the editor's
65/// `ArcSwap<RenderState>`. Renderers load with
66/// `editor.render_state.load_full()` once per frame.
67#[derive(Debug, Clone)]
68pub struct RenderState {
69 /// I.5.1 (2026-06-05): inner `ArcSwap` so a keystroke can
70 /// republish the active-document substate WITHOUT reswapping the
71 /// whole monolith `RenderState` (I.5.3 the keystroke-only publish).
72 /// The outer monolith Arc stays the single shared cell threaded to
73 /// every renderer + worker; this inner cell is what the hot path
74 /// stores into. Readers go through `.active_document.load().load()`.
75 /// Mirrors the established `VirtualRowsRenderState.pane_matrices`
76 /// nested-`ArcSwap` shape.
77 pub active_document: Arc<arc_swap::ArcSwap<ActiveDocumentRenderState>>,
78 pub buffers: Arc<BuffersRenderState>,
79 pub panes: Arc<PanesRenderState>,
80 pub lsp: Arc<LspRenderState>,
81 pub syntax: Arc<SyntaxRenderState>,
82 pub picker: Arc<PickerRenderState>,
83 pub completion: Arc<CompletionRenderState>,
84 pub popup: Arc<PopupRenderState>,
85 /// WK.12: the minibuffer band, published beside the popup rather than
86 /// instead of it — both can be open at once.
87 pub band: Arc<PopupRenderState>,
88 pub messages: Arc<MessagesRenderState>,
89 /// NOTIF.1b: corner-anchored notifications.
90 pub notifications: Arc<NotificationsRenderState>,
91 pub modeline: Arc<ModelineRenderState>,
92 /// ML.0b-2: published snapshot of the configurable-modeline element
93 /// system (descriptors + content, both `Arc`-backed so this clone is
94 /// cheap). The renderers lay out Left/Center/Right zones from this
95 /// (ML.1/ML.2). Distinct from `modeline` above, which is the legacy
96 /// cmdline/search sub-state — different surface, kept separate.
97 pub modeline_elements: lattice_mode::ModelineSnapshot,
98 /// Editor's working directory set by `:cd`. `None` means
99 /// fall back to `std::env::current_dir()`.
100 pub current_dir: Option<std::path::PathBuf>,
101 /// Slice 3c.final.B.10: typed-options registry published as a
102 /// wait-free Arc clone so the renderer's
103 /// `picker_display_is_minibuffer` (and any future per-frame
104 /// typed-option read) doesn't take an actor round-trip.
105 pub options: Arc<OptionsRenderState>,
106 /// PI.4: per-buffer mode-resolved options (renderer-agnostic option
107 /// resolution). Read via [`Self::resolved_option_for`].
108 pub resolved_opts: Arc<ResolvedOptionsRenderState>,
109 /// Slice 3c.final.B.11: active modes per buffer, published as
110 /// `Arc<HashMap<BufferId, Arc<ActiveModes>>>` so per-buffer
111 /// reads in the modeline + future hot paths are wait-free.
112 pub modes: Arc<ModesRenderState>,
113 /// Slice 3c.final.B.9: buffer-locals per buffer, published as
114 /// `Arc<HashMap<BufferId, Arc<BufferLocals>>>` so the
115 /// modeline / help-render / file-tree / oil paint paths read
116 /// without an actor round-trip.
117 pub buffer_locals: Arc<BufferLocalsRenderState>,
118 pub diagnostics: Arc<DiagnosticsRenderState>,
119 /// Issue #29 (2026-05-22): tab pages snapshot. Per-tab
120 /// labels + active idx + the resolved `show`-decision so
121 /// both peers paint the tabline from the same source.
122 pub tabs: Arc<TabsRenderState>,
123 /// Phase 5.8.AF.5 / Slice 3c.final.B (group 5): translator
124 /// inputs — published so the renderer's input loop can build
125 /// a `TranslateContext` from owned snapshots instead of
126 /// `&'a` borrows that tie it to `Editor`'s lifetime.
127 pub translator: Arc<TranslatorRenderState>,
128 /// Phase 5.8.AF.5 / Slice 3c.final.B (group 6): renderer
129 /// lifecycle flags (should_quit, pending_redraw,
130 /// terminal_width). Carries the per-tick "renderer should
131 /// notice this" signals that the main loop reads before
132 /// composing the next frame.
133 pub lifecycle: Arc<LifecycleRenderState>,
134 /// T.4 (theme-system): the resolved theme read table, snapshotted
135 /// from the `ThemeRegistryHandle` at publish. Renderers read
136 /// `resolved_theme.get(theme_ids.<elem>)` — an O(1) array index
137 /// (design §7). T.6.t deleted the flat host `Theme` field that
138 /// used to ride alongside this; all style reads now go through the
139 /// resolved table, and non-style chrome (glyphs, separator chars,
140 /// dim/nerd-fonts flags) through the typed-options registry. The
141 /// TUI rebuilds its ratatui cache from this only when
142 /// `ResolvedTheme::version()` changes (no per-frame adaptation);
143 /// GPUI adapts inline.
144 pub resolved_theme: std::sync::Arc<crate::ui::theme::ResolvedTheme>,
145 /// T.4: builtin element ids (Copy), interned once at boot. Paired
146 /// with [`Self::resolved_theme`] so a read is
147 /// `resolved_theme.get(theme_ids.x)`.
148 pub theme_ids: crate::ui::theme::BuiltinElementIds,
149 /// SG.2b: sign definitions + their pre-resolved theme elements, so a
150 /// `GutterDecoration::Sign` paints without a name lookup on the render
151 /// path. See [`SignsRenderState`].
152 pub signs: Arc<SignsRenderState>,
153 /// S2.1 (2026-05-26): cell-grid renderer substrate state.
154 /// Carries the published `CellMatrix` cell + the inputs the
155 /// cell-builder worker (S2.2+) reads to rebuild. See
156 /// [`CellsRenderState`] and
157 /// `docs/dev/architecture/cell-grid-renderer.md`.
158 ///
159 /// I.5.2: inner `ArcSwap` so the keystroke fast path can
160 /// republish the cells substate WITHOUT reswapping the whole
161 /// monolith (I.5.3). Mirrors [`active_document`](Self::active_document).
162 /// Readers go through `.cells.load()`.
163 pub cells: Arc<arc_swap::ArcSwap<CellsRenderState>>,
164 /// D.3.d.1 (2026-05-29): inline-diff overlay render state.
165 /// Carries the active document's `DiffSignMap` for the
166 /// gutter-sign column. Renderers read via
167 /// `rs.diff.sign_map.sign_at(line_idx)`. Snapshot is taken
168 /// in `build_render_state` from
169 /// `editor.diff_signs_for_active()`; absent ⇒ empty map.
170 pub diff: Arc<DiffRenderState>,
171 /// D.3.b.1 (2026-05-29): virtual-rows render state.
172 /// Carries the published `VirtualRowMatrix` so the
173 /// renderer can interleave deletion-block + multibuffer-
174 /// header rows between document rows. Snapshot is taken
175 /// in `build_render_state` from
176 /// `editor.virtual_rows_matrix_cell.load_full()`.
177 pub virtual_rows: Arc<VirtualRowsRenderState>,
178 /// §12 async-result render-wake — the off-keystroke paint gate.
179 ///
180 /// A content hash over every render-visible surface that is NOT
181 /// owned by the cells / virtual-rows workers (those fire
182 /// `paint_request` on their own content change). The actor's
183 /// off-keystroke arms (`async_landed` / inline-diag) compare this
184 /// against the previously-published value and fire `paint_request`
185 /// only when it moved, so an async arrival that changes the modeline
186 /// badge, a diagnostics overlay, a popup, etc. reaches a frame
187 /// without a keystroke — and a no-op publish does NOT (which also
188 /// keeps the GPUI paint-bridge's `run_tick_pending` → re-publish
189 /// loop from spinning). Stamped by
190 /// [`Editor::compute_paint_revision`](crate::dispatch::Editor::compute_paint_revision)
191 /// in `build_render_state`. The cells `MatrixVersion` axis is the
192 /// dual: it gates cell rebuilds; this gates everything else
193 /// (`lattice-cells/src/version.rs` enumerates the overlay set).
194 pub paint_revision: u64,
195 /// PL8.E: per-buffer WASM gutter-decoration cache. A clone of `Editor`'s
196 /// `wasm_decorations.cache` slot, so an off-render-path producer task's
197 /// writes are observed without republishing this snapshot. Renderers merge
198 /// `get_for(buffer_id).decorations` into the same gutter partition they walk
199 /// for `Mode::gutter_decorations` — the producer never runs at paint time
200 /// (paramount #1). Empty when no decoration plugin is loaded.
201 pub wasm_gutter_decorations:
202 crate::per_buffer_cache::PerBufferCache<crate::wasm_decorations::WasmGutterDecorationCache>,
203 /// CM.3c: per-buffer severity gutter index for the `*compilation*`
204 /// buffer, keyed by [`BufferId`] (mirrors `diff.sign_maps`). Written
205 /// by the `AppEffect::CompilationGutterSet` host arm from the
206 /// off-thread compilation drain; each entry is the buffer's full
207 /// `(line, level)` list. Renderers look up `compilation_severity
208 /// .get(buffer_id)` per pane and inject it into the mode's
209 /// `gutter_decorations` via
210 /// [`lattice_mode::CompilationSeverityData`] — the renderer never
211 /// depends on `lattice-compilation`. Empty when no compilation has
212 /// produced marks. The value `Arc` makes both the publish clone and
213 /// the render-path read O(1).
214 pub compilation_severity: std::sync::Arc<
215 std::collections::HashMap<
216 lattice_core::BufferId,
217 std::sync::Arc<Vec<(u32, lattice_mode::GutterSeverityLevel)>>,
218 >,
219 >,
220 /// CM.3c (2026-07-22): per-buffer compilation location-line index
221 /// for theme-based highlighting of navigable lines in the
222 /// `*compilation*` buffer. Written by the
223 /// `AppEffect::CompilationLocationLines` host arm from the
224 /// off-thread compilation drain; renderers check
225 /// `compilation_location_lines.get(buffer_id)` when painting each
226 /// line and apply the `compilation.location` theme element bg to
227 /// any row whose index appears in the set. Empty when no
228 /// compilation is active or no location lines have been produced.
229 /// The value `Arc` makes both the publish clone and the render-path
230 /// read O(1).
231 pub compilation_location_lines: std::sync::Arc<
232 std::collections::HashMap<lattice_core::BufferId, std::sync::Arc<Vec<(u32, u32, u32)>>>,
233 >,
234 /// MC.3: per-buffer set of source lines inside a fenced/indented code block.
235 /// Snapshot of `Editor::code_block_lines`; renderers paint the full-width
236 /// `syntax.code_block` background on any row whose index is in the set.
237 pub code_block_lines:
238 std::sync::Arc<std::collections::HashMap<lattice_core::BufferId, std::sync::Arc<Vec<u32>>>>,
239 /// CM.3d (2026-07-22): snapshot of `Editor::compilation_theme_colors`.
240 pub compilation_theme_colors: std::sync::Arc<(u32, u32)>,
241}
242
243impl Default for RenderState {
244 fn default() -> Self {
245 Self {
246 active_document: Arc::new(arc_swap::ArcSwap::from_pointee(
247 ActiveDocumentRenderState::default(),
248 )),
249 buffers: Arc::new(BuffersRenderState::default()),
250 panes: Arc::new(PanesRenderState::default()),
251 lsp: Arc::new(LspRenderState::default()),
252 syntax: Arc::new(SyntaxRenderState::default()),
253 picker: Arc::new(PickerRenderState::default()),
254 completion: Arc::new(CompletionRenderState::default()),
255 popup: Arc::new(PopupRenderState::default()),
256 band: Arc::new(PopupRenderState::default()),
257 messages: Arc::new(MessagesRenderState::default()),
258 notifications: Arc::new(NotificationsRenderState::default()),
259 modeline: Arc::new(ModelineRenderState::default()),
260 modeline_elements: lattice_mode::ModelineSnapshot::default(),
261 current_dir: None,
262 options: Arc::new(OptionsRenderState::default()),
263 resolved_opts: Arc::new(ResolvedOptionsRenderState::default()),
264 modes: Arc::new(ModesRenderState::default()),
265 buffer_locals: Arc::new(BufferLocalsRenderState::default()),
266 diagnostics: Arc::new(DiagnosticsRenderState::default()),
267 tabs: Arc::new(TabsRenderState::default()),
268 translator: Arc::new(TranslatorRenderState::default()),
269 lifecycle: Arc::new(LifecycleRenderState::default()),
270 resolved_theme: Arc::new(crate::ui::theme::ResolvedTheme::default()),
271 theme_ids: crate::ui::theme::BuiltinElementIds::default(),
272 signs: Arc::new(SignsRenderState::default()),
273 cells: Arc::new(arc_swap::ArcSwap::from_pointee(CellsRenderState::default())),
274 diff: Arc::new(DiffRenderState::default()),
275 virtual_rows: Arc::new(VirtualRowsRenderState::default()),
276 paint_revision: 0,
277 wasm_gutter_decorations: crate::per_buffer_cache::empty(),
278 compilation_severity: std::sync::Arc::new(std::collections::HashMap::new()),
279 compilation_location_lines: std::sync::Arc::new(std::collections::HashMap::new()),
280 code_block_lines: std::sync::Arc::new(std::collections::HashMap::new()),
281 compilation_theme_colors: std::sync::Arc::new((0x45475a, 0x89b4fa)),
282 }
283 }
284}
285
286impl RenderState {
287 /// Resolve the fold list + `foldenable` that a pane showing
288 /// `buffer_id` must render with.
289 ///
290 /// Folds are **per-buffer** (a buffer's `zf` / `za` / computed +
291 /// overlay folds are shared by every pane showing it — the
292 /// user-confirmed model). The bug this fixes: both renderers
293 /// previously sourced folds for *every* pane from
294 /// `active_document.folds`, so an inactive pane showing a
295 /// *different* buffer rendered with the **active** buffer's
296 /// folds — folding buffer A elided lines in buffer B's pane
297 /// (GPUI), and switching focus away made A's inactive pane drop
298 /// its folds (TUI). Both renderers now call this so the source
299 /// is the pane's own buffer, uniformly (TUI/GPUI parity).
300 ///
301 /// The active buffer reads the live `active_document.folds`
302 /// (freshest — updated synchronously on the fold keystroke);
303 /// any other buffer reads its published per-pane entry in
304 /// `cells.panes` (sourced from its `DocumentFolds` buffer-local).
305 /// A buffer in no pane (no `cells.panes` entry) yields an empty
306 /// list — nothing to elide.
307 /// PI.0: the horizontal-centring pad for a pane rendering `buffer_id`.
308 /// Resolved from THAT buffer's `CenterContentWidth` local + the width
309 /// of the pane showing it — never the active-buffer identity.
310 ///
311 /// Previously the renderers gated centring on `buffer_id ==
312 /// document_buffer_id`, so a picker preview that swapped
313 /// `document_buffer_id` to the previewed file collapsed the dashboard's
314 /// centring to 0 while the pane still showed the dashboard. Reading the
315 /// rendered buffer's own local keeps centring attached to the buffer
316 /// that carries it. Shared by the TUI and GPUI peers.
317 pub fn content_left_pad_for(&self, buffer_id: lattice_core::BufferId) -> u32 {
318 let block_width = self
319 .buffer_locals
320 .map
321 .get(&buffer_id)
322 .and_then(|l| l.get::<crate::modes::CenterContentWidth>())
323 .map(|c| c.0)
324 .unwrap_or(0);
325 if block_width == 0 {
326 return 0;
327 }
328 let tree = &self.panes.tree;
329 let viewport_width = tree
330 .leaves()
331 .iter()
332 .find(|p| p.buffer_id == buffer_id)
333 .map(|p| p.viewport_width)
334 .unwrap_or_else(|| tree.active().viewport_width);
335 viewport_width.saturating_sub(block_width) / 2
336 }
337
338 /// PI.4: resolve option `D` for `buffer_id` from the published
339 /// per-buffer resolved-options snapshot — the renderer-agnostic seam
340 /// both peers use (mirror of `Editor::resolved_option`). Falls back to
341 /// the global typed-option default when the buffer has no cached entry
342 /// (transient publish gap / a buffer resolved lazily). O(1) `TypeId`
343 /// lookup on the `Arc`-shared `ResolvedOptions`.
344 pub fn resolved_option_for<D: lattice_config::OptionDecl>(
345 &self,
346 buffer_id: lattice_core::BufferId,
347 ) -> Arc<D::Value>
348 where
349 D::Value: Clone + Send + Sync + 'static,
350 {
351 if let Some(resolved) = self.resolved_opts.map.get(&buffer_id)
352 && let Some(v) = resolved.get::<D>()
353 {
354 return v;
355 }
356 self.options
357 .config
358 .get_typed::<D>()
359 .expect("option not registered")
360 }
361
362 /// PI.4: whether a pane showing `buffer_id` paints its cursorline
363 /// (`:set cursorline` / `current-line-highlight-mode`), resolved
364 /// per-buffer. Both peers read this for the focused preview pane so the
365 /// previewed buffer keeps its own cursorline (e.g. an LSP-reference /
366 /// grep location preview keeps the target line highlighted).
367 pub fn current_line_highlight_for(&self, buffer_id: lattice_core::BufferId) -> bool {
368 *self.resolved_option_for::<lattice_config::CursorLine>(buffer_id)
369 }
370
371 pub fn folds_for_buffer(
372 &self,
373 buffer_id: lattice_core::BufferId,
374 ) -> (Arc<[lattice_core::Fold]>, bool) {
375 let ad = self.active_document.load();
376 if ad.document_buffer_id == buffer_id {
377 return (ad.folds.clone(), ad.option_cache.foldenable);
378 }
379 let cells = self.cells.load();
380 if let Some(pane) = cells.panes.iter().find(|p| p.buffer_id == buffer_id) {
381 return (pane.folds.clone(), pane.foldenable);
382 }
383 (Arc::from([]), ad.option_cache.foldenable)
384 }
385
386 /// Resolve the byte-baked inlay-hint rows a pane showing
387 /// `buffer_id` must render.
388 ///
389 /// Like [`Self::folds_for_buffer`], this gives every pane its OWN
390 /// buffer's decoration so active and inactive panes render through
391 /// ONE code path (inactive == active, modulo dimming). It replaces
392 /// a duplicated seam where the active pane spliced the baked
393 /// `syntax.inlay_hints` while inactive panes re-derived hints from
394 /// the per-buffer LSP cache with their own utf-16→utf-8 conversion —
395 /// two sources that could drift.
396 ///
397 /// The active buffer reads the canonical baked list
398 /// (`syntax.inlay_hints`); any other buffer reads its published
399 /// `cells.panes` entry (built by `build_inlay_hints_for_buffer`,
400 /// identical gating + byte-baking). A buffer in no pane yields empty.
401 pub fn inlay_hints_for_buffer(&self, buffer_id: lattice_core::BufferId) -> Arc<[InlayHintRow]> {
402 let ad = self.active_document.load();
403 if ad.document_buffer_id == buffer_id {
404 return self.syntax.inlay_hints.clone();
405 }
406 let cells = self.cells.load();
407 if let Some(pane) = cells.panes.iter().find(|p| p.buffer_id == buffer_id) {
408 return pane.inlay_hints.clone();
409 }
410 Arc::from([])
411 }
412}
413
414/// D.3.b.1 (2026-05-29): renderer-side projection of the
415/// virtual-rows worker's published `VirtualRowMatrix`.
416/// Carries the matrix so the TUI / GPUI renderer can
417/// interleave virtual rows between document rows when
418/// painting visible content.
419///
420/// `matrix` defaults to an empty `VirtualRowMatrix` so the
421/// renderer never has to handle an `Option`; an empty matrix
422/// reports no rows for any line.
423#[derive(Clone, Debug)]
424pub struct VirtualRowsRenderState {
425 pub matrix: Arc<lattice_cells::VirtualRowMatrix>,
426 /// D.4.d.2.1.d (2026-05-30): `PaneId → virtual-rows matrix`
427 /// lookup derived from `CellsRenderState::panes` at publish
428 /// time so renderers can find a pane's virtual-rows matrix
429 /// by id without scanning the panes slice. One entry per
430 /// visible Document leaf; non-Document panes are absent
431 /// (the publisher filters them out of
432 /// `CellsRenderState::panes`). Mirror of
433 /// [`CellsRenderState::pane_matrices`].
434 ///
435 /// Use [`Self::matrix_for_pane`] for the read; direct
436 /// access to the map is fine when batching multiple
437 /// lookups.
438 pub pane_matrices: Arc<
439 std::collections::HashMap<
440 lattice_core::ui::pane::PaneId,
441 Arc<arc_swap::ArcSwap<lattice_cells::VirtualRowMatrix>>,
442 >,
443 >,
444}
445
446impl Default for VirtualRowsRenderState {
447 fn default() -> Self {
448 Self {
449 matrix: Arc::new(lattice_cells::VirtualRowMatrix::empty()),
450 pane_matrices: Arc::new(std::collections::HashMap::new()),
451 }
452 }
453}
454
455impl VirtualRowsRenderState {
456 /// D.4.d.2.1.d: look up the virtual-rows matrix cell for
457 /// `pane_id`. Returns `None` when the pane is not a Document
458 /// leaf (file tree / help / messages / oil / terminal panes
459 /// skip the cells path entirely, so they're absent from
460 /// `pane_matrices` — see
461 /// `crate::dispatch::Editor::build_cells_panes`). Mirror of
462 /// [`CellsRenderState::matrix_for_pane`].
463 pub fn matrix_for_pane(
464 &self,
465 pane_id: lattice_core::ui::pane::PaneId,
466 ) -> Option<&Arc<arc_swap::ArcSwap<lattice_cells::VirtualRowMatrix>>> {
467 self.pane_matrices.get(&pane_id)
468 }
469}
470
471/// D.3.d.1 (2026-05-29): renderer-side projection of the
472/// active document's diff overlay state. Carries the
473/// `DiffSignMap` for the gutter-sign column; future D.3.e
474/// (line tints) reads through the same map.
475///
476/// `sign_map` defaults to an empty `Arc<DiffSignMap>` so
477/// renderers never have to handle the `Option` path; an
478/// empty map's `sign_at` is `None` for every line.
479#[derive(Clone, Debug, Default)]
480pub struct DiffRenderState {
481 pub sign_map: Arc<crate::diff::overlay::DiffSignMap>,
482 /// D-fix.3b: per-buffer sign maps for EVERY live diff session, so a
483 /// side-by-side diff tints BOTH panes — each pane reads its own
484 /// buffer's map by `BufferId`. The proposed/current (right) buffer maps
485 /// to the current-side `sign_map()`; the baseline (left) buffer maps to
486 /// the `baseline_sign_map()`. Renderers look up `sign_maps.get(buffer_id)`
487 /// per pane; the legacy single `sign_map` above stays the active-doc map
488 /// for the modeline hunk count.
489 pub sign_maps: Arc<
490 std::collections::HashMap<lattice_core::BufferId, Arc<crate::diff::overlay::DiffSignMap>>,
491 >,
492 /// D.3.g (2026-05-29): hunk count for the active
493 /// document's `DiffSession`, if any. `None` means no
494 /// session is open for the active buffer. `Some(0)` means
495 /// a session is open but the buffer currently matches
496 /// baseline (no hunks). The modeline diff-mode indicator
497 /// reads this to render `[diff: N hunks]` only when a
498 /// session is active.
499 pub active_session_hunk_count: Option<usize>,
500}
501
502/// Active buffer's hot-path render-side projection.
503///
504/// Carries everything the renderer needs to draw the currently-
505/// active buffer regardless of its kind (`Document` / `Help` /
506/// `Oil` / `FileTree`). Per "everything is a buffer" (CLAUDE.md):
507/// the same fields apply uniformly to every kind — the kind
508/// itself is one of the carried fields.
509///
510/// Split out of [`BuffersRenderState`] (which is the *registry*
511/// of all buffers) because read frequencies differ by orders of
512/// magnitude:
513///
514/// - The active-buffer state churns on every motion / edit /
515/// scroll — per-frame critical.
516/// - The registry churns only on `:b N` / `:e <path>` / `:bd`.
517///
518/// Splitting lets Slice 3b republish them independently — a
519/// motion republishes `ActiveDocumentRenderState` without forcing
520/// `BuffersRenderState` to allocate a new Arc.
521///
522/// Phase 5.8.AF.5 / Slice 3c.1: populated. Renderers migrate
523/// their direct `editor.X` reads to this sub-state in Slices
524/// 3c.2 (TUI) + 3c.3 (GPUI); the field set covers every paint-
525/// time hot-path read.
526#[derive(Debug, Clone)]
527pub struct ActiveDocumentRenderState {
528 /// Buffer kind (Document / Help / Oil / FileTree). The
529 /// renderer's paint switch dispatches on this for kind-
530 /// specific overlays (oil row prefix, file-tree decorations,
531 /// help anchors, …).
532 pub buffer_kind: lattice_core::BufferKind,
533 /// BufferId of the currently-active document. For the
534 /// Document kind this equals `active_pane_buffer_id`; for
535 /// help / oil / file-tree the kinds may diverge (a help
536 /// popup sits over a document pane).
537 pub document_buffer_id: lattice_core::BufferId,
538 /// BufferId of the active pane's surface (what the user
539 /// sees in the focused pane). Used by per-pane reads.
540 pub active_pane_buffer_id: lattice_core::BufferId,
541 /// Cursor position (line + byte). Per-frame critical.
542 pub cursor: lattice_protocol::position::Position,
543 /// First visible buffer line. Drives the viewport's top.
544 pub scroll: u32,
545 /// First visible display column (horizontal scroll). Drives the
546 /// body's left clip when `wrap` is off; 0 under wrap.
547 pub leftcol: u32,
548 /// Viewport height in screen-cell rows (active pane's
549 /// content area). Set by the renderer; read back here for
550 /// motions, scroll math, and the gutter.
551 pub viewport_height: u32,
552 /// Modal state (Normal / Insert / Visual / OpPending /
553 /// Command / Search / Replace). Drives cursor shape, the
554 /// modeline label, and gates per-mode paint behavior.
555 pub modal: lattice_grammar::ModalState,
556 /// Visual selection anchor; `None` when not in Visual.
557 pub visual_anchor: Option<lattice_protocol::position::Position>,
558 /// Active document's snapshot pointer (cheap rope `Arc`
559 /// clone). Captured at publication time so the renderer
560 /// holds a per-frame consistent view. Wait-free read for
561 /// downstream consumers (line iteration, byte indexing).
562 pub snapshot: Arc<lattice_runtime::DocumentSnapshot>,
563 /// Pending motion-count accumulator (e.g. `3` in `3dw`).
564 /// Slice 3c.atomic.J: mirrored here so the input translator
565 /// can build its `TranslateContext` from a published snapshot
566 /// instead of reaching through `app.editor.X` per keystroke.
567 pub pending_count: u32,
568 /// Operator-pending count (e.g. `2` in `d2w`). Same
569 /// rationale as `pending_count`.
570 pub op_count: u32,
571 /// `true` while a macro is being recorded (`q<reg>`).
572 /// Used by the translator to gate the `q` rebind and by the
573 /// modeline's recording indicator.
574 pub macro_recording: bool,
575 /// `true` while the insert-completion popup is open.
576 /// Gates insert-mode keystroke translation (Tab cycle,
577 /// CR accept, Esc dismiss).
578 pub completion_open: bool,
579 /// `true` while a picker overlay is open. Gates the
580 /// normal-mode keymap so picker-local keys take precedence.
581 pub picker_open: bool,
582 /// `true` while the `:` line sits on an `ArgKind::Chord` arg slot
583 /// (e.g. the armed `:describe-key ` prompt) — the next keystroke is
584 /// CAPTURED as that chord, not dispatched. Published (computed via
585 /// [`crate::dispatch::Editor::chord_capture_active`]) so the actor-based
586 /// GPUI peer can read it the same way the TUI App computes it live;
587 /// drives `TranslateContext::chord_capture`.
588 pub chord_capture: bool,
589 /// `true` while a snippet's tab-stop chain is active.
590 /// Gates Tab / S-Tab to drive `next_tabstop` / `prev_tabstop`
591 /// instead of falling back to insert-completion / outdent.
592 pub snippet_active: bool,
593 /// PU refactor (2026-07-22): `true` when a Steal popup has
594 /// keyboard focus (State B). Replaces the architectural leak
595 /// where `active_buffer == BufferKind::Help` doubled as a
596 /// focus-state canary. Set/cleared in `Editor`'s popup-
597 /// mutation methods; published here so TUI and GPUI renderers
598 /// read `popup_focused` directly instead of re-deriving it
599 /// from `buffer_kind`.
600 pub popup_focused: bool,
601 /// MB.1 (rich minibuffer): `true` while the `*command-line*` buffer
602 /// is focused for editing (the `:` line is open). Swapping
603 /// `self.document` to that buffer would otherwise make the active
604 /// pane render the command-line text; renderers read this flag to
605 /// route the active pane to its own (registry-keyed) buffer instead —
606 /// the Help-popup pattern. See `docs/dev/architecture/rich-minibuffer.md`.
607 pub command_line_active: bool,
608 /// MB.5: `true` while the `*search-line*` buffer is focused for
609 /// editing (a `/` or `?` search is being typed). Same semantics as
610 /// `command_line_active` — renderers read this flag to route the
611 /// active pane to its own (registry-keyed) buffer.
612 pub search_line_active: bool,
613 /// Terminal-mode T2.a (2026-05-25): `true` when
614 /// `terminal-insert-mode` is active on the active Terminal
615 /// buffer. Drives the translate-layer branch that encodes
616 /// keystrokes to ANSI bytes (and emits
617 /// `Action::TerminalInput`) instead of running them through
618 /// the normal-in-terminal vim grammar.
619 pub terminal_insert_active: bool,
620 /// Terminal-mode T2.b.0 (2026-05-25): resolved value of the
621 /// `terminal.esc-exits` typed option. Mirrored into the
622 /// render state so the input translator can build its
623 /// `TranslateContext` from the published snapshot rather
624 /// than reaching into `editor.config` per keystroke. When
625 /// `true`, `<Esc>` while `terminal_insert_active` emits
626 /// `Action::ExitTerminalInsert` instead of encoding to
627 /// `\x1b` for the PTY.
628 pub terminal_esc_exits: bool,
629 /// Terminal-mode T3.b.2 (2026-05-25): `true` when the
630 /// active Terminal buffer has a linewise Visual selection
631 /// in flight (i.e. `TerminalBuffer::visual.is_some()`).
632 /// Drives the modeline label (`TERMINAL-VISUAL`) and the
633 /// translate-layer routing for `j` / `k` (extend head vs
634 /// scroll viewport) without renderers having to reach into
635 /// the buffer registry themselves.
636 pub terminal_visual_active: bool,
637 /// Terminal-mode T2.c (2026-05-25): DECCKM bit read from
638 /// the active terminal's alacritty `Term`. When `true`,
639 /// the translate layer feeds it to
640 /// `keymap_terminal::key_to_ansi_with_mode` so arrow keys
641 /// encode as SS3 (`ESC O A`) rather than CSI
642 /// (`ESC [ A`). Programs like vim / less / htop / fzf
643 /// flip this with `ESC [ ? 1 h`.
644 pub terminal_app_cursor_keys: bool,
645 /// Terminal-mode T2.c (2026-05-25): `true` between the
646 /// `<C-\>` arming chord and the subsequent confirm key.
647 /// When set, the next translate call routes:
648 /// - `<C-n>` → `ExitTerminalInsert`
649 /// - any other chord → encode `\x1c` + the chord's
650 /// normal PTY bytes
651 ///
652 /// Cleared by both paths so the next chord starts fresh.
653 pub terminal_insert_exit_pending: bool,
654 /// 2026-05-25: program basename ("zsh", "bash", "cargo") of
655 /// the child process driving the active Terminal buffer.
656 /// Published from `TerminalBuffer::program_name` so the
657 /// modeline can surface "what's running here" rather than
658 /// the generic `TERMINAL` label. Empty when the active
659 /// buffer is not a Terminal.
660 pub terminal_program_name: std::sync::Arc<str>,
661 /// T-clean-1 Phase A.1 (2026-05-28): the active Terminal
662 /// pane's cursor in alacritty grid coordinates
663 /// `(absolute_line, col)`. Derived by the publisher from
664 /// `self.cursor` (doc-space) + `synthetic.origin_top_line`.
665 /// Renderers read this instead of reaching into
666 /// `TerminalBuffer::nav_cursor` so the bespoke mirror can
667 /// retire (Phase A.3). `None` when the active buffer is
668 /// not a Terminal or has no SyntheticDoc (Insert mode).
669 pub terminal_nav_cursor: Option<(i32, u16)>,
670 /// T-clean-1 Phase A.1 (2026-05-28): published copy of
671 /// `TerminalBuffer::visual` for renderer consumption. Same
672 /// shape as on the buffer (grid coords). Renderers read
673 /// this so the buffer-side field can later move to a
674 /// doc-space source. `None` when not in terminal-Visual.
675 pub terminal_visual: Option<lattice_terminal::TerminalVisualState>,
676 /// Phase 5.8.AF.5 / Slice 3c.final.B (group 2): folds for
677 /// the active document. Renderers read
678 /// `rs.active_document.load().folds` instead of `app.editor.folds`.
679 /// `Arc<[Fold]>` so subsequent reader frames share the
680 /// allocation; typical fold count is <20 so cloning at
681 /// publish-time is sub-µs.
682 pub folds: std::sync::Arc<[lattice_core::Fold]>,
683 /// Hlsearch matches in the active document. Each entry is a
684 /// `ProtoRange` covering one occurrence; the renderer paints
685 /// every range with the softer match bg. Cap is bounded by
686 /// `:set max_hits` (default 1000) so the clone is bounded.
687 pub all_matches: std::sync::Arc<[lattice_protocol::position::Range]>,
688 /// Primary search hit the cursor sits on (painted with the
689 /// strongest match colour). `None` outside Search mode.
690 pub current_match: Option<lattice_protocol::position::Range>,
691 /// Resolved visual selection range (anchor → head, normalised).
692 /// `None` when not in Visual. Mirrors the host's
693 /// `Editor::visual_selection_range()` helper so renderers don't
694 /// need to reach for that method through `&Editor`.
695 pub visual_range: Option<lattice_protocol::position::Range>,
696 /// Rectangular block extents when `modal == Visual(Blockwise)`,
697 /// `None` otherwise. Renderers prefer this over `visual_range`
698 /// in Blockwise — `visual_range` only expresses a linear span
699 /// and can't represent the per-line column band a block needs.
700 /// Mirrors [`crate::visual::Editor::visual_block_extents`].
701 pub visual_block_extents: Option<crate::visual::BlockExtents>,
702 /// `:s/pat/repl/...` preview overlay. `None` while no
703 /// substitute is being typed. The renderer paints the
704 /// match ranges (and replacement text, if any) with the
705 /// destructive-preview colour. `Arc` for cheap cloning.
706 pub substitute_preview: Option<std::sync::Arc<crate::state::SubstitutePreview>>,
707 /// Active document's selection set (multi-cursor / linewise /
708 /// blockwise). Already an `Arc` on `RopeDocumentHandle` so this
709 /// is one Arc bump.
710 pub selections: std::sync::Arc<lattice_protocol::SelectionSet>,
711 /// Hot-path option cache (typed-options resolved values).
712 /// `Copy` so the publish is a plain struct move. Used heavily
713 /// by per-row paint (whitespace glyphs, current-line highlight,
714 /// line-number style).
715 pub option_cache: crate::state::OptionCache,
716 /// K.4.6 follow-up (2026-06-02): per-composed-row source line
717 /// number lookup. `None` for regular Documents (the gutter
718 /// uses the composed row index, which IS the source line
719 /// number — identity mapping). `Some(arr)` for Multibuffer
720 /// views, where `arr[composed_row]` gives the source line
721 /// number in the originating source buffer.
722 ///
723 /// The renderer's `render_gutter_for` reads
724 /// `arr[composed_row]` when present and formats THAT as the
725 /// gutter label, so the user sees the actual source file's
726 /// line numbers (e.g. 429, 430, 432 — skipping non-hit
727 /// lines) rather than the composed-buffer row indices
728 /// (0, 1, 2 — meaningless for navigation).
729 ///
730 /// Substrate-published per Option (a) confirmed in the K.4.6
731 /// design discussion: the gutter has one job ("show this
732 /// row's display line number"), the mapping is data, no
733 /// kind-special-casing in the renderer.
734 pub display_line_numbers: Option<Arc<[u32]>>,
735}
736
737impl Default for ActiveDocumentRenderState {
738 fn default() -> Self {
739 // Default uses a `Document` kind with `BufferId(0)` and
740 // an empty snapshot. Renderers reading the default
741 // before the first dispatch publication see a
742 // consistent zero-state.
743 Self {
744 buffer_kind: lattice_core::BufferKind::Document,
745 document_buffer_id: lattice_core::BufferId(0),
746 active_pane_buffer_id: lattice_core::BufferId(0),
747 cursor: lattice_protocol::position::Position::ZERO,
748 scroll: 0,
749 leftcol: 0,
750 viewport_height: 0,
751 modal: lattice_grammar::ModalState::Normal,
752 visual_anchor: None,
753 snapshot: Arc::new(lattice_runtime::DocumentSnapshot::default()),
754 pending_count: 0,
755 op_count: 0,
756 macro_recording: false,
757 completion_open: false,
758 picker_open: false,
759 chord_capture: false,
760 snippet_active: false,
761 popup_focused: false,
762 command_line_active: false,
763 search_line_active: false,
764 terminal_insert_active: false,
765 terminal_esc_exits: true,
766 terminal_visual_active: false,
767 terminal_app_cursor_keys: false,
768 terminal_insert_exit_pending: false,
769 terminal_program_name: Arc::from(""),
770 terminal_nav_cursor: None,
771 terminal_visual: None,
772 folds: Arc::from(Vec::<lattice_core::Fold>::new().into_boxed_slice()),
773 all_matches: Arc::from(
774 Vec::<lattice_protocol::position::Range>::new().into_boxed_slice(),
775 ),
776 current_match: None,
777 visual_range: None,
778 visual_block_extents: None,
779 substitute_preview: None,
780 selections: Arc::new(lattice_protocol::SelectionSet::default()),
781 option_cache: crate::state::OptionCache::default(),
782 display_line_numbers: None,
783 }
784 }
785}
786
787/// Buffer registry's render-side projection — the list of
788/// buffers the editor knows about, independent of which one is
789/// currently active.
790///
791/// Per "everything is a buffer" (CLAUDE.md): files, help, oil,
792/// file-tree, `*messages*`, scratch — all are entries in this
793/// one index. Active-buffer hot-path state lives in
794/// [`ActiveDocumentRenderState`]; this sub-state is touched on
795/// registry changes only.
796///
797/// Phase 5.8.AF.5 / Slice 3c.final.B (group 1): populated.
798/// `registry` carries a clone of the editor's [`BufferRegistry`]
799/// — the registry is internally `Arc<Mutex<...>>`-backed so the
800/// clone is one Arc bump and the inner lookups (`document_handle`,
801/// `name_of`, `with_oil`, `flags_of`, `kind_of`) see the latest
802/// editor state without any further publication.
803///
804/// `uris` mirrors `Editor::buffer_uris` — published as a fresh
805/// `HashMap` clone per publish since the editor's field is owned
806/// directly. The renderer reads `rs.buffers.uris.get(&id)` instead
807/// of `app.editor.buffer_uris.get(&id)`.
808#[derive(Debug, Default, Clone)]
809pub struct BuffersRenderState {
810 /// Cloned [`crate::buffer_registry::BufferRegistry`]. Wait-free
811 /// to construct (one Arc bump); inner methods take their own
812 /// lock for each call.
813 pub registry: crate::buffer_registry::BufferRegistry,
814 /// LSP URI per buffer id. Published fresh each tick. For ~10
815 /// buffers the clone is sub-µs; if the registry grows large,
816 /// migrate to `Arc<HashMap<...>>` on the editor side to
817 /// collapse the clone into one Arc bump.
818 pub uris: std::sync::Arc<std::collections::HashMap<lattice_core::BufferId, lattice_lsp::Uri>>,
819}
820
821/// Pane tree's render-side projection.
822///
823/// Phase 5.8.AF.5 / Slice 3c.final.B (group 1): populated.
824/// Carries a clone of the editor's [`PaneTree`] inside an `Arc`
825/// so subsequent reader frames share the same allocation.
826/// Renderers read `rs.panes.tree.X()` instead of
827/// `app.editor.pane_tree.X()`; every existing `PaneTree` method
828/// (`root`, `leaves`, `active`, `active_index`, `compute_rects`)
829/// flows through unchanged.
830///
831/// `Arc::new(self.pane_tree.clone())` on publish is the simple
832/// shape; the `PaneTree::clone` cost is bounded by the tree depth
833/// (one `Vec<PaneState>` + a handful of `Box<PaneNode>` allocations
834/// for splits). For typical 1–3 pane layouts this is sub-µs.
835/// Optimisation path (post-1.0): keep an `Arc<PaneTree>` on the
836/// editor side and `Arc::make_mut` on mutation so publish collapses
837/// to one Arc bump.
838#[derive(Debug, Clone)]
839pub struct PanesRenderState {
840 pub tree: std::sync::Arc<lattice_core::ui::pane::PaneTree>,
841}
842
843impl Default for PanesRenderState {
844 fn default() -> Self {
845 Self {
846 tree: std::sync::Arc::new(lattice_core::ui::pane::PaneTree::default()),
847 }
848 }
849}
850
851/// Issue #29 (2026-05-22): published per-frame tab snapshot.
852/// Carries the user-visible label for each tab + the active
853/// index + the resolved visibility decision (`auto` ⇒ Multi-
854/// or-zero already evaluated by the publisher).
855#[derive(Debug, Clone)]
856pub struct TabsRenderState {
857 /// One entry per tab. Index parallels `Editor::tabs`.
858 pub items: std::sync::Arc<[TabRenderItem]>,
859 /// Active tab index (mirror of `Editor::active_tab`).
860 pub active: usize,
861 /// Whether the tabline should be rendered this frame. The
862 /// publisher evaluates `tabline.show` × `tabs.len()` and
863 /// stores the final decision so both peers don't re-derive.
864 pub visible: bool,
865}
866
867#[derive(Debug, Clone)]
868pub struct TabRenderItem {
869 pub id: lattice_core::ui::tab::TabId,
870 /// User-visible label. Derived by the publisher from the
871 /// tab's `label` override or, when None, from the active
872 /// pane's buffer name (basename of path, or `[scratch]`).
873 pub label: std::sync::Arc<str>,
874 /// ZP.4: this tab has a zoomed pane, and `pane.zoom-indicator`
875 /// asks for the tabline marker. Resolved by the publisher so both
876 /// renderer peers paint the same thing without re-deriving it.
877 ///
878 /// This is the only zoom surface that can report a *background*
879 /// tab — zoom is per-tab state stashed in `TabSlot.panes`, so
880 /// without it you cannot tell a zoomed tab from an unzoomed one
881 /// until you switch to it.
882 pub zoomed: bool,
883}
884
885impl TabRenderItem {
886 /// ZP.4: the tab's full display text, including the leading
887 /// 1-based number and the zoom marker.
888 ///
889 /// Host-side because both renderer peers had this format string
890 /// written out by hand, and adding the marker to one of them is
891 /// exactly the kind of divergence that ships unnoticed — the
892 /// tabline is only visible with multiple tabs open, and the marker
893 /// only inside those with a zoomed pane.
894 pub fn tabline_text(&self, index: usize) -> String {
895 if self.zoomed {
896 format!(
897 " {} {} {} ",
898 index + 1,
899 self.label,
900 lattice_core::ui::pane::ZOOM_MARKER
901 )
902 } else {
903 format!(" {} {} ", index + 1, self.label)
904 }
905 }
906}
907
908impl Default for TabsRenderState {
909 fn default() -> Self {
910 Self {
911 items: std::sync::Arc::from([]),
912 active: 0,
913 visible: false,
914 }
915 }
916}
917
918/// LSP feature data the renderer reads beyond diagnostics.
919///
920/// Slice 3a stubbed this empty. Slice 3b.0 wires the first
921/// drained subsystem: `document_highlights`. Subsequent 3b
922/// sub-slices add the remaining LSP caches one at a time
923/// (hovers, signature help, inlay hints, semantic tokens, code
924/// actions, document links, code lenses) following the same
925/// `Arc<ArcSwapOption<...>>` shape -- the spawned request task
926/// writes directly, the renderer reads wait-free.
927#[derive(Debug, Default, Clone)]
928pub struct LspRenderState {
929 /// `textDocument/documentHighlight` cache for the active
930 /// buffer + symbol position. Cloned `Arc` shared with
931 /// `Editor.lsp_document_highlights` so the spawned request
932 /// task's `.store()` is observable by readers without any
933 /// republication of `RenderState` itself.
934 ///
935 /// Renderers read via
936 /// `rs.lsp.document_highlights.load()` and self-validate
937 /// `cache.buffer_id == active_buffer_id` to ignore results
938 /// that raced a buffer switch.
939 pub document_highlights:
940 Arc<arc_swap::ArcSwapOption<lattice_lsp::cache::DocumentHighlightCache>>,
941 /// Slice 3b.1: per-buffer `textDocument/inlayHint` cache.
942 /// Spawned request task writes via
943 /// `PerBufferCacheExt::insert_for`; renderers read wait-free
944 /// via `.get_for(buffer_id)` and get a detached
945 /// `Arc<LspInlayHintCache>`.
946 pub inlay_hints: crate::per_buffer_cache::PerBufferCache<lattice_lsp::cache::LspInlayHintCache>,
947 /// Slice 3b.1: per-buffer `textDocument/foldingRange` cache.
948 /// Same shape as `inlay_hints`; renderers read via
949 /// `.get_for(buffer_id)`.
950 pub folds: crate::per_buffer_cache::PerBufferCache<lattice_lsp::cache::LspFoldsCache>,
951 /// Slice 3b.2: per-buffer `textDocument/semanticTokens/*`
952 /// cache. Spawned request task handles Items / Delta-applied /
953 /// Empty outcomes by writing directly via `insert_for` (or
954 /// `remove_for` on Delta result_id mismatch). Renderers read
955 /// via `.get_for(buffer_id)`.
956 pub semantic_tokens:
957 crate::per_buffer_cache::PerBufferCache<lattice_lsp::cache::LspSemanticTokensCache>,
958 /// Slice 3b.3: per-buffer `textDocument/codeLens` cache.
959 /// Spawned request task writes via `insert_for`; the
960 /// `codeLens/refresh` drain evicts per-server entries via
961 /// `PerBufferCacheExt::retain`. Renderers read via
962 /// `.get_for(buffer_id)`.
963 pub code_lens: crate::per_buffer_cache::PerBufferCache<lattice_lsp::cache::LspCodeLensCache>,
964 /// Slice 3b.4: per-buffer `textDocument/documentLink` cache.
965 pub document_links:
966 crate::per_buffer_cache::PerBufferCache<lattice_lsp::cache::LspDocumentLinksCache>,
967 /// Slice 3b.4: per-buffer `textDocument/documentColor` cache.
968 pub document_color:
969 crate::per_buffer_cache::PerBufferCache<lattice_lsp::cache::LspDocumentColorCache>,
970 /// Slice 3b.5: per-buffer `textDocument/diagnostic` (pull)
971 /// result_id cache. The actual diagnostics live in
972 /// `diagnostics.layer` (DiagnosticsLayer); this slot tracks
973 /// only the (version, result_id) pair the next pump uses for
974 /// the `previousResultId` short-circuit.
975 pub pull_diagnostics:
976 crate::per_buffer_cache::PerBufferCache<lattice_lsp::cache::LspPullDiagnosticsCache>,
977 // ML.3c: `progress` + `server_status` removed. The modeline badge
978 // they fed is produced by `lattice_lsp::modeline` from its own
979 // `LspProgressStore` (decision A); no renderer reads them.
980 /// Slice 3c.final.B (group 4): LSP supervisor handle clone.
981 /// The handle is internally `Arc<ArcSwap<SupervisorSnapshot>>`-
982 /// backed so `Clone` is one Arc bump and `servers_for(uri)`
983 /// stays wait-free. Renderers query
984 /// `rs.lsp.supervisor.servers_for(&uri)` for the modeline's
985 /// `[lsp:rust]` indicator instead of `app.editor.lsp.servers_for(...)`.
986 pub supervisor: lattice_lsp::LspSupervisorHandle,
987}
988
989/// Tree-sitter syntax inputs + static-overlay bucket cache.
990///
991/// Phase 5.8.AF.5 / Slice X2: split into two halves.
992///
993/// **Inputs** (`syntax_handle`, `scroll`, `viewport_height`,
994/// `fold_hash`, `text_version`, `doc_highlights`,
995/// `static_overlay_version`) are written by dispatch's
996/// `publish_render_state` from current `Editor` state. The
997/// background overlay worker reads them via the published
998/// `RenderState` snapshot to decide whether to re-bucket.
999///
1000/// **Output** (`static_overlay_quads`) is a nested
1001/// `Arc<ArcSwap<...>>` so the worker can publish a fresh
1002/// `StaticOverlayQuads` *without* going through
1003/// `publish_render_state`. The outer `RenderState` `Arc` stays
1004/// stable across a frame; the inner cell can be swapped at any
1005/// time. Renderers read with
1006/// `render_state.syntax.static_overlay_quads.load()` — wait-free.
1007///
1008/// display-line B4.2 (gut + rename): the dead `visible_spans` /
1009/// `visible_rows` prepaint output cells were deleted; syntax colour
1010/// now flows through the cells / `DisplayMatrix` substrate, and only
1011/// the static-overlay bucket remains as a worker output here.
1012///
1013/// Goal #1 ("no parsing on the UI thread") is enforced by this
1014/// split: the overlay bucketing runs on the worker, not in any
1015/// renderer's per-frame body.
1016#[derive(Debug, Clone)]
1017pub struct SyntaxRenderState {
1018 /// Active document's syntax handle. `None` when no language
1019 /// is attached (scratch buffer, plain text). The worker calls
1020 /// `.snapshot()` on this each tick to capture the current
1021 /// tree state for the highlight walk.
1022 pub syntax_handle: Option<Arc<lattice_syntax::SyntaxHandle>>,
1023 /// First visible line (the worker passes this as `start` to
1024 /// `highlight_lines(start, end_line)`).
1025 pub scroll: u32,
1026 /// Visible pane height in lines. The worker computes
1027 /// `end_line = scroll + viewport_height` (clamped by the
1028 /// snapshot's line count) when [`Self::end_line_override`]
1029 /// is `None`.
1030 pub viewport_height: u32,
1031 /// Fold-aware highlight-window upper bound. `Some(n)` makes
1032 /// the worker walk `[scroll, n)` instead of the default
1033 /// `[scroll, scroll + viewport_height)`. Set by the peer
1034 /// when closed folds collapse multiple buffer lines onto a
1035 /// single visible row, so a `n_row` viewport may need to
1036 /// highlight `n_row + interior_fold_lines` buffer lines for
1037 /// the post-fold tail to render with syntax styling. Slice
1038 /// X2.9 plumbing -- before this the legacy
1039 /// `Editor::refresh_highlights_window` accepted `end_line`
1040 /// as an explicit argument; the X2 worker now reads it
1041 /// through the same render-state cell as every other input.
1042 pub end_line_override: Option<u32>,
1043 /// Caller-tracked signature of closed folds in the visible
1044 /// range. Folds change which physical lines are visible, so
1045 /// the cache key must include this to avoid serving stale
1046 /// spans across fold toggles.
1047 pub fold_hash: u64,
1048 /// Current document text version. The stale-snapshot HOLD
1049 /// (worker recompute path) compares the document's version
1050 /// against the snapshot's `text_version()` to decide whether
1051 /// the snapshot is still current or has fallen behind.
1052 pub text_version: u64,
1053 // display-line B4.2 (gut + rename): the worker-published
1054 // `visible_spans` + `visible_rows` prepaint output cells were
1055 // deleted. Their consumers (TUI compose loop, GPUI active-pane
1056 // shaping) migrated to the cells / `DisplayMatrix` substrate in
1057 // the B-series, leaving these cells with zero readers. The
1058 // surviving worker output is `static_overlay_quads` below.
1059 /// Perf plan A.2 slice A.2b.1: active document's flattened
1060 /// inlay-hint list, pre-gated by the buffer's
1061 /// `lsp-inlay-hint-mode` enable-flag. Populated once per
1062 /// publish from `Editor::lsp_inlay_hints_cache` for the active
1063 /// document; empty when the mode is off, no LSP, or no hints
1064 /// have arrived yet.
1065 ///
1066 /// Why on `SyntaxRenderState` and not `LspRenderState`:
1067 /// inlays are an INPUT to the cells / `DisplayMatrix` worker's
1068 /// row composition (A.2b.2). The cells worker walks this list to
1069 /// splice inlay text into each composed row; downstream readers
1070 /// (GPUI's active-pane prepaint) consume the woven rows. The raw
1071 /// per-buffer LSP cache stays on `lsp.inlay_hints` for the
1072 /// inactive-pane fallback path that flattens its own list.
1073 /// (display-line B4.2: the dead `RowPrepaint` cell that
1074 /// previously consumed this is gone.)
1075 ///
1076 /// Coordinates: `byte` is a utf-8 offset against the active
1077 /// document's line text; `text` already has `padding_left`
1078 /// / `padding_right` spaces baked in at the publish boundary.
1079 pub inlay_hints: Arc<[InlayHintRow]>,
1080 /// Perf plan A.2 slice A.2b.2: content hash of `inlay_hints`.
1081 /// Paired with [`VisibleHighlightsKey::inlay_version`] so the
1082 /// worker invalidates its row cache when the inlay payload
1083 /// changes (arrivals, mode-gate flip, label edits). Stable
1084 /// across pure-scroll ticks (same payload → same hash → cache
1085 /// hit). Built by the publisher in the same pass that builds
1086 /// `inlay_hints` so the two stay aligned by construction.
1087 pub inlay_version: u64,
1088 /// Perf plan B.2: worker-published output cell for per-row
1089 /// pre-bucketed STATIC overlay quads (doc_highlight,
1090 /// all_matches, substitute). Same nested
1091 /// `Arc<ArcSwap<...>>` shape as `visible_rows` so the worker
1092 /// can swap a fresh bucket without rebuilding the outer
1093 /// `RenderState`. Inner `Arc` identity is the per-cell handle
1094 /// cloned from `Editor::syntax_static_overlay_quads_cell` at
1095 /// every publish.
1096 ///
1097 /// Active-pane only — inactive panes keep the legacy
1098 /// per-frame bucket path in their renderer (the worker only
1099 /// pre-paints the active pane's window). Cursor-coupled
1100 /// layers (`visual_range`, `current_match`) are merged in by
1101 /// the renderer at prepaint time; they're cheap per-row
1102 /// (one range each) and would force a worker wake on every
1103 /// cursor blink if pushed off-thread.
1104 pub static_overlay_quads: Arc<arc_swap::ArcSwap<StaticOverlayQuads>>,
1105 /// Perf plan B.2: active document's LSP document-highlight
1106 /// ranges, pre-converted from utf-16 columns to utf-8 byte
1107 /// offsets at publish time. The worker consumes this list
1108 /// directly when bucketing the `DocHighlight` layer instead
1109 /// of forcing the renderer to repeat the per-frame
1110 /// conversion against the snapshot text. Empty when the
1111 /// active buffer has no highlights or the LSP isn't attached
1112 /// — matches the steady-state no-highlight path on a single
1113 /// cheap branch. Parallels [`Self::inlay_hints`] (A.2b.1).
1114 pub doc_highlights: Arc<[lattice_protocol::position::Range]>,
1115 /// Perf plan B.2: content hash of the static-overlay payload
1116 /// (doc_highlights + all_matches + substitute_matches).
1117 /// Paired with
1118 /// [`VisibleHighlightsKey::static_overlay_version`] so the
1119 /// worker invalidates its overlay bucket when any layer
1120 /// changes (search query bump, LSP response, substitute
1121 /// input edit). Independent from `inlay_version` so search
1122 /// churn doesn't invalidate the row cache and vice versa.
1123 /// Built by the publisher from the same payload in
1124 /// [`static_overlay_state_version`] so the hash stays
1125 /// byte-aligned with the published list.
1126 pub static_overlay_version: u64,
1127 // DR.2 (decoration-retention): the per-pane `pane_highlights` span
1128 // cache was retired from the published syntax sub-state. Inactive
1129 // panes read their own retained per-pane `DisplayMatrix` (via
1130 // `CellsRenderState::display_matrix_for_pane`), the same canonical
1131 // producer the active pane uses.
1132}
1133
1134impl Default for SyntaxRenderState {
1135 fn default() -> Self {
1136 Self {
1137 syntax_handle: None,
1138 scroll: 0,
1139 viewport_height: 0,
1140 end_line_override: None,
1141 fold_hash: 0,
1142 text_version: 0,
1143 inlay_hints: Arc::from(Vec::<InlayHintRow>::new().into_boxed_slice()),
1144 inlay_version: 0,
1145 static_overlay_quads: Arc::new(arc_swap::ArcSwap::from_pointee(
1146 StaticOverlayQuads::default(),
1147 )),
1148 doc_highlights: Arc::from(
1149 Vec::<lattice_protocol::position::Range>::new().into_boxed_slice(),
1150 ),
1151 static_overlay_version: 0,
1152 }
1153 }
1154}
1155
1156/// Cell-grid renderer substrate state. Mirror of
1157/// [`SyntaxRenderState`] in shape (wait-free output cell + read
1158/// inputs); replaces the per-frame `shape_line` path for
1159/// code-class buffers.
1160///
1161/// **S2.1**: only the plumbing is in place — `matrix` is published
1162/// but stays empty until S2.2 spawns the cell-builder worker that
1163/// will write to it. The other fields are populated from current
1164/// `Editor` state by `build_render_state` so the worker has its
1165/// full input set the moment it lands.
1166///
1167/// Anchor: [`docs/dev/architecture/cell-grid-renderer.md`](../../../docs/dev/architecture/cell-grid-renderer.md).
1168#[derive(Debug, Clone)]
1169pub struct CellsRenderState {
1170 /// Worker-published output cell. Inner Arc identity stays
1171 /// stable across publishes (cloned from
1172 /// `Editor::cells_matrix_cell`) so the worker's writes
1173 /// survive subsequent publishes — same stability pattern as
1174 /// [`SyntaxRenderState::visible_spans`].
1175 pub matrix: Arc<arc_swap::ArcSwap<lattice_cells::CellMatrix>>,
1176
1177 /// Aggregate version stamp of the inputs below. Worker
1178 /// compares against the *published* matrix's
1179 /// [`lattice_cells::CellMatrix::version`] to decide rebuild —
1180 /// see [`lattice_cells::MatrixVersion::differs_from`]. Folds
1181 /// `text_version`, `syntax`-derived stamp, `inlay_hints`
1182 /// content hash, `folds` content hash, and `theme` version
1183 /// into one comparison value.
1184 pub version: lattice_cells::MatrixVersion,
1185
1186 /// Active document snapshot. The cell-builder walks this
1187 /// line-by-line. `None` when no document is active (initial
1188 /// boot or between buffer switches). Cloned by reference from
1189 /// the active `RopeDocumentHandle.snapshot()` at publish time.
1190 pub snapshot: Option<Arc<lattice_runtime::DocumentSnapshot>>,
1191
1192 /// Active buffer's syntax handle. Consumed by S2.3 cell
1193 /// construction to resolve per-cell foreground colour from
1194 /// the syntax span set. `None` when no language is attached
1195 /// — cells fall back to the theme default fg.
1196 pub syntax_handle: Option<Arc<lattice_syntax::SyntaxHandle>>,
1197
1198 /// Pre-flattened inlay hints for the active buffer. Same
1199 /// payload as [`SyntaxRenderState::inlay_hints`]; carried
1200 /// here so the cell-builder can splice inlay text into cells
1201 /// without re-reading the LSP cache (S2.3).
1202 pub inlay_hints: Arc<[InlayHintRow]>,
1203
1204 /// Active buffer's fold ranges. Consumed by S2.3 for row
1205 /// elision (folded source lines do not produce matrix rows).
1206 pub folds: Arc<[lattice_core::Fold]>,
1207
1208 /// Visible pane height in matrix rows. S2.4 reads this to
1209 /// pick `chunk_size = 2 × viewport_height` when above the
1210 /// whole-doc-mode threshold.
1211 pub viewport_height: u32,
1212
1213 /// `:set foldenable` for the active buffer. The cell-builder
1214 /// feeds this into [`crate::folds::FoldIndex::from_folds`] so
1215 /// elision predicates collapse to `false` when folding is off
1216 /// — `zi` then yields the unfolded matrix without a separate
1217 /// code path. Folded into [`lattice_cells::MatrixVersion::folds`]
1218 /// at publish time so toggling foldenable invalidates the
1219 /// matrix.
1220 pub foldenable: bool,
1221
1222 /// S2.4.b (2026-05-26): single-edit delta covering the bump
1223 /// from the previous publish's text_version to this publish's
1224 /// text_version. `Some(d)` when exactly one
1225 /// `apply_edit_blocking` happened since the last build and the
1226 /// worker can take the incremental rebuild path; `None`
1227 /// otherwise (no edit, batch, undo / redo, multi-edit
1228 /// coalescing) — in which case the worker conservatively
1229 /// full-rebuilds. Sourced from `Editor::last_edit_for_cells`
1230 /// via `take()` at `build_render_state` time, so subsequent
1231 /// publishes without further edits see `None`.
1232 pub last_edit: Option<lattice_cells::EditDelta>,
1233
1234 /// T.5 (theme-system): the resolved read table + builtin ids the
1235 /// cell-builder uses to resolve each span's `lattice_syntax::Style`
1236 /// → `resolved.get(syntax_element_id(ids, s))` (O(1) array index).
1237 /// Snapshotted at publish. T.6.t deleted the flat host `Theme` field
1238 /// that used to ride alongside it; the matrix invalidation key
1239 /// (`MatrixVersion::theme`) is now `ResolvedTheme::version()`, set at
1240 /// `build_render_state` time, so a palette change still rebuilds the
1241 /// matrix with fresh colours.
1242 pub resolved_theme: std::sync::Arc<crate::ui::theme::ResolvedTheme>,
1243 pub theme_ids: crate::ui::theme::BuiltinElementIds,
1244
1245 /// 2026-05-27: `display.whitespace.*` snapshot. Worker
1246 /// substitutes whitespace bytes with marker glyphs +
1247 /// `WS_MARKER` flag when `show` is true. A hash of this
1248 /// struct is folded into
1249 /// [`lattice_cells::MatrixVersion::whitespace`] at publish
1250 /// time so any `:set` of a whitespace option invalidates the
1251 /// matrix and triggers a rebuild.
1252 pub whitespace: crate::cells_worker::WhitespaceConfig,
1253
1254 /// D.4.d.1.a (2026-05-29): one entry per visible Document
1255 /// pane. Populated by `publish_render_state` from
1256 /// `pane_tree.leaves()`; non-Document leaves are skipped.
1257 ///
1258 /// D.4.d.1.b consumes this slice in
1259 /// [`crate::cells_worker::recompute`] — each entry's
1260 /// `matrix` is the per-buffer registry cell the worker
1261 /// writes through. The active pane's entry shares Arc
1262 /// identity with [`Self::matrix`] so today's renderer
1263 /// read path keeps landing on the worker's writes for
1264 /// the active pane; [`Self::pane_matrices`] +
1265 /// [`Self::matrix_for_pane`] are the per-pane read
1266 /// surface renderers can use to find a non-active pane's
1267 /// matrix without iterating `panes`.
1268 pub panes: Arc<[PaneCellsInputs]>,
1269
1270 /// D.4.d.1.c (2026-05-29): `PaneId → matrix` lookup
1271 /// derived from [`Self::panes`] at publish time so
1272 /// renderers can find a pane's matrix by id without
1273 /// scanning the panes slice. One entry per visible
1274 /// Document leaf; non-Document panes are absent (the
1275 /// renderer's per-kind dispatch already knows not to
1276 /// consult cells for those).
1277 ///
1278 /// Use [`Self::matrix_for_pane`] for the read; direct
1279 /// access to the map is fine when batching multiple
1280 /// lookups.
1281 pub pane_matrices: Arc<
1282 std::collections::HashMap<
1283 lattice_core::ui::pane::PaneId,
1284 Arc<arc_swap::ArcSwap<lattice_cells::CellMatrix>>,
1285 >,
1286 >,
1287
1288 /// B2.1 (2026-06-04): active-pane per-line display matrix.
1289 /// Clone of `Editor::display_matrix_cell` (stable Arc identity so
1290 /// the worker's writes survive subsequent publishes) — the
1291 /// per-line analogue of [`Self::matrix`]. Empty until the B2.2
1292 /// worker build path writes through it. See
1293 /// `docs/dev/architecture/display-line.md`.
1294 pub display_matrix: Arc<arc_swap::ArcSwap<crate::display_matrix::DisplayMatrix>>,
1295
1296 /// B2.1 (2026-06-04): `PaneId → display matrix` lookup, the
1297 /// per-line analogue of [`Self::pane_matrices`]. One entry per
1298 /// visible Document leaf; derived at publish time from
1299 /// [`Self::panes`]. Read via [`Self::display_matrix_for_pane`].
1300 pub display_pane_matrices: Arc<
1301 std::collections::HashMap<
1302 lattice_core::ui::pane::PaneId,
1303 Arc<arc_swap::ArcSwap<crate::display_matrix::DisplayMatrix>>,
1304 >,
1305 >,
1306 /// IG.2 (2026-08-16): `PaneId → indentation guides`, the peer of
1307 /// [`Self::display_pane_matrices`] and derived from the same
1308 /// `panes` list at publish time. Read via
1309 /// [`Self::indent_guides_for_pane`].
1310 pub pane_indent_guides: Arc<
1311 std::collections::HashMap<
1312 lattice_core::ui::pane::PaneId,
1313 Arc<arc_swap::ArcSwap<crate::indent_guides::IndentGuides>>,
1314 >,
1315 >,
1316 /// TC.3b: `PaneId → sticky context`, the peer of
1317 /// [`Self::pane_indent_guides`], derived from the same `panes` list at
1318 /// publish time. Read via [`Self::sticky_context_for_pane`].
1319 pub pane_sticky_context: Arc<
1320 std::collections::HashMap<
1321 lattice_core::ui::pane::PaneId,
1322 Arc<arc_swap::ArcSwap<crate::sticky_context::StickyContext>>,
1323 >,
1324 >,
1325}
1326
1327impl CellsRenderState {
1328 /// B2.1 (2026-06-04): look up the per-line display matrix for
1329 /// `pane_id`. `None` when the pane is not a Document leaf (same
1330 /// semantics as [`Self::matrix_for_pane`]).
1331 pub fn display_matrix_for_pane(
1332 &self,
1333 pane_id: lattice_core::ui::pane::PaneId,
1334 ) -> Option<&Arc<arc_swap::ArcSwap<crate::display_matrix::DisplayMatrix>>> {
1335 self.display_pane_matrices.get(&pane_id)
1336 }
1337
1338 /// IG.2 (2026-08-16): the indentation-guide layer for `pane_id`.
1339 /// `None` for a pane with no display matrix (non-Document leaves
1340 /// skip the cells path), which is the correct "no guides" answer.
1341 pub fn indent_guides_for_pane(
1342 &self,
1343 pane_id: lattice_core::ui::pane::PaneId,
1344 ) -> Option<&Arc<arc_swap::ArcSwap<crate::indent_guides::IndentGuides>>> {
1345 self.pane_indent_guides.get(&pane_id)
1346 }
1347
1348 /// TC.3b: look up the pinned context strip for `pane_id`. `None` when the
1349 /// pane is not a document-backed leaf (it then pins nothing).
1350 pub fn sticky_context_for_pane(
1351 &self,
1352 pane_id: lattice_core::ui::pane::PaneId,
1353 ) -> Option<&Arc<arc_swap::ArcSwap<crate::sticky_context::StickyContext>>> {
1354 self.pane_sticky_context.get(&pane_id)
1355 }
1356
1357 /// D.4.d.1.c: look up the cell matrix for `pane_id`.
1358 /// Returns `None` when the pane is not a Document leaf
1359 /// (file tree / help / messages / oil / terminal panes
1360 /// skip the cells path entirely — see
1361 /// `crate::dispatch::Editor::build_cells_panes`).
1362 pub fn matrix_for_pane(
1363 &self,
1364 pane_id: lattice_core::ui::pane::PaneId,
1365 ) -> Option<&Arc<arc_swap::ArcSwap<lattice_cells::CellMatrix>>> {
1366 self.pane_matrices.get(&pane_id)
1367 }
1368
1369 /// The **current** whitespace-axis stamp for `pane_id` — what a
1370 /// matrix built right now would carry. A renderer compares it
1371 /// against the matrix it is about to paint from
1372 /// ([`lattice_cells::MatrixVersion::whitespace`]); a mismatch means
1373 /// the cells were emitted under a *different* `display.whitespace.*`
1374 /// configuration and their decoration is wrong until the worker
1375 /// rebuilds.
1376 ///
1377 /// Falls back to the top-level aggregate when the pane has no entry
1378 /// (non-Document leaf, or a publish gap) — that stamp is computed
1379 /// from the same option fields, so the comparison stays meaningful
1380 /// rather than silently passing.
1381 pub fn whitespace_version_for_pane(&self, pane_id: lattice_core::ui::pane::PaneId) -> u64 {
1382 self.panes
1383 .iter()
1384 .find(|p| p.pane_id == pane_id)
1385 .map(|p| p.version.whitespace)
1386 .unwrap_or(self.version.whitespace)
1387 }
1388
1389 /// CV.4: the soft-wrap column width for `pane_id` derived from
1390 /// **live** published geometry — `viewport_width` minus the
1391 /// gutter reservation, or `0` when the pane does not wrap.
1392 ///
1393 /// This is the value the cells worker will stamp onto the matrix
1394 /// as `wrap_width`, computed from the same two published inputs,
1395 /// and therefore available a frame *before* the worker runs. A
1396 /// renderer that reads the matrix's stamp instead paints
1397 /// unwrapped until the worker catches up: on a freshly opened
1398 /// file, right after a resize, and on the keystroke that ran
1399 /// `:set wrap`. Reading it here keeps both peers and the host's
1400 /// scroll clamp on one number.
1401 ///
1402 /// `0` for a pane with no published entry (non-Document leaves
1403 /// skip the cells path), which is the correct "no wrapping"
1404 /// answer for them.
1405 pub fn wrap_width_for_pane(&self, pane_id: lattice_core::ui::pane::PaneId) -> u32 {
1406 self.panes
1407 .iter()
1408 .find(|p| p.pane_id == pane_id)
1409 .filter(|p| p.wrap)
1410 .map(|p| p.viewport_width.saturating_sub(p.wrap_reserved_cols).max(1))
1411 .unwrap_or(0)
1412 }
1413}
1414
1415/// D.4.d.1.a (2026-05-29): per-visible-Document-pane build
1416/// inputs for the cell-builder worker. Mirrors the shape of
1417/// the top-level [`CellsRenderState`] active-doc fields but
1418/// keyed by `(pane_id, buffer_id)` so the worker can resolve
1419/// each entry's matrix Arc via
1420/// [`crate::editor::Editor::cells_matrix_for`] at publish
1421/// time and rebuild per visible buffer.
1422///
1423/// K.4.7 (2026-06-07): per-excerpt syntax entry for multibuffer
1424/// panes. The cells worker reads `excerpt_syntax` to apply
1425/// per-source tree-sitter highlights across composed rows.
1426///
1427/// - `composed_start` / `composed_end`: inclusive row range in the
1428/// multibuffer's composed coordinate space.
1429/// - `source_start`: the first source-buffer row that maps to
1430/// `composed_start` (used to translate highlight results back).
1431/// - `handle`: per-excerpt highlight provider (impl `ExcerptHighlighter`).
1432/// Owned by `MultibufferState`; accessed via trait object so the host
1433/// never depends on the concrete `SyntaxHandle` type.
1434#[derive(Clone)]
1435pub struct ExcerptSyntax {
1436 pub composed_start: u32,
1437 pub composed_end: u32,
1438 pub source_start: u32,
1439 pub handle: std::sync::Arc<dyn lattice_cells::ExcerptHighlighter>,
1440 /// OA.7b: the excerpt's grammar name, for its conceal rules. See
1441 /// [`lattice_cells::ExcerptHighlight::lang`].
1442 pub lang: Option<&'static str>,
1443}
1444
1445impl std::fmt::Debug for ExcerptSyntax {
1446 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1447 f.debug_struct("ExcerptSyntax")
1448 .field("composed_start", &self.composed_start)
1449 .field("composed_end", &self.composed_end)
1450 .field("source_start", &self.source_start)
1451 .finish_non_exhaustive()
1452 }
1453}
1454
1455/// `last_edit` is `Some(delta)` only for the active pane
1456/// (edits land on the active document; the publish path
1457/// `take()`s `Editor::last_edit_for_cells` exactly once);
1458/// non-active panes always carry `None` so the worker
1459/// conservatively full-rebuilds on text-version bumps for
1460/// those buffers. That is correct today because non-active
1461/// buffers don't take edits in normal use; LSP-driven edits
1462/// to non-active buffers also flush through `apply_edit`
1463/// which clears the slot.
1464#[derive(Debug, Clone)]
1465pub struct PaneCellsInputs {
1466 /// `PaneTree` leaf id. Stable across publishes until the
1467 /// pane is closed or reorganised.
1468 pub pane_id: lattice_core::ui::pane::PaneId,
1469 /// Document buffer this pane is showing. Worker uses
1470 /// this as the registry key.
1471 pub buffer_id: lattice_core::BufferId,
1472 /// Per-pane matrix output cell. Cloned from
1473 /// `Editor::cells_matrix_for(buffer_id)` so worker
1474 /// writes via `cell.store(...)` are visible through
1475 /// every later `render_state.load_full()`. Active-pane
1476 /// entries share Arc identity with
1477 /// [`CellsRenderState::matrix`].
1478 pub matrix: Arc<arc_swap::ArcSwap<lattice_cells::CellMatrix>>,
1479 /// B2.1 (2026-06-04): per-pane per-line display-matrix output
1480 /// cell. Cloned from `Editor::display_matrix_for(buffer_id)` so
1481 /// worker writes via `cell.store(...)` are visible through every
1482 /// later `render_state.load_full()`. Active-pane entries share
1483 /// Arc identity with [`CellsRenderState::display_matrix`].
1484 pub display_matrix: Arc<arc_swap::ArcSwap<crate::display_matrix::DisplayMatrix>>,
1485 /// IG.2 (2026-08-16): per-pane indentation-guide output cell.
1486 /// Cloned from `Editor::indent_guides_for(buffer_id)`. Written in
1487 /// the same worker pass that writes [`Self::display_matrix`], from
1488 /// the same snapshot and with the same version stamp — the two are
1489 /// two projections of one build, not two caches.
1490 pub indent_guides: Arc<arc_swap::ArcSwap<crate::indent_guides::IndentGuides>>,
1491 /// H.4 (2026-08-29): this pane renders links raw rather than
1492 /// concealed. True iff the editor is in Insert/Replace **and** this
1493 /// pane holds the buffer being edited.
1494 ///
1495 /// The active-pane condition is not incidental. `ModalState` is
1496 /// editor-global, so revealing on the mode alone would repaint every
1497 /// visible org buffer in every split the moment you pressed `i` in
1498 /// one of them — a pixel change to content the user did not touch,
1499 /// which is the standing veto. A pane that is not being edited has
1500 /// no reason to show editing affordances.
1501 /// CL.1: the ONE line whose conceals are suppressed, or `None`.
1502 ///
1503 /// Was a buffer-wide `bool`: pressing `i` revealed every link in the
1504 /// buffer at once, which is not what any editor does. Vim scopes reveal to
1505 /// the cursor LINE (`concealcursor`), because the reason to reveal is to
1506 /// edit the thing under the cursor — the other forty links on screen have
1507 /// no reason to turn back into `[[id:…][…]]`.
1508 ///
1509 /// A line rather than a bool is also what keeps this affordable: the two
1510 /// lines whose reveal state changed are rebuilt incrementally and every
1511 /// other row is `Arc`-reused, so a cursor move costs two rows instead of a
1512 /// window.
1513 pub conceal_reveal_line: Option<u32>,
1514 /// IG.2: this buffer's resolved indent level. Guides are spaced by
1515 /// `shiftwidth` (one level of indent), not by `tabstop` (the width
1516 /// of a tab byte); `tabstop` is here too because measuring a
1517 /// tab-indented line's depth in display columns needs it.
1518 pub indent_unit: lattice_core::IndentUnit,
1519 /// IG.2: `display.indent-guides` for this buffer. `false` publishes
1520 /// the empty layer rather than skipping the write, so turning guides
1521 /// off clears them on the next publish instead of leaving the last
1522 /// build painted.
1523 pub indent_guides_enabled: bool,
1524 /// TC.3b: the source lines this pane pins above its text, resolved
1525 /// HOST-side from the cached scopes + this pane's anchor and viewport
1526 /// (`lattice_cells::context::resolve_context`). The host produces this
1527 /// list and reserves exactly `len()` rows for it, so the reservation
1528 /// and the paint cannot drift — the failure mode the gutter-width
1529 /// mismatch taught, designed out rather than tested for.
1530 pub sticky_context_lines: Arc<[u32]>,
1531 /// TC.8: whether those rows show source line numbers
1532 /// (`context.line-numbers`). Resolved host-side with the lines so the
1533 /// worker stamps it onto the layer it builds and neither renderer reads a
1534 /// plugin option.
1535 pub sticky_context_line_numbers: bool,
1536 /// TC.12: `context.separator`'s glyph, resolved host-side.
1537 pub sticky_context_separator: Option<char>,
1538 /// TC.3b: per-PANE sticky-context output cell. Keyed by pane id, not
1539 /// buffer id, because the rows genuinely differ per pane: one buffer in
1540 /// two splits with cursors in different scopes must show different
1541 /// context. Cloned from `Editor::sticky_context_for(pane_id)`.
1542 pub sticky_context: Arc<arc_swap::ArcSwap<crate::sticky_context::StickyContext>>,
1543 /// D.4.d.2.1.b (2026-05-29): per-pane virtual-rows matrix
1544 /// output cell. Cloned from
1545 /// `Editor::virtual_rows_matrix_for(buffer_id)` so the
1546 /// virtual-rows worker (D.4.d.2.1.c) can write via
1547 /// `cell.store(...)` and have the writes visible
1548 /// through every later `render_state.load_full()`.
1549 /// Active-pane entries share Arc identity with
1550 /// [`crate::editor::Editor::virtual_rows_matrix_cell`]
1551 /// (boot-seeded invariant from D.4.d.2.0) so the
1552 /// existing single-document read path through
1553 /// [`VirtualRowsRenderState::matrix`] keeps landing on
1554 /// the worker's writes for the active pane until
1555 /// D.4.d.2.1.d switches the renderer to a per-pane
1556 /// lookup.
1557 pub virtual_rows_matrix: Arc<arc_swap::ArcSwap<lattice_cells::VirtualRowMatrix>>,
1558 /// Aggregate version for this pane's inputs. Worker
1559 /// compares against `matrix.load().version` to
1560 /// short-circuit cache hits per pane.
1561 pub version: lattice_cells::MatrixVersion,
1562 /// Document snapshot for `buffer_id`. `None` when the
1563 /// buffer is missing from the registry (transient race
1564 /// during close); the worker treats `None` as "skip,
1565 /// matrix unchanged."
1566 pub snapshot: Option<Arc<lattice_runtime::DocumentSnapshot>>,
1567 /// Syntax handle for `buffer_id`, if any. Resolves
1568 /// through `Editor::document_syntax_for` so non-active
1569 /// buffers carrying a parsed `Syntax` are still themed.
1570 pub syntax_handle: Option<Arc<lattice_syntax::SyntaxHandle>>,
1571 /// Flattened inlay hints for `buffer_id`. Empty when
1572 /// `lsp-inlay-hint-mode` is off for this buffer or no
1573 /// hints have arrived.
1574 pub inlay_hints: Arc<[InlayHintRow]>,
1575 /// Fold ranges for `buffer_id`. Sourced from
1576 /// `buffer_locals[buffer_id].DocumentFolds`.
1577 pub folds: Arc<[lattice_core::Fold]>,
1578 /// Pane-local visible-buffer height. Sourced from
1579 /// `PaneState.viewport_height` (Issue #25). Drives the
1580 /// worker's chunked-mode threshold.
1581 pub viewport_height: u32,
1582 /// Pane-local first visible source line. For the active pane this
1583 /// is `Editor::scroll`; inactive panes carry their stashed
1584 /// `PaneState.scroll`. H.3 (2026-06-04): the cells worker windows
1585 /// the chunked-mode matrix around `[scroll, scroll +
1586 /// viewport_height)` (plus overscan) rather than building the
1587 /// whole document, so large-file build + rebuild are O(viewport)
1588 /// not O(file). H.3a plumbs the field; H.3b makes the worker read
1589 /// it. Mirrors the long-standing `SyntaxRenderState.scroll`
1590 /// precedent for the legacy windowed-highlight path.
1591 pub scroll: u32,
1592 /// Pane-local visible-buffer width in columns. Sourced from
1593 /// `PaneState.viewport_width`. Soft-wrap (W.1): the cells
1594 /// worker stamps `CellMatrix.wrap_width` from this when `wrap`
1595 /// is on (W.2). `0` means "not yet laid out".
1596 pub viewport_width: u32,
1597 /// Soft-wrap (W.2): `:set wrap` resolved for this pane's
1598 /// buffer. When `true` (and `viewport_width > 0`) the cells
1599 /// worker stamps the published matrix's `wrap_width` so
1600 /// consumers expand each source line into `⌈col/width⌉`
1601 /// display rows. `false` ⇒ `wrap_width` stays `0` (one
1602 /// display row per source line — the historical default).
1603 pub wrap: bool,
1604 /// Columns reserved for this pane's gutter (line-number column +
1605 /// diagnostic + diff-sign cells). The cells worker subtracts
1606 /// this from `viewport_width` to get the soft-wrap width, so
1607 /// the stamped `wrap_width` — read by `segment_count` (the
1608 /// vertical scroll clamp) and both renderers' paint paths —
1609 /// matches the width the renderer actually wraps body text at.
1610 /// Without this the clamp under-counts wrapped display rows and
1611 /// `G` clips the document tail. `0` for gutterless panes (the
1612 /// floating popups). Computed via
1613 /// [`crate::cells_worker::gutter_cols`], shared with
1614 /// [`crate::editor::Editor::body_text_width`] to keep the
1615 /// vertical and horizontal clamps in lockstep.
1616 pub wrap_reserved_cols: u32,
1617 /// Per-pane foldenable. Global today (no per-buffer
1618 /// setting); kept here so a future per-buffer
1619 /// `foldenable` doesn't require a substate reshape.
1620 pub foldenable: bool,
1621 /// Single-edit delta for the incremental rebuild path.
1622 /// `Some(delta)` only for the active pane on the publish
1623 /// cycle when exactly one edit was applied; `None`
1624 /// otherwise. See struct-level docstring.
1625 pub last_edit: Option<lattice_cells::EditDelta>,
1626 /// K.4.7 (2026-06-07): per-excerpt syntax entries for multibuffer
1627 /// panes. Empty for ordinary single-document panes.
1628 pub excerpt_syntax: Arc<[ExcerptSyntax]>,
1629 /// PU.1b-2a: generic per-buffer *static* highlight spans (indexed
1630 /// by source line), merged ON TOP OF the grammar spans in the
1631 /// matrix build. Sourced from the buffer's `ExtraHighlights` local
1632 /// (help links; later, any non-grammar-derivable styling). Empty
1633 /// `[]` for every buffer without the local — the merge is a no-op
1634 /// then, so ordinary panes render byte-identically.
1635 pub extra_spans: Arc<[Vec<lattice_syntax::StyledSpan>]>,
1636 /// DR.3 (2026-08-12): per-line intra-line diff refinement — byte
1637 /// ranges whose BACKGROUND overrides their row's diff tint.
1638 /// Sourced from the buffer's `ExtraRefinement` local, published by
1639 /// the same drain as `extra_spans` so the two cannot drift. Empty
1640 /// `[]` for every buffer without it, so ordinary panes render
1641 /// byte-identically.
1642 pub extra_refine: Arc<[Vec<lattice_cells::RefineSpan>]>,
1643}
1644
1645impl Default for CellsRenderState {
1646 fn default() -> Self {
1647 Self {
1648 matrix: Arc::new(arc_swap::ArcSwap::from_pointee(
1649 lattice_cells::CellMatrix::empty(),
1650 )),
1651 version: lattice_cells::MatrixVersion::ZERO,
1652 snapshot: None,
1653 syntax_handle: None,
1654 inlay_hints: Arc::from(Vec::<InlayHintRow>::new().into_boxed_slice()),
1655 folds: Arc::from(Vec::<lattice_core::Fold>::new().into_boxed_slice()),
1656 viewport_height: 0,
1657 foldenable: true,
1658 last_edit: None,
1659 resolved_theme: Arc::new(crate::ui::theme::ResolvedTheme::default()),
1660 theme_ids: crate::ui::theme::BuiltinElementIds::default(),
1661 whitespace: crate::cells_worker::WhitespaceConfig::default(),
1662 panes: Arc::from(Vec::<PaneCellsInputs>::new().into_boxed_slice()),
1663 pane_matrices: Arc::new(std::collections::HashMap::new()),
1664 display_matrix: Arc::new(arc_swap::ArcSwap::from_pointee(
1665 crate::display_matrix::DisplayMatrix::empty(),
1666 )),
1667 display_pane_matrices: Arc::new(std::collections::HashMap::new()),
1668 pane_indent_guides: Arc::new(std::collections::HashMap::new()),
1669 pane_sticky_context: Arc::new(std::collections::HashMap::new()),
1670 }
1671 }
1672}
1673
1674/// Cache key identifying the inputs that produced a particular
1675/// [`StaticOverlayQuads`]. The overlay worker compares the *current*
1676/// inputs against `StaticOverlayQuads::computed_for_key` to
1677/// short-circuit re-bucketing on a no-op tick (cursor blink,
1678/// unchanged scroll/viewport/folds).
1679///
1680/// `snapshot_ptr` is the `Arc::as_ptr` of the snapshot the bucket
1681/// was computed against — distinct snapshots produce distinct keys
1682/// even if `text_version` happens to match.
1683///
1684/// Migrated from `crates/lattice-host/src/highlights.rs` in X2;
1685/// the renderer's read contract is now the canonical owner.
1686///
1687/// Perf plan A.2 slice A.2b.2: `inlay_version` axis — a content
1688/// hash of the gated `SyntaxRenderState.inlay_hints` payload. When
1689/// inlays arrive, change, or the mode-gate flips, the hash bumps
1690/// and the worker recomposes rows so the inlay splice stays
1691/// current. Stable across pure-scroll / cursor-blink ticks (the
1692/// hash is recomputed from the same payload).
1693#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1694pub struct VisibleHighlightsKey {
1695 /// Which buffer these quads were bucketed for.
1696 ///
1697 /// Without it the key describes a viewport rather than a DOCUMENT, and
1698 /// the two are not the same thing the moment the active buffer changes.
1699 /// The stale-snapshot hold below re-publishes the previous quads under a
1700 /// new key, which is right mid-edit in one buffer and wrong across a
1701 /// switch — it painted the buffer you left onto the buffer you opened, at
1702 /// the exact offsets it had there. Nothing else in this key could tell the
1703 /// two cases apart.
1704 pub buffer_id: lattice_core::BufferId,
1705 pub snapshot_ptr: usize,
1706 pub syntax_text_version: u64,
1707 pub scroll: u32,
1708 pub viewport_height: u32,
1709 pub fold_hash: u64,
1710 pub inlay_version: u64,
1711 /// Perf plan B.2: content hash of the static-overlay payload
1712 /// (doc_highlights + all_matches + substitute_matches). Bumps
1713 /// independently from `inlay_version` so a search-query change
1714 /// invalidates the overlay bucket without forcing a row
1715 /// recompose, and an inlay arrival doesn't invalidate the
1716 /// overlay bucket. Built from
1717 /// [`static_overlay_state_version`] at publish time so the
1718 /// hash and the payload stay aligned by construction.
1719 pub static_overlay_version: u64,
1720}
1721
1722// display-line B4.2 (gut + rename): `VisibleSpans` (the worker's
1723// per-line styled-span output) was deleted with its overlay worker.
1724// Syntax colour now flows through the cells / `DisplayMatrix`
1725// substrate; nothing reads a span-grid cell off `RenderState` any
1726// more. `RowRun` (below) survives — it is the `DisplayLine` run
1727// type, a separate live consumer.
1728
1729/// One coloured run within a display row's combined text.
1730///
1731/// Perf plan A.2. Carries either a [`lattice_syntax::Style`] tag
1732/// for source bytes or an `Inlay` discriminant for inlay-spliced
1733/// bytes. Runs are NOT baked to RGB — renderers map `style → Rgba`
1734/// at paint time against the resolved theme table. Reasons for the
1735/// tag, not the colour:
1736///
1737/// - A theme switch doesn't invalidate the producer's cache (only
1738/// the colour resolution at paint changes; the run topology
1739/// doesn't).
1740/// - The producer stays theme-independent — no theme reads.
1741///
1742/// Slice A.2b.2 promoted this from a struct to an enum so the
1743/// producer can mark inlay-text bytes distinctly. Consumers map
1744/// `Inlay` to their inlay-virtual-text colour without having to
1745/// track byte ranges separately.
1746///
1747/// display-line B-series: this is the `DisplayLine` run type, owned
1748/// by the cells / `DisplayMatrix` substrate. (The deleted
1749/// `RowPrepaint` was the original consumer; B4.2 removed it but
1750/// `display_matrix.rs` keeps `RowRun` as its style-run type.)
1751#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1752pub enum RowRun {
1753 /// Source-text run carrying a tree-sitter style tag.
1754 Source {
1755 /// Number of utf-8 bytes in this run inside the combined text.
1756 len: u32,
1757 /// Style tag from the highlight grammar. `Style::Default`
1758 /// for runs that fall outside any tree-sitter capture.
1759 style: lattice_syntax::Style,
1760 },
1761 /// Inlay-virtual-text run. Carries only the byte length; the
1762 /// colour is consumer-resolved.
1763 Inlay {
1764 /// Number of utf-8 bytes of inlay text in this run.
1765 len: u32,
1766 },
1767}
1768
1769impl RowRun {
1770 /// Byte length of this run inside the row's combined text.
1771 /// Convenience accessor so consumers don't need to match every
1772 /// time the partition is walked. (Clippy: paired enums often
1773 /// also expose `is_empty`, but `RowRun::Inlay { len: 0 }`
1774 /// would be a producer bug — the partition is built with the
1775 /// invariant that every run is non-empty, so an `is_empty`
1776 /// would be misleading.)
1777 #[allow(clippy::len_without_is_empty)]
1778 pub fn len(&self) -> u32 {
1779 match self {
1780 RowRun::Source { len, .. } | RowRun::Inlay { len } => *len,
1781 }
1782 }
1783}
1784
1785// display-line B4.2 (gut + rename): `RowPrepaint` (one pre-painted
1786// visible row) and `VisibleRows` (the per-pane prepaint output cell)
1787// were deleted with the overlay worker's dead span/row cache. Their
1788// consumers migrated to the cells / `DisplayMatrix` substrate in the
1789// B-series; nothing reads a prepaint-rows cell off `RenderState` any
1790// more. `RowRun` (above) survives as the `DisplayLine` run type.
1791
1792/// Perf plan B.2: overlay layer tag carried on each per-row
1793/// pre-bucketed quad in [`StaticOverlayQuads`]. The renderer uses
1794/// the tag to interleave cursor-coupled layers (`current_match`,
1795/// `visual_range`) at the right precedence at prepaint time:
1796///
1797/// ```text
1798/// doc_highlight → all_matches → current_match → visual → substitute
1799/// ```
1800///
1801/// Push order = paint order = visual precedence (`paint_quad`
1802/// overwrites; later quads in each row's Vec win).
1803#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1804pub enum OverlayLayer {
1805 /// LSP `textDocument/documentHighlight` ranges — symbol
1806 /// occurrences under the cursor. Cursor-settle cadence (the
1807 /// LSP returns a new response after the cursor lands on a
1808 /// new symbol); we treat it as static across cursor blinks.
1809 DocHighlight,
1810 /// `hlsearch` matches across the active document. Bumps on
1811 /// `text_version` edits and search-query changes.
1812 AllMatches,
1813 /// `:s/pat/repl/` preview overlay. Bumps as the substitute
1814 /// command line is typed.
1815 Substitute,
1816}
1817
1818/// Perf plan B.2: one pre-bucketed static-overlay quad inside a
1819/// row of [`StaticOverlayQuads`]. Coordinates are in
1820/// **source utf-8 byte space** — the byte offsets into the SOURCE
1821/// line text (not into any combined / inlay-spliced row text).
1822///
1823/// Why source-byte and not combined-column: both renderer peers
1824/// already do their own coordinate transforms (GPUI runs
1825/// `byte_to_combined_col` for cursor / diagnostic underlines per
1826/// frame; TUI uses source bytes directly for overlay application).
1827/// Publishing in source-byte space lets the TUI consume the bucket
1828/// without any reverse mapping, and the per-quad conversion GPUI
1829/// pays on prepaint is cheap (one `chars().count()` walk on a
1830/// single line, amortised over a handful of quads per row).
1831#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1832pub struct RowOverlayQuad {
1833 pub layer: OverlayLayer,
1834 pub source_byte_start: u32,
1835 pub source_byte_end: u32,
1836}
1837
1838/// Perf plan B.2: worker-published per-row pre-bucketed
1839/// static-overlay quads for the active pane's visible window.
1840///
1841/// `quads[i]` is the per-row tagged quad list for visible line
1842/// `i` (i.e. doc line `scroll + i`). Each entry tags its overlay
1843/// layer ([`OverlayLayer`]) so the renderer can interleave
1844/// cursor-coupled layers (`current_match`, `visual_range`) in
1845/// the right precedence order at prepaint time.
1846///
1847/// Active-pane only — inactive panes keep the legacy per-frame
1848/// bucket path. The cell is published on every overlay-worker
1849/// `recompute` and invalidates on its own axis
1850/// ([`VisibleHighlightsKey::static_overlay_version`]) so a
1851/// search-query bump re-buckets without touching unrelated state.
1852#[derive(Debug, Clone)]
1853pub struct StaticOverlayQuads {
1854 /// `Arc<[T]>` per the D.1 pattern — HOLD / partial-reuse
1855 /// paths bump the outer Arc instead of cloning the per-row
1856 /// `Vec`s. Typical viewport (120 rows) × typical quads/row
1857 /// (≤ a few per layer) keeps this comfortably small.
1858 pub quads: Arc<[Vec<RowOverlayQuad>]>,
1859 pub computed_for_key: VisibleHighlightsKey,
1860}
1861
1862impl Default for StaticOverlayQuads {
1863 fn default() -> Self {
1864 Self {
1865 quads: Arc::from(Vec::<Vec<RowOverlayQuad>>::new().into_boxed_slice()),
1866 computed_for_key: VisibleHighlightsKey::default(),
1867 }
1868 }
1869}
1870
1871/// Perf plan A.2 slice A.2b.2: content hash of a flattened inlay-
1872/// hint list. Stable per-payload (same vec → same hash) so it can
1873/// drive [`VisibleHighlightsKey::inlay_version`] for the worker's
1874/// row-cache invalidation. Empty list hashes to 0 — matches the
1875/// `inlay_version: 0` default and keeps the steady-state no-hint
1876/// path on a single cheap branch.
1877///
1878/// Implementation is a fold over each row's `(line, byte, text)`
1879/// triple using the default `DefaultHasher` (SipHash 1-3, suitable
1880/// for non-cryptographic versioning). For the typical viewport of
1881/// <200 hints this is sub-µs once per publish.
1882pub fn inlay_hints_version(rows: &[InlayHintRow]) -> u64 {
1883 use std::hash::{Hash, Hasher};
1884 if rows.is_empty() {
1885 return 0;
1886 }
1887 let mut h = std::collections::hash_map::DefaultHasher::new();
1888 for r in rows {
1889 r.line.hash(&mut h);
1890 r.byte.hash(&mut h);
1891 r.text.hash(&mut h);
1892 }
1893 h.finish()
1894}
1895
1896/// Perf plan B.2: content hash of the three static-overlay layer
1897/// payloads. Drives [`VisibleHighlightsKey::static_overlay_version`]
1898/// for the worker's overlay-bucket invalidation. All-empty payloads
1899/// hash to 0 so the steady-state no-overlay path stays on a single
1900/// cheap branch (matches the `static_overlay_version: 0` default).
1901///
1902/// Each layer is tagged with a distinct discriminator byte
1903/// (0 / 1 / 2) before its ranges are folded in so the SAME range
1904/// list appearing in different layers produces distinct hashes —
1905/// avoids accidental cross-layer collisions.
1906///
1907/// Implementation is a fold over each range's `(start.line,
1908/// start.byte, end.line, end.byte)` quadruple using
1909/// `DefaultHasher` (SipHash 1-3, suitable for non-cryptographic
1910/// versioning). For the bounded sizes the editor enforces
1911/// (`max_hits` caps `all_matches` at 1000; doc_highlights /
1912/// substitute lists are typically <50) this is sub-µs once per
1913/// publish.
1914pub fn static_overlay_state_version(
1915 doc_highlights: &[lattice_protocol::position::Range],
1916 all_matches: &[lattice_protocol::position::Range],
1917 substitute_matches: &[lattice_protocol::position::Range],
1918) -> u64 {
1919 use std::hash::{Hash, Hasher};
1920 if doc_highlights.is_empty() && all_matches.is_empty() && substitute_matches.is_empty() {
1921 return 0;
1922 }
1923 let mut h = std::collections::hash_map::DefaultHasher::new();
1924 let fold_layer = |h: &mut std::collections::hash_map::DefaultHasher,
1925 tag: u8,
1926 ranges: &[lattice_protocol::position::Range]| {
1927 tag.hash(h);
1928 for r in ranges {
1929 r.start.line.hash(h);
1930 r.start.byte.hash(h);
1931 r.end.line.hash(h);
1932 r.end.byte.hash(h);
1933 }
1934 };
1935 fold_layer(&mut h, 0, doc_highlights);
1936 fold_layer(&mut h, 1, all_matches);
1937 fold_layer(&mut h, 2, substitute_matches);
1938 h.finish()
1939}
1940
1941/// Perf plan B.4: identity-preserving sub-state cache.
1942///
1943/// `Editor::build_render_state` rebuilds every sub-state `Arc`
1944/// from scratch on every publish today. Most publishes don't
1945/// touch most sub-states (cursor moves, scroll, etc. only update
1946/// `active_document`), so the inner allocations and deep clones
1947/// for the rest are wasted.
1948///
1949/// This struct lives behind `Editor::publish_cache` (a
1950/// `std::sync::Mutex<PublishCache>` because `Editor` is shared as
1951/// `Arc<Editor>` and therefore must be `Sync`). The mutex is
1952/// uncontested in practice — only `build_render_state` takes the
1953/// lock, and only the actor thread calls it.
1954///
1955/// Each slot pairs a `u64` version (captured from the
1956/// corresponding `Versioned<T>` field on `Editor` at the moment
1957/// the cached Arc was built) with the cached `Arc<SubState>`.
1958/// On the next publish, if the field's current version matches
1959/// the cached version, the cached Arc is reused (same Arc
1960/// identity preserved across the publish — `Arc::ptr_eq` returns
1961/// true). Otherwise the slot is rebuilt and the new
1962/// `(version, Arc)` pair is stored.
1963///
1964/// **Targeted sub-states:**
1965///
1966/// B.4.a (5 subs):
1967///
1968/// - `panes` — full sub-state Arc. Mutates on
1969/// `pane_tree.split_active` / `close_active` / `set_active` /
1970/// tab swap; otherwise stable.
1971/// - `modes` — full sub-state Arc. Mutates on `activate_mode` /
1972/// `deactivate_mode`; otherwise stable.
1973/// - `buffer_locals` — full sub-state Arc. Mutates on the few
1974/// `buffer_locals.entry(...).or_default()` / `.insert` / `.remove`
1975/// sites; otherwise stable. Largest savings because the per-entry
1976/// clone deep-walks the typed-map.
1977///
1978/// (ML.3c retired the `lsp_progress` inner-Arc cache with
1979/// `RenderState.lsp.progress`; DR.2 retired the sibling
1980/// `pane_highlights_map` cache.)
1981///
1982/// B.4.b (3 subs):
1983///
1984/// - `buffers` — full `Arc<BuffersRenderState>`. Keyed on
1985/// `buffer_uris.version()` alone. The inner `registry` field is
1986/// `Arc<Mutex<...>>`-backed so the SAME registry handle inside a
1987/// reused Arc still sees current state — no version dependency
1988/// on registry mutations needed for this sub-state's cache
1989/// hit/miss decision. Saves the `buffer_uris.clone()` HashMap
1990/// allocation per no-op publish.
1991/// - `tabs` — full `Arc<TabsRenderState>`. Composite key over
1992/// `tabs.version()` (tab list shape) + `active_tab` (per-publish
1993/// read) + `pane_tree.version()` (active pane's buffer) +
1994/// `buffers.version()` (label-resolving names). Saves the
1995/// `build_tabs_render_state` walk per no-op publish.
1996#[derive(Debug, Default)]
1997pub struct PublishCache {
1998 pub panes: Option<(u64, std::sync::Arc<PanesRenderState>)>,
1999 pub modes: Option<(u64, std::sync::Arc<ModesRenderState>)>,
2000 pub buffer_locals: Option<(u64, std::sync::Arc<BufferLocalsRenderState>)>,
2001 /// PI.4: keyed on `Editor::resolved_options_version`.
2002 pub resolved_opts: Option<(u64, std::sync::Arc<ResolvedOptionsRenderState>)>,
2003 // DR.2 (decoration-retention): `pane_highlights_map` cache slot
2004 // retired with the `pane_highlights` producer. ML.3c: `lsp_progress`
2005 // cache slot retired with `RenderState.lsp.progress`.
2006 /// Perf plan B.4.b: keyed on `buffer_uris.version()` only.
2007 pub buffers: Option<(u64, std::sync::Arc<BuffersRenderState>)>,
2008 /// Perf plan B.4.b: keyed on a composite of `tabs.version()`,
2009 /// `active_tab`, `pane_tree.version()`, `buffers.version()`.
2010 /// The composite is encoded into one `u64` via a small fold so
2011 /// the cache slot shape stays uniform with the other entries.
2012 pub tabs: Option<(u64, std::sync::Arc<TabsRenderState>)>,
2013 /// Slice I.4 (publish coalescing): depth of the in-flight
2014 /// `dispatch` / `handle_effect` batch. While `> 0`, intermediate
2015 /// `publish_render_state()` calls (from chained setters,
2016 /// `ensure_cursor_visible`, `maybe_reparse_syntax`, …) suppress
2017 /// their build/store/wake and instead set `publish_pending`; the
2018 /// single real publish fires once when the outermost batch
2019 /// unwinds. Collapses ~6 whole-world publishes per keystroke to 1
2020 /// (and 12 worker wakes to 2). Lives here (not on `Editor`) because
2021 /// `PublishCache` is `Default`-derived and already the actor's
2022 /// single publish-side lock — no new `Editor` construction churn,
2023 /// and `build_render_state` already takes this lock.
2024 pub publish_batch_depth: u32,
2025 /// A suppressed publish occurred during the current batch; flushed
2026 /// once when the batch depth returns to 0.
2027 pub publish_pending: bool,
2028 /// §12 paint gate: the `RenderState::paint_revision` of the last
2029 /// real (un-suppressed) publish. `publish_render_state` compares the
2030 /// freshly-built revision against this; a change means a
2031 /// render-visible non-cell surface moved off-keystroke, so the actor
2032 /// fires `paint_request`. Lives here (not on `Editor`) for the same
2033 /// reason as the cache slots: `PublishCache` is `Default`-derived and
2034 /// already the actor's single publish-side lock, so no `Editor`
2035 /// construction churn. `u64::MAX` would be a valid revision, so this
2036 /// starts at 0 and the first publish (revision rarely 0) paints — a
2037 /// harmless extra first frame.
2038 pub last_paint_revision: u64,
2039}
2040
2041impl PublishCache {
2042 /// Reset every slot. Useful in tests that want a clean
2043 /// baseline; production code never needs this (a version
2044 /// mismatch already triggers rebuild).
2045 pub fn clear(&mut self) {
2046 *self = Self::default();
2047 }
2048}
2049
2050/// Perf plan B.4: cache-or-build helper for the sub-state Arc
2051/// memoisation in `build_render_state`. Returns the cached Arc
2052/// when `current_version` matches the version stored in `slot`;
2053/// otherwise calls `build`, stores the result, and returns it.
2054///
2055/// Inlined into a single helper so each cached sub-state is one
2056/// line at the call site instead of the same `if let Some((v, arc))
2057/// = ... { ... } else { ... }` pattern repeated five times.
2058pub fn cached_or_build<T, F: FnOnce() -> std::sync::Arc<T>>(
2059 slot: &mut Option<(u64, std::sync::Arc<T>)>,
2060 current_version: u64,
2061 build: F,
2062) -> std::sync::Arc<T> {
2063 if let Some((v, arc)) = slot.as_ref()
2064 && *v == current_version
2065 {
2066 return arc.clone();
2067 }
2068 let next = build();
2069 *slot = Some((current_version, next.clone()));
2070 next
2071}
2072
2073/// One inlay-hint row published on
2074/// [`SyntaxRenderState::inlay_hints`].
2075///
2076/// Perf plan A.2 slice A.2b.1. Caller flattens the LSP
2077/// [`InlayHintLabel`](lattice_lsp::lsp_types::InlayHintLabel) to a
2078/// plain string and pre-applies `padding_left` / `padding_right`
2079/// spacing; consumers splice `text` into shaped lines at `byte`
2080/// (utf-8 byte offset into the original line's text) without
2081/// further label processing.
2082///
2083/// The renderer-side type (`lattice_ui_gpui::editor_element::InlayHintRow`)
2084/// is a re-export of this struct so the two peers exchange the same
2085/// shape across the published `RenderState`.
2086///
2087/// Sort order is the publisher's responsibility — A.2b.1 publishes
2088/// in the same order the LSP cache stores hints (insertion order);
2089/// the worker re-sorts by `(line, byte)` during its row-weave pass.
2090#[derive(Debug, Clone, PartialEq, Eq)]
2091pub struct InlayHintRow {
2092 /// 0-based buffer-line index.
2093 pub line: u32,
2094 /// 0-based utf-8 byte offset into that line's text.
2095 pub byte: u32,
2096 /// Pre-flattened label with `padding_left` / `padding_right`
2097 /// applied.
2098 pub text: String,
2099 /// How to paint it.
2100 ///
2101 /// DL.3a: inlay runs used to be forced to a hardcoded
2102 /// `Color::Named(DarkGray)` regardless of anything, so every
2103 /// producer got one colour and the registered `inlay.hint` theme
2104 /// element was bypassed. [`lattice_cells::Style::InlayHint`] is the
2105 /// default and reproduces LSP hints' appearance through the theme;
2106 /// a producer that owns its own vocabulary passes
2107 /// [`lattice_cells::Style::Element`] instead.
2108 pub style: lattice_cells::Style,
2109}
2110
2111impl InlayHintRow {
2112 /// An inlay painted as a plain hint — the LSP case.
2113 pub fn hint(line: u32, byte: u32, text: String) -> Self {
2114 Self {
2115 line,
2116 byte,
2117 text,
2118 style: lattice_cells::Style::InlayHint,
2119 }
2120 }
2121}
2122
2123/// Active picker's render-side projection.
2124///
2125/// Phase 5.8.AF.5 / Slice 3c.final.B (group 3): populated.
2126/// Carries an `Arc<Picker>` clone when a picker is open. The
2127/// renderer reads candidate list, selection index, query, title
2128/// through this clone instead of `app.editor.picker.as_ref()`.
2129/// `Picker` is large enough that the publish path goes through
2130/// `Arc::new(picker.clone())` per tick when open; the typical
2131/// candidate count keeps the clone sub-µs.
2132#[derive(Debug, Default, Clone)]
2133pub struct PickerRenderState {
2134 pub state: Option<std::sync::Arc<lattice_picker::Picker>>,
2135}
2136
2137/// Insert-completion + cmdline-completion popup state.
2138///
2139/// Phase 5.8.AF.5 / Slice 3c.final.B (group 3): populated.
2140/// Two slots — `insert` for the in-buffer ghost popup
2141/// (`InsertCompletionState`) and `state` for the cmdline
2142/// completion popup (`CompletionState`). Both `Arc`-wrapped so
2143/// reader frames share the allocation; both `None` when no
2144/// popup is open.
2145#[derive(Debug, Default, Clone)]
2146pub struct CompletionRenderState {
2147 pub insert: Option<std::sync::Arc<lattice_completion::InsertCompletionState>>,
2148 pub state: Option<std::sync::Arc<crate::state::CompletionState>>,
2149 /// PU.5c: the ephemeral registry buffer backing the completion-docs
2150 /// side popup (`Editor::completion_docs_buffer`). Both renderers read
2151 /// it to source the docs snapshot + per-buffer options from the
2152 /// registry and route the content through the `PaneId::COMPLETION_DOCS`
2153 /// compose seam. `None` when no docs buffer exists.
2154 pub docs_buffer_id: Option<lattice_core::BufferId>,
2155}
2156
2157/// Help / hover / signature popup's render-side projection.
2158///
2159/// Phase 5.8.AF.5 / Slice 3c.final.B (group 3): populated.
2160/// `buffer_id` mirrors `Editor::popup_buffer` (the active popup's
2161/// id, `None` when no popup is open). `placement` echoes
2162/// `Editor::popup_placement`. Help content + syntax / link styling
2163/// are carried by the registry Document at `buffer_id` and its live
2164/// cells-worker `DisplayMatrix` (grammar spans + the `ExtraHighlights`
2165/// link overlay) — both renderers source the popup's CONTENT, TITLE,
2166/// and line-count from the registry Document directly (PU.2 / PU.1b-4b),
2167/// so no popup-side `HelpBuffer` snapshot is published.
2168#[derive(Debug, Clone, Default)]
2169pub struct PopupRenderState {
2170 pub buffer_id: Option<lattice_core::BufferId>,
2171 /// PU.1b-4b: the popup's State-A view scroll (= `Editor::popup_scroll`).
2172 /// Genuine popup-only view state — NOT in the registry Document (the
2173 /// Document carries no per-popup scroll), so it is published here for
2174 /// both renderers' State-A (popup-shown-but-not-focused) anchor. State
2175 /// B (focused) reads the live `active_document.scroll` instead.
2176 pub scroll: u32,
2177 pub placement: lattice_core::ui::popup::PopupPlacement,
2178 /// 2026-05-22 popup-anchor: cursor position snapshotted at
2179 /// popup-open time. CursorAnchored renderers read this so
2180 /// the popup stays pinned to the symbol it was invoked from
2181 /// instead of re-deriving from the active cursor every frame
2182 /// (which made the popup follow motions). Both TUI and GPUI
2183 /// peers consume the same field from the published RS.
2184 pub anchor: Option<lattice_protocol::Position>,
2185 /// Document scroll at popup-open time. Used by CursorAnchored
2186 /// renderers to convert `anchor.line` (document coordinates)
2187 /// into a screen row without being confused by State B, where
2188 /// `active_document.scroll` reflects the POPUP's scroll
2189 /// rather than the document's. Fixed once at open; survives
2190 /// the State A → B transition.
2191 pub doc_scroll_at_anchor: u32,
2192}
2193
2194impl PopupRenderState {
2195 /// Convenience: `true` while a popup is open. Equivalent to
2196 /// `buffer_id.is_some()` but reads cleaner at call sites that
2197 /// previously gated on `app.editor.popup_buffer.is_some()`.
2198 pub fn is_open(&self) -> bool {
2199 self.buffer_id.is_some()
2200 }
2201}
2202
2203/// Buffer-locals per buffer. Slice 3c.final.B.9 — drops the
2204/// per-frame `read_editor(|e| e.buffer_locals.get(&buf).and_then(...))`
2205/// chain in the modeline, help-render, file-tree, and oil paint
2206/// paths to a wait-free Arc-bump lookup off the published
2207/// snapshot.
2208///
2209/// Outer `Arc<HashMap<...>>` for cheap clone-on-publish; per-entry
2210/// `Arc<BufferLocals>` so reads don't clone the typed-map body.
2211/// Mutation surface (mode `on_activate` / `on_deactivate` setters,
2212/// pulled-diagnostics writes, file-tree refresh) deep-clones each
2213/// modified entry via `BufferLocals::clone` and replaces the Arc;
2214/// reads stay wait-free under concurrent mutation.
2215#[derive(Debug, Default, Clone)]
2216pub struct BufferLocalsRenderState {
2217 pub map: std::sync::Arc<
2218 std::collections::HashMap<
2219 lattice_core::BufferId,
2220 std::sync::Arc<lattice_mode::BufferLocals>,
2221 >,
2222 >,
2223}
2224
2225/// Active modes per buffer. Slice 3c.final.B.11 — drops the
2226/// per-frame `read_editor(|e| e.active_modes.get(&buf))` call in
2227/// the modeline `is_messages_buffer` check to a wait-free Arc-bump
2228/// lookup off the published snapshot.
2229///
2230/// Outer `Arc<HashMap<...>>` for cheap clone-on-publish; per-entry
2231/// `Arc<ActiveModes>` so reads don't clone the inner mode chain.
2232/// Mutation surface (every `activate_mode` / `deactivate_mode`)
2233/// rebuilds the modified entry's Arc — rare path (buffer-switch),
2234/// not per-frame.
2235#[derive(Debug, Default, Clone)]
2236pub struct ModesRenderState {
2237 pub map: std::sync::Arc<
2238 std::collections::HashMap<
2239 lattice_core::BufferId,
2240 std::sync::Arc<lattice_mode::ActiveModes>,
2241 >,
2242 >,
2243 /// Read-only after boot — one Arc bump per `ModesRenderState`
2244 /// publish. Lets the renderer call `mode.status_line_items()`
2245 /// without an actor round-trip.
2246 pub mode_registry: std::sync::Arc<lattice_mode::ModeRegistry>,
2247}
2248
2249/// SG.2b — everything a renderer needs to paint a `GutterDecoration::Sign`,
2250/// resolved on the actor thread so the render path does neither a string
2251/// hash nor a theme lookup by name.
2252///
2253/// A placement carries a [`lattice_mode::SignId`]; painting it needs the
2254/// definition (for the glyph) and a [`crate::ui::theme::ElementId`] (for the
2255/// colour). The id is what the theme's `by_name` map answers, and asking it
2256/// per placed line per frame would put a `HashMap<String, _>` probe on the
2257/// hot path to re-derive something that changes only when a sign is defined
2258/// or a theme is loaded. So it is resolved once, here.
2259///
2260/// **Rebuilt every publish rather than version-cached**, deliberately: the
2261/// map is keyed by *definitions*, of which there are single digits (a
2262/// provider registers its vocabulary at load), not by placements, of which
2263/// there is one per visible marked line. Caching it would need an
2264/// invalidation axis folding the sign registry's version AND the theme's,
2265/// and a missed bump there paints a stale glyph in the wrong colour — the
2266/// failure mode is silent and the saving is a handful of string hashes on
2267/// the *publish* path.
2268#[derive(Debug, Default, Clone)]
2269pub struct SignsRenderState {
2270 /// The definition snapshot. `get(id)` answers `None` for a retired id,
2271 /// which paints nothing.
2272 pub registry: std::sync::Arc<lattice_mode::SignRegistry>,
2273 /// `SignId` → the element its `theme_element` names, for the ids whose
2274 /// element is actually registered. **Absent means fall back to
2275 /// `gutter.sign`** — a plugin that shipped a sign without its element,
2276 /// or one whose theme has not been reloaded. That fallback is SG.2b's
2277 /// whole point: a sign was placed to say something, and painting it
2278 /// invisibly is the one outcome that loses the information entirely.
2279 pub elements: std::sync::Arc<
2280 std::collections::HashMap<lattice_mode::SignId, crate::ui::theme::ElementId>,
2281 >,
2282 /// SG.4b: the interned built-in sign ids, so a renderer can hand them to
2283 /// the decoration context without reaching into `Editor` — GPUI reads only
2284 /// this published snapshot, and a renderer that had to reach further would
2285 /// be the seam leaking.
2286 pub builtin: lattice_mode::BuiltinSignIds,
2287}
2288
2289/// Typed-options registry handle. Slice 3c.final.B.10 — drops the
2290/// per-frame `read_editor(|e| e.config.get_typed::<X>())` calls
2291/// in `picker_display_is_minibuffer` and elsewhere to a wait-free
2292/// Arc bump off the published snapshot. The inner `ConfigRegistry`
2293/// is already Arc-shared, so a publish here is one Arc clone.
2294#[derive(Debug, Default, Clone)]
2295pub struct OptionsRenderState {
2296 pub config: std::sync::Arc<lattice_config::ConfigRegistry>,
2297}
2298
2299/// PI.4: per-buffer mode-resolved options, published so BOTH renderer
2300/// peers resolve a buffer's options (Number, Wrap, CursorLine, …) through
2301/// ONE renderer-agnostic seam — [`RenderState::resolved_option_for`] —
2302/// instead of the TUI reading the live editor and GPUI reading the active
2303/// document's `option_cache`. Mirror of the host's
2304/// `Editor::resolved_options`; a buffer absent from the map falls back to
2305/// the global typed-option default via [`OptionsRenderState::config`].
2306#[derive(Debug, Default, Clone)]
2307pub struct ResolvedOptionsRenderState {
2308 pub map: std::sync::Arc<
2309 std::collections::HashMap<
2310 lattice_core::BufferId,
2311 std::sync::Arc<lattice_config::ResolvedOptions>,
2312 >,
2313 >,
2314}
2315
2316/// `*messages*` buffer + echo line state.
2317///
2318/// Slice 3c.final.B.7: populates the per-frame echo-area read.
2319/// Renderers paint `last` as the bottom-row message (replacing the
2320/// modeline when present). The full ring of messages stays
2321/// host-side; only the surface the renderer paints lives here.
2322#[derive(Debug, Default, Clone)]
2323pub struct MessagesRenderState {
2324 /// Last echo-area message — `None` when the row is blank.
2325 /// Wrapped in `Arc` so the per-publish clone is one Arc bump
2326 /// regardless of how long the text is.
2327 pub last: Option<std::sync::Arc<crate::action::EchoMessage>>,
2328}
2329
2330/// NOTIF.1b: corner-anchored notifications, as the renderers see them.
2331///
2332/// Published into [`RenderState`] the way every other per-frame surface
2333/// is, rather than read back through the editor — and that is not
2334/// stylistic. In production the renderer holds an `EditorActorHandle`
2335/// and reaching the editor is a blocking RPC; a per-frame round-trip
2336/// asking "any notifications?" would sit on the paint path, which is
2337/// what paramount goal #1 forbids.
2338#[derive(Debug, Default, Clone)]
2339pub struct NotificationsRenderState {
2340 /// What to paint, oldest first, already limited to
2341 /// `lattice_notify::MAX_VISIBLE`.
2342 pub visible: Vec<lattice_notify::Notification>,
2343 /// How many are waiting behind them — painted as a "+N more" line
2344 /// rather than dropped silently.
2345 pub queued: usize,
2346}
2347
2348/// Modeline status (cmdline text, search indicator, mode hints).
2349///
2350/// Slice 3c.final.B.7: populates the per-frame fields the
2351/// renderer reads through `read_editor` today (cmdline text,
2352/// search pattern + direction, auto-submit hint). The active mode
2353/// chain remains via the existing `active_modes` lookup; future
2354/// slices may lift that here too.
2355#[derive(Debug, Clone)]
2356pub struct ModelineRenderState {
2357 /// Renderer-side cmdline text. `Arc<str>` so per-publish clone
2358 /// is one Arc bump regardless of length.
2359 pub cmdline_text: std::sync::Arc<str>,
2360 /// MG.51: the `*prompt-line*` buffer's text while `ModalState::Prompt`
2361 /// is live (`Effect::OpenPrompt` — magit's branch checkout, tag name,
2362 /// …), empty otherwise.
2363 ///
2364 /// Published rather than read off the active document by each peer,
2365 /// because `open_prompt_line` splits the prompt across two places —
2366 /// the LABEL goes to the echo, the typed text to this buffer — and a
2367 /// renderer that knows only about the echo draws the label with no
2368 /// input under it, which is exactly the bug this field fixes. One
2369 /// field, both peers, and GPUI's `bottom_row_content` stays pure.
2370 pub prompt_text: std::sync::Arc<str>,
2371 /// `:describe-key<CR>` armed the chord-capture prompt; the
2372 /// renderer paints a "press a chord" hint after the cursor.
2373 pub auto_submit_hint: bool,
2374 /// `/` or `?` search pattern; `None` when no search is in
2375 /// flight. `Arc<str>` for the same reason as `cmdline_text`.
2376 pub search_pattern: Option<std::sync::Arc<str>>,
2377 /// `/` (forward) or `?` (backward); accompanies
2378 /// `search_pattern` and is `None` whenever `search_pattern` is.
2379 pub search_direction: Option<lattice_grammar::SearchDirection>,
2380 /// MB.2: `true` while the `:` line is expanded into the tier-2
2381 /// mini-buffer band; the renderer grows the echo row into a band and
2382 /// draws [`Self::cmdline_full_text`] instead of the one-row line.
2383 pub cmdline_expanded: bool,
2384 /// MB.2: the full (possibly multi-line) `:` line text, for the
2385 /// expanded band. Empty / unused in tier 1 (the one-row `cmdline_text`
2386 /// carries the single line there).
2387 pub cmdline_full_text: std::sync::Arc<str>,
2388 /// MB.2e: the resolved `command-line.expand-height` policy. The
2389 /// renderer applies it to the live frame height (`ExpandHeight::rows`)
2390 /// to size the expanded band. Published (not read at draw time) so the
2391 /// draw path stays free of config reads.
2392 pub cmdline_expand_height: lattice_config::ExpandHeight,
2393 /// MB.4: live `:` line decorations — syntax spans, a validation
2394 /// error, and a parameter hint. Produced off the render thread
2395 /// (`refresh_command_line_decorations`) and read here at draw time.
2396 /// `None` when the command line is closed / bare.
2397 pub cmdline_decorations: Option<crate::excommand::CommandLineDecorations>,
2398}
2399
2400impl Default for ModelineRenderState {
2401 fn default() -> Self {
2402 Self {
2403 cmdline_text: std::sync::Arc::from(""),
2404 auto_submit_hint: false,
2405 search_pattern: None,
2406 search_direction: None,
2407 cmdline_expanded: false,
2408 cmdline_full_text: std::sync::Arc::from(""),
2409 prompt_text: std::sync::Arc::from(""),
2410 cmdline_expand_height: lattice_config::ExpandHeight::default(),
2411 cmdline_decorations: None,
2412 }
2413 }
2414}
2415
2416/// Renderer lifecycle flags published per tick.
2417///
2418/// Phase 5.8.AF.5 / Slice 3c.final.B (group 6). Carries the
2419/// three per-tick "renderer should notice this" signals:
2420///
2421/// - `should_quit` — set by `:q` / `:wq` / `:qa!` (host-side
2422/// `Editor::should_quit`). The TUI's `main_loop` reads this at
2423/// the top of every iteration to break out; the GPUI peer's
2424/// `on_key_down` reads it after dispatch to call `cx.quit()`.
2425/// - `pending_redraw` — set by `<C-l>` (`RedrawScreen`) so the
2426/// TUI peer clears the terminal buffer on the next frame. The
2427/// field is "renderer-consumed" — a separate
2428/// `acknowledge_pending_redraw` action (slice C target) is
2429/// needed to clear it from the renderer side once consumed.
2430/// - `terminal_width` — last reported terminal column count from
2431/// the TUI peer. Mirrored here so any future renderer-thread
2432/// reader sees the published value instead of the live
2433/// `Editor::terminal_width` field.
2434#[derive(Debug, Default, Clone)]
2435pub struct LifecycleRenderState {
2436 pub should_quit: bool,
2437 pub pending_redraw: bool,
2438 pub terminal_width: Option<u16>,
2439}
2440
2441/// Translator inputs for the renderer's input loop.
2442///
2443/// Phase 5.8.AF.5 / Slice 3c.final.B (group 5) — closes the
2444/// audit's slice-D holdout for the `TranslateContext` `&'a` borrow
2445/// batch (`builtins`, `keymap`, `partial_chord`). The translator
2446/// runs on the renderer thread; in the slice-E end-state it can
2447/// no longer borrow through `&Editor`. Publishing these inputs as
2448/// owned/Arc-backed values lets `runtime.rs` build a
2449/// `TranslateContext` from a single snapshot load per keystroke.
2450///
2451/// All three fields are cheap to publish:
2452/// - `builtins` is `Copy` so the field is a plain move.
2453/// - `keymap` is an `Arc<KeymapRegistry>`-backed handle; `Clone`
2454/// is one Arc bump and `resolve()` stays wait-free via the
2455/// handle's internal `ArcSwap`.
2456/// - `partial_chord` is small (typically 0–2 entries during a
2457/// chord sequence) so the per-publish `Arc<[KeyChord]>` clone
2458/// is sub-µs.
2459#[derive(Debug, Default, Clone)]
2460pub struct TranslatorRenderState {
2461 pub builtins: lattice_grammar::builtins::Builtins,
2462 pub keymap: crate::keymap_registry::KeymapHandle,
2463 pub partial_chord: std::sync::Arc<[crate::chord::KeyChord]>,
2464 /// D.5.b (2026-05-30): active buffer's minor modes,
2465 /// snapshotted at publish time and threaded into
2466 /// `TranslateContext` so chord bindings registered against
2467 /// `MinorMode(ModeId)` layers gate on per-buffer
2468 /// activation (K.1.c). Empty in headless / mid-boot when
2469 /// no buffer has been activated yet. One Arc bump per
2470 /// publish; the slice is typically 0–3 entries
2471 /// (diff-mode, completion-popup-mode,
2472 /// active-snippet-mode in the busiest realistic case).
2473 pub active_minor_modes: std::sync::Arc<[lattice_mode::ModeId]>,
2474}
2475
2476/// Diagnostics — the proof-of-life sub-state for Slice 3a.
2477///
2478/// Carries a clone of `lattice_lsp::DiagnosticsLayer`. The
2479/// layer is itself `Arc<ArcSwap<DiagnosticsSnapshot>>`-backed,
2480/// so cloning is cheap (one Arc bump) and lookups
2481/// (`.line_severity`, `.diagnostics_arc`, `.diagnostics_for`)
2482/// are wait-free.
2483#[derive(Debug, Default, Clone)]
2484pub struct DiagnosticsRenderState {
2485 /// The diagnostics layer the renderer queries for per-line
2486 /// severity, per-buffer diagnostic lists, and counts.
2487 pub layer: lattice_lsp::DiagnosticsLayer,
2488 /// L4a.2 (lsp-architecture.md §15): the active inline end-of-line
2489 /// diagnostic summary, as `(line, summary)`, when the cursor-line
2490 /// idle gate has fired. `None` whenever the gate is disarmed
2491 /// (`ui.diagnostics.inline = off`, Insert/Replace, pre-idle, or
2492 /// the line carries no qualifying diagnostic). The renderer
2493 /// (L4a.3) splices `summary.text` as trailing virtual text on
2494 /// `line`, themed by `summary.severity_rank`. Recomputed each
2495 /// publish while the gate is visible, so diagnostics that land on
2496 /// the line after the gate fires refresh it for free.
2497 pub inline_summary: Option<(u32, lattice_lsp::InlineDiagnosticSummary)>,
2498}
2499
2500#[cfg(test)]
2501mod tests {
2502 use crate::action::Action;
2503 use crate::editor::Editor;
2504 use lattice_lsp::DiagnosticEvent;
2505 use lattice_lsp::lsp_types::{
2506 Diagnostic, DiagnosticSeverity, Position, PublishDiagnosticsParams, Range, Uri,
2507 };
2508 use std::str::FromStr;
2509 use std::sync::Arc;
2510
2511 /// Write `contents` to a uniquely-named temp file; the returned
2512 /// guard removes it on drop. Local to this module so the fold
2513 /// test can open a second on-disk buffer without depending on
2514 /// the `dispatch` test module's `write_temp`.
2515 struct TempFilePathRs(std::path::PathBuf);
2516 impl Drop for TempFilePathRs {
2517 fn drop(&mut self) {
2518 let _ = std::fs::remove_file(&self.0);
2519 }
2520 }
2521 fn write_temp_rs(contents: &str) -> TempFilePathRs {
2522 let mut path = std::env::temp_dir();
2523 path.push(format!(
2524 "lattice-foldbuf-{}-{}.tmp",
2525 std::process::id(),
2526 std::time::SystemTime::now()
2527 .duration_since(std::time::UNIX_EPOCH)
2528 .map(|d| d.as_nanos())
2529 .unwrap_or(0),
2530 ));
2531 std::fs::write(&path, contents).expect("write temp file");
2532 TempFilePathRs(path)
2533 }
2534
2535 /// Calling `dispatch()` publishes a fresh `RenderState` Arc
2536 /// into the editor's `ArcSwap`. The Arc identity must
2537 /// differ across dispatches — otherwise readers can't tell
2538 /// the snapshot is fresh.
2539 #[test]
2540 fn dispatch_publishes_fresh_render_state_arc() {
2541 let mut editor = Editor::default();
2542 let before = editor.render_state.load_full();
2543 // A no-op action is enough — the dispatch tail always
2544 // republishes regardless of whether state changed.
2545 editor.dispatch(Action::None);
2546 let after = editor.render_state.load_full();
2547 assert!(
2548 !std::sync::Arc::ptr_eq(&before, &after),
2549 "dispatch must publish a fresh RenderState Arc (identity changes)"
2550 );
2551 }
2552
2553 /// `publish_render_state` is the manual hook subsystems will
2554 /// call in Slice 3b. Verify it produces a fresh Arc too —
2555 /// not folded into the `dispatch()` tail by accident.
2556 #[test]
2557 fn publish_render_state_replaces_arc() {
2558 let mut editor = Editor::default();
2559 let before = editor.render_state.load_full();
2560 editor.publish_render_state();
2561 let after = editor.render_state.load_full();
2562 assert!(
2563 !std::sync::Arc::ptr_eq(&before, &after),
2564 "publish_render_state must store a fresh Arc"
2565 );
2566 }
2567
2568 /// The proof-of-life path: write a diagnostic into the
2569 /// editor's `lsp_diagnostics` layer, publish, and confirm
2570 /// the renderer-side read through `render_state` sees it.
2571 #[test]
2572 fn diagnostics_substate_reflects_published_layer() {
2573 let mut editor = Editor::default();
2574 let uri = Uri::from_str("file:///tmp/test.rs").expect("valid uri");
2575 let diag = Diagnostic {
2576 range: Range {
2577 start: Position {
2578 line: 4,
2579 character: 0,
2580 },
2581 end: Position {
2582 line: 4,
2583 character: 5,
2584 },
2585 },
2586 severity: Some(DiagnosticSeverity::ERROR),
2587 code: None,
2588 code_description: None,
2589 source: None,
2590 message: "synthetic".to_string(),
2591 related_information: None,
2592 tags: None,
2593 data: None,
2594 };
2595 editor.lsp_diagnostics.apply(DiagnosticEvent::from_lsp(
2596 Arc::from("rust"),
2597 PublishDiagnosticsParams {
2598 uri: uri.clone(),
2599 version: None,
2600 diagnostics: vec![diag],
2601 },
2602 ));
2603 // Force a publication so the render-state layer reflects
2604 // the freshly-written diagnostic. In prod this happens
2605 // at the dispatch tail; tests poke it directly.
2606 editor.publish_render_state();
2607 let rs = editor.render_state.load_full();
2608 assert_eq!(
2609 rs.diagnostics.layer.line_severity(&uri, 4),
2610 Some(DiagnosticSeverity::ERROR),
2611 "renderer reading via render_state must see the diagnostic the editor wrote"
2612 );
2613 assert_eq!(
2614 rs.diagnostics.layer.line_severity(&uri, 0),
2615 None,
2616 "lines without a diagnostic return None through the same path"
2617 );
2618 }
2619
2620 // --- L4a.2: inline cursor-line diagnostic-summary idle gate ---
2621
2622 /// Build an editor whose active buffer maps to `uri` with one
2623 /// ERROR diagnostic on `line`, cursor parked there. Used by the
2624 /// inline-summary gate tests.
2625 fn editor_with_diag_on_line(uri: &Uri, line: u32, message: &str) -> Editor {
2626 use lattice_protocol::position::Position as ProtoPos;
2627 let mut editor = Editor::default();
2628 editor
2629 .buffer_uris
2630 .insert(editor.document_buffer_id, uri.clone());
2631 editor.cursor = ProtoPos::new(line, 0);
2632 let diag = Diagnostic {
2633 range: Range {
2634 start: Position { line, character: 0 },
2635 end: Position { line, character: 5 },
2636 },
2637 severity: Some(DiagnosticSeverity::ERROR),
2638 code: None,
2639 code_description: None,
2640 source: None,
2641 message: message.to_string(),
2642 related_information: None,
2643 tags: None,
2644 data: None,
2645 };
2646 editor.lsp_diagnostics.apply(DiagnosticEvent::from_lsp(
2647 Arc::from("rust"),
2648 PublishDiagnosticsParams {
2649 uri: uri.clone(),
2650 version: None,
2651 diagnostics: vec![diag],
2652 },
2653 ));
2654 editor
2655 }
2656
2657 /// Landing on a new line arms the idle gate (deadline set,
2658 /// summary not yet visible). The eol summary only appears once the
2659 /// timer fires — never on the cursor-move publish itself.
2660 #[test]
2661 fn inline_summary_armed_but_hidden_until_idle_fires() {
2662 let uri = Uri::from_str("file:///tmp/gate.rs").unwrap();
2663 let mut editor = editor_with_diag_on_line(&uri, 4, "synthetic");
2664 editor.publish_render_state();
2665 assert_eq!(editor.inline_diag_line, Some(4));
2666 assert!(editor.inline_diag_deadline.is_some(), "gate armed");
2667 assert!(!editor.inline_diag_visible);
2668 assert!(
2669 editor
2670 .render_state
2671 .load_full()
2672 .diagnostics
2673 .inline_summary
2674 .is_none(),
2675 "no summary before the idle deadline fires"
2676 );
2677
2678 // Simulate the editor actor's timer arm firing.
2679 editor.fire_inline_diag_gate();
2680 editor.publish_render_state();
2681 let rs = editor.render_state.load_full();
2682 let (line, summary) = rs
2683 .diagnostics
2684 .inline_summary
2685 .clone()
2686 .expect("summary visible after fire");
2687 assert_eq!(line, 4);
2688 assert_eq!(summary.severity_rank, 0);
2689 assert!(summary.text.contains("synthetic"));
2690 // Firing cleared the deadline so the pinned sleep won't refire.
2691 assert!(editor.inline_diag_deadline.is_none());
2692 }
2693
2694 /// Insert (active text entry) suppresses the summary: entering
2695 /// Insert hides any visible summary and disarms the gate, which
2696 /// re-arms on return to Normal.
2697 #[test]
2698 fn inline_summary_suppressed_in_insert() {
2699 use lattice_grammar::ModalState;
2700 let uri = Uri::from_str("file:///tmp/gate.rs").unwrap();
2701 let mut editor = editor_with_diag_on_line(&uri, 2, "boom");
2702 editor.publish_render_state();
2703 editor.fire_inline_diag_gate();
2704 editor.publish_render_state();
2705 assert!(
2706 editor
2707 .render_state
2708 .load_full()
2709 .diagnostics
2710 .inline_summary
2711 .is_some()
2712 );
2713
2714 editor.modal = ModalState::Insert;
2715 editor.publish_render_state();
2716 assert!(!editor.inline_diag_visible);
2717 assert!(editor.inline_diag_deadline.is_none());
2718 assert!(
2719 editor.inline_diag_line.is_none(),
2720 "armed line cleared so the gate re-arms on leaving Insert"
2721 );
2722 assert!(
2723 editor
2724 .render_state
2725 .load_full()
2726 .diagnostics
2727 .inline_summary
2728 .is_none()
2729 );
2730
2731 // Back to Normal re-arms (hidden again until idle).
2732 editor.modal = ModalState::Normal;
2733 editor.publish_render_state();
2734 assert_eq!(editor.inline_diag_line, Some(2));
2735 assert!(editor.inline_diag_deadline.is_some());
2736 assert!(!editor.inline_diag_visible);
2737 }
2738
2739 /// Moving the cursor to a new line re-arms the gate and hides the
2740 /// previous line's summary immediately.
2741 #[test]
2742 fn inline_summary_hidden_when_cursor_leaves_line() {
2743 use lattice_protocol::position::Position as ProtoPos;
2744 let uri = Uri::from_str("file:///tmp/gate.rs").unwrap();
2745 let mut editor = editor_with_diag_on_line(&uri, 3, "err here");
2746 editor.publish_render_state();
2747 editor.fire_inline_diag_gate();
2748 editor.publish_render_state();
2749 assert!(
2750 editor
2751 .render_state
2752 .load_full()
2753 .diagnostics
2754 .inline_summary
2755 .is_some()
2756 );
2757
2758 // Cursor moves to a clean line: re-arm, summary gone.
2759 editor.cursor = ProtoPos::new(0, 0);
2760 editor.publish_render_state();
2761 assert_eq!(editor.inline_diag_line, Some(0));
2762 assert!(!editor.inline_diag_visible);
2763 assert!(
2764 editor
2765 .render_state
2766 .load_full()
2767 .diagnostics
2768 .inline_summary
2769 .is_none()
2770 );
2771 }
2772
2773 /// `ui.diagnostics.inline = off` disarms the gate entirely — even
2774 /// with the cursor on a diagnostic line and the timer force-fired,
2775 /// no summary is published.
2776 #[test]
2777 fn inline_summary_off_option_disarms() {
2778 let uri = Uri::from_str("file:///tmp/gate.rs").unwrap();
2779 let mut editor = editor_with_diag_on_line(&uri, 1, "nope");
2780 editor.config.init_from_linkme();
2781 editor
2782 .config
2783 .parse_and_set_command("ui.diagnostics.inline=off")
2784 .unwrap();
2785 // Force-fire, then publish: `update_inline_diag_gate` clears it
2786 // because the option is Off before `build_render_state` reads.
2787 editor.fire_inline_diag_gate();
2788 editor.publish_render_state();
2789 assert!(!editor.inline_diag_visible);
2790 assert!(
2791 editor
2792 .render_state
2793 .load_full()
2794 .diagnostics
2795 .inline_summary
2796 .is_none()
2797 );
2798 }
2799
2800 /// Slice 3c.1: `ActiveDocumentRenderState` reflects current
2801 /// editor state after dispatch. Mutating `editor.cursor`
2802 /// directly + publishing produces a snapshot whose
2803 /// `cursor` field matches.
2804 #[test]
2805 fn active_document_substate_reflects_editor_fields() {
2806 use lattice_protocol::position::Position;
2807 let mut editor = Editor {
2808 cursor: Position::new(7, 3),
2809 scroll: 5,
2810 viewport_height: 30,
2811 ..Default::default()
2812 };
2813 editor.publish_render_state();
2814 let rs = editor.render_state.load();
2815 assert_eq!(rs.active_document.load().cursor, Position::new(7, 3));
2816 assert_eq!(rs.active_document.load().scroll, 5);
2817 assert_eq!(rs.active_document.load().viewport_height, 30);
2818 assert_eq!(
2819 rs.active_document.load().modal,
2820 lattice_grammar::ModalState::Normal
2821 );
2822 assert_eq!(
2823 rs.active_document.load().buffer_kind,
2824 lattice_core::BufferKind::Document
2825 );
2826 // Snapshot is a fresh Arc clone from `editor.document`.
2827 // Identity isn't preserved across publications (naive
2828 // rebuild today); the value is what matters.
2829 assert_eq!(rs.active_document.load().snapshot.buffer.byte_len(), 0);
2830 // Slice 3c.atomic.J: translator-context mirror fields
2831 // default to zero/false when no count, no macro, no
2832 // picker, no completion, no snippet is active.
2833 assert_eq!(rs.active_document.load().pending_count, 0);
2834 assert_eq!(rs.active_document.load().op_count, 0);
2835 assert!(!rs.active_document.load().macro_recording);
2836 assert!(!rs.active_document.load().completion_open);
2837 assert!(!rs.active_document.load().picker_open);
2838 assert!(!rs.active_document.load().snippet_active);
2839 }
2840
2841 /// Slice 3c.atomic.J: writing the translator-context
2842 /// fields directly + publishing produces a snapshot whose
2843 /// mirror fields match. Proves `runtime.rs` building
2844 /// `TranslateContext` from `app.ad()` sees the same values
2845 /// it used to read from `app.editor.X` directly.
2846 #[test]
2847 fn active_document_substate_reflects_translator_context_fields() {
2848 let mut editor = Editor {
2849 pending_count: 7,
2850 op_count: 3,
2851 ..Default::default()
2852 };
2853 editor.publish_render_state();
2854 let rs = editor.render_state.load();
2855 assert_eq!(rs.active_document.load().pending_count, 7);
2856 assert_eq!(rs.active_document.load().op_count, 3);
2857 // The Option-typed fields (`macro_recording`,
2858 // `completion_state`, `picker`, `active_snippet`) need
2859 // domain types to populate. The mirror's contract is
2860 // tested via the `.is_some()` projection; constructing
2861 // those types here would just be `.is_some()` returning
2862 // true for a freshly-built variant, so the existing
2863 // `false` baseline from the prior test plus the explicit
2864 // u32 mirrors here are enough to lock the contract.
2865 }
2866
2867 /// Slice 3b.0 template proof: a write into the editor's
2868 /// `lsp_document_highlights` `ArcSwapOption` is visible
2869 /// through `render_state.lsp.document_highlights.load()`
2870 /// without re-publishing `RenderState`.
2871 ///
2872 /// This is the contract every Slice 3b.* migration relies
2873 /// on: the background request task `.store()`s directly
2874 /// into the cache slot, and renderer reads through
2875 /// `RenderState` see the new value immediately because the
2876 /// sub-state's Arc points at the same underlying ArcSwap.
2877 #[test]
2878 fn document_highlights_substate_reflects_arcswap_writes() {
2879 use lattice_lsp::cache::DocumentHighlightCache;
2880 use lattice_lsp::lsp_types::{
2881 DocumentHighlight, DocumentHighlightKind, Position as LspPosition, Range as LspRange,
2882 };
2883 use lattice_protocol::position::Position;
2884 use std::sync::Arc;
2885 let mut editor = Editor::default();
2886 // Force a publication so RenderState.lsp carries a clone
2887 // of the editor's lsp_document_highlights ArcSwap.
2888 editor.publish_render_state();
2889 // Sanity: empty initially.
2890 assert!(
2891 editor
2892 .render_state
2893 .load()
2894 .lsp
2895 .document_highlights
2896 .load()
2897 .is_none(),
2898 "renderer must see None before any task writes"
2899 );
2900 // Simulate the spawned task's write -- same code path
2901 // it executes when the LSP response arrives.
2902 editor
2903 .lsp_document_highlights
2904 .store(Some(Arc::new(DocumentHighlightCache {
2905 buffer_id: editor.document_buffer_id,
2906 cursor: Position::new(0, 0),
2907 highlights: vec![DocumentHighlight {
2908 range: LspRange {
2909 start: LspPosition {
2910 line: 1,
2911 character: 0,
2912 },
2913 end: LspPosition {
2914 line: 1,
2915 character: 5,
2916 },
2917 },
2918 kind: Some(DocumentHighlightKind::READ),
2919 }],
2920 })));
2921 // Renderer reads through RenderState -- no re-publish
2922 // needed. The sub-state's Arc points at the same
2923 // ArcSwap the task wrote to.
2924 let rs = editor.render_state.load();
2925 let cache = rs
2926 .lsp
2927 .document_highlights
2928 .load_full()
2929 .expect("post-store, renderer must see the cache");
2930 assert_eq!(
2931 cache.highlights.len(),
2932 1,
2933 "renderer must see the highlight the task stored"
2934 );
2935 assert_eq!(cache.highlights[0].range.start.line, 1);
2936 }
2937
2938 /// Perf plan A.2 slice A.2b.1: `syntax.inlay_hints` is empty
2939 /// by default — no LSP cache entries, no mode toggle,
2940 /// `Editor::default()` straight off the constructor.
2941 #[test]
2942 fn syntax_inlay_hints_empty_on_default_editor() {
2943 let mut editor = Editor::default();
2944 editor.publish_render_state();
2945 let rs = editor.render_state.load();
2946 assert!(
2947 rs.syntax.inlay_hints.is_empty(),
2948 "expected empty inlay_hints on default editor; got {} entries",
2949 rs.syntax.inlay_hints.len()
2950 );
2951 }
2952
2953 /// Perf plan A.2 slice A.2b.1: even with hints in the LSP
2954 /// cache, `syntax.inlay_hints` stays empty while the
2955 /// `lsp-inlay-hint-mode` minor mode is OFF for the active
2956 /// buffer. The publish-time gate is the same one the
2957 /// renderer used to evaluate per-pane — moved off the hot
2958 /// path onto dispatch.
2959 #[test]
2960 fn syntax_inlay_hints_empty_when_mode_disabled() {
2961 use crate::per_buffer_cache::PerBufferCacheExt;
2962 use lattice_lsp::cache::LspInlayHintCache;
2963 use lattice_lsp::lsp_types::{InlayHint, InlayHintLabel, Position as LspPosition};
2964 let mut editor = Editor::default();
2965 editor.lsp_inlay_hints_cache.insert_for(
2966 editor.document_buffer_id,
2967 LspInlayHintCache {
2968 document_version: editor.document.snapshot().version,
2969 hints: vec![InlayHint {
2970 position: LspPosition {
2971 line: 0,
2972 character: 0,
2973 },
2974 label: InlayHintLabel::String(": i32".into()),
2975 kind: None,
2976 text_edits: None,
2977 tooltip: None,
2978 padding_left: None,
2979 padding_right: None,
2980 data: None,
2981 }],
2982 requested_first_line: 0,
2983 requested_last_line: u32::MAX,
2984 },
2985 );
2986 // Mode is OFF (Editor::default's `active_modes` doesn't
2987 // include `lsp-inlay-hint-mode`) — gate must drop the
2988 // hint despite the cache being non-empty.
2989 editor.publish_render_state();
2990 let rs = editor.render_state.load();
2991 assert!(
2992 rs.syntax.inlay_hints.is_empty(),
2993 "expected empty inlay_hints when mode is off; got {} entries",
2994 rs.syntax.inlay_hints.len()
2995 );
2996 }
2997
2998 // Happy-path coverage (cache populated, mode enabled, hints
2999 // flattened with padding + utf-16 → utf-8 conversion) is
3000 // exercised at the App layer by
3001 // `lattice_ui_tui::render::tests::inlay_hint_overlay_splices_virtual_text`
3002 // and will gain a direct worker-level test in A.2b.2 (the
3003 // worker will read `syntax.inlay_hints` and splice into
3004 // `RowPrepaint`; that path is unit-testable without an
3005 // `editor_boot` fixture).
3006
3007 /// Non-cached sub-states still rebuild Arc-fresh per publish.
3008 ///
3009 /// Perf plan B.4 introduced the per-sub-state cache for
3010 /// `panes` / `modes` / `buffer_locals` plus the inner-Arc
3011 /// memoisation for `lsp.progress` (DR.2 retired the sibling
3012 /// `syntax.pane_highlights` inner-Arc cache).
3013 /// The other sub-states — `diagnostics`, the outer `lsp`,
3014 /// `popup`, and the cursor-coupled `active_document` — still
3015 /// rebuild on every publish because their inputs change every
3016 /// tick (or because the savings haven't been measured worth the
3017 /// surface). This test pins the current behaviour for the
3018 /// non-cached set: Arc identity changes per publication.
3019 ///
3020 /// The positive contract for the CACHED set lives in
3021 /// [`cached_substates_preserve_arc_identity_on_no_op_publish`].
3022 #[test]
3023 fn substate_identity_changes_naively_per_publication() {
3024 let mut editor = Editor::default();
3025 let a = editor.render_state.load_full();
3026 editor.publish_render_state();
3027 let b = editor.render_state.load_full();
3028 // These sub-states are not cached by B.4 — Arc identity
3029 // changes per publication.
3030 assert!(!std::sync::Arc::ptr_eq(&a.diagnostics, &b.diagnostics));
3031 assert!(!std::sync::Arc::ptr_eq(&a.lsp, &b.lsp));
3032 assert!(!std::sync::Arc::ptr_eq(&a.popup, &b.popup));
3033 }
3034
3035 /// Perf plan B.4: identity-preserving Arc publish for the
3036 /// cached sub-states.
3037 ///
3038 /// On a no-op republish (publish twice with no mutation
3039 /// between), every cached sub-state's `Arc` survives — same
3040 /// pointer, no allocation. This is the wait-free read seam's
3041 /// new contract: renderers can short-circuit per-pane /
3042 /// per-mode work by comparing `Arc::ptr_eq` on consecutive
3043 /// frames.
3044 ///
3045 /// Covers (B.4.a + B.4.b):
3046 /// - `panes` (outer `Arc<PanesRenderState>`)
3047 /// - `modes` (outer `Arc<ModesRenderState>`)
3048 /// - `buffer_locals` (outer `Arc<BufferLocalsRenderState>`)
3049 /// - `buffers` (outer `Arc<BuffersRenderState>`)
3050 /// - `tabs` (outer `Arc<TabsRenderState>`)
3051 /// - `lsp.progress` (inner progress HashMap Arc)
3052 #[test]
3053 fn cached_substates_preserve_arc_identity_on_no_op_publish() {
3054 let mut editor = Editor::default();
3055 editor.publish_render_state();
3056 let a = editor.render_state.load_full();
3057 editor.publish_render_state();
3058 let b = editor.render_state.load_full();
3059 // Full sub-state caches (B.4.a).
3060 assert!(
3061 std::sync::Arc::ptr_eq(&a.panes, &b.panes),
3062 "panes sub-state should reuse its Arc when pane_tree.version() hasn't moved"
3063 );
3064 assert!(
3065 std::sync::Arc::ptr_eq(&a.modes, &b.modes),
3066 "modes sub-state should reuse its Arc when active_modes.version() hasn't moved"
3067 );
3068 assert!(
3069 std::sync::Arc::ptr_eq(&a.buffer_locals, &b.buffer_locals),
3070 "buffer_locals sub-state should reuse its Arc when buffer_locals.version() hasn't moved"
3071 );
3072 // Full sub-state caches (B.4.b).
3073 assert!(
3074 std::sync::Arc::ptr_eq(&a.buffers, &b.buffers),
3075 "buffers sub-state should reuse its Arc when buffer_uris.version() hasn't moved"
3076 );
3077 assert!(
3078 std::sync::Arc::ptr_eq(&a.tabs, &b.tabs),
3079 "tabs sub-state should reuse its Arc when its composite key hasn't moved"
3080 );
3081 // ML.3c retired the `lsp.progress` inner-Arc cache assertion with
3082 // the field; DR.2 retired the `syntax.pane_highlights` one.
3083 }
3084
3085 /// Perf plan B.4.b: a registry-only mutation (no buffer_uris
3086 /// change) preserves the `buffers` sub-state cache, but
3087 /// invalidates the `tabs` cache because tab labels depend on
3088 /// `buffers.name_of(...)`.
3089 #[test]
3090 fn buffers_substate_survives_registry_only_mutation_but_tabs_invalidates() {
3091 use crate::buffer_registry::{BufferData, BufferEntry};
3092 use crate::buffers::BufferFlags;
3093 use lattice_core::BufferId;
3094 let mut editor = Editor::default();
3095 editor.publish_render_state();
3096 let a = editor.render_state.load_full();
3097 // Registry mutation only — buffer_uris untouched.
3098 let id = BufferId(7_777);
3099 editor.buffers.insert(BufferEntry {
3100 id,
3101 name: Some("*scratch-versioned-test*".to_string()),
3102 flags: BufferFlags::default(),
3103 data: BufferData::FileTree(crate::buffer_registry::DocumentEntry {
3104 id,
3105 handle: std::sync::Arc::new(lattice_runtime::spawn_document(
3106 id,
3107 lattice_core::Document::empty(),
3108 std::sync::Arc::new(arc_swap::ArcSwap::from_pointee(
3109 lattice_grammar::registry::CommandRegistry::default(),
3110 )),
3111 )),
3112 }),
3113 });
3114 editor.publish_render_state();
3115 let b = editor.render_state.load_full();
3116 // `buffers` cache survives because `buffer_uris.version()`
3117 // didn't move — the inner registry handle still sees the
3118 // newly inserted buffer through the shared Arc<Mutex<...>>.
3119 assert!(
3120 std::sync::Arc::ptr_eq(&a.buffers, &b.buffers),
3121 "buffers Arc should survive a registry-only mutation when buffer_uris didn't change"
3122 );
3123 // `tabs` cache invalidates because the composite key
3124 // includes `buffers.version()`, which bumped on `insert`.
3125 assert!(
3126 !std::sync::Arc::ptr_eq(&a.tabs, &b.tabs),
3127 "tabs Arc must rebuild after a buffer insert (tab labels depend on registry names)"
3128 );
3129 }
3130
3131 /// Perf plan B.4: mutating one cached input invalidates ONLY
3132 /// that sub-state's cached Arc; the others survive.
3133 ///
3134 /// Touching `editor.active_modes` (via DerefMut) bumps the
3135 /// modes-version counter; the next `build_render_state` rebuilds
3136 /// the `modes` sub-state but leaves `panes` / `buffer_locals`
3137 /// alone — their versions haven't moved, so the cache hits.
3138 #[test]
3139 fn cached_substate_invalidation_is_per_field() {
3140 use lattice_core::BufferId;
3141 use lattice_mode::ActiveModes;
3142 let mut editor = Editor::default();
3143 editor.publish_render_state();
3144 let a = editor.render_state.load_full();
3145 // Touch active_modes through DerefMut: insert bumps the
3146 // wrapped HashMap's version counter once.
3147 editor
3148 .active_modes
3149 .insert(BufferId(99), ActiveModes::default());
3150 editor.publish_render_state();
3151 let b = editor.render_state.load_full();
3152 // `modes` invalidated (version bumped).
3153 assert!(
3154 !std::sync::Arc::ptr_eq(&a.modes, &b.modes),
3155 "modes Arc must rebuild after `active_modes.insert` bumps the version"
3156 );
3157 // `panes` and `buffer_locals` untouched — Arc identity
3158 // preserved.
3159 assert!(
3160 std::sync::Arc::ptr_eq(&a.panes, &b.panes),
3161 "panes Arc must survive a mutation to a different sub-state"
3162 );
3163 assert!(
3164 std::sync::Arc::ptr_eq(&a.buffer_locals, &b.buffer_locals),
3165 "buffer_locals Arc must survive a mutation to a different sub-state"
3166 );
3167 }
3168
3169 /// Slice 3c.final.B (group 1): publishing a `RenderState`
3170 /// while the editor holds a multi-pane tree exposes the
3171 /// tree through `rs.panes.tree`. Renderers reading the
3172 /// snapshot see the same `active_index`, `leaves()` count,
3173 /// and `root` shape they used to read from
3174 /// `app.editor.pane_tree.X()` directly.
3175 #[test]
3176 fn panes_substate_reflects_pane_tree() {
3177 use lattice_core::ui::pane::{PaneState, PaneTree, SplitOrientation};
3178 let mut editor = Editor {
3179 pane_tree: crate::versioned::Versioned::new(PaneTree::single(PaneState::default())),
3180 ..Default::default()
3181 };
3182 editor.pane_tree.split_active(SplitOrientation::Vertical);
3183 editor.pane_tree.set_active(1);
3184 editor.publish_render_state();
3185 let rs = editor.render_state.load_full();
3186 assert_eq!(
3187 rs.panes.tree.leaves().len(),
3188 2,
3189 "renderer must see both leaves through `rs.panes.tree.leaves()`"
3190 );
3191 assert_eq!(
3192 rs.panes.tree.active_index(),
3193 1,
3194 "renderer must see the same active_index as the editor"
3195 );
3196 }
3197
3198 /// Slice 3c.final.B (group 1): the buffers sub-state's
3199 /// registry clone routes the renderer's `name_of` / kind
3200 /// queries to the same underlying buffer-id index the editor
3201 /// owns. Writing into the editor's registry is observable
3202 /// through the published clone without re-publishing
3203 /// (registry is `Arc<Mutex<...>>`-backed).
3204 #[test]
3205 fn buffers_substate_registry_clone_observes_editor_writes() {
3206 use crate::buffer_registry::{BufferData, BufferEntry};
3207 use crate::buffers::BufferFlags;
3208 use lattice_core::{BufferId, BufferKind};
3209 let mut editor = Editor::default();
3210 editor.publish_render_state();
3211 // Insert via the editor's registry handle, then read
3212 // through the published render-state's clone. FileTree
3213 // is the simplest constructible variant for this assert.
3214 let inserted_id = BufferId(424242);
3215 editor.buffers.insert(BufferEntry {
3216 id: inserted_id,
3217 name: Some("*scratch-test*".to_string()),
3218 flags: BufferFlags::default(),
3219 data: BufferData::FileTree(crate::buffer_registry::DocumentEntry {
3220 id: inserted_id,
3221 handle: std::sync::Arc::new(lattice_runtime::spawn_document(
3222 inserted_id,
3223 lattice_core::Document::empty(),
3224 std::sync::Arc::new(arc_swap::ArcSwap::from_pointee(
3225 lattice_grammar::registry::CommandRegistry::default(),
3226 )),
3227 )),
3228 }),
3229 });
3230 let rs = editor.render_state.load_full();
3231 assert_eq!(
3232 rs.buffers.registry.kind_of(inserted_id),
3233 Some(BufferKind::FileTree),
3234 "registry clone observes the post-publish insert"
3235 );
3236 assert_eq!(
3237 rs.buffers.registry.name_of(inserted_id).as_deref(),
3238 Some("*scratch-test*"),
3239 );
3240 }
3241
3242 /// Slice 3c.final.B (group 2): mutating editor.folds and
3243 /// publishing exposes the same fold list through
3244 /// `rs.active_document.load().folds`.
3245 #[test]
3246 fn active_document_folds_reflects_editor_state() {
3247 use lattice_core::Fold;
3248 let mut editor = Editor::default();
3249 editor.folds.push(Fold {
3250 start_line: 5,
3251 end_line: 10,
3252 closed: true,
3253 identity: None,
3254 });
3255 editor.folds.push(Fold {
3256 start_line: 20,
3257 end_line: 30,
3258 closed: false,
3259 identity: None,
3260 });
3261 editor.publish_render_state();
3262 let rs = editor.render_state.load_full();
3263 assert_eq!(rs.active_document.load().folds.len(), 2);
3264 assert_eq!(rs.active_document.load().folds[0].start_line, 5);
3265 assert!(rs.active_document.load().folds[0].closed);
3266 assert_eq!(rs.active_document.load().folds[1].end_line, 30);
3267 assert!(!rs.active_document.load().folds[1].closed);
3268 }
3269
3270 /// Fold-bleed regression: with buffer A folded in one pane and a
3271 /// *different* buffer B in another split, each pane must resolve
3272 /// folds from ITS OWN buffer — not from `active_document.folds`.
3273 /// Both renderers previously read the active doc's folds for every
3274 /// pane, so folding A elided B's lines (GPUI) and A's inactive pane
3275 /// dropped its folds on focus-out (TUI). `folds_for_buffer` is the
3276 /// shared per-buffer source that closes the bug for both peers.
3277 #[test]
3278 fn folds_for_buffer_is_per_buffer_not_active_document() {
3279 use lattice_core::Fold;
3280 use lattice_core::ui::pane::SplitOrientation;
3281
3282 // Buffer A (boot document): give it a closed fold.
3283 let document = lattice_core::Document::from_text(
3284 (0..10).map(|i| format!("a{i}\n")).collect::<String>(),
3285 );
3286 let mut editor = Editor::boot(document);
3287 let a_id = editor.document_buffer_id;
3288 editor.folds.push(Fold {
3289 start_line: 0,
3290 end_line: 4,
3291 closed: true,
3292 identity: None,
3293 });
3294
3295 // Split and open a DIFFERENT buffer B in the new pane. `do_edit`
3296 // snapshots A's folds into its `DocumentFolds` on switch-away,
3297 // so A's inactive pane keeps them in the published `cells.panes`.
3298 let path = write_temp_rs(&(0..10).map(|i| format!("b{i}\n")).collect::<String>());
3299 editor.do_split_pane(SplitOrientation::Vertical);
3300 let new_idx = editor.pane_tree.split_active(SplitOrientation::Vertical);
3301 editor.pane_tree.set_active(new_idx);
3302 let _ = editor.do_edit(Some(path.0.clone()), false);
3303 let b_id = editor.pane_tree.active().buffer_id;
3304 assert_ne!(a_id, b_id, "B must be a distinct buffer from A");
3305
3306 editor.publish_render_state();
3307 let rs = editor.render_state.load_full();
3308
3309 // A is now inactive, but its pane still resolves A's own folds.
3310 let (a_folds, a_foldenable) = rs.folds_for_buffer(a_id);
3311 assert!(a_foldenable, "foldenable defaults on");
3312 assert!(
3313 a_folds.iter().any(|f| f.closed && f.start_line == 0),
3314 "inactive buffer A keeps its own closed fold, got {a_folds:?}"
3315 );
3316
3317 // B is active and has no folds — it must NOT inherit A's folds.
3318 let (b_folds, _) = rs.folds_for_buffer(b_id);
3319 assert!(
3320 b_folds.is_empty(),
3321 "active buffer B resolves to its own (empty) folds, got {b_folds:?}"
3322 );
3323 }
3324
3325 /// PI.0: content-centring follows the buffer that carries
3326 /// `CenterContentWidth`, not the active-buffer identity. A centred
3327 /// buffer keeps its pad even when a DIFFERENT buffer is the active
3328 /// document — the picker-preview scenario that used to collapse the
3329 /// dashboard's centring to 0 the instant `document_buffer_id` pointed
3330 /// at the previewed file.
3331 #[test]
3332 fn content_left_pad_follows_rendered_buffer_not_active_identity() {
3333 use lattice_core::ui::pane::SplitOrientation;
3334
3335 let document = lattice_core::Document::from_text("centered\n");
3336 let mut editor = Editor::boot(document);
3337 let a_id = editor.document_buffer_id;
3338
3339 // Open B in a split and focus it, so A is no longer the active doc.
3340 let path = write_temp_rs("b0\nb1\n");
3341 let new_idx = editor.pane_tree.split_active(SplitOrientation::Vertical);
3342 editor.pane_tree.set_active(new_idx);
3343 let _ = editor.do_edit(Some(path.0.clone()), false);
3344 let b_id = editor.pane_tree.active().buffer_id;
3345 assert_ne!(a_id, b_id, "B must be distinct from A");
3346 assert_ne!(editor.document_buffer_id, a_id, "A is no longer active");
3347
3348 // Mark A for centring within a 20-col block; give A's (now
3349 // inactive) pane an 80-col viewport.
3350 editor
3351 .buffer_locals
3352 .entry(a_id)
3353 .or_default()
3354 .insert(crate::modes::CenterContentWidth(20));
3355 for leaf in editor.pane_tree.leaves_mut() {
3356 if leaf.buffer_id == a_id {
3357 leaf.viewport_width = 80;
3358 }
3359 }
3360
3361 editor.publish_render_state();
3362 let rs = editor.render_state.load_full();
3363
3364 // A keeps its centring pad = (80 - 20) / 2 = 30 despite being
3365 // inactive; the fix reads A's own local, not `== document_buffer_id`.
3366 assert_eq!(
3367 rs.content_left_pad_for(a_id),
3368 30,
3369 "centred buffer A keeps its pad when a different buffer is active"
3370 );
3371 // B carries no CenterContentWidth → no pad.
3372 assert_eq!(rs.content_left_pad_for(b_id), 0);
3373 }
3374
3375 /// Exact user repro (2026-06-30): A in pane1, B (folded) active in
3376 /// pane2, then `<C-w>w` focus back to A. B's folds must survive the
3377 /// switch-away — `folds_for_buffer(B)` must still report B's closed
3378 /// fold once B is inactive. Pane navigation goes through
3379 /// `activate_pane` (NOT `do_edit`/`activate_document`), so this guards
3380 /// the `sync_active_document_to_pane` → `snapshot_active_document`
3381 /// stash path specifically.
3382 #[test]
3383 fn fold_survives_switching_pane_focus_away_from_its_buffer() {
3384 use lattice_core::Fold;
3385 use lattice_core::ui::pane::SplitOrientation;
3386
3387 let document = lattice_core::Document::from_text(
3388 (0..10).map(|i| format!("a{i}\n")).collect::<String>(),
3389 );
3390 let mut editor = Editor::boot(document);
3391 let a_id = editor.document_buffer_id;
3392 let a_idx = editor.pane_tree.active_index();
3393
3394 // Split, open B in the new pane → B active.
3395 let path = write_temp_rs(&(0..10).map(|i| format!("b{i}\n")).collect::<String>());
3396 let b_idx = editor.pane_tree.split_active(SplitOrientation::Vertical);
3397 editor.pane_tree.set_active(b_idx);
3398 let _ = editor.do_edit(Some(path.0.clone()), false);
3399 let b_id = editor.pane_tree.active().buffer_id;
3400 assert_ne!(a_id, b_id);
3401
3402 // Fold B while it is the active buffer (simulates z<Space>).
3403 editor.folds.push(Fold {
3404 start_line: 1,
3405 end_line: 5,
3406 closed: true,
3407 identity: None,
3408 });
3409 editor.publish_render_state();
3410 let rs = editor.render_state.load_full();
3411 assert!(
3412 rs.folds_for_buffer(b_id).0.iter().any(|f| f.closed),
3413 "sanity: B is folded while active"
3414 );
3415
3416 // <C-w>w back to A: pane navigation, NOT activate_document.
3417 editor.activate_pane(a_idx);
3418 editor.publish_render_state();
3419 let rs = editor.render_state.load_full();
3420
3421 let (b_folds, _) = rs.folds_for_buffer(b_id);
3422 assert!(
3423 b_folds.iter().any(|f| f.closed && f.start_line == 1),
3424 "B keeps its closed fold after focus moves away, got {b_folds:?}"
3425 );
3426 }
3427
3428 /// `inlay_hints_for_buffer` routes the active buffer to the baked
3429 /// `syntax.inlay_hints` list and an unknown buffer to empty — the
3430 /// per-pane source that lets both renderers splice hints through one
3431 /// path regardless of focus.
3432 #[test]
3433 fn inlay_hints_for_buffer_routes_active_and_unknown() {
3434 use crate::render_state::InlayHintRow;
3435 let mut editor = Editor::default();
3436 let a_id = editor.document_buffer_id;
3437 editor.publish_render_state();
3438 let rs = editor.render_state.load_full();
3439
3440 // Active buffer returns whatever `syntax.inlay_hints` holds —
3441 // Arc-identity equal to the published list.
3442 let active = rs.inlay_hints_for_buffer(a_id);
3443 assert!(Arc::ptr_eq(&active, &rs.syntax.inlay_hints));
3444
3445 // An unknown buffer (no pane entry) routes to empty, never a panic.
3446 let _ = InlayHintRow::hint(0, 0, String::new());
3447 let unknown = rs.inlay_hints_for_buffer(lattice_core::BufferId(999_999));
3448 assert!(unknown.is_empty());
3449 }
3450
3451 /// Slice 3c.final.B (group 2): hlsearch matches /
3452 /// current_match / option_cache round-trip through the
3453 /// published snapshot.
3454 #[test]
3455 fn active_document_search_and_options_reflect_editor_state() {
3456 use lattice_protocol::position::{Position, Range};
3457 let mut editor = Editor::default();
3458 let r = Range::new(Position::new(2, 0), Position::new(2, 5));
3459 editor.all_matches.push(r);
3460 editor.current_match = Some(r);
3461 editor.option_cache.show_whitespace = true;
3462 editor.option_cache.current_line_highlight = true;
3463 editor.publish_render_state();
3464 let rs = editor.render_state.load_full();
3465 assert_eq!(rs.active_document.load().all_matches.len(), 1);
3466 assert_eq!(rs.active_document.load().all_matches[0], r);
3467 assert_eq!(rs.active_document.load().current_match, Some(r));
3468 assert!(rs.active_document.load().option_cache.show_whitespace);
3469 assert!(
3470 rs.active_document
3471 .load()
3472 .option_cache
3473 .current_line_highlight
3474 );
3475 }
3476
3477 /// Slice 3c.final.B (group 4): editor.lsp_progress is
3478 /// published as a fresh `Arc<HashMap<...>>` per tick. Mutating
3479 /// the editor's map and re-publishing makes the new entry
3480 /// visible through `rs.lsp.progress`.
3481 /// Slice 3c.final.B (group 5): translator substate carries
3482 /// the published `builtins`, `keymap`, and `partial_chord`
3483 /// so the renderer's input loop can build a
3484 /// `TranslateContext` from the snapshot.
3485 #[test]
3486 fn translator_substate_reflects_editor_inputs() {
3487 use crate::chord::KeyChord;
3488 let mut editor = Editor::default();
3489 // Seed a non-empty partial_chord so the publish path
3490 // exercises the slice conversion.
3491 editor.partial_chord = vec![KeyChord::char('g')];
3492 editor.publish_render_state();
3493 let rs = editor.render_state.load_full();
3494 assert_eq!(rs.translator.partial_chord.len(), 1);
3495 // Builtins is `Copy`; the published snapshot has the
3496 // same default-shaped value as editor.
3497 let _: lattice_grammar::builtins::Builtins = rs.translator.builtins;
3498 // Keymap handle clones to an Arc-backed view; verify
3499 // we can dereference it without panic.
3500 let _ = &rs.translator.keymap;
3501 }
3502
3503 /// Slice 3c.final.B.9: buffer_locals map round-trip.
3504 #[test]
3505 fn buffer_locals_map_reflects_editor_state() {
3506 use lattice_core::BufferId;
3507 use lattice_mode::BufferLocals;
3508 let mut editor = Editor::default();
3509 let buf = BufferId(7);
3510 editor.buffer_locals.insert(buf, BufferLocals::new());
3511 editor.publish_render_state();
3512 let rs = editor.render_state.load_full();
3513 assert!(rs.buffer_locals.map.contains_key(&buf));
3514 editor.buffer_locals.remove(&buf);
3515 editor.publish_render_state();
3516 let rs = editor.render_state.load_full();
3517 assert!(!rs.buffer_locals.map.contains_key(&buf));
3518 }
3519
3520 // DR.2 (decoration-retention): the `pane_highlights_reflect_editor_state`
3521 // round-trip test was retired with the `pane_highlights` producer.
3522 // Inactive-pane styling now flows through the per-pane `DisplayMatrix`
3523 // (covered by the cells-worker tests).
3524
3525 /// Slice 3c.final.B.11: active-modes map round-trip. Inserts
3526 /// an entry at a synthetic buffer id and verifies the
3527 /// published map carries it (the `set_major` API on
3528 /// `ActiveModes` is `pub(crate)` to `lattice-mode`, so we can't
3529 /// populate the chain from outside that crate — the
3530 /// round-trip-shape assertion is what matters here).
3531 #[test]
3532 fn modes_map_reflects_editor_state() {
3533 use lattice_core::BufferId;
3534 use lattice_mode::ActiveModes;
3535 let mut editor = Editor::default();
3536 let buf = BufferId(42);
3537 editor.active_modes.insert(buf, ActiveModes::new());
3538 editor.publish_render_state();
3539 let rs = editor.render_state.load_full();
3540 assert!(
3541 rs.modes.map.contains_key(&buf),
3542 "published map should carry the inserted entry",
3543 );
3544 // Removal also round-trips.
3545 editor.active_modes.remove(&buf);
3546 editor.publish_render_state();
3547 let rs = editor.render_state.load_full();
3548 assert!(
3549 !rs.modes.map.contains_key(&buf),
3550 "removed entry should not appear in next publish",
3551 );
3552 }
3553
3554 /// Slice 3c.final.B.10: typed-options registry round-trip.
3555 #[test]
3556 fn options_registry_reflects_editor_state() {
3557 let mut editor = Editor::default();
3558 editor.publish_render_state();
3559 let rs = editor.render_state.load_full();
3560 // The published `config` Arc shares the registry identity
3561 // with `editor.config` (one Arc::clone per publish).
3562 assert!(
3563 std::sync::Arc::ptr_eq(&rs.options.config, &editor.config),
3564 "options.config should be the same Arc instance as editor.config",
3565 );
3566 }
3567
3568 /// Slice 3c.final.B.7: messages + modeline round-trip
3569 /// through the published snapshot.
3570 #[test]
3571 fn messages_and_modeline_reflect_editor_state() {
3572 use crate::action::{Action, EchoLevel, EchoMessage};
3573 use lattice_grammar::SearchDirection;
3574
3575 let mut editor = Editor::boot(lattice_core::Document::empty());
3576
3577 // Default: empty cmdline, no message, no search.
3578 editor.publish_render_state();
3579 let rs = editor.render_state.load_full();
3580 assert!(rs.messages.last.is_none());
3581 assert_eq!(rs.modeline.cmdline_text.as_ref(), "");
3582 assert!(!rs.modeline.auto_submit_hint);
3583 assert!(rs.modeline.search_pattern.is_none());
3584 assert!(rs.modeline.search_direction.is_none());
3585
3586 // Populated messages + cmdline.
3587 editor.last_message = Some(EchoMessage {
3588 text: "hello".to_string(),
3589 level: EchoLevel::Info,
3590 });
3591 editor.set_command_line_text("describe-key ");
3592 editor.auto_submit_after_chord = true;
3593 editor.publish_render_state();
3594 let rs = editor.render_state.load_full();
3595
3596 let last = rs.messages.last.as_deref().expect("last set");
3597 assert_eq!(last.text, "hello");
3598 assert_eq!(last.level, EchoLevel::Info);
3599 assert_eq!(rs.modeline.cmdline_text.as_ref(), "describe-key ");
3600 assert!(rs.modeline.auto_submit_hint);
3601 // At this point only the cmdline is active, so search fields are
3602 // empty.
3603 assert!(rs.modeline.search_pattern.is_none());
3604 assert!(rs.modeline.search_direction.is_none());
3605
3606 // Close the cmdline and open the search line instead — they
3607 // share a single `minibuffer_focus` slot (MB.5a).
3608 _ = editor.dispatch(Action::CommandLineCancel);
3609 editor.open_search_line(SearchDirection::Backward);
3610 editor.set_search_line_text("needle");
3611 editor.publish_render_state();
3612 let rs = editor.render_state.load_full();
3613
3614 assert_eq!(rs.modeline.search_pattern.as_deref(), Some("needle"),);
3615 assert_eq!(
3616 rs.modeline.search_direction,
3617 Some(SearchDirection::Backward),
3618 );
3619 assert_eq!(rs.modeline.cmdline_text.as_ref(), "");
3620 }
3621
3622 /// Slice 3c.final.B (group 6): lifecycle flags round-trip
3623 /// through the published snapshot. (T.6.t removed the host
3624 /// `Theme` field assertion — the resolved table now carries
3625 /// all style state and `MatrixVersion::theme` carries its
3626 /// version.)
3627 #[test]
3628 fn lifecycle_reflects_editor_state() {
3629 let mut editor = Editor {
3630 should_quit: true,
3631 pending_redraw: true,
3632 terminal_width: Some(120),
3633 ..Default::default()
3634 };
3635 editor.publish_render_state();
3636 let rs = editor.render_state.load_full();
3637 assert!(rs.lifecycle.should_quit);
3638 assert!(rs.lifecycle.pending_redraw);
3639 assert_eq!(rs.lifecycle.terminal_width, Some(120));
3640 }
3641
3642 // ML.3c: `lsp_progress_reflects_published_map` retired with the host
3643 // accumulator + `RenderState.lsp.progress`. The progress fold + badge
3644 // are now covered by `lattice_lsp::modeline`'s store tests.
3645
3646 /// Slice 3c.final.B (group 4): popup_buffer / placement
3647 /// fields published into `PopupRenderState`. With no popup
3648 /// open the substate reports `is_open() == false` and
3649 /// `help` is `None`.
3650 #[test]
3651 fn popup_substate_defaults_closed() {
3652 let mut editor = Editor::default();
3653 editor.publish_render_state();
3654 let rs = editor.render_state.load_full();
3655 assert!(!rs.popup.is_open());
3656 assert_eq!(rs.popup.buffer_id, None);
3657 assert_eq!(rs.popup.scroll, 0);
3658 }
3659
3660 /// Slice 3c.final.B (group 3): picker + completion slots
3661 /// default to None when no overlay is open.
3662 #[test]
3663 fn picker_and_completion_substates_default_closed() {
3664 let mut editor = Editor::default();
3665 editor.publish_render_state();
3666 let rs = editor.render_state.load_full();
3667 assert!(rs.picker.state.is_none());
3668 assert!(rs.completion.insert.is_none());
3669 assert!(rs.completion.state.is_none());
3670 }
3671
3672 /// Slice 3c.final.B (group 1): `Editor::buffer_uris` is
3673 /// published as a fresh `Arc<HashMap<...>>` per tick.
3674 /// Mutating the editor's map and re-publishing makes the
3675 /// new entry visible through `rs.buffers.uris`.
3676 #[test]
3677 fn buffers_substate_uris_reflects_published_map() {
3678 use lattice_core::BufferId;
3679 use lattice_lsp::Uri;
3680 use std::str::FromStr;
3681 let mut editor = Editor::default();
3682 let id = BufferId(7);
3683 let uri = Uri::from_str("file:///tmp/foo.rs").expect("valid uri");
3684 editor.buffer_uris.insert(id, uri.clone());
3685 editor.publish_render_state();
3686 let rs = editor.render_state.load_full();
3687 assert_eq!(
3688 rs.buffers.uris.get(&id),
3689 Some(&uri),
3690 "renderer must see the URI the editor inserted before publishing"
3691 );
3692 assert!(
3693 rs.buffers.uris.get(&BufferId(9999)).is_none(),
3694 "absent ids return None through the published map"
3695 );
3696 }
3697
3698 // ---- S2.1 (cell-grid renderer plumbing) ----
3699
3700 /// `CellsRenderState` is populated by `publish_render_state`.
3701 /// The matrix Arc is published as the active pane's
3702 /// per-buffer matrix cell (via
3703 /// `Editor::cells_matrix_for(document_buffer_id)`); other
3704 /// fields aggregate active document + syntax inputs.
3705 ///
3706 /// D.4.d.1.b (2026-05-29): pre-d.1.b the assertion was
3707 /// `Arc::ptr_eq(&rs.cells.matrix, &editor.cells_matrix_cell)`.
3708 /// The worker now writes through `cells.panes[i].matrix`
3709 /// (each entry's cell comes from the registry), so the
3710 /// renderer's top-level read target switched to the
3711 /// registry's active-buffer cell to keep the renderer
3712 /// back-compat path landing on the worker's writes.
3713 #[test]
3714 fn cells_substate_is_populated_on_publish() {
3715 let mut editor = Editor {
3716 viewport_height: 24,
3717 ..Default::default()
3718 };
3719 editor.publish_render_state();
3720 let rs = editor.render_state.load_full();
3721 // I.5.2: `cells` is an inner `ArcSwap`; load the snapshot once.
3722 let rsc = rs.cells.load();
3723 // Matrix Arc identity matches the registry's active
3724 // cell (so the worker's writes via that cell are
3725 // visible through the published RS without a republish
3726 // round-trip).
3727 let registry_cell = editor.cells_matrix_for(editor.document_buffer_id);
3728 assert!(
3729 std::sync::Arc::ptr_eq(&rsc.matrix, ®istry_cell),
3730 "cells.matrix must come from cells_matrix_for(active_buffer)"
3731 );
3732 // No worker yet → matrix stays empty.
3733 let m = rsc.matrix.load();
3734 assert!(m.is_empty(), "matrix is empty until S2.2 worker lands");
3735 // viewport_height + text version surface through to the
3736 // worker via the same RS path.
3737 assert_eq!(rsc.viewport_height, 24);
3738 assert_eq!(rsc.version.text, editor.document.text_version());
3739 // Snapshot is populated (the cell-builder reads it
3740 // line-by-line in S2.2+).
3741 assert!(rsc.snapshot.is_some());
3742 }
3743
3744 /// The matrix Arc identity persists across publishes. This is
3745 /// the load-bearing invariant for S2.2's worker: the worker
3746 /// holds its sibling Arc and writes via `store()`; subsequent
3747 /// publishes must NOT swap the cell out from under it. The
3748 /// registry's lazy-insert is idempotent so repeat lookups for
3749 /// the same buffer return the same Arc — d.1.b preserves the
3750 /// invariant by sourcing `cells.matrix` from the registry.
3751 #[test]
3752 fn cells_matrix_arc_identity_is_stable_across_publishes() {
3753 let mut editor = Editor::default();
3754 editor.publish_render_state();
3755 let rs1 = editor.render_state.load_full();
3756 let cell1 = rs1.cells.load().matrix.clone();
3757 editor.publish_render_state();
3758 editor.publish_render_state();
3759 let rs2 = editor.render_state.load_full();
3760 assert!(
3761 std::sync::Arc::ptr_eq(&cell1, &rs2.cells.load().matrix),
3762 "cells.matrix Arc identity must persist across publishes"
3763 );
3764 }
3765
3766 // ---- D.4.d.1.a (per-pane cells inputs) ----
3767
3768 /// D.4.d.1.a: the default editor's single Document leaf
3769 /// surfaces through `cells.panes` as exactly one entry whose
3770 /// `pane_id` matches the active leaf, `buffer_id` matches
3771 /// the active document, and `matrix` resolves through the
3772 /// registry's idempotent `cells_matrix_for` port (the worker
3773 /// will write through the same Arc in D.4.d.1.b).
3774 ///
3775 /// The d.0 boot invariant — active-doc registry entry
3776 /// shares Arc identity with `cells_matrix_cell` — is set up
3777 /// by `Editor::boot`. `Editor::default()` (used here)
3778 /// doesn't seed the registry, so we assert the weaker
3779 /// registry-port contract instead: every panes entry's
3780 /// `matrix` is the same Arc the registry returns for that
3781 /// `buffer_id`.
3782 #[test]
3783 fn cells_panes_populated_for_single_document_pane() {
3784 let mut editor = Editor::default();
3785 editor.publish_render_state();
3786 let rs = editor.render_state.load_full();
3787 let rsc = rs.cells.load();
3788 assert_eq!(
3789 rsc.panes.len(),
3790 1,
3791 "default single Document leaf produces one panes entry"
3792 );
3793 let entry = &rsc.panes[0];
3794 assert_eq!(entry.buffer_id, editor.document_buffer_id);
3795 assert_eq!(entry.pane_id, editor.pane_tree.active().id);
3796 assert!(
3797 entry.snapshot.is_some(),
3798 "active pane entry must carry the document snapshot"
3799 );
3800 let registry_cell = editor.cells_matrix_for(entry.buffer_id);
3801 assert!(
3802 std::sync::Arc::ptr_eq(&entry.matrix, ®istry_cell),
3803 "panes entry matrix must come from the registry's cells_matrix_for port"
3804 );
3805 // Active pane's version must match the top-level cells
3806 // version — same hashes, same inputs.
3807 assert_eq!(entry.version, rsc.version);
3808 }
3809
3810 /// D.4.d.1.a: a vsplit produces two Document leaves; each
3811 /// surfaces with a distinct `pane_id`. With both leaves
3812 /// still pointing at the active buffer, both entries share
3813 /// `buffer_id` and the same registry matrix Arc — the
3814 /// registry hands out one cell per buffer, not per pane.
3815 #[test]
3816 fn cells_panes_populated_per_visible_document_leaf() {
3817 use lattice_core::ui::pane::SplitOrientation;
3818 let mut editor = Editor::default();
3819 editor.pane_tree.split_active(SplitOrientation::Vertical);
3820 editor.publish_render_state();
3821 let rs = editor.render_state.load_full();
3822 assert_eq!(
3823 rs.cells.load().panes.len(),
3824 2,
3825 "two Document leaves expected"
3826 );
3827 assert_ne!(
3828 rs.cells.load().panes[0].pane_id,
3829 rs.cells.load().panes[1].pane_id,
3830 "leaves must surface with distinct pane ids"
3831 );
3832 let shared_cell = editor.cells_matrix_for(editor.document_buffer_id);
3833 for entry in rs.cells.load().panes.iter() {
3834 assert_eq!(entry.buffer_id, editor.document_buffer_id);
3835 assert!(
3836 std::sync::Arc::ptr_eq(&entry.matrix, &shared_cell),
3837 "panes showing the same buffer must share the registry's matrix Arc"
3838 );
3839 }
3840 }
3841
3842 // ---- D.4.d.2.1.b (virtual_rows_matrix on PaneCellsInputs) ----
3843
3844 /// D.4.d.2.1.b: the publish path attaches a per-buffer
3845 /// `virtual_rows_matrix` cell to each `PaneCellsInputs` so
3846 /// D.4.d.2.1.c's worker iteration can write through
3847 /// `pane.virtual_rows_matrix.store(...)`. Single-pane
3848 /// invariant: the entry's cell resolves through the same
3849 /// registry port (`virtual_rows_matrix_for`) the
3850 /// renderer-side lookup (D.4.d.2.1.d) will read.
3851 ///
3852 /// `Editor::default()` doesn't seed the registry (boot
3853 /// does), so we assert the registry-port equality here —
3854 /// not the boot-seeded Arc-identity against
3855 /// `virtual_rows_matrix_cell`, which the D.4.d.2.0
3856 /// `virtual_rows_matrix_for_active_doc_shares_field_arc`
3857 /// test in `dispatch::tests` already covers via
3858 /// `Editor::boot`.
3859 #[test]
3860 fn cells_panes_carry_virtual_rows_matrix_for_single_document_pane() {
3861 let mut editor = Editor::default();
3862 editor.publish_render_state();
3863 let rs = editor.render_state.load_full();
3864 let rsc = rs.cells.load();
3865 assert_eq!(rsc.panes.len(), 1);
3866 let entry = &rsc.panes[0];
3867 let registry_cell = editor.virtual_rows_matrix_for(entry.buffer_id);
3868 assert!(
3869 std::sync::Arc::ptr_eq(&entry.virtual_rows_matrix, ®istry_cell),
3870 "panes entry virtual_rows_matrix must come from the \
3871 registry's `virtual_rows_matrix_for` port"
3872 );
3873 }
3874
3875 /// D.4.d.2.1.b: a vsplit produces two Document leaves; both
3876 /// surface with the same `virtual_rows_matrix` Arc because
3877 /// they share `buffer_id` (the registry hands out one cell
3878 /// per buffer, not per pane — same contract as the cells
3879 /// side). When `:diffsplit` lands (D.4.d.3), the second
3880 /// leaf will point at a different buffer and the two
3881 /// `virtual_rows_matrix` Arcs will diverge — that case is
3882 /// already covered by the D.4.d.2.0 distinct-buffers test;
3883 /// here we lock the same-buffer-shares-cell invariant the
3884 /// worker iteration in D.4.d.2.1.c will rely on.
3885 #[test]
3886 fn cells_panes_share_virtual_rows_matrix_when_buffers_match() {
3887 use lattice_core::ui::pane::SplitOrientation;
3888 let mut editor = Editor::default();
3889 editor.pane_tree.split_active(SplitOrientation::Vertical);
3890 editor.publish_render_state();
3891 let rs = editor.render_state.load_full();
3892 assert_eq!(
3893 rs.cells.load().panes.len(),
3894 2,
3895 "two Document leaves expected"
3896 );
3897 let shared_cell = editor.virtual_rows_matrix_for(editor.document_buffer_id);
3898 for entry in rs.cells.load().panes.iter() {
3899 assert_eq!(entry.buffer_id, editor.document_buffer_id);
3900 assert!(
3901 std::sync::Arc::ptr_eq(&entry.virtual_rows_matrix, &shared_cell),
3902 "panes showing the same buffer must share the \
3903 registry's virtual_rows_matrix Arc"
3904 );
3905 }
3906 }
3907
3908 /// D.4.d.1.a: non-Document leaves (file tree / help /
3909 /// messages / oil / terminal) don't take the cells path —
3910 /// they're filtered out of `cells.panes` so the worker can
3911 /// iterate without a kind check per entry.
3912 #[test]
3913 fn cells_panes_skip_non_document_leaves() {
3914 use lattice_core::BufferKind;
3915 use lattice_core::ui::pane::SplitOrientation;
3916 let mut editor = Editor::default();
3917 editor.pane_tree.split_active(SplitOrientation::Vertical);
3918 // Flip pane index 1 to a kind that is NOT document-backed. The
3919 // filter keys on `leaf.buffer` directly, so the buffer_id doesn't
3920 // need to point at a real terminal for this assertion.
3921 //
3922 // 2026-08-16: this was `FileTree`, which stopped being a valid
3923 // stand-in when DL.4/DL.5 moved both listings onto the shared
3924 // document render path. Terminal is the only kind left that
3925 // genuinely paints from something other than a rope (an alacritty
3926 // cell grid), so it is the honest example of what this filter is
3927 // for.
3928 editor.pane_tree.leaves_mut()[1].buffer = BufferKind::Terminal;
3929 editor.publish_render_state();
3930 let rs = editor.render_state.load_full();
3931 assert_eq!(
3932 rs.cells.load().panes.len(),
3933 1,
3934 "non-Document leaves must be filtered out of panes"
3935 );
3936 assert_eq!(
3937 rs.cells.load().panes[0].buffer_id,
3938 editor.document_buffer_id
3939 );
3940 }
3941
3942 /// D.4.d.1.a: the active pane's entry carries the
3943 /// single-edit delta the publisher just `take()`d off
3944 /// `Editor::last_edit_for_cells`; other panes (even ones
3945 /// showing the same buffer) carry `None`. Prevents the
3946 /// worker from double-applying the same delta when
3947 /// iterating per pane.
3948 #[test]
3949 fn cells_panes_last_edit_routes_only_to_active_pane() {
3950 use lattice_core::ui::pane::SplitOrientation;
3951 let mut editor = Editor::default();
3952 editor.pane_tree.split_active(SplitOrientation::Vertical);
3953 // Make pane 0 the active one; both leaves share the
3954 // active document so without the pane filter both would
3955 // get the delta.
3956 editor.pane_tree.set_active(0);
3957 let active_pane_id = editor.pane_tree.active().id;
3958 editor.last_edit_for_cells = Some(lattice_cells::EditDelta {
3959 start_line: 0,
3960 lines_removed: 0,
3961 lines_added: 1,
3962 ..Default::default()
3963 });
3964 editor.publish_render_state();
3965 let rs = editor.render_state.load_full();
3966 let rsc = rs.cells.load();
3967 assert_eq!(rsc.panes.len(), 2);
3968 let active_entry = rsc
3969 .panes
3970 .iter()
3971 .find(|p| p.pane_id == active_pane_id)
3972 .expect("active pane must be present in panes");
3973 assert!(
3974 active_entry.last_edit.is_some(),
3975 "active pane entry must carry the consumed delta"
3976 );
3977 let other_entry = rsc
3978 .panes
3979 .iter()
3980 .find(|p| p.pane_id != active_pane_id)
3981 .expect("non-active pane must be present in panes");
3982 assert!(
3983 other_entry.last_edit.is_none(),
3984 "non-active pane entries must never carry the active delta"
3985 );
3986 // Slot drained — next publish sees None on every entry.
3987 editor.publish_render_state();
3988 let rs2 = editor.render_state.load_full();
3989 assert!(rs2.cells.load().panes.iter().all(|p| p.last_edit.is_none()));
3990 }
3991
3992 /// CHARACTERIZATION (PC.1): a focused popup does not re-anchor the pane
3993 /// behind it.
3994 ///
3995 /// **This passed before the PC.1 clamp fix and is not a regression test
3996 /// for it** — it is pinned because the property is load-bearing and holds
3997 /// only INCIDENTALLY. `owns_live_view` excludes prompts from taking the
3998 /// live cursor but says nothing about popups; what actually saves the pane
3999 /// is `active_doc_active`, which omits `BufferKind::Help`, so a focused
4000 /// popup drops `is_active_pane` to false and the leaf keeps its own
4001 /// scroll and context anchor.
4002 ///
4003 /// Add Help to that list for some unrelated reason and the pane behind
4004 /// would silently start resolving its sticky-context strip from wherever
4005 /// the popup is scrolled to — the file's strip wearing the popup's
4006 /// coordinates. Asserted on `scroll`, which `owns_live_view` gates jointly
4007 /// with the context anchor and which the published entry carries; the
4008 /// anchor itself is consumed inside the resolve and never surfaces.
4009 #[test]
4010 fn a_focused_popup_does_not_re_anchor_the_pane_behind_it() {
4011 let mut editor = Editor::boot(lattice_core::Document::from_text(
4012 "l0\nl1\nl2\nl3\nl4\nl5\nl6\nl7\nl8\nl9\nl10\nl11\nl12\n",
4013 ));
4014 editor.viewport_height = 6;
4015 // The FILE is parked partway down; this is the anchor its own strip
4016 // must keep using.
4017 editor.cursor.line = 8;
4018 editor.scroll = 6;
4019 let pane_id = editor.pane_tree.active().id;
4020
4021 let content = lattice_help::parse_help_lines(
4022 "hover",
4023 (0..40).map(|i| format!("popup {i}")).collect(),
4024 );
4025 let _ = editor.open_floating_popup(content, crate::popup::PopupPlacement::CursorAnchored);
4026 editor.focus_help_popup();
4027 editor.popup_viewport_height = 8;
4028
4029 // Scroll the POPUP a long way from where the file sits.
4030 editor.cursor.line = 33;
4031 editor.scroll = 30;
4032
4033 editor.publish_render_state();
4034 let rs = editor.render_state.load_full();
4035 let rsc = rs.cells.load();
4036 let pane = rsc
4037 .panes
4038 .iter()
4039 .find(|p| p.pane_id == pane_id)
4040 .expect("the document pane is still published behind the popup");
4041 assert_eq!(
4042 pane.scroll, 6,
4043 "the pane behind must keep its OWN view; taking the popup's \
4044 scroll (30) would re-anchor its context strip to the popup"
4045 );
4046 }
4047
4048 /// PC.2: the popup's context anchor is its own cursor, not its scroll top.
4049 ///
4050 /// **A consistency pin, not a behaviour test — and the distinction is the
4051 /// point.** The popup pane already resolves a sticky-context strip from
4052 /// its own buffer: it goes through the same `build_one_pane_cells_input`
4053 /// as every leaf, so a help popup (markdown `SyntaxHandle` via
4054 /// `seed_help_metadata_locals`) pins its enclosing heading. What it did
4055 /// NOT do was honour `context.anchor`: it hardcoded the scroll top,
4056 /// justified by "no scopes are ever cached for a popup buffer" — false,
4057 /// since the refresh pump keys on `document_buffer_id`, which IS the
4058 /// popup's buffer while one is focused.
4059 ///
4060 /// The two anchors happen to agree in practice, which is why this asserts
4061 /// the plumbing rather than a resolved strip. `resolve_context` keeps only
4062 /// scopes with `header_end < viewport_top`, and any such scope containing
4063 /// the cursor also contains the viewport top (the cursor is at or below
4064 /// it) — so no fixture can make the two disagree without contradicting
4065 /// itself. An earlier draft of this test asserted resolved rows and could
4066 /// not be made to fail; that is what this comment records.
4067 #[test]
4068 fn a_focused_popups_context_anchor_is_its_own_cursor() {
4069 let mut editor = Editor::boot(lattice_core::Document::from_text("a\nb\nc\n"));
4070 editor.viewport_height = 6;
4071 let content = lattice_help::parse_help_lines(
4072 "hover",
4073 (0..40).map(|i| format!("popup {i}")).collect(),
4074 );
4075 let _ = editor.open_floating_popup(content, crate::popup::PopupPlacement::CursorAnchored);
4076 editor.focus_help_popup();
4077 editor.popup_viewport_width = 40;
4078 editor.popup_viewport_height = 8;
4079 editor.scroll = 20;
4080 editor.cursor.line = 27;
4081
4082 let specs = editor.synthetic_popup_panes_for_test();
4083 let popup = specs
4084 .iter()
4085 .find(|s| s.pane_id == lattice_core::ui::pane::PaneId::POPUP)
4086 .expect("a focused floating popup publishes a synthetic pane");
4087 assert_eq!(popup.scroll, 20, "the popup's own scroll");
4088 assert_eq!(
4089 popup.cursor_line, 27,
4090 "…and its own CURSOR, carried so the resolver is asked the same \
4091 question a document pane is asked"
4092 );
4093 }
4094
4095 /// `G` in a popup leaves the last line behind the bottom border.
4096 ///
4097 /// > the cursor is landing on the correct last line, but the line is just
4098 /// > behind the bottom border and not visible […] if I manually scroll
4099 /// > with `j` it is good
4100 ///
4101 /// `j` being fine is the clue that rules out a constant off-by-one: the
4102 /// clamp reserves the strip count from the LAST PUBLISH, which is correct
4103 /// while scrolling a line at a time and stale across a jump. Near the top
4104 /// no heading has scrolled off, so it reserves 0 and picks a scroll that
4105 /// fills the whole window; the jump then scrolls a heading off, the strip
4106 /// takes a row, and the row it takes is the one the caret was on.
4107 ///
4108 /// Same arithmetic on a document pane, so this is not popup-specific —
4109 /// the popup is just where a one-row loss is impossible to miss.
4110 #[test]
4111 fn a_jump_reserves_the_strip_the_jump_itself_creates() {
4112 use crate::per_buffer_cache::PerBufferCacheExt;
4113 use lattice_cells::context::ContextScope;
4114
4115 let mut editor = Editor::boot(lattice_core::Document::from_text("a\nb\n"));
4116 editor.viewport_height = 12;
4117 let content = lattice_help::parse_help_lines(
4118 "org",
4119 (0..30).map(|i| format!("help line {i:02}")).collect(),
4120 );
4121 let _ = editor.open_floating_popup(content, crate::popup::PopupPlacement::CursorAnchored);
4122 editor.focus_help_popup();
4123 let popup_id = editor.popup_buffer.expect("a popup");
4124 editor.popup_viewport_width = 40;
4125 editor.popup_viewport_height = 8;
4126
4127 // One heading at line 5 enclosing the rest of the document — the
4128 // shape `:h org` has. At the top it is on screen and pins nothing;
4129 // after a jump to the end it has scrolled off and takes a row.
4130 editor.wasm_context.cache.insert_for(
4131 popup_id,
4132 crate::wasm_context::ContextScopeCache {
4133 parse_version: 0,
4134 scopes: vec![ContextScope {
4135 scope_start: 5,
4136 scope_end: 29,
4137 header_start: 5,
4138 header_end: 5,
4139 }],
4140 },
4141 );
4142
4143 // At the top: nothing scrolled off, so the published strip is empty —
4144 // which is the state `G` is pressed from.
4145 editor.scroll = 0;
4146 editor.cursor.line = 0;
4147 editor.publish_render_state();
4148
4149 // `G`.
4150 editor.cursor.line = 29;
4151 editor.ensure_cursor_visible();
4152
4153 // What the strip will actually be once this scroll is published.
4154 let strip = editor
4155 .resolve_sticky_context_lines(popup_id, editor.cursor.line, editor.scroll, 8)
4156 .len() as u32;
4157 let content_rows = 8 - strip;
4158 assert!(
4159 editor.cursor.line < editor.scroll + content_rows,
4160 "the caret must be inside the CONTENT rows: scroll={} strip={} \
4161 content_rows={} cursor={} — one short means the last line is \
4162 behind the bottom border, which is the report",
4163 editor.scroll,
4164 strip,
4165 content_rows,
4166 editor.cursor.line
4167 );
4168 }
4169
4170 // ---- D.4.d.1.c (per-pane matrix lookup) ----
4171
4172 /// D.4.d.1.c: `cells.pane_matrices` carries one entry per
4173 /// visible Document leaf; each entry's matrix Arc identity
4174 /// matches the corresponding `panes[i].matrix` (the
4175 /// registry cell the worker writes through).
4176 #[test]
4177 fn cells_pane_matrices_mirror_panes_entries() {
4178 use lattice_core::ui::pane::SplitOrientation;
4179 let mut editor = Editor::default();
4180 editor.pane_tree.split_active(SplitOrientation::Vertical);
4181 editor.publish_render_state();
4182 let rs = editor.render_state.load_full();
4183 let rsc = rs.cells.load();
4184 assert_eq!(rsc.panes.len(), 2);
4185 assert_eq!(rsc.pane_matrices.len(), 2);
4186 for entry in rsc.panes.iter() {
4187 let lookup = rsc
4188 .pane_matrices
4189 .get(&entry.pane_id)
4190 .expect("every pane must appear in pane_matrices");
4191 assert!(
4192 std::sync::Arc::ptr_eq(lookup, &entry.matrix),
4193 "pane_matrices lookup must return the same Arc as panes[i].matrix"
4194 );
4195 }
4196 }
4197
4198 /// D.4.d.1.c: `matrix_for_pane` is the typed read on top of
4199 /// `pane_matrices`. Returns the matching cell for visible
4200 /// Document panes and `None` for any other id (closed pane,
4201 /// non-Document leaf, unknown id).
4202 #[test]
4203 fn cells_matrix_for_pane_returns_matching_cell_or_none() {
4204 let mut editor = Editor::default();
4205 editor.publish_render_state();
4206 let rs = editor.render_state.load_full();
4207 let rsc = rs.cells.load();
4208 let active_pane_id = editor.pane_tree.active().id;
4209 let cell = rsc
4210 .matrix_for_pane(active_pane_id)
4211 .expect("active Document pane must resolve through matrix_for_pane");
4212 assert!(
4213 std::sync::Arc::ptr_eq(cell, &rsc.matrix),
4214 "active pane's matrix_for_pane lookup must match top-level cells.matrix"
4215 );
4216 // Unknown id returns None.
4217 let unknown = lattice_core::ui::pane::PaneId(u32::MAX);
4218 assert!(rsc.matrix_for_pane(unknown).is_none());
4219 }
4220
4221 /// D.4.d.1.c: non-Document leaves are absent from
4222 /// `pane_matrices` (the worker filters them out of `panes`).
4223 /// Renderers' per-kind dispatch already knows not to consult
4224 /// cells for those kinds.
4225 #[test]
4226 fn cells_pane_matrices_skip_non_document_leaves() {
4227 use lattice_core::BufferKind;
4228 use lattice_core::ui::pane::SplitOrientation;
4229 let mut editor = Editor::default();
4230 editor.pane_tree.split_active(SplitOrientation::Vertical);
4231 let non_doc_pane_id = {
4232 let leaves = editor.pane_tree.leaves_mut();
4233 // Terminal, not FileTree: DL.4/DL.5 made both listings
4234 // document-backed, so only Terminal still exercises this filter.
4235 leaves[1].buffer = BufferKind::Terminal;
4236 leaves[1].id
4237 };
4238 editor.publish_render_state();
4239 let rs = editor.render_state.load_full();
4240 assert_eq!(rs.cells.load().pane_matrices.len(), 1);
4241 assert!(rs.cells.load().matrix_for_pane(non_doc_pane_id).is_none());
4242 }
4243
4244 // ---- D.4.d.2.1.d (per-pane virtual-rows matrix lookup) ----
4245
4246 /// D.4.d.2.1.d: `virtual_rows.pane_matrices` carries one
4247 /// entry per visible Document leaf; each entry's matrix Arc
4248 /// identity matches the corresponding
4249 /// `cells.panes[i].virtual_rows_matrix` (the registry cell
4250 /// the worker writes through). Mirror of the cells
4251 /// `cells_pane_matrices_mirror_panes_entries` test for the
4252 /// virtual-rows pipeline.
4253 #[test]
4254 fn virtual_rows_pane_matrices_mirror_panes_entries() {
4255 use lattice_core::ui::pane::SplitOrientation;
4256 let mut editor = Editor::default();
4257 editor.pane_tree.split_active(SplitOrientation::Vertical);
4258 editor.publish_render_state();
4259 let rs = editor.render_state.load_full();
4260 assert_eq!(rs.cells.load().panes.len(), 2);
4261 assert_eq!(rs.virtual_rows.pane_matrices.len(), 2);
4262 for entry in rs.cells.load().panes.iter() {
4263 let lookup = rs
4264 .virtual_rows
4265 .pane_matrices
4266 .get(&entry.pane_id)
4267 .expect("every pane must appear in virtual_rows.pane_matrices");
4268 assert!(
4269 std::sync::Arc::ptr_eq(lookup, &entry.virtual_rows_matrix),
4270 "pane_matrices lookup must return the same Arc as panes[i].virtual_rows_matrix"
4271 );
4272 }
4273 }
4274
4275 /// D.4.d.2.1.d: `matrix_for_pane` is the typed read on top
4276 /// of `pane_matrices`. Returns the matching cell for visible
4277 /// Document panes and `None` for any other id (closed pane,
4278 /// non-Document leaf, unknown id).
4279 ///
4280 /// `Editor::default()` doesn't run the boot seed (which is
4281 /// what shares Arc identity with `virtual_rows_matrix_cell`),
4282 /// so we assert the registry-port equality here — the
4283 /// matrix the renderer would read through this lookup is
4284 /// the same one `virtual_rows_matrix_for(buffer_id)` returns.
4285 /// The D.4.d.2.0 boot Arc-identity invariant is covered in
4286 /// `dispatch::tests::virtual_rows_matrix_for_active_doc_shares_field_arc`.
4287 #[test]
4288 fn virtual_rows_matrix_for_pane_returns_matching_cell_or_none() {
4289 let mut editor = Editor::default();
4290 editor.publish_render_state();
4291 let rs = editor.render_state.load_full();
4292 let active_pane_id = editor.pane_tree.active().id;
4293 let cell = rs
4294 .virtual_rows
4295 .matrix_for_pane(active_pane_id)
4296 .expect("active Document pane must resolve through matrix_for_pane");
4297 let registry_cell = editor.virtual_rows_matrix_for(editor.document_buffer_id);
4298 assert!(
4299 std::sync::Arc::ptr_eq(cell, ®istry_cell),
4300 "active pane's matrix_for_pane lookup must return the same Arc as the registry"
4301 );
4302 // Unknown id returns None.
4303 let unknown = lattice_core::ui::pane::PaneId(u32::MAX);
4304 assert!(rs.virtual_rows.matrix_for_pane(unknown).is_none());
4305 }
4306
4307 /// D.4.d.2.1.d: non-Document leaves are absent from
4308 /// `virtual_rows.pane_matrices` (the publisher filters
4309 /// them out of `cells.panes` upstream, so the lookup
4310 /// derived from `panes` is automatically scoped to
4311 /// Document panes only). Mirror of
4312 /// `cells_pane_matrices_skip_non_document_leaves`.
4313 #[test]
4314 fn virtual_rows_pane_matrices_skip_non_document_leaves() {
4315 use lattice_core::BufferKind;
4316 use lattice_core::ui::pane::SplitOrientation;
4317 let mut editor = Editor::default();
4318 editor.pane_tree.split_active(SplitOrientation::Vertical);
4319 let non_doc_pane_id = {
4320 let leaves = editor.pane_tree.leaves_mut();
4321 // Terminal, not FileTree: DL.4/DL.5 made both listings
4322 // document-backed, so only Terminal still exercises this filter.
4323 leaves[1].buffer = BufferKind::Terminal;
4324 leaves[1].id
4325 };
4326 editor.publish_render_state();
4327 let rs = editor.render_state.load_full();
4328 assert_eq!(rs.virtual_rows.pane_matrices.len(), 1);
4329 assert!(rs.virtual_rows.matrix_for_pane(non_doc_pane_id).is_none());
4330 }
4331
4332 /// `publish_render_state` fires `cells_wake.notify_one()` —
4333 /// the permit-style coalescing means a single `notified()`
4334 /// resolves after one or many publishes. Validates S2.2's
4335 /// future worker will see the wake.
4336 #[tokio::test]
4337 async fn publish_render_state_fires_cells_wake() {
4338 let mut editor = Editor::default();
4339 // Permit set by the publish call; subsequent
4340 // `notified().await` resolves immediately.
4341 editor.publish_render_state();
4342 let waker = editor.cells_wake.0.clone();
4343 // Borderline impossible to hit the timeout if the permit
4344 // is set — `tokio::time::timeout` returns Err only on
4345 // genuine miss.
4346 let result = tokio::time::timeout(std::time::Duration::from_millis(50), async move {
4347 waker.notified().await
4348 })
4349 .await;
4350 assert!(
4351 result.is_ok(),
4352 "cells_wake permit must be set by publish_render_state"
4353 );
4354 }
4355}