This commit is contained in:
@@ -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<Cell>,
|
||||
}
|
||||
|
||||
@@ -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 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]) {
|
||||
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),
|
||||
}
|
||||
}
|
||||
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::<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)]
|
||||
@@ -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 ");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user