use ratatui::style::Color;
use std::path::Path;
#[derive(Clone, Copy, PartialEq, Debug)]
pub struct Theme {
pub user: Color,
pub assistant: Color,
pub tool: Color,
pub system: Color,
pub dim: Color,
pub accent: Color,
pub border: Color,
pub surface: Color,
pub base: Color,
pub elevated: Color,
pub accent2: Color,
pub success: Color,
pub warning: Color,
pub danger: Color,
pub info: Color,
pub on_accent: Color,
pub dark: bool,
}
impl Default for Theme {
fn default() -> Self {
theme_by_name("cordy")
}
}
impl Theme {
fn from_slots(s: &Slots) -> Theme {
let text = s.text;
Theme {
user: text,
assistant: blend(text, s.base, 0.16),
tool: s.tool,
system: s.muted,
dim: s.muted,
accent: s.accent,
border: s.border,
surface: s.surface,
base: s.base,
elevated: s.elevated,
accent2: s.accent2,
success: s.success,
warning: s.warning,
danger: s.danger,
info: s.accent2,
on_accent: if luma(s.accent) > 0.55 {
blend(s.base, Color::Rgb(0, 0, 0), 0.35)
} else {
Color::Rgb(255, 255, 255)
},
dark: s.dark,
}
}
pub fn with_overrides(mut self, c: &ColorOverrides) -> Self {
let set = |slot: &mut Color, hex: &Option<String>| {
if let Some(h) = hex
&& let Some(col) = parse_hex(h)
{
*slot = col;
}
};
set(&mut self.user, &c.user);
set(&mut self.assistant, &c.assistant);
set(&mut self.tool, &c.tool);
set(&mut self.system, &c.system);
set(&mut self.dim, &c.dim);
set(&mut self.accent, &c.accent);
set(&mut self.border, &c.border);
set(&mut self.surface, &c.surface);
set(&mut self.base, &c.base);
set(&mut self.elevated, &c.elevated);
set(&mut self.accent2, &c.accent2);
set(&mut self.success, &c.success);
set(&mut self.warning, &c.warning);
set(&mut self.danger, &c.danger);
self
}
pub fn ramp(&self, n: usize) -> Vec<Color> {
(0..n.max(1))
.map(|i| {
let t = if n <= 1 {
0.0
} else {
i as f32 / (n - 1) as f32
};
blend(self.accent, self.accent2, t)
})
.collect()
}
pub fn gauge(&self, pct: u64) -> Color {
match pct {
0..=74 => self.accent,
75..=89 => self.warning,
_ => self.danger,
}
}
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct ColorOverrides {
pub user: Option<String>,
pub assistant: Option<String>,
pub tool: Option<String>,
pub system: Option<String>,
pub dim: Option<String>,
pub accent: Option<String>,
pub border: Option<String>,
pub surface: Option<String>,
pub base: Option<String>,
pub elevated: Option<String>,
pub accent2: Option<String>,
pub success: Option<String>,
pub warning: Option<String>,
pub danger: Option<String>,
}
struct Slots {
base: Color,
surface: Color,
elevated: Color,
border: Color,
text: Color,
muted: Color,
accent: Color,
accent2: Color,
success: Color,
warning: Color,
danger: Color,
tool: Color,
dark: bool,
}
type Row = (
&'static str,
bool,
&'static str,
&'static str,
&'static str,
&'static str,
&'static str,
&'static str,
&'static str,
&'static str,
&'static str,
&'static str,
&'static str,
&'static str,
);
#[rustfmt::skip]
const BUILTIN: &[Row] = &[
("cordy", true, "#0e0f16","#171926","#1e2133","#2b2f45","#e6e8f2","#767c99","#7c6cff","#3fd8c7","#4ade80","#fbbf24","#f87171","#a78bfa"),
("cordy-light", false, "#fbfbfe","#f1f2f8","#e7e9f3","#d6d9e6","#1b1d29","#6c7288","#5b46e8","#0f9e8f","#15803d","#b45309","#dc2626","#7c3aed"),
("ember", true, "#120e0c","#1d1715","#26201d","#3a302b","#f0e6df","#9a8a80","#ff7a45","#ffc55c","#7cc47f","#ffb454","#ff5f56","#ffa657"),
("abyss", true, "#080b10","#101620","#161f2c","#22303f","#dce6f0","#6c8199","#38bdf8","#22d3ee","#34d399","#fbbf24","#fb7185","#60a5fa"),
("dark", true, "#14161c","#20232b","#272b35","#3a404e","#e1e6f0","#697282","#6e9bf5","#63d2c0","#68d391","#e8b339","#f0736a","#b4becd"),
("light", false, "#fafafc","#eef0f4","#e4e7ed","#d2d6de","#1e222a","#828a96","#1e64d2","#0e8f86","#137a3a","#a2600a","#c62b2b","#5a606c"),
("mono", true, "#0c0c0c","#1c1c1c","#242424","#3a3a3a","#e8e8e8","#7a7a7a","#e8e8e8","#a8a8a8","#c8c8c8","#c8c8c8","#e8e8e8","#a8a8a8"),
("mono-light", false, "#fcfcfc","#f0f0f0","#e6e6e6","#cccccc","#141414","#6a6a6a","#141414","#4a4a4a","#3a3a3a","#3a3a3a","#141414","#4a4a4a"),
("tokyonight", true, "#1a1b26","#24283b","#2f334d","#2f337d","#c0caf5","#565f89","#7aa2f7","#7dcfff","#9ece6a","#e0af68","#f7768e","#e0af68"),
("tokyonight-storm", true, "#24283b","#2f334d","#343a52","#3b4261","#c0caf5","#565f89","#7aa2f7","#7dcfff","#9ece6a","#e0af68","#f7768e","#bb9af7"),
("tokyonight-day", false,"#e1e2e7","#d0d5e3","#c4c8da","#a8aecb","#3760bf","#8990b3","#2e7de9","#007197","#587539","#8c6c3e","#f52a65","#b15c00"),
("catppuccin-mocha", true, "#1e1e2e","#313244","#45475a","#585b70","#cdd6f4","#6c7086","#89b4fa","#94e2d5","#a6e3a1","#f9e2af","#f38ba8","#fab387"),
("catppuccin-macchiato", true,"#24273a","#363a4f","#494d64","#5b6078","#cad3f5","#6e738d","#8aadf4","#8bd5ca","#a6da95","#eed49f","#ed8796","#f5a97f"),
("catppuccin-frappe",true, "#303446","#414559","#51576d","#626880","#c6d0f5","#737994","#8caaee","#81c8be","#a6d189","#e5c890","#e78284","#ef9f76"),
("catppuccin-latte", false,"#eff1f5","#e6e9ef","#dce0e8","#bcc0cc","#4c4f69","#8c8fa1","#1e66f5","#179299","#40a02b","#df8e1d","#d20f39","#fe640b"),
("dracula", true, "#282a36","#343746","#414458","#4d5066","#f8f8f2","#6272a4","#bd93f9","#8be9fd","#50fa7b","#f1fa8c","#ff5555","#ffb86c"),
("gruvbox-dark", true, "#282828","#32302f","#3c3836","#504945","#ebdbb2","#928374","#83a598","#8ec07c","#b8bb26","#fabd2f","#fb4934","#d3869b"),
("gruvbox-light",false, "#fbf1c7","#f2e5bc","#ebdbb2","#d5c4a1","#3c3836","#7c6f64","#076678","#427b58","#79740e","#b57614","#9d0006","#8f3f71"),
("nord", true, "#2e3440","#3b4252","#434c5e","#4c566a","#eceff4","#616e88","#88c0d0","#8fbcbb","#a3be8c","#ebcb8b","#bf616a","#d08770"),
("rose-pine", true, "#191724","#1f1d2e","#26233a","#403d52","#e0def4","#6e6a86","#c4a7e7","#9ccfd8","#31748f","#f6c177","#eb6f92","#f6c177"),
("rose-pine-moon",true, "#232136","#2a273f","#393552","#44415a","#e0def4","#6e6a86","#c4a7e7","#9ccfd8","#3e8fb0","#f6c177","#eb6f92","#ea9a97"),
("rose-pine-dawn",false,"#faf4ed","#fffaf3","#f2e9e1","#dfdad9","#575279","#9893a5","#907aa9","#56949f","#286983","#ea9d34","#b4637a","#d7827e"),
("everforest", true, "#2d353b","#343f44","#3d484d","#4f585e","#d3c6aa","#859289","#a7c080","#83c092","#a7c080","#dbbc7f","#e67e80","#d699b6"),
("everforest-light",false,"#fdf6e3","#f4f0d9","#efebd4","#ddd8be","#5c6a72","#939f91","#8da101","#35a77c","#8da101","#dfa000","#f85552","#df69ba"),
("kanagawa", true, "#1f1f28","#2a2a37","#363646","#54546d","#dcd7ba","#727169","#7e9cd8","#7aa89f","#98bb6c","#e6c384","#e82424","#ffa066"),
("kanagawa-dragon",true,"#181616","#282727","#393836","#54546d","#c5c9c5","#737c73","#8ba4b0","#8ea4a2","#87a987","#c4b28a","#c4746e","#b6927b"),
("solarized-dark",true, "#002b36","#073642","#0a4451","#0f5666","#eee8d5","#657b83","#268bd2","#2aa198","#859900","#b58900","#dc322f","#cb4b16"),
("solarized-light",false,"#fdf6e3","#eee8d5","#e4ddc8","#d3cbb7","#073642","#93a1a1","#268bd2","#2aa198","#859900","#b58900","#dc322f","#cb4b16"),
("monokai", true, "#272822","#31322c","#3b3c35","#4a4b44","#f8f8f2","#75715e","#66d9ef","#a6e22e","#a6e22e","#e6db74","#f92672","#fd971f"),
("one-dark", true, "#282c34","#31363f","#3a3f4b","#4b5263","#abb2bf","#5c6370","#61afef","#56b6c2","#98c379","#e5c07b","#e06c75","#c678dd"),
("one-light", false, "#fafafa","#eaeaeb","#e0e0e1","#c8c8c9","#383a42","#a0a1a7","#4078f2","#0184bc","#50a14f","#c18401","#e45649","#a626a4"),
("ayu-dark", true, "#0b0e14","#131721","#1b1f2b","#2d3444","#bfbdb6","#565b66","#e6b450","#59c2ff","#7fd962","#ffb454","#f26d78","#ffb454"),
("ayu-mirage", true, "#1f2430","#242936","#2b3140","#3d4657","#cccac2","#707a8c","#ffcc66","#73d0ff","#87d96c","#ffd173","#f28779","#ffad66"),
("ayu-light", false, "#fcfcfc","#f3f4f5","#eaebec","#d4d6d8","#5c6166","#8a9199","#ff9940","#399ee6","#6cbf43","#f2ae49","#f07171","#fa8d3e"),
("material", true, "#263238","#2e3c43","#37474f","#425b67","#eeffff","#546e7a","#82aaff","#89ddff","#c3e88d","#ffcb6b","#f07178","#f78c6c"),
("night-owl", true, "#011627","#0b2942","#0e3049","#1d3b53","#d6deeb","#637777","#82aaff","#7fdbca","#addb67","#ecc48d","#ef5350","#c792ea"),
("synthwave", true, "#241b2f","#2a2139","#34294f","#495495","#f8f8f2","#848bbd","#ff7edb","#36f9f6","#72f1b8","#fede5d","#fe4450","#ff8b39"),
("nightfox", true, "#192330","#212e3f","#29394f","#39506d","#cdcecf","#71839b","#719cd6","#63cdcf","#81b29a","#dbc074","#c94f6d","#f4a261"),
("oxocarbon", true, "#161616","#262626","#303030","#393939","#f2f4f8","#6f6f6f","#78a9ff","#3ddbd9","#42be65","#ff7eb6","#ee5396","#be95ff"),
("zenburn", true, "#3f3f3f","#4a4a4a","#545454","#6f6f6f","#dcdccc","#8f8f8f","#8cd0d3","#93e0e3","#7f9f7f","#f0dfaf","#dca3a3","#dfaf8f"),
("github-dark", true, "#0d1117","#161b22","#1c2128","#30363d","#c9d1d9","#8b949e","#58a6ff","#39c5cf","#3fb950","#d29922","#f85149","#a5d6ff"),
("github-light", false, "#ffffff","#f6f8fa","#eaeef2","#d0d7de","#24292f","#6e7781","#0969da","#1b7c83","#1a7f37","#9a6700","#cf222e","#0550ae"),
];
const ALIASES: &[(&str, &str)] = &[
("catppuccin", "catppuccin-mocha"),
("rosepine", "rose-pine"),
("gruvbox", "gruvbox-dark"),
("onedark", "one-dark"),
("ayu", "ayu-dark"),
("nightowl", "night-owl"),
];
fn row_to_theme(r: &Row) -> Theme {
let hex = |s: &str| parse_hex(s).unwrap_or(Color::Rgb(255, 0, 255));
Theme::from_slots(&Slots {
dark: r.1,
base: hex(r.2),
surface: hex(r.3),
elevated: hex(r.4),
border: hex(r.5),
text: hex(r.6),
muted: hex(r.7),
accent: hex(r.8),
accent2: hex(r.9),
success: hex(r.10),
warning: hex(r.11),
danger: hex(r.12),
tool: hex(r.13),
})
}
pub fn theme_by_name(name: &str) -> Theme {
let lower = name.to_ascii_lowercase();
let canonical = ALIASES
.iter()
.find(|(from, _)| *from == lower)
.map(|(_, to)| *to)
.unwrap_or(lower.as_str());
let row = BUILTIN
.iter()
.find(|r| r.0 == canonical)
.unwrap_or(&BUILTIN[0]);
row_to_theme(row)
}
#[derive(Clone, Default)]
pub struct ThemeRegistry {
entries: Vec<(String, Theme)>,
sig: u64,
}
impl ThemeRegistry {
pub fn builtin() -> Self {
ThemeRegistry {
entries: BUILTIN
.iter()
.map(|r| (r.0.to_string(), row_to_theme(r)))
.collect(),
sig: 0,
}
}
pub fn load(dirs: &[std::path::PathBuf]) -> Self {
let mut reg = ThemeRegistry::builtin();
for dir in dirs {
for (name, theme) in load_dir(dir) {
match reg.entries.iter_mut().find(|(n, _)| *n == name) {
Some(slot) => slot.1 = theme,
None => reg.entries.push((name, theme)),
}
}
}
reg.sig = signature(dirs);
reg
}
pub fn reload_if_changed(&mut self, dirs: &[std::path::PathBuf]) -> bool {
let sig = signature(dirs);
if sig == self.sig {
return false;
}
*self = ThemeRegistry::load(dirs);
true
}
pub fn names(&self) -> Vec<String> {
self.entries.iter().map(|(n, _)| n.clone()).collect()
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn at(&self, i: usize) -> Theme {
self.entries
.get(i.min(self.entries.len().saturating_sub(1)))
.map(|(_, t)| *t)
.unwrap_or_default()
}
pub fn name_at(&self, i: usize) -> &str {
self.entries
.get(i.min(self.entries.len().saturating_sub(1)))
.map(|(n, _)| n.as_str())
.unwrap_or("cordy")
}
pub fn index_of(&self, name: &str) -> Option<usize> {
let lower = name.to_ascii_lowercase();
let canonical = ALIASES
.iter()
.find(|(from, _)| *from == lower)
.map(|(_, to)| *to)
.unwrap_or(lower.as_str());
self.entries.iter().position(|(n, _)| n == canonical)
}
pub fn get(&self, name: &str) -> Option<Theme> {
self.index_of(name).map(|i| self.at(i))
}
}
fn load_dir(dir: &Path) -> Vec<(String, Theme)> {
let Ok(rd) = std::fs::read_dir(dir) else {
return Vec::new();
};
let mut out = Vec::new();
for entry in rd.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
let Ok(text) = std::fs::read_to_string(&path) else {
continue;
};
let Ok(json) = serde_json::from_str::<serde_json::Value>(&text) else {
continue;
};
let stem = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("custom")
.to_string();
let name = json
.get("name")
.and_then(|v| v.as_str())
.unwrap_or(&stem)
.to_ascii_lowercase();
out.push((name, theme_from_json(&json)));
}
out.sort_by(|a, b| a.0.cmp(&b.0));
out
}
fn theme_from_json(json: &serde_json::Value) -> Theme {
let base_name = json
.get("extends")
.and_then(|v| v.as_str())
.unwrap_or("cordy");
let mut t = theme_by_name(base_name);
if let Some(dark) = json.get("dark").and_then(|v| v.as_bool()) {
t.dark = dark;
}
let pick = |key: &str| json.get(key).and_then(|v| v.as_str()).and_then(parse_hex);
if let Some(c) = pick("base") {
t.base = c;
}
if let Some(c) = pick("surface") {
t.surface = c;
}
if let Some(c) = pick("elevated") {
t.elevated = c;
}
if let Some(c) = pick("border") {
t.border = c;
}
if let Some(c) = pick("text") {
t.user = c;
t.assistant = blend(c, t.base, 0.16);
}
if let Some(c) = pick("muted") {
t.system = c;
t.dim = c;
}
if let Some(c) = pick("accent") {
t.accent = c;
t.on_accent = if luma(c) > 0.55 {
blend(t.base, Color::Rgb(0, 0, 0), 0.35)
} else {
Color::Rgb(255, 255, 255)
};
}
if let Some(c) = pick("accent2") {
t.accent2 = c;
t.info = c;
}
for (key, slot) in [
("success", &mut t.success),
("warning", &mut t.warning),
("danger", &mut t.danger),
("tool", &mut t.tool),
("user", &mut t.user),
("assistant", &mut t.assistant),
("system", &mut t.system),
("dim", &mut t.dim),
("info", &mut t.info),
] {
if let Some(c) = pick(key) {
*slot = c;
}
}
t
}
fn signature(dirs: &[std::path::PathBuf]) -> u64 {
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
let mut mix = |b: &[u8]| {
for byte in b {
h ^= *byte as u64;
h = h.wrapping_mul(0x1000_0000_01b3);
}
};
for dir in dirs {
let Ok(rd) = std::fs::read_dir(dir) else {
continue;
};
let mut rows: Vec<(String, u64, u64)> = Vec::new();
for e in rd.flatten() {
let p = e.path();
if p.extension().and_then(|x| x.to_str()) != Some("json") {
continue;
}
let meta = e.metadata().ok();
let mtime = meta
.as_ref()
.and_then(|m| m.modified().ok())
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs())
.unwrap_or(0);
let size = meta.as_ref().map(|m| m.len()).unwrap_or(0);
rows.push((p.to_string_lossy().to_string(), mtime, size));
}
rows.sort();
for (name, mtime, size) in rows {
mix(name.as_bytes());
mix(&mtime.to_le_bytes());
mix(&size.to_le_bytes());
}
}
h
}
pub fn parse_hex(s: &str) -> Option<Color> {
let h = s.trim().trim_start_matches('#');
if h.len() != 6 {
return None;
}
let r = u8::from_str_radix(&h[0..2], 16).ok()?;
let g = u8::from_str_radix(&h[2..4], 16).ok()?;
let b = u8::from_str_radix(&h[4..6], 16).ok()?;
Some(Color::Rgb(r, g, b))
}
pub fn blend(a: Color, b: Color, t: f32) -> Color {
let (Color::Rgb(ar, ag, ab), Color::Rgb(br, bg, bb)) = (a, b) else {
return a;
};
let t = t.clamp(0.0, 1.0);
let mix = |x: u8, y: u8| (x as f32 + (y as f32 - x as f32) * t).round() as u8;
Color::Rgb(mix(ar, br), mix(ag, bg), mix(ab, bb))
}
pub fn luma(c: Color) -> f32 {
match c {
Color::Rgb(r, g, b) => (0.2126 * r as f32 + 0.7152 * g as f32 + 0.0722 * b as f32) / 255.0,
_ => 0.5,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolves_named_themes_and_aliases() {
assert_eq!(theme_by_name("mono").user, Color::Rgb(0xe8, 0xe8, 0xe8));
assert_eq!(theme_by_name("nope").accent, theme_by_name("cordy").accent);
assert_ne!(theme_by_name("light").user, theme_by_name("mono").user);
assert_eq!(
theme_by_name("tokyonight").accent,
Color::Rgb(122, 162, 247)
);
assert_eq!(theme_by_name("nord").accent, Color::Rgb(136, 192, 208));
assert_eq!(
theme_by_name("catppuccin").accent,
theme_by_name("catppuccin-mocha").accent
);
assert_eq!(
theme_by_name("rosepine").base,
theme_by_name("rose-pine").base
);
}
#[test]
fn every_builtin_parses_and_names_are_unique() {
let reg = ThemeRegistry::builtin();
assert!(reg.len() >= 36, "expected 36+ themes, got {}", reg.len());
let mut names = reg.names();
names.sort();
let before = names.len();
names.dedup();
assert_eq!(before, names.len(), "duplicate theme name");
for i in 0..reg.len() {
let t = reg.at(i);
assert_ne!(t.base, Color::Rgb(255, 0, 255), "{}", reg.name_at(i));
assert_ne!(t.accent, Color::Rgb(255, 0, 255), "{}", reg.name_at(i));
}
}
#[test]
fn hex_parsing_and_overrides() {
assert_eq!(parse_hex("#ff8800"), Some(Color::Rgb(255, 136, 0)));
assert_eq!(parse_hex("00ff00"), Some(Color::Rgb(0, 255, 0)));
assert_eq!(parse_hex("bad"), None);
let over = ColorOverrides {
accent: Some("#010203".into()),
..Default::default()
};
let t = theme_by_name("dark").with_overrides(&over);
assert_eq!(t.accent, Color::Rgb(1, 2, 3));
assert_eq!(t.user, theme_by_name("dark").user); }
#[test]
fn custom_json_theme_extends_a_builtin() {
let json: serde_json::Value = serde_json::from_str(
r##"{"name":"mine","extends":"nord","accent":"#ff0000","text":"#ffffff"}"##,
)
.unwrap();
let t = theme_from_json(&json);
assert_eq!(t.accent, Color::Rgb(255, 0, 0));
assert_eq!(t.user, Color::Rgb(255, 255, 255));
assert_eq!(t.base, theme_by_name("nord").base); }
#[test]
fn custom_themes_shadow_builtins_and_hot_reload() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("nord.json");
std::fs::write(&path, r##"{"accent":"#123456"}"##).unwrap();
let dirs = vec![dir.path().to_path_buf()];
let mut reg = ThemeRegistry::load(&dirs);
assert_eq!(
reg.get("nord").unwrap().accent,
Color::Rgb(0x12, 0x34, 0x56)
);
assert_eq!(reg.len(), ThemeRegistry::builtin().len()); assert!(!reg.reload_if_changed(&dirs));
std::fs::write(dir.path().join("zzz.json"), r##"{"accent":"#00ff00"}"##).unwrap();
assert!(reg.reload_if_changed(&dirs));
assert_eq!(reg.get("zzz").unwrap().accent, Color::Rgb(0, 255, 0));
}
#[test]
fn gauge_escalates_and_ramp_spans_both_accents() {
let t = theme_by_name("cordy");
assert_eq!(t.gauge(10), t.accent);
assert_eq!(t.gauge(80), t.warning);
assert_eq!(t.gauge(95), t.danger);
let ramp = t.ramp(5);
assert_eq!(ramp.first(), Some(&t.accent));
assert_eq!(ramp.last(), Some(&t.accent2));
}
}