use ratatui::backend::{Backend as RtBackend, ClearType, WindowSize};
use ratatui::buffer::Cell;
use ratatui::layout::{Position, Size};
use ratatui::style::Color as RatatuiColor;
use crate::backend::ColorDepth;
pub(crate) fn quantize(color: RatatuiColor, depth: ColorDepth) -> RatatuiColor {
match (depth, color) {
(ColorDepth::TrueColor, c) => c,
(ColorDepth::Indexed256, RatatuiColor::Rgb(r, g, b)) => {
RatatuiColor::Indexed(rgb_to_indexed256(r, g, b))
}
(ColorDepth::Indexed256, c) => c,
(ColorDepth::Ansi16, RatatuiColor::Rgb(r, g, b)) => rgb_to_ansi16(r, g, b),
(ColorDepth::Ansi16, RatatuiColor::Indexed(idx)) => {
let (r, g, b) = indexed256_to_rgb(idx);
rgb_to_ansi16(r, g, b)
}
(ColorDepth::Ansi16, c) => c,
}
}
const CUBE_LEVELS: [u8; 6] = [0, 95, 135, 175, 215, 255];
fn dist2(a: (u8, u8, u8), b: (u8, u8, u8)) -> i32 {
let dr = a.0 as i32 - b.0 as i32;
let dg = a.1 as i32 - b.1 as i32;
let db = a.2 as i32 - b.2 as i32;
dr * dr + dg * dg + db * db
}
pub(crate) fn rgb_to_indexed256(r: u8, g: u8, b: u8) -> u8 {
let level = |v: u8| -> usize {
CUBE_LEVELS
.iter()
.enumerate()
.min_by_key(|(_, &lv)| (v as i32 - lv as i32).abs())
.map(|(i, _)| i)
.expect("CUBE_LEVELS is non-empty")
};
let (rl, gl, bl) = (level(r), level(g), level(b));
let cube_idx = 16 + 36 * rl + 6 * gl + bl;
let cube_rgb = (CUBE_LEVELS[rl], CUBE_LEVELS[gl], CUBE_LEVELS[bl]);
let avg = (r as i32 + g as i32 + b as i32) / 3;
let gray_i = (((avg - 8).max(0)) / 10).clamp(0, 23) as usize;
let gray_val = (8 + 10 * gray_i as i32) as u8;
let gray_idx = 232 + gray_i;
let target = (r, g, b);
if dist2(target, cube_rgb) <= dist2(target, (gray_val, gray_val, gray_val)) {
cube_idx as u8
} else {
gray_idx as u8
}
}
fn indexed256_to_rgb(idx: u8) -> (u8, u8, u8) {
match idx {
0..=15 => ANSI16_PALETTE[idx as usize].1,
16..=231 => {
let i = idx - 16;
let r = CUBE_LEVELS[(i / 36) as usize];
let g = CUBE_LEVELS[((i / 6) % 6) as usize];
let b = CUBE_LEVELS[(i % 6) as usize];
(r, g, b)
}
232..=255 => {
let v = 8 + 10 * (idx - 232) as u16;
(v as u8, v as u8, v as u8)
}
}
}
const ANSI16_PALETTE: [(RatatuiColor, (u8, u8, u8)); 16] = [
(RatatuiColor::Black, (0, 0, 0)),
(RatatuiColor::Red, (205, 0, 0)),
(RatatuiColor::Green, (0, 205, 0)),
(RatatuiColor::Yellow, (205, 205, 0)),
(RatatuiColor::Blue, (0, 0, 238)),
(RatatuiColor::Magenta, (205, 0, 205)),
(RatatuiColor::Cyan, (0, 205, 205)),
(RatatuiColor::Gray, (229, 229, 229)),
(RatatuiColor::DarkGray, (127, 127, 127)),
(RatatuiColor::LightRed, (255, 0, 0)),
(RatatuiColor::LightGreen, (0, 255, 0)),
(RatatuiColor::LightYellow, (255, 255, 0)),
(RatatuiColor::LightBlue, (92, 92, 255)),
(RatatuiColor::LightMagenta, (255, 0, 255)),
(RatatuiColor::LightCyan, (0, 255, 255)),
(RatatuiColor::White, (255, 255, 255)),
];
pub(crate) fn rgb_to_ansi16(r: u8, g: u8, b: u8) -> RatatuiColor {
ANSI16_PALETTE
.iter()
.min_by_key(|(_, rgb)| dist2((r, g, b), *rgb))
.map(|(c, _)| *c)
.expect("ANSI16_PALETTE is non-empty")
}
pub struct DepthLimitedBackend<B> {
inner: B,
depth: ColorDepth,
}
impl<B> DepthLimitedBackend<B> {
pub fn new(inner: B, depth: ColorDepth) -> Self {
Self { inner, depth }
}
pub fn depth(&self) -> ColorDepth {
self.depth
}
pub fn set_depth(&mut self, depth: ColorDepth) {
self.depth = depth;
}
pub fn into_inner(self) -> B {
self.inner
}
}
impl<B: RtBackend> RtBackend for DepthLimitedBackend<B> {
type Error = B::Error;
fn draw<'a, I>(&mut self, content: I) -> Result<(), Self::Error>
where
I: Iterator<Item = (u16, u16, &'a Cell)>,
{
if self.depth == ColorDepth::TrueColor {
return self.inner.draw(content);
}
let quantized: Vec<(u16, u16, Cell)> = content
.map(|(x, y, cell)| {
let mut cell = cell.clone();
cell.fg = quantize(cell.fg, self.depth);
cell.bg = quantize(cell.bg, self.depth);
cell.underline_color = quantize(cell.underline_color, self.depth);
(x, y, cell)
})
.collect();
self.inner
.draw(quantized.iter().map(|(x, y, cell)| (*x, *y, cell)))
}
fn append_lines(&mut self, n: u16) -> Result<(), Self::Error> {
self.inner.append_lines(n)
}
fn hide_cursor(&mut self) -> Result<(), Self::Error> {
self.inner.hide_cursor()
}
fn show_cursor(&mut self) -> Result<(), Self::Error> {
self.inner.show_cursor()
}
fn get_cursor_position(&mut self) -> Result<Position, Self::Error> {
self.inner.get_cursor_position()
}
fn set_cursor_position<P: Into<Position>>(&mut self, position: P) -> Result<(), Self::Error> {
self.inner.set_cursor_position(position)
}
fn clear(&mut self) -> Result<(), Self::Error> {
self.inner.clear()
}
fn clear_region(&mut self, clear_type: ClearType) -> Result<(), Self::Error> {
self.inner.clear_region(clear_type)
}
fn size(&self) -> Result<Size, Self::Error> {
self.inner.size()
}
fn window_size(&mut self) -> Result<WindowSize, Self::Error> {
self.inner.window_size()
}
fn flush(&mut self) -> Result<(), Self::Error> {
self.inner.flush()
}
}
impl<B: std::io::Write> std::io::Write for DepthLimitedBackend<B> {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.inner.write(buf)
}
fn flush(&mut self) -> std::io::Result<()> {
self.inner.flush()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pure_red_maps_to_a_cube_index() {
assert_eq!(rgb_to_indexed256(255, 0, 0), 196);
}
#[test]
fn pure_black_maps_to_cube_origin() {
assert_eq!(rgb_to_indexed256(0, 0, 0), 16);
}
#[test]
fn pure_white_maps_to_cube_corner() {
assert_eq!(rgb_to_indexed256(255, 255, 255), 231);
}
#[test]
fn mid_gray_prefers_the_grayscale_ramp_over_the_cube() {
let idx = rgb_to_indexed256(128, 128, 128);
assert!(
(232..=255).contains(&idx),
"expected a ramp index, got {idx}"
);
}
#[test]
fn indexed256_round_trips_within_one_cube_step() {
for idx in 16u16..=255 {
let (r, g, b) = indexed256_to_rgb(idx as u8);
assert_eq!(
rgb_to_indexed256(r, g, b),
idx as u8,
"index {idx} ({r},{g},{b}) did not round-trip"
);
}
}
#[test]
fn pure_red_maps_to_light_red() {
assert_eq!(rgb_to_ansi16(255, 0, 0), RatatuiColor::LightRed);
}
#[test]
fn dim_red_maps_to_basic_red() {
assert_eq!(rgb_to_ansi16(205, 0, 0), RatatuiColor::Red);
}
#[test]
fn pure_black_maps_to_black() {
assert_eq!(rgb_to_ansi16(0, 0, 0), RatatuiColor::Black);
}
#[test]
fn pure_white_maps_to_white() {
assert_eq!(rgb_to_ansi16(255, 255, 255), RatatuiColor::White);
}
#[test]
fn truecolor_is_identity_for_every_variant() {
for c in [
RatatuiColor::Rgb(12, 34, 56),
RatatuiColor::Indexed(200),
RatatuiColor::Red,
RatatuiColor::Reset,
] {
assert_eq!(quantize(c, ColorDepth::TrueColor), c);
}
}
#[test]
fn indexed256_leaves_named_colours_alone() {
assert_eq!(
quantize(RatatuiColor::LightBlue, ColorDepth::Indexed256),
RatatuiColor::LightBlue
);
}
#[test]
fn indexed256_downgrades_rgb() {
assert_eq!(
quantize(RatatuiColor::Rgb(255, 0, 0), ColorDepth::Indexed256),
RatatuiColor::Indexed(196)
);
}
#[test]
fn ansi16_downgrades_rgb() {
assert_eq!(
quantize(RatatuiColor::Rgb(255, 0, 0), ColorDepth::Ansi16),
RatatuiColor::LightRed
);
}
#[test]
fn ansi16_downgrades_indexed() {
assert_eq!(
quantize(RatatuiColor::Indexed(196), ColorDepth::Ansi16),
RatatuiColor::LightRed
);
}
#[test]
fn truecolor_draw_is_byte_identical_to_the_wrapped_backend() {
use ratatui::backend::TestBackend;
let mut direct = TestBackend::new(10, 1);
let mut wrapped = DepthLimitedBackend::new(TestBackend::new(10, 1), ColorDepth::TrueColor);
let mut cell = Cell::default();
cell.set_char('x');
cell.fg = RatatuiColor::Rgb(10, 20, 30);
cell.bg = RatatuiColor::Rgb(40, 50, 60);
let content = [(0u16, 0u16, &cell)];
direct.draw(content.iter().copied()).unwrap();
wrapped.draw(content.iter().copied()).unwrap();
assert_eq!(direct.buffer(), wrapped.into_inner().buffer());
}
#[test]
fn indexed256_draw_quantises_the_painted_cell() {
use ratatui::backend::TestBackend;
let mut wrapped = DepthLimitedBackend::new(TestBackend::new(10, 1), ColorDepth::Indexed256);
let mut cell = Cell::default();
cell.set_char('x');
cell.fg = RatatuiColor::Rgb(255, 0, 0);
let content = [(0u16, 0u16, &cell)];
wrapped.draw(content.iter().copied()).unwrap();
let inner = wrapped.into_inner();
assert_eq!(inner.buffer()[(0, 0)].fg, RatatuiColor::Indexed(196));
}
#[test]
fn theme_default_pipeline_colors_quantise_to_the_values_the_pty_fixture_expects() {
assert_eq!(rgb_to_indexed256(220, 220, 220), 253); assert_eq!(rgb_to_ansi16(220, 220, 220), RatatuiColor::Gray);
assert_eq!(rgb_to_indexed256(28, 32, 44), 234); assert_eq!(rgb_to_ansi16(28, 32, 44), RatatuiColor::Black);
}
#[test]
fn pipeline_theme_pair_quantises_through_a_real_draw_at_every_depth() {
use crate::theme::Theme;
use crate::tui::ratatui_color;
use ratatui::backend::TestBackend;
let theme = Theme::default();
let fg = ratatui_color(theme.foreground);
let bg = ratatui_color(theme.surface_bg);
assert_eq!(fg, RatatuiColor::Rgb(220, 220, 220));
assert_eq!(bg, RatatuiColor::Rgb(28, 32, 44));
for (depth, want_fg, want_bg) in [
(
ColorDepth::TrueColor,
RatatuiColor::Rgb(220, 220, 220),
RatatuiColor::Rgb(28, 32, 44),
),
(
ColorDepth::Indexed256,
RatatuiColor::Indexed(253),
RatatuiColor::Indexed(234),
),
(ColorDepth::Ansi16, RatatuiColor::Gray, RatatuiColor::Black),
] {
let mut wrapped = DepthLimitedBackend::new(TestBackend::new(4, 1), depth);
let mut cell = Cell::default();
cell.set_char('S');
cell.fg = fg;
cell.bg = bg;
wrapped.draw([(0u16, 0u16, &cell)].into_iter()).unwrap();
let inner = wrapped.into_inner();
let painted = &inner.buffer()[(0, 0)];
assert_eq!(painted.fg, want_fg, "foreground at {depth:?}");
assert_eq!(painted.bg, want_bg, "background at {depth:?}");
}
}
}