use std::path::Path;
use ratatui::style::Color;
use serde::Deserialize;
#[derive(Debug, Clone, Deserialize)]
pub struct Theme {
pub name: String,
#[serde(with = "hex_color")]
pub bg: Color,
#[serde(with = "hex_color")]
pub panel: Color,
#[serde(with = "hex_color")]
pub panel_alt: Color,
#[serde(with = "hex_color")]
pub selection_bg: Color,
#[serde(with = "hex_color")]
pub text: Color,
#[serde(with = "hex_color")]
pub text_muted: Color,
#[serde(with = "hex_color")]
pub accent: Color,
#[serde(with = "hex_color")]
pub shadow: Color,
#[serde(with = "hex_color")]
pub dim_fg: Color,
#[serde(with = "hex_color")]
pub status_running: Color,
#[serde(with = "hex_color")]
pub status_waiting: Color,
#[serde(with = "hex_color")]
pub status_idle: Color,
#[serde(with = "hex_color")]
pub status_error: Color,
}
impl Theme {
pub fn load(name: &str, user_dir: Option<&Path>) -> Self {
if let Some(dir) = user_dir {
let path = dir.join(format!("{name}.toml"));
if let Ok(s) = std::fs::read_to_string(&path) {
match toml::from_str::<Theme>(&s) {
Ok(t) => return t,
Err(e) => {
tracing::warn!("failed to parse user theme {:?}: {}", path, e);
}
}
}
}
Self::builtin(name).unwrap_or_else(Self::default_opencode)
}
pub fn builtin(name: &str) -> Option<Self> {
let src = match name {
"opencode" => include_str!("../../themes/opencode.toml"),
"tokyonight" => include_str!("../../themes/tokyonight.toml"),
"dracula" => include_str!("../../themes/dracula.toml"),
"catppuccin-mocha" => include_str!("../../themes/catppuccin-mocha.toml"),
"one-dark-pro" => include_str!("../../themes/one-dark-pro.toml"),
"ayu-mirage" => include_str!("../../themes/ayu-mirage.toml"),
"nord" => include_str!("../../themes/nord.toml"),
"gruvbox-dark" => include_str!("../../themes/gruvbox-dark.toml"),
"rose-pine" => include_str!("../../themes/rose-pine.toml"),
"github-dark" => include_str!("../../themes/github-dark.toml"),
"github-light" => include_str!("../../themes/github-light.toml"),
"one-light" => include_str!("../../themes/one-light.toml"),
"solarized-light" => include_str!("../../themes/solarized-light.toml"),
"ayu-light" => include_str!("../../themes/ayu-light.toml"),
"quiet-light" => include_str!("../../themes/quiet-light.toml"),
_ => return None,
};
match toml::from_str::<Theme>(src) {
Ok(t) => Some(t),
Err(e) => {
tracing::error!("built-in theme {} failed to parse: {}", name, e);
None
}
}
}
pub fn builtin_names() -> &'static [&'static str] {
&[
"opencode",
"tokyonight",
"dracula",
"catppuccin-mocha",
"one-dark-pro",
"ayu-mirage",
"nord",
"gruvbox-dark",
"rose-pine",
"github-dark",
"github-light",
"one-light",
"solarized-light",
"ayu-light",
"quiet-light",
]
}
pub fn available(user_dir: Option<&Path>) -> Vec<String> {
let mut names: Vec<String> = Self::builtin_names()
.iter()
.map(|s| s.to_string())
.collect();
if let Some(dir) = user_dir {
if let Ok(read) = std::fs::read_dir(dir) {
for entry in read.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("toml") {
continue;
}
if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
let stem = stem.to_string();
if !names.contains(&stem) {
names.push(stem);
}
}
}
}
}
names
}
pub fn default_opencode() -> Self {
Self::builtin("opencode").expect("built-in opencode theme must parse")
}
}
mod hex_color {
use ratatui::style::Color;
use serde::{Deserialize, Deserializer};
pub fn deserialize<'de, D>(de: D) -> Result<Color, D::Error>
where
D: Deserializer<'de>,
{
let s = String::deserialize(de)?;
parse_hex(&s).map_err(serde::de::Error::custom)
}
fn parse_hex(s: &str) -> Result<Color, String> {
let hex = s.trim().trim_start_matches('#');
if hex.len() != 6 {
return Err(format!("expected #RRGGBB hex color, got {s:?}"));
}
let r = u8::from_str_radix(&hex[0..2], 16).map_err(|e| e.to_string())?;
let g = u8::from_str_radix(&hex[2..4], 16).map_err(|e| e.to_string())?;
let b = u8::from_str_radix(&hex[4..6], 16).map_err(|e| e.to_string())?;
Ok(Color::Rgb(r, g, b))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn builtin_opencode_parses() {
let t = Theme::builtin("opencode").expect("opencode must parse");
assert_eq!(t.name, "opencode");
assert_eq!(t.accent, Color::Rgb(0x7c, 0x5c, 0xff));
assert_eq!(t.bg, Color::Rgb(0x0b, 0x0d, 0x12));
}
#[test]
fn builtin_tokyonight_parses() {
let t = Theme::builtin("tokyonight").expect("tokyonight must parse");
assert_eq!(t.name, "tokyonight");
assert_eq!(t.accent, Color::Rgb(0x7a, 0xa2, 0xf7));
}
#[test]
fn every_builtin_listed_also_parses() {
for name in Theme::builtin_names() {
let t =
Theme::builtin(name).unwrap_or_else(|| panic!("built-in theme {name} must parse"));
assert_eq!(
t.name, *name,
"theme file {name}.toml has name = {:?}, expected {:?}",
t.name, name
);
}
}
#[test]
fn available_themes_include_all_builtins_when_no_user_dir() {
let names = Theme::available(None);
for builtin in Theme::builtin_names() {
assert!(names.iter().any(|n| n == builtin), "missing {builtin}");
}
}
#[test]
fn available_themes_add_user_dir_entries() {
let dir = tempdir();
std::fs::write(
dir.join("my-custom.toml"),
r##"name = "my-custom"
bg = "#000000"
panel = "#000000"
panel_alt = "#000000"
selection_bg = "#000000"
text = "#ffffff"
text_muted = "#ffffff"
accent = "#ff0000"
shadow = "#000000"
dim_fg = "#000000"
status_running = "#00ff00"
status_waiting = "#ffff00"
status_idle = "#888888"
status_error = "#ff0000"
"##,
)
.unwrap();
let names = Theme::available(Some(&dir));
assert!(names.contains(&"my-custom".to_string()));
let opencode_count = names.iter().filter(|n| *n == "opencode").count();
assert_eq!(opencode_count, 1);
}
#[test]
fn unknown_builtin_is_none() {
assert!(Theme::builtin("does-not-exist").is_none());
}
#[test]
fn load_missing_theme_falls_back_to_opencode() {
let t = Theme::load("nonexistent", None);
assert_eq!(t.name, "opencode");
}
#[test]
fn user_dir_override_takes_precedence() {
let dir = tempdir();
std::fs::write(
dir.join("opencode.toml"),
r##"name = "opencode-custom"
bg = "#000000"
panel = "#000000"
panel_alt = "#000000"
selection_bg = "#000000"
text = "#ffffff"
text_muted = "#ffffff"
accent = "#ff0000"
shadow = "#000000"
dim_fg = "#000000"
status_running = "#00ff00"
status_waiting = "#ffff00"
status_idle = "#888888"
status_error = "#ff0000"
"##,
)
.unwrap();
let t = Theme::load("opencode", Some(&dir));
assert_eq!(t.name, "opencode-custom");
assert_eq!(t.accent, Color::Rgb(0xff, 0x00, 0x00));
}
fn tempdir() -> std::path::PathBuf {
let base = std::env::temp_dir().join(format!(
"bosun-theme-test-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&base).unwrap();
base
}
}