use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Color, Modifier, Style},
widgets::Widget,
};
#[must_use]
pub fn vt100_color_to_ratatui(color: vt100::Color) -> Color {
match color {
vt100::Color::Default => Color::Reset,
vt100::Color::Idx(i) => Color::Indexed(i),
vt100::Color::Rgb(r, g, b) => Color::Rgb(r, g, b),
}
}
#[must_use]
pub fn cell_to_style(cell: &vt100::Cell) -> Style {
let mut style = Style::default()
.fg(vt100_color_to_ratatui(cell.fgcolor()))
.bg(vt100_color_to_ratatui(cell.bgcolor()));
if cell.bold() {
style = style.add_modifier(Modifier::BOLD);
}
if cell.italic() {
style = style.add_modifier(Modifier::ITALIC);
}
if cell.underline() {
style = style.add_modifier(Modifier::UNDERLINED);
}
if cell.inverse() {
style = style.add_modifier(Modifier::REVERSED);
}
style
}
pub struct TerminalView<'a> {
parser: &'a vt100::Parser,
}
impl<'a> TerminalView<'a> {
#[must_use]
pub fn new(parser: &'a vt100::Parser) -> Self {
Self { parser }
}
}
impl Widget for TerminalView<'_> {
#[allow(clippy::cast_possible_truncation)]
fn render(self, area: Rect, buf: &mut Buffer) {
if area.width == 0 || area.height == 0 {
return;
}
for y in area.y..area.y + area.height {
for x in area.x..area.x + area.width {
buf[(x, y)].set_symbol(" ").set_style(Style::default());
}
}
let screen = self.parser.screen();
let screen_rows = screen.size().0 as usize;
let screen_cols = screen.size().1 as usize;
let visible_rows = area.height as usize;
let start_row = screen_rows.saturating_sub(visible_rows);
for (dy, row) in (start_row..screen_rows.min(start_row + visible_rows)).enumerate() {
for col in 0..screen_cols.min(area.width as usize) {
let cell = screen.cell(row as u16, col as u16);
if let Some(cell) = cell {
let x = area.x + col as u16;
let y = area.y + dy as u16;
if x < area.x + area.width && y < area.y + area.height {
let style = cell_to_style(cell);
let contents = cell.contents();
let symbol = if contents.is_empty() { " " } else { &contents };
buf[(x, y)].set_symbol(symbol).set_style(style);
}
}
}
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn test_color_conversion_default() {
assert_eq!(vt100_color_to_ratatui(vt100::Color::Default), Color::Reset);
}
#[test]
fn test_color_conversion_indexed() {
assert_eq!(
vt100_color_to_ratatui(vt100::Color::Idx(1)),
Color::Indexed(1)
);
assert_eq!(
vt100_color_to_ratatui(vt100::Color::Idx(255)),
Color::Indexed(255)
);
}
#[test]
fn test_color_conversion_rgb() {
assert_eq!(
vt100_color_to_ratatui(vt100::Color::Rgb(100, 150, 200)),
Color::Rgb(100, 150, 200)
);
}
#[test]
fn test_render_empty_screen() {
let parser = vt100::Parser::new(24, 80, 0);
let view = TerminalView::new(&parser);
let area = Rect::new(0, 0, 40, 10);
let mut buf = Buffer::empty(area);
view.render(area, &mut buf);
}
#[test]
fn test_render_hello_world() {
let mut parser = vt100::Parser::new(24, 80, 0);
parser.process(b"Hello, World!");
let view = TerminalView::new(&parser);
let area = Rect::new(0, 0, 80, 24);
let mut buf = Buffer::empty(area);
view.render(area, &mut buf);
let mut output = String::new();
for x in 0..13u16 {
output.push_str(buf[(x, 0)].symbol());
}
assert_eq!(output, "Hello, World!");
}
#[test]
fn test_render_ansi_colors() {
let mut parser = vt100::Parser::new(24, 80, 0);
parser.process(b"\x1b[31mRed\x1b[0m");
let view = TerminalView::new(&parser);
let area = Rect::new(0, 0, 80, 24);
let mut buf = Buffer::empty(area);
view.render(area, &mut buf);
let cell = &buf[(0, 0)];
assert_eq!(cell.fg, Color::Indexed(1));
}
#[test]
fn test_render_with_scroll() {
let mut parser = vt100::Parser::new(5, 80, 100);
for i in 0..10 {
parser.process(format!("Line {i}\n").as_bytes());
}
parser.set_scrollback(2);
let view = TerminalView::new(&parser);
let area = Rect::new(0, 0, 80, 5);
let mut buf = Buffer::empty(area);
view.render(area, &mut buf);
parser.set_scrollback(0);
}
#[test]
fn test_cell_to_style_modifiers() {
let mut parser = vt100::Parser::new(24, 80, 0);
parser.process(b"\x1b[1mBold\x1b[0m");
let screen = parser.screen();
let cell = screen.cell(0, 0).unwrap();
let style = cell_to_style(cell);
assert!(style.add_modifier.contains(Modifier::BOLD));
}
#[test]
fn test_render_after_clear() {
let mut parser = vt100::Parser::new(24, 80, 0);
parser.process(b"Hello, World!");
parser.process(b"\x1b[2J\x1b[H");
let view = TerminalView::new(&parser);
let area = Rect::new(0, 0, 80, 24);
let mut buf = Buffer::empty(area);
for y in 0..24u16 {
for x in 0..80u16 {
buf[(x, y)].set_symbol("X");
}
}
view.render(area, &mut buf);
assert_eq!(buf[(0, 0)].symbol(), " ");
assert_eq!(buf[(13, 0)].symbol(), " "); }
#[test]
fn test_cell_to_style_italic() {
let mut parser = vt100::Parser::new(24, 80, 0);
parser.process(b"\x1b[3mItalic\x1b[0m");
let screen = parser.screen();
let cell = screen.cell(0, 0).unwrap();
let style = cell_to_style(cell);
assert!(style.add_modifier.contains(Modifier::ITALIC));
}
#[test]
fn test_cell_to_style_underline() {
let mut parser = vt100::Parser::new(24, 80, 0);
parser.process(b"\x1b[4mUnderline\x1b[0m");
let screen = parser.screen();
let cell = screen.cell(0, 0).unwrap();
let style = cell_to_style(cell);
assert!(style.add_modifier.contains(Modifier::UNDERLINED));
}
#[test]
fn test_cell_to_style_inverse() {
let mut parser = vt100::Parser::new(24, 80, 0);
parser.process(b"\x1b[7mInverse\x1b[0m");
let screen = parser.screen();
let cell = screen.cell(0, 0).unwrap();
let style = cell_to_style(cell);
assert!(style.add_modifier.contains(Modifier::REVERSED));
}
#[test]
fn test_render_with_large_scroll_offset() {
let mut parser = vt100::Parser::new(5, 80, 0);
parser.process(b"Line 1\nLine 2\nLine 3");
parser.set_scrollback(100);
let view = TerminalView::new(&parser);
let area = Rect::new(0, 0, 80, 5);
let mut buf = Buffer::empty(area);
view.render(area, &mut buf);
parser.set_scrollback(0);
}
#[test]
fn test_vt100_set_scrollback_shows_history() {
let mut parser = vt100::Parser::new(5, 20, 100);
for i in 0..10 {
parser.process(format!("Line {i}\n").as_bytes());
}
let contents = parser.screen().contents();
assert!(contents.contains("Line 9"), "Should see Line 9: {contents}");
assert!(
!contents.contains("Line 0"),
"Should NOT see Line 0 without scrollback: {contents}"
);
parser.set_scrollback(5);
let scrolled_contents = parser.screen().contents();
assert!(
scrolled_contents.contains("Line 4"),
"Should see Line 4 with scrollback=5: {scrolled_contents}"
);
parser.set_scrollback(0);
}
mod snapshots {
use super::*;
use crate::tui::test_utils::render_to_snapshot;
use insta::assert_snapshot;
#[test]
fn empty_screen() {
let parser = vt100::Parser::new(3, 20, 0);
let view = TerminalView::new(&parser);
assert_snapshot!(render_to_snapshot(view, 20, 3));
}
#[test]
fn plain_text() {
let mut parser = vt100::Parser::new(3, 20, 0);
parser.process(b"Hello, World!");
let view = TerminalView::new(&parser);
assert_snapshot!(render_to_snapshot(view, 20, 3));
}
#[test]
fn ansi_colored_text() {
let mut parser = vt100::Parser::new(1, 15, 0);
parser.process(b"\x1b[31mERROR\x1b[0m \x1b[32mOK\x1b[0m");
let view = TerminalView::new(&parser);
assert_snapshot!(render_to_snapshot(view, 15, 1));
}
#[test]
fn ansi_bold_text() {
let mut parser = vt100::Parser::new(1, 15, 0);
parser.process(b"\x1b[1mBold\x1b[0m Normal");
let view = TerminalView::new(&parser);
assert_snapshot!(render_to_snapshot(view, 15, 1));
}
#[test]
fn multi_line_content() {
let mut parser = vt100::Parser::new(5, 10, 0);
parser.process(b"Line 1\nLine 2\nLine 3");
let view = TerminalView::new(&parser);
assert_snapshot!(render_to_snapshot(view, 10, 5));
}
}
}