//! 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::client::blit::{BlitEncoder, EncodedBlit}; use crate::server::protocol::{ CursorState, FrameData, RenderEncoding, ServerMessage, TerminalFrame, }; /// 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 encoded = blit_encoder.encode(frame, false); 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 note_oversized_frame(&mut self, frame: FrameData) { if let Self::Semantic { last_frame } = self { *last_frame = Some(frame); } } 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), } } } /// 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, }) } } 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. pub(crate) fn render_virtual( app_state: &mut AppState, area: Rect, resize_panes: bool, ) -> (ratatui::buffer::Buffer, Option) { if resize_panes { crate::ui::compute_view(app_state, area); } else { crate::ui::compute_view_without_resizing_panes(app_state, 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(app_state, frame); }) .expect("render to TestBackend should never fail"); let buffer = terminal.backend().buffer().clone(); let cursor = focused_terminal_cursor(app_state).or_else(|| terminal.backend().rendered_cursor()); (buffer, cursor) } pub(crate) fn visible_hyperlinks(app_state: &AppState) -> 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.runtimes.get(&info.id) { links.extend(runtime.visible_hyperlinks(info.inner_rect)); } } links } fn focused_terminal_cursor(app_state: &AppState) -> Option { if app_state.mode != Mode::Terminal { return None; } let ws_idx = app_state.active?; let ws = app_state.workspaces.get(ws_idx)?; let info = app_state .view .pane_infos .iter() .find(|info| info.is_focused)?; let rt = ws.runtimes.get(&info.id)?; let cursor = rt.cursor_state(info.inner_rect, true)?; Some(CursorState { x: cursor.x, y: cursor.y, visible: cursor.visible, }) }