use vtparse::{VTActor, VTParser, CsiParam};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Cell {
pub c: char,
pub fg: Option<u8>,
pub bg: Option<u8>,
pub bold: bool,
pub italic: bool,
pub underline: bool,
}
impl Default for Cell {
fn default() -> Self {
Self {
c: ' ',
fg: None,
bg: None,
bold: false,
italic: false,
underline: false,
}
}
}
#[derive(Debug, Clone)]
pub struct SixelRegion {
pub start_row: u16,
pub start_col: u16,
pub width: u32,
pub height: u32,
pub data: Vec<u8>,
}
struct TerminalState {
cursor_pos: (u16, u16),
sixel_regions: Vec<SixelRegion>,
current_sixel_data: Vec<u8>,
current_sixel_params: Vec<i64>,
in_sixel_mode: bool,
width: u16,
height: u16,
cells: Vec<Vec<Cell>>,
current_fg: Option<u8>,
current_bg: Option<u8>,
current_bold: bool,
current_italic: bool,
current_underline: bool,
}
impl TerminalState {
fn new(width: u16, height: u16) -> Self {
let cells = vec![vec![Cell::default(); width as usize]; height as usize];
Self {
cursor_pos: (0, 0),
sixel_regions: Vec::new(),
current_sixel_data: Vec::new(),
current_sixel_params: Vec::new(),
in_sixel_mode: false,
width,
height,
cells,
current_fg: None,
current_bg: None,
current_bold: false,
current_italic: false,
current_underline: false,
}
}
fn put_char(&mut self, ch: char) {
let (row, col) = self.cursor_pos;
if row < self.height && col < self.width {
self.cells[row as usize][col as usize] = Cell {
c: ch,
fg: self.current_fg,
bg: self.current_bg,
bold: self.current_bold,
italic: self.current_italic,
underline: self.current_underline,
};
if col + 1 < self.width {
self.cursor_pos.1 = col + 1;
}
}
}
fn move_cursor(&mut self, row: u16, col: u16) {
self.cursor_pos = (row.min(self.height - 1), col.min(self.width - 1));
}
fn parse_raster_attributes(&self, data: &[u8]) -> Option<(u32, u32)> {
let data_str = std::str::from_utf8(data).ok()?;
let raster_start = data_str.find('"')?;
let after_quote = &data_str[raster_start + 1..];
let end_pos = after_quote
.find(|c: char| !c.is_ascii_digit() && c != ';')
.unwrap_or(after_quote.len());
let raster_part = &after_quote[..end_pos];
let parts: Vec<&str> = raster_part
.split(';')
.filter(|s| !s.is_empty())
.collect();
match parts.len() {
4 => {
let width = parts[2].parse::<u32>().ok()?;
let height = parts[3].parse::<u32>().ok()?;
if width > 0 && height > 0 {
Some((width, height))
} else {
None
}
}
2 => {
let width = parts[0].parse::<u32>().ok()?;
let height = parts[1].parse::<u32>().ok()?;
if width > 0 && height > 0 {
Some((width, height))
} else {
None
}
}
_ => None,
}
}
fn pixels_to_cells(width_px: u32, height_px: u32) -> (u16, u16) {
const PIXELS_PER_COL: u32 = 8;
const PIXELS_PER_ROW: u32 = 6;
let cols = if width_px > 0 {
((width_px + PIXELS_PER_COL - 1) / PIXELS_PER_COL) as u16
} else {
0
};
let rows = if height_px > 0 {
((height_px + PIXELS_PER_ROW - 1) / PIXELS_PER_ROW) as u16
} else {
0
};
(cols, rows)
}
}
impl VTActor for TerminalState {
fn print(&mut self, ch: char) {
self.put_char(ch);
}
fn execute_c0_or_c1(&mut self, control: u8) {
match control {
b'\r' => {
self.cursor_pos.1 = 0;
}
b'\n' => {
if self.cursor_pos.0 + 1 < self.height {
self.cursor_pos.0 += 1;
}
}
b'\t' => {
let next_tab = ((self.cursor_pos.1 / 8) + 1) * 8;
self.cursor_pos.1 = next_tab.min(self.width - 1);
}
_ => {}
}
}
fn dcs_hook(
&mut self,
mode: u8,
params: &[i64],
_intermediates: &[u8],
_ignored_excess_intermediates: bool,
) {
if mode == b'q' {
self.in_sixel_mode = true;
self.current_sixel_data.clear();
self.current_sixel_params = params.to_vec();
}
}
fn dcs_put(&mut self, byte: u8) {
if self.in_sixel_mode {
self.current_sixel_data.push(byte);
}
}
fn dcs_unhook(&mut self) {
if self.in_sixel_mode {
let (width, height) = self
.parse_raster_attributes(&self.current_sixel_data)
.unwrap_or((0, 0));
let region = SixelRegion {
start_row: self.cursor_pos.0,
start_col: self.cursor_pos.1,
width,
height,
data: self.current_sixel_data.clone(),
};
self.sixel_regions.push(region);
self.in_sixel_mode = false;
self.current_sixel_data.clear();
self.current_sixel_params.clear();
}
}
fn csi_dispatch(&mut self, params: &[CsiParam], _truncated: bool, byte: u8) {
match byte {
b'H' | b'f' => {
let integers: Vec<i64> = params
.iter()
.filter_map(|p| p.as_integer())
.collect();
let row = integers
.get(0)
.copied()
.unwrap_or(1)
.saturating_sub(1) as u16;
let col = integers
.get(1)
.copied()
.unwrap_or(1)
.saturating_sub(1) as u16;
self.move_cursor(row, col);
}
b'A' => {
let n = params
.iter()
.find_map(|p| p.as_integer())
.unwrap_or(1) as u16;
self.cursor_pos.0 = self.cursor_pos.0.saturating_sub(n);
}
b'B' => {
let n = params
.iter()
.find_map(|p| p.as_integer())
.unwrap_or(1) as u16;
self.cursor_pos.0 = (self.cursor_pos.0 + n).min(self.height - 1);
}
b'C' => {
let n = params
.iter()
.find_map(|p| p.as_integer())
.unwrap_or(1) as u16;
self.cursor_pos.1 = (self.cursor_pos.1 + n).min(self.width - 1);
}
b'D' => {
let n = params
.iter()
.find_map(|p| p.as_integer())
.unwrap_or(1) as u16;
self.cursor_pos.1 = self.cursor_pos.1.saturating_sub(n);
}
b'm' => {
let integers: Vec<i64> = params
.iter()
.filter_map(|p| p.as_integer())
.collect();
if integers.is_empty() {
self.current_fg = None;
self.current_bg = None;
self.current_bold = false;
self.current_italic = false;
self.current_underline = false;
return;
}
let mut i = 0;
while i < integers.len() {
match integers[i] {
0 => {
self.current_fg = None;
self.current_bg = None;
self.current_bold = false;
self.current_italic = false;
self.current_underline = false;
}
1 => self.current_bold = true,
3 => self.current_italic = true,
4 => self.current_underline = true,
22 => self.current_bold = false,
23 => self.current_italic = false,
24 => self.current_underline = false,
30..=37 => self.current_fg = Some((integers[i] - 30) as u8),
90..=97 => self.current_fg = Some((integers[i] - 90 + 8) as u8),
39 => self.current_fg = None, 40..=47 => self.current_bg = Some((integers[i] - 40) as u8),
100..=107 => self.current_bg = Some((integers[i] - 100 + 8) as u8),
49 => self.current_bg = None, 38 | 48 => {
if i + 2 < integers.len() && integers[i + 1] == 5 {
let color = integers[i + 2] as u8;
if integers[i] == 38 {
self.current_fg = Some(color);
} else {
self.current_bg = Some(color);
}
i += 2; }
}
_ => {} }
i += 1;
}
}
_ => {}
}
}
fn esc_dispatch(
&mut self,
_params: &[i64],
_intermediates: &[u8],
_ignored_excess_intermediates: bool,
byte: u8,
) {
match byte {
b'D' => {
if self.cursor_pos.0 + 1 < self.height {
self.cursor_pos.0 += 1;
}
}
b'E' => {
if self.cursor_pos.0 + 1 < self.height {
self.cursor_pos.0 += 1;
}
self.cursor_pos.1 = 0;
}
_ => {}
}
}
fn osc_dispatch(&mut self, _params: &[&[u8]]) {
}
fn apc_dispatch(&mut self, _data: Vec<u8>) {
}
}
pub struct ScreenState {
parser: VTParser,
state: TerminalState,
width: u16,
height: u16,
}
impl ScreenState {
pub fn new(width: u16, height: u16) -> Self {
let parser = VTParser::new();
let state = TerminalState::new(width, height);
Self {
parser,
state,
width,
height,
}
}
pub fn feed(&mut self, data: &[u8]) {
self.parser.parse(data, &mut self.state);
}
pub fn contents(&self) -> String {
self.state
.cells
.iter()
.map(|row| row.iter().map(|cell| cell.c).collect::<String>())
.collect::<Vec<_>>()
.join("\n")
}
pub fn row_contents(&self, row: u16) -> String {
if row < self.height {
self.state.cells[row as usize].iter().map(|cell| cell.c).collect()
} else {
String::new()
}
}
pub fn text_at(&self, row: u16, col: u16) -> Option<char> {
if row < self.height && col < self.width {
Some(self.state.cells[row as usize][col as usize].c)
} else {
None
}
}
pub fn get_cell(&self, row: u16, col: u16) -> Option<&Cell> {
if row < self.height && col < self.width {
Some(&self.state.cells[row as usize][col as usize])
} else {
None
}
}
pub fn cursor_position(&self) -> (u16, u16) {
self.state.cursor_pos
}
pub fn size(&self) -> (u16, u16) {
(self.width, self.height)
}
pub fn sixel_regions(&self) -> &[SixelRegion] {
&self.state.sixel_regions
}
pub fn has_sixel_at(&self, row: u16, col: u16) -> bool {
self.state.sixel_regions.iter().any(|region| {
region.start_row == row && region.start_col == col
})
}
pub fn debug_contents(&self) -> String {
self.contents()
}
pub fn contains(&self, text: &str) -> bool {
self.contents().contains(text)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_create_screen() {
let screen = ScreenState::new(80, 24);
assert_eq!(screen.size(), (80, 24));
}
#[test]
fn test_feed_simple_text() {
let mut screen = ScreenState::new(80, 24);
screen.feed(b"Hello, World!");
assert!(screen.contents().contains("Hello, World!"));
}
#[test]
fn test_cursor_position() {
let mut screen = ScreenState::new(80, 24);
assert_eq!(screen.cursor_position(), (0, 0));
screen.feed(b"\x1b[5;10H");
let (row, col) = screen.cursor_position();
assert_eq!(row, 4); assert_eq!(col, 9); }
#[test]
fn test_text_at() {
let mut screen = ScreenState::new(80, 24);
screen.feed(b"Test");
assert_eq!(screen.text_at(0, 0), Some('T'));
assert_eq!(screen.text_at(0, 1), Some('e'));
assert_eq!(screen.text_at(0, 2), Some('s'));
assert_eq!(screen.text_at(0, 3), Some('t'));
assert_eq!(screen.text_at(0, 4), Some(' '));
assert_eq!(screen.text_at(100, 100), None);
}
#[test]
fn test_parse_raster_full() {
let state = TerminalState::new(80, 24);
let data = b"\"1;1;100;50#0;2;100;100;100#0~";
assert_eq!(state.parse_raster_attributes(data), Some((100, 50)));
let data = b"\"2;1;200;100#0~";
assert_eq!(state.parse_raster_attributes(data), Some((200, 100)));
}
#[test]
fn test_parse_raster_partial() {
let state = TerminalState::new(80, 24);
let data = b"\"100;50#0~";
assert_eq!(state.parse_raster_attributes(data), Some((100, 50)));
let data = b"\"80;60#0;2;0;0;0";
assert_eq!(state.parse_raster_attributes(data), Some((80, 60)));
}
#[test]
fn test_parse_raster_malformed() {
let state = TerminalState::new(80, 24);
assert_eq!(state.parse_raster_attributes(b"#0~"), None);
assert_eq!(state.parse_raster_attributes(b""), None);
assert_eq!(state.parse_raster_attributes(&[0xFF, 0xFE]), None);
assert_eq!(state.parse_raster_attributes(b"\"100"), None);
assert_eq!(state.parse_raster_attributes(b"\"1;1;100"), None);
assert_eq!(state.parse_raster_attributes(b"\"1;1;0;50"), None, "Should reject zero width");
assert_eq!(state.parse_raster_attributes(b"\"1;1;100;0"), None, "Should reject zero height");
assert_eq!(state.parse_raster_attributes(b"\"0;0"), None, "Should reject zero dimensions in abbreviated format");
assert_eq!(state.parse_raster_attributes(b"\"abc;def"), None);
assert_eq!(state.parse_raster_attributes(b"\"1;1;abc;def"), Some((1, 1)));
}
#[test]
fn test_parse_raster_edge_cases() {
let state = TerminalState::new(80, 24);
let data = b"\"1;1;4096;2048#0~";
assert_eq!(state.parse_raster_attributes(data), Some((4096, 2048)));
let data = b"\"1;1;1;1#0~";
assert_eq!(state.parse_raster_attributes(data), Some((1, 1)));
let data = b"\"1;1;100;50 \t#0~";
assert_eq!(state.parse_raster_attributes(data), Some((100, 50)));
}
#[test]
fn test_pixels_to_cells() {
assert_eq!(TerminalState::pixels_to_cells(80, 60), (10, 10));
assert_eq!(TerminalState::pixels_to_cells(0, 0), (0, 0));
assert_eq!(TerminalState::pixels_to_cells(800, 600), (100, 100));
assert_eq!(TerminalState::pixels_to_cells(16, 12), (2, 2));
assert_eq!(TerminalState::pixels_to_cells(81, 61), (11, 11));
assert_eq!(TerminalState::pixels_to_cells(100, 50), (13, 9));
assert_eq!(TerminalState::pixels_to_cells(1, 1), (1, 1));
assert_eq!(TerminalState::pixels_to_cells(640, 480), (80, 80));
assert_eq!(TerminalState::pixels_to_cells(320, 240), (40, 40));
}
#[test]
fn test_sixel_region_tracking() {
let mut screen = ScreenState::new(80, 24);
screen.feed(b"\x1b[5;10H"); screen.feed(b"\x1bPq"); screen.feed(b"\"1;1;100;50"); screen.feed(b"#0;2;100;100;100"); screen.feed(b"#0~~@@"); screen.feed(b"\x1b\\");
let regions = screen.sixel_regions();
assert_eq!(regions.len(), 1, "Should capture exactly one Sixel region");
let region = ®ions[0];
assert_eq!(region.start_row, 4, "Row should be 4 (0-based from 5)");
assert_eq!(region.start_col, 9, "Col should be 9 (0-based from 10)");
assert_eq!(region.width, 100, "Width should be 100 pixels");
assert_eq!(region.height, 50, "Height should be 50 pixels");
assert!(!region.data.is_empty(), "Data should be captured");
assert!(screen.has_sixel_at(4, 9), "Should detect Sixel at position");
assert!(!screen.has_sixel_at(0, 0), "Should not detect Sixel at wrong position");
}
#[test]
fn test_multiple_sixel_regions() {
let mut screen = ScreenState::new(100, 30);
screen.feed(b"\x1b[5;5H\x1bPq\"1;1;80;60#0~\x1b\\");
screen.feed(b"\x1b[15;50H\x1bPq\"1;1;100;80#0~\x1b\\");
let regions = screen.sixel_regions();
assert_eq!(regions.len(), 2, "Should capture both Sixel regions");
assert_eq!(regions[0].start_row, 4);
assert_eq!(regions[0].start_col, 4);
assert_eq!(regions[0].width, 80);
assert_eq!(regions[0].height, 60);
assert_eq!(regions[1].start_row, 14);
assert_eq!(regions[1].start_col, 49);
assert_eq!(regions[1].width, 100);
assert_eq!(regions[1].height, 80);
}
#[test]
fn test_sixel_without_raster_attributes() {
let mut screen = ScreenState::new(80, 24);
screen.feed(b"\x1b[10;10H\x1bPq#0~\x1b\\");
let regions = screen.sixel_regions();
assert_eq!(regions.len(), 1, "Should still capture region");
let region = ®ions[0];
assert_eq!(region.width, 0, "Width should be 0 without raster attributes");
assert_eq!(region.height, 0, "Height should be 0 without raster attributes");
}
#[test]
fn test_sixel_abbreviated_format() {
let mut screen = ScreenState::new(80, 24);
screen.feed(b"\x1b[1;1H\x1bPq\"200;150#0~\x1b\\");
let regions = screen.sixel_regions();
assert_eq!(regions.len(), 1);
let region = ®ions[0];
assert_eq!(region.width, 200);
assert_eq!(region.height, 150);
}
}