use crate::rgb::Rgb;
use iascii::grid::Grid;
use iascii::render::ColorDepth;
pub(crate) static ANSI16_PALETTE: [(u8, u8, u8); 16] = [
(0, 0, 0),
(128, 0, 0),
(0, 128, 0),
(128, 128, 0),
(0, 0, 128),
(128, 0, 128),
(0, 128, 128),
(192, 192, 192),
(128, 128, 128),
(255, 0, 0),
(0, 255, 0),
(255, 255, 0),
(0, 0, 255),
(255, 0, 255),
(0, 255, 255),
(255, 255, 255),
];
static ANSI16_ESCAPES: [&[u8]; 16] = [
b"\x1b[30m",
b"\x1b[31m",
b"\x1b[32m",
b"\x1b[33m",
b"\x1b[34m",
b"\x1b[35m",
b"\x1b[36m",
b"\x1b[37m",
b"\x1b[90m",
b"\x1b[91m",
b"\x1b[92m",
b"\x1b[93m",
b"\x1b[94m",
b"\x1b[95m",
b"\x1b[96m",
b"\x1b[97m",
];
const CUBE_LEVELS: [u8; 6] = [0, 95, 135, 175, 215, 255];
#[inline]
pub(crate) fn nearest_ansi16(r: u8, g: u8, b: u8) -> u8 {
let r = r as i32;
let g = g as i32;
let b = b as i32;
let mut best_idx: u8 = 0;
let mut best_dist = u32::MAX;
let mut i: usize = 0;
while i < 16 {
let (pr, pg, pb) = ANSI16_PALETTE[i];
let dr = r - pr as i32;
let dg = g - pg as i32;
let db = b - pb as i32;
let d = (dr * dr + dg * dg + db * db) as u32;
if d < best_dist {
best_dist = d;
best_idx = i as u8;
if d == 0 {
break;
}
}
i += 1;
}
best_idx
}
#[inline]
fn cube_level_index(v: u8) -> usize {
if v <= 47 {
0
} else if v <= 115 {
1
} else if v <= 155 {
2
} else if v <= 195 {
3
} else if v <= 235 {
4
} else {
5
}
}
#[inline]
fn dist2(ar: i32, ag: i32, ab: i32, br: i32, bg: i32, bb: i32) -> u32 {
let dr = ar - br;
let dg = ag - bg;
let db = ab - bb;
(dr * dr + dg * dg + db * db) as u32
}
#[inline]
pub(crate) fn nearest_ansi256(r: u8, g: u8, b: u8) -> u8 {
let ri = cube_level_index(r);
let gi = cube_level_index(g);
let bi = cube_level_index(b);
let rv = CUBE_LEVELS[ri] as i32;
let gv = CUBE_LEVELS[gi] as i32;
let bv = CUBE_LEVELS[bi] as i32;
let cube_dist = dist2(r as i32, g as i32, b as i32, rv, gv, bv);
let avg = ((r as u32 + g as u32 + b as u32) / 3) as u8;
let gray_idx: u8 = if avg < 8 {
0
} else if avg > 238 {
23
} else {
(avg - 8 + 5) / 10
};
let gray_val = (8 + gray_idx * 10) as i32;
let gray_dist = dist2(r as i32, g as i32, b as i32, gray_val, gray_val, gray_val);
if gray_dist < cube_dist {
232 + gray_idx
} else {
16 + (36 * ri + 6 * gi + bi) as u8
}
}
#[inline]
pub(crate) fn push_u8(buf: &mut Vec<u8>, v: u8) {
if v >= 100 {
buf.push(b'0' + v / 100);
let rem = v % 100;
buf.push(b'0' + rem / 10);
buf.push(b'0' + rem % 10);
} else if v >= 10 {
buf.push(b'0' + v / 10);
buf.push(b'0' + v % 10);
} else {
buf.push(b'0' + v);
}
}
#[inline]
pub(crate) fn push_ansi16_escape(buf: &mut Vec<u8>, idx: u8) {
buf.extend_from_slice(ANSI16_ESCAPES[(idx & 15) as usize]);
}
#[inline]
pub(crate) fn push_ansi256_escape(buf: &mut Vec<u8>, code: u8) {
buf.extend_from_slice(b"\x1b[38;5;");
push_u8(buf, code);
buf.push(b'm');
}
#[inline]
pub(crate) fn push_truecolor_escape(buf: &mut Vec<u8>, r: u8, g: u8, b: u8) {
buf.extend_from_slice(b"\x1b[38;2;");
push_u8(buf, r);
buf.push(b';');
push_u8(buf, g);
buf.push(b';');
push_u8(buf, b);
buf.push(b'm');
}
#[inline]
pub(crate) fn push_char(buf: &mut Vec<u8>, ch: char) {
if (ch as u32) < 128 {
buf.push(ch as u8);
} else {
let mut tmp = [0u8; 4];
buf.extend_from_slice(ch.encode_utf8(&mut tmp).as_bytes());
}
}
pub(crate) type ParsedCell = (char, Option<Rgb>);
pub(crate) const SPAN_MERGE_GAP: usize = 4;
pub(crate) fn apply_sgr(params: &str, cur: &mut Option<Rgb>) {
let mut nums = [0u8; 5];
let mut n = 0usize;
let mut overflow = false;
for p in params.split(';') {
if let Ok(v) = p.parse::<u8>() {
if n < nums.len() {
nums[n] = v;
n += 1;
} else {
overflow = true;
}
}
}
if overflow {
return; }
match &nums[..n] {
[] | [0] | [39] => *cur = None,
[38, 2, r, g, b] => *cur = Some(Rgb::new(*r, *g, *b)),
[38, 5, idx] => {
let (r, g, b) = crate::palette::ansi256_rgb(*idx);
*cur = Some(Rgb::new(r, g, b));
}
[code] if (30..=37).contains(code) => {
let (r, g, b) = ANSI16_PALETTE[(code - 30) as usize];
*cur = Some(Rgb::new(r, g, b));
}
[code] if (90..=97).contains(code) => {
let (r, g, b) = ANSI16_PALETTE[(code - 90 + 8) as usize];
*cur = Some(Rgb::new(r, g, b));
}
_ => {}
}
}
pub(crate) fn parse_sgr_cells(line: &str, out: &mut Vec<ParsedCell>) {
out.clear();
let mut cur: Option<Rgb> = None;
let mut rest = line;
while !rest.is_empty() {
if let Some(after_esc) = rest.strip_prefix("\x1b[") {
match after_esc.find('m') {
Some(end) => {
apply_sgr(&after_esc[..end], &mut cur);
rest = &after_esc[end + 1..];
}
None => break, }
continue;
}
let mut chars = rest.chars();
let ch = chars.next().unwrap_or(' ');
out.push((ch, cur));
rest = chars.as_str();
}
}
fn push_cells_run(out: &mut String, cells: &[ParsedCell]) {
use std::fmt::Write as _;
let mut run: Option<Rgb> = None;
let mut first = true;
for (ch, color) in cells {
match color {
None => {
if run.take().is_some() || first {
out.push_str("\x1b[0m");
}
out.push(*ch);
}
Some(c) => {
if run != Some(*c) {
if run.is_some() {
out.push_str("\x1b[0m");
}
let _ = write!(out, "\x1b[38;2;{};{};{}m", c.0, c.1, c.2);
run = Some(*c);
}
out.push(*ch);
}
}
first = false;
}
if run.is_some() {
out.push_str("\x1b[0m");
}
}
pub(crate) fn diff_cells_into(
out: &mut String,
row1: usize,
prev: &[ParsedCell],
next: &[ParsedCell],
) {
use std::fmt::Write as _;
let max_len = prev.len().max(next.len());
let mut col = 0usize;
while col < max_len {
if prev.get(col) == next.get(col) {
col += 1;
continue;
}
let start = col;
let mut last_diff = col;
let mut gap = 0usize;
col += 1;
while col < max_len {
if prev.get(col) != next.get(col) {
last_diff = col;
gap = 0;
} else {
gap += 1;
if gap > SPAN_MERGE_GAP {
break;
}
}
col += 1;
}
let end = last_diff + 1; let emit_end = end.min(next.len());
if start < emit_end {
let _ = write!(out, "\x1b[{row1};{}H", start + 1);
push_cells_run(out, &next[start..emit_end]);
}
if end > next.len() {
let _ = write!(out, "\x1b[{row1};{}H\x1b[K", next.len() + 1);
}
}
}
pub(crate) fn posterize_lut(levels: u8) -> [u8; 256] {
let levels = levels.max(2) as f32;
let step = 255.0 / (levels - 1.0);
let mut lut = [0u8; 256];
for (v, slot) in lut.iter_mut().enumerate() {
*slot = ((v as f32 / step).round() * step).clamp(0.0, 255.0) as u8;
}
lut
}
fn bytes_to_string(buf: Vec<u8>) -> String {
unsafe { String::from_utf8_unchecked(buf) }
}
pub(crate) fn render_ansi_fast(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();
}
let cells = grid.as_slice();
debug_assert_eq!(cells.len(), w * h);
let per_cell = match depth {
ColorDepth::TrueColor => 20,
ColorDepth::Ansi256 => 13,
ColorDepth::Ansi16 => 7,
};
let mut buf = Vec::with_capacity(h * (w * per_cell + 5));
match depth {
ColorDepth::TrueColor => {
let mut cur = u32::MAX;
for row in cells.chunks_exact(w) {
for cell in row {
let key = (cell.color.r as u32) << 16
| (cell.color.g as u32) << 8
| cell.color.b as u32;
if key != cur {
push_truecolor_escape(&mut buf, cell.color.r, cell.color.g, cell.color.b);
cur = key;
}
push_char(&mut buf, cell.ch);
}
buf.extend_from_slice(b"\x1b[0m\n");
cur = u32::MAX;
}
}
ColorDepth::Ansi256 => {
let mut cur: u32 = u32::MAX;
for row in cells.chunks_exact(w) {
for cell in row {
let code = nearest_ansi256(cell.color.r, cell.color.g, cell.color.b) as u32;
if code != cur {
push_ansi256_escape(&mut buf, code as u8);
cur = code;
}
push_char(&mut buf, cell.ch);
}
buf.extend_from_slice(b"\x1b[0m\n");
cur = u32::MAX;
}
}
ColorDepth::Ansi16 => {
let mut cur: u32 = u32::MAX;
for row in cells.chunks_exact(w) {
for cell in row {
let code = nearest_ansi16(cell.color.r, cell.color.g, cell.color.b) as u32;
if code != cur {
push_ansi16_escape(&mut buf, code as u8);
cur = code;
}
push_char(&mut buf, cell.ch);
}
buf.extend_from_slice(b"\x1b[0m\n");
cur = u32::MAX;
}
}
}
bytes_to_string(buf)
}
pub(crate) fn render_ansi_fast_lines(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();
}
let cells = grid.as_slice();
let per_cell = match depth {
ColorDepth::TrueColor => 20,
ColorDepth::Ansi256 => 13,
ColorDepth::Ansi16 => 7,
};
let mut lines = Vec::with_capacity(h);
for row in cells.chunks_exact(w) {
let mut buf = Vec::with_capacity(w * per_cell + 4);
match depth {
ColorDepth::TrueColor => {
let mut cur = u32::MAX;
for cell in row {
let key = (cell.color.r as u32) << 16
| (cell.color.g as u32) << 8
| cell.color.b as u32;
if key != cur {
push_truecolor_escape(&mut buf, cell.color.r, cell.color.g, cell.color.b);
cur = key;
}
push_char(&mut buf, cell.ch);
}
}
ColorDepth::Ansi256 => {
let mut cur: u32 = u32::MAX;
for cell in row {
let code = nearest_ansi256(cell.color.r, cell.color.g, cell.color.b) as u32;
if code != cur {
push_ansi256_escape(&mut buf, code as u8);
cur = code;
}
push_char(&mut buf, cell.ch);
}
}
ColorDepth::Ansi16 => {
let mut cur: u32 = u32::MAX;
for cell in row {
let code = nearest_ansi16(cell.color.r, cell.color.g, cell.color.b) as u32;
if code != cur {
push_ansi16_escape(&mut buf, code as u8);
cur = code;
}
push_char(&mut buf, cell.ch);
}
}
}
buf.extend_from_slice(b"\x1b[0m");
lines.push(bytes_to_string(buf));
}
lines
}
#[cfg(test)]
mod tests {
use super::*;
use iascii::config::{ConfigBuilder as AsciiConfigBuilder, OutputSizing};
use iascii::convert::convert_image;
fn gradient_grid(w: u32, h: u32) -> Grid {
let cfg = AsciiConfigBuilder::new()
.output_sizing(OutputSizing::Explicit {
width: w as usize,
height: h as usize,
})
.build()
.unwrap();
let mut pix = Vec::with_capacity((w * h * 3) as usize);
for y in 0..h {
for x in 0..w {
pix.push((x * 255 / w.max(1)) as u8);
pix.push((y * 255 / h.max(1)) as u8);
pix.push(((x + y) * 255 / (w + h).max(1)) as u8);
}
}
convert_image(w, h, &pix, &cfg).unwrap()
}
#[test]
fn matches_iascii_render_on_all_depths() {
for (w, h) in [(1, 1), (2, 2), (7, 5), (64, 32)] {
let grid = gradient_grid(w, h);
for depth in [
ColorDepth::TrueColor,
ColorDepth::Ansi256,
ColorDepth::Ansi16,
] {
assert_eq!(
render_ansi_fast(&grid, depth),
iascii::render::render_ansi(&grid, depth),
"mismatch at {w}x{h} {depth:?}"
);
}
}
}
#[test]
fn lines_variant_matches_split_render() {
let grid = gradient_grid(9, 6);
for depth in [
ColorDepth::TrueColor,
ColorDepth::Ansi256,
ColorDepth::Ansi16,
] {
let joined = render_ansi_fast(&grid, depth);
let expected: Vec<String> = joined.lines().map(str::to_owned).collect();
assert_eq!(render_ansi_fast_lines(&grid, depth), expected);
}
}
#[test]
fn ansi256_matches_reference_on_all_inputs() {
let mut checked = 0u32;
for r in (0..=255u16).step_by(7) {
for g in (0..=255u16).step_by(13) {
let b = ((r * 31 + g * 17) % 256) as u8;
assert_eq!(
nearest_ansi256(r as u8, g as u8, b),
reference_ansi256(r as u8, g as u8, b),
"mismatch at ({r},{g},{b})"
);
checked += 1;
}
}
for v in 0..=255u8 {
assert_eq!(nearest_ansi256(v, v, v), reference_ansi256(v, v, v));
}
assert!(checked > 500);
}
fn reference_ansi256(r: u8, g: u8, b: u8) -> u8 {
let closest = |v: u8| -> (usize, u8) {
let mut best_d = u32::MAX;
let mut best = (0, CUBE_LEVELS[0]);
for (i, &l) in CUBE_LEVELS.iter().enumerate() {
let d = (v as i32 - l as i32).unsigned_abs();
if d < best_d {
best_d = d;
best = (i, l);
}
}
best
};
let (ri, rv) = closest(r);
let (gi, gv) = closest(g);
let (bi, bv) = closest(b);
let cube_dist = {
let dr = r as i32 - rv as i32;
let dg = g as i32 - gv as i32;
let db = b as i32 - bv as i32;
(dr * dr + dg * dg + db * db) as u32
};
let gray_avg = ((r as u32 + g as u32 + b as u32) / 3) as u8;
let gray_idx = if gray_avg < 8 {
0
} else if gray_avg > 238 {
23
} else {
((gray_avg - 8) as f32 / 10.0).round() as u8
};
let gray_val = 8 + gray_idx * 10;
let gray_dist = {
let dr = r as i32 - gray_val as i32;
let dg = g as i32 - gray_val as i32;
let db = b as i32 - gray_val as i32;
(dr * dr + dg * dg + db * db) as u32
};
if gray_dist < cube_dist {
232 + gray_idx
} else {
16 + (36 * ri + 6 * gi + bi) as u8
}
}
#[test]
fn ansi16_matches_reference_on_sampled_inputs() {
for r in (0..=255u16).step_by(17) {
for g in (0..=255u16).step_by(19) {
let b = ((r + g * 3) % 256) as u8;
let (rr, gg, bb) = (r as u8, g as u8, b);
let mut best_d = u32::MAX;
let mut best_i = 0u8;
for (i, &(pr, pg, pb)) in ANSI16_PALETTE.iter().enumerate() {
let dr = rr as i32 - pr as i32;
let dg = gg as i32 - pg as i32;
let db = bb as i32 - pb as i32;
let d = (dr * dr + dg * dg + db * db) as u32;
if d < best_d {
best_d = d;
best_i = i as u8;
}
}
assert_eq!(nearest_ansi16(rr, gg, bb), best_i);
}
}
}
#[test]
fn posterize_lut_matches_float_formula() {
for levels in [2u8, 3, 6, 16] {
let lut = posterize_lut(levels);
let l = levels.max(2) as f32;
let step = 255.0 / (l - 1.0);
for v in 0..=255u8 {
let expected = ((v as f32 / step).round() * step).clamp(0.0, 255.0) as u8;
assert_eq!(lut[v as usize], expected, "levels={levels} v={v}");
}
}
}
fn parse(line: &str) -> Vec<ParsedCell> {
let mut out = Vec::new();
parse_sgr_cells(line, &mut out);
out
}
#[test]
fn parse_sgr_cells_reads_truecolor_256_and_16_color_escapes() {
let cells = parse("\x1b[38;2;10;20;30mAB\x1b[0mC\x1b[38;5;196mD");
assert_eq!(
cells,
vec![
('A', Some(Rgb::new(10, 20, 30))),
('B', Some(Rgb::new(10, 20, 30))),
('C', None),
('D', Some(Rgb::new(255, 0, 0))), ]
);
let cells = parse("\x1b[91mX\x1b[37mY");
assert_eq!(cells[0].1, Some(Rgb::new(255, 0, 0)));
assert_eq!(cells[1].1, Some(Rgb::new(192, 192, 192)));
assert_eq!(parse("ab"), vec![('a', None), ('b', None)]);
}
#[test]
fn diff_cells_into_is_empty_for_equal_rows() {
let row = parse("\x1b[38;2;1;2;3mab\x1b[0m");
let mut out = String::new();
diff_cells_into(&mut out, 1, &row, &row);
assert_eq!(out, "");
}
#[test]
fn diff_cells_into_patches_only_the_changed_span() {
let prev = parse("abcXXXghi");
let next = parse("abcYYYghi");
let mut out = String::new();
diff_cells_into(&mut out, 1, &prev, &next);
assert!(out.contains("YYY"));
assert!(!out.contains("abc"), "unchanged prefix must not be re-sent");
assert!(!out.contains("ghi"), "unchanged suffix must not be re-sent");
assert!(
out.starts_with("\x1b[1;4H"),
"cursor lands on the span start"
);
}
#[test]
fn diff_cells_into_never_splits_an_escape_sequence() {
let prev = parse("\x1b[38;2;255;0;0mAB");
let next = parse("\x1b[38;2;0;255;0mAB");
let mut out = String::new();
diff_cells_into(&mut out, 2, &prev, &next);
assert!(out.contains("\x1b[38;2;0;255;0mAB"), "got {out:?}");
let stripped = out.replace("\x1b[38;2;0;255;0m", "");
assert!(!stripped.contains('8'), "escape fragment leaked: {out:?}");
}
#[test]
fn diff_cells_into_merges_small_gaps_into_one_cursor_move() {
let prev = parse("aXcdefgh");
let next = parse("aYcZefgh");
let mut out = String::new();
diff_cells_into(&mut out, 1, &prev, &next);
assert_eq!(
out.matches("\x1b[1;").count(),
1,
"one cursor move: {out:?}"
);
assert!(
out.contains("YcZ"),
"gap cell re-sent inside the span: {out:?}"
);
}
#[test]
fn diff_cells_into_separate_wide_gaps_get_separate_moves() {
let prev = parse("aXcdefghijklmnop"); let next = parse("aYcdefghijkZmnop");
let mut out = String::new();
diff_cells_into(&mut out, 1, &prev, &next);
assert_eq!(
out.matches("\x1b[1;").count(),
2,
"gap wider than SPAN_MERGE_GAP: two moves: {out:?}"
);
}
#[test]
fn diff_cells_into_clears_a_shrunk_row_tail_once() {
let prev = parse("hello world");
let next = parse("hello");
let mut out = String::new();
diff_cells_into(&mut out, 1, &prev, &next);
assert_eq!(out, "\x1b[1;6H\x1b[K", "clear right after the new content");
}
#[test]
fn diff_cells_into_resets_before_uncolored_span_starts() {
let prev = parse("a b");
let next = parse("aXb");
let mut out = String::new();
diff_cells_into(&mut out, 1, &prev, &next);
assert!(out.contains("\x1b[0mX"), "reset before bare glyph: {out:?}");
}
#[test]
fn diff_cells_into_handles_row_appearing_and_vanishing() {
let mut out = String::new();
diff_cells_into(&mut out, 3, &[], &parse("ab"));
assert_eq!(out, "\x1b[3;1H\x1b[0mab");
let mut out = String::new();
diff_cells_into(&mut out, 3, &parse("ab"), &[]);
assert_eq!(out, "\x1b[3;1H\x1b[K");
}
#[test]
fn apply_sgr_ignores_malformed_and_overflowing_params() {
let mut cur = Some(Rgb::new(1, 1, 1));
apply_sgr("38;2;999;0;0", &mut cur); assert_eq!(cur, Some(Rgb::new(1, 1, 1)), "malformed leaves color alone");
apply_sgr("1;2;3;4;5;6", &mut cur); assert_eq!(cur, Some(Rgb::new(1, 1, 1)));
apply_sgr("0", &mut cur);
assert_eq!(cur, None);
}
}