#![allow(dead_code)]
use std::path::Path;
use ratatui::style::Color;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Theme {
pub name: String,
pub bg: Color,
pub fg: Color,
pub accent: Color,
pub success: Color,
pub warning: Color,
pub error: Color,
pub muted: Color,
pub border: Color,
pub header_bg: Color,
pub highlight: Color,
pub row_alt: Color,
pub overlay_bg: Color,
}
impl Default for Theme {
fn default() -> Self {
Theme::dark()
}
}
impl Theme {
pub fn dark() -> Self {
Theme {
name: "dark".to_string(),
bg: Color::Reset,
fg: Color::White,
accent: Color::Cyan,
success: Color::Green,
warning: Color::Yellow,
error: Color::Red,
muted: Color::DarkGray,
border: Color::DarkGray,
header_bg: Color::Rgb(30, 30, 40),
highlight: Color::Rgb(60, 60, 80),
row_alt: Color::Rgb(25, 25, 30),
overlay_bg: Color::Rgb(20, 20, 30),
}
}
pub fn light() -> Self {
Theme {
name: "light".to_string(),
bg: Color::Reset,
fg: Color::Black,
accent: Color::Blue,
success: Color::Green,
warning: Color::Rgb(176, 124, 0),
error: Color::Red,
muted: Color::Gray,
border: Color::Gray,
header_bg: Color::Rgb(230, 230, 238),
highlight: Color::Rgb(210, 215, 235),
row_alt: Color::Rgb(240, 240, 244),
overlay_bg: Color::Rgb(245, 245, 250),
}
}
pub fn named(name: &str) -> Option<Theme> {
match name.to_ascii_lowercase().as_str() {
"dark" => Some(Theme::dark()),
"light" => Some(Theme::light()),
_ => None,
}
}
pub fn builtin_names() -> &'static [&'static str] {
&["dark", "light"]
}
pub fn load(path: &Path) -> std::io::Result<Theme> {
let text = std::fs::read_to_string(path)?;
let cfg: ThemeConfig = toml::from_str(&text)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
Ok(cfg.into_theme())
}
pub fn to_toml(&self) -> Result<String, toml::ser::Error> {
toml::to_string_pretty(&ThemeConfig::from_theme(self))
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ThemeConfig {
#[serde(default = "default_name")]
pub name: String,
#[serde(default)]
pub bg: Option<String>,
#[serde(default)]
pub fg: Option<String>,
#[serde(default)]
pub accent: Option<String>,
#[serde(default)]
pub success: Option<String>,
#[serde(default)]
pub warning: Option<String>,
#[serde(default)]
pub error: Option<String>,
#[serde(default)]
pub muted: Option<String>,
#[serde(default)]
pub border: Option<String>,
#[serde(default)]
pub header_bg: Option<String>,
#[serde(default)]
pub highlight: Option<String>,
#[serde(default)]
pub row_alt: Option<String>,
#[serde(default)]
pub overlay_bg: Option<String>,
}
fn default_name() -> String {
"custom".to_string()
}
impl ThemeConfig {
pub fn into_theme(self) -> Theme {
let base = Theme::dark();
let pick = |opt: Option<String>, fallback: Color| -> Color {
opt.as_deref().and_then(parse_color).unwrap_or(fallback)
};
Theme {
name: self.name,
bg: pick(self.bg, base.bg),
fg: pick(self.fg, base.fg),
accent: pick(self.accent, base.accent),
success: pick(self.success, base.success),
warning: pick(self.warning, base.warning),
error: pick(self.error, base.error),
muted: pick(self.muted, base.muted),
border: pick(self.border, base.border),
header_bg: pick(self.header_bg, base.header_bg),
highlight: pick(self.highlight, base.highlight),
row_alt: pick(self.row_alt, base.row_alt),
overlay_bg: pick(self.overlay_bg, base.overlay_bg),
}
}
pub fn from_theme(t: &Theme) -> ThemeConfig {
ThemeConfig {
name: t.name.clone(),
bg: Some(color_to_string(t.bg)),
fg: Some(color_to_string(t.fg)),
accent: Some(color_to_string(t.accent)),
success: Some(color_to_string(t.success)),
warning: Some(color_to_string(t.warning)),
error: Some(color_to_string(t.error)),
muted: Some(color_to_string(t.muted)),
border: Some(color_to_string(t.border)),
header_bg: Some(color_to_string(t.header_bg)),
highlight: Some(color_to_string(t.highlight)),
row_alt: Some(color_to_string(t.row_alt)),
overlay_bg: Some(color_to_string(t.overlay_bg)),
}
}
}
pub fn parse_color(s: &str) -> Option<Color> {
let s = s.trim();
match s.to_ascii_lowercase().as_str() {
"reset" => return Some(Color::Reset),
"black" => return Some(Color::Black),
"red" => return Some(Color::Red),
"green" => return Some(Color::Green),
"yellow" => return Some(Color::Yellow),
"blue" => return Some(Color::Blue),
"magenta" => return Some(Color::Magenta),
"cyan" => return Some(Color::Cyan),
"gray" | "grey" => return Some(Color::Gray),
"darkgray" | "darkgrey" => return Some(Color::DarkGray),
"white" => return Some(Color::White),
_ => {}
}
let hex = s.strip_prefix('#').unwrap_or(s);
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));
}
None
}
pub fn color_to_string(c: Color) -> String {
match c {
Color::Reset => "reset".to_string(),
Color::Black => "black".to_string(),
Color::Red => "red".to_string(),
Color::Green => "green".to_string(),
Color::Yellow => "yellow".to_string(),
Color::Blue => "blue".to_string(),
Color::Magenta => "magenta".to_string(),
Color::Cyan => "cyan".to_string(),
Color::Gray => "gray".to_string(),
Color::DarkGray => "darkgray".to_string(),
Color::White => "white".to_string(),
Color::Rgb(r, g, b) => format!("#{:02x}{:02x}{:02x}", r, g, b),
Color::Indexed(i) => format!("indexed:{}", i),
other => format!("{:?}", other),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dark_matches_legacy_constants() {
let t = Theme::dark();
assert_eq!(t.accent, Color::Cyan);
assert_eq!(t.muted, Color::DarkGray);
assert_eq!(t.header_bg, Color::Rgb(30, 30, 40));
assert_eq!(t.highlight, Color::Rgb(60, 60, 80));
assert_eq!(t.row_alt, Color::Rgb(25, 25, 30));
assert_eq!(t.overlay_bg, Color::Rgb(20, 20, 30));
}
#[test]
fn parse_color_forms() {
assert_eq!(parse_color("reset"), Some(Color::Reset));
assert_eq!(parse_color("cyan"), Some(Color::Cyan));
assert_eq!(parse_color("#1e1e28"), Some(Color::Rgb(0x1e, 0x1e, 0x28)));
assert_eq!(parse_color("1e1e28"), Some(Color::Rgb(0x1e, 0x1e, 0x28)));
assert_eq!(parse_color("nope"), None);
}
#[test]
fn toml_round_trip() {
let t = Theme::dark();
let toml_str = t.to_toml().expect("serialize");
let cfg: ThemeConfig = toml::from_str(&toml_str).expect("deserialize");
assert_eq!(cfg.into_theme(), t);
}
#[test]
fn missing_fields_fall_back_to_dark() {
let cfg: ThemeConfig =
toml::from_str("name = \"partial\"\naccent = \"#ff0000\"\n").expect("deserialize");
let t = cfg.into_theme();
assert_eq!(t.name, "partial");
assert_eq!(t.accent, Color::Rgb(0xff, 0, 0));
assert_eq!(t.muted, Theme::dark().muted);
}
#[test]
fn named_lookup() {
assert!(Theme::named("dark").is_some());
assert!(Theme::named("LIGHT").is_some());
assert!(Theme::named("solarized").is_none());
}
}