293 lines
8.0 KiB
Rust
293 lines
8.0 KiB
Rust
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<Cell>,
|
|
}
|
|
|
|
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::<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)]
|
|
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 ");
|
|
}
|
|
}
|