// Copyright 2026 Leo Cheng
// SPDX-License-Identifier: Apache-2.0
// The colours a terminal has.
//
// Three tiers, and the number that reaches a terminal is the interesting part:
// sixteen named colours that every emulator has, a 256-colour palette that most
// have, and twenty-four-bit colour that the ones in use today have. Reducing a
// colour to a lower tier is a nearest-neighbour search, and the question is what
// "nearest" means.
//
// It means ΔE OK here: the palette is uneven — the sixteen are arbitrary, the
// cube is coarse at the dark end and fine at the light one — and a distance
// measured in Oklab is even across that. Measuring in sRGB would put a colour
// near black further from its neighbours than the same distance near white.
//
// The palette is the one xterm uses, which is what every terminal emulator in
// practice is compatible with. It is not what the original ANSI standard said,
// and the two differ: the standard's blue was darker.
///|
/// The sixteen colours a terminal names, in the order their escape sequences
/// number them.
pub(all) enum Ansi16 {
Black
Red
Green
Yellow
Blue
Magenta
Cyan
White
BrightBlack
BrightRed
BrightGreen
BrightYellow
BrightBlue
BrightMagenta
BrightCyan
BrightWhite
} derive(Eq, Debug)
///|
pub extend Ansi16 with Eq::{equal, not_equal}
///|
pub extend Ansi16 with Debug::{to_repr}
///|
/// A named slot writes itself as the name a theme file would use.
pub impl Show for Ansi16 with fn output(self, logger) {
logger.write_string(
match self {
Black => "black"
Red => "red"
Green => "green"
Yellow => "yellow"
Blue => "blue"
Magenta => "magenta"
Cyan => "cyan"
White => "white"
BrightBlack => "bright-black"
BrightRed => "bright-red"
BrightGreen => "bright-green"
BrightYellow => "bright-yellow"
BrightBlue => "bright-blue"
BrightMagenta => "bright-magenta"
BrightCyan => "bright-cyan"
BrightWhite => "bright-white"
},
)
}
///|
pub extend Ansi16 with Show::{to_string, output}
///|
/// Where a named colour sits in the palette, which is also the low nibble of the
/// escape sequence that selects it.
pub fn Ansi16::code(self : Ansi16) -> Int32 {
match self {
Black => 0
Red => 1
Green => 2
Yellow => 3
Blue => 4
Magenta => 5
Cyan => 6
White => 7
BrightBlack => 8
BrightRed => 9
BrightGreen => 10
BrightYellow => 11
BrightBlue => 12
BrightMagenta => 13
BrightCyan => 14
BrightWhite => 15
}
}
///|
/// The colour a palette slot stands for, if the number names one.
pub fn Ansi16::of(code : Int32) -> Ansi16? {
match code {
0 => Some(Black)
1 => Some(Red)
2 => Some(Green)
3 => Some(Yellow)
4 => Some(Blue)
5 => Some(Magenta)
6 => Some(Cyan)
7 => Some(White)
8 => Some(BrightBlack)
9 => Some(BrightRed)
10 => Some(BrightGreen)
11 => Some(BrightYellow)
12 => Some(BrightBlue)
13 => Some(BrightMagenta)
14 => Some(BrightCyan)
15 => Some(BrightWhite)
_ => None
}
}
///|
/// The sRGB colour of a named slot: xterm's defaults.
pub fn Ansi16::color(self : Ansi16) -> Color {
match self {
Black => Color::rgb(0, 0, 0)
Red => Color::rgb(128, 0, 0)
Green => Color::rgb(0, 128, 0)
Yellow => Color::rgb(128, 128, 0)
Blue => Color::rgb(0, 0, 128)
Magenta => Color::rgb(128, 0, 128)
Cyan => Color::rgb(0, 128, 128)
White => Color::rgb(192, 192, 192)
BrightBlack => Color::rgb(128, 128, 128)
BrightRed => Color::rgb(255, 0, 0)
BrightGreen => Color::rgb(0, 255, 0)
BrightYellow => Color::rgb(255, 255, 0)
BrightBlue => Color::rgb(0, 0, 255)
BrightMagenta => Color::rgb(255, 0, 255)
BrightCyan => Color::rgb(0, 255, 255)
BrightWhite => Color::rgb(255, 255, 255)
}
}
///|
/// A slot in the 256-colour palette: 0–15 are the named colours, 16–231 a 6×6×6
/// cube, and 232–255 a grey ramp.
pub(all) struct Ansi256 {
/// 0–255. Kept in range by `Ansi256::of`; written by hand it is a number a
/// terminal would mask, so nothing here trusts it to be inside.
code : Int32
} derive(Eq, Debug)
///|
pub extend Ansi256 with Eq::{equal, not_equal}
///|
pub extend Ansi256 with Debug::{to_repr}
///|
/// The palette slot with this number. Numbers outside 0–255 are clamped rather
/// than wrapped, so that an out-of-range number lands on the nearest end rather
/// than on a colour that is nothing like it.
pub fn Ansi256::of(code : Int32) -> Ansi256 {
let code = if code < 0 { 0 } else if code > 255 { 255 } else { code }
{ code, }
}
///|
pub impl Show for Ansi256 with fn output(self, logger) {
logger.write_string(self.code.to_string())
}
///|
pub extend Ansi256 with Show::{to_string, output}
///|
/// The number to put in an escape sequence.
pub fn Ansi256::code(self : Ansi256) -> Int32 {
self.code
}
///|
/// One of the six levels a cube axis has. Not a linear ramp: the first step is
/// the largest, because the eye needs more of a jump down near black to see the
/// same change.
fn cube_level(index : Int32) -> Int32 {
if index <= 0 {
0
} else {
55 + 40 * index
}
}
///|
/// The colour a slot stands for.
pub fn Ansi256::color(self : Ansi256) -> Color {
let code = self.code
if code < 16 {
// A slot in the first sixteen is a named colour; the cast cannot fail
// because the table covers exactly those numbers.
match Ansi16::of(code) {
Some(named) => named.color()
None => Color::black()
}
} else if code < 232 {
let index = code - 16
Color::rgb(
cube_level(index / 36),
cube_level(index / 6 % 6),
cube_level(index % 6),
)
} else {
let level = 8 + 10 * (code - 232)
Color::rgb(level, level, level)
}
}
///|
/// The whole palette in Oklab, computed once: the nearest-colour search looks at
/// every slot, and converting 256 colours per call would make a per-cell lookup
/// cost more than the drawing around it.
let palette_lab : Array[(Double, Double, Double)] = {
let out : Array[(Double, Double, Double)] = []
for code in 0..<256 {
out.push(Ansi256::of(code).color().to_oklab().components())
}
out
}
///|
/// The sixteen named colours in Oklab, for the same reason.
let named_lab : Array[(Double, Double, Double)] = {
let out : Array[(Double, Double, Double)] = []
for code in 0..<16 {
match Ansi16::of(code) {
Some(named) => out.push(named.color().to_oklab().components())
None => out.push((0.0, 0.0, 0.0))
}
}
out
}
///|
/// The squared ΔE OK between a colour and a palette entry, squared so that the
/// search needs no square root per candidate.
fn squared_distance(
first : (Double, Double, Double),
second : (Double, Double, Double),
) -> Double {
let dl = first.0 - second.0
let da = first.1 - second.1
let db = first.2 - second.2
dl * dl + da * da + db * db
}
///|
/// The slot of the 256-colour palette closest to this colour by ΔE OK.
pub fn Color::to_ansi256(self : Color) -> Ansi256 {
let lab = self.to_oklab().components()
let mut best = 0
let mut best_distance = @double.infinity
for code in 0..<256 {
let distance = squared_distance(lab, palette_lab[code])
if distance < best_distance {
best = code
best_distance = distance
}
}
Ansi256::of(best)
}
///|
/// The named slot closest to this colour by ΔE OK.
pub fn Color::to_ansi16(self : Color) -> Ansi16 {
let lab = self.to_oklab().components()
let mut best = 0
let mut best_distance = @double.infinity
for code in 0..<16 {
let distance = squared_distance(lab, named_lab[code])
if distance < best_distance {
best = code
best_distance = distance
}
}
match Ansi16::of(best) {
Some(named) => named
None => Ansi16::Black
}
}
///|
/// The colour as the three bytes a true-colour escape sequence carries,
/// 0–255 each, clipped the way a terminal clips.
pub fn Color::rgb8(self : Color) -> (Int32, Int32, Int32) {
let srgb = self.to_srgb()
let byte = fn(value : Double) {
if value.is_nan() {
0
} else {
byte_of(value)
}
}
(byte(srgb.x), byte(srgb.y), byte(srgb.z))
}