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:
co-authored by
Claude Opus 5
parent
7bee985279
commit
401760d670
@@ -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
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user