use ratatui::style::Color;
use std::sync::RwLock;
#[allow(dead_code)]
#[derive(Debug, Clone, Copy)]
pub struct Theme {
pub name: &'static str,
pub bg: Color,
pub fg: Color,
pub dim: Color,
pub faint: Color,
pub red: Color,
pub green: Color,
pub yellow: Color,
pub cyan: Color,
pub magenta: Color,
pub white: Color,
pub br_green: Color,
pub br_cyan: Color,
pub br_white: Color,
pub sel_bg: Color,
pub warn_bg: Color,
pub err_bg: Color,
pub ok_bg: Color,
}
pub const THEME_NAMES: &[&str] = &[
"dark",
"light",
"ocean",
"solarized",
"dracula",
"nord",
"terminal",
];
pub const fn dark() -> Theme {
Theme {
name: "dark",
bg: Color::Rgb(0x0c, 0x14, 0x18),
fg: Color::Rgb(0xc5, 0xd1, 0xd6),
dim: Color::Rgb(0x6b, 0x80, 0x88),
faint: Color::Rgb(0x44, 0x56, 0x60),
red: Color::Rgb(0xff, 0x78, 0x78),
green: Color::Rgb(0x5c, 0xd9, 0x89),
yellow: Color::Rgb(0xf0, 0xc0, 0x60),
cyan: Color::Rgb(0x5f, 0xdc, 0xff),
magenta: Color::Rgb(0xd9, 0x7a, 0xff),
white: Color::Rgb(0xe6, 0xf0, 0xf2),
br_green: Color::Rgb(0x9a, 0xe6, 0xb4),
br_cyan: Color::Rgb(0x86, 0xe6, 0xff),
br_white: Color::Rgb(0xff, 0xff, 0xff),
sel_bg: Color::Rgb(0x1a, 0x33, 0x40),
warn_bg: Color::Rgb(0x3a, 0x2c, 0x14),
err_bg: Color::Rgb(0x3a, 0x1c, 0x1c),
ok_bg: Color::Rgb(0x16, 0x32, 0x1f),
}
}
pub const fn light() -> Theme {
Theme {
name: "light",
bg: Color::Rgb(0xf5, 0xf5, 0xf2),
fg: Color::Rgb(30, 30, 30),
dim: Color::Rgb(90, 90, 90),
faint: Color::Rgb(160, 160, 160),
red: Color::Rgb(180, 30, 30),
green: Color::Rgb(0, 120, 50),
yellow: Color::Rgb(170, 110, 0),
cyan: Color::Rgb(0, 100, 160),
magenta: Color::Rgb(120, 60, 160),
white: Color::Rgb(0, 0, 0),
br_green: Color::Rgb(0, 150, 65),
br_cyan: Color::Rgb(0, 130, 200),
br_white: Color::Rgb(0, 0, 0),
sel_bg: Color::Rgb(220, 230, 240),
warn_bg: Color::Rgb(0xfa, 0xf0, 0xd6),
err_bg: Color::Rgb(0xfa, 0xdc, 0xdc),
ok_bg: Color::Rgb(0xdc, 0xea, 0xf7),
}
}
pub const fn ocean() -> Theme {
Theme {
name: "ocean",
bg: Color::Rgb(0x22, 0x4F, 0xBC),
fg: Color::Rgb(0xFF, 0xFF, 0xFF),
dim: Color::Rgb(0xB5, 0xB6, 0xB7),
faint: Color::Rgb(0xB5, 0xB6, 0xB7),
red: Color::Rgb(0xFC, 0x39, 0x1F),
green: Color::Rgb(0x31, 0xE7, 0x22),
yellow: Color::Rgb(0xEA, 0xEC, 0x23),
cyan: Color::Rgb(0x14, 0xF0, 0xF0),
magenta: Color::Rgb(0xFF, 0x40, 0xFF),
white: Color::Rgb(0xCB, 0xCC, 0xCD),
br_green: Color::Rgb(0x31, 0xE7, 0x22),
br_cyan: Color::Rgb(0x14, 0xF0, 0xF0),
br_white: Color::Rgb(0xFF, 0xFF, 0xFF),
sel_bg: Color::Rgb(0x21, 0x6D, 0xFF),
warn_bg: Color::Rgb(0x3A, 0x4A, 0x12),
err_bg: Color::Rgb(0x4A, 0x1F, 0x1F),
ok_bg: Color::Rgb(0x1A, 0x3A, 0x6E),
}
}
pub const fn solarized() -> Theme {
Theme {
name: "solarized",
bg: Color::Rgb(0, 43, 54), fg: Color::Rgb(131, 148, 150), dim: Color::Rgb(101, 123, 131), faint: Color::Rgb(62, 84, 92), red: Color::Rgb(220, 50, 47),
green: Color::Rgb(133, 153, 0),
yellow: Color::Rgb(181, 137, 0),
cyan: Color::Rgb(42, 161, 152),
magenta: Color::Rgb(211, 54, 130),
white: Color::Rgb(147, 161, 161), br_green: Color::Rgb(152, 175, 0),
br_cyan: Color::Rgb(58, 180, 170),
br_white: Color::Rgb(238, 232, 213), sel_bg: Color::Rgb(7, 54, 66), warn_bg: Color::Rgb(40, 36, 14),
err_bg: Color::Rgb(50, 18, 18),
ok_bg: Color::Rgb(10, 50, 60),
}
}
pub const fn dracula() -> Theme {
Theme {
name: "dracula",
bg: Color::Rgb(40, 42, 54),
fg: Color::Rgb(248, 248, 242),
dim: Color::Rgb(98, 114, 164), faint: Color::Rgb(68, 71, 90),
red: Color::Rgb(255, 85, 85),
green: Color::Rgb(80, 250, 123),
yellow: Color::Rgb(241, 250, 140),
cyan: Color::Rgb(139, 233, 253),
magenta: Color::Rgb(189, 147, 249), white: Color::Rgb(248, 248, 242),
br_green: Color::Rgb(120, 255, 150),
br_cyan: Color::Rgb(170, 240, 255),
br_white: Color::Rgb(255, 255, 255),
sel_bg: Color::Rgb(68, 71, 90),
warn_bg: Color::Rgb(60, 50, 30),
err_bg: Color::Rgb(70, 30, 30),
ok_bg: Color::Rgb(35, 55, 70),
}
}
pub const fn nord() -> Theme {
Theme {
name: "nord",
bg: Color::Rgb(46, 52, 64), fg: Color::Rgb(216, 222, 233), dim: Color::Rgb(129, 161, 193), faint: Color::Rgb(67, 76, 94), red: Color::Rgb(191, 97, 106), green: Color::Rgb(163, 190, 140),
yellow: Color::Rgb(235, 203, 139),
cyan: Color::Rgb(136, 192, 208), magenta: Color::Rgb(180, 142, 173),
white: Color::Rgb(229, 233, 240),
br_green: Color::Rgb(180, 205, 160),
br_cyan: Color::Rgb(150, 205, 220),
br_white: Color::Rgb(236, 239, 244),
sel_bg: Color::Rgb(59, 66, 82),
warn_bg: Color::Rgb(60, 56, 40),
err_bg: Color::Rgb(60, 36, 38),
ok_bg: Color::Rgb(48, 62, 78),
}
}
pub const fn terminal() -> Theme {
Theme {
name: "terminal",
bg: Color::Reset,
fg: Color::Reset,
dim: Color::Gray,
faint: Color::DarkGray,
red: Color::Red,
green: Color::Green,
yellow: Color::Yellow,
cyan: Color::Cyan,
magenta: Color::Magenta,
white: Color::White,
br_green: Color::LightGreen,
br_cyan: Color::LightCyan,
br_white: Color::White,
sel_bg: Color::Indexed(8),
warn_bg: Color::Reset,
err_bg: Color::Reset,
ok_bg: Color::Reset,
}
}
pub fn by_name(name: &str) -> Theme {
match name.to_lowercase().as_str() {
"terminal" | "system" | "ansi" => terminal(),
"light" => light(),
"ocean" => ocean(),
"solarized" => solarized(),
"dracula" => dracula(),
"nord" => nord(),
_ => dark(),
}
}
static ACTIVE: RwLock<Theme> = RwLock::new(dark());
pub fn active() -> Theme {
*ACTIVE.read().expect("theme lock poisoned")
}
pub fn set_by_name(name: &str) {
*ACTIVE.write().expect("theme lock poisoned") = by_name(name);
}
pub fn name() -> &'static str {
active().name
}
pub fn cycle() -> &'static str {
let current = name();
let i = THEME_NAMES.iter().position(|n| *n == current).unwrap_or(0);
let next = THEME_NAMES[(i + 1) % THEME_NAMES.len()];
set_by_name(next);
next
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn all_builtin_themes_load() {
for n in THEME_NAMES {
assert_eq!(by_name(n).name, *n);
}
}
#[test]
fn unknown_falls_back_to_dark() {
assert_eq!(by_name("nonsense").name, "dark");
assert_eq!(by_name("").name, "dark");
}
#[test]
fn dark_preserves_the_design_handoff() {
let t = dark();
assert_eq!(t.bg, Color::Rgb(0x0c, 0x14, 0x18));
assert_eq!(t.fg, Color::Rgb(0xc5, 0xd1, 0xd6));
assert_eq!(t.cyan, Color::Rgb(0x5f, 0xdc, 0xff));
assert_eq!(t.red, Color::Rgb(0xff, 0x78, 0x78));
assert_eq!(t.sel_bg, Color::Rgb(0x1a, 0x33, 0x40));
}
#[test]
fn terminal_theme_pins_no_rgb() {
let rendered = format!("{:?}", terminal());
assert!(
!rendered.contains("Rgb"),
"terminal theme must not pin RGB values: {rendered}"
);
}
#[test]
fn terminal_theme_never_paints_backgrounds() {
let t = terminal();
assert_eq!(t.bg, Color::Reset);
assert_eq!(t.fg, Color::Reset);
assert_eq!(t.warn_bg, Color::Reset);
assert_eq!(t.err_bg, Color::Reset);
assert_eq!(t.ok_bg, Color::Reset);
}
fn luminance(c: Color) -> Option<f64> {
let (r, g, b) = match c {
Color::Rgb(r, g, b) => (r, g, b),
_ => return None,
};
let ch = |v: u8| {
let s = v as f64 / 255.0;
if s <= 0.03928 {
s / 12.92
} else {
((s + 0.055) / 1.055).powf(2.4)
}
};
Some(0.2126 * ch(r) + 0.7152 * ch(g) + 0.0722 * ch(b))
}
fn contrast(a: Color, b: Color) -> Option<f64> {
let (la, lb) = (luminance(a)?, luminance(b)?);
let (hi, lo) = if la > lb { (la, lb) } else { (lb, la) };
Some((hi + 0.05) / (lo + 0.05))
}
#[test]
fn every_theme_has_readable_body_text() {
for n in THEME_NAMES {
let t = by_name(n);
let Some(ratio) = contrast(t.fg, t.bg) else {
continue;
};
assert!(
ratio >= 4.5,
"{n}: body text contrast {ratio:.1}:1 is below WCAG AA (4.5:1)"
);
}
}
#[test]
fn every_theme_keeps_muted_text_legible() {
for n in THEME_NAMES {
let t = by_name(n);
let Some(ratio) = contrast(t.dim, t.bg) else {
continue;
};
assert!(
ratio >= 3.0,
"{n}: dim text contrast {ratio:.1}:1 below 3:1"
);
}
}
#[test]
fn light_theme_emphasis_goes_darker_not_brighter() {
let t = light();
let bg = luminance(t.bg).unwrap();
assert!(luminance(t.br_white).unwrap() < bg);
assert!(luminance(t.white).unwrap() < bg);
}
#[test]
fn cycle_visits_every_theme_and_returns_home() {
let start = name();
let mut seen = Vec::new();
for _ in 0..THEME_NAMES.len() {
seen.push(cycle());
}
seen.sort_unstable();
let mut expected = THEME_NAMES.to_vec();
expected.sort_unstable();
assert_eq!(seen, expected);
assert_eq!(name(), start, "a full cycle must land back where it began");
}
#[test]
fn terminal_theme_accepts_common_aliases() {
for alias in ["terminal", "system", "ansi", "TERMINAL", "System"] {
assert_eq!(by_name(alias).name, "terminal", "alias {alias} failed");
}
}
}