use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Theme {
pub name: String,
pub author: String,
pub category: String,
pub background: String,
pub foreground: String,
pub cursor: String,
#[serde(skip)]
pub colors: [u32; 16],
#[serde(rename = "colors")]
pub raw_colors: Vec<String>,
}
pub struct ThemeRegistry {
pub themes: Vec<Theme>,
pub selected_index: usize,
}
impl ThemeRegistry {
pub fn append_cybercore_themes(&mut self) -> Option<String> {
let catalog = cybercore::theme::ThemeCatalog::load().ok()?;
let active = catalog.active_id().to_string();
for (id, entry) in catalog.iter() {
let palette = entry.document.palette_for(catalog.active_appearance());
let slots = [
&palette.bg,
&palette.red,
&palette.acid_green,
&palette.orange,
&palette.purple,
&palette.hot_pink,
&palette.cyan,
&palette.white,
&palette.muted,
&palette.red,
&palette.acid_green,
&palette.orange,
&palette.purple,
&palette.hot_pink,
&palette.cyan,
&palette.white,
];
let raw_colors: Vec<String> = slots.iter().map(|color| format!("#{color}")).collect();
let mut colors = [0u32; 16];
for (index, value) in raw_colors.iter().enumerate() {
colors[index] = u32::from_str_radix(value.trim_start_matches('#'), 16).unwrap_or(0);
}
let theme = Theme {
name: id.to_string(),
author: entry.document.metadata.author.clone(),
category: entry.document.metadata.family.clone(),
background: format!("#{}", palette.bg),
foreground: format!("#{}", palette.white),
cursor: format!("#{}", palette.acid_green),
colors,
raw_colors,
};
if let Some(existing) = self.themes.iter_mut().find(|theme| theme.name == id) {
*existing = theme;
} else {
self.themes.push(theme);
}
}
self.themes.sort_by(|a, b| a.name.cmp(&b.name));
Some(active)
}
pub fn load_from_dir<P: AsRef<Path>>(base_dir: P) -> Self {
let base_dir = base_dir.as_ref();
let mut themes = Vec::new();
if let Ok(entries) = fs::read_dir(base_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_file() && path.extension().is_some_and(|e| e == "conf") {
if let Ok(theme) = Self::parse_theme_file(&path) {
themes.push(theme);
}
}
}
}
if let Ok(categories) = fs::read_dir(base_dir) {
for cat_entry in categories.flatten() {
if cat_entry.path().is_dir() {
if let Ok(sub_folders) = fs::read_dir(cat_entry.path()) {
for folder_entry in sub_folders.flatten() {
if folder_entry.path().is_dir() {
if let Ok(files) = fs::read_dir(folder_entry.path()) {
for file_entry in files.flatten() {
let file_path = file_entry.path();
if file_path.is_file()
&& file_path.extension().is_some_and(|e| e == "json")
{
if let Ok(theme) = Self::parse_json_theme(&file_path) {
themes.push(theme);
}
}
}
}
}
}
}
}
}
}
ThemeRegistry {
themes,
selected_index: 0,
}
}
fn parse_theme_file(path: &Path) -> Result<Theme, Box<dyn std::error::Error>> {
let raw = fs::read_to_string(path)?;
let mut colors = [0u32; 16];
let mut raw_colors: Vec<String> = vec![String::new(); 16];
let mut background: Option<String> = None;
let mut foreground: Option<String> = None;
let mut cursor: Option<String> = None;
for line in raw.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let Some((key, value)) = line.split_once(char::is_whitespace) else {
continue;
};
let key = key.trim();
let value = value.trim();
if let Some(index_str) = key.strip_prefix("color") {
let Ok(index) = index_str.parse::<usize>() else {
continue;
};
if index >= 16 {
continue;
}
let hex = value.trim_start_matches('#');
if let Ok(val) = u32::from_str_radix(hex, 16) {
colors[index] = val;
raw_colors[index] = value.to_string();
}
continue;
}
match key {
"background" => background = Some(value.to_string()),
"foreground" => foreground = Some(value.to_string()),
"cursor" => cursor = Some(value.to_string()),
_ => {} }
}
let name = path
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "unnamed".to_string());
let background = background.unwrap_or_else(|| raw_colors[0].clone());
let foreground = foreground.unwrap_or_else(|| raw_colors[7].clone());
let cursor = cursor.unwrap_or_else(|| foreground.clone());
Ok(Theme {
name,
author: "built-in".to_string(),
category: "built-in".to_string(),
background,
foreground,
cursor,
colors,
raw_colors,
})
}
fn parse_json_theme(path: &Path) -> Result<Theme, Box<dyn std::error::Error>> {
let raw_json = fs::read_to_string(path)?;
let mut theme: Theme = serde_json::from_str(&raw_json)?;
let mut color_buffer = [0u32; 16];
for (i, hex_str) in theme.raw_colors.iter().enumerate().take(16) {
let clean_hex = hex_str.trim_start_matches('#');
if let Ok(val) = u32::from_str_radix(clean_hex, 16) {
color_buffer[i] = val;
}
}
theme.colors = color_buffer;
Ok(theme)
}
pub fn create_new_theme_template<P: AsRef<Path>>(
dir_path: P,
raw_name: &str,
) -> Result<PathBuf, std::io::Error> {
let target_dir = dir_path.as_ref();
fs::create_dir_all(target_dir)?;
let file_slug = raw_name
.chars()
.filter(|c| c.is_alphanumeric() || *c == '_')
.collect::<String>();
if file_slug.is_empty() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"Invalid theme name",
));
}
let theme_path = target_dir.join(format!("{}.json", file_slug));
let template_content = format!(
"{{\n\
\x20\x20\"name\": \"{}\",\n\
\x20\x20\"author\": \"Brett\",\n\
\x20\x20\"category\": \"sub-cyber\",\n\
\x20\x20\"background\": \"#0a0a0f\",\n\
\x20\x20\"foreground\": \"#bcbcbc\",\n\
\x20\x20\"cursor\": \"#00ffaa\",\n\
\x20\x20\"colors\": [\n\
\x20\x20\x20\x20\"#0a0a0f\", \"#ff0055\", \"#00ffaa\", \"#f3e600\",\n\
\x20\x20\x20\x20\"#00bfff\", \"#de00fe\", \"#00ffff\", \"#bcbcbc\",\n\
\x20\x20\x20\x20\"#222233\", \"#ff3377\", \"#33ffbb\", \"#ffff55\",\n\
\x20\x20\x20\x20\"#33ccff\", \"#e533ff\", \"#55ffff\", \"#ffffff\"\n\
\x20\x20]\n\
}}",
raw_name
);
fs::write(&theme_path, template_content)?;
Ok(theme_path)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_real_kitty_conf_syntax() {
let dir = std::env::temp_dir().join(format!("cyberterm_theme_test_{}", std::process::id()));
fs::create_dir_all(&dir).unwrap();
let path = dir.join("test_kitty.conf");
fs::write(
&path,
"# a comment line, and a blank line below\n\
\n\
background #1e1f29\n\
foreground #f8f8f2\n\
cursor #ff79c6\n\
selection_background #444444\n\
color0 #1e1f29\n\
color1 #ff5555\n\
color7 #f8f8f2\n\
color15 #ffffff\n",
)
.unwrap();
let theme = ThemeRegistry::parse_theme_file(&path).unwrap();
assert_eq!(theme.name, "test_kitty");
assert_eq!(theme.background, "#1e1f29");
assert_eq!(theme.foreground, "#f8f8f2");
assert_eq!(theme.cursor, "#ff79c6"); assert_eq!(theme.colors[0], 0x1e1f29);
assert_eq!(theme.colors[1], 0xff5555);
assert_eq!(theme.colors[15], 0xffffff);
assert_eq!(theme.colors[8], 0);
fs::remove_dir_all(&dir).ok();
}
#[test]
fn falls_back_to_ansi_convention_when_named_keys_missing() {
let dir =
std::env::temp_dir().join(format!("cyberterm_theme_test2_{}", std::process::id()));
fs::create_dir_all(&dir).unwrap();
let path = dir.join("no_named_keys.conf");
fs::write(&path, "color0 #050505\ncolor7 #e0e0e0\n").unwrap();
let theme = ThemeRegistry::parse_theme_file(&path).unwrap();
assert_eq!(theme.background, "#050505"); assert_eq!(theme.foreground, "#e0e0e0"); assert_eq!(theme.cursor, "#e0e0e0");
fs::remove_dir_all(&dir).ok();
}
#[test]
fn load_from_dir_finds_flat_conf_files() {
let dir =
std::env::temp_dir().join(format!("cyberterm_theme_test3_{}", std::process::id()));
fs::create_dir_all(&dir).unwrap();
fs::write(
dir.join("one.conf"),
"background #000000\nforeground #ffffff\n",
)
.unwrap();
fs::write(dir.join("not_a_theme.txt"), "background #ff00ff\n").unwrap();
let registry = ThemeRegistry::load_from_dir(&dir);
assert_eq!(registry.themes.len(), 1);
assert_eq!(registry.themes[0].name, "one");
fs::remove_dir_all(&dir).ok();
}
}