aboutsummaryrefslogtreecommitdiff
path: root/src/client/blit.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/client/blit.rs')
-rw-r--r--src/client/blit.rs793
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"));
+ }
+}