Generate the theme catalog from real themes instead of a hand table

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
This commit is contained in:
Metal Agent
2026-09-01 12:51:25 -07:00
co-authored by Claude Opus 5
parent 7bee985279
commit 401760d670
108 changed files with 72648 additions and 12 deletions
+226
View File
@@ -0,0 +1,226 @@
//! 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");
}
}