use crossterm::style::{Color, SetForegroundColor};
use std::io::Write as _;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct CellStyle {
pub fg: Option<Color>,
pub bold: bool,
pub reverse: bool,
pub faint: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Cell {
pub ch: char,
pub style: CellStyle,
pub width: u8,
}
impl Default for Cell {
fn default() -> Self {
Self {
ch: ' ',
style: CellStyle::default(),
width: 1,
}
}
}
impl Cell {
pub fn blank() -> Self {
Self::default()
}
pub fn continuation() -> Self {
Self {
ch: ' ',
style: CellStyle::default(),
width: 0,
}
}
}
const SOFT_TAB_WIDTH: usize = 4;
pub fn push_str_cells(row: &mut Vec<Cell>, s: &str, style: &CellStyle) {
for ch in s.chars() {
if ch == '\n' || ch == '\r' {
continue;
}
if ch == '\t' {
for _ in 0..SOFT_TAB_WIDTH {
row.push(Cell {
ch: ' ',
style: style.clone(),
width: 1,
});
}
continue;
}
let w = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(1);
if w == 0 {
continue;
}
row.push(Cell {
ch,
style: style.clone(),
width: w as u8,
});
for _ in 1..w {
row.push(Cell::continuation());
}
}
}
pub fn push_str_cells_sgr(
row: &mut Vec<Cell>,
s: &str,
mut working_style: CellStyle,
) -> CellStyle {
let mut chars = s.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '\x1b' {
if chars.peek() == Some(&'[') {
chars.next();
let mut params = String::new();
let mut final_byte: Option<char> = None;
while let Some(&p) = chars.peek() {
chars.next();
if ('\x40'..='\x7E').contains(&p) {
final_byte = Some(p);
break;
}
params.push(p);
}
if final_byte == Some('m') {
apply_sgr_params(¶ms, &mut working_style);
}
}
continue;
}
if ch == '\n' || ch == '\r' {
continue;
}
if ch == '\t' {
for _ in 0..SOFT_TAB_WIDTH {
row.push(Cell {
ch: ' ',
style: working_style.clone(),
width: 1,
});
}
continue;
}
let w = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(1);
if w == 0 {
continue;
}
row.push(Cell {
ch,
style: working_style.clone(),
width: w as u8,
});
for _ in 1..w {
row.push(Cell::continuation());
}
}
working_style
}
fn apply_sgr_params(params: &str, style: &mut CellStyle) {
if params.is_empty() {
*style = CellStyle::default();
return;
}
for code in params.split(';') {
if code.is_empty() {
*style = CellStyle::default();
continue;
}
let Ok(n) = code.parse::<u16>() else { continue };
match n {
0 => *style = CellStyle::default(),
1 => style.bold = true,
2 => style.faint = true,
7 => style.reverse = true,
22 => {
style.bold = false;
style.faint = false;
}
27 => style.reverse = false,
30 => style.fg = Some(Color::Black),
31 => style.fg = Some(Color::DarkRed),
32 => style.fg = Some(Color::DarkGreen),
33 => style.fg = Some(Color::DarkYellow),
34 => style.fg = Some(Color::DarkBlue),
35 => style.fg = Some(Color::DarkMagenta),
36 => style.fg = Some(Color::DarkCyan),
37 => style.fg = Some(Color::Grey),
39 => style.fg = None,
90 => style.fg = Some(Color::DarkGrey),
91 => style.fg = Some(Color::Red),
92 => style.fg = Some(Color::Green),
93 => style.fg = Some(Color::Yellow),
94 => style.fg = Some(Color::Blue),
95 => style.fg = Some(Color::Magenta),
96 => style.fg = Some(Color::Cyan),
97 => style.fg = Some(Color::White),
_ => {}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Patch {
pub row: u16,
pub col: u16,
pub cell: Cell,
}
pub fn diff_cell_frames(prev: &[Vec<Cell>], next: &[Vec<Cell>]) -> Vec<Patch> {
let mut patches = Vec::new();
let max_rows = prev.len().max(next.len());
let blank = Cell::blank();
for r in 0..max_rows {
let p = prev.get(r).map(Vec::as_slice).unwrap_or(&[]);
let n = next.get(r).map(Vec::as_slice).unwrap_or(&[]);
let max_cols = p.len().max(n.len());
for c in 0..max_cols {
let pc = p.get(c).unwrap_or(&blank);
let nc = n.get(c).unwrap_or(&blank);
if pc != nc {
patches.push(Patch {
row: (r + 1) as u16,
col: (c + 1) as u16,
cell: nc.clone(),
});
}
}
}
patches
}
pub fn serialize_patches(patches: &[Patch]) -> Vec<u8> {
if patches.is_empty() {
return Vec::new();
}
let mut out = Vec::with_capacity(patches.len() * 8);
let mut current_style: Option<CellStyle> = None;
let mut expected_cursor: Option<(u16, u16)> = None;
let mut emitted_any_sgr = false;
for patch in patches {
if patch.cell.width == 0 {
continue;
}
if expected_cursor != Some((patch.row, patch.col)) {
let _ = write!(out, "\x1b[{};{}H", patch.row, patch.col);
expected_cursor = Some((patch.row, patch.col));
}
if current_style.as_ref() != Some(&patch.cell.style) {
let before = out.len();
emit_sgr_transition(&mut out, current_style.as_ref(), &patch.cell.style);
if out.len() > before {
emitted_any_sgr = true;
}
current_style = Some(patch.cell.style.clone());
}
let mut buf = [0u8; 4];
let encoded = patch.cell.ch.encode_utf8(&mut buf);
out.extend_from_slice(encoded.as_bytes());
if let Some((r, c)) = expected_cursor {
expected_cursor = Some((r, c + patch.cell.width as u16));
}
}
if emitted_any_sgr {
out.extend_from_slice(b"\x1b[0m");
}
out
}
pub fn serialize_row(row: &[Cell]) -> Vec<u8> {
let mut out = Vec::with_capacity(row.len() * 4);
let mut current_style: Option<CellStyle> = None;
let mut emitted_any_sgr = false;
for cell in row {
if cell.width == 0 {
continue;
}
if current_style.as_ref() != Some(&cell.style) {
let before = out.len();
emit_sgr_transition(&mut out, current_style.as_ref(), &cell.style);
if out.len() > before {
emitted_any_sgr = true;
}
current_style = Some(cell.style.clone());
}
let mut buf = [0u8; 4];
let encoded = cell.ch.encode_utf8(&mut buf);
out.extend_from_slice(encoded.as_bytes());
}
if emitted_any_sgr {
out.extend_from_slice(b"\x1b[0m");
}
out
}
fn emit_sgr_transition(out: &mut Vec<u8>, from: Option<&CellStyle>, to: &CellStyle) {
let from_default = CellStyle::default();
let from = from.unwrap_or(&from_default);
let bold_off = from.bold && !to.bold;
let reverse_off = from.reverse && !to.reverse;
let faint_off = from.faint && !to.faint;
let fg_change = from.fg != to.fg;
let needs_reset = bold_off
|| reverse_off
|| faint_off
|| (from.fg.is_some() && to.fg.is_none());
if needs_reset {
out.extend_from_slice(b"\x1b[0m");
if to.bold {
out.extend_from_slice(b"\x1b[1m");
}
if to.faint {
out.extend_from_slice(b"\x1b[2m");
}
if to.reverse {
out.extend_from_slice(b"\x1b[7m");
}
if let Some(c) = to.fg {
let _ = write!(out, "{}", SetForegroundColor(c));
}
} else {
if !from.bold && to.bold {
out.extend_from_slice(b"\x1b[1m");
}
if !from.faint && to.faint {
out.extend_from_slice(b"\x1b[2m");
}
if !from.reverse && to.reverse {
out.extend_from_slice(b"\x1b[7m");
}
if fg_change {
if let Some(c) = to.fg {
let _ = write!(out, "{}", SetForegroundColor(c));
} else {
out.extend_from_slice(b"\x1b[39m");
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn cyan() -> Color {
Color::Cyan
}
fn style_bold_cyan() -> CellStyle {
CellStyle {
fg: Some(cyan()),
bold: true,
reverse: false,
faint: false,
}
}
#[test]
fn cell_equality_is_field_wise() {
let a = Cell {
ch: 'x',
style: style_bold_cyan(),
width: 1,
};
let b = Cell {
ch: 'x',
style: style_bold_cyan(),
width: 1,
};
assert_eq!(a, b);
let c = Cell {
ch: 'y',
style: style_bold_cyan(),
width: 1,
};
assert_ne!(a, c);
}
#[test]
fn push_str_cells_spreads_one_char_per_cell() {
let mut row = Vec::new();
push_str_cells(&mut row, "ab", &CellStyle::default());
assert_eq!(row.len(), 2);
assert_eq!(row[0].ch, 'a');
assert_eq!(row[1].ch, 'b');
}
#[test]
fn diff_cell_frames_produces_one_indexed_coords() {
let row: Vec<Cell> = "ab"
.chars()
.map(|ch| Cell {
ch,
style: Default::default(),
width: 1,
})
.collect();
let mut changed = row.clone();
changed[0].ch = 'X';
let prev = vec![row.clone()];
let next = vec![changed];
let patches = diff_cell_frames(&prev, &next);
assert_eq!(patches.len(), 1);
assert_eq!(patches[0].row, 1, "slice row 0 -> ANSI row 1");
assert_eq!(patches[0].col, 1, "slice col 0 -> ANSI col 1");
assert_eq!(patches[0].cell.ch, 'X');
}
#[test]
fn diff_cell_frames_empty_frames() {
let patches = diff_cell_frames(&[], &[]);
assert!(patches.is_empty());
}
#[test]
fn diff_shorter_next_emits_blanks_for_trailing() {
let prev_row: Vec<Cell> = "hello"
.chars()
.map(|ch| Cell {
ch,
style: Default::default(),
width: 1,
})
.collect();
let next_row: Vec<Cell> = "he"
.chars()
.map(|ch| Cell {
ch,
style: Default::default(),
width: 1,
})
.collect();
let prev = vec![prev_row];
let next = vec![next_row];
let patches = diff_cell_frames(&prev, &next);
assert_eq!(patches.len(), 3);
for p in &patches {
assert_eq!(p.cell, Cell::blank());
}
}
#[test]
fn serialize_empty_patches_emits_nothing() {
assert!(serialize_patches(&[]).is_empty());
}
#[test]
fn serialize_single_patch_emits_cursor_plus_char() {
let p = Patch {
row: 10,
col: 5,
cell: Cell {
ch: 'x',
style: Default::default(),
width: 1,
},
};
let bytes = serialize_patches(std::slice::from_ref(&p));
let s = String::from_utf8(bytes).unwrap();
assert!(s.contains("\x1b[10;5H"));
assert!(s.contains('x'));
assert!(!s.contains("\x1b[0m"));
}
#[test]
fn serialize_final_reset_on_styled_patches() {
let p = Patch {
row: 1,
col: 1,
cell: Cell {
ch: 'x',
style: style_bold_cyan(),
width: 1,
},
};
let bytes = serialize_patches(std::slice::from_ref(&p));
let s = String::from_utf8(bytes).unwrap();
assert!(s.ends_with("\x1b[0m"));
}
#[test]
fn serialize_adjacent_cells_skip_cursor_move() {
let p1 = Patch {
row: 5,
col: 1,
cell: Cell {
ch: 'a',
style: Default::default(),
width: 1,
},
};
let p2 = Patch {
row: 5,
col: 2,
cell: Cell {
ch: 'b',
style: Default::default(),
width: 1,
},
};
let bytes = serialize_patches(&[p1, p2]);
let s = String::from_utf8(bytes).unwrap();
assert_eq!(s.matches("\x1b[").count(), 1);
}
#[test]
fn serialize_style_change_only_emits_sgr_once() {
let p1 = Patch {
row: 5,
col: 1,
cell: Cell {
ch: 'a',
style: Default::default(),
width: 1,
},
};
let p2 = Patch {
row: 5,
col: 2,
cell: Cell {
ch: 'b',
style: CellStyle {
fg: None,
bold: true,
reverse: false,
faint: false,
},
width: 1,
},
};
let bytes = serialize_patches(&[p1, p2]);
let s = String::from_utf8(bytes).unwrap();
assert!(s.contains("\x1b[1m"), "expected bold SGR, got: {:?}", s);
}
#[test]
fn serialize_faint_emits_sgr_two_and_final_reset() {
let p = Patch {
row: 1,
col: 1,
cell: Cell {
ch: 'h',
style: CellStyle {
fg: None,
bold: false,
reverse: false,
faint: true,
},
width: 1,
},
};
let bytes = serialize_patches(std::slice::from_ref(&p));
let s = String::from_utf8(bytes).unwrap();
assert!(s.contains("\x1b[2m"), "expected faint SGR, got: {:?}", s);
assert!(s.ends_with("\x1b[0m"), "faint cell must close with reset");
}
#[test]
fn serialize_faint_off_goes_through_reset() {
let faint = Patch {
row: 1,
col: 1,
cell: Cell {
ch: 'a',
style: CellStyle {
fg: None,
bold: false,
reverse: false,
faint: true,
},
width: 1,
},
};
let plain = Patch {
row: 1,
col: 2,
cell: Cell {
ch: 'b',
style: CellStyle::default(),
width: 1,
},
};
let bytes = serialize_patches(&[faint, plain]);
let s = String::from_utf8(bytes).unwrap();
assert!(s.contains("\x1b[2m"));
let reset_idx = s
.match_indices("\x1b[0m")
.map(|(i, _)| i)
.find(|&i| i < s.find('b').unwrap())
.expect("expected mid-stream reset before plain cell");
let _ = reset_idx;
}
#[test]
fn unicode_cjk_ideograph_expands_to_two_cells() {
let mut row = Vec::new();
push_str_cells(&mut row, "你是谁", &CellStyle::default());
assert_eq!(row.len(), 6, "3 CJK chars × (1 real + 1 cont) = 6 cells");
assert_eq!(row[0].ch, '你');
assert_eq!(row[0].width, 2);
assert_eq!(row[2].ch, '是');
assert_eq!(row[2].width, 2);
assert_eq!(row[4].ch, '谁');
assert_eq!(row[4].width, 2);
for i in [1, 3, 5] {
assert_eq!(row[i].width, 0, "cell {} should be continuation", i);
}
}
#[test]
fn unicode_single_codepoint_emoji_expands_to_two_cells() {
let mut row = Vec::new();
push_str_cells(&mut row, "😀", &CellStyle::default());
assert_eq!(row.len(), 2);
assert_eq!(row[0].ch, '😀');
assert_eq!(row[0].width, 2);
assert_eq!(row[1].width, 0);
}
#[test]
fn unicode_zwj_sequence_is_not_grapheme_aware_known_limitation() {
let mut row = Vec::new();
push_str_cells(&mut row, "👨\u{200D}👩\u{200D}👧", &CellStyle::default());
let real_cells = row.iter().filter(|c| c.width > 0).count();
let cont_cells = row.iter().filter(|c| c.width == 0).count();
eprintln!(
"[UNICODE DIAG] ZWJ family: real={} cont={} total={} (terminal would show 1 glyph = 2 cols)",
real_cells, cont_cells, row.len()
);
assert_eq!(real_cells, 3);
assert_eq!(cont_cells, 3);
assert_eq!(row.len(), 6);
}
#[test]
fn unicode_skin_tone_modifier_not_segmented_known_limitation() {
let mut row = Vec::new();
push_str_cells(&mut row, "👍🏽", &CellStyle::default());
let real_cells = row.iter().filter(|c| c.width > 0).count();
eprintln!(
"[UNICODE DIAG] skin-tone emoji: real={} cells total={}",
real_cells,
row.len()
);
assert_eq!(real_cells, 2);
}
#[test]
fn unicode_ambiguous_width_defaults_narrow() {
let mut row = Vec::new();
push_str_cells(&mut row, "§±¶", &CellStyle::default());
assert_eq!(row.len(), 3);
for (i, ch) in "§±¶".chars().enumerate() {
assert_eq!(row[i].ch, ch);
assert_eq!(row[i].width, 1);
}
}
#[test]
fn unicode_nfd_combining_mark_is_dropped_known_limitation() {
let mut row = Vec::new();
push_str_cells(&mut row, "e\u{301}", &CellStyle::default());
assert_eq!(row.len(), 1, "combining mark dropped by width=0 guard");
assert_eq!(row[0].ch, 'e');
assert_eq!(row[0].width, 1);
}
#[test]
fn unicode_nfc_precomposed_accent_narrow_cell() {
let mut row = Vec::new();
push_str_cells(&mut row, "café", &CellStyle::default());
assert_eq!(row.len(), 4);
for (i, ch) in "café".chars().enumerate() {
assert_eq!(row[i].ch, ch);
assert_eq!(row[i].width, 1);
}
}
#[test]
fn unicode_zero_width_invisibles_dropped() {
let mut row = Vec::new();
push_str_cells(&mut row, "a\u{200B}b\u{FEFF}c", &CellStyle::default());
assert_eq!(row.len(), 3);
assert_eq!(row[0].ch, 'a');
assert_eq!(row[1].ch, 'b');
assert_eq!(row[2].ch, 'c');
}
#[test]
fn unicode_mixed_width_cell_indices_match_terminal_cols() {
let mut row = Vec::new();
push_str_cells(&mut row, "a你b", &CellStyle::default());
assert_eq!(row.len(), 4);
let total_advance: u16 = row.iter().map(|c| c.width as u16).sum();
assert_eq!(total_advance, 4);
}
#[test]
fn unicode_diff_narrow_to_wide_at_same_position() {
let prev_row: Vec<Cell> = vec![
Cell {
ch: 'a',
style: CellStyle::default(),
width: 1,
},
Cell {
ch: 'b',
style: CellStyle::default(),
width: 1,
},
];
let next_row: Vec<Cell> = vec![
Cell {
ch: '你',
style: CellStyle::default(),
width: 2,
},
Cell::continuation(),
];
let prev: Vec<Vec<Cell>> = (0..9)
.map(|_| Vec::new())
.chain(std::iter::once(prev_row))
.collect();
let next: Vec<Vec<Cell>> = (0..9)
.map(|_| Vec::new())
.chain(std::iter::once(next_row))
.collect();
let patches = diff_cell_frames(&prev, &next);
assert_eq!(patches.len(), 2, "both cols changed");
let bytes = serialize_patches(&patches);
let s = String::from_utf8(bytes).unwrap();
assert!(s.contains('你'));
assert_eq!(
s.matches("\x1b[10;").count(),
1,
"continuation must not trigger a cursor move: {:?}",
s
);
}
#[test]
fn unicode_diff_wide_to_narrow_erases_right_half() {
let prev_row: Vec<Cell> = vec![
Cell {
ch: '你',
style: CellStyle::default(),
width: 2,
},
Cell::continuation(),
];
let next_row: Vec<Cell> = vec![
Cell {
ch: 'a',
style: CellStyle::default(),
width: 1,
},
Cell {
ch: 'b',
style: CellStyle::default(),
width: 1,
},
];
let prev: Vec<Vec<Cell>> = (0..4)
.map(|_| Vec::new())
.chain(std::iter::once(prev_row))
.collect();
let next: Vec<Vec<Cell>> = (0..4)
.map(|_| Vec::new())
.chain(std::iter::once(next_row))
.collect();
let patches = diff_cell_frames(&prev, &next);
assert_eq!(patches.len(), 2);
assert_eq!(patches[0].col, 1);
assert_eq!(patches[0].cell.ch, 'a');
assert_eq!(patches[1].col, 2);
assert_eq!(patches[1].cell.ch, 'b');
let bytes = serialize_patches(&patches);
let s = String::from_utf8(bytes).unwrap();
assert!(s.contains('a'));
assert!(s.contains('b'));
}
}