feat(vt): port Ghostty's VT parsing layer

Ports the parsing half of ZIG/src/terminal into the ghostty-vt crate:
- Parser state machine with a compile-time generated parse table and
  the UTF-8 decoder
- OSC parser incl. all sub-parsers (titles, colors, clipboard, hyperlinks,
  semantic prompts/OSC 133, OSC 9 / ConEmu, iTerm2, kitty clipboard,
  color and text-sizing protocols, context signals)
- CSI, SGR, modes table with save/restore, charsets, ansi, device status,
  mouse shapes, APC, DCS (incl. XTGETTCAP), tmux control-mode parser
- color palette / X11 color names, kitty color and graphics command parser

Deviations from Zig (documented in docs/rewrite/notes/ghostty-vt-W*.md):
parser actions own their payloads instead of borrowing parser buffers;
OSC strings are Vec<u8>; non-exhaustive Zig enums gain an Other(raw)
variant; tmux regexes replaced by equivalent hand-written matchers.

Known follow-up: semantic_prompt.rs carries local copies of the printf-q
and percent decoders that belong in ghostty-os.

Test Plan:
- cargo test -p ghostty-vt: 395 passed, 1 ignored (skipped in Zig too)
- cargo clippy -p ghostty-vt --all-targets -- -D warnings: clean
This commit is contained in:
ddidderr committed 2026-10-11 21:52:41 +02:00
1 parent 987f2957ad
commit bf84326c2f
44 files changed
+15040 -6

No files matched your search

+1220
View File
@@ -0,0 +1,1220 @@
//! Terminal colors (ZIG/src/terminal/color.zig).
use crate::x11_color;
/// The default palette.
pub const DEFAULT: Palette = default_palette();
const fn default_palette() -> Palette {
let mut result = [Rgb { r: 0, g: 0, b: 0 }; 256];
// Named values
let mut i: usize = 0;
while i < 16 {
result[i] = match Name(i as u8).default() {
Ok(c) => c,
Err(_) => unreachable!(),
};
i += 1;
}
// Cube
assert!(i == 16);
let mut r: u8 = 0;
while r < 6 {
let mut g: u8 = 0;
while g < 6 {
let mut b: u8 = 0;
while b < 6 {
result[i] = Rgb {
r: if r == 0 { 0 } else { r * 40 + 55 },
g: if g == 0 { 0 } else { g * 40 + 55 },
b: if b == 0 { 0 } else { b * 40 + 55 },
};
i += 1;
b += 1;
}
g += 1;
}
r += 1;
}
// Gray ramp
assert!(i == 232);
while i < 256 {
let value = ((i as u8 - 232) * 10) + 8;
result[i] = Rgb {
r: value,
g: value,
b: value,
};
i += 1;
}
result
}
/// Palette is the 256 color palette.
pub type Palette = [Rgb; 256];
/// Mask that can be used to set which palette indexes were set.
/// (Zig `std.StaticBitSet(256)`.)
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct PaletteMask([u64; 4]);
impl PaletteMask {
pub const fn init_empty() -> Self {
Self([0; 4])
}
pub fn is_set(&self, idx: usize) -> bool {
self.0[idx / 64] & (1 << (idx % 64)) != 0
}
pub fn set(&mut self, idx: usize) {
self.0[idx / 64] |= 1 << (idx % 64);
}
pub fn unset(&mut self, idx: usize) {
self.0[idx / 64] &= !(1 << (idx % 64));
}
pub fn count(&self) -> usize {
self.0.iter().map(|w| w.count_ones() as usize).sum()
}
/// Iterate over set indexes in ascending order.
pub fn iter(&self) -> impl Iterator<Item = usize> + '_ {
(0..256).filter(|&i| self.is_set(i))
}
}
/// Generate the 256-color palette from the user's base16 theme colors,
/// terminal background, and terminal foreground.
///
/// Motivation: The default 256-color palette uses fixed, fully-saturated
/// colors that clash with custom base16 themes, have poor readability in
/// dark shades (the first non-black shade jumps to 37% intensity instead
/// of the expected 20%), and exhibit inconsistent perceived brightness
/// across hues of the same shade (e.g., blue appears darker than green).
/// By generating the extended palette from the user's chosen colors,
/// programs can use the richer 256-color range without requiring their
/// own theme configuration, and light/dark switching works automatically.
///
/// The 216-color cube (indices 16–231) is built via trilinear
/// interpolation in CIELAB space over the 8 base colors. The base16
/// palette maps to the 8 corners of a 6×6×6 RGB cube as follows:
///
/// R=0 edge: bg → base[1] (red)
/// R=5 edge: base[6] → fg
/// G=0 edge: bg/base[6] (via R) → base[2]/base[4] (green/blue via R)
/// G=5 edge: base[1]/fg (via R) → base[3]/base[5] (yellow/magenta via R)
///
/// For each R slice, four corner colors (c0–c3) are interpolated along
/// the R axis, then for each G row two edge colors (c4–c5) are
/// interpolated along G, and finally each B cell is interpolated along B
/// to produce the final color. CIELAB interpolation ensures perceptually
/// uniform brightness transitions across different hues.
///
/// The 24-step grayscale ramp (indices 232–255) is a simple linear
/// interpolation in CIELAB from the background to the foreground,
/// excluding pure black and white (available in the cube at (0,0,0)
/// and (5,5,5)). The interpolation parameter runs from 1/25 to 24/25.
///
/// Fill `skip` with user-defined color indexes to avoid replacing them.
pub fn generate_256_color(
base: Palette,
skip: PaletteMask,
bg: Rgb,
fg: Rgb,
harmonious: bool,
) -> Palette {
// Convert the background, foreground, and 8 base theme colors into
// CIELAB space so that all interpolation is perceptually uniform.
let base8_lab: [Lab; 8] = {
let mut base8 = [
Lab::from_rgb(bg),
Lab::from_rgb(base[1]),
Lab::from_rgb(base[2]),
Lab::from_rgb(base[3]),
Lab::from_rgb(base[4]),
Lab::from_rgb(base[5]),
Lab::from_rgb(base[6]),
Lab::from_rgb(fg),
];
// For light themes (where the foreground is darker than the
// background), the cube's dark-to-light orientation is inverted
// relative to the base color mapping. When `harmonious` is false,
// swap bg and fg so the cube still runs from black (16) to
// white (231).
let is_light_theme = base8[7].l < base8[0].l;
let invert = is_light_theme && !harmonious;
if invert {
base8.swap(0, 7);
}
base8
};
// Start from the base palette so indices 0-15 are preserved as-is.
let mut result = base;
// Build the 216-color cube (indices 16-231) via trilinear interpolation
// in CIELAB. The three nested loops correspond to the R, G, and B axes
// of a 6x6x6 cube.
let mut idx: usize = 16;
for ri in 0..6 {
// R-axis corners: blend base colors along the red dimension.
let tr = ri as f32 / 5.0;
let c0 = Lab::lerp(tr, base8_lab[0], base8_lab[1]);
let c1 = Lab::lerp(tr, base8_lab[2], base8_lab[3]);
let c2 = Lab::lerp(tr, base8_lab[4], base8_lab[5]);
let c3 = Lab::lerp(tr, base8_lab[6], base8_lab[7]);
for gi in 0..6 {
// G-axis edges: blend the R-interpolated corners along green.
let tg = gi as f32 / 5.0;
let c4 = Lab::lerp(tg, c0, c1);
let c5 = Lab::lerp(tg, c2, c3);
for bi in 0..6 {
// B-axis: final interpolation along blue, then convert back to RGB.
if !skip.is_set(idx) {
let c6 = Lab::lerp(bi as f32 / 5.0, c4, c5);
result[idx] = c6.to_rgb();
}
idx += 1;
}
}
}
// Build the 24-step grayscale ramp (indices 232-255) by linearly
// interpolating in CIELAB from background to foreground. The parameter
// runs from 1/25 to 24/25, excluding the endpoints which are already
// available in the cube at (0,0,0) and (5,5,5).
for i in 0..24 {
let t = (i + 1) as f32 / 25.0;
if !skip.is_set(idx) {
let c = Lab::lerp(t, base8_lab[0], base8_lab[7]);
result[idx] = c.to_rgb();
}
idx += 1;
}
result
}
/// A palette that can have its colors changed and reset. Purposely built
/// for terminal color operations.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DynamicPalette {
/// The current palette including any user modifications.
pub current: Palette,
/// The original/default palette values.
pub original: Palette,
/// A bitset where each bit represents whether the corresponding
/// palette index has been modified from its default value.
pub mask: PaletteMask,
}
impl DynamicPalette {
pub const DEFAULT: DynamicPalette = DynamicPalette::init(DEFAULT);
/// Initialize a dynamic palette with a default palette.
pub const fn init(def: Palette) -> DynamicPalette {
DynamicPalette {
current: def,
original: def,
mask: PaletteMask::init_empty(),
}
}
/// Set a custom color at the given palette index.
pub fn set(&mut self, idx: u8, color: Rgb) {
self.current[idx as usize] = color;
self.mask.set(idx as usize);
}
/// Reset the color at the given palette index to its original value.
pub fn reset(&mut self, idx: u8) {
self.current[idx as usize] = self.original[idx as usize];
self.mask.unset(idx as usize);
}
/// Reset all colors to their original values.
pub fn reset_all(&mut self) {
*self = Self::init(self.original);
}
/// Change the default palette, but preserve the changed values.
pub fn change_default(&mut self, def: Palette) {
self.original = def;
// Fast path, the palette is usually not changed.
if self.mask.count() == 0 {
self.current = self.original;
return;
}
// There are usually less set than unset, so iterate over the changed
// values and override them.
let mut current = def;
for idx in self.mask.iter() {
current[idx] = self.current[idx];
}
self.current = current;
}
}
/// RGB value that can be changed and reset. This can also be totally unset
/// in every way, in which case the caller can determine their own ultimate
/// default.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct DynamicRgb {
pub override_: Option<Rgb>,
pub default: Option<Rgb>,
}
impl DynamicRgb {
pub const UNSET: DynamicRgb = DynamicRgb {
override_: None,
default: None,
};
pub fn init(def: Rgb) -> DynamicRgb {
DynamicRgb {
override_: None,
default: Some(def),
}
}
pub fn get(&self) -> Option<Rgb> {
self.override_.or(self.default)
}
pub fn set(&mut self, color: Rgb) {
self.override_ = Some(color);
}
pub fn reset(&mut self) {
self.override_ = self.default;
}
}
/// Color names in the standard 8 or 16 color palette. Remainders (any other
/// `u8`) are valid unnamed values in the 256 color palette, so this is a
/// newtype rather than an enum.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Name(pub u8);
/// Error returned by [`Name::default`] for unnamed palette values.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct NoDefaultValue;
impl Name {
pub const BLACK: Name = Name(0);
pub const RED: Name = Name(1);
pub const GREEN: Name = Name(2);
pub const YELLOW: Name = Name(3);
pub const BLUE: Name = Name(4);
pub const MAGENTA: Name = Name(5);
pub const CYAN: Name = Name(6);
pub const WHITE: Name = Name(7);
pub const BRIGHT_BLACK: Name = Name(8);
pub const BRIGHT_RED: Name = Name(9);
pub const BRIGHT_GREEN: Name = Name(10);
pub const BRIGHT_YELLOW: Name = Name(11);
pub const BRIGHT_BLUE: Name = Name(12);
pub const BRIGHT_MAGENTA: Name = Name(13);
pub const BRIGHT_CYAN: Name = Name(14);
pub const BRIGHT_WHITE: Name = Name(15);
/// Default colors for tagged values.
pub const fn default(self) -> Result<Rgb, NoDefaultValue> {
let (r, g, b) = match self.0 {
0 => (0x1D, 0x1F, 0x21),
1 => (0xCC, 0x66, 0x66),
2 => (0xB5, 0xBD, 0x68),
3 => (0xF0, 0xC6, 0x74),
4 => (0x81, 0xA2, 0xBE),
5 => (0xB2, 0x94, 0xBB),
6 => (0x8A, 0xBE, 0xB7),
7 => (0xC5, 0xC8, 0xC6),
8 => (0x66, 0x66, 0x66),
9 => (0xD5, 0x4E, 0x53),
10 => (0xB9, 0xCA, 0x4A),
11 => (0xE7, 0xC5, 0x47),
12 => (0x7A, 0xA6, 0xDA),
13 => (0xC3, 0x97, 0xD8),
14 => (0x70, 0xC0, 0xB1),
15 => (0xEA, 0xEA, 0xEA),
_ => return Err(NoDefaultValue),
};
Ok(Rgb { r, g, b })
}
}
/// The "special colors" as denoted by xterm. These can be set via
/// OSC 5 or via OSC 4 by adding the palette length to it.
///
/// https://invisible-island.net/xterm/ctlseqs/ctlseqs.html
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[repr(u8)]
pub enum Special {
Bold = 0,
Underline = 1,
Blink = 2,
Reverse = 3,
Italic = 4,
}
impl Special {
pub fn osc4(self) -> u16 {
// "The special colors can also be set by adding the maximum
// number of colors (e.g., 88 or 256) to these codes in an
// OSC 4 control" - xterm ctlseqs
self as u16 + 256
}
/// Convert from the raw value (Zig `@enumFromInt` / `intToEnum`).
pub fn from_u8(v: u8) -> Option<Special> {
Some(match v {
0 => Special::Bold,
1 => Special::Underline,
2 => Special::Blink,
3 => Special::Reverse,
4 => Special::Italic,
_ => return None,
})
}
}
/// The "dynamic colors" as denoted by xterm. These can be set via
/// OSC 10 through 19.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[repr(u8)]
pub enum Dynamic {
Foreground = 10,
Background = 11,
Cursor = 12,
PointerForeground = 13,
PointerBackground = 14,
TektronixForeground = 15,
TektronixBackground = 16,
HighlightBackground = 17,
TektronixCursor = 18,
HighlightForeground = 19,
}
impl Dynamic {
/// Convert from the raw value (10..=19).
pub fn from_u8(v: u8) -> Option<Dynamic> {
Some(match v {
10 => Dynamic::Foreground,
11 => Dynamic::Background,
12 => Dynamic::Cursor,
13 => Dynamic::PointerForeground,
14 => Dynamic::PointerBackground,
15 => Dynamic::TektronixForeground,
16 => Dynamic::TektronixBackground,
17 => Dynamic::HighlightBackground,
18 => Dynamic::TektronixCursor,
19 => Dynamic::HighlightForeground,
_ => return None,
})
}
/// The next dynamic color sequentially. This is required because
/// specifying colors sequentially without their index will automatically
/// use the next dynamic color.
///
/// "Each successive parameter changes the next color in the list. The
/// value of Ps tells the starting point in the list."
pub fn next(self) -> Option<Dynamic> {
Dynamic::from_u8(self as u8 + 1)
}
}
/// Error returned when parsing a color specification fails.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct InvalidFormat;
impl std::fmt::Display for InvalidFormat {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("invalid color format")
}
}
impl std::error::Error for InvalidFormat {}
/// RGB
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub struct Rgb {
pub r: u8,
pub g: u8,
pub b: u8,
}
/// Alias matching the Zig name.
pub type RGB = Rgb;
/// Zig `std.fmt.parseUnsigned(u16, value, 16)` semantics (optional leading
/// `+`; a leading `-` is only valid if the value is zero; no underscores).
fn parse_unsigned_hex_u16(value: &[u8]) -> Option<u16> {
let (neg, digits) = match value.first()? {
b'+' => (false, &value[1..]),
b'-' => (true, &value[1..]),
_ => (false, value),
};
if digits.is_empty() {
return None;
}
let mut acc: u32 = 0;
for &c in digits {
let d = (c as char).to_digit(16)?;
acc = acc * 16 + d;
if acc > u16::MAX as u32 {
return None;
}
}
if neg && acc != 0 {
return None;
}
Some(acc as u16)
}
impl Rgb {
pub fn eql(self, other: Rgb) -> bool {
self.r == other.r && self.g == other.g && self.b == other.b
}
/// Calculates the contrast ratio between two colors. The contrast
/// ration is a value between 1 and 21 where 1 is the lowest contrast
/// and 21 is the highest contrast.
///
/// https://www.w3.org/TR/WCAG20/#contrast-ratiodef
pub fn contrast(self, other: Rgb) -> f64 {
// pair[0] = lighter, pair[1] = darker
let self_lum = self.luminance();
let other_lum = other.luminance();
let pair = if self_lum > other_lum {
[self_lum, other_lum]
} else {
[other_lum, self_lum]
};
(pair[0] + 0.05) / (pair[1] + 0.05)
}
/// Calculates luminance based on the W3C formula. This returns a
/// normalized value between 0 and 1 where 0 is black and 1 is white.
///
/// https://www.w3.org/TR/WCAG20/#relativeluminancedef
pub fn luminance(self) -> f64 {
let r_lum = component_luminance(self.r);
let g_lum = component_luminance(self.g);
let b_lum = component_luminance(self.b);
0.2126 * r_lum + 0.7152 * g_lum + 0.0722 * b_lum
}
/// Calculates "perceived luminance" which is better for determining
/// light vs dark.
///
/// Source: https://www.w3.org/TR/AERT/#color-contrast
pub fn perceived_luminance(self) -> f64 {
let r = self.r as f64;
let g = self.g as f64;
let b = self.b as f64;
0.299 * (r / 255.0) + 0.587 * (g / 255.0) + 0.114 * (b / 255.0)
}
/// Parse a color from a floating point intensity value.
///
/// The value should be between 0.0 and 1.0, inclusive.
fn from_intensity(value: &[u8]) -> Result<u8, InvalidFormat> {
let s = std::str::from_utf8(value).map_err(|_| InvalidFormat)?;
let i: f64 = s.parse().map_err(|_| InvalidFormat)?;
if !(0.0..=1.0).contains(&i) && !i.is_nan() {
return Err(InvalidFormat);
}
Ok((i * 255.0) as u8)
}
/// Parse a color from a string of hexadecimal digits
///
/// The string can contain 1, 2, 3, or 4 characters and represents the color
/// value scaled in 4, 8, 12, or 16 bits, respectively.
fn from_hex(value: &[u8]) -> Result<u8, InvalidFormat> {
if value.is_empty() || value.len() > 4 {
return Err(InvalidFormat);
}
let color = parse_unsigned_hex_u16(value).ok_or(InvalidFormat)?;
let divisor: usize = match value.len() {
1 => 0xf,
2 => 0xff,
3 => 0xfff,
4 => 0xffff,
_ => unreachable!(),
};
Ok((color as usize * 255 / divisor) as u8)
}
/// Parse a color specification.
///
/// Any of the following forms are accepted:
///
/// 1. rgb:<red>/<green>/<blue>
///
/// <red>, <green>, <blue> := h | hh | hhh | hhhh
///
/// where `h` is a single hexadecimal digit.
///
/// 2. rgbi:<red>/<green>/<blue>
///
/// where <red>, <green>, and <blue> are floating point values between
/// 0.0 and 1.0 (inclusive).
///
/// 3. #rgb, #rrggbb, #rrrgggbbb #rrrrggggbbbb
///
/// where `r`, `g`, and `b` are a single hexadecimal digit.
/// These specify a color with 4, 8, 12, and 16 bits of precision
/// per color channel.
pub fn parse(value: &str) -> Result<Rgb, InvalidFormat> {
Self::parse_bytes(value.as_bytes())
}
/// Byte-slice variant of [`Rgb::parse`].
pub fn parse_bytes(value: &[u8]) -> Result<Rgb, InvalidFormat> {
if value.is_empty() {
return Err(InvalidFormat);
}
if value[0] == b'#' {
return match value.len() {
4 => Ok(Rgb {
r: Rgb::from_hex(&value[1..2])?,
g: Rgb::from_hex(&value[2..3])?,
b: Rgb::from_hex(&value[3..4])?,
}),
7 => Ok(Rgb {
r: Rgb::from_hex(&value[1..3])?,
g: Rgb::from_hex(&value[3..5])?,
b: Rgb::from_hex(&value[5..7])?,
}),
10 => Ok(Rgb {
r: Rgb::from_hex(&value[1..4])?,
g: Rgb::from_hex(&value[4..7])?,
b: Rgb::from_hex(&value[7..10])?,
}),
13 => Ok(Rgb {
r: Rgb::from_hex(&value[1..5])?,
g: Rgb::from_hex(&value[5..9])?,
b: Rgb::from_hex(&value[9..13])?,
}),
_ => Err(InvalidFormat),
};
}
// Check for X11 named colors. We allow whitespace around the edges
// of the color because Kitty allows whitespace. This is not part of
// any spec I could find.
let trimmed = {
let mut s = value;
while let [b' ', rest @ ..] = s {
s = rest;
}
while let [rest @ .., b' '] = s {
s = rest;
}
s
};
if let Some(rgb) = x11_color::get_bytes(trimmed) {
return Ok(rgb);
}
if value.len() < "rgb:a/a/a".len() || &value[0..3] != b"rgb" {
return Err(InvalidFormat);
}
let mut i: usize = 3;
let use_intensity = if value[i] == b'i' {
i += 1;
true
} else {
false
};
if value[i] != b':' {
return Err(InvalidFormat);
}
i += 1;
let component = |slice: &[u8]| {
if use_intensity {
Rgb::from_intensity(slice)
} else {
Rgb::from_hex(slice)
}
};
let r = {
let end = value[i..]
.iter()
.position(|&c| c == b'/')
.map(|p| p + i)
.ok_or(InvalidFormat)?;
let slice = &value[i..end];
i += slice.len() + 1;
component(slice)?
};
let g = {
let end = value[i..]
.iter()
.position(|&c| c == b'/')
.map(|p| p + i)
.ok_or(InvalidFormat)?;
let slice = &value[i..end];
i += slice.len() + 1;
component(slice)?
};
let b = component(&value[i..])?;
Ok(Rgb { r, g, b })
}
}
/// Calculates single-component luminance based on the W3C formula.
///
/// Expects sRGB color space which at the time of writing we don't
/// generally use but it's a good enough approximation until we fix that.
/// https://www.w3.org/TR/WCAG20/#relativeluminancedef
fn component_luminance(c: u8) -> f64 {
let normalized = c as f64 / 255.0;
if normalized <= 0.03928 {
return normalized / 12.92;
}
((normalized + 0.055) / 1.055).powf(2.4)
}
/// LAB color space
#[derive(Debug, Clone, Copy, PartialEq)]
struct Lab {
l: f32,
a: f32,
b: f32,
}
#[allow(clippy::excessive_precision)]
impl Lab {
/// RGB to LAB
fn from_rgb(rgb: Rgb) -> Lab {
// Step 1: Normalize sRGB channels from [0, 255] to [0.0, 1.0].
let mut r = rgb.r as f32 / 255.0;
let mut g = rgb.g as f32 / 255.0;
let mut b = rgb.b as f32 / 255.0;
// Step 2: Apply the inverse sRGB companding (gamma correction) to
// convert from sRGB to linear RGB.
r = if r > 0.04045 {
((r + 0.055) / 1.055).powf(2.4)
} else {
r / 12.92
};
g = if g > 0.04045 {
((g + 0.055) / 1.055).powf(2.4)
} else {
g / 12.92
};
b = if b > 0.04045 {
((b + 0.055) / 1.055).powf(2.4)
} else {
b / 12.92
};
// Step 3: Convert linear RGB to CIE XYZ using the sRGB to XYZ
// transformation matrix (D65 illuminant). The X and Z values are
// normalized by the D65 white point reference values (Xn=0.95047,
// Zn=1.08883; Yn=1.0 is implicit).
let mut x = (r * 0.4124564 + g * 0.3575761 + b * 0.1804375) / 0.95047;
let mut y = r * 0.2126729 + g * 0.7151522 + b * 0.0721750;
let mut z = (r * 0.0193339 + g * 0.1191920 + b * 0.9503041) / 1.08883;
// Step 4: Apply the CIE f(t) nonlinear transform to each XYZ
// component.
x = if x > 0.008856 {
x.cbrt()
} else {
7.787 * x + 16.0 / 116.0
};
y = if y > 0.008856 {
y.cbrt()
} else {
7.787 * y + 16.0 / 116.0
};
z = if z > 0.008856 {
z.cbrt()
} else {
7.787 * z + 16.0 / 116.0
};
// Step 5: Compute the final CIELAB values from the transformed XYZ.
Lab {
l: 116.0 * y - 16.0,
a: 500.0 * (x - y),
b: 200.0 * (y - z),
}
}
/// LAB to RGB
fn to_rgb(self) -> Rgb {
// Step 1: Recover the intermediate f(Y), f(X), f(Z) values from
// L*a*b* by inverting the CIELAB formulas.
let y = (self.l + 16.0) / 116.0;
let x = self.a / 500.0 + y;
let z = y - self.b / 200.0;
// Step 2: Apply the inverse CIE f(t) transform to get back to XYZ.
let x3 = x * x * x;
let y3 = y * y * y;
let z3 = z * z * z;
let xf = (if x3 > 0.008856 {
x3
} else {
(x - 16.0 / 116.0) / 7.787
}) * 0.95047;
let yf = if y3 > 0.008856 {
y3
} else {
(y - 16.0 / 116.0) / 7.787
};
let zf = (if z3 > 0.008856 {
z3
} else {
(z - 16.0 / 116.0) / 7.787
}) * 1.08883;
// Step 3: Convert CIE XYZ back to linear RGB.
let mut r = xf * 3.2404542 - yf * 1.5371385 - zf * 0.4985314;
let mut g = -xf * 0.9692660 + yf * 1.8760108 + zf * 0.0415560;
let mut b = xf * 0.0556434 - yf * 0.2040259 + zf * 1.0572252;
// Step 4: Apply sRGB companding (gamma correction).
r = if r > 0.0031308 {
1.055 * r.powf(1.0 / 2.4) - 0.055
} else {
12.92 * r
};
g = if g > 0.0031308 {
1.055 * g.powf(1.0 / 2.4) - 0.055
} else {
12.92 * g
};
b = if b > 0.0031308 {
1.055 * b.powf(1.0 / 2.4) - 0.055
} else {
12.92 * b
};
// Step 5: Clamp to [0.0, 1.0], scale to [0, 255], and round to
// the nearest integer to produce the final 8-bit sRGB values.
Rgb {
r: (r.clamp(0.0, 1.0) * 255.0 + 0.5) as u8,
g: (g.clamp(0.0, 1.0) * 255.0 + 0.5) as u8,
b: (b.clamp(0.0, 1.0) * 255.0 + 0.5) as u8,
}
}
/// Linearly interpolate between two LAB colors component-wise.
/// `t` is the interpolation factor in [0, 1]: t=0 returns `a`,
/// t=1 returns `b`, and values in between blend proportionally.
fn lerp(t: f32, a: Lab, b: Lab) -> Lab {
Lab {
l: a.l + t * (b.l - a.l),
a: a.a + t * (b.a - a.a),
b: a.b + t * (b.b - a.b),
}
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
fn rgb(r: u8, g: u8, b: u8) -> Rgb {
Rgb { r, g, b }
}
#[test]
fn special_osc4() {
assert_eq!(256, Special::Bold.osc4());
assert_eq!(257, Special::Underline.osc4());
assert_eq!(258, Special::Blink.osc4());
assert_eq!(259, Special::Reverse.osc4());
assert_eq!(260, Special::Italic.osc4());
}
#[test]
fn dynamic_next() {
use Dynamic::*;
assert_eq!(Some(Background), Foreground.next());
assert_eq!(Some(Cursor), Background.next());
assert_eq!(Some(PointerForeground), Cursor.next());
assert_eq!(Some(PointerBackground), PointerForeground.next());
assert_eq!(Some(TektronixForeground), PointerBackground.next());
assert_eq!(Some(TektronixBackground), TektronixForeground.next());
assert_eq!(Some(HighlightBackground), TektronixBackground.next());
assert_eq!(Some(TektronixCursor), HighlightBackground.next());
assert_eq!(Some(HighlightForeground), TektronixCursor.next());
assert_eq!(None, HighlightForeground.next());
}
#[test]
fn palette_default() {
for i in 0..16u8 {
assert_eq!(Name(i).default().unwrap(), DEFAULT[i as usize]);
}
// Spot checks of the cube and ramp.
assert_eq!(DEFAULT[16], rgb(0, 0, 0));
assert_eq!(DEFAULT[17], rgb(0, 0, 95));
assert_eq!(DEFAULT[231], rgb(255, 255, 255));
assert_eq!(DEFAULT[232], rgb(8, 8, 8));
assert_eq!(DEFAULT[255], rgb(238, 238, 238));
}
#[test]
fn rgb_parse() {
assert_eq!(rgb(255, 0, 0), Rgb::parse("rgbi:1.0/0/0").unwrap());
assert_eq!(rgb(127, 160, 0), Rgb::parse("rgb:7f/a0a0/0").unwrap());
assert_eq!(rgb(255, 255, 255), Rgb::parse("rgb:f/ff/fff").unwrap());
assert_eq!(rgb(255, 255, 255), Rgb::parse("#ffffff").unwrap());
assert_eq!(rgb(255, 255, 255), Rgb::parse("#fff").unwrap());
assert_eq!(rgb(255, 255, 255), Rgb::parse("#fffffffff").unwrap());
assert_eq!(rgb(255, 255, 255), Rgb::parse("#ffffffffffff").unwrap());
assert_eq!(rgb(255, 0, 16), Rgb::parse("#ff0010").unwrap());
assert_eq!(rgb(0, 0, 0), Rgb::parse("black").unwrap());
assert_eq!(rgb(255, 0, 0), Rgb::parse("red").unwrap());
assert_eq!(rgb(0, 255, 0), Rgb::parse("green").unwrap());
assert_eq!(rgb(0, 0, 255), Rgb::parse("blue").unwrap());
assert_eq!(rgb(255, 255, 255), Rgb::parse("white").unwrap());
assert_eq!(rgb(124, 252, 0), Rgb::parse("LawnGreen").unwrap());
assert_eq!(rgb(0, 250, 154), Rgb::parse("medium spring green").unwrap());
assert_eq!(rgb(34, 139, 34), Rgb::parse(" Forest Green ").unwrap());
// Invalid format
for bad in [
"rgb;",
"rgb:",
":a/a/a",
"a/a/a",
"rgb:a/a/a/",
"rgb:00000///",
"rgb:000/",
"rgbi:a/a/a",
"rgb:0.5/0.0/1.0",
"rgb:not/hex/zz",
"#",
"#ff",
"#ffff",
"#fffff",
"#gggggg",
] {
assert_eq!(Err(InvalidFormat), Rgb::parse(bad), "{bad}");
}
}
#[test]
fn dynamic_palette_init() {
let p = DynamicPalette::init(DEFAULT);
assert_eq!(DEFAULT, p.current);
assert_eq!(DEFAULT, p.original);
assert_eq!(0, p.mask.count());
}
#[test]
fn dynamic_palette_set() {
let mut p = DynamicPalette::init(DEFAULT);
let new_color = rgb(255, 0, 0);
p.set(0, new_color);
assert_eq!(new_color, p.current[0]);
assert!(p.mask.is_set(0));
assert_eq!(1, p.mask.count());
assert_eq!(DEFAULT[0], p.original[0]);
}
#[test]
fn dynamic_palette_reset() {
let mut p = DynamicPalette::init(DEFAULT);
p.set(0, rgb(255, 0, 0));
assert!(p.mask.is_set(0));
p.reset(0);
assert_eq!(DEFAULT[0], p.current[0]);
assert!(!p.mask.is_set(0));
assert_eq!(0, p.mask.count());
}
#[test]
fn dynamic_palette_reset_all() {
let mut p = DynamicPalette::init(DEFAULT);
let c = rgb(255, 0, 0);
p.set(0, c);
p.set(5, c);
p.set(10, c);
assert_eq!(3, p.mask.count());
p.reset_all();
assert_eq!(DEFAULT, p.current);
assert_eq!(DEFAULT, p.original);
assert_eq!(0, p.mask.count());
}
#[test]
fn dynamic_palette_change_default_with_no_changes() {
let mut p = DynamicPalette::init(DEFAULT);
let mut new_palette = DEFAULT;
new_palette[0] = rgb(100, 100, 100);
p.change_default(new_palette);
assert_eq!(new_palette, p.original);
assert_eq!(new_palette, p.current);
assert_eq!(0, p.mask.count());
}
#[test]
fn dynamic_palette_change_default_preserves_changes() {
let mut p = DynamicPalette::init(DEFAULT);
let custom = rgb(255, 0, 0);
p.set(5, custom);
assert!(p.mask.is_set(5));
let mut new_palette = DEFAULT;
new_palette[0] = rgb(100, 100, 100);
new_palette[5] = rgb(50, 50, 50);
p.change_default(new_palette);
assert_eq!(new_palette, p.original);
assert_eq!(new_palette[0], p.current[0]);
assert_eq!(custom, p.current[5]);
assert!(p.mask.is_set(5));
assert_eq!(1, p.mask.count());
}
#[test]
fn dynamic_palette_change_default_with_multiple_changes() {
let mut p = DynamicPalette::init(DEFAULT);
let red = rgb(255, 0, 0);
let green = rgb(0, 255, 0);
let blue = rgb(0, 0, 255);
p.set(1, red);
p.set(2, green);
p.set(3, blue);
let mut new_palette = DEFAULT;
new_palette[0] = rgb(50, 50, 50);
new_palette[1] = rgb(60, 60, 60);
p.change_default(new_palette);
assert_eq!(new_palette[0], p.current[0]);
assert_eq!(red, p.current[1]);
assert_eq!(green, p.current[2]);
assert_eq!(blue, p.current[3]);
assert_eq!(3, p.mask.count());
}
fn approx(expected: f32, actual: f32) {
assert!((expected - actual).abs() <= 0.5, "{expected} vs {actual}");
}
#[test]
fn lab_from_rgb() {
let white = Lab::from_rgb(rgb(255, 255, 255));
approx(100.0, white.l);
approx(0.0, white.a);
approx(0.0, white.b);
let black = Lab::from_rgb(rgb(0, 0, 0));
approx(0.0, black.l);
approx(0.0, black.a);
approx(0.0, black.b);
let red = Lab::from_rgb(rgb(255, 0, 0));
approx(53.23, red.l);
approx(80.11, red.a);
approx(67.22, red.b);
let green = Lab::from_rgb(rgb(0, 128, 0));
approx(46.23, green.l);
approx(-51.70, green.a);
approx(49.90, green.b);
let blue = Lab::from_rgb(rgb(0, 0, 255));
approx(32.30, blue.l);
approx(79.20, blue.a);
approx(-107.86, blue.b);
}
#[test]
fn generate256_color_base16_preserved() {
let bg = rgb(0, 0, 0);
let fg = rgb(255, 255, 255);
let palette = generate_256_color(DEFAULT, PaletteMask::init_empty(), bg, fg, false);
for i in 0..16 {
assert_eq!(DEFAULT[i], palette[i]);
}
}
#[test]
fn generate256_color_cube_corners_match_base_colors() {
let bg = rgb(0, 0, 0);
let fg = rgb(255, 255, 255);
let palette = generate_256_color(DEFAULT, PaletteMask::init_empty(), bg, fg, false);
assert_eq!(bg, palette[16]);
assert_eq!(fg, palette[231]);
}
#[test]
fn generate256_color_cube_corners_black_white_with_harmonious_false() {
let black = rgb(0, 0, 0);
let white = rgb(255, 255, 255);
let dark = generate_256_color(DEFAULT, PaletteMask::init_empty(), black, white, false);
assert_eq!(black, dark[16]);
assert_eq!(white, dark[231]);
let light = generate_256_color(DEFAULT, PaletteMask::init_empty(), white, black, false);
assert_eq!(black, light[16]);
assert_eq!(white, light[231]);
}
#[test]
fn generate256_color_light_theme_cube_corners_with_harmonious_true() {
let white = rgb(255, 255, 255);
let black = rgb(0, 0, 0);
let palette = generate_256_color(DEFAULT, PaletteMask::init_empty(), white, black, true);
assert_eq!(white, palette[16]);
assert_eq!(black, palette[231]);
}
#[test]
fn generate256_color_grayscale_ramp_monotonic_luminance() {
let bg = rgb(0, 0, 0);
let fg = rgb(255, 255, 255);
let palette = generate_256_color(DEFAULT, PaletteMask::init_empty(), bg, fg, false);
let mut prev_lum = 0.0;
for c in &palette[232..256] {
let lum = c.luminance();
assert!(lum >= prev_lum);
prev_lum = lum;
}
}
#[test]
fn generate256_color_skip_mask_preserves_original_colors() {
let bg = rgb(0, 0, 0);
let fg = rgb(255, 255, 255);
let mut skip = PaletteMask::init_empty();
skip.set(20);
skip.set(100);
skip.set(240);
let palette = generate_256_color(DEFAULT, skip, bg, fg, false);
assert_eq!(DEFAULT[20], palette[20]);
assert_eq!(DEFAULT[100], palette[100]);
assert_eq!(DEFAULT[240], palette[240]);
assert!(!palette[21].eql(DEFAULT[21]));
}
#[test]
fn generate256_color_dark_theme_harmonious_has_no_effect() {
let bg = rgb(0, 0, 0);
let fg = rgb(255, 255, 255);
let normal = generate_256_color(DEFAULT, PaletteMask::init_empty(), bg, fg, false);
let harmonious = generate_256_color(DEFAULT, PaletteMask::init_empty(), bg, fg, true);
for i in 16..256 {
assert_eq!(normal[i], harmonious[i]);
}
}
#[test]
fn generate256_color_light_theme_harmonious_skips_inversion() {
let bg = rgb(255, 255, 255);
let fg = rgb(0, 0, 0);
let inverted = generate_256_color(DEFAULT, PaletteMask::init_empty(), bg, fg, false);
let harmonious = generate_256_color(DEFAULT, PaletteMask::init_empty(), bg, fg, true);
assert_eq!(bg, harmonious[16]);
assert!(!inverted[16].eql(bg));
let differ = (16..232)
.filter(|&i| !inverted[i].eql(harmonious[i]))
.count();
assert!(differ > 0);
}
#[test]
fn generate256_color_light_theme_harmonious_grayscale_ramp() {
let bg = rgb(255, 255, 255);
let fg = rgb(0, 0, 0);
{
let palette = generate_256_color(DEFAULT, PaletteMask::init_empty(), bg, fg, false);
let mut prev_lum = 0.0;
for c in &palette[232..256] {
let lum = c.luminance();
assert!(lum >= prev_lum);
prev_lum = lum;
}
}
{
let palette = generate_256_color(DEFAULT, PaletteMask::init_empty(), bg, fg, true);
let mut prev_lum = 1.0;
for c in &palette[232..256] {
let lum = c.luminance();
assert!(lum <= prev_lum);
prev_lum = lum;
}
}
}
#[test]
fn lab_to_rgb() {
let cases = [
rgb(255, 255, 255),
rgb(0, 0, 0),
rgb(255, 0, 0),
rgb(0, 128, 0),
rgb(0, 0, 255),
rgb(128, 128, 128),
rgb(64, 224, 208),
];
for expected in cases {
let actual = Lab::from_rgb(expected).to_rgb();
assert_eq!(expected, actual);
}
}
}