lattice_ui_tui/runtime.rs
1//! Terminal IO loop. Sets up raw mode + alt screen, draws frames, polls
2//! events, restores terminal state on exit.
3//!
4//! This is the only file in the crate that talks to the terminal directly.
5//! Everything else is pure and unit-tested.
6
7use std::io::Stdout;
8use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
9use std::sync::mpsc;
10use std::thread;
11use std::time::{Duration, Instant};
12
13use anyhow::{Context, Result};
14use crossterm::cursor::SetCursorStyle;
15use crossterm::event::{
16 self, DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture,
17 Event, KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
18};
19use crossterm::execute;
20use crossterm::terminal::{
21 EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
22 supports_keyboard_enhancement,
23};
24use lattice_grammar::ModalState;
25use ratatui::Terminal;
26use ratatui::backend::CrosstermBackend;
27
28use lattice_core::Document;
29
30use crate::app::{Action, App};
31use crate::input::{TranslateContext, translate};
32use crate::render::draw_frame;
33
34/// Perf instrumentation (paired with `LATTICE_PERF_INPUT`): counts bytes
35/// written to the terminal so the input timer can report **per-frame write
36/// volume**. This is the real driver of terminal present time on a slow pty:
37/// `terminal.draw()` returns as soon as the diff lands in the pty buffer, but
38/// the terminal emulator then spends time parsing + presenting those bytes —
39/// time our `input→glyph` timer can't see. vim writes a few bytes per
40/// keystroke; if we write kilobytes (a whole-viewport rewrite) the same
41/// terminal that renders vim instantly will visibly lag on us. Reset before
42/// each `draw()`, read after.
43static PERF_FRAME_BYTES: AtomicU64 = AtomicU64::new(0);
44
45/// Wraps the terminal writer and tallies bytes into [`PERF_FRAME_BYTES`].
46/// Forwards everything to the inner writer; the relaxed atomic add is
47/// negligible. Always wired (so the byte count is available whenever
48/// `LATTICE_PERF_INPUT` is set) — the only cost when the env is unset is the
49/// add itself, which is in the noise next to the syscall it accompanies.
50struct CountingWriter<W> {
51 inner: W,
52}
53
54impl<W: std::io::Write> std::io::Write for CountingWriter<W> {
55 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
56 let n = self.inner.write(buf)?;
57 PERF_FRAME_BYTES.fetch_add(n as u64, Ordering::Relaxed);
58 Ok(n)
59 }
60
61 fn flush(&mut self) -> std::io::Result<()> {
62 self.inner.flush()
63 }
64}
65
66/// The concrete terminal backend type, now wrapped in the byte counter.
67type TermBackend = CrosstermBackend<CountingWriter<Stdout>>;
68
69/// The chord table a modal state resolves against — the same mapping the host
70/// uses. Needed by the AP.0.2 fall-through to ask the trie which layer bound a
71/// declined chord.
72pub fn run(document: Document, startup_lesson: Option<u32>) -> Result<()> {
73 let mut terminal = setup().context("setup terminal")?;
74 let mut app = App::new(document);
75 // Load persistent config (TOML) before LSP attach work so
76 // any overrides that affect LSP behaviour land before the
77 // attach driver processes the initial document's
78 // `Event::DocumentOpened`. Workspace root is the CWD
79 // walked up to the first `.git` / `.lattice/` marker (or
80 // the CWD itself if neither is found). Failures are
81 // surfaced via the App's echo, never abort startup.
82 // Phase 5.8.AA.u: workspace-root discovery + persistent-config
83 // loading both live on the host so GPUI gets the same boot
84 // behaviour. The TUI runtime is now a thin wrapper.
85 let workspace_root = lattice_core::project::root_from_cwd();
86 app.load_persistent_config(workspace_root.as_deref());
87 app.apply_per_language_toml_overrides();
88 // OS.1: only now is `ui.keyboard_enhancement` real -- `setup()` ran before
89 // the TOML was read, so asking there would always answer the default.
90 let keyboard_enhanced = push_keyboard_enhancement(
91 app.options()
92 .config
93 .get_typed::<lattice_host::ui::theme_options::UiKeyboardEnhancement>()
94 .map(|v| *v)
95 .unwrap_or(true),
96 );
97 // built-ins 2026-06-13: load embedded + user snippet packs once
98 // at startup (after config so `snippet_dirs` overrides apply).
99 // Quiet — logs, no echo.
100 app.load_snippets_at_startup();
101 // T.5: `--tutor [N]` opens the tutor buffer before the first draw.
102 if let Some(n) = startup_lesson {
103 app.open_tutor(n);
104 }
105 // LSP attach is event-driven: `App::new` already
106 // published `Event::DocumentOpened` for the initial
107 // document (if path-bearing). The attach driver wired in
108 // `build_lsp_subsystem` runs on the LSP runtime and
109 // submits the open to the supervisor *off* the UI thread.
110 // The first frame can draw immediately without waiting on
111 // the LSP `initialize` round-trip -- paramount goal #4
112 // (asynchronicity).
113 let result = main_loop(&mut terminal, app);
114 teardown(&mut terminal, keyboard_enhanced).context("teardown terminal")?;
115 result
116}
117
118// Phase 5.8.AA.u hoisted this out of the TUI runtime onto the host;
119// PR.2 moved it again, to `lattice_core::project::root_from_cwd` —
120// so both renderers and every other consumer share one rule about
121// where a project begins.
122
123// Phase 5.5.LSP.1: the shared LSP runtime + spawn helper now
124// live in `lattice_runtime::runtime` so host-side dispatchers
125// can fire LSP requests without taking a back-edge through this
126// (renderer-specific) crate. Re-exported here so the existing
127// `crate::runtime::*` call sites (34 inside `App`) keep
128// compiling unchanged. Both names point at the single
129// `lattice_runtime::LSP_RUNTIME` OnceLock -- no behaviour change.
130// Phase 5.8.AD.2: `lsp_runtime` re-export retired -- last
131// caller (`do_lsp_restart`) migrated host-side. `spawn_on_lsp_runtime`
132// is still publicly re-exported for the few App-resident async
133// helpers that haven't migrated yet.
134pub use lattice_runtime::runtime::spawn_on_lsp_runtime;
135
136fn setup() -> Result<Terminal<TermBackend>> {
137 enable_raw_mode().context("enable raw mode")?;
138 let mut stdout = std::io::stdout();
139 execute!(stdout, EnterAlternateScreen).context("enter alt screen")?;
140 // Bracketed paste tells the terminal to wrap clipboard pastes in
141 // ESC[200~ ... ESC[201~ markers. Crossterm decodes those as
142 // `Event::Paste(String)`, which we hand to the App as a single
143 // bracketed-paste burst (one undo unit). Without this, terminals
144 // that bind Ctrl+V to clipboard paste (Konsole, Windows Terminal,
145 // tmux configs) replay the clipboard contents as a stream of
146 // raw key events -- which Normal mode then interprets as commands
147 // and the user gets unexpected behaviour instead of a paste.
148 execute!(stdout, EnableBracketedPaste).context("enable bracketed paste")?;
149 let backend = CrosstermBackend::new(CountingWriter { inner: stdout });
150 Terminal::new(backend).context("create terminal")
151}
152
153/// OS.1: should the keyboard-enhancement protocol be pushed?
154///
155/// Both conditions, extracted so the decision is testable without a tty —
156/// which is the only part of this slice a test can reach.
157fn should_push_enhancement(option: bool, supported: bool) -> bool {
158 option && supported
159}
160
161/// OS.1: ask the terminal to disambiguate `<S-CR>` / `<C-CR>` / `<M-S-CR>`
162/// from a bare `<CR>`.
163///
164/// Without this a terminal sends the same `\r` for all four, so those chords
165/// are unreachable however they are bound. `lattice-protocol` has always
166/// spelled them and the GPUI peer has always delivered them; this closes the
167/// renderer asymmetry rather than adding a capability.
168///
169/// `DISAMBIGUATE_ESCAPE_CODES` only. `REPORT_ALL_KEYS_AS_ESCAPE_CODES` would
170/// route ordinary text input through the escape path, which is not wanted.
171///
172/// **Called after the persistent config loads, not from [`setup`].** `setup`
173/// runs before `load_persistent_config`, so an option read there would always
174/// see the compiled-in default and `:set ui.keyboard_enhancement=off` in a
175/// user's TOML would be silently ignored. Nothing reads a key between the two
176/// points, so pushing here costs nothing.
177///
178/// Returns whether the push actually happened, so teardown pops exactly what
179/// was pushed. A terminal that advertises support and then refuses must not
180/// fail startup: log and carry on unenhanced.
181fn push_keyboard_enhancement(option: bool) -> bool {
182 if !should_push_enhancement(option, supports_keyboard_enhancement().unwrap_or(false)) {
183 return false;
184 }
185 let mut stdout = std::io::stdout();
186 match execute!(
187 stdout,
188 PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES)
189 ) {
190 Ok(()) => true,
191 Err(e) => {
192 tracing::debug!(error = %e, "terminal refused the keyboard-enhancement push");
193 false
194 }
195 }
196}
197
198/// MO.1: turn terminal mouse reporting on or off, following `ui.mouse`.
199///
200/// Kept out of [`setup`] because it is the one terminal capability the
201/// editor asks for that **takes something away**: while capture is on,
202/// the terminal emulator stops seeing the mouse, so click-drag text
203/// selection and middle-click paste no longer work (some terminals
204/// offer Shift-drag as an escape hatch; not all do). That is why the
205/// option defaults off and why this is toggleable at runtime rather
206/// than decided once at startup — `:set ui.mouse` takes effect on the
207/// next loop iteration, and the user can turn it back off the moment
208/// they want to copy something.
209///
210/// Failures are logged and swallowed: a terminal that rejects the
211/// escape sequence should leave the editor running without mouse
212/// support, not refuse to start.
213fn set_mouse_capture(on: bool) {
214 let mut stdout = std::io::stdout();
215 let result = if on {
216 execute!(stdout, EnableMouseCapture)
217 } else {
218 execute!(stdout, DisableMouseCapture)
219 };
220 if let Err(e) = result {
221 tracing::debug!(error = %e, enable = on, "terminal rejected mouse-capture toggle");
222 }
223}
224
225fn teardown(terminal: &mut Terminal<TermBackend>, keyboard_enhanced: bool) -> Result<()> {
226 // Unconditional, and harmless when capture was never enabled: the
227 // sequence is a no-op for a terminal that is not reporting. Leaving
228 // a terminal in reporting mode after exit is the failure worth
229 // avoiding — the user's shell would receive mouse escape codes as
230 // garbage input.
231 execute!(terminal.backend_mut(), DisableMouseCapture)
232 .context("disable mouse capture")
233 .unwrap_or_else(|e| tracing::debug!(error = %e, "disable mouse capture"));
234 // Restore the user's default cursor shape before tearing down the
235 // alt screen -- otherwise the shell prompt inherits whatever the
236 // editor was rendering.
237 execute!(terminal.backend_mut(), SetCursorStyle::DefaultUserShape)
238 .context("restore cursor style")?;
239 // OS.1: pop exactly what was pushed, and before raw mode goes away. A
240 // terminal left in enhancement mode hands the user's shell disambiguated
241 // key encodings, which is a worse failure than the one the push fixes --
242 // so this is best-effort-logged rather than `?`, like the mouse restore
243 // above: one refused sequence must not skip the restores below it.
244 if keyboard_enhanced {
245 execute!(terminal.backend_mut(), PopKeyboardEnhancementFlags)
246 .unwrap_or_else(|e| tracing::debug!(error = %e, "pop keyboard enhancement"));
247 }
248 execute!(terminal.backend_mut(), DisableBracketedPaste).context("disable bracketed paste")?;
249 disable_raw_mode().context("disable raw mode")?;
250 execute!(terminal.backend_mut(), LeaveAlternateScreen).context("leave alt screen")?;
251 terminal.show_cursor().context("show cursor")?;
252 Ok(())
253}
254
255/// Map the App's modal state to a terminal cursor shape via the
256/// renderer-neutral `host::cursor_shape::CursorShape`. The vim
257/// convention (Block for command-language modes, Bar for Insert /
258/// Command-line, Underscore for Replace) lives once on the host
259/// (5.8.N); this peer just maps to crossterm's primitive.
260///
261/// Terminal-mode T2.b (2026-05-25): when the active buffer is a
262/// Terminal AND `terminal-insert-mode` is active, the shape
263/// flips to `SteadyBar` even though `ModalState` stays `Normal`
264/// (TerminalInsert is a minor mode, not a separate modal
265/// variant). The Normal-in-terminal state keeps `SteadyBlock`.
266fn cursor_style_for(modal: ModalState, terminal_insert_active: bool) -> SetCursorStyle {
267 use lattice_host::cursor_shape::CursorShape;
268 if terminal_insert_active {
269 return SetCursorStyle::SteadyBar;
270 }
271 match CursorShape::for_mode(modal) {
272 CursorShape::Block => SetCursorStyle::SteadyBlock,
273 CursorShape::Bar => SetCursorStyle::SteadyBar,
274 CursorShape::Underline => SetCursorStyle::SteadyUnderScore,
275 }
276}
277
278/// I.3 (event-driven wake): a reason the main loop woke. The loop blocks on a
279/// single channel of these instead of polling the terminal on a 100ms timer.
280/// `Input` carries a decoded terminal event from the dedicated reader thread;
281/// `Repaint` is forwarded from the actor's `paint_request` `Notify` (async
282/// syntax / LSP / worker republishes) so a background republish reaches the
283/// screen promptly without the loop spinning. See
284/// `docs/dev/operations/slice-plans/input-latency.md` I.3.
285enum Wake {
286 Input(Event),
287 Repaint,
288}
289
290/// Apply one decoded terminal event to the app. Mirrors the per-event arm the
291/// pre-I.3 inline drain ran: keys translate through the published `translator`
292/// substate (no `&Editor` borrow); a bracketed paste is one edit; a resize
293/// defers to the next iteration's viewport setup. Sets `last_input_at` only for
294/// events that produced fresh input, so the `LATTICE_PERF_INPUT` timer
295/// attributes the next draw to a keystroke and not to an async repaint.
296fn apply_event(app: &mut App, ev: Event, perf_input: bool, last_input_at: &mut Option<Instant>) {
297 match ev {
298 Event::Key(k) => {
299 // Esc dismisses an open floating popup (hover / signature /
300 // `:describe-*`) even when the DOCUMENT keeps focus (State A) —
301 // mirrors the GPUI peer's `on_key_down` gate (window.rs). Without
302 // this, Esc only closed the popup once focus had moved into it
303 // (State B) or on cursor motion (HoverMode auto-dismiss); plain
304 // `<Esc>` over the document did nothing. Gated to State A
305 // (`buffer_kind != Help`) so a FOCUSED popup (State B) keeps the
306 // normal Esc path, where in-popup sub-modes (search, …) can
307 // cancel first before the popup closes.
308 if matches!(k.code, crossterm::event::KeyCode::Esc)
309 && app.popup().is_open()
310 && !app.ad().popup_focused
311 {
312 app.dismiss_popup();
313 if perf_input {
314 *last_input_at = Some(Instant::now());
315 }
316 return;
317 }
318 // Slice 3c.final.B (group 5): translator inputs read through
319 // `rs.translator` instead of `&app.editor.{builtins,keymap,
320 // partial_chord}`. The Arc-bound substate keeps the borrows valid
321 // for the translate call without tying them to `Editor`'s lifetime.
322 let ad = app.ad();
323 let translator = app.render_state.load().translator.clone();
324 let ctx = TranslateContext {
325 modal: ad.modal,
326 builtins: &translator.builtins,
327 pending_count: ad.pending_count,
328 op_count: ad.op_count,
329 recording_macro: ad.macro_recording,
330 active_buffer: ad.buffer_kind,
331 completion_open: ad.completion_open,
332 chord_capture: app.chord_capture_active(),
333 picker_open: ad.picker_open,
334 insert_completion_open: app.completion_popup_active(),
335 snippet_active: ad.snippet_active,
336 terminal_insert_active: ad.terminal_insert_active,
337 terminal_esc_exits: ad.terminal_esc_exits,
338 terminal_app_cursor_keys: ad.terminal_app_cursor_keys,
339 terminal_insert_exit_pending: ad.terminal_insert_exit_pending,
340 terminal_visual_active: ad.terminal_visual_active,
341 keymap: &translator.keymap,
342 partial_chord: &translator.partial_chord,
343 active_minor_modes: &translator.active_minor_modes,
344 };
345 // AP.0.2: the inputs the fall-through has to reuse, captured
346 // BEFORE `apply` mutates the partial-chord state. Re-resolving with
347 // an empty prefix resolves the trailing key alone, which is how a
348 // declined `<leader>oJ` ran vim's `J` and joined two lines.
349 let prefix_before: Vec<lattice_protocol::KeyChord> = translator.partial_chord.to_vec();
350 let layers_before: Vec<lattice_mode::ModeId> = translator.active_minor_modes.to_vec();
351 let action = translate(ctx, k);
352 if app.apply(action) {
353 // AP.0.2 fall-through (TUI live path): the resolved binding
354 // was a plugin grammar action that DECLINED — it did nothing,
355 // so re-resolve with the DECLINING LAYER REMOVED, peeling one
356 // layer per decline until something handles the key or only the
357 // always-on Builtin / User layers are left.
358 //
359 // Mirrors the host `dispatch_chord` peel exactly (GPUI rides
360 // that path). Dropping every mode layer at once — which is what
361 // this did — means a second declining layer never sees the
362 // chord, so org-table-mode's `<Tab>` skipped org-mode's fold
363 // cycle and hit the builtin. The TUI's translate→apply path
364 // lost the chord by dispatch time, so the re-translate happens
365 // here where `k` is still in hand.
366 // `k` is still the crossterm event; the trie speaks `KeyChord`.
367 let Some(k_chord) = crate::chord::from_event(&k) else {
368 return;
369 };
370 // The walk is `lattice_host::decline::DeclinePeel`'s, not this
371 // loop's. It was this loop's, and the host dispatcher had a
372 // second copy, and the GPUI peer had none — which is how every
373 // key auto-pair binds came to be dead in that renderer while
374 // this one was fine.
375 let mut peel = lattice_host::decline::DeclinePeel::new(
376 app.ad().modal,
377 prefix_before.clone(),
378 k_chord,
379 layers_before.clone(),
380 );
381 loop {
382 let ad = app.ad();
383 let translator = app.render_state.load().translator.clone();
384 // `None` ⇒ the winner came from Builtin / User, which
385 // cannot decline: nothing left to peel.
386 let Some(layers) = peel
387 .peel(&translator.keymap)
388 .map(<[lattice_mode::ModeId]>::to_vec)
389 else {
390 break;
391 };
392 let ctx = TranslateContext {
393 modal: ad.modal,
394 builtins: &translator.builtins,
395 pending_count: ad.pending_count,
396 op_count: ad.op_count,
397 recording_macro: ad.macro_recording,
398 active_buffer: ad.buffer_kind,
399 completion_open: ad.completion_open,
400 chord_capture: app.chord_capture_active(),
401 picker_open: ad.picker_open,
402 insert_completion_open: app.completion_popup_active(),
403 snippet_active: ad.snippet_active,
404 terminal_insert_active: ad.terminal_insert_active,
405 terminal_esc_exits: ad.terminal_esc_exits,
406 terminal_app_cursor_keys: ad.terminal_app_cursor_keys,
407 terminal_insert_exit_pending: ad.terminal_insert_exit_pending,
408 terminal_visual_active: ad.terminal_visual_active,
409 keymap: &translator.keymap,
410 partial_chord: &prefix_before,
411 active_minor_modes: &layers,
412 };
413 let fallthrough = translate(ctx, k);
414 let declined_again = app.apply(fallthrough);
415 if !declined_again || peel.exhausted() {
416 break;
417 }
418 }
419 }
420 if perf_input {
421 *last_input_at = Some(Instant::now());
422 }
423 }
424 Event::Paste(text) => {
425 // Real bracketed-paste burst from the terminal's clipboard
426 // shortcut. Hand the payload to the app as a single edit; Ctrl+V
427 // keystrokes (the binding for blockwise visual) still arrive as
428 // `Event::Key` because they're not the terminal's paste path.
429 app.apply(Action::PasteText(text));
430 if perf_input {
431 *last_input_at = Some(Instant::now());
432 }
433 }
434 Event::Resize(_, _) => {
435 // next iteration's top-of-loop setup reads the new size
436 }
437 // ML.4: a left-click on a modeline element dispatches the
438 // element's declared command. Mouse events only arrive at all
439 // when `ui.mouse` is on (MO.1), so no gate is needed here.
440 //
441 // The host is a router and nothing more: the handler body lives
442 // in the mode or plugin that registered the element, reached
443 // through the same `CommandInvocation` path a keystroke takes
444 // (modeline.md §6, §9). No per-element handler, and no new
445 // `Action` variant.
446 Event::Mouse(m) => {
447 use crossterm::event::{MouseButton, MouseEventKind};
448 // The modeline claims a left-press before the body sees it:
449 // its zones sit on the pane's status row, which is outside
450 // every `PaneHitZone` (those cover `content_rect` only), so
451 // the two maps cannot both answer — but asking the modeline
452 // first keeps that a property of the code and not only of
453 // the geometry.
454 let modeline_hit = if matches!(m.kind, MouseEventKind::Down(MouseButton::Left)) {
455 // Bound out of the `if` so the `RefCell` borrow ends
456 // before `app.apply` takes `&mut app`.
457 app.modeline_hits.borrow().hit(m.column, m.row)
458 } else {
459 None
460 };
461 if let Some(command) = modeline_hit {
462 app.apply(Action::Invoke(lattice_grammar::CommandInvocation::of(
463 command,
464 )));
465 if perf_input {
466 *last_input_at = Some(Instant::now());
467 }
468 return;
469 }
470 // MO.2: the editor body. `resolve` answers `None` for a cell
471 // no pane painted (the `:` line, a status row, the gap
472 // between splits), which is how those stay inert.
473 let resolved = app.pane_hits.borrow().resolve(m.column, m.row);
474 let Some(hit) = resolved else {
475 return;
476 };
477 let action = match m.kind {
478 // The wheel scrolls the pane under the POINTER and does
479 // not focus it — vim's `mousescroll`, Zed and Helix all
480 // agree, and it is what makes a wheel over a reference
481 // split usable mid-edit.
482 MouseEventKind::ScrollDown => Some(Action::MouseScroll {
483 pane: hit.zone.pane_id,
484 down: true,
485 }),
486 MouseEventKind::ScrollUp => Some(Action::MouseScroll {
487 pane: hit.zone.pane_id,
488 down: false,
489 }),
490 // A press positions the caret; dragging with the button
491 // held extends from where the press landed. `Drag` is
492 // reported only while a button is down, so the two share
493 // one resolver and differ by `extend` alone.
494 MouseEventKind::Down(MouseButton::Left) => mouse_goto(app, &hit, false),
495 MouseEventKind::Drag(MouseButton::Left) => mouse_goto(app, &hit, true),
496 _ => None,
497 };
498 if let Some(action) = action {
499 app.apply(action);
500 if perf_input {
501 *last_input_at = Some(Instant::now());
502 }
503 }
504 }
505 _ => {}
506 }
507}
508
509/// MO.2: turn a resolved cell into a buffer position.
510///
511/// The inverse of the compose loop, and derived from it rather than
512/// re-implemented: the row's origin was **recorded while painting**
513/// (`PaneHitMap::set_rows`), and the column goes back through
514/// `lattice_cells::display_col_to_source_byte`, the binary-search
515/// inverse of the very map the caret is drawn with. That is what makes
516/// a click land exactly where the caret would be — including under soft
517/// wrap, inlay hints and conceal, none of which this function knows
518/// anything about individually.
519///
520/// `None` when the cell is on the gutter or on a row mirroring no source
521/// line, which is how those stay inert rather than resolving to
522/// column 0 of something.
523fn mouse_goto(app: &App, hit: &lattice_host::mouse::BodyHit, extend: bool) -> Option<Action> {
524 let body_width = hit.zone.width.saturating_sub(hit.zone.text_left).max(1) as u32;
525 let logical_col = hit.logical_col(body_width)?;
526 let origin = hit.origin?;
527
528 let handle = app.buffers().registry.document_handle(hit.zone.buffer_id)?;
529 let snapshot = handle.snapshot();
530 let line_text = snapshot.buffer.line(origin.source_line).unwrap_or_default();
531
532 // The display row carries the tables the forward map used: inlay
533 // splices and conceal ranges for THIS line. Absent (matrix not built
534 // yet for a just-opened buffer) means no splices, which is the
535 // identity — a click still lands, just without corrections nothing
536 // has applied yet either.
537 let matrix = app
538 .render_state
539 .load()
540 .cells
541 .load_full()
542 .display_matrix_for_pane(hit.zone.pane_id)
543 .map(|cell| cell.load_full());
544 let row = matrix
545 .as_ref()
546 .and_then(|m| m.row_at_source_line(origin.source_line));
547
548 // Char columns, not bytes — the space the cell substrate's column
549 // tables live in (see the byte-vs-char note on
550 // `CellRow::byte_to_combined_col`). The conversion back to a byte
551 // offset is this function's job, and it is why `max_source_byte` is
552 // a char count.
553 let char_len = line_text.chars().count() as u32;
554 let char_col = match row {
555 Some(r) => lattice_cells::display_col_to_source_byte(
556 logical_col,
557 char_len,
558 &r.col_map,
559 &r.conceals,
560 ),
561 None => logical_col.min(char_len),
562 };
563 let byte = line_text
564 .char_indices()
565 .nth(char_col as usize)
566 .map(|(b, _)| b as u32)
567 .unwrap_or(line_text.len() as u32);
568
569 Some(Action::MouseGoto {
570 pane: hit.zone.pane_id,
571 line: origin.source_line,
572 byte,
573 extend,
574 })
575}
576
577/// Drain a batch of wakes: the `first` one that unblocked the loop plus every
578/// other wake already buffered (zero-wait `try_recv`). Input events apply and
579/// coalesce — a typing burst of N queued keys becomes N applies + ONE draw (the
580/// keystroke UX contract: the displayed text never trails the keystrokes).
581/// Stops the instant a quit lands so later buffered keys aren't applied against
582/// a tearing-down app. `Repaint` wakes apply nothing — they exist purely to
583/// trigger the single redraw at the loop top so an async republish (syntax
584/// recolour, LSP decoration) reaches the screen.
585///
586/// Returns `true` when the batch contained at least one `Repaint` and NO input
587/// events. The caller uses this to call `drain_async_pending` so idle LSP
588/// arrivals (hover, signature-help) reach the screen on the repaint itself
589/// rather than waiting for the next keystroke (X1b).
590fn drain_wakes(
591 app: &mut App,
592 rx: &mpsc::Receiver<Wake>,
593 first: Wake,
594 perf_input: bool,
595 last_input_at: &mut Option<Instant>,
596) -> bool {
597 let mut had_input = false;
598 let mut next = Some(first);
599 while let Some(wake) = next.take() {
600 if let Wake::Input(ev) = wake {
601 had_input = true;
602 apply_event(app, ev, perf_input, last_input_at);
603 }
604 if app.render_state.load().lifecycle.should_quit {
605 return false;
606 }
607 next = rx.try_recv().ok();
608 }
609 !had_input
610}
611
612fn main_loop(terminal: &mut Terminal<TermBackend>, mut app: App) -> Result<()> {
613 // Terminal-mode T2.b (2026-05-25): cursor-style cache now keys
614 // off `(modal, terminal_insert_active)` since TerminalInsert is
615 // a minor mode that doesn't flip `ModalState`. Without the
616 // minor-mode bit in the cache key, entering / leaving
617 // Terminal-Insert wouldn't re-push the cursor style and the
618 // user would see a stale block where a bar belongs.
619 let mut last_cursor_inputs: Option<(ModalState, bool)> = None;
620 // Diff cache for the per-frame terminal-width dispatch (see loop body):
621 // the width only changes on a resize, so we dispatch only on change
622 // rather than every frame.
623 let mut last_terminal_width: Option<u16> = None;
624 // Diff cache for the per-frame viewport-height push. `set_viewport_height`
625 // was dispatched UNCONDITIONALLY every iteration → a publish (+ both worker
626 // wakes) per loop tick even when nothing changed — the idle/per-keystroke
627 // publish storm. Push only when the resolved height actually changes.
628 let mut last_viewport_height: Option<u32> = None;
629 // PU.1b-3: diff cache for the floating-popup inner-geometry hand-off
630 // (mirror of `last_viewport_height` for the synthetic popup pane).
631 // `None` when no floating popup is open; `Some((rows, cols))` carries
632 // the popup's inner rect. Push only on change so a steady-state popup
633 // costs zero actor RPCs per frame.
634 let mut last_popup_dims: Option<(u32, u32)> = None;
635 let mut last_band_dims: Option<(u32, u32)> = None;
636 // PU.5c: diff-then-send cache for the completion-docs popup geometry.
637 let mut last_completion_docs_dims: Option<(u32, u32)> = None;
638 // Opt-in keystroke→glyph timer (set env `LATTICE_PERF_INPUT=1`). Measures
639 // OUR input-to-draw latency only — from the instant an input event was
640 // applied to the instant `terminal.draw()` returns (i.e. we've written the
641 // frame's diff to stdout). It does NOT include the terminal emulator /
642 // pty / compositor present time, which on WSL2 is outside our process.
643 // So: a tiny number here + felt lag ⇒ the lag is the WSL2 terminal, not us;
644 // a large number here ⇒ the lag is our draw. Writes one clean line per
645 // rendered keystroke straight to stderr (bypasses tracing, so it does not
646 // add to the debug-log flood). Off by default — zero cost when unset.
647 // Emit the perf line ONLY when stderr is REDIRECTED (not the
648 // alternate-screen terminal). In the TUI, stderr is the same tty as the
649 // rendered display, so a raw `eprintln!` corrupts it: ratatui's diff
650 // can't repair an out-of-band write, and each line's newline scrolls the
651 // alt-screen — leaving fragments of the previous frame (e.g. preview
652 // line-tails) on screen until a force redraw. Require `2>file`.
653 use std::io::IsTerminal;
654 let perf_env = std::env::var_os("LATTICE_PERF_INPUT").is_some();
655 let stderr_redirected = !std::io::stderr().is_terminal();
656 if perf_env && !stderr_redirected {
657 tracing::info!(
658 "LATTICE_PERF_INPUT is set but stderr is the terminal — perf lines are \
659 suppressed to avoid corrupting the TUI display. Re-run with `2>perf.log` \
660 (or any redirect) to capture them."
661 );
662 }
663 let perf_input = perf_env && stderr_redirected;
664 let mut last_input_at: Option<Instant> = None;
665 // Tracks the active buffer + its line count across frames so a buffer
666 // switch that SHRINKS the content can force a full repaint (see the
667 // clear in the draw loop).
668 let mut last_active_buffer: Option<(crate::buffers::BufferId, usize)> = None;
669
670 // I.3 (event-driven wake): replace the 100ms terminal poll with a wake on
671 // (input-ready OR actor-publish). A dedicated reader thread owns terminal
672 // events and forwards each as `Wake::Input`; a tiny task on the shared LSP
673 // runtime forwards the actor's `paint_request` `Notify` (async syntax / LSP
674 // / worker republishes) as `Wake::Repaint`. The loop blocks on a single
675 // channel of `Wake`s, so it draws exactly when something changed and stays
676 // fully idle (zero draws, zero CPU) otherwise — the up-to-100ms async-
677 // repaint lag is gone. The reader polls a stop flag on a 100ms tick (NOT a
678 // redraw timer — it never wakes the loop on its own) purely so it tears
679 // down cleanly on quit. See input-latency.md I.3.
680 let (wake_tx, wake_rx) = mpsc::channel::<Wake>();
681 let reader_stop = std::sync::Arc::new(AtomicBool::new(false));
682 let reader_handle = {
683 let tx = wake_tx.clone();
684 let stop = reader_stop.clone();
685 thread::Builder::new()
686 .name("lattice-tui-input".into())
687 .spawn(move || {
688 while !stop.load(Ordering::Relaxed) {
689 // Block up to 100ms for an event; on timeout, loop back to
690 // re-check the stop flag. `poll` returns the instant an
691 // event is ready, so input latency is NOT capped at 100ms.
692 match event::poll(Duration::from_millis(100)) {
693 Ok(true) => match event::read() {
694 Ok(ev) => {
695 if tx.send(Wake::Input(ev)).is_err() {
696 break; // main loop dropped the receiver
697 }
698 }
699 Err(_) => break,
700 },
701 Ok(false) => {}
702 Err(_) => break,
703 }
704 }
705 })
706 .expect("spawn input reader thread")
707 };
708 // Forward async `paint_request` notifications onto the same wake channel.
709 // The actor's workers (syntax highlights, cells, virtual-rows) call
710 // `paint_request.notify_one()` after publishing; `Notify` is permit-style,
711 // so a notify that arrives before this re-awaits is not lost. The task
712 // exits when the main loop drops the receiver (`send` errors).
713 {
714 let tx = wake_tx.clone();
715 // App holds the actor handle in production (`cfg(not(test))`) and the
716 // Editor directly in test builds (3c.final.E.swap); both expose the
717 // same shared `paint_request` Notify.
718 #[cfg(not(test))]
719 let paint_request = app.editor_actor.paint_request();
720 #[cfg(test)]
721 let paint_request = app.editor.paint_request.clone();
722 spawn_on_lsp_runtime(async move {
723 loop {
724 paint_request.notified().await;
725 if tx.send(Wake::Repaint).is_err() {
726 break;
727 }
728 }
729 });
730 }
731 // The main loop owns only the receiver; the two producer clones above keep
732 // the channel alive until both the reader thread and the bridge task exit.
733 drop(wake_tx);
734
735 // Slice 3c.final.B (group 6): lifecycle read via published
736 // substate. `should_quit` flips from inside dispatch (`:q`,
737 // `:wq`, `:qa!`) which republishes at its tail, so the next
738 // iteration's load sees the new value.
739 // Slice 3c.final.E.4: read through App-cached `render_state`.
740 // MO.1: mirrors `ui.mouse` into the terminal's reporting state.
741 // Checked once per iteration (an O(1) typed-option read, not work
742 // proportional to content) and acted on only when it *changes*, so
743 // `:set ui.mouse` takes effect without a restart while the steady
744 // state costs one lookup and no escape sequences.
745 let mut mouse_capture = false;
746 while !app.render_state.load().lifecycle.should_quit {
747 let want_mouse = app
748 .render_state
749 .load()
750 .options
751 .config
752 .get_typed::<lattice_config::MouseEnabled>()
753 .map(|a| *a)
754 .unwrap_or(false);
755 if want_mouse != mouse_capture {
756 set_mouse_capture(want_mouse);
757 mouse_capture = want_mouse;
758 }
759 // Phase 5.8.AF.5 / Slice X1: `run_tick_pending` no longer
760 // runs per-frame here. It moved to `App::apply`'s tail
761 // (`crates/lattice-ui-tui/src/app/dispatch.rs`) so the
762 // drain happens on the keystroke that caused the work,
763 // not in the renderer's per-frame body. Per paramount
764 // goal #1, the UI thread does no I/O / event drain; the
765 // ~30-channel aggregator we used to call here is exactly
766 // the kind of work the spec forbids on this thread.
767 //
768 // Idle LSP arrivals (a response with no keystroke in flight)
769 // drain off-keystroke via the editor actor's `async_landed`
770 // arm, NOT here: every async LSP result fires `async_landed`
771 // (direct-write caches, event-bus forwarders, and — since slice
772 // AW.1 — the channel-delivered action results: gr/gd/K/code-
773 // actions/rename/format/…). The actor runs `run_tick_pending` +
774 // publish + `paint_request` on that wake, which lands here as a
775 // `Wake::Repaint`. See `docs/dev/architecture/lsp-architecture.md`
776 // §12 ("Async-result render-wake").
777 // Update viewport height. The buffer-area band is the
778 // terminal minus the mode line + cmdline/echo row (and the
779 // candidate-list row band, when a picker / completion popup
780 // is up). Within that band, the active *pane* gets only its
781 // share -- horizontal/vertical splits divide the area, and
782 // multi-pane layouts reserve the bottom row of each pane
783 // for its status line. The viewport must reflect the active
784 // pane's content height, not the full buffer band, so
785 // motions / scroll / cursor visibility agree with what the
786 // renderer actually paints.
787 let size = terminal.size().context("query terminal size")?;
788 // `chrome_rows` is the single source of truth for tabline +
789 // picker/completion candidate-band rows, shared with
790 // `draw_frame`'s paint layout (see its doc comment for the
791 // regression this closes: this loop used to hand-duplicate the
792 // candidate-band formula and omit the tabline row entirely, so
793 // the viewport logic believed it had one more row than
794 // `draw_frame` actually painted once the tabline showed).
795 let chrome = crate::render::chrome_rows(&app);
796 // Option A: global modeline removed; each pane has its own
797 // 1-row status footer. Subtract cmdline (1), the tabline (0/1),
798 // and any picker/completion candidate band.
799 let buffer_height = size
800 .height
801 .saturating_sub(1)
802 .saturating_sub(chrome.tabline)
803 .saturating_sub(chrome.extra()) as u32;
804 // Diff-then-push: only dispatch when the resolved viewport height
805 // changed. Unconditional dispatch here was publishing (+ waking both
806 // cells/virtual-rows workers) every loop iteration.
807 let vh = app.active_pane_content_height(buffer_height);
808 if last_viewport_height != Some(vh) {
809 app.set_viewport_height(vh);
810 last_viewport_height = Some(vh);
811 }
812 // 2026-05-27: fire `set_pane_viewport(idx, rows, cols)` per
813 // leaf so the host writes the per-pane geometry onto
814 // `PaneState` AND, for terminal panes, resizes alacritty +
815 // PTY to match the allocated area. Without this, terminal
816 // grids stayed at their spawn-time 80×24 default and never
817 // wrapped to the actual TUI pane width — the GPUI peer
818 // had this loop in its render path but the TUI never did.
819 //
820 // Diff-then-send: only send when the leaf's published
821 // viewport differs from what compute_rects computed this
822 // frame. Steady-state (no resize) fires zero commands.
823 let panes_arc = app.panes();
824 let area_for_panes = crate::pane::PaneRect {
825 x: 0,
826 y: 0,
827 width: size.width,
828 height: buffer_height as u16,
829 };
830 let rects = panes_arc.tree.compute_rects(area_for_panes);
831 let current_leaves: Vec<_> = panes_arc.tree.leaves().to_vec();
832 for (idx, prect) in &rects {
833 // Every pane reserves a bottom row for its status line
834 // (Option A: global modeline removed). Mirror of
835 // draw_panes which unconditionally splits rect.height >= 2.
836 let content_h = if prect.height >= 2 {
837 prect.height - 1
838 } else {
839 prect.height
840 };
841 let rows = u32::from(content_h).max(1);
842 let cols = u32::from(prect.width).max(1);
843 let needs = current_leaves
844 .get(*idx)
845 .map(|l| l.viewport_height != rows || l.viewport_width != cols)
846 .unwrap_or(true);
847 if needs {
848 app.set_pane_viewport(*idx, rows, cols);
849 }
850 }
851 // PU.1b-3: floating-popup inner-geometry hand-off. Mirror of the
852 // per-pane loop above: the renderer is the sizing authority, so it
853 // pushes the popup's resolved inner `(rows, cols)` to the host for
854 // `build_cells_panes` to size the synthetic popup-pane matrix.
855 // `popup_feedback_inner_dims` recomputes the SAME geometry
856 // `draw_help_overlay` paints into (`popup_outer_size` over the
857 // buffer area minus tabline/cmdline/candidate rows), so the matrix
858 // and the painted box agree on width. Diff-then-send: a steady-state
859 // popup fires zero RPCs; closing the popup pushes `None` once.
860 // WK.12: the band's geometry hand-off, beside the popup's.
861 let band_dims = crate::render::band_feedback_inner_dims(&app, size.width, buffer_height);
862 if last_band_dims != band_dims {
863 if let Some((rows, cols)) = band_dims {
864 app.set_band_viewport(rows, cols);
865 }
866 last_band_dims = band_dims;
867 }
868 let popup_dims = crate::render::popup_feedback_inner_dims(&app, size.width, buffer_height);
869 if last_popup_dims != popup_dims {
870 if let Some((rows, cols)) = popup_dims {
871 app.set_popup_viewport(rows, cols);
872 }
873 last_popup_dims = popup_dims;
874 }
875 // Slice 3c.final.C: terminal_width via Action. Diff-then-send
876 // (mirrors the pane-viewport loop above): the width only changes
877 // on a terminal resize, so dispatching every frame meant a blocking
878 // actor RPC + a full `publish_render_state` on every frame (hence on
879 // every keystroke's draw) for a no-op field write. Cache the
880 // last-sent width UI-side; dispatch only on change.
881 if last_terminal_width != Some(size.width) {
882 app.apply(lattice_host::action::Action::SetTerminalWidth(size.width));
883 last_terminal_width = Some(size.width);
884 }
885 // `<C-l>` (RedrawScreen) sets `pending_redraw`; honour it
886 // by clearing the terminal buffer so the next draw repaints
887 // every cell instead of letting ratatui's diff engine
888 // assume the previous frame's contents are intact.
889 // Slice 3c.final.B (group 6) + 3c.final.C: pending_redraw
890 // read via the published substate; acknowledge-write goes
891 // through `Action::AcknowledgeRedraw` so the renderer no
892 // longer mutates editor state directly. Dispatch tail
893 // republishes RS so the next iteration's load observes
894 // the cleared flag.
895 // Slice 3c.final.E.4: read through App-cached `render_state`.
896 if app.render_state.load().lifecycle.pending_redraw {
897 terminal.clear().context("clear terminal for redraw")?;
898 app.apply(lattice_host::action::Action::AcknowledgeRedraw);
899 }
900 // Force a full repaint when a buffer switch SHRINKS the content. A
901 // switch to a shorter buffer (picker live-preview flipping to a
902 // smaller file, the binary "<no preview>" placeholder, or the
903 // no-match restore to the empty origin) leaves rows the previous
904 // buffer painted that the new (shorter) content can't cover; ratatui
905 // leaves those stale in the live async run — the preview "bleeding"
906 // (line-tail fragments surviving until a manual `<C-l>`). Clearing
907 // makes the next draw repaint every cell, like RedrawScreen.
908 //
909 // Scoped to SHRINK only: switching among full-screen buffers (the
910 // common preview-typing case) grows/keeps the line count, so it
911 // never clears → no flicker. Within-row trailing cells already clear
912 // for rows present in both frames; only the vacated rows bleed.
913 let active_buffer = app.active_buffer_id();
914 let line_count = app.ad().snapshot.buffer.content_line_count() as usize;
915 if last_active_buffer.is_some_and(|(prev_id, prev_lines)| {
916 prev_id != active_buffer && line_count < prev_lines
917 }) {
918 terminal.clear().context("clear terminal on shrink")?;
919 }
920 last_active_buffer = Some((active_buffer, line_count));
921 // Phase 5.8.AF.5 / Slice X2.5: removed
922 // `app.refresh_highlights()` from the per-frame body.
923 // display-line B4.2: active-pane syntax colour now flows
924 // through the cells / `DisplayMatrix` substrate (rebuilt off
925 // the UI thread by the cells worker), and overlay backgrounds
926 // through the `lattice_host::overlay_worker` (woken via
927 // `Editor::overlay_wake`). Both keep the per-frame body free
928 // of any tree-sitter walk. Pre-X2 cost: 200–600µs per frame
929 // on scroll cache miss (tree-sitter walk on UI thread);
930 // now zero UI-thread parse cost. Goal #1 violation B1 closed
931 // for the TUI peer.
932 // B4: the per-frame UI-thread `refresh_pane_highlights`
933 // recompute was disabled then deleted (it only fed the dead
934 // inactive-pane `pane_highlights` cache; inactive panes read
935 // their retained per-pane `DisplayMatrix`).
936
937 // Push the cursor shape only when the inputs change --
938 // terminals accept these every frame, but emitting on every
939 // iteration adds a few bytes of escape sequence to the
940 // stream that isn't free. Cache key includes both `modal`
941 // and `terminal_insert_active` so the bar / block flip on
942 // entering / leaving TerminalInsert re-pushes.
943 let cursor_inputs = (app.ad().modal, app.ad().terminal_insert_active);
944 if last_cursor_inputs != Some(cursor_inputs) {
945 execute!(
946 terminal.backend_mut(),
947 cursor_style_for(cursor_inputs.0, cursor_inputs.1)
948 )
949 .context("set cursor style")?;
950 last_cursor_inputs = Some(cursor_inputs);
951 }
952
953 // §5.6.8: one Cache::load per frame for the active document.
954 // Steady-state ~300ps when the actor hasn't published since
955 // last frame; ~16ns on the first read after a publish.
956 // The Arc keeps the snapshot alive for the entire frame --
957 // the actor is free to publish concurrently and the new
958 // pointer is observed by next frame's load.
959 // Slice 3c.final.E.5j: per-frame snapshot read via the
960 // published `ad().snapshot` mirror (Arc-bump clone off the
961 // same source as `snapshot_cache.load_arc()`).
962 let frame_snap = app.ad().snapshot.clone();
963 let draw_t0 = if perf_input {
964 // Reset the per-frame byte tally so we count only THIS draw's
965 // write (the cursor-style execute! above already happened).
966 PERF_FRAME_BYTES.store(0, Ordering::Relaxed);
967 Some(Instant::now())
968 } else {
969 None
970 };
971 // Pin the ActiveDocumentRenderState snapshot so every
972 // `app.ad()` call inside the draw sees the same cursor,
973 // scroll, option, and modal state — without the pin the
974 // actor can publish a new RenderState between any two
975 // independent ad() calls, causing the cursorline highlight
976 // and cursor blink position to disagree.
977 app.pin_render_state();
978 let mut completion_docs_dims: Option<(u32, u32)> = None;
979 terminal
980 .draw(|frame| {
981 completion_docs_dims = draw_frame(frame, &app, &frame_snap);
982 })
983 .context("draw frame")?;
984 app.unpin_render_state();
985 // PU.5c: completion-docs popup geometry hand-off (diff-then-send,
986 // peer of the floating-popup feedback above). `draw_frame` returns
987 // the docs popup's inner (rows, cols) when shown — computed at the
988 // exact draw site (it is cursor-anchored, so position-dependent) —
989 // and the host sizes the `PaneId::COMPLETION_DOCS` matrix from it.
990 // `None` when no docs popup → nothing sent (the synthetic pane just
991 // isn't built).
992 if last_completion_docs_dims != completion_docs_dims {
993 if let Some((rows, cols)) = completion_docs_dims {
994 app.set_completion_docs_viewport(rows, cols);
995 }
996 last_completion_docs_dims = completion_docs_dims;
997 }
998 // Perf timer: this draw is the one that renders the keystroke applied
999 // at the end of the PREVIOUS iteration. Report input→glyph (our side),
1000 // the bare draw duration, AND the bytes written to the terminal — the
1001 // last is the real driver of present time on a slow pty (vim writes a
1002 // handful; a whole-viewport rewrite is kilobytes). Clear so we only log
1003 // frames that actually rendered fresh input (not async repaints).
1004 if perf_input && let (Some(input_at), Some(t0)) = (last_input_at.take(), draw_t0) {
1005 let done = Instant::now();
1006 eprintln!(
1007 "[perf] input→glyph {:>7.3}ms (draw {:>7.3}ms, {} bytes)",
1008 done.duration_since(input_at).as_secs_f64() * 1e3,
1009 done.duration_since(t0).as_secs_f64() * 1e3,
1010 PERF_FRAME_BYTES.load(Ordering::Relaxed),
1011 );
1012 }
1013
1014 // I.3 (event-driven wake): block until the input reader forwards a
1015 // terminal event or the actor's `paint_request` forwards a repaint,
1016 // then drain every wake already buffered before looping back to draw
1017 // once. A typing burst collapses to N applies + ONE draw (I.1's
1018 // coalescing, now over the wake channel — the mockable seam I.1
1019 // deferred to here). `recv()` parks the thread with zero CPU while
1020 // idle, so an idle editor issues zero draws and the up-to-100ms
1021 // async-repaint lag is gone. The translate context is rebuilt per key
1022 // inside `apply_event` because applying one event can change the modal
1023 // state / mode stack that governs the next event's translation. See
1024 // docs/dev/architecture/input-pipeline.md.
1025 match wake_rx.recv() {
1026 Ok(first) => {
1027 let repaint_only =
1028 drain_wakes(&mut app, &wake_rx, first, perf_input, &mut last_input_at);
1029 // X1b: repaint-only wakes (hover response, syntax reparse,
1030 // search excerpts) need a tick drain so the pending channel
1031 // is read before the next draw. Keystrokes drain it already
1032 // at the apply tail; this path covers the idle-arrival gap.
1033 if repaint_only {
1034 app.drain_async_pending();
1035 }
1036 }
1037 // Both producer clones are gone (the reader thread died and the
1038 // paint bridge exited) — nothing can wake us again; leave the loop.
1039 Err(_) => break,
1040 }
1041 }
1042 // I.3 teardown: stop the reader thread and join it before `run` restores
1043 // the terminal, so no detached thread is left reading stdin once raw mode
1044 // is disabled. The reader observes the flag within its 100ms poll tick.
1045 reader_stop.store(true, Ordering::Relaxed);
1046 let _ = reader_handle.join();
1047 Ok(())
1048}
1049
1050#[cfg(test)]
1051mod tests {
1052 #![allow(clippy::unwrap_used, clippy::panic)]
1053 use super::*;
1054 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
1055 use lattice_grammar::{SearchDirection, VisualKind};
1056
1057 /// Build a plain `Event::Key` for a character (no modifiers).
1058 fn key(c: char) -> Event {
1059 Event::Key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE))
1060 }
1061
1062 /// I.3: `drain_wakes` applies every buffered input event in one batch —
1063 /// the coalescing the event-driven loop relies on for the keystroke UX
1064 /// contract (N queued keys → N applies → ONE draw) — and arms the perf
1065 /// timer because real input was applied. The wake channel is the mockable
1066 /// event source I.1's burst test was deferred to.
1067 #[test]
1068 fn drain_wakes_applies_and_coalesces_buffered_input() {
1069 let mut app = App::new(Document::from_text("hello\n"));
1070 let (tx, rx) = mpsc::channel::<Wake>();
1071 // Queue a burst: `i` enters Insert, `X` types a char.
1072 tx.send(Wake::Input(key('i'))).unwrap();
1073 tx.send(Wake::Input(key('X'))).unwrap();
1074 let first = rx.recv().unwrap();
1075 let mut last_input_at = None;
1076 let _ = drain_wakes(&mut app, &rx, first, true, &mut last_input_at);
1077 // Whole burst drained in one batch — nothing left for a second draw.
1078 assert!(rx.try_recv().is_err(), "burst must coalesce into one drain");
1079 // Real input applied → perf timer armed + modal advanced to Insert.
1080 assert!(
1081 last_input_at.is_some(),
1082 "applied input must arm the perf timer"
1083 );
1084 assert_eq!(app.ad().modal, ModalState::Insert, "`i` must enter Insert");
1085 }
1086
1087 // --- ML.4: modeline clicks ----------------------------------------
1088
1089 /// Build a left-button press at `(col, row)`.
1090 fn click(col: u16, row: u16) -> Event {
1091 Event::Mouse(crossterm::event::MouseEvent {
1092 kind: crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left),
1093 column: col,
1094 row,
1095 modifiers: KeyModifiers::NONE,
1096 })
1097 }
1098
1099 /// A click that lands on a recorded region dispatches that region's
1100 /// command through the ordinary invocation path — the same one a
1101 /// keystroke bound to the action would take.
1102 #[test]
1103 fn a_click_on_a_recorded_region_invokes_its_command() {
1104 let mut app = App::new(Document::from_text("hello world\n"));
1105 // `:` opens the command line — a visible, cheap-to-assert effect
1106 // reachable from a CommandId without standing up a mode.
1107 let command = app.editor.builtins.word_forward.0;
1108 app.modeline_hits
1109 .borrow_mut()
1110 .push(lattice_host::modeline::ModelineHitZone {
1111 row: 23,
1112 col_start: 4,
1113 col_end: 9,
1114 action: command,
1115 });
1116
1117 let mut last_input_at = None;
1118 apply_event(&mut app, click(5, 23), true, &mut last_input_at);
1119
1120 assert_eq!(
1121 app.ad().cursor.byte,
1122 6,
1123 "the click must dispatch the element's command (w → next word)"
1124 );
1125 assert!(
1126 last_input_at.is_some(),
1127 "a click that did something is real input"
1128 );
1129 }
1130
1131 /// A click on dead space is a no-op — not a swallowed key, not a
1132 /// spurious perf-timer arm.
1133 #[test]
1134 fn a_click_outside_every_region_does_nothing() {
1135 let mut app = App::new(Document::from_text("hello\n"));
1136 let command = app.editor.builtins.word_forward.0;
1137 app.modeline_hits
1138 .borrow_mut()
1139 .push(lattice_host::modeline::ModelineHitZone {
1140 row: 23,
1141 col_start: 4,
1142 col_end: 9,
1143 action: command,
1144 });
1145
1146 let mut last_input_at = None;
1147 apply_event(&mut app, click(20, 23), true, &mut last_input_at);
1148
1149 assert_eq!(app.ad().cursor.byte, 0, "cursor must not move");
1150 assert!(
1151 last_input_at.is_none(),
1152 "a click on nothing must not be counted as input"
1153 );
1154 }
1155
1156 /// Only the left button acts. Right / middle are reserved (a context
1157 /// menu is a plausible future) and wheel events must never be
1158 /// mistaken for clicks — scrolling over a modeline would otherwise
1159 /// fire its command repeatedly.
1160 #[test]
1161 fn non_left_button_events_are_ignored() {
1162 let mut app = App::new(Document::from_text("hello\n"));
1163 let command = app.editor.builtins.word_forward.0;
1164 app.modeline_hits
1165 .borrow_mut()
1166 .push(lattice_host::modeline::ModelineHitZone {
1167 row: 23,
1168 col_start: 4,
1169 col_end: 9,
1170 action: command,
1171 });
1172
1173 let mut last_input_at = None;
1174 for kind in [
1175 crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Right),
1176 crossterm::event::MouseEventKind::Up(crossterm::event::MouseButton::Left),
1177 crossterm::event::MouseEventKind::ScrollDown,
1178 crossterm::event::MouseEventKind::Moved,
1179 ] {
1180 apply_event(
1181 &mut app,
1182 Event::Mouse(crossterm::event::MouseEvent {
1183 kind,
1184 column: 5,
1185 row: 23,
1186 modifiers: KeyModifiers::NONE,
1187 }),
1188 true,
1189 &mut last_input_at,
1190 );
1191 assert_eq!(app.ad().cursor.byte, 0, "{kind:?} must not dispatch");
1192 }
1193 }
1194
1195 /// I.3: a `Repaint` wake (forwarded from `paint_request`) drives the
1196 /// redraw at the loop top but applies NO input — it must not arm the perf
1197 /// timer or mutate modal state. This is what keeps an async republish
1198 /// (syntax recolour, LSP) repainting promptly without being mistaken for a
1199 /// keystroke.
1200 #[test]
1201 fn drain_wakes_repaint_applies_no_input() {
1202 let mut app = App::new(Document::from_text("hello\n"));
1203 let (_tx, rx) = mpsc::channel::<Wake>();
1204 let mut last_input_at = None;
1205 let repaint_only = drain_wakes(&mut app, &rx, Wake::Repaint, true, &mut last_input_at);
1206 assert!(repaint_only, "repaint batch must return repaint_only=true");
1207 assert!(last_input_at.is_none(), "repaint must not look like input");
1208 assert_eq!(
1209 app.ad().modal,
1210 ModalState::Normal,
1211 "repaint must not change modal"
1212 );
1213 }
1214
1215 /// `<Esc>` dismisses an open floating popup even when the DOCUMENT keeps
1216 /// focus (State A) — the GPUI peer already does this; the TUI used to
1217 /// only close on State-B Esc or cursor motion. Regression guard for the
1218 /// user-reported "Esc doesn't close the hover popup" bug.
1219 #[test]
1220 fn esc_dismisses_floating_popup_in_state_a() {
1221 let mut app = App::new(Document::from_text("fn main() {}\n"));
1222 let content = crate::help::HelpContent::from_lines("hover", vec!["doc".to_string()]);
1223 app.open_floating_popup(
1224 content,
1225 lattice_core::ui::popup::PopupPlacement::CursorAnchored,
1226 );
1227 assert!(app.popup().is_open(), "floating popup open");
1228 assert_ne!(
1229 app.ad().buffer_kind,
1230 crate::buffers::BufferKind::Help,
1231 "State A: document keeps focus (popup not focused)"
1232 );
1233 let mut last = None;
1234 apply_event(
1235 &mut app,
1236 Event::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)),
1237 false,
1238 &mut last,
1239 );
1240 assert!(
1241 !app.popup().is_open(),
1242 "Esc over the document must dismiss the floating popup (State A)"
1243 );
1244 }
1245
1246 #[test]
1247 fn esc_closes_a_help_split_pane() {
1248 // `help.describe-display=split-h` opens help in its OWN split pane.
1249 // `<Esc>` — now owned by help-mode's keymap — must CLOSE that pane and
1250 // return to the sibling, NOT restore a buffer into it (the reported
1251 // "Esc should close help buffers even when opened in splits"). Driven
1252 // end-to-end through `apply_event` so the whole path is exercised: the
1253 // help-mode `<Esc>` keymap → `action:help-dismiss` → `Effect::DismissPopup`
1254 // → `dismiss_popup`'s split-close branch. `App::new` runs `Editor::boot`,
1255 // so the mode keymap + the command are wired exactly as in production.
1256 let mut app = App::new(Document::from_text("fn main() {}\n"));
1257 let content = crate::help::HelpContent::from_lines("describe", vec!["body".to_string()]);
1258 app.open_help_in_split(
1259 content,
1260 lattice_core::ui::pane::SplitOrientation::Horizontal,
1261 );
1262 assert_eq!(
1263 app.editor.pane_tree.len(),
1264 2,
1265 "split-h opens a second pane holding help"
1266 );
1267 assert_eq!(app.editor.active_buffer, crate::buffers::BufferKind::Help);
1268
1269 let mut last = None;
1270 apply_event(
1271 &mut app,
1272 Event::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)),
1273 false,
1274 &mut last,
1275 );
1276
1277 assert_eq!(
1278 app.editor.pane_tree.len(),
1279 1,
1280 "Esc must CLOSE the help split pane, not restore a buffer into it"
1281 );
1282 assert_ne!(
1283 app.editor.active_buffer,
1284 crate::buffers::BufferKind::Help,
1285 "focus returns to the surviving (document) pane"
1286 );
1287 }
1288
1289 #[test]
1290 fn normal_mode_uses_block_cursor() {
1291 assert!(matches!(
1292 cursor_style_for(ModalState::Normal, false),
1293 SetCursorStyle::SteadyBlock
1294 ));
1295 }
1296
1297 #[test]
1298 fn insert_mode_uses_bar_cursor() {
1299 assert!(matches!(
1300 cursor_style_for(ModalState::Insert, false),
1301 SetCursorStyle::SteadyBar
1302 ));
1303 }
1304
1305 #[test]
1306 fn visual_charwise_uses_block_cursor() {
1307 assert!(matches!(
1308 cursor_style_for(ModalState::Visual(VisualKind::Charwise), false),
1309 SetCursorStyle::SteadyBlock
1310 ));
1311 }
1312
1313 #[test]
1314 fn visual_linewise_uses_block_cursor() {
1315 assert!(matches!(
1316 cursor_style_for(ModalState::Visual(VisualKind::Linewise), false),
1317 SetCursorStyle::SteadyBlock
1318 ));
1319 }
1320
1321 #[test]
1322 fn operator_pending_uses_block_cursor() {
1323 assert!(matches!(
1324 cursor_style_for(ModalState::OperatorPending, false),
1325 SetCursorStyle::SteadyBlock
1326 ));
1327 }
1328
1329 #[test]
1330 fn replace_mode_uses_underscore_cursor() {
1331 assert!(matches!(
1332 cursor_style_for(ModalState::Replace, false),
1333 SetCursorStyle::SteadyUnderScore
1334 ));
1335 }
1336
1337 #[test]
1338 fn command_mode_uses_bar_cursor() {
1339 assert!(matches!(
1340 cursor_style_for(ModalState::Command, false),
1341 SetCursorStyle::SteadyBar
1342 ));
1343 }
1344
1345 #[test]
1346 fn search_mode_uses_bar_cursor() {
1347 assert!(matches!(
1348 cursor_style_for(ModalState::Search(SearchDirection::Forward), false),
1349 SetCursorStyle::SteadyBar
1350 ));
1351 assert!(matches!(
1352 cursor_style_for(ModalState::Search(SearchDirection::Backward), false),
1353 SetCursorStyle::SteadyBar
1354 ));
1355 }
1356
1357 /// Terminal-mode T2.b: `terminal_insert_active` forces the
1358 /// shape to `SteadyBar` regardless of `ModalState` (which
1359 /// stays `Normal` because TerminalInsert is a minor mode).
1360 /// Normal-in-terminal (insert-active = false) stays
1361 /// `SteadyBlock` so the user can tell at a glance which
1362 /// sub-state they're in.
1363 #[test]
1364 fn terminal_insert_minor_mode_overrides_to_bar() {
1365 assert!(matches!(
1366 cursor_style_for(ModalState::Normal, true),
1367 SetCursorStyle::SteadyBar
1368 ));
1369 assert!(matches!(
1370 cursor_style_for(ModalState::Normal, false),
1371 SetCursorStyle::SteadyBlock
1372 ));
1373 }
1374}
1375
1376/// OS.1: the keyboard-enhancement gate.
1377///
1378/// The push itself needs a tty and cannot be tested here; the DECISION can,
1379/// which is why it is a separate function. Both conditions must hold, and the
1380/// two failure directions are different bugs: pushing when the user turned it
1381/// off ignores their config, and pushing when the terminal does not support it
1382/// sends an escape sequence into a terminal that will render it as garbage.
1383#[cfg(test)]
1384mod os1_tests {
1385 use super::should_push_enhancement;
1386
1387 #[test]
1388 fn the_option_off_means_no_push_even_when_supported() {
1389 assert!(!should_push_enhancement(false, true));
1390 }
1391
1392 #[test]
1393 fn an_unsupporting_terminal_is_not_pushed_to() {
1394 assert!(!should_push_enhancement(true, false));
1395 }
1396
1397 #[test]
1398 fn a_supporting_terminal_with_the_option_on_is_pushed_to() {
1399 assert!(should_push_enhancement(true, true));
1400 }
1401}