use std::sync::OnceLock;
use crossterm::style::Color;
use serde::Deserialize;
#[derive(Debug, Clone, Copy)]
pub struct Theme {
pub agent: Color,
pub user: Color,
pub system: Color,
pub tool: Color,
pub perm: Color,
pub result: Color,
pub critic: Color,
pub thinking: Color,
pub error: Color,
pub warn: Color,
pub accent: Color,
pub dim: Color,
pub header: Color,
pub divider: Color,
pub banner_primary: Color,
pub banner_secondary: Color,
pub background: Color,
pub label: &'static str,
}
impl Theme {
pub const fn phosphor() -> Self {
Theme {
agent: Color::Rgb {
r: 138,
g: 232,
b: 156,
},
user: Color::Rgb {
r: 125,
g: 205,
b: 210,
},
system: Color::Rgb {
r: 106,
g: 140,
b: 120,
},
tool: Color::Rgb {
r: 108,
g: 188,
b: 150,
},
perm: Color::Rgb {
r: 255,
g: 185,
b: 85,
},
result: Color::Rgb {
r: 118,
g: 158,
b: 132,
},
critic: Color::Rgb {
r: 186,
g: 166,
b: 216,
},
thinking: Color::Rgb {
r: 128,
g: 150,
b: 140,
},
error: Color::Rgb {
r: 255,
g: 95,
b: 90,
},
warn: Color::Rgb {
r: 212,
g: 168,
b: 96,
},
accent: Color::Rgb {
r: 158,
g: 238,
b: 172,
},
dim: Color::Rgb {
r: 86,
g: 116,
b: 96,
},
header: Color::Rgb {
r: 96,
g: 220,
b: 140,
},
divider: Color::Rgb {
r: 72,
g: 98,
b: 82,
},
banner_primary: Color::Rgb {
r: 138,
g: 232,
b: 156,
},
banner_secondary: Color::Rgb {
r: 86,
g: 116,
b: 96,
},
background: Color::Rgb {
r: 0x22,
g: 0x22,
b: 0x22,
},
label: "PHOSPHOR",
}
}
pub const fn plain() -> Self {
Theme {
agent: Color::White,
user: Color::Green,
system: Color::DarkGrey,
tool: Color::Yellow,
perm: Color::Magenta,
result: Color::DarkGrey,
critic: Color::Blue,
thinking: Color::Rgb {
r: 140,
g: 140,
b: 155,
},
error: Color::Red,
warn: Color::Yellow,
accent: Color::Cyan,
dim: Color::DarkGrey,
header: Color::Cyan,
divider: Color::DarkGrey,
banner_primary: Color::Cyan,
banner_secondary: Color::DarkGrey,
background: Color::Reset,
label: "PLAIN",
}
}
}
#[derive(Deserialize, Default, Debug)]
#[serde(default, deny_unknown_fields)]
struct ThemeJson {
agent: Option<ColorValue>,
user: Option<ColorValue>,
system: Option<ColorValue>,
tool: Option<ColorValue>,
perm: Option<ColorValue>,
result: Option<ColorValue>,
critic: Option<ColorValue>,
thinking: Option<ColorValue>,
error: Option<ColorValue>,
warn: Option<ColorValue>,
accent: Option<ColorValue>,
dim: Option<ColorValue>,
header: Option<ColorValue>,
divider: Option<ColorValue>,
banner_primary: Option<ColorValue>,
banner_secondary: Option<ColorValue>,
background: Option<ColorValue>,
label: Option<String>,
}
#[derive(Debug)]
struct ColorValue(Color);
impl<'de> Deserialize<'de> for ColorValue {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
use serde::de::Error;
let v = serde_json::Value::deserialize(d)?;
match v {
serde_json::Value::String(s) => parse_color_value(&s)
.map(ColorValue)
.map_err(D::Error::custom),
serde_json::Value::Number(n) => {
let n = n.as_u64().ok_or_else(|| {
D::Error::custom("color index must be a non-negative integer 0..=255")
})?;
if n > 255 {
return Err(D::Error::custom("color index out of range 0..=255"));
}
Ok(ColorValue(Color::AnsiValue(n as u8)))
}
other => Err(D::Error::custom(format!(
"color must be a name string, hex string, or 0..=255 integer; got {other:?}"
))),
}
}
}
fn parse_color_value(raw: &str) -> Result<Color, String> {
let s = raw.trim();
if let Some(hex) = s.strip_prefix('#') {
if hex.len() != 6 {
return Err(format!(
"hex color must be `#rrggbb` (6 hex digits); got `{}`",
raw
));
}
let r = u8::from_str_radix(&hex[0..2], 16).map_err(|e| format!("bad red byte: {e}"))?;
let g = u8::from_str_radix(&hex[2..4], 16).map_err(|e| format!("bad green byte: {e}"))?;
let b = u8::from_str_radix(&hex[4..6], 16).map_err(|e| format!("bad blue byte: {e}"))?;
return Ok(Color::Rgb { r, g, b });
}
let key: String = s
.chars()
.filter(|c| *c != '_' && *c != '-')
.map(|c| c.to_ascii_lowercase())
.collect();
Ok(match key.as_str() {
"black" => Color::Black,
"darkgrey" | "darkgray" => Color::DarkGrey,
"red" => Color::Red,
"darkred" => Color::DarkRed,
"green" => Color::Green,
"darkgreen" => Color::DarkGreen,
"yellow" => Color::Yellow,
"darkyellow" => Color::DarkYellow,
"blue" => Color::Blue,
"darkblue" => Color::DarkBlue,
"magenta" => Color::Magenta,
"darkmagenta" => Color::DarkMagenta,
"cyan" => Color::Cyan,
"darkcyan" => Color::DarkCyan,
"white" => Color::White,
"grey" | "gray" => Color::Grey,
"reset" => Color::Reset,
_ => return Err(format!("unknown color name: {raw}")),
})
}
impl ThemeJson {
fn merge_into(self, base: Theme, label: &'static str) -> Result<Theme, String> {
let pick = |o: Option<ColorValue>, b: Color| match o {
Some(c) => c.0,
None => b,
};
Ok(Theme {
agent: pick(self.agent, base.agent),
user: pick(self.user, base.user),
system: pick(self.system, base.system),
tool: pick(self.tool, base.tool),
perm: pick(self.perm, base.perm),
result: pick(self.result, base.result),
critic: pick(self.critic, base.critic),
thinking: pick(self.thinking, base.thinking),
error: pick(self.error, base.error),
warn: pick(self.warn, base.warn),
accent: pick(self.accent, base.accent),
dim: pick(self.dim, base.dim),
header: pick(self.header, base.header),
divider: pick(self.divider, base.divider),
banner_primary: pick(self.banner_primary, base.banner_primary),
banner_secondary: pick(self.banner_secondary, base.banner_secondary),
background: pick(self.background, base.background),
label,
})
}
}
static THEME: OnceLock<Theme> = OnceLock::new();
pub fn init(name: &str) {
let theme = match name.to_ascii_lowercase().as_str() {
"phosphor" | "" => Theme::phosphor(),
"plain" => Theme::plain(),
other => load_custom_theme(other).unwrap_or_else(|err| {
eprintln!(
"warning: theme '{}' could not be loaded ({}); using phosphor.\n\
Custom themes live at ~/.config/dirge/<name>.theme.json.",
other, err,
);
Theme::phosphor()
}),
};
let _ = THEME.set(theme);
}
fn load_custom_theme(name: &str) -> Result<Theme, String> {
let path = crate::session::storage::config_path().join(format!("{name}.theme.json"));
if !path.exists() {
return Err(format!("no such file: {}", path.display()));
}
let raw =
std::fs::read_to_string(&path).map_err(|e| format!("read {}: {e}", path.display()))?;
let overrides: ThemeJson =
serde_json::from_str(&raw).map_err(|e| format!("parse {}: {e}", path.display()))?;
let label_str = overrides
.label
.clone()
.unwrap_or_else(|| name.to_ascii_uppercase());
let label: &'static str = Box::leak(label_str.into_boxed_str());
overrides.merge_into(Theme::phosphor(), label)
}
pub fn current() -> &'static Theme {
THEME.get_or_init(Theme::phosphor)
}
static NO_COLOR: OnceLock<bool> = OnceLock::new();
pub fn init_no_color(enabled: bool) {
let _ = NO_COLOR.set(enabled);
}
#[inline]
pub fn no_color() -> bool {
NO_COLOR.get().copied().unwrap_or(false)
}
#[inline]
fn apply_no_color(c: Color, no_color: bool) -> Color {
if no_color { Color::Reset } else { c }
}
#[inline]
fn themed(c: Color) -> Color {
apply_no_color(c, no_color())
}
pub fn agent() -> Color {
themed(current().agent)
}
pub fn user() -> Color {
themed(current().user)
}
pub fn system() -> Color {
themed(current().system)
}
pub fn tool() -> Color {
themed(current().tool)
}
pub fn perm() -> Color {
themed(current().perm)
}
pub fn result() -> Color {
themed(current().result)
}
pub fn critic() -> Color {
themed(current().critic)
}
pub fn thinking() -> Color {
themed(current().thinking)
}
pub fn error() -> Color {
themed(current().error)
}
pub fn warn() -> Color {
themed(current().warn)
}
pub fn accent() -> Color {
themed(current().accent)
}
pub fn dim() -> Color {
themed(current().dim)
}
pub fn header() -> Color {
themed(current().header)
}
pub fn divider() -> Color {
themed(current().divider)
}
pub fn banner_primary() -> Color {
themed(current().banner_primary)
}
pub fn banner_secondary() -> Color {
themed(current().banner_secondary)
}
pub fn background() -> Color {
themed(current().background)
}
#[allow(dead_code)]
pub fn is_bright(c: Color) -> bool {
match c {
Color::Green
| Color::Red
| Color::Yellow
| Color::Cyan
| Color::Magenta
| Color::Blue
| Color::White => true,
Color::Rgb { r, g, b } => r.max(g).max(b) > 128,
Color::AnsiValue(v) => match v {
0..=7 => false,
8..=15 => true,
16..=231 => {
let n = v - 16;
let r = n / 36;
let g = (n / 6) % 6;
let b = n % 6;
(r + g + b) as u32 > 7 }
232..=255 => v >= 244, },
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn apply_no_color_collapses_only_when_enabled() {
assert_eq!(apply_no_color(Color::Green, true), Color::Reset);
assert_eq!(
apply_no_color(Color::Rgb { r: 1, g: 2, b: 3 }, true),
Color::Reset
);
assert_eq!(apply_no_color(Color::Green, false), Color::Green);
assert_eq!(
apply_no_color(Color::Rgb { r: 1, g: 2, b: 3 }, false),
Color::Rgb { r: 1, g: 2, b: 3 }
);
}
#[test]
fn is_bright_handles_rgb_and_ansivalue() {
assert!(is_bright(Color::Rgb {
r: 255,
g: 0,
b: 255,
}));
assert!(!is_bright(Color::Rgb { r: 0, g: 0, b: 32 }));
assert!(!is_bright(Color::AnsiValue(0)));
assert!(is_bright(Color::AnsiValue(15)));
assert!(is_bright(Color::AnsiValue(226)));
assert!(!is_bright(Color::AnsiValue(232)));
assert!(is_bright(Color::AnsiValue(255)));
}
#[test]
fn presets_are_distinct() {
assert_ne!(Theme::phosphor().agent, Theme::plain().agent);
assert_ne!(Theme::phosphor().accent, Theme::plain().accent);
}
#[test]
fn error_and_warn_stay_loud() {
fn is_reddish(c: Color) -> bool {
match c {
Color::Red | Color::DarkRed => true,
Color::Rgb { r, g, b } => r > 180 && r >= g + 40 && r >= b + 40,
_ => false,
}
}
fn is_amber(c: Color) -> bool {
match c {
Color::Yellow | Color::DarkYellow => true,
Color::Rgb { r, g, b } => r >= 160 && g >= 120 && r >= g && b + 40 <= g,
_ => false,
}
}
for t in [Theme::phosphor(), Theme::plain()] {
assert!(
is_reddish(t.error),
"theme {} broke error contract",
t.label
);
assert!(is_amber(t.warn), "theme {} broke warn contract", t.label);
}
}
#[test]
fn init_with_unknown_name_falls_back() {
let t = current();
assert!(!t.label.is_empty());
}
#[test]
fn parse_color_value_named() {
assert!(matches!(parse_color_value("green"), Ok(Color::Green)));
assert!(matches!(parse_color_value("GREEN"), Ok(Color::Green)));
assert!(matches!(parse_color_value("DarkRed"), Ok(Color::DarkRed)));
assert!(matches!(parse_color_value("dark_red"), Ok(Color::DarkRed)));
assert!(matches!(parse_color_value("dark-red"), Ok(Color::DarkRed)));
assert!(matches!(parse_color_value("gray"), Ok(Color::Grey)));
assert!(matches!(parse_color_value("grey"), Ok(Color::Grey)));
}
#[test]
fn parse_color_value_hex_rgb() {
let c = parse_color_value("#1a2b3c").unwrap();
assert!(matches!(
c,
Color::Rgb {
r: 0x1a,
g: 0x2b,
b: 0x3c,
}
));
assert!(parse_color_value("#FFFFFF").is_ok());
}
#[test]
fn parse_color_value_hex_must_be_6_digits() {
assert!(parse_color_value("#abc").is_err());
assert!(parse_color_value("#1234567").is_err());
assert!(parse_color_value("#xx1234").is_err());
}
#[test]
fn parse_color_value_rejects_unknown_name() {
assert!(parse_color_value("eggplant").is_err());
assert!(parse_color_value("").is_err());
}
#[test]
fn theme_json_partial_override_inherits_base() {
let json = r#"{"agent": "blue"}"#;
let overrides: ThemeJson = serde_json::from_str(json).unwrap();
let base = Theme::phosphor();
let theme = overrides.merge_into(base, "TEST").unwrap();
assert!(matches!(theme.agent, Color::Blue), "agent overridden");
assert_eq!(theme.error, base.error, "error unchanged");
assert_eq!(theme.warn, base.warn, "warn unchanged");
assert_eq!(theme.user, base.user, "user unchanged");
}
#[test]
fn theme_json_full_override_replaces_all_fields() {
let json = r#"{
"agent": "red",
"user": "green",
"system": "yellow",
"tool": "blue",
"perm": "magenta",
"result": "cyan",
"critic": "blue",
"thinking": "darkgrey",
"error": "darkred",
"warn": "darkyellow",
"accent": "white",
"dim": "darkgrey",
"header": "darkcyan",
"divider": "darkgreen",
"banner_primary": "darkblue",
"banner_secondary": "darkmagenta",
"label": "MIDNIGHT"
}"#;
let overrides: ThemeJson = serde_json::from_str(json).unwrap();
let theme = overrides.merge_into(Theme::phosphor(), "MIDNIGHT").unwrap();
assert!(matches!(theme.agent, Color::Red));
assert!(matches!(theme.error, Color::DarkRed));
assert!(matches!(theme.banner_primary, Color::DarkBlue));
assert!(matches!(theme.critic, Color::Blue));
assert!(matches!(theme.thinking, Color::DarkGrey));
assert_eq!(theme.label, "MIDNIGHT");
}
#[test]
fn theme_json_accepts_hex_colors() {
let json = r##"{"accent": "#ff8800"}"##;
let overrides: ThemeJson = serde_json::from_str(json).unwrap();
let theme = overrides.merge_into(Theme::phosphor(), "T").unwrap();
assert!(matches!(
theme.accent,
Color::Rgb {
r: 0xff,
g: 0x88,
b: 0x00,
}
));
}
#[test]
fn theme_json_accepts_ansi_value() {
let json = r#"{"accent": 42}"#;
let overrides: ThemeJson = serde_json::from_str(json).unwrap();
let theme = overrides.merge_into(Theme::phosphor(), "T").unwrap();
assert!(matches!(theme.accent, Color::AnsiValue(42)));
}
#[test]
fn theme_json_unknown_color_name_errors() {
let json = r#"{"agent": "eggplant"}"#;
let r: Result<ThemeJson, _> = serde_json::from_str(json);
assert!(r.is_err(), "expected parse error for unknown color");
}
#[test]
fn theme_json_unknown_field_errors() {
let json = r#"{"acccent": "blue"}"#;
let r: Result<ThemeJson, _> = serde_json::from_str(json);
assert!(r.is_err(), "expected error for misspelled field");
}
#[test]
fn load_custom_theme_missing_file_includes_path() {
let err = load_custom_theme("__definitely_not_a_real_theme_xyz")
.expect_err("missing file must error");
assert!(
err.contains("__definitely_not_a_real_theme_xyz") || err.contains("no such file"),
"error should reference the path or 'no such file': {err}",
);
}
}