//! Virtual rendering helpers for headless client frame streaming. use ratatui::backend::{Backend, ClearType, TestBackend, WindowSize}; use ratatui::layout::{Position, Rect, Size}; use crate::app::state::AppState; use crate::app::Mode; use crate::protocol::render_ansi::{BlitEncoder, EncodedBlit}; use crate::protocol::{CursorState, FrameData, RenderEncoding, ServerMessage, TerminalFrame}; use crate::terminal::TerminalRuntimeRegistry; /// Per-client render baseline for the negotiated render encoding. pub(crate) enum ClientRenderState { /// Semantic clients compare full frame data and skip identical frames. Semantic { last_frame: Option }, /// Terminal-ANSI clients keep a terminal diff encoder and sequence number. TerminalAnsi { blit_encoder: BlitEncoder, seq: u64 }, } impl ClientRenderState { pub(crate) fn new(render_encoding: RenderEncoding) -> Self { match render_encoding { RenderEncoding::SemanticFrame => Self::Semantic { last_frame: None }, RenderEncoding::TerminalAnsi => Self::TerminalAnsi { blit_encoder: BlitEncoder::new(), seq: 0, }, } } pub(crate) fn reset_baseline(&mut self) { match self { Self::Semantic { last_frame } => *last_frame = None, Self::TerminalAnsi { blit_encoder, .. } => *blit_encoder = BlitEncoder::new(), } } pub(crate) fn reset_semantic_input_baseline(&mut self) { if let Self::Semantic { last_frame } = self { *last_frame = None; } } pub(crate) fn prepare_frame(&mut self, frame: &FrameData) -> Option { match self { Self::Semantic { last_frame } => { if last_frame.as_ref() == Some(frame) { return None; } Some(PreparedRender { message: ServerMessage::Frame(frame.clone()), encoded: None, }) } Self::TerminalAnsi { blit_encoder, seq } => { if blit_encoder.is_current(frame) { return None; } let mut encoded = blit_encoder.encode(frame, false); insert_graphics_before_sync_end(&mut encoded.bytes, &frame.graphics); Some(PreparedRender { message: ServerMessage::Terminal(TerminalFrame { seq: *seq + 1, width: frame.width, height: frame.height, full: encoded.full, bytes: encoded.bytes.clone(), }), encoded: Some(encoded), }) } } } pub(crate) fn commit_sent_frame(&mut self, frame: FrameData, prepared: PreparedRender) { match (self, prepared.encoded) { (Self::Semantic { last_frame }, None) => *last_frame = Some(frame), (Self::TerminalAnsi { blit_encoder, seq }, Some(encoded)) => { blit_encoder.commit(frame, encoded); *seq += 1; } _ => {} } } #[cfg(test)] pub(crate) fn terminal_seq(&self) -> Option { match self { Self::Semantic { .. } => None, Self::TerminalAnsi { seq, .. } => Some(*seq), } } } const SYNC_OUTPUT_END: &[u8] = b"\x1b[?2026l"; fn insert_graphics_before_sync_end(encoded: &mut Vec, graphics: &[u8]) { if graphics.is_empty() { return; } if let Some(sync_end) = rfind_subslice(encoded, SYNC_OUTPUT_END) { encoded.splice(sync_end..sync_end, graphics.iter().copied()); } else { encoded.extend_from_slice(graphics); } } fn rfind_subslice(haystack: &[u8], needle: &[u8]) -> Option { if needle.is_empty() || needle.len() > haystack.len() { return None; } haystack .windows(needle.len()) .rposition(|window| window == needle) } /// A prepared client render message plus any baseline state needed after send. pub(crate) struct PreparedRender { message: ServerMessage, encoded: Option, } impl PreparedRender { pub(crate) fn message(&self) -> &ServerMessage { &self.message } } struct CursorTrackingBackend { inner: TestBackend, rendered_cursor: Option, } impl CursorTrackingBackend { fn new(width: u16, height: u16) -> Self { Self { inner: TestBackend::new(width, height), rendered_cursor: None, } } fn buffer(&self) -> &ratatui::buffer::Buffer { self.inner.buffer() } fn rendered_cursor(&self) -> Option { self.rendered_cursor.map(|pos| CursorState { x: pos.x, y: pos.y, visible: true, shape: 0, }) } } impl Backend for CursorTrackingBackend { type Error = std::convert::Infallible; fn draw<'a, I>(&mut self, content: I) -> Result<(), Self::Error> where I: Iterator, { self.inner.draw(content) } fn append_lines(&mut self, n: u16) -> Result<(), Self::Error> { self.inner.append_lines(n) } fn hide_cursor(&mut self) -> Result<(), Self::Error> { self.inner.hide_cursor()?; self.rendered_cursor = None; Ok(()) } fn show_cursor(&mut self) -> Result<(), Self::Error> { self.inner.show_cursor() } fn get_cursor_position(&mut self) -> Result { self.inner.get_cursor_position() } fn set_cursor_position>(&mut self, position: P) -> Result<(), Self::Error> { let position = position.into(); self.inner.set_cursor_position(position)?; self.rendered_cursor = Some(position); Ok(()) } fn clear(&mut self) -> Result<(), Self::Error> { self.inner.clear() } fn clear_region(&mut self, clear_type: ClearType) -> Result<(), Self::Error> { self.inner.clear_region(clear_type) } fn size(&self) -> Result { self.inner.size() } fn window_size(&mut self) -> Result { self.inner.window_size() } fn flush(&mut self) -> Result<(), Self::Error> { self.inner.flush() } } /// Renders the AppState to an in-memory ratatui Buffer. /// /// This produces the same output as the monolithic binary's terminal draw, /// but writes to a `Buffer` instead of stdout. Cursor visibility is captured /// from explicit frame cursor intent rather than incidental backend state. #[cfg_attr(not(test), allow(dead_code))] pub(crate) fn render_virtual( app_state: &mut AppState, area: Rect, resize_panes: bool, ) -> (ratatui::buffer::Buffer, Option) { let terminal_runtimes = TerminalRuntimeRegistry::new(); render_virtual_with_runtime_registry( app_state, &terminal_runtimes, area, resize_panes, crate::kitty_graphics::HostCellSize::default(), ) } pub(crate) fn render_virtual_with_runtime_registry( app_state: &mut AppState, terminal_runtimes: &TerminalRuntimeRegistry, area: Rect, resize_panes: bool, cell_size: crate::kitty_graphics::HostCellSize, ) -> (ratatui::buffer::Buffer, Option) { if resize_panes { crate::ui::compute_view_with_cell_size(app_state, terminal_runtimes, area, cell_size); } else { crate::ui::compute_view_without_resizing_panes(app_state, terminal_runtimes, area); } let backend = CursorTrackingBackend::new(area.width, area.height); let mut terminal = ratatui::Terminal::new(backend).expect("TestBackend::new should never fail"); terminal .draw(|frame| { crate::ui::render_with_runtime_registry(app_state, terminal_runtimes, frame); }) .expect("render to TestBackend should never fail"); let buffer = terminal.backend().buffer().clone(); let cursor = focused_terminal_cursor(app_state, terminal_runtimes) .or_else(|| terminal.backend().rendered_cursor()); (buffer, cursor) } /// Renders one server-owned terminal directly for `terminal attach` clients. pub(crate) fn render_terminal_virtual( runtime: &crate::terminal::TerminalRuntime, area: Rect, ) -> (ratatui::buffer::Buffer, Option) { let backend = CursorTrackingBackend::new(area.width, area.height); let mut terminal = ratatui::Terminal::new(backend).expect("TestBackend::new should never fail"); terminal .draw(|frame| { runtime.render(frame, area, true); }) .expect("render to TestBackend should never fail"); let buffer = terminal.backend().buffer().clone(); let cursor = runtime .cursor_state(area, true) .map(|cursor| CursorState { x: cursor.x, y: cursor.y, visible: cursor.visible && !crate::ui::pane_is_scrolled_back(runtime), shape: cursor.shape, }) .or_else(|| terminal.backend().rendered_cursor()); (buffer, cursor) } pub(crate) fn visible_hyperlinks( app_state: &AppState, terminal_runtimes: &TerminalRuntimeRegistry, ) -> Vec<((u16, u16), String, String)> { let Some(ws_idx) = app_state.active else { return Vec::new(); }; let Some(tab) = app_state .workspaces .get(ws_idx) .and_then(crate::workspace::Workspace::active_tab) else { return Vec::new(); }; let mut links = Vec::new(); for info in &app_state.view.pane_infos { if let Some(runtime) = tab .terminal_id(info.id) .and_then(|terminal_id| terminal_runtimes.get(terminal_id)) { links.extend(runtime.visible_hyperlinks(info.inner_rect)); } } links } fn focused_terminal_cursor( app_state: &AppState, terminal_runtimes: &TerminalRuntimeRegistry, ) -> Option { if app_state.mode != Mode::Terminal { return None; } let ws_idx = app_state.active?; let info = app_state .view .pane_infos .iter() .find(|info| info.is_focused)?; let rt = app_state.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, info.id)?; let scrolled_back = crate::ui::pane_is_scrolled_back(rt); // Determine whether the IME-anchor reveal applies to this focused pane. // The master switch must be on, and either no agent filter is configured // (apply to any pane) or the focused pane's detected agent matches the // allow-list. A configured list with no valid entries reveals nothing. let reveal = app_state.reveal_hidden_cursor_for_cjk_ime && (!app_state.cjk_ime_agent_filter_configured || { let detected = app_state .workspaces .get(ws_idx) .and_then(|ws| ws.terminal_id(info.id)) .and_then(|tid| app_state.terminals.get(tid)) .and_then(|t| t.detected_agent); detected.is_some_and(|agent| app_state.cjk_ime_agents.contains(&agent)) }); if let Some(cursor) = rt.cursor_state(info.inner_rect, true) { // When the reveal applies, expose the cursor anchor regardless of the // pane's `?25l` request so macOS IMEs keep tracking the candidate // window when TUIs paint their own cursor. Scrollback suppression // still applies. let visible = if reveal { !scrolled_back } else { cursor.visible && !scrolled_back }; Some(CursorState { x: cursor.x, y: cursor.y, visible, shape: if reveal && visible { app_state.cjk_ime_cursor_shape } else { cursor.shape }, }) } else if reveal && !scrolled_back { // cursor_state() returned None — the viewport has no cursor position // (can happen with complex TUIs). Fall back to the pane's top-left so // the outer terminal still exposes a cursor anchor for IME tracking. Some(CursorState { x: info.inner_rect.x, y: info.inner_rect.y, visible: true, shape: app_state.cjk_ime_cursor_shape, }) } else { None } }