aboutsummaryrefslogtreecommitdiff
path: root/src/server
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-05-13 14:14:40 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-05-13 14:15:26 +0300
commit4600309051e5d6bea1f64e72cec585b102117ce5 (patch)
tree8ca78803bbef8453bdd2d9a0de312201cd0ee60d /src/server
parentfb9aff54087aa8a606e9260a689986d2bd76f5fa (diff)
feat: support kitty graphics in attached clients
Diffstat (limited to 'src/server')
-rw-r--r--src/server/client_transport.rs32
-rw-r--r--src/server/headless.rs147
-rw-r--r--src/server/protocol.rs61
-rw-r--r--src/server/render_stream.rs49
4 files changed, 252 insertions, 37 deletions
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);
}