use std::collections::HashMap;
use std::fmt::Write;
use crate::buffer::CellChange;
use crate::cell::Cell;
use crate::color::{Color, NamedColor};
use crate::style::Style;
use crate::terminal::ColorSupport;
pub struct Renderer {
color_support: ColorSupport,
synchronized_output: bool,
}
impl Renderer {
pub fn new(color_support: ColorSupport, synchronized_output: bool) -> Self {
Self {
color_support,
synchronized_output,
}
}
pub fn render(&self, changes: &[CellChange]) -> String {
if changes.is_empty() {
return String::new();
}
let mut output = String::with_capacity(changes.len() * 16);
if self.synchronized_output {
output.push_str("\x1b[?2026h");
}
let mut last_x: Option<u16> = None;
let mut last_y: Option<u16> = None;
let mut last_style = Style::default();
let mut style_active = false;
for change in changes {
if change.cell.width == 0 {
continue;
}
let need_move = !matches!((last_x, last_y), (Some(lx), Some(ly)) if ly == change.y && lx == change.x);
if need_move {
let _ = write!(output, "\x1b[{};{}H", change.y + 1, change.x + 1);
}
self.write_style_diff(&mut output, &last_style, &change.cell.style, style_active);
last_style = change.cell.style.clone();
style_active = true;
output.push_str(&change.cell.grapheme);
last_x = Some(change.x + u16::from(change.cell.width));
last_y = Some(change.y);
}
if style_active && !last_style.is_empty() {
output.push_str("\x1b[0m");
}
if self.synchronized_output {
output.push_str("\x1b[?2026l");
}
output
}
pub fn render_batched(&self, changes: &[CellChange]) -> String {
let batches = batch_changes(changes);
if batches.is_empty() {
return String::new();
}
let mut output = String::with_capacity(changes.len() * 12);
if self.synchronized_output {
output.push_str("\x1b[?2026h");
}
let mut last_style = Style::default();
let mut style_active = false;
let mut last_cursor_x: Option<u16> = None;
let mut last_cursor_y: Option<u16> = None;
for batch in &batches {
let need_move = !matches!(
(last_cursor_x, last_cursor_y),
(Some(lx), Some(ly)) if ly == batch.y && lx == batch.x
);
if need_move {
let _ = write!(output, "\x1b[{};{}H", batch.y + 1, batch.x + 1);
}
let mut cursor_x = batch.x;
for cell in &batch.cells {
self.write_style_diff(&mut output, &last_style, &cell.style, style_active);
last_style = cell.style.clone();
style_active = true;
output.push_str(&cell.grapheme);
cursor_x += u16::from(cell.width);
}
last_cursor_x = Some(cursor_x);
last_cursor_y = Some(batch.y);
}
if style_active && !last_style.is_empty() {
output.push_str("\x1b[0m");
}
if self.synchronized_output {
output.push_str("\x1b[?2026l");
}
output
}
pub fn render_optimized(&self, changes: &[CellChange]) -> String {
if changes.is_empty() {
return String::new();
}
let mut output = String::with_capacity(changes.len() * 16);
output.push_str("\x1b[?25l");
if self.synchronized_output {
output.push_str("\x1b[?2026h");
}
let mut last_x: Option<u16> = None;
let mut last_y: Option<u16> = None;
let mut last_style = Style::default();
let mut style_active = false;
for change in changes {
if change.cell.width == 0 {
continue;
}
let need_move = !matches!((last_x, last_y), (Some(lx), Some(ly)) if ly == change.y && lx == change.x);
if need_move {
let _ = write!(output, "\x1b[{};{}H", change.y + 1, change.x + 1);
}
if !style_active
|| needs_reset(&last_style, &change.cell.style)
|| last_style != change.cell.style
{
if style_active && !last_style.is_empty() {
output.push_str("\x1b[0m");
}
let sgr = build_sgr_sequence(&change.cell.style, self.color_support);
output.push_str(&sgr);
}
last_style = change.cell.style.clone();
style_active = true;
output.push_str(&change.cell.grapheme);
last_x = Some(change.x + u16::from(change.cell.width));
last_y = Some(change.y);
}
if style_active && !last_style.is_empty() {
output.push_str("\x1b[0m");
}
if self.synchronized_output {
output.push_str("\x1b[?2026l");
}
output.push_str("\x1b[?25h");
output
}
fn write_style_diff(&self, output: &mut String, prev: &Style, next: &Style, active: bool) {
if !active || needs_reset(prev, next) {
if active && !prev.is_empty() {
output.push_str("\x1b[0m");
}
self.write_full_style(output, next);
return;
}
if prev.fg != next.fg {
self.write_fg(output, &next.fg);
}
if prev.bg != next.bg {
self.write_bg(output, &next.bg);
}
if !prev.bold && next.bold {
output.push_str("\x1b[1m");
}
if !prev.dim && next.dim {
output.push_str("\x1b[2m");
}
if !prev.italic && next.italic {
output.push_str("\x1b[3m");
}
if !prev.underline && next.underline {
output.push_str("\x1b[4m");
}
if !prev.reverse && next.reverse {
output.push_str("\x1b[7m");
}
if !prev.strikethrough && next.strikethrough {
output.push_str("\x1b[9m");
}
}
fn write_full_style(&self, output: &mut String, style: &Style) {
self.write_fg(output, &style.fg);
self.write_bg(output, &style.bg);
if style.bold {
output.push_str("\x1b[1m");
}
if style.dim {
output.push_str("\x1b[2m");
}
if style.italic {
output.push_str("\x1b[3m");
}
if style.underline {
output.push_str("\x1b[4m");
}
if style.reverse {
output.push_str("\x1b[7m");
}
if style.strikethrough {
output.push_str("\x1b[9m");
}
}
fn write_fg(&self, output: &mut String, color: &Option<Color>) {
match color {
None => {}
Some(c) => {
let downgraded = self.downgrade_color(c);
write_fg_color(output, &downgraded);
}
}
}
fn write_bg(&self, output: &mut String, color: &Option<Color>) {
match color {
None => {}
Some(c) => {
let downgraded = self.downgrade_color(c);
write_bg_color(output, &downgraded);
}
}
}
fn downgrade_color<'a>(&self, color: &'a Color) -> std::borrow::Cow<'a, Color> {
if std::env::var("NO_COLOR").is_ok() {
return std::borrow::Cow::Owned(Color::Reset);
}
match self.color_support {
ColorSupport::TrueColor => std::borrow::Cow::Borrowed(color),
ColorSupport::Extended256 => match color {
Color::Rgb { r, g, b } => {
std::borrow::Cow::Owned(Color::Indexed(rgb_to_256(*r, *g, *b)))
}
_ => std::borrow::Cow::Borrowed(color),
},
ColorSupport::Basic16 => match color {
Color::Rgb { r, g, b } => {
std::borrow::Cow::Owned(Color::Named(rgb_to_16(*r, *g, *b)))
}
Color::Indexed(i) => std::borrow::Cow::Owned(Color::Named(index_to_named(*i))),
_ => std::borrow::Cow::Borrowed(color),
},
ColorSupport::NoColor => std::borrow::Cow::Owned(Color::Reset),
}
}
}
fn needs_reset(prev: &Style, next: &Style) -> bool {
(prev.bold && !next.bold)
|| (prev.dim && !next.dim)
|| (prev.italic && !next.italic)
|| (prev.underline && !next.underline)
|| (prev.reverse && !next.reverse)
|| (prev.strikethrough && !next.strikethrough)
}
pub fn build_sgr_sequence(style: &Style, color_support: ColorSupport) -> String {
let mut codes: Vec<String> = Vec::new();
if style.bold {
codes.push("1".to_string());
}
if style.dim {
codes.push("2".to_string());
}
if style.italic {
codes.push("3".to_string());
}
if style.underline {
codes.push("4".to_string());
}
if style.reverse {
codes.push("7".to_string());
}
if style.strikethrough {
codes.push("9".to_string());
}
if let Some(ref fg) = style.fg {
let downgraded = downgrade_color_standalone(fg, color_support);
codes.extend(fg_color_codes(&downgraded));
}
if let Some(ref bg) = style.bg {
let downgraded = downgrade_color_standalone(bg, color_support);
codes.extend(bg_color_codes(&downgraded));
}
if codes.is_empty() {
return String::new();
}
format!("\x1b[{}m", codes.join(";"))
}
fn downgrade_color_standalone(color: &Color, support: ColorSupport) -> Color {
if std::env::var("NO_COLOR").is_ok() {
return Color::Reset;
}
match support {
ColorSupport::TrueColor => color.clone(),
ColorSupport::Extended256 => match color {
Color::Rgb { r, g, b } => Color::Indexed(rgb_to_256(*r, *g, *b)),
_ => color.clone(),
},
ColorSupport::Basic16 => match color {
Color::Rgb { r, g, b } => Color::Named(rgb_to_16(*r, *g, *b)),
Color::Indexed(i) => Color::Named(index_to_named(*i)),
_ => color.clone(),
},
ColorSupport::NoColor => Color::Reset,
}
}
fn fg_color_codes(color: &Color) -> Vec<String> {
match color {
Color::Rgb { r, g, b } => vec![
"38".to_string(),
"2".to_string(),
r.to_string(),
g.to_string(),
b.to_string(),
],
Color::Indexed(i) => vec!["38".to_string(), "5".to_string(), i.to_string()],
Color::Named(n) => vec![named_fg_code(n).to_string()],
Color::Reset => vec!["39".to_string()],
}
}
fn bg_color_codes(color: &Color) -> Vec<String> {
match color {
Color::Rgb { r, g, b } => vec![
"48".to_string(),
"2".to_string(),
r.to_string(),
g.to_string(),
b.to_string(),
],
Color::Indexed(i) => vec!["48".to_string(), "5".to_string(), i.to_string()],
Color::Named(n) => vec![named_bg_code(n).to_string()],
Color::Reset => vec!["49".to_string()],
}
}
fn write_fg_color(output: &mut String, color: &Color) {
match color {
Color::Rgb { r, g, b } => {
let _ = write!(output, "\x1b[38;2;{r};{g};{b}m");
}
Color::Indexed(i) => {
let _ = write!(output, "\x1b[38;5;{i}m");
}
Color::Named(n) => {
let _ = write!(output, "\x1b[{}m", named_fg_code(n));
}
Color::Reset => {
output.push_str("\x1b[39m");
}
}
}
fn write_bg_color(output: &mut String, color: &Color) {
match color {
Color::Rgb { r, g, b } => {
let _ = write!(output, "\x1b[48;2;{r};{g};{b}m");
}
Color::Indexed(i) => {
let _ = write!(output, "\x1b[48;5;{i}m");
}
Color::Named(n) => {
let _ = write!(output, "\x1b[{}m", named_bg_code(n));
}
Color::Reset => {
output.push_str("\x1b[49m");
}
}
}
fn named_fg_code(color: &NamedColor) -> u8 {
match color {
NamedColor::Black => 30,
NamedColor::Red => 31,
NamedColor::Green => 32,
NamedColor::Yellow => 33,
NamedColor::Blue => 34,
NamedColor::Magenta => 35,
NamedColor::Cyan => 36,
NamedColor::White => 37,
NamedColor::BrightBlack => 90,
NamedColor::BrightRed => 91,
NamedColor::BrightGreen => 92,
NamedColor::BrightYellow => 93,
NamedColor::BrightBlue => 94,
NamedColor::BrightMagenta => 95,
NamedColor::BrightCyan => 96,
NamedColor::BrightWhite => 97,
}
}
fn named_bg_code(color: &NamedColor) -> u8 {
match color {
NamedColor::Black => 40,
NamedColor::Red => 41,
NamedColor::Green => 42,
NamedColor::Yellow => 43,
NamedColor::Blue => 44,
NamedColor::Magenta => 45,
NamedColor::Cyan => 46,
NamedColor::White => 47,
NamedColor::BrightBlack => 100,
NamedColor::BrightRed => 101,
NamedColor::BrightGreen => 102,
NamedColor::BrightYellow => 103,
NamedColor::BrightBlue => 104,
NamedColor::BrightMagenta => 105,
NamedColor::BrightCyan => 106,
NamedColor::BrightWhite => 107,
}
}
#[derive(Debug)]
pub struct ColorMapper {
cache_256: HashMap<(u8, u8, u8), u8>,
cache_16: HashMap<(u8, u8, u8), NamedColor>,
}
impl Default for ColorMapper {
fn default() -> Self {
Self::new()
}
}
impl ColorMapper {
pub fn new() -> Self {
Self {
cache_256: HashMap::new(),
cache_16: HashMap::new(),
}
}
pub fn map_to_256(&mut self, r: u8, g: u8, b: u8) -> u8 {
if let Some(&cached) = self.cache_256.get(&(r, g, b)) {
return cached;
}
let result = rgb_to_256(r, g, b);
self.cache_256.insert((r, g, b), result);
result
}
pub fn map_to_16(&mut self, r: u8, g: u8, b: u8) -> NamedColor {
if let Some(&cached) = self.cache_16.get(&(r, g, b)) {
return cached;
}
let result = rgb_to_16(r, g, b);
self.cache_16.insert((r, g, b), result);
result
}
pub fn clear_cache(&mut self) {
self.cache_256.clear();
self.cache_16.clear();
}
}
#[derive(Debug, Clone, Copy)]
struct Lab {
l: f32,
a: f32,
b: f32,
}
fn rgb_to_lab(r: u8, g: u8, b: u8) -> Lab {
let r_linear = srgb_to_linear(r);
let g_linear = srgb_to_linear(g);
let b_linear = srgb_to_linear(b);
let x = r_linear * 0.4124 + g_linear * 0.3576 + b_linear * 0.1805;
let y = r_linear * 0.2126 + g_linear * 0.7152 + b_linear * 0.0722;
let z = r_linear * 0.0193 + g_linear * 0.1192 + b_linear * 0.9505;
let x_n = 0.95047;
let y_n = 1.0;
let z_n = 1.08883;
let fx = lab_f(x / x_n);
let fy = lab_f(y / y_n);
let fz = lab_f(z / z_n);
let l = 116.0 * fy - 16.0;
let a = 500.0 * (fx - fy);
let b = 200.0 * (fy - fz);
Lab { l, a, b }
}
fn srgb_to_linear(c: u8) -> f32 {
let c_norm = f32::from(c) / 255.0;
if c_norm <= 0.04045 {
c_norm / 12.92
} else {
((c_norm + 0.055) / 1.055).powf(2.4)
}
}
fn lab_f(t: f32) -> f32 {
let delta: f32 = 6.0 / 29.0;
if t > delta.powi(3) {
t.cbrt()
} else {
t / (3.0 * delta.powi(2)) + 4.0 / 29.0
}
}
fn lab_distance(lab1: Lab, lab2: Lab) -> f32 {
let dl = lab1.l - lab2.l;
let da = lab1.a - lab2.a;
let db = lab1.b - lab2.b;
(dl * dl + da * da + db * db).sqrt()
}
pub fn rgb_to_256(r: u8, g: u8, b: u8) -> u8 {
let source_lab = rgb_to_lab(r, g, b);
let mut best_idx = 16_u8;
let mut best_distance = f32::MAX;
for i in 0..24_u8 {
let gray = 8 + 10 * i;
let lab = rgb_to_lab(gray, gray, gray);
let dist = lab_distance(source_lab, lab);
if dist < best_distance {
best_distance = dist;
best_idx = 232 + i;
}
}
for ri in 0..6_u8 {
for gi in 0..6_u8 {
for bi in 0..6_u8 {
let r_val = if ri == 0 { 0 } else { 55 + 40 * ri };
let g_val = if gi == 0 { 0 } else { 55 + 40 * gi };
let b_val = if bi == 0 { 0 } else { 55 + 40 * bi };
let lab = rgb_to_lab(r_val, g_val, b_val);
let dist = lab_distance(source_lab, lab);
if dist < best_distance {
best_distance = dist;
best_idx = 16 + 36 * ri + 6 * gi + bi;
}
}
}
}
let basic_16_rgb = [
(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), ];
for (i, (cr, cg, cb)) in basic_16_rgb.iter().enumerate() {
let lab = rgb_to_lab(*cr, *cg, *cb);
let dist = lab_distance(source_lab, lab);
if dist < best_distance {
best_distance = dist;
best_idx = i as u8;
}
}
best_idx
}
pub fn rgb_to_16(r: u8, g: u8, b: u8) -> NamedColor {
let source_lab = rgb_to_lab(r, g, b);
let candidates: [(NamedColor, (u8, u8, u8)); 16] = [
(NamedColor::Black, (0, 0, 0)),
(NamedColor::Red, (128, 0, 0)),
(NamedColor::Green, (0, 128, 0)),
(NamedColor::Yellow, (128, 128, 0)),
(NamedColor::Blue, (0, 0, 128)),
(NamedColor::Magenta, (128, 0, 128)),
(NamedColor::Cyan, (0, 128, 128)),
(NamedColor::White, (192, 192, 192)),
(NamedColor::BrightBlack, (128, 128, 128)),
(NamedColor::BrightRed, (255, 0, 0)),
(NamedColor::BrightGreen, (0, 255, 0)),
(NamedColor::BrightYellow, (255, 255, 0)),
(NamedColor::BrightBlue, (0, 0, 255)),
(NamedColor::BrightMagenta, (255, 0, 255)),
(NamedColor::BrightCyan, (0, 255, 255)),
(NamedColor::BrightWhite, (255, 255, 255)),
];
let mut best = NamedColor::White;
let mut best_distance = f32::MAX;
for (name, (cr, cg, cb)) in &candidates {
let lab = rgb_to_lab(*cr, *cg, *cb);
let dist = lab_distance(source_lab, lab);
if dist < best_distance {
best_distance = dist;
best = *name;
}
}
best
}
#[allow(dead_code)] fn color_cube_index(val: u8) -> u8 {
if val < 48 {
0
} else if val < 115 {
1
} else {
((u16::from(val) - 35) / 40) as u8
}
}
pub fn rgb_to_named(r: u8, g: u8, b: u8) -> NamedColor {
rgb_to_16(r, g, b)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeltaBatch {
pub x: u16,
pub y: u16,
pub cells: Vec<Cell>,
}
pub fn batch_changes(changes: &[CellChange]) -> Vec<DeltaBatch> {
let mut batches: Vec<DeltaBatch> = Vec::new();
for change in changes {
if change.cell.width == 0 {
continue;
}
let can_extend = match batches.last() {
Some(batch) => {
batch.y == change.y && {
let last_cell_x = batch.x;
let total_width: u16 = batch.cells.iter().map(|c| u16::from(c.width)).sum();
last_cell_x + total_width == change.x
}
}
None => false,
};
if can_extend {
match batches.last_mut() {
Some(batch) => batch.cells.push(change.cell.clone()),
None => unreachable!(),
}
} else {
batches.push(DeltaBatch {
x: change.x,
y: change.y,
cells: vec![change.cell.clone()],
});
}
}
batches
}
fn index_to_named(idx: u8) -> NamedColor {
match idx {
0 => NamedColor::Black,
1 => NamedColor::Red,
2 => NamedColor::Green,
3 => NamedColor::Yellow,
4 => NamedColor::Blue,
5 => NamedColor::Magenta,
6 => NamedColor::Cyan,
7 => NamedColor::White,
8 => NamedColor::BrightBlack,
9 => NamedColor::BrightRed,
10 => NamedColor::BrightGreen,
11 => NamedColor::BrightYellow,
12 => NamedColor::BrightBlue,
13 => NamedColor::BrightMagenta,
14 => NamedColor::BrightCyan,
15 => NamedColor::BrightWhite,
16..=231 => {
let idx = idx - 16;
let b_idx = idx % 6;
let g_idx = (idx / 6) % 6;
let r_idx = idx / 36;
let r = if r_idx == 0 { 0 } else { 55 + 40 * r_idx };
let g = if g_idx == 0 { 0 } else { 55 + 40 * g_idx };
let b = if b_idx == 0 { 0 } else { 55 + 40 * b_idx };
rgb_to_16(r, g, b)
}
_ => {
let gray = 8 + 10 * (idx - 232);
rgb_to_16(gray, gray, gray)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::buffer::CellChange;
use crate::cell::Cell;
#[test]
fn render_empty_changes() {
let renderer = Renderer::new(ColorSupport::TrueColor, false);
let output = renderer.render(&[]);
assert!(output.is_empty());
}
#[test]
fn render_cursor_position() {
let renderer = Renderer::new(ColorSupport::TrueColor, false);
let changes = vec![CellChange {
x: 5,
y: 3,
cell: Cell::new("A", Style::default()),
}];
let output = renderer.render(&changes);
assert!(output.contains("\x1b[4;6H"));
assert!(output.contains('A'));
}
#[test]
fn render_adjacent_cells_no_redundant_move() {
let renderer = Renderer::new(ColorSupport::TrueColor, false);
let changes = vec![
CellChange {
x: 0,
y: 0,
cell: Cell::new("A", Style::default()),
},
CellChange {
x: 1,
y: 0,
cell: Cell::new("B", Style::default()),
},
];
let output = renderer.render(&changes);
let move_count = output.matches("\x1b[").count();
assert_eq!(move_count, 1, "output: {output:?}");
}
#[test]
fn render_fg_truecolor() {
let renderer = Renderer::new(ColorSupport::TrueColor, false);
let style = Style::new().fg(Color::Rgb {
r: 255,
g: 128,
b: 0,
});
let changes = vec![CellChange {
x: 0,
y: 0,
cell: Cell::new("X", style),
}];
let output = renderer.render(&changes);
assert!(output.contains("\x1b[38;2;255;128;0m"));
}
#[test]
fn render_bg_truecolor() {
let renderer = Renderer::new(ColorSupport::TrueColor, false);
let style = Style::new().bg(Color::Rgb {
r: 0,
g: 128,
b: 255,
});
let changes = vec![CellChange {
x: 0,
y: 0,
cell: Cell::new("X", style),
}];
let output = renderer.render(&changes);
assert!(output.contains("\x1b[48;2;0;128;255m"));
}
#[test]
fn render_bold_italic() {
let renderer = Renderer::new(ColorSupport::TrueColor, false);
let style = Style::new().bold(true).italic(true);
let changes = vec![CellChange {
x: 0,
y: 0,
cell: Cell::new("X", style),
}];
let output = renderer.render(&changes);
assert!(output.contains("\x1b[1m")); assert!(output.contains("\x1b[3m")); }
#[test]
fn render_named_color() {
let renderer = Renderer::new(ColorSupport::TrueColor, false);
let style = Style::new().fg(Color::Named(NamedColor::Red));
let changes = vec![CellChange {
x: 0,
y: 0,
cell: Cell::new("X", style),
}];
let output = renderer.render(&changes);
assert!(output.contains("\x1b[31m")); }
#[test]
fn render_indexed_color() {
let renderer = Renderer::new(ColorSupport::TrueColor, false);
let style = Style::new().fg(Color::Indexed(42));
let changes = vec![CellChange {
x: 0,
y: 0,
cell: Cell::new("X", style),
}];
let output = renderer.render(&changes);
assert!(output.contains("\x1b[38;5;42m"));
}
#[test]
fn render_style_reset_at_end() {
let renderer = Renderer::new(ColorSupport::TrueColor, false);
let style = Style::new().bold(true);
let changes = vec![CellChange {
x: 0,
y: 0,
cell: Cell::new("X", style),
}];
let output = renderer.render(&changes);
assert!(output.ends_with("\x1b[0m"));
}
#[test]
fn render_no_reset_for_default_style() {
let renderer = Renderer::new(ColorSupport::TrueColor, false);
let changes = vec![CellChange {
x: 0,
y: 0,
cell: Cell::new("X", Style::default()),
}];
let output = renderer.render(&changes);
assert!(!output.contains("\x1b[0m"));
}
#[test]
fn render_skip_continuation_cells() {
let renderer = Renderer::new(ColorSupport::TrueColor, false);
let changes = vec![
CellChange {
x: 0,
y: 0,
cell: Cell::new("\u{4e16}", Style::default()), },
CellChange {
x: 1,
y: 0,
cell: Cell::continuation(), },
];
let output = renderer.render(&changes);
assert!(output.contains("\u{4e16}"));
let esc_count = output.matches("\x1b[").count();
assert_eq!(esc_count, 1);
}
#[test]
fn synchronized_output_wrapping() {
let renderer = Renderer::new(ColorSupport::TrueColor, true);
let changes = vec![CellChange {
x: 0,
y: 0,
cell: Cell::new("A", Style::default()),
}];
let output = renderer.render(&changes);
assert!(output.starts_with("\x1b[?2026h"));
assert!(output.ends_with("\x1b[?2026l"));
}
#[test]
fn no_sync_when_disabled() {
let renderer = Renderer::new(ColorSupport::TrueColor, false);
let changes = vec![CellChange {
x: 0,
y: 0,
cell: Cell::new("A", Style::default()),
}];
let output = renderer.render(&changes);
assert!(!output.contains("\x1b[?2026h"));
assert!(!output.contains("\x1b[?2026l"));
}
#[test]
fn truecolor_passthrough() {
let renderer = Renderer::new(ColorSupport::TrueColor, false);
let style = Style::new().fg(Color::Rgb {
r: 100,
g: 200,
b: 50,
});
let changes = vec![CellChange {
x: 0,
y: 0,
cell: Cell::new("X", style),
}];
let output = renderer.render(&changes);
assert!(output.contains("\x1b[38;2;100;200;50m"));
}
#[test]
fn truecolor_to_256() {
let renderer = Renderer::new(ColorSupport::Extended256, false);
let style = Style::new().fg(Color::Rgb { r: 255, g: 0, b: 0 });
let changes = vec![CellChange {
x: 0,
y: 0,
cell: Cell::new("X", style),
}];
let output = renderer.render(&changes);
assert!(output.contains("\x1b[38;5;"));
assert!(!output.contains("\x1b[38;2;"));
}
#[test]
fn truecolor_to_16() {
let renderer = Renderer::new(ColorSupport::Basic16, false);
let style = Style::new().fg(Color::Rgb { r: 255, g: 0, b: 0 });
let changes = vec![CellChange {
x: 0,
y: 0,
cell: Cell::new("X", style),
}];
let output = renderer.render(&changes);
assert!(output.contains("\x1b[91m"));
}
#[test]
fn no_color_strips_all() {
let renderer = Renderer::new(ColorSupport::NoColor, false);
let style = Style::new()
.fg(Color::Rgb { r: 255, g: 0, b: 0 })
.bg(Color::Named(NamedColor::Blue));
let changes = vec![CellChange {
x: 0,
y: 0,
cell: Cell::new("X", style),
}];
let output = renderer.render(&changes);
assert!(output.contains("\x1b[39m")); assert!(output.contains("\x1b[49m")); }
#[test]
fn rgb_to_256_pure_red() {
let idx = rgb_to_256(255, 0, 0);
assert_eq!(idx, 196);
}
#[test]
fn rgb_to_256_grayscale() {
let idx = rgb_to_256(128, 128, 128);
assert_eq!(idx, 244);
}
#[test]
fn rgb_to_256_black() {
let idx = rgb_to_256(0, 0, 0);
assert_eq!(idx, 16); }
#[test]
fn rgb_to_named_pure_red() {
let named = rgb_to_named(255, 0, 0);
assert_eq!(named, NamedColor::BrightRed);
}
#[test]
fn rgb_to_named_pure_black() {
let named = rgb_to_named(0, 0, 0);
assert_eq!(named, NamedColor::Black);
}
#[test]
fn rgb_to_named_pure_white() {
let named = rgb_to_named(255, 255, 255);
assert_eq!(named, NamedColor::BrightWhite);
}
#[test]
fn batch_changes_empty() {
let batches = batch_changes(&[]);
assert!(batches.is_empty());
}
#[test]
fn batch_changes_single_cell() {
let changes = vec![CellChange {
x: 3,
y: 1,
cell: Cell::new("A", Style::default()),
}];
let batches = batch_changes(&changes);
assert_eq!(batches.len(), 1);
assert_eq!(batches[0].x, 3);
assert_eq!(batches[0].y, 1);
assert_eq!(batches[0].cells.len(), 1);
assert_eq!(batches[0].cells[0].grapheme, "A");
}
#[test]
fn batch_changes_consecutive_same_row() {
let changes = vec![
CellChange {
x: 0,
y: 0,
cell: Cell::new("A", Style::default()),
},
CellChange {
x: 1,
y: 0,
cell: Cell::new("B", Style::default()),
},
CellChange {
x: 2,
y: 0,
cell: Cell::new("C", Style::default()),
},
];
let batches = batch_changes(&changes);
assert_eq!(batches.len(), 1);
assert_eq!(batches[0].cells.len(), 3);
assert_eq!(batches[0].cells[0].grapheme, "A");
assert_eq!(batches[0].cells[1].grapheme, "B");
assert_eq!(batches[0].cells[2].grapheme, "C");
}
#[test]
fn batch_changes_different_rows() {
let changes = vec![
CellChange {
x: 0,
y: 0,
cell: Cell::new("A", Style::default()),
},
CellChange {
x: 0,
y: 1,
cell: Cell::new("B", Style::default()),
},
];
let batches = batch_changes(&changes);
assert_eq!(batches.len(), 2);
assert_eq!(batches[0].y, 0);
assert_eq!(batches[1].y, 1);
}
#[test]
fn batch_changes_gap_in_column() {
let changes = vec![
CellChange {
x: 0,
y: 0,
cell: Cell::new("A", Style::default()),
},
CellChange {
x: 5,
y: 0,
cell: Cell::new("B", Style::default()),
},
];
let batches = batch_changes(&changes);
assert_eq!(batches.len(), 2);
assert_eq!(batches[0].x, 0);
assert_eq!(batches[1].x, 5);
}
#[test]
fn batch_changes_skips_continuation_cells() {
let changes = vec![
CellChange {
x: 0,
y: 0,
cell: Cell::new("\u{4e16}", Style::default()), },
CellChange {
x: 1,
y: 0,
cell: Cell::continuation(), },
CellChange {
x: 2,
y: 0,
cell: Cell::new("A", Style::default()),
},
];
let batches = batch_changes(&changes);
assert_eq!(batches.len(), 1);
assert_eq!(batches[0].cells.len(), 2);
assert_eq!(batches[0].cells[0].grapheme, "\u{4e16}");
assert_eq!(batches[0].cells[1].grapheme, "A");
}
#[test]
fn batch_changes_wide_characters() {
let changes = vec![
CellChange {
x: 0,
y: 0,
cell: Cell::new("\u{4e16}", Style::default()), },
CellChange {
x: 1,
y: 0,
cell: Cell::continuation(),
},
CellChange {
x: 2,
y: 0,
cell: Cell::new("\u{754c}", Style::default()), },
];
let batches = batch_changes(&changes);
assert_eq!(batches.len(), 1);
assert_eq!(batches[0].cells.len(), 2);
}
#[test]
fn render_batched_empty() {
let renderer = Renderer::new(ColorSupport::TrueColor, false);
let output = renderer.render_batched(&[]);
assert!(output.is_empty());
}
#[test]
fn render_batched_produces_valid_output() {
let renderer = Renderer::new(ColorSupport::TrueColor, false);
let changes = vec![
CellChange {
x: 0,
y: 0,
cell: Cell::new("A", Style::default()),
},
CellChange {
x: 1,
y: 0,
cell: Cell::new("B", Style::default()),
},
];
let output = renderer.render_batched(&changes);
assert!(output.contains('A'));
assert!(output.contains('B'));
assert!(output.contains("\x1b[1;1H"));
}
#[test]
fn render_batched_no_longer_than_render() {
let renderer = Renderer::new(ColorSupport::TrueColor, false);
let style = Style::new().fg(Color::Named(NamedColor::Red));
let changes = vec![
CellChange {
x: 0,
y: 0,
cell: Cell::new("A", style.clone()),
},
CellChange {
x: 1,
y: 0,
cell: Cell::new("B", style.clone()),
},
CellChange {
x: 2,
y: 0,
cell: Cell::new("C", style),
},
];
let normal_output = renderer.render(&changes);
let batched_output = renderer.render_batched(&changes);
assert!(batched_output.len() <= normal_output.len());
}
#[test]
fn render_batched_with_styles() {
let renderer = Renderer::new(ColorSupport::TrueColor, false);
let style = Style::new().bold(true).fg(Color::Named(NamedColor::Blue));
let changes = vec![CellChange {
x: 0,
y: 0,
cell: Cell::new("X", style),
}];
let output = renderer.render_batched(&changes);
assert!(output.contains("\x1b[1m")); assert!(output.contains("\x1b[34m")); assert!(output.contains('X'));
assert!(output.ends_with("\x1b[0m")); }
#[test]
fn render_optimized_starts_with_cursor_hide() {
let renderer = Renderer::new(ColorSupport::TrueColor, false);
let changes = vec![CellChange {
x: 0,
y: 0,
cell: Cell::new("A", Style::default()),
}];
let output = renderer.render_optimized(&changes);
assert!(output.starts_with("\x1b[?25l"));
}
#[test]
fn render_optimized_ends_with_cursor_show() {
let renderer = Renderer::new(ColorSupport::TrueColor, false);
let changes = vec![CellChange {
x: 0,
y: 0,
cell: Cell::new("A", Style::default()),
}];
let output = renderer.render_optimized(&changes);
assert!(output.ends_with("\x1b[?25h"));
}
#[test]
fn render_optimized_sync_before_cursor_show() {
let renderer = Renderer::new(ColorSupport::TrueColor, true);
let changes = vec![CellChange {
x: 0,
y: 0,
cell: Cell::new("A", Style::default()),
}];
let output = renderer.render_optimized(&changes);
assert!(output.starts_with("\x1b[?25l\x1b[?2026h"));
assert!(output.ends_with("\x1b[?2026l\x1b[?25h"));
}
#[test]
fn build_sgr_combined_bold_italic_red() {
let style = Style::new()
.bold(true)
.italic(true)
.fg(Color::Named(NamedColor::Red));
let sgr = build_sgr_sequence(&style, ColorSupport::TrueColor);
assert!(sgr.starts_with("\x1b["));
assert!(sgr.ends_with('m'));
assert!(sgr.contains("1;"));
assert!(sgr.contains(";3;"));
assert!(sgr.contains("31"));
let esc_count = sgr.matches("\x1b[").count();
assert_eq!(esc_count, 1);
}
#[test]
fn build_sgr_default_style_is_empty() {
let style = Style::default();
let sgr = build_sgr_sequence(&style, ColorSupport::TrueColor);
assert!(sgr.is_empty());
}
#[test]
fn render_optimized_contains_correct_content() {
let renderer = Renderer::new(ColorSupport::TrueColor, false);
let style = Style::new().bold(true).fg(Color::Named(NamedColor::Green));
let changes = vec![CellChange {
x: 0,
y: 0,
cell: Cell::new("Z", style),
}];
let output = renderer.render_optimized(&changes);
assert!(output.contains('Z'));
assert!(output.contains("1;"));
assert!(output.contains("32"));
assert!(output.contains("\x1b[0m"));
}
#[test]
fn render_optimized_empty_is_empty() {
let renderer = Renderer::new(ColorSupport::TrueColor, false);
let output = renderer.render_optimized(&[]);
assert!(output.is_empty());
}
#[test]
fn build_sgr_truecolor_rgb() {
let style = Style::new().fg(Color::Rgb {
r: 100,
g: 200,
b: 50,
});
let sgr = build_sgr_sequence(&style, ColorSupport::TrueColor);
assert_eq!(sgr, "\x1b[38;2;100;200;50m");
}
#[test]
fn build_sgr_fg_and_bg() {
let style = Style::new()
.fg(Color::Named(NamedColor::Red))
.bg(Color::Named(NamedColor::Blue));
let sgr = build_sgr_sequence(&style, ColorSupport::TrueColor);
let esc_count = sgr.matches("\x1b[").count();
assert_eq!(esc_count, 1);
assert!(sgr.contains("31")); assert!(sgr.contains("44")); }
#[test]
fn color_mapper_caches_256_mappings() {
let mut mapper = ColorMapper::new();
let idx1 = mapper.map_to_256(255, 0, 0);
let idx2 = mapper.map_to_256(255, 0, 0);
assert_eq!(idx1, idx2);
assert_eq!(mapper.cache_256.len(), 1);
}
#[test]
fn color_mapper_caches_16_mappings() {
let mut mapper = ColorMapper::new();
let name1 = mapper.map_to_16(255, 0, 0);
let name2 = mapper.map_to_16(255, 0, 0);
assert_eq!(name1, name2);
assert_eq!(name1, NamedColor::BrightRed);
assert_eq!(mapper.cache_16.len(), 1);
}
#[test]
fn color_mapper_clear_cache() {
let mut mapper = ColorMapper::new();
mapper.map_to_256(255, 0, 0);
mapper.map_to_16(255, 0, 0);
assert_eq!(mapper.cache_256.len(), 1);
assert_eq!(mapper.cache_16.len(), 1);
mapper.clear_cache();
assert_eq!(mapper.cache_256.len(), 0);
assert_eq!(mapper.cache_16.len(), 0);
}
#[test]
fn rgb_to_lab_black() {
let lab = rgb_to_lab(0, 0, 0);
assert!(lab.l < 1.0);
}
#[test]
fn rgb_to_lab_white() {
let lab = rgb_to_lab(255, 255, 255);
assert!(lab.l > 99.0);
}
#[test]
fn lab_distance_same_color() {
let lab1 = rgb_to_lab(128, 128, 128);
let lab2 = rgb_to_lab(128, 128, 128);
let dist = lab_distance(lab1, lab2);
assert!(dist < 0.001);
}
#[test]
fn lab_distance_different_colors() {
let lab1 = rgb_to_lab(255, 0, 0); let lab2 = rgb_to_lab(0, 0, 255); let dist = lab_distance(lab1, lab2);
assert!(dist > 100.0);
}
#[test]
fn rgb_to_16_pure_colors() {
assert_eq!(rgb_to_16(255, 0, 0), NamedColor::BrightRed);
assert_eq!(rgb_to_16(0, 255, 0), NamedColor::BrightGreen);
assert_eq!(rgb_to_16(0, 0, 255), NamedColor::BrightBlue);
assert_eq!(rgb_to_16(255, 255, 0), NamedColor::BrightYellow);
assert_eq!(rgb_to_16(255, 0, 255), NamedColor::BrightMagenta);
assert_eq!(rgb_to_16(0, 255, 255), NamedColor::BrightCyan);
assert_eq!(rgb_to_16(0, 0, 0), NamedColor::Black);
assert_eq!(rgb_to_16(255, 255, 255), NamedColor::BrightWhite);
}
#[test]
fn rgb_to_16_dark_colors() {
assert_eq!(rgb_to_16(128, 0, 0), NamedColor::Red);
assert_eq!(rgb_to_16(0, 128, 0), NamedColor::Green);
assert_eq!(rgb_to_16(0, 0, 128), NamedColor::Blue);
}
#[test]
fn rgb_to_256_with_lab_pure_red() {
let idx = rgb_to_256(255, 0, 0);
assert!((9..=231).contains(&idx)); }
#[test]
fn rgb_to_256_with_lab_grayscale() {
let idx = rgb_to_256(128, 128, 128);
assert!(idx >= 8); }
#[test]
fn rgb_to_256_with_lab_pure_black() {
let idx = rgb_to_256(0, 0, 0);
assert!(idx <= 16);
}
#[test]
fn rgb_to_256_with_lab_pure_white() {
let idx = rgb_to_256(255, 255, 255);
assert!(idx == 15 || idx >= 231);
}
#[test]
fn no_color_environment_variable() {
unsafe {
std::env::set_var("NO_COLOR", "1");
}
let renderer = Renderer::new(ColorSupport::TrueColor, false);
let style = Style::new().fg(Color::Rgb { r: 255, g: 0, b: 0 });
let changes = vec![CellChange {
x: 0,
y: 0,
cell: Cell::new("X", style),
}];
let output = renderer.render(&changes);
assert!(output.contains("\x1b[39m")); assert!(!output.contains("\x1b[38;2;"));
unsafe {
std::env::remove_var("NO_COLOR");
}
}
#[test]
fn no_color_strips_all_colors() {
unsafe {
std::env::set_var("NO_COLOR", "1");
}
let renderer = Renderer::new(ColorSupport::TrueColor, false);
let style = Style::new()
.fg(Color::Rgb { r: 255, g: 0, b: 0 })
.bg(Color::Named(NamedColor::Blue));
let changes = vec![CellChange {
x: 0,
y: 0,
cell: Cell::new("X", style),
}];
let output = renderer.render(&changes);
assert!(output.contains("\x1b[39m")); assert!(output.contains("\x1b[49m"));
unsafe {
std::env::remove_var("NO_COLOR");
}
}
#[test]
fn no_color_overrides_color_support() {
unsafe {
std::env::set_var("NO_COLOR", "1");
}
let renderer = Renderer::new(ColorSupport::TrueColor, false);
let style = Style::new().fg(Color::Rgb {
r: 100,
g: 200,
b: 50,
});
let changes = vec![CellChange {
x: 0,
y: 0,
cell: Cell::new("X", style),
}];
let output = renderer.render(&changes);
assert!(output.contains("\x1b[39m"));
assert!(!output.contains("\x1b[38;2;"));
unsafe {
std::env::remove_var("NO_COLOR");
}
}
#[test]
fn downgrade_color_standalone_no_color() {
unsafe {
std::env::set_var("NO_COLOR", "1");
}
let color = Color::Rgb { r: 255, g: 0, b: 0 };
let result = downgrade_color_standalone(&color, ColorSupport::TrueColor);
assert_eq!(result, Color::Reset);
unsafe {
std::env::remove_var("NO_COLOR");
}
}
#[test]
fn build_sgr_with_no_color() {
unsafe {
std::env::set_var("NO_COLOR", "1");
}
let style = Style::new()
.bold(true)
.fg(Color::Rgb { r: 255, g: 0, b: 0 });
let sgr = build_sgr_sequence(&style, ColorSupport::TrueColor);
assert!(sgr.contains('1'));
assert!(sgr.contains("39"));
unsafe {
std::env::remove_var("NO_COLOR");
}
}
#[test]
fn rgb_to_16_perceptual_accuracy() {
let c1 = rgb_to_16(255, 0, 0);
let c2 = rgb_to_16(255, 10, 10);
let c3 = rgb_to_16(250, 0, 5);
assert_eq!(c1, NamedColor::BrightRed);
assert_eq!(c2, NamedColor::BrightRed);
assert_eq!(c3, NamedColor::BrightRed);
}
#[test]
fn rgb_to_256_perceptual_better_than_euclidean() {
let idx = rgb_to_256(128, 255, 128);
let is_greenish = if idx <= 15 {
matches!(
idx,
2 | 10 )
} else if (16..=231).contains(&idx) {
let idx = idx - 16;
let g_idx = (idx / 6) % 6;
g_idx >= 4 } else {
false };
assert!(is_greenish, "idx={idx} should be greenish");
}
}