use iascii::grid::{Cell, Grid};
use iascii::render::ColorDepth;
use crate::fast_ansi;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Style {
Plain,
Full,
Gray,
Mono(crate::rgb::Rgb),
BlackWhite {
threshold: u8,
},
}
impl Default for Style {
fn default() -> Self {
Style::Full
}
}
impl Style {
pub fn render(self, grid: &Grid, depth: ColorDepth) -> String {
render(self, grid, depth)
}
pub fn render_lines(self, grid: &Grid, depth: ColorDepth) -> Vec<String> {
render_lines(self, grid, depth)
}
}
#[inline]
pub(crate) fn luma(r: u8, g: u8, b: u8) -> u8 {
((77u32 * r as u32 + 150u32 * g as u32 + 29u32 * b as u32) >> 8) as u8
}
#[inline]
fn shade(color: crate::rgb::Rgb, luma: u8) -> (u8, u8, u8) {
let l = luma as u32;
(
(color.0 as u32 * l / 255) as u8,
(color.1 as u32 * l / 255) as u8,
(color.2 as u32 * l / 255) as u8,
)
}
fn bytes_to_string(buf: Vec<u8>) -> String {
unsafe { String::from_utf8_unchecked(buf) }
}
fn render_row(buf: &mut Vec<u8>, row: &[Cell], style: Style, depth: ColorDepth) {
match style {
Style::Plain => {
for cell in row {
fast_ansi::push_char(buf, cell.ch);
}
}
Style::Full => {
let mut cur = u32::MAX;
for cell in row {
let (r, g, b) = (cell.color.r, cell.color.g, cell.color.b);
let key = match depth {
ColorDepth::TrueColor => (r as u32) << 16 | (g as u32) << 8 | b as u32,
ColorDepth::Ansi256 => fast_ansi::nearest_ansi256(r, g, b) as u32,
ColorDepth::Ansi16 => fast_ansi::nearest_ansi16(r, g, b) as u32,
};
if key != cur {
match depth {
ColorDepth::TrueColor => fast_ansi::push_truecolor_escape(buf, r, g, b),
ColorDepth::Ansi256 => fast_ansi::push_ansi256_escape(buf, key as u8),
ColorDepth::Ansi16 => fast_ansi::push_ansi16_escape(buf, key as u8),
}
cur = key;
}
fast_ansi::push_char(buf, cell.ch);
}
buf.extend_from_slice(b"\x1b[0m");
}
Style::Gray => {
let mut cur = u32::MAX;
for cell in row {
if cell.ch == ' ' {
if cur != u32::MAX {
buf.extend_from_slice(b"\x1b[0m");
cur = u32::MAX;
}
buf.push(b' ');
continue;
}
let v = luma(cell.color.r, cell.color.g, cell.color.b);
let key = match depth {
ColorDepth::TrueColor => (v as u32) << 16 | (v as u32) << 8 | v as u32,
ColorDepth::Ansi256 => fast_ansi::nearest_ansi256(v, v, v) as u32,
ColorDepth::Ansi16 => fast_ansi::nearest_ansi16(v, v, v) as u32,
};
if key != cur {
match depth {
ColorDepth::TrueColor => fast_ansi::push_truecolor_escape(buf, v, v, v),
ColorDepth::Ansi256 => fast_ansi::push_ansi256_escape(buf, key as u8),
ColorDepth::Ansi16 => fast_ansi::push_ansi16_escape(buf, key as u8),
}
cur = key;
}
fast_ansi::push_char(buf, cell.ch);
}
buf.extend_from_slice(b"\x1b[0m");
}
Style::Mono(tint) => {
let mut cur = u32::MAX;
for cell in row {
if cell.ch == ' ' {
if cur != u32::MAX {
buf.extend_from_slice(b"\x1b[0m");
cur = u32::MAX;
}
buf.push(b' ');
continue;
}
let v = luma(cell.color.r, cell.color.g, cell.color.b);
let (r, g, b) = shade(tint, v);
let key = match depth {
ColorDepth::TrueColor => (r as u32) << 16 | (g as u32) << 8 | b as u32,
ColorDepth::Ansi256 => fast_ansi::nearest_ansi256(r, g, b) as u32,
ColorDepth::Ansi16 => fast_ansi::nearest_ansi16(r, g, b) as u32,
};
if key != cur {
match depth {
ColorDepth::TrueColor => fast_ansi::push_truecolor_escape(buf, r, g, b),
ColorDepth::Ansi256 => fast_ansi::push_ansi256_escape(buf, key as u8),
ColorDepth::Ansi16 => fast_ansi::push_ansi16_escape(buf, key as u8),
}
cur = key;
}
fast_ansi::push_char(buf, cell.ch);
}
buf.extend_from_slice(b"\x1b[0m");
}
Style::BlackWhite { threshold } => {
let mut cur_white: Option<bool> = None;
for cell in row {
if cell.ch == ' ' {
if cur_white.take().is_some() {
buf.extend_from_slice(b"\x1b[0m");
}
buf.push(b' ');
continue;
}
let white = luma(cell.color.r, cell.color.g, cell.color.b) >= threshold;
if cur_white != Some(white) {
if cur_white.is_some() {
buf.extend_from_slice(b"\x1b[0m");
}
buf.extend_from_slice(if white { b"\x1b[97m" } else { b"\x1b[30m" });
cur_white = Some(white);
}
fast_ansi::push_char(buf, cell.ch);
}
buf.extend_from_slice(b"\x1b[0m");
}
}
}
pub fn render(style: Style, grid: &Grid, depth: ColorDepth) -> String {
let w = grid.width() as usize;
let h = grid.height() as usize;
if w == 0 || h == 0 {
return String::new();
}
if style == Style::Plain {
return render_plain(grid);
}
let cells = grid.as_slice();
let per_cell = match (style, depth) {
(Style::Full, ColorDepth::TrueColor) => 20,
(Style::Full, ColorDepth::Ansi256) => 13,
(Style::Full, ColorDepth::Ansi16) => 7,
(_, ColorDepth::TrueColor) => 20,
(_, ColorDepth::Ansi256) => 13,
_ => 7,
};
let mut buf = Vec::with_capacity(h * (w * per_cell + 5));
for row in cells.chunks_exact(w) {
render_row(&mut buf, row, style, depth);
buf.push(b'\n');
}
bytes_to_string(buf)
}
pub fn render_lines(style: Style, grid: &Grid, depth: ColorDepth) -> Vec<String> {
let w = grid.width() as usize;
let h = grid.height() as usize;
if w == 0 || h == 0 {
return Vec::new();
}
if style == Style::Plain {
return grid
.rows()
.map(|row| row.iter().map(|cell| cell.ch).collect())
.collect();
}
let per_cell = match (style, depth) {
(Style::Full, ColorDepth::TrueColor) => 20,
(Style::Full, ColorDepth::Ansi256) => 13,
(Style::Full, ColorDepth::Ansi16) => 7,
(_, ColorDepth::TrueColor) => 20,
(_, ColorDepth::Ansi256) => 13,
_ => 7,
};
let mut lines = Vec::with_capacity(h);
for row in grid.as_slice().chunks_exact(w) {
let mut buf = Vec::with_capacity(w * per_cell + 4);
render_row(&mut buf, row, style, depth);
lines.push(bytes_to_string(buf));
}
lines
}
fn render_plain(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
}
#[cfg(test)]
mod tests {
use super::*;
use iascii::config::{ConfigBuilder as AsciiConfigBuilder, OutputSizing};
use iascii::convert::convert_image;
fn test_grid() -> Grid {
let cfg = AsciiConfigBuilder::new()
.output_sizing(OutputSizing::Explicit {
width: 6,
height: 4,
})
.build()
.unwrap();
let mut pix = Vec::with_capacity(6 * 4 * 3);
for y in 0..4u32 {
for x in 0..6u32 {
pix.push((x * 51) as u8);
pix.push((y * 85) as u8);
pix.push(((x + y) * 36) as u8);
}
}
convert_image(6, 4, &pix, &cfg).unwrap()
}
#[test]
fn plain_has_no_escapes_and_matches_plain_string() {
let grid = test_grid();
let out = render(Style::Plain, &grid, ColorDepth::TrueColor);
assert!(!out.contains('\x1b'));
assert_eq!(out, crate::render::plain_string(&grid));
assert_eq!(out.lines().count(), 4);
}
#[test]
fn full_matches_the_historical_renderer() {
let grid = test_grid();
for depth in [
ColorDepth::TrueColor,
ColorDepth::Ansi256,
ColorDepth::Ansi16,
] {
assert_eq!(
render(Style::Full, &grid, depth),
iascii::render::render_ansi(&grid, depth),
"Full must stay byte-identical at {depth:?}"
);
}
}
#[test]
fn gray_truecolor_uses_equal_channels_only() {
let grid = test_grid();
let out = render(Style::Gray, &grid, ColorDepth::TrueColor);
for line in out.lines() {
let mut rest = line;
while let Some(start) = rest.find("38;2;") {
let esc = &rest[start + 5..];
let end = esc.find('m').unwrap();
let parts: Vec<&str> = esc[..end].split(';').collect();
assert_eq!(parts.len(), 3);
assert_eq!(parts[0], parts[1]);
assert_eq!(parts[1], parts[2]);
rest = &esc[end..];
}
}
assert!(out.contains('\x1b'));
}
#[test]
fn mono_shades_only_the_chosen_hue() {
let grid = test_grid();
let out = render(
Style::Mono(crate::rgb::Rgb::new(255, 0, 0)),
&grid,
ColorDepth::TrueColor,
);
assert!(out.contains('\x1b'));
let mut rest = out.as_str();
let mut seen = 0;
while let Some(start) = rest.find("38;2;") {
let esc = &rest[start + 5..];
let end = esc.find('m').unwrap();
let parts: Vec<&str> = esc[..end].split(';').collect();
assert_eq!(parts.len(), 3);
assert_eq!(parts[1], "0");
assert_eq!(parts[2], "0");
seen += 1;
rest = &esc[end..];
}
assert!(seen > 0, "mono render should emit its color");
}
#[test]
fn black_white_uses_only_two_codes() {
let grid = test_grid();
let out = render(
Style::BlackWhite { threshold: 128 },
&grid,
ColorDepth::Ansi16,
);
assert!(out.contains('\x1b'));
assert!(!out.contains("38;2;"), "no truecolor escapes in BW mode");
assert!(!out.contains("38;5;"), "no 256-color escapes in BW mode");
for line in out.lines() {
let mut rest = line;
while let Some(start) = rest.find("\x1b[") {
let esc = &rest[start + 2..];
let end = esc.find('m').unwrap();
let code = &esc[..end];
assert!(
code == "30" || code == "97" || code == "0",
"unexpected SGR code {code:?}"
);
rest = &esc[end..];
}
}
}
#[test]
fn black_white_threshold_picks_sides() {
let grid = test_grid();
let all_white = render(
Style::BlackWhite { threshold: 0 },
&grid,
ColorDepth::TrueColor,
);
assert!(!all_white.contains("\x1b[30m"));
assert!(all_white.contains("\x1b[97m"));
}
#[test]
fn styled_spaces_stay_bare() {
let grid = test_grid();
for style in [
Style::Gray,
Style::Mono(crate::rgb::Rgb::GREEN),
Style::BlackWhite { threshold: 128 },
] {
let out = render(style, &grid, ColorDepth::TrueColor);
assert!(
!out.contains("m \x1b[0m") && !out.contains("\x1b[0m \x1b["),
"spaces must not be wrapped in escapes for {style:?}"
);
}
}
#[test]
fn lines_variant_matches_split_render() {
let grid = test_grid();
for style in [
Style::Plain,
Style::Full,
Style::Gray,
Style::Mono(crate::rgb::Rgb::CYAN),
Style::BlackWhite { threshold: 128 },
] {
let joined = render(style, &grid, ColorDepth::TrueColor);
let expected: Vec<String> = joined.lines().map(str::to_owned).collect();
assert_eq!(
render_lines(style, &grid, ColorDepth::TrueColor),
expected,
"lines mismatch for {style:?}"
);
}
}
#[test]
fn luma_matches_bt601_float() {
for (r, g, b) in [(0, 0, 0), (255, 255, 255), (200, 50, 50), (20, 200, 90)] {
let expected = (0.299 * r as f32 + 0.587 * g as f32 + 0.114 * b as f32).round() as u8;
let got = luma(r, g, b);
assert!(
(got as i16 - expected as i16).abs() <= 1,
"luma({r},{g},{b}) = {got}, float BT.601 = {expected}"
);
}
}
}