use serde::{Deserialize, Serialize};
use std::io::Write;
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct Color {
pub r: u8,
pub g: u8,
pub b: u8,
}
impl Color {
pub const WHITE: Self = Self { r: 255, g: 255, b: 255 };
pub const BLACK: Self = Self { r: 0, g: 0, b: 0 };
#[must_use]
pub const fn new(r: u8, g: u8, b: u8) -> Self {
Self { r, g, b }
}
}
impl Default for Color {
fn default() -> Self {
Self::WHITE
}
}
pub const ANSI_COLORS: [Color; 8] = [
Color::new(0, 0, 0), Color::new(205, 49, 49), Color::new(13, 188, 121), Color::new(229, 229, 16), Color::new(36, 114, 200), Color::new(188, 63, 188), Color::new(17, 168, 205), Color::new(229, 229, 229), ];
pub const ANSI_BRIGHT_COLORS: [Color; 8] = [
Color::new(102, 102, 102), Color::new(241, 76, 76), Color::new(35, 209, 139), Color::new(245, 245, 67), Color::new(59, 142, 234), Color::new(214, 112, 214), Color::new(41, 184, 219), Color::new(255, 255, 255), ];
#[must_use]
pub fn default_ansi_palette() -> [Color; 16] {
let mut palette = [Color::BLACK; 16];
palette[..8].copy_from_slice(&ANSI_COLORS);
palette[8..].copy_from_slice(&ANSI_BRIGHT_COLORS);
palette
}
#[must_use]
pub fn ansi_256_color(idx: u16, palette: &[Color; 16]) -> Color {
match idx {
0..=15 => palette[idx as usize],
16..=231 => {
let idx = idx - 16;
let r_idx = idx / 36;
let g_idx = (idx % 36) / 6;
let b_idx = idx % 6;
let to_byte = |i: u16| -> u8 {
if i == 0 { 0 } else { (55 + 40 * i) as u8 }
};
Color::new(to_byte(r_idx), to_byte(g_idx), to_byte(b_idx))
}
232..=255 => {
let v = (8 + 10 * (idx - 232)) as u8;
Color::new(v, v, v)
}
_ => Color::WHITE,
}
}
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct CellAttrs(pub u8);
impl CellAttrs {
pub const NONE: Self = Self(0);
pub const BOLD: Self = Self(1 << 0);
pub const ITALIC: Self = Self(1 << 1);
pub const UNDERLINE: Self = Self(1 << 2);
pub const BLINK: Self = Self(1 << 3);
pub const INVERSE: Self = Self(1 << 4);
pub const STRIKETHROUGH: Self = Self(1 << 5);
pub const DIM: Self = Self(1 << 6);
pub const HIDDEN: Self = Self(1 << 7);
#[must_use]
pub const fn contains(self, other: Self) -> bool {
(self.0 & other.0) == other.0
}
pub fn insert(&mut self, other: Self) {
self.0 |= other.0;
}
pub fn remove(&mut self, other: Self) {
self.0 &= !other.0;
}
#[must_use]
pub const fn is_empty(self) -> bool {
self.0 == 0
}
#[must_use]
pub const fn bits(self) -> u8 {
self.0
}
}
#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Cell {
pub ch: char,
pub fg: Color,
pub bg: Color,
pub attrs: CellAttrs,
}
impl Cell {
pub const BLANK: Self = Self {
ch: ' ',
fg: Color::WHITE,
bg: Color::BLACK,
attrs: CellAttrs::NONE,
};
}
impl Default for Cell {
fn default() -> Self {
Self::BLANK
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct PaneSnapshot {
pub rows: usize,
pub cols: usize,
pub cells: Vec<Vec<Cell>>,
pub cursor_row: usize,
pub cursor_col: usize,
#[serde(default)]
pub alt_screen_active: bool,
#[serde(default = "default_true")]
pub cursor_visible: bool,
#[serde(default)]
pub title: Option<String>,
#[serde(default)]
pub cursor_keys_mode: bool,
}
fn default_true() -> bool {
true
}
impl PaneSnapshot {
#[must_use]
pub fn to_text_rows(&self) -> Vec<String> {
self.cells
.iter()
.map(|row| row.iter().map(|c| c.ch).collect::<String>())
.collect()
}
#[must_use]
pub fn to_text(&self) -> String {
self.to_text_rows().join("\n")
}
#[must_use]
pub fn to_ansi(&self) -> Vec<u8> {
let mut buf: Vec<u8> = Vec::with_capacity(self.rows * self.cols * 4 + 64);
if self.alt_screen_active {
buf.extend_from_slice(b"\x1b[?1049h");
}
buf.extend_from_slice(b"\x1b[0m\x1b[2J\x1b[H");
let mut cur_fg = Color::WHITE;
let mut cur_bg = Color::BLACK;
let mut cur_attrs = CellAttrs::NONE;
for (r, row) in self.cells.iter().enumerate() {
let _ = write!(buf, "\x1b[{};1H", r + 1);
for cell in row {
if cell.attrs != cur_attrs {
buf.extend_from_slice(b"\x1b[0m");
cur_fg = Color::WHITE;
cur_bg = Color::BLACK;
write_sgr_attrs(&mut buf, cell.attrs);
cur_attrs = cell.attrs;
}
if cell.fg != cur_fg {
let _ = write!(buf, "\x1b[38;2;{};{};{}m", cell.fg.r, cell.fg.g, cell.fg.b);
cur_fg = cell.fg;
}
if cell.bg != cur_bg {
let _ = write!(buf, "\x1b[48;2;{};{};{}m", cell.bg.r, cell.bg.g, cell.bg.b);
cur_bg = cell.bg;
}
let mut tmp = [0u8; 4];
buf.extend_from_slice(cell.ch.encode_utf8(&mut tmp).as_bytes());
}
}
let _ = write!(
buf,
"\x1b[{};{}H",
self.cursor_row + 1,
self.cursor_col + 1
);
if !self.cursor_visible {
buf.extend_from_slice(b"\x1b[?25l");
}
buf
}
}
#[cfg(test)]
mod to_ansi_tests {
use super::*;
fn snap_with(rows: usize, cols: usize, ch: char) -> PaneSnapshot {
PaneSnapshot {
rows,
cols,
cells: (0..rows)
.map(|_| (0..cols).map(|_| Cell { ch, ..Cell::BLANK }).collect())
.collect(),
cursor_row: 0,
cursor_col: 0,
alt_screen_active: false,
cursor_visible: true,
title: None,
cursor_keys_mode: false,
}
}
#[test]
fn empty_grid_emits_clear_and_home() {
let s = snap_with(2, 3, ' ');
let bytes = s.to_ansi();
let text = String::from_utf8_lossy(&bytes);
assert!(text.contains("\x1b[0m"));
assert!(text.contains("\x1b[2J"));
assert!(text.contains("\x1b[H"));
assert!(text.contains("\x1b[1;1H"));
}
#[test]
fn cells_appear_in_output() {
let mut s = snap_with(1, 5, 'x');
s.cells[0][2].ch = 'Y';
let text = String::from_utf8_lossy(&s.to_ansi()).into_owned();
assert!(text.contains("xxYxx"), "got: {text:?}");
}
#[test]
fn cursor_position_emitted_one_based() {
let mut s = snap_with(5, 5, ' ');
s.cursor_row = 3;
s.cursor_col = 2;
let text = String::from_utf8_lossy(&s.to_ansi()).into_owned();
assert!(text.contains("\x1b[4;3H"), "got: {text:?}");
}
#[test]
fn alt_screen_active_prepends_csi_1049h() {
let mut s = snap_with(1, 1, ' ');
s.alt_screen_active = true;
let bytes = s.to_ansi();
assert!(bytes.starts_with(b"\x1b[?1049h"));
}
#[test]
fn invisible_cursor_emits_csi_25l() {
let mut s = snap_with(1, 1, ' ');
s.cursor_visible = false;
let text = String::from_utf8_lossy(&s.to_ansi()).into_owned();
assert!(text.contains("\x1b[?25l"));
}
#[test]
fn fg_color_change_emits_truecolor_sgr() {
let mut s = snap_with(1, 1, 'r');
s.cells[0][0].fg = Color::new(255, 100, 0); let text = String::from_utf8_lossy(&s.to_ansi()).into_owned();
assert!(text.contains("\x1b[38;2;255;100;0m"), "got: {text:?}");
}
#[test]
fn bg_color_change_emits_truecolor_sgr() {
let mut s = snap_with(1, 1, ' ');
s.cells[0][0].bg = Color::new(0, 50, 100);
let text = String::from_utf8_lossy(&s.to_ansi()).into_owned();
assert!(text.contains("\x1b[48;2;0;50;100m"), "got: {text:?}");
}
#[test]
fn both_fg_and_bg_change_in_one_cell() {
let mut s = snap_with(1, 1, '!');
s.cells[0][0].fg = Color::new(10, 20, 30);
s.cells[0][0].bg = Color::new(200, 150, 100);
let text = String::from_utf8_lossy(&s.to_ansi()).into_owned();
assert!(text.contains("\x1b[38;2;10;20;30m"));
assert!(text.contains("\x1b[48;2;200;150;100m"));
assert!(text.contains('!'));
}
#[test]
fn each_cellattr_flag_emits_matching_sgr() {
let cases: &[(CellAttrs, &str)] = &[
(CellAttrs::BOLD, "\x1b[1m"),
(CellAttrs::DIM, "\x1b[2m"),
(CellAttrs::ITALIC, "\x1b[3m"),
(CellAttrs::UNDERLINE, "\x1b[4m"),
(CellAttrs::BLINK, "\x1b[5m"),
(CellAttrs::INVERSE, "\x1b[7m"),
(CellAttrs::HIDDEN, "\x1b[8m"),
(CellAttrs::STRIKETHROUGH, "\x1b[9m"),
];
for (attr, expected_sgr) in cases {
let mut s = snap_with(1, 1, 'a');
s.cells[0][0].attrs = *attr;
let text = String::from_utf8_lossy(&s.to_ansi()).into_owned();
assert!(
text.contains(expected_sgr),
"attr {:?} should emit {:?}, got {text:?}",
attr,
expected_sgr
);
}
}
#[test]
fn combined_attrs_emit_all_sgr_codes() {
let mut s = snap_with(1, 1, 'a');
let mut combined = CellAttrs::NONE;
combined.insert(CellAttrs::BOLD);
combined.insert(CellAttrs::UNDERLINE);
combined.insert(CellAttrs::ITALIC);
s.cells[0][0].attrs = combined;
let text = String::from_utf8_lossy(&s.to_ansi()).into_owned();
assert!(text.contains("\x1b[1m"));
assert!(text.contains("\x1b[3m"));
assert!(text.contains("\x1b[4m"));
}
#[test]
fn identical_runs_do_not_re_emit_sgr() {
let mut s = snap_with(1, 3, 'x');
for c in &mut s.cells[0] {
c.fg = Color::new(50, 100, 150);
}
let text = String::from_utf8_lossy(&s.to_ansi()).into_owned();
let count = text.matches("\x1b[38;2;50;100;150m").count();
assert_eq!(count, 1, "expected 1 fg SGR for identical run, got {count}");
}
#[test]
fn fg_change_mid_row_re_emits_sgr_once() {
let mut s = snap_with(1, 4, 'a');
s.cells[0][0].fg = Color::new(255, 0, 0);
s.cells[0][1].fg = Color::new(255, 0, 0);
s.cells[0][2].fg = Color::new(0, 255, 0);
s.cells[0][3].fg = Color::new(0, 255, 0);
let text = String::from_utf8_lossy(&s.to_ansi()).into_owned();
assert_eq!(text.matches("\x1b[38;2;255;0;0m").count(), 1);
assert_eq!(text.matches("\x1b[38;2;0;255;0m").count(), 1);
}
#[test]
fn each_row_gets_explicit_cursor_position() {
let s = snap_with(3, 2, '.');
let text = String::from_utf8_lossy(&s.to_ansi()).into_owned();
assert!(text.contains("\x1b[1;1H"));
assert!(text.contains("\x1b[2;1H"));
assert!(text.contains("\x1b[3;1H"));
}
#[test]
fn utf8_multibyte_chars_round_trip() {
let mut s = snap_with(1, 5, '·');
s.cells[0][0].ch = '日';
s.cells[0][1].ch = '本';
s.cells[0][2].ch = '語';
let text = String::from_utf8_lossy(&s.to_ansi()).into_owned();
assert!(text.contains("日本語"), "got: {text:?}");
}
#[test]
fn alt_screen_plus_cursor_hidden_combine() {
let mut s = snap_with(1, 1, ' ');
s.alt_screen_active = true;
s.cursor_visible = false;
let bytes = s.to_ansi();
let text = String::from_utf8_lossy(&bytes);
assert!(bytes.starts_with(b"\x1b[?1049h"));
assert!(text.contains("\x1b[?25l"));
let pos_idx = text.find("\x1b[1;1H").unwrap();
let hide_idx = text.find("\x1b[?25l").unwrap();
assert!(hide_idx > pos_idx, "cursor hide must follow position");
}
#[test]
fn wide_grid_emits_proportional_bytes() {
let s = snap_with(24, 80, '*');
let bytes = s.to_ansi();
assert!(bytes.len() >= 1920, "expected >=1920 bytes, got {}", bytes.len());
let text = String::from_utf8_lossy(&bytes);
for row in 1..=24 {
let csi = format!("\x1b[{row};1H");
assert!(text.contains(&csi), "row {row} CSI missing");
}
}
}
fn write_sgr_attrs(buf: &mut Vec<u8>, attrs: CellAttrs) {
if attrs.contains(CellAttrs::BOLD) { buf.extend_from_slice(b"\x1b[1m"); }
if attrs.contains(CellAttrs::DIM) { buf.extend_from_slice(b"\x1b[2m"); }
if attrs.contains(CellAttrs::ITALIC) { buf.extend_from_slice(b"\x1b[3m"); }
if attrs.contains(CellAttrs::UNDERLINE) { buf.extend_from_slice(b"\x1b[4m"); }
if attrs.contains(CellAttrs::BLINK) { buf.extend_from_slice(b"\x1b[5m"); }
if attrs.contains(CellAttrs::INVERSE) { buf.extend_from_slice(b"\x1b[7m"); }
if attrs.contains(CellAttrs::HIDDEN) { buf.extend_from_slice(b"\x1b[8m"); }
if attrs.contains(CellAttrs::STRIKETHROUGH) { buf.extend_from_slice(b"\x1b[9m"); }
}