//! Virtual rendering helpers for headless client frame streaming. use std::collections::HashSet; 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::selection::Selection; use crate::terminal::{TerminalRuntime, 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) { crate::render_prof::event("prepare_frame.semantic.skip_current"); return None; } crate::render_prof::event("prepare_frame.semantic.changed"); Some(PreparedRender::Semantic { message: ServerMessage::Frame(frame), }) } Self::TerminalAnsi { blit_encoder, seq } => { if blit_encoder.is_current(&frame) { crate::render_prof::event("prepare_frame.ansi.skip_current"); return None; } let mut encoded = blit_encoder.encode(&frame, false); crate::render_prof::event("prepare_frame.ansi.changed"); crate::render_prof::counter("prepare_frame.ansi.bytes", encoded.bytes.len() as u64); if encoded.full { crate::render_prof::event("prepare_frame.ansi.full"); } else { crate::render_prof::event("prepare_frame.ansi.partial"); } insert_graphics_before_sync_end(&mut encoded.bytes, &frame.graphics); crate::render_prof::counter( "prepare_frame.graphics.bytes", frame.graphics.len() as u64, ); Some(PreparedRender::TerminalAnsi { message: ServerMessage::Terminal(TerminalFrame { seq: *seq + 1, width: frame.width, height: frame.height, full: encoded.full, bytes: encoded.bytes.clone(), }), frame, encoded: Some(encoded), }) } } } pub(crate) fn last_frame(&self) -> Option<&FrameData> { match self { Self::Semantic { last_frame } => last_frame.as_ref(), Self::TerminalAnsi { blit_encoder, .. } => blit_encoder.last_frame(), } } pub(crate) fn commit_sent_frame(&mut self, prepared: PreparedRender) { match (self, prepared) { ( Self::Semantic { last_frame }, PreparedRender::Semantic { message: ServerMessage::Frame(frame), }, ) => *last_frame = Some(frame), ( Self::TerminalAnsi { blit_encoder, seq }, PreparedRender::TerminalAnsi { frame, encoded: 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) enum PreparedRender { Semantic { message: ServerMessage, }, TerminalAnsi { message: ServerMessage, frame: FrameData, encoded: Option, }, } impl PreparedRender { pub(crate) fn message(&self) -> &ServerMessage { match self { Self::Semantic { message } | Self::TerminalAnsi { message, .. } => message, } } pub(crate) fn into_frame(self) -> Option { match self { Self::Semantic { message: ServerMessage::Frame(frame), } => Some(frame), Self::TerminalAnsi { frame, .. } => Some(frame), _ => None, } } } 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) { let pre_compute_suppresses_focused_terminal_cursor = focused_terminal_suppresses_host_cursor(app_state, terminal_runtimes); 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 suppress_focused_terminal_cursor = pre_compute_suppresses_focused_terminal_cursor || focused_terminal_suppresses_host_cursor(app_state, terminal_runtimes); 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 = if suppress_focused_terminal_cursor { None } else { focused_terminal_cursor(app_state, terminal_runtimes).or_else(|| { (!focused_terminal_owns_host_cursor(app_state, terminal_runtimes)) .then(|| terminal.backend().rendered_cursor()) .flatten() }) }; (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 suppress_cursor = runtime.synchronized_output_active(); 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 = (!suppress_cursor) .then(|| runtime.cursor_state(area, true)) .flatten() .map(|cursor| CursorState { x: cursor.x, y: cursor.y, visible: cursor.visible && !crate::ui::pane_is_scrolled_back(runtime), shape: cursor.shape, }) .or_else(|| { (!suppress_cursor) .then(|| terminal.backend().rendered_cursor()) .flatten() }); (buffer, cursor) } pub(crate) fn visible_hyperlinks( app_state: &AppState, terminal_runtimes: &TerminalRuntimeRegistry, final_buffer: Option<&ratatui::buffer::Buffer>, ) -> Vec<((u16, u16), String, String)> { let Some(ws_idx) = app_state.active else { return Vec::new(); }; if app_state.workspaces.get(ws_idx).is_none() { return Vec::new(); } let mut links = Vec::new(); let mut occupied = HashSet::new(); for info in &app_state.view.pane_infos { if let Some(runtime) = app_state.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, info.id) { for link in runtime.visible_hyperlinks(info.inner_rect) { occupied.insert(link.0); links.push(link); } let metrics = app_state.pane_scroll_metrics(terminal_runtimes, info.id); for link in plain_url_hyperlinks_for_pane( runtime, info.id, info.inner_rect, metrics, final_buffer, ) { if occupied.insert(link.0) { links.push(link); } } } } links } fn plain_url_hyperlinks_for_pane( runtime: &TerminalRuntime, pane_id: crate::layout::PaneId, area: Rect, metrics: Option, final_buffer: Option<&ratatui::buffer::Buffer>, ) -> Vec<((u16, u16), String, String)> { if area.width == 0 || area.height == 0 { return Vec::new(); } let selection = Selection::line_range( pane_id, Selection::absolute_row_for_viewport(0, metrics), Selection::absolute_row_for_viewport(area.height.saturating_sub(1), metrics), area.width.saturating_sub(1), ); let Some(text) = runtime.extract_selection(&selection) else { return Vec::new(); }; let mut links = Vec::new(); let cells = crate::app::actions::visible_text_cells(&text, area.width); let mut line_start = 0; for (byte_idx, ch) in text.char_indices() { if ch == '\n' { push_plain_url_line_links( &mut links, &text[line_start..byte_idx], line_start, &cells, area, ); line_start = byte_idx + ch.len_utf8(); } } push_plain_url_line_links(&mut links, &text[line_start..], line_start, &cells, area); if links.is_empty() { return links; } let pane_buffer = final_buffer.and_then(|_| render_runtime_buffer(runtime, area)); links .into_iter() .filter(|((x, y), _, _)| { pane_cell_is_visible( x.saturating_sub(area.x), y.saturating_sub(area.y), *x, *y, pane_buffer.as_ref(), final_buffer, ) }) .collect() } fn render_runtime_buffer(runtime: &TerminalRuntime, area: Rect) -> Option { let backend = TestBackend::new(area.width, area.height); let mut terminal = ratatui::Terminal::new(backend).ok()?; terminal .draw(|frame| { runtime.render(frame, Rect::new(0, 0, area.width, area.height), false); }) .ok()?; Some(terminal.backend().buffer().clone()) } fn push_plain_url_line_links( links: &mut Vec<((u16, u16), String, String)>, line: &str, line_start: usize, cells: &[crate::app::actions::VisibleTextCell], area: Rect, ) { for (start_col, end_col, url) in crate::app::actions::visible_url_spans(line) { for cell in cells.iter().filter(|cell| { cell.byte_index >= line_start && cell.byte_index < line_start + line.len() && cell.logical_col >= start_col && cell.logical_col <= end_col && cell.screen_row < area.height }) { let x = area.x.saturating_add(cell.screen_col); let y = area.y.saturating_add(cell.screen_row); links.push(((x, y), cell.ch.to_string(), url.to_owned())); } } } fn pane_cell_is_visible( pane_x: u16, pane_y: u16, frame_x: u16, frame_y: u16, pane_buffer: Option<&ratatui::buffer::Buffer>, final_buffer: Option<&ratatui::buffer::Buffer>, ) -> bool { let (Some(pane_buffer), Some(final_buffer)) = (pane_buffer, final_buffer) else { return true; }; let Some(pane_cell) = pane_buffer.cell((pane_x, pane_y)) else { return false; }; let Some(final_cell) = final_buffer.cell((frame_x, frame_y)) else { return false; }; pane_cell.symbol() == final_cell.symbol() && pane_cell.fg == final_cell.fg && pane_cell.bg == final_cell.bg && pane_cell.modifier == final_cell.modifier } pub(crate) 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)?; if !app_state.pane_exposes_host_cursor(ws_idx, info.id) { return None; } let rt = app_state.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, info.id)?; if rt.synchronized_output_active() { return None; } 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 } } fn focused_terminal_owns_host_cursor( app_state: &AppState, terminal_runtimes: &TerminalRuntimeRegistry, ) -> bool { if app_state.mode != Mode::Terminal { return false; } let Some(ws_idx) = app_state.active else { return false; }; let Some(info) = app_state .view .pane_infos .iter() .find(|info| info.is_focused) else { return false; }; if !app_state.pane_exposes_host_cursor(ws_idx, info.id) { return false; } app_state .runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, info.id) .is_some() } fn focused_terminal_suppresses_host_cursor( app_state: &AppState, terminal_runtimes: &TerminalRuntimeRegistry, ) -> bool { if app_state.mode != Mode::Terminal { return false; } let Some(ws_idx) = app_state.active else { return false; }; let Some(info) = app_state .view .pane_infos .iter() .find(|info| info.is_focused) else { return false; }; if !app_state.pane_exposes_host_cursor(ws_idx, info.id) { return false; } app_state .runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, info.id) .is_some_and(crate::terminal::TerminalRuntime::synchronized_output_active) }