use ratatui::style::Color;
#[derive(Debug, Clone, Copy)]
pub struct Theme {
pub semantic: Semantic,
}
#[derive(Debug, Clone, Copy)]
pub struct Semantic {
pub base: Color,
pub mantle: Color,
pub crust: Color,
pub surface0: Color,
pub surface2: Color,
pub overlay0: Color,
pub text: Color,
pub subtext0: Color,
pub border_focused: Color,
pub border_unfocused: Color,
pub directory: Color,
pub file: Color,
pub selection_bg: Color,
pub selection_fg: Color,
pub hint: Color,
pub error: Color,
pub warning: Color,
pub mode_browse: Color,
pub mode_search: Color,
pub mode_insert: Color,
pub mode_normal: Color,
pub mode_leader: Color,
pub mode_visual: Color,
pub badge_repo: Color,
pub badge_org: Color,
pub search_match: Color,
}
impl Theme {
pub fn catppuccin_mocha() -> Self {
Theme {
semantic: Semantic {
crust: Color::from_u32(0x11111b),
mantle: Color::from_u32(0x181825),
base: Color::from_u32(0x1e1e2e),
surface0: Color::from_u32(0x313244),
surface2: Color::from_u32(0x585b70),
overlay0: Color::from_u32(0x6c7086),
subtext0: Color::from_u32(0xa6adc8),
text: Color::from_u32(0xcdd6f4),
border_focused: Color::from_u32(0x89b4fa), border_unfocused: Color::from_u32(0x585b70), directory: Color::from_u32(0x89b4fa), file: Color::from_u32(0xcdd6f4), selection_bg: Color::from_u32(0x313244), selection_fg: Color::from_u32(0x89b4fa), hint: Color::from_u32(0xa6adc8), error: Color::from_u32(0xf38ba8), warning: Color::from_u32(0xf9e2af),
mode_browse: Color::from_u32(0xa6e3a1), mode_search: Color::from_u32(0xf9e2af), mode_insert: Color::from_u32(0x94e2d5), mode_normal: Color::from_u32(0x89b4fa), mode_leader: Color::from_u32(0xfab387), mode_visual: Color::from_u32(0xf5c2e7),
badge_repo: Color::from_u32(0x89b4fa), badge_org: Color::from_u32(0xfab387),
search_match: Color::from_u32(0xf9e2af), },
}
}
pub fn available_names() -> Vec<String> {
let mut names = vec!["catppuccin-mocha".to_string()];
if let Some(dir) = dirs::config_dir().map(|d| d.join("rootle").join("themes"))
&& let Ok(entries) = std::fs::read_dir(dir)
{
for entry in entries.flatten() {
if entry.path().extension().is_some_and(|e| e == "toml")
&& let Some(stem) = entry.path().file_stem().and_then(|s| s.to_str())
{
names.push(stem.to_string());
}
}
}
names.sort();
names.dedup();
names
}
pub fn load(name: &str) -> Self {
let mut theme = Self::catppuccin_mocha();
let Some(dir) = dirs::config_dir() else {
return theme;
};
let path = dir
.join("rootle")
.join("themes")
.join(format!("{name}.toml"));
let Ok(text) = std::fs::read_to_string(&path) else {
return theme;
};
let Ok(overrides) = toml::from_str::<ThemeOverrides>(&text) else {
return theme;
};
overrides.apply(&mut theme);
theme
}
}
#[derive(Debug, Default, serde::Deserialize)]
struct ThemeOverrides {
#[serde(default)]
semantic: std::collections::HashMap<String, String>,
}
fn parse_hex(s: &str) -> Option<Color> {
let hex = s.strip_prefix('#').unwrap_or(s);
u32::from_str_radix(hex, 16).ok().map(Color::from_u32)
}
impl ThemeOverrides {
fn apply(self, theme: &mut Theme) {
let sem = &mut theme.semantic;
for (role, hex) in self.semantic {
let Some(color) = parse_hex(&hex) else {
continue;
};
match role.as_str() {
"crust" => sem.crust = color,
"mantle" => sem.mantle = color,
"base" => sem.base = color,
"surface0" => sem.surface0 = color,
"surface2" => sem.surface2 = color,
"overlay0" => sem.overlay0 = color,
"subtext0" => sem.subtext0 = color,
"text" => sem.text = color,
"border_focused" => sem.border_focused = color,
"border_unfocused" => sem.border_unfocused = color,
"directory" => sem.directory = color,
"file" => sem.file = color,
"selection_bg" => sem.selection_bg = color,
"selection_fg" => sem.selection_fg = color,
"hint" => sem.hint = color,
"error" => sem.error = color,
"warning" => sem.warning = color,
"mode_browse" => sem.mode_browse = color,
"mode_search" => sem.mode_search = color,
"mode_insert" => sem.mode_insert = color,
"mode_normal" => sem.mode_normal = color,
"mode_leader" => sem.mode_leader = color,
"mode_visual" => sem.mode_visual = color,
"badge_repo" => sem.badge_repo = color,
"badge_org" => sem.badge_org = color,
"search_match" => sem.search_match = color,
_ => {} }
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hex_parsing() {
assert_eq!(parse_hex("#89b4fa"), Some(Color::from_u32(0x89b4fa)));
assert_eq!(parse_hex("89b4fa"), Some(Color::from_u32(0x89b4fa)));
assert_eq!(parse_hex("nope"), None);
}
#[test]
fn overrides_merge_onto_mocha() {
let toml = r##"
[semantic]
border_focused = "#ff0000"
unknown_role = "#00ff00"
"##;
let overrides: ThemeOverrides = toml::from_str(toml).unwrap();
let mut theme = Theme::catppuccin_mocha();
overrides.apply(&mut theme);
assert_eq!(theme.semantic.border_focused, Color::from_u32(0xff0000));
assert_eq!(theme.semantic.directory, Color::from_u32(0x89b4fa));
}
}