mod diff;
mod palettes;
mod strop;
use ratatui::style::Color;
use ratatui::widgets::BorderType;
#[derive(Debug, Clone, Copy)]
pub struct Theme {
pub semantic: Semantic,
pub syntax: Syntax,
pub border: BorderShape,
pub nerd_font: bool,
pub separator: SeparatorShape,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SeparatorShape {
#[default]
Pipe,
Caret,
}
impl SeparatorShape {
pub fn parse(s: &str) -> Option<Self> {
match s.trim().to_ascii_lowercase().as_str() {
"pipe" => Some(Self::Pipe),
"caret" => Some(Self::Caret),
_ => None,
}
}
pub fn glyph(self) -> &'static str {
match self {
Self::Pipe => "|",
Self::Caret => "\u{276f}",
}
}
}
#[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 forge: Color,
pub forge_fg: Color,
pub badge_repo: Color,
pub badge_org: Color,
pub search_match: Color,
pub diff_add_fg: Color,
pub diff_del_fg: Color,
pub diff_add_bg: Color,
pub diff_del_bg: Color,
pub diff_add_strong: Color,
pub diff_del_strong: Color,
pub diff_band: Color,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Syntax {
pub keyword: Color,
pub string: Color,
pub comment: Color,
pub function: Color,
pub type_: Color,
pub constant: Color,
pub tag: Color,
pub namespace: Color,
pub invalid: Color,
pub number: Option<Color>,
pub attribute: Option<Color>,
pub operator: Option<Color>,
pub punctuation: Option<Color>,
pub variable: Option<Color>,
pub property: Option<Color>,
pub heading: Option<Color>,
pub link: Option<Color>,
pub code: Option<Color>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum BorderShape {
#[default]
Plain,
Rounded,
Thick,
Double,
}
impl BorderShape {
pub fn parse(s: &str) -> Option<Self> {
match s.trim().to_ascii_lowercase().as_str() {
"plain" => Some(Self::Plain),
"rounded" => Some(Self::Rounded),
"thick" => Some(Self::Thick),
"double" => Some(Self::Double),
_ => None,
}
}
pub fn border_type(self) -> BorderType {
match self {
Self::Plain => BorderType::Plain,
Self::Rounded => BorderType::Rounded,
Self::Thick => BorderType::Thick,
Self::Double => BorderType::Double,
}
}
}
impl Theme {
pub fn hone() -> Self {
Self::embedded("hone").expect("Hone is an embedded palette")
}
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),
forge: Color::from_u32(0x6c7086), forge_fg: Color::from_u32(0x11111b),
badge_repo: Color::from_u32(0x89b4fa), badge_org: Color::from_u32(0xfab387),
search_match: Color::from_u32(0xf9e2af), diff_add_fg: Color::from_u32(0xa9c47c),
diff_del_fg: Color::from_u32(0xe8677a),
diff_add_bg: Color::from_u32(0x1b2620),
diff_del_bg: Color::from_u32(0x2a1d20),
diff_add_strong: Color::from_u32(0x273a30),
diff_del_strong: Color::from_u32(0x402a2e),
diff_band: Color::from_u32(0x22242e),
},
border: BorderShape::default(),
nerd_font: false,
separator: SeparatorShape::Pipe,
syntax: Syntax {
keyword: Color::from_u32(0xcba6f7), string: Color::from_u32(0xa6e3a1), comment: Color::from_u32(0x6c7086), function: Color::from_u32(0x89b4fa), type_: Color::from_u32(0xf9e2af), constant: Color::from_u32(0xfab387), tag: Color::from_u32(0xf38ba8), namespace: Color::from_u32(0x94e2d5), invalid: Color::from_u32(0xf38ba8), number: None,
attribute: None,
operator: None,
punctuation: None,
variable: None,
property: None,
heading: None,
link: None,
code: None,
},
}
}
pub fn embedded(name: &str) -> Option<Self> {
palettes::EMBEDDED
.iter()
.find(|(n, _, _)| *n == name)
.map(|(_, roles, syntax)| {
let mut theme = Self::catppuccin_mocha();
for &(role, hex) in *roles {
set_role(&mut theme.semantic, role, Color::from_u32(hex));
}
if !roles.iter().any(|(role, _)| *role == "forge_fg") {
theme.semantic.forge_fg = theme.semantic.crust;
}
for &(role, hex) in *syntax {
set_syntax_role(&mut theme.syntax, role, Color::from_u32(hex));
}
if name != "catppuccin-mocha" {
diff::apply(&mut theme.semantic, |name| {
roles.iter().any(|(role, _)| *role == name)
});
}
theme
})
}
pub fn with_border(mut self, shape: BorderShape) -> Self {
self.border = shape;
self
}
pub fn border_type(&self) -> BorderType {
self.border.border_type()
}
pub fn with_nerd_font(mut self, on: bool) -> Self {
self.nerd_font = on;
self
}
pub fn with_separator(mut self, sep: &str) -> Self {
if let Some(shape) = SeparatorShape::parse(sep) {
self.separator = shape;
}
self
}
pub fn available_names() -> Vec<String> {
let mut names: Vec<String> = palettes::EMBEDDED
.iter()
.map(|(n, _, _)| n.to_string())
.collect();
if let Some(dir) =
rootle_provider::paths::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::embedded(name).unwrap_or_else(Self::hone);
let Some(dir) = rootle_provider::paths::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>,
#[serde(default)]
syntax: 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)
}
fn set_syntax_role(syn: &mut Syntax, role: &str, color: Color) {
match role {
"keyword" => syn.keyword = color,
"string" => syn.string = color,
"comment" => syn.comment = color,
"function" => syn.function = color,
"type" => syn.type_ = color,
"constant" => syn.constant = color,
"tag" => syn.tag = color,
"namespace" => syn.namespace = color,
"invalid" => syn.invalid = color,
"number" => syn.number = Some(color),
"attribute" => syn.attribute = Some(color),
"operator" => syn.operator = Some(color),
"punctuation" => syn.punctuation = Some(color),
"variable" => syn.variable = Some(color),
"property" => syn.property = Some(color),
"heading" => syn.heading = Some(color),
"link" => syn.link = Some(color),
"code" => syn.code = Some(color),
_ => {} }
}
fn set_role(sem: &mut Semantic, role: &str, color: Color) {
match role {
"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,
"forge" => sem.forge = color,
"forge_fg" => sem.forge_fg = color,
"badge_repo" => sem.badge_repo = color,
"badge_org" => sem.badge_org = color,
"search_match" => sem.search_match = color,
"diff_add_fg" => sem.diff_add_fg = color,
"diff_del_fg" => sem.diff_del_fg = color,
"diff_add_bg" => sem.diff_add_bg = color,
"diff_del_bg" => sem.diff_del_bg = color,
"diff_add_strong" => sem.diff_add_strong = color,
"diff_del_strong" => sem.diff_del_strong = color,
"diff_band" => sem.diff_band = color,
_ => {} }
}
impl ThemeOverrides {
fn apply(self, theme: &mut Theme) {
let forge_follows_crust = theme.semantic.forge_fg == theme.semantic.crust;
let diff_follows_browse = theme.semantic.diff_add_fg == theme.semantic.mode_browse;
for (role, hex) in &self.semantic {
if let Some(color) = parse_hex(hex) {
set_role(&mut theme.semantic, role, color);
}
}
if forge_follows_crust && !self.semantic.contains_key("forge_fg") {
theme.semantic.forge_fg = theme.semantic.crust;
}
if ["base", "surface0", "mode_browse", "error"]
.iter()
.any(|role| self.semantic.contains_key(*role))
{
diff::apply(&mut theme.semantic, |role| {
self.semantic.contains_key(role) || (role == "diff_add_fg" && !diff_follows_browse)
});
}
for (role, hex) in self.syntax {
if let Some(color) = parse_hex(&hex) {
set_syntax_role(&mut theme.syntax, &role, color);
}
}
}
}
#[cfg(test)]
mod tests;