use crate::{Rgb, wcag_contrast};
use crate::mix;
#[allow(unused_imports)]
use crate::ThemeColors;
pub const ANSI_16: [Rgb; 16] = [
Rgb {
r: 0x00,
g: 0x00,
b: 0x00,
},
Rgb {
r: 0xaa,
g: 0x00,
b: 0x00,
},
Rgb {
r: 0x00,
g: 0xaa,
b: 0x00,
},
Rgb {
r: 0xaa,
g: 0x55,
b: 0x00,
},
Rgb {
r: 0x00,
g: 0x00,
b: 0xaa,
},
Rgb {
r: 0xaa,
g: 0x00,
b: 0xaa,
},
Rgb {
r: 0x00,
g: 0xaa,
b: 0xaa,
},
Rgb {
r: 0xaa,
g: 0xaa,
b: 0xaa,
},
Rgb {
r: 0x55,
g: 0x55,
b: 0x55,
},
Rgb {
r: 0xff,
g: 0x55,
b: 0x55,
},
Rgb {
r: 0x55,
g: 0xff,
b: 0x55,
},
Rgb {
r: 0xff,
g: 0xff,
b: 0x55,
},
Rgb {
r: 0x55,
g: 0x55,
b: 0xff,
},
Rgb {
r: 0xff,
g: 0x55,
b: 0xff,
},
Rgb {
r: 0x55,
g: 0xff,
b: 0xff,
},
Rgb {
r: 0xff,
g: 0xff,
b: 0xff,
},
];
pub const ANSI_256: [Rgb; 256] = build_ansi_256();
pub const ANSI_240: &[Rgb] = ANSI_256.split_at(16).1;
pub const ANSI_240_OFFSET: usize = 16;
pub const GRAY_RAMP_START: usize = 232;
pub const GRAY_RAMP_LEN: usize = 24;
#[must_use]
pub fn gray_ramp(page: Rgb, ink: Rgb) -> [Rgb; GRAY_RAMP_LEN] {
let mut ramp = [page; GRAY_RAMP_LEN];
for (step, slot) in ramp.iter_mut().enumerate() {
#[allow(clippy::cast_precision_loss)]
let t = step as f32 / (GRAY_RAMP_LEN - 1) as f32;
*slot = mix(page, ink, t);
}
ramp
}
const CHROMATIC: [(usize, &str); 12] = [
(1, "status.danger"),
(2, "status.success"),
(3, "status.warning"),
(4, "status.info"),
(5, "category.five"),
(6, "category.six"),
(9, "status.danger"),
(10, "status.success"),
(11, "status.warning"),
(12, "status.info"),
(13, "category.five"),
(14, "category.six"),
];
fn achromatic_slot(index: usize, variant: &str) -> Option<&'static str> {
let dark = variant == "dark";
Some(match (index, dark) {
(0, false) => "content.primary", (0, true) => "surface.sunken", (7, false) => "surface.raised", (7, true) => "content.secondary", (8, _) => "content.muted", (15, false) => "surface.overlay", (15, true) => "content.primary", _ => return None,
})
}
#[must_use]
pub fn ansi_intent(index: usize, variant: &str) -> Option<&'static str> {
achromatic_slot(index, variant).or_else(|| {
CHROMATIC
.iter()
.find(|(slot, _)| *slot == index)
.map(|(_, intent)| *intent)
})
}
const fn build_ansi_256() -> [Rgb; 256] {
let mut table = [Rgb { r: 0, g: 0, b: 0 }; 256];
let mut i = 0;
while i < 16 {
table[i] = ANSI_16[i];
i += 1;
}
const LEVELS: [u8; 6] = [0, 95, 135, 175, 215, 255];
let mut r = 0;
while r < 6 {
let mut g = 0;
while g < 6 {
let mut b = 0;
while b < 6 {
table[16 + 36 * r + 6 * g + b] = Rgb {
r: LEVELS[r],
g: LEVELS[g],
b: LEVELS[b],
};
b += 1;
}
g += 1;
}
r += 1;
}
let mut k = 0;
while k < 24 {
let v = 8 + 10 * k as u8;
table[232 + k as usize] = Rgb { r: v, g: v, b: v };
k += 1;
}
table
}
pub const DISTINCT: f32 = 3.0;
fn oklab_distance(a: Rgb, b: Rgb) -> f32 {
let (x, y) = (a.to_oklab(), b.to_oklab());
((x.l - y.l).powi(2) + (x.a - y.a).powi(2) + (x.b - y.b).powi(2)).sqrt()
}
pub fn quantize(c: Rgb, palette: &[Rgb]) -> usize {
assert!(!palette.is_empty(), "a palette needs at least one color");
let mut best = 0;
let mut best_distance = f32::INFINITY;
for (index, entry) in palette.iter().enumerate() {
let distance = oklab_distance(c, *entry);
if distance < best_distance {
best = index;
best_distance = distance;
}
}
best
}
pub fn quantize_against(fg: Rgb, bg: Rgb, palette: &[Rgb]) -> usize {
assert!(!palette.is_empty(), "a palette needs at least one color");
let shown = palette[quantize(bg, palette)];
let mut order: Vec<usize> = (0..palette.len()).collect();
order.sort_by(|a, b| {
oklab_distance(fg, palette[*a]).total_cmp(&oklab_distance(fg, palette[*b]))
});
order
.iter()
.copied()
.find(|index| wcag_contrast(palette[*index], shown) >= DISTINCT)
.unwrap_or_else(|| {
order
.iter()
.copied()
.max_by(|a, b| {
wcag_contrast(palette[*a], shown).total_cmp(&wcag_contrast(palette[*b], shown))
})
.expect("the palette is not empty")
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::color::rel_luminance;
use crate::fixture::bundled;
use crate::{embedded_themes, parse_theme_str, resolve};
#[test]
fn the_ansi_palette_is_sixteen_distinct_colors() {
let mut seen: Vec<(u8, u8, u8)> = ANSI_16.iter().map(|c| c.tuple()).collect();
seen.sort_unstable();
seen.dedup();
assert_eq!(seen.len(), 16);
}
#[test]
fn every_ansi_slot_names_an_intent_on_either_polarity() {
for variant in ["light", "dark", "high-contrast"] {
for index in 0..16 {
assert!(
ansi_intent(index, variant).is_some(),
"slot {index} unanswered on {variant}"
);
}
assert_eq!(ansi_intent(16, variant), None);
}
}
#[test]
fn ansi_zero_is_darker_than_ansi_fifteen_on_either_polarity() {
for id in ["akari-dawn", "akari-night"] {
let theme = bundled(id);
let slot = |i: usize| -> Rgb {
let key = ansi_intent(i, &theme.meta.variant).expect("in range");
Rgb::from_hex(theme.colors.get(key).expect("theme carries it")).expect("valid hex")
};
assert!(
rel_luminance(slot(0)) < rel_luminance(slot(15)),
"{id}: ANSI 0 {} should be darker than ANSI 15 {}",
slot(0).to_hex(),
slot(15).to_hex(),
);
}
}
#[test]
fn the_container_slot_and_the_text_slot_stay_legible() {
for id in ["akari-dawn", "akari-night"] {
let theme = bundled(id);
let slot = |i: usize| -> Rgb {
let key = ansi_intent(i, &theme.meta.variant).expect("in range");
Rgb::from_hex(theme.colors.get(key).expect("theme carries it")).expect("valid hex")
};
let contrast = wcag_contrast(slot(0), slot(7));
assert!(contrast >= 4.5, "{id}: ANSI 0 on ANSI 7 is {contrast:.2}:1");
}
}
#[test]
fn the_chromatic_slots_do_not_vary_with_polarity() {
for index in [1, 2, 3, 4, 5, 6, 9, 10, 11, 12, 13, 14] {
assert_eq!(
ansi_intent(index, "light"),
ansi_intent(index, "dark"),
"slot {index} moved with polarity"
);
}
}
#[test]
fn quantize_picks_the_obvious_entry() {
let black = Rgb { r: 0, g: 0, b: 0 };
let white = Rgb {
r: 255,
g: 255,
b: 255,
};
assert_eq!(quantize(black, &ANSI_16), 0);
assert_eq!(quantize(white, &ANSI_16), 15);
}
#[test]
fn two_colors_can_quantize_to_one_entry() {
let page = Rgb::from_hex("#a8a8a8").unwrap();
let border = Rgb::from_hex("#b4b4b4").unwrap();
assert_eq!(quantize(page, &ANSI_16), quantize(border, &ANSI_16));
assert_ne!(
quantize_against(border, page, &ANSI_16),
quantize(page, &ANSI_16)
);
}
#[test]
fn quantize_against_keeps_the_border_off_the_page() {
let page = Rgb::from_hex("#e4ded6").unwrap();
let border = Rgb::from_hex("#7f786d").unwrap();
let shown_page = ANSI_16[quantize(page, &ANSI_16)];
let shown_border = ANSI_16[quantize_against(border, page, &ANSI_16)];
assert!(
wcag_contrast(shown_border, shown_page) >= DISTINCT,
"border {} on page {} is {:.2}:1",
shown_border.to_hex(),
shown_page.to_hex(),
wcag_contrast(shown_border, shown_page)
);
}
#[test]
fn quantize_against_leaves_a_readable_color_alone() {
let page = Rgb::from_hex("#e4ded6").unwrap();
let text = Rgb::from_hex("#1a1816").unwrap();
assert_eq!(
quantize_against(text, page, &ANSI_16),
quantize(text, &ANSI_16)
);
}
#[test]
fn an_impossible_palette_gets_the_most_legible_entry() {
let page = Rgb::from_hex("#ffffff").unwrap();
let border = Rgb::from_hex("#fefefe").unwrap();
let palette = [
Rgb::from_hex("#ffffff").unwrap(),
Rgb::from_hex("#fdfdfd").unwrap(),
];
let chosen = palette[quantize_against(border, page, &palette)];
assert_eq!(chosen.to_hex(), "#fdfdfd");
}
#[test]
fn a_sixteen_color_terminal_gets_one_bevel_edge_and_not_two() {
for (id, source) in embedded_themes() {
let theme = parse_theme_str(id, source, false).unwrap();
let t = resolve(&theme);
let (Some(face), Some(light), Some(dark)) = (
t.hex("surface-raised").and_then(Rgb::from_hex),
t.hex("bevel-light").and_then(Rgb::from_hex),
t.hex("bevel-dark").and_then(Rgb::from_hex),
) else {
continue;
};
let face_index = quantize(face, &ANSI_16);
let light_survives = quantize(light, &ANSI_16) != face_index;
let dark_survives = quantize(dark, &ANSI_16) != face_index;
assert!(
light_survives != dark_survives,
"{id}: expected exactly one bevel edge to survive 16 colors, \
highlight {light_survives} shadow {dark_survives}"
);
assert_eq!(
quantize_against(light, face, &ANSI_16),
quantize_against(dark, face, &ANSI_16),
"{id}: quantize_against is expected to be unusable for a bevel pair"
);
}
}
#[test]
fn two_hundred_fifty_six_colors_keep_both_bevel_edges() {
const LOSES_AN_EDGE: &[&str] = &[
"gruvbox-light",
"neobrute",
"oxocarbon-light",
"rosepine-dawn",
];
let mut lost: Vec<String> = Vec::new();
for (id, source) in embedded_themes() {
let theme = parse_theme_str(id, source, false).unwrap();
let t = resolve(&theme);
let (Some(face), Some(light), Some(dark)) = (
t.hex("surface-raised").and_then(Rgb::from_hex),
t.hex("bevel-light").and_then(Rgb::from_hex),
t.hex("bevel-dark").and_then(Rgb::from_hex),
) else {
continue;
};
let f = quantize(face, ANSI_240);
let l = quantize(light, ANSI_240);
let d = quantize(dark, ANSI_240);
if l == f || d == f || l == d {
lost.push(id.to_string());
}
}
lost.sort();
assert_eq!(
lost, LOSES_AN_EDGE,
"themes that cannot hold a two-tone bevel on a 256-color terminal"
);
}
#[test]
fn the_256_table_has_its_three_regions() {
assert_eq!(ANSI_256[..16], ANSI_16);
assert_eq!(ANSI_256[16].tuple(), (0, 0, 0));
assert_eq!(ANSI_256[231].tuple(), (255, 255, 255));
assert_eq!(ANSI_256[16 + 36 * 2 + 6 * 3 + 4].tuple(), (135, 175, 215));
assert_eq!(ANSI_256[232].tuple(), (8, 8, 8));
assert_eq!(ANSI_256[255].tuple(), (238, 238, 238));
assert_eq!(ANSI_240.len(), 240);
assert_eq!(ANSI_240[0], ANSI_256[ANSI_240_OFFSET]);
}
#[test]
fn gray_ramp_spans_the_theme_and_keeps_the_index_meaning() {
let page = Rgb::from_hex("#e8e2da").unwrap();
let ink = Rgb::from_hex("#080808").unwrap();
let ramp = gray_ramp(page, ink);
assert_eq!(ramp[0], page);
assert_eq!(ramp[GRAY_RAMP_LEN - 1], ink);
for pair in ramp.windows(2) {
assert!(
wcag_contrast(pair[1], page) >= wcag_contrast(pair[0], page),
"the ramp must not fall back toward the ground",
);
}
}
#[test]
fn gray_ramp_dims_toward_the_ground_on_either_polarity() {
for (page, ink) in [("#e8e2da", "#080808"), ("#151310", "#f8f8f8")] {
let page = Rgb::from_hex(page).unwrap();
let ink = Rgb::from_hex(ink).unwrap();
let ramp = gray_ramp(page, ink);
let dim = ramp[248 - GRAY_RAMP_START];
assert!(
wcag_contrast(dim, page) >= 4.5,
"dim text at 248 must clear AA on both polarities",
);
}
}
}