use serde::Deserialize; use tracing::warn; /// Theme configuration: pick a built-in or override individual tokens. /// /// ```toml /// [theme] /// name = "tokyo-night" # built-in: catppuccin, terminal, dracula, nord, etc. /// /// [theme.custom] # override individual tokens on top of the base /// accent = "#f5c2e7" /// red = "#ff6188" /// ``` #[derive(Debug, Clone, Default, Deserialize)] #[serde(default)] pub struct ThemeConfig { /// Built-in theme name. Default: "catppuccin". pub name: Option, /// Follow host terminal light/dark appearance and switch between theme names. pub auto_switch: bool, /// Theme name used when `auto_switch` selects a dark appearance. pub dark_name: Option, /// Theme name used when `auto_switch` selects a light appearance. pub light_name: Option, /// Custom overrides — applied on top of the selected base theme. pub custom: Option, } /// Per-token color overrides. All fields optional — only set what you want to change. #[derive(Debug, Clone, Default, Deserialize)] #[serde(default)] pub struct CustomThemeColors { pub accent: Option, pub panel_bg: Option, pub surface0: Option, pub surface1: Option, pub surface_dim: Option, pub overlay0: Option, pub overlay1: Option, pub text: Option, pub subtext0: Option, pub mauve: Option, pub green: Option, pub yellow: Option, pub red: Option, pub blue: Option, pub teal: Option, pub peach: Option, } /// Parse a color string into a ratatui Color. /// Supports: hex (#rrggbb, #rgb), named colors, rgb(r,g,b), and reset aliases. pub fn parse_color(s: &str) -> ratatui::style::Color { use ratatui::style::Color; let s = s.trim().to_lowercase(); match s.as_str() { "reset" | "default" | "none" | "transparent" => return Color::Reset, _ => {} } if let Some(hex) = s.strip_prefix('#') { if hex.len() == 6 { if let (Ok(r), Ok(g), Ok(b)) = ( u8::from_str_radix(&hex[0..2], 16), u8::from_str_radix(&hex[2..4], 16), u8::from_str_radix(&hex[4..6], 16), ) { return Color::Rgb(r, g, b); } } else if hex.len() == 3 { let chars: Vec = hex .chars() .filter_map(|c| u8::from_str_radix(&c.to_string(), 16).ok()) .collect(); if chars.len() == 3 { return Color::Rgb(chars[0] * 17, chars[1] * 17, chars[2] * 17); } } } if let Some(inner) = s.strip_prefix("rgb(").and_then(|s| s.strip_suffix(')')) { let parts: Vec<&str> = inner.split(',').collect(); if parts.len() == 3 { if let (Ok(r), Ok(g), Ok(b)) = ( parts[0].trim().parse::(), parts[1].trim().parse::(), parts[2].trim().parse::(), ) { return Color::Rgb(r, g, b); } } } match s.as_str() { "black" => Color::Black, "red" => Color::Red, "green" => Color::Green, "yellow" => Color::Yellow, "blue" => Color::Blue, "magenta" | "purple" => Color::Magenta, "cyan" => Color::Cyan, "white" => Color::White, "gray" | "grey" => Color::Gray, "darkgray" | "darkgrey" => Color::DarkGray, "lightred" => Color::LightRed, "lightgreen" => Color::LightGreen, "lightyellow" => Color::LightYellow, "lightblue" => Color::LightBlue, "lightmagenta" => Color::LightMagenta, "lightcyan" => Color::LightCyan, _ => { warn!(color = s, "unknown color, defaulting to cyan"); Color::Cyan } } } #[cfg(test)] mod tests { use super::*; use crate::config::Config; #[test] fn theme_name_parses() { let toml = r#" [theme] name = "dracula" "#; let config: Config = toml::from_str(toml).unwrap(); assert_eq!(config.theme.name.as_deref(), Some("dracula")); } #[test] fn parse_color_accepts_reset_aliases() { use ratatui::style::Color; for value in ["reset", "default", "none", "transparent"] { assert_eq!(parse_color(value), Color::Reset, "value: {value}"); } } #[test] fn theme_auto_switch_fields_parse() { let toml = r#" [theme] name = "catppuccin" auto_switch = true dark_name = "tokyo-night" light_name = "catppuccin-latte" "#; let config: Config = toml::from_str(toml).unwrap(); assert_eq!(config.theme.name.as_deref(), Some("catppuccin")); assert!(config.theme.auto_switch); assert_eq!(config.theme.dark_name.as_deref(), Some("tokyo-night")); assert_eq!(config.theme.light_name.as_deref(), Some("catppuccin-latte")); } #[test] fn theme_custom_overrides_parse() { let toml = r##" [theme] name = "nord" [theme.custom] panel_bg = "#1e1e2e" accent = "#ff79c6" red = "rgb(255, 85, 85)" "##; let config: Config = toml::from_str(toml).unwrap(); assert_eq!(config.theme.name.as_deref(), Some("nord")); let custom = config.theme.custom.as_ref().unwrap(); assert_eq!(custom.panel_bg.as_deref(), Some("#1e1e2e")); assert_eq!(custom.accent.as_deref(), Some("#ff79c6")); assert_eq!(custom.red.as_deref(), Some("rgb(255, 85, 85)")); assert!(custom.green.is_none()); } #[test] fn theme_defaults_when_missing() { let config: Config = toml::from_str("").unwrap(); assert!(config.theme.name.is_none()); assert!(!config.theme.auto_switch); assert!(config.theme.dark_name.is_none()); assert!(config.theme.light_name.is_none()); assert!(config.theme.custom.is_none()); } }