//! Theme color definitions — file-based themes loaded from ~/.config/ferrosonic/themes/ use std::path::Path; use ratatui::style::Color; use serde::Deserialize; use tracing::{error, info, warn}; use crate::config::paths; /// Color palette for a theme #[derive(Debug, Clone, Copy)] pub struct ThemeColors { /// Primary highlight color (focused elements, selected tabs) pub primary: Color, /// Secondary color (borders, less important elements) pub secondary: Color, /// Accent color (currently playing, important highlights) pub accent: Color, /// Artist names pub artist: Color, /// Album names pub album: Color, /// Song titles (default) pub song: Color, /// Muted text (track numbers, durations, hints) pub muted: Color, /// Selection/highlight background pub highlight_bg: Color, /// Text on highlighted background pub highlight_fg: Color, /// Success messages pub success: Color, /// Error messages pub error: Color, /// Playing indicator pub playing: Color, /// Played songs in queue pub played: Color, /// Border color (focused) pub border_focused: Color, /// Border color (unfocused) pub border_unfocused: Color, } /// A loaded theme: display name + colors + cava gradients #[derive(Debug, Clone)] pub struct ThemeData { /// Display name (e.g. "Catppuccin", "Default") pub name: String, /// UI colors pub colors: ThemeColors, /// Cava vertical gradient (8 hex strings) pub cava_gradient: [String; 8], /// Cava horizontal gradient (8 hex strings) pub cava_horizontal_gradient: [String; 8], } // ── TOML deserialization structs ────────────────────────────────────────────── #[derive(Deserialize)] struct ThemeFile { colors: ThemeFileColors, cava: Option, } #[derive(Deserialize)] struct ThemeFileColors { primary: String, secondary: String, accent: String, artist: String, album: String, song: String, muted: String, highlight_bg: String, highlight_fg: String, success: String, error: String, playing: String, played: String, border_focused: String, border_unfocused: String, } #[derive(Deserialize)] struct ThemeFileCava { gradient: Option>, horizontal_gradient: Option>, } // ── Hex color parsing ───────────────────────────────────────────────────────── fn hex_to_color(hex: &str) -> Color { let hex = hex.trim_start_matches('#'); 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); } } warn!("Invalid hex color '{}', falling back to white", hex); Color::White } fn parse_gradient(values: &[String], fallback: &[&str; 8]) -> [String; 8] { let mut result: [String; 8] = std::array::from_fn(|i| fallback[i].to_string()); for (i, v) in values.iter().enumerate().take(8) { result[i] = v.clone(); } result } // ── ThemeData construction ──────────────────────────────────────────────────── impl ThemeData { fn from_file_content(name: &str, content: &str) -> Result { let file: ThemeFile = toml::from_str(content).map_err(|e| format!("Failed to parse theme '{}': {}", name, e))?; let c = &file.colors; let colors = ThemeColors { primary: hex_to_color(&c.primary), secondary: hex_to_color(&c.secondary), accent: hex_to_color(&c.accent), artist: hex_to_color(&c.artist), album: hex_to_color(&c.album), song: hex_to_color(&c.song), muted: hex_to_color(&c.muted), highlight_bg: hex_to_color(&c.highlight_bg), highlight_fg: hex_to_color(&c.highlight_fg), success: hex_to_color(&c.success), error: hex_to_color(&c.error), playing: hex_to_color(&c.playing), played: hex_to_color(&c.played), border_focused: hex_to_color(&c.border_focused), border_unfocused: hex_to_color(&c.border_unfocused), }; let default_g: [&str; 8] = [ "#59cc33", "#cccc33", "#cc8033", "#cc5533", "#cc3333", "#bb1111", "#990000", "#990000", ]; let default_h: [&str; 8] = [ "#c45161", "#e094a0", "#f2b6c0", "#f2dde1", "#cbc7d8", "#8db7d2", "#5e62a9", "#434279", ]; let cava = file.cava.as_ref(); let cava_gradient = match cava.and_then(|c| c.gradient.as_ref()) { Some(g) => parse_gradient(g, &default_g), None => std::array::from_fn(|i| default_g[i].to_string()), }; let cava_horizontal_gradient = match cava.and_then(|c| c.horizontal_gradient.as_ref()) { Some(h) => parse_gradient(h, &default_h), None => std::array::from_fn(|i| default_h[i].to_string()), }; Ok(ThemeData { name: name.to_string(), colors, cava_gradient, cava_horizontal_gradient, }) } /// The hardcoded Default theme pub fn default_theme() -> Self { ThemeData { name: "Default".to_string(), colors: ThemeColors { primary: Color::Cyan, secondary: Color::DarkGray, accent: Color::Yellow, artist: Color::LightGreen, album: Color::Magenta, song: Color::Magenta, muted: Color::Gray, highlight_bg: Color::Rgb(102, 51, 153), highlight_fg: Color::White, success: Color::Green, error: Color::Red, playing: Color::LightGreen, played: Color::Red, border_focused: Color::Cyan, border_unfocused: Color::DarkGray, }, cava_gradient: [ "#59cc33".into(), "#cccc33".into(), "#cc8033".into(), "#cc5533".into(), "#cc3333".into(), "#bb1111".into(), "#990000".into(), "#990000".into(), ], cava_horizontal_gradient: [ "#c45161".into(), "#e094a0".into(), "#f2b6c0".into(), "#f2dde1".into(), "#cbc7d8".into(), "#8db7d2".into(), "#5e62a9".into(), "#434279".into(), ], } } } // ── Loading ─────────────────────────────────────────────────────────────────── /// Load all themes: Default (hardcoded) + TOML files from themes dir (sorted alphabetically) pub fn load_themes() -> Vec { let mut themes = vec![ThemeData::default_theme()]; if let Some(dir) = paths::themes_dir() { if dir.is_dir() { let mut entries: Vec<_> = std::fs::read_dir(&dir) .into_iter() .flatten() .filter_map(|e| e.ok()) .filter(|e| { e.path() .extension() .is_some_and(|ext| ext == "toml") }) .collect(); entries.sort_by_key(|e| e.file_name()); for entry in entries { let path = entry.path(); let stem = path .file_stem() .and_then(|s| s.to_str()) .unwrap_or("unknown"); // Capitalize first letter for display name let name = titlecase_filename(stem); match std::fs::read_to_string(&path) { Ok(content) => match ThemeData::from_file_content(&name, &content) { Ok(theme) => { info!("Loaded theme '{}' from {}", name, path.display()); themes.push(theme); } Err(e) => error!("{}", e), }, Err(e) => error!("Failed to read {}: {}", path.display(), e), } } } } themes } /// Convert a filename stem like "tokyo-night" or "rose_pine" to "Tokyo Night" or "Rose Pine" fn titlecase_filename(s: &str) -> String { s.split(['-', '_']) .filter(|w| !w.is_empty()) .map(|word| { let mut chars = word.chars(); match chars.next() { Some(first) => { let upper: String = first.to_uppercase().collect(); upper + chars.as_str() } None => String::new(), } }) .collect::>() .join(" ") } // ── Seeding built-in themes ─────────────────────────────────────────────────── /// Write the built-in themes as TOML files into the given directory. /// Only writes files that don't already exist. pub fn seed_default_themes(dir: &Path) { if let Err(e) = std::fs::create_dir_all(dir) { error!("Failed to create themes directory: {}", e); return; } for (filename, content) in BUILTIN_THEMES { let path = dir.join(filename); if !path.exists() { if let Err(e) = std::fs::write(&path, content) { error!("Failed to write theme {}: {}", filename, e); } else { info!("Seeded theme file: {}", filename); } } } } use super::theme_builtins::BUILTIN_THEMES;