Skip to main content

void/theme/
catalog.rs

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}