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
+55
View File
@@ -17,6 +17,7 @@ use crate::color::{
Srgb, adjust, contrast_foreground, find_color_with_luminance, mix, relative_luminance,
};
use crate::palette::Palette;
use crate::terminal::TerminalPalette;
/// The luminance step between the chrome and the work surface.
///
@@ -43,6 +44,15 @@ pub struct ThemeAnchors {
pub added: Option<Srgb>,
pub deleted: Option<Srgb>,
pub modified: Option<Srgb>,
/// The theme's own sixteen ANSI colours.
///
/// Not derived by [`derive`]: the terminal palette is extracted from the
/// theme file rather than computed from the anchors, because a theme that
/// ships `terminal.ansi*` keys has already made those choices and guessing
/// over them would be inventing a value. Where a theme ships none, the
/// catalog generator derives them once, at generation time, and writes down
/// what it did.
pub terminal: TerminalPalette,
/// Exact values for roles that would otherwise be derived.
pub overrides: RoleOverrides,
}
@@ -67,6 +77,26 @@ pub struct RoleOverrides {
pub attention: Option<Srgb>,
}
impl RoleOverrides {
/// No overrides at all: every role is derived.
///
/// The same value as [`RoleOverrides::default`], but usable in a `const`
/// item, which is what the generated catalog needs. Every catalog theme
/// spells this out rather than pinning a role, because a catalog theme that
/// set one would be defeating the engine.
pub const NONE: Self = Self {
chrome: None,
surface: None,
surface_raised: None,
surface_active: None,
border: None,
border_quiet: None,
accent: None,
success: None,
attention: None,
};
}
impl ThemeAnchors {
#[must_use]
pub fn is_dark(&self) -> bool {
@@ -166,6 +196,30 @@ pub fn derive(anchors: &ThemeAnchors, accent: Srgb) -> Palette {
mod tests {
use super::{RoleOverrides, ThemeAnchors, chrome_and_surface, derive};
use crate::color::Srgb;
use crate::terminal::TerminalPalette;
/// A terminal palette for fixtures. The derivation never reads it, so the
/// values only have to be distinct enough that a mix-up would show.
fn ramp(bg: u32, fg: u32) -> TerminalPalette {
let mut ansi = [Srgb::from_hex(bg); 16];
for (index, slot) in ansi.iter_mut().enumerate() {
#[expect(
clippy::cast_possible_truncation,
reason = "the loop runs over exactly sixteen slots"
)]
let step = (index * 16) as u8;
slot.r = step;
slot.g = step;
slot.b = step;
}
TerminalPalette {
background: Srgb::from_hex(bg),
foreground: Srgb::from_hex(fg),
cursor: Srgb::from_hex(fg),
cursor_text: Srgb::from_hex(bg),
ansi,
}
}
fn anchors(bg: u32, fg: u32, comment: u32) -> ThemeAnchors {
ThemeAnchors {
@@ -177,6 +231,7 @@ mod tests {
added: None,
deleted: None,
modified: None,
terminal: ramp(bg, fg),
overrides: RoleOverrides::default(),
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,15 @@
//! Files written by `tools/theme-gen`.
//!
//! Nothing in here is hand-edited. The generator reads `assets/themes/` and
//! writes text, and the text is checked in, so a colour that changes because an
//! upstream theme changed turns up in a review diff rather than inside a build
//! directory. `cargo run -p theme-gen -- --check` fails if these have drifted
//! from the assets they were made from.
pub mod catalog;
/// The reference implementation's answers, for the test that compares against
/// them. Test-only: the same colours ship inside [`catalog`], so building this
/// into the binary would put a second copy of the catalog in it for nothing.
#[cfg(test)]
pub mod reference_vectors;
File diff suppressed because it is too large Load Diff
+432 -12
View File
@@ -22,12 +22,15 @@
pub mod accent;
pub mod color;
pub mod derive;
pub mod generated;
pub mod palette;
pub mod terminal;
pub use accent::{ACCENTS, Accent, DEFAULT_ACCENT, accent_or_default};
pub use color::{Srgb, contrast_ratio, relative_luminance};
pub use derive::{RoleOverrides, ThemeAnchors, derive};
pub use palette::{Palette, Provenance};
pub use terminal::{AnsiColor, TerminalPalette};
/// The default theme's identifier.
pub const DEFAULT_THEME: &str = "lumbridge-slate";
@@ -49,6 +52,36 @@ pub const LUMBRIDGE_SLATE: ThemeAnchors = ThemeAnchors {
added: Some(Srgb::from_hex(0x70d6a8)),
deleted: Some(Srgb::from_hex(0xff6b6b)),
modified: Some(Srgb::from_hex(0xf1b96a)),
// The sixteen values `main.rs` already hard-codes for indexed terminal
// colours, moved here unchanged, so adopting per-theme terminal palettes
// costs the default theme no pixels either. The cursor colour is the one
// `main.rs` uses; the colour of the glyph *under* a block cursor was never
// chosen, so it derives the way every catalog theme's does — from the
// background — rather than being guessed at.
terminal: TerminalPalette {
background: Srgb::from_hex(0x101620),
foreground: Srgb::from_hex(0xdbe5f4),
cursor: Srgb::from_hex(0xf4f8ff),
cursor_text: Srgb::from_hex(0x101620),
ansi: [
Srgb::from_hex(0x1d2430),
Srgb::from_hex(0xff6b6b),
Srgb::from_hex(0x70d6a8),
Srgb::from_hex(0xf1c76a),
Srgb::from_hex(0x68b5f8),
Srgb::from_hex(0xc79bf2),
Srgb::from_hex(0x63d5da),
Srgb::from_hex(0xdbe5f4),
Srgb::from_hex(0x6d7a91),
Srgb::from_hex(0xff8b8b),
Srgb::from_hex(0x93e6be),
Srgb::from_hex(0xf8d98c),
Srgb::from_hex(0x8bc8ff),
Srgb::from_hex(0xd9b4fb),
Srgb::from_hex(0x86e7eb),
Srgb::from_hex(0xf4f8ff),
],
},
overrides: RoleOverrides {
chrome: Some(Srgb::from_hex(0x090c12)),
surface: Some(Srgb::from_hex(0x101620)),
@@ -62,8 +95,30 @@ pub const LUMBRIDGE_SLATE: ThemeAnchors = ThemeAnchors {
},
};
/// Every theme available. One for now; the catalog lands separately.
pub const CATALOG: &[ThemeAnchors] = &[LUMBRIDGE_SLATE];
/// The number of themes on offer: the first-party one plus the generated set.
const CATALOG_LEN: usize = 1 + generated::catalog::CATALOG.len();
/// Assembles the catalog with the first-party theme in front.
///
/// Written out rather than folded into the generated file so that the ordering
/// rule lives where a reader looks for it. Decision 0018 requires the default
/// theme to keep reproducing the appearance it replaced, and it is the only
/// theme allowed to carry `RoleOverrides`; a generated file that knew about it
/// could quietly stop putting it first.
const fn build_catalog() -> [ThemeAnchors; CATALOG_LEN] {
let mut catalog = [LUMBRIDGE_SLATE; CATALOG_LEN];
let mut index = 0;
while index < generated::catalog::CATALOG.len() {
catalog[index + 1] = generated::catalog::CATALOG[index];
index += 1;
}
catalog
}
const CATALOG_STORAGE: [ThemeAnchors; CATALOG_LEN] = build_catalog();
/// Every theme available, `lumbridge-slate` first.
pub const CATALOG: &[ThemeAnchors] = &CATALOG_STORAGE;
/// Looks a theme up by name, falling back to the default.
///
@@ -90,6 +145,12 @@ mod tests {
derive(&LUMBRIDGE_SLATE, accent.resolve(true, LUMBRIDGE_SLATE.fg))
}
/// A theme as the interface will paint it out of the box.
fn catalog_palette(theme: &crate::ThemeAnchors) -> crate::Palette {
let accent = accent_or_default(crate::DEFAULT_ACCENT);
derive(theme, accent.resolve(theme.is_dark(), theme.fg))
}
#[test]
fn the_default_theme_reproduces_the_palette_it_replaced() {
// The eleven constants that lived in main.rs. If the engine ever stops
@@ -139,22 +200,61 @@ mod tests {
}
}
/// The catalog is only usable if every theme in it is readable, so the gate
/// is here rather than only in the generator: a theme added by hand, or a
/// generator whose contrast rule regressed, has to fail somewhere the test
/// suite runs.
#[test]
fn every_catalog_theme_keeps_text_readable_on_its_own_surface() {
fn every_catalog_theme_keeps_body_text_readable_on_its_own_surface() {
for theme in CATALOG {
let accent = accent_or_default(crate::DEFAULT_ACCENT);
let palette = derive(theme, accent.resolve(theme.is_dark(), theme.fg));
let palette = catalog_palette(theme);
let text = contrast_ratio(palette.text, palette.surface);
assert!(
text >= 4.5,
"{}: body text is {text:.2}:1 against its own surface",
"{}: body text is {text:.2}:1 against its own surface, below WCAG AA; \
the generator should have held this theme back",
theme.name
);
}
}
/// Secondary text is the theme author's comment colour, and a comment is
/// meant to recede, so a handful of themes put it below the 3:1 large-text
/// floor. That is a fact about those themes rather than a defect, and the
/// generated list is where it is written down — checked in both directions
/// so it can neither grow quietly nor go stale.
#[test]
fn secondary_text_below_the_readable_floor_is_named_in_the_catalog() {
let listed = crate::generated::catalog::SECONDARY_TEXT_BELOW_3_TO_1;
for theme in CATALOG {
let palette = catalog_palette(theme);
let muted = contrast_ratio(palette.muted, palette.surface);
let recorded = listed.iter().find(|(name, _)| *name == theme.name);
match recorded {
None => assert!(
muted >= 3.0,
"{}: secondary text is {muted:.2}:1 but the catalog does not say so; \
regenerate with `cargo run -p theme-gen`",
theme.name
),
Some((_, ratio)) => {
assert!(
muted < 3.0,
"{}: listed as below 3:1 but measures {muted:.2}:1; the list is stale",
theme.name
);
assert!(
(muted - ratio).abs() < 0.01,
"{}: recorded {ratio:.3}:1 but measures {muted:.3}:1",
theme.name
);
}
}
}
for (name, _) in listed {
assert!(
muted >= 3.0,
"{}: secondary text is {muted:.2}:1",
theme.name
CATALOG.iter().any(|theme| theme.name == *name),
"{name} is listed as low-contrast but is not in the catalog"
);
}
}
@@ -164,14 +264,14 @@ mod tests {
#[test]
fn provenance_colours_stay_distinguishable_in_every_theme() {
for theme in CATALOG {
let accent = accent_or_default(crate::DEFAULT_ACCENT);
let palette = derive(theme, accent.resolve(theme.is_dark(), theme.fg));
let palette = catalog_palette(theme);
let roles = palette.provenance_roles();
for (index, first) in roles.iter().enumerate() {
for second in &roles[index + 1..] {
assert_ne!(
first, second,
"{}: two provenance colours are identical",
"{}: two provenance colours are identical, so a usage value's \
source would be unreadable",
theme.name
);
}
@@ -179,6 +279,63 @@ mod tests {
}
}
/// A terminal that paints two different escape codes the same colour has
/// dropped information the running program encoded. The generator forces the
/// eight base hues apart; this is the assertion that says it must.
#[test]
fn every_theme_keeps_its_eight_base_ansi_hues_apart() {
for theme in CATALOG {
let hues = theme.terminal.base_hues();
for (index, first) in hues.iter().enumerate() {
for second in &hues[index + 1..] {
assert_ne!(
first,
second,
"{}: ANSI slots {index} and later share {:06x}",
theme.name,
first.to_hex()
);
}
}
}
}
#[test]
fn the_catalog_leads_with_the_first_party_theme_and_nothing_else_pins_a_role() {
assert_eq!(
CATALOG.first().map(|theme| theme.name),
Some(DEFAULT_THEME),
"decision 0018 puts the first-party theme first"
);
for theme in &CATALOG[1..] {
assert_eq!(
theme.overrides,
crate::RoleOverrides::NONE,
"{}: a catalog theme that pins a role is defeating the engine",
theme.name
);
}
}
#[test]
fn every_catalog_theme_has_an_identifier_no_other_theme_uses() {
let mut seen: Vec<&str> = Vec::with_capacity(CATALOG.len());
for theme in CATALOG {
assert!(!theme.name.is_empty(), "a theme has no identifier");
assert!(
!theme.display_name.is_empty(),
"{}: no display name to show in a picker",
theme.name
);
assert!(
!seen.contains(&theme.name),
"{} appears twice; theme_or_default would resolve to whichever came first",
theme.name
);
seen.push(theme.name);
}
}
#[test]
fn provenance_maps_to_a_role_and_cannot_see_a_value() {
let palette = slate_palette();
@@ -193,3 +350,266 @@ mod tests {
assert_eq!(theme_or_default(DEFAULT_THEME).name, DEFAULT_THEME);
}
}
/// The catalog and the derivation, checked against Buzz's original TypeScript.
///
/// The vectors in [`generated::reference_vectors`] were produced by running that
/// TypeScript under Node over the same theme files this catalog was generated
/// from — not by re-deriving them, which decision 0018 records as having gone
/// wrong once already. A failure here means the Rust port drifted from the
/// implementation the catalog's colours were promised to match.
#[cfg(test)]
mod reference_tests {
use crate::color::Srgb;
use crate::derive::{RoleOverrides, ThemeAnchors, derive};
use crate::generated::catalog::{ANSI_SUBSTITUTIONS, COMMENT_SOURCES, SKIPPED_GIT_KEYS};
use crate::generated::reference_vectors::{REFERENCE_VECTORS, ReferenceVector};
use crate::palette::Palette;
use crate::terminal::TerminalPalette;
use crate::{CATALOG, DEFAULT_THEME};
fn palette_of(vector: &ReferenceVector) -> Palette {
let anchors = ThemeAnchors {
name: vector.name,
display_name: vector.name,
bg: vector.bg,
fg: vector.fg,
comment: vector.comment,
added: vector.added,
deleted: vector.deleted,
modified: vector.modified,
terminal: TerminalPalette {
background: vector.bg,
foreground: vector.fg,
cursor: vector.fg,
cursor_text: vector.bg,
ansi: vector.ansi,
},
overrides: RoleOverrides::NONE,
};
derive(&anchors, Srgb::from_hex(0x3b82f6))
}
/// Sixty-four real backgrounds through the luminance bisection, compared
/// against the answers the reference gives for each one.
#[test]
fn the_derivation_reproduces_the_reference_for_every_vendored_theme() {
assert!(
REFERENCE_VECTORS.len() > 60,
"the reference vectors look truncated: {} themes",
REFERENCE_VECTORS.len()
);
for vector in REFERENCE_VECTORS {
let palette = palette_of(vector);
for (role, ours, theirs) in [
("chrome", palette.chrome, vector.chrome),
("surface", palette.surface, vector.surface),
(
"surface_raised",
palette.surface_raised,
vector.surface_raised,
),
(
"surface_active",
palette.surface_active,
vector.surface_active,
),
(
"surface_overlay",
palette.surface_overlay,
vector.surface_overlay,
),
(
"surface_between",
palette.surface_between,
vector.surface_between,
),
("border", palette.border, vector.border),
("border_quiet", palette.border_quiet, vector.border_quiet),
] {
assert_eq!(
ours.to_hex(),
theirs.to_hex(),
"{}: {role} derived {:06x}, reference says {:06x}",
vector.name,
ours.to_hex(),
theirs.to_hex()
);
}
assert_eq!(
palette.is_dark, vector.is_dark,
"{}: light/dark classification disagrees",
vector.name
);
}
}
/// Every catalog theme's anchors, against what the reference extracted from
/// the same file. The comment colour is the one field the two can disagree
/// on, and only for the themes the catalog names and says why.
#[test]
fn the_extracted_anchors_reproduce_the_reference() {
for theme in &CATALOG[1..] {
let vector = reference_for(theme.name);
assert_eq!(
theme.bg.to_hex(),
vector.bg.to_hex(),
"{}: background disagrees with the reference",
theme.name
);
assert_eq!(
theme.fg.to_hex(),
vector.fg.to_hex(),
"{}: foreground disagrees with the reference",
theme.name
);
match COMMENT_SOURCES.iter().find(|(name, _)| *name == theme.name) {
None => assert_eq!(
theme.comment.to_hex(),
vector.comment.to_hex(),
"{}: comment colour disagrees with the reference and is not \
recorded as one of the themes the reference cannot read",
theme.name
),
Some((_, why)) => {
assert_eq!(
vector.comment.to_hex(),
vector.fg.to_hex(),
"{}: recorded as unreadable by the reference ({why}), but the \
reference did read a colour for it",
theme.name
);
assert_ne!(
theme.comment.to_hex(),
theme.fg.to_hex(),
"{}: recorded as recovered ({why}), yet secondary text is still \
the same colour as body text",
theme.name
);
}
}
}
}
/// The terminal palette, slot by slot, with the recorded collisions as the
/// only permitted disagreements.
#[test]
fn the_terminal_palette_reproduces_the_reference_except_where_a_collision_was_recorded() {
for theme in &CATALOG[1..] {
let vector = reference_for(theme.name);
for (index, ours) in theme.terminal.ansi.iter().enumerate() {
let substituted = ANSI_SUBSTITUTIONS.contains(&(theme.name, index));
let theirs = vector.ansi[index];
if substituted {
assert_ne!(
ours.to_hex(),
theirs.to_hex(),
"{}: slot {index} is recorded as substituted but matches the \
reference, so the record is stale",
theme.name
);
} else {
assert_eq!(
ours.to_hex(),
theirs.to_hex(),
"{}: slot {index} is {:06x}, reference says {:06x}, and no \
substitution is recorded for it",
theme.name,
ours.to_hex(),
theirs.to_hex()
);
}
}
}
}
/// A substitution recorded for a theme that no longer ships would hide a
/// real disagreement behind a name nobody checks.
#[test]
fn every_recorded_substitution_belongs_to_a_theme_that_ships() {
for (name, slot) in ANSI_SUBSTITUTIONS {
assert!(
CATALOG.iter().any(|theme| theme.name == *name),
"{name} has a recorded ANSI substitution for slot {slot} but is not in \
the catalog"
);
assert!(
*slot < 8 || ANSI_SUBSTITUTIONS.contains(&(*name, slot - 8)),
"{name}: bright slot {slot} is recorded as substituted but its base is \
not, so nothing explains why it moved"
);
}
for (name, why) in COMMENT_SOURCES {
assert!(
CATALOG.iter().any(|theme| theme.name == *name),
"{name} has a recorded comment source ({why}) but is not in the catalog"
);
}
for (name, role, key) in SKIPPED_GIT_KEYS {
assert!(
CATALOG.iter().any(|theme| theme.name == *name),
"{name} records skipping `{key}` for {role} but is not in the catalog"
);
}
}
/// The three git-derived role colours, against what the reference read from
/// the same file. A role whose first key the catalog passed over is allowed
/// to differ, and only that role.
#[test]
fn the_git_role_colours_reproduce_the_reference_except_where_a_key_was_skipped() {
for theme in &CATALOG[1..] {
let vector = reference_for(theme.name);
for (role, ours, theirs) in [
("added", theme.added, vector.added),
("deleted", theme.deleted, vector.deleted),
("modified", theme.modified, vector.modified),
] {
if SKIPPED_GIT_KEYS
.iter()
.any(|(name, skipped, _)| *name == theme.name && *skipped == role)
{
assert_ne!(
ours, theirs,
"{}: {role} is recorded as having skipped a key but matches the \
reference, so the record is stale",
theme.name
);
continue;
}
assert_eq!(
ours.map(Srgb::to_hex),
theirs.map(Srgb::to_hex),
"{}: {role} disagrees with the reference and no skipped key is \
recorded for it",
theme.name
);
}
}
}
fn reference_for(name: &str) -> &'static ReferenceVector {
REFERENCE_VECTORS
.iter()
.find(|vector| vector.name == name)
.unwrap_or_else(|| {
panic!(
"{name} is in the catalog but has no reference vector; rerun \
tools/theme-gen/reference/emit-reference-vectors.mjs"
)
})
}
/// The first-party theme has no upstream file, so it has no vector either.
#[test]
fn the_first_party_theme_is_the_only_one_without_a_reference_vector() {
assert_eq!(CATALOG[0].name, DEFAULT_THEME);
for theme in &CATALOG[1..] {
assert!(
REFERENCE_VECTORS.iter().any(|v| v.name == theme.name),
"{} has no reference vector",
theme.name
);
}
}
}
+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");
}
}