Files
agent-manager/src/theme.rs
T

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"), "x");
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(""));
assert_eq!(find("mono").unwrap().frame("x"), "x");
}
}