#[cfg(feature = "video")]
use crate::error::EgerError;
use crate::error::Result;
use iascii::grid::Grid;
use iascii::render::{render_ansi, 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) => render_ansi(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(_) => grid_to_string(grid, depth)
.lines()
.map(str::to_owned)
.collect(),
}
}
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 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 frame_delay = Duration::from_secs_f64(1.0 / fps);
let mut stdout = tokio::io::stdout();
for frame in frames {
stdout.write_all(b"\x1B[H\x1B[2J").await?;
stdout.write_all(frame.as_bytes()).await?;
stdout.flush().await?;
tokio::time::sleep(frame_delay).await;
}
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 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();
}
}