Files
ferrosonic/src/ui/theme.rs
Jamie Hewitt fb0786122e Extract built-in theme data from theme.rs into theme_builtins.rs
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>
2026-01-28 00:07:29 +00:00

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;