use turbo_vision::core::ansi::AnsiParser;
use turbo_vision::core::draw::Cell;
use turbo_vision::core::palette::{Attr, Style, TvColor};
const ERASE_LINE: &[u8] = b"\r\x1b[0K";
#[must_use]
pub fn attr_to_sgr(attr: Attr) -> String {
fn ansi256_index(color: TvColor) -> u8 {
match color {
TvColor::Black | TvColor::Rgb { .. } => 0,
TvColor::Red => 1,
TvColor::Green => 2,
TvColor::Brown => 3,
TvColor::Blue => 4,
TvColor::Magenta => 5,
TvColor::Cyan => 6,
TvColor::LightGray => 7,
TvColor::DarkGray => 8,
TvColor::LightRed => 9,
TvColor::LightGreen => 10,
TvColor::Yellow => 11,
TvColor::LightBlue => 12,
TvColor::LightMagenta => 13,
TvColor::LightCyan => 14,
TvColor::White => 15,
}
}
fn one(kind: u8, color: TvColor) -> String {
match color {
TvColor::Rgb { r, g, b } => format!("\x1b[{kind};2;{r};{g};{b}m"),
other => format!("\x1b[{kind};5;{}m", ansi256_index(other)),
}
}
use std::fmt::Write as _;
let mut style = String::new();
for (flag, code) in [
(Style::BOLD, 1),
(Style::DIM, 2),
(Style::ITALIC, 3),
(Style::UNDERLINE, 4),
(Style::REVERSE, 7),
(Style::STRIKETHROUGH, 9),
] {
if attr.style.contains(flag) {
let _ = write!(style, "\x1b[{code}m");
}
}
format!("{}{}{}", one(38, attr.fg), one(48, attr.bg), style)
}
pub struct AnsiLineAssembler {
parser: AnsiParser,
pending: Vec<u8>,
carry: Attr,
ready: Vec<Vec<Cell>>,
}
impl std::fmt::Debug for AnsiLineAssembler {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AnsiLineAssembler")
.field("pending_len", &self.pending.len())
.field("carry", &self.carry)
.field("ready_len", &self.ready.len())
.finish_non_exhaustive()
}
}
impl AnsiLineAssembler {
#[must_use]
pub fn new() -> Self {
Self {
parser: AnsiParser::new(),
pending: Vec::new(),
carry: Attr::new(TvColor::LightGray, TvColor::Black),
ready: Vec::new(),
}
}
pub fn push(&mut self, bytes: &[u8]) {
for &b in bytes {
if b == b'\n' {
let line = self.parse_pending();
self.carry = self.trailing_attr_of_pending();
self.ready.push(line);
self.pending.clear();
} else {
self.pending.push(b);
if self.pending.ends_with(ERASE_LINE) {
self.pending.clear();
}
}
}
}
pub fn take_complete_lines(&mut self) -> Vec<Vec<Cell>> {
std::mem::take(&mut self.ready)
}
#[must_use]
pub fn partial_line(&self) -> Vec<Cell> {
self.parse_pending()
}
pub fn flush(&mut self) -> Option<Vec<Cell>> {
if self.pending.is_empty() {
return None;
}
let line = self.parse_pending();
self.carry = self.trailing_attr_of_pending();
self.pending.clear();
Some(line)
}
fn parse_pending(&self) -> Vec<Cell> {
let usable = &self.pending[..complete_len(&self.pending)];
let text = String::from_utf8_lossy(usable);
let with_state = format!("{}{}", attr_to_sgr(self.carry), text);
self.parser.parse_line(&with_state)
}
fn trailing_attr_of_pending(&self) -> Attr {
let usable = &self.pending[..complete_len(&self.pending)];
let text = String::from_utf8_lossy(usable);
let probe = format!("{}{}X", attr_to_sgr(self.carry), text);
self.parser
.parse_line(&probe)
.last()
.map_or(self.carry, |c| c.attr)
}
}
impl Default for AnsiLineAssembler {
fn default() -> Self {
Self::new()
}
}
fn complete_len(buf: &[u8]) -> usize {
let Some(esc) = buf.iter().rposition(|&b| b == 0x1b) else {
return buf.len();
};
let tail = &buf[esc..];
if tail.len() == 1 {
return esc;
}
if tail[1] != b'[' {
return buf.len();
}
if tail[2..].iter().any(|&b| (0x40..=0x7e).contains(&b)) {
buf.len()
} else {
esc
}
}
#[cfg(test)]
mod tests {
use super::*;
use turbo_vision::core::palette::TvColor;
fn text(cells: &[Cell]) -> String {
cells.iter().map(|c| c.ch).collect()
}
#[test]
fn splits_on_newline_and_holds_the_tail() {
let mut a = AnsiLineAssembler::new();
a.push(b"one\ntwo");
let lines = a.take_complete_lines();
assert_eq!(lines.len(), 1);
assert_eq!(text(&lines[0]), "one");
assert_eq!(text(&a.partial_line()), "two");
}
#[test]
fn attribute_carries_across_a_line_break() {
let mut a = AnsiLineAssembler::new();
a.push(b"\x1b[31mred one\nstill red");
let lines = a.take_complete_lines();
assert_eq!(lines[0].last().unwrap().attr.fg, TvColor::Red);
let partial = a.partial_line();
assert_eq!(
partial[0].attr.fg,
TvColor::Red,
"SGR state must survive the newline"
);
}
#[test]
fn text_style_carries_across_a_line_break() {
use turbo_vision::core::palette::Style;
let mut a = AnsiLineAssembler::new();
a.push(b"\x1b[1;3mstyled one\nstill styled");
let lines = a.take_complete_lines();
assert!(lines[0].last().unwrap().attr.style.contains(Style::BOLD));
let partial = a.partial_line();
assert!(
partial[0].attr.style.contains(Style::BOLD),
"bold must survive the newline"
);
assert!(
partial[0].attr.style.contains(Style::ITALIC),
"italic must survive the newline"
);
}
#[test]
fn byte_at_a_time_matches_whole_delivery() {
let input = b"\x1b[1;32mgreen\x1b[0m plain\nnext\n";
let mut whole = AnsiLineAssembler::new();
whole.push(input);
let expected = whole.take_complete_lines();
let mut drip = AnsiLineAssembler::new();
let mut got = Vec::new();
for b in input {
drip.push(&[*b]);
got.extend(drip.take_complete_lines());
}
assert_eq!(got, expected);
}
#[test]
fn escape_split_across_chunks_is_not_shown_as_text() {
let mut a = AnsiLineAssembler::new();
a.push(b"x\x1b[3");
assert_eq!(
text(&a.partial_line()),
"x",
"an incomplete escape must not leak as literal characters"
);
a.push(b"1mY");
assert_eq!(text(&a.partial_line()), "xY");
assert_eq!(a.partial_line()[1].attr.fg, TvColor::Red);
}
#[test]
fn carriage_return_is_dropped_not_rendered() {
let mut a = AnsiLineAssembler::new();
a.push(b"abc\r\n");
let lines = a.take_complete_lines();
assert_eq!(text(&lines[0]), "abc");
}
#[test]
fn fence_repaint_replaces_the_line_instead_of_appending_to_it() {
let mut a = AnsiLineAssembler::new();
a.push(b"fn main() {}\x1b[0m\r\x1b[0K\x1b[38;5;214mfn\x1b[0m main() {}\n");
let lines = a.take_complete_lines();
assert_eq!(
text(&lines[0]),
"fn main() {}",
"the plain pre-repaint text must not survive alongside the repaint"
);
assert_ne!(
lines[0][0].attr.fg,
TvColor::LightGray,
"the repainted line must carry the highlight color, not the default"
);
}
#[test]
fn trailing_sgr_after_the_last_char_does_not_bleed_into_the_next_line() {
let mut a = AnsiLineAssembler::new();
a.push(b"\x1b[38;5;8mpondering\x1b[0m\nplain text\n");
let lines = a.take_complete_lines();
assert_eq!(
lines[1][0].attr.fg,
TvColor::LightGray,
"the reset after the last char of line 0 must carry into line 1, \
not line 0's last cell color"
);
}
#[test]
fn flush_emits_a_trailing_line_without_a_newline() {
let mut a = AnsiLineAssembler::new();
a.push(b"tail");
assert_eq!(text(&a.flush().unwrap()), "tail");
assert!(a.flush().is_none(), "flush must be idempotent");
}
}