Skip to main content

lattice_host/
editor_actor.rs

1//! `EditorActor` — the editor runs on its own thread.
2//!
3//! Phase 5.8.AF.5 / Slice 3c.0.
4//!
5//! ## Why this exists
6//!
7//! Paramount goal #4 (CLAUDE.md): "Three-layer architecture
8//! (UI / Core / Plugins) communicating via typed message passing.
9//! Multi-threaded by construction. Nothing blocks the UI -- enforced
10//! architecturally, not by discipline."
11//!
12//! After Slices 3a + 3b.* moved every per-buffer LSP cache off
13//! the renderer thread via wait-free read primitives, the last
14//! piece of architectural debt is `Editor` itself living on the
15//! renderer thread. While the renderer holds `&mut Editor`, the
16//! UI thread *can* (in principle) do editor work synchronously,
17//! and any future feature that does so silently regresses the
18//! architecture. The fix: relocate `Editor` to its own thread so
19//! the renderer is *physically incapable* of touching it
20//! directly.
21//!
22//! ## Shape
23//!
24//! - `EditorActorHandle` — what the renderer holds. Carries the
25//!   command-send half (`cmd_tx`), the signal-receive half
26//!   (`signal_rx`), and a clone of the editor's
27//!   `Arc<ArcSwap<RenderState>>`. Not `Clone` because it owns
28//!   the unique receiver; `send_action` / `send_command` go
29//!   through `&self`.
30//! - `EditorCommand` — the typed mailbox payload. Renderer-to-
31//!   editor messages. Includes `Apply(Action)`, `HandleEffect`,
32//!   `DispatchBlocking { invocation, reply }`, `Tick`, `Ping`,
33//!   `Shutdown`. Extensible: subsequent slices add variants.
34//! - `spawn_editor_actor(editor) -> EditorActorHandle` — takes
35//!   ownership of an `Editor`, spawns a dedicated thread with a
36//!   `current_thread` tokio runtime, returns the handle. The
37//!   thread name is `"lattice-editor"` for observability.
38//!
39//! ## Slice 3c.0 status
40//!
41//! Everything in this module is **dormant** -- no production call
42//! site wires `spawn_editor_actor` yet. The follow-on sub-slices:
43//!
44//! - **3c.1**: populate `ActiveDocumentRenderState` (the read
45//!   contract for cursor/scroll/modal/etc.) so renderers can
46//!   migrate off direct `editor.X` reads.
47//! - **3c.2 / 3c.3**: TUI / GPUI renderers switch their reads
48//!   to `RenderState`.
49//! - **3c.4**: renderers wire `EditorActorHandle`; `App::apply`
50//!   becomes `handle.send_action(action)`.
51//! - **3c.5**: sever `Arc<Editor>` from renderer entirely.
52//! - **3c.6 / 3c.7**: polish + docs.
53//!
54//! Tests in this file verify the substrate works end-to-end:
55//! spawn the actor, send a `Ping`, await the reply.
56
57use std::sync::Arc;
58
59use arc_swap::ArcSwap;
60use lattice_grammar::CommandInvocation;
61use lattice_grammar::effect::Effect;
62use lattice_runtime::RuntimeError;
63use tokio::sync::{mpsc, oneshot};
64
65use crate::action::Action;
66use crate::dispatch::{DispatchOutcome, RendererSignal};
67use crate::editor::Editor;
68use crate::render_state::RenderState;
69
70/// Error returned by the synchronous handle methods when the
71/// actor thread has shut down. Production code maps this to a
72/// fatal condition: the editor thread dying is unrecoverable.
73/// In tests, surfaces as a `panic!` via `unwrap` (acceptable —
74/// test fixtures keep the handle alive for their duration).
75#[derive(Debug, Clone, Copy, PartialEq, Eq)]
76pub struct ActorGone;
77
78impl std::fmt::Display for ActorGone {
79    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
80        write!(f, "editor actor thread has terminated")
81    }
82}
83
84impl std::error::Error for ActorGone {}
85
86/// Renderer-to-editor mailbox payload. Each variant corresponds
87/// to a way the renderer (or another task) drives the editor's
88/// state.
89pub enum EditorCommand {
90    /// Apply a renderer-translated `Action`. The action's
91    /// `DispatchOutcome` is processed inside the actor: signals
92    /// fan to `signal_tx`; queued `next_actions` and `effects`
93    /// are processed iteratively (same shape as
94    /// [`crate::editor::Editor::dispatch`]'s caller-side drain).
95    Apply(Action),
96    /// Synchronous variant of [`Self::Apply`]: dispatch the
97    /// action, then signal completion on `reply`. Phase 5.8.AF.5
98    /// / Slice 3c.final.E: used by the renderer's synchronous
99    /// `App::apply` wrapper so existing call sites that expect
100    /// to read editor-published state on the next line keep
101    /// working unchanged. The actor processes commands serially,
102    /// so awaiting this reply is a barrier — any previously-
103    /// queued commands (including signals) drain first.
104    ApplyAndReply {
105        action: Action,
106        reply: oneshot::Sender<()>,
107    },
108    /// Closure escape hatch (3c.final.E): run an arbitrary
109    /// mutation against `Editor` on the actor thread. Fire-and-
110    /// forget — no reply. Used by the renderer's `mutate_async`
111    /// helper for callsites that don't need a synchronous
112    /// barrier (e.g., LSP response handlers that simply stash a
113    /// cache and let the next publish flow downstream).
114    Mutate(Box<dyn FnOnce(&mut Editor) + Send>),
115    /// Synchronous variant of [`Self::Mutate`]: run the closure
116    /// against `Editor`, then signal completion. Used by the
117    /// majority of App-side helpers during the slice-E sweep;
118    /// the existing helper bodies move into the closure verbatim
119    /// and the caller blocks on `reply.recv()`. As helpers gain
120    /// typed `Action::*` variants over follow-up slices, their
121    /// callers retire `MutateAndReply` in favour of
122    /// `ApplyAndReply`.
123    MutateAndReply {
124        closure: Box<dyn FnOnce(&mut Editor) + Send>,
125        reply: oneshot::Sender<()>,
126    },
127    /// Read-side RPC (3c.final.E.swap): run a closure against
128    /// `&Editor` (immutable borrow) and reply with the boxed
129    /// result. The closure's return type is erased via
130    /// `Box<dyn Any + Send>` so the actor's command enum stays
131    /// non-generic; the caller's `with_editor<R>` helper
132    /// downcasts back to `R`. Used by every `&self`-receiver
133    /// helper on `App` / `GpuiApp` that previously did
134    /// `self.editor.X(...)` for a read.
135    Read {
136        closure: Box<dyn FnOnce(&Editor) -> Box<dyn std::any::Any + Send> + Send>,
137        reply: oneshot::Sender<Box<dyn std::any::Any + Send>>,
138    },
139    /// Read-and-mutate RPC (3c.final.E.swap): like `MutateAndReply`
140    /// but the closure returns a value. Used by `mutate_editor_with`
141    /// post-swap so closures returning `Vec<RendererSignal>` or
142    /// `Result<_, _>` still work. Same `Box<dyn Any>` erasure
143    /// pattern as [`Self::Read`].
144    MutateWithReply {
145        closure: Box<dyn FnOnce(&mut Editor) -> Box<dyn std::any::Any + Send> + Send>,
146        reply: oneshot::Sender<Box<dyn std::any::Any + Send>>,
147    },
148    /// Apply a renderer-emitted `Effect` directly (bypasses
149    /// dispatch). Used by Effect routers that already mapped
150    /// the effect upstream.
151    HandleEffect(Effect),
152    /// Synchronous dispatch with a single-reply oneshot. The
153    /// caller awaits the response. Used today by the `:g` /
154    /// `:v` body-replay loop and any other path that needs the
155    /// effect *immediately* (not via signal fan-out).
156    DispatchBlocking {
157        invocation: CommandInvocation,
158        reply: oneshot::Sender<Result<Effect, RuntimeError>>,
159    },
160    /// Fire the periodic `run_tick_pending` aggregator (LSP
161    /// drains, fs events, request fires). Sent by the renderer
162    /// at frame cadence today; once the editor task owns a
163    /// periodic timer of its own, this command retires.
164    Tick,
165    /// Empty roundtrip — replies as soon as the actor receives
166    /// the message. Used by tests to await processing without
167    /// observing side effects. Also useful for synchronous
168    /// barriers in subsequent slices.
169    Ping { reply: oneshot::Sender<()> },
170    /// Tear down the actor. The thread joins cleanly after the
171    /// in-flight command completes.
172    Shutdown,
173
174    // ------------------------------------------------------------
175    // 3c.atomic.F: typed setter commands. Mirror the in-process
176    // setters added in 3c.atomic.C (`Editor::set_cursor` /
177    // `set_cursor_line` / `set_cursor_byte` / `set_scroll` /
178    // `set_modal`) and the publish wrapper around
179    // `set_viewport_height` introduced in 3c.atomic.D.
180    //
181    // These commands cover the OUT-OF-DISPATCH write surface --
182    // viewport resize signalled by the renderer, test fixtures
183    // that wanted to seed cursor/scroll/modal directly, and
184    // future paths where a non-editor task needs to nudge active-
185    // document state. Each variant routes to the corresponding
186    // setter, which writes the field and publishes a fresh
187    // `RenderState`. The renderer observes the change via its
188    // shared `Arc<ArcSwap<RenderState>>` clone -- identical
189    // contract to the in-process method, executed on the editor
190    // thread.
191    // ------------------------------------------------------------
192    /// Replace `Editor::cursor` and publish render-state.
193    SetCursor(lattice_protocol::position::Position),
194    /// Replace `Editor::cursor.line` and publish render-state.
195    SetCursorLine(u32),
196    /// Replace `Editor::cursor.byte` and publish render-state.
197    SetCursorByte(u32),
198    /// Replace `Editor::scroll` and publish render-state.
199    SetScroll(u32),
200    /// Replace `Editor::modal` and publish render-state.
201    SetModal(lattice_grammar::ModalState),
202    /// Resize the active pane's viewport. Mirrors the App-side
203    /// `set_viewport_height` wrapper (clamp to >= 1, run
204    /// `ensure_cursor_visible`, publish). The actor wraps the
205    /// no-publish editor field write + the visibility fan-out
206    /// into one atomic command so the renderer's per-frame "tell
207    /// editor the pane size" hand-off is a single `cmd_tx.send`.
208    SetViewportHeight(u32),
209    /// Per-pane geometry update. Issue #25 (2026-05-22): the
210    /// renderer's per-frame layout pass walks the pane tree and
211    /// fires one of these per leaf with the leaf's computed
212    /// height + width in screen rows / columns. The host stores
213    /// them on `PaneState`; the active leaf's viewport_height
214    /// is mirrored into `Editor::viewport_height` for the
215    /// cursor-clamp + highlights-worker code paths that don't
216    /// carry a pane index. Publishes RS at the tail.
217    SetPaneViewport { idx: usize, height: u32, width: u32 },
218}
219
220/// Renderer-side handle to the editor actor.
221///
222/// Holds:
223/// - `cmd_tx` — unbounded; the renderer sends commands without
224///   blocking. Backpressure is the actor's per-command processing
225///   cost; today every command processes in microseconds so the
226///   queue stays effectively empty.
227/// - `signal_rx` — unbounded; the renderer drains signals once
228///   per frame via [`Self::poll_signal`]. The actor pushes
229///   signals as it processes commands.
230/// - `render_state` — a clone of the editor's
231///   `Arc<ArcSwap<RenderState>>`. The renderer loads via
232///   `handle.render_state.load()` (wait-free); the editor's
233///   `publish_render_state` calls are observable through this
234///   shared Arc.
235///
236/// Not `Clone`: the `signal_rx` is uniquely owned. Renderers
237/// that need to send commands from multiple places can clone
238/// the underlying `mpsc::UnboundedSender` via
239/// [`Self::cmd_sender`], which is freely `Clone`.
240pub struct EditorActorHandle {
241    cmd_tx: mpsc::UnboundedSender<EditorCommand>,
242    signal_rx: mpsc::UnboundedReceiver<RendererSignal>,
243    render_state: Arc<ArcSwap<RenderState>>,
244    /// I.3: the editor's `paint_request` `Notify`, fired by the async
245    /// workers after a republish. Cloned to the TUI event loop so a
246    /// background publish wakes a repaint without polling.
247    paint_request: Arc<tokio::sync::Notify>,
248    /// Join handle to the dedicated editor thread. Held so
249    /// shutdown can join cleanly when the handle drops. `Some`
250    /// in normal construction; `None` after explicit
251    /// [`Self::shutdown_and_join`].
252    join: Option<std::thread::JoinHandle<()>>,
253}
254
255/// Runtime-flavor-aware `oneshot::Receiver::blocking_recv`.
256///
257/// Slice `3c.fixup.actor-sync-rpc` (companion to
258/// `3c.fixup.actor-block-on` in `lattice-runtime/src/runtime.rs`).
259/// The sync-RPC methods below (`apply_blocking`, `mutate_blocking`,
260/// `mutate_blocking_with`, `with_editor`) all park the caller's
261/// thread on a `oneshot::Receiver` until the actor replies. Tokio's
262/// `blocking_recv` panics with
263///   "Cannot block the current thread from within a runtime"
264/// when called from inside an async context.
265///
266/// The GPUI peer's main thread hosts a tokio runtime (current-thread,
267/// for `tokio_main`-style entry); any App-side helper that reaches
268/// the actor seam from that thread previously panicked at startup.
269/// Caught by `cargo run --features window` on 2026-05-21; the fix
270/// follows the same three-arm pattern as
271/// `lattice_runtime::block_on`:
272///
273///   1. No current handle — direct `blocking_recv()` (the previous
274///      contract).
275///   2. MultiThread runtime — relinquish the worker via
276///      `task::block_in_place(|| rx.blocking_recv())`.
277///   3. Non-MultiThread runtime (the GPUI main thread case) —
278///      escape to a fresh OS thread via `std::thread::scope` and
279///      do the blocking recv there, outside any tokio context.
280fn safe_blocking_recv<T: Send>(rx: oneshot::Receiver<T>) -> Result<T, oneshot::error::RecvError> {
281    use tokio::runtime::{Handle, RuntimeFlavor};
282    match Handle::try_current() {
283        Ok(handle) if matches!(handle.runtime_flavor(), RuntimeFlavor::MultiThread) => {
284            tokio::task::block_in_place(|| rx.blocking_recv())
285        }
286        Ok(_) => std::thread::scope(|s| {
287            s.spawn(|| rx.blocking_recv())
288                .join()
289                .expect("nested-blocking_recv bridge thread completed")
290        }),
291        Err(_) => rx.blocking_recv(),
292    }
293}
294
295impl EditorActorHandle {
296    /// Send a command to the editor actor. Returns `Err` only
297    /// when the actor has already shut down (channel closed).
298    /// In production this should never happen during normal
299    /// operation; in tests the failure mode is "test forgot to
300    /// keep the handle alive."
301    pub fn send(&self, cmd: EditorCommand) -> Result<(), mpsc::error::SendError<EditorCommand>> {
302        self.cmd_tx.send(cmd)
303    }
304
305    /// Convenience: send an `Apply(action)` command. Fire-and-
306    /// forget — does not wait for completion. The renderer
307    /// keystroke handler uses this so the input loop returns
308    /// immediately; the dispatched action runs on the actor
309    /// thread and publishes RenderState when done (the existing
310    /// `paint_request` Notify wakes the GPUI peer's next paint).
311    pub fn send_action(&self, action: Action) -> Result<(), mpsc::error::SendError<EditorCommand>> {
312        self.send(EditorCommand::Apply(action))
313    }
314
315    /// Synchronous dispatch — blocks until the actor finishes
316    /// processing this action (and any cascade). Used by
317    /// `App::apply` so existing call sites that read editor-
318    /// derived state on the next line keep working. Returns
319    /// `Err` only if the actor died mid-await.
320    ///
321    /// Safe to call from inside or outside a tokio runtime:
322    /// `safe_blocking_recv` selects the right wait path based on
323    /// the current runtime flavor (see its docstring).
324    pub fn apply_blocking(&self, action: Action) -> Result<(), ActorGone> {
325        let (reply_tx, reply_rx) = oneshot::channel();
326        self.send(EditorCommand::ApplyAndReply {
327            action,
328            reply: reply_tx,
329        })
330        .map_err(|_| ActorGone)?;
331        safe_blocking_recv(reply_rx).map_err(|_| ActorGone)
332    }
333
334    /// Closure escape hatch — synchronous: send the closure +
335    /// block until the actor runs it and publishes RS. Used by
336    /// App-side helpers during the slice-E sweep when the
337    /// helper's body mutates editor state directly. Each helper
338    /// wraps its body in one closure; the caller's read-back of
339    /// editor-derived state via `app.ad()` etc. sees the post-
340    /// mutation snapshot.
341    pub fn mutate_blocking(
342        &self,
343        closure: Box<dyn FnOnce(&mut Editor) + Send>,
344    ) -> Result<(), ActorGone> {
345        let (reply_tx, reply_rx) = oneshot::channel();
346        self.send(EditorCommand::MutateAndReply {
347            closure,
348            reply: reply_tx,
349        })
350        .map_err(|_| ActorGone)?;
351        // Slice 3c.fixup.actor-sync-rpc: runtime-flavor-aware wait.
352        safe_blocking_recv(reply_rx).map_err(|_| ActorGone)
353    }
354
355    /// Closure escape hatch — fire-and-forget. For tokio task
356    /// callers that can't block (e.g., LSP response handlers).
357    pub fn mutate_async(
358        &self,
359        closure: Box<dyn FnOnce(&mut Editor) + Send>,
360    ) -> Result<(), ActorGone> {
361        self.send(EditorCommand::Mutate(closure))
362            .map_err(|_| ActorGone)
363    }
364
365    /// Read-side RPC (3c.final.E.swap): synchronously run a
366    /// closure against `&Editor` on the actor thread and return
367    /// the result. Used by every `&self`-receiver helper on
368    /// `App` / `GpuiApp` that needs to read editor state.
369    /// Internally uses `Box<dyn Any>` erasure to keep the
370    /// actor's command enum non-generic.
371    pub fn with_editor<R, F>(&self, f: F) -> R
372    where
373        R: Send + 'static,
374        F: FnOnce(&Editor) -> R + Send + 'static,
375    {
376        let (tx, rx) = oneshot::channel::<Box<dyn std::any::Any + Send>>();
377        let closure: Box<dyn FnOnce(&Editor) -> Box<dyn std::any::Any + Send> + Send> =
378            Box::new(move |e| Box::new(f(e)));
379        self.send(EditorCommand::Read { closure, reply: tx })
380            .expect("editor actor alive");
381        // Slice 3c.fixup.actor-sync-rpc: runtime-flavor-aware wait.
382        let any = safe_blocking_recv(rx).expect("editor actor alive");
383        *any.downcast::<R>().expect("read RPC result type matches")
384    }
385
386    /// Sync mutate RPC with return value (3c.final.E.swap).
387    /// Used by `mutate_editor_with` post-swap: the closure
388    /// returns `R`; the actor publishes RS at its tail.
389    pub fn mutate_blocking_with<R, F>(&self, f: F) -> R
390    where
391        R: Send + 'static,
392        F: FnOnce(&mut Editor) -> R + Send + 'static,
393    {
394        let (tx, rx) = oneshot::channel::<Box<dyn std::any::Any + Send>>();
395        let closure: Box<dyn FnOnce(&mut Editor) -> Box<dyn std::any::Any + Send> + Send> =
396            Box::new(move |e| Box::new(f(e)));
397        self.send(EditorCommand::MutateWithReply { closure, reply: tx })
398            .expect("editor actor alive");
399        // Slice 3c.fixup.actor-sync-rpc: runtime-flavor-aware wait.
400        let any = safe_blocking_recv(rx).expect("editor actor alive");
401        *any.downcast::<R>().expect("mutate RPC result type matches")
402    }
403
404    // ------------------------------------------------------------
405    // 3c.atomic.F: typed setter helpers. Each wraps the
406    // corresponding command variant so callers write
407    // `handle.set_cursor(p)` instead of
408    // `handle.send(EditorCommand::SetCursor(p))`. Symmetric with
409    // the in-process `Editor::set_*` setters added in
410    // 3c.atomic.C: same call shape, same semantics, different
411    // dispatch (in-actor vs. in-process).
412    // ------------------------------------------------------------
413
414    /// Replace the editor's cursor and republish.
415    pub fn set_cursor(
416        &self,
417        cursor: lattice_protocol::position::Position,
418    ) -> Result<(), mpsc::error::SendError<EditorCommand>> {
419        self.send(EditorCommand::SetCursor(cursor))
420    }
421
422    /// Replace `cursor.line` and republish.
423    pub fn set_cursor_line(&self, line: u32) -> Result<(), mpsc::error::SendError<EditorCommand>> {
424        self.send(EditorCommand::SetCursorLine(line))
425    }
426
427    /// Replace `cursor.byte` and republish.
428    pub fn set_cursor_byte(&self, byte: u32) -> Result<(), mpsc::error::SendError<EditorCommand>> {
429        self.send(EditorCommand::SetCursorByte(byte))
430    }
431
432    /// Replace `scroll` and republish.
433    pub fn set_scroll(&self, scroll: u32) -> Result<(), mpsc::error::SendError<EditorCommand>> {
434        self.send(EditorCommand::SetScroll(scroll))
435    }
436
437    /// Replace `modal` and republish.
438    pub fn set_modal(
439        &self,
440        modal: lattice_grammar::ModalState,
441    ) -> Result<(), mpsc::error::SendError<EditorCommand>> {
442        self.send(EditorCommand::SetModal(modal))
443    }
444
445    /// Resize the active pane's viewport. Runs the same body as
446    /// the App-side wrapper: clamp to >= 1, run
447    /// `ensure_cursor_visible`, publish.
448    pub fn set_viewport_height(
449        &self,
450        height: u32,
451    ) -> Result<(), mpsc::error::SendError<EditorCommand>> {
452        self.send(EditorCommand::SetViewportHeight(height))
453    }
454
455    /// Issue #25 (2026-05-22): per-pane geometry setter. The
456    /// renderer's per-frame layout pass walks the pane tree and
457    /// fires one of these per leaf. The active leaf's height is
458    /// auto-mirrored into `Editor::viewport_height` for cursor
459    /// clamp + highlights worker.
460    pub fn set_pane_viewport(
461        &self,
462        idx: usize,
463        height: u32,
464        width: u32,
465    ) -> Result<(), mpsc::error::SendError<EditorCommand>> {
466        self.send(EditorCommand::SetPaneViewport { idx, height, width })
467    }
468
469    /// Drain a single pending signal from the editor's
470    /// signal stream. Returns `None` when the queue is empty
471    /// (steady-state on most frames). Called per-frame by the
472    /// renderer; the typical drain pattern is
473    /// `while let Some(sig) = handle.poll_signal() { ... }`.
474    pub fn poll_signal(&mut self) -> Option<RendererSignal> {
475        self.signal_rx.try_recv().ok()
476    }
477
478    /// Cheap clone of the command-send side. Use this when
479    /// multiple call sites in the renderer need to send
480    /// commands without sharing a `&self` to the full handle.
481    pub fn cmd_sender(&self) -> mpsc::UnboundedSender<EditorCommand> {
482        self.cmd_tx.clone()
483    }
484
485    /// Wait-free read of the current `RenderState` snapshot.
486    /// Returns an `Arc<RenderState>` the renderer holds across
487    /// the frame.
488    pub fn render_state(&self) -> Arc<RenderState> {
489        self.render_state.load_full()
490    }
491
492    /// Shared `ArcSwap` reference for callers that need to
493    /// store it alongside other Arc-shared state (e.g., a
494    /// renderer that wants to clone the cell into a paint
495    /// closure).
496    pub fn render_state_arc(&self) -> Arc<ArcSwap<RenderState>> {
497        self.render_state.clone()
498    }
499
500    /// Shared `paint_request` `Notify` the actor's workers fire after an
501    /// async republish (syntax recolour, LSP decoration, cells/virtual-rows).
502    /// The TUI event loop (I.3) awaits this to repaint promptly; the GPUI peer
503    /// uses the same notify natively.
504    pub fn paint_request(&self) -> Arc<tokio::sync::Notify> {
505        self.paint_request.clone()
506    }
507
508    /// Send `Shutdown` and join the editor thread. Idempotent
509    /// in the sense that subsequent calls return `Ok(())`
510    /// without re-joining. Returns `Err` only if the thread
511    /// panicked.
512    pub fn shutdown_and_join(&mut self) -> std::thread::Result<()> {
513        let _ = self.cmd_tx.send(EditorCommand::Shutdown);
514        if let Some(handle) = self.join.take() {
515            handle.join()
516        } else {
517            Ok(())
518        }
519    }
520}
521
522impl Drop for EditorActorHandle {
523    fn drop(&mut self) {
524        // Best-effort clean shutdown. If the renderer holds the
525        // handle as a long-lived field, this only fires at
526        // editor exit; the join inside is short because the
527        // actor's command loop is fast.
528        if self.join.is_some() {
529            let _ = self.cmd_tx.send(EditorCommand::Shutdown);
530            if let Some(h) = self.join.take() {
531                let _ = h.join();
532            }
533        }
534    }
535}
536
537/// Spawn the editor actor on a dedicated thread with a
538/// `current_thread` tokio runtime. Takes ownership of `editor`.
539///
540/// The dedicated thread is named `"lattice-editor"` for
541/// observability. The actor's tokio runtime is `current_thread`
542/// (single-threaded executor); editor work that needs the
543/// multi-thread LSP runtime spawns onto it via
544/// `lattice_runtime::runtime::spawn_on_lsp_runtime` as before
545/// (unchanged).
546///
547/// Returns an [`EditorActorHandle`]. Drop the handle to shut
548/// the actor down.
549pub fn spawn_editor_actor(editor: Editor) -> EditorActorHandle {
550    let (cmd_tx, cmd_rx) = mpsc::unbounded_channel::<EditorCommand>();
551    let (signal_tx, signal_rx) = mpsc::unbounded_channel::<RendererSignal>();
552    let render_state = editor.render_state.clone();
553    let paint_request = editor.paint_request.clone();
554
555    let join = std::thread::Builder::new()
556        .name("lattice-editor".to_string())
557        .spawn(move || {
558            // current_thread runtime: single-task executor on
559            // this thread. The editor task can spawn onto the
560            // multi-thread `lsp_runtime` for LSP work as
561            // before; only the editor's *own* work runs here.
562            let rt = tokio::runtime::Builder::new_current_thread()
563                .enable_all()
564                .thread_name("lattice-editor")
565                .build()
566                .expect("editor runtime should build");
567            rt.block_on(run_actor(editor, cmd_rx, signal_tx));
568        })
569        .expect("spawn editor thread");
570
571    EditorActorHandle {
572        cmd_tx,
573        signal_rx,
574        render_state,
575        paint_request,
576        join: Some(join),
577    }
578}
579
580/// Actor event loop. Processes commands one at a time; the
581/// `Editor` is owned exclusively by this task so all mutations
582/// are single-writer. Signals flow to `signal_tx`;
583/// `DispatchOutcome` cascades (`effects`, `next_actions`) are
584/// processed iteratively to avoid stack growth on long chains.
585async fn run_actor(
586    mut editor: Editor,
587    mut cmd_rx: mpsc::UnboundedReceiver<EditorCommand>,
588    signal_tx: mpsc::UnboundedSender<RendererSignal>,
589) {
590    // Slice B.1 (2026-06-03): the loop wakes on EITHER an incoming
591    // command OR `async_landed` — fired by async completions (today
592    // the syntax reparse worker on publish) that produce
593    // render-relevant state with no keystroke in flight. On that wake
594    // we run the tick aggregator + re-publish, so e.g. an idle markdown
595    // reparse repaints without waiting for the next key. Runs on the
596    // single-writer actor thread, not the UI thread (paramount #1).
597    let async_landed = editor.async_landed.clone();
598    // L4a.2 (lsp-architecture.md §15): the inline cursor-line
599    // diagnostic-summary idle gate. A pinned sleep, seeded far in the
600    // future and retargeted each iteration to `editor`'s armed
601    // deadline (set in `update_inline_diag_gate` during publish). The
602    // guarded select! arm fires only for a live arm; on fire it makes
603    // the summary visible + republishes, all on the actor thread
604    // (paramount #1 — never the UI thread).
605    let inline_diag_sleep = tokio::time::sleep(std::time::Duration::from_secs(60 * 60));
606    tokio::pin!(inline_diag_sleep);
607    // WK.3: the generic idle-gate registry's sleep — the same shape as the
608    // one above, but targeting the earliest deadline armed by ANY subsystem
609    // (which-key's pending-chord delay is the first). A second pinned sleep
610    // rather than a shared one, because the inline-diagnostic gate is not
611    // migrated into the registry yet (which-key.md §9: its arm decision lives
612    // inside `publish_render_state` and needs a `CursorSettled` event that
613    // does not exist). When that migration lands this arm absorbs the one
614    // above.
615    let idle_gate_sleep = tokio::time::sleep(std::time::Duration::from_secs(60 * 60));
616    tokio::pin!(idle_gate_sleep);
617    loop {
618        // Retarget the idle-gate sleep to the current deadline. Cheap;
619        // when disarmed we point it an hour out and the `is_some()`
620        // guard keeps the arm dormant.
621        inline_diag_sleep
622            .as_mut()
623            .reset(editor.inline_diag_deadline.unwrap_or_else(|| {
624                tokio::time::Instant::now() + std::time::Duration::from_secs(60 * 60)
625            }));
626        let idle_gate_deadline = editor.idle_gate_deadline();
627        idle_gate_sleep
628            .as_mut()
629            .reset(idle_gate_deadline.unwrap_or_else(|| {
630                tokio::time::Instant::now() + std::time::Duration::from_secs(60 * 60)
631            }));
632        let cmd = tokio::select! {
633            maybe_cmd = cmd_rx.recv() => match maybe_cmd {
634                Some(cmd) => cmd,
635                None => break,
636            },
637            _ = async_landed.notified() => {
638                let signals = editor.run_tick_pending();
639                // AW.4 (hover-popup fix): an async drain — hover (`K`),
640                // signature-help, an async picker — can emit a `DisplayBuffer`
641                // signal, a request to open a popup. The popup is HOST state
642                // (`Editor::popup_buffer`); both peers' own `DisplayBuffer`
643                // handlers just call `editor.display_buffer` then render
644                // `popup_buffer` from RenderState. But the TUI peer has NO
645                // `signal_rx` consumer (it drives entirely off `paint_request` +
646                // RenderState), so a `DisplayBuffer` forwarded on `signal_tx` from
647                // *this* arm is silently dropped and the popup never shows — the
648                // server-responds-but-no-popup bug. Apply it host-side HERE,
649                // before `publish_render_state`, so opening `popup_buffer` moves
650                // `paint_revision` → `paint_request` fires → both peers repaint and
651                // render the popup off the wake. Consuming (not forwarding) it is
652                // parity with the GPUI peer's handler, not a divergence.
653                let forward = editor.absorb_async_display_signals(signals);
654                // §12 paint gate: an async arrival that moved a non-cell
655                // render-visible surface (LSP readiness badge, diagnostics
656                // overlay, popup, …) must reach a frame WITHOUT a keystroke
657                // — the cells / virtual-rows workers only paint on their
658                // own content change. `publish_render_state` reports
659                // whether `paint_revision` moved; fire `paint_request`
660                // when it did. Gated so a no-op publish doesn't spin the
661                // GPUI paint bridge.
662                let painted = editor.publish_render_state();
663                if painted {
664                    editor.paint_request.notify_one();
665                }
666                // Notify cells via the event bus so the wake is
667                // sequenced after the ArcSwap store in
668                // publish_render_state. Cells wakes via the
669                // AsyncRenderStatePublished bridge in editor_boot.rs.
670                editor.event_bus.publish_typed(
671                    crate::events::AsyncRenderStatePublished,
672                );
673                for sig in forward {
674                    let _ = signal_tx.send(sig);
675                }
676                continue;
677            }
678            // L4a.2: the inline-diagnostic idle deadline elapsed.
679            // Flip the gate visible, republish (so `build_render_state`
680            // emits the cursor-line summary), and wake cells via the
681            // same `AsyncRenderStatePublished` bridge the async_landed
682            // arm uses. No `run_tick_pending` — the gate only changes
683            // presentation, not pending async work.
684            // WK.3: a subsystem's armed deadline elapsed. Run every due
685            // gate, apply its effects, republish and repaint — all on the
686            // actor thread, and all WITHOUT a keystroke, which is the whole
687            // point of the primitive (a popup that only appears once the
688            // user presses something is not a hint, it is a bug).
689            _ = &mut idle_gate_sleep, if idle_gate_deadline.is_some() => {
690                let signals = editor.fire_idle_gates();
691                let forward = editor.absorb_async_display_signals(signals);
692                let painted = editor.publish_render_state();
693                if painted {
694                    editor.paint_request.notify_one();
695                }
696                editor.event_bus.publish_typed(
697                    crate::events::AsyncRenderStatePublished,
698                );
699                for sig in forward {
700                    let _ = signal_tx.send(sig);
701                }
702                continue;
703            }
704            _ = &mut inline_diag_sleep, if editor.inline_diag_deadline.is_some() => {
705                editor.fire_inline_diag_gate();
706                // §12 paint gate: the idle gate flips the inline summary
707                // visible — a non-cell surface — so fire `paint_request`
708                // when the publish reports the change.
709                let painted = editor.publish_render_state();
710                if painted {
711                    editor.paint_request.notify_one();
712                }
713                editor.event_bus.publish_typed(
714                    crate::events::AsyncRenderStatePublished,
715                );
716                continue;
717            }
718        };
719        match cmd {
720            EditorCommand::Apply(action) => {
721                let outcome = editor.dispatch(action);
722                drain_outcome(outcome, &mut editor, &signal_tx);
723            }
724            EditorCommand::ApplyAndReply { action, reply } => {
725                let outcome = editor.dispatch(action);
726                drain_outcome(outcome, &mut editor, &signal_tx);
727                // Signal completion after dispatch + cascade.
728                // Render-state already published by dispatch tail
729                // so the caller's next `render_state.load()` sees
730                // the new state.
731                let _ = reply.send(());
732            }
733            EditorCommand::Mutate(closure) => {
734                closure(&mut editor);
735                // The closure may or may not have published RS;
736                // for the renderer's read contract to hold, fire
737                // a publish here so the caller's next read sees
738                // any field mutation the closure made.
739                editor.publish_render_state();
740            }
741            EditorCommand::MutateAndReply { closure, reply } => {
742                closure(&mut editor);
743                editor.publish_render_state();
744                let _ = reply.send(());
745            }
746            EditorCommand::Read { closure, reply } => {
747                let any = closure(&editor);
748                let _ = reply.send(any);
749            }
750            EditorCommand::MutateWithReply { closure, reply } => {
751                let any = closure(&mut editor);
752                editor.publish_render_state();
753                let _ = reply.send(any);
754            }
755            EditorCommand::HandleEffect(effect) => {
756                let outcome = editor.handle_effect(effect);
757                drain_outcome(outcome, &mut editor, &signal_tx);
758            }
759            EditorCommand::DispatchBlocking { invocation, reply } => {
760                let result = editor.dispatch_blocking(invocation);
761                let _ = reply.send(result);
762            }
763            EditorCommand::Tick => {
764                let signals = editor.run_tick_pending();
765                for sig in signals {
766                    let _ = signal_tx.send(sig);
767                }
768            }
769            EditorCommand::Ping { reply } => {
770                let _ = reply.send(());
771            }
772            EditorCommand::Shutdown => break,
773            // 3c.atomic.F: typed setter commands. Each delegates
774            // to the in-process setter, which writes the field
775            // and publishes. The renderer's shared
776            // `Arc<ArcSwap<RenderState>>` clone observes the new
777            // pointer wait-free.
778            EditorCommand::SetCursor(p) => editor.set_cursor(p),
779            EditorCommand::SetCursorLine(line) => editor.set_cursor_line(line),
780            EditorCommand::SetCursorByte(byte) => editor.set_cursor_byte(byte),
781            EditorCommand::SetScroll(s) => editor.set_scroll(s),
782            EditorCommand::SetModal(m) => editor.set_modal(m),
783            EditorCommand::SetViewportHeight(h) => {
784                // Mirrors the App-side wrapper:
785                // clamp to >= 1, run ensure_cursor_visible (which
786                // may adjust `scroll`), publish once at the tail.
787                editor.viewport_height = h.max(1);
788                editor.ensure_cursor_visible();
789                editor.publish_render_state();
790            }
791            EditorCommand::SetPaneViewport { idx, height, width } => {
792                // Issue #25 (2026-05-22): write per-pane geometry
793                // onto the leaf and, when this is the active pane,
794                // mirror its height into `Editor::viewport_height`
795                // so cursor-clamp + highlights-worker keep reading
796                // a single value but it now always reflects the
797                // active pane's actual painted area.
798                let active_idx = editor.pane_tree.active_index();
799                let leaves = editor.pane_tree.leaves_mut();
800                let pane_kind = leaves.get(idx).map(|l| (l.buffer, l.buffer_id));
801                if idx < leaves.len() {
802                    leaves[idx].viewport_height = height.max(1);
803                    leaves[idx].viewport_width = width.max(1);
804                }
805                if idx == active_idx {
806                    editor.viewport_height = height.max(1);
807                    editor.ensure_cursor_visible();
808                    // DB.4: content-centring pad depends on the pane width, so
809                    // recompute it (and the rest of the option cache) when the
810                    // active pane resizes — keeps the dashboard centred.
811                    editor.rebuild_option_cache();
812                }
813                // T4.1 (2026-05-25): when the pane hosts a
814                // terminal, propagate the new geometry to both
815                // the alacritty grid (via SharedTerm::resize)
816                // and the PTY (via PtyHandle::resize) so the
817                // child sees a SIGWINCH and re-lays-out its UI.
818                if let Some((lattice_core::BufferKind::Terminal, buf_id)) = pane_kind {
819                    let rows = height.max(1).min(u16::MAX as u32) as u16;
820                    let cols = width.max(1).min(u16::MAX as u32) as u16;
821                    let _ = editor.buffers.with_terminal(buf_id, |t| {
822                        t.term.resize(rows, cols);
823                        let _ = t.pty.resize(rows, cols);
824                    });
825                }
826                editor.publish_render_state();
827            }
828        }
829    }
830}
831
832/// Drain a `DispatchOutcome`'s signals + cascade follow-ups.
833/// Iterative (not recursive) to bound stack usage on long
834/// `next_actions` chains.
835fn drain_outcome(
836    outcome: DispatchOutcome,
837    editor: &mut Editor,
838    signal_tx: &mpsc::UnboundedSender<RendererSignal>,
839) {
840    let mut work: Vec<DispatchOutcome> = vec![outcome];
841    while let Some(mut o) = work.pop() {
842        for sig in o.renderer_signals.drain(..) {
843            let _ = signal_tx.send(sig);
844        }
845        for effect in o.effects.drain(..) {
846            work.push(editor.handle_effect(effect));
847        }
848        for action in o.next_actions.drain(..) {
849            work.push(editor.dispatch(action));
850        }
851        // `consumed` is a renderer-side flag (used by
852        // `sync_keymap_overlays`); irrelevant inside the actor.
853    }
854}
855
856#[cfg(test)]
857mod tests {
858    use super::*;
859
860    /// Spawn the actor, send a Ping, await the reply. Verifies
861    /// the substrate: thread spawns, runtime initializes,
862    /// command channel works, oneshot reply works.
863    #[test]
864    fn actor_ping_pong_roundtrip() {
865        let editor = Editor::default();
866        let handle = spawn_editor_actor(editor);
867        let (reply_tx, reply_rx) = oneshot::channel();
868        handle
869            .send(EditorCommand::Ping { reply: reply_tx })
870            .expect("send ping");
871        reply_rx.blocking_recv().expect("ping reply");
872    }
873
874    /// `Apply(Action::None)` causes the editor's render_state
875    /// publication to fire — observable via Arc identity change
876    /// on `handle.render_state()`.
877    #[test]
878    fn actor_apply_action_publishes_render_state() {
879        let editor = Editor::default();
880        let handle = spawn_editor_actor(editor);
881        let before = handle.render_state();
882        handle.send_action(Action::None).expect("send action");
883        // Synchronize: Ping reply after the Apply guarantees the
884        // actor processed the Apply (commands are serialized).
885        let (reply_tx, reply_rx) = oneshot::channel();
886        handle
887            .send(EditorCommand::Ping { reply: reply_tx })
888            .expect("send ping");
889        reply_rx.blocking_recv().expect("ping reply");
890        let after = handle.render_state();
891        assert!(
892            !Arc::ptr_eq(&before, &after),
893            "dispatch() tail must publish a fresh RenderState Arc"
894        );
895    }
896
897    /// Dropping the handle cleanly shuts the thread down. No
898    /// hang; no panic; thread joins.
899    #[test]
900    fn actor_drop_handle_joins_thread() {
901        let editor = Editor::default();
902        let handle = spawn_editor_actor(editor);
903        let (reply_tx, reply_rx) = oneshot::channel();
904        handle
905            .send(EditorCommand::Ping { reply: reply_tx })
906            .expect("send ping");
907        reply_rx.blocking_recv().expect("ping reply");
908        drop(handle);
909        // If we reach here without hanging, the thread joined.
910    }
911
912    /// Explicit shutdown_and_join works without panic and is
913    /// idempotent on subsequent calls.
914    #[test]
915    fn actor_explicit_shutdown_join_idempotent() {
916        let editor = Editor::default();
917        let mut handle = spawn_editor_actor(editor);
918        handle.shutdown_and_join().expect("first join");
919        // Second call returns Ok without re-joining (no
920        // handle to join).
921        handle.shutdown_and_join().expect("second join");
922    }
923
924    /// `Tick` runs `run_tick_pending` and forwards any signals.
925    /// On a fresh editor with no pending drains, the signal
926    /// stream stays empty.
927    #[test]
928    fn actor_tick_with_no_pending_work_emits_no_signals() {
929        let editor = Editor::default();
930        let mut handle = spawn_editor_actor(editor);
931        handle.send(EditorCommand::Tick).expect("send tick");
932        // Synchronize.
933        let (reply_tx, reply_rx) = oneshot::channel();
934        handle
935            .send(EditorCommand::Ping { reply: reply_tx })
936            .expect("send ping");
937        reply_rx.blocking_recv().expect("ping reply");
938        // Drain any signals.
939        let mut sig_count = 0usize;
940        while handle.poll_signal().is_some() {
941            sig_count += 1;
942        }
943        // A fresh-default editor with no LSP attach has no
944        // signal-producing drains; tick should be silent.
945        assert_eq!(sig_count, 0, "fresh editor Tick should be silent");
946    }
947
948    // ------------------------------------------------------------
949    // 3c.atomic.F: typed setter command tests.
950    //
951    // Each test spawns a fresh actor, sends a setter command,
952    // synchronizes with a Ping, and asserts that the published
953    // `RenderState` reflects the mutation. The published-Arc
954    // identity changes on each setter (different `Arc::ptr_eq`),
955    // which is the canonical signal that `publish_render_state`
956    // fired inside the actor body.
957    // ------------------------------------------------------------
958
959    /// Synchronize on the actor's command queue. Ping reply
960    /// is sent after the preceding command's handler returns,
961    /// because commands are processed serially.
962    fn await_actor(handle: &EditorActorHandle) {
963        let (reply_tx, reply_rx) = oneshot::channel();
964        handle
965            .send(EditorCommand::Ping { reply: reply_tx })
966            .expect("send ping");
967        reply_rx.blocking_recv().expect("ping reply");
968    }
969
970    #[test]
971    fn actor_set_cursor_publishes_new_position() {
972        let editor = Editor::default();
973        let handle = spawn_editor_actor(editor);
974        let before = handle.render_state();
975        let target = lattice_protocol::position::Position::new(3, 7);
976        handle.set_cursor(target).expect("send set_cursor");
977        await_actor(&handle);
978        let after = handle.render_state();
979        assert!(
980            !Arc::ptr_eq(&before, &after),
981            "SetCursor must publish a fresh RenderState Arc"
982        );
983        assert_eq!(after.active_document.load().cursor, target);
984    }
985
986    #[test]
987    fn actor_set_cursor_line_byte_publish_independently() {
988        let editor = Editor::default();
989        let handle = spawn_editor_actor(editor);
990        handle.set_cursor_line(5).expect("send set_cursor_line");
991        await_actor(&handle);
992        let after_line = handle.render_state();
993        assert_eq!(after_line.active_document.load().cursor.line, 5);
994        // byte stays at default.
995        assert_eq!(after_line.active_document.load().cursor.byte, 0);
996
997        handle.set_cursor_byte(11).expect("send set_cursor_byte");
998        await_actor(&handle);
999        let after_byte = handle.render_state();
1000        assert_eq!(after_byte.active_document.load().cursor.line, 5);
1001        assert_eq!(after_byte.active_document.load().cursor.byte, 11);
1002    }
1003
1004    #[test]
1005    fn actor_set_scroll_publishes_new_scroll() {
1006        let editor = Editor::default();
1007        let handle = spawn_editor_actor(editor);
1008        handle.set_scroll(42).expect("send set_scroll");
1009        await_actor(&handle);
1010        let after = handle.render_state();
1011        assert_eq!(after.active_document.load().scroll, 42);
1012    }
1013
1014    #[test]
1015    fn actor_set_modal_publishes_new_modal() {
1016        let editor = Editor::default();
1017        let handle = spawn_editor_actor(editor);
1018        handle
1019            .set_modal(lattice_grammar::ModalState::Insert)
1020            .expect("send set_modal");
1021        await_actor(&handle);
1022        let after = handle.render_state();
1023        assert!(matches!(
1024            after.active_document.load().modal,
1025            lattice_grammar::ModalState::Insert
1026        ));
1027    }
1028
1029    /// Slice 3c.final.E: `apply_blocking` dispatches the action,
1030    /// blocks until the actor processes it, then returns. The
1031    /// caller's next render_state read sees the post-dispatch
1032    /// state.
1033    #[test]
1034    fn actor_apply_blocking_completes_synchronously() {
1035        let editor = Editor::default();
1036        let handle = spawn_editor_actor(editor);
1037        let before = handle.render_state();
1038        handle
1039            .apply_blocking(Action::None)
1040            .expect("apply blocking succeeds");
1041        let after = handle.render_state();
1042        assert!(
1043            !Arc::ptr_eq(&before, &after),
1044            "apply_blocking must publish a fresh RS before returning"
1045        );
1046    }
1047
1048    /// Slice 3c.final.E: `mutate_blocking` runs the closure and
1049    /// fires `publish_render_state`. The mutation is visible
1050    /// through the published RS after the call returns.
1051    #[test]
1052    fn actor_mutate_blocking_applies_closure_and_publishes() {
1053        use lattice_protocol::position::Position;
1054        let editor = Editor::default();
1055        let handle = spawn_editor_actor(editor);
1056        handle
1057            .mutate_blocking(Box::new(|e| {
1058                e.cursor = Position::new(9, 4);
1059                e.scroll = 7;
1060            }))
1061            .expect("mutate blocking succeeds");
1062        let rs = handle.render_state();
1063        assert_eq!(rs.active_document.load().cursor, Position::new(9, 4));
1064        assert_eq!(rs.active_document.load().scroll, 7);
1065    }
1066
1067    /// Slice 3c.final.E: `mutate_async` is fire-and-forget; a
1068    /// subsequent `apply_blocking` barrier ensures the mutation
1069    /// has landed before the assertion.
1070    #[test]
1071    fn actor_mutate_async_applies_when_drained() {
1072        use lattice_protocol::position::Position;
1073        let editor = Editor::default();
1074        let handle = spawn_editor_actor(editor);
1075        handle
1076            .mutate_async(Box::new(|e| e.cursor = Position::new(2, 1)))
1077            .expect("mutate async succeeds");
1078        // Barrier: serial command processing means this returns
1079        // only after the prior Mutate runs.
1080        handle
1081            .apply_blocking(Action::None)
1082            .expect("apply blocking barrier");
1083        assert_eq!(
1084            handle.render_state().active_document.load().cursor,
1085            Position::new(2, 1)
1086        );
1087    }
1088
1089    #[test]
1090    fn actor_set_viewport_height_clamps_and_publishes() {
1091        let editor = Editor::default();
1092        let handle = spawn_editor_actor(editor);
1093        // 0 must clamp to 1 -- mirror App-side wrapper.
1094        handle.set_viewport_height(0).expect("send vh=0");
1095        await_actor(&handle);
1096        assert_eq!(
1097            handle.render_state().active_document.load().viewport_height,
1098            1
1099        );
1100
1101        handle.set_viewport_height(24).expect("send vh=24");
1102        await_actor(&handle);
1103        assert_eq!(
1104            handle.render_state().active_document.load().viewport_height,
1105            24
1106        );
1107    }
1108}