404 lines
10 KiB
Rust
404 lines
10 KiB
Rust
//! Output color themes: named palettes applied to help, tables, the
|
|
//! version output and the completion menu.
|
|
//!
|
|
//! A theme is a set of SGR (Select Graphic Rendition) parameter strings
|
|
//! (e.g. "1;38;5;75" = bold + 256-color 75). Painting wraps the text in
|
|
//! "\x1b[<sgr>m...\x1b[0m". The "mono" theme uses empty sequences and is
|
|
//! therefore plain text.
|
|
|
|
/// One color theme.
|
|
#[derive(Debug, Clone, Copy)]
|
|
pub struct Theme {
|
|
/// Machine name ("default", "ocean", ...).
|
|
pub name: &'static str,
|
|
/// Human-readable title.
|
|
pub title: &'static str,
|
|
/// Section headers ("Usage:", table header rows, ...) — bold.
|
|
pub header: &'static str,
|
|
/// Accent color: prompts, command names, status "running".
|
|
pub accent: &'static str,
|
|
/// Secondary color: types, values, status "installed".
|
|
pub info: &'static str,
|
|
/// Faint: search terms, secondary text, "not-installed".
|
|
pub dim: &'static str,
|
|
/// Frame color: the borders of tables and boxes (list, version, ls...).
|
|
pub border: &'static str,
|
|
/// Warnings and "external" status.
|
|
pub warn: &'static str,
|
|
/// Errors and "not-installable".
|
|
pub error: &'static str,
|
|
/// Success / "ok".
|
|
pub ok: &'static str,
|
|
/// Version table keys.
|
|
pub key: &'static str,
|
|
/// Version table values.
|
|
pub value: &'static str,
|
|
}
|
|
|
|
pub static THEMES: &[Theme] = &[
|
|
Theme {
|
|
name: "default",
|
|
title: "Default (green/cyan)",
|
|
header: "1;36",
|
|
accent: "1;32",
|
|
info: "36",
|
|
dim: "2",
|
|
border: "36",
|
|
warn: "33",
|
|
error: "31",
|
|
ok: "1;32",
|
|
key: "36",
|
|
value: "1",
|
|
},
|
|
Theme {
|
|
name: "ocean",
|
|
title: "Ocean (blues)",
|
|
header: "1;38;5;75",
|
|
accent: "1;38;5;81",
|
|
info: "38;5;117",
|
|
dim: "2",
|
|
border: "38;5;75",
|
|
warn: "38;5;221",
|
|
error: "38;5;203",
|
|
ok: "1;38;5;81",
|
|
key: "38;5;111",
|
|
value: "1",
|
|
},
|
|
Theme {
|
|
name: "sunset",
|
|
title: "Sunset (orange/pink)",
|
|
header: "1;38;5;209",
|
|
accent: "1;38;5;215",
|
|
info: "38;5;213",
|
|
dim: "2",
|
|
border: "38;5;209",
|
|
warn: "38;5;221",
|
|
error: "38;5;203",
|
|
ok: "1;38;5;215",
|
|
key: "38;5;213",
|
|
value: "1",
|
|
},
|
|
Theme {
|
|
name: "forest",
|
|
title: "Forest (greens)",
|
|
header: "1;38;5;71",
|
|
accent: "1;38;5;107",
|
|
info: "38;5;72",
|
|
dim: "2",
|
|
border: "38;5;71",
|
|
warn: "38;5;179",
|
|
error: "38;5;167",
|
|
ok: "1;38;5;107",
|
|
key: "38;5;108",
|
|
value: "1",
|
|
},
|
|
Theme {
|
|
name: "dracula",
|
|
title: "Dracula (purple/green)",
|
|
header: "1;38;5;141",
|
|
accent: "1;38;5;84",
|
|
info: "38;5;117",
|
|
dim: "2",
|
|
border: "38;5;141",
|
|
warn: "38;5;228",
|
|
error: "38;5;203",
|
|
ok: "1;38;5;84",
|
|
key: "38;5;141",
|
|
value: "1",
|
|
},
|
|
Theme {
|
|
name: "nord",
|
|
title: "Nord (arctic frost)",
|
|
header: "1;38;5;109",
|
|
accent: "1;38;5;108",
|
|
info: "38;5;110",
|
|
dim: "2",
|
|
border: "38;5;109",
|
|
warn: "38;5;222",
|
|
error: "38;5;167",
|
|
ok: "1;38;5;108",
|
|
key: "38;5;110",
|
|
value: "1",
|
|
},
|
|
Theme {
|
|
name: "solarized",
|
|
title: "Solarized (dark)",
|
|
header: "1;38;5;32",
|
|
accent: "1;38;5;100",
|
|
info: "38;5;37",
|
|
dim: "2",
|
|
border: "38;5;102",
|
|
warn: "38;5;136",
|
|
error: "38;5;160",
|
|
ok: "1;38;5;100",
|
|
key: "38;5;37",
|
|
value: "1",
|
|
},
|
|
Theme {
|
|
name: "gruvbox",
|
|
title: "Gruvbox (retro warm)",
|
|
header: "1;38;5;109",
|
|
accent: "1;38;5;142",
|
|
info: "38;5;66",
|
|
dim: "2",
|
|
border: "38;5;59",
|
|
warn: "38;5;214",
|
|
error: "38;5;167",
|
|
ok: "1;38;5;142",
|
|
key: "38;5;108",
|
|
value: "1",
|
|
},
|
|
Theme {
|
|
name: "catppuccin",
|
|
title: "Catppuccin (pastel)",
|
|
header: "1;38;5;111",
|
|
accent: "1;38;5;114",
|
|
info: "38;5;117",
|
|
dim: "2",
|
|
border: "38;5;111",
|
|
warn: "38;5;229",
|
|
error: "38;5;210",
|
|
ok: "1;38;5;114",
|
|
key: "38;5;140",
|
|
value: "1",
|
|
},
|
|
Theme {
|
|
name: "tokyo-night",
|
|
title: "Tokyo Night (neon)",
|
|
header: "1;38;5;111",
|
|
accent: "1;38;5;114",
|
|
info: "38;5;117",
|
|
dim: "2",
|
|
border: "38;5;111",
|
|
warn: "38;5;215",
|
|
error: "38;5;204",
|
|
ok: "1;38;5;114",
|
|
key: "38;5;141",
|
|
value: "1",
|
|
},
|
|
Theme {
|
|
name: "monokai",
|
|
title: "Monokai (classic IDE)",
|
|
header: "1;38;5;81",
|
|
accent: "1;38;5;148",
|
|
info: "38;5;81",
|
|
dim: "2",
|
|
border: "38;5;81",
|
|
warn: "38;5;185",
|
|
error: "38;5;197",
|
|
ok: "1;38;5;148",
|
|
key: "38;5;141",
|
|
value: "1",
|
|
},
|
|
Theme {
|
|
name: "matrix",
|
|
title: "Matrix (green phosphor)",
|
|
header: "1;38;5;46",
|
|
accent: "1;38;5;40",
|
|
info: "38;5;34",
|
|
dim: "2",
|
|
border: "38;5;34",
|
|
warn: "38;5;190",
|
|
error: "38;5;196",
|
|
ok: "1;38;5;40",
|
|
key: "38;5;40",
|
|
value: "1",
|
|
},
|
|
Theme {
|
|
name: "rose-pine",
|
|
title: "Rose Pine (elegant pink)",
|
|
header: "1;38;5;183",
|
|
accent: "1;38;5;216",
|
|
info: "38;5;117",
|
|
dim: "2",
|
|
border: "38;5;183",
|
|
warn: "38;5;180",
|
|
error: "38;5;204",
|
|
ok: "1;38;5;216",
|
|
key: "38;5;183",
|
|
value: "1",
|
|
},
|
|
Theme {
|
|
name: "amber",
|
|
title: "Amber (retro CRT)",
|
|
header: "1;38;5;214",
|
|
accent: "1;38;5;215",
|
|
info: "38;5;178",
|
|
dim: "2",
|
|
border: "38;5;172",
|
|
warn: "38;5;220",
|
|
error: "38;5;196",
|
|
ok: "1;38;5;215",
|
|
key: "38;5;214",
|
|
value: "1",
|
|
},
|
|
Theme {
|
|
name: "mono",
|
|
title: "Mono (no colors)",
|
|
header: "1",
|
|
accent: "",
|
|
info: "",
|
|
dim: "",
|
|
border: "",
|
|
warn: "",
|
|
error: "",
|
|
ok: "1",
|
|
key: "",
|
|
value: "",
|
|
},
|
|
];
|
|
|
|
/// The theme used when nothing else is configured.
|
|
pub fn default_theme() -> &'static Theme {
|
|
&THEMES[0]
|
|
}
|
|
|
|
/// Look a theme up by name (case-insensitive).
|
|
pub fn find(name: &str) -> Option<&'static Theme> {
|
|
let lower = name.trim().to_lowercase();
|
|
THEMES.iter().find(|t| t.name.to_lowercase() == lower)
|
|
}
|
|
|
|
/// Machine names of every theme, for error messages and help.
|
|
pub fn names() -> Vec<&'static str> {
|
|
THEMES.iter().map(|t| t.name).collect()
|
|
}
|
|
|
|
/// One line per theme: "name - title", for the help output.
|
|
pub fn describe_all() -> Vec<String> {
|
|
THEMES
|
|
.iter()
|
|
.map(|t| format!("{} - {}", t.name, t.title))
|
|
.collect()
|
|
}
|
|
|
|
impl Theme {
|
|
/// Wrap text in this SGR sequence (empty sequence = plain text).
|
|
pub fn paint(&self, sgr: &str, text: &str) -> String {
|
|
if sgr.is_empty() || text.is_empty() {
|
|
text.to_string()
|
|
} else {
|
|
format!("\x1b[{sgr}m{text}\x1b[0m")
|
|
}
|
|
}
|
|
|
|
pub fn hdr(&self, text: &str) -> String {
|
|
self.paint(self.header, text)
|
|
}
|
|
|
|
pub fn acc(&self, text: &str) -> String {
|
|
self.paint(self.accent, text)
|
|
}
|
|
|
|
pub fn inf(&self, text: &str) -> String {
|
|
self.paint(self.info, text)
|
|
}
|
|
|
|
pub fn dimmed(&self, text: &str) -> String {
|
|
self.paint(self.dim, text)
|
|
}
|
|
|
|
pub fn frame(&self, text: &str) -> String {
|
|
self.paint(self.border, text)
|
|
}
|
|
|
|
pub fn warn(&self, text: &str) -> String {
|
|
self.paint(self.warn, text)
|
|
}
|
|
|
|
pub fn err(&self, text: &str) -> String {
|
|
self.paint(self.error, text)
|
|
}
|
|
|
|
pub fn ok(&self, text: &str) -> String {
|
|
self.paint(self.ok, text)
|
|
}
|
|
|
|
pub fn key(&self, text: &str) -> String {
|
|
self.paint(self.key, text)
|
|
}
|
|
|
|
pub fn val(&self, text: &str) -> String {
|
|
self.paint(self.value, text)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn finds_themes_case_insensitively() {
|
|
assert_eq!(find("Ocean").unwrap().name, "ocean");
|
|
assert_eq!(find("default").unwrap().name, "default");
|
|
assert_eq!(find("DRACULA").unwrap().name, "dracula");
|
|
assert!(find("blorp").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn default_is_first() {
|
|
assert_eq!(default_theme().name, "default");
|
|
}
|
|
|
|
#[test]
|
|
fn mono_theme_paints_nothing() {
|
|
let mono = find("mono").unwrap();
|
|
// Bold (structure) survives; colors are gone.
|
|
assert_eq!(mono.hdr("x"), "[1mx[0m");
|
|
assert_eq!(mono.acc("x"), "x");
|
|
assert_eq!(mono.inf("x"), "x");
|
|
assert_eq!(mono.dimmed("x"), "x");
|
|
assert_eq!(mono.key("x"), "x");
|
|
assert_eq!(mono.val("x"), "x");
|
|
}
|
|
|
|
#[test]
|
|
fn paint_wraps_with_ansi() {
|
|
let t = default_theme();
|
|
let s = t.acc("ok");
|
|
assert!(s.starts_with("\x1b[1;32m"));
|
|
assert!(s.ends_with("\x1b[0m"));
|
|
}
|
|
|
|
#[test]
|
|
fn every_theme_has_a_description() {
|
|
for t in THEMES {
|
|
assert!(!t.title.is_empty(), "{}: missing title", t.name);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn theme_names_are_unique() {
|
|
let mut names: Vec<&str> = THEMES.iter().map(|t| t.name).collect();
|
|
names.sort();
|
|
names.dedup();
|
|
assert_eq!(names.len(), THEMES.len(), "duplicate theme names");
|
|
}
|
|
|
|
#[test]
|
|
fn extended_catalog_is_findable() {
|
|
for name in [
|
|
"nord",
|
|
"solarized",
|
|
"gruvbox",
|
|
"catppuccin",
|
|
"tokyo-night",
|
|
"monokai",
|
|
"matrix",
|
|
"rose-pine",
|
|
"amber",
|
|
] {
|
|
assert!(find(name).is_some(), "missing theme {name}");
|
|
}
|
|
assert_eq!(THEMES.len(), 15);
|
|
}
|
|
|
|
#[test]
|
|
fn frame_paints_with_the_theme_border_color() {
|
|
let t = find("ocean").unwrap();
|
|
let s = t.frame("╭──╮");
|
|
assert!(s.starts_with("[38;5;75m"));
|
|
assert_eq!(find("mono").unwrap().frame("x"), "x");
|
|
}
|
|
}
|