//! Turning a `TextMate` theme document into the five anchors and sixteen ANSI //! colours the catalog stores. //! //! The extraction rules are Buzz's, read out of //! `desktop/src/shared/theme/theme-loader.ts` and //! `desktop/src/shared/theme/terminal-palette.ts` (block/buzz, Apache-2.0, //! Copyright 2026 Block, Inc.) and reimplemented here so that Lumbridge's //! catalog contains the same colours a Buzz user would see. No implementation //! code was copied; `tools/theme-gen/reference/` holds a script that runs the //! originals under Node and writes the vectors the Rust side is tested against, //! which is what keeps the two honest. See decision 0018. //! //! Three details in those rules are easy to get wrong and are load-bearing: //! //! - The comment scope match is **exact**, and upstream compares against array //! elements only, so an entry whose scope is the single string //! `"comment, punctuation.definition.comment"` does not match it. Four themes //! spell it that way and upstream reads their comment colour as their //! foreground. Lumbridge runs upstream's rule first and only widens when it //! comes back with nothing usable — see [`CommentSource`]. //! - The terminal extractor's hex parser is **stricter** than the anchor //! extractor's: it wants a leading `#` and 3, 6, or 8 digits, and rejects the //! 4-digit form the anchor parser accepts. A theme spelling a terminal colour //! any other way falls through to the derived hue rather than being coerced. //! - An absent colour is absent. Where upstream has no value it derives one //! from colours the theme does carry; it never substitutes a stock palette. use lumbridge_theme::color::{contrast_ratio, mix, relative_luminance}; use lumbridge_theme::terminal::{AnsiColor, TerminalPalette}; use lumbridge_theme::{Srgb, ThemeAnchors, derive}; use serde_json::Value; /// The six chromatic hues used when a theme names neither a terminal colour nor /// a syntax token that could stand in for one. Upstream's table, kept because a /// different one would silently repaint every theme that relies on it. const FALLBACK_HUES: [(AnsiColor, u32); 6] = [ (AnsiColor::Red, 0xd75f5f), (AnsiColor::Green, 0x5faf87), (AnsiColor::Yellow, 0xd7af5f), (AnsiColor::Blue, 0x5f87d7), (AnsiColor::Magenta, 0xaf87d7), (AnsiColor::Cyan, 0x5fafd7), ]; /// Which syntax scopes stand in for which terminal hue. Upstream's table. const SCOPE_ANCHORS: [(AnsiColor, &[&str]); 6] = [ (AnsiColor::Red, &["invalid", "keyword", "deleted"]), (AnsiColor::Green, &["string", "inserted", "tag"]), (AnsiColor::Yellow, &["type", "class", "escape"]), (AnsiColor::Blue, &["function", "call", "method"]), (AnsiColor::Magenta, &["numeric", "constant", "operator"]), (AnsiColor::Cyan, &["attribute", "parameter", "property"]), ]; /// How much of the foreground is left in a comment colour derived for a theme /// that does not usefully declare one. Comments recede; this is how far. const DERIVED_COMMENT_MIX: f64 = 0.40; /// Where a theme's secondary-text colour came from. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum CommentSource { /// A `tokenColors` entry whose scope array holds the exact string /// `comment`. Upstream's rule, and by far the common case. ExactScope, /// The same rule, applied to an entry that spells its scope list as one /// comma-separated string rather than as an array. /// /// `TextMate` allows both spellings and several themes use the second one. /// Upstream compares against array elements only and so reads straight past /// them, which leaves the comment colour equal to the foreground — and a /// theme whose secondary text is identical to its body text has lost a /// distinction the interface depends on. Splitting the string first reads /// the colour the theme's author actually wrote down. CommaSeparatedScope, /// The theme declares no comment colour distinguishable from its /// foreground, so one is mixed from the theme's own foreground and /// background. Recorded per theme in the generated catalog. DerivedFromForeground, } /// A git-decoration key that was passed over because its colour was already in /// use for something else. pub struct SkippedGitKey { /// `added`, `deleted`, or `modified`. pub role: &'static str, /// The theme key that was skipped. pub key: &'static str, /// What already had that colour. pub collided_with: &'static str, } /// A base hue that had to be replaced because it repeated an earlier one. /// /// Recorded rather than applied silently: a reader looking at a theme whose /// blue is not the blue upstream shipped deserves to see why in the same file. pub struct Substitution { pub slot: AnsiColor, pub collided_with: AnsiColor, pub original: Srgb, pub replacement: Srgb, } /// Everything the catalog needs about one theme, plus the measurements the /// generator gates on. pub struct Extracted { pub bg: Srgb, pub fg: Srgb, pub comment: Srgb, pub comment_source: CommentSource, pub added: Option, pub deleted: Option, pub modified: Option, pub terminal: TerminalPalette, pub substitutions: Vec, /// Every ANSI slot whose colour is not the one the extraction rules alone /// would produce: the base hues that were forced apart, plus the bright /// slots that are derived from them and therefore moved with them. pub diverging_ansi_slots: Vec, pub skipped_git_keys: Vec, /// Body text against the surface the interface will actually paint, which /// is the theme's background except on the near-black themes where the /// derivation lifts the surface instead of sinking the frame. pub text_contrast: f64, /// The comment colour against that same surface. pub secondary_contrast: f64, } /// Reads one theme document. #[must_use] pub fn extract(document: &Value) -> Extracted { let colors = document.get("colors"); let bg = anchor(colors, "editor.background", 0x1e_1e1e); let fg = anchor(colors, "editor.foreground", 0xd4_d4d4); let tokens = token_entries(document); let (comment, comment_source) = comment_anchor(tokens, bg, fg); let (terminal, substitutions, diverging_ansi_slots) = terminal_palette(document, tokens); let ([added, deleted, modified], skipped_git_keys) = git_colors(colors, fg, comment); let mut extracted = Extracted { bg, fg, comment, comment_source, added, deleted, modified, terminal, substitutions, diverging_ansi_slots, skipped_git_keys, text_contrast: 0.0, secondary_contrast: 0.0, }; let surface = surface_of(&extracted); extracted.text_contrast = contrast_ratio(fg, surface); extracted.secondary_contrast = contrast_ratio(comment, surface); extracted } /// The work surface the interface derives for this theme. fn surface_of(extracted: &Extracted) -> Srgb { let anchors = ThemeAnchors { name: "", display_name: "", bg: extracted.bg, fg: extracted.fg, comment: extracted.comment, added: extracted.added, deleted: extracted.deleted, modified: extracted.modified, terminal: extracted.terminal, overrides: lumbridge_theme::RoleOverrides::default(), }; derive(&anchors, extracted.fg).surface } const GIT_ADDED_KEYS: &[&str] = &[ "gitDecoration.addedResourceForeground", "editorGutter.addedBackground", "diffEditor.insertedTextBackground", ]; const GIT_DELETED_KEYS: &[&str] = &[ "gitDecoration.deletedResourceForeground", "editorGutter.deletedBackground", "diffEditor.removedTextBackground", ]; const GIT_MODIFIED_KEYS: &[&str] = &[ "gitDecoration.modifiedResourceForeground", "editorGutter.modifiedBackground", ]; /// The three git-decoration colours, each the first the theme declares that is /// not a colour some other role has already taken. /// /// Two themes force this. Gruvbox sets `gitDecoration.addedResourceForeground` /// to its editor foreground — a fine choice in a file tree, where "added" files /// are simply not dimmed, but useless as a *colour*: it would make a /// provider-reported usage number and a locally computed one the same shade. /// `LaserWave` gives "added" and "modified" the same mint green. Decision 0012 /// needs these apart, so a key whose colour is already spoken for is passed over /// and the theme's next declaration for the same role is read instead — which on /// every theme here is the colour it paints that role in the gutter. Reading /// further, not inventing. fn git_colors( colors: Option<&Value>, foreground: Srgb, comment: Srgb, ) -> ([Option; 3], Vec) { let mut taken = vec![ (foreground, "the foreground colour"), (comment, "the comment colour"), ]; let mut skipped = Vec::new(); let mut chosen = [None; 3]; let roles: [(&'static str, &[&'static str]); 3] = [ ("added", GIT_ADDED_KEYS), ("deleted", GIT_DELETED_KEYS), ("modified", GIT_MODIFIED_KEYS), ]; for (index, (role, keys)) in roles.into_iter().enumerate() { for key in keys { let Some(color) = color_string(colors, key).and_then(loose_hex) else { continue; }; if let Some((_, owner)) = taken.iter().find(|(other, _)| *other == color) { skipped.push(SkippedGitKey { role, key, collided_with: owner, }); continue; } taken.push((color, role)); chosen[index] = Some(color); break; } } (chosen, skipped) } /// `editor.background` and friends, with upstream's fallback when absent. fn anchor(colors: Option<&Value>, key: &str, fallback: u32) -> Srgb { color_string(colors, key) .and_then(loose_hex) .unwrap_or_else(|| Srgb::from_hex(fallback)) } fn color_string<'a>(colors: Option<&'a Value>, key: &str) -> Option<&'a str> { colors? .get(key) .and_then(Value::as_str) .filter(|value| !value.is_empty()) } /// The anchor parser: 3, 4, 6, or 8 hex digits, `#` optional, alpha discarded. fn loose_hex(text: &str) -> Option { Srgb::parse(text) } /// The terminal parser, which is deliberately stricter — see the module note. fn strict_hex(text: &str) -> Option { let trimmed = text.trim(); let digits = trimmed.strip_prefix('#')?; if !matches!(digits.len(), 3 | 6 | 8) { return None; } Srgb::parse(digits) } fn token_entries(document: &Value) -> &[Value] { document .get("tokenColors") .or_else(|| document.get("settings")) .and_then(Value::as_array) .map_or(&[], Vec::as_slice) } /// The theme's secondary-text colour, and where it came from. /// /// Upstream's rule runs first and unchanged. Only when it produces nothing the /// interface can use — either no match at all, or a match that is the /// foreground colour over again — do the two fallbacks run, and each one is /// recorded so the catalog says which themes took them. A theme whose secondary /// text is the same colour as its body text would make decision 0012's /// provenance colouring unreadable, because "local" and "unavailable" are those /// two roles. fn comment_anchor(tokens: &[Value], bg: Srgb, fg: Srgb) -> (Srgb, CommentSource) { if let Some(color) = comment_color(tokens, false).filter(|color| *color != fg) { return (color, CommentSource::ExactScope); } if let Some(color) = comment_color(tokens, true).filter(|color| *color != fg) { return (color, CommentSource::CommaSeparatedScope); } ( mix(fg, bg, DERIVED_COMMENT_MIX), CommentSource::DerivedFromForeground, ) } /// The first token entry that both names the exact scope `comment` and sets a /// foreground. An entry that names the scope but sets no foreground is skipped /// rather than ending the search, which is upstream's behaviour and matters for /// themes that style comments in two passes. fn comment_color(tokens: &[Value], split_commas: bool) -> Option { tokens.iter().find_map(|entry| { let foreground = entry.get("settings")?.get("foreground")?.as_str()?; if scopes_of(entry, split_commas).any(|scope| scope == "comment") { loose_hex(foreground) } else { None } }) } /// A token entry's scopes, whether it spelled them as a string or an array. /// /// `split_commas` decides whether a single string holding a comma-separated /// list counts as several scopes or as one. Upstream treats it as one; see /// [`CommentSource::CommaSeparatedScope`]. It makes no difference to the /// terminal-hue search, which splits on punctuation again afterwards. fn scopes_of(entry: &Value, split_commas: bool) -> impl Iterator { let scope = entry.get("scope"); let single = scope .and_then(Value::as_str) .map_or(Vec::new(), |text| { if split_commas { text.split(',').map(str::trim).collect() } else { vec![text] } }) .into_iter(); let many = scope .and_then(Value::as_array) .map_or(&[][..], Vec::as_slice) .iter() .filter_map(Value::as_str); single.chain(many) } /// The sixteen terminal colours, and the base hues that had to be moved. /// /// The order of the three passes matters. Bases are read, *then* forced apart, /// *then* the bright half is lifted from them — because a bright slot the theme /// does not declare is derived from its base, and deriving it before the base /// moved would leave bright cyan sitting on bright blue after the dim pair had /// already been separated. fn terminal_palette( document: &Value, tokens: &[Value], ) -> (TerminalPalette, Vec, Vec) { let colors = document.get("colors"); // The terminal extractor re-reads the background and foreground through its // own stricter parser, so a theme whose `editor.background` is spelled in // the 4-digit form gets the stock terminal background while the interface // still uses the parsed one. Reproduced rather than tidied: tidying it would // change colours users already have. let background = color_string(colors, "editor.background") .and_then(strict_hex) .unwrap_or_else(|| Srgb::from_hex(0x1e_1e1e)); let foreground = color_string(colors, "editor.foreground") .and_then(strict_hex) .unwrap_or_else(|| Srgb::from_hex(0xd4_d4d4)); let mut ansi = [background; 16]; ansi[AnsiColor::Black as usize] = ansi_key(colors, AnsiColor::Black).unwrap_or_else(|| mix(background, foreground, 0.16)); ansi[AnsiColor::White as usize] = ansi_key(colors, AnsiColor::White).unwrap_or_else(|| mix(background, foreground, 0.82)); for (slot, anchors) in SCOPE_ANCHORS { let anchored = scope_color(tokens, anchors).and_then(strict_hex); ansi[slot as usize] = ansi_key(colors, slot) .or(anchored) .unwrap_or_else(|| derived_hue(slot, background, foreground)); } let substitutions = decollide(&mut ansi, background, foreground); let mut diverging: Vec = substitutions .iter() .map(|substitution| substitution.slot as usize) .collect(); let toward = if relative_luminance(background) > relative_luminance(foreground) { Srgb::BLACK } else { Srgb::WHITE }; for index in 8..16 { let slot = AnsiColor::ALL[index]; if let Some(declared) = ansi_key(colors, slot) { ansi[index] = declared; continue; } ansi[index] = mix(ansi[index - 8], toward, 0.28); if diverging.contains(&(index - 8)) { diverging.push(index); } } let palette = TerminalPalette { background, foreground, cursor: color_string(colors, "terminalCursor.foreground") .and_then(strict_hex) .unwrap_or(foreground), cursor_text: color_string(colors, "terminalCursor.background") .and_then(strict_hex) .unwrap_or(background), ansi, }; (palette, substitutions, diverging) } fn ansi_key(colors: Option<&Value>, slot: AnsiColor) -> Option { let key = format!("terminal.ansi{}", slot.key_suffix()); color_string(colors, &key).and_then(strict_hex) } /// The hue a slot falls back to: the stock hue pulled 18% toward the theme's own /// foreground, so it sits in the theme's light rather than beside it. fn derived_hue(slot: AnsiColor, background: Srgb, foreground: Srgb) -> Srgb { match slot { AnsiColor::Black => mix(background, foreground, 0.16), AnsiColor::White => mix(background, foreground, 0.82), _ => { let hue = FALLBACK_HUES .iter() .find(|(name, _)| *name == slot) .map_or(0x80_8080, |(_, hue)| *hue); mix(Srgb::from_hex(hue), foreground, 0.18) } } } /// The first token colour whose scope mentions one of `anchors`. fn scope_color<'a>(tokens: &'a [Value], anchors: &[&str]) -> Option<&'a str> { tokens.iter().find_map(|entry| { let foreground = entry.get("settings")?.get("foreground")?.as_str()?; let matched = scopes_of(entry, false).any(|scope| { scope .to_ascii_lowercase() .split(|character: char| { character == '.' || character == ',' || character.is_whitespace() }) .any(|part| anchors.iter().any(|anchor| part.contains(anchor))) }); matched.then_some(foreground) }) } /// Forces the eight base hues apart. /// /// Themes collide here for two different reasons and both are handled the same /// way: some derive two slots from one syntax token by accident, and at least /// one (poimandres) ships `terminal.ansiBlue` and `terminal.ansiCyan` set to the /// same value on purpose. Upstream's own choice loses either way, because a /// terminal that cannot tell blue from cyan has dropped information the running /// program encoded; what upstream chose is written into the catalog as a comment /// so the trade is visible. fn decollide(ansi: &mut [Srgb; 16], background: Srgb, foreground: Srgb) -> Vec { let mut substitutions = Vec::new(); for index in 0..8 { let slot = AnsiColor::ALL[index]; let Some(earlier) = ansi[..index].iter().position(|hue| *hue == ansi[index]) else { continue; }; let original = ansi[index]; let replacement = separate(&ansi[..index], slot, background, foreground); ansi[index] = replacement; substitutions.push(Substitution { slot, collided_with: AnsiColor::ALL[earlier], original, replacement, }); } substitutions } /// The nearest distinct stand-in for a colliding slot. /// /// The derived hue is tried first. If that also repeats an earlier slot the hue /// is walked toward the foreground in fixed steps, which keeps the result inside /// the theme's own range instead of reaching for an unrelated colour. The walk /// is bounded; running out returns the last candidate and the caller's assertion /// catches it, because shipping a duplicate quietly is the failure this whole /// routine exists to prevent. fn separate(taken: &[Srgb], slot: AnsiColor, background: Srgb, foreground: Srgb) -> Srgb { let base = derived_hue(slot, background, foreground); let mut candidate = base; for step in 0..=5 { candidate = mix(base, foreground, f64::from(step) * 0.06); if !taken.contains(&candidate) { return candidate; } } candidate } #[cfg(test)] mod tests { use super::{CommentSource, extract}; use lumbridge_theme::Srgb; use lumbridge_theme::terminal::AnsiColor; use serde_json::{Value, json}; /// A theme document with the colours a test cares about and nothing else. fn document(colors: &Value, tokens: &Value) -> Value { json!({ "colors": colors, "tokenColors": tokens }) } fn comment_token(scope: &Value, foreground: &str) -> Value { json!({ "scope": scope, "settings": { "foreground": foreground } }) } #[test] fn a_theme_with_no_colours_at_all_falls_back_to_the_stock_pair() { let extracted = extract(&json!({})); assert_eq!( extracted.bg.to_hex(), 0x1e1e1e, "an absent editor.background must use the documented stock value" ); assert_eq!(extracted.fg.to_hex(), 0xd4d4d4); assert_eq!( extracted.added, None, "a theme that declares no git colours must report none, not a guess" ); } #[test] fn the_comment_colour_comes_from_an_exact_scope_match() { let extracted = extract(&document( &json!({ "editor.background": "#101010", "editor.foreground": "#eeeeee" }), &json!([ comment_token(&json!("comment.line"), "#111111"), comment_token(&json!(["punctuation", "comment"]), "#777777"), ]), )); assert_eq!( extracted.comment.to_hex(), 0x777777, "`comment.line` is not the exact scope `comment` and must not match" ); assert_eq!(extracted.comment_source, CommentSource::ExactScope); } #[test] fn a_comma_separated_scope_list_is_read_only_when_the_exact_rule_finds_nothing() { let extracted = extract(&document( &json!({ "editor.background": "#101010", "editor.foreground": "#eeeeee" }), &json!([comment_token( &json!("comment, punctuation.definition.comment"), "#858585" )]), )); assert_eq!( extracted.comment.to_hex(), 0x858585, "a scope list spelled as one string still names the comment scope" ); assert_eq!( extracted.comment_source, CommentSource::CommaSeparatedScope, "reading past upstream's rule must be recorded, not silent" ); } #[test] fn a_comment_colour_equal_to_the_foreground_is_replaced_rather_than_kept() { let extracted = extract(&document( &json!({ "editor.background": "#000000", "editor.foreground": "#ffffff" }), &json!([comment_token(&json!(["comment"]), "#ffffffaa")]), )); assert_ne!( extracted.comment, extracted.fg, "secondary text identical to body text would collapse two provenance colours" ); assert_eq!( extracted.comment_source, CommentSource::DerivedFromForeground ); assert_eq!( extracted.comment.to_hex(), 0x999999, "40% toward the background" ); } #[test] fn a_git_key_holding_the_foreground_colour_is_passed_over_for_the_next_one() { let extracted = extract(&document( &json!({ "editor.background": "#101010", "editor.foreground": "#ebdbb2", "gitDecoration.addedResourceForeground": "#ebdbb2", "editorGutter.addedBackground": "#b8bb26", }), &json!([comment_token(&json!(["comment"]), "#928374")]), )); assert_eq!( extracted.added.map(Srgb::to_hex), Some(0xb8bb26), "the first key is the foreground colour and carries no information as a role" ); let skipped = &extracted.skipped_git_keys; assert_eq!(skipped.len(), 1, "the skip must be recorded"); assert_eq!(skipped[0].role, "added"); assert_eq!(skipped[0].key, "gitDecoration.addedResourceForeground"); } #[test] fn two_git_roles_may_not_end_up_the_same_colour() { let extracted = extract(&document( &json!({ "editor.background": "#101010", "editor.foreground": "#ffffff", "editorGutter.addedBackground": "#74dfc4", "gitDecoration.modifiedResourceForeground": "#74dfc4", "editorGutter.modifiedBackground": "#40b4c4", }), &json!([comment_token(&json!(["comment"]), "#888888")]), )); assert_eq!(extracted.added.map(Srgb::to_hex), Some(0x74dfc4)); assert_eq!( extracted.modified.map(Srgb::to_hex), Some(0x40b4c4), "a modified colour equal to the added colour must read the next key" ); } #[test] fn a_terminal_colour_spelled_in_a_form_the_upstream_parser_rejects_is_not_coerced() { let extracted = extract(&document( &json!({ "editor.background": "#101010", "editor.foreground": "#eeeeee", // Four digits: valid to the anchor parser, rejected by the // terminal parser, so this must not become the red slot. "terminal.ansiRed": "#f00f", "terminal.ansiGreen": "#00ff00", }), &json!([comment_token(&json!(["comment"]), "#888888")]), )); assert_eq!( extracted.terminal.color(AnsiColor::Green).to_hex(), 0x00ff00, "a six-digit terminal colour is read as written" ); assert_ne!( extracted.terminal.color(AnsiColor::Red).to_hex(), 0xff0000, "a four-digit terminal colour must fall through rather than be coerced" ); } #[test] fn two_base_slots_declared_with_the_same_colour_are_forced_apart() { let extracted = extract(&document( &json!({ "editor.background": "#101010", "editor.foreground": "#eeeeee", "terminal.ansiBlue": "#89ddff", "terminal.ansiCyan": "#89ddff", }), &json!([comment_token(&json!(["comment"]), "#888888")]), )); let blue = extracted.terminal.color(AnsiColor::Blue); let cyan = extracted.terminal.color(AnsiColor::Cyan); assert_eq!( blue.to_hex(), 0x89ddff, "the earlier slot keeps what it asked for" ); assert_ne!( cyan, blue, "a terminal that cannot tell blue from cyan has dropped information" ); let recorded = &extracted.substitutions; assert_eq!(recorded.len(), 1, "the substitution must be recorded"); assert_eq!(recorded[0].slot, AnsiColor::Cyan); assert_eq!(recorded[0].collided_with, AnsiColor::Blue); assert_eq!(recorded[0].original.to_hex(), 0x89ddff); } #[test] fn a_bright_slot_is_lifted_from_the_base_slot_that_replaced_a_collision() { let extracted = extract(&document( &json!({ "editor.background": "#101010", "editor.foreground": "#eeeeee", "terminal.ansiBlue": "#89ddff", "terminal.ansiCyan": "#89ddff", }), &json!([comment_token(&json!(["comment"]), "#888888")]), )); assert_ne!( extracted.terminal.color(AnsiColor::BrightCyan), extracted.terminal.color(AnsiColor::BrightBlue), "a bright slot derived from a substituted base must follow the substitution" ); } #[test] fn contrast_is_measured_against_the_surface_the_interface_will_paint() { // Pitch black leaves no room below it, so the derivation lifts the // surface instead of sinking the frame; the measurement has to follow. let extracted = extract(&document( &json!({ "editor.background": "#000000", "editor.foreground": "#ffffff" }), &json!([comment_token(&json!(["comment"]), "#777777")]), )); assert!( extracted.text_contrast < 21.0, "white on a lifted surface is not the 21:1 of white on pure black" ); assert!(extracted.text_contrast > 15.0); } }