//! 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[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 { 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"); } }