Files
lumbridge-code/crates/lumbridge-theme/src/terminal.rs
T
Metal AgentandClaude Opus 5 401760d670 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
2026-09-01 12:51:25 -07:00

227 lines
7.3 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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");
}
}