diff options
Diffstat (limited to 'src/client/blit.rs')
| -rw-r--r-- | src/client/blit.rs | 793 |
1 files changed, 793 insertions, 0 deletions
diff --git a/src/client/blit.rs b/src/client/blit.rs new file mode 100644 index 00000000..aa443a7b --- /dev/null +++ b/src/client/blit.rs @@ -0,0 +1,793 @@ +//! 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. 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. +//! 4. After writing all changed cells, restore the final cursor visibility +//! and position from `frame.cursor`. +//! +//! Escape sequences used: +//! - `CSI H` (CUP) — move cursor to (row, col) +//! - `CSI m` (SGR) — set graphic rendition (colors, bold, etc.) +//! - `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::{self, Write}; + +use unicode_width::UnicodeWidthStr; + +use crate::server::protocol::{CellData, FrameData}; + +// --------------------------------------------------------------------------- +// 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 + // Skip flag is only for ratatui internal use, not visual. +} + +// --------------------------------------------------------------------------- +// Blitting +// --------------------------------------------------------------------------- + +/// Blits a frame to the terminal, diffing against the previous frame. +/// +/// On the first frame (no previous), does a full redraw. +/// On subsequent frames, only writes cells that changed. +/// After writing cells, positions the cursor. +pub fn blit_frame(frame: &FrameData, prev: Option<&FrameData>) { + let mut stdout = io::stdout(); + blit_frame_to(&mut stdout, frame, prev); +} + +/// Blits a frame to a writer, diffing against the previous frame. +fn blit_frame_to(mut writer: impl Write, frame: &FrameData, prev: Option<&FrameData>) { + // 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); + + // 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"); + + 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 and set visibility. + if let Some(cursor) = &frame.cursor { + if cursor.visible { + // Show cursor (restore visibility if it was hidden on a previous frame). + let _ = writer.write_all(b"\x1b[?25h"); + // CUP: move cursor to (row+1, col+1) — 1-based. + let _ = write!(writer, "\x1b[{};{}H", cursor.y + 1, cursor.x + 1); + } else { + // Hide cursor. + let _ = writer.write_all(b"\x1b[?25l"); + } + } else { + // No cursor info — hide cursor. + let _ = writer.write_all(b"\x1b[?25l"); + } +} + +/// Writes all cells in the frame (full redraw). +fn cell_width(cell: &CellData) -> usize { + cell.symbol.width() +} + +fn write_all_cells(writer: &mut impl Write, frame: &FrameData) { + 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 the symbol. + let _ = writer.write_all(cell.symbol.as_bytes()); + to_skip = cell_width(cell).saturating_sub(1); + } + } + + // Reset style at the end. + let _ = writer.write_all(b"\x1b[0m"); +} + +fn write_cell(writer: &mut impl Write, row: u16, col: u16, cell: &CellData, last_sgr: &mut String) { + 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; + } + + let _ = writer.write_all(cell.symbol.as_bytes()); +} + +/// Writes only the cells that changed between the previous and current frame. +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. + + 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 && (cell != prev_cell || invalidated > 0) && to_skip == 0 { + write_cell(writer, row, col, cell, &mut last_sgr); + } + + 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); + } + } + + // 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, + } + } + + fn make_frame(width: u16, height: u16, cells: Vec<CellData>) -> FrameData { + FrameData { + cells, + width, + height, + cursor: None, + } + } + + #[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[?25l"), + "should 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[?25l"), + "should hide cursor before any cell writes during diff" + ); + } + + #[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, + }), + }; + + 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, + }), + }; + + 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, + }; + + 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, + }), + }; + + 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, + }), + }; + + 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 after showing it" + ); + } + + #[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, + }), + }; + let curr = FrameData { + cells: vec![make_cell("B", 0, 0, 0)], + width: 1, + height: 1, + cursor: None, + }; + + 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, + }; + + 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, + }; + 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, + }; + + 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, + }; + 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, + }; + + 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")); + } +} |
