use unicode_width::UnicodeWidthStr;
use crate::locator::MatchedSpan;
use crate::region::Region;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CellColor {
pub red: u8,
pub green: u8,
pub blue: u8,
}
impl CellColor {
pub fn new(red: u8, green: u8, blue: u8) -> Self {
Self { red, green, blue }
}
pub fn white() -> Self {
Self::new(255, 255, 255)
}
pub fn black() -> Self {
Self::new(0, 0, 0)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct CellStyle {
flags: u8,
}
const BOLD_FLAG: u8 = 0x01;
const ITALIC_FLAG: u8 = 0x02;
const UNDERLINE_FLAG: u8 = 0x04;
const INVERSE_FLAG: u8 = 0x08;
const DIM_FLAG: u8 = 0x10;
impl CellStyle {
pub fn from_raw(flags: u8) -> Self {
Self { flags }
}
pub(crate) fn from_cell(cell: &vt100::Cell) -> Self {
let mut flags = 0u8;
if cell.bold() {
flags |= BOLD_FLAG;
}
if cell.italic() {
flags |= ITALIC_FLAG;
}
if cell.underline() {
flags |= UNDERLINE_FLAG;
}
if cell.inverse() {
flags |= INVERSE_FLAG;
}
if cell.dim() {
flags |= DIM_FLAG;
}
Self { flags }
}
pub fn bold(self) -> bool {
self.flags & BOLD_FLAG != 0
}
pub fn italic(self) -> bool {
self.flags & ITALIC_FLAG != 0
}
pub fn underline(self) -> bool {
self.flags & UNDERLINE_FLAG != 0
}
pub fn inverse(self) -> bool {
self.flags & INVERSE_FLAG != 0
}
pub fn dim(self) -> bool {
self.flags & DIM_FLAG != 0
}
}
pub struct TerminalFrame {
parser: vt100::Parser,
}
impl TerminalFrame {
pub fn new(cols: u16, rows: u16, data: &[u8]) -> Self {
let mut parser = vt100::Parser::new(rows, cols, 0);
parser.process(data);
Self { parser }
}
pub fn cols(&self) -> u16 {
self.parser.screen().size().1
}
pub fn rows(&self) -> u16 {
self.parser.screen().size().0
}
pub fn row_text(&self, row: u16) -> String {
let screen = self.parser.screen();
let cols = self.cols();
let mut text = String::with_capacity(usize::from(cols));
for col in 0..cols {
let Some(cell) = screen.cell(row, col) else {
text.push(' ');
continue;
};
if cell.is_wide_continuation() {
continue;
}
let contents = cell.contents();
if contents.is_empty() {
text.push(' ');
} else {
text.push_str(contents);
}
}
text.trim_end().to_string()
}
pub fn all_text(&self) -> String {
let rows = self.rows();
let mut lines = Vec::with_capacity(usize::from(rows));
for row in 0..rows {
lines.push(self.row_text(row));
}
while lines.last().is_some_and(std::string::String::is_empty) {
lines.pop();
}
lines.join("\n")
}
pub fn contents_formatted(&self) -> Vec<u8> {
self.parser.screen().contents_formatted()
}
pub fn cell_text(&self, row: u16, col: u16) -> &str {
self.parser.screen().cell(row, col).map_or(" ", |cell| {
let text = cell.contents();
if text.is_empty() { " " } else { text }
})
}
pub fn text_in_region(&self, region: &Region) -> String {
let mut lines = Vec::new();
for row in region.row..region.bottom().min(self.rows()) {
let screen = self.parser.screen();
let mut line = String::new();
for col in region.col..region.right().min(self.cols()) {
let Some(cell) = screen.cell(row, col) else {
line.push(' ');
continue;
};
if cell.is_wide_continuation() {
continue;
}
let contents = cell.contents();
if contents.is_empty() {
line.push(' ');
} else {
line.push_str(contents);
}
}
lines.push(line.trim_end().to_string());
}
while lines.last().is_some_and(std::string::String::is_empty) {
lines.pop();
}
lines.join("\n")
}
pub fn find_text(&self, needle: &str) -> Vec<MatchedSpan> {
if needle.is_empty() {
return Vec::new();
}
let mut matches = Vec::new();
for row in 0..self.rows() {
let (row_content, byte_to_col) = self.row_text_with_column_map(row);
let mut search_start = 0;
while let Some(byte_offset) = row_content[search_start..].find(needle) {
let match_byte_start = search_start + byte_offset;
let match_byte_end = match_byte_start + needle.len();
let start_col = byte_to_col[match_byte_start];
let end_col = byte_to_col[match_byte_end];
let span_length = end_col - start_col;
let span = self.extract_span(row, start_col, span_length);
matches.push(span);
search_start = match_byte_start
+ row_content[match_byte_start..]
.chars()
.next()
.map_or(1, char::len_utf8);
}
}
matches
}
pub fn find_text_in_region(&self, needle: &str, region: &Region) -> Vec<MatchedSpan> {
self.find_text(needle)
.into_iter()
.filter(|span| region.encloses(&span.rect))
.collect()
}
pub fn fg_color(&self, row: u16, col: u16) -> Option<CellColor> {
let cell = self.parser.screen().cell(row, col)?;
convert_vt100_color(cell.fgcolor())
}
pub fn bg_color(&self, row: u16, col: u16) -> Option<CellColor> {
let cell = self.parser.screen().cell(row, col)?;
convert_vt100_color(cell.bgcolor())
}
pub fn cell_style(&self, row: u16, col: u16) -> Option<CellStyle> {
let cell = self.parser.screen().cell(row, col)?;
Some(CellStyle::from_cell(cell))
}
fn row_text_with_column_map(&self, row: u16) -> (String, Vec<u16>) {
let screen = self.parser.screen();
let cols = self.cols();
let mut text = String::with_capacity(usize::from(cols));
let mut byte_to_col = Vec::with_capacity(usize::from(cols) + 1);
for col in 0..cols {
let contents = screen.cell(row, col).map_or("", |cell| cell.contents());
if contents.is_empty() {
continue;
}
for _ in 0..contents.len() {
byte_to_col.push(col);
}
text.push_str(contents);
}
let trimmed_len = text.trim_end().len();
text.truncate(trimmed_len);
byte_to_col.truncate(trimmed_len);
let sentinel = if trimmed_len > 0 {
let last_col = byte_to_col[trimmed_len - 1];
let last_contents = screen
.cell(row, last_col)
.map_or("", |cell| cell.contents());
let display_width = UnicodeWidthStr::width(last_contents).max(1);
last_col + u16::try_from(display_width).unwrap_or(1)
} else {
0
};
byte_to_col.push(sentinel);
(text, byte_to_col)
}
fn extract_span(&self, row: u16, col: u16, length: u16) -> MatchedSpan {
let screen = self.parser.screen();
let mut text = String::new();
let first_cell = screen.cell(row, col);
let foreground = first_cell.and_then(|cell| convert_vt100_color(cell.fgcolor()));
let background = first_cell.and_then(|cell| convert_vt100_color(cell.bgcolor()));
let style = first_cell.map(CellStyle::from_cell).unwrap_or_default();
for offset in 0..length {
if let Some(cell) = screen.cell(row, col + offset) {
text.push_str(cell.contents());
}
}
MatchedSpan {
text,
rect: Region::new(col, row, length, 1),
foreground,
background,
style,
}
}
}
fn convert_vt100_color(color: vt100::Color) -> Option<CellColor> {
match color {
vt100::Color::Default => None,
vt100::Color::Idx(idx) => Some(ansi_index_to_rgb(idx)),
vt100::Color::Rgb(red, green, blue) => Some(CellColor::new(red, green, blue)),
}
}
fn ansi_index_to_rgb(idx: u8) -> CellColor {
match idx {
0 => CellColor::new(0, 0, 0),
1 => CellColor::new(128, 0, 0),
2 => CellColor::new(0, 128, 0),
3 => CellColor::new(128, 128, 0),
4 => CellColor::new(0, 0, 128),
5 => CellColor::new(128, 0, 128),
6 => CellColor::new(0, 128, 128),
7 => CellColor::new(192, 192, 192),
8 => CellColor::new(128, 128, 128),
9 => CellColor::new(255, 0, 0),
10 => CellColor::new(0, 255, 0),
11 => CellColor::new(255, 255, 0),
12 => CellColor::new(0, 0, 255),
13 => CellColor::new(255, 0, 255),
14 => CellColor::new(0, 255, 255),
15 => CellColor::new(255, 255, 255),
16..=231 => {
let adjusted = idx - 16;
let blue = adjusted % 6;
let green = (adjusted / 6) % 6;
let red = adjusted / 36;
let to_component = |value: u8| -> u8 { if value == 0 { 0 } else { 55 + 40 * value } };
CellColor::new(to_component(red), to_component(green), to_component(blue))
}
232..=255 => {
let level = 8 + 10 * (idx - 232);
CellColor::new(level, level, level)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn new_frame_captures_plain_text() {
let data = b"Hello, World!";
let frame = TerminalFrame::new(80, 24, data);
assert_eq!(frame.row_text(0), "Hello, World!");
assert_eq!(frame.cols(), 80);
assert_eq!(frame.rows(), 24);
}
#[test]
fn row_text_trims_trailing_spaces() {
let data = b"abc";
let frame = TerminalFrame::new(80, 24, data);
assert_eq!(frame.row_text(0), "abc");
assert_eq!(frame.row_text(0).len(), 3);
}
#[test]
fn find_text_returns_all_matches() {
let data = b"foo bar foo";
let frame = TerminalFrame::new(80, 24, data);
let matches = frame.find_text("foo");
assert_eq!(matches.len(), 2);
assert_eq!(matches[0].rect.col, 0);
assert_eq!(matches[1].rect.col, 8);
}
#[test]
fn find_text_with_empty_needle_returns_empty() {
let data = b"foo bar";
let frame = TerminalFrame::new(80, 24, data);
let matches = frame.find_text("");
assert!(matches.is_empty());
}
#[test]
fn find_text_locates_multibyte_utf8_at_correct_column() {
let data = "café ok".as_bytes();
let frame = TerminalFrame::new(80, 24, data);
let matches = frame.find_text("ok");
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].rect.col, 5);
assert_eq!(matches[0].rect.width, 2);
}
#[test]
fn find_text_in_region_filters_by_region() {
let data = b"foo bar foo";
let frame = TerminalFrame::new(80, 24, data);
let region = Region::new(5, 0, 75, 1);
let matches = frame.find_text_in_region("foo", ®ion);
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].rect.col, 8);
}
#[test]
fn text_in_region_extracts_substring() {
let data = b"Hello, World!";
let frame = TerminalFrame::new(80, 24, data);
let region = Region::new(7, 0, 5, 1);
let text = frame.text_in_region(®ion);
assert_eq!(text, "World");
}
#[test]
fn all_text_joins_rows() {
let data = b"Line 1\r\nLine 2\r\nLine 3";
let frame = TerminalFrame::new(80, 24, data);
let text = frame.all_text();
assert!(text.contains("Line 1"));
assert!(text.contains("Line 2"));
assert!(text.contains("Line 3"));
}
#[test]
fn row_text_preserves_blank_columns_between_runs() {
let data = b"Hello\x1b[1;11HWorld";
let frame = TerminalFrame::new(80, 24, data);
let row = frame.row_text(0);
let all = frame.all_text();
assert_eq!(row, "Hello World");
assert!(
all.contains("Hello World"),
"all_text should preserve blank columns between runs, got: {all:?}"
);
assert!(
!all.contains("HelloWorld"),
"blank columns must not collapse distinct runs together, got: {all:?}"
);
}
#[test]
fn row_text_skips_wide_character_continuation_cells() {
let data = "あ_".as_bytes();
let frame = TerminalFrame::new(80, 24, data);
let row = frame.row_text(0);
assert_eq!(row, "あ_");
}
#[test]
fn row_text_trims_trailing_empty_cells() {
let data = b"abc";
let frame = TerminalFrame::new(80, 24, data);
assert_eq!(frame.row_text(0), "abc");
assert_eq!(frame.row_text(0).len(), 3);
}
#[test]
fn ansi_color_codes_are_parsed() {
let data = b"\x1b[31mRed\x1b[0m";
let frame = TerminalFrame::new(80, 24, data);
let fg_color = frame.fg_color(0, 0);
assert_eq!(fg_color, Some(CellColor::new(128, 0, 0)));
}
#[test]
fn bold_style_is_detected() {
let data = b"\x1b[1mBold\x1b[0m";
let frame = TerminalFrame::new(80, 24, data);
let style = frame.cell_style(0, 0);
assert!(style.is_some_and(CellStyle::bold));
}
#[test]
fn cell_text_returns_character() {
let frame = TerminalFrame::new(80, 24, b"Hello");
assert_eq!(frame.cell_text(0, 0), "H");
assert_eq!(frame.cell_text(0, 4), "o");
}
#[test]
fn cell_text_returns_space_for_empty_cell() {
let frame = TerminalFrame::new(80, 24, b"A");
let text = frame.cell_text(0, 5);
assert_eq!(text, " ");
}
#[test]
fn dim_style_is_detected() {
let data = b"\x1b[2mDim\x1b[0m";
let frame = TerminalFrame::new(80, 24, data);
let style = frame.cell_style(0, 0);
assert!(style.is_some_and(CellStyle::dim));
assert!(!style.is_some_and(CellStyle::bold));
}
#[test]
fn contents_formatted_roundtrips_colors() {
let data = b"\x1b[31mRed\x1b[0m Plain";
let frame = TerminalFrame::new(80, 24, data);
let formatted = frame.contents_formatted();
let reconstructed = TerminalFrame::new(80, 24, &formatted);
assert_eq!(
reconstructed.fg_color(0, 0),
Some(CellColor::new(128, 0, 0))
);
assert_eq!(reconstructed.row_text(0), frame.row_text(0));
}
#[test]
fn ansi_index_to_rgb_standard_colors() {
assert_eq!(ansi_index_to_rgb(0), CellColor::black());
assert_eq!(ansi_index_to_rgb(15), CellColor::white());
assert_eq!(ansi_index_to_rgb(9), CellColor::new(255, 0, 0));
}
#[test]
fn ansi_index_to_rgb_grayscale_ramp() {
let darkest = ansi_index_to_rgb(232);
let lightest = ansi_index_to_rgb(255);
assert_eq!(darkest, CellColor::new(8, 8, 8));
assert_eq!(lightest, CellColor::new(238, 238, 238));
}
}