Moves ~265 lines of TOML theme constant data to a dedicated file, reducing theme.rs from 553 to ~290 lines of logic. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
290 lines
9.9 KiB
Rust
290 lines
9.9 KiB
Rust
//! 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<ThemeFileCava>,
|
|
}
|
|
|
|
#[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<Vec<String>>,
|
|
horizontal_gradient: Option<Vec<String>>,
|
|
}
|
|
|
|
// ── 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<Self, String> {
|
|
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<ThemeData> {
|
|
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::<Vec<_>>()
|
|
.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;
|