use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum Color { Default, Ansi(u8), } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub struct CellStyle { pub foreground: Color, pub bold: bool, } impl Default for CellStyle { fn default() -> Self { Self { foreground: Color::Default, bold: false, } } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Cell { pub ch: char, pub style: CellStyle, } impl Default for Cell { fn default() -> Self { Self { ch: ' ', style: CellStyle::default(), } } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct TerminalGrid { cols: u16, rows: u16, cursor_col: u16, cursor_row: u16, style: CellStyle, bracketed_paste: bool, cells: Vec, } impl TerminalGrid { pub fn new(cols: u16, rows: u16) -> Self { let size = usize::from(cols) * usize::from(rows); Self { cols, rows, cursor_col: 0, cursor_row: 0, style: CellStyle::default(), bracketed_paste: false, cells: vec![Cell::default(); size], } } pub fn cols(&self) -> u16 { self.cols } pub fn rows(&self) -> u16 { self.rows } pub fn bracketed_paste_enabled(&self) -> bool { self.bracketed_paste } pub fn resize(&mut self, cols: u16, rows: u16) { let mut resized = vec![Cell::default(); usize::from(cols) * usize::from(rows)]; let copy_rows = self.rows.min(rows); let copy_cols = self.cols.min(cols); for row in 0..copy_rows { for col in 0..copy_cols { let old_index = usize::from(row) * usize::from(self.cols) + usize::from(col); let new_index = usize::from(row) * usize::from(cols) + usize::from(col); resized[new_index] = self.cells[old_index].clone(); } } self.cols = cols; self.rows = rows; self.cursor_col = self.cursor_col.min(cols.saturating_sub(1)); self.cursor_row = self.cursor_row.min(rows.saturating_sub(1)); self.cells = resized; } pub fn write_utf8(&mut self, bytes: &[u8]) { AnsiParser::default().feed(self, bytes); } pub fn visible_text(&self) -> String { self.line_text(0) } pub fn line_text(&self, row: u16) -> String { if row >= self.rows { return String::new(); } let start = usize::from(row) * usize::from(self.cols); let end = start + usize::from(self.cols); self.cells[start..end].iter().map(|cell| cell.ch).collect() } pub fn cell(&self, col: u16, row: u16) -> Option<&Cell> { if col >= self.cols || row >= self.rows { return None; } self.cells .get(usize::from(row) * usize::from(self.cols) + usize::from(col)) } fn put_char(&mut self, ch: char) { if self.cursor_row >= self.rows || self.cursor_col >= self.cols { return; } let index = usize::from(self.cursor_row) * usize::from(self.cols) + usize::from(self.cursor_col); self.cells[index] = Cell { ch, style: self.style, }; self.cursor_col += 1; if self.cursor_col >= self.cols { self.newline(); } } fn newline(&mut self) { self.cursor_col = 0; if self.cursor_row + 1 < self.rows { self.cursor_row += 1; } } fn carriage_return(&mut self) { self.cursor_col = 0; } fn clear(&mut self) { self.cells.fill(Cell::default()); self.cursor_col = 0; self.cursor_row = 0; } fn move_cursor_one_based(&mut self, row: u16, col: u16) { self.cursor_row = row.saturating_sub(1).min(self.rows.saturating_sub(1)); self.cursor_col = col.saturating_sub(1).min(self.cols.saturating_sub(1)); } } #[derive(Debug, Default, Clone, Copy)] pub struct AnsiParser; impl AnsiParser { pub fn feed(&mut self, grid: &mut TerminalGrid, bytes: &[u8]) { let text = String::from_utf8_lossy(bytes); let mut chars = text.chars().peekable(); while let Some(ch) = chars.next() { match ch { '\u{1b}' if chars.peek() == Some(&'[') => { chars.next(); let mut seq = String::new(); while let Some(next) = chars.next() { seq.push(next); if matches!(next, 'A'..='Z' | 'a'..='z' | '~') { break; } } Self::apply_csi(grid, &seq); } '\r' => grid.carriage_return(), '\n' => grid.newline(), _ => grid.put_char(ch), } } } fn apply_csi(grid: &mut TerminalGrid, seq: &str) { if seq == "2J" { grid.clear(); } else if seq == "?2004h" { grid.bracketed_paste = true; } else if seq == "?2004l" { grid.bracketed_paste = false; } else if let Some(args) = seq.strip_suffix('m') { Self::apply_sgr(grid, args); } else if let Some(args) = seq.strip_suffix('H') { let mut parts = args.split(';'); let row = parts.next().and_then(|p| p.parse().ok()).unwrap_or(1); let col = parts.next().and_then(|p| p.parse().ok()).unwrap_or(1); grid.move_cursor_one_based(row, col); } } fn apply_sgr(grid: &mut TerminalGrid, args: &str) { if args.is_empty() || args == "0" { grid.style = CellStyle::default(); return; } for code in args.split(';').filter_map(|part| part.parse::().ok()) { match code { 0 => grid.style = CellStyle::default(), 1 => grid.style.bold = true, 30..=37 => grid.style.foreground = Color::Ansi(code - 30), 39 => grid.style.foreground = Color::Default, _ => {} } } } } #[cfg(test)] mod tests { use super::*; #[test] fn writes_ascii_text_to_grid() { let mut grid = TerminalGrid::new(8, 2); grid.write_utf8(b"hello"); assert_eq!(grid.visible_text(), "hello "); } #[test] fn handles_newline_and_carriage_return() { let mut grid = TerminalGrid::new(6, 2); grid.write_utf8(b"abc\rZ\ndef"); assert_eq!(grid.line_text(0), "Zbc "); assert_eq!(grid.line_text(1), "def "); } #[test] fn tolerates_utf8_double_width_input() { let mut grid = TerminalGrid::new(6, 1); grid.write_utf8("한A".as_bytes()); assert!(grid.line_text(0).starts_with("한A")); } #[test] fn parses_ansi_color_and_reset() { let mut grid = TerminalGrid::new(8, 1); grid.write_utf8(b"\x1b[31mR\x1b[0mN"); assert_eq!(grid.cell(0, 0).unwrap().style.foreground, Color::Ansi(1)); assert_eq!(grid.cell(1, 0).unwrap().style.foreground, Color::Default); } #[test] fn parses_cursor_movement_and_clear_screen() { let mut grid = TerminalGrid::new(6, 2); grid.write_utf8(b"hello\x1b[2;3HZ\x1b[2Jx"); assert_eq!(grid.line_text(0), "x "); assert_eq!(grid.line_text(1), " "); } #[test] fn tracks_bracketed_paste_mode() { let mut grid = TerminalGrid::new(4, 1); grid.write_utf8(b"\x1b[?2004h"); assert!(grid.bracketed_paste_enabled()); grid.write_utf8(b"\x1b[?2004l"); assert!(!grid.bracketed_paste_enabled()); } #[test] fn resize_preserves_visible_cells() { let mut grid = TerminalGrid::new(4, 1); grid.write_utf8(b"ab"); grid.resize(6, 2); assert_eq!(grid.cols(), 6); assert_eq!(grid.rows(), 2); assert_eq!(grid.line_text(0), "ab "); } }