From d8db1bce7ecf7e56d4cf220efbbc986988878df4 Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Fri, 8 May 2026 23:23:06 +0300 Subject: [PATCH] refactor(server): extract headless render stream helpers --- src/server/headless.rs | 285 ++++-------------------------------- src/server/mod.rs | 1 + src/server/render_stream.rs | 267 +++++++++++++++++++++++++++++++++ 3 files changed, 296 insertions(+), 257 deletions(-) create mode 100644 src/server/render_stream.rs diff --git a/src/server/headless.rs b/src/server/headless.rs index 123f28b2..3417c0da 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -23,8 +23,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use std::time::{Duration, Instant}; -use ratatui::backend::{Backend, ClearType, TestBackend, WindowSize}; -use ratatui::layout::{Position, Rect, Size}; +use ratatui::layout::Rect; use tokio::sync::mpsc; use tracing::{debug, error, info, warn}; @@ -33,16 +32,15 @@ use base64::Engine; use crate::api; use crate::app; use crate::app::state::AppState; -use crate::app::Mode; -use crate::client::blit::BlitEncoder; use crate::config; use crate::detect::AgentState; use crate::events::AppEvent; use crate::layout::PaneId; use crate::server::client_transport::{self, ClientWriter, ServerEvent}; use crate::server::protocol::{ - self, CursorState, FrameData, RenderEncoding, ServerMessage, TerminalFrame, MAX_FRAME_SIZE, + self, CursorState, FrameData, RenderEncoding, ServerMessage, MAX_FRAME_SIZE, }; +use crate::server::render_stream::ClientRenderState; // --------------------------------------------------------------------------- // Loop event enum for the headless server event loop @@ -191,47 +189,6 @@ fn derive_client_socket_from_api_socket(api_socket_path: &Path) -> PathBuf { // Connected client state // --------------------------------------------------------------------------- -/// Per-client render baseline for the negotiated render encoding. -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 { - 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, - }, - } - } - - fn reset_baseline(&mut self) { - match self { - Self::Semantic { last_frame } => *last_frame = None, - Self::TerminalAnsi { blit_encoder, .. } => *blit_encoder = BlitEncoder::new(), - } - } - - fn reset_semantic_input_baseline(&mut self) { - if let Self::Semantic { last_frame } = self { - *last_frame = None; - } - } - - #[cfg(test)] - fn terminal_seq(&self) -> Option { - match self { - Self::Semantic { .. } => None, - Self::TerminalAnsi { seq, .. } => Some(*seq), - } - } -} - /// A connected client tracked by the server. struct ClientConnection { /// The client's terminal size (after clamping). @@ -292,166 +249,6 @@ fn restrict_socket_permissions(path: &Path) -> io::Result<()> { crate::ipc::restrict_socket_permissions(path, SOCKET_PERMISSION_MODE) } -// --------------------------------------------------------------------------- -// Virtual rendering -// --------------------------------------------------------------------------- - -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. -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) -} - -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, - }) -} - // --------------------------------------------------------------------------- // Headless server // --------------------------------------------------------------------------- @@ -1638,7 +1435,11 @@ impl HeadlessServer { let (cols, rows) = self.effective_size; let area = Rect::new(0, 0, cols, rows); let resize_panes = self.app.state.view.pane_infos.is_empty(); - let _ = render_virtual(&mut self.app.state, area, resize_panes); + let _ = crate::server::render_stream::render_virtual( + &mut self.app.state, + area, + resize_panes, + ); debug!( cols, rows, resize_panes, "rendered virtual frame with no attached clients" @@ -1649,8 +1450,12 @@ impl HeadlessServer { let mut broken_clients: Vec = Vec::new(); for (client_id, (cols, rows), is_foreground) in render_targets { let area = Rect::new(0, 0, cols, rows); - let (buffer, cursor) = render_virtual(&mut self.app.state, area, is_foreground); - let hyperlinks = visible_hyperlinks(&self.app.state); + let (buffer, cursor) = crate::server::render_stream::render_virtual( + &mut self.app.state, + area, + is_foreground, + ); + let hyperlinks = crate::server::render_stream::visible_hyperlinks(&self.app.state); let frame = FrameData::from_ratatui_buffer_with_hyperlinks(&buffer, cursor, &hyperlinks); @@ -1661,43 +1466,19 @@ impl HeadlessServer { continue; }; - let mut terminal_encoded = None; - let message = match &mut client.render_state { - ClientRenderState::Semantic { last_frame } => { - if last_frame.as_ref() == Some(&frame) { - client.render_pending = false; - continue; - } - ServerMessage::Frame(frame.clone()) - } - ClientRenderState::TerminalAnsi { blit_encoder, seq } => { - if blit_encoder.is_current(&frame) { - client.render_pending = false; - continue; - } - let encoded = blit_encoder.encode(&frame, false); - let message = ServerMessage::Terminal(TerminalFrame { - seq: *seq + 1, - width: frame.width, - height: frame.height, - full: encoded.full, - bytes: encoded.bytes.clone(), - }); - terminal_encoded = Some(encoded); - message - } + let Some(prepared) = client.render_state.prepare_frame(&frame) else { + client.render_pending = false; + continue; }; - let serialized = match Self::frame_server_message(&message) { + let serialized = match Self::frame_server_message(prepared.message()) { Ok(framed) => framed, Err(protocol::FramingError::Oversized { claimed, max }) => { warn!( client_id, claimed, max, "skipping oversized frame for client" ); - if let ClientRenderState::Semantic { last_frame } = &mut client.render_state { - *last_frame = Some(frame); - } + client.render_state.note_oversized_frame(frame); continue; } Err(err) => { @@ -1710,21 +1491,7 @@ impl HeadlessServer { match writer.render.try_send(serialized) { Ok(()) => { client.render_pending = false; - match (&mut client.render_state, message) { - (ClientRenderState::Semantic { last_frame }, ServerMessage::Frame(_)) => { - *last_frame = Some(frame); - } - ( - ClientRenderState::TerminalAnsi { blit_encoder, seq }, - ServerMessage::Terminal(_), - ) => { - if let Some(encoded) = terminal_encoded { - blit_encoder.commit(frame, encoded); - *seq += 1; - } - } - _ => {} - } + client.render_state.commit_sent_frame(frame, prepared); } Err(std::sync::mpsc::TrySendError::Full(_)) => { client.render_pending = true; @@ -2180,7 +1947,8 @@ mod tests { fn virtual_render_produces_nonempty_buffer() { let mut state = AppState::test_new(); let area = Rect::new(0, 0, 80, 24); - let (buffer, _cursor) = render_virtual(&mut state, area, true); + let (buffer, _cursor) = + crate::server::render_stream::render_virtual(&mut state, area, true); assert_eq!(buffer.area.width, 80); assert_eq!(buffer.area.height, 24); } @@ -2189,7 +1957,8 @@ mod tests { fn virtual_render_without_frame_cursor_keeps_cursor_hidden() { let mut state = AppState::test_new(); let area = Rect::new(0, 0, 80, 24); - let (_buffer, cursor) = render_virtual(&mut state, area, true); + let (_buffer, cursor) = + crate::server::render_stream::render_virtual(&mut state, area, true); assert_eq!(cursor, None); } @@ -2210,7 +1979,8 @@ mod tests { state.mode = crate::app::Mode::Terminal; let area = Rect::new(0, 0, 80, 24); - let (_buffer, cursor) = render_virtual(&mut state, area, true); + let (_buffer, cursor) = + crate::server::render_stream::render_virtual(&mut state, area, true); let pane = state .view .pane_infos @@ -2244,7 +2014,8 @@ mod tests { state.mode = crate::app::Mode::Terminal; let area = Rect::new(0, 0, 80, 24); - let (_buffer, cursor) = render_virtual(&mut state, area, true); + let (_buffer, cursor) = + crate::server::render_stream::render_virtual(&mut state, area, true); let pane = state .view .pane_infos diff --git a/src/server/mod.rs b/src/server/mod.rs index afe99098..eab079ee 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -2,3 +2,4 @@ pub mod autodetect; pub(crate) mod client_transport; pub mod headless; pub mod protocol; +pub(crate) mod render_stream; 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 }, + /// 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, + }) +}