use std::path::{Path, PathBuf};
use clap::ValueEnum;
use crossterm::style::Color;
use serde::Deserialize;
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
#[value(rename_all = "lowercase")]
pub enum Theme {
Default,
Dark,
Mono,
}
impl Theme {
fn parse(name: &str) -> Option<Theme> {
match name.to_ascii_lowercase().as_str() {
"default" => Some(Theme::Default),
"dark" => Some(Theme::Dark),
"mono" => Some(Theme::Mono),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct ThemeColors {
pub accent: Color,
pub border: Color,
pub text: Color,
pub muted: Color,
pub selected_bg: Color,
pub selected_fg: Color,
}
impl ThemeColors {
pub fn default_theme() -> Self {
ThemeColors {
accent: Color::Magenta,
border: Color::DarkGrey,
text: Color::White,
muted: Color::Grey,
selected_bg: Color::DarkMagenta,
selected_fg: Color::Black,
}
}
pub fn dark() -> Self {
ThemeColors {
accent: Color::Cyan,
border: Color::DarkBlue,
text: Color::Grey,
muted: Color::DarkGrey,
selected_bg: Color::DarkCyan,
selected_fg: Color::Black,
}
}
pub fn mono() -> Self {
ThemeColors {
accent: Color::White,
border: Color::White,
text: Color::White,
muted: Color::Grey,
selected_bg: Color::Grey,
selected_fg: Color::Black,
}
}
pub fn for_theme(theme: Theme) -> Self {
match theme {
Theme::Default => Self::default_theme(),
Theme::Dark => Self::dark(),
Theme::Mono => Self::mono(),
}
}
}
#[derive(Debug, Deserialize, Default, Clone, PartialEq, Eq)]
pub struct CustomTheme {
#[serde(default)]
pub accent: Option<String>,
#[serde(default)]
pub border: Option<String>,
#[serde(default)]
pub text: Option<String>,
#[serde(default)]
pub muted: Option<String>,
#[serde(default)]
pub selected_bg: Option<String>,
#[serde(default)]
pub selected_fg: Option<String>,
}
impl CustomTheme {
pub fn apply_over(&self, base: ThemeColors) -> ThemeColors {
ThemeColors {
accent: self.accent.as_deref().and_then(parse_color).unwrap_or(base.accent),
border: self.border.as_deref().and_then(parse_color).unwrap_or(base.border),
text: self.text.as_deref().and_then(parse_color).unwrap_or(base.text),
muted: self.muted.as_deref().and_then(parse_color).unwrap_or(base.muted),
selected_bg: self.selected_bg.as_deref().and_then(parse_color).unwrap_or(base.selected_bg),
selected_fg: self.selected_fg.as_deref().and_then(parse_color).unwrap_or(base.selected_fg),
}
}
pub fn invalid_fields(&self) -> Vec<(&'static str, &str)> {
let mut bad = Vec::new();
for (field, value) in [
("accent", &self.accent),
("border", &self.border),
("text", &self.text),
("muted", &self.muted),
("selected_bg", &self.selected_bg),
("selected_fg", &self.selected_fg),
] {
if let Some(v) = value
&& parse_color(v).is_none() {
bad.push((field, v.as_str()));
}
}
bad
}
}
pub fn parse_color(spec: &str) -> Option<Color> {
let s = spec.trim();
if let Some(hex) = s.strip_prefix('#') {
if hex.len() != 6 {
return None;
}
let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
return Some(Color::Rgb { r, g, b });
}
match s.to_ascii_lowercase().as_str() {
"black" => Some(Color::Black),
"darkgrey" | "darkgray" => Some(Color::DarkGrey),
"red" => Some(Color::Red),
"darkred" => Some(Color::DarkRed),
"green" => Some(Color::Green),
"darkgreen" => Some(Color::DarkGreen),
"yellow" => Some(Color::Yellow),
"darkyellow" => Some(Color::DarkYellow),
"blue" => Some(Color::Blue),
"darkblue" => Some(Color::DarkBlue),
"magenta" => Some(Color::Magenta),
"darkmagenta" => Some(Color::DarkMagenta),
"cyan" => Some(Color::Cyan),
"darkcyan" => Some(Color::DarkCyan),
"white" => Some(Color::White),
"grey" | "gray" => Some(Color::Grey),
_ => None,
}
}
pub fn resolve_theme_file_path(theme_file: &str, config_dir: &Path) -> PathBuf {
if let Some(rest) = theme_file.strip_prefix("~/")
&& let Some(home) = dirs::home_dir() {
return home.join(rest);
}
let path = PathBuf::from(theme_file);
if path.is_absolute() {
path
} else {
config_dir.join(path)
}
}
pub fn load_theme_file(path: &Path) -> Option<CustomTheme> {
let raw = std::fs::read_to_string(path).ok()?;
toml::from_str(&raw).ok()
}
pub fn resolve_theme(
cli_theme: Option<&str>,
meta_theme: Option<&str>,
inline_theme: Option<&CustomTheme>,
theme_file: Option<&Path>,
no_color: bool,
) -> ThemeColors {
if no_color || std::env::var_os("NO_COLOR").is_some() {
return ThemeColors::mono();
}
if let Some(name) = cli_theme.and_then(Theme::parse) {
return ThemeColors::for_theme(name);
}
let base = ThemeColors::for_theme(meta_theme.and_then(Theme::parse).unwrap_or(Theme::Default));
if let Some(custom) = inline_theme {
return custom.apply_over(base);
}
if let Some(path) = theme_file
&& let Some(custom) = load_theme_file(path) {
return custom.apply_over(base);
}
base
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn built_in_themes_pair_a_dark_selected_fg_with_their_light_selected_bg() {
for theme in [ThemeColors::default_theme(), ThemeColors::dark(), ThemeColors::mono()] {
assert_eq!(theme.selected_fg, Color::Black);
assert_ne!(theme.selected_fg, theme.selected_bg);
}
}
#[test]
fn no_color_forces_mono() {
let c = resolve_theme(Some("dark"), None, None, None, true);
assert!(matches!(c.accent, Color::White));
}
#[test]
fn cli_takes_precedence_over_meta() {
let cli = resolve_theme(Some("dark"), Some("mono"), None, None, false);
let meta = resolve_theme(None, Some("dark"), None, None, false);
assert!(matches!(cli.accent, Color::Cyan));
assert!(matches!(meta.accent, Color::Cyan));
}
#[test]
fn unknown_theme_falls_back_to_default() {
let c = resolve_theme(Some("nope"), None, None, None, false);
assert!(matches!(c.accent, Color::Magenta));
}
#[test]
fn hex_color_parses() {
assert_eq!(parse_color("#ff8800"), Some(Color::Rgb { r: 0xff, g: 0x88, b: 0x00 }));
}
#[test]
fn named_color_parses_case_insensitively() {
assert_eq!(parse_color("Cyan"), Some(Color::Cyan));
assert_eq!(parse_color("DARKGREY"), Some(Color::DarkGrey));
}
#[test]
fn invalid_color_returns_none() {
assert_eq!(parse_color("not-a-color"), None);
assert_eq!(parse_color("#zzz"), None);
}
#[test]
fn inline_custom_theme_overrides_only_set_fields() {
let custom = CustomTheme {
accent: Some("#00ff00".to_string()),
..Default::default()
};
let resolved = resolve_theme(None, Some("dark"), Some(&custom), None, false);
assert_eq!(resolved.accent, Color::Rgb { r: 0, g: 255, b: 0 });
assert!(matches!(resolved.border, Color::DarkBlue));
}
#[test]
fn invalid_fields_reports_bad_color_names() {
let custom = CustomTheme {
accent: Some("nonsense".to_string()),
border: Some("cyan".to_string()),
..Default::default()
};
let bad = custom.invalid_fields();
assert_eq!(bad, vec![("accent", "nonsense")]);
}
}