Skip to main content

lattice_ui_gpui/
lib.rs

1//! The GPUI renderer: lattice's windowed, GPU-accelerated front end, a peer
2//! of the terminal renderer (`lattice-ui-tui`) over the same `lattice-host`
3//! editor substrate (Phase 5.7). The real window needs the `window` feature,
4//! which `lattice-cli` turns on under its `gui` feature.
5//!
6//! Design anchor: `docs/dev/architecture/design.md` §5.6 (rendering
7//! layered architecture) + `docs/dev/architecture/phase-5-extraction.md`
8//! slice 5.7 + post-Option-E pivot notes (lines 348–349). The
9//! original §5.6.1 `Renderer` trait (with `Frame` / `InputEvent` /
10//! `LayoutConstraints` / `paint`) was dropped: ratatui's pull-based
11//! draw loop and GPUI's retained-mode element tree are structurally
12//! different, and forcing a shared `Frame` type buys nothing. The
13//! current renderer-trait shape (`lattice_host::Renderer`) is the
14//! composition-typed associated-types version that came out of 5.B.
15//!
16//! Per the design (§5.6.1) the **GPU UI is the primary v1 surface**;
17//! the TUI is a first-class peer for headless / SSH / low-bandwidth
18//! use. This crate is the GPU peer; both renderers share the host
19//! substrate (`lattice-host`) and never depend on each other.
20//!
21//! ## Slicing
22//!
23//! This file ships the renderer-neutral scaffold types — they build
24//! on every host (including headless CI and WSL2 without display
25//! libs) and exercise the host-substrate decoupling claim:
26//!
27//! - [`GpuiTheme`] — stub theme cache. Mirrors the role of
28//!   `lattice-ui-tui::theme::Theme` (cached pre-computed render
29//!   primitives so per-frame reads are direct) but will hold GPUI
30//!   native style primitives (`Hsla`, `TextStyle`) in 5.7.B+. Empty
31//!   today so the scaffold has no transitive gpui-link requirement.
32//!
33//! - [`GpuiPaneRenderRegistry`] — stub registry implementing
34//!   [`lattice_host::pane_render::ProviderLookup`]. The host's
35//!   mode-walk ([`lattice_host::pane_render::resolve_pane_render_mode`])
36//!   already routes through it; storage will fill with the GPUI-typed
37//!   render fn signature in 5.8+.
38//!
39//! - [`GpuiRenderer`] — `impl lattice_host::Renderer` zero-sized
40//!   marker that surfaces the renderer-specific associated types
41//!   to the host's renderer-trait machinery.
42//!
43//! - [`GpuiApp`] — the renderer-side composition root. Mirrors
44//!   `lattice-ui-tui::app::App` in shape: `{editor, theme,
45//!   pane_render_registry}` (an `lsp_file_watcher` field joins when
46//!   the LSP runtime adapter for GPUI lands in 5.8+).
47//!
48//! The window-opening entry point lives in the `lattice-gpui`
49//! binary (`src/bin/lattice_gpui.rs`) behind the `window` Cargo
50//! feature. That binary pulls real gpui at link time; the lib above
51//! does not. This split keeps the scaffold's test target buildable
52//! everywhere while the binary is opt-in for environments with
53//! display libs installed.
54//!
55//! Phase 5.7.B.1 landed [`lattice_host::editor::Editor::boot`]:
56//! the renderer-neutral construction body (LSP subsystem,
57//! command registry, mode registry, snippet registry, event
58//! bus, completion / picker / config registries, syntax +
59//! buffer registry seeding) lives on the host substrate. Phase
60//! 5.7.B.2 (this slice) wires [`GpuiApp::new`] to call it:
61//! `GpuiApp::new(document)` mirrors `lattice-ui-tui::App::new`'s
62//! shape — the renderer-neutral half goes through `Editor::boot`,
63//! the renderer-side wrapper holds the theme + pane-render
64//! registry. Real dispatch + paint wiring (key events ->
65//! `Action` -> `editor.dispatch`; paint reads `editor.document`
66//! snapshot + cursor) follows in 5.7.B.3 / 5.7.B.4.
67
68use lattice_core::{BufferKind, Document};
69use lattice_host::Renderer;
70use lattice_host::action::Action;
71use lattice_host::chord::KeyChord;
72use lattice_host::dispatch::{DispatchOutcome, RendererSignal};
73use lattice_host::editor::Editor;
74use lattice_host::input::TranslateContext;
75use lattice_host::pane_render::ProviderLookup;
76use lattice_host::render_state::{ActiveDocumentRenderState, RenderState};
77use lattice_mode::ModeId;
78use std::sync::Arc;
79
80pub mod gpui_chord;
81
82/// GPUI window-opening entry point ([`run`], [`document_from_path`],
83/// [`document_from_first_arg`]). Behind the `window` Cargo feature
84/// so the lib's headless build doesn't link gpui. Lifted from the
85/// `lattice-gpui` binary in Phase 5.9 so `lattice-cli --gpu` can
86/// reuse the same entry without duplicating the window setup.
87#[cfg(feature = "window")]
88pub mod window;
89
90/// Per-pane resolved view options (gutter, cursorline, sign columns, indent
91/// guides). Deliberately NOT behind the `window` feature: it names no gpui
92/// type, and the rule it enforces — a pane's options come from the buffer it
93/// shows, never the active document — is worth testing in the headless build
94/// that CI runs.
95pub mod pane_options;
96
97/// Slice W.3: pure `decorations` → GPUI window-chrome map
98/// (`window_chrome`/`full_titlebar`). Gated identically to `window` since
99/// it names `gpui::{SharedString, TitlebarOptions, WindowDecorations}` and
100/// is only reachable from `window.rs`'s `WindowOptions` construction.
101#[cfg(feature = "window")]
102pub mod window_chrome;
103
104/// Phase 5.8.AF.5 / Slice X3.full.1: custom GPUI `Element` rendering
105/// pane text via `ShapedLine::paint` -- replaces the per-char-Div
106/// element tree that dominated `paint_us`.
107#[cfg(feature = "window")]
108// Phase 5.8.AF.6 / Slice X5: `editor_element` is normally
109// crate-private (the only legitimate caller is `window::paint_pane`).
110// With `bench-internals` it becomes `pub` so the frame-budget
111// bench can call the pre-paint logic (`build_line_with_inlays`,
112// `byte_to_combined_col`, ...) without spinning up a real GPUI
113// window. The shaped-text + paint phases remain GPU-bound and
114// are not reachable from the bench surface.
115#[cfg(not(feature = "bench-internals"))]
116pub(crate) mod editor_element;
117#[cfg(feature = "bench-internals")]
118pub mod editor_element;
119// S4.0 (2026-05-26): cell-grid → GPUI TextRun converter. The
120// substrate→GPUI translation layer that turns
121// `lattice_cells::Cell` payloads into the
122// `(combined_text, Vec<TextRun>, inlay_offsets)` shape
123// `EditorElement::prepaint` consumes. Gated behind `window`
124// because it depends on `gpui::{Font, TextRun}`; mirrors the
125// `editor_element` gating. Anchor:
126// `docs/dev/architecture/cell-grid-renderer.md`.
127#[cfg(feature = "window")]
128pub mod cells_paint;
129// S4.final.a (2026-05-27): per-codepoint glyph-id cache. The
130// software cache that converts `(FontId, char)` →
131// `Option<ResolvedGlyph>` so the `paint_cells` loop can hand
132// `(font_id, glyph_id)` to `Window::paint_glyph` without going
133// through `shape_line`. Gated behind `window`; mirrors the
134// `cells_paint` gating.
135#[cfg(feature = "window")]
136pub mod glyph_resolver;
137// S4.final.b (2026-05-27): per-cell `paint_glyph` body path.
138// `paint_cells_row` walks one CellRow and emits per-cell bg
139// quads + glyphs. Gated behind a runtime env-var toggle
140// (`LATTICE_PAINT_CELLS=1`) so both `shape_line` and
141// `paint_cells` paths coexist until S4.final.f retires
142// `shape_line` on the document body.
143#[cfg(feature = "window")]
144pub mod paint_cells;
145// S4.final.c (2026-05-27): hit-testing primitives on the cell
146// grid. `x_to_combined_col` / `col_to_x` / `combined_col_to_byte`
147// for mapping mouse positions to buffer coordinates without
148// going through `ShapedLine`. Forward-looking infrastructure
149// for the eventual mouse-select / drag-select handler in
150// `window.rs`.
151#[cfg(feature = "window")]
152pub mod hit_test;
153// IG.4 (2026-08-16): indentation-guide paint geometry. NOT gated on
154// `window`: the column arithmetic is the part worth testing, and gating
155// it would put it out of reach of `cargo test -p lattice-ui-gpui`. The
156// paint itself lives in `editor_element`, which is gated.
157// Anchor: `docs/dev/architecture/indent-guides.md`.
158pub(crate) mod indent_guides;
159
160#[cfg(feature = "window")]
161pub use window::{document_from_path, run};
162
163/// GPUI peer's typed theme cache.
164///
165/// Phase 5.7.B.12: real fields land for the surfaces the
166/// binary currently paints (background, foreground, status
167/// line, cursor inversion). Stored as `Rgba` so the render
168/// hot path is a direct `.bg(self.theme.background)` etc. --
169/// no per-frame conversion.
170///
171/// The defaults match the Catppuccin Mocha-ish palette the
172/// binary's render used inline pre-5.7.B.12. Future host-side
173/// slices will grow `host_theme` to carry window bg / fg /
174/// status / cursor fields; once that lands,
175/// [`GpuiApp::rebuild_gpui_theme`] reads from there and
176/// `RendererSignal::ThemeChanged` will visibly recolor the
177/// window on every `:set ui.*`. For now the rebuild is a
178/// shape-only no-op -- the wiring is what this slice unblocks.
179///
180/// Why `Rgba` and not `Hsla`: gpui's `rgb(0x...) -> Rgba`
181/// builder is the natural literal form; `.bg(Rgba)` /
182/// `.text_color(Rgba)` accept it directly via `Into<Background>`
183/// / `Into<Hsla>` blanket impls. Sticking with `Rgba` keeps the
184/// theme literal-friendly + the binary's render free of
185/// conversion boilerplate.
186/// Color storage discipline: the lib stores `u32` packed
187/// `0xRRGGBB` hex so it builds without the `window` feature (no
188/// transitive `gpui` link). The binary converts to `gpui::Rgba`
189/// at render time via `gpui::rgb(theme.background)`.
190#[derive(Debug, Clone)]
191pub struct GpuiTheme {
192    /// Font family name for the editor window. Must be a monospace
193    /// typeface. Configurable via `ui.font_family`; defaults to
194    /// "Menlo" (built-in macOS monospace). Updated by
195    /// `rebuild_gpui_theme` on every `RendererSignal::ThemeChanged`.
196    pub font_family: String,
197    /// Font size in points. Configurable via `ui.font_size`.
198    pub font_size_pt: u32,
199    /// Whether OpenType ligatures are enabled. Configurable via
200    /// `ui.ligatures` (default `true`). When `false`,
201    /// `FontFeatures::disable_ligatures()` is applied to every
202    /// `Font` before shaping, suppressing `calt` (drives programming ligatures).
203    pub ligatures: bool,
204    /// Main document background.
205    pub background: u32,
206    /// Main document foreground (text + non-cursor chars).
207    pub foreground: u32,
208    /// Status-line background.
209    pub status_background: u32,
210    /// Status-line foreground.
211    pub status_foreground: u32,
212    /// Cursor block background (Block shape) + bar / underline
213    /// border color (Bar / Underline shapes).
214    pub cursor_background: u32,
215    /// Cursor block foreground -- the character color when the
216    /// block inverts (Normal/Visual mode cursor cell). Unused
217    /// by Bar / Underline since the underlying char text stays
218    /// the document foreground.
219    pub cursor_foreground: u32,
220    /// Popup-overlay background (DisplayBuffer help surface).
221    /// Slightly darker than the main bg for visual separation.
222    pub popup_background: u32,
223    /// Popup-overlay border / accent color.
224    pub popup_border: u32,
225    /// PP.2b: the root a rooted picker is scoped to, in its prompt.
226    /// Its own slot rather than `popup_border` (which it shared with the
227    /// `(n/m)` count) so the one piece of context the prompt carries is
228    /// not the dimmest thing on the line.
229    pub picker_root: u32,
230    /// PP.2c: the picker prompt's source name, `>` marker and match
231    /// count. Own slots rather than the borrowed `cursor_background` /
232    /// `popup_border` they used — borrowing is what left the count and
233    /// the root indistinguishable, and is the same failure the
234    /// `transient.*` set was introduced to end.
235    pub picker_title: u32,
236    pub picker_prompt: u32,
237    pub picker_count: u32,
238    /// Popup header TITLE colour (bold accent) — `ui.popup.title`.
239    pub popup_title: u32,
240    /// Popup header HINT colour (dim) — `ui.popup.hint`.
241    pub popup_hint: u32,
242    /// Notification-severity colours, sourced from the
243    /// `diagnostic.{warning,error}` theme elements.
244    ///
245    /// The notification overlay referenced `diff_change_line_bg` /
246    /// `diff_remove_line_bg` here — fields that never existed on this
247    /// struct, so the `window`-feature build did not compile. Diff
248    /// line backgrounds were the wrong source anyway: these tint
249    /// notification *text* by severity, which is what
250    /// `diagnostic.warning` / `diagnostic.error` already mean, and
251    /// they recolour with `:colorscheme` like every other element.
252    pub notification_warn: u32,
253    pub notification_error: u32,
254    /// Info and success rows ↔ `diagnostic.info` / `diff.add.sign`,
255    /// the elements the TUI peer reads for the same levels.
256    pub notification_info: u32,
257    pub notification_success: u32,
258    /// Issue #35 (2026-05-22): picker match-range highlight
259    /// color. Painted on the substring of each candidate that
260    /// matched the query. Catppuccin Mocha peach by default —
261    /// distinct from `foreground` so matches actually pop
262    /// (previously used `cursor_background` which equals
263    /// `foreground` in the default palette and made matches
264    /// invisible).
265    pub picker_match_highlight: u32,
266    /// Picker marginalia (annotation / kind glyph) color.
267    /// Mid-grey by default so it doesn't compete with the
268    /// candidate text.
269    pub picker_marginalia_fg: u32,
270    /// IG.4: indentation-guide rule colour, from the `indent.guide`
271    /// theme element. Dim by default — a guide bright enough to read
272    /// directly is one that competes with the code it measures.
273    pub indent_guide: u32,
274    /// IG.4: the guide for the block enclosing the cursor, from
275    /// `indent.guide.active`. Same hue undimmed; the contrast between
276    /// the two is what carries the signal.
277    pub indent_guide_active: u32,
278}
279
280impl Default for GpuiTheme {
281    fn default() -> Self {
282        Self {
283            font_family: String::from("Menlo"),
284            font_size_pt: 14,
285            ligatures: true,
286            // Catppuccin Mocha base.
287            background: 0x1e1e2e,
288            // Catppuccin Mocha text.
289            foreground: 0xcdd6f4,
290            // Catppuccin Mocha surface0.
291            status_background: 0x313244,
292            // Catppuccin Mocha green.
293            status_foreground: 0xa6e3a1,
294            // Catppuccin Mocha text (matches block-cursor "highlight").
295            cursor_background: 0xcdd6f4,
296            // Catppuccin Mocha base (inverted, for block-cursor char).
297            cursor_foreground: 0x1e1e2e,
298            // Catppuccin Mocha mantle (deeper than base).
299            popup_background: 0x181825,
300            // Catppuccin Mocha lavender (accent).
301            popup_border: 0xb4befe,
302            picker_root: 0x89b4fa,
303            picker_title: 0x94e2d5,
304            picker_prompt: 0x94e2d5,
305            picker_count: 0x9399b2,
306            // Catppuccin Mocha blue — popup title accent (`ui.popup.title`).
307            popup_title: 0x89b4fa,
308            // Catppuccin Mocha overlay — dim popup hint (`ui.popup.hint`).
309            popup_hint: 0x6c7086,
310            // Catppuccin-ish peach / red, matching the diagnostic
311            // defaults elsewhere; overridden by the resolved elements.
312            notification_warn: 0xfab387,
313            notification_error: 0xf38ba8,
314            // Catppuccin Mocha sky / green.
315            notification_info: 0x89dceb,
316            notification_success: 0xa6e3a1,
317            // Issue #35: Catppuccin Mocha peach — bright accent
318            // distinct from `foreground` (text). Highly
319            // visible against both light and dark backgrounds.
320            picker_match_highlight: 0xfab387,
321            // Catppuccin Mocha overlay1 — mid-grey for
322            // marginalia / annotations. Softer than
323            // `popup_border` (which doubles as the popup
324            // accent) so kind glyphs don't dominate the row.
325            picker_marginalia_fg: 0x7f849c,
326            // Catppuccin Mocha surface1 / overlay0 — the same pair the
327            // TUI resolves from `indent.guide{,.active}` when the theme
328            // leaves them at their registered defaults.
329            indent_guide: 0x45475a,
330            indent_guide_active: 0x6c7086,
331        }
332    }
333}
334
335/// Stub GPUI pane-render registry. Implements [`ProviderLookup`] so
336/// the host's mode-walk already routes through it; storage fills with
337/// the GPUI-typed render fn signature in 5.8+.
338#[derive(Default)]
339pub struct GpuiPaneRenderRegistry {
340    /// Forward-compat placeholder. Replaced by
341    /// `HashMap<ModeId, GpuiPaneRenderProvider>` once the GPUI
342    /// render fn shape stabilises.
343    _registered: std::collections::HashSet<ModeId>,
344}
345
346impl ProviderLookup for GpuiPaneRenderRegistry {
347    fn has_provider(&self, mode: ModeId) -> bool {
348        self._registered.contains(&mode)
349    }
350}
351
352/// GPUI renderer marker. Holds no state — the renderer-trait
353/// associated types point at the renderer-specific theme +
354/// pane-render registry types this crate owns.
355pub struct GpuiRenderer;
356
357impl Renderer for GpuiRenderer {
358    type Theme = GpuiTheme;
359    type PaneRenderRegistry = GpuiPaneRenderRegistry;
360}
361
362/// The GPUI-side renderer composition root. Mirrors
363/// `lattice-ui-tui::app::App` in shape: renderer-side caches
364/// plus the renderer-neutral [`Editor`]. A future `lsp_file_watcher`
365/// field joins when the LSP runtime adapter for GPUI lands.
366pub struct GpuiApp {
367    /// Slice 3c.final.E.swap: cfg-gated. Production builds hold an
368    /// [`EditorActorHandle`]; test builds keep direct `Editor`
369    /// ownership for fixtures that mutate state without going
370    /// through the dispatch path. Same shape as TUI peer's `App`.
371    #[cfg(not(test))]
372    pub editor_actor: lattice_host::editor_actor::EditorActorHandle,
373    #[cfg(test)]
374    pub editor: Editor,
375    /// Phase 5.8.AF.5 / Slice 3c.atomic.K: renderer-side clone of
376    /// the editor's `RenderState` cell. Parallel of the TUI peer's
377    /// `App.render_state` field (3c.atomic.A): isolates the
378    /// renderer's read path from `self.editor` so the eventual
379    /// `App.editor: Editor → handle` swap doesn't disturb call
380    /// sites. Today both Arc handles point at the same underlying
381    /// `ArcSwap<RenderState>` instance — readers observe identical
382    /// values byte-for-byte regardless of which they go through.
383    pub render_state: Arc<arc_swap::ArcSwap<RenderState>>,
384    pub theme: GpuiTheme,
385    pub pane_render_registry: GpuiPaneRenderRegistry,
386    /// Slice 3c.final.B-extension: `paint_request` cloned at boot so
387    /// `EditorView::new` can subscribe without a `read_editor` round-trip.
388    /// The `Arc<Notify>` is shared with the highlights worker; wakes
389    /// propagate to GPUI's foreground executor via `cx.notify()`.
390    pub paint_request: std::sync::Arc<tokio::sync::Notify>,
391    // Phase 5.8.AE: `popup_content` retired. Popup state is
392    // unified in `editor.popup_buffer` (+ buffer-locals for
393    // links/anchors/highlights). The binary's render reads
394    // `editor.popup_help()` for the buffer and the host
395    // accessors for metadata, so both renderer peers paint
396    // popups from the same source-of-truth state.
397}
398
399impl GpuiApp {
400    /// Build the GPUI peer's composition root from an initial
401    /// [`Document`]. Mirrors `lattice-ui-tui::app::App::new`:
402    /// delegates the renderer-neutral construction to
403    /// [`Editor::boot`] (LSP subsystem, command / mode /
404    /// completion / picker / config registries, snippet handle,
405    /// event bus, syntax, buffer registry) and supplies the
406    /// renderer-side caches alongside.
407    ///
408    /// Real dispatch (key events -> `Action` -> `editor.dispatch`) +
409    /// paint (read `editor.document` snapshot + cursor) wire in
410    /// 5.7.B.3 / 5.7.B.4. The renderer-side post-boot helpers
411    /// the TUI peer runs after `Editor::boot`
412    /// (`activate_major_for_buffer_kind`,
413    /// `publish_document_opened_for_active`,
414    /// `ensure_named_synthetic_document`,
415    /// `ensure_messages_buffer`) plug in as the corresponding
416    /// GPUI wiring lands -- their bodies are host-resident, so
417    /// this peer will call the same methods via its own renderer-
418    /// signal handler.
419    pub fn new(document: Document) -> Self {
420        // DB.5 (design.md §9.1): capture the opened-file path BEFORE
421        // `document` moves into `Editor::boot` (which consumes it) —
422        // mirrors the TUI seam in `lattice-ui-tui/src/app/boot.rs`.
423        let opened_file = document.path().map(|p| p.to_path_buf());
424        let mut editor = Editor::boot(document);
425        // CB.4 (docs/dev/architecture/clipboard.md §4/§7): override CB.0's
426        // default `FakeClipboard` with the shared native `arboard` backend
427        // (the same `lattice_host::clipboard::ArboardClipboard` the TUI
428        // peer uses — one impl, honoring the synchronous-read contract).
429        // The GPUI peer always links display libs (the `window` feature),
430        // so `arboard` is always available in a real GUI build and needs
431        // no OSC52 fallback (a GUI is never a headless terminal). Fork-1
432        // named "gpui-native" here, but gpui's own clipboard is reachable
433        // only via `&App` on the main thread, which can't satisfy the
434        // `Send + Sync` trait's synchronous read from the editor actor
435        // thread; arboard is the sound resolution (see the slice plan
436        // CB.4 note). Gated on `system-clipboard` (pulled by `window`) so
437        // the non-window scaffold build stays dep-light. `Arc::get_mut`
438        // succeeds here for the same reason as the TUI seam: the registry
439        // is a freshly-frozen, uniquely-owned Arc immediately after
440        // `Editor::boot`.
441        //
442        // `not(test)`: test builds keep CB.0's `FakeClipboard` so `cargo
443        // test` never clobbers the developer's real system clipboard —
444        // the same hermeticity rule the TUI peer applies in
445        // `lattice-ui-tui/src/clipboard.rs`'s `boot_backend`. Nothing else
446        // changes: leaving the fake in place is already this seam's
447        // no-display path below.
448        #[cfg(all(feature = "system-clipboard", not(test)))]
449        match lattice_host::clipboard::ArboardClipboard::new() {
450            Some(native) => {
451                if let Some(services) = std::sync::Arc::get_mut(&mut editor.services) {
452                    let clipboard: lattice_core::ClipboardHandle = std::sync::Arc::new(native);
453                    services.register(clipboard);
454                }
455            }
456            None => {
457                // No reachable display clipboard — leave CB.0's
458                // FakeClipboard (in-memory register behavior). Unusual for
459                // a GUI (which owns a window), so note it once for
460                // diagnosis; never fatal.
461                tracing::debug!(
462                    "clipboard: native (arboard) init failed at GPUI boot; \
463                     paste-from-another-app unavailable this session"
464                );
465            }
466        }
467        // DB.5 (test isolation): disable the dashboard auto-open BEFORE
468        // the `Startup` publish so unit tests (which build pathless
469        // documents that look like a no-file launch) don't get the
470        // dashboard buffer instead of their own text. Race-free — the
471        // trigger's task reads `dashboard.enabled` only after receiving
472        // `Startup`, which can't arrive before this publish. Mirrors the
473        // TUI seam in `lattice-ui-tui/src/app/boot.rs`.
474        #[cfg(test)]
475        {
476            let _ = editor
477                .config
478                .parse_and_set_command("dashboard.enabled=false");
479        }
480        // DB.5: publish `Startup` once `editor` exists, right after `boot`
481        // returns — see the TUI seam for the full rationale.
482        editor
483            .event_bus
484            .publish_typed(lattice_mode::Startup { opened_file });
485        let render_state = editor.render_state.clone();
486        // Slice 3c.final.E.swap: run boot-time setup directly on
487        // the owned Editor BEFORE handing it to the actor. The
488        // finalize_boot body's host-routed work runs here as
489        // direct Editor calls. Renderer-side post-actor wiring
490        // (theme rebuild) runs after the App is built.
491        editor.rebuild_option_cache();
492        if editor.viewport_height == 0 {
493            editor.viewport_height = 30;
494        }
495        let doc_id = editor.document_buffer_id;
496        let _ = editor.activate_major_for_buffer_kind(doc_id, BufferKind::Document);
497        editor.publish_document_opened_for_active();
498        editor.ensure_subsystem_buffers();
499        let workspace_root = lattice_core::project::root_from_cwd();
500        let _ = editor.load_persistent_config(workspace_root.as_deref());
501        editor.apply_per_language_toml_overrides();
502        // built-ins 2026-06-13: load embedded + user snippet packs
503        // once at startup (parity with the TUI runtime). Quiet —
504        // logs, no echo.
505        editor.load_snippets_at_startup();
506        // Initial RS publish so `app.ad()` returns boot state.
507        editor.publish_render_state();
508
509        // Clone before the actor consumes the editor so EditorView::new
510        // can subscribe without a read_editor round-trip.
511        let paint_request = editor.paint_request.clone();
512
513        // Slice 3c.final.E.swap: hand Editor to the actor (prod)
514        // or keep inline (test).
515        #[cfg(not(test))]
516        let editor_field = lattice_host::editor_actor::spawn_editor_actor(editor);
517        #[cfg(test)]
518        let editor_field = editor;
519
520        let mut app = Self {
521            #[cfg(not(test))]
522            editor_actor: editor_field,
523            #[cfg(test)]
524            editor: editor_field,
525            render_state,
526            theme: GpuiTheme::default(),
527            pane_render_registry: GpuiPaneRenderRegistry::default(),
528            paint_request,
529        };
530        // App-side post-actor: rebuild the cached GPUI theme from
531        // the freshly-published `render_state.theme`.
532        app.rebuild_gpui_theme();
533        app
534    }
535
536    /// Phase 5.8.AF.5 / Slice 3c.atomic.K: wait-free snapshot of
537    /// the active-buffer hot-path render state. Returns an `Arc`
538    /// clone of `RenderState.active_document`. Parallel of the
539    /// TUI peer's `App::ad()` -- the GPUI peer reads its
540    /// renderer-side state through this method so the eventual
541    /// `editor → handle` swap leaves call sites untouched.
542    pub fn ad(&self) -> Arc<ActiveDocumentRenderState> {
543        self.render_state.load().active_document.load_full()
544    }
545
546    /// Slice 3c.final.B.7: published echo-area state — parallel
547    /// of TUI peer's `App::messages()`.
548    pub fn messages(&self) -> Arc<lattice_host::render_state::MessagesRenderState> {
549        self.render_state.load().messages.clone()
550    }
551
552    /// Slice 3c.final.B.7: published modeline + cmdline + search
553    /// state — parallel of TUI peer's `App::modeline()`.
554    pub fn modeline(&self) -> Arc<lattice_host::render_state::ModelineRenderState> {
555        self.render_state.load().modeline.clone()
556    }
557
558    /// Slice 3c.final.B.10: published typed-options registry —
559    /// parallel of TUI peer's `App::options()`.
560    pub fn options(&self) -> Arc<lattice_host::render_state::OptionsRenderState> {
561        self.render_state.load().options.clone()
562    }
563
564    /// Slice 3c.final.B.11: published active-modes map —
565    /// parallel of TUI peer's `App::modes()`.
566    pub fn modes(&self) -> Arc<lattice_host::render_state::ModesRenderState> {
567        self.render_state.load().modes.clone()
568    }
569
570    /// Slice 3c.final.B.9: published buffer-locals map —
571    /// parallel of TUI peer's `App::buffer_locals()`.
572    pub fn buffer_locals(&self) -> Arc<lattice_host::render_state::BufferLocalsRenderState> {
573        self.render_state.load().buffer_locals.clone()
574    }
575
576    /// Phase 5.8.AF.5 / Slice 3c.final.E.4: routing helpers for
577    /// editor mutations. Same shape as the TUI peer's
578    /// `App::mutate_editor` / `App::mutate_editor_with`. Pre-swap
579    /// runs the closure against `&mut self.editor` + publishes RS;
580    /// post-swap delegates to the actor handle. Forward-compatible
581    /// `Send + 'static` bounds.
582    pub fn mutate_editor<F>(&mut self, f: F)
583    where
584        F: FnOnce(&mut Editor) + Send + 'static,
585    {
586        #[cfg(not(test))]
587        {
588            self.editor_actor
589                .mutate_blocking(Box::new(f))
590                .expect("editor actor alive");
591        }
592        #[cfg(test)]
593        {
594            f(&mut self.editor);
595            self.editor.publish_render_state();
596        }
597    }
598
599    /// Variant for closures that return a value.
600    pub fn mutate_editor_with<F, R>(&mut self, f: F) -> R
601    where
602        F: FnOnce(&mut Editor) -> R + Send + 'static,
603        R: Send + 'static,
604    {
605        #[cfg(not(test))]
606        {
607            self.editor_actor.mutate_blocking_with(f)
608        }
609        #[cfg(test)]
610        {
611            let r = f(&mut self.editor);
612            self.editor.publish_render_state();
613            r
614        }
615    }
616
617    /// Read-only helper -- parallels `App::read_editor` on the TUI
618    /// peer. In production routes through the actor handle's
619    /// `with_editor` blocking RPC; in tests calls directly.
620    pub fn read_editor<F, R>(&self, f: F) -> R
621    where
622        F: FnOnce(&Editor) -> R + Send + 'static,
623        R: Send + 'static,
624    {
625        #[cfg(not(test))]
626        {
627            self.editor_actor.with_editor(f)
628        }
629        #[cfg(test)]
630        {
631            f(&self.editor)
632        }
633    }
634
635    /// Phase 5.8.AF.5 / Slice 3c.atomic.K: publishing wrapper
636    /// around `editor.viewport_height` writes. Parallel of the
637    /// TUI peer's `App::set_viewport_height` (3c.atomic.D): the
638    /// per-frame viewport recompute happens outside dispatch, so
639    /// it must republish render-state for `ad().viewport_height`
640    /// and `ad().scroll` to observe the new values. Without this
641    /// wrapper, a window resize would leave the published
642    /// viewport / scroll one frame behind every time.
643    pub fn set_viewport_height(&mut self, height: u32) {
644        // Slice 3c.final.C: route through dispatch so the
645        // mutation goes via `Action::SetViewportHeight`. Dispatch
646        // tail publishes RenderState; no manual publish needed.
647        self.dispatch_action(lattice_host::action::Action::SetViewportHeight(height));
648    }
649
650    /// Issue #25 (2026-05-22): per-pane geometry hand-off. The
651    /// renderer's per-frame layout pass fires one of these per
652    /// leaf in the pane tree. Production routes through the
653    /// editor actor's typed `SetPaneViewport` command;
654    /// cfg(test) mutates the in-process editor directly
655    /// (parallels the `mutate_editor_with` pattern). The
656    /// host's handler writes onto `PaneState[idx]` and mirrors
657    /// the active leaf's height into `Editor::viewport_height`
658    /// for cursor-clamp + highlights worker.
659    pub fn set_pane_viewport(&mut self, idx: usize, rows: u32, cols: u32) {
660        #[cfg(not(test))]
661        {
662            let _ = self.editor_actor.set_pane_viewport(idx, rows, cols);
663        }
664        #[cfg(test)]
665        {
666            let active_idx = self.editor.pane_tree.active_index();
667            let leaves = self.editor.pane_tree.leaves_mut();
668            if idx < leaves.len() {
669                leaves[idx].viewport_height = rows.max(1);
670                leaves[idx].viewport_width = cols.max(1);
671            }
672            if idx == active_idx {
673                self.editor.viewport_height = rows.max(1);
674                self.editor.ensure_cursor_visible();
675            }
676            self.editor.publish_render_state();
677        }
678    }
679
680    /// PU.2: floating-popup inner-geometry hand-off — the GPUI peer of
681    /// the TUI `App::set_popup_viewport`. The renderer is the sizing
682    /// authority for the popup's inner rect, so it pushes the resolved
683    /// `(rows, cols)` to the host; `build_cells_panes` reads
684    /// `Editor::popup_viewport_{height,width}` to size the synthetic
685    /// `PaneId::POPUP` `DisplayMatrix` the popup interior `EditorElement`
686    /// paints from. Plain field writes (no special host handler), so it
687    /// routes through `mutate_editor` (which republishes RS) rather than a
688    /// typed actor command. Diff-then-send in the render loop keeps a
689    /// steady-state popup at zero RPCs.
690    pub fn set_popup_viewport(&mut self, rows: u32, cols: u32) {
691        self.mutate_editor(move |e| {
692            e.popup_viewport_height = rows.max(1);
693            e.popup_viewport_width = cols.max(1);
694        });
695    }
696
697    /// WK.12: the minibuffer band's inner-geometry hand-off — the peer of
698    /// [`Self::set_popup_viewport`] for `PaneId::MINIBUFFER_BAND`.
699    /// `build_cells_panes` reads `Editor::band_viewport_{height,width}` to size
700    /// the band's matrix; without this the band paints unstyled fallback text.
701    pub fn set_band_viewport(&mut self, rows: u32, cols: u32) {
702        self.mutate_editor(move |e| {
703            e.band_viewport_height = rows.max(1);
704            e.band_viewport_width = cols.max(1);
705        });
706    }
707
708    /// PU.5d: completion-docs side-popup inner-geometry hand-off — the peer
709    /// of [`Self::set_popup_viewport`] for the second synthetic popup
710    /// (`PaneId::COMPLETION_DOCS`). `build_cells_panes` reads
711    /// `Editor::completion_docs_viewport_{height,width}` to size the docs
712    /// `DisplayMatrix`. Diff-then-send in the render loop keeps churn down.
713    pub fn set_completion_docs_viewport(&mut self, rows: u32, cols: u32) {
714        self.mutate_editor(move |e| {
715            e.completion_docs_viewport_height = rows.max(1);
716            e.completion_docs_viewport_width = cols.max(1);
717        });
718    }
719
720    /// Dismiss any active help popup. Phase 5.8.AE: routes
721    /// through the host's `dismiss_popup` so popup state lands
722    /// uniformly across both peers. Slice 3c.final.C: now goes
723    /// through `Action::DismissPopup` instead of a direct host
724    /// call.
725    pub fn dismiss_popup(&mut self) {
726        self.dispatch_action(lattice_host::action::Action::DismissPopup);
727    }
728
729    // display-line B4.2: the `refresh_highlights` no-op compatibility
730    // shim was deleted. It carried no behaviour (the worker drove the
731    // span cell that was itself deleted) and had no callers. Syntax
732    // colour flows through the cells / `DisplayMatrix` substrate;
733    // overlay backgrounds through `lattice_host::overlay_worker`.
734
735    /// Auto-scroll the active pane so the cursor stays in the
736    /// visible viewport (`[scroll, scroll + viewport_height)`).
737    /// Mirrors the TUI peer's per-tick scroll-clamp; both renderer
738    /// peers funnel through the host's `editor.scroll` so any
739    /// host-side cursor jump (a motion, a `:N`, a search bounce)
740    /// is reflected here on the next paint.
741    ///
742    /// Phase 5.8.O: foundational for cursorline / visual selection
743    /// (5.8.P, 5.8.Q) — those features paint *within* the viewport
744    /// and would mis-render if the cursor sat outside it.
745    pub fn ensure_cursor_in_viewport(&mut self) {
746        // Slice 3c.final.C: route through `Action::EnsureCursorVisible`
747        // so the per-frame mutation goes via dispatch. Tail
748        // publishes RS — no manual publish needed.
749        self.dispatch_action(lattice_host::action::Action::EnsureCursorVisible);
750    }
751
752    // Slice 3c.final.E.swap: `finalize_boot` retired. Its body
753    // (rebuild_option_cache / activate_major_for_buffer_kind /
754    // publish_document_opened_for_active / ensure_subsystem_buffers
755    // / load_persistent_config / apply_per_language_toml_overrides)
756    // was inlined into `GpuiApp::new`, running directly on the
757    // owned Editor BEFORE the actor spawns — the App-side
758    // wrappers would have funnelled through the actor mailbox
759    // which doesn't exist yet at that point in construction.
760    // `rebuild_gpui_theme` runs post-actor against the published
761    // RS theme.
762
763    /// Renderer-side handler for the [`RendererSignal`] stream
764    /// the host emits from option cascades + mode lifecycle
765    /// helpers. Phase 5.7.B.7 wiring -- mirrors the TUI peer's
766    /// `App::handle_renderer_signal` in shape; each arm's body
767    /// reflects the GPUI peer's current capability surface.
768    ///
769    /// As GPUI-side infrastructure grows (theme cache, popup +
770    /// pane display, file-tree buffers, window-close bridge),
771    /// the corresponding arms light up. The signal contract
772    /// itself is stable -- the host doesn't know which renderer
773    /// is on the other side; both peers see the same enum.
774    pub fn handle_renderer_signal(&mut self, signal: RendererSignal) {
775        match signal {
776            // 5.7.B.12: the cache holds real `Rgba` fields now,
777            // and the binary's render reads from `self.theme.*`
778            // instead of inline rgb literals. Rebuilding from
779            // `editor.host_theme` still needs window-bg / -fg /
780            // -cursor mappings on the host side, so this arm
781            // calls `rebuild_gpui_theme()` but the body is
782            // currently a shape-only no-op. Once those host
783            // mappings land, a `:set ui.bg=...` cascade will
784            // visibly recolor the window on the next frame.
785            RendererSignal::ThemeChanged => {
786                self.rebuild_gpui_theme();
787                tracing::debug!(
788                    "ThemeChanged signal: rebuild_gpui_theme called (host_theme→GpuiTheme mapping is a stub for now)"
789                );
790            }
791            // `editor.should_quit` was set alongside the signal
792            // emission (see `Action::Quit` in `Editor::dispatch`).
793            // The GPUI binary polls `should_quit` after each
794            // `dispatch_keystroke` and calls `cx.quit()` from the
795            // event handler -- the lib has no `gpui::App` context
796            // to drive shutdown from here.
797            RendererSignal::Quit => {
798                tracing::debug!("Quit signal: editor.should_quit set; binary polls per-event");
799            }
800            // The TUI peer's file-tree buffers embed nerd-font
801            // glyphs in their rendered rope, so a palette flip
802            // needs a rope refresh. The GPUI peer doesn't have
803            // file-tree buffers (or rope-side icon embedding)
804            // yet; the toggle is read live from
805            // `editor.host_theme.nerd_fonts` when those views
806            // exist. No-op for now -- the host emits this
807            // alongside `ThemeChanged` so renderers that don't
808            // track file-tree state can drop it.
809            RendererSignal::NerdFontsToggled => {
810                tracing::debug!(
811                    "NerdFontsToggled signal: no file-tree buffers in GPUI peer; no-op"
812                );
813            }
814            // The fan-out body migrated to
815            // `Editor::fan_out_did_change_configuration` in
816            // Phase 5.7.B.7; both peers route through the same
817            // LSP supervisor walk + per-actor `did_change_configuration`.
818            RendererSignal::LspConfigChanged(server_id) => {
819                self.mutate_editor(move |e| e.fan_out_did_change_configuration(&server_id));
820            }
821            // 5.7.B.10: surface the help content as a centered
822            // popup overlay. Renderer-side state is just
823            // `popup_content`; the binary's render reads the
824            // field each frame and draws the overlay when
825            // `Some`. The user dismisses with `Esc` (binary-
826            // side pre-empt before chord dispatch). Future
827            // refinement: resolve the request's `category` to a
828            // typed `BufferDisplay` preference (popup vs
829            // active-pane vs split) -- today's GPUI peer only
830            // implements the popup surface, so every category
831            // collapses to a popup overlay.
832            RendererSignal::DisplayBuffer(req) => {
833                // Phase 5.8.AE: route through host's display_buffer
834                // so the popup state lands on `editor.popup_buffer`.
835                // The binary's render reads `editor.popup_buffer` +
836                // `editor.popup_help()` instead of the now-retired
837                // `popup_content` field — keeping the popup state
838                // unified across both peers.
839                let lattice_host::dispatch::DisplayBufferRequest { content, category } = *req;
840                tracing::debug!(
841                    category = ?category,
842                    title = %content.buffer.title,
843                    "DisplayBuffer signal: routing through editor.display_buffer"
844                );
845                let (_id, mode_signals) =
846                    self.mutate_editor_with(move |e| e.display_buffer(content, category));
847                // Mode-activate signals don't recurse meaningfully
848                // for the popup paths (no further DisplayBuffer);
849                // drain through the handler so any ThemeChanged
850                // etc. still propagates.
851                for s in mode_signals {
852                    tracing::debug!(signal = ?s, "popup mode-activate cascade signal");
853                }
854            }
855        }
856    }
857
858    /// Convenience entry point for GPUI's key-down event handler:
859    /// `dispatch_keystroke(&ev.keystroke.key, mods.control, mods.alt,
860    /// mods.shift, mods.platform)`. Walks the same pipeline as the
861    /// TUI peer (chord normalisation → renderer-neutral `translate`
862    /// → `editor.dispatch`); returns the [`DispatchOutcome`] for
863    /// callers that want to inspect renderer signals (or
864    /// [`None`] if the key string didn't map to a chord).
865    ///
866    /// Phase 5.7.B.3 scope: the outcome's renderer signals are
867    /// returned but not yet fanned out (no GPUI-side equivalent of
868    /// `App::handle_renderer_signal` yet). 5.7.B.4+ adds a signal
869    /// handler so theme changes, option-cache rebuilds, and major-
870    /// mode activations propagate to the renderer caches.
871    pub fn dispatch_keystroke(
872        &mut self,
873        key: &str,
874        control: bool,
875        alt: bool,
876        shift: bool,
877        platform: bool,
878    ) -> Option<DispatchOutcome> {
879        let chord = gpui_chord::from_keystroke(key, control, alt, shift, platform)?;
880        Some(self.dispatch_chord(chord))
881    }
882
883    /// Translate a canonical [`KeyChord`] into an [`Action`] (via
884    /// the renderer-neutral host pipeline) and dispatch it. Used
885    /// by [`Self::dispatch_keystroke`] after the GPUI-shaped event
886    /// has been normalised, and directly by tests that drive the
887    /// editor with synthetic chords.
888    ///
889    /// Builds [`TranslateContext`] from current `Editor` state.
890    /// `chord_capture` reads the published `ad().chord_capture` flag (the
891    /// host computes it via `Editor::chord_capture_active` each publish), so
892    /// the actor-based peer doesn't need a live `&Editor`. With it,
893    /// `:describe-key` (and any `ArgKind::Chord` arg) captures the next
894    /// keystroke instead of dispatching it — was hard-wired `false`.
895    pub fn dispatch_chord(&mut self, chord: KeyChord) -> DispatchOutcome {
896        // AP.0.2 — captured BEFORE the dispatch, which mutates both.
897        //
898        // `partial_chord` is the sequence this chord COMPLETES; re-resolving
899        // the trailing key alone is what turned a declined `<leader>oJ` into
900        // vim's `J`. `active_minor_modes` is the layer set the peel walks
901        // down, one layer per decline.
902        let (prefix_before, layers_before) = {
903            let t = self.render_state.load().translator.clone();
904            (t.partial_chord.to_vec(), t.active_minor_modes.to_vec())
905        };
906        let modal_before = self.ad().modal;
907        let mut outcome = self.dispatch_chord_in_layers(chord, &prefix_before, &layers_before);
908        if !outcome.declined {
909            return outcome;
910        }
911        // The binding was a mode action that DECLINED — it did nothing, so
912        // re-resolve the chord with the declining layer removed, until
913        // something handles it or only the always-on Builtin / User layers
914        // are left. Without this every key auto-pair binds — `( [ { ) ] } " '
915        // and backspace — is dead in this renderer, and `<Tab>` never falls
916        // through to org's fold cycle.
917        let mut peel = lattice_host::decline::DeclinePeel::new(
918            modal_before,
919            prefix_before,
920            chord,
921            layers_before,
922        );
923        loop {
924            let keymap = self.render_state.load().translator.keymap.clone();
925            let Some(layers) = peel.peel(&keymap) else {
926                // The winner came from Builtin / User: nothing left to peel,
927                // and those cannot decline.
928                break;
929            };
930            let layers = layers.to_vec();
931            outcome = self.dispatch_chord_in_layers(chord, peel.prefix(), &layers);
932            if !outcome.declined || peel.exhausted() {
933                break;
934            }
935        }
936        outcome.declined = false;
937        outcome
938    }
939
940    /// Resolve `chord` against an explicit prefix and layer set, and dispatch
941    /// what it resolves to.
942    ///
943    /// Both the first resolution and every peel pass go through here, so a
944    /// fall-through is translated with exactly the context the original
945    /// keystroke was — only the layer set narrows.
946    fn dispatch_chord_in_layers(
947        &mut self,
948        chord: KeyChord,
949        prefix: &[KeyChord],
950        layers: &[lattice_mode::ModeId],
951    ) -> DispatchOutcome {
952        // Investigation 2026-05-22: trace partial_chord at
953        // dispatch_chord entry so we can see if it survives
954        // publishes between keystrokes. info! so it lands in
955        // *messages* without needing RUST_LOG.
956        {
957            let pre_partial = self.render_state.load().translator.partial_chord.clone();
958            tracing::debug!(
959                "[chord-trace] CHORD {:?} partial_chord_from_rs={:?}",
960                chord,
961                pre_partial,
962            );
963        }
964        let action = {
965            // Slice 3c.atomic.K: read value-typed TranslateContext
966            // inputs through the published render-state snapshot
967            // (`ad()`). Parallel of the TUI peer's runtime.rs
968            // migration (3c.atomic.J). The borrowed-reference
969            // inputs (`builtins`, `keymap`, `partial_chord`) still
970            // come off `self.editor` -- they need `&` lifetimes
971            // the published state doesn't carry; they migrate
972            // when the actor swap (3c.final) replaces `editor`
973            // with a handle.
974            // Slice 3c.final.E.5: translator inputs via published
975            // substate (same pattern as TUI runtime.rs).
976            let ad = self.ad();
977            let translator = self.render_state.load().translator.clone();
978            let insert_completion_open = self.render_state.load().completion.insert.is_some();
979            let ctx = TranslateContext {
980                modal: ad.modal,
981                builtins: &translator.builtins,
982                pending_count: ad.pending_count,
983                op_count: ad.op_count,
984                recording_macro: ad.macro_recording,
985                active_buffer: ad.buffer_kind,
986                completion_open: ad.completion_open,
987                chord_capture: ad.chord_capture,
988                picker_open: ad.picker_open,
989                insert_completion_open,
990                snippet_active: ad.snippet_active,
991                terminal_insert_active: ad.terminal_insert_active,
992                terminal_esc_exits: ad.terminal_esc_exits,
993                terminal_app_cursor_keys: ad.terminal_app_cursor_keys,
994                terminal_insert_exit_pending: ad.terminal_insert_exit_pending,
995                terminal_visual_active: ad.terminal_visual_active,
996                keymap: &translator.keymap,
997                partial_chord: prefix,
998                active_minor_modes: layers,
999            };
1000            lattice_host::input::translate(ctx, chord)
1001        };
1002        self.dispatch_action(action)
1003    }
1004
1005    /// Rebuild the cached [`GpuiTheme`] from
1006    /// [`editor.host_theme`](lattice_host::editor::Editor).
1007    /// Mirrors the TUI peer's `App::rebuild_tui_theme`. Called
1008    /// at boot (inside `finalize_boot`) and on every
1009    /// [`RendererSignal::ThemeChanged`] so theme cascades
1010    /// propagate to the GPUI cache without the binary having
1011    /// to re-read `host_theme` per frame.
1012    ///
1013    /// Phase 5.7.B.12: the body is currently a shape-only
1014    /// no-op -- `host_theme` doesn't yet carry direct window-bg
1015    /// / fg / status / cursor fields the GPUI peer can map. The
1016    /// next iteration grows `host_theme` (or adds a GPUI-typed
1017    /// theme overlay in `lattice-host::ui::theme`) and this
1018    /// method translates each into `Rgba`. Until then the cache
1019    /// keeps its `Default` palette; the wiring is what this
1020    /// slice unblocks.
1021    pub fn rebuild_gpui_theme(&mut self) {
1022        // Phase 5.8.K: live-cascade host_theme → GpuiTheme for the
1023        // fields the resolved element table sources today. T.11.0b
1024        // closed the last canvas gap: editor bg/fg, the block-cursor
1025        // inversion, and the popup surface now read from the palette-
1026        // driven `editor.*` / `ui.popup.background` elements (below), so
1027        // a `:colorscheme` / palette swap recolors the whole canvas. The
1028        // wiring is driven by `RendererSignal::ThemeChanged` firing this
1029        // method, so a palette/theme swap flows through automatically.
1030        // Slice 3c.final.E.5: theme via published top-level field.
1031        // T.9: the pane-chrome STYLE fields moved off the host `Theme`
1032        // onto theme elements; read them from the published resolved
1033        // table (`resolved_theme.get(theme_ids.<elem>)`) which already
1034        // reflects any `:set ui.*` registry overrides.
1035        let rs = self.render_state.load();
1036        let resolved: &lattice_host::ui::theme::ResolvedTheme = &rs.resolved_theme;
1037        let ids: &lattice_host::ui::theme::BuiltinElementIds = &rs.theme_ids;
1038        let defaults = GpuiTheme::default();
1039
1040        // Status line ↔ `pane.status.active` element (mirrors the
1041        // TUI's active-pane status row).
1042        let pane_status_active = resolved.get(ids.pane_status_active);
1043        if let Some(bg) = pane_status_active.bg {
1044            self.theme.status_background = bg.to_rgb_u32(defaults.status_background);
1045        }
1046        if let Some(fg) = pane_status_active.fg {
1047            self.theme.status_foreground = fg.to_rgb_u32(defaults.status_foreground);
1048        }
1049        // Popup border ↔ `pane.separator` element (same conceptual
1050        // role: thin accent line between visual regions).
1051        if let Some(fg) = resolved.get(ids.pane_separator).fg {
1052            self.theme.popup_border = fg.to_rgb_u32(defaults.popup_border);
1053        }
1054        // PP.2b / PP.2c: the picker prompt line ↔ `picker.*`.
1055        if let Some(fg) = resolved.get(ids.picker_root).fg {
1056            self.theme.picker_root = fg.to_rgb_u32(defaults.picker_root);
1057        }
1058        if let Some(fg) = resolved.get(ids.picker_title).fg {
1059            self.theme.picker_title = fg.to_rgb_u32(defaults.picker_title);
1060        }
1061        if let Some(fg) = resolved.get(ids.picker_prompt).fg {
1062            self.theme.picker_prompt = fg.to_rgb_u32(defaults.picker_prompt);
1063        }
1064        if let Some(fg) = resolved.get(ids.picker_count).fg {
1065            self.theme.picker_count = fg.to_rgb_u32(defaults.picker_count);
1066        }
1067        // Popup header title / hint ↔ `ui.popup.title` / `ui.popup.hint`
1068        // (shared with the TUI peer so the accent is themeable + identical).
1069        if let Some(fg) = resolved.get(ids.ui_popup_title).fg {
1070            self.theme.popup_title = fg.to_rgb_u32(defaults.popup_title);
1071        }
1072        if let Some(fg) = resolved.get(ids.diagnostic_warning).fg {
1073            self.theme.notification_warn = fg.to_rgb_u32(defaults.notification_warn);
1074        }
1075        if let Some(fg) = resolved.get(ids.diagnostic_error).fg {
1076            self.theme.notification_error = fg.to_rgb_u32(defaults.notification_error);
1077        }
1078        if let Some(fg) = resolved.get(ids.diagnostic_info).fg {
1079            self.theme.notification_info = fg.to_rgb_u32(defaults.notification_info);
1080        }
1081        if let Some(fg) = resolved.get(ids.diff_add_sign).fg {
1082            self.theme.notification_success = fg.to_rgb_u32(defaults.notification_success);
1083        }
1084        if let Some(fg) = resolved.get(ids.ui_popup_hint).fg {
1085            self.theme.popup_hint = fg.to_rgb_u32(defaults.popup_hint);
1086        }
1087        // IG.4: guide colours ↔ `indent.guide` / `indent.guide.active`,
1088        // the same two elements the TUI peer resolves, so a
1089        // `:colorscheme` recolours the rules in both renderers together.
1090        if let Some(fg) = resolved.get(ids.indent_guide).fg {
1091            self.theme.indent_guide = fg.to_rgb_u32(defaults.indent_guide);
1092        }
1093        if let Some(fg) = resolved.get(ids.indent_guide_active).fg {
1094            self.theme.indent_guide_active = fg.to_rgb_u32(defaults.indent_guide_active);
1095        }
1096        // T.11.0b: source the canvas (window bg/fg, block-cursor
1097        // inversion, popup surface) from the resolved table so a
1098        // `:colorscheme` / palette swap recolors the whole canvas — the
1099        // light-theme seam. Each falls back to the GpuiTheme default
1100        // (Catppuccin Mocha) when the element leaves the channel unset,
1101        // keeping the default render byte-identical.
1102        self.theme.background = resolved
1103            .get(ids.editor_background)
1104            .bg
1105            .map(|c| c.to_rgb_u32(defaults.background))
1106            .unwrap_or(defaults.background);
1107        self.theme.foreground = resolved
1108            .get(ids.editor_foreground)
1109            .fg
1110            .map(|c| c.to_rgb_u32(defaults.foreground))
1111            .unwrap_or(defaults.foreground);
1112        let editor_cursor = resolved.get(ids.editor_cursor);
1113        self.theme.cursor_background = editor_cursor
1114            .bg
1115            .map(|c| c.to_rgb_u32(defaults.cursor_background))
1116            .unwrap_or(defaults.cursor_background);
1117        self.theme.cursor_foreground = editor_cursor
1118            .fg
1119            .map(|c| c.to_rgb_u32(defaults.cursor_foreground))
1120            .unwrap_or(defaults.cursor_foreground);
1121        self.theme.popup_background = resolved
1122            .get(ids.ui_popup_background)
1123            .bg
1124            .map(|c| c.to_rgb_u32(defaults.popup_background))
1125            .unwrap_or(defaults.popup_background);
1126        // Font family + size: read live from the published options
1127        // config so `:set ui.font_family=...` takes effect on the
1128        // next frame without restarting.
1129        let config = &self.render_state.load().options.config;
1130        if let Some(family) = config.get_typed::<lattice_host::ui::theme_options::UiFontFamily>() {
1131            self.theme.font_family = (**family).to_owned();
1132        }
1133        if let Some(size) = config.get_typed::<lattice_host::ui::theme_options::UiFontSize>() {
1134            self.theme.font_size_pt = (*size).clamp(4, 96) as u32;
1135        }
1136        if let Some(ligatures) = config.get_typed::<lattice_host::ui::theme_options::UiLigatures>()
1137        {
1138            self.theme.ligatures = *ligatures;
1139        }
1140    }
1141
1142    /// Dispatch a single [`Action`] through `editor.dispatch` and
1143    /// drain the deferred-action queue iteratively.
1144    ///
1145    /// `editor.dispatch` runs ONE action and returns the outcome;
1146    /// for many actions (`Action::Invoke` resolving to an
1147    /// `AppEffect::EnterMode` / `EnterVisual` / etc.) the host
1148    /// emits a follow-up [`Action`] in
1149    /// `outcome.next_actions` rather than mutating directly --
1150    /// see `Editor::apply_app_effect` in `lattice_host::dispatch`.
1151    /// The TUI peer's `App::apply` drains the queue with a
1152    /// recursive call; this method does the same shape with an
1153    /// explicit FIFO loop so basic state transitions land without
1154    /// needing the renderer's full effect / signal fan-out yet
1155    /// (5.7.B.4+ work).
1156    ///
1157    /// `outcome.effects` are aggregated across the chain but NOT
1158    /// re-applied -- the host has already called
1159    /// `editor.handle_effect(effect.clone())` on each one during
1160    /// the inner dispatch (the renderer-coupled match arms remain
1161    /// a 5.7.B.4+ follow-up). `renderer_signals` accumulate the
1162    /// same way; callers can inspect them once the GPUI peer
1163    /// gains a signal handler.
1164    pub fn dispatch_action(&mut self, action: Action) -> DispatchOutcome {
1165        // Phase 5.8.AC.1: GPUI-side intercepts for App-only Action
1166        // arms. The host's `handle_action` doesn't dispatch these
1167        // (the TUI App's `apply` catches them with renderer-coupled
1168        // bodies). Until the full set migrates, the GPUI peer
1169        // intercepts the most-common ones here. The host's matching
1170        // `do_*` helpers cover the simple Buffer / Path routings;
1171        // exotic routings (mark / register / command / snippet /
1172        // LSP code-action / completion) still warn until they
1173        // migrate.
1174        match &action {
1175            Action::PickerAccept => {
1176                // Route through the transient-aware accept, NOT `do_picker_accept`
1177                // directly: in transient mode `<CR>` must fire whatever
1178                // `<C-n>`/`<C-p>` selected. Calling `do_picker_accept` here is a
1179                // no-op for a transient, which is why a scrolled-to transient
1180                // action ran in the TUI but did nothing in GPUI.
1181                let mut outcome = self.mutate_editor_with(|e| {
1182                    let mut out = DispatchOutcome::default();
1183                    e.do_picker_or_transient_accept(&mut out);
1184                    out
1185                });
1186                outcome.consumed = true;
1187                // Drain the accept's follow-up effects. A picker accept can
1188                // invoke a command that returns one — magit branch-delete picks
1189                // a branch and returns `Effect::Confirm` — and Confirm (like the
1190                // other renderer-owned effects) is left in `outcome.effects` for
1191                // the renderer to apply; the host does not. This early-return arm
1192                // drained only `renderer_signals`, skipping the generic
1193                // effect-drain further down (`for effect in outcome.effects ...
1194                // apply_effect_gpui`), so the confirm prompt never opened in
1195                // GPUI and the flow "ended abruptly" after the pick. The TUI has
1196                // always drained these (`App::apply_effect_app_arms`). Effects
1197                // before signals, matching the generic path.
1198                for effect in outcome.effects.iter().cloned() {
1199                    self.apply_effect_gpui(effect);
1200                }
1201                for s in std::mem::take(&mut outcome.renderer_signals) {
1202                    self.handle_renderer_signal(s);
1203                }
1204                return outcome;
1205            }
1206            // MG.29 lockstep: `<Esc>` routes here now, and this peer
1207            // intercepts the dismiss family rather than letting it reach
1208            // the generic path — so without this arm the unwind would
1209            // work in the TUI and do nothing in GPUI.
1210            Action::TransientDismiss => {
1211                let signals = self.mutate_editor_with(|e| {
1212                    let popped = e
1213                        .picker
1214                        .as_mut()
1215                        .map(|p| p.transient_unwind())
1216                        .unwrap_or(false);
1217                    if popped {
1218                        Vec::new()
1219                    } else {
1220                        e.do_picker_dismiss()
1221                    }
1222                });
1223                let outcome = DispatchOutcome {
1224                    consumed: true,
1225                    renderer_signals: signals.clone(),
1226                    ..Default::default()
1227                };
1228                for s in signals {
1229                    self.handle_renderer_signal(s);
1230                }
1231                return outcome;
1232            }
1233            Action::PickerDismiss => {
1234                let signals = self.mutate_editor_with(|e| e.do_picker_dismiss());
1235                let outcome = DispatchOutcome {
1236                    consumed: true,
1237                    renderer_signals: signals.clone(),
1238                    ..Default::default()
1239                };
1240                for s in signals {
1241                    self.handle_renderer_signal(s);
1242                }
1243                return outcome;
1244            }
1245            _ => {}
1246        }
1247        let mut outcome = self.mutate_editor_with(move |e| e.dispatch(action));
1248        let mut pending: std::collections::VecDeque<Action> =
1249            outcome.next_actions.drain(..).collect();
1250        while let Some(follow_up) = pending.pop_front() {
1251            let mut next_out = self.mutate_editor_with(move |e| e.dispatch(follow_up));
1252            pending.extend(next_out.next_actions.drain(..));
1253            outcome.effects.append(&mut next_out.effects);
1254            outcome
1255                .renderer_signals
1256                .append(&mut next_out.renderer_signals);
1257            outcome.consumed |= next_out.consumed;
1258            if self.render_state.load().lifecycle.should_quit {
1259                // Mirrors `App::apply`'s mid-macro-quit semantic:
1260                // a recorded `:q` short-circuits the rest of the
1261                // chain so we don't keep firing actions against
1262                // an editor that's tearing down.
1263                break;
1264            }
1265        }
1266        // Phase 5.8.AC.1: drain renderer-coupled effects. The
1267        // host's `editor.dispatch` already called
1268        // `editor.handle_effect` for every Effect, but the
1269        // renderer-coupled tail (`OpenBuffer` → `do_edit`,
1270        // `OpenBufferPicker` → `do_open_buffer_picker`,
1271        // `QuitEditor` → `do_quit`, ...) doesn't run inside the
1272        // host's `handle_effect`. The TUI peer drains these in
1273        // `App::apply_effect_app_arms`; the GPUI peer mirrors
1274        // those arms here for the variants whose handlers are
1275        // host-resident today. Variants whose handlers are still
1276        // App-only (file-tree / oil / hover / LSP-request /
1277        // diagnostics navigation / picker accept) log a one-line
1278        // trace; they'll light up as the matching host methods
1279        // land in subsequent slices.
1280        for effect in outcome.effects.iter().cloned() {
1281            self.apply_effect_gpui(effect);
1282        }
1283        // Action arms App handles that the host's `handle_action`
1284        // doesn't yet route through: `PickerAccept` / `PickerDismiss`
1285        // — these need a renderer-coupled close path. Until the
1286        // accept body migrates host-side, GPUI catches the action
1287        // by inspecting `outcome.consumed` and falling back to a
1288        // minimal close + activate-restore for the dismiss arm.
1289        // 5.7.B.7: drain renderer signals through the GPUI
1290        // handler. Drained BEFORE returning so callers see a
1291        // post-signal-handling editor state; the returned
1292        // outcome still carries the (now-handled) signal list
1293        // for callers that want to inspect or re-route them.
1294        // Signals queued on the editor rather than returned — the
1295        // `ModeActivator` trait surface and `Editor::activate_buffer`,
1296        // both of which complete work whose signature cannot hand the
1297        // cascade back. Folded in beside the outcome's own so neither
1298        // producer depends on a caller remembering it. TUI parity:
1299        // `app/dispatch.rs` drains at the same point.
1300        let mut signals = self.mutate_editor_with(|e| e.drain_pending_renderer_signals());
1301        signals.append(&mut std::mem::take(&mut outcome.renderer_signals));
1302        for signal in signals.iter().cloned() {
1303            self.handle_renderer_signal(signal);
1304        }
1305        // Restore the list on the outcome so the returned value
1306        // matches the historical TUI shape (App::apply also
1307        // hands these to the caller after fan-out).
1308        outcome.renderer_signals = signals;
1309        // Phase 5.8.AF.5 / Slice X1: drain pending LSP / event /
1310        // mode-lifecycle results here at the keystroke-driven
1311        // dispatch tail rather than in the per-frame body
1312        // (`crates/lattice-ui-gpui/src/window.rs::Render::render`).
1313        // Paramount goal #1 forbids I/O / event drain on the UI
1314        // thread; the renderer body is the UI thread.
1315        // `run_tick_pending` is the host aggregator that polls
1316        // ~30 channels for async results -- on a busy frame
1317        // (file open) it can take 49ms, which is 6x over the
1318        // one-frame ceiling (8.3 ms at 120Hz). Running it
1319        // here makes that cost happen during the keystroke that
1320        // caused the work (the open) instead of on the next
1321        // paint after open. The post-X1 perf trace expects
1322        // `tick_us` in `lattice_gpui::perf` to drop to ~0 once
1323        // dispatch tails take over the drain.
1324        //
1325        // Idle LSP arrivals (response with no keystroke in
1326        // flight) are NOT drained until the next keystroke:
1327        // see slice X1b (`docs/dev/operations/render-thread-
1328        // discipline-remediation.md` §X1b) for the wake-bridge
1329        // that closes that gap.
1330        // Parity with the TUI keystroke tail (`Editor::dispatch_fused`, which
1331        // TUI reaches via `App::apply`): reparse the buffer's syntax BEFORE
1332        // draining ticks. This peer dispatches via bare `Editor::dispatch`,
1333        // which does NOT run the post-dispatch tail, so `maybe_reparse_syntax`
1334        // was never called on a GPUI edit. The consequence was subtle and
1335        // permanent: the syntax tree stayed at the pre-edit text, so
1336        // `SyntaxSnapshot::tree_reflects(new_text)` was false and the cells
1337        // worker's rebuild skipped highlighting; and because the syntax
1338        // `render_version` never advanced, the worker then CACHE-HIT that
1339        // uncoloured matrix on every later wake — syntax colour "disappeared"
1340        // on the first edit and never came back (recoverable only by `:e!`).
1341        // Especially visible on async-parsed / plugin-grammar buffers (e.g.
1342        // `.org`). `maybe_reparse_syntax` no-ops when the text did not change
1343        // (motions), so it is cheap on the hot path, and it also drives
1344        // `recompute_folds`, matching TUI. Folded into the same actor crossing
1345        // as `run_tick_pending` to keep the keystroke a single round-trip.
1346        let tick_signals = self.mutate_editor_with(|e| {
1347            e.maybe_reparse_syntax();
1348            e.run_tick_pending()
1349        });
1350        for signal in tick_signals {
1351            self.handle_renderer_signal(signal);
1352        }
1353        // OR.16: the renderer-owned effects an off-renderer path (the async
1354        // picker accept, the fill target, the picker's delete verb) had no
1355        // renderer to hand over. The TUI drains these in the same place.
1356        let queued = self.mutate_editor_with(|e| e.drain_pending_renderer_effects());
1357        for effect in queued {
1358            self.apply_effect_gpui(effect);
1359        }
1360        outcome
1361    }
1362
1363    /// Renderer-coupled effect handler for the GPUI peer.
1364    ///
1365    /// Phase 5.8.AC.1: mirrors the role of TUI's
1366    /// `App::apply_effect_app_arms`. For ex-effects whose
1367    /// renderer-coupled tail lives host-side today
1368    /// (`OpenBuffer`, `QuitEditor`, `OpenBufferPicker`), call
1369    /// the host method and fan signals through the existing
1370    /// `RendererSignal` handler. For the rest (file-tree, oil,
1371    /// hover, picker open, LSP requests, diagnostics navigation),
1372    /// log a one-line trace + continue; those light up as the
1373    /// matching host methods land in subsequent slices.
1374    fn apply_effect_gpui(&mut self, effect: lattice_grammar::Effect) {
1375        use lattice_grammar::Effect;
1376        match effect {
1377            // Document-only effects the host has already
1378            // applied via `editor.handle_effect`; nothing for the
1379            // renderer to do.
1380            // AP.0.2: a declined effect is consumed by the dispatcher
1381            // (fall-through) and never surfaces here; no-op for parity
1382            // with the TUI peer ([[feedback_tui_gpui_parity]]).
1383            Effect::Declined
1384            | Effect::None
1385            | Effect::Edits(_)
1386            // CR.0: host's `handle_effect` translates `ApplyEdit` into
1387            // `Action::ApplyEdit`; the renderer has nothing to do (parity
1388            // with the TUI peer per [[feedback_tui_gpui_parity]]).
1389            | Effect::ApplyEdit { .. }
1390            // XF.1: host-applied, parity with the TUI peer and with
1391            // `ApplyEdit` above — the host owns path→buffer, the insert and
1392            // the cut; there is nothing renderer-coupled in any of it.
1393            | Effect::WriteToFile { .. }
1394            | Effect::CursorMove(_)
1395            // MG.18d: the host applies (or drops) the targeted cursor
1396            // move in `handle_effect`; nothing for the renderer to do,
1397            // parity with `CursorMove` above.
1398            | Effect::CursorMoveIn { .. }
1399            | Effect::SelectionChange(_)
1400            | Effect::EnterMode(_)
1401            | Effect::Yank { .. }
1402            | Effect::SetOption { .. }
1403            | Effect::SetLocalOption { .. }
1404            | Effect::SetGlobalOption { .. }
1405            // T.9.b: `:colorscheme` swaps the registry palette/overrides
1406            // host-side in `editor.handle_effect`; the renderer rebuilds
1407            // off the emitted `RendererSignal::ThemeChanged`, so there's
1408            // nothing to apply here (parity with `SetOption`'s `ui.*`).
1409            | Effect::SetColorscheme(_)
1410            | Effect::ClearSearchHighlight
1411            | Effect::Echo { .. }
1412            | Effect::ShowDiagnosticsPopup { .. }
1413            // L7: LSP nav requests are host-applied (`editor.lsp_request`).
1414            | Effect::Lsp(_)
1415            | Effect::EchoRegisters
1416            | Effect::EchoMarks
1417            | Effect::ListBuffers
1418            | Effect::DescribeBuffer
1419            | Effect::ListKeymap
1420            | Effect::DescribeOption { .. }
1421            | Effect::DescribeElement { .. }
1422            | Effect::ListOptions
1423            | Effect::DescribePluginApi { .. }
1424            | Effect::ListPluginApis
1425            | Effect::ListCommands
1426            | Effect::DescribePlugin { .. }
1427            | Effect::ListPlugins
1428            | Effect::DescribeOptionResolution { .. }
1429            | Effect::DescribeEvents
1430            | Effect::DescribeEvent { .. }
1431            | Effect::DescribeDiff
1432            | Effect::DiffOpen
1433            // DB.2: host-applied via handle_effect; peer no-op.
1434            | Effect::OpenDashboard
1435            | Effect::DiffOff { .. }
1436            | Effect::Diffthis
1437            | Effect::Diffsplit { .. }
1438            | Effect::DiffGetCmd { .. }
1439            | Effect::DiffPutCmd { .. }
1440            | Effect::DiffAccept
1441            | Effect::DiffReject
1442            | Effect::DiffAcceptAll
1443            | Effect::DiffRejectAll
1444            // D-fix.6: session-scoped diff close is host-applied (the
1445            // renderer no-ops, parity with the TUI peer's classifier).
1446            | Effect::CloseSessionDiffs { .. }
1447            | Effect::CloseAllSessionDiffs { .. }
1448            | Effect::NextHunk
1449            | Effect::PrevHunk
1450            | Effect::BufferNext
1451            | Effect::BufferPrev
1452            | Effect::FocusBuffer(_)
1453            | Effect::InvokeCommand { .. }
1454            | Effect::BufferDelete { .. }
1455            | Effect::ListModes
1456            | Effect::DescribeMode { .. }
1457            | Effect::DescribeActiveModes
1458            | Effect::DescribeActiveBindings
1459            | Effect::Customize { .. }
1460            | Effect::ListDiagnostics
1461            | Effect::ListErrors
1462            | Effect::DeleteCurrentLine
1463            | Effect::Substitute { .. }
1464            | Effect::DescribeCommand { .. }
1465            | Effect::Apropos { .. }
1466            | Effect::DescribeKey { .. }
1467            | Effect::AppAction(_)
1468            // BC.8c: showDocument open effects are host-applied in
1469            // `Editor::handle_effect` (TUI/GPUI parity); the peer no-ops them.
1470            | Effect::OpenExternalUri { .. }
1471            | Effect::OpenBufferAtColumn { .. }
1472            // I5.1: terminal spawn is host-applied; the peer no-ops it.
1473            | Effect::SpawnTerminal { .. }
1474            // D-fix.4: terminal input (`:claude-interrupt`) is host-applied.
1475            | Effect::TerminalInput(_)
1476            // I3/BC.8c follow-up: SaveBuffer host-applied (reuses do_write).
1477            | Effect::SaveBuffer { .. }
1478            | Effect::RecordJump
1479            // Host-applied cd/pwd effects (GPUI parity with TUI peer).
1480            | Effect::ChangeDir(_)
1481            | Effect::PrintWorkingDir
1482            | Effect::PrintProjectRoot => {}
1483            // Renderer-coupled effects whose body lives host-side.
1484            Effect::QuitEditor { force, scope } => {
1485                self.mutate_editor(move |e| e.do_quit(force, scope))
1486            }
1487            Effect::OpenBuffer { path, force } => self.apply_open_buffer(path, force),
1488            // M.10.3 bug fix (2026-06-03): atomic open-and-position.
1489            // GPUI parity with TUI per [[feedback_tui_gpui_parity]].
1490            // CD.2: the same host body as the TUI peer — seed, minor, and
1491            // position-with-reveal. One method is what keeps the peers from
1492            // drifting, which is how the reveal came to be missing from both.
1493            Effect::OpenBufferAt {
1494                path,
1495                position,
1496                force,
1497                content,
1498                activate_minor,
1499            } => {
1500                let outcome = self.mutate_editor_with(move |e| {
1501                    e.open_buffer_at(
1502                        path,
1503                        position,
1504                        force,
1505                        content.as_deref(),
1506                        activate_minor.as_deref(),
1507                    )
1508                });
1509                self.handle_do_edit_outcome(outcome);
1510            }
1511            // LM.0: open in a target pane — GPUI parity with the TUI peer
1512            // ([[feedback_tui_gpui_parity]]). Same host body as OpenBufferAt,
1513            // preceded by the picker-accept split sequence.
1514            Effect::OpenInTarget {
1515                path,
1516                position,
1517                target,
1518            } => {
1519                let outcome = self.mutate_editor_with(move |e| {
1520                    e.prepare_open_target_pane(target);
1521                    e.open_buffer_at(path, position, false, None, None)
1522                });
1523                self.handle_do_edit_outcome(outcome);
1524            }
1525            // I3/BC.8c follow-up: `SaveBuffer` is now HOST-applied in
1526            // `Editor::handle_effect` (reuses `do_write`; works on the
1527            // off-keystroke inbound tick path) — the peer arm is retired to the
1528            // grouped no-op above (TUI/GPUI parity). `:w`'s full LSP fan-out
1529            // (BeforeSave / willSave / didSave / …) was already host-resident.
1530            // Phase 5.8.AD.2: LSP commands whose bodies migrated.
1531            Effect::LspStatus => {
1532                let signals = self.mutate_editor_with(|e| e.do_lsp_status());
1533                for s in signals {
1534                    self.handle_renderer_signal(s);
1535                }
1536            }
1537            // EP.4: GPUI parity for the manual diagnostics pull.
1538            Effect::LspDiagnosticsToErrorList => {
1539                self.mutate_editor(|e| e.do_lsp_diagnostics_to_error_list());
1540            }
1541            Effect::LspRestart { server_id } => {
1542                self.mutate_editor(move |e| e.do_lsp_restart(&server_id));
1543            }
1544            Effect::LspExpandRegion => self.mutate_editor(|e| e.do_lsp_expand_region()),
1545            Effect::LspShrinkRegion => self.mutate_editor(|e| e.do_lsp_shrink_region()),
1546            Effect::LspProgressCancel { server_id } => {
1547                self.mutate_editor(move |e| e.do_lsp_progress_cancel(server_id.as_deref()));
1548            }
1549            Effect::SetLspLogLevel { server_id, level } => {
1550                self.mutate_editor(move |e| e.do_set_lsp_log_level(server_id.as_deref(), &level));
1551            }
1552            Effect::LspLogClear { server_id } => {
1553                self.mutate_editor(move |e| e.do_lsp_log_clear(server_id.as_deref()));
1554            }
1555            Effect::LspCodeAction => self.mutate_editor(|e| e.do_lsp_code_action_request()),
1556            // IN.8b: the LSP-independent cascade. Kept in lockstep
1557            // with the TUI arm per the cross-renderer rule.
1558            Effect::Format => self.mutate_editor(|e| e.do_format_request()),
1559            Effect::LspFormat => self.mutate_editor(|e| e.do_lsp_format_request(false)),
1560            Effect::LspFormatRange => self.mutate_editor(|e| e.do_lsp_format_request(true)),
1561            Effect::LspRename { new_name } => {
1562                self.mutate_editor(move |e| e.do_lsp_rename_request(&new_name))
1563            }
1564            Effect::LspIncomingCalls => {
1565                self.mutate_editor(|e| e.do_lsp_call_hierarchy_request(false))
1566            }
1567            Effect::LspOutgoingCalls => {
1568                self.mutate_editor(|e| e.do_lsp_call_hierarchy_request(true))
1569            }
1570            Effect::LspSupertypes => self.mutate_editor(|e| e.do_lsp_type_hierarchy_request(false)),
1571            Effect::LspSubtypes => self.mutate_editor(|e| e.do_lsp_type_hierarchy_request(true)),
1572            Effect::LspMoniker => self.mutate_editor(|e| e.do_lsp_moniker_request()),
1573            Effect::OpenLspLog { server_id } => {
1574                self.mutate_editor(move |e| e.do_open_lsp_log(server_id.as_deref()));
1575            }
1576            Effect::OpenAiLog { session } => {
1577                self.mutate_editor(move |e| e.do_open_ai_log(session.as_deref()));
1578            }
1579            Effect::ExportPluginApi { format } => {
1580                self.mutate_editor(move |e| e.do_export_plugin_api(format.as_deref()));
1581            }
1582            Effect::OpenSyntheticBuffer {
1583                name,
1584                mode_id,
1585                content,
1586                cursor,
1587                activate_minor,
1588            } => {
1589                // OC.7a: parity with the TUI arm. `None` throughout is the
1590                // pre-OC.7a call, so native emitters are unaffected.
1591                self.mutate_editor(move |e| {
1592                    e.open_synthetic_buffer_seeded(
1593                        &name,
1594                        &mode_id,
1595                        content.as_deref(),
1596                        cursor,
1597                        activate_minor.as_deref(),
1598                    )
1599                });
1600            }
1601            // MG.50: peer of `OpenBufferAt` for synthetic buffers — open
1602            // then position, in one arm, so the caret lands on the buffer
1603            // that was just opened rather than on whatever preceded it.
1604            Effect::OpenSyntheticBufferAt {
1605                name,
1606                mode_id,
1607                position,
1608            } => {
1609                self.mutate_editor(move |e| {
1610                    e.open_synthetic_buffer(&name, &mode_id);
1611                    e.set_cursor_clamped(position);
1612                });
1613            }
1614            Effect::OpenLspTraceLog { server_id } => {
1615                self.mutate_editor(move |e| e.do_open_lsp_trace_log(server_id.as_deref()));
1616            }
1617            Effect::ToggleLspTrace { server_id } => {
1618                self.mutate_editor(move |e| e.do_toggle_lsp_trace(&server_id));
1619            }
1620            Effect::LspServerLogListing => self.mutate_editor(|e| e.do_lsp_server_log_listing()),
1621            Effect::LspCodeLens => self.mutate_editor(|e| e.do_lsp_code_lens_picker()),
1622            Effect::LspColorPresentation => self.mutate_editor(|e| e.do_lsp_color_presentation()),
1623            // Phase 5.8.AD.4: completion / signature / snippet
1624            // entry points are host-resident; both peers reach
1625            // them through the same dispatch.
1626            Effect::LspSignatureHelp => self.mutate_editor(|e| e.lsp_signature_help_request()),
1627            Effect::LspComplete => self.mutate_editor(|e| e.lsp_completion_request()),
1628            // SN.3c.1: mode-owned `<C-x><C-s>` emits the trigger
1629            // range; the host resolves + expands (peer parity with
1630            // TUI). `feedback_tui_gpui_parity`.
1631            Effect::ExpandSnippet { replace_range } => {
1632                self.mutate_editor(move |e| e.expand_snippet_from_range(replace_range))
1633            }
1634            // 5.5.LSP.5: symbol helpers host-side; both peers reach
1635            // them through the same dispatch.
1636            Effect::LspDocumentSymbol => self.mutate_editor(|e| e.lsp_document_symbol_request()),
1637            Effect::LspWorkspaceSymbol { query } => {
1638                self.mutate_editor(move |e| e.lsp_workspace_symbol_request(&query));
1639            }
1640            // Phase 5.8.AD.5: describe / hover / tutor / customize
1641            // entries now host-resident.
1642            Effect::OpenHelpTopic { topic } => {
1643                let signals =
1644                    self.mutate_editor_with(move |e| e.do_open_help_topic(topic.as_deref()));
1645                for s in signals {
1646                    self.handle_renderer_signal(s);
1647                }
1648            }
1649            Effect::OpenHover { markdown } => {
1650                let signals = self.mutate_editor_with(move |e| e.do_open_hover(&markdown));
1651                for s in signals {
1652                    self.handle_renderer_signal(s);
1653                }
1654            }
1655            Effect::DismissPopup => {
1656                // DismissPopup is App-side popup state today; GPUI's
1657                // popup_content covers it directly.
1658                self.dismiss_popup();
1659            }
1660            // Targeted peer: a no-op unless the popup showing is the named
1661            // one. Goes through the host primitive rather than
1662            // `self.dismiss_popup()` because only the host can compare the
1663            // popup buffer's synthetic name. See `Editor::dismiss_popup_named`.
1664            Effect::DismissPopupNamed { name } => {
1665                self.mutate_editor(move |e| {
1666                    e.dismiss_popup_named(&name);
1667                });
1668            }
1669            Effect::BuryBuffer => {
1670                // Distinct from DismissPopup: a full-pane synthetic
1671                // buffer swapped the active document, so returning has
1672                // to swap it back — which `bury_buffer` does through
1673                // `activate_buffer`. Dismissing a popup would leave the
1674                // active document pointing at the buried buffer and
1675                // paint it over the file.
1676                self.mutate_editor(|e| {
1677                    e.bury_buffer();
1678                });
1679            }
1680            Effect::KillBuffer => {
1681                // Bury, then delete what was buried — same swap-back as
1682                // above, so the same reason it is not a DismissPopup.
1683                self.mutate_editor(|e| {
1684                    e.kill_buffer();
1685                });
1686            }
1687            Effect::OpenPopup {
1688                name,
1689                mode_id,
1690                placement,
1691                focus,
1692            } => {
1693                // Content-agnostic popup open (popup-api.md §4.3): delegate to
1694                // the host primitive, same as the TUI peer. Signals are always
1695                // empty today, so the return is discarded.
1696                self.mutate_editor(move |e| {
1697                    e.open_popup_named(&name, &mode_id, placement, focus);
1698                });
1699            }
1700            Effect::Tutor { lesson } => {
1701                let signals = self.mutate_editor_with(move |e| e.do_tutor(lesson));
1702                for s in signals {
1703                    self.handle_renderer_signal(s);
1704                }
1705            }
1706            Effect::OpenBufferPicker => {
1707                let signals = self.mutate_editor_with(|e| e.do_open_buffer_picker());
1708                for s in signals {
1709                    self.handle_renderer_signal(s);
1710                }
1711            }
1712            // Phase 5.8.AF.3: `:messages` activates the
1713            // `*messages*` Document buffer host-side.
1714            Effect::OpenMessages => {
1715                let signals = self.mutate_editor_with(|e| e.do_open_messages());
1716                for s in signals {
1717                    self.handle_renderer_signal(s);
1718                }
1719            }
1720            // Phase 5.8.AF.3: diagnostic navigation host-side.
1721            Effect::NextDiagnostic => self.mutate_editor(|e| e.do_next_diagnostic()),
1722            Effect::PrevDiagnostic => self.mutate_editor(|e| e.do_prev_diagnostic()),
1723            // Phase 5.8.AF.3: `:reload-snippets` host-side.
1724            Effect::ReloadSnippets => self.mutate_editor(|e| e.do_reload_snippets()),
1725            // Phase 5.8.AF.3: `:toggle-mode` host-side. Returned
1726            // bool flags an unknown-mode-name miss (the host has
1727            // already echoed); GPUI has no further fan-out.
1728            Effect::ToggleMode { mode_name } => {
1729                let _ = self.mutate_editor_with(move |e| e.toggle_mode_by_name(&mode_name));
1730            }
1731            // Phase 5.8.AF.3: `:g` / `:v` host-side. Body effects
1732            // are drained through `apply_effect_gpui` so any
1733            // renderer-coupled tail (popup / picker fan-out) still
1734            // flows through this peer.
1735            Effect::Global {
1736                pattern,
1737                inverted,
1738                body,
1739            } => {
1740                // Slice 3c.final.E.swap: build outcome inside the
1741                // closure, return owned `DispatchOutcome` from
1742                // `mutate_editor_with`. Same pattern as TUI edit.rs.
1743                let mut out = self.mutate_editor_with(move |e| {
1744                    let mut out = lattice_host::dispatch::DispatchOutcome::default();
1745                    e.do_global(&pattern, inverted, body.as_ref(), &mut out);
1746                    out
1747                });
1748                for eff in std::mem::take(&mut out.effects) {
1749                    self.apply_effect_gpui(eff);
1750                }
1751            }
1752            // Phase 5.8.AF.3: `:picker <source>` host-side.
1753            Effect::OpenPicker {
1754                source,
1755                args,
1756                root,
1757                fill_action,
1758                query,
1759            } => {
1760                // PC.11: root write + fill-target capture + rollback all live
1761                // on `Editor`, so this peer and the TUI cannot drift.
1762                let signals = self.mutate_editor_with(move |e| {
1763                    e.open_picker_for_effect(source, args, root, fill_action, query)
1764                });
1765                for s in signals {
1766                    self.handle_renderer_signal(s);
1767                }
1768            }
1769            // PU.6: confirmation dialog opens a transient picker for
1770            // y/n/q dismissal (TUI/GPUI parity per `do_confirm`).
1771            Effect::Confirm { prompt, yes_action, args } => {
1772                let signals = self.mutate_editor_with(move |e| {
1773                    let Some(cmd_reg) = e.services.get::<lattice_grammar::CommandRegistryHandle>()
1774                    else {
1775                        e.set_message(
1776                            lattice_host::action::EchoLevel::Error,
1777                            "confirm: command registry unavailable".to_string(),
1778                        );
1779                        return Vec::new();
1780                    };
1781                    let Some(cmd_id) = cmd_reg.load().id_by_name(&yes_action) else {
1782                        e.set_message(
1783                            lattice_host::action::EchoLevel::Error,
1784                            format!("confirm: unknown action `{yes_action}`"),
1785                        );
1786                        return Vec::new();
1787                    };
1788                    let spec = lattice_picker::confirm_transient_spec(&prompt, cmd_id);
1789                    // IX.1: seed the dialog's state with the
1790                    // yes-action's arguments so what fires on `y` is
1791                    // what the prompt named (parity with the TUI peer's
1792                    // `do_confirm`).
1793                    let seed = match cmd_reg.load().lookup(cmd_id) {
1794                        Some(spec) => e.seed_confirm_args(&spec.args_schema, &args),
1795                        None => Default::default(),
1796                    };
1797                    let signals = e.open_transient(spec);
1798                    e.extend_transient_state(seed);
1799                    signals
1800                });
1801                for s in signals {
1802                    self.handle_renderer_signal(s);
1803                }
1804            }
1805            // Fold audit fix: named transient menus (magit-dispatch /
1806            // magit-file-dispatch), resolved via the owning mode
1807            // crate's `TransientSourceRegistry` registration. TUI/GPUI
1808            // parity with `do_open_transient`.
1809            Effect::OpenTransient { source, args } => {
1810                // TR.2: the body is `Editor::open_named_transient` —
1811                // shared verbatim with the TUI peer rather than copied,
1812                // which is also what gives the guest-backed async build
1813                // path parity for free.
1814                let signals =
1815                    self.mutate_editor_with(move |e| e.open_named_transient(source, args));
1816                for s in signals {
1817                    self.handle_renderer_signal(s);
1818                }
1819            }
1820            // Generic one-line minibuffer text prompt. TUI/GPUI
1821            // parity with `do_open_prompt`.
1822            Effect::OpenPrompt {
1823                prompt,
1824                initial,
1825                on_submit_action,
1826                buffer_name,
1827            } => {
1828                let signals = self.mutate_editor_with(move |e| {
1829                    e.open_prompt_line(prompt, initial, on_submit_action, buffer_name)
1830                });
1831                for s in signals {
1832                    self.handle_renderer_signal(s);
1833                }
1834            }
1835            // Phase 5.8.AD.1: oil + file-tree migrated host-side
1836            // so `:e .` / `:Oil` / `:Tree` work in GPUI.
1837            Effect::OpenOil { dir } => {
1838                let signals = self.mutate_editor_with(move |e| e.do_open_oil(dir));
1839                for s in signals {
1840                    self.handle_renderer_signal(s);
1841                }
1842            }
1843            Effect::OpenFileTree { root } => {
1844                let signals = self.mutate_editor_with(move |e| e.do_open_file_tree(root));
1845                for s in signals {
1846                    self.handle_renderer_signal(s);
1847                }
1848            }
1849            // LM.2: in-place re-list / toggle — GPUI parity with the TUI
1850            // peer ([[feedback_tui_gpui_parity]]). Peer-applied like OpenOil.
1851            Effect::OilNavigate { view, dir, focus } => {
1852                let signals =
1853                    self.mutate_editor_with(move |e| e.apply_oil_navigate(view, dir, focus));
1854                for s in signals {
1855                    self.handle_renderer_signal(s);
1856                }
1857            }
1858            Effect::FileTreeToggle { view, entry_index } => {
1859                let signals =
1860                    self.mutate_editor_with(move |e| e.apply_file_tree_toggle(view, entry_index));
1861                for s in signals {
1862                    self.handle_renderer_signal(s);
1863                }
1864            }
1865            Effect::CloseFileTree => {
1866                let signals = self.mutate_editor_with(|e| e.dismiss_file_tree());
1867                for s in signals {
1868                    self.handle_renderer_signal(s);
1869                }
1870            }
1871            // Many recurses through the same handler so inner
1872            // arms hit their renderer-coupled tail too.
1873            Effect::Many(parts) => {
1874                for p in parts {
1875                    self.apply_effect_gpui(p);
1876                }
1877            }
1878        }
1879        // 5.8.AF.3 closeout: every renderer-neutral `Effect` is now
1880        // explicitly handled. The match is exhaustive — a future
1881        // variant becomes a compile error rather than a silent
1882        // runtime warning, which is the louder signal.
1883    }
1884
1885    /// Wrapper around `editor.do_edit(path, force)` that translates
1886    /// the host's [`DoEditOutcome`] for the GPUI peer. Mirrors
1887    /// TUI's `App::do_edit`: `Directory` routes to `do_open_oil`,
1888    /// `Opened`/`Activated`/`Reloaded` fan signals through the
1889    /// renderer handler, `NoFileName`/`Failed` are silent (the
1890    /// host already echoed).
1891    fn apply_open_buffer(&mut self, path: Option<std::path::PathBuf>, force: bool) {
1892        let outcome = self.mutate_editor_with(move |e| e.do_edit(path, force));
1893        self.handle_do_edit_outcome(outcome);
1894    }
1895
1896    /// Route a host `DoEditOutcome` through the peer's follow-ups — shared by
1897    /// `:e` and `Effect::OpenBufferAt`, as in the TUI peer.
1898    fn handle_do_edit_outcome(&mut self, outcome: lattice_host::dispatch::DoEditOutcome) {
1899        use lattice_host::dispatch::DoEditOutcome;
1900        match outcome {
1901            DoEditOutcome::NoFileName | DoEditOutcome::Failed => {}
1902            DoEditOutcome::Directory(dir) => {
1903                // Phase 5.8.AD.1: oil is now host-side; route the
1904                // directory `:e` through the same path the TUI peer
1905                // uses.
1906                let signals = self.mutate_editor_with(move |e| e.do_open_oil(Some(dir)));
1907                for s in signals {
1908                    self.handle_renderer_signal(s);
1909                }
1910            }
1911            DoEditOutcome::Reloaded(signals)
1912            | DoEditOutcome::Activated(signals)
1913            | DoEditOutcome::Opened(signals) => {
1914                for s in signals {
1915                    self.handle_renderer_signal(s);
1916                }
1917            }
1918        }
1919    }
1920}
1921
1922#[cfg(test)]
1923mod tests {
1924    use super::*;
1925
1926    use lattice_grammar::ModalState;
1927
1928    /// The 5.7 scaffold's core claim: a GPUI-side composition root
1929    /// constructs from the host substrate alone. If this builds, the
1930    /// host crate has no transitive ratatui / crossterm dep — that's
1931    /// the entire point of the slice.
1932    ///
1933    /// 5.7.B.2: `GpuiApp::new(document)` goes through
1934    /// `Editor::boot`, so this test also exercises that the host's
1935    /// boot path is callable without `lattice-ui-tui` in the dep
1936    /// tree (no transitive ratatui / crossterm pull-in).
1937    #[test]
1938    fn scaffold_constructs_without_ui_tui() {
1939        let app = GpuiApp::new(Document::empty());
1940        // `Editor::boot` populates the mode registry; the entry
1941        // count is nonzero so the host's mode-registration path
1942        // ran.
1943        assert!(app.editor.mode_registry.load().iter_meta().next().is_some());
1944        // 5.7.B.6: `GpuiApp::new` now also runs `finalize_boot`
1945        // which calls `activate_major_for_buffer_kind` for the
1946        // initial document buffer. The active-modes map should
1947        // therefore contain an entry for `document_buffer_id`.
1948        assert!(
1949            app.editor
1950                .active_modes
1951                .contains_key(&app.editor.document_buffer_id),
1952            "finalize_boot should activate a major mode for the initial document buffer"
1953        );
1954        // ProviderLookup is implemented on the registry; the
1955        // empty stub answers `false` for every mode id.
1956        let probe: &dyn ProviderLookup = &app.pane_render_registry;
1957        let dummy = lattice_mode::ModeId::new("__scaffold_probe__");
1958        assert!(!probe.has_provider(dummy));
1959    }
1960
1961    /// CB.4 regression guard: `GpuiApp::new` must always leave a working
1962    /// `ClipboardHandle` registered and never panic through its
1963    /// clipboard-override path. The arboard override is `not(test)`-gated,
1964    /// so every test run — feature on or off, display or none — keeps CB.0's
1965    /// `FakeClipboard`, and `cargo test` can never clobber the developer's
1966    /// real system clipboard (the TUI peer's `clipboard::boot_backend`
1967    /// applies the same rule). Hence the hermeticity assertions below:
1968    /// starts empty, round-trips. The production arboard path is exercised
1969    /// by the `--features window` build on the dev machine.
1970    #[test]
1971    fn new_leaves_a_hermetic_clipboard_handle() {
1972        let app = GpuiApp::new(Document::empty());
1973        let clipboard = app
1974            .editor
1975            .services
1976            .get::<lattice_core::ClipboardHandle>()
1977            .expect("GpuiApp::new must leave a ClipboardHandle registered");
1978        assert_eq!(
1979            clipboard.read(),
1980            None,
1981            "a freshly booted test GpuiApp must expose an empty in-memory \
1982             clipboard -- a non-None read means the suite is talking to the \
1983             OS clipboard"
1984        );
1985        clipboard.write("cb4-gpui-probe".to_string());
1986        assert_eq!(clipboard.read(), Some("cb4-gpui-probe".to_string()));
1987    }
1988
1989    /// End-to-end pipeline assertion: a key string flows through
1990    /// the GPUI peer's adapter, the host's `translate` path, and
1991    /// `editor.dispatch`, and the editor's modal state actually
1992    /// changes. This is the smallest credible "input dispatch
1993    /// works" test — it doesn't need GPUI's native event types
1994    /// or a window.
1995    ///
1996    /// `"i"` in Normal mode is canonical vim "enter Insert"; if
1997    /// the pipeline is wired correctly the editor's `modal` ends
1998    /// up in [`ModalState::Insert`].
1999    #[test]
2000    fn dispatch_keystroke_routes_i_to_insert_mode() {
2001        let mut app = GpuiApp::new(Document::empty());
2002        assert_eq!(app.editor.modal, ModalState::Normal);
2003        let outcome = app.dispatch_keystroke("i", false, false, false, false);
2004        assert!(
2005            outcome.is_some(),
2006            "`i` should normalise to a chord and dispatch"
2007        );
2008        assert_eq!(app.editor.modal, ModalState::Insert);
2009    }
2010
2011    /// The `:` line resolves keys in the Command context in THIS peer too.
2012    ///
2013    /// Both peers call `lattice_host::input::translate`, so routing
2014    /// `ModalState::Command` at `BindingMode::Command` reaches GPUI for free —
2015    /// which is exactly the kind of claim that was asserted in a comment and
2016    /// never checked before, and cost a renderer its fall-through for a month.
2017    /// Backspace on the `:` line is the observable: it deletes a character,
2018    /// rather than being eaten by whichever Insert-bound minor happens to be
2019    /// globally active.
2020    #[test]
2021    fn the_command_line_edits_in_the_command_context_in_this_peer_too() {
2022        use lattice_host::chord::{KeyChord, KeyKind, KeyMods, SpecialKey};
2023
2024        let mut app = GpuiApp::new(Document::from_text("x\n"));
2025        app.dispatch_chord(KeyChord::new(KeyKind::Char(':'), KeyMods::NONE));
2026        assert_eq!(
2027            app.editor.modal,
2028            ModalState::Command,
2029            "precondition: `:` opens the command line"
2030        );
2031        for c in ['a', 'b', 'c'] {
2032            app.dispatch_chord(KeyChord::new(KeyKind::Char(c), KeyMods::NONE));
2033        }
2034        assert_eq!(app.editor.command_line(), "abc");
2035
2036        app.dispatch_chord(KeyChord::new(
2037            KeyKind::Special(SpecialKey::Backspace),
2038            KeyMods::NONE,
2039        ));
2040        assert_eq!(
2041            app.editor.command_line(),
2042            "ab",
2043            "backspace must delete on the `:` line"
2044        );
2045    }
2046
2047    /// AP.0.2 — a DECLINED chord falls through to the layer below.
2048    ///
2049    /// This peer had no fall-through at all, and the cost was not subtle: a
2050    /// mode that binds a chord it only *sometimes* wants made that key dead.
2051    /// `auto-pair` binds `( [ { ) ] } " ' \`` and `<BS>` in Insert with a
2052    /// Global policy and declines whenever the situation is not a pair — so
2053    /// every one of those keys did nothing in GPUI, while `<C-w>` (the one
2054    /// insert key auto-pair does not bind) worked. `<Tab>` was the same bug
2055    /// through `table-mode`.
2056    ///
2057    /// Driven here with `table-mode`, which is native — no plugin fixture
2058    /// needed — and binds `<Tab>` in Insert, declining outside a table. The
2059    /// fall-through is the builtin insert-tab, so the observable is a tab
2060    /// character actually reaching the buffer.
2061    #[test]
2062    fn a_declined_chord_falls_through_to_the_layer_below() {
2063        use lattice_host::chord::{KeyChord, KeyKind, KeyMods, SpecialKey};
2064
2065        let mut app = GpuiApp::new(Document::from_text("x\n"));
2066        let buffer = app.editor.document_buffer_id;
2067        let _ = app
2068            .editor
2069            .activate_mode_by_id(buffer, lattice_mode::TableMode::mode_id());
2070        app.editor.publish_render_state();
2071        assert!(
2072            app.render_state
2073                .load()
2074                .translator
2075                .active_minor_modes
2076                .contains(&lattice_mode::TableMode::mode_id()),
2077            "precondition: table-mode must be an active layer, or there is \
2078             nothing to decline and the test proves nothing"
2079        );
2080
2081        app.dispatch_chord(KeyChord::new(KeyKind::Char('i'), KeyMods::NONE));
2082        app.dispatch_chord(KeyChord::new(
2083            KeyKind::Special(SpecialKey::Tab),
2084            KeyMods::NONE,
2085        ));
2086
2087        assert!(
2088            app.editor.document.text().contains('\t'),
2089            "`<Tab>` outside a table must fall through to insert-tab; the \
2090             buffer is {:?}",
2091            app.editor.document.text()
2092        );
2093    }
2094
2095    /// `dispatch_chord` is the path tests + programmatic drivers
2096    /// take. Sanity-check that handing it a canonical chord
2097    /// reaches the same state change as the keystroke entry
2098    /// point.
2099    #[test]
2100    fn dispatch_chord_drives_normal_to_insert_transition() {
2101        use lattice_host::chord::{KeyChord, KeyKind, KeyMods};
2102        let mut app = GpuiApp::new(Document::empty());
2103        let chord = KeyChord::new(KeyKind::Char('i'), KeyMods::NONE);
2104        app.dispatch_chord(chord);
2105        assert_eq!(app.editor.modal, ModalState::Insert);
2106    }
2107
2108    /// Test-coverage gap user flagged 2026-05-21: chord sequences
2109    /// like `gg` had no GPUI end-to-end test. The TUI peer covers
2110    /// the keymap-handler layer (key_harness_gg_jumps_to_first_line)
2111    /// but GPUI's specific dispatch_keystroke path — including
2112    /// partial_chord publish + reload between keys — wasn't
2113    /// exercised.
2114    ///
2115    /// This test drives `gg` through `dispatch_keystroke`, the
2116    /// SAME path GPUI's `on_key_down` uses. If `partial_chord`
2117    /// doesn't survive the publish-and-reload cycle between the
2118    /// two `g` presses, the second `g` won't see the first one
2119    /// as prefix and dispatch will fail.
2120    #[test]
2121    fn gpui_dispatch_keystroke_handles_gg_chord_sequence() {
2122        // Seed a multi-line document so `gg` has somewhere to
2123        // jump to.
2124        let doc = Document::from_text("alpha\nbeta\ngamma\ndelta\nepsilon\n");
2125        let mut app = GpuiApp::new(doc);
2126        // Move cursor down 2 lines so `gg` has work to do.
2127        app.dispatch_keystroke("j", false, false, false, false);
2128        app.dispatch_keystroke("j", false, false, false, false);
2129        assert_eq!(
2130            app.editor.cursor.line, 2,
2131            "cursor should be on line 2 before gg"
2132        );
2133
2134        // First `g`: should absorb into partial_chord, NOT execute.
2135        let outcome1 = app.dispatch_keystroke("g", false, false, false, false);
2136        assert!(outcome1.is_some(), "first `g` should dispatch");
2137        assert_eq!(
2138            app.editor.partial_chord.len(),
2139            1,
2140            "first `g` should populate partial_chord"
2141        );
2142        assert_eq!(
2143            app.editor.cursor.line, 2,
2144            "first `g` alone should NOT move the cursor"
2145        );
2146
2147        // Verify the published RS reflects the partial_chord update
2148        // — this is the key invariant. If RS still shows empty
2149        // partial_chord, the second `g` won't find the prefix and
2150        // dispatch silently fails.
2151        assert_eq!(
2152            app.render_state.load().translator.partial_chord.len(),
2153            1,
2154            "published RS must observe the partial_chord update before next keystroke"
2155        );
2156
2157        // Second `g`: with [g] as prefix, trie resolves gg →
2158        // motion:goto-first-line.
2159        let outcome2 = app.dispatch_keystroke("g", false, false, false, false);
2160        assert!(outcome2.is_some(), "second `g` should dispatch");
2161        assert_eq!(
2162            app.editor.cursor.line, 0,
2163            "gg should jump to line 0; got line {}",
2164            app.editor.cursor.line
2165        );
2166        assert!(
2167            app.editor.partial_chord.is_empty(),
2168            "partial_chord should clear after gg resolves"
2169        );
2170    }
2171
2172    /// PU.2: the GPUI floating-popup geometry hand-off. `set_popup_viewport`
2173    /// writes the popup's resolved inner `(rows, cols)` onto the editor so
2174    /// the host's `build_cells_panes` can size the synthetic `PaneId::POPUP`
2175    /// `DisplayMatrix` the popup interior `EditorElement` paints from — the
2176    /// GPUI peer of the TUI's `App::set_popup_viewport`. (Host-side coverage
2177    /// that these fields actually build the synthetic pane lives in
2178    /// `floating_popup_gets_synthetic_cells_pane_when_geometry_fed`; this
2179    /// just pins the GPUI plumbing + the zero→1 clamp.)
2180    #[test]
2181    fn set_popup_viewport_writes_editor_geometry_fields() {
2182        let mut app = GpuiApp::new(Document::empty());
2183        // Both 0 until the first feedback (the gate `popup_viewport_width
2184        // > 0` skips the synthetic pane until then).
2185        assert_eq!(app.editor.popup_viewport_height, 0);
2186        assert_eq!(app.editor.popup_viewport_width, 0);
2187        app.set_popup_viewport(18, 60);
2188        assert_eq!(app.editor.popup_viewport_height, 18);
2189        assert_eq!(app.editor.popup_viewport_width, 60);
2190        // A zero-sized matrix is meaningless; both axes clamp to >= 1.
2191        app.set_popup_viewport(0, 0);
2192        assert_eq!(app.editor.popup_viewport_height, 1);
2193        assert_eq!(app.editor.popup_viewport_width, 1);
2194    }
2195
2196    /// Test-coverage gap (sibling to gg): `zz` / `zt` / `zb`.
2197    /// These also live behind a chord-prefix state machine; the
2198    /// `z` keystroke absorbs into partial_chord, then the second
2199    /// keystroke (`z`/`t`/`b`) selects the action.
2200    #[test]
2201    fn gpui_dispatch_keystroke_handles_zz_chord_sequence() {
2202        // Long enough document that zz centering would be
2203        // observable (we don't assert viewport position, just
2204        // that the action dispatched and partial_chord clears).
2205        let doc = Document::from_text("line\n".repeat(20));
2206        let mut app = GpuiApp::new(doc);
2207
2208        let outcome1 = app.dispatch_keystroke("z", false, false, false, false);
2209        assert!(outcome1.is_some(), "first `z` should dispatch");
2210        assert_eq!(
2211            app.editor.partial_chord.len(),
2212            1,
2213            "first `z` should populate partial_chord"
2214        );
2215        assert_eq!(
2216            app.render_state.load().translator.partial_chord.len(),
2217            1,
2218            "published RS must observe the z partial_chord update"
2219        );
2220
2221        let outcome2 = app.dispatch_keystroke("z", false, false, false, false);
2222        assert!(outcome2.is_some(), "second `z` should dispatch");
2223        assert!(
2224            app.editor.partial_chord.is_empty(),
2225            "partial_chord should clear after zz resolves"
2226        );
2227    }
2228
2229    /// Slice 3c.atomic.K: `GpuiApp::ad()` reflects the modal
2230    /// transition driven by dispatch. Proves the renderer-side
2231    /// publish chain (`dispatch_chord` → `dispatch_action` →
2232    /// dispatch tail `publish_render_state()`) reaches the
2233    /// `GpuiApp.render_state` cell, so paint-time reads through
2234    /// `ad()` see the freshest editor state.
2235    #[test]
2236    fn gpui_app_ad_reflects_dispatched_state() {
2237        use lattice_host::chord::{KeyChord, KeyKind, KeyMods};
2238        let mut app = GpuiApp::new(Document::empty());
2239        // Boot publish hydrates ad() before any dispatch.
2240        assert_eq!(app.ad().modal, ModalState::Normal);
2241        app.dispatch_chord(KeyChord::new(KeyKind::Char('i'), KeyMods::NONE));
2242        assert_eq!(
2243            app.ad().modal,
2244            ModalState::Insert,
2245            "ad() must observe the post-dispatch modal state through render_state"
2246        );
2247    }
2248
2249    /// LG.1: `rebuild_gpui_theme` propagates `ui.ligatures` to
2250    /// `GpuiTheme.ligatures`. Default is `true`; setting the option
2251    /// to `false` flips the field.
2252    #[test]
2253    fn rebuild_gpui_theme_propagates_ligatures() {
2254        let mut app = GpuiApp::new(Document::empty());
2255        // Default is true (ligatures on by default).
2256        assert!(app.theme.ligatures, "default ligatures should be true");
2257        // Override via the config registry that's already in the render state.
2258        let rs = app.render_state.load();
2259        rs.options
2260            .config
2261            .parse_and_set_command("ui.ligatures=off")
2262            .unwrap();
2263        drop(rs);
2264        app.rebuild_gpui_theme();
2265        assert!(
2266            !app.theme.ligatures,
2267            "ligatures should be false after ui.ligatures=off"
2268        );
2269        // Toggle back on.
2270        let rs = app.render_state.load();
2271        rs.options
2272            .config
2273            .parse_and_set_command("ui.ligatures=on")
2274            .unwrap();
2275        drop(rs);
2276        app.rebuild_gpui_theme();
2277        assert!(
2278            app.theme.ligatures,
2279            "ligatures should be true after ui.ligatures=on"
2280        );
2281    }
2282
2283    /// Slice 3c.atomic.K: `set_viewport_height` clamps height
2284    /// to a minimum of 1 and republishes so `ad().viewport_height`
2285    /// observes the change. Matches the contract the TUI peer's
2286    /// `App::set_viewport_height` exposes (3c.atomic.D).
2287    #[test]
2288    fn gpui_app_set_viewport_height_publishes() {
2289        let mut app = GpuiApp::new(Document::empty());
2290        app.set_viewport_height(24);
2291        assert_eq!(app.ad().viewport_height, 24);
2292        // Clamp: 0 becomes 1 (renderer's per-frame layout never
2293        // gives the buffer a zero-height pane, but the wrapper's
2294        // contract guarantees a usable lower bound regardless).
2295        app.set_viewport_height(0);
2296        assert_eq!(app.ad().viewport_height, 1);
2297    }
2298}