The derivation landed in decision 0018 with the catalog and the terminal ANSI palette still fixed tables written by hand. tools/theme-gen reads the TextMate themes under assets/themes/ and emits the catalog and the reference vectors, so the anchors a palette is derived from are the ones the theme actually ships rather than the ones somebody transcribed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SPYebLiN2w4TqnHUYGdECq
227 lines
7.3 KiB
Rust
227 lines
7.3 KiB
Rust
//! The sixteen ANSI colours a theme lends to the terminal.
|
||
//!
|
||
//! Decision 0018 left this deliberately unfinished: the engine derived the
|
||
//! interface but the terminal kept a single fixed table, so every theme in the
|
||
//! catalog would have painted the same red. This is the type that closes that
|
||
//! gap. It carries only the named colours — the 6×6×6 cube and the greyscale
|
||
//! ramp above index 15 are arithmetic on the index, not theme data, and belong
|
||
//! to whoever renders them.
|
||
//!
|
||
//! Two rules the catalog generator enforces and the tests re-check:
|
||
//!
|
||
//! - **The eight base hues are pairwise distinct.** A terminal that renders
|
||
//! `\e[34m` and `\e[35m` identically has lost information the program being
|
||
//! run intended to convey, and several upstream themes do exactly that —
|
||
//! sometimes by accident, sometimes on purpose. The generator substitutes a
|
||
//! derived hue for the collision and records the substitution in a comment
|
||
//! above the theme, so the choice can be argued with.
|
||
//! - **Nothing here is invented from nothing.** A colour is either the theme's
|
||
//! own `terminal.ansi*` key, a syntax token the theme already uses for that
|
||
//! role, or a documented derivation from the theme's own background and
|
||
//! foreground. There is no "plausible default" tier.
|
||
|
||
use crate::color::Srgb;
|
||
|
||
/// One of the sixteen named ANSI colours, in the order the escape codes use.
|
||
///
|
||
/// The discriminants are the SGR indices, so `AnsiColor::Blue as u8` is 4 and a
|
||
/// renderer can index [`TerminalPalette::ansi`] with it directly.
|
||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||
#[repr(u8)]
|
||
pub enum AnsiColor {
|
||
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,
|
||
}
|
||
|
||
impl AnsiColor {
|
||
/// Every named colour, dim first then bright, in SGR index order.
|
||
pub const ALL: [Self; 16] = [
|
||
Self::Black,
|
||
Self::Red,
|
||
Self::Green,
|
||
Self::Yellow,
|
||
Self::Blue,
|
||
Self::Magenta,
|
||
Self::Cyan,
|
||
Self::White,
|
||
Self::BrightBlack,
|
||
Self::BrightRed,
|
||
Self::BrightGreen,
|
||
Self::BrightYellow,
|
||
Self::BrightBlue,
|
||
Self::BrightMagenta,
|
||
Self::BrightCyan,
|
||
Self::BrightWhite,
|
||
];
|
||
|
||
/// The colour an SGR index names, or `None` for an index outside 0..=15.
|
||
///
|
||
/// Returning `None` rather than clamping is the point: indices 16 and above
|
||
/// are the colour cube, which is computed rather than themed, and silently
|
||
/// folding one of those into `BrightWhite` would paint a wrong colour
|
||
/// confidently.
|
||
#[must_use]
|
||
pub const fn from_index(index: u8) -> Option<Self> {
|
||
if index < 16 {
|
||
Some(Self::ALL[index as usize])
|
||
} else {
|
||
None
|
||
}
|
||
}
|
||
|
||
/// The wire name used in theme files, e.g. `terminal.ansiBrightBlue`.
|
||
#[must_use]
|
||
pub const fn key_suffix(self) -> &'static str {
|
||
match self {
|
||
Self::Black => "Black",
|
||
Self::Red => "Red",
|
||
Self::Green => "Green",
|
||
Self::Yellow => "Yellow",
|
||
Self::Blue => "Blue",
|
||
Self::Magenta => "Magenta",
|
||
Self::Cyan => "Cyan",
|
||
Self::White => "White",
|
||
Self::BrightBlack => "BrightBlack",
|
||
Self::BrightRed => "BrightRed",
|
||
Self::BrightGreen => "BrightGreen",
|
||
Self::BrightYellow => "BrightYellow",
|
||
Self::BrightBlue => "BrightBlue",
|
||
Self::BrightMagenta => "BrightMagenta",
|
||
Self::BrightCyan => "BrightCyan",
|
||
Self::BrightWhite => "BrightWhite",
|
||
}
|
||
}
|
||
}
|
||
|
||
/// A theme's terminal colours.
|
||
///
|
||
/// `background` and `foreground` are the theme's own editor colours rather than
|
||
/// the derived interface roles, because a terminal is a work surface: it should
|
||
/// match the editor it sits beside, not the frame around it.
|
||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||
pub struct TerminalPalette {
|
||
pub background: Srgb,
|
||
pub foreground: Srgb,
|
||
pub cursor: Srgb,
|
||
/// The glyph under the block cursor.
|
||
pub cursor_text: Srgb,
|
||
/// The sixteen named colours, indexed by [`AnsiColor`].
|
||
pub ansi: [Srgb; 16],
|
||
}
|
||
|
||
impl TerminalPalette {
|
||
/// The colour for a named ANSI slot.
|
||
#[must_use]
|
||
pub const fn color(&self, color: AnsiColor) -> Srgb {
|
||
self.ansi[color as usize]
|
||
}
|
||
|
||
/// The eight dim slots, which are the ones required to stay distinct.
|
||
///
|
||
/// The bright eight are allowed to collide with each other — plenty of
|
||
/// themes render bright white and white identically on purpose — but a
|
||
/// program that writes `\e[31m` and `\e[32m` is naming two different things.
|
||
#[must_use]
|
||
pub const fn base_hues(&self) -> [Srgb; 8] {
|
||
[
|
||
self.ansi[0],
|
||
self.ansi[1],
|
||
self.ansi[2],
|
||
self.ansi[3],
|
||
self.ansi[4],
|
||
self.ansi[5],
|
||
self.ansi[6],
|
||
self.ansi[7],
|
||
]
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::{AnsiColor, TerminalPalette};
|
||
use crate::color::Srgb;
|
||
|
||
fn palette() -> TerminalPalette {
|
||
let mut ansi = [Srgb::BLACK; 16];
|
||
for (index, slot) in ansi.iter_mut().enumerate() {
|
||
#[expect(
|
||
clippy::cast_possible_truncation,
|
||
reason = "the loop runs over exactly sixteen slots"
|
||
)]
|
||
let channel = index as u8;
|
||
*slot = Srgb {
|
||
r: channel,
|
||
g: 0,
|
||
b: 0,
|
||
};
|
||
}
|
||
TerminalPalette {
|
||
background: Srgb::BLACK,
|
||
foreground: Srgb::WHITE,
|
||
cursor: Srgb::WHITE,
|
||
cursor_text: Srgb::BLACK,
|
||
ansi,
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn a_named_colour_indexes_the_slot_its_escape_code_names() {
|
||
let palette = palette();
|
||
assert_eq!(
|
||
palette.color(AnsiColor::Blue).r,
|
||
4,
|
||
"AnsiColor::Blue must be SGR index 4, or every terminal colour shifts"
|
||
);
|
||
assert_eq!(palette.color(AnsiColor::BrightWhite).r, 15);
|
||
}
|
||
|
||
#[test]
|
||
fn an_index_above_the_named_range_is_unknown_rather_than_clamped() {
|
||
assert_eq!(AnsiColor::from_index(15), Some(AnsiColor::BrightWhite));
|
||
assert_eq!(
|
||
AnsiColor::from_index(16),
|
||
None,
|
||
"index 16 is the colour cube, not a themed colour, and must not \
|
||
resolve to a named slot"
|
||
);
|
||
assert_eq!(AnsiColor::from_index(255), None);
|
||
}
|
||
|
||
#[test]
|
||
fn the_base_hues_are_the_first_eight_slots_in_order() {
|
||
let palette = palette();
|
||
let hues = palette.base_hues();
|
||
for (index, hue) in hues.iter().enumerate() {
|
||
#[expect(
|
||
clippy::cast_possible_truncation,
|
||
reason = "the loop runs over exactly eight slots"
|
||
)]
|
||
let expected = index as u8;
|
||
assert_eq!(
|
||
hue.r, expected,
|
||
"base hue {index} must be ANSI slot {index}"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn every_named_colour_reports_the_theme_key_it_was_read_from() {
|
||
assert_eq!(AnsiColor::BrightBlue.key_suffix(), "BrightBlue");
|
||
assert_eq!(AnsiColor::Black.key_suffix(), "Black");
|
||
}
|
||
}
|