diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-13 14:14:40 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-13 14:15:26 +0300 |
| commit | 4600309051e5d6bea1f64e72cec585b102117ce5 (patch) | |
| tree | 8ca78803bbef8453bdd2d9a0de312201cd0ee60d /src | |
| parent | fb9aff54087aa8a606e9260a689986d2bd76f5fa (diff) | |
feat: support kitty graphics in attached clients
Diffstat (limited to 'src')
| -rw-r--r-- | src/app/mod.rs | 10 | ||||
| -rw-r--r-- | src/app/state.rs | 2 | ||||
| -rw-r--r-- | src/client/blit.rs | 20 | ||||
| -rw-r--r-- | src/client/mod.rs | 146 | ||||
| -rw-r--r-- | src/config/model.rs | 18 | ||||
| -rw-r--r-- | src/ghostty/mod.rs | 97 | ||||
| -rw-r--r-- | src/kitty_graphics.rs | 500 | ||||
| -rw-r--r-- | src/main.rs | 5 | ||||
| -rw-r--r-- | src/pane.rs | 21 | ||||
| -rw-r--r-- | src/pane/terminal.rs | 46 | ||||
| -rw-r--r-- | src/server/client_transport.rs | 32 | ||||
| -rw-r--r-- | src/server/headless.rs | 147 | ||||
| -rw-r--r-- | src/server/protocol.rs | 61 | ||||
| -rw-r--r-- | src/server/render_stream.rs | 49 | ||||
| -rw-r--r-- | src/ui.rs | 1 |
15 files changed, 975 insertions, 180 deletions
diff --git a/src/app/mod.rs b/src/app/mod.rs index 0719dc0c..84b8e72f 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -194,6 +194,7 @@ impl App { event_hub: crate::api::EventHub, ) -> Self { let (prefix_code, prefix_mods) = config.prefix_key(); + crate::kitty_graphics::set_enabled(config.advanced.kitty_graphics); let (event_tx, event_rx) = mpsc::channel::<AppEvent>(64); let render_notify = Arc::new(Notify::new()); let render_dirty = Arc::new(AtomicBool::new(false)); @@ -365,6 +366,7 @@ impl App { agent_panel_scope, confirm_close: config.ui.confirm_close, show_agent_labels_on_pane_borders: config.ui.show_agent_labels_on_pane_borders, + kitty_graphics_enabled: config.advanced.kitty_graphics, pane_scrollback_limit_bytes: config.advanced.scrollback_limit_bytes, accent: crate::config::parse_color(&config.ui.accent), sound: config.ui.sound.clone(), @@ -506,7 +508,11 @@ impl App { crate::ui::compute_view_with_cell_size(&mut self.state, area, cell_size); crate::ui::render(&self.state, frame); })?; - crate::kitty_graphics::paint_local_pane_graphics(&self.state, cell_size)?; + if self.state.kitty_graphics_enabled { + crate::kitty_graphics::paint_local_pane_graphics(&self.state, cell_size)?; + } else { + crate::kitty_graphics::clear_all_host_graphics()?; + } self.last_render_at = Some(now); needs_render = false; continue; @@ -689,6 +695,8 @@ impl App { } if !invalid_section("advanced") { + self.state.kitty_graphics_enabled = config.advanced.kitty_graphics; + crate::kitty_graphics::set_enabled(config.advanced.kitty_graphics); self.state.pane_scrollback_limit_bytes = config.advanced.scrollback_limit_bytes; } diff --git a/src/app/state.rs b/src/app/state.rs index b350235a..c79fbc24 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -711,6 +711,7 @@ pub struct AppState { pub agent_panel_scope: AgentPanelScope, pub confirm_close: bool, pub show_agent_labels_on_pane_borders: bool, + pub kitty_graphics_enabled: bool, pub pane_scrollback_limit_bytes: usize, #[allow(dead_code)] // kept for backward compat; palette.accent is the source of truth pub accent: Color, @@ -878,6 +879,7 @@ impl AppState { agent_panel_scope: AgentPanelScope::AllWorkspaces, confirm_close: true, show_agent_labels_on_pane_borders: false, + kitty_graphics_enabled: false, pane_scrollback_limit_bytes: crate::config::DEFAULT_SCROLLBACK_LIMIT_BYTES, accent: Color::Cyan, sound: SoundConfig { diff --git a/src/client/blit.rs b/src/client/blit.rs index a206f97e..4a5c90c3 100644 --- a/src/client/blit.rs +++ b/src/client/blit.rs @@ -597,6 +597,7 @@ mod tests { height, cursor: None, hyperlinks: Vec::new(), + graphics: Vec::new(), } } @@ -781,6 +782,7 @@ mod tests { visible: true, }), hyperlinks: Vec::new(), + graphics: Vec::new(), }; let mut output = Vec::new(); @@ -809,6 +811,7 @@ mod tests { visible: true, }), hyperlinks: Vec::new(), + graphics: Vec::new(), }; let hidden = FrameData { cells: vec![make_cell("B", 0, 0, 0); 9], @@ -820,6 +823,7 @@ mod tests { visible: false, }), hyperlinks: Vec::new(), + graphics: Vec::new(), }; let mut last_visible_cursor = None; let mut output = Vec::new(); @@ -974,6 +978,7 @@ mod tests { visible: true, }), hyperlinks: Vec::new(), + graphics: Vec::new(), }; let mut output = Vec::new(); @@ -998,6 +1003,7 @@ mod tests { visible: false, }), hyperlinks: Vec::new(), + graphics: Vec::new(), }; let mut output = Vec::new(); @@ -1018,6 +1024,7 @@ mod tests { height: 1, cursor: None, hyperlinks: Vec::new(), + graphics: Vec::new(), }; let mut output = Vec::new(); @@ -1043,6 +1050,7 @@ mod tests { visible: false, }), hyperlinks: Vec::new(), + graphics: Vec::new(), }; let mut output = Vec::new(); @@ -1063,6 +1071,7 @@ mod tests { visible: true, }), hyperlinks: Vec::new(), + graphics: Vec::new(), }; let mut output = Vec::new(); @@ -1090,6 +1099,7 @@ mod tests { visible: true, }), hyperlinks: Vec::new(), + graphics: Vec::new(), }; let mut curr = prev.clone(); curr.cells[0] = make_cell("B", 0, 0, 0); @@ -1127,6 +1137,7 @@ mod tests { visible: true, }), hyperlinks: Vec::new(), + graphics: Vec::new(), }; let hidden = FrameData { cells: vec![make_cell("B", 0, 0, 0); 9], @@ -1134,6 +1145,7 @@ mod tests { height: 3, cursor: None, hyperlinks: Vec::new(), + graphics: Vec::new(), }; let mut last_visible_cursor = None; let mut output = Vec::new(); @@ -1165,6 +1177,7 @@ mod tests { height: 2, cursor: None, hyperlinks: Vec::new(), + graphics: Vec::new(), }; let mut last_visible_cursor = None; let mut output = Vec::new(); @@ -1190,6 +1203,7 @@ mod tests { visible: true, }), hyperlinks: Vec::new(), + graphics: Vec::new(), }; let curr = FrameData { cells: vec![make_cell("B", 0, 0, 0)], @@ -1197,6 +1211,7 @@ mod tests { height: 1, cursor: None, hyperlinks: Vec::new(), + graphics: Vec::new(), }; let mut output = Vec::new(); @@ -1220,6 +1235,7 @@ mod tests { height: 1, cursor: None, hyperlinks: Vec::new(), + graphics: Vec::new(), }; let mut output = Vec::new(); @@ -1243,6 +1259,7 @@ mod tests { height: 1, cursor: None, hyperlinks: Vec::new(), + graphics: Vec::new(), }; let curr = FrameData { cells: vec![ @@ -1254,6 +1271,7 @@ mod tests { height: 1, cursor: None, hyperlinks: Vec::new(), + graphics: Vec::new(), }; let mut output = Vec::new(); @@ -1279,6 +1297,7 @@ mod tests { height: 1, cursor: None, hyperlinks: Vec::new(), + graphics: Vec::new(), }; let curr = FrameData { cells: vec![ @@ -1290,6 +1309,7 @@ mod tests { height: 1, cursor: None, hyperlinks: Vec::new(), + graphics: Vec::new(), }; let mut output = Vec::new(); diff --git a/src/client/mod.rs b/src/client/mod.rs index e378427f..a29a40f6 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -35,6 +35,9 @@ use crate::server::protocol::{ PROTOCOL_VERSION, }; +const DELETE_ALL_KITTY_GRAPHICS: &[u8] = b"\x1b_Ga=d,d=A,q=2;\x1b\\"; +static RECEIVED_KITTY_GRAPHICS: AtomicBool = AtomicBool::new(false); + // --------------------------------------------------------------------------- // Client state // --------------------------------------------------------------------------- @@ -172,6 +175,10 @@ fn write_terminal_restore_postlude(writer: &mut impl io::Write) -> io::Result<() } fn restore_terminal_state(in_tmux: bool) { + if RECEIVED_KITTY_GRAPHICS.swap(false, Ordering::AcqRel) { + let _ = io::stdout().write_all(DELETE_ALL_KITTY_GRAPHICS); + } + // Reset modifyOtherKeys if we enabled it. if in_tmux { let _ = io::stdout().write_all(b"\x1b[>4;0m"); @@ -214,6 +221,8 @@ fn do_handshake( stream: &mut UnixStream, cols: u16, rows: u16, + cell_width_px: u32, + cell_height_px: u32, requested_encoding: RenderEncoding, ) -> Result<RenderEncoding, ClientError> { stream @@ -225,6 +234,8 @@ fn do_handshake( version: PROTOCOL_VERSION, cols, rows, + cell_width_px, + cell_height_px, requested_encoding, }; protocol::write_message(stream, &hello) @@ -266,7 +277,7 @@ enum ClientLoopEvent { /// Raw input bytes from stdin. StdinInput(Vec<u8>), /// Terminal resize detected. - Resize(u16, u16), + Resize(u16, u16, u32, u32), /// Server message received. ServerMessage(ServerMessage), /// Server reader thread exited (connection lost). @@ -300,13 +311,20 @@ pub fn run_client() -> io::Result<()> { } }; - // Get the terminal size before handshake (before raw mode). - let (cols, rows) = crossterm::terminal::size().unwrap_or((80, 24)); + // Get the terminal geometry before handshake (before raw mode). + let (cols, rows, cell_width_px, cell_height_px) = current_terminal_geometry(); let requested_encoding = requested_render_encoding(); // Perform handshake while the stream is still in blocking mode. - let negotiated_encoding = match do_handshake(&mut stream, cols, rows, requested_encoding) { + let negotiated_encoding = match do_handshake( + &mut stream, + cols, + rows, + cell_width_px, + cell_height_px, + requested_encoding, + ) { Ok(encoding) => encoding, Err(err) => { eprintln!("herdr: {err}"); @@ -452,11 +470,13 @@ async fn run_client_loop( return Err(ClientError::ConnectionLost(e)); } } - ClientLoopEvent::Resize(new_cols, new_rows) => { + ClientLoopEvent::Resize(new_cols, new_rows, cell_width_px, cell_height_px) => { state.reported_size = (new_cols, new_rows); let msg = ClientMessage::Resize { cols: new_cols, rows: new_rows, + cell_width_px, + cell_height_px, }; if let Err(e) = write_to_server(&mut write_stream, &msg) { return Err(ClientError::ConnectionLost(e)); @@ -466,15 +486,28 @@ async fn run_client_loop( ServerMessage::Frame(frame_data) => { let encoded = state.blit_encoder.encode(&frame_data, false); let mut stdout = io::stdout(); - let _ = stdout.write_all(&encoded.bytes); + let _ = write_encoded_frame_with_graphics( + &mut stdout, + &encoded.bytes, + &frame_data.graphics, + ); let _ = stdout.flush(); state.blit_encoder.commit(frame_data, encoded); } ServerMessage::Terminal(frame) => { + if contains_kitty_graphics_bytes(&frame.bytes) { + RECEIVED_KITTY_GRAPHICS.store(true, Ordering::Release); + } let mut stdout = io::stdout(); let _ = stdout.write_all(&frame.bytes); let _ = stdout.flush(); } + ServerMessage::Graphics { bytes } => { + RECEIVED_KITTY_GRAPHICS.store(true, Ordering::Release); + let mut stdout = io::stdout(); + let _ = stdout.write_all(&bytes); + let _ = stdout.flush(); + } ServerMessage::ServerShutdown { reason } => { return Err(ClientError::ServerShutdown { reason }); } @@ -661,9 +694,69 @@ fn forward_clipboard(data: &str) { } // --------------------------------------------------------------------------- +// Frame output +// --------------------------------------------------------------------------- + +const SYNC_OUTPUT_END: &[u8] = b"\x1b[?2026l"; + +fn write_encoded_frame_with_graphics( + mut writer: impl io::Write, + encoded: &[u8], + graphics: &[u8], +) -> io::Result<()> { + if graphics.is_empty() { + return writer.write_all(encoded); + } + + RECEIVED_KITTY_GRAPHICS.store(true, Ordering::Release); + + if let Some(sync_end) = rfind_subslice(encoded, SYNC_OUTPUT_END) { + writer.write_all(&encoded[..sync_end])?; + writer.write_all(graphics)?; + writer.write_all(&encoded[sync_end..])?; + return Ok(()); + } + + writer.write_all(b"\x1b[?2026h")?; + writer.write_all(encoded)?; + writer.write_all(graphics)?; + writer.write_all(SYNC_OUTPUT_END) +} + +fn contains_kitty_graphics_bytes(bytes: &[u8]) -> bool { + bytes.windows(3).any(|window| window == b"\x1b_G") +} + +fn rfind_subslice(haystack: &[u8], needle: &[u8]) -> Option<usize> { + if needle.is_empty() || needle.len() > haystack.len() { + return None; + } + + haystack + .windows(needle.len()) + .rposition(|window| window == needle) +} + +// --------------------------------------------------------------------------- // Resize polling // --------------------------------------------------------------------------- +fn current_terminal_geometry() -> (u16, u16, u32, u32) { + let (cols, rows) = crossterm::terminal::size().unwrap_or((80, 24)); + let Ok(size) = crossterm::terminal::window_size() else { + return (cols, rows, 8, 16); + }; + if size.columns == 0 || size.rows == 0 || size.width == 0 || size.height == 0 { + return (cols, rows, 8, 16); + } + ( + cols, + rows, + (size.width as u32 / size.columns as u32).max(1), + (size.height as u32 / size.rows as u32).max(1), + ) +} + /// Polls the terminal size and sends resize events when it changes. fn resize_poll_loop( resize_tx: tokio::sync::mpsc::Sender<ClientLoopEvent>, @@ -671,14 +764,22 @@ fn resize_poll_loop( initial_rows: u16, should_quit: &Arc<AtomicBool>, ) { - let mut last_size = (initial_cols, initial_rows); + let (_, _, initial_cell_width, initial_cell_height) = current_terminal_geometry(); + let mut last_size = ( + initial_cols, + initial_rows, + initial_cell_width, + initial_cell_height, + ); while !should_quit.load(Ordering::Acquire) { std::thread::sleep(Duration::from_millis(100)); - let new_size = crossterm::terminal::size().unwrap_or(last_size); + let new_size = current_terminal_geometry(); if new_size != last_size { last_size = new_size; if resize_tx - .blocking_send(ClientLoopEvent::Resize(new_size.0, new_size.1)) + .blocking_send(ClientLoopEvent::Resize( + new_size.0, new_size.1, new_size.2, new_size.3, + )) .is_err() { break; // Main loop gone. @@ -714,6 +815,33 @@ mod tests { use super::*; #[test] + fn graphics_bytes_are_inserted_before_blit_sync_end() { + let mut output = Vec::new(); + write_encoded_frame_with_graphics( + &mut output, + b"\x1b[?2026htext\x1b[?2026lcursor", + b"graphics", + ) + .unwrap(); + + assert_eq!(output, b"\x1b[?2026htextgraphics\x1b[?2026lcursor"); + } + + #[test] + fn graphics_bytes_fallback_wraps_unsynchronized_blit() { + let mut output = Vec::new(); + write_encoded_frame_with_graphics(&mut output, b"text", b"graphics").unwrap(); + + assert_eq!(output, b"\x1b[?2026htextgraphics\x1b[?2026l"); + } + + #[test] + fn terminal_frame_kitty_detection_matches_apc_prefix() { + assert!(contains_kitty_graphics_bytes(b"text\x1b_Ga=p;\x1b\\")); + assert!(!contains_kitty_graphics_bytes(b"text\x1b[?2026h")); + } + + #[test] fn write_host_terminal_theme_query_emits_osc_queries() { let mut output = Vec::new(); write_host_terminal_theme_query(&mut output).unwrap(); diff --git a/src/config/model.rs b/src/config/model.rs index 8d2b356f..ffc117ed 100644 --- a/src/config/model.rs +++ b/src/config/model.rs @@ -149,6 +149,8 @@ pub struct UiConfig { pub struct AdvancedConfig { /// Allow launching herdr inside an existing herdr pane. Default: false. pub allow_nested: bool, + /// Experimental local Kitty graphics rendering for attached clients. Default: false. + pub kitty_graphics: bool, /// Maximum scrollback buffer size in bytes retained per pane terminal. Default: 10000000. #[serde(alias = "scrollback_lines")] pub scrollback_limit_bytes: usize, @@ -237,6 +239,7 @@ impl Default for AdvancedConfig { fn default() -> Self { Self { allow_nested: false, + kitty_graphics: false, scrollback_limit_bytes: DEFAULT_SCROLLBACK_LIMIT_BYTES, } } @@ -332,14 +335,29 @@ delivery = "terminal" } #[test] + fn kitty_graphics_default_off_and_parse() { + let config = Config::default(); + assert!(!config.advanced.kitty_graphics); + + let toml = r#" +[advanced] +kitty_graphics = true +"#; + let config: Config = toml::from_str(toml).unwrap(); + assert!(config.advanced.kitty_graphics); + } + + #[test] fn advanced_config_parses() { let toml = r#" [advanced] allow_nested = true +kitty_graphics = true scrollback_limit_bytes = 12345 "#; let config: Config = toml::from_str(toml).unwrap(); assert!(config.advanced.allow_nested); + assert!(config.advanced.kitty_graphics); assert_eq!(config.advanced.scrollback_limit_bytes, 12345); } diff --git a/src/ghostty/mod.rs b/src/ghostty/mod.rs index d3a67540..1652621a 100644 --- a/src/ghostty/mod.rs +++ b/src/ghostty/mod.rs @@ -10,8 +10,10 @@ )] pub mod bindings; +use std::collections::hash_map::DefaultHasher; use std::ffi::c_void; use std::fmt; +use std::hash::{Hash, Hasher}; use std::marker::PhantomData; use std::mem; use std::os::raw::c_char; @@ -149,7 +151,7 @@ const APC_MAX_BYTES_KITTY: usize = 16 * 1024 * 1024; static INSTALL_PNG_DECODER: Once = Once::new(); -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq)] pub enum KittyImageFormat { Rgb, Rgba, @@ -166,11 +168,24 @@ pub struct KittyImagePlacement { pub image_width: u32, pub image_height: u32, pub format: KittyImageFormat, + pub data_len: usize, + pub data_fingerprint: u64, pub data: Vec<u8>, pub render: KittyPlacementRenderInfo, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct KittyImageDescriptor { + pub image_id: u32, + pub placement_id: u32, + pub image_width: u32, + pub image_height: u32, + pub format: KittyImageFormat, + pub data_len: usize, + pub data_fingerprint: u64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct KittyPlacementRenderInfo { pub pixel_width: u32, pub pixel_height: u32, @@ -837,6 +852,16 @@ impl Terminal { } pub fn kitty_image_placements(&self) -> Result<Vec<KittyImagePlacement>, Error> { + self.kitty_image_placements_with_data_filter(|_| true) + } + + pub fn kitty_image_placements_with_data_filter<F>( + &self, + mut needs_data: F, + ) -> Result<Vec<KittyImagePlacement>, Error> + where + F: FnMut(KittyImageDescriptor) -> bool, + { let mut graphics: ffi::GhosttyKittyGraphics = ptr::null_mut(); unsafe { ffi::ghostty_terminal_get( @@ -865,7 +890,9 @@ impl Terminal { let mut placements = Vec::new(); while unsafe { ffi::ghostty_kitty_graphics_placement_next(iterator) } { - if let Some(placement) = self.kitty_image_placement(graphics, iterator)? { + if let Some(placement) = + self.kitty_image_placement(graphics, iterator, &mut needs_data)? + { placements.push(placement); } } @@ -873,11 +900,15 @@ impl Terminal { Ok(placements) } - fn kitty_image_placement( + fn kitty_image_placement<F>( &self, graphics: ffi::GhosttyKittyGraphics, iterator: ffi::GhosttyKittyGraphicsPlacementIterator, - ) -> Result<Option<KittyImagePlacement>, Error> { + needs_data: &mut F, + ) -> Result<Option<KittyImagePlacement>, Error> + where + F: FnMut(KittyImageDescriptor) -> bool, + { let image_id = kitty_placement_u32( iterator, ffi::GhosttyKittyGraphicsPlacementData_GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_IMAGE_ID, @@ -917,11 +948,27 @@ impl Terminal { if compression != ffi::GhosttyKittyImageCompression_GHOSTTY_KITTY_IMAGE_COMPRESSION_NONE { return Ok(None); } - let data = kitty_image_data(image)?; let placement_id = kitty_placement_u32( iterator, ffi::GhosttyKittyGraphicsPlacementData_GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_PLACEMENT_ID, )?; + let (data_ptr, data_len) = kitty_image_data_ptr_len(image)?; + let data_fingerprint = + kitty_image_fingerprint(data_ptr, data_len, image_width, image_height, format); + let descriptor = KittyImageDescriptor { + image_id, + placement_id, + image_width, + image_height, + format, + data_len, + data_fingerprint, + }; + let data = if needs_data(descriptor) { + kitty_image_data_from_ptr(data_ptr, data_len) + } else { + Vec::new() + }; let x_offset = kitty_placement_u32( iterator, ffi::GhosttyKittyGraphicsPlacementData_GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_X_OFFSET, @@ -944,6 +991,8 @@ impl Terminal { image_width, image_height, format, + data_len, + data_fingerprint, data, render: KittyPlacementRenderInfo { pixel_width: raw_info.pixel_width, @@ -1093,7 +1142,9 @@ fn kitty_image_compression( Ok(out) } -fn kitty_image_data(image: ffi::GhosttyKittyGraphicsImage) -> Result<Vec<u8>, Error> { +fn kitty_image_data_ptr_len( + image: ffi::GhosttyKittyGraphicsImage, +) -> Result<(*const u8, usize), Error> { let mut ptr_out: *const u8 = ptr::null(); let mut len = 0usize; unsafe { @@ -1110,10 +1161,40 @@ fn kitty_image_data(image: ffi::GhosttyKittyGraphicsImage) -> Result<Vec<u8>, Er ) .into_result()?; } + Ok((ptr_out, len)) +} + +fn kitty_image_data_from_ptr(ptr_out: *const u8, len: usize) -> Vec<u8> { + if ptr_out.is_null() || len == 0 { + return Vec::new(); + } + unsafe { slice::from_raw_parts(ptr_out, len) }.to_vec() +} + +fn kitty_image_fingerprint( + ptr_out: *const u8, + len: usize, + image_width: u32, + image_height: u32, + format: KittyImageFormat, +) -> u64 { + let mut hasher = DefaultHasher::new(); + len.hash(&mut hasher); + image_width.hash(&mut hasher); + image_height.hash(&mut hasher); + format.hash(&mut hasher); if ptr_out.is_null() || len == 0 { - return Ok(Vec::new()); + return hasher.finish(); + } + + let data = unsafe { slice::from_raw_parts(ptr_out, len) }; + let prefix_len = data.len().min(256); + data[..prefix_len].hash(&mut hasher); + if data.len() > prefix_len { + let suffix_len = data.len().min(256); + data[data.len() - suffix_len..].hash(&mut hasher); } - Ok(unsafe { slice::from_raw_parts(ptr_out, len) }.to_vec()) + hasher.finish() } fn grid_ref_hyperlink_uri(grid_ref: &ffi::GhosttyGridRef) -> Result<Option<String>, Error> { diff --git a/src/kitty_graphics.rs b/src/kitty_graphics.rs index 3dc96e67..43cbacbd 100644 --- a/src/kitty_graphics.rs +++ b/src/kitty_graphics.rs @@ -3,6 +3,7 @@ use std::collections::{HashMap, HashSet}; use std::fmt::Write as FmtWrite; use std::hash::{Hash, Hasher}; use std::io::{self, Write}; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Mutex, OnceLock}; use base64::Engine; @@ -10,11 +11,12 @@ use ratatui::layout::Rect; use crate::app::state::AppState; use crate::app::Mode; -use crate::ghostty::{KittyImageFormat, KittyImagePlacement}; +use crate::ghostty::{KittyImageDescriptor, KittyImageFormat, KittyImagePlacement}; use crate::layout::PaneId; const KITTY_CHUNK_BYTES: usize = 3072; const HOST_IMAGE_ID_BASE: u32 = 10_000; +const STALE_PLACEMENT_GRACE_FRAMES: u8 = 1; #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub(crate) struct HostCellSize { @@ -60,6 +62,12 @@ impl HostCellSize { } } +#[derive(Debug, Clone, PartialEq, Eq)] +struct HostViewKey { + workspace_index: usize, + tab_index: usize, +} + #[derive(Debug)] struct HostPlacement { pane_id: PaneId, @@ -75,7 +83,13 @@ struct ImageSignature { image_height: u32, format_code: u32, data_len: usize, - data_hash: u64, + data_fingerprint: u64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct HostPlacementState { + signature: PlacementSignature, + missed_frames: u8, } #[derive(Debug, Clone, Copy, Hash, PartialEq, Eq)] @@ -106,10 +120,49 @@ struct ClippedPlacement { y_offset: u32, } -static HOST_IMAGES: OnceLock<Mutex<HashMap<u32, ImageSignature>>> = OnceLock::new(); -static HOST_PLACEMENTS: OnceLock<Mutex<HashMap<(u32, u32), PlacementSignature>>> = OnceLock::new(); +#[derive(Debug, Default, Clone)] +pub(crate) struct HostGraphicsCache { + images: HashMap<u32, ImageSignature>, + placements: HashMap<(u32, u32), HostPlacementState>, + view: Option<HostViewKey>, +} + +static KITTY_GRAPHICS_ENABLED: AtomicBool = AtomicBool::new(false); +static LOCAL_HOST_GRAPHICS: OnceLock<Mutex<HostGraphicsCache>> = OnceLock::new(); + +pub(crate) fn set_enabled(enabled: bool) { + KITTY_GRAPHICS_ENABLED.store(enabled, Ordering::Release); +} + +pub(crate) fn is_enabled() -> bool { + KITTY_GRAPHICS_ENABLED.load(Ordering::Acquire) +} pub(crate) fn paint_local_pane_graphics(app: &AppState, cell_size: HostCellSize) -> io::Result<()> { + let cache = LOCAL_HOST_GRAPHICS.get_or_init(|| Mutex::new(HostGraphicsCache::default())); + let mut bytes = Vec::new(); + if let Ok(mut cache) = cache.lock() { + bytes = encode_local_pane_graphics(app, cell_size, &mut cache); + } + if bytes.is_empty() { + return Ok(()); + } + + let mut framed = Vec::with_capacity(bytes.len() + 8); + framed.extend_from_slice(b"\x1b7"); + framed.extend_from_slice(&bytes); + framed.extend_from_slice(b"\x1b8"); + + let mut stdout = io::stdout().lock(); + stdout.write_all(&framed)?; + stdout.flush() +} + +pub(crate) fn encode_local_pane_graphics( + app: &AppState, + cell_size: HostCellSize, + cache: &mut HostGraphicsCache, +) -> Vec<u8> { let mode_ok = app.mode == Mode::Terminal; let cell_ok = cell_size.is_known(); tracing::debug!( @@ -130,139 +183,188 @@ pub(crate) fn paint_local_pane_graphics(app: &AppState, cell_size: HostCellSize) }, "paint_local_pane_graphics early return" ); - return clear_all_host_images(); + return cache.clear_bytes(); } - let placements = collect_visible_placements(app, cell_size); + let view_key = active_view_key(app); + let uploaded_images = cache.images.clone(); + let placements = collect_visible_placements(app, cell_size, &uploaded_images); tracing::debug!( placements_collected = placements.len(), "collect_visible_placements result" ); + let mut bytes = Vec::new(); - let mut current_placements = HashSet::new(); + let view_changed = cache.update_view(view_key); + encode_graphics_update( + &mut bytes, + &placements, + view_changed, + &mut cache.images, + &mut cache.placements, + ); + tracing::debug!( + placements = placements.len(), + bytes = bytes.len(), + cell_width_px = cell_size.width_px, + cell_height_px = cell_size.height_px, + "painting kitty graphics placements" + ); + bytes +} - let image_state = HOST_IMAGES.get_or_init(|| Mutex::new(HashMap::new())); - let placement_state = HOST_PLACEMENTS.get_or_init(|| Mutex::new(HashMap::new())); - if let (Ok(mut host_images), Ok(mut host_placements)) = - (image_state.lock(), placement_state.lock()) - { - for placement in &placements { - let clipped = clipped_placement(placement); - tracing::debug!( - pane_id = ?placement.pane_id, - has_clipped = clipped.is_some(), - grid_cols = placement.placement.render.grid_cols, - grid_rows = placement.placement.render.grid_rows, - viewport_col = placement.placement.render.viewport_col, - viewport_row = placement.placement.render.viewport_row, - area_w = placement.area.width, - area_h = placement.area.height, - "clipped_placement result" - ); - let Some((clipped, format_code)) = clipped else { - continue; - }; - let host_id = host_image_id(placement.pane_id, &placement.placement); - let host_placement_id = host_placement_id(placement.pane_id, &placement.placement); - let image_signature = image_signature(placement, format_code); - let placement_signature = placement_signature(clipped); - let placement_key = (host_id, host_placement_id); - current_placements.insert(placement_key); - - match host_images.get(&host_id).copied() { - Some(existing) if existing == image_signature => {} - Some(_) => { - encode_delete_image(&mut bytes, host_id); - host_placements.retain(|(image_id, _), _| *image_id != host_id); - encode_upload_image(&mut bytes, placement, format_code, host_id); - host_images.insert(host_id, image_signature); - } - None => { - encode_upload_image(&mut bytes, placement, format_code, host_id); - host_images.insert(host_id, image_signature); - } +fn encode_graphics_update( + bytes: &mut Vec<u8>, + placements: &[HostPlacement], + view_changed: bool, + host_images: &mut HashMap<u32, ImageSignature>, + host_placements: &mut HashMap<(u32, u32), HostPlacementState>, +) { + let mut current_placements = HashSet::new(); + for placement in placements { + let clipped = clipped_placement(placement); + tracing::debug!( + pane_id = ?placement.pane_id, + has_clipped = clipped.is_some(), + grid_cols = placement.placement.render.grid_cols, + grid_rows = placement.placement.render.grid_rows, + viewport_col = placement.placement.render.viewport_col, + viewport_row = placement.placement.render.viewport_row, + area_w = placement.area.width, + area_h = placement.area.height, + "clipped_placement result" + ); + let Some((clipped, format_code)) = clipped else { + continue; + }; + let host_id = host_image_id(placement.pane_id, &placement.placement); + let host_placement_id = host_placement_id(placement.pane_id, &placement.placement); + let image_signature = image_signature(placement, format_code); + let placement_signature = placement_signature(clipped); + let placement_key = (host_id, host_placement_id); + current_placements.insert(placement_key); + + match host_images.get(&host_id).copied() { + Some(existing) if existing == image_signature => {} + Some(_) => { + encode_delete_image(bytes, host_id); + host_placements.retain(|(image_id, placement_id), _| { + if *image_id == host_id { + current_placements.remove(&(*image_id, *placement_id)); + false + } else { + true + } + }); + encode_upload_image(bytes, placement, format_code, host_id); + host_images.insert(host_id, image_signature); } + None => { + encode_upload_image(bytes, placement, format_code, host_id); + host_images.insert(host_id, image_signature); + } + } - match host_placements.get_mut(&placement_key) { - Some(existing) if *existing == placement_signature => {} - Some(existing) => { - encode_display_placement( - &mut bytes, - clipped, - host_id, - host_placement_id, - placement.placement.z, - ); - *existing = placement_signature; - } - None => { - encode_display_placement( - &mut bytes, - clipped, - host_id, - host_placement_id, - placement.placement.z, - ); - host_placements.insert(placement_key, placement_signature); - } + match host_placements.get_mut(&placement_key) { + Some(existing) if existing.signature == placement_signature => { + existing.missed_frames = 0; + } + Some(existing) => { + encode_display_placement( + bytes, + clipped, + host_id, + host_placement_id, + placement.placement.z, + ); + existing.signature = placement_signature; + existing.missed_frames = 0; + } + None => { + encode_display_placement( + bytes, + clipped, + host_id, + host_placement_id, + placement.placement.z, + ); + host_placements.insert( + placement_key, + HostPlacementState { + signature: placement_signature, + missed_frames: 0, + }, + ); } } + } - let stale_placements: Vec<(u32, u32)> = host_placements - .keys() - .copied() - .filter(|key| !current_placements.contains(key)) - .collect(); - for (host_id, host_placement_id) in stale_placements { - encode_delete_placement(&mut bytes, host_id, host_placement_id); - host_placements.remove(&(host_id, host_placement_id)); + let mut stale_placements = Vec::new(); + for (key, state) in &mut *host_placements { + if current_placements.contains(key) { + continue; + } + if !view_changed && state.missed_frames < STALE_PLACEMENT_GRACE_FRAMES { + state.missed_frames += 1; + continue; } + stale_placements.push(*key); } + for (host_id, host_placement_id) in stale_placements { + encode_delete_placement(bytes, host_id, host_placement_id); + host_placements.remove(&(host_id, host_placement_id)); + } +} +pub(crate) fn clear_all_host_graphics() -> io::Result<()> { + let cache = LOCAL_HOST_GRAPHICS.get_or_init(|| Mutex::new(HostGraphicsCache::default())); + let mut bytes = Vec::new(); + if let Ok(mut cache) = cache.lock() { + bytes = cache.clear_bytes(); + } if bytes.is_empty() { return Ok(()); } - - tracing::debug!( - placements = placements.len(), - bytes = bytes.len(), - cell_width_px = cell_size.width_px, - cell_height_px = cell_size.height_px, - "painting kitty graphics placements" - ); - - let mut framed = Vec::with_capacity(bytes.len() + 8); - framed.extend_from_slice(b"\x1b7"); - framed.extend_from_slice(&bytes); - framed.extend_from_slice(b"\x1b8"); - let mut stdout = io::stdout().lock(); - stdout.write_all(&framed)?; + stdout.write_all(&bytes)?; stdout.flush() } -fn clear_all_host_images() -> io::Result<()> { - let image_state = HOST_IMAGES.get_or_init(|| Mutex::new(HashMap::new())); - let placement_state = HOST_PLACEMENTS.get_or_init(|| Mutex::new(HashMap::new())); - let mut bytes = Vec::new(); - if let (Ok(mut host_images), Ok(mut host_placements)) = - (image_state.lock(), placement_state.lock()) - { - for id in host_images.keys().copied().collect::<Vec<_>>() { +impl HostGraphicsCache { + pub(crate) fn clear_bytes(&mut self) -> Vec<u8> { + let mut bytes = Vec::new(); + for id in self.images.keys().copied().collect::<Vec<_>>() { encode_delete_image(&mut bytes, id); } - host_images.clear(); - host_placements.clear(); + self.images.clear(); + self.placements.clear(); + self.view = None; + bytes } - if bytes.is_empty() { - return Ok(()); + + fn update_view(&mut self, view_key: Option<HostViewKey>) -> bool { + if self.view == view_key { + return false; + } + self.view = view_key; + true } - let mut stdout = io::stdout().lock(); - stdout.write_all(&bytes)?; - stdout.flush() } -fn collect_visible_placements(app: &AppState, cell_size: HostCellSize) -> Vec<HostPlacement> { +fn active_view_key(app: &AppState) -> Option<HostViewKey> { + let ws_idx = app.active?; + let ws = app.workspaces.get(ws_idx)?; + Some(HostViewKey { + workspace_index: ws_idx, + tab_index: ws.active_tab_index(), + }) +} + +fn collect_visible_placements( + app: &AppState, + cell_size: HostCellSize, + uploaded_images: &HashMap<u32, ImageSignature>, +) -> Vec<HostPlacement> { let ws_idx = match app.active { Some(idx) => idx, None => { @@ -297,7 +399,12 @@ fn collect_visible_placements(app: &AppState, cell_size: HostCellSize) -> Vec<Ho continue; } }; - for placement in runtime.kitty_image_placements() { + for placement in runtime.kitty_image_placements_with_data_filter(|descriptor| { + let host_id = host_image_id_raw(info.id, descriptor.image_id); + let format_code = kitty_format_code(descriptor.format); + uploaded_images.get(&host_id).copied() + != Some(image_signature_from_descriptor(descriptor, format_code)) + }) { let scrollback_offset = runtime .scroll_metrics() .map(|m| m.offset_from_bottom as u32) @@ -319,9 +426,13 @@ fn collect_visible_placements(app: &AppState, cell_size: HostCellSize) -> Vec<Ho } fn host_image_id(pane_id: PaneId, placement: &KittyImagePlacement) -> u32 { + host_image_id_raw(pane_id, placement.image_id) +} + +fn host_image_id_raw(pane_id: PaneId, image_id: u32) -> u32 { let mut hasher = DefaultHasher::new(); pane_id.raw().hash(&mut hasher); - placement.image_id.hash(&mut hasher); + image_id.hash(&mut hasher); HOST_IMAGE_ID_BASE + ((hasher.finish() as u32) % 900_000) } @@ -544,14 +655,25 @@ fn scale_pixels(value: u32, source: u32, dest: u32) -> u32 { } fn image_signature(placement: &HostPlacement, format_code: u32) -> ImageSignature { - let mut hasher = DefaultHasher::new(); - placement.placement.data.hash(&mut hasher); ImageSignature { image_width: placement.placement.image_width, image_height: placement.placement.image_height, format_code, - data_len: placement.placement.data.len(), - data_hash: hasher.finish(), + data_len: placement.placement.data_len, + data_fingerprint: placement.placement.data_fingerprint, + } +} + +fn image_signature_from_descriptor( + descriptor: KittyImageDescriptor, + format_code: u32, +) -> ImageSignature { + ImageSignature { + image_width: descriptor.image_width, + image_height: descriptor.image_height, + format_code, + data_len: descriptor.data_len, + data_fingerprint: descriptor.data_fingerprint, } } @@ -593,3 +715,153 @@ fn encode_kitty_data(out: &mut Vec<u8>, control: &str, data: &[u8]) { let _ = write!(out, "\x1b_Gm={more};{encoded}\x1b\\"); } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::ghostty::KittyPlacementRenderInfo; + + fn test_placement(viewport_col: i32, viewport_row: i32) -> HostPlacement { + HostPlacement { + pane_id: PaneId::from_raw(1), + area: Rect::new(0, 0, 20, 10), + cell_size: HostCellSize { + width_px: 10, + height_px: 10, + }, + scrollback_offset: 0, + placement: KittyImagePlacement { + image_id: 7, + placement_id: 3, + z: 0, + x_offset: 0, + y_offset: 0, + image_width: 30, + image_height: 30, + format: KittyImageFormat::Rgba, + data_len: 30 * 30 * 4, + data_fingerprint: 42, + data: vec![255; 30 * 30 * 4], + render: KittyPlacementRenderInfo { + pixel_width: 0, + pixel_height: 0, + grid_cols: 3, + grid_rows: 3, + viewport_col, + viewport_row, + source_x: 0, + source_y: 0, + source_width: 0, + source_height: 0, + }, + }, + } + } + + #[test] + fn clipped_placement_handles_positive_viewport_without_wrapping() { + let placement = test_placement(2, 2); + let (clipped, _) = clipped_placement(&placement).expect("visible placement"); + + assert_eq!(clipped.x, 2); + assert_eq!(clipped.y, 2); + assert_eq!(clipped.cols, 3); + assert_eq!(clipped.rows, 3); + assert_eq!(clipped.source_x, 0); + assert_eq!(clipped.source_y, 0); + } + + #[test] + fn clipped_placement_crops_negative_viewport_offsets() { + let placement = test_placement(-1, -1); + let (clipped, _) = clipped_placement(&placement).expect("partially visible placement"); + + assert_eq!(clipped.x, 0); + assert_eq!(clipped.y, 0); + assert_eq!(clipped.cols, 2); + assert_eq!(clipped.rows, 2); + assert_eq!(clipped.source_x, 10); + assert_eq!(clipped.source_y, 10); + } + + #[test] + fn graphics_update_uploads_once_then_repositions_only() { + let mut images = HashMap::new(); + let mut placements = HashMap::new(); + let mut bytes = Vec::new(); + let placement = test_placement(0, 0); + + encode_graphics_update( + &mut bytes, + &[placement], + false, + &mut images, + &mut placements, + ); + let first = String::from_utf8_lossy(&bytes); + assert!(first.contains("a=t")); + assert!(first.contains("a=p")); + + bytes.clear(); + let same = test_placement(0, 0); + encode_graphics_update(&mut bytes, &[same], false, &mut images, &mut placements); + assert!(bytes.is_empty()); + + let moved = test_placement(0, 1); + encode_graphics_update(&mut bytes, &[moved], false, &mut images, &mut placements); + let moved_bytes = String::from_utf8_lossy(&bytes); + assert!(!moved_bytes.contains("a=t")); + assert!(moved_bytes.contains("a=p")); + } + + #[test] + fn stale_placement_grace_deletes_placement_not_image() { + let mut images = HashMap::new(); + let mut placements = HashMap::new(); + let mut bytes = Vec::new(); + let placement = test_placement(0, 0); + + encode_graphics_update( + &mut bytes, + &[placement], + false, + &mut images, + &mut placements, + ); + assert_eq!(placements.len(), 1); + + bytes.clear(); + encode_graphics_update(&mut bytes, &[], false, &mut images, &mut placements); + assert!(bytes.is_empty()); + assert_eq!(placements.len(), 1); + + encode_graphics_update(&mut bytes, &[], false, &mut images, &mut placements); + let delete = String::from_utf8_lossy(&bytes); + assert!(delete.contains("a=d,d=i")); + assert!(!delete.contains("d=I")); + assert!(placements.is_empty()); + assert_eq!(images.len(), 1); + } + + #[test] + fn view_change_deletes_stale_placement_immediately() { + let mut images = HashMap::new(); + let mut placements = HashMap::new(); + let mut bytes = Vec::new(); + let placement = test_placement(0, 0); + + encode_graphics_update( + &mut bytes, + &[placement], + false, + &mut images, + &mut placements, + ); + bytes.clear(); + encode_graphics_update(&mut bytes, &[], true, &mut images, &mut placements); + + let delete = String::from_utf8_lossy(&bytes); + assert!(delete.contains("a=d,d=i")); + assert!(placements.is_empty()); + } +} diff --git a/src/main.rs b/src/main.rs index 18e6138a..d2dfedd9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -156,6 +156,9 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration [advanced] # Allow launching herdr from inside a herdr-managed pane. # allow_nested = false +# Experimental local Kitty graphics rendering for attached clients. +# Requires a Kitty graphics-compatible outer terminal. Detach/headless replay is not supported yet. +# kitty_graphics = false # Maximum scrollback buffer size in bytes retained per pane terminal. # Matches Ghostty's default scrollback-limit behavior. # scrollback_limit_bytes = 10000000 @@ -424,6 +427,7 @@ fn main() -> io::Result<()> { if panic_in_tmux { let _ = std::io::Write::write_all(&mut io::stdout(), b"\x1b[>4;0m"); } + let _ = crate::kitty_graphics::clear_all_host_graphics(); let _ = execute!( io::stdout(), PopKeyboardEnhancementFlags, @@ -486,6 +490,7 @@ fn main() -> io::Result<()> { std::io::stdout().flush()?; } + crate::kitty_graphics::clear_all_host_graphics()?; execute!( io::stdout(), PopKeyboardEnhancementFlags, diff --git a/src/pane.rs b/src/pane.rs index e9a35357..8a89fa54 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -437,6 +437,16 @@ impl PaneRuntime { if result.request_render && !render_dirty.swap(true, Ordering::AcqRel) { render_notify.notify_one(); } + if let Some(delay) = result.render_delay { + let render_notify = render_notify.clone(); + let render_dirty = render_dirty.clone(); + rt.spawn(async move { + tokio::time::sleep(delay).await; + if !render_dirty.swap(true, Ordering::AcqRel) { + render_notify.notify_one(); + } + }); + } for content in result.clipboard_writes { if let Err(err) = rt.block_on(events.send(AppEvent::ClipboardWrite { content })) @@ -817,8 +827,15 @@ impl PaneRuntime { self.terminal.visible_hyperlinks(area) } - pub fn kitty_image_placements(&self) -> Vec<crate::ghostty::KittyImagePlacement> { - self.terminal.kitty_image_placements() + pub fn kitty_image_placements_with_data_filter<F>( + &self, + needs_data: F, + ) -> Vec<crate::ghostty::KittyImagePlacement> + where + F: FnMut(crate::ghostty::KittyImageDescriptor) -> bool, + { + self.terminal + .kitty_image_placements_with_data_filter(needs_data) } pub fn keyboard_protocol(&self) -> crate::input::KeyboardProtocol { diff --git a/src/pane/terminal.rs b/src/pane/terminal.rs index f45d9df7..33cd8ed5 100644 --- a/src/pane/terminal.rs +++ b/src/pane/terminal.rs @@ -1,11 +1,12 @@ use std::borrow::Cow; use std::sync::Mutex; +use std::time::Duration; use bytes::Bytes; use ratatui::style::{Color, Modifier, Style}; use ratatui::{layout::Rect, Frame}; use tokio::sync::mpsc; -use tracing::{debug, error, info, warn}; +use tracing::{debug, error, warn}; use unicode_width::UnicodeWidthStr; use crate::layout::PaneId; @@ -24,6 +25,7 @@ use super::{ }; const DEFAULT_DETECTION_ROWS: usize = 24; +const KITTY_GRAPHICS_REDRAW_SETTLE: Duration = Duration::from_millis(20); #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct ScrollMetrics { @@ -59,6 +61,7 @@ impl InputState { #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct ProcessBytesResult { pub request_render: bool, + pub render_delay: Option<Duration>, pub clipboard_writes: Vec<Vec<u8>>, } @@ -170,8 +173,15 @@ impl PaneTerminal { self.ghostty.visible_hyperlinks(area) } - pub fn kitty_image_placements(&self) -> Vec<crate::ghostty::KittyImagePlacement> { - self.ghostty.kitty_image_placements() + pub fn kitty_image_placements_with_data_filter<F>( + &self, + needs_data: F, + ) -> Vec<crate::ghostty::KittyImagePlacement> + where + F: FnMut(crate::ghostty::KittyImageDescriptor) -> bool, + { + self.ghostty + .kitty_image_placements_with_data_filter(needs_data) } pub fn apply_host_terminal_theme(&self, theme: crate::terminal_theme::TerminalTheme) { @@ -315,6 +325,7 @@ impl GhosttyPaneTerminal { error!(pane = pane_id.raw(), "ghostty core lock poisoned in reader"); return ProcessBytesResult { request_render: false, + render_delay: None, clipboard_writes: Vec::new(), }; }; @@ -358,9 +369,14 @@ impl GhosttyPaneTerminal { } core.terminal.write(filtered_bytes.as_ref()); - if contains_kitty_graphics_sequence(filtered_bytes.as_ref()) { - match core.terminal.kitty_image_placements() { - Ok(placements) => info!( + let has_kitty_graphics_sequence = crate::kitty_graphics::is_enabled() + && contains_kitty_graphics_sequence(filtered_bytes.as_ref()); + if has_kitty_graphics_sequence { + match core + .terminal + .kitty_image_placements_with_data_filter(|_| false) + { + Ok(placements) => debug!( pane = pane_id.raw(), placements = placements.len(), "processed kitty graphics sequence" @@ -381,7 +397,9 @@ impl GhosttyPaneTerminal { .unwrap_or(false); let _ = response_writer; ProcessBytesResult { - request_render: !synchronized_output, + request_render: !synchronized_output && !has_kitty_graphics_sequence, + render_delay: (!synchronized_output && has_kitty_graphics_sequence) + .then_some(KITTY_GRAPHICS_REDRAW_SETTLE), clipboard_writes, } } @@ -642,11 +660,21 @@ impl GhosttyPaneTerminal { .unwrap_or_default() } - pub fn kitty_image_placements(&self) -> Vec<crate::ghostty::KittyImagePlacement> { + pub fn kitty_image_placements_with_data_filter<F>( + &self, + needs_data: F, + ) -> Vec<crate::ghostty::KittyImagePlacement> + where + F: FnMut(crate::ghostty::KittyImageDescriptor) -> bool, + { self.core .lock() .ok() - .and_then(|core| core.terminal.kitty_image_placements().ok()) + .and_then(|core| { + core.terminal + .kitty_image_placements_with_data_filter(needs_data) + .ok() + }) .unwrap_or_default() } diff --git a/src/server/client_transport.rs b/src/server/client_transport.rs index c4c1acc0..a74bf7f6 100644 --- a/src/server/client_transport.rs +++ b/src/server/client_transport.rs @@ -51,6 +51,8 @@ pub(crate) enum ServerEvent { client_id: u64, cols: u16, rows: u16, + cell_width_px: u32, + cell_height_px: u32, render_encoding: RenderEncoding, writer: ClientWriter, }, @@ -61,6 +63,8 @@ pub(crate) enum ServerEvent { client_id: u64, cols: u16, rows: u16, + cell_width_px: u32, + cell_height_px: u32, }, /// A client detached gracefully. ClientDetach { client_id: u64 }, @@ -113,11 +117,13 @@ pub(crate) fn handle_client_handshake( } }; - let (client_cols, client_rows, render_encoding) = match hello { + let (client_cols, client_rows, cell_width_px, cell_height_px, render_encoding) = match hello { ClientMessage::Hello { version, cols, rows, + cell_width_px, + cell_height_px, requested_encoding, } => { // Version check. @@ -137,7 +143,13 @@ pub(crate) fn handle_client_handshake( // Clamp size. let (clamped_cols, clamped_rows) = clamp_terminal_size(cols, rows); - (clamped_cols, clamped_rows, requested_encoding) + ( + clamped_cols, + clamped_rows, + cell_width_px.max(1), + cell_height_px.max(1), + requested_encoding, + ) } _ => { // First message must be Hello. @@ -176,6 +188,8 @@ pub(crate) fn handle_client_handshake( client_id, cols: client_cols, rows: client_rows, + cell_width_px, + cell_height_px, render_encoding, writer, }); @@ -324,12 +338,19 @@ fn client_read_loop( ServerEvent::ClientInput { client_id, data } } } - ClientMessage::Resize { cols, rows } => { + ClientMessage::Resize { + cols, + rows, + cell_width_px, + cell_height_px, + } => { let (clamped_cols, clamped_rows) = clamp_terminal_size(cols, rows); ServerEvent::ClientResize { client_id, cols: clamped_cols, rows: clamped_rows, + cell_width_px: cell_width_px.max(1), + cell_height_px: cell_height_px.max(1), } } ClientMessage::Detach => ServerEvent::ClientDetach { client_id }, @@ -399,6 +420,8 @@ mod tests { version: PROTOCOL_VERSION, cols: 100, rows: 30, + cell_width_px: 8, + cell_height_px: 16, requested_encoding: RenderEncoding::TerminalAnsi, }, ) @@ -427,11 +450,14 @@ mod tests { client_id, cols, rows, + cell_width_px, + cell_height_px, render_encoding, writer, } => { assert_eq!(client_id, 42); assert_eq!((cols, rows), (100, 30)); + assert_eq!((cell_width_px, cell_height_px), (8, 16)); assert_eq!(render_encoding, RenderEncoding::TerminalAnsi); drop(writer); } diff --git a/src/server/headless.rs b/src/server/headless.rs index d9227404..3e978d5a 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -191,6 +191,8 @@ fn derive_client_socket_from_api_socket(api_socket_path: &Path) -> PathBuf { struct ClientConnection { /// The client's terminal size (after clamping). terminal_size: (u16, u16), + /// Pixel size of one client terminal cell. + cell_size: crate::kitty_graphics::HostCellSize, /// Last known host terminal default colors for this client. host_terminal_theme: crate::terminal_theme::TerminalTheme, /// Last reported focus state for this client's outer terminal. @@ -199,6 +201,8 @@ struct ClientConnection { last_activity: u64, /// Render baseline for the negotiated client encoding. render_state: ClientRenderState, + /// Client-local host Kitty graphics cache. + graphics_cache: crate::kitty_graphics::HostGraphicsCache, /// Whether a render was skipped because the render channel was full. render_pending: bool, /// Channels for sending framed ServerMessage data to the client writer thread. @@ -208,6 +212,7 @@ struct ClientConnection { impl ClientConnection { fn new( terminal_size: (u16, u16), + cell_size: crate::kitty_graphics::HostCellSize, host_terminal_theme: crate::terminal_theme::TerminalTheme, outer_terminal_focus: Option<bool>, last_activity: u64, @@ -216,10 +221,12 @@ impl ClientConnection { ) -> Self { Self { terminal_size, + cell_size, host_terminal_theme, outer_terminal_focus, last_activity, render_state: ClientRenderState::new(render_encoding), + graphics_cache: crate::kitty_graphics::HostGraphicsCache::default(), render_pending: false, writer, } @@ -490,9 +497,15 @@ impl HeadlessServer { if self.foreground_client_id.is_none() { return; } + let Some(client_id) = self.foreground_client_id else { + return; + }; + let Some(client) = self.clients.get(&client_id) else { + return; + }; let (cols, rows) = self.effective_size; let area = Rect::new(0, 0, cols, rows); - crate::ui::compute_view(&mut self.app.state, area); + crate::ui::compute_view_with_cell_size(&mut self.app.state, area, client.cell_size); // Shared runtime size changes affect pane wrapping and foreground-driven // rendering semantics. Force one fresh frame to every remaining client @@ -564,6 +577,7 @@ impl HeadlessServer { fn remove_client(&mut self, client_id: u64) -> bool { let was_foreground = self.foreground_client_id == Some(client_id); + self.send_client_graphics_cleanup(client_id); self.clients.remove(&client_id); if was_foreground { self.promote_latest_remaining_client() @@ -572,6 +586,33 @@ impl HeadlessServer { } } + fn send_client_graphics_cleanup(&mut self, client_id: u64) { + let (writer, bytes) = match self.clients.get_mut(&client_id) { + Some(client) => { + let bytes = client.graphics_cache.clear_bytes(); + (client.writer.as_ref().cloned(), bytes) + } + None => return, + }; + if bytes.is_empty() { + return; + } + let Some(writer) = writer else { + return; + }; + let Ok(serialized) = Self::frame_server_message(&ServerMessage::Graphics { bytes }) else { + return; + }; + let _ = writer.control.send(serialized); + } + + fn send_all_clients_graphics_cleanup(&mut self) { + let client_ids = self.clients.keys().copied().collect::<Vec<_>>(); + for client_id in client_ids { + self.send_client_graphics_cleanup(client_id); + } + } + fn update_client_host_theme_from_events( &mut self, client_id: u64, @@ -1047,15 +1088,29 @@ impl HeadlessServer { client_id, cols, rows, + cell_width_px, + cell_height_px, writer, render_encoding, } => { - info!(client_id, cols, rows, ?render_encoding, "client connected"); + info!( + client_id, + cols, + rows, + cell_width_px, + cell_height_px, + ?render_encoding, + "client connected" + ); let last_activity = self.allocate_activity_stamp(); self.clients.insert( client_id, ClientConnection::new( (cols, rows), + crate::kitty_graphics::HostCellSize { + width_px: cell_width_px, + height_px: cell_height_px, + }, crate::terminal_theme::TerminalTheme::default(), None, last_activity, @@ -1097,6 +1152,10 @@ impl HeadlessServer { self.app.state.detach_requested = false; info!(client_id, "client detach requested via keybind"); + // Clear client-local host graphics before sending ServerShutdown + // so the outer terminal does not retain stale images. + self.send_client_graphics_cleanup(client_id); + // Send a ServerShutdown with "detached" reason to this client // so it exits cleanly (not with a connection-lost error). // The client will close its connection after receiving this, @@ -1130,10 +1189,19 @@ impl HeadlessServer { client_id, cols, rows, + cell_width_px, + cell_height_px, } => { - info!(client_id, cols, rows, "client resize"); + info!( + client_id, + cols, rows, cell_width_px, cell_height_px, "client resize" + ); if let Some(client) = self.clients.get_mut(&client_id) { client.terminal_size = (cols, rows); + client.cell_size = crate::kitty_graphics::HostCellSize { + width_px: cell_width_px, + height_px: cell_height_px, + }; } self.promote_client_to_foreground(client_id); self.resize_shared_runtime_to_effective_size(); @@ -1355,20 +1423,21 @@ impl HeadlessServer { /// frames to all connected clients. fn render_and_stream(&mut self) { let foreground_client_id = self.foreground_client_id; - let mut render_targets: Vec<(u64, (u16, u16), bool)> = self - .clients - .iter() - .filter(|(_, client)| client.writer.is_some()) - .map(|(&client_id, client)| { - ( - client_id, - client.terminal_size, - foreground_client_id == Some(client_id), - ) - }) - .collect(); + let mut render_targets: Vec<(u64, (u16, u16), crate::kitty_graphics::HostCellSize, bool)> = + self.clients + .iter() + .filter(|(_, client)| client.writer.is_some()) + .map(|(&client_id, client)| { + ( + client_id, + client.terminal_size, + client.cell_size, + foreground_client_id == Some(client_id), + ) + }) + .collect(); - render_targets.sort_by_key(|(client_id, _, is_foreground)| (*is_foreground, *client_id)); + render_targets.sort_by_key(|(client_id, _, _, is_foreground)| (*is_foreground, *client_id)); if render_targets.is_empty() { let (cols, rows) = self.effective_size; @@ -1387,20 +1456,32 @@ impl HeadlessServer { } let mut broken_clients: Vec<u64> = Vec::new(); - for (client_id, (cols, rows), is_foreground) in render_targets { + for (client_id, (cols, rows), cell_size, is_foreground) in render_targets { let area = Rect::new(0, 0, cols, rows); - let (buffer, cursor) = crate::server::render_stream::render_virtual( + let (buffer, cursor) = crate::server::render_stream::render_virtual_with_cell_size( &mut self.app.state, area, is_foreground, + cell_size, ); let hyperlinks = crate::server::render_stream::visible_hyperlinks(&self.app.state); - let frame = + let mut frame = FrameData::from_ratatui_buffer_with_hyperlinks(&buffer, cursor, &hyperlinks); let Some(client) = self.clients.get_mut(&client_id) else { continue; }; + let mut next_graphics_cache = client.graphics_cache.clone(); + if self.app.state.kitty_graphics_enabled { + frame.graphics = crate::kitty_graphics::encode_local_pane_graphics( + &self.app.state, + cell_size, + &mut next_graphics_cache, + ); + } else { + frame.graphics = next_graphics_cache.clear_bytes(); + } + let Some(writer) = client.writer.as_ref().cloned() else { continue; }; @@ -1417,7 +1498,6 @@ impl HeadlessServer { client_id, claimed, max, "skipping oversized frame for client" ); - client.render_state.note_oversized_frame(frame); continue; } Err(err) => { @@ -1430,6 +1510,7 @@ impl HeadlessServer { match writer.render.try_send(serialized) { Ok(()) => { client.render_pending = false; + client.graphics_cache = next_graphics_cache; client.render_state.commit_sent_frame(frame, prepared); } Err(std::sync::mpsc::TrySendError::Full(_)) => { @@ -1534,7 +1615,8 @@ impl HeadlessServer { info!("server shutdown initiated"); self.shutting_down = true; - // Send ServerShutdown to all connected clients. + // Clear client-local host graphics, then send ServerShutdown to all connected clients. + self.send_all_clients_graphics_cleanup(); let shutdown_msg = ServerMessage::ServerShutdown { reason: Some("server is shutting down".to_owned()), }; @@ -1557,6 +1639,7 @@ impl HeadlessServer { // Send ServerShutdown to all remaining clients. if !self.clients.is_empty() { + self.send_all_clients_graphics_cleanup(); let shutdown_msg = ServerMessage::ServerShutdown { reason: Some("server is shutting down".to_owned()), }; @@ -2026,6 +2109,7 @@ mod tests { 1, ClientConnection::new( (160, 45), + crate::kitty_graphics::HostCellSize::default(), crate::terminal_theme::TerminalTheme { foreground: Some(crate::terminal_theme::RgbColor { r: 0xaa, @@ -2048,6 +2132,7 @@ mod tests { 2, ClientConnection::new( (80, 24), + crate::kitty_graphics::HostCellSize::default(), crate::terminal_theme::TerminalTheme { foreground: Some(crate::terminal_theme::RgbColor { r: 0x10, @@ -2100,6 +2185,7 @@ mod tests { 1, ClientConnection::new( (120, 40), + crate::kitty_graphics::HostCellSize::default(), crate::terminal_theme::TerminalTheme::default(), None, 1, @@ -2111,6 +2197,7 @@ mod tests { 2, ClientConnection::new( (80, 24), + crate::kitty_graphics::HostCellSize::default(), crate::terminal_theme::TerminalTheme::default(), Some(true), 2, @@ -2140,6 +2227,7 @@ mod tests { 1, ClientConnection::new( (120, 40), + crate::kitty_graphics::HostCellSize::default(), crate::terminal_theme::TerminalTheme::default(), None, 1, @@ -2151,6 +2239,7 @@ mod tests { 2, ClientConnection::new( (80, 24), + crate::kitty_graphics::HostCellSize::default(), crate::terminal_theme::TerminalTheme::default(), Some(true), 2, @@ -2180,6 +2269,7 @@ mod tests { 1, ClientConnection::new( (120, 40), + crate::kitty_graphics::HostCellSize::default(), crate::terminal_theme::TerminalTheme::default(), Some(true), 1, @@ -2215,6 +2305,7 @@ mod tests { 1, ClientConnection::new( (120, 40), + crate::kitty_graphics::HostCellSize::default(), crate::terminal_theme::TerminalTheme::default(), None, 1, @@ -2226,6 +2317,7 @@ mod tests { 2, ClientConnection::new( (80, 24), + crate::kitty_graphics::HostCellSize::default(), crate::terminal_theme::TerminalTheme::default(), None, 2, @@ -2270,6 +2362,7 @@ mod tests { 1, ClientConnection::new( (120, 40), + crate::kitty_graphics::HostCellSize::default(), crate::terminal_theme::TerminalTheme::default(), None, 1, @@ -2303,6 +2396,7 @@ mod tests { 1, ClientConnection::new( (80, 24), + crate::kitty_graphics::HostCellSize::default(), crate::terminal_theme::TerminalTheme::default(), None, 1, @@ -2348,6 +2442,7 @@ mod tests { 1, ClientConnection::new( (80, 24), + crate::kitty_graphics::HostCellSize::default(), crate::terminal_theme::TerminalTheme::default(), None, 1, @@ -2406,6 +2501,7 @@ mod tests { 1, ClientConnection::new( (80, 24), + crate::kitty_graphics::HostCellSize::default(), crate::terminal_theme::TerminalTheme::default(), None, 1, @@ -2449,6 +2545,7 @@ mod tests { 1, ClientConnection::new( (80, 24), + crate::kitty_graphics::HostCellSize::default(), crate::terminal_theme::TerminalTheme::default(), None, 1, @@ -2499,6 +2596,7 @@ mod tests { 1, ClientConnection::new( (80, 24), + crate::kitty_graphics::HostCellSize::default(), crate::terminal_theme::TerminalTheme::default(), None, 1, @@ -2530,6 +2628,7 @@ mod tests { 1, ClientConnection::new( (80, 24), + crate::kitty_graphics::HostCellSize::default(), crate::terminal_theme::TerminalTheme::default(), None, 1, @@ -2562,6 +2661,7 @@ mod tests { 1, ClientConnection::new( (120, 40), + crate::kitty_graphics::HostCellSize::default(), crate::terminal_theme::TerminalTheme::default(), None, 1, @@ -2573,6 +2673,7 @@ mod tests { 2, ClientConnection::new( (80, 24), + crate::kitty_graphics::HostCellSize::default(), crate::terminal_theme::TerminalTheme::default(), None, 2, @@ -2614,6 +2715,7 @@ mod tests { 1, ClientConnection::new( (120, 40), + crate::kitty_graphics::HostCellSize::default(), crate::terminal_theme::TerminalTheme::default(), None, 1, @@ -2625,6 +2727,7 @@ mod tests { 2, ClientConnection::new( (80, 24), + crate::kitty_graphics::HostCellSize::default(), crate::terminal_theme::TerminalTheme::default(), None, 2, @@ -2668,6 +2771,7 @@ mod tests { 1, ClientConnection::new( (80, 24), + crate::kitty_graphics::HostCellSize::default(), crate::terminal_theme::TerminalTheme::default(), None, 1, @@ -2719,6 +2823,7 @@ mod tests { 1, ClientConnection::new( (80, 24), + crate::kitty_graphics::HostCellSize::default(), crate::terminal_theme::TerminalTheme::default(), None, 1, diff --git a/src/server/protocol.rs b/src/server/protocol.rs index 5eda0231..743817ac 100644 --- a/src/server/protocol.rs +++ b/src/server/protocol.rs @@ -13,11 +13,12 @@ use serde::{Deserialize, Serialize}; // --------------------------------------------------------------------------- /// Current protocol version. Bumped when wire format changes incompatibly. -pub const PROTOCOL_VERSION: u32 = 4; +pub const PROTOCOL_VERSION: u32 = 5; -/// Maximum allowed frame payload size (2 MB). Frames larger than this are -/// rejected to prevent denial-of-service via oversized length prefixes. -pub const MAX_FRAME_SIZE: usize = 2 * 1024 * 1024; +/// Maximum allowed frame payload size (32 MB). Frames larger than this are +/// rejected to prevent denial-of-service via oversized length prefixes while +/// leaving room for first-frame Kitty graphics image uploads. +pub const MAX_FRAME_SIZE: usize = 32 * 1024 * 1024; /// Length of the u32 little-endian length prefix in bytes. const LENGTH_PREFIX_BYTES: usize = 4; @@ -46,6 +47,10 @@ pub enum ClientMessage { cols: u16, /// Terminal height in rows. rows: u16, + /// Width of a terminal cell in physical pixels. + cell_width_px: u32, + /// Height of a terminal cell in physical pixels. + cell_height_px: u32, /// Render encoding requested by the client. requested_encoding: RenderEncoding, }, @@ -62,6 +67,10 @@ pub enum ClientMessage { cols: u16, /// New terminal height in rows. rows: u16, + /// Width of a terminal cell in physical pixels. + cell_width_px: u32, + /// Height of a terminal cell in physical pixels. + cell_height_px: u32, }, /// Graceful disconnect request. @@ -114,6 +123,8 @@ pub struct FrameData { pub cursor: Option<CursorState>, /// OSC 8 hyperlink URIs referenced by cells. pub hyperlinks: Vec<String>, + /// Kitty graphics protocol bytes to apply after the text frame. + pub graphics: Vec<u8>, } impl FrameData { @@ -178,6 +189,7 @@ impl FrameData { height, cursor, hyperlinks: hyperlink_uris, + graphics: Vec::new(), } } @@ -255,6 +267,12 @@ pub enum ServerMessage { /// Terminal bytes to write directly for a terminal-ANSI client. Terminal(TerminalFrame), + /// Client-local Kitty graphics bytes to write directly to the host terminal. + Graphics { + /// Raw Kitty graphics protocol bytes. + bytes: Vec<u8>, + }, + /// Server is shutting down. Clients should exit gracefully. ServerShutdown { /// Optional reason for the shutdown. @@ -546,6 +564,8 @@ mod tests { version: PROTOCOL_VERSION, cols: 80, rows: 24, + cell_width_px: 8, + cell_height_px: 16, requested_encoding: RenderEncoding::SemanticFrame, }; let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap(); @@ -584,7 +604,12 @@ mod tests { #[test] fn client_resize_roundtrip() { - let msg = ClientMessage::Resize { cols: 80, rows: 24 }; + let msg = ClientMessage::Resize { + cols: 80, + rows: 24, + cell_width_px: 8, + cell_height_px: 16, + }; let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap(); let (decoded, _): (ClientMessage, _) = bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap(); @@ -690,6 +715,7 @@ mod tests { visible: true, }), hyperlinks: vec!["https://example.com".to_owned()], + graphics: Vec::new(), }; let msg = ServerMessage::Frame(frame.clone()); let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap(); @@ -740,6 +766,17 @@ mod tests { } #[test] + fn server_graphics_roundtrip() { + let msg = ServerMessage::Graphics { + bytes: b"\x1b_Ga=d,d=A,q=2;\x1b\\".to_vec(), + }; + let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap(); + let (decoded, _): (ServerMessage, _) = + bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap(); + assert_eq!(msg, decoded); + } + + #[test] fn server_terminal_frame_roundtrip() { let msg = ServerMessage::Terminal(TerminalFrame { seq: 7, @@ -771,6 +808,8 @@ mod tests { version: PROTOCOL_VERSION, cols: 80, rows: 24, + cell_width_px: 8, + cell_height_px: 16, requested_encoding: RenderEncoding::SemanticFrame, }; let mut buf = Vec::new(); @@ -811,6 +850,7 @@ mod tests { visible: true, }), hyperlinks: Vec::new(), + graphics: Vec::new(), }; let msg = ServerMessage::Frame(frame); @@ -839,6 +879,8 @@ mod tests { version: PROTOCOL_VERSION, cols: (80 + (i % 40) as u16), rows: (24 + (i % 20) as u16), + cell_width_px: 8, + cell_height_px: 16, requested_encoding: RenderEncoding::SemanticFrame, }, 1 => ClientMessage::Input { @@ -847,6 +889,8 @@ mod tests { 2 => ClientMessage::Resize { cols: (100 + (i % 30) as u16), rows: (30 + (i % 10) as u16), + cell_width_px: 8, + cell_height_px: 16, }, 3 => ClientMessage::Detach, _ => unreachable!(), @@ -1142,6 +1186,7 @@ mod tests { height: 2, cursor: None, hyperlinks: Vec::new(), + graphics: Vec::new(), }; assert!(frame.to_ratatui_buffer().is_none()); } @@ -1263,6 +1308,8 @@ mod tests { version: PROTOCOL_VERSION, cols: 80, rows: 24, + cell_width_px: 8, + cell_height_px: 16, requested_encoding: RenderEncoding::SemanticFrame, }; let mut buf = Vec::new(); @@ -1294,6 +1341,8 @@ mod tests { version: PROTOCOL_VERSION, cols: 200, rows: 60, + cell_width_px: 8, + cell_height_px: 16, requested_encoding: RenderEncoding::SemanticFrame, }, ClientMessage::Input { @@ -1302,6 +1351,8 @@ mod tests { ClientMessage::Resize { cols: 100, rows: 30, + cell_width_px: 8, + cell_height_px: 16, }, ClientMessage::Detach, ]; diff --git a/src/server/render_stream.rs b/src/server/render_stream.rs index 45542a7a..ca0ecddd 100644 --- a/src/server/render_stream.rs +++ b/src/server/render_stream.rs @@ -57,7 +57,8 @@ impl ClientRenderState { if blit_encoder.is_current(frame) { return None; } - let encoded = blit_encoder.encode(frame, false); + 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, @@ -72,12 +73,6 @@ impl ClientRenderState { } } - 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), @@ -98,6 +93,30 @@ impl ClientRenderState { } } +const SYNC_OUTPUT_END: &[u8] = b"\x1b[?2026l"; + +fn insert_graphics_before_sync_end(encoded: &mut Vec<u8>, 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<usize> { + 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, @@ -202,8 +221,22 @@ pub(crate) fn render_virtual( area: Rect, resize_panes: bool, ) -> (ratatui::buffer::Buffer, Option<CursorState>) { + render_virtual_with_cell_size( + app_state, + area, + resize_panes, + crate::kitty_graphics::HostCellSize::default(), + ) +} + +pub(crate) fn render_virtual_with_cell_size( + app_state: &mut AppState, + area: Rect, + resize_panes: bool, + cell_size: crate::kitty_graphics::HostCellSize, +) -> (ratatui::buffer::Buffer, Option<CursorState>) { if resize_panes { - crate::ui::compute_view(app_state, area); + crate::ui::compute_view_with_cell_size(app_state, area, cell_size); } else { crate::ui::compute_view_without_resizing_panes(app_state, area); } @@ -85,6 +85,7 @@ pub(super) fn spinner_frame(tick: u32) -> &'static str { /// Compute view geometry and reconcile pane sizes. /// Called before render to separate mutation from drawing. +#[cfg_attr(not(test), allow(dead_code))] pub fn compute_view(app: &mut AppState, area: Rect) { compute_view_internal( app, |
