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
719 lines
28 KiB
Rust
719 lines
28 KiB
Rust
//! 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<Srgb>,
|
|
pub deleted: Option<Srgb>,
|
|
pub modified: Option<Srgb>,
|
|
pub terminal: TerminalPalette,
|
|
pub substitutions: Vec<Substitution>,
|
|
/// 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<usize>,
|
|
pub skipped_git_keys: Vec<SkippedGitKey>,
|
|
/// 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<Srgb>; 3], Vec<SkippedGitKey>) {
|
|
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> {
|
|
Srgb::parse(text)
|
|
}
|
|
|
|
/// The terminal parser, which is deliberately stricter — see the module note.
|
|
fn strict_hex(text: &str) -> Option<Srgb> {
|
|
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<Srgb> {
|
|
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<Item = &str> {
|
|
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<Substitution>, Vec<usize>) {
|
|
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<usize> = 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<Srgb> {
|
|
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<Substitution> {
|
|
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);
|
|
}
|
|
}
|