#![forbid(unsafe_code)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct Rgb {
pub r: u8,
pub g: u8,
pub b: u8,
}
impl Rgb {
pub const fn new(r: u8, g: u8, b: u8) -> Self {
Self { r, g, b }
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct ModelStyle {
pub fg: Option<Rgb>,
pub bg: Option<Rgb>,
pub bold: bool,
pub dim: bool,
pub italic: bool,
pub underline: bool,
pub blink: bool,
pub reverse: bool,
pub strikethrough: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ModelCell {
pub ch: char,
pub style: ModelStyle,
pub link: Option<String>,
}
impl Default for ModelCell {
fn default() -> Self {
Self {
ch: ' ',
style: ModelStyle::default(),
link: None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum EraseMode {
ToEnd,
ToStart,
All,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum ParserState {
Ground,
Escape,
Csi,
Osc,
}
pub struct TerminalModel {
grid: Vec<Vec<ModelCell>>,
cursor_x: u16,
cursor_y: u16,
current_style: ModelStyle,
current_link: Option<String>,
width: u16,
height: u16,
state: ParserState,
csi_params: Vec<u16>,
csi_current: u16,
osc_buffer: Vec<u8>,
}
impl TerminalModel {
pub fn new(width: u16, height: u16) -> Self {
let grid = (0..height)
.map(|_| (0..width).map(|_| ModelCell::default()).collect())
.collect();
Self {
grid,
cursor_x: 0,
cursor_y: 0,
current_style: ModelStyle::default(),
current_link: None,
width,
height,
state: ParserState::Ground,
csi_params: Vec::new(),
csi_current: 0,
osc_buffer: Vec::new(),
}
}
#[inline]
pub fn width(&self) -> u16 {
self.width
}
#[inline]
pub fn height(&self) -> u16 {
self.height
}
#[inline]
pub fn cursor(&self) -> (u16, u16) {
(self.cursor_x, self.cursor_y)
}
pub fn char_at(&self, x: u16, y: u16) -> char {
self.cell_at(x, y).map_or(' ', |c| c.ch)
}
pub fn style_at(&self, x: u16, y: u16) -> ModelStyle {
self.cell_at(x, y)
.map_or_else(ModelStyle::default, |c| c.style.clone())
}
pub fn link_at(&self, x: u16, y: u16) -> Option<String> {
self.cell_at(x, y).and_then(|c| c.link.clone())
}
fn cell_at(&self, x: u16, y: u16) -> Option<&ModelCell> {
self.grid
.get(y as usize)
.and_then(|row| row.get(x as usize))
}
pub fn row_text(&self, y: u16) -> String {
if let Some(row) = self.grid.get(y as usize) {
let s: String = row.iter().map(|c| c.ch).collect();
s.trim_end().to_string()
} else {
String::new()
}
}
pub fn screen_text(&self) -> String {
let mut lines: Vec<String> = (0..self.height).map(|y| self.row_text(y)).collect();
while lines.last().is_some_and(|l| l.is_empty()) {
lines.pop();
}
lines.join("\n")
}
pub fn dump(&self) -> String {
let mut out = String::new();
for (y, row) in self.grid.iter().enumerate() {
out.push_str(&format!("{y:3}| "));
for cell in row {
out.push(cell.ch);
}
out.push('\n');
}
out.push_str(&format!("Cursor: ({}, {})\n", self.cursor_x, self.cursor_y));
out.push_str(&format!("Style: {:?}\n", self.current_style));
out
}
pub fn feed(&mut self, bytes: &[u8]) {
for &byte in bytes {
self.advance(byte);
}
}
pub fn feed_str(&mut self, s: &str) {
self.feed(s.as_bytes());
}
fn advance(&mut self, byte: u8) {
match self.state {
ParserState::Ground => self.ground(byte),
ParserState::Escape => self.escape(byte),
ParserState::Csi => self.csi(byte),
ParserState::Osc => self.osc(byte),
}
}
fn ground(&mut self, byte: u8) {
match byte {
0x1b => {
self.state = ParserState::Escape;
}
0x0a if self.cursor_y + 1 < self.height => {
self.cursor_y += 1;
}
0x0d => {
self.cursor_x = 0;
}
0x08 => {
self.cursor_x = self.cursor_x.saturating_sub(1);
}
0x09 => {
self.cursor_x = ((self.cursor_x / 8) + 1) * 8;
if self.cursor_x >= self.width {
self.cursor_x = self.width.saturating_sub(1);
}
}
0x20..=0x7e => {
self.put_char(byte as char);
}
0xc0..=0xff => {
self.put_char('?');
}
_ => {
}
}
}
fn escape(&mut self, byte: u8) {
match byte {
b'[' => {
self.state = ParserState::Csi;
self.csi_params.clear();
self.csi_current = 0;
}
b']' => {
self.state = ParserState::Osc;
self.osc_buffer.clear();
}
_ => {
self.state = ParserState::Ground;
}
}
}
fn csi(&mut self, byte: u8) {
match byte {
b'0'..=b'9' => {
self.csi_current = self.csi_current.saturating_mul(10) + (byte - b'0') as u16;
}
b';' => {
self.csi_params.push(self.csi_current);
self.csi_current = 0;
}
b'?' => {
}
b'A' => {
self.csi_params.push(self.csi_current);
let n = self.param(0, 1);
self.cursor_y = self.cursor_y.saturating_sub(n);
self.state = ParserState::Ground;
}
b'B' => {
self.csi_params.push(self.csi_current);
let n = self.param(0, 1);
self.cursor_y = (self.cursor_y + n).min(self.height.saturating_sub(1));
self.state = ParserState::Ground;
}
b'C' => {
self.csi_params.push(self.csi_current);
let n = self.param(0, 1);
self.cursor_x = (self.cursor_x + n).min(self.width.saturating_sub(1));
self.state = ParserState::Ground;
}
b'D' => {
self.csi_params.push(self.csi_current);
let n = self.param(0, 1);
self.cursor_x = self.cursor_x.saturating_sub(n);
self.state = ParserState::Ground;
}
b'H' | b'f' => {
self.csi_params.push(self.csi_current);
let row = self.param(0, 1);
let col = self.param(1, 1);
self.cursor_y = row.saturating_sub(1).min(self.height.saturating_sub(1));
self.cursor_x = col.saturating_sub(1).min(self.width.saturating_sub(1));
self.state = ParserState::Ground;
}
b'J' => {
self.csi_params.push(self.csi_current);
let mode = match self.param(0, 0) {
0 => EraseMode::ToEnd,
1 => EraseMode::ToStart,
_ => EraseMode::All,
};
self.erase_display(mode);
self.state = ParserState::Ground;
}
b'K' => {
self.csi_params.push(self.csi_current);
let mode = match self.param(0, 0) {
0 => EraseMode::ToEnd,
1 => EraseMode::ToStart,
_ => EraseMode::All,
};
self.erase_line(mode);
self.state = ParserState::Ground;
}
b'm' => {
self.csi_params.push(self.csi_current);
self.apply_sgr();
self.state = ParserState::Ground;
}
b'h' | b'l' | b's' | b'u' => {
self.state = ParserState::Ground;
}
_ => {
self.state = ParserState::Ground;
}
}
}
fn osc(&mut self, byte: u8) {
match byte {
0x07 => {
self.process_osc();
self.state = ParserState::Ground;
}
0x1b => {
self.process_osc();
self.state = ParserState::Escape;
}
_ => {
self.osc_buffer.push(byte);
}
}
}
fn process_osc(&mut self) {
let osc_str = String::from_utf8_lossy(&self.osc_buffer).to_string();
if let Some(rest) = osc_str.strip_prefix("8;") {
if let Some((_params, url)) = rest.split_once(';') {
if url.is_empty() {
self.current_link = None;
} else {
self.current_link = Some(url.to_string());
}
}
}
}
fn param(&self, index: usize, default: u16) -> u16 {
self.csi_params.get(index).copied().unwrap_or(default)
}
fn put_char(&mut self, ch: char) {
let x = self.cursor_x as usize;
let y = self.cursor_y as usize;
if y < self.grid.len() && x < self.grid[y].len() {
self.grid[y][x] = ModelCell {
ch,
style: self.current_style.clone(),
link: self.current_link.clone(),
};
}
self.cursor_x += 1;
if self.cursor_x >= self.width {
self.cursor_x = 0;
if self.cursor_y + 1 < self.height {
self.cursor_y += 1;
}
}
}
fn erase_line(&mut self, mode: EraseMode) {
let y = self.cursor_y as usize;
if y >= self.grid.len() {
return;
}
let (start, end) = match mode {
EraseMode::ToEnd => (self.cursor_x as usize, self.width as usize),
EraseMode::ToStart => (0, self.cursor_x as usize + 1),
EraseMode::All => (0, self.width as usize),
};
for x in start..end.min(self.grid[y].len()) {
self.grid[y][x] = ModelCell::default();
}
}
fn erase_display(&mut self, mode: EraseMode) {
match mode {
EraseMode::ToEnd => {
self.erase_line(EraseMode::ToEnd);
for y in (self.cursor_y + 1) as usize..self.height as usize {
for cell in &mut self.grid[y] {
*cell = ModelCell::default();
}
}
}
EraseMode::ToStart => {
for y in 0..self.cursor_y as usize {
for cell in &mut self.grid[y] {
*cell = ModelCell::default();
}
}
self.erase_line(EraseMode::ToStart);
}
EraseMode::All => {
for row in &mut self.grid {
for cell in row {
*cell = ModelCell::default();
}
}
}
}
}
fn apply_sgr(&mut self) {
if self.csi_params.is_empty() || (self.csi_params.len() == 1 && self.csi_params[0] == 0) {
self.current_style = ModelStyle::default();
return;
}
let mut i = 0;
while i < self.csi_params.len() {
match self.csi_params[i] {
0 => self.current_style = ModelStyle::default(),
1 => self.current_style.bold = true,
2 => self.current_style.dim = true,
3 => self.current_style.italic = true,
4 => self.current_style.underline = true,
5 => self.current_style.blink = true,
7 => self.current_style.reverse = true,
9 => self.current_style.strikethrough = true,
22 => {
self.current_style.bold = false;
self.current_style.dim = false;
}
23 => self.current_style.italic = false,
24 => self.current_style.underline = false,
25 => self.current_style.blink = false,
27 => self.current_style.reverse = false,
29 => self.current_style.strikethrough = false,
38 if i + 4 < self.csi_params.len() && self.csi_params[i + 1] == 2 => {
self.current_style.fg = Some(Rgb::new(
self.csi_params[i + 2] as u8,
self.csi_params[i + 3] as u8,
self.csi_params[i + 4] as u8,
));
i += 4;
}
39 => self.current_style.fg = None,
48 if i + 4 < self.csi_params.len() && self.csi_params[i + 1] == 2 => {
self.current_style.bg = Some(Rgb::new(
self.csi_params[i + 2] as u8,
self.csi_params[i + 3] as u8,
self.csi_params[i + 4] as u8,
));
i += 4;
}
49 => self.current_style.bg = None,
_ => {}
}
i += 1;
}
}
}
#[derive(Debug, Clone)]
pub struct CellDiff {
pub x: u16,
pub y: u16,
pub expected: ModelCell,
pub actual: ModelCell,
}
impl std::fmt::Display for CellDiff {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"({}, {}): expected '{}' got '{}'",
self.x, self.y, self.expected.ch, self.actual.ch
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn new_model_empty() {
let m = TerminalModel::new(10, 5);
assert_eq!(m.width(), 10);
assert_eq!(m.height(), 5);
assert_eq!(m.cursor(), (0, 0));
assert_eq!(m.char_at(0, 0), ' ');
}
#[test]
fn print_text() {
let mut m = TerminalModel::new(20, 5);
m.feed(b"Hello");
assert_eq!(m.char_at(0, 0), 'H');
assert_eq!(m.char_at(1, 0), 'e');
assert_eq!(m.char_at(2, 0), 'l');
assert_eq!(m.char_at(3, 0), 'l');
assert_eq!(m.char_at(4, 0), 'o');
assert_eq!(m.cursor(), (5, 0));
}
#[test]
fn cursor_wraps_at_edge() {
let mut m = TerminalModel::new(5, 3);
m.feed(b"ABCDE");
assert_eq!(m.cursor(), (0, 1));
assert_eq!(m.char_at(0, 0), 'A');
assert_eq!(m.char_at(4, 0), 'E');
}
#[test]
fn newline() {
let mut m = TerminalModel::new(20, 5);
m.feed(b"AB\nCD");
assert_eq!(m.char_at(0, 0), 'A');
assert_eq!(m.char_at(1, 0), 'B');
assert_eq!(m.char_at(2, 1), 'C');
assert_eq!(m.char_at(3, 1), 'D');
}
#[test]
fn carriage_return() {
let mut m = TerminalModel::new(20, 5);
m.feed(b"Hello\r");
assert_eq!(m.cursor(), (0, 0));
m.feed(b"World");
assert_eq!(m.row_text(0), "World");
}
#[test]
fn crlf() {
let mut m = TerminalModel::new(20, 5);
m.feed(b"Line1\r\nLine2");
assert_eq!(m.row_text(0), "Line1");
assert_eq!(m.row_text(1), "Line2");
}
#[test]
fn cursor_position_cup() {
let mut m = TerminalModel::new(20, 10);
m.feed(b"\x1b[5;10H");
assert_eq!(m.cursor(), (9, 4));
}
#[test]
fn cursor_position_default() {
let mut m = TerminalModel::new(20, 10);
m.feed(b"\x1b[H");
assert_eq!(m.cursor(), (0, 0));
}
#[test]
fn cursor_movement() {
let mut m = TerminalModel::new(20, 10);
m.feed(b"\x1b[5;10H"); m.feed(b"\x1b[2A"); assert_eq!(m.cursor(), (9, 2));
m.feed(b"\x1b[3B"); assert_eq!(m.cursor(), (9, 5));
m.feed(b"\x1b[4C"); assert_eq!(m.cursor(), (13, 5));
m.feed(b"\x1b[2D"); assert_eq!(m.cursor(), (11, 5));
}
#[test]
fn cursor_movement_clamps() {
let mut m = TerminalModel::new(10, 5);
m.feed(b"\x1b[100A"); assert_eq!(m.cursor(), (0, 0));
m.feed(b"\x1b[100B"); assert_eq!(m.cursor(), (0, 4));
m.feed(b"\x1b[100D"); assert_eq!(m.cursor(), (0, 4));
m.feed(b"\x1b[100C"); assert_eq!(m.cursor(), (9, 4));
}
#[test]
fn sgr_bold() {
let mut m = TerminalModel::new(20, 5);
m.feed(b"\x1b[1mBold\x1b[0m");
assert!(m.style_at(0, 0).bold);
assert!(m.style_at(3, 0).bold);
}
#[test]
fn sgr_reset() {
let mut m = TerminalModel::new(20, 5);
m.feed(b"\x1b[1;3mBI\x1b[0mN");
assert!(m.style_at(0, 0).bold);
assert!(m.style_at(0, 0).italic);
assert!(!m.style_at(2, 0).bold);
assert!(!m.style_at(2, 0).italic);
}
#[test]
fn sgr_truecolor_fg() {
let mut m = TerminalModel::new(20, 5);
m.feed(b"\x1b[38;2;255;0;128mX\x1b[0m");
let style = m.style_at(0, 0);
assert_eq!(style.fg, Some(Rgb::new(255, 0, 128)));
}
#[test]
fn sgr_truecolor_bg() {
let mut m = TerminalModel::new(20, 5);
m.feed(b"\x1b[48;2;10;20;30mX\x1b[0m");
let style = m.style_at(0, 0);
assert_eq!(style.bg, Some(Rgb::new(10, 20, 30)));
}
#[test]
fn sgr_combined() {
let mut m = TerminalModel::new(20, 5);
m.feed(b"\x1b[1;3;4;38;2;255;128;0mX\x1b[0m");
let style = m.style_at(0, 0);
assert!(style.bold);
assert!(style.italic);
assert!(style.underline);
assert_eq!(style.fg, Some(Rgb::new(255, 128, 0)));
}
#[test]
fn sgr_selective_reset() {
let mut m = TerminalModel::new(20, 5);
m.feed(b"\x1b[1;3mX\x1b[23mY");
let x_style = m.style_at(0, 0);
assert!(x_style.bold);
assert!(x_style.italic);
let y_style = m.style_at(1, 0);
assert!(y_style.bold);
assert!(!y_style.italic);
}
#[test]
fn erase_line_to_end() {
let mut m = TerminalModel::new(10, 3);
m.feed(b"ABCDEFGHIJ");
m.feed(b"\x1b[1;4H"); m.feed(b"\x1b[0K"); assert_eq!(m.row_text(0), "ABC");
}
#[test]
fn erase_line_to_start() {
let mut m = TerminalModel::new(10, 3);
m.feed(b"ABCDEFGHIJ");
m.feed(b"\x1b[1;4H"); m.feed(b"\x1b[1K"); assert_eq!(m.char_at(0, 0), ' ');
assert_eq!(m.char_at(1, 0), ' ');
assert_eq!(m.char_at(2, 0), ' ');
assert_eq!(m.char_at(3, 0), ' ');
assert_eq!(m.char_at(4, 0), 'E');
}
#[test]
fn erase_line_all() {
let mut m = TerminalModel::new(10, 3);
m.feed(b"ABCDEFGHIJ");
m.feed(b"\x1b[1;4H");
m.feed(b"\x1b[2K"); assert_eq!(m.row_text(0), "");
}
#[test]
fn erase_display_to_end() {
let mut m = TerminalModel::new(10, 3);
m.feed(b"Line1 ");
m.feed(b"Line2 ");
m.feed(b"Line3 ");
m.feed(b"\x1b[2;1H"); m.feed(b"\x1b[0J"); assert_eq!(m.row_text(0), "Line1");
assert_eq!(m.row_text(1), "");
assert_eq!(m.row_text(2), "");
}
#[test]
fn erase_display_all() {
let mut m = TerminalModel::new(10, 3);
m.feed(b"XXXXXXXXXX");
m.feed(b"YYYYYYYYYY");
m.feed(b"\x1b[2J");
assert_eq!(m.screen_text(), "");
}
#[test]
fn osc8_hyperlink() {
let mut m = TerminalModel::new(30, 3);
m.feed(b"\x1b]8;;https://example.com\x07Link\x1b]8;;\x07");
assert_eq!(m.char_at(0, 0), 'L');
assert_eq!(m.link_at(0, 0), Some("https://example.com".to_string()));
assert_eq!(m.link_at(3, 0), Some("https://example.com".to_string()));
assert_eq!(m.link_at(4, 0), None);
}
#[test]
fn osc8_with_st_terminator() {
let mut m = TerminalModel::new(30, 3);
m.feed(b"\x1b]8;;http://test.com\x1b\\Link\x1b]8;;\x1b\\");
assert_eq!(m.link_at(0, 0), Some("http://test.com".to_string()));
}
#[test]
fn screen_text_trims() {
let mut m = TerminalModel::new(10, 3);
m.feed(b"Hello");
let text = m.screen_text();
assert_eq!(text, "Hello");
}
#[test]
fn dump_format() {
let mut m = TerminalModel::new(5, 2);
m.feed(b"Hi");
let dump = m.dump();
assert!(dump.contains("Hi"));
assert!(dump.contains("Cursor:"));
}
#[test]
fn tab_stop() {
let mut m = TerminalModel::new(20, 3);
m.feed(b"A\tB");
assert_eq!(m.char_at(0, 0), 'A');
assert_eq!(m.char_at(8, 0), 'B');
}
#[test]
fn backspace() {
let mut m = TerminalModel::new(20, 3);
m.feed(b"AB\x08C");
assert_eq!(m.char_at(0, 0), 'A');
assert_eq!(m.char_at(1, 0), 'C');
}
#[test]
fn feed_str_convenience() {
let mut m = TerminalModel::new(20, 3);
m.feed_str("Hello");
assert_eq!(m.row_text(0), "Hello");
}
#[test]
fn sgr_all_attributes() {
let mut m = TerminalModel::new(20, 3);
m.feed(b"\x1b[1;2;3;4;5;7;9mX\x1b[0m");
let s = m.style_at(0, 0);
assert!(s.bold);
assert!(s.dim);
assert!(s.italic);
assert!(s.underline);
assert!(s.blink);
assert!(s.reverse);
assert!(s.strikethrough);
}
#[test]
fn sgr_reset_individual() {
let mut m = TerminalModel::new(20, 3);
m.feed(b"\x1b[1;3;4;9mX\x1b[22;23;24;29mY");
let x = m.style_at(0, 0);
assert!(x.bold);
assert!(x.italic);
assert!(x.underline);
assert!(x.strikethrough);
let y = m.style_at(1, 0);
assert!(!y.bold);
assert!(!y.italic);
assert!(!y.underline);
assert!(!y.strikethrough);
}
#[test]
fn sgr_default_fg_bg() {
let mut m = TerminalModel::new(20, 3);
m.feed(b"\x1b[38;2;255;0;0mR\x1b[39mX");
assert_eq!(m.style_at(0, 0).fg, Some(Rgb::new(255, 0, 0)));
assert_eq!(m.style_at(1, 0).fg, None);
m.feed(b"\x1b[48;2;0;255;0mG\x1b[49mX");
assert_eq!(m.style_at(2, 0).bg, Some(Rgb::new(0, 255, 0)));
assert_eq!(m.style_at(3, 0).bg, None);
}
#[test]
fn multiple_lines_rendering() {
let mut m = TerminalModel::new(20, 5);
m.feed(b"\x1b[1;1HLine 1");
m.feed(b"\x1b[2;1HLine 2");
m.feed(b"\x1b[3;1HLine 3");
assert_eq!(m.row_text(0), "Line 1");
assert_eq!(m.row_text(1), "Line 2");
assert_eq!(m.row_text(2), "Line 3");
}
#[test]
fn styled_text_rendering() {
let mut m = TerminalModel::new(30, 3);
m.feed(b"\x1b[1;38;2;255;0;0mERROR\x1b[0m: something");
assert!(m.style_at(0, 0).bold);
assert_eq!(m.style_at(0, 0).fg, Some(Rgb::new(255, 0, 0)));
assert!(!m.style_at(5, 0).bold);
assert_eq!(m.style_at(5, 0).fg, None);
assert_eq!(m.row_text(0), "ERROR: something");
}
#[test]
fn cell_diff_display() {
let diff = CellDiff {
x: 3,
y: 7,
expected: ModelCell {
ch: 'A',
style: ModelStyle::default(),
link: None,
},
actual: ModelCell {
ch: 'B',
style: ModelStyle::default(),
link: None,
},
};
let s = format!("{diff}");
assert!(s.contains("(3, 7)"));
assert!(s.contains("expected 'A'"));
assert!(s.contains("got 'B'"));
}
#[test]
fn cell_diff_debug_clone() {
let diff = CellDiff {
x: 0,
y: 0,
expected: ModelCell::default(),
actual: ModelCell::default(),
};
let debug = format!("{diff:?}");
assert!(debug.contains("CellDiff"));
let cloned = diff.clone();
assert_eq!(cloned.x, 0);
assert_eq!(cloned.y, 0);
}
#[test]
fn rgb_new_and_default() {
let rgb = Rgb::new(10, 20, 30);
assert_eq!(rgb.r, 10);
assert_eq!(rgb.g, 20);
assert_eq!(rgb.b, 30);
let def = Rgb::default();
assert_eq!(def, Rgb::new(0, 0, 0));
}
#[test]
fn rgb_debug_copy_eq() {
let a = Rgb::new(255, 128, 0);
let b = a; assert_eq!(a, b);
let debug = format!("{a:?}");
assert!(debug.contains("Rgb"));
}
#[test]
fn model_style_default() {
let s = ModelStyle::default();
assert!(s.fg.is_none());
assert!(s.bg.is_none());
assert!(!s.bold);
assert!(!s.dim);
assert!(!s.italic);
assert!(!s.underline);
assert!(!s.blink);
assert!(!s.reverse);
assert!(!s.strikethrough);
}
#[test]
fn model_style_debug_clone_eq() {
let s = ModelStyle {
bold: true,
fg: Some(Rgb::new(1, 2, 3)),
..ModelStyle::default()
};
let cloned = s.clone();
assert_eq!(s, cloned);
let debug = format!("{s:?}");
assert!(debug.contains("ModelStyle"));
}
#[test]
fn model_cell_default() {
let c = ModelCell::default();
assert_eq!(c.ch, ' ');
assert_eq!(c.style, ModelStyle::default());
assert!(c.link.is_none());
}
#[test]
fn model_cell_debug_clone_eq() {
let c = ModelCell {
ch: 'X',
style: ModelStyle::default(),
link: Some("http://test.com".to_string()),
};
let cloned = c.clone();
assert_eq!(c, cloned);
let debug = format!("{c:?}");
assert!(debug.contains("ModelCell"));
}
#[test]
fn cursor_wrap_at_bottom_edge() {
let mut m = TerminalModel::new(3, 2);
m.feed(b"ABCDE");
assert_eq!(m.cursor(), (2, 1));
m.feed(b"F");
assert_eq!(m.cursor(), (0, 1));
assert_eq!(m.char_at(2, 1), 'F');
}
#[test]
fn lf_at_bottom_of_screen() {
let mut m = TerminalModel::new(10, 2);
m.feed(b"\x1b[2;1H"); assert_eq!(m.cursor(), (0, 1));
m.feed(b"\n"); assert_eq!(m.cursor(), (0, 1));
}
#[test]
fn bs_at_column_zero() {
let mut m = TerminalModel::new(10, 3);
m.feed(b"\x08"); assert_eq!(m.cursor(), (0, 0));
}
#[test]
fn tab_near_end_of_line() {
let mut m = TerminalModel::new(10, 3);
m.feed(b"1234567\t"); assert_eq!(m.cursor(), (8, 0));
m.feed(b"\r12345678\t"); assert_eq!(m.cursor(), (9, 0));
}
#[test]
fn tab_already_at_end() {
let mut m = TerminalModel::new(8, 3);
m.feed(b"12345678"); m.feed(b"\x1b[1;8H"); m.feed(b"\t"); assert_eq!(m.cursor().0, 7);
}
#[test]
fn cup_f_variant() {
let mut m = TerminalModel::new(20, 10);
m.feed(b"\x1b[3;5f"); assert_eq!(m.cursor(), (4, 2));
}
#[test]
fn cup_clamps_to_screen_bounds() {
let mut m = TerminalModel::new(10, 5);
m.feed(b"\x1b[100;200H");
assert_eq!(m.cursor(), (9, 4));
}
#[test]
fn cup_zero_params_default_to_1_1() {
let mut m = TerminalModel::new(10, 5);
m.feed(b"\x1b[5;5H"); m.feed(b"\x1b[0;0H"); assert_eq!(m.cursor(), (0, 0));
}
#[test]
fn erase_display_to_start() {
let mut m = TerminalModel::new(10, 3);
m.feed(b"Line1 ");
m.feed(b"Line2 ");
m.feed(b"Line3 ");
m.feed(b"\x1b[2;5H"); m.feed(b"\x1b[1J");
assert_eq!(m.row_text(0), "");
assert_eq!(m.char_at(0, 1), ' ');
assert_eq!(m.char_at(3, 1), ' ');
assert_eq!(m.char_at(4, 1), ' ');
assert_eq!(m.row_text(2), "Line3");
}
#[test]
fn sgr_truecolor_insufficient_params_fg() {
let mut m = TerminalModel::new(10, 3);
m.feed(b"\x1b[38;2;255mX");
assert!(m.style_at(0, 0).fg.is_none());
}
#[test]
fn sgr_truecolor_insufficient_params_bg() {
let mut m = TerminalModel::new(10, 3);
m.feed(b"\x1b[48;2mX");
assert!(m.style_at(0, 0).bg.is_none());
}
#[test]
fn sgr_empty_is_reset() {
let mut m = TerminalModel::new(10, 3);
m.feed(b"\x1b[1mA\x1b[mB"); assert!(m.style_at(0, 0).bold);
assert!(!m.style_at(1, 0).bold);
}
#[test]
fn sgr_unknown_code_ignored() {
let mut m = TerminalModel::new(10, 3);
m.feed(b"\x1b[1;99;3mX"); let s = m.style_at(0, 0);
assert!(s.bold);
assert!(s.italic);
}
#[test]
fn multi_byte_utf8_treated_as_question() {
let mut m = TerminalModel::new(10, 3);
m.feed(&[0xC3, 0xA9]); assert_eq!(m.char_at(0, 0), '?');
}
#[test]
fn char_at_out_of_bounds() {
let m = TerminalModel::new(5, 3);
assert_eq!(m.char_at(10, 0), ' ');
assert_eq!(m.char_at(0, 10), ' ');
assert_eq!(m.char_at(100, 100), ' ');
}
#[test]
fn style_at_out_of_bounds() {
let m = TerminalModel::new(5, 3);
let s = m.style_at(100, 100);
assert_eq!(s, ModelStyle::default());
}
#[test]
fn link_at_out_of_bounds() {
let m = TerminalModel::new(5, 3);
assert!(m.link_at(100, 100).is_none());
}
#[test]
fn row_text_out_of_bounds() {
let m = TerminalModel::new(5, 3);
assert_eq!(m.row_text(100), "");
}
#[test]
fn screen_text_all_empty() {
let m = TerminalModel::new(5, 3);
assert_eq!(m.screen_text(), "");
}
#[test]
fn screen_text_trailing_empty_lines_trimmed() {
let mut m = TerminalModel::new(10, 5);
m.feed(b"Hello");
m.feed(b"\x1b[2;1HWorld");
let text = m.screen_text();
assert_eq!(text, "Hello\nWorld");
}
#[test]
fn unknown_escape_sequence_returns_to_ground() {
let mut m = TerminalModel::new(10, 3);
m.feed(b"\x1b)A"); assert_eq!(m.char_at(0, 0), 'A');
}
#[test]
fn unknown_csi_final_byte_returns_to_ground() {
let mut m = TerminalModel::new(10, 3);
m.feed(b"\x1b[5ZA"); assert_eq!(m.char_at(0, 0), 'A');
}
#[test]
fn csi_private_mode_prefix_ignored() {
let mut m = TerminalModel::new(10, 3);
m.feed(b"\x1b[?2026hA");
assert_eq!(m.char_at(0, 0), 'A');
}
#[test]
fn csi_save_restore_cursor_ignored() {
let mut m = TerminalModel::new(10, 3);
m.feed(b"AB");
m.feed(b"\x1b[s"); m.feed(b"CD");
m.feed(b"\x1b[u"); m.feed(b"EF");
assert_eq!(m.row_text(0), "ABCDEF");
}
#[test]
fn osc8_link_toggle() {
let mut m = TerminalModel::new(30, 3);
m.feed(b"\x1b]8;;http://a.com\x07A\x1b]8;;\x07B\x1b]8;;http://b.com\x07C\x1b]8;;\x07");
assert_eq!(m.link_at(0, 0), Some("http://a.com".to_string()));
assert!(m.link_at(1, 0).is_none());
assert_eq!(m.link_at(2, 0), Some("http://b.com".to_string()));
}
#[test]
fn cr_lf_sequence() {
let mut m = TerminalModel::new(10, 5);
m.feed(b"ABC\r\nDEF\r\nGHI");
assert_eq!(m.row_text(0), "ABC");
assert_eq!(m.row_text(1), "DEF");
assert_eq!(m.row_text(2), "GHI");
}
#[test]
fn multiple_backspaces() {
let mut m = TerminalModel::new(10, 3);
m.feed(b"ABCDE\x08\x08\x08XY");
assert_eq!(m.row_text(0), "ABXYE");
}
#[test]
fn cursor_movement_explicit_one() {
let mut m = TerminalModel::new(20, 10);
m.feed(b"\x1b[5;10H"); m.feed(b"\x1b[1A"); assert_eq!(m.cursor(), (9, 3));
m.feed(b"\x1b[1B"); assert_eq!(m.cursor(), (9, 4));
m.feed(b"\x1b[1C"); assert_eq!(m.cursor(), (10, 4));
m.feed(b"\x1b[1D"); assert_eq!(m.cursor(), (9, 4));
}
#[test]
fn cursor_movement_no_param_is_zero() {
let mut m = TerminalModel::new(20, 10);
m.feed(b"\x1b[5;10H"); m.feed(b"\x1b[A"); assert_eq!(m.cursor(), (9, 4));
}
#[test]
fn sgr_22_resets_both_bold_and_dim() {
let mut m = TerminalModel::new(10, 3);
m.feed(b"\x1b[1;2mA\x1b[22mB");
let a = m.style_at(0, 0);
assert!(a.bold);
assert!(a.dim);
let b = m.style_at(1, 0);
assert!(!b.bold);
assert!(!b.dim);
}
#[test]
fn sgr_blink_and_reverse_reset() {
let mut m = TerminalModel::new(10, 3);
m.feed(b"\x1b[5;7mA\x1b[25;27mB");
let a = m.style_at(0, 0);
assert!(a.blink);
assert!(a.reverse);
let b = m.style_at(1, 0);
assert!(!b.blink);
assert!(!b.reverse);
}
#[test]
fn erase_line_at_row_zero() {
let mut m = TerminalModel::new(10, 3);
m.feed(b"ABCDEFGHIJ");
m.feed(b"\x1b[1;1H\x1b[2K");
assert_eq!(m.row_text(0), "");
}
#[test]
fn erase_display_all_preserves_cursor() {
let mut m = TerminalModel::new(10, 3);
m.feed(b"XXXXXXXXXX");
m.feed(b"\x1b[1;5H"); m.feed(b"\x1b[2J");
assert_eq!(m.screen_text(), "");
assert_eq!(m.cursor(), (4, 0));
}
#[test]
fn feed_empty_bytes() {
let mut m = TerminalModel::new(10, 3);
m.feed(b"");
assert_eq!(m.cursor(), (0, 0));
assert_eq!(m.screen_text(), "");
}
#[test]
fn feed_str_empty() {
let mut m = TerminalModel::new(10, 3);
m.feed_str("");
assert_eq!(m.cursor(), (0, 0));
}
#[test]
fn put_char_at_full_grid_bottom_right() {
let mut m = TerminalModel::new(3, 2);
m.feed(b"\x1b[2;3H"); m.feed(b"Z");
assert_eq!(m.char_at(2, 1), 'Z');
assert_eq!(m.cursor(), (0, 1));
}
#[test]
fn control_chars_ignored() {
let mut m = TerminalModel::new(10, 3);
m.feed(&[0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x0b, 0x0c]);
assert_eq!(m.cursor(), (0, 0));
assert_eq!(m.screen_text(), "");
}
#[test]
fn printable_ascii_range() {
let mut m = TerminalModel::new(95, 1);
let printable: Vec<u8> = (0x20..=0x7eu8).collect();
m.feed(&printable);
assert_eq!(m.char_at(0, 0), ' ');
assert_eq!(m.char_at(94, 0), '~');
}
#[test]
fn dump_shows_cursor_and_style() {
let mut m = TerminalModel::new(5, 2);
m.feed(b"\x1b[1mBold\x1b[0m");
let dump = m.dump();
assert!(dump.contains("Bold"));
assert!(dump.contains("Cursor:"));
assert!(dump.contains("Style:"));
}
#[test]
fn multiple_sgr_sequences_accumulate() {
let mut m = TerminalModel::new(10, 3);
m.feed(b"\x1b[1m\x1b[3m\x1b[4mX");
let s = m.style_at(0, 0);
assert!(s.bold);
assert!(s.italic);
assert!(s.underline);
}
#[test]
fn sgr_zero_in_middle_resets_all() {
let mut m = TerminalModel::new(10, 3);
m.feed(b"\x1b[1;0;3mX");
let s = m.style_at(0, 0);
assert!(!s.bold);
assert!(s.italic);
}
#[test]
fn width_1_terminal() {
let mut m = TerminalModel::new(1, 3);
m.feed(b"ABC");
assert_eq!(m.char_at(0, 0), 'A');
assert_eq!(m.char_at(0, 1), 'B');
assert_eq!(m.char_at(0, 2), 'C');
}
#[test]
fn height_1_terminal() {
let mut m = TerminalModel::new(10, 1);
m.feed(b"Hello");
assert_eq!(m.row_text(0), "Hello");
m.feed(b"\n"); assert_eq!(m.cursor(), (5, 0));
}
}