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}