use super::Crosswords;
use crate::config::colors::AnsiColor;
use crate::crosswords::grid::Dimensions;
use crate::crosswords::pos::{Column, Line};
use crate::crosswords::style::{Style, StyleFlags};
use crate::event::EventListener;
use std::fmt::Write as _;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Emit {
Plain,
Vt,
}
#[derive(Debug, Clone, Copy)]
pub struct FormatOptions {
pub emit: Emit,
pub trim: bool,
}
impl FormatOptions {
pub fn plain() -> Self {
Self {
emit: Emit::Plain,
trim: true,
}
}
pub fn vt() -> Self {
Self {
emit: Emit::Vt,
trim: true,
}
}
}
impl<U: EventListener> Crosswords<U> {
pub fn format(&self, opts: FormatOptions) -> String {
match opts.emit {
Emit::Plain => self.format_plain(),
Emit::Vt => self.format_vt(opts),
}
}
pub fn contents_formatted(&self) -> Vec<u8> {
self.format(FormatOptions::vt()).into_bytes()
}
fn format_plain(&self) -> String {
let rows = self.screen_lines();
let cols = self.columns();
if rows == 0 || cols == 0 {
return String::new();
}
let start = crate::crosswords::pos::Pos::new(Line(0), Column(0));
let end = crate::crosswords::pos::Pos::new(Line(rows as i32 - 1), Column(cols - 1));
self.bounds_to_string(start, end)
}
fn format_vt(&self, opts: FormatOptions) -> String {
let rows = self.screen_lines();
let cols = self.columns();
if rows == 0 || cols == 0 {
return String::new();
}
let mut out = String::new();
out.push_str("\x1b[0m\x1b[2J\x1b[H");
let mut current = Style::default();
let last_row = if opts.trim {
(0..rows)
.rev()
.find(|&line| self.row_has_content(line, cols))
.map(|l| l as i32)
} else {
Some(rows as i32 - 1)
};
let Some(last_row) = last_row else {
self.push_cursor(&mut out);
return out;
};
for line in 0..=last_row {
let last_col = self.row_last_content_col(line, cols, opts.trim);
let mut col = 0usize;
while let Some(limit) = last_col {
if col > limit {
break;
}
let square = &self.grid[Line(line)][Column(col)];
if square.is_spacer() {
col += 1;
continue;
}
let style = self.grid.style_of(square);
if style != current {
write_sgr(&mut out, &style);
current = style;
}
let c = square.c();
out.push(if c == '\0' { ' ' } else { c });
col += if square.is_wide() { 2 } else { 1 };
}
if line < last_row {
out.push_str("\r\n");
}
}
out.push_str("\x1b[0m");
self.push_cursor(&mut out);
out
}
fn push_cursor(&self, out: &mut String) {
let pos = self.grid.cursor.pos;
let row = pos.row.0.max(0) + 1;
let col = pos.col.0 + 1;
let _ = write!(out, "\x1b[{row};{col}H");
}
fn row_has_content(&self, line: usize, cols: usize) -> bool {
self.row_last_content_col(line as i32, cols, true).is_some()
}
fn row_last_content_col(&self, line: i32, cols: usize, trim: bool) -> Option<usize> {
if !trim {
return Some(cols.saturating_sub(1));
}
let row = &self.grid[Line(line)];
(0..cols).rev().find(|&col| {
let c = row[Column(col)].c();
c != ' ' && c != '\0'
})
}
}
fn write_sgr(out: &mut String, style: &Style) {
out.push_str("\x1b[0");
let flags = style.flags;
if flags.contains(StyleFlags::BOLD) {
out.push_str(";1");
}
if flags.contains(StyleFlags::DIM) {
out.push_str(";2");
}
if flags.contains(StyleFlags::ITALIC) {
out.push_str(";3");
}
if flags.contains(StyleFlags::UNDERLINE) {
out.push_str(";4");
}
if flags.contains(StyleFlags::INVERSE) {
out.push_str(";7");
}
if flags.contains(StyleFlags::HIDDEN) {
out.push_str(";8");
}
if flags.contains(StyleFlags::STRIKEOUT) {
out.push_str(";9");
}
push_color(out, style.fg, true);
push_color(out, style.bg, false);
out.push('m');
}
fn push_color(params: &mut String, color: AnsiColor, fg: bool) {
match color {
AnsiColor::Named(named) => {
let n = named as u16;
if n <= 7 {
let base = if fg { 30 } else { 40 };
let _ = write!(params, ";{}", base + n);
} else if n <= 15 {
let base = if fg { 90 } else { 100 };
let _ = write!(params, ";{}", base + (n - 8));
}
}
AnsiColor::Indexed(i) => {
let intro = if fg { "38" } else { "48" };
let _ = write!(params, ";{intro};5;{i}");
}
AnsiColor::Spec(rgb) => {
let intro = if fg { "38" } else { "48" };
let _ = write!(params, ";{intro};2;{};{};{}", rgb.r, rgb.g, rgb.b);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ansi::CursorShape;
use crate::crosswords::square::Square;
use crate::crosswords::{Crosswords, CrosswordsSize};
use crate::event::{VoidListener, WindowId};
use crate::performer::handler::Processor;
fn term(cols: usize, rows: usize) -> Crosswords<VoidListener> {
Crosswords::new(
CrosswordsSize::new(cols, rows),
CursorShape::Block,
VoidListener,
WindowId::from(0),
0,
0,
)
}
fn feed(t: &mut Crosswords<VoidListener>, bytes: &[u8]) {
let mut p = Processor::default();
p.advance(t, bytes);
}
#[test]
fn vt_roundtrip_preserves_text_and_style() {
let mut a = term(20, 4);
feed(&mut a, b"\x1b[31mhello\x1b[0m \x1b[1;34mworld\x1b[0m");
let snapshot = a.contents_formatted();
let text = String::from_utf8(snapshot.clone()).unwrap();
assert!(text.contains("31"), "missing red fg: {text:?}");
assert!(text.contains("34"), "missing blue fg: {text:?}");
let mut b = term(20, 4);
feed(&mut b, &snapshot);
assert_eq!(
a.format(FormatOptions::plain()),
b.format(FormatOptions::plain()),
);
let sa = *(&a.grid[Line(0)][Column(6)] as &Square);
let sb = *(&b.grid[Line(0)][Column(6)] as &Square);
assert_eq!(sa.c(), 'w');
assert_eq!(sb.c(), 'w');
assert_eq!(a.grid.style_of(&sa).fg, b.grid.style_of(&sb).fg);
assert!(b.grid.style_of(&sb).flags.contains(StyleFlags::BOLD));
}
#[test]
fn plain_and_vt_agree_on_text() {
let mut a = term(10, 3);
feed(&mut a, b"abc\r\ndef");
let plain = a.format(FormatOptions::plain());
assert!(plain.contains("abc"));
assert!(plain.contains("def"));
}
}