1use std::fs;
2use std::path::{Path, PathBuf};
3
4use anyhow::{Context, Result};
5
6use super::builtin::BUILTINS;
7use super::file::ThemeFile;
8
9const EMBEDDED: &[(&str, &str, &str)] = &[
10 (
11 "catppuccin-mocha",
12 "Catppuccin Mocha",
13 include_str!("../../themes/catppuccin-mocha.toml"),
14 ),
15 (
16 "catppuccin-latte",
17 "Catppuccin Latte",
18 include_str!("../../themes/catppuccin-latte.toml"),
19 ),
20];
21
22#[derive(Debug, Clone)]
23pub struct ThemeEntry {
24 pub id: String,
25 pub label: String,
26 pub source: ThemeSource,
27}
28
29#[derive(Debug, Clone)]
30pub enum ThemeSource {
31 Builtin,
32 Embedded(&'static str),
33 File(PathBuf),
34}
35
36#[derive(Debug, Clone, Default)]
37pub struct ThemeCatalog {
38 entries: Vec<ThemeEntry>,
39}
40
41impl ThemeCatalog {
42 pub fn load() -> Self {
43 let mut catalog = Self::default();
44 for (id, label) in BUILTINS {
45 catalog.entries.push(ThemeEntry {
46 id: (*id).to_string(),
47 label: (*label).to_string(),
48 source: ThemeSource::Builtin,
49 });
50 }
51 for (id, label, toml) in EMBEDDED {
52 catalog.entries.push(ThemeEntry {
53 id: (*id).to_string(),
54 label: (*label).to_string(),
55 source: ThemeSource::Embedded(toml),
56 });
57 }
58 if let Ok(dir) = themes_dir() {
59 catalog.scan_dir(&dir);
60 }
61 if let Ok(extra) = std::env::var("VOID_THEMES_DIR") {
62 catalog.scan_dir(Path::new(&extra));
63 }
64 catalog
65 }
66
67 pub fn entries(&self) -> &[ThemeEntry] {
68 &self.entries
69 }
70
71 pub fn label(&self, id: &str) -> String {
72 self.entries
73 .iter()
74 .find(|entry| entry.id == id)
75 .map(|entry| entry.label.clone())
76 .unwrap_or_else(|| id.to_string())
77 }
78
79 pub fn next_id(&self, current: &str) -> String {
80 if self.entries.is_empty() {
81 return current.to_string();
82 }
83 let idx = self
84 .entries
85 .iter()
86 .position(|entry| entry.id == current)
87 .unwrap_or(0);
88 self.entries[(idx + 1) % self.entries.len()].id.clone()
89 }
90
91 pub fn resolve_entry(&self, id: &str) -> Result<&ThemeEntry> {
92 self.entries
93 .iter()
94 .find(|entry| entry.id == id)
95 .with_context(|| format!("unknown theme `{id}`"))
96 }
97
98 fn scan_dir(&mut self, dir: &Path) {
99 let Ok(read) = fs::read_dir(dir) else {
100 return;
101 };
102 let mut found = Vec::new();
103 for entry in read.flatten() {
104 let path = entry.path();
105 if path.extension().is_none_or(|ext| ext != "toml") {
106 continue;
107 }
108 let Ok(file) = ThemeFile::from_path(&path) else {
109 continue;
110 };
111 let id = path
112 .file_stem()
113 .and_then(|s| s.to_str())
114 .unwrap_or("")
115 .to_string();
116 if id.is_empty() || self.entries.iter().any(|e| e.id == id) {
117 continue;
118 }
119 found.push(ThemeEntry {
120 id,
121 label: file.name,
122 source: ThemeSource::File(path),
123 });
124 }
125 found.sort_by(|a, b| a.label.cmp(&b.label));
126 self.entries.extend(found);
127 }
128}
129
130pub fn themes_dir() -> Result<PathBuf> {
131 dirs::config_dir()
132 .map(|dir| dir.join("void").join("themes"))
133 .context("resolve config directory")
134}
135
136#[cfg(test)]
137mod tests {
138 use super::*;
139
140 #[test]
141 fn catalog_includes_catppuccin_embedded() {
142 let catalog = ThemeCatalog::load();
143 assert!(catalog.entries.iter().any(|e| e.id == "catppuccin-mocha"));
144 }
145
146 #[test]
147 fn cycles_through_entries() {
148 let catalog = ThemeCatalog::load();
149 let first = catalog.entries[0].id.clone();
150 let second = catalog.next_id(&first);
151 assert_ne!(first, second);
152 let wrap = catalog.next_id(catalog.entries.last().unwrap().id.as_str());
153 assert_eq!(wrap, first);
154 }
155}