use std::{env, fs, path::PathBuf};
use anyhow::{Context, Result};
use ratatui::style::Color;
use serde::Deserialize;
#[derive(Debug, Clone)]
pub struct Config {
pub theme: Theme,
}
impl Default for Config {
fn default() -> Self {
Self {
theme: Theme::default(),
}
}
}
impl Config {
pub fn load() -> Self {
let Some(path) = config_path() else {
return Self::default();
};
match load_from_path(&path) {
Ok(cfg) => cfg,
Err(err) => {
eprintln!("trix: config load failed ({}): {err:#}", path.display());
Self::default()
}
}
}
}
#[derive(Debug, Clone)]
pub struct Theme {
pub background: Color,
pub title_accent: Color,
pub current_track_accent: Color,
pub playing_indicator: Color,
pub library_accent: Color,
pub now_accent: Color,
pub progress_accent: Color,
pub hints_accent: Color,
pub search_accent: Color,
pub move_accent: Color,
pub key_accent: Color,
pub song_title_accent: Color,
pub text_primary: Color,
pub text_muted: Color,
pub error: Color,
}
impl Default for Theme {
fn default() -> Self {
Self {
background: Color::Reset,
title_accent: Color::Rgb(0x61, 0xaf, 0xef), current_track_accent: Color::Rgb(0x56, 0xb6, 0xc2), playing_indicator: Color::Rgb(0x98, 0xc3, 0x79),
library_accent: Color::Rgb(0xe5, 0xc0, 0x7b),
now_accent: Color::Rgb(0x61, 0xaf, 0xef), progress_accent: Color::Rgb(0x98, 0xc3, 0x79), hints_accent: Color::Rgb(0xc6, 0x78, 0xdd), search_accent: Color::Rgb(0x56, 0xb6, 0xc2), move_accent: Color::Rgb(0xe5, 0xc0, 0x7b),
key_accent: Color::Rgb(0xc6, 0x78, 0xdd), song_title_accent: Color::Rgb(0xe5, 0xc0, 0x7b),
text_primary: Color::Rgb(0xab, 0xb2, 0xbf), text_muted: Color::Rgb(0x5c, 0x63, 0x70), error: Color::Rgb(0xe0, 0x6c, 0x75), }
}
}
#[derive(Debug, Default, Deserialize)]
struct RawConfig {
theme: Option<RawTheme>,
}
#[derive(Debug, Default, Deserialize)]
struct RawTheme {
background: Option<String>,
title_accent: Option<String>,
current_track_accent: Option<String>,
playing_indicator: Option<String>,
library_accent: Option<String>,
now_accent: Option<String>,
progress_accent: Option<String>,
hints_accent: Option<String>,
search_accent: Option<String>,
move_accent: Option<String>,
key_accent: Option<String>,
song_title_accent: Option<String>,
text_primary: Option<String>,
text_muted: Option<String>,
error: Option<String>,
}
fn load_from_path(path: &PathBuf) -> Result<Config> {
let data = match fs::read_to_string(path) {
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Config::default()),
Err(e) => return Err(e).with_context(|| format!("read {}", path.display())),
};
let raw: RawConfig = toml::from_str(&data).context("parse TOML")?;
let mut cfg = Config::default();
if let Some(theme) = raw.theme {
apply_theme(&mut cfg.theme, theme);
}
Ok(cfg)
}
fn apply_theme(out: &mut Theme, raw: RawTheme) {
apply_color(&mut out.background, raw.background, "theme.background");
apply_color(&mut out.title_accent, raw.title_accent, "theme.title_accent");
apply_color(
&mut out.current_track_accent,
raw.current_track_accent,
"theme.current_track_accent",
);
apply_color(
&mut out.playing_indicator,
raw.playing_indicator,
"theme.playing_indicator",
);
apply_color(
&mut out.library_accent,
raw.library_accent,
"theme.library_accent",
);
apply_color(&mut out.now_accent, raw.now_accent, "theme.now_accent");
apply_color(
&mut out.progress_accent,
raw.progress_accent,
"theme.progress_accent",
);
apply_color(
&mut out.hints_accent,
raw.hints_accent,
"theme.hints_accent",
);
apply_color(
&mut out.search_accent,
raw.search_accent,
"theme.search_accent",
);
apply_color(
&mut out.move_accent,
raw.move_accent,
"theme.move_accent",
);
apply_color(&mut out.key_accent, raw.key_accent, "theme.key_accent");
apply_color(
&mut out.song_title_accent,
raw.song_title_accent,
"theme.song_title_accent",
);
apply_color(
&mut out.text_primary,
raw.text_primary,
"theme.text_primary",
);
apply_color(&mut out.text_muted, raw.text_muted, "theme.text_muted");
apply_color(&mut out.error, raw.error, "theme.error");
}
fn apply_color(slot: &mut Color, value: Option<String>, key: &str) {
let Some(value) = value else { return };
match parse_color(&value) {
Some(c) => *slot = c,
None => {
eprintln!("trix: ignoring invalid color for {key}: {value}");
}
}
}
fn parse_color(s: &str) -> Option<Color> {
let s = s.trim();
if s.is_empty() {
return None;
}
let lower = s.to_ascii_lowercase();
match lower.as_str() {
"reset" | "default" | "none" | "transparent" | "terminal" => return Some(Color::Reset),
_ => {}
}
if let Some(hex) = lower.strip_prefix('#') {
if hex.len() == 6 {
let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
return Some(Color::Rgb(r, g, b));
}
}
match lower.as_str() {
"black" => Some(Color::Black),
"red" => Some(Color::Red),
"green" => Some(Color::Green),
"yellow" => Some(Color::Yellow),
"blue" => Some(Color::Blue),
"magenta" => Some(Color::Magenta),
"cyan" => Some(Color::Cyan),
"gray" | "grey" => Some(Color::Gray),
"darkgray" | "dark_gray" | "darkgrey" | "dark_grey" => Some(Color::DarkGray),
"lightred" | "light_red" => Some(Color::LightRed),
"lightgreen" | "light_green" => Some(Color::LightGreen),
"lightyellow" | "light_yellow" => Some(Color::LightYellow),
"lightblue" | "light_blue" => Some(Color::LightBlue),
"lightmagenta" | "light_magenta" => Some(Color::LightMagenta),
"lightcyan" | "light_cyan" => Some(Color::LightCyan),
"white" => Some(Color::White),
_ => None,
}
}
fn config_path() -> Option<PathBuf> {
let base = env::var_os("XDG_CONFIG_HOME").map(PathBuf::from).or_else(|| {
env::var_os("HOME").map(|h| PathBuf::from(h).join(".config"))
})?;
Some(base.join("trix").join("config.toml"))
}