From 86e9c4825c7926f32a2078efb3523d81e4992cd2 Mon Sep 17 00:00:00 2001 From: Tom You Date: Wed, 8 Jul 2026 10:40:53 -0500 Subject: [PATCH] feat(core): parse terminal control sequences --- rabby-core/src/terminal_model/mod.rs | 205 +++++++++++++++++++++++++-- 1 file changed, 194 insertions(+), 11 deletions(-) diff --git a/rabby-core/src/terminal_model/mod.rs b/rabby-core/src/terminal_model/mod.rs index ed81456..646fd36 100644 --- a/rabby-core/src/terminal_model/mod.rs +++ b/rabby-core/src/terminal_model/mod.rs @@ -1,20 +1,49 @@ -#[derive(Debug, Clone, PartialEq, Eq)] +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: ' ' } + Self { + ch: ' ', + style: CellStyle::default(), + } } } -#[derive(Debug, Clone, PartialEq, Eq)] +#[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, } @@ -26,19 +55,44 @@ impl TerminalGrid { rows, cursor_col: 0, cursor_row: 0, + style: CellStyle::default(), + bracketed_paste: false, cells: vec![Cell::default(); size], } } - pub fn write_utf8(&mut self, bytes: &[u8]) { - let text = String::from_utf8_lossy(bytes); - for ch in text.chars() { - match ch { - '\r' => self.cursor_col = 0, - '\n' => self.newline(), - _ => self.put_char(ch), + 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 { @@ -54,13 +108,24 @@ impl TerminalGrid { 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].ch = ch; + self.cells[index] = Cell { + ch, + style: self.style, + }; self.cursor_col += 1; if self.cursor_col >= self.cols { self.newline(); @@ -73,6 +138,82 @@ impl TerminalGrid { 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)] @@ -106,4 +247,46 @@ mod tests { 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 "); + } }