feat(core): parse terminal control sequences
test / workspace (push) Successful in 13m16s

This commit is contained in:
Tom You
2026-07-08 10:40:53 -05:00
parent ad50784919
commit 86e9c4825c
+194 -11
View File
@@ -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 struct Cell {
pub ch: char, pub ch: char,
pub style: CellStyle,
} }
impl Default for Cell { impl Default for Cell {
fn default() -> Self { 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 { pub struct TerminalGrid {
cols: u16, cols: u16,
rows: u16, rows: u16,
cursor_col: u16, cursor_col: u16,
cursor_row: u16, cursor_row: u16,
style: CellStyle,
bracketed_paste: bool,
cells: Vec<Cell>, cells: Vec<Cell>,
} }
@@ -26,19 +55,44 @@ impl TerminalGrid {
rows, rows,
cursor_col: 0, cursor_col: 0,
cursor_row: 0, cursor_row: 0,
style: CellStyle::default(),
bracketed_paste: false,
cells: vec![Cell::default(); size], cells: vec![Cell::default(); size],
} }
} }
pub fn write_utf8(&mut self, bytes: &[u8]) { pub fn cols(&self) -> u16 {
let text = String::from_utf8_lossy(bytes); self.cols
for ch in text.chars() { }
match ch {
'\r' => self.cursor_col = 0, pub fn rows(&self) -> u16 {
'\n' => self.newline(), self.rows
_ => self.put_char(ch), }
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 { pub fn visible_text(&self) -> String {
@@ -54,13 +108,24 @@ impl TerminalGrid {
self.cells[start..end].iter().map(|cell| cell.ch).collect() 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) { fn put_char(&mut self, ch: char) {
if self.cursor_row >= self.rows || self.cursor_col >= self.cols { if self.cursor_row >= self.rows || self.cursor_col >= self.cols {
return; return;
} }
let index = let index =
usize::from(self.cursor_row) * usize::from(self.cols) + usize::from(self.cursor_col); 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; self.cursor_col += 1;
if self.cursor_col >= self.cols { if self.cursor_col >= self.cols {
self.newline(); self.newline();
@@ -73,6 +138,82 @@ impl TerminalGrid {
self.cursor_row += 1; 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::<u8>().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)] #[cfg(test)]
@@ -106,4 +247,46 @@ mod tests {
assert!(grid.line_text(0).starts_with("한A")); 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 ");
}
} }