Skip to main content

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, &registry_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, &registry_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, &registry_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, &registry_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}