diff options
Diffstat (limited to 'src/server/render_stream.rs')
| -rw-r--r-- | src/server/render_stream.rs | 267 |
1 files changed, 267 insertions, 0 deletions
diff --git a/src/server/render_stream.rs b/src/server/render_stream.rs new file mode 100644 index 00000000..f1749657 --- /dev/null +++ b/src/server/render_stream.rs @@ -0,0 +1,267 @@ +//! 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<FrameData> }, + /// 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<PreparedRender> { + 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<u64> { + 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<EncodedBlit>, +} + +impl PreparedRender { + pub(crate) fn message(&self) -> &ServerMessage { + &self.message + } +} + +struct CursorTrackingBackend { + inner: TestBackend, + rendered_cursor: Option<Position>, +} + +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<CursorState> { + 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<Item = (u16, u16, &'a ratatui::buffer::Cell)>, + { + 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<Position, Self::Error> { + self.inner.get_cursor_position() + } + + fn set_cursor_position<P: Into<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<Size, Self::Error> { + self.inner.size() + } + + fn window_size(&mut self) -> Result<WindowSize, Self::Error> { + 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<CursorState>) { + 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<CursorState> { + 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, + }) +} |
