diff --git a/src/daemon.rs b/src/daemon.rs index 5178d32..0fb8cee 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -1069,10 +1069,20 @@ fn dispatcher_loop( // the sweep below); other-frontend snapshots lag by // at most one tick. Imperceptible at frame cadence. let other_presences = session_registry.other_presences_for(*fid); + let render_size = render_states + .get(fid) + .expect("render_state present for attached grid fid") + .size(); + let terminal_snapshots = editor.prepare_terminal_views(*fid, render_size); let render_state = render_states .get_mut(fid) .expect("render_state present for attached grid fid"); - render_state.render_frame(editor, &other_presences) + render_state.render_frame( + editor, + *fid, + &terminal_snapshots, + &other_presences, + ) }; // T M10.6 per-frontend presence sweep. The snapshot is @@ -1103,7 +1113,7 @@ fn dispatcher_loop( s.negotiated_capabilities.crdt_replica && s.negotiated_protocol_version >= 4 }) && let Some(stream) = streams.get_mut(fid) { - let idle_now = editor.dispatch_idle(); + let idle_now = editor.dispatch_idle_for(*fid); if last_dispatch_idle_sent.get(fid) != Some(&idle_now) { if let Err(e) = write_message(stream, &InstanceMessage::DispatchIdle { idle: idle_now }) @@ -1298,6 +1308,7 @@ fn dispatcher_loop( last_dispatch_idle_sent.remove(fid); last_active_buffer_sent.remove(fid); terminal_bell_baselines.remove(fid); + editor.detach_frontend_input(*fid); editor.terminal_manager.borrow_mut().detach_frontend(*fid); session_registry.unregister_session(*fid); editor @@ -1370,6 +1381,15 @@ fn dispatcher_loop( Err(mpsc::RecvTimeoutError::Disconnected) => break, } + // Accepted terminal context controls PTY size. Apply any focus, + // window, or resize changes before consuming another child-output + // batch so screen reflow and subsequent bytes share one geometry. + for frontend_id in &attached_fids { + if let Some(size) = term_sizes.get(frontend_id).copied() { + editor.sync_terminal_layout(*frontend_id, size); + } + } + // `tick_async` last: the M4.5 async bridge settles awaiters // inside `tick_lsp` (via the message bus); draining + resuming // in the same frame keeps LSP `:await()` latency at one frame @@ -1658,6 +1678,12 @@ fn handle_dispatcher_event( editor.dispatch_menu_pointer(source, index, invoke); } } + FrontendEvent::FocusGained(_) => { + editor.dispatch_focus(source, true); + } + FrontendEvent::FocusLost(_) => { + editor.dispatch_focus(source, false); + } FrontendEvent::Paste { frontend_id: claimed_fid, data, @@ -1680,7 +1706,9 @@ fn handle_dispatcher_event( // source's command chain (Q#KR2), and it fires // `buffer.after-edit` like any other edit (Q#KR10b) // — previously it never did, so LSP missed pastes. - handle_inbound_paste(editor, source, claimed_fid, &data); + if !editor.dispatch_paste(source, &data) { + handle_inbound_paste(editor, source, claimed_fid, &data); + } } _ => { let term_size = *term_sizes @@ -1721,6 +1749,7 @@ fn handle_dispatcher_event( last_dispatch_idle_sent.remove(&frontend_id); last_active_buffer_sent.remove(&frontend_id); terminal_bell_baselines.remove(&frontend_id); + editor.detach_frontend_input(frontend_id); editor .terminal_manager .borrow_mut() diff --git a/src/editor_core.rs b/src/editor_core.rs index 0fcbf2b..4625de9 100644 --- a/src/editor_core.rs +++ b/src/editor_core.rs @@ -2484,8 +2484,13 @@ impl EditorCore { /// read (`C-k`'s killed line, an appended chain), so the Lua ring /// pushes the exact bytes here. pub fn clipboard_set(&mut self, bytes: Vec) { + self.clipboard_set_for(self.active_frontend, bytes); + } + + /// Set and publish clipboard bytes to one authenticated frontend. + pub fn clipboard_set_for(&mut self, frontend_id: FrontendId, bytes: Vec) { self.clipboard_slot.clone_from(&bytes); - self.pending_clipboard = Some((self.active_frontend, bytes)); + self.pending_clipboard = Some((frontend_id, bytes)); } /// The clipboard slot's current bytes, or `None` when empty (kill @@ -2895,15 +2900,21 @@ impl EditorCore { .map_err(|e| e.to_string()) } - /// Switch the active window to a different buffer, allocating a - /// fresh [`TextView`] for it. - pub fn switch_active_buffer(&mut self, buffer_id: BufferId) -> Result<(), String> { + /// Switch one frontend's active window to a different buffer, allocating + /// a fresh [`TextView`] for it without changing global active state. + pub fn switch_active_buffer_for( + &mut self, + frontend_id: FrontendId, + buffer_id: BufferId, + ) -> Result<(), String> { let text_view = { let reg = self.registry.borrow(); let buf = reg.get(buffer_id).map_err(|e| e.to_string())?; TextView::new(buf) }; - let aw = self.active_window_mut(); + let aw = self + .active_window_mut_for(frontend_id) + .ok_or_else(|| format!("frontend {frontend_id:?} has no active window"))?; aw.buffer_id = buffer_id; aw.text_view = text_view; // Overlays were keyed to the previous buffer's coordinates; @@ -2917,6 +2928,11 @@ impl EditorCore { aw.goal_col = None; Ok(()) } + + /// Switch the globally active frontend's active window. + pub fn switch_active_buffer(&mut self, buffer_id: BufferId) -> Result<(), String> { + self.switch_active_buffer_for(self.active_frontend, buffer_id) + } } // --------------------------------------------------------------------------- diff --git a/src/lua_bindings/mod.rs b/src/lua_bindings/mod.rs index 38fb010..cb25a00 100644 --- a/src/lua_bindings/mod.rs +++ b/src/lua_bindings/mod.rs @@ -55,6 +55,7 @@ use crate::buffer_registry::BufferRegistry; use crate::cell::{Color, Style, UnderlineStyle}; use crate::command::{Command, CommandError, CommandRegistry, SourceLocation}; use crate::editor_core::EditorCore; +use crate::editor::InteractiveCommandOrigin; use crate::highlight::SyntaxHighlightView; use crate::hook::{Hook, HookRegistry}; use crate::key::{display_sequence, parse_sequence}; @@ -8134,6 +8135,111 @@ pub fn make_terminal_manager( Ok(manager) } +fn terminal_shared_core(lua: &Lua, operation: &str) -> mlua::Result { + lua.app_data_ref::() + .map(|core| core.clone()) + .ok_or_else(|| { + mlua::Error::external(format!( + "pmacs.terminal.{operation}: editor core unavailable" + )) + }) +} + +fn terminal_command_frontend(lua: &Lua, core: &SharedCore) -> crate::protocol::FrontendId { + lua.app_data_ref::() + .and_then(|origin| origin.current()) + .unwrap_or_else(|| core.borrow().active_frontend) +} + +fn active_terminal_view_key( + lua: &Lua, + core: &SharedCore, + operation: &str, +) -> mlua::Result { + let frontend_id = lua + .app_data_ref::() + .and_then(|origin| origin.current()) + .ok_or_else(|| { + mlua::Error::external(format!( + "pmacs.terminal.{operation}: requires an interactive frontend context" + )) + })?; + let core = core.borrow(); + let window = core.active_window_for(frontend_id).ok_or_else(|| { + mlua::Error::external(format!( + "pmacs.terminal.{operation}: invoking frontend has no active window" + )) + })?; + Ok(crate::terminal::TerminalViewKey::new( + frontend_id, + core.views + .get(&frontend_id) + .expect("active window implies registered frontend view") + .active, + window.buffer_id, + )) +} + +fn terminal_view_key_from_context( + core: &SharedCore, + context: &Table, +) -> mlua::Result> { + const FIELDS: &[&str] = &["frontend", "window", "buffer", "active"]; + let mut unknown = None; + context.clone().for_each(|key: Value, _: Value| { + if unknown.is_none() { + match key { + Value::String(value) => { + let value = value.to_str()?; + if !FIELDS.contains(&value.as_ref()) { + unknown = Some(value.to_owned()); + } + } + other => unknown = Some(format!("{other:?}")), + } + } + Ok(()) + })?; + if let Some(field) = unknown { + return Err(mlua::Error::external(format!( + "pmacs.terminal.view_state: unknown field `{field}`" + ))); + } + let frontend_raw = context.get::("frontend")?; + let frontend_id = crate::protocol::FrontendId(u64::try_from(frontend_raw).map_err(|_| { + mlua::Error::external("pmacs.terminal.view_state: `frontend` must be nonnegative") + })?); + let window_raw = u64::try_from(context.get::("window")?).map_err(|_| { + mlua::Error::external("pmacs.terminal.view_state: `window` must be nonnegative") + })?; + let buffer_id = context.get::("buffer")?.0; + + let core = core.borrow(); + let Some(view) = core.views.get(&frontend_id) else { + return Ok(None); + }; + let Some(window_id) = view + .layout + .iter_ids() + .into_iter() + .find(|window_id| window_id.raw() == window_raw) + else { + return Ok(None); + }; + if core + .windows + .get(&window_id) + .is_none_or(|window| window.buffer_id != buffer_id) + { + return Ok(None); + } + Ok(Some(crate::terminal::TerminalViewKey::new( + frontend_id, + window_id, + buffer_id, + ))) +} + fn install_terminal( lua: &Lua, manager: &crate::terminal::SharedTerminalManager, @@ -8155,6 +8261,12 @@ fn install_terminal( .ok_or_else(|| { mlua::Error::external("pmacs.terminal.open: editor core unavailable") })?; + let frontend_id = terminal_command_frontend(lua, &core); + if core.borrow().active_window_for(frontend_id).is_none() { + return Err(mlua::Error::external( + "pmacs.terminal.open: target frontend has no active window", + )); + } let buffer_id = { let mut manager = manager.borrow_mut(); manager @@ -8167,7 +8279,7 @@ fn install_terminal( }; let key = { let mut core = core.borrow_mut(); - if let Err(error) = core.switch_active_buffer(buffer_id) { + if let Err(error) = core.switch_active_buffer_for(frontend_id, buffer_id) { let _ = core.registry.borrow_mut().remove(buffer_id); manager .borrow_mut() @@ -8177,18 +8289,27 @@ fn install_terminal( ))); } crate::terminal::TerminalViewKey::new( - core.active_frontend, - core.active_window_id(), + frontend_id, + core.views + .get(&frontend_id) + .expect("checked frontend has active view") + .active, buffer_id, ) }; - { + let claimed = { let mut manager = manager.borrow_mut(); - if !manager.register_view(key) || !manager.claim_controller(key) { - return Err(mlua::Error::external( - "pmacs.terminal.open: failed to claim the new terminal view", - )); - } + manager.register_view(key) && manager.claim_controller(key) + }; + if !claimed { + let mut core = core.borrow_mut(); + let _ = core.registry.borrow_mut().remove(buffer_id); + manager + .borrow_mut() + .prune(&mut core, &mut supervisor.borrow_mut()); + return Err(mlua::Error::external( + "pmacs.terminal.open: failed to claim the new terminal view", + )); } run_hook_if_defined(lua, "buffer.after-switch", mlua::MultiValue::new()); Ok(BufferIdLua(buffer_id)) @@ -8250,6 +8371,88 @@ fn install_terminal( )?; } + { + let manager = manager.clone(); + terminal.set( + "view_state", + lua.create_function(move |lua, context: Table| -> mlua::Result> { + let core = terminal_shared_core(lua, "view_state")?; + let Some(key) = terminal_view_key_from_context(&core, &context)? else { + return Ok(None); + }; + let Some(status) = manager.borrow_mut().view_status(key) else { + return Ok(None); + }; + let table = lua.create_table()?; + table.set("at_bottom", status.at_bottom)?; + table.set("scroll_offset", status.scroll_offset)?; + table.set("selection", status.selection)?; + Ok(Some(table)) + })?, + )?; + } + + { + let manager = manager.clone(); + terminal.set( + "scroll", + lua.create_function(move |lua, lines: i64| { + let lines = i32::try_from(lines).map_err(|_| { + mlua::Error::external("pmacs.terminal.scroll: `lines` exceeds i32 range") + })?; + let core = terminal_shared_core(lua, "scroll")?; + let key = active_terminal_view_key(lua, &core, "scroll")?; + Ok(manager.borrow_mut().scroll_lines(key, lines)) + })?, + )?; + } + + { + let manager = manager.clone(); + terminal.set( + "_scroll_page", + lua.create_function(move |lua, direction: i64| { + let direction = i32::try_from(direction).map_err(|_| { + mlua::Error::external( + "pmacs.terminal._scroll_page: `direction` exceeds i32 range", + ) + })?; + let core = terminal_shared_core(lua, "_scroll_page")?; + let key = active_terminal_view_key(lua, &core, "_scroll_page")?; + Ok(manager.borrow_mut().scroll_page(key, direction)) + })?, + )?; + } + + { + let manager = manager.clone(); + terminal.set( + "scroll_to_bottom", + lua.create_function(move |lua, ()| { + let core = terminal_shared_core(lua, "scroll_to_bottom")?; + let key = active_terminal_view_key(lua, &core, "scroll_to_bottom")?; + Ok(manager.borrow_mut().scroll_to_bottom(key)) + })?, + )?; + } + + { + let manager = manager.clone(); + terminal.set( + "copy_selection", + lua.create_function(move |lua, ()| { + let core = terminal_shared_core(lua, "copy_selection")?; + let key = active_terminal_view_key(lua, &core, "copy_selection")?; + let Some(bytes) = manager.borrow_mut().copy_selection(key) else { + return Ok(false); + }; + core.borrow_mut() + .clipboard_set_for(key.frontend_id, bytes); + Ok(true) + })?, + )?; + } + pmacs.set("terminal", terminal) } diff --git a/src/semantic_render.rs b/src/semantic_render.rs index 14ff5a7..35b6389 100644 --- a/src/semantic_render.rs +++ b/src/semantic_render.rs @@ -3824,7 +3824,12 @@ mod tests { let buffer_id = active_buffer(&state); sem.set_viewport(buffer_id, ByteRange { start: 0, end: 80 }, 0); - let grid_msgs = grid.render_frame(&state, &[]); + let grid_msgs = grid.render_frame( + &state, + FrontendId::LOCAL, + &HashMap::new(), + &[], + ); let sem_msgs = sem.render_frame(&state); assert!( matches!(grid_msgs[0], InstanceMessage::CellDelta { .. }),