aboutsummaryrefslogtreecommitdiff
path: root/src/protocol
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-05-21 20:19:54 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-05-21 20:22:57 +0300
commit866aaf740e10fcfce169aaf81380ea0257fbe272 (patch)
treef815392b7c6b24a7c0de14e655bd635656383813 /src/protocol
parentca579c402d5668560ca13059f82c03178be18b2a (diff)
refactor: separate server client boundaries
Diffstat (limited to 'src/protocol')
-rw-r--r--src/protocol/mod.rs6
-rw-r--r--src/protocol/render_ansi.rs1430
-rw-r--r--src/protocol/wire.rs1513
3 files changed, 2949 insertions, 0 deletions
diff --git a/src/protocol/mod.rs b/src/protocol/mod.rs
new file mode 100644
index 00000000..0cdeecbb
--- /dev/null
+++ b/src/protocol/mod.rs
@@ -0,0 +1,6 @@
+//! Shared wire protocol and presentation encoding code.
+
+pub(crate) mod render_ansi;
+mod wire;
+
+pub use wire::*;
diff --git a/src/protocol/render_ansi.rs b/src/protocol/render_ansi.rs
new file mode 100644
index 00000000..8caa98d0
--- /dev/null
+++ b/src/protocol/render_ansi.rs
@@ -0,0 +1,1430 @@
+//! Frame blitting — renders FrameData to the terminal using diff-based updates.
+//!
+//! The blitting strategy:
+//! 1. On the first frame, write the entire buffer (full redraw).
+//! 2. On subsequent frames, diff against the last frame and only write
+//! the cells that changed.
+//! 3. Wrap each frame in synchronized output so terminals that support it do
+//! not expose intermediate cursor positions while the frame is painted.
+//! 4. Before writing any cells, hide the cursor to avoid stray cursor
+//! artifacts on terminals that render the hardware cursor at intermediate
+//! `CUP` positions during the frame stream.
+//! 5. After writing all changed cells, restore the final cursor visibility
+//! and position from `frame.cursor`.
+//! 6. After ending synchronized output, repeat the final cursor anchor so
+//! external IMEs can place candidate windows at the real input position.
+//!
+//! Escape sequences used:
+//! - `CSI H` (CUP) — move cursor to (row, col)
+//! - `CSI m` (SGR) — set graphic rendition (colors, bold, etc.)
+//! - `CSI ? 2026 h/l` — begin/end synchronized output
+//! - `CSI Ps SP q` — DECSCUSR cursor shape
+//! - `ESC ] 52 ; c ; <base64> BEL` — OSC 52 clipboard write
+//!
+//! The goal is minimal output: skip unchanged cells, batch adjacent changes,
+//! and minimize cursor movement.
+
+use std::cmp;
+use std::io::Write;
+
+use unicode_width::UnicodeWidthStr;
+
+use crate::protocol::{CellData, FrameData};
+
+/// Bytes produced by a [`BlitEncoder`] for one terminal frame.
+pub(crate) struct EncodedBlit {
+ /// Terminal escape bytes ready to write to the host terminal.
+ pub(crate) bytes: Vec<u8>,
+ /// Whether this frame was encoded as a full redraw.
+ pub(crate) full: bool,
+ next_last_visible_cursor: Option<(u16, u16)>,
+ next_last_cursor_shape: u8,
+}
+
+/// Stateful encoder that diffs semantic frames into terminal ANSI bytes.
+#[derive(Default)]
+pub(crate) struct BlitEncoder {
+ last_frame: Option<FrameData>,
+ last_visible_cursor: Option<(u16, u16)>,
+ last_cursor_shape: u8,
+}
+
+impl BlitEncoder {
+ pub(crate) fn new() -> Self {
+ Self::default()
+ }
+
+ pub(crate) fn encode(&self, frame: &FrameData, force_full: bool) -> EncodedBlit {
+ let prev = if force_full {
+ None
+ } else {
+ self.last_frame.as_ref()
+ };
+ let full = force_full
+ || prev.is_none()
+ || prev.is_some_and(|p| p.width != frame.width || p.height != frame.height);
+ let mut bytes = Vec::new();
+ let mut next_last_visible_cursor = self.last_visible_cursor;
+ let mut next_last_cursor_shape = self.last_cursor_shape;
+ blit_frame_to_with_cursor_memory(
+ &mut bytes,
+ frame,
+ prev,
+ &mut next_last_visible_cursor,
+ &mut next_last_cursor_shape,
+ );
+ EncodedBlit {
+ bytes,
+ full,
+ next_last_visible_cursor,
+ next_last_cursor_shape,
+ }
+ }
+
+ pub(crate) fn commit(&mut self, frame: FrameData, encoded: EncodedBlit) {
+ self.last_visible_cursor = encoded.next_last_visible_cursor;
+ self.last_cursor_shape = encoded.next_last_cursor_shape;
+ self.last_frame = Some(frame);
+ }
+
+ pub(crate) fn is_current(&self, frame: &FrameData) -> bool {
+ self.last_frame.as_ref() == Some(frame)
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Color → escape sequence
+// ---------------------------------------------------------------------------
+
+/// Converts a packed u32 color to an SGR escape sequence fragment.
+///
+/// Returns a string like `38;5;123` (indexed) or `38;2;255;128;64` (RGB)
+/// or `39` (reset), without the leading `\x1b[` or trailing `m`.
+fn color_to_sgr_fg(val: u32) -> String {
+ match val >> 24 {
+ 0x00 => match val & 0xFF {
+ 0x00 => "39".to_owned(), // Reset
+ 0x01 => "30".to_owned(), // Black
+ 0x02 => "31".to_owned(), // Red
+ 0x03 => "32".to_owned(), // Green
+ 0x04 => "33".to_owned(), // Yellow
+ 0x05 => "34".to_owned(), // Blue
+ 0x06 => "35".to_owned(), // Magenta
+ 0x07 => "36".to_owned(), // Cyan
+ 0x08 => "37".to_owned(), // Gray (light gray)
+ 0x09 => "90".to_owned(), // DarkGray
+ 0x0A => "91".to_owned(), // LightRed
+ 0x0B => "92".to_owned(), // LightGreen
+ 0x0C => "93".to_owned(), // LightYellow
+ 0x0D => "94".to_owned(), // LightBlue
+ 0x0E => "95".to_owned(), // LightMagenta
+ 0x0F => "96".to_owned(), // LightCyan
+ 0x10 => "97".to_owned(), // White
+ _ => "39".to_owned(), // Unknown → Reset
+ },
+ 0x01 => format!("38;5;{}", val & 0xFF), // Indexed
+ 0x02 => {
+ // RGB
+ let r = (val >> 16) & 0xFF;
+ let g = (val >> 8) & 0xFF;
+ let b = val & 0xFF;
+ format!("38;2;{r};{g};{b}")
+ }
+ _ => "39".to_owned(), // Unknown → Reset
+ }
+}
+
+/// Converts a packed u32 color to a background SGR fragment.
+fn color_to_sgr_bg(val: u32) -> String {
+ match val >> 24 {
+ 0x00 => match val & 0xFF {
+ 0x00 => "49".to_owned(), // Reset
+ 0x01 => "40".to_owned(), // Black
+ 0x02 => "41".to_owned(), // Red
+ 0x03 => "42".to_owned(), // Green
+ 0x04 => "43".to_owned(), // Yellow
+ 0x05 => "44".to_owned(), // Blue
+ 0x06 => "45".to_owned(), // Magenta
+ 0x07 => "46".to_owned(), // Cyan
+ 0x08 => "47".to_owned(), // Gray (light gray)
+ 0x09 => "100".to_owned(), // DarkGray
+ 0x0A => "101".to_owned(), // LightRed
+ 0x0B => "102".to_owned(), // LightGreen
+ 0x0C => "103".to_owned(), // LightYellow
+ 0x0D => "104".to_owned(), // LightBlue
+ 0x0E => "105".to_owned(), // LightMagenta
+ 0x0F => "106".to_owned(), // LightCyan
+ 0x10 => "107".to_owned(), // White
+ _ => "49".to_owned(), // Unknown → Reset
+ },
+ 0x01 => format!("48;5;{}", val & 0xFF), // Indexed
+ 0x02 => {
+ let r = (val >> 16) & 0xFF;
+ let g = (val >> 8) & 0xFF;
+ let b = val & 0xFF;
+ format!("48;2;{r};{g};{b}")
+ }
+ _ => "49".to_owned(),
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Modifier → SGR
+// ---------------------------------------------------------------------------
+
+/// Converts a u16 modifier bitmask to SGR escape sequence fragments.
+///
+/// Returns a Vec of SGR parameter strings (e.g., "1" for bold, "3" for italic).
+fn modifier_to_sgr_parts(val: u16) -> Vec<&'static str> {
+ let mut parts = Vec::new();
+
+ // ratatui::Modifier bits (from bitflags)
+ const BOLD: u16 = 1 << 0; // 0x01
+ const DIM: u16 = 1 << 1; // 0x02
+ const ITALIC: u16 = 1 << 2; // 0x04
+ const UNDERLINED: u16 = 1 << 3; // 0x08
+ const SLOW_BLINK: u16 = 1 << 4; // 0x10
+ const RAPID_BLINK: u16 = 1 << 5; // 0x20
+ const REVERSED: u16 = 1 << 6; // 0x40
+ const HIDDEN: u16 = 1 << 7; // 0x80
+ const CROSSED_OUT: u16 = 1 << 8; // 0x100
+
+ if val & BOLD != 0 {
+ parts.push("1");
+ }
+ if val & DIM != 0 {
+ parts.push("2");
+ }
+ if val & ITALIC != 0 {
+ parts.push("3");
+ }
+ if val & UNDERLINED != 0 {
+ parts.push("4");
+ }
+ if val & SLOW_BLINK != 0 {
+ parts.push("5");
+ }
+ if val & RAPID_BLINK != 0 {
+ parts.push("6");
+ }
+ if val & REVERSED != 0 {
+ parts.push("7");
+ }
+ if val & HIDDEN != 0 {
+ parts.push("8");
+ }
+ if val & CROSSED_OUT != 0 {
+ parts.push("9");
+ }
+
+ parts
+}
+
+/// Builds a complete SGR escape sequence for a cell's style.
+fn build_sgr(fg: u32, bg: u32, modifier: u16) -> String {
+ let mut parts = vec!["0".to_owned()];
+ parts.extend(
+ modifier_to_sgr_parts(modifier)
+ .into_iter()
+ .map(str::to_owned),
+ );
+ parts.push(color_to_sgr_fg(fg));
+ parts.push(color_to_sgr_bg(bg));
+ format!("\x1b[{}m", parts.join(";"))
+}
+
+// ---------------------------------------------------------------------------
+// Cell comparison
+// ---------------------------------------------------------------------------
+
+/// Checks if two cells are visually identical.
+#[cfg(test)]
+fn cells_equal(a: &CellData, b: &CellData) -> bool {
+ a.symbol == b.symbol
+ && a.fg == b.fg
+ && a.bg == b.bg
+ && a.modifier == b.modifier
+ && a.hyperlink == b.hyperlink
+ // Skip flag is only for ratatui internal use, not visual.
+}
+
+// ---------------------------------------------------------------------------
+// Blitting
+// ---------------------------------------------------------------------------
+
+/// Blits a frame to a writer, diffing against the previous frame.
+#[cfg(test)]
+fn blit_frame_to(writer: impl Write, frame: &FrameData, prev: Option<&FrameData>) {
+ let mut last_visible_cursor = None;
+ let mut last_cursor_shape = 0;
+ blit_frame_to_with_cursor_memory(
+ writer,
+ frame,
+ prev,
+ &mut last_visible_cursor,
+ &mut last_cursor_shape,
+ );
+}
+
+fn blit_frame_to_with_cursor_memory(
+ mut writer: impl Write,
+ frame: &FrameData,
+ prev: Option<&FrameData>,
+ last_visible_cursor: &mut Option<(u16, u16)>,
+ last_cursor_shape: &mut u8,
+) {
+ // On first frame or size change, do a full redraw.
+ let full_redraw =
+ prev.is_none() || prev.is_some_and(|p| p.width != frame.width || p.height != frame.height);
+
+ // Ask terminals that support synchronized output to apply the whole frame
+ // atomically. This keeps IMEs and cursor trackers from observing the
+ // intermediate CUP positions used while painting changed cells.
+ let _ = writer.write_all(b"\x1b[?2026h");
+
+ // Hide cursor before any cell writes to avoid stray cursor artifacts
+ // on terminals that render the hardware cursor at intermediate CUP positions.
+ let _ = writer.write_all(b"\x1b[?25l");
+
+ // Start each frame from a known OSC 8 state. If a previous write was
+ // interrupted or the outer terminal had an active hyperlink, unlinked cells
+ // must not inherit it.
+ let _ = writer.write_all(b"\x1b]8;;\x1b\\");
+
+ if full_redraw {
+ // Clear the screen and write all cells.
+ let _ = writer.write_all(b"\x1b[2J\x1b[H");
+ write_all_cells(&mut writer, frame);
+ } else {
+ // Diff-based update: only write changed cells.
+ let prev = prev.unwrap();
+ write_changed_cells(&mut writer, frame, prev);
+ }
+
+ // Position the cursor while it is still hidden, then restore visibility.
+ // Showing before moving makes slow terminals and IMEs briefly observe the
+ // cursor at the last painted cell, which can be an animated sidebar/status
+ // cell rather than the focused pane's input position. When the focused pane
+ // hides its cursor, still park the host cursor intentionally so IMEs do not
+ // anchor to whichever cell happened to be painted last.
+ let host_cursor = resolve_host_cursor_state(frame, last_visible_cursor);
+ write_host_cursor_state(&mut writer, host_cursor, last_cursor_shape);
+
+ // End the synchronized output block immediately after the final cursor
+ // state is emitted so supporting terminals can present the frame atomically.
+ let _ = writer.write_all(b"\x1b[?2026l");
+
+ // Some native IMEs track candidate-window placement from normal terminal
+ // cursor updates and may not observe cursor moves emitted inside synchronized
+ // output. Re-emit only the resolved final cursor anchor after the sync block;
+ // intermediate paint cursor positions remain hidden and the focused pane's
+ // requested cursor visibility is preserved.
+ write_ime_anchor_cursor_state(&mut writer, host_cursor);
+ let _ = writer.flush();
+}
+
+/// Writes all cells in the frame (full redraw).
+fn cell_width(cell: &CellData) -> usize {
+ cell.symbol.width()
+}
+
+#[derive(Clone, Copy)]
+struct HostCursorState {
+ position: (u16, u16),
+ visible: bool,
+ /// DECSCUSR parameter (0–6). 0 means terminal default.
+ shape: u8,
+}
+
+fn resolve_host_cursor_state(
+ frame: &FrameData,
+ last_visible_cursor: &mut Option<(u16, u16)>,
+) -> HostCursorState {
+ if let Some(cursor) = &frame.cursor {
+ if cursor.visible {
+ let position = clamp_cursor_position(frame, cursor.x, cursor.y);
+ *last_visible_cursor = Some(position);
+ return HostCursorState {
+ position,
+ visible: true,
+ shape: normalize_cursor_shape(cursor.shape),
+ };
+ }
+
+ let position = clamp_cursor_position(frame, cursor.x, cursor.y);
+ return HostCursorState {
+ position,
+ visible: false,
+ shape: normalize_cursor_shape(cursor.shape),
+ };
+ }
+
+ let position = (*last_visible_cursor)
+ .map(|(x, y)| clamp_cursor_position(frame, x, y))
+ .unwrap_or_else(|| default_hidden_cursor_position(frame));
+ HostCursorState {
+ position,
+ visible: false,
+ shape: 0,
+ }
+}
+
+fn normalize_cursor_shape(shape: u8) -> u8 {
+ if shape <= 6 {
+ shape
+ } else {
+ 0
+ }
+}
+
+fn default_hidden_cursor_position(frame: &FrameData) -> (u16, u16) {
+ (
+ frame.width.saturating_sub(1),
+ frame.height.saturating_sub(1),
+ )
+}
+
+fn clamp_cursor_position(frame: &FrameData, x: u16, y: u16) -> (u16, u16) {
+ (
+ x.min(frame.width.saturating_sub(1)),
+ y.min(frame.height.saturating_sub(1)),
+ )
+}
+
+fn write_cursor_position(writer: &mut impl Write, (x, y): (u16, u16)) {
+ // CUP: move cursor to (row+1, col+1) — 1-based.
+ let _ = write!(writer, "\x1b[{};{}H", y + 1, x + 1);
+}
+
+fn write_host_cursor_state(writer: &mut impl Write, cursor: HostCursorState, last_shape: &mut u8) {
+ write_cursor_position(writer, cursor.position);
+ if cursor.shape != *last_shape {
+ let _ = write!(writer, "\x1b[{} q", cursor.shape);
+ *last_shape = cursor.shape;
+ }
+ if cursor.visible {
+ // Show cursor only after it is already at the final position.
+ let _ = writer.write_all(b"\x1b[?25h");
+ } else {
+ let _ = writer.write_all(b"\x1b[?25l");
+ }
+}
+
+fn write_ime_anchor_cursor_state(writer: &mut impl Write, cursor: HostCursorState) {
+ write_cursor_position(writer, cursor.position);
+ if cursor.visible {
+ let _ = writer.write_all(b"\x1b[?25h");
+ } else {
+ let _ = writer.write_all(b"\x1b[?25l");
+ }
+}
+
+fn write_all_cells(writer: &mut impl Write, frame: &FrameData) {
+ let mut active_hyperlink = None;
+ for row in 0..frame.height {
+ let mut to_skip = 0usize;
+ for col in 0..frame.width {
+ if to_skip > 0 {
+ to_skip -= 1;
+ continue;
+ }
+
+ let idx = (row as usize) * (frame.width as usize) + (col as usize);
+ let cell = &frame.cells[idx];
+
+ if cell.skip {
+ continue;
+ }
+
+ // Move cursor to position (1-based).
+ let _ = write!(writer, "\x1b[{};{}H", row + 1, col + 1);
+
+ // Set style.
+ let sgr = build_sgr(cell.fg, cell.bg, cell.modifier);
+ let _ = writer.write_all(sgr.as_bytes());
+
+ write_hyperlink_if_changed(
+ writer,
+ &mut active_hyperlink,
+ cell_hyperlink_uri(frame, cell),
+ );
+
+ // Write the symbol.
+ let _ = writer.write_all(cell.symbol.as_bytes());
+ to_skip = cell_width(cell).saturating_sub(1);
+ }
+ }
+
+ close_hyperlink(writer, &mut active_hyperlink);
+
+ // Reset style at the end.
+ let _ = writer.write_all(b"\x1b[0m");
+}
+
+fn cell_hyperlink_uri<'a>(frame: &'a FrameData, cell: &CellData) -> Option<&'a str> {
+ let index = cell.hyperlink? as usize;
+ frame.hyperlinks.get(index).map(String::as_str)
+}
+
+fn sanitized_hyperlink_uri(uri: &str) -> Option<String> {
+ let sanitized: String = uri
+ .chars()
+ .filter(|ch| *ch != '\x1b' && *ch != '\x07' && !ch.is_control())
+ .collect();
+ (!sanitized.is_empty()).then_some(sanitized)
+}
+
+fn sanitized_frame_hyperlinks(frame: &FrameData) -> Vec<Option<String>> {
+ frame
+ .hyperlinks
+ .iter()
+ .map(|uri| sanitized_hyperlink_uri(uri))
+ .collect()
+}
+
+fn sanitized_cell_hyperlink_uri<'a>(
+ sanitized_hyperlinks: &'a [Option<String>],
+ cell: &CellData,
+) -> Option<&'a str> {
+ let index = cell.hyperlink? as usize;
+ sanitized_hyperlinks.get(index)?.as_deref()
+}
+
+fn write_hyperlink_if_changed(
+ writer: &mut impl Write,
+ active: &mut Option<String>,
+ requested: Option<&str>,
+) {
+ let requested = requested.and_then(sanitized_hyperlink_uri);
+ if active.as_deref() == requested.as_deref() {
+ return;
+ }
+
+ if active.is_some() {
+ let _ = writer.write_all(b"\x1b]8;;\x1b\\");
+ }
+ *active = requested;
+ if let Some(uri) = active.as_deref() {
+ let _ = write!(writer, "\x1b]8;;{uri}\x1b\\");
+ }
+}
+
+fn close_hyperlink(writer: &mut impl Write, active: &mut Option<String>) {
+ if active.take().is_some() {
+ let _ = writer.write_all(b"\x1b]8;;\x1b\\");
+ }
+}
+
+fn write_cell(
+ writer: &mut impl Write,
+ row: u16,
+ col: u16,
+ cell: &CellData,
+ last_sgr: &mut String,
+ active_hyperlink: &mut Option<String>,
+ frame: &FrameData,
+) {
+ if cell.skip {
+ return;
+ }
+
+ let _ = write!(writer, "\x1b[{};{}H", row + 1, col + 1);
+
+ let sgr = build_sgr(cell.fg, cell.bg, cell.modifier);
+ if sgr != *last_sgr {
+ let _ = writer.write_all(sgr.as_bytes());
+ *last_sgr = sgr;
+ }
+
+ write_hyperlink_if_changed(writer, active_hyperlink, cell_hyperlink_uri(frame, cell));
+ let _ = writer.write_all(cell.symbol.as_bytes());
+}
+
+/// Writes only the cells that changed between the previous and current frame.
+fn cells_visually_equal(
+ sanitized_hyperlinks: &[Option<String>],
+ cell: &CellData,
+ prev_sanitized_hyperlinks: &[Option<String>],
+ prev_cell: &CellData,
+) -> bool {
+ cell.symbol == prev_cell.symbol
+ && cell.fg == prev_cell.fg
+ && cell.bg == prev_cell.bg
+ && cell.modifier == prev_cell.modifier
+ && sanitized_cell_hyperlink_uri(sanitized_hyperlinks, cell)
+ == sanitized_cell_hyperlink_uri(prev_sanitized_hyperlinks, prev_cell)
+ // Skip flag is only for ratatui internal use, not visual.
+}
+
+fn write_changed_cells(writer: &mut impl Write, frame: &FrameData, prev: &FrameData) {
+ let mut last_sgr = String::new(); // Track last SGR to avoid redundant style changes.
+ let mut active_hyperlink = None;
+ let sanitized_hyperlinks = sanitized_frame_hyperlinks(frame);
+ let prev_sanitized_hyperlinks = sanitized_frame_hyperlinks(prev);
+
+ for row in 0..frame.height {
+ let mut invalidated = 0usize;
+ let mut to_skip = 0usize;
+
+ for col in 0..frame.width {
+ let idx = (row as usize) * (frame.width as usize) + (col as usize);
+ let cell = &frame.cells[idx];
+ let prev_cell = &prev.cells[idx];
+
+ if !cell.skip
+ && (!cells_visually_equal(
+ &sanitized_hyperlinks,
+ cell,
+ &prev_sanitized_hyperlinks,
+ prev_cell,
+ ) || invalidated > 0)
+ && to_skip == 0
+ {
+ write_cell(
+ writer,
+ row,
+ col,
+ cell,
+ &mut last_sgr,
+ &mut active_hyperlink,
+ frame,
+ );
+ }
+
+ to_skip = cell_width(cell).saturating_sub(1);
+ let affected_width = cmp::max(cell_width(cell), cell_width(prev_cell));
+ invalidated = cmp::max(affected_width, invalidated).saturating_sub(1);
+ }
+ }
+
+ close_hyperlink(writer, &mut active_hyperlink);
+
+ // Reset style if we wrote anything.
+ if !last_sgr.is_empty() {
+ let _ = writer.write_all(b"\x1b[0m");
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::protocol::{CellData, CursorState};
+
+ const WIDE_GRAPHEME: &str = "💡";
+
+ fn make_cell(symbol: &str, fg: u32, bg: u32, modifier: u16) -> CellData {
+ CellData {
+ symbol: symbol.to_owned(),
+ fg,
+ bg,
+ modifier,
+ skip: false,
+ hyperlink: None,
+ }
+ }
+
+ fn make_frame(width: u16, height: u16, cells: Vec<CellData>) -> FrameData {
+ FrameData {
+ cells,
+ width,
+ height,
+ cursor: None,
+ hyperlinks: Vec::new(),
+ graphics: Vec::new(),
+ }
+ }
+
+ fn linked_cell(symbol: &str, index: u32) -> CellData {
+ let mut cell = make_cell(symbol, 0, 0, 0);
+ cell.hyperlink = Some(index);
+ cell
+ }
+
+ #[test]
+ fn color_to_sgr_fg_named_colors() {
+ assert_eq!(color_to_sgr_fg(0x00_00_00_00), "39"); // Reset
+ assert_eq!(color_to_sgr_fg(0x00_00_00_01), "30"); // Black
+ assert_eq!(color_to_sgr_fg(0x00_00_00_02), "31"); // Red
+ assert_eq!(color_to_sgr_fg(0x00_00_00_10), "97"); // White
+ }
+
+ #[test]
+ fn color_to_sgr_fg_indexed() {
+ assert_eq!(color_to_sgr_fg(0x01_00_00_AB), "38;5;171");
+ }
+
+ #[test]
+ fn color_to_sgr_fg_rgb() {
+ assert_eq!(color_to_sgr_fg(0x02_FF_80_40), "38;2;255;128;64");
+ }
+
+ #[test]
+ fn color_to_sgr_bg_named_colors() {
+ assert_eq!(color_to_sgr_bg(0x00_00_00_00), "49"); // Reset
+ assert_eq!(color_to_sgr_bg(0x00_00_00_01), "40"); // Black
+ assert_eq!(color_to_sgr_bg(0x00_00_00_10), "107"); // White
+ }
+
+ #[test]
+ fn color_to_sgr_bg_rgb() {
+ assert_eq!(color_to_sgr_bg(0x02_FF_80_40), "48;2;255;128;64");
+ }
+
+ #[test]
+ fn modifier_to_sgr_parts_bold() {
+ let parts = modifier_to_sgr_parts(1); // BOLD
+ assert!(parts.contains(&"1"));
+ }
+
+ #[test]
+ fn modifier_to_sgr_parts_italic() {
+ let parts = modifier_to_sgr_parts(4); // ITALIC
+ assert!(parts.contains(&"3"));
+ }
+
+ #[test]
+ fn modifier_to_sgr_parts_empty() {
+ let parts = modifier_to_sgr_parts(0);
+ assert!(parts.is_empty());
+ }
+
+ #[test]
+ fn build_sgr_produces_valid_sequence() {
+ let sgr = build_sgr(0x00_00_00_02, 0x00_00_00_01, 1); // fg=Red, bg=Black, bold
+ assert!(sgr.starts_with("\x1b["));
+ assert!(sgr.ends_with("m"));
+ assert!(sgr.contains("0")); // reset existing style first
+ assert!(sgr.contains("1")); // bold
+ assert!(sgr.contains("31")); // fg red
+ assert!(sgr.contains("40")); // bg black
+ }
+
+ #[test]
+ fn build_sgr_resets_previous_modifiers_when_cell_is_plain() {
+ assert_eq!(build_sgr(0x00_00_00_00, 0x00_00_00_00, 0), "\x1b[0;39;49m");
+ }
+
+ #[test]
+ fn cells_equal_identical() {
+ let a = make_cell("A", 2, 1, 0);
+ let b = make_cell("A", 2, 1, 0);
+ assert!(cells_equal(&a, &b));
+ }
+
+ #[test]
+ fn cells_equal_different_symbol() {
+ let a = make_cell("A", 2, 1, 0);
+ let b = make_cell("B", 2, 1, 0);
+ assert!(!cells_equal(&a, &b));
+ }
+
+ #[test]
+ fn cells_equal_different_color() {
+ let a = make_cell("A", 2, 1, 0);
+ let b = make_cell("A", 3, 1, 0);
+ assert!(!cells_equal(&a, &b));
+ }
+
+ #[test]
+ fn blit_frame_hides_cursor_before_full_redraw_writes() {
+ let frame = make_frame(
+ 2,
+ 2,
+ vec![
+ make_cell("H", 0, 0, 0),
+ make_cell("i", 0, 0, 0),
+ make_cell("!", 0, 0, 0),
+ make_cell(" ", 0, 0, 0),
+ ],
+ );
+
+ let mut output = Vec::new();
+ blit_frame_to(&mut output, &frame, None);
+
+ let output_str = String::from_utf8(output).unwrap();
+ assert!(
+ output_str.starts_with("\x1b[?2026h\x1b[?25l"),
+ "should begin synchronized output and hide cursor before any cell writes during full redraw"
+ );
+ }
+
+ #[test]
+ fn blit_frame_hides_cursor_before_diff_writes() {
+ let prev = make_frame(
+ 2,
+ 2,
+ vec![
+ make_cell("H", 0, 0, 0),
+ make_cell("i", 0, 0, 0),
+ make_cell("!", 0, 0, 0),
+ make_cell(" ", 0, 0, 0),
+ ],
+ );
+
+ let curr = make_frame(
+ 2,
+ 2,
+ vec![
+ make_cell("X", 0, 0, 0), // Changed
+ make_cell("i", 0, 0, 0), // Same
+ make_cell("!", 0, 0, 0), // Same
+ make_cell(" ", 0, 0, 0), // Same
+ ],
+ );
+
+ let mut output = Vec::new();
+ blit_frame_to(&mut output, &curr, Some(&prev));
+
+ let output_str = String::from_utf8(output).unwrap();
+ assert!(
+ output_str.starts_with("\x1b[?2026h\x1b[?25l"),
+ "should begin synchronized output and hide cursor before any cell writes during diff"
+ );
+ }
+
+ #[test]
+ fn blit_frame_wraps_frame_in_synchronized_output() {
+ let frame = make_frame(1, 1, vec![make_cell("A", 0, 0, 0)]);
+
+ let mut output = Vec::new();
+ blit_frame_to(&mut output, &frame, None);
+
+ let output_str = String::from_utf8(output).unwrap();
+ assert!(
+ output_str.starts_with("\x1b[?2026h"),
+ "should begin synchronized output before frame writes"
+ );
+ let sync_end = output_str
+ .find("\x1b[?2026l")
+ .expect("should end synchronized output after frame writes");
+ assert!(
+ sync_end + "\x1b[?2026l".len() < output_str.len(),
+ "should end synchronized output before trailing IME cursor update"
+ );
+ }
+
+ #[test]
+ fn blit_frame_repeats_final_cursor_state_after_synchronized_output() {
+ let frame = FrameData {
+ cells: vec![make_cell("A", 0, 0, 0); 9],
+ width: 3,
+ height: 3,
+ cursor: Some(CursorState {
+ x: 2,
+ y: 1,
+ visible: true,
+ shape: 0,
+ }),
+ hyperlinks: Vec::new(),
+ graphics: Vec::new(),
+ };
+
+ let mut output = Vec::new();
+ blit_frame_to(&mut output, &frame, None);
+
+ let output_str = String::from_utf8(output).unwrap();
+ let sync_end = output_str
+ .find("\x1b[?2026l")
+ .expect("should end synchronized output");
+ let trailing_cursor = &output_str[sync_end + "\x1b[?2026l".len()..];
+ assert_eq!(
+ trailing_cursor, "\x1b[2;3H\x1b[?25h",
+ "should expose only the final cursor state after synchronized output"
+ );
+ }
+
+ #[test]
+ fn blit_frame_emits_cursor_shape_before_visibility_without_touching_ime_anchor() {
+ let frame = FrameData {
+ cells: vec![make_cell("A", 0, 0, 0)],
+ width: 1,
+ height: 1,
+ cursor: Some(CursorState {
+ x: 0,
+ y: 0,
+ visible: true,
+ shape: 6,
+ }),
+ hyperlinks: Vec::new(),
+ graphics: Vec::new(),
+ };
+
+ let mut output = Vec::new();
+ blit_frame_to(&mut output, &frame, None);
+
+ let output_str = String::from_utf8(output).unwrap();
+ let final_cursor = output_str
+ .find("\x1b[1;1H\x1b[6 q\x1b[?25h")
+ .expect("should set cursor shape before showing cursor");
+ let sync_end = output_str
+ .find("\x1b[?2026l")
+ .expect("should end synchronized output");
+ assert!(
+ final_cursor < sync_end,
+ "shape should be part of the synchronized final cursor state"
+ );
+ let trailing_cursor = &output_str[sync_end + "\x1b[?2026l".len()..];
+ assert_eq!(
+ trailing_cursor, "\x1b[1;1H\x1b[?25h",
+ "IME anchor update should preserve the existing position/visibility-only contract"
+ );
+ }
+
+ #[test]
+ fn blit_frame_repeats_explicit_hidden_cursor_anchor_after_synchronized_output() {
+ let visible = FrameData {
+ cells: vec![make_cell("A", 0, 0, 0); 9],
+ width: 3,
+ height: 3,
+ cursor: Some(CursorState {
+ x: 0,
+ y: 0,
+ visible: true,
+ shape: 0,
+ }),
+ hyperlinks: Vec::new(),
+ graphics: Vec::new(),
+ };
+ let hidden = FrameData {
+ cells: vec![make_cell("B", 0, 0, 0); 9],
+ width: 3,
+ height: 3,
+ cursor: Some(CursorState {
+ x: 2,
+ y: 1,
+ visible: false,
+ shape: 0,
+ }),
+ hyperlinks: Vec::new(),
+ graphics: Vec::new(),
+ };
+ let mut last_visible_cursor = None;
+ let mut last_cursor_shape = 0;
+ let mut output = Vec::new();
+
+ blit_frame_to_with_cursor_memory(
+ &mut output,
+ &visible,
+ None,
+ &mut last_visible_cursor,
+ &mut last_cursor_shape,
+ );
+ output.clear();
+ blit_frame_to_with_cursor_memory(
+ &mut output,
+ &hidden,
+ Some(&visible),
+ &mut last_visible_cursor,
+ &mut last_cursor_shape,
+ );
+
+ let output_str = String::from_utf8(output).unwrap();
+ let sync_end = output_str
+ .find("\x1b[?2026l")
+ .expect("should end synchronized output");
+ let trailing_cursor = &output_str[sync_end + "\x1b[?2026l".len()..];
+ assert_eq!(
+ trailing_cursor, "\x1b[2;3H\x1b[?25l",
+ "should repeat the explicit hidden cursor position while preserving visibility"
+ );
+ }
+
+ #[test]
+ fn blit_frame_emits_osc8_for_linked_cells() {
+ let mut frame = make_frame(
+ 3,
+ 1,
+ vec![
+ linked_cell("L", 0),
+ linked_cell("i", 0),
+ make_cell("!", 0, 0, 0),
+ ],
+ );
+ frame.hyperlinks.push("https://example.com".to_owned());
+
+ let mut output = Vec::new();
+ blit_frame_to(&mut output, &frame, None);
+
+ let output_str = String::from_utf8(output).unwrap();
+ assert!(output_str.contains("\x1b]8;;https://example.com\x1b\\L"));
+ assert!(output_str.contains('i'));
+ assert!(output_str.contains("\x1b]8;;\x1b\\"));
+ }
+
+ #[test]
+ fn blit_frame_sanitizes_hyperlink_uris() {
+ let mut frame = make_frame(1, 1, vec![linked_cell("L", 0)]);
+ frame
+ .hyperlinks
+ .push("https://exa\x1b\x07mple.com".to_owned());
+
+ let mut output = Vec::new();
+ blit_frame_to(&mut output, &frame, None);
+
+ let output_str = String::from_utf8(output).unwrap();
+ assert!(output_str.contains("\x1b]8;;https://example.com\x1b\\L"));
+ }
+
+ #[test]
+ fn blit_frame_first_frame_produces_output() {
+ let frame = make_frame(
+ 2,
+ 2,
+ vec![
+ make_cell("H", 0, 0, 0),
+ make_cell("i", 0, 0, 0),
+ make_cell("!", 0, 0, 0),
+ make_cell(" ", 0, 0, 0),
+ ],
+ );
+
+ let mut output = Vec::new();
+ blit_frame_to(&mut output, &frame, None);
+
+ let output_str = String::from_utf8(output).unwrap();
+ // Full redraw should start with clear screen.
+ assert!(
+ output_str.contains("\x1b[2J"),
+ "full redraw should clear screen"
+ );
+ assert!(
+ output_str.contains('H') || output_str.contains('i'),
+ "should contain cell content"
+ );
+ }
+
+ #[test]
+ fn blit_frame_diff_only_writes_changed_cells() {
+ let prev = make_frame(
+ 2,
+ 2,
+ vec![
+ make_cell("H", 0, 0, 0),
+ make_cell("i", 0, 0, 0),
+ make_cell("!", 0, 0, 0),
+ make_cell(" ", 0, 0, 0),
+ ],
+ );
+
+ // Only the first cell changed.
+ let curr = make_frame(
+ 2,
+ 2,
+ vec![
+ make_cell("X", 0, 0, 0), // Changed
+ make_cell("i", 0, 0, 0), // Same
+ make_cell("!", 0, 0, 0), // Same
+ make_cell(" ", 0, 0, 0), // Same
+ ],
+ );
+
+ let mut output = Vec::new();
+ blit_frame_to(&mut output, &curr, Some(&prev));
+
+ let output_str = String::from_utf8(output).unwrap();
+ // Diff should NOT clear the screen.
+ assert!(
+ !output_str.contains("\x1b[2J"),
+ "diff should not clear screen"
+ );
+ // Should contain the changed cell content.
+ assert!(output_str.contains('X'), "should contain changed cell 'X'");
+ }
+
+ #[test]
+ fn blit_frame_size_change_triggers_full_redraw() {
+ let prev = make_frame(2, 2, vec![make_cell("A", 0, 0, 0); 4]);
+
+ let curr = make_frame(3, 2, vec![make_cell("B", 0, 0, 0); 6]);
+
+ let mut output = Vec::new();
+ blit_frame_to(&mut output, &curr, Some(&prev));
+
+ let output_str = String::from_utf8(output).unwrap();
+ assert!(
+ output_str.contains("\x1b[2J"),
+ "size change should trigger full redraw"
+ );
+ }
+
+ #[test]
+ fn blit_frame_positions_cursor() {
+ let frame = FrameData {
+ cells: vec![make_cell("A", 0, 0, 0)],
+ width: 1,
+ height: 1,
+ cursor: Some(CursorState {
+ x: 0,
+ y: 0,
+ visible: true,
+ shape: 0,
+ }),
+ hyperlinks: Vec::new(),
+ graphics: Vec::new(),
+ };
+
+ let mut output = Vec::new();
+ blit_frame_to(&mut output, &frame, None);
+
+ let output_str = String::from_utf8(output).unwrap();
+ assert!(
+ output_str.contains("\x1b[1;1H"),
+ "should position cursor at (1,1)"
+ );
+ }
+
+ #[test]
+ fn blit_frame_hides_cursor_when_invisible() {
+ let frame = FrameData {
+ cells: vec![make_cell("A", 0, 0, 0)],
+ width: 1,
+ height: 1,
+ cursor: Some(CursorState {
+ x: 0,
+ y: 0,
+ visible: false,
+ shape: 0,
+ }),
+ hyperlinks: Vec::new(),
+ graphics: Vec::new(),
+ };
+
+ let mut output = Vec::new();
+ blit_frame_to(&mut output, &frame, None);
+
+ let output_str = String::from_utf8(output).unwrap();
+ assert!(
+ output_str.contains("\x1b[?25l"),
+ "should hide cursor when invisible"
+ );
+ }
+
+ #[test]
+ fn blit_frame_no_cursor_hides_cursor() {
+ let frame = FrameData {
+ cells: vec![make_cell("A", 0, 0, 0)],
+ width: 1,
+ height: 1,
+ cursor: None,
+ hyperlinks: Vec::new(),
+ graphics: Vec::new(),
+ };
+
+ let mut output = Vec::new();
+ blit_frame_to(&mut output, &frame, None);
+
+ let output_str = String::from_utf8(output).unwrap();
+ assert!(
+ output_str.contains("\x1b[?25l"),
+ "should hide cursor when no cursor state"
+ );
+ }
+
+ #[test]
+ fn blit_frame_restores_cursor_visibility() {
+ // First frame: cursor hidden.
+ let prev = FrameData {
+ cells: vec![make_cell("A", 0, 0, 0)],
+ width: 1,
+ height: 1,
+ cursor: Some(CursorState {
+ x: 0,
+ y: 0,
+ visible: false,
+ shape: 0,
+ }),
+ hyperlinks: Vec::new(),
+ graphics: Vec::new(),
+ };
+
+ let mut output = Vec::new();
+ blit_frame_to(&mut output, &prev, None);
+ assert!(
+ String::from_utf8(output).unwrap().contains("\x1b[?25l"),
+ "first frame should hide cursor"
+ );
+
+ // Second frame: cursor visible — should restore visibility.
+ let curr = FrameData {
+ cells: vec![make_cell("B", 0, 0, 0)],
+ width: 1,
+ height: 1,
+ cursor: Some(CursorState {
+ x: 0,
+ y: 0,
+ visible: true,
+ shape: 0,
+ }),
+ hyperlinks: Vec::new(),
+ graphics: Vec::new(),
+ };
+
+ let mut output = Vec::new();
+ blit_frame_to(&mut output, &curr, Some(&prev));
+ let output_str = String::from_utf8(output).unwrap();
+ assert!(
+ output_str.contains("\x1b[?25h"),
+ "second frame should restore cursor visibility with ?25h"
+ );
+ assert!(
+ output_str.contains("\x1b[1;1H"),
+ "should position cursor before showing it"
+ );
+ }
+
+ #[test]
+ fn blit_frame_positions_cursor_before_showing_it() {
+ let prev = FrameData {
+ cells: vec![make_cell("A", 0, 0, 0); 9],
+ width: 3,
+ height: 3,
+ cursor: Some(CursorState {
+ x: 0,
+ y: 0,
+ visible: true,
+ shape: 0,
+ }),
+ hyperlinks: Vec::new(),
+ graphics: Vec::new(),
+ };
+ let mut curr = prev.clone();
+ curr.cells[0] = make_cell("B", 0, 0, 0);
+ curr.cursor = Some(CursorState {
+ x: 2,
+ y: 2,
+ visible: true,
+ shape: 0,
+ });
+
+ let mut output = Vec::new();
+ blit_frame_to(&mut output, &curr, Some(&prev));
+ let output_str = String::from_utf8(output).unwrap();
+ let final_move = output_str
+ .rfind("\x1b[3;3H")
+ .expect("should move cursor to final position");
+ let show = output_str
+ .rfind("\x1b[?25h")
+ .expect("should show cursor after positioning it");
+
+ assert!(
+ final_move < show,
+ "should move cursor to final position before showing it"
+ );
+ }
+
+ #[test]
+ fn blit_frame_parks_hidden_cursor_at_last_visible_position() {
+ let visible = FrameData {
+ cells: vec![make_cell("A", 0, 0, 0); 9],
+ width: 3,
+ height: 3,
+ cursor: Some(CursorState {
+ x: 1,
+ y: 1,
+ visible: true,
+ shape: 0,
+ }),
+ hyperlinks: Vec::new(),
+ graphics: Vec::new(),
+ };
+ let hidden = FrameData {
+ cells: vec![make_cell("B", 0, 0, 0); 9],
+ width: 3,
+ height: 3,
+ cursor: None,
+ hyperlinks: Vec::new(),
+ graphics: Vec::new(),
+ };
+ let mut last_visible_cursor = None;
+ let mut last_cursor_shape = 0;
+ let mut output = Vec::new();
+
+ blit_frame_to_with_cursor_memory(
+ &mut output,
+ &visible,
+ None,
+ &mut last_visible_cursor,
+ &mut last_cursor_shape,
+ );
+ output.clear();
+ blit_frame_to_with_cursor_memory(
+ &mut output,
+ &hidden,
+ Some(&visible),
+ &mut last_visible_cursor,
+ &mut last_cursor_shape,
+ );
+
+ let output_str = String::from_utf8(output).unwrap();
+ let park = output_str
+ .rfind("\x1b[2;2H")
+ .expect("should park hidden cursor at last visible position");
+ let hide = output_str
+ .rfind("\x1b[?25l")
+ .expect("should keep hidden cursor hidden");
+ assert!(park < hide, "should park cursor before hiding it");
+ }
+
+ #[test]
+ fn blit_frame_parks_hidden_cursor_at_bottom_right_without_history() {
+ let frame = FrameData {
+ cells: vec![make_cell("A", 0, 0, 0); 6],
+ width: 3,
+ height: 2,
+ cursor: None,
+ hyperlinks: Vec::new(),
+ graphics: Vec::new(),
+ };
+ let mut last_visible_cursor = None;
+ let mut last_cursor_shape = 0;
+ let mut output = Vec::new();
+
+ blit_frame_to_with_cursor_memory(
+ &mut output,
+ &frame,
+ None,
+ &mut last_visible_cursor,
+ &mut last_cursor_shape,
+ );
+
+ let output_str = String::from_utf8(output).unwrap();
+ assert!(
+ output_str.contains("\x1b[2;3H\x1b[?25l"),
+ "should park hidden cursor at bottom-right before ending the frame"
+ );
+ }
+
+ #[test]
+ fn blit_frame_hides_previous_visible_cursor_when_next_frame_has_none() {
+ let prev = FrameData {
+ cells: vec![make_cell("A", 0, 0, 0)],
+ width: 1,
+ height: 1,
+ cursor: Some(CursorState {
+ x: 0,
+ y: 0,
+ visible: true,
+ shape: 0,
+ }),
+ hyperlinks: Vec::new(),
+ graphics: Vec::new(),
+ };
+ let curr = FrameData {
+ cells: vec![make_cell("B", 0, 0, 0)],
+ width: 1,
+ height: 1,
+ cursor: None,
+ hyperlinks: Vec::new(),
+ graphics: Vec::new(),
+ };
+
+ let mut output = Vec::new();
+ blit_frame_to(&mut output, &curr, Some(&prev));
+
+ assert!(
+ String::from_utf8(output).unwrap().contains("\x1b[?25l"),
+ "diff redraw should hide a previously visible cursor when the next frame has none"
+ );
+ }
+
+ #[test]
+ fn full_redraw_skips_trailing_cells_covered_by_wide_graphemes() {
+ let frame = FrameData {
+ cells: vec![
+ make_cell(WIDE_GRAPHEME, 0, 0, 0),
+ make_cell(" ", 0, 0, 0),
+ make_cell("Z", 0, 0, 0),
+ ],
+ width: 3,
+ height: 1,
+ cursor: None,
+ hyperlinks: Vec::new(),
+ graphics: Vec::new(),
+ };
+
+ let mut output = Vec::new();
+ blit_frame_to(&mut output, &frame, None);
+ let output_str = String::from_utf8(output).unwrap();
+
+ assert!(output_str.contains("\x1b[1;1H"));
+ assert!(!output_str.contains("\x1b[1;2H"));
+ assert!(output_str.contains("\x1b[1;3H"));
+ }
+
+ #[test]
+ fn diff_redraw_reveals_cells_hidden_by_previous_wide_graphemes() {
+ let prev = FrameData {
+ cells: vec![
+ make_cell(WIDE_GRAPHEME, 0, 0, 0),
+ make_cell(" ", 0, 0, 0),
+ make_cell("Z", 0, 0, 0),
+ ],
+ width: 3,
+ height: 1,
+ cursor: None,
+ hyperlinks: Vec::new(),
+ graphics: Vec::new(),
+ };
+ let curr = FrameData {
+ cells: vec![
+ make_cell("A", 0, 0, 0),
+ make_cell(" ", 0, 0, 0),
+ make_cell("Z", 0, 0, 0),
+ ],
+ width: 3,
+ height: 1,
+ cursor: None,
+ hyperlinks: Vec::new(),
+ graphics: Vec::new(),
+ };
+
+ let mut output = Vec::new();
+ blit_frame_to(&mut output, &curr, Some(&prev));
+ let output_str = String::from_utf8(output).unwrap();
+
+ assert!(output_str.contains("\x1b[1;1H"));
+ assert!(
+ output_str.contains("\x1b[1;2H"),
+ "cells hidden by a previous wide grapheme must be redrawn when they become visible"
+ );
+ }
+
+ #[test]
+ fn diff_redraw_skips_new_trailing_cells_covered_by_wide_graphemes() {
+ let prev = FrameData {
+ cells: vec![
+ make_cell("A", 0, 0, 0),
+ make_cell("B", 0, 0, 0),
+ make_cell("Z", 0, 0, 0),
+ ],
+ width: 3,
+ height: 1,
+ cursor: None,
+ hyperlinks: Vec::new(),
+ graphics: Vec::new(),
+ };
+ let curr = FrameData {
+ cells: vec![
+ make_cell(WIDE_GRAPHEME, 0, 0, 0),
+ make_cell(" ", 0, 0, 0),
+ make_cell("Z", 0, 0, 0),
+ ],
+ width: 3,
+ height: 1,
+ cursor: None,
+ hyperlinks: Vec::new(),
+ graphics: Vec::new(),
+ };
+
+ let mut output = Vec::new();
+ blit_frame_to(&mut output, &curr, Some(&prev));
+ let output_str = String::from_utf8(output).unwrap();
+
+ assert!(output_str.contains("\x1b[1;1H"));
+ assert!(!output_str.contains("\x1b[1;2H"));
+ }
+}
diff --git a/src/protocol/wire.rs b/src/protocol/wire.rs
new file mode 100644
index 00000000..33a5f513
--- /dev/null
+++ b/src/protocol/wire.rs
@@ -0,0 +1,1513 @@
+//! Wire protocol for herdr server/client communication.
+//!
+//! Defines the message types, framing, version negotiation, and safety
+//! constraints for the binary protocol over Unix domain sockets.
+
+use std::collections::HashMap;
+use std::io::{self, Read, Write};
+
+use serde::{Deserialize, Serialize};
+
+// ---------------------------------------------------------------------------
+// Protocol constants
+// ---------------------------------------------------------------------------
+
+/// Current protocol version. Bumped when wire format changes incompatibly.
+pub const PROTOCOL_VERSION: u32 = 9;
+
+/// 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 server-to-client frame payload when Kitty graphics are enabled.
+/// Normal traffic keeps `MAX_FRAME_SIZE`; this larger cap is only for explicit
+/// image payloads that are naturally much larger after base64 encoding.
+pub const MAX_GRAPHICS_FRAME_SIZE: usize = 32 * 1024 * 1024;
+
+/// Maximum clipboard image payload size for remote paste bridging.
+pub const MAX_CLIPBOARD_IMAGE_PAYLOAD: usize = 16 * 1024 * 1024;
+
+/// Length of the u32 little-endian length prefix in bytes.
+const LENGTH_PREFIX_BYTES: usize = 4;
+
+// ---------------------------------------------------------------------------
+// Client → Server messages
+// ---------------------------------------------------------------------------
+
+/// Render payload encoding negotiated during client handshake.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+pub enum RenderEncoding {
+ /// Send full semantic FrameData values. This is the local/default mode.
+ SemanticFrame,
+ /// Send already-diffed terminal ANSI byte streams.
+ TerminalAnsi,
+}
+
+/// Keybinding profile requested by an attached app client.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub enum ClientKeybindings {
+ /// Use the server's own keybinding config.
+ Server,
+ /// Use this attached client's normalized local `[keys]` config.
+ Local { keys_toml: String },
+}
+
+/// Messages sent from the client to the server over the client protocol socket.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub enum ClientMessage {
+ /// Handshake: client announces its protocol version and terminal dimensions.
+ Hello {
+ /// Protocol version the client speaks.
+ version: u32,
+ /// Terminal width in columns.
+ cols: u16,
+ /// Terminal height in rows.
+ rows: u16,
+ /// Width of a terminal cell in physical pixels, or 0 when client-side Kitty graphics are disabled.
+ cell_width_px: u32,
+ /// Height of a terminal cell in physical pixels, or 0 when client-side Kitty graphics are disabled.
+ cell_height_px: u32,
+ /// Render encoding requested by the client.
+ requested_encoding: RenderEncoding,
+ /// Keybinding profile requested by the client.
+ keybindings: ClientKeybindings,
+ },
+
+ /// Raw input bytes read from the client's stdin.
+ Input {
+ /// Raw terminal input (possibly multi-byte escape sequences).
+ data: Vec<u8>,
+ },
+
+ /// Image bytes read from the client's local clipboard for remote paste bridging.
+ ClipboardImage {
+ /// Image file extension without a leading dot.
+ extension: String,
+ /// Raw image bytes.
+ data: Vec<u8>,
+ },
+
+ /// Terminal resize notification from the client.
+ Resize {
+ /// New terminal width in columns.
+ cols: u16,
+ /// New terminal height in rows.
+ rows: u16,
+ /// Width of a terminal cell in physical pixels, or 0 when client-side Kitty graphics are disabled.
+ cell_width_px: u32,
+ /// Height of a terminal cell in physical pixels, or 0 when client-side Kitty graphics are disabled.
+ cell_height_px: u32,
+ },
+
+ /// Graceful disconnect request.
+ Detach,
+
+ /// Switch this connection into direct terminal attach mode.
+ AttachTerminal {
+ /// Terminal id to attach to.
+ terminal_id: String,
+ /// Replace an existing writable attach owner for this terminal.
+ takeover: bool,
+ },
+}
+
+// ---------------------------------------------------------------------------
+// Server → Client messages
+// ---------------------------------------------------------------------------
+
+/// A single cell in a rendered frame, serialized independently from ratatui's
+/// `Cell` type to keep the wire protocol stable.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct CellData {
+ /// Grapheme cluster displayed in this cell (usually 1–2 chars).
+ pub symbol: String,
+ /// Foreground color as a packed u32 (0xAARRGGBB or ratatui Color index).
+ pub fg: u32,
+ /// Background color as a packed u32.
+ pub bg: u32,
+ /// Bitmask of style modifiers (bold, italic, etc.).
+ pub modifier: u16,
+ /// Whether this cell should be skipped during diff-based rendering.
+ pub skip: bool,
+ /// Index into `FrameData::hyperlinks` for this cell's OSC 8 target, if any.
+ pub hyperlink: Option<u32>,
+}
+
+/// Cursor shape encoded as a DECSCUSR parameter.
+///
+/// 0 = terminal default, 1 = blinking block, 2 = steady block,
+/// 3 = blinking underline, 4 = steady underline, 5 = blinking bar,
+/// 6 = steady bar.
+pub type CursorShapeParam = u8;
+
+/// Cursor position within a rendered frame.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct CursorState {
+ /// Column offset (0-based) of the cursor.
+ pub x: u16,
+ /// Row offset (0-based) of the cursor.
+ pub y: u16,
+ /// Whether the cursor is visible.
+ pub visible: bool,
+ /// Cursor shape as a DECSCUSR parameter.
+ #[serde(default)]
+ pub shape: CursorShapeParam,
+}
+
+/// A rendered frame to be displayed by the client.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct FrameData {
+ /// Cells in row-major order. Length must equal `width * height`.
+ pub cells: Vec<CellData>,
+ /// Frame width in columns.
+ pub width: u16,
+ /// Frame height in rows.
+ pub height: u16,
+ /// Cursor state for this frame, if applicable.
+ 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 {
+ /// Creates a `FrameData` from a ratatui `Buffer` and optional cursor.
+ ///
+ /// This converts ratatui's internal cell representation into the
+ /// wire-protocol cell format. The conversion is lossless for all
+ /// commonly used cell attributes.
+ #[cfg(test)]
+ pub fn from_ratatui_buffer(
+ buffer: &ratatui::buffer::Buffer,
+ cursor: Option<CursorState>,
+ ) -> Self {
+ Self::from_ratatui_buffer_with_hyperlinks(buffer, cursor, &[])
+ }
+
+ pub fn from_ratatui_buffer_with_hyperlinks(
+ buffer: &ratatui::buffer::Buffer,
+ cursor: Option<CursorState>,
+ hyperlinks: &[((u16, u16), String, String)],
+ ) -> Self {
+ let area = buffer.area;
+ let width = area.width;
+ let height = area.height;
+
+ let mut hyperlink_uris = Vec::<String>::new();
+ let mut hyperlink_indices = HashMap::<&str, u32>::new();
+ let mut hyperlink_by_position = HashMap::<(u16, u16), (&str, &str)>::new();
+ for ((x, y), symbol, uri) in hyperlinks {
+ hyperlink_by_position.insert((*x, *y), (symbol.as_str(), uri.as_str()));
+ }
+ let mut cells = Vec::with_capacity((width as usize) * (height as usize));
+ for row in 0..height {
+ for col in 0..width {
+ let cell = buffer.cell((col, row)).expect("cell within bounds");
+ let hyperlink = hyperlink_by_position
+ .get(&(col, row))
+ .and_then(|(symbol, uri)| {
+ if *symbol != cell.symbol() {
+ return None;
+ }
+ Some(*hyperlink_indices.entry(*uri).or_insert_with(|| {
+ let index = hyperlink_uris.len() as u32;
+ hyperlink_uris.push((*uri).to_owned());
+ index
+ }))
+ });
+ cells.push(CellData {
+ symbol: cell.symbol().to_owned(),
+ fg: color_to_u32(cell.fg),
+ bg: color_to_u32(cell.bg),
+ modifier: modifier_to_u16(cell.modifier),
+ skip: cell.skip,
+ hyperlink,
+ });
+ }
+ }
+
+ FrameData {
+ cells,
+ width,
+ height,
+ cursor,
+ hyperlinks: hyperlink_uris,
+ graphics: Vec::new(),
+ }
+ }
+
+ /// Reconstructs a ratatui `Buffer` from this frame data.
+ ///
+ /// Returns `None` if the cells vector length doesn't match `width * height`.
+ #[cfg(test)]
+ pub fn to_ratatui_buffer(&self) -> Option<ratatui::buffer::Buffer> {
+ let expected = (self.width as usize) * (self.height as usize);
+ if self.cells.len() != expected {
+ return None;
+ }
+
+ let area = ratatui::layout::Rect::new(0, 0, self.width, self.height);
+ let mut buffer = ratatui::buffer::Buffer::filled(area, ratatui::buffer::Cell::new(" "));
+
+ for row in 0..self.height {
+ for col in 0..self.width {
+ let idx = (row as usize) * (self.width as usize) + (col as usize);
+ let cell_data = &self.cells[idx];
+ let cell = buffer.cell_mut((col, row)).expect("cell within bounds");
+ cell.set_symbol(&cell_data.symbol);
+ cell.fg = u32_to_color(cell_data.fg);
+ cell.bg = u32_to_color(cell_data.bg);
+ cell.modifier = u16_to_modifier(cell_data.modifier);
+ cell.skip = cell_data.skip;
+ }
+ }
+
+ Some(buffer)
+ }
+}
+
+/// Terminal ANSI bytes encoded by the server for network-efficient clients.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct TerminalFrame {
+ /// Monotonic per-client frame sequence.
+ pub seq: u64,
+ /// Frame width in columns.
+ pub width: u16,
+ /// Frame height in rows.
+ pub height: u16,
+ /// Whether bytes contain a full redraw rather than an incremental diff.
+ pub full: bool,
+ /// Terminal escape bytes ready to write directly to stdout.
+ pub bytes: Vec<u8>,
+}
+
+/// Notification kind forwarded from server to client.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub enum NotifyKind {
+ /// Play a sound (bell/agent-done, etc.).
+ Sound,
+ /// Display a toast message through the outer terminal.
+ Toast,
+ /// Display a toast message through the host OS notification service.
+ SystemToast,
+}
+
+/// Messages sent from the server to the client over the client protocol socket.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub enum ServerMessage {
+ /// Handshake response: server acknowledges (or rejects) the client.
+ Welcome {
+ /// Protocol version the server speaks.
+ version: u32,
+ /// Render encoding selected by the server for this connection.
+ encoding: RenderEncoding,
+ /// If present, the handshake failed and this describes why.
+ /// The client should exit with a clear error message.
+ error: Option<String>,
+ },
+
+ /// A rendered frame to be displayed by a semantic-frame client.
+ Frame(FrameData),
+
+ /// 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.
+ reason: Option<String>,
+ },
+
+ /// A notification event (sound/toast) to be rendered locally by the client.
+ Notify {
+ /// What kind of notification.
+ kind: NotifyKind,
+ /// Human-readable message.
+ message: String,
+ },
+
+ /// OSC 52 clipboard data forwarded from a PTY through the server.
+ Clipboard {
+ /// Base64-encoded clipboard data.
+ data: String,
+ },
+
+ /// Client-local sound config changed on disk; refresh it without reconnecting.
+ ReloadSoundConfig,
+
+ /// Whether the client should currently capture host mouse input.
+ MouseCapture {
+ /// True when Herdr mouse UI is enabled or the focused pane app requests mouse reporting.
+ enabled: bool,
+ },
+}
+
+// ---------------------------------------------------------------------------
+// Color / Modifier conversion helpers
+// ---------------------------------------------------------------------------
+
+/// Converts a ratatui `Color` to a packed u32 for wire transport.
+///
+/// Encoding:
+/// - Named colors (Reset, Black, …, White) → `0x00_00_00_XX` where XX is 0..=16
+/// - Indexed palette → `0x01_00_00_XX` where XX is the palette index
+/// - RGB → `0x02_RR_GG_BB` with components in the lower 3 bytes
+fn color_to_u32(color: ratatui::style::Color) -> u32 {
+ match color {
+ ratatui::style::Color::Reset => 0x00_00_00_00,
+ ratatui::style::Color::Black => 0x00_00_00_01,
+ ratatui::style::Color::Red => 0x00_00_00_02,
+ ratatui::style::Color::Green => 0x00_00_00_03,
+ ratatui::style::Color::Yellow => 0x00_00_00_04,
+ ratatui::style::Color::Blue => 0x00_00_00_05,
+ ratatui::style::Color::Magenta => 0x00_00_00_06,
+ ratatui::style::Color::Cyan => 0x00_00_00_07,
+ ratatui::style::Color::Gray => 0x00_00_00_08,
+ ratatui::style::Color::DarkGray => 0x00_00_00_09,
+ ratatui::style::Color::LightRed => 0x00_00_00_0A,
+ ratatui::style::Color::LightGreen => 0x00_00_00_0B,
+ ratatui::style::Color::LightYellow => 0x00_00_00_0C,
+ ratatui::style::Color::LightBlue => 0x00_00_00_0D,
+ ratatui::style::Color::LightMagenta => 0x00_00_00_0E,
+ ratatui::style::Color::LightCyan => 0x00_00_00_0F,
+ ratatui::style::Color::White => 0x00_00_00_10,
+ ratatui::style::Color::Indexed(i) => 0x01_00_00_00 | (i as u32),
+ ratatui::style::Color::Rgb(r, g, b) => {
+ 0x02_00_00_00 | ((r as u32) << 16) | ((g as u32) << 8) | (b as u32)
+ }
+ }
+}
+
+/// Converts a packed u32 back to a ratatui `Color`.
+#[cfg(test)]
+fn u32_to_color(val: u32) -> ratatui::style::Color {
+ match val >> 24 {
+ 0x00 => match val & 0xFF {
+ 0x00 => ratatui::style::Color::Reset,
+ 0x01 => ratatui::style::Color::Black,
+ 0x02 => ratatui::style::Color::Red,
+ 0x03 => ratatui::style::Color::Green,
+ 0x04 => ratatui::style::Color::Yellow,
+ 0x05 => ratatui::style::Color::Blue,
+ 0x06 => ratatui::style::Color::Magenta,
+ 0x07 => ratatui::style::Color::Cyan,
+ 0x08 => ratatui::style::Color::Gray,
+ 0x09 => ratatui::style::Color::DarkGray,
+ 0x0A => ratatui::style::Color::LightRed,
+ 0x0B => ratatui::style::Color::LightGreen,
+ 0x0C => ratatui::style::Color::LightYellow,
+ 0x0D => ratatui::style::Color::LightBlue,
+ 0x0E => ratatui::style::Color::LightMagenta,
+ 0x0F => ratatui::style::Color::LightCyan,
+ 0x10 => ratatui::style::Color::White,
+ _ => ratatui::style::Color::Reset, // unknown named → Reset
+ },
+ 0x01 => ratatui::style::Color::Indexed((val & 0xFF) as u8),
+ 0x02 => {
+ let r = ((val >> 16) & 0xFF) as u8;
+ let g = ((val >> 8) & 0xFF) as u8;
+ let b = (val & 0xFF) as u8;
+ ratatui::style::Color::Rgb(r, g, b)
+ }
+ _ => ratatui::style::Color::Reset, // unknown tag → Reset
+ }
+}
+
+/// Converts a ratatui `Modifier` bitmask to a u16 for wire transport.
+fn modifier_to_u16(modifier: ratatui::style::Modifier) -> u16 {
+ modifier.bits()
+}
+
+/// Converts a u16 back to a ratatui `Modifier`.
+#[cfg(test)]
+fn u16_to_modifier(val: u16) -> ratatui::style::Modifier {
+ ratatui::style::Modifier::from_bits_truncate(val)
+}
+
+// ---------------------------------------------------------------------------
+// Framing: length-prefixed binary messages
+// ---------------------------------------------------------------------------
+
+/// Errors that can occur during framing operations.
+#[derive(Debug)]
+pub enum FramingError {
+ /// The decoded payload length exceeds the configured maximum frame size.
+ Oversized { claimed: usize, max: usize },
+ /// An I/O error occurred while reading or writing.
+ Io(io::Error),
+ /// Bincode serialization or deserialization failed.
+ Bincode(String),
+ /// The connection was closed before a complete frame could be read.
+ UnexpectedEof,
+}
+
+impl std::fmt::Display for FramingError {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ FramingError::Oversized { claimed, max } => {
+ write!(f, "frame size {claimed} exceeds maximum {max}")
+ }
+ FramingError::Io(e) => write!(f, "I/O error: {e}"),
+ FramingError::Bincode(e) => write!(f, "bincode error: {e}"),
+ FramingError::UnexpectedEof => write!(f, "unexpected end of stream"),
+ }
+ }
+}
+
+impl std::error::Error for FramingError {
+ fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
+ match self {
+ FramingError::Io(e) => Some(e),
+ _ => None,
+ }
+ }
+}
+
+impl From<io::Error> for FramingError {
+ fn from(e: io::Error) -> Self {
+ FramingError::Io(e)
+ }
+}
+
+/// Serializes a message and writes it as a length-prefixed frame:
+/// `[u32LE length][bincode payload]`.
+///
+/// This is a blocking/synchronous write suitable for use with `std::os::unix::net::UnixStream`
+/// in blocking mode, or with any `Write` implementor.
+///
+/// # Errors
+///
+/// Returns `FramingError::Bincode` if the payload length exceeds `u32::MAX`
+/// (would be truncated by the length prefix cast).
+pub fn write_message<W: Write, M: Serialize>(writer: &mut W, msg: &M) -> Result<(), FramingError> {
+ let payload = bincode::serde::encode_to_vec(msg, bincode::config::standard())
+ .map_err(|e| FramingError::Bincode(e.to_string()))?;
+
+ let len = payload.len();
+ if len > u32::MAX as usize {
+ return Err(FramingError::Bincode(format!(
+ "payload length {len} exceeds u32::MAX ({}), would be truncated by length prefix",
+ u32::MAX
+ )));
+ }
+
+ writer.write_all(&(len as u32).to_le_bytes())?;
+ writer.write_all(&payload)?;
+ writer.flush()?;
+ Ok(())
+}
+
+/// Reads and deserializes a length-prefixed frame from a reader.
+///
+/// Reassembles partial reads correctly. Rejects frames whose declared
+/// length exceeds `max_frame_size` without panicking or allocating
+/// oversized buffers.
+pub fn read_message<R: Read, M: for<'de> Deserialize<'de>>(
+ reader: &mut R,
+ max_frame_size: usize,
+) -> Result<M, FramingError> {
+ // Read the 4-byte length prefix, reassembling partial reads.
+ let mut len_buf = [0u8; LENGTH_PREFIX_BYTES];
+ read_exact_or_eof(reader, &mut len_buf)?;
+ let claimed_len = u32::from_le_bytes(len_buf) as usize;
+
+ if claimed_len > max_frame_size {
+ return Err(FramingError::Oversized {
+ claimed: claimed_len,
+ max: max_frame_size,
+ });
+ }
+
+ // Read the payload, reassembling partial reads.
+ let mut payload = vec![0u8; claimed_len];
+ read_exact_or_eof(reader, &mut payload)?;
+
+ let (msg, consumed) = bincode::serde::decode_from_slice(&payload, bincode::config::standard())
+ .map_err(|e| FramingError::Bincode(e.to_string()))?;
+
+ // Enforce that the decoder consumed the full payload.
+ // Trailing bytes after the decoded message indicate a protocol violation
+ // (e.g., a corrupted length prefix or concatenated payloads).
+ if consumed != claimed_len {
+ return Err(FramingError::Bincode(format!(
+ "decoded {} bytes but payload length was {claimed_len}; trailing bytes are not allowed",
+ consumed
+ )));
+ }
+
+ Ok(msg)
+}
+
+/// Like `Read::read_exact`, but returns `FramingError::UnexpectedEof`
+/// when the reader hits end-of-stream before filling the buffer, instead
+/// of the generic `io::ErrorKind::UnexpectedEof`.
+fn read_exact_or_eof<R: Read>(reader: &mut R, buf: &mut [u8]) -> Result<(), FramingError> {
+ reader.read_exact(buf).map_err(|e| {
+ if e.kind() == io::ErrorKind::UnexpectedEof {
+ FramingError::UnexpectedEof
+ } else {
+ FramingError::Io(e)
+ }
+ })
+}
+
+// ---------------------------------------------------------------------------
+// Version negotiation
+// ---------------------------------------------------------------------------
+
+/// Result of checking a client's protocol version against the server's.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum VersionCheck {
+ /// Versions are compatible. The server should reply with a successful Welcome.
+ Compatible,
+ /// Versions are incompatible. The server should reply with a Welcome error and close.
+ Incompatible(String),
+}
+
+/// Checks whether a client's protocol version is compatible with this server.
+///
+/// Current rules:
+/// - Version 0 (pre-persistence client) is always rejected.
+/// - Matching major versions are accepted.
+/// - A client with a newer version than the server is rejected.
+/// - A client with an older version than the server is rejected
+/// (backward compatibility is not yet supported).
+pub fn check_client_version(client_version: u32) -> VersionCheck {
+ if client_version == 0 {
+ return VersionCheck::Incompatible(
+ "pre-persistence client (version 0) is not supported".to_owned(),
+ );
+ }
+
+ if client_version == PROTOCOL_VERSION {
+ VersionCheck::Compatible
+ } else if client_version < PROTOCOL_VERSION {
+ VersionCheck::Incompatible(format!(
+ "client version {client_version} is older than server version {PROTOCOL_VERSION}; please upgrade your herdr client"
+ ))
+ } else {
+ VersionCheck::Incompatible(format!(
+ "client version {client_version} is newer than server version {PROTOCOL_VERSION}; please upgrade the herdr server"
+ ))
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use ratatui::style::{Color, Modifier};
+
+ // ---- Round-trip: ClientMessage ----
+
+ #[test]
+ fn client_hello_roundtrip() {
+ let msg = ClientMessage::Hello {
+ version: PROTOCOL_VERSION,
+ cols: 80,
+ rows: 24,
+ cell_width_px: 8,
+ cell_height_px: 16,
+ requested_encoding: RenderEncoding::SemanticFrame,
+ keybindings: ClientKeybindings::Server,
+ };
+ 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();
+ assert_eq!(msg, decoded);
+ }
+
+ #[test]
+ fn client_input_roundtrip() {
+ let msg = ClientMessage::Input {
+ data: vec![0x1b, 0x5b, 0x41], // ESC [ A (up arrow)
+ };
+ 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();
+ assert_eq!(msg, decoded);
+ }
+
+ #[test]
+ fn client_clipboard_image_roundtrip() {
+ let msg = ClientMessage::ClipboardImage {
+ extension: "png".to_owned(),
+ data: vec![0x89, b'P', b'N', b'G'],
+ };
+ 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();
+ assert_eq!(msg, decoded);
+ }
+
+ #[test]
+ fn client_input_large_multilingual_payload_roundtrip() {
+ let text = "你好,今天我们测试一段比较长的语音输入。こんにちは。안녕하세요.🙂".repeat(1024);
+ assert!(text.len() > 64 * 1024);
+ assert!(text.len() < MAX_FRAME_SIZE);
+ let msg = ClientMessage::Input {
+ data: text.as_bytes().to_vec(),
+ };
+
+ let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap();
+ let (decoded, consumed): (ClientMessage, _) =
+ bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap();
+
+ assert_eq!(consumed, encoded.len());
+ assert_eq!(decoded, msg);
+ }
+
+ #[test]
+ fn client_resize_roundtrip() {
+ 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();
+ assert_eq!(msg, decoded);
+ }
+
+ #[test]
+ fn client_detach_roundtrip() {
+ let msg = ClientMessage::Detach;
+ 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();
+ assert_eq!(msg, decoded);
+ }
+
+ #[test]
+ fn client_attach_terminal_roundtrip() {
+ let msg = ClientMessage::AttachTerminal {
+ terminal_id: "term_123".to_owned(),
+ takeover: true,
+ };
+ 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();
+ assert_eq!(msg, decoded);
+ }
+
+ // ---- Round-trip: ServerMessage ----
+
+ #[test]
+ fn server_welcome_roundtrip() {
+ let msg = ServerMessage::Welcome {
+ version: PROTOCOL_VERSION,
+ encoding: RenderEncoding::SemanticFrame,
+ error: None,
+ };
+ 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_welcome_with_error_roundtrip() {
+ let msg = ServerMessage::Welcome {
+ version: PROTOCOL_VERSION,
+ encoding: RenderEncoding::SemanticFrame,
+ error: Some("incompatible version".to_owned()),
+ };
+ 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_frame_roundtrip_nontrivial() {
+ // Build a 3×2 frame with varied styles (≥2×2).
+ let frame = FrameData {
+ cells: vec![
+ CellData {
+ symbol: "H".into(),
+ fg: color_to_u32(Color::Red),
+ bg: color_to_u32(Color::Black),
+ modifier: Modifier::BOLD.bits(),
+ skip: false,
+ hyperlink: None,
+ },
+ CellData {
+ symbol: "i".into(),
+ fg: color_to_u32(Color::Green),
+ bg: color_to_u32(Color::Reset),
+ modifier: Modifier::ITALIC.bits(),
+ skip: false,
+ hyperlink: None,
+ },
+ CellData {
+ symbol: "!".into(),
+ fg: color_to_u32(Color::Rgb(255, 128, 0)),
+ bg: color_to_u32(Color::Indexed(220)),
+ modifier: (Modifier::BOLD | Modifier::UNDERLINED).bits(),
+ skip: false,
+ hyperlink: Some(0),
+ },
+ CellData {
+ symbol: " ".into(),
+ fg: color_to_u32(Color::Reset),
+ bg: color_to_u32(Color::Reset),
+ modifier: Modifier::empty().bits(),
+ skip: true,
+ hyperlink: None,
+ },
+ CellData {
+ symbol: "→".into(), // multi-byte grapheme
+ fg: color_to_u32(Color::Cyan),
+ bg: color_to_u32(Color::Blue),
+ modifier: Modifier::REVERSED.bits(),
+ skip: false,
+ hyperlink: None,
+ },
+ CellData {
+ symbol: "🦀".into(), // emoji, wide grapheme cluster
+ fg: color_to_u32(Color::Yellow),
+ bg: color_to_u32(Color::Magenta),
+ modifier: Modifier::empty().bits(),
+ skip: false,
+ hyperlink: None,
+ },
+ ],
+ width: 3,
+ height: 2,
+ cursor: Some(CursorState {
+ x: 0,
+ y: 0,
+ visible: true,
+ shape: 6,
+ }),
+ 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();
+ let (decoded, _): (ServerMessage, _) =
+ bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap();
+ assert_eq!(msg, decoded);
+ match decoded {
+ ServerMessage::Frame(frame) => {
+ assert_eq!(frame.cells[2].hyperlink, Some(0));
+ assert_eq!(frame.hyperlinks, vec!["https://example.com".to_owned()]);
+ }
+ other => panic!("expected frame, got {other:?}"),
+ }
+ }
+
+ #[test]
+ fn server_shutdown_roundtrip() {
+ let msg = ServerMessage::ServerShutdown {
+ reason: Some("updating".to_owned()),
+ };
+ 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_notify_roundtrip() {
+ for kind in [
+ NotifyKind::Sound,
+ NotifyKind::Toast,
+ NotifyKind::SystemToast,
+ ] {
+ let msg = ServerMessage::Notify {
+ kind,
+ message: "agent done".to_owned(),
+ };
+ 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_clipboard_roundtrip() {
+ let msg = ServerMessage::Clipboard {
+ data: "dGVzdA==".to_owned(), // base64 "test"
+ };
+ 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_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,
+ width: 120,
+ height: 40,
+ full: false,
+ bytes: b"\x1b[1;1Hhello".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_reload_sound_config_roundtrip() {
+ let msg = ServerMessage::ReloadSoundConfig;
+ 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_mouse_capture_roundtrip() {
+ let msg = ServerMessage::MouseCapture { enabled: true };
+ 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);
+ }
+
+ // ---- Framing ----
+
+ #[test]
+ fn framing_small_message_roundtrip() {
+ let msg = ClientMessage::Hello {
+ version: PROTOCOL_VERSION,
+ cols: 80,
+ rows: 24,
+ cell_width_px: 8,
+ cell_height_px: 16,
+ requested_encoding: RenderEncoding::SemanticFrame,
+ keybindings: ClientKeybindings::Server,
+ };
+ let mut buf = Vec::new();
+ write_message(&mut buf, &msg).unwrap();
+ let decoded: ClientMessage = read_message(&mut buf.as_slice(), MAX_FRAME_SIZE).unwrap();
+ assert_eq!(msg, decoded);
+ }
+
+ #[test]
+ fn framing_large_payload_roundtrip() {
+ // Create a Frame message that is ≥128 KB.
+ // Use a large frame with verbose cell data to exceed 128 KB after bincode encoding.
+ // 200×50 = 10000 cells. With varied symbols and styles, this should easily exceed 128 KB.
+ let width: u16 = 200;
+ let height: u16 = 50;
+ let cells: Vec<CellData> = (0..(width as usize) * (height as usize))
+ .map(|i| CellData {
+ symbol: if i % 256 < 32 {
+ " ".to_owned()
+ } else {
+ format!("{:03}", i % 1000)
+ },
+ fg: color_to_u32(Color::Rgb((i % 256) as u8, ((i / 256) % 256) as u8, 128)),
+ bg: color_to_u32(Color::Indexed((i % 256) as u8)),
+ modifier: ((i % 16) as u16),
+ skip: i % 100 == 0,
+ hyperlink: None,
+ })
+ .collect();
+
+ let frame = FrameData {
+ cells,
+ width,
+ height,
+ cursor: Some(CursorState {
+ x: 10,
+ y: 5,
+ visible: true,
+ shape: 0,
+ }),
+ hyperlinks: Vec::new(),
+ graphics: Vec::new(),
+ };
+ let msg = ServerMessage::Frame(frame);
+
+ let mut buf = Vec::new();
+ write_message(&mut buf, &msg).unwrap();
+ // Verify the payload is at least 128 KB
+ assert!(
+ buf.len() >= 128 * 1024,
+ "framed payload should be >= 128 KB, got {} bytes",
+ buf.len()
+ );
+
+ let decoded: ServerMessage = read_message(&mut buf.as_slice(), MAX_FRAME_SIZE).unwrap();
+ assert_eq!(msg, decoded);
+ }
+
+ #[test]
+ fn framing_multiple_messages_sequential() {
+ // Write 100+ messages of varying types and read them back.
+ let mut buf = Vec::new();
+ let mut expected = Vec::new();
+
+ for i in 0..150u32 {
+ let msg = match i % 5 {
+ 0 => ClientMessage::Hello {
+ 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,
+ keybindings: ClientKeybindings::Server,
+ },
+ 1 => ClientMessage::Input {
+ data: vec![(i % 256) as u8; (i as usize % 50) + 1],
+ },
+ 2 => ClientMessage::ClipboardImage {
+ extension: "png".to_owned(),
+ data: vec![0x89, b'P', b'N', b'G', (i % 256) as u8],
+ },
+ 3 => ClientMessage::Resize {
+ cols: (100 + (i % 30) as u16),
+ rows: (30 + (i % 10) as u16),
+ cell_width_px: 8,
+ cell_height_px: 16,
+ },
+ 4 => ClientMessage::Detach,
+ _ => unreachable!(),
+ };
+ write_message(&mut buf, &msg).unwrap();
+ expected.push(msg);
+ }
+
+ let mut cursor = buf.as_slice();
+ for expected_msg in &expected {
+ let decoded: ClientMessage = read_message(&mut cursor, MAX_FRAME_SIZE).unwrap();
+ assert_eq!(*expected_msg, decoded);
+ }
+ }
+
+ #[test]
+ fn framing_oversized_rejected_without_panic() {
+ // Craft a frame with a huge length prefix (4 GB claim).
+ let mut buf: Vec<u8> = (u32::MAX).to_le_bytes().to_vec();
+ // Add a few garbage bytes after the length prefix.
+ buf.extend_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF]);
+
+ let result: Result<ClientMessage, FramingError> =
+ read_message(&mut buf.as_slice(), MAX_FRAME_SIZE);
+ match result {
+ Err(FramingError::Oversized { claimed, max }) => {
+ assert_eq!(claimed, u32::MAX as usize);
+ assert_eq!(max, MAX_FRAME_SIZE);
+ }
+ other => panic!("expected Oversized error, got: {other:?}"),
+ }
+ }
+
+ #[test]
+ fn framing_malformed_payload_rejected_without_panic() {
+ // Valid length prefix pointing to garbage data.
+ let payload = vec![0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02];
+ let mut buf = (payload.len() as u32).to_le_bytes().to_vec();
+ buf.extend_from_slice(&payload);
+
+ let result: Result<ClientMessage, FramingError> =
+ read_message(&mut buf.as_slice(), MAX_FRAME_SIZE);
+ assert!(result.is_err(), "malformed payload should be rejected");
+ match result {
+ Err(FramingError::Bincode(_)) => {} // expected
+ other => panic!("expected Bincode error, got: {other:?}"),
+ }
+ }
+
+ #[test]
+ fn framing_truncated_stream_returns_unexpected_eof() {
+ // Write a length prefix claiming 100 bytes, but only provide 4.
+ let mut buf: Vec<u8> = 100u32.to_le_bytes().to_vec();
+ buf.extend_from_slice(&[0xAA, 0xBB, 0xCC, 0xDD]);
+
+ let result: Result<ClientMessage, FramingError> =
+ read_message(&mut buf.as_slice(), MAX_FRAME_SIZE);
+ match result {
+ Err(FramingError::UnexpectedEof) => {}
+ other => panic!("expected UnexpectedEof, got: {other:?}"),
+ }
+ }
+
+ #[test]
+ fn framing_zero_length_message() {
+ // A 1-byte message (smallest possible valid bincode payload).
+ // Actually, let's test with the smallest real message: Detach.
+ let msg = ClientMessage::Detach;
+ let mut buf = Vec::new();
+ write_message(&mut buf, &msg).unwrap();
+
+ // Verify the length prefix is correct
+ let len = u32::from_le_bytes(buf[..4].try_into().unwrap()) as usize;
+ assert_eq!(
+ len,
+ buf.len() - 4,
+ "length prefix should match payload size"
+ );
+
+ let decoded: ClientMessage = read_message(&mut buf.as_slice(), MAX_FRAME_SIZE).unwrap();
+ assert_eq!(msg, decoded);
+ }
+
+ #[test]
+ fn framing_partial_read_reassembly() {
+ // Simulate partial reads by using a reader that yields small chunks.
+ let msg = ClientMessage::Input {
+ data: vec![42; 500], // 500-byte input payload
+ };
+ let mut full_buf = Vec::new();
+ write_message(&mut full_buf, &msg).unwrap();
+
+ // Wrap in a chunked reader that only yields 7 bytes at a time.
+ let mut chunked = ChunkedReader::new(full_buf, 7);
+ let decoded: ClientMessage = read_message(&mut chunked, MAX_FRAME_SIZE).unwrap();
+ assert_eq!(msg, decoded);
+ }
+
+ // ---- Version negotiation ----
+
+ #[test]
+ fn version_compatible() {
+ assert_eq!(
+ check_client_version(PROTOCOL_VERSION),
+ VersionCheck::Compatible
+ );
+ }
+
+ #[test]
+ fn version_older_client_rejected() {
+ let result = check_client_version(PROTOCOL_VERSION - 1);
+ assert!(matches!(result, VersionCheck::Incompatible(_)));
+ if let VersionCheck::Incompatible(msg) = result {
+ assert!(msg.contains("older"), "error should mention older version");
+ }
+ }
+
+ #[test]
+ fn version_newer_client_rejected() {
+ let result = check_client_version(PROTOCOL_VERSION + 1);
+ assert!(matches!(result, VersionCheck::Incompatible(_)));
+ if let VersionCheck::Incompatible(msg) = result {
+ assert!(msg.contains("newer"), "error should mention newer version");
+ }
+ }
+
+ // ---- Pre-persistence client rejection ----
+
+ #[test]
+ fn prepersistence_version_zero_rejected() {
+ let result = check_client_version(0);
+ match result {
+ VersionCheck::Incompatible(msg) => {
+ assert!(
+ msg.contains("pre-persistence"),
+ "error should mention pre-persistence: {msg}"
+ );
+ }
+ _ => panic!("version 0 should be rejected as incompatible"),
+ }
+ }
+
+ #[test]
+ fn prepersistence_version_zero_welcome_has_error() {
+ // Simulating what the server would send to a v0 client.
+ let check = check_client_version(0);
+ let response = match check {
+ VersionCheck::Compatible => ServerMessage::Welcome {
+ version: PROTOCOL_VERSION,
+ encoding: RenderEncoding::SemanticFrame,
+ error: None,
+ },
+ VersionCheck::Incompatible(reason) => ServerMessage::Welcome {
+ version: PROTOCOL_VERSION,
+ encoding: RenderEncoding::SemanticFrame,
+ error: Some(reason),
+ },
+ };
+
+ match response {
+ ServerMessage::Welcome { error: Some(_), .. } => {}
+ other => panic!("expected Welcome with error, got: {other:?}"),
+ }
+ }
+
+ // ---- Malformed/oversized input ----
+
+ #[test]
+ fn oversized_frame_does_not_panic() {
+ // Claim 4GB payload — should return Oversized error, not panic.
+ let mut buf: Vec<u8> = 0xFFC00000u32.to_le_bytes().to_vec(); // ~4 GB claim
+ buf.extend_from_slice(&[0; 8]);
+
+ let result: Result<ClientMessage, FramingError> =
+ read_message(&mut buf.as_slice(), MAX_FRAME_SIZE);
+ assert!(result.is_err());
+ // Did not panic — test passing is proof.
+ }
+
+ #[test]
+ fn malformed_frame_does_not_panic() {
+ // Random garbage bytes after a valid-ish length prefix.
+ let garbage: Vec<u8> = (0..200).map(|i| (i ^ 0xAA) as u8).collect();
+ let mut buf = (garbage.len() as u32).to_le_bytes().to_vec();
+ buf.extend_from_slice(&garbage);
+
+ let result: Result<ClientMessage, FramingError> =
+ read_message(&mut buf.as_slice(), MAX_FRAME_SIZE);
+ assert!(result.is_err());
+ // Did not panic.
+ }
+
+ #[test]
+ fn oversized_input_rejected_custom_max() {
+ // Verify a custom (small) max_frame_size is enforced.
+ let msg = ClientMessage::Input {
+ data: vec![0x41; 1000],
+ };
+ let mut buf = Vec::new();
+ write_message(&mut buf, &msg).unwrap();
+
+ let result: Result<ClientMessage, FramingError> = read_message(&mut buf.as_slice(), 64);
+ // The actual bincode payload for 1000 bytes of input will be > 64 bytes.
+ assert!(
+ matches!(result, Err(FramingError::Oversized { .. })),
+ "expected Oversized with small max_frame_size"
+ );
+ }
+
+ // ---- FrameData ↔ ratatui Buffer conversion ----
+
+ #[test]
+ fn frame_data_roundtrip_through_ratatui_buffer() {
+ let area = ratatui::layout::Rect::new(0, 0, 5, 3);
+ let mut buffer = ratatui::buffer::Buffer::filled(area, ratatui::buffer::Cell::new(" "));
+
+ // Write some styled content.
+ buffer.cell_mut((0, 0)).unwrap().set_symbol("H");
+ buffer.cell_mut((0, 0)).unwrap().fg = Color::Red;
+ buffer.cell_mut((0, 0)).unwrap().modifier = Modifier::BOLD;
+
+ buffer.cell_mut((1, 0)).unwrap().set_symbol("i");
+ buffer.cell_mut((1, 0)).unwrap().fg = Color::Green;
+ buffer.cell_mut((1, 0)).unwrap().modifier = Modifier::ITALIC;
+
+ buffer.cell_mut((2, 0)).unwrap().set_symbol("!");
+ buffer.cell_mut((2, 0)).unwrap().fg = Color::Rgb(255, 128, 0);
+ buffer.cell_mut((2, 0)).unwrap().bg = Color::Indexed(220);
+
+ let cursor = CursorState {
+ x: 1,
+ y: 0,
+ visible: true,
+ shape: 0,
+ };
+ let frame = FrameData::from_ratatui_buffer(&buffer, Some(cursor.clone()));
+
+ // Verify frame dimensions.
+ assert_eq!(frame.width, 5);
+ assert_eq!(frame.height, 3);
+ assert_eq!(frame.cells.len(), 15);
+ assert_eq!(frame.cursor, Some(cursor));
+
+ // Verify specific cells survived the conversion.
+ assert_eq!(frame.cells[0].symbol, "H");
+ assert_eq!(frame.cells[0].fg, color_to_u32(Color::Red));
+ assert_eq!(frame.cells[0].modifier, Modifier::BOLD.bits());
+
+ assert_eq!(frame.cells[1].symbol, "i");
+ assert_eq!(frame.cells[1].fg, color_to_u32(Color::Green));
+ assert_eq!(frame.cells[1].modifier, Modifier::ITALIC.bits());
+
+ assert_eq!(frame.cells[2].symbol, "!");
+ assert_eq!(frame.cells[2].fg, color_to_u32(Color::Rgb(255, 128, 0)));
+ assert_eq!(frame.cells[2].bg, color_to_u32(Color::Indexed(220)));
+
+ let with_links = FrameData::from_ratatui_buffer_with_hyperlinks(
+ &buffer,
+ None,
+ &[((1, 0), "i".to_owned(), "https://example.com".to_owned())],
+ );
+ assert_eq!(with_links.cells[1].hyperlink, Some(0));
+ assert_eq!(
+ with_links.hyperlinks,
+ vec!["https://example.com".to_owned()]
+ );
+
+ // Convert back to ratatui buffer and compare.
+ let restored = frame.to_ratatui_buffer().expect("should reconstruct");
+ assert_eq!(restored.area, area);
+ assert_eq!(restored.cell((0, 0)).unwrap().symbol(), "H");
+ assert_eq!(restored.cell((0, 0)).unwrap().fg, Color::Red);
+ assert_eq!(restored.cell((0, 0)).unwrap().modifier, Modifier::BOLD);
+ assert_eq!(restored.cell((1, 0)).unwrap().symbol(), "i");
+ assert_eq!(restored.cell((2, 0)).unwrap().symbol(), "!");
+ assert_eq!(restored.cell((2, 0)).unwrap().fg, Color::Rgb(255, 128, 0));
+ }
+
+ #[test]
+ fn frame_data_rejects_mismatched_cell_count() {
+ let frame = FrameData {
+ cells: vec![
+ CellData {
+ symbol: "X".into(),
+ fg: 0,
+ bg: 0,
+ modifier: 0,
+ skip: false,
+ hyperlink: None,
+ };
+ 5
+ ], // 5 cells but 3×2 = 6 expected
+ width: 3,
+ height: 2,
+ cursor: None,
+ hyperlinks: Vec::new(),
+ graphics: Vec::new(),
+ };
+ assert!(frame.to_ratatui_buffer().is_none());
+ }
+
+ // ---- Color conversion coverage ----
+
+ #[test]
+ fn color_roundtrip_all_named_colors() {
+ let named = [
+ Color::Reset,
+ Color::Black,
+ Color::Red,
+ Color::Green,
+ Color::Yellow,
+ Color::Blue,
+ Color::Magenta,
+ Color::Cyan,
+ Color::Gray,
+ Color::DarkGray,
+ Color::LightRed,
+ Color::LightGreen,
+ Color::LightYellow,
+ Color::LightBlue,
+ Color::LightMagenta,
+ Color::LightCyan,
+ Color::White,
+ ];
+ for c in named {
+ assert_eq!(
+ u32_to_color(color_to_u32(c)),
+ c,
+ "roundtrip failed for {c:?}"
+ );
+ }
+ }
+
+ #[test]
+ fn color_roundtrip_indexed() {
+ for i in 0..=255u8 {
+ let c = Color::Indexed(i);
+ assert_eq!(
+ u32_to_color(color_to_u32(c)),
+ c,
+ "roundtrip failed for Indexed({i})"
+ );
+ }
+ }
+
+ #[test]
+ fn color_roundtrip_rgb() {
+ let c = Color::Rgb(0xAB, 0xCD, 0xEF);
+ assert_eq!(u32_to_color(color_to_u32(c)), c);
+
+ let c = Color::Rgb(0, 0, 0);
+ assert_eq!(u32_to_color(color_to_u32(c)), c);
+
+ let c = Color::Rgb(255, 255, 255);
+ assert_eq!(u32_to_color(color_to_u32(c)), c);
+ }
+
+ // ---- Modifier conversion ----
+
+ #[test]
+ fn modifier_roundtrip() {
+ let all_mods = [
+ Modifier::BOLD,
+ Modifier::ITALIC,
+ Modifier::REVERSED,
+ Modifier::UNDERLINED,
+ Modifier::DIM,
+ Modifier::SLOW_BLINK,
+ Modifier::CROSSED_OUT,
+ Modifier::BOLD | Modifier::ITALIC,
+ Modifier::BOLD | Modifier::UNDERLINED | Modifier::REVERSED,
+ Modifier::empty(),
+ ];
+ for m in all_mods {
+ assert_eq!(
+ u16_to_modifier(modifier_to_u16(m)),
+ m,
+ "roundtrip failed for {m:?}"
+ );
+ }
+ }
+
+ #[test]
+ fn read_message_rejects_trailing_bytes() {
+ // Encode a valid message, then append an extra byte after it.
+ let msg = ClientMessage::Detach;
+ let mut payload = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap();
+ let original_len = payload.len();
+ payload.push(0xDE); // trailing garbage
+
+ // Frame it with the inflated length (original + 1).
+ let mut buf = (payload.len() as u32).to_le_bytes().to_vec();
+ buf.extend_from_slice(&payload);
+
+ let result: Result<ClientMessage, FramingError> =
+ read_message(&mut buf.as_slice(), MAX_FRAME_SIZE);
+ match result {
+ Err(FramingError::Bincode(msg)) => {
+ assert!(
+ msg.contains("trailing bytes"),
+ "error should mention trailing bytes: {msg}"
+ );
+ assert!(
+ msg.contains(&format!("decoded {original_len}")),
+ "error should mention decoded byte count: {msg}"
+ );
+ }
+ other => panic!("expected Bincode error about trailing bytes, got: {other:?}"),
+ }
+ }
+
+ #[test]
+ fn read_message_accepts_exact_payload() {
+ // A normally-framed message should decode without error.
+ let msg = ClientMessage::Hello {
+ version: PROTOCOL_VERSION,
+ cols: 80,
+ rows: 24,
+ cell_width_px: 8,
+ cell_height_px: 16,
+ requested_encoding: RenderEncoding::SemanticFrame,
+ keybindings: ClientKeybindings::Server,
+ };
+ let mut buf = Vec::new();
+ write_message(&mut buf, &msg).unwrap();
+ let decoded: ClientMessage = read_message(&mut buf.as_slice(), MAX_FRAME_SIZE).unwrap();
+ assert_eq!(msg, decoded);
+ }
+
+ #[test]
+ fn write_message_rejects_oversized_payload() {
+ // We can't easily create a message that exceeds u32::MAX in a test,
+ // but we can verify the check exists by testing that normal messages
+ // have lengths well within the limit and the function doesn't fail.
+ let msg = ClientMessage::Detach;
+ let mut buf = Vec::new();
+ assert!(write_message(&mut buf, &msg).is_ok());
+ }
+
+ // ---- Unix socketpair integration test ----
+
+ #[test]
+ fn framing_over_unix_socketpair() {
+ use std::os::unix::net::UnixStream;
+
+ let (mut a, mut b) = UnixStream::pair().expect("socketpair");
+
+ let messages = vec![
+ ClientMessage::Hello {
+ version: PROTOCOL_VERSION,
+ cols: 200,
+ rows: 60,
+ cell_width_px: 8,
+ cell_height_px: 16,
+ requested_encoding: RenderEncoding::SemanticFrame,
+ keybindings: ClientKeybindings::Server,
+ },
+ ClientMessage::Input {
+ data: b"hello world".to_vec(),
+ },
+ ClientMessage::ClipboardImage {
+ extension: "png".to_owned(),
+ data: vec![0x89, b'P', b'N', b'G'],
+ },
+ ClientMessage::Resize {
+ cols: 100,
+ rows: 30,
+ cell_width_px: 8,
+ cell_height_px: 16,
+ },
+ ClientMessage::Detach,
+ ];
+
+ // Set non-blocking so we can write and read in the same test.
+ a.set_nonblocking(false).unwrap();
+ b.set_nonblocking(false).unwrap();
+
+ for msg in &messages {
+ write_message(&mut a, msg).unwrap();
+ }
+
+ for expected in &messages {
+ let decoded: ClientMessage = read_message(&mut b, MAX_FRAME_SIZE).unwrap();
+ assert_eq!(*expected, decoded);
+ }
+ }
+
+ // ---- Helper: chunked reader for simulating partial reads ----
+
+ /// A `Read` wrapper that yields at most `chunk_size` bytes per `read()` call,
+ /// simulating partial reads on a real socket.
+ struct ChunkedReader {
+ data: Vec<u8>,
+ pos: usize,
+ chunk_size: usize,
+ }
+
+ impl ChunkedReader {
+ fn new(data: Vec<u8>, chunk_size: usize) -> Self {
+ Self {
+ data,
+ pos: 0,
+ chunk_size,
+ }
+ }
+ }
+
+ impl Read for ChunkedReader {
+ fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
+ if self.pos >= self.data.len() {
+ return Ok(0);
+ }
+ let remaining = self.data.len() - self.pos;
+ let to_read = buf.len().min(remaining).min(self.chunk_size);
+ buf[..to_read].copy_from_slice(&self.data[self.pos..self.pos + to_read]);
+ self.pos += to_read;
+ Ok(to_read)
+ }
+ }
+}