#[cfg(feature = "video")]
use crate::error::EgerError;
use crate::error::Result;
use crate::fast_ansi;
use iascii::grid::Grid;
use iascii::render::ColorDepth;
use std::io::Write;
use std::path::PathBuf;
#[cfg(feature = "video")]
use std::time::Duration;
#[cfg(feature = "video")]
use tokio::io::AsyncWriteExt;
#[derive(Debug, Clone)]
pub enum RenderTarget {
Stdout,
File(PathBuf),
String,
Lines,
}
#[derive(Debug)]
pub enum RenderOutput {
Displayed,
Written(PathBuf),
Text(String),
Lines(Vec<String>),
}
pub fn grid_to_string(grid: &Grid, depth: Option<ColorDepth>) -> String {
match depth {
Some(depth) => fast_ansi::render_ansi_fast(grid, depth),
None => plain_string(grid),
}
}
pub fn plain_string(grid: &Grid) -> String {
let mut out = String::with_capacity((grid.width() as usize + 1) * grid.height() as usize);
for row in grid.rows() {
for cell in row {
out.push(cell.ch);
}
out.push('\n');
}
out
}
pub fn grid_to_lines(grid: &Grid, depth: Option<ColorDepth>) -> Vec<String> {
match depth {
None => grid
.rows()
.map(|row| row.iter().map(|cell| cell.ch).collect())
.collect(),
Some(depth) => fast_ansi::render_ansi_fast_lines(grid, depth),
}
}
pub fn detect_render_depth() -> Option<ColorDepth> {
use std::io::IsTerminal;
detect_render_depth_from(
std::env::var_os("NO_COLOR").is_some(),
std::env::var("COLORTERM").ok(),
std::env::var("TERM").ok(),
std::io::stdout().is_terminal(),
)
}
fn detect_render_depth_from(
no_color: bool,
colorterm: Option<String>,
term: Option<String>,
is_tty: bool,
) -> Option<ColorDepth> {
if no_color || !is_tty {
return None;
}
if term.as_deref() == Some("dumb") {
return None;
}
if colorterm
.as_deref()
.is_some_and(|c| c == "truecolor" || c == "24bit")
{
return Some(ColorDepth::TrueColor);
}
if term.as_deref().is_some_and(|t| t.contains("256color")) {
return Some(ColorDepth::Ansi256);
}
Some(ColorDepth::Ansi16)
}
pub fn terminal_size() -> Option<(u16, u16)> {
terminal_size::terminal_size().map(|(terminal_size::Width(w), terminal_size::Height(h))| (w, h))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DiffGranularity {
Line,
Cell,
}
pub fn diff_frame(prev: &str, next: &str, granularity: DiffGranularity) -> String {
let mut out = String::new();
diff_frame_into(prev, next, granularity, &mut out);
out
}
pub fn diff_frame_into(prev: &str, next: &str, granularity: DiffGranularity, out: &mut String) {
let prev_lines: Vec<&str> = prev.lines().collect();
let next_lines: Vec<&str> = next.lines().collect();
let max_lines = prev_lines.len().max(next_lines.len());
let mut prev_cells: Vec<fast_ansi::ParsedCell> = Vec::new();
let mut next_cells: Vec<fast_ansi::ParsedCell> = Vec::new();
for row in 0..max_lines {
let prev_line = prev_lines.get(row).copied().unwrap_or("");
let next_line = next_lines.get(row).copied().unwrap_or("");
if prev_line == next_line {
continue;
}
match granularity {
DiffGranularity::Line => {
use std::fmt::Write as _;
let _ = write!(out, "\x1B[{};1H\x1B[K{next_line}", row + 1);
}
DiffGranularity::Cell => {
fast_ansi::parse_sgr_cells(prev_line, &mut prev_cells);
fast_ansi::parse_sgr_cells(next_line, &mut next_cells);
fast_ansi::diff_cells_into(out, row + 1, &prev_cells, &next_cells);
}
}
}
}
#[cfg(feature = "video")]
pub(crate) struct FramePacer {
next: tokio::time::Instant,
step: Duration,
}
#[cfg(feature = "video")]
impl FramePacer {
pub(crate) fn new(step: Duration) -> Self {
Self {
next: tokio::time::Instant::now() + step,
step,
}
}
pub(crate) fn set_step(&mut self, step: Duration) {
self.step = step;
}
pub(crate) async fn wait(&mut self) {
let now = tokio::time::Instant::now();
if self.next + self.step < now {
self.next = now; }
tokio::time::sleep_until(self.next).await;
self.next += self.step;
}
}
#[cfg(feature = "video")]
pub async fn play_terminal_diffed(
frames: &[String],
fps: f64,
granularity: DiffGranularity,
) -> Result<()> {
if !(fps > 0.0) {
return Err(EgerError::Render("fps must be positive".into()));
}
let Some((first, rest)) = frames.split_first() else {
return Ok(());
};
let mut pacer = FramePacer::new(Duration::from_secs_f64(1.0 / fps));
let mut stdout = tokio::io::stdout();
stdout.write_all(b"\x1B[?25l\x1B[H\x1B[2J").await?;
let result: Result<()> = async {
stdout.write_all(first.as_bytes()).await?;
stdout.flush().await?;
pacer.wait().await;
let mut prev = first;
let mut patch = String::new();
for next in rest {
patch.clear();
diff_frame_into(prev, next, granularity, &mut patch);
if !patch.is_empty() {
stdout.write_all(patch.as_bytes()).await?;
stdout.flush().await?;
}
prev = next;
pacer.wait().await;
}
Ok(())
}
.await;
let restore = stdout.write_all(b"\x1B[?25h").await;
stdout.flush().await?;
result?;
restore?;
Ok(())
}
pub fn dispatch_text(target: &RenderTarget, text: String) -> Result<RenderOutput> {
match target {
RenderTarget::Stdout => {
let mut stdout = std::io::stdout().lock();
stdout.write_all(text.as_bytes())?;
stdout.write_all(b"\n")?;
Ok(RenderOutput::Displayed)
}
RenderTarget::File(path) => {
std::fs::write(path, &text)?;
Ok(RenderOutput::Written(path.clone()))
}
RenderTarget::String => Ok(RenderOutput::Text(text)),
RenderTarget::Lines => Ok(RenderOutput::Lines(
text.lines().map(str::to_owned).collect(),
)),
}
}
pub fn dispatch(
target: &RenderTarget,
grid: &Grid,
depth: Option<ColorDepth>,
) -> Result<RenderOutput> {
match target {
RenderTarget::Stdout => {
let text = grid_to_string(grid, depth);
let mut stdout = std::io::stdout().lock();
stdout.write_all(text.as_bytes())?;
stdout.write_all(b"\n")?;
Ok(RenderOutput::Displayed)
}
RenderTarget::File(path) => {
let text = grid_to_string(grid, depth);
std::fs::write(path, &text)?;
Ok(RenderOutput::Written(path.clone()))
}
RenderTarget::String => Ok(RenderOutput::Text(grid_to_string(grid, depth))),
RenderTarget::Lines => Ok(RenderOutput::Lines(grid_to_lines(grid, depth))),
}
}
#[cfg(feature = "video")]
pub async fn dispatch_async(
target: &RenderTarget,
grid: &Grid,
depth: Option<ColorDepth>,
) -> Result<RenderOutput> {
match target {
RenderTarget::Stdout => {
let text = grid_to_string(grid, depth);
let mut stdout = tokio::io::stdout();
stdout.write_all(text.as_bytes()).await?;
stdout.write_all(b"\n").await?;
stdout.flush().await?;
Ok(RenderOutput::Displayed)
}
RenderTarget::File(path) => {
let text = grid_to_string(grid, depth);
tokio::fs::write(path, &text).await?;
Ok(RenderOutput::Written(path.clone()))
}
RenderTarget::String => Ok(RenderOutput::Text(grid_to_string(grid, depth))),
RenderTarget::Lines => Ok(RenderOutput::Lines(grid_to_lines(grid, depth))),
}
}
#[cfg(feature = "video")]
pub async fn play_terminal(frames: &[String], fps: f64) -> Result<()> {
if !(fps > 0.0) {
return Err(EgerError::Render("fps must be positive".into()));
}
let mut pacer = FramePacer::new(Duration::from_secs_f64(1.0 / fps));
let mut stdout = tokio::io::stdout();
stdout.write_all(b"\x1B[?25l").await?;
let result: Result<()> = async {
for frame in frames {
stdout.write_all(b"\x1B[H\x1B[2J").await?;
stdout.write_all(frame.as_bytes()).await?;
stdout.flush().await?;
pacer.wait().await;
}
Ok(())
}
.await;
let restore = stdout.write_all(b"\x1B[?25h").await;
stdout.flush().await?;
result?;
restore?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use iascii::config::{ConfigBuilder as AsciiConfigBuilder, OutputSizing};
use iascii::convert::convert_image;
fn sample_grid() -> Grid {
let ascii_config = AsciiConfigBuilder::new()
.output_sizing(OutputSizing::Explicit {
width: 2,
height: 2,
})
.build()
.expect("ascii config should build with defaults");
#[rustfmt::skip]
let pixels: [u8; 2 * 2 * 3] = [
0, 0, 0, 255, 255, 255,
255, 255, 255, 0, 0, 0,
];
convert_image(2, 2, &pixels, &ascii_config)
.expect("conversion of a valid buffer should succeed")
}
#[test]
fn plain_string_has_no_escape_codes_and_expected_shape() {
let grid = sample_grid();
let s = plain_string(&grid);
assert!(!s.contains('\x1b'));
let lines: Vec<&str> = s.lines().collect();
assert_eq!(lines.len(), grid.height() as usize);
for line in &lines {
assert_eq!(line.chars().count(), grid.width() as usize);
}
}
#[test]
fn checkerboard_pixels_map_to_two_distinct_characters() {
let grid = sample_grid();
let s = plain_string(&grid);
let distinct: std::collections::HashSet<char> = s.chars().filter(|c| *c != '\n').collect();
assert!(distinct.len() >= 2);
}
#[test]
fn grid_to_lines_none_depth_matches_row_by_row_plain_reconstruction() {
let grid = sample_grid();
let expected: Vec<String> = plain_string(&grid).lines().map(str::to_owned).collect();
assert_eq!(grid_to_lines(&grid, None), expected);
}
#[test]
fn grid_to_lines_some_depth_splits_colored_output_by_row() {
let grid = sample_grid();
let lines = grid_to_lines(&grid, Some(ColorDepth::TrueColor));
assert_eq!(lines.len(), grid.height() as usize);
for line in &lines {
assert!(line.contains('\x1b'));
}
}
#[test]
fn no_color_env_var_forces_plain_even_on_a_rich_terminal() {
assert_eq!(
detect_render_depth_from(
true,
Some("truecolor".into()),
Some("xterm-256color".into()),
true
),
None
);
}
#[test]
fn non_terminal_stdout_forces_plain_regardless_of_env() {
assert_eq!(
detect_render_depth_from(
false,
Some("truecolor".into()),
Some("xterm-256color".into()),
false
),
None
);
}
#[test]
fn dumb_terminal_forces_plain() {
assert_eq!(
detect_render_depth_from(false, None, Some("dumb".into()), true),
None
);
}
#[test]
fn colorterm_truecolor_picked_over_lesser_term_signals() {
assert_eq!(
detect_render_depth_from(false, Some("truecolor".into()), Some("xterm".into()), true),
Some(ColorDepth::TrueColor)
);
}
#[test]
fn term_256color_picked_when_colorterm_absent() {
assert_eq!(
detect_render_depth_from(false, None, Some("screen-256color".into()), true),
Some(ColorDepth::Ansi256)
);
}
#[test]
fn falls_back_to_universally_supported_ansi16() {
assert_eq!(
detect_render_depth_from(false, None, Some("xterm".into()), true),
Some(ColorDepth::Ansi16)
);
}
#[test]
fn diff_frame_is_empty_for_identical_frames() {
let a = "line one\nline two\n";
assert_eq!(diff_frame(a, a, DiffGranularity::Line), "");
assert_eq!(diff_frame(a, a, DiffGranularity::Cell), "");
}
#[test]
fn diff_frame_line_granularity_only_touches_changed_rows() {
let prev = "same\nold\nsame";
let next = "same\nnew\nsame";
let patch = diff_frame(prev, next, DiffGranularity::Line);
assert!(patch.contains("new"));
assert!(!patch.contains("old"));
assert!(patch.contains("\x1B[2;1H"));
assert!(!patch.contains("\x1B[1;1H"));
assert!(!patch.contains("\x1B[3;1H"));
}
#[test]
fn diff_frame_cell_granularity_only_rewrites_the_changed_span() {
let prev = "abcXXXghi";
let next = "abcYYYghi";
let patch = diff_frame(prev, next, DiffGranularity::Cell);
assert!(patch.contains("YYY"));
assert!(!patch.contains("abcYYY"));
assert!(!patch.contains("YYYghi"));
}
#[test]
fn diff_frame_cell_granularity_clears_a_shrunk_line_tail() {
let prev = "hello world";
let next = "hello";
let patch = diff_frame(prev, next, DiffGranularity::Cell);
assert!(patch.contains("\x1B[1;6H\x1B[K"));
}
#[test]
fn diff_frame_handles_frames_with_different_line_counts() {
let prev = "a\nb\nc";
let next = "a\nb";
let patch = diff_frame(prev, next, DiffGranularity::Line);
assert!(patch.contains("\x1B[3;1H\x1B[K"));
}
#[test]
fn diff_frame_cell_granularity_survives_a_color_only_change() {
let prev = "\x1b[38;2;255;0;0mAB\x1b[0m\n";
let next = "\x1b[38;2;0;255;0mAB\x1b[0m\n";
let patch = diff_frame(prev, next, DiffGranularity::Cell);
assert!(
patch.contains("\x1b[38;2;0;255;0mAB"),
"whole escape re-emitted: {patch:?}"
);
let residue = patch.replace("\x1b[38;2;0;255;0m", "");
assert!(
!residue.contains('8'),
"no escape fragment leaked: {patch:?}"
);
}
#[test]
fn diff_frame_cell_granularity_shrinks_a_colored_row_to_a_patch() {
let prev = "\x1b[38;2;1;2;3m..hello world\x1b[0m\n";
let next = "\x1b[38;2;1;2;3m..hello\x1b[0m\n";
let patch = diff_frame(prev, next, DiffGranularity::Cell);
assert!(patch.contains("\x1B[K"), "{patch:?}");
assert!(
!patch.contains("hello"),
"prefix must not be re-sent: {patch:?}"
);
}
#[test]
fn diff_frame_into_matches_the_allocating_variant() {
let prev = "same\n\x1b[31mold\x1b[0m\nsame\n";
let next = "same\n\x1b[32mnew\x1b[0m\nsame\n";
for g in [DiffGranularity::Line, DiffGranularity::Cell] {
let mut buf = String::from("PREFIX");
diff_frame_into(prev, next, g, &mut buf);
assert_eq!(buf, format!("PREFIX{}", diff_frame(prev, next, g)));
}
}
#[test]
fn diff_frame_cell_granularity_recolors_a_single_dirty_cell() {
let prev = "\x1b[38;5;9mabcdef\x1b[0m\n";
let next = "\x1b[38;5;9mabcdXf\x1b[0m\n";
let patch = diff_frame(prev, next, DiffGranularity::Cell);
assert_eq!(patch.matches("\x1B[1;").count(), 1, "{patch:?}");
assert!(patch.contains("X\x1b[0m"), "{patch:?}");
}
#[test]
fn terminal_size_does_not_panic_without_a_real_terminal() {
let _ = terminal_size();
}
#[test]
fn dispatch_file_target_writes_and_returns_path() {
let mut path = std::env::temp_dir();
path.push(format!("eger-render-test-{}.txt", std::process::id()));
let grid = sample_grid();
let out = dispatch(&RenderTarget::File(path.clone()), &grid, None).unwrap();
match out {
RenderOutput::Written(p) => assert_eq!(p, path),
other => panic!("expected Written, got {other:?}"),
}
std::fs::remove_file(&path).ok();
}
}