diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-21 20:19:54 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-21 20:22:57 +0300 |
| commit | 866aaf740e10fcfce169aaf81380ea0257fbe272 (patch) | |
| tree | f815392b7c6b24a7c0de14e655bd635656383813 /src/client | |
| parent | ca579c402d5668560ca13059f82c03178be18b2a (diff) | |
refactor: separate server client boundaries
Diffstat (limited to 'src/client')
| -rw-r--r-- | src/client/blit.rs | 1430 | ||||
| -rw-r--r-- | src/client/mod.rs | 12 |
2 files changed, 6 insertions, 1436 deletions
diff --git a/src/client/blit.rs b/src/client/blit.rs deleted file mode 100644 index 2196e1ff..00000000 --- a/src/client/blit.rs +++ /dev/null @@ -1,1430 +0,0 @@ -//! 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::server::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::server::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/client/mod.rs b/src/client/mod.rs index 4d962b64..11273061 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -12,7 +12,6 @@ //! - Forwards OSC 52 clipboard writes from server to its own stdout //! - Displays sound/toast notifications forwarded from server -pub(crate) mod blit; mod input; use std::collections::HashSet; @@ -30,11 +29,12 @@ use crossterm::event::{ use crossterm::execute; use tracing::{debug, info, warn}; -use crate::server::headless::client_socket_path; -use crate::server::protocol::{ +use crate::protocol::render_ansi; +use crate::protocol::{ self, ClientKeybindings, ClientMessage, NotifyKind, RenderEncoding, ServerMessage, MAX_CLIPBOARD_IMAGE_PAYLOAD, MAX_FRAME_SIZE, MAX_GRAPHICS_FRAME_SIZE, PROTOCOL_VERSION, }; +use crate::server::socket_paths::client_socket_path; static RECEIVED_KITTY_GRAPHICS_IDS: OnceLock<Mutex<HashSet<u32>>> = OnceLock::new(); @@ -45,7 +45,7 @@ static RECEIVED_KITTY_GRAPHICS_IDS: OnceLock<Mutex<HashSet<u32>>> = OnceLock::ne /// State tracking for the thin client. struct ClientState { /// Stateful semantic-frame encoder used when the server sends FrameData. - blit_encoder: blit::BlitEncoder, + blit_encoder: render_ansi::BlitEncoder, /// Whether host mouse capture is currently active. mouse_capture_active: bool, /// The terminal size we reported to the server in our last Hello/Resize. @@ -106,7 +106,7 @@ impl AttachEscapeState { impl ClientState { fn request_full_redraw(&mut self) { - self.blit_encoder = blit::BlitEncoder::new(); + self.blit_encoder = render_ansi::BlitEncoder::new(); } } @@ -574,7 +574,7 @@ async fn run_client_loop( attach_escape: Option<AttachEscapeState>, ) -> Result<(), ClientError> { let mut state = ClientState { - blit_encoder: blit::BlitEncoder::new(), + blit_encoder: render_ansi::BlitEncoder::new(), mouse_capture_active, reported_size: (cols, rows), sound_config, |
