//! 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 { 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"); } }