From 0dacac7e8d9f55cd4dd61cf5a30a5322ea599b1e Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 21 Jul 2026 19:21:50 -0400 Subject: [PATCH] feat(vterm): compose terminal views in the TUI Render exact per-frontend terminal snapshots inside split-window content rects, route terminal key, paste, focus, and mouse input through durable controllers, and preserve logical row anchors across reflow, selection, and scrollback. Co-Authored-By: Claude --- src/editor.rs | 991 ++++++++++++++++++++++++++++++++--------- src/frontend.rs | 5 +- src/instance_render.rs | 59 ++- src/terminal/view.rs | 853 ++++++++++++++++++++++++++++++++++- 4 files changed, 1673 insertions(+), 235 deletions(-) diff --git a/src/editor.rs b/src/editor.rs index 95d2139..8cd6984 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -13,7 +13,7 @@ //! until the user quits. use std::cell::{Cell, RefCell}; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::io; use std::path::PathBuf; use std::rc::Rc; @@ -24,7 +24,7 @@ use unicode_segmentation::UnicodeSegmentation; use unicode_width::UnicodeWidthStr; use crate::async_runtime::SharedAsyncRuntime; -use crate::cell::CellCoord; +use crate::cell::{CellCoord, CellSize}; use crate::editor_core::EditorCore; use crate::file_io::load_file; use crate::frontend::{Event, Frontend, KeyEvent, KeyEventKind, MouseEvent, install_panic_hook}; @@ -33,9 +33,15 @@ use crate::keymap_stack::{Action, KeyDispatcher}; use crate::lua::LuaHost; use crate::lua_bindings::SharedCore; use crate::minibuffer::Minibuffer; -use crate::protocol::FrontendId; +use crate::protocol::{ + FrontendId, InstanceMessage, InstanceSignal, Key as TerminalKey, + Modifiers as TerminalModifiers, MouseButton as TerminalMouseButton, + MouseKind as TerminalMouseKind, +}; +use crate::terminal::TerminalSnapshot; use crate::view::{View, Viewport}; use crate::window::{Rect, WindowId}; +use crate::terminal::view::TerminalViewKey; /// Ephemeral authenticated origin for one interactive command invocation. /// @@ -85,8 +91,8 @@ pub struct EditorState { pub core: SharedCore, /// The embedded Lua VM and its command/keymap registries. pub lua_host: LuaHost, - /// Multi-key prefix state machine. Driven by the run loop. - pub dispatcher: KeyDispatcher, + /// Independent key-prefix and terminal-escape state per authenticated frontend. + dispatchers: HashMap, /// Authenticated frontend scoped to the current interactive invocation. pub(crate) interactive_origin: InteractiveCommandOrigin, /// Main-thread async runtime (T M3.3). Owns the worker pool and @@ -153,6 +159,12 @@ pub struct EditorState { mouse_click: Option, } +#[derive(Default)] +struct FrontendDispatchState { + dispatcher: KeyDispatcher, + terminal_escape: bool, +} + impl Drop for EditorState { /// Tear down the worker-pool threads. /// @@ -288,7 +300,17 @@ impl EditorState { // shutdown enforces no-zombie cleanup at editor exit. let process_supervisor = crate::lua_bindings::make_process_supervisor(lua_host.lua()) .expect("install pmacs.process"); - let terminal_manager = Rc::new(RefCell::new(crate::terminal::TerminalManager::new())); + let terminal_manager = crate::lua_bindings::make_terminal_manager( + lua_host.lua(), + process_supervisor.clone(), + ) + .expect("install pmacs.terminal"); + lua_host + .eval( + Some("@pmacs/builtin/runtime/terminal.lua"), + include_str!("../builtin/runtime/terminal.lua"), + ) + .expect("load terminal builtin chunk"); // T M4.5 LSP manager. Wires onto the same supervisor so its // spawn/restart/I/O machinery is shared with `pmacs.process.*`. // The manager itself is reachable from Lua as `pmacs.lsp.*`. @@ -527,7 +549,7 @@ impl EditorState { Self { core, lua_host, - dispatcher: KeyDispatcher::new(), + dispatchers: HashMap::new(), interactive_origin, async_runtime, syntax_registry, @@ -698,51 +720,42 @@ impl EditorState { let _ = core.switch_active_buffer(buffer_id); } - /// Translate a key event into a chord, run it through the - /// dispatcher, and invoke the resolved command (or the - /// self-insert fallback for an unbound printable chord). - /// - /// Whether the daemon's key-dispatch path is currently "idle" in - /// the sense that the *next* key event would self-insert into the - /// active buffer rather than being intercepted. - /// - /// `false` when either: - /// - /// - the dispatcher holds a pending multi-key prefix (e.g. the - /// user has typed `C-x` and the daemon is waiting for the next - /// chord), or - /// - a minibuffer prompt is active and absorbing keys, or - /// - an incremental search is running and absorbing keys (Q#SR5). - /// - /// Used by the daemon to drive the `InstanceMessage::DispatchIdle` - /// wire signal that gates `crdt_replica` frontends' optimistic-apply - /// path. Without this signal the optimistic layer would Insert a - /// plain-char keystroke into the active document while the - /// daemon's actual intent is to route the keystroke into the - /// minibuffer prompt — the M10.10 "documented limitation" that - /// surfaced during session-5 manual validation. Isearch reuses the - /// exact same gate: while a search runs every keystroke must - /// round-trip so the daemon's `dispatch_search_key` receives it - /// (extend the query / step) instead of the frontend self-inserting - /// it into the buffer. + /// Whether `frontend_id` may optimistically self-insert its next key. #[must_use] - pub fn dispatch_idle(&self) -> bool { - if !self.dispatcher.pending().is_empty() { + pub fn dispatch_idle_for(&self, frontend_id: FrontendId) -> bool { + if self + .dispatchers + .get(&frontend_id) + .is_some_and(|state| { + state.terminal_escape || !state.dispatcher.pending().is_empty() + }) + { return false; } let core = self.core.borrow(); - // A live context menu shadows the keymap too (Q#CM1): keys must - // round-trip so the daemon's `dispatch_menu_key` drives the menu - // rather than the frontend self-inserting. A round-trip buffer - // (Arc 1b Q#P6 — a focused panel) is the buffer-shaped member of - // the same family: RET must reach its buffer-local bindings and - // typing must reach its read-only intercept, neither of which an - // optimistic local edit would do. !core.minibuffer.is_active() && !core.search_active() && !core.query_replace_active() && !core.menu_is_open() - && !core.active_buffer_round_trips() + && core + .active_window_for(frontend_id) + .is_some_and(|window| { + !core.active_buffer_round_trips_for(window.buffer_id) + }) + } + + /// Local-frontend compatibility wrapper. + #[must_use] + pub fn dispatch_idle(&self) -> bool { + self.dispatch_idle_for(FrontendId::LOCAL) + } + + /// Drop one detached frontend's pending key and terminal escape state. + pub fn detach_frontend_input(&mut self, frontend_id: FrontendId) { + self.dispatchers.remove(&frontend_id); + self.terminal_manager + .borrow_mut() + .detach_frontend(frontend_id); } /// `frontend_id` records which frontend produced the event. v0.1 @@ -753,21 +766,14 @@ impl EditorState { /// before any command body runs, so observers always see a fresh /// value. pub fn dispatch_key(&mut self, frontend_id: FrontendId, key: KeyEvent) { - let Some(chord) = key_event_to_chord(key) else { + if !matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) { return; - }; + } + let chord = key_event_to_chord(key); { let mut core = self.core.borrow_mut(); core.status.clear(); core.active_frontend = frontend_id; - } - - // Modal surfaces beat the completion popup (Q#C3): if a menu / - // search / minibuffer opened while the popup was up, close the - // popup before the modal shadow swallows this key --- otherwise - // it would linger, rendered but unreachable. - { - let mut core = self.core.borrow_mut(); if core.completion_popup_is_open() && (core.menu_is_open() || core.search_active() @@ -778,91 +784,105 @@ impl EditorState { } } - // Context-menu interception (Q#CM1): while a menu is open every - // key drives it (navigate / invoke / dismiss), shadowing the - // global keymap like search and the minibuffer. Same shared path - // both frontends reach via the `FrontendEvent::Key` round-trip. + // Global modal surfaces own input before terminal transport. if self.core.borrow().menu_is_open() { - self.dispatch_menu_key(chord); + if let Some(chord) = chord { + self.dispatch_menu_key(frontend_id, chord); + } return; } - - // Incremental-search interception: while an isearch is running, - // every key routes through the search handler (the global keymap - // is shadowed, like the minibuffer). Printable chars extend the - // query; C-s / C-r step; RET accepts; C-g / Esc cancel. This is - // the shared input path for both frontends — the daemon's - // `FrontendEvent::Key` round-trip lands here too. if self.core.borrow().search_active() { - self.dispatch_search_key(chord); + if let Some(chord) = chord { + self.dispatch_search_key(chord); + } return; } - - // Query-replace interception (Arc 2): the fifth modal shadow. - // While the interactive phase runs, every key drives it - // (y/n/!/./q), shadowing the global keymap like search. Both - // frontends reach this via the `FrontendEvent::Key` round-trip - // (`dispatch_idle` is false while it runs). The handler fires - // `buffer.after-edit` itself — a modal shadow returns before the - // normal post-command edit check below (Q#QR1). if self.core.borrow().query_replace_active() { - self.dispatch_query_replace_key(chord); + if let Some(chord) = chord { + self.dispatch_query_replace_key(chord); + } return; } - - // Minibuffer interception: when a prompt is active, every key - // routes through the minibuffer's hardcoded handler. The main - // editor's keymap is bypassed; the user can still cancel with - // C-g and resume normal dispatch. if self.core.borrow().minibuffer.is_active() { - self.dispatch_minibuffer_key(chord); + if let Some(chord) = chord { + self.dispatch_minibuffer_key(frontend_id, chord); + } return; } - // In-buffer completion popup (Q#C3): a PARTIAL shadow, the - // fourth member of the family above. Only the popup-control - // chords (TAB / RET / C-n / C-p / Up / Down / Esc / C-g) are - // intercepted; every other key falls through to normal dispatch - // below, so typing keeps self-inserting and motion keys keep - // moving. The post-dispatch validation at the bottom of this - // function closes the session when a fallen-through key breaks - // the anchor/prefix invariant. A pending multi-key prefix owns - // the keyboard: while one is in flight (`C-x ...`) the popup - // must not steal its continuation or its `C-g` abort --- and - // the Pending arm below closes the popup anyway, so this guard - // only covers the same-dispatch race. + let dispatcher_pending = self + .dispatchers + .get(&frontend_id) + .is_some_and(|state| !state.dispatcher.pending().is_empty()); if self.core.borrow().completion_popup_is_open() - && self.dispatcher.pending().is_empty() - && let Some(key) = CompletionPopupKey::from_chord(chord) + && !dispatcher_pending + && let Some(popup_key) = chord.and_then(CompletionPopupKey::from_chord) { - self.dispatch_completion_key(key); + self.dispatch_completion_key(popup_key); return; } - // Buffer-scope keybindings need the active buffer id passed - // through the dispatcher (otherwise `keymap_stack::resolve` - // skips the buffer-local map entirely and every "scope = - // buffer" binding falls through to global). The id is read - // outside the keymap borrow so a single-buffer focus check - // doesn't collide with the stack lookup below. + let terminal_key = self.active_terminal_key(frontend_id); + let escaped = self + .dispatchers + .get(&frontend_id) + .is_some_and(|state| state.terminal_escape); + if let Some(view_key) = terminal_key { + if escaped { + self.dispatchers + .entry(frontend_id) + .or_default() + .terminal_escape = false; + if chord.is_some_and(is_terminal_escape_chord) { + self.claim_terminal_controller(view_key); + self.send_terminal_bytes(view_key.buffer_id, &[0x03]); + return; + } + // The post-escape key starts a fresh ordinary sequence below. + } else if !dispatcher_pending { + if chord.is_some_and(is_terminal_escape_chord) { + let state = self.dispatchers.entry(frontend_id).or_default(); + state.terminal_escape = true; + state.dispatcher = KeyDispatcher::new(); + self.claim_terminal_controller(view_key); + return; + } + let Some((terminal_key, modifiers)) = terminal_key_from_crossterm(key) else { + return; + }; + let modes = self + .terminal_manager + .borrow() + .modes_for_view(view_key) + .unwrap_or_default(); + if let Some(bytes) = + crate::terminal::input::encode_key(terminal_key, modifiers, modes) + { + self.claim_terminal_controller(view_key); + self.send_terminal_bytes(view_key.buffer_id, &bytes); + } + return; + } + } + + let Some(chord) = chord else { + return; + }; let active_buffer = Some(self.core.borrow().active_buffer_id()); let action = { let stack = self.lua_host.keymaps().borrow(); - self.dispatcher.dispatch(chord, &stack, active_buffer, &[]) + self.dispatchers + .entry(frontend_id) + .or_default() + .dispatcher + .dispatch(chord, &stack, active_buffer, &[]) }; - - // Snapshot the active buffer's edit revision before the command - // runs so we can fire `buffer.after-edit` only when it changes. - // Stale-id paths (active buffer killed mid-dispatch) fall back - // to "no edit observed", which is the correct conservative call. let pre_revision = self.active_buffer_revision(); match action { Action::Run { command, .. } => { - // Kill ring Q#KR2: record the command boundary before the - // body runs, so the body's own `ed.last_command()` reads - // its *predecessor* (Emacs `last-command` semantics). self.core.borrow_mut().rotate_command(frontend_id, &command); + let _origin = self.interactive_origin.enter(frontend_id); if let Err(e) = self .lua_host .invoke_command(&command, mlua::MultiValue::new()) @@ -872,39 +892,22 @@ impl EditorState { } } Action::Pending { .. } => { - // The pending prefix is rendered from - // `dispatcher.pending()`; no command runs yet. Starting - // a command sequence dismisses the completion popup: - // leaving it open would route the sequence's `C-g` - // abort (and its continuation chords) into the popup's - // shadow instead of the dispatcher. self.core.borrow_mut().completion_popup_close(); } Action::Unbound { sequence } => { if let Some(ch) = printable_char(&sequence) { - // Typing a character is a command too (Q#KR2): it - // must break a kill chain — `C-k x C-k` is two ring - // entries, not an append. self.core .borrow_mut() .rotate_command(frontend_id, "buffer.self-insert"); - // Auto-pairing Q#AP9: this dispatch is the typed - // self-insert producer — arm the exact typed-edit - // record so the after-edit fan-out below can - // expose it. The insert primitive completes the - // record with the effective (post-intercept) edit; - // `typed_edit_finish` takes it back on every path - // out of this dispatch. self.core.borrow_mut().typed_edit_arm(frontend_id, ch); let mut args = mlua::MultiValue::new(); args.push_back(mlua::Value::Integer(ch as i64)); + let _origin = self.interactive_origin.enter(frontend_id); if let Err(e) = self.lua_host.invoke_command("buffer.self-insert", args) { self.core.borrow_mut().status = format!("self-insert failed: {}", first_line(&e.to_string())); } } else { - // An unbound key still breaks the chain (Q#KR2) — - // Emacs's `undefined` runs as a command. self.core.borrow_mut().break_command_chain(frontend_id); self.core.borrow_mut().status = format!("{}: not bound", display_sequence(&sequence)); @@ -912,15 +915,7 @@ impl EditorState { } } - // Auto-pairing Q#AP9: take back the typed-edit arm on every - // path out of this dispatch — command error, rejected insert, - // and the no-revision-change case all land here with either a - // completed record or nothing. The record is armed for Lua - // only across the one after-edit fan-out below and cleared - // the moment it returns, so paste, later dispatches, and - // manual hook runs can never observe a stale record. let typed_edit = self.core.borrow_mut().typed_edit_finish(frontend_id); - let post_revision = self.active_buffer_revision(); if pre_revision != post_revision { if let Some(record) = typed_edit { @@ -932,15 +927,239 @@ impl EditorState { .run_hook("buffer.after-edit", mlua::MultiValue::new()); self.core.borrow_mut().typed_edit_clear_armed(); } - - // Q#C3 post-dispatch validation, deliberately AFTER the - // after-edit hook: the Lua driver may have just refreshed (or - // re-anchored) the popup for this very edit, and validation - // must judge the fresh session, not the stale one. A closed - // popup makes this a single mutex peek. self.core.borrow_mut().completion_popup_validate(); } + fn active_terminal_key(&self, frontend_id: FrontendId) -> Option { + let core = self.core.borrow(); + let view = core.views.get(&frontend_id)?; + let window = core.windows.get(&view.active)?; + let key = TerminalViewKey::new(frontend_id, window.id, window.buffer_id); + self.terminal_manager + .borrow() + .is_terminal(window.buffer_id) + .then_some(key) + } + + fn claim_terminal_controller(&self, key: TerminalViewKey) { + let mut manager = self.terminal_manager.borrow_mut(); + if let Some(previous) = manager.controller_view_for_frontend(key.frontend_id) + && previous != key + { + let _ = manager.release_controller(previous); + } + let _ = manager.register_view(key); + let _ = manager.claim_controller(key); + } + + fn send_terminal_bytes(&self, buffer_id: crate::buffer::BufferId, bytes: &[u8]) { + let result = self.terminal_manager.borrow().send( + buffer_id, + bytes, + &mut self.process_supervisor.borrow_mut(), + ); + if let Err(error) = result { + self.core.borrow_mut().status = error.to_string(); + } + } + + /// Consume a paste as terminal input for one authenticated frontend. + /// + /// Returns `false` when modal/document paste handling must run instead. + pub fn dispatch_paste(&mut self, frontend_id: FrontendId, bytes: &[u8]) -> bool { + { + let mut core = self.core.borrow_mut(); + core.active_frontend = frontend_id; + if core.menu_is_open() + || core.search_active() + || core.query_replace_active() + || core.minibuffer.is_active() + { + return false; + } + } + let Some(key) = self.active_terminal_key(frontend_id) else { + return false; + }; + let modes = self + .terminal_manager + .borrow() + .modes_for_view(key) + .unwrap_or_default(); + let encoded = crate::terminal::input::encode_paste(bytes, modes.bracketed_paste); + self.claim_terminal_controller(key); + self.send_terminal_bytes(key.buffer_id, &encoded); + true + } + + /// Apply authenticated frontend focus to terminal control/reporting. + pub fn dispatch_focus(&mut self, frontend_id: FrontendId, gained: bool) { + self.core.borrow_mut().active_frontend = frontend_id; + if gained { + let Some(key) = self.active_terminal_key(frontend_id) else { + return; + }; + let modes = self + .terminal_manager + .borrow() + .modes_for_view(key) + .unwrap_or_default(); + self.claim_terminal_controller(key); + if let Some(bytes) = + crate::terminal::input::encode_focus(true, modes.focus_reporting) + { + self.send_terminal_bytes(key.buffer_id, &bytes); + } + return; + } + + let controlled = self + .terminal_manager + .borrow() + .controller_view_for_frontend(frontend_id); + let Some(key) = controlled else { + return; + }; + let modes = self + .terminal_manager + .borrow() + .modes_for_view(key) + .unwrap_or_default(); + if let Some(bytes) = + crate::terminal::input::encode_focus(false, modes.focus_reporting) + { + self.send_terminal_bytes(key.buffer_id, &bytes); + } + let _ = self.terminal_manager.borrow_mut().release_controller(key); + } + + /// Resize the one session durably controlled by `frontend_id`. + /// + /// This is called before process drain and paint, never from rendering. + pub fn sync_terminal_layout( + &mut self, + frontend_id: FrontendId, + term_size: CellSize, + ) -> bool { + let Some(key) = self + .terminal_manager + .borrow() + .controller_view_for_frontend(frontend_id) + else { + return false; + }; + let content = { + let core = self.core.borrow(); + let Some(view) = core.views.get(&frontend_id) else { + let _ = self.terminal_manager.borrow_mut().release_controller(key); + return false; + }; + if view.active != key.window_id + || core + .windows + .get(&key.window_id) + .is_none_or(|window| window.buffer_id != key.buffer_id) + { + let _ = self.terminal_manager.borrow_mut().release_controller(key); + return false; + } + let Some(placement) = + window_placements(&core, frontend_id, term_size).get(&key.window_id).copied() + else { + return false; + }; + placement.content + }; + if content.size.rows == 0 || content.size.cols == 0 { + return false; + } + let old_size = self + .terminal_manager + .borrow() + .snapshot(key.buffer_id) + .map(|snapshot| snapshot.size); + if old_size == Some(content.size) { + return false; + } + let Ok(rows) = u16::try_from(content.size.rows) else { + return false; + }; + let Ok(cols) = u16::try_from(content.size.cols) else { + return false; + }; + let result = self.terminal_manager.borrow_mut().resize( + key.buffer_id, + rows, + cols, + &mut self.process_supervisor.borrow_mut(), + ); + if let Err(error) = result { + self.core.borrow_mut().status = error.to_string(); + false + } else { + true + } + } + + /// Precompute owned terminal view snapshots before entering paint borrows. + pub fn prepare_terminal_views( + &mut self, + frontend_id: FrontendId, + term_size: CellSize, + ) -> HashMap { + let (live, sizes) = { + let core = self.core.borrow(); + let placements = window_placements(&core, frontend_id, term_size); + let mut live = HashSet::new(); + let mut sizes = Vec::new(); + for (window_id, placement) in placements { + let Some(window) = core.windows.get(&window_id) else { + continue; + }; + if placement.content.size.rows == 0 || placement.content.size.cols == 0 { + continue; + } + let key = + TerminalViewKey::new(frontend_id, window_id, window.buffer_id); + if self.terminal_manager.borrow().is_terminal(window.buffer_id) { + live.insert(key); + sizes.push((key, placement.content.size)); + } + } + (live, sizes) + }; + let mut manager = self.terminal_manager.borrow_mut(); + manager.retain_frontend_views(frontend_id, &live); + sizes + .into_iter() + .filter_map(|(key, size)| { + manager + .snapshot_for_view(key, size) + .map(|snapshot| (key.window_id, snapshot)) + }) + .collect() + } + + /// Drain local-only terminal/clipboard output signals after a frame. + pub fn take_local_signals(&mut self) -> Vec { + let frontend_id = FrontendId::LOCAL; + let active = self.active_terminal_key(frontend_id); + let mut messages = Vec::new(); + if self + .terminal_manager + .borrow_mut() + .take_bell_for_frontend(frontend_id, active) + { + messages.push(InstanceMessage::Signal(InstanceSignal::Bell)); + } + if let Some((target, bytes)) = self.core.borrow_mut().take_pending_clipboard() + && target == frontend_id + { + messages.push(InstanceMessage::Signal(InstanceSignal::Clipboard(bytes))); + } + messages + } + /// Active buffer's edit revision, or `None` if the registry no /// longer knows about the active buffer (e.g. a command killed it /// mid-dispatch). @@ -1006,13 +1225,13 @@ impl EditorState { /// Keys without a handler are silently ignored --- this matches /// Emacs's behaviour, where minibuffer mode shadows the global /// keymap. - fn dispatch_minibuffer_key(&mut self, chord: Chord) { + fn dispatch_minibuffer_key(&mut self, frontend_id: FrontendId, chord: Chord) { use crate::minibuffer::MinibufferAction; use crossterm::event::{KeyCode, KeyModifiers}; let action = MinibufferAction::from_chord(chord); match action { - MinibufferAction::Accept => self.minibuffer_accept(), + MinibufferAction::Accept => self.minibuffer_accept(frontend_id), MinibufferAction::Cancel => self.minibuffer_cancel(), MinibufferAction::Complete => self.minibuffer_complete(), MinibufferAction::HistoryPrev => self.with_minibuffer(Minibuffer::history_prev), @@ -1155,11 +1374,11 @@ impl EditorState { } /// Drive an open context menu from a keystroke (Q#CM1). - fn dispatch_menu_key(&mut self, chord: Chord) { + fn dispatch_menu_key(&mut self, frontend_id: FrontendId, chord: Chord) { match MenuKey::from_chord(chord) { MenuKey::Next => self.core.borrow_mut().menu_step(1), MenuKey::Prev => self.core.borrow_mut().menu_step(-1), - MenuKey::Invoke => self.menu_invoke_active(), + MenuKey::Invoke => self.menu_invoke_active(frontend_id), MenuKey::Cancel | MenuKey::Dismiss => self.core.borrow_mut().menu_close(), } } @@ -1168,7 +1387,7 @@ impl EditorState { /// menu closes *first* so the command runs against a clean state /// (and a command that itself opens a menu isn't immediately torn /// down). - fn menu_invoke_active(&mut self) { + fn menu_invoke_active(&mut self, frontend_id: FrontendId) { let command = self.core.borrow().menu_active_command(); self.core.borrow_mut().menu_close(); if let Some(command) = command { @@ -1176,14 +1395,13 @@ impl EditorState { // rotate the boundary so a menu Cut chains like a keybound // one. The invoke below bypasses dispatch_key, which would // otherwise leave the boundary stale. - { - let mut core = self.core.borrow_mut(); - let fid = core.active_frontend; - core.rotate_command(fid, &command); - } + self.core + .borrow_mut() + .rotate_command(frontend_id, &command); // Q#KR10b: menu invocation bypasses dispatch_key's // revision check — a menu Cut's edit must still fire // `buffer.after-edit`. + let _origin = self.interactive_origin.enter(frontend_id); self.with_after_edit_check(|state| { if let Err(e) = state .lua_host @@ -1251,7 +1469,13 @@ impl EditorState { /// Drive an open menu from a mouse event (Q#CM1): hover highlights, /// left-click invokes, a click outside (or right-click) dismisses. - fn dispatch_menu_mouse(&mut self, ev: MouseEvent, cell_row: u32, cell_col: u32) { + fn dispatch_menu_mouse( + &mut self, + frontend_id: FrontendId, + ev: MouseEvent, + cell_row: u32, + cell_col: u32, + ) { use crossterm::event::{MouseButton, MouseEventKind}; let hit = self.core.borrow().menu_hit(cell_row, cell_col); match ev.kind { @@ -1263,7 +1487,7 @@ impl EditorState { MouseEventKind::Down(MouseButton::Left) => match hit { Some(row) => { self.core.borrow_mut().menu_set_active_row(row); - self.menu_invoke_active(); + self.menu_invoke_active(frontend_id); } None => self.core.borrow_mut().menu_close(), }, @@ -1287,7 +1511,7 @@ impl EditorState { } } - fn minibuffer_accept(&mut self) { + fn minibuffer_accept(&mut self, frontend_id: FrontendId) { let outcome = self.core.borrow_mut().minibuffer.accept(); let Some((on_accept, contents)) = outcome else { return; @@ -1306,6 +1530,7 @@ impl EditorState { // post-command revision check (the minibuffer interception // returns before it), so an M-x'd editing command would never // fire `buffer.after-edit` without this wrapper. + let _origin = self.interactive_origin.enter(frontend_id); self.with_after_edit_check(|state| { if let Err(e) = on_accept.call::(args) { state.core.borrow_mut().status = format!( @@ -1373,17 +1598,37 @@ impl EditorState { // outside dismisses) — handled before window hit-testing so an // outside click anywhere closes it. if self.core.borrow().menu_is_open() { - self.dispatch_menu_mouse(ev, cell_row, cell_col); + self.dispatch_menu_mouse(frontend_id, ev, cell_row, cell_col); return; } let Some((win_id, rect)) = - window_at_cell(&self.core.borrow(), term_size, cell_row, cell_col) + window_at_cell(&self.core.borrow(), frontend_id, term_size, cell_row, cell_col) else { return; }; let inner_rows = rect.size.rows.saturating_sub(1); let local_row = cell_row.saturating_sub(rect.origin.row); + let buffer_id = self.core.borrow().windows[&win_id].buffer_id; + if self.terminal_manager.borrow().is_terminal(buffer_id) { + let content_size = CellSize::new(inner_rows, rect.size.cols); + if local_row >= inner_rows || content_size.rows == 0 || content_size.cols == 0 { + self.mouse_click = None; + return; + } + let local = CellCoord::new( + local_row, + cell_col.saturating_sub(rect.origin.col), + ); + self.dispatch_terminal_mouse( + TerminalViewKey::new(frontend_id, win_id, buffer_id), + content_size, + local, + ev, + (cell_row, cell_col), + ); + return; + } // UX gutter (Q#UX6): subtract the reserved gutter width so the // hit-test lands on the right text byte. A click inside the gutter // strip (raw < gutter_w) saturates to column 0 → the start of that @@ -1469,6 +1714,74 @@ impl EditorState { } } + fn dispatch_terminal_mouse( + &mut self, + key: TerminalViewKey, + viewport_size: CellSize, + coord: CellCoord, + event: MouseEvent, + global: (u32, u32), + ) { + use crossterm::event::{MouseButton, MouseEventKind}; + + let Some(kind) = terminal_mouse_kind(event.kind) else { + return; + }; + let modifiers = terminal_modifiers(event.modifiers); + let shift = modifiers.contains(TerminalModifiers::SHIFT); + let (at_bottom, modes, screen_size) = { + let mut manager = self.terminal_manager.borrow_mut(); + let Some(snapshot) = manager.snapshot_for_view(key, viewport_size) else { + return; + }; + let modes = manager.modes_for_view(key).unwrap_or_default(); + let screen_size = manager + .snapshot(key.buffer_id) + .map_or(viewport_size, |snapshot| snapshot.size); + (snapshot.at_bottom, modes, screen_size) + }; + + if !shift + && at_bottom + && modes.mouse_sgr + && coord.row < screen_size.rows + && coord.col < screen_size.cols + && let Some(bytes) = + crate::terminal::input::encode_mouse(kind, coord, modifiers, modes) + { + self.claim_terminal_controller(key); + self.send_terminal_bytes(key.buffer_id, &bytes); + return; + } + + self.claim_terminal_controller(key); + let mut manager = self.terminal_manager.borrow_mut(); + match event.kind { + MouseEventKind::ScrollUp => { + let _ = manager.scroll_view(key, viewport_size, SCROLL_LINES); + } + MouseEventKind::ScrollDown => { + let _ = manager.scroll_view(key, viewport_size, -SCROLL_LINES); + } + MouseEventKind::Down(MouseButton::Left) => { + let _ = manager.begin_selection(key, viewport_size, coord); + } + MouseEventKind::Drag(MouseButton::Left) => { + let _ = manager.update_selection(key, viewport_size, coord); + } + MouseEventKind::Up(MouseButton::Left) => { + let _ = manager.finish_selection(key, viewport_size, coord); + } + MouseEventKind::Down(MouseButton::Right) => { + drop(manager); + self.core.borrow_mut().break_command_chain(key.frontend_id); + let rows = self.build_menu_rows(); + self.core.borrow_mut().menu_open(rows, global); + } + _ => {} + } + } + fn is_double_click( &self, frontend_id: FrontendId, @@ -1652,7 +1965,7 @@ impl EditorState { (Some(i), false) => self.core.borrow_mut().menu_set_active_row(i as usize), (Some(i), true) => { self.core.borrow_mut().menu_set_active_row(i as usize); - self.menu_invoke_active(); + self.menu_invoke_active(frontend_id); } (None, true) => self.core.borrow_mut().menu_close(), (None, false) => {} @@ -1754,6 +2067,42 @@ impl EditorState { /// readline / Emacs default and is what most terminal users expect. const SCROLL_LINES: i32 = 3; +/// Shared outer/content geometry consumed by terminal paint and PTY resize. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct WindowPlacement { + pub(crate) outer: Rect, + pub(crate) content: Rect, +} + +/// Compute one explicit frontend's split geometry. +#[must_use] +pub(crate) fn window_placements( + core: &EditorCore, + frontend_id: FrontendId, + term_size: CellSize, +) -> HashMap { + if term_size.rows < 2 || term_size.cols == 0 { + return HashMap::new(); + } + let Some(view) = core.views.get(&frontend_id) else { + return HashMap::new(); + }; + let area = Rect::new(0, 0, term_size.rows - 1, term_size.cols); + view.layout + .compute(area) + .into_iter() + .map(|(window_id, outer)| { + let content = Rect::new( + outer.origin.row, + outer.origin.col, + outer.size.rows.saturating_sub(1), + outer.size.cols, + ); + (window_id, WindowPlacement { outer, content }) + }) + .collect() +} + /// Find the leaf window whose viewport rectangle contains /// `(cell_row, cell_col)` in the global cell grid. Used by the mouse /// dispatcher to route clicks. The bottom row of the terminal (status @@ -1761,26 +2110,23 @@ const SCROLL_LINES: i32 = 3; /// `None`. fn window_at_cell( core: &EditorCore, - term_size: crate::cell::CellSize, + frontend_id: FrontendId, + term_size: CellSize, cell_row: u32, cell_col: u32, ) -> Option<(WindowId, Rect)> { - if term_size.rows < 2 { + if cell_row >= term_size.rows.saturating_sub(1) { return None; } - let text_rows = term_size.rows - 1; - if cell_row >= text_rows { - return None; - } - let area = Rect::new(0, 0, text_rows, term_size.cols); - let placements = core.active_layout().compute(area); - placements.iter().find_map(|(id, rect)| { + let placements = window_placements(core, frontend_id, term_size); + placements.iter().find_map(|(id, placement)| { + let rect = placement.outer; if cell_row >= rect.origin.row && cell_row < rect.origin.row + rect.size.rows && cell_col >= rect.origin.col && cell_col < rect.origin.col + rect.size.cols { - Some((*id, *rect)) + Some((*id, rect)) } else { None } @@ -1871,8 +2217,16 @@ pub fn run(file: Option) -> io::Result<()> { let mut render_state = crate::instance_render::RenderState::new(frontend.size()); loop { - // In-process TUI never has remote frontends; no overlays. - let messages = render_state.render_frame(&state, &[]); + let size = frontend.size(); + let _ = state.sync_terminal_layout(FrontendId::LOCAL, size); + let terminal_snapshots = state.prepare_terminal_views(FrontendId::LOCAL, size); + let mut messages = render_state.render_frame( + &state, + FrontendId::LOCAL, + &terminal_snapshots, + &[], + ); + messages.extend(state.take_local_signals()); frontend.present_messages(&messages)?; if state.core.borrow().quit { break; @@ -1914,6 +2268,7 @@ pub fn run(file: Option) -> io::Result<()> { // resumption by a full frame. The documented invariant is // only `tick_processes → tick_lsp → tick_mcp` (same-batch // supervisor I/O ordering), which is preserved. + let _ = state.sync_terminal_layout(FrontendId::LOCAL, frontend.size()); state.tick_processes(); state.tick_lsp(); state.tick_mcp(); @@ -1953,7 +2308,20 @@ fn process_event(state: &mut EditorState, ev: Event, term_size: crate::cell::Cel Event::Mouse(m) => { state.dispatch_mouse(frontend_id, m, term_size); } - _ => {} + Event::Paste(bytes) => { + if !state.dispatch_paste(frontend_id, bytes.as_bytes()) { + state.core.borrow_mut().active_frontend = frontend_id; + state.with_after_edit_check(|state| { + if let Err(error) = state.core.borrow_mut().paste_inbound(bytes.as_bytes()) { + state.core.borrow_mut().status = error; + } + }); + } + } + Event::FocusGained => state.dispatch_focus(frontend_id, true), + Event::FocusLost => state.dispatch_focus(frontend_id, false), + Event::Key(_) => {} + Event::Resize(_, _) => {} } } @@ -2179,17 +2547,17 @@ impl CompletionPopupKey { #[allow(clippy::too_many_lines, reason = "linear paint pipeline")] pub fn paint_frame( state: &EditorState, + frontend_id: FrontendId, + terminal_snapshots: &HashMap, grid: &mut crate::cell::CellGrid<'_>, - term_size: crate::cell::CellSize, + term_size: CellSize, ) -> Option { if term_size.rows < 2 || term_size.cols == 0 { return None; } - let text_rows = term_size.rows - 1; // Statusline callbacks may call arbitrary editor APIs. Evaluate the // complete visible-window fan-out before the long mutable core borrow // below, then paint only the transactionally validated owned results. - let frontend_id = state.core.borrow().active_frontend; let statusline_evaluation = crate::statusline::evaluate_statusline( state.lua_host.lua(), &state.core, @@ -2213,15 +2581,17 @@ pub fn paint_frame( let t = handle.lock().expect("theme mutex poisoned"); t.clone() }; + let empty_dispatcher = KeyDispatcher::new(); + let dispatcher = state + .dispatchers + .get(&frontend_id) + .map_or(&empty_dispatcher, |state| &state.dispatcher); let mut core_ref = state.core.borrow_mut(); let core: &mut EditorCore = &mut core_ref; - // Compute per-window rectangles. The text area is the term size - // minus the bottom row (status / minibuffer). - let text_area = crate::window::Rect::new(0, 0, text_rows, term_size.cols); - let placements = core.active_layout().compute(text_area); - let active = core.active_window_id(); + let placements = window_placements(core, frontend_id, term_size); + let active = core.views.get(&frontend_id)?.active; // Clear the whole grid first so windows that shrink on resize // don't leak the old contents. @@ -2233,12 +2603,15 @@ pub fn paint_frame( // Scroll the active window so its cursor stays visible. Inactive // windows keep their existing scroll. - if let Some(active_rect) = placements.get(&active) { - let inner_rows = inner_rows(active_rect); + if let Some(active_placement) = placements.get(&active) { + let inner_rows = active_placement.content.size.rows; let registry = core.registry.clone(); let reg = registry.borrow(); - let buf_id = core.active_buffer_id(); - if let Ok(buf) = reg.get(buf_id) { + let buf_id = core.windows.get(&active).map(|window| window.buffer_id); + if !terminal_snapshots.contains_key(&active) + && let Some(buf_id) = buf_id + && let Ok(buf) = reg.get(buf_id) + { let aw = core.windows.get_mut(&active).expect( "invariant: active_window_id always references a live window in core.windows", ); @@ -2259,16 +2632,46 @@ pub fn paint_frame( let reg = registry.borrow(); let diag_store = state.lsp_manager.borrow().diag_store(); for (id, window) in &mut core.windows { - let Some(rect) = placements.get(id).copied() else { + let Some(placement) = placements.get(id).copied() else { continue; }; - let inner_rows = inner_rows(&rect); + let rect = placement.outer; + let inner_rows = placement.content.size.rows; // Record viewport height for page motion (cursor.page-down / // cursor.page-up consume this). window.last_visible_rows = inner_rows; if inner_rows == 0 || rect.size.cols == 0 { continue; } + if let Some(snapshot) = terminal_snapshots.get(id) { + paint_terminal_snapshot(grid, placement.content, snapshot, &theme); + let Ok(buf) = reg.get(window.buffer_id) else { + continue; + }; + let cursor = snapshot.cursor.unwrap_or_default(); + let scroll = if snapshot.scroll_offset == 0 { + String::new() + } else { + format!("↑{}", snapshot.scroll_offset) + }; + let custom = statusline_by_window.get(id); + paint_mode_line( + grid, + &rect, + buf.name(), + false, + *id == active, + cursor.row, + cursor.col, + &scroll, + "", + mode_line_style(&theme), + custom.map_or(&[], |segments| segments.left.as_slice()), + custom.map_or(&[], |segments| segments.right.as_slice()), + &theme, + ); + continue; + } let Ok(buf) = reg.get(window.buffer_id) else { continue; }; @@ -2346,7 +2749,7 @@ pub fn paint_frame( grid, core, &state.lua_host, - &state.dispatcher, + dispatcher, term_size, &theme, ); @@ -2367,7 +2770,20 @@ pub fn paint_frame( if let Some(col) = mb_cursor_col { return Some(CellCoord::new(term_size.rows - 1, col)); } - let active_rect = placements.get(&active).copied()?; + let active_placement = placements.get(&active).copied()?; + if let Some(snapshot) = terminal_snapshots.get(&active) { + let cursor = snapshot.cursor?; + if cursor.row >= active_placement.content.size.rows + || cursor.col >= active_placement.content.size.cols + { + return None; + } + return Some(CellCoord::new( + active_placement.content.origin.row + cursor.row, + active_placement.content.origin.col + cursor.col, + )); + } + let active_rect = active_placement.outer; let registry = core.registry.clone(); let reg = registry.borrow(); let aw = &core.windows[&active]; @@ -2390,6 +2806,47 @@ pub fn paint_frame( Some(CellCoord::new(grid_row, grid_col)) } +fn paint_terminal_snapshot( + grid: &mut crate::cell::CellGrid<'_>, + content: Rect, + snapshot: &TerminalSnapshot, + theme: &crate::highlight::Theme, +) { + let rows = content.size.rows.min(snapshot.size.rows); + let cols = content.size.cols.min(snapshot.size.cols); + for row in 0..rows { + for col in 0..cols { + let source = row as usize * snapshot.size.cols as usize + col as usize; + *grid.at(CellCoord::new( + content.origin.row + row, + content.origin.col + col, + )) = snapshot.cells[source].clone(); + } + } + let overlay = theme.face("ui.selection").map_or( + crate::cell::Style { + reverse: true, + ..crate::cell::Style::default() + }, + |face| crate::cell::Style { + bg: face.bg, + ..crate::cell::Style::default() + }, + ); + for span in &snapshot.selection { + if span.row >= rows { + continue; + } + for col in span.start_col.min(cols)..span.end_col.min(cols) { + let cell = grid.at(CellCoord::new( + content.origin.row + span.row, + content.origin.col + col, + )); + cell.style = crate::overlay::merge_styles(cell.style, overlay); + } + } +} + /// The mode-line row style (themes arc Q#TH5): a set `ui.modeline` /// face owns the surface within its {fg, bg, reverse} mask — the row /// resets to plain plus the face's in-mask components — else today's @@ -3142,6 +3599,43 @@ fn sanitize_single_line(s: &str) -> String { .collect() } +fn is_terminal_escape_chord(chord: Chord) -> bool { + chord.code == KeyCode::Char('c') && chord.modifiers == KeyModifiers::CONTROL +} + +fn terminal_key_from_crossterm(key: KeyEvent) -> Option<(TerminalKey, TerminalModifiers)> { + let modifiers = + crate::protocol::crossterm_translate::mods_from_crossterm(key.modifiers); + let key = crate::protocol::crossterm_translate::keycode_from_crossterm(key.code); + if matches!(key, TerminalKey::Unknown(_)) { + return None; + } + Some((key, modifiers)) +} + +fn terminal_modifiers(modifiers: KeyModifiers) -> TerminalModifiers { + crate::protocol::crossterm_translate::mods_from_crossterm(modifiers) +} + +fn terminal_mouse_kind(kind: crossterm::event::MouseEventKind) -> Option { + use crossterm::event::{MouseButton, MouseEventKind}; + let button = |button| match button { + MouseButton::Left => TerminalMouseButton::Left, + MouseButton::Right => TerminalMouseButton::Right, + MouseButton::Middle => TerminalMouseButton::Middle, + }; + Some(match kind { + MouseEventKind::Down(value) => TerminalMouseKind::Down(button(value)), + MouseEventKind::Up(value) => TerminalMouseKind::Up(button(value)), + MouseEventKind::Drag(value) => TerminalMouseKind::Drag(button(value)), + MouseEventKind::Moved => TerminalMouseKind::Move, + MouseEventKind::ScrollUp => TerminalMouseKind::ScrollUp, + MouseEventKind::ScrollDown => TerminalMouseKind::ScrollDown, + MouseEventKind::ScrollLeft => TerminalMouseKind::ScrollLeft, + MouseEventKind::ScrollRight => TerminalMouseKind::ScrollRight, + }) +} + fn key_event_to_chord(key: KeyEvent) -> Option { // Accept Press and Repeat. Some terminals (notably ones speaking // the kitty keyboard protocol with auto-repeat) deliver held-key @@ -3193,6 +3687,83 @@ mod tests { use super::*; use crate::frontend::KeyEventKind; + fn local_dispatcher(state: &EditorState) -> &KeyDispatcher { + &state + .dispatchers + .get(&FrontendId::LOCAL) + .expect("local dispatcher registered by dispatch") + .dispatcher + } + + #[test] + fn dispatch_prefix_state_is_independent_per_frontend() { + let mut state = fresh_with(b""); + let other = FrontendId(77); + state.dispatch_key(FrontendId::LOCAL, ctrl('x')); + state.dispatch_key(other, plain(KeyCode::Char('a'))); + assert_eq!(local_dispatcher(&state).pending().len(), 1); + assert!( + state + .dispatchers + .get(&other) + .expect("other dispatcher registered") + .dispatcher + .pending() + .is_empty() + ); + assert_eq!(state.core.borrow().active_buffer_len(), 1); + } + + #[test] + fn terminal_snapshot_composes_only_content_and_translates_cursor() { + let state = fresh_with(b""); + let window_id = state.core.borrow().active_window_id(); + let buffer_id = state.core.borrow().active_buffer_id(); + let size = CellSize::new(4, 5); + let viewport = CellSize::new(2, 5); + let mut cells = vec![crate::cell::Cell::default(); viewport.area() as usize]; + cells[0].glyph = crate::cell::Glyph::Char('T'); + cells[7].glyph = crate::cell::Glyph::Char('X'); + let snapshot = TerminalSnapshot { + buffer_id, + size: viewport, + cells, + cursor: Some(CellCoord::new(1, 2)), + title: Some("shell".into()), + screen_generation: 1, + selection: vec![crate::terminal::TerminalSelectionSpan { + row: 0, + start_col: 0, + end_col: 1, + }], + scroll_offset: 0, + at_bottom: true, + pid: 1, + process: crate::terminal::TerminalProcessState::Running, + }; + let snapshots = HashMap::from([(window_id, snapshot)]); + let mut backing = vec![crate::cell::Cell::default(); size.area() as usize]; + let cursor = { + let mut grid = crate::cell::CellGrid { + cells: &mut backing, + stride: size.cols, + size, + }; + paint_frame( + &state, + FrontendId::LOCAL, + &snapshots, + &mut grid, + size, + ) + }; + assert_eq!(backing[0].glyph, crate::cell::Glyph::Char('T')); + assert!(backing[0].style.reverse); + assert_eq!(backing[7].glyph, crate::cell::Glyph::Char('X')); + assert_ne!(backing[10].glyph, crate::cell::Glyph::Char('X')); + assert_eq!(cursor, Some(CellCoord::new(1, 2))); + } + #[test] fn line_number_gutter_renders_right_aligned_digits() { use crate::buffer::{Buffer, BufferId}; @@ -3417,10 +3988,10 @@ mod tests { fn cx_cc_quits() { let mut s = fresh_with(b""); s.dispatch_key(FrontendId::LOCAL, ctrl('x')); - assert_eq!(s.dispatcher.pending().len(), 1); + assert_eq!(local_dispatcher(&s).pending().len(), 1); s.dispatch_key(FrontendId::LOCAL, ctrl('c')); assert!(s.core.borrow().quit); - assert!(s.dispatcher.pending().is_empty()); + assert!(local_dispatcher(&s).pending().is_empty()); } #[test] @@ -3542,10 +4113,10 @@ mod tests { let size = crate::cell::CellSize::new(24, 80); let mut rs = crate::instance_render::RenderState::new(size); - let _ = rs.render_frame(&s, &[]); + let _ = rs.render_frame(&s, FrontendId::LOCAL, &HashMap::new(), &[]); process_event(&mut s, Event::Key(ctrl('s')), size); assert!(s.core.borrow().search_active(), "C-s starts the search"); - let _ = rs.render_frame(&s, &[]); + let _ = rs.render_frame(&s, FrontendId::LOCAL, &HashMap::new(), &[]); for c in "foo".chars() { process_event( @@ -3553,7 +4124,7 @@ mod tests { Event::Key(key(KeyCode::Char(c), KeyModifiers::NONE)), size, ); - let _ = rs.render_frame(&s, &[]); + let _ = rs.render_frame(&s, FrontendId::LOCAL, &HashMap::new(), &[]); } assert_eq!( s.core.borrow().search_query(), @@ -3581,7 +4152,7 @@ mod tests { stride: size.cols, size, }; - let _ = paint_frame(&s, &mut grid, size); + let _ = paint_frame(&s, FrontendId::LOCAL, &HashMap::new(), &mut grid, size); // The active match [0,3) washes row 0's first cells (bright // Indexed(11); lazy matches would be Indexed(3)). @@ -3707,10 +4278,10 @@ mod tests { state: crossterm::event::KeyEventState::NONE, }; s.dispatch_key(FrontendId::LOCAL, cx_press); - assert_eq!(s.dispatcher.pending().len(), 1); + assert_eq!(local_dispatcher(&s).pending().len(), 1); s.dispatch_key(FrontendId::LOCAL, cb_repeat); assert!( - s.dispatcher.pending().is_empty(), + local_dispatcher(&s).pending().is_empty(), "Repeat-kind C-b did not resolve the pending C-x prefix" ); assert_eq!(s.core.borrow().active_buffer_name(), "*buffer-list*"); @@ -3737,7 +4308,7 @@ mod tests { s.dispatch_key(FrontendId::LOCAL, cx_press); s.dispatch_key(FrontendId::LOCAL, cx_release); assert_eq!( - s.dispatcher.pending().len(), + local_dispatcher(&s).pending().len(), 1, "Release events should be ignored, but the prefix was disturbed" ); @@ -3772,10 +4343,10 @@ mod tests { // window to the *buffer-list* buffer). let mut s = fresh_with(b""); s.dispatch_key(FrontendId::LOCAL, ctrl('x')); - assert_eq!(s.dispatcher.pending().len(), 1, "C-x should start prefix"); + assert_eq!(local_dispatcher(&s).pending().len(), 1, "C-x should start prefix"); s.dispatch_key(FrontendId::LOCAL, ctrl('b')); assert!( - s.dispatcher.pending().is_empty(), + local_dispatcher(&s).pending().is_empty(), "C-x C-b should resolve, leaving no pending prefix; status: {}", s.core.borrow().status ); @@ -3833,9 +4404,9 @@ mod tests { fn unknown_chord_continuation_clears_prefix_with_message() { let mut s = fresh_with(b""); s.dispatch_key(FrontendId::LOCAL, ctrl('x')); - assert_eq!(s.dispatcher.pending().len(), 1); + assert_eq!(local_dispatcher(&s).pending().len(), 1); s.dispatch_key(FrontendId::LOCAL, ctrl('q')); - assert!(s.dispatcher.pending().is_empty()); + assert!(local_dispatcher(&s).pending().is_empty()); assert!(s.core.borrow().status.contains("not bound")); } @@ -3876,7 +4447,7 @@ mod tests { key(KeyCode::Char('u'), KeyModifiers::NONE), ); assert_eq!(s.core.borrow().active_buffer_len(), 0); - assert!(s.dispatcher.pending().is_empty()); + assert!(local_dispatcher(&s).pending().is_empty()); } #[test] @@ -4219,7 +4790,7 @@ mod tests { #[test] fn empty_status_row_is_blank() { let s = fresh_with(b"hello\n"); - let line = build_status_line(&s.core.borrow(), &s.lua_host, &s.dispatcher, 80); + let line = build_status_line(&s.core.borrow(), &s.lua_host, &KeyDispatcher::new(), 80); assert_eq!(line, "", "status row should be empty when nothing to say"); } @@ -4227,7 +4798,7 @@ mod tests { fn captured_lua_error_appears_in_status_line() { let mut s = fresh_with(b""); let _ = s.lua_host.eval(Some("usercfg"), "error('kapow')"); - let line = build_status_line(&s.core.borrow(), &s.lua_host, &s.dispatcher, 200); + let line = build_status_line(&s.core.borrow(), &s.lua_host, &KeyDispatcher::new(), 200); assert!(line.contains("lua: "), "status line: {line}"); assert!(line.contains("kapow"), "status line: {line}"); } @@ -4242,7 +4813,7 @@ mod tests { let s = fresh_with(b""); s.core.borrow_mut().status = "M-x error: command \"foo\" not found\nstack traceback:\n\t[C]: in ?".into(); - let line = build_status_line(&s.core.borrow(), &s.lua_host, &s.dispatcher, 200); + let line = build_status_line(&s.core.borrow(), &s.lua_host, &KeyDispatcher::new(), 200); assert!(!line.contains('\n'), "status line leaked newline: {line:?}"); assert!(!line.contains('\r'), "status line leaked CR: {line:?}"); assert!( @@ -4261,7 +4832,7 @@ mod tests { let _ = s .lua_host .eval(Some("usercfg"), "error('boom\\nlots\\nof\\nlines')"); - let line = build_status_line(&s.core.borrow(), &s.lua_host, &s.dispatcher, 200); + let line = build_status_line(&s.core.borrow(), &s.lua_host, &KeyDispatcher::new(), 200); assert!(!line.contains('\n'), "status line leaked newline: {line:?}"); assert!(line.contains("lua: "), "status line: {line}"); } @@ -4271,7 +4842,7 @@ mod tests { let mut s = fresh_with(b""); let _ = s.lua_host.eval(None, "error('latent')"); s.core.borrow_mut().status = "saved foo".into(); - let line = build_status_line(&s.core.borrow(), &s.lua_host, &s.dispatcher, 200); + let line = build_status_line(&s.core.borrow(), &s.lua_host, &KeyDispatcher::new(), 200); assert!(line.contains("saved foo")); assert!(!line.contains("lua: ")); } @@ -5096,7 +5667,7 @@ mod tests { "raw status leaked newline: {raw:?} (default.lua should take first line)" ); assert!(raw.starts_with("M-x error: "), "raw status: {raw}"); - let line = build_status_line(&s.core.borrow(), &s.lua_host, &s.dispatcher, 200); + let line = build_status_line(&s.core.borrow(), &s.lua_host, &KeyDispatcher::new(), 200); assert!( !line.contains('\n'), "rendered status line leaked newline: {line:?} (raw: {raw:?})" @@ -7085,7 +7656,7 @@ mod tests { stride: cols, size: crate::cell::CellSize::new(rows, cols), }; - let cursor = paint_frame(s, &mut grid, crate::cell::CellSize::new(rows, cols)); + let cursor = paint_frame(s, FrontendId::LOCAL, &HashMap::new(), &mut grid, crate::cell::CellSize::new(rows, cols)); (backing, cols, cursor) } diff --git a/src/frontend.rs b/src/frontend.rs index 8fe6831..6c1ca45 100644 --- a/src/frontend.rs +++ b/src/frontend.rs @@ -348,8 +348,11 @@ impl Frontend { let payload = format!("\x1b]52;c;{}\x07", osc52_base64(data)); queue!(self.out, Print(payload))?; } + InstanceMessage::Signal(InstanceSignal::Bell) => { + queue!(self.out, Print("\x07"))?; + } InstanceMessage::ModeLine(_) - // Bell / window-title Signals stay reserved for v0.3. + // Window-title requests remain metadata-only. | InstanceMessage::Signal(_) | InstanceMessage::Goodbye(_) // T M10.5: CrdtOp's wire shape exists; the v1.0 TUI doesn't diff --git a/src/instance_render.rs b/src/instance_render.rs index 4cbe405..dc19f2d 100644 --- a/src/instance_render.rs +++ b/src/instance_render.rs @@ -18,7 +18,10 @@ use crate::cell::{Cell, CellGrid, CellSize, diff}; use crate::editor::{EditorState, paint_frame}; -use crate::protocol::{CursorState, InstanceMessage}; +use crate::protocol::{CursorState, FrontendId, InstanceMessage}; +use crate::terminal::TerminalSnapshot; +use crate::window::WindowId; +use std::collections::HashMap; /// Owns the cell buffers and runs the paint-and-diff cycle. pub struct RenderState { @@ -95,6 +98,8 @@ impl RenderState { pub fn render_frame( &mut self, state: &EditorState, + frontend_id: FrontendId, + terminal_snapshots: &HashMap, other_presences: &[crate::overlay_paint::OtherPresence], ) -> Vec { if self.size.rows < 2 || self.size.cols == 0 { @@ -107,7 +112,13 @@ impl RenderState { stride: self.size.cols, size: self.size, }; - let coord = paint_frame(state, &mut grid, self.size); + let coord = paint_frame( + state, + frontend_id, + terminal_snapshots, + &mut grid, + self.size, + ); // T M10.9 — overlay paint after main paint, before diff. // Modifies cells in `next`; diff captures the changes // as ordinary style updates. @@ -186,7 +197,7 @@ mod tests { #[test] fn render_returns_cell_delta_and_cursor() { let mut r = RenderState::new(CellSize::new(24, 80)); - let msgs = r.render_frame(&empty_state(), &[]); + let msgs = r.render_frame(&empty_state(), FrontendId::LOCAL, &HashMap::new(), &[]); assert_eq!(msgs.len(), 2); assert!(matches!(msgs[0], InstanceMessage::CellDelta { .. })); assert!(matches!(msgs[1], InstanceMessage::Cursor(_))); @@ -195,7 +206,7 @@ mod tests { #[test] fn first_frame_is_full_grid_sync() { let mut r = RenderState::new(CellSize::new(24, 80)); - let msgs = r.render_frame(&empty_state(), &[]); + let msgs = r.render_frame(&empty_state(), FrontendId::LOCAL, &HashMap::new(), &[]); match &msgs[0] { InstanceMessage::CellDelta { full_grid, .. } => assert!(*full_grid), _ => panic!("expected CellDelta first"), @@ -205,8 +216,8 @@ mod tests { #[test] fn second_frame_is_differential() { let mut r = RenderState::new(CellSize::new(24, 80)); - let _ = r.render_frame(&empty_state(), &[]); - let msgs = r.render_frame(&empty_state(), &[]); + let _ = r.render_frame(&empty_state(), FrontendId::LOCAL, &HashMap::new(), &[]); + let msgs = r.render_frame(&empty_state(), FrontendId::LOCAL, &HashMap::new(), &[]); match &msgs[0] { InstanceMessage::CellDelta { full_grid, .. } => assert!(!*full_grid), _ => panic!("expected CellDelta first"), @@ -217,8 +228,8 @@ mod tests { fn unchanged_state_produces_empty_spans_after_first_frame() { let state = empty_state(); let mut r = RenderState::new(CellSize::new(24, 80)); - let _ = r.render_frame(&state, &[]); - let msgs = r.render_frame(&state, &[]); + let _ = r.render_frame(&state, FrontendId::LOCAL, &HashMap::new(), &[]); + let msgs = r.render_frame(&state, FrontendId::LOCAL, &HashMap::new(), &[]); match &msgs[0] { InstanceMessage::CellDelta { spans, .. } => assert!( spans.is_empty(), @@ -231,7 +242,7 @@ mod tests { #[test] fn resize_reallocates_and_flags_full_grid() { let mut r = RenderState::new(CellSize::new(24, 80)); - let _ = r.render_frame(&empty_state(), &[]); + let _ = r.render_frame(&empty_state(), FrontendId::LOCAL, &HashMap::new(), &[]); assert!(!r.needs_full_grid); r.resize(CellSize::new(40, 120)); @@ -240,7 +251,7 @@ mod tests { assert_eq!(r.next.len(), 40 * 120); assert!(r.needs_full_grid); - let msgs = r.render_frame(&empty_state(), &[]); + let msgs = r.render_frame(&empty_state(), FrontendId::LOCAL, &HashMap::new(), &[]); match &msgs[0] { InstanceMessage::CellDelta { full_grid, .. } => assert!(*full_grid), _ => unreachable!(), @@ -250,7 +261,7 @@ mod tests { #[test] fn resize_to_same_size_is_noop() { let mut r = RenderState::new(CellSize::new(24, 80)); - let _ = r.render_frame(&empty_state(), &[]); + let _ = r.render_frame(&empty_state(), FrontendId::LOCAL, &HashMap::new(), &[]); assert!(!r.needs_full_grid); r.resize(CellSize::new(24, 80)); // No reallocation, no full-grid flip. @@ -260,7 +271,7 @@ mod tests { #[test] fn force_full_grid_resync_flips_flag() { let mut r = RenderState::new(CellSize::new(24, 80)); - let _ = r.render_frame(&empty_state(), &[]); + let _ = r.render_frame(&empty_state(), FrontendId::LOCAL, &HashMap::new(), &[]); assert!(!r.needs_full_grid); r.force_full_grid_resync(); assert!(r.needs_full_grid); @@ -270,16 +281,22 @@ mod tests { fn too_small_grid_returns_empty_messages() { // rows < 2 means we can't paint a text-area + status row. let mut r = RenderState::new(CellSize::new(1, 80)); - assert!(r.render_frame(&empty_state(), &[]).is_empty()); + assert!( + r.render_frame(&empty_state(), FrontendId::LOCAL, &HashMap::new(), &[]) + .is_empty() + ); let mut r = RenderState::new(CellSize::new(24, 0)); - assert!(r.render_frame(&empty_state(), &[]).is_empty()); + assert!( + r.render_frame(&empty_state(), FrontendId::LOCAL, &HashMap::new(), &[]) + .is_empty() + ); } #[test] fn cursor_message_carries_coord_when_paint_returns_one() { let mut r = RenderState::new(CellSize::new(24, 80)); - let msgs = r.render_frame(&empty_state(), &[]); + let msgs = r.render_frame(&empty_state(), FrontendId::LOCAL, &HashMap::new(), &[]); match &msgs[1] { InstanceMessage::Cursor(Some(cs)) => { assert!(cs.visible); @@ -309,7 +326,7 @@ mod tests { // Criterion 1: the first frame after construction is a full-grid // CellDelta carrying every non-default cell. let mut r = RenderState::new(CellSize::new(24, 80)); - let msgs = r.render_frame(&empty_state(), &[]); + let msgs = r.render_frame(&empty_state(), FrontendId::LOCAL, &HashMap::new(), &[]); match &msgs[0] { InstanceMessage::CellDelta { full_grid, spans } => { assert!(*full_grid, "first frame must be flagged full_grid=true"); @@ -335,7 +352,7 @@ mod tests { let mut state = EditorState::new(); let mut r = RenderState::new(size); // Seat the prev buffer. - let _ = r.render_frame(&state, &[]); + let _ = r.render_frame(&state, FrontendId::LOCAL, &HashMap::new(), &[]); // Single character insert. state.dispatch_key( @@ -347,7 +364,7 @@ mod tests { state: KeyEventState::empty(), }, ); - let msgs = r.render_frame(&state, &[]); + let msgs = r.render_frame(&state, FrontendId::LOCAL, &HashMap::new(), &[]); match &msgs[0] { InstanceMessage::CellDelta { full_grid, spans } => { assert!(!*full_grid, "differential frame must not flag full_grid"); @@ -374,7 +391,7 @@ mod tests { let mut r = RenderState::new(size); // First render: seats prev with the painted frame. - let first = r.render_frame(&empty_state(), &[]); + let first = r.render_frame(&empty_state(), FrontendId::LOCAL, &HashMap::new(), &[]); let baseline_changed: usize = match &first[0] { InstanceMessage::CellDelta { spans, .. } => spans.iter().map(|s| s.cells.len()).sum(), _ => unreachable!(), @@ -383,7 +400,7 @@ mod tests { // A second render with no state change normally produces zero // spans (the state matches prev exactly). - let unchanged = r.render_frame(&empty_state(), &[]); + let unchanged = r.render_frame(&empty_state(), FrontendId::LOCAL, &HashMap::new(), &[]); match &unchanged[0] { InstanceMessage::CellDelta { full_grid, spans } => { assert!(!*full_grid); @@ -396,7 +413,7 @@ mod tests { // what's on screen. force_full_grid_resync flags the next frame // for full sync. r.force_full_grid_resync(); - let resync = r.render_frame(&empty_state(), &[]); + let resync = r.render_frame(&empty_state(), FrontendId::LOCAL, &HashMap::new(), &[]); match &resync[0] { InstanceMessage::CellDelta { full_grid, spans } => { assert!(*full_grid, "post-resync frame must be full_grid=true"); diff --git a/src/terminal/view.rs b/src/terminal/view.rs index b16c462..d05ac71 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -1,10 +1,18 @@ -//! Per-frontend terminal viewport and selection identities. +//! Per-frontend terminal viewport, selection, copy, and bell projection. //! -//! These types identify projections over one [`super::screen::TerminalScreen`]. -//! They never own or mirror terminal cells. +//! A view stores only logical anchors into one [`TerminalScreen`]. Cells, +//! modes, history, and process state remain session-owned. use crate::buffer::BufferId; +use crate::cell::{Cell, CellCoord, CellSize, Glyph, Style}; use crate::protocol::FrontendId; +use crate::terminal::screen::{ScreenProjection, TerminalModes, TerminalRow}; +use crate::terminal::session::{ + TerminalManager, TerminalSelectionSpan, TerminalSnapshot, +}; +use crate::terminal::{ + MAX_TERMINAL_COLS, MAX_TERMINAL_ROWS, MAX_TERMINAL_VISIBLE_CELLS, +}; use crate::window::WindowId; /// One frontend/window projection of a terminal session. @@ -48,6 +56,17 @@ pub struct TerminalSelection { pub head: LogicalCellAnchor, } +/// Fresh context metadata for Lua/statusline consumers. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct TerminalViewStatus { + /// Geometric live-tail visibility. + pub at_bottom: bool, + /// Physical retained rows between this viewport and the live tail. + pub scroll_offset: u32, + /// Whether the view owns a nonempty selection. + pub selection: bool, +} + /// Mutable state for one [`TerminalViewKey`]. #[derive(Clone, Debug, Default, Eq, PartialEq)] pub struct TerminalViewState { @@ -57,6 +76,10 @@ pub struct TerminalViewState { pub selection: Option, /// Current editor-owned drag endpoint; cleared on release. pub drag: Option, + pub(super) alternate_active: Option, + pub(super) last_bell_count: u64, + pub(super) viewport_size: Option, + pub(super) selection_froze_top: bool, } /// The one authenticated frontend/window allowed to control a session's PTY. @@ -84,3 +107,827 @@ impl TerminalController { self.frontend_id == key.frontend_id && self.window_id == key.window_id } } + +impl TerminalManager { + /// Project one exact view into an owned viewport-sized snapshot. + /// + /// Zero or out-of-range viewports and unknown sessions return `None` + /// without registering view state. + #[must_use] + pub fn snapshot_for_view( + &mut self, + key: TerminalViewKey, + viewport_size: CellSize, + ) -> Option { + if !valid_viewport(viewport_size) { + return None; + } + let session = self.sessions.get(&key.buffer_id)?; + let projection = session.screen.projection(); + let pid = session.pid; + let process = session.process.clone(); + let bell_count = session.screen.bell_count(); + + let state = self.views.entry(key).or_insert_with(|| TerminalViewState { + alternate_active: Some(projection.alternate_active), + last_bell_count: bell_count, + ..TerminalViewState::default() + }); + normalize_state(state, &projection); + state.viewport_size = Some(viewport_size); + Some(project_snapshot( + key.buffer_id, + viewport_size, + &projection, + state, + pid, + process, + )) + } + + /// Scroll one view by physical retained rows. + /// + /// Positive values move toward older rows and negative values move toward + /// the live tail. Returns whether the top anchor changed. + pub fn scroll_view( + &mut self, + key: TerminalViewKey, + viewport_size: CellSize, + lines: i32, + ) -> bool { + if lines == 0 || !valid_viewport(viewport_size) { + return false; + } + let Some(session) = self.sessions.get(&key.buffer_id) else { + return false; + }; + let projection = session.screen.projection(); + let bell_count = session.screen.bell_count(); + let state = self.views.entry(key).or_insert_with(|| TerminalViewState { + alternate_active: Some(projection.alternate_active), + last_bell_count: bell_count, + ..TerminalViewState::default() + }); + normalize_state(state, &projection); + state.viewport_size = Some(viewport_size); + let rows = retained_rows(&projection); + if rows.is_empty() { + return false; + } + let geometry = view_geometry(&rows, state, viewport_size.rows); + let tail_start = rows.len().saturating_sub(viewport_size.rows as usize); + let magnitude = lines.unsigned_abs() as usize; + let next = if lines > 0 { + geometry.start.saturating_sub(magnitude) + } else { + geometry.start.saturating_add(magnitude).min(tail_start) + }; + if next == geometry.start { + return false; + } + state.top = if next == tail_start && state.selection.is_none() { + None + } else { + Some(row_lead(rows[next])) + }; + true + } + + /// Scroll by `lines` using the last nonzero rendered viewport. + pub fn scroll_lines(&mut self, key: TerminalViewKey, lines: i32) -> bool { + if lines == 0 { + return false; + } + let Some(size) = self.views.get(&key).and_then(|state| state.viewport_size) else { + return false; + }; + self.scroll_view(key, size, lines) + } + + /// Scroll by one last-rendered page in `direction` (`1` older, `-1` tail). + pub fn scroll_page(&mut self, key: TerminalViewKey, direction: i32) -> bool { + if direction == 0 { + return false; + } + let Some(size) = self.views.get(&key).and_then(|state| state.viewport_size) else { + return false; + }; + let rows = i32::try_from(size.rows).unwrap_or(i32::MAX); + self.scroll_view(key, size, rows.saturating_mul(direction.signum())) + } + + /// Return fresh geometric status for one registered view. + #[must_use] + pub fn view_status(&mut self, key: TerminalViewKey) -> Option { + let projection = self.sessions.get(&key.buffer_id)?.screen.projection(); + let state = self.views.get_mut(&key)?; + normalize_state(state, &projection); + let rows = retained_rows(&projection); + let size = state.viewport_size?; + let geometry = view_geometry(&rows, state, size.rows); + Some(TerminalViewStatus { + at_bottom: geometry.scroll_offset == 0, + scroll_offset: geometry.scroll_offset, + selection: state.selection.is_some(), + }) + } + + /// Clear selection and resume live-tail following for one view. + pub fn scroll_to_bottom(&mut self, key: TerminalViewKey) -> bool { + let Some(state) = self.views.get_mut(&key) else { + return false; + }; + let changed = state.top.is_some() || state.selection.is_some() || state.drag.is_some(); + state.top = None; + state.selection = None; + state.drag = None; + state.selection_froze_top = false; + changed + } + + /// Serialize one view's current selection from retained terminal rows. + #[must_use] + pub fn copy_selection(&mut self, key: TerminalViewKey) -> Option> { + let session = self.sessions.get(&key.buffer_id)?; + let projection = session.screen.projection(); + let state = self.views.get_mut(&key)?; + normalize_state(state, &projection); + let selection = state.selection?; + let rows = retained_rows(&projection); + copy_selection_bytes(&rows, selection) + } + + /// Start an editor-owned primary selection at a viewport coordinate. + pub fn begin_selection( + &mut self, + key: TerminalViewKey, + viewport_size: CellSize, + coord: CellCoord, + ) -> bool { + let Some(session) = self.sessions.get(&key.buffer_id) else { + return false; + }; + let projection = session.screen.projection(); + let bell_count = session.screen.bell_count(); + let state = self.views.entry(key).or_insert_with(|| TerminalViewState { + alternate_active: Some(projection.alternate_active), + last_bell_count: bell_count, + ..TerminalViewState::default() + }); + normalize_state(state, &projection); + let rows = retained_rows(&projection); + let geometry = view_geometry(&rows, state, viewport_size.rows); + let Some(anchor) = anchor_at(&rows, &geometry, viewport_size, coord) else { + state.selection = None; + state.drag = None; + return false; + }; + state.selection_froze_top = state.top.is_none(); + if state.top.is_none() { + state.top = rows + .get(geometry.start) + .copied() + .map(row_lead); + } + state.selection = Some(TerminalSelection { + anchor, + head: anchor, + }); + state.drag = Some(anchor); + true + } + + /// Move an active editor-owned terminal selection. + pub fn update_selection( + &mut self, + key: TerminalViewKey, + viewport_size: CellSize, + coord: CellCoord, + ) -> bool { + let Some(session) = self.sessions.get(&key.buffer_id) else { + return false; + }; + let projection = session.screen.projection(); + let Some(state) = self.views.get_mut(&key) else { + return false; + }; + normalize_state(state, &projection); + if state.drag.is_none() { + return false; + } + let rows = retained_rows(&projection); + let geometry = view_geometry(&rows, state, viewport_size.rows); + let Some(head) = anchor_at(&rows, &geometry, viewport_size, coord) else { + return false; + }; + let changed = state.selection.is_some_and(|selection| selection.head != head); + if let Some(selection) = state.selection.as_mut() { + selection.head = head; + } + state.drag = Some(head); + changed + } + + /// Finish an editor-owned terminal selection. + pub fn finish_selection( + &mut self, + key: TerminalViewKey, + viewport_size: CellSize, + coord: CellCoord, + ) -> bool { + let moved = self.update_selection(key, viewport_size, coord); + let Some(state) = self.views.get_mut(&key) else { + return false; + }; + let was_dragging = state.drag.take().is_some(); + if state + .selection + .is_some_and(|selection| selection.anchor == selection.head) + { + state.selection = None; + if state.selection_froze_top { + state.top = None; + } + state.selection_froze_top = false; + } + moved || was_dragging + } + + /// Clear one view's terminal selection without changing its scroll anchor. + pub fn clear_selection(&mut self, key: TerminalViewKey) -> bool { + let Some(state) = self.views.get_mut(&key) else { + return false; + }; + let changed = state.selection.take().is_some() || state.drag.take().is_some(); + changed + } + + /// Current child input modes for one session. + #[must_use] + pub fn modes_for_view(&self, key: TerminalViewKey) -> Option { + self.sessions + .get(&key.buffer_id) + .map(|session| session.screen.modes()) + } + + /// Exact controlled view for one frontend, if it still exists. + #[must_use] + pub fn controller_view_for_frontend( + &self, + frontend_id: FrontendId, + ) -> Option { + self.controllers.iter().find_map(|(buffer_id, controller)| { + (controller.frontend_id == frontend_id) + .then(|| TerminalViewKey::new(frontend_id, controller.window_id, *buffer_id)) + }) + } + + /// Observe BEL counters for every live view on one frontend. + /// + /// Returns `true` only for a new bell in `active`; all other counters are + /// advanced so historical bells cannot replay after a later activation. + pub fn take_bell_for_frontend( + &mut self, + frontend_id: FrontendId, + active: Option, + ) -> bool { + let mut ring = false; + for (key, state) in &mut self.views { + if key.frontend_id != frontend_id { + continue; + } + let Some(session) = self.sessions.get(&key.buffer_id) else { + continue; + }; + let current = session.screen.bell_count(); + if Some(*key) == active && current > state.last_bell_count { + ring = true; + } + state.last_bell_count = current; + } + ring + } +} + +#[derive(Clone, Copy)] +struct ResolvedCell { + row: usize, + col: usize, +} + +struct ViewGeometry { + start: usize, + top_padding: usize, + scroll_offset: u32, +} + +fn valid_viewport(size: CellSize) -> bool { + size.rows > 0 + && size.cols > 0 + && size.rows <= u32::from(MAX_TERMINAL_ROWS) + && size.cols <= u32::from(MAX_TERMINAL_COLS) + && size.area() as usize <= MAX_TERMINAL_VISIBLE_CELLS +} + +fn retained_rows(projection: &ScreenProjection) -> Vec<&TerminalRow> { + projection + .history + .iter() + .chain(projection.visible_rows.iter()) + .collect() +} + +fn row_lead(row: &TerminalRow) -> LogicalCellAnchor { + LogicalCellAnchor { + logical_line_id: row.logical_line_id, + cell_offset: row.cell_offset, + } +} + +fn resolve_anchor(rows: &[&TerminalRow], anchor: LogicalCellAnchor) -> Option { + rows.iter().enumerate().find_map(|(row_index, row)| { + if row.logical_line_id != anchor.logical_line_id { + return None; + } + let start = row.cell_offset; + let end = start.saturating_add(row.cells.len() as u32); + if anchor.cell_offset < start || anchor.cell_offset >= end { + return None; + } + let col = (anchor.cell_offset - start) as usize; + Some(ResolvedCell { + row: row_index, + col: canonical_col(row, col), + }) + }) +} + +fn canonical_col(row: &TerminalRow, mut col: usize) -> usize { + col = col.min(row.cells.len().saturating_sub(1)); + while col > 0 && matches!(row.cells[col].glyph, Glyph::Continuation) { + col -= 1; + } + col +} + +fn anchor_for(rows: &[&TerminalRow], resolved: ResolvedCell) -> LogicalCellAnchor { + let row = rows[resolved.row]; + LogicalCellAnchor { + logical_line_id: row.logical_line_id, + cell_offset: row.cell_offset.saturating_add(resolved.col as u32), + } +} + +fn clamp_or_clear( + rows: &[&TerminalRow], + anchor: LogicalCellAnchor, +) -> Option { + if let Some(resolved) = resolve_anchor(rows, anchor) { + return Some(anchor_for(rows, resolved)); + } + let first = rows.first()?; + (anchor.logical_line_id < first.logical_line_id).then(|| row_lead(first)) +} + +fn normalize_state(state: &mut TerminalViewState, projection: &ScreenProjection) { + if state + .alternate_active + .is_some_and(|active| active != projection.alternate_active) + { + state.top = None; + state.selection = None; + state.drag = None; + state.selection_froze_top = false; + } + state.alternate_active = Some(projection.alternate_active); + let rows = retained_rows(projection); + state.top = state.top.and_then(|anchor| clamp_or_clear(&rows, anchor)); + state.selection = state.selection.and_then(|selection| { + let anchor = clamp_or_clear(&rows, selection.anchor)?; + let head = clamp_or_clear(&rows, selection.head)?; + let collapsed_by_clamp = + anchor == head && (anchor != selection.anchor || head != selection.head); + (!collapsed_by_clamp).then_some(TerminalSelection { anchor, head }) + }); + state.drag = state.drag.and_then(|anchor| clamp_or_clear(&rows, anchor)); + if state.selection.is_none() { + state.drag = None; + state.selection_froze_top = false; + } +} + +fn view_geometry( + rows: &[&TerminalRow], + state: &TerminalViewState, + viewport_rows: u32, +) -> ViewGeometry { + let viewport_rows = viewport_rows as usize; + let follow = state.top.is_none() && state.selection.is_none(); + let tail_start = rows.len().saturating_sub(viewport_rows); + let start = if follow { + tail_start + } else { + state + .top + .and_then(|anchor| resolve_anchor(rows, anchor)) + .map_or(tail_start, |resolved| resolved.row) + }; + let top_padding = if follow && rows.len() < viewport_rows { + viewport_rows - rows.len() + } else { + 0 + }; + let rows_after_view = rows.len().saturating_sub(start.saturating_add(viewport_rows)); + ViewGeometry { + start, + top_padding, + scroll_offset: u32::try_from(rows_after_view).unwrap_or(u32::MAX), + } +} + +fn viewport_row( + geometry: &ViewGeometry, + viewport_rows: usize, + retained_row: usize, +) -> Option { + if retained_row < geometry.start { + return None; + } + let row = geometry + .top_padding + .saturating_add(retained_row - geometry.start); + (row < viewport_rows).then_some(row) +} + +fn anchor_at( + rows: &[&TerminalRow], + geometry: &ViewGeometry, + viewport_size: CellSize, + coord: CellCoord, +) -> Option { + if coord.row >= viewport_size.rows || coord.col >= viewport_size.cols { + return None; + } + let viewport_row = coord.row as usize; + if viewport_row < geometry.top_padding { + return None; + } + let retained_row = geometry + .start + .saturating_add(viewport_row - geometry.top_padding); + let row = *rows.get(retained_row)?; + if coord.col as usize >= row.cells.len() { + return None; + } + let col = canonical_col(row, coord.col as usize); + Some(LogicalCellAnchor { + logical_line_id: row.logical_line_id, + cell_offset: row.cell_offset.saturating_add(col as u32), + }) +} + +fn normalized_selection( + rows: &[&TerminalRow], + selection: TerminalSelection, +) -> Option<(ResolvedCell, ResolvedCell)> { + let mut start = resolve_anchor(rows, selection.anchor)?; + let mut end = resolve_anchor(rows, selection.head)?; + if (start.row, start.col) > (end.row, end.col) { + std::mem::swap(&mut start, &mut end); + } + Some((start, end)) +} + +fn glyph_width(row: &TerminalRow, col: usize) -> usize { + if col + 1 < row.cells.len() && matches!(row.cells[col + 1].glyph, Glyph::Continuation) { + 2 + } else { + 1 + } +} + +fn project_snapshot( + buffer_id: BufferId, + viewport_size: CellSize, + projection: &ScreenProjection, + state: &TerminalViewState, + pid: u32, + process: crate::terminal::session::TerminalProcessState, +) -> TerminalSnapshot { + let rows = retained_rows(projection); + let geometry = view_geometry(&rows, state, viewport_size.rows); + let mut cells = vec![Cell::default(); viewport_size.area() as usize]; + for retained_row in geometry.start..rows.len() { + let Some(target_row) = + viewport_row(&geometry, viewport_size.rows as usize, retained_row) + else { + continue; + }; + let row = rows[retained_row]; + let copy_cols = row.cells.len().min(viewport_size.cols as usize); + let target = target_row * viewport_size.cols as usize; + cells[target..target + copy_cols].clone_from_slice(&row.cells[..copy_cols]); + } + + let selection = state + .selection + .and_then(|selection| normalized_selection(&rows, selection)) + .map_or_else(Vec::new, |(start, end)| { + let mut spans = Vec::new(); + for retained_row in start.row..=end.row { + let Some(target_row) = + viewport_row(&geometry, viewport_size.rows as usize, retained_row) + else { + continue; + }; + let row = rows[retained_row]; + let start_col = if retained_row == start.row { + start.col + } else { + 0 + }; + let end_col = if retained_row == end.row { + end.col.saturating_add(glyph_width(row, end.col)) + } else { + row.cells.len() + } + .min(viewport_size.cols as usize); + if start_col < end_col && start_col < viewport_size.cols as usize { + spans.push(TerminalSelectionSpan { + row: target_row as u32, + start_col: start_col as u32, + end_col: end_col as u32, + }); + } + } + spans + }); + + let cursor = if geometry.scroll_offset == 0 { + projection.cursor.and_then(|cursor| { + let retained_row = projection + .history + .len() + .saturating_add(cursor.row as usize); + let row = viewport_row(&geometry, viewport_size.rows as usize, retained_row)?; + (cursor.col < viewport_size.cols).then(|| CellCoord::new(row as u32, cursor.col)) + }) + } else { + None + }; + + TerminalSnapshot { + buffer_id, + size: viewport_size, + cells, + cursor, + title: projection.title.clone(), + screen_generation: projection.generation, + selection, + scroll_offset: geometry.scroll_offset, + at_bottom: geometry.scroll_offset == 0, + pid, + process, + } +} + +fn is_default_blank(cell: &Cell) -> bool { + matches!(cell.glyph, Glyph::Char(' ')) + && cell.style == Style::default() + && cell.attachment.is_none() +} + +fn copy_selection_bytes( + rows: &[&TerminalRow], + selection: TerminalSelection, +) -> Option> { + let (start, end) = normalized_selection(rows, selection)?; + let mut out = Vec::new(); + for row_index in start.row..=end.row { + let row = rows[row_index]; + let from = if row_index == start.row { start.col } else { 0 }; + let mut to = if row_index == end.row { + end.col.saturating_add(glyph_width(row, end.col)) + } else { + row.cells.len() + }; + while to > from && is_default_blank(&row.cells[to - 1]) { + to -= 1; + } + for cell in &row.cells[from..to] { + match &cell.glyph { + Glyph::Char(ch) => { + let mut bytes = [0; 4]; + out.extend_from_slice(ch.encode_utf8(&mut bytes).as_bytes()); + } + Glyph::Cluster(bytes) => out.extend_from_slice(bytes), + Glyph::Continuation => {} + } + } + if row_index < end.row && !row.soft_wrapped { + out.push(b'\n'); + } + } + Some(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::terminal::session::TerminalProcessState; + + fn row(id: u64, offset: u32, text: &str, soft_wrapped: bool) -> TerminalRow { + TerminalRow { + cells: text + .chars() + .map(|ch| Cell { + glyph: Glyph::Char(ch), + style: Style::default(), + attachment: None, + }) + .collect(), + logical_line_id: id, + cell_offset: offset, + soft_wrapped, + } + } + + fn projection(history: Vec, visible_rows: Vec) -> ScreenProjection { + let cols = visible_rows + .first() + .map_or(1, |row| row.cells.len() as u32); + ScreenProjection { + size: CellSize::new(visible_rows.len() as u32, cols), + alternate_active: false, + history, + visible_rows, + cursor: None, + title: Some("shell".into()), + generation: 7, + } + } + + #[test] + fn tail_projection_pads_above_and_right_and_translates_cursor() { + let mut source = projection(Vec::new(), vec![row(1, 0, "abc", false), row(2, 0, "def", false)]); + source.cursor = Some(CellCoord::new(1, 2)); + let snapshot = project_snapshot( + BufferId::next(), + CellSize::new(4, 5), + &source, + &TerminalViewState::default(), + 42, + TerminalProcessState::Running, + ); + assert_eq!(snapshot.cells.len(), 20); + assert!(snapshot.cells[..10].iter().all(|cell| *cell == Cell::default())); + assert_eq!(snapshot.cells[10].glyph, Glyph::Char('a')); + assert_eq!(snapshot.cells[13], Cell::default()); + assert_eq!(snapshot.cells[15].glyph, Glyph::Char('d')); + assert_eq!(snapshot.cursor, Some(CellCoord::new(3, 2))); + assert!(snapshot.at_bottom); + assert_eq!(snapshot.scroll_offset, 0); + } + + #[test] + fn frozen_top_is_geometrically_at_bottom_when_view_still_reaches_tail() { + let source = projection( + vec![row(1, 0, "aaa", false)], + vec![row(2, 0, "bbb", false), row(3, 0, "ccc", false)], + ); + let state = TerminalViewState { + top: Some(LogicalCellAnchor { + logical_line_id: 1, + cell_offset: 0, + }), + selection: Some(TerminalSelection { + anchor: LogicalCellAnchor { + logical_line_id: 2, + cell_offset: 0, + }, + head: LogicalCellAnchor { + logical_line_id: 2, + cell_offset: 1, + }, + }), + ..TerminalViewState::default() + }; + let snapshot = project_snapshot( + BufferId::next(), + CellSize::new(3, 3), + &source, + &state, + 1, + TerminalProcessState::Running, + ); + assert!(snapshot.at_bottom); + assert_eq!(snapshot.scroll_offset, 0); + } + + #[test] + fn copy_joins_soft_wraps_trims_default_blanks_and_separates_hard_rows() { + let rows = vec![ + row(1, 0, "ab ", true), + row(1, 3, "cd ", false), + row(2, 0, "e ", false), + ]; + let refs: Vec<&TerminalRow> = rows.iter().collect(); + let bytes = copy_selection_bytes( + &refs, + TerminalSelection { + anchor: LogicalCellAnchor { + logical_line_id: 1, + cell_offset: 0, + }, + head: LogicalCellAnchor { + logical_line_id: 2, + cell_offset: 2, + }, + }, + ) + .expect("selection resolves"); + assert_eq!(bytes, b"abcd\ne"); + } + + #[test] + fn wide_continuation_canonicalizes_to_lead_and_copies_once() { + let wide = TerminalRow { + cells: vec![ + Cell { + glyph: Glyph::Char('界'), + style: Style::default(), + attachment: None, + }, + Cell { + glyph: Glyph::Continuation, + style: Style::default(), + attachment: None, + }, + Cell::default(), + ], + logical_line_id: 9, + cell_offset: 0, + soft_wrapped: false, + }; + let refs = vec![&wide]; + let continuation = resolve_anchor( + &refs, + LogicalCellAnchor { + logical_line_id: 9, + cell_offset: 1, + }, + ) + .expect("continuation resolves"); + assert_eq!(continuation.col, 0); + let bytes = copy_selection_bytes( + &refs, + TerminalSelection { + anchor: LogicalCellAnchor { + logical_line_id: 9, + cell_offset: 0, + }, + head: LogicalCellAnchor { + logical_line_id: 9, + cell_offset: 1, + }, + }, + ) + .expect("wide selection resolves"); + assert_eq!(bytes, "界".as_bytes()); + } + + #[test] + fn alternate_switch_clears_view_anchors_and_selection() { + let source = ScreenProjection { + size: CellSize::new(1, 3), + alternate_active: true, + history: Vec::new(), + visible_rows: vec![row(10, 0, "alt", false)], + cursor: None, + title: None, + generation: 2, + }; + let mut state = TerminalViewState { + top: Some(LogicalCellAnchor { + logical_line_id: 1, + cell_offset: 0, + }), + selection: Some(TerminalSelection { + anchor: LogicalCellAnchor { + logical_line_id: 1, + cell_offset: 0, + }, + head: LogicalCellAnchor { + logical_line_id: 1, + cell_offset: 1, + }, + }), + alternate_active: Some(false), + ..TerminalViewState::default() + }; + normalize_state(&mut state, &source); + assert_eq!(state.top, None); + assert_eq!(state.selection, None); + assert_eq!(state.alternate_active, Some(true)); + } +}