//! Low-color terminals use crate::{Rgb, wcag_contrast}; // Names this module's prose links to, resolved for rustdoc. #[allow(unused_imports)] use crate::{ThemeColors, mix}; /// The 16 colors an ANSI terminal addresses by index, in the PC/VGA /// arrangement the Linux console and most emulators start from. /// /// 0-7 are the normal colors and 8-15 the bright ones. Index 7 is a light gray /// rather than white, which is the entry a themed surface usually lands on, and /// index 15 is the true white. /// /// Emulators let the user repaint all sixteen, so this is the standard /// arrangement rather than a promise about any one terminal. The Linux console /// keeps it, which is the case that matters: a console app cannot fall back to /// 24-bit color there. 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, }, ]; /// The 256 colors an xterm-compatible terminal addresses by index, so that /// entry `i` is what the terminal paints for `38;5;i`. /// /// Three regions, and they are not equally trustworthy. 0-15 are the [`ANSI_16`] /// system colors, which every emulator lets the user repaint. 16-231 are a /// 6x6x6 RGB cube and 232-255 a 24-step gray ramp, and those 240 are fixed. /// /// So a color whose whole job is to be told apart from another should quantize /// against [`ANSI_240`] rather than against this table: a match landing in the /// low sixteen is a match against a color the user may have moved. pub const ANSI_256: [Rgb; 256] = build_ansi_256(); /// The fixed region of [`ANSI_256`]: the 6x6x6 cube and the gray ramp, without /// the sixteen repaintable system colors. /// /// Quantizing against this returns an index into *this* slice; add /// [`ANSI_240_OFFSET`] to get the index the terminal wants. pub const ANSI_240: &[Rgb] = ANSI_256.split_at(16).1; /// What to add to an [`ANSI_240`] index to get an [`ANSI_256`] one. pub const ANSI_240_OFFSET: usize = 16; /// The twelve chromatic ANSI slots, as the intents that paint them. /// /// Indexed 1-6 and 9-14. The hues do not depend on whether the theme is light /// or dark, since red is the theme's danger tone either way, which is exactly /// why the four achromatic slots are not in this table. /// /// Here rather than in each consumer, so a program that paints its own palette /// at runtime resolves the same slots as one reading a generated config. Slot /// 14 is `category.six`. const CHROMATIC: [(usize, &str); 12] = [ (1, "status.danger"), (2, "status.success"), (3, "status.warning"), (4, "status.info"), (5, "category.five"), (6, "category.six"), (9, "action.primary"), // bright red, the theme's warm accent (10, "status.success"), (11, "status.warning"), (12, "status.info"), (13, "category.five"), (14, "category.six"), ]; /// The four achromatic slots, 0, 7, 8 and 15, which invert with the theme. /// /// These are the slots a naive table gets wrong. ANSI 0 is "black" and 7 is /// "white", but what a terminal wants there is *the darkest tone* and *the /// lightest tone*, and which intent that is flips with the theme's polarity. A /// light theme's darkest tone is its ink; a dark theme's is its deepest /// surface. Pinning slot 0 to `content.primary` reads correctly on a light /// theme and hands a dark one a pale cream as "black". /// /// Slot 7 is a surface and not a text tone, because it is what a program with /// no way to name anything else draws its container on: a greeter's login card /// is a light card on the darker field slot 0 paints. /// /// Anything that is not `dark`, including `high-contrast`, follows the light /// anchors. fn achromatic_slot(index: usize, variant: &str) -> Option<&'static str> { let dark = variant == "dark"; Some(match (index, dark) { (0, false) => "content.primary", // darkest text tone (0, true) => "surface.sunken", // darkest surface (7, false) => "surface.raised", // the login card (7, true) => "content.secondary", // a readable light tone (8, _) => "content.muted", // muted chrome, either way (15, false) => "surface.overlay", // lightest surface (15, true) => "content.primary", // lightest text tone _ => return None, }) } /// The authored intent painting ANSI slot `index` under a theme of `variant`, /// as a dotted key into [`ThemeColors::colors`]. /// /// `None` for an index outside 0-15. Every slot in range resolves, so a caller /// that has the intent can fill all sixteen. /// /// This is what makes a bare console, a terminal emulator and a generated /// config agree on what red means. They disagreed for as long as each kept its /// own table. #[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; } // The cube's six levels are not evenly spaced. The step from black to the // first is more than twice any later one, which is xterm's arrangement // rather than a choice available here, and it is why the darkest tones a // theme can reach on 256 colors come from the gray ramp instead. 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; } // 8 to 238 in steps of 10. Neither end is black or white; both of those are // in the cube, so the ramp is 24 steps of gray between them rather than 24 // steps of the whole range. 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 } /// The contrast ratio two colors must clear to read as separate areas. /// /// WCAG 2.x asks 3:1 of user interface components and graphics, which is what /// a border, a rule, or a focus ring is. Text wants more, and a caller drawing /// text can ask for more by checking [`wcag_contrast`] itself. pub const DISTINCT: f32 = 3.0; /// Perceptual distance between two colors, for choosing the closest of a set. 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() } /// Index of the entry in `palette` that looks most like `c`. /// /// OKLab distance rather than distance in sRGB, for the same reason [`mix`] /// interpolates there: sRGB's numbers are not spaced the way seeing is, so a /// nearest match computed in it picks visibly wrong entries in the mid tones. /// /// # Panics /// /// If `palette` is empty. 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 } /// Index of the entry in `palette` closest to `fg` that still reads against /// `bg`. /// /// [`quantize`] answers about one color at a time, and two colors that differ /// can quantize to the same entry: a themed page and a border drawn on it are /// often a few steps apart in a 24-bit theme and land together on a 16-color /// terminal, leaving one flat area where there was a frame. Alloy's console /// showed exactly this, and it is not a contrived pairing: a light page and the /// mid-tone border derived from it both land on index 7. /// /// So the background is quantized first, because what the border must be /// distinguished from is the entry the terminal will actually paint, not the /// color the theme asked for. Then the nearest entry to `fg` clearing /// [`DISTINCT`] against it wins. When nothing clears it, the entry that gets /// furthest does: at that point the palette cannot honor the design, and the /// most legible approximation beats the closest invisible one. /// /// Only for colors whose whole job is to be told apart from their background. /// Applied to every token it would push a deliberately quiet one until it /// shouted. /// /// # Panics /// /// If `palette` is empty. 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 = (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}; // ---- low-color terminals ---- #[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); } // ---- the intent-to-slot table ---- // Sixteen slots, every one of them answered. A caller filling a terminal // palette has no fallback for a hole: the slot would keep whatever the // emulator started with, and one raw ANSI colour in a themed table is more // obviously wrong than all sixteen would be. #[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); } } // The property the four achromatic slots exist to hold: 0 is the darkest // tone the theme offers and 15 the lightest, in either polarity. A table // that pins slot 0 to `content.primary` passes this on a light theme and // inverts on a dark one, which is the bug the polarity split fixes. #[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(), ); } } // The pair a greeter draws with: its container on 7, its text on 0. If // those collapse the login screen is one flat block, and slot 7 being a // surface rather than a text tone is what keeps them apart. #[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"); } } // The hues do not move with polarity. Red is the theme's danger tone on a // light theme and on a dark one, which is why only four slots are in the // polarity table at all. #[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); } // Nearest-entry quantization is per-color, so two colors a theme keeps // apart can arrive as one. These two are both closest to the palette's // light gray, and a border drawn in one on a page painted the other is not // drawn at all. #[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) ); } // A color that already reads against its background is left where it is, // so this can be applied without redesigning what already worked. #[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) ); } // With nothing in the palette to satisfy the request, the most legible // entry is the answer. Returning the nearest one would return the // background itself, which is the failure this function exists to avoid. #[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"); } // What the bevel pair does on a sixteen-color terminal, measured across the // shipped set rather than assumed. Two results, both load-bearing for a // consumer that has to render one there. // // Exactly one edge survives, never both. A raised face quantizes onto one of // the palette's three grays, and the palette is too coarse to hold anything // between that entry and its neighbour, so whichever edge is pushed toward // the end of the ramp the face already sits on lands back on the face. Light // themes and most dark ones keep the shadow and lose the highlight; a face // that quantizes to black keeps the highlight and loses the shadow. // // So a low-color consumer draws the single edge it can render, on the side // the palette left it, rather than a bevel that resolves on two sides. // // And `quantize_against` is the wrong function for this pair, though it is // the right one for a border. It answers "nearest entry that clears DISTINCT // against the background", which has no notion of direction, so both edges // are pushed onto the same contrasting entry and the bevel inverts on one // side. Plain `quantize` keeps them apart and in the right order. #[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}" ); // Direction-blind, so it collapses the pair it is asked to separate. 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" ); } } // 256 colors is where the bevel starts working. At 16 every shipped theme // loses an edge; here all but the five whose raised surface sits at the very // top of the ramp keep both, and those five fail for the reason they fail in // truecolor rather than for a palette reason. // // Three of them cannot bevel at any depth, so they are the // `bevel_edges_are_distinct_from_their_face` set. The other two are new here: // they hold a highlight in 24-bit, but not one wide enough to survive // rounding onto the cube. #[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 = 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; }; // Against the fixed region, which is what a consumer should use: a // match in the low sixteen is a match against a repaintable color. 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() { // Index is the escape-sequence index, so the low sixteen must match. assert_eq!(ANSI_256[..16], ANSI_16); // The cube's corners, at both ends and one interior level. 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)); // The gray ramp runs 8 to 238 and contains neither black nor white. assert_eq!(ANSI_256[232].tuple(), (8, 8, 8)); assert_eq!(ANSI_256[255].tuple(), (238, 238, 238)); // The fixed region is the table minus the repaintable colors. assert_eq!(ANSI_240.len(), 240); assert_eq!(ANSI_240[0], ANSI_256[ANSI_240_OFFSET]); } }