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 on(&self, bg: Color) -> Color {
match bg {
Color::Rgb(r, g, b) => {
let luminance = 0.299 * r as f32 + 0.587 * g as f32 + 0.114 * b as f32;
if luminance > 140.0 {
Color::Rgb(0x10, 0x12, 0x18) } else {
Color::Rgb(0xf2, 0xf3, 0xf5) }
}
_ => self.text,
}
}
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")
}
}
pub fn terminal_truecolor() -> bool {
use std::sync::OnceLock;
static TRUECOLOR: OnceLock<bool> = OnceLock::new();
*TRUECOLOR.get_or_init(|| {
if let Some(v) = std::env::var_os("BOSUN_TRUECOLOR") {
return v == "1" || v == "true";
}
matches!(
std::env::var("COLORTERM").as_deref(),
Ok("truecolor") | Ok("24bit")
)
})
}
pub fn degrade_buffer_to_256(buf: &mut ratatui::buffer::Buffer) {
let area = buf.area;
for y in area.top()..area.bottom() {
for x in area.left()..area.right() {
let cell = &mut buf[(x, y)];
cell.fg = to_256(cell.fg);
cell.bg = to_256(cell.bg);
cell.underline_color = to_256(cell.underline_color);
}
}
}
fn to_256(c: Color) -> Color {
match c {
Color::Rgb(r, g, b) => Color::Indexed(rgb_to_xterm256(r, g, b)),
other => other,
}
}
fn rgb_to_xterm256(r: u8, g: u8, b: u8) -> u8 {
const STEPS: [u8; 6] = [0, 95, 135, 175, 215, 255];
let nearest_step = |c: u8| -> (usize, u8) {
let mut best = 0usize;
let mut best_d = u16::MAX;
for (i, &s) in STEPS.iter().enumerate() {
let d = (s as i16 - c as i16).unsigned_abs();
if d < best_d {
best_d = d;
best = i;
}
}
(best, STEPS[best])
};
let (ri, rv) = nearest_step(r);
let (gi, gv) = nearest_step(g);
let (bi, bv) = nearest_step(b);
let cube_code = (16 + 36 * ri + 6 * gi + bi) as u8;
let avg = ((r as u16 + g as u16 + b as u16) / 3) as i16;
let gray_i = (((avg - 8) as f32 / 10.0).round() as i16).clamp(0, 23);
let gray_v = (8 + gray_i * 10) as u8;
let gray_code = 232 + gray_i as u8;
let dist = |cr: u8, cg: u8, cb: u8| -> i32 {
let dr = r as i32 - cr as i32;
let dg = g as i32 - cg as i32;
let db = b as i32 - cb as i32;
dr * dr + dg * dg + db * db
};
if dist(rv, gv, bv) <= dist(gray_v, gray_v, gray_v) {
cube_code
} else {
gray_code
}
}
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 rgb_to_256_maps_cube_corners() {
assert_eq!(rgb_to_xterm256(0, 0, 0), 16);
assert_eq!(rgb_to_xterm256(255, 255, 255), 231);
assert_eq!(rgb_to_xterm256(255, 0, 0), 196); assert_eq!(rgb_to_xterm256(0, 255, 0), 46); assert_eq!(rgb_to_xterm256(0, 0, 255), 21); }
#[test]
fn rgb_to_256_prefers_grayscale_ramp_for_grays() {
let idx = rgb_to_xterm256(0x80, 0x80, 0x80);
assert!((232..=255).contains(&idx), "expected ramp, got {idx}");
}
#[test]
fn to_256_passes_through_non_rgb() {
assert_eq!(to_256(Color::Reset), Color::Reset);
assert_eq!(to_256(Color::Indexed(42)), Color::Indexed(42));
assert_eq!(to_256(Color::Red), Color::Red);
assert!(matches!(to_256(Color::Rgb(10, 20, 30)), Color::Indexed(_)));
}
#[test]
fn on_picks_dark_ink_for_light_accent() {
let t = Theme::builtin("tokyonight").expect("tokyonight must parse");
assert_eq!(t.on(t.accent), Color::Rgb(0x10, 0x12, 0x18));
assert_eq!(
t.on(Color::Rgb(0xff, 0xff, 0xff)),
Color::Rgb(0x10, 0x12, 0x18)
);
}
#[test]
fn on_picks_light_ink_for_dark_accent() {
let t = Theme::builtin("opencode").expect("opencode must parse");
assert_eq!(t.on(t.accent), Color::Rgb(0xf2, 0xf3, 0xf5));
assert_eq!(
t.on(Color::Rgb(0x00, 0x00, 0x00)),
Color::Rgb(0xf2, 0xf3, 0xf5)
);
}
#[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
}
}