#![cfg(test)]
use crossterm::style::Color;
use vte::{Params, Parser, Perform};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GridCell {
pub ch: char,
pub bold: bool,
pub reverse: bool,
pub fg: Option<Color>,
}
impl Default for GridCell {
fn default() -> Self {
Self {
ch: ' ',
bold: false,
reverse: false,
fg: None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct Style {
bold: bool,
reverse: bool,
fg: Option<Color>,
}
impl Default for Style {
fn default() -> Self {
Self {
bold: false,
reverse: false,
fg: None,
}
}
}
pub struct VirtualTerminal {
width: u16,
height: u16,
grid: Vec<Vec<GridCell>>,
cursor_row: u16,
cursor_col: u16,
cursor_visible: bool,
style: Style,
scroll_top: u16,
scroll_bottom: u16,
scrollback: Vec<Vec<GridCell>>,
ed_promotes_to_scrollback: bool,
}
impl VirtualTerminal {
pub fn new(width: u16, height: u16) -> Self {
let row = vec![GridCell::default(); width as usize];
let grid = vec![row; height as usize];
Self {
width,
height,
grid,
cursor_row: 0,
cursor_col: 0,
cursor_visible: true,
style: Style::default(),
scroll_top: 0,
scroll_bottom: height.saturating_sub(1),
scrollback: Vec::new(),
ed_promotes_to_scrollback: false,
}
}
pub fn set_ed_promotes_to_scrollback(&mut self, on: bool) {
self.ed_promotes_to_scrollback = on;
}
fn scroll_region_up(&mut self) {
let top = self.scroll_top as usize;
let bot = self.scroll_bottom as usize;
if top >= bot || bot >= self.grid.len() {
return;
}
if top == 0 {
self.scrollback.push(self.grid[0].clone());
}
for r in top..bot {
self.grid[r] = self.grid[r + 1].clone();
}
let blank = vec![GridCell::default(); self.width as usize];
self.grid[bot] = blank;
}
pub fn width(&self) -> u16 {
self.width
}
pub fn height(&self) -> u16 {
self.height
}
pub fn cursor(&self) -> (u16, u16) {
(self.cursor_row, self.cursor_col)
}
pub fn cursor_visible(&self) -> bool {
self.cursor_visible
}
pub fn feed(&mut self, bytes: &[u8]) {
let mut parser: Parser = Parser::new();
parser.advance(self, bytes);
}
pub fn cell_at(&self, row: usize, col: usize) -> GridCell {
self.grid
.get(row)
.and_then(|r| r.get(col))
.cloned()
.unwrap_or_default()
}
pub fn row_text(&self, row: usize) -> String {
self.grid
.get(row)
.map(|r| r.iter().map(|c| c.ch).collect())
.unwrap_or_default()
}
pub fn any_row<F: FnMut(&str) -> bool>(&self, mut f: F) -> bool {
(0..self.height as usize).any(|r| f(&self.row_text(r)))
}
pub fn scrollback_texts(&self) -> Vec<String> {
self.scrollback
.iter()
.map(|row| {
row.iter()
.map(|c| c.ch)
.collect::<String>()
.trim_end()
.to_string()
})
.collect()
}
pub fn scrollback_len(&self) -> usize {
self.scrollback.len()
}
pub fn dump(&self) -> String {
self.grid
.iter()
.enumerate()
.map(|(r, row)| {
let text: String = row.iter().map(|c| c.ch).collect();
format!("{:>3} │{}│", r, text.trim_end_matches(' '))
})
.collect::<Vec<_>>()
.join("\n")
}
fn put_char(&mut self, ch: char) {
if self.cursor_row as usize >= self.grid.len() {
return;
}
let row = &mut self.grid[self.cursor_row as usize];
if (self.cursor_col as usize) < row.len() {
row[self.cursor_col as usize] = GridCell {
ch,
bold: self.style.bold,
reverse: self.style.reverse,
fg: self.style.fg,
};
}
let w = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(1) as u16;
self.cursor_col = self.cursor_col.saturating_add(w);
}
fn apply_sgr(&mut self, params: &Params) {
if params.is_empty() {
self.style = Style::default();
return;
}
let codes: Vec<u16> = params.iter().filter_map(|p| p.first().copied()).collect();
let mut i = 0;
while i < codes.len() {
let code = codes[i];
match code {
0 => self.style = Style::default(),
1 => self.style.bold = true,
22 => self.style.bold = false,
7 => self.style.reverse = true,
27 => self.style.reverse = false,
39 => self.style.fg = None,
30..=37 => self.style.fg = Some(ansi16_color(code - 30)),
90..=97 => self.style.fg = Some(ansi16_color((code - 90) + 8)),
38 => {
if i + 2 < codes.len() && codes[i + 1] == 5 {
self.style.fg = Some(ansi16_color(codes[i + 2]));
i += 2;
} else if i + 4 < codes.len() && codes[i + 1] == 2 {
let r = codes[i + 2] as u8;
let g = codes[i + 3] as u8;
let b = codes[i + 4] as u8;
self.style.fg = Some(Color::Rgb { r, g, b });
i += 4;
}
}
_ => {}
}
i += 1;
}
}
}
fn ansi16_color(idx: u16) -> Color {
match idx {
0 => Color::Black,
1 => Color::DarkRed,
2 => Color::DarkGreen,
3 => Color::DarkYellow,
4 => Color::DarkBlue,
5 => Color::DarkMagenta,
6 => Color::DarkCyan,
7 => Color::Grey,
8 => Color::DarkGrey,
9 => Color::Red,
10 => Color::Green,
11 => Color::Yellow,
12 => Color::Blue,
13 => Color::Magenta,
14 => Color::Cyan,
15 => Color::White,
n => Color::AnsiValue(n as u8),
}
}
impl Perform for VirtualTerminal {
fn print(&mut self, c: char) {
self.put_char(c);
}
fn execute(&mut self, byte: u8) {
match byte {
b'\n' => {
if self.cursor_row == self.scroll_bottom {
self.scroll_region_up();
} else if self.cursor_row + 1 < self.height {
self.cursor_row += 1;
}
}
b'\r' => {
self.cursor_col = 0;
}
_ => {}
}
}
fn csi_dispatch(&mut self, params: &Params, intermediates: &[u8], _ignore: bool, action: char) {
match action {
'H' | 'f' => {
let mut it = params.iter();
let row = it.next().and_then(|p| p.first().copied()).unwrap_or(1);
let col = it.next().and_then(|p| p.first().copied()).unwrap_or(1);
self.cursor_row = (row.saturating_sub(1) as u16).min(self.height.saturating_sub(1));
self.cursor_col = (col.saturating_sub(1) as u16).min(self.width.saturating_sub(1));
}
'J' => {
let mode = params
.iter()
.next()
.and_then(|p| p.first().copied())
.unwrap_or(0);
let blank = GridCell::default();
let blank_row = vec![blank; self.width as usize];
match mode {
0 => {
let row_idx = self.cursor_row as usize;
let col_idx = self.cursor_col as usize;
if let Some(row) = self.grid.get_mut(row_idx) {
for col in col_idx..row.len() {
row[col] = GridCell::default();
}
}
for row in self.grid.iter_mut().skip(row_idx + 1) {
*row = blank_row.clone();
}
}
1 => {
let row_idx = self.cursor_row as usize;
let col_idx = self.cursor_col as usize;
for row in self.grid.iter_mut().take(row_idx) {
*row = blank_row.clone();
}
if let Some(row) = self.grid.get_mut(row_idx) {
let end = (col_idx + 1).min(row.len());
for col in 0..end {
row[col] = GridCell::default();
}
}
}
2 => {
if self.ed_promotes_to_scrollback {
for row in &self.grid {
let non_blank = row.iter().any(|c| c.ch != ' ');
if non_blank {
self.scrollback.push(row.clone());
}
}
}
for row in &mut self.grid {
*row = blank_row.clone();
}
}
_ => {}
}
}
'K' => {
let mode = params
.iter()
.next()
.and_then(|p| p.first().copied())
.unwrap_or(0);
if let Some(row) = self.grid.get_mut(self.cursor_row as usize) {
match mode {
0 => {
for col in (self.cursor_col as usize)..row.len() {
row[col] = GridCell::default();
}
}
1 => {
for col in
0..=(self.cursor_col as usize).min(row.len().saturating_sub(1))
{
row[col] = GridCell::default();
}
}
2 => {
for cell in row.iter_mut() {
*cell = GridCell::default();
}
}
_ => {}
}
}
}
'r' => {
let mut it = params.iter();
let top = it.next().and_then(|p| p.first().copied()).unwrap_or(1);
let bot = it
.next()
.and_then(|p| p.first().copied())
.unwrap_or(self.height as u16);
let top0 = top.saturating_sub(1).min(self.height.saturating_sub(1));
let bot0 = bot.saturating_sub(1).min(self.height.saturating_sub(1));
if top0 < bot0 {
self.scroll_top = top0;
self.scroll_bottom = bot0;
}
}
'm' => self.apply_sgr(params),
'h' | 'l' if intermediates == b"?" => {
let on = action == 'h';
let code = params
.iter()
.next()
.and_then(|p| p.first().copied())
.unwrap_or(0);
match code {
25 => self.cursor_visible = on,
_ => {}
}
}
_ => {
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn vt_prints_to_grid_at_cursor() {
let mut vt = VirtualTerminal::new(10, 3);
vt.feed(b"hello");
assert_eq!(vt.row_text(0), "hello ");
assert_eq!(vt.cursor(), (0, 5));
}
#[test]
fn vt_cup_jumps_cursor() {
let mut vt = VirtualTerminal::new(10, 5);
vt.feed(b"\x1b[3;5Habc");
assert_eq!(vt.row_text(2), " abc ");
assert_eq!(vt.cursor(), (2, 7));
}
#[test]
fn vt_clear_screen_blanks_all_rows() {
let mut vt = VirtualTerminal::new(5, 3);
vt.feed(b"abc\r\nxyz\x1b[2J");
assert!(vt.row_text(0).chars().all(|c| c == ' '));
assert!(vt.row_text(1).chars().all(|c| c == ' '));
}
#[test]
fn vt_sgr_bold_reverse_tracked_per_cell() {
let mut vt = VirtualTerminal::new(10, 1);
vt.feed(b"a\x1b[1mb\x1b[7mc\x1b[0md");
assert!(!vt.cell_at(0, 0).bold); assert!(vt.cell_at(0, 1).bold); assert!(vt.cell_at(0, 2).bold); assert!(vt.cell_at(0, 2).reverse);
assert!(!vt.cell_at(0, 3).bold); }
#[test]
fn vt_cjk_advances_two_cols() {
let mut vt = VirtualTerminal::new(10, 1);
vt.feed("你好".as_bytes());
assert_eq!(vt.cell_at(0, 0).ch, '你');
assert_eq!(vt.cell_at(0, 2).ch, '好');
assert_eq!(vt.cursor(), (0, 4));
}
#[test]
fn vt_cursor_visibility_toggles() {
let mut vt = VirtualTerminal::new(5, 1);
assert!(vt.cursor_visible());
vt.feed(b"\x1b[?25l");
assert!(!vt.cursor_visible());
vt.feed(b"\x1b[?25h");
assert!(vt.cursor_visible());
}
#[test]
fn vt_scrollback_captures_rows_exiting_top_anchored_region() {
let mut vt = VirtualTerminal::new(6, 3);
vt.feed(b"row0\r\nrow1\r\nrow2");
assert_eq!(vt.scrollback_len(), 0, "no scroll yet");
vt.feed(b"\x1b[3;1H\nrow3");
vt.feed(b"\x1b[3;1H\nrow4");
let sb = vt.scrollback_texts();
assert_eq!(sb, vec!["row0", "row1"]);
}
#[test]
fn vt_scrollback_ignored_for_non_top_anchored_region() {
let mut vt = VirtualTerminal::new(6, 5);
vt.feed(b"\x1b[2;4r"); vt.feed(b"\x1b[4;1H\n");
vt.feed(b"\x1b[4;1H\n");
assert_eq!(vt.scrollback_len(), 0);
}
#[test]
fn vt_ed_promotes_visible_rows_to_scrollback_when_enabled() {
let mut vt = VirtualTerminal::new(6, 3);
vt.set_ed_promotes_to_scrollback(true);
vt.feed(b"abc\r\nxyz");
assert_eq!(vt.scrollback_len(), 0, "no ED yet");
vt.feed(b"\x1b[2J");
assert_eq!(
vt.scrollback_texts(),
vec!["abc", "xyz"],
"ED should have promoted both non-blank rows"
);
assert!(vt.row_text(0).chars().all(|c| c == ' '));
assert!(vt.row_text(1).chars().all(|c| c == ' '));
}
}