steer_tui/tui/theme/
loader.rs1use super::{RawTheme, Theme, ThemeError};
4use directories::ProjectDirs;
5use std::fs;
6use std::path::{Path, PathBuf};
7
8const BUNDLED_THEMES: &[(&str, &str)] = &[
10 (
12 "catppuccin-mocha",
13 include_str!("../../../themes/catppuccin-mocha.toml"),
14 ),
15 ("dracula", include_str!("../../../themes/dracula.toml")),
16 (
17 "gruvbox-dark",
18 include_str!("../../../themes/gruvbox-dark.toml"),
19 ),
20 ("nord", include_str!("../../../themes/nord.toml")),
21 ("one-dark", include_str!("../../../themes/one-dark.toml")),
22 (
23 "solarized-dark",
24 include_str!("../../../themes/solarized-dark.toml"),
25 ),
26 (
27 "tokyo-night-storm",
28 include_str!("../../../themes/tokyo-night-storm.toml"),
29 ),
30 (
32 "catppuccin-latte",
33 include_str!("../../../themes/catppuccin-latte.toml"),
34 ),
35 (
36 "github-light",
37 include_str!("../../../themes/github-light.toml"),
38 ),
39 (
40 "gruvbox-light",
41 include_str!("../../../themes/gruvbox-light.toml"),
42 ),
43 ("one-light", include_str!("../../../themes/one-light.toml")),
44 (
45 "solarized-light",
46 include_str!("../../../themes/solarized-light.toml"),
47 ),
48];
49
50pub struct ThemeLoader {
52 search_paths: Vec<PathBuf>,
53}
54
55impl ThemeLoader {
56 pub fn new() -> Self {
58 let mut search_paths = Vec::new();
59
60 if let Some(proj_dirs) = ProjectDirs::from("", "", "steer") {
62 search_paths.push(proj_dirs.config_dir().join("themes"));
64
65 search_paths.push(proj_dirs.data_dir().join("themes"));
67 }
68
69 Self { search_paths }
70 }
71
72 pub fn add_search_path(&mut self, path: PathBuf) {
74 self.search_paths.push(path);
75 }
76
77 pub fn load_theme(&self, name: &str) -> Result<Theme, ThemeError> {
79 for (theme_name, theme_content) in BUNDLED_THEMES {
81 if theme_name == &name {
82 let raw_theme: RawTheme = toml::from_str(theme_content)?;
83 return raw_theme.into_theme();
84 }
85 }
86
87 let theme_file = self.find_theme_file(name)?;
89
90 let content = fs::read_to_string(&theme_file)?;
92 let raw_theme: RawTheme = toml::from_str(&content)?;
93
94 if raw_theme.name.to_lowercase() != name.to_lowercase() {
96 return Err(ThemeError::Validation(format!(
97 "Theme name mismatch: expected '{}', found '{}'",
98 name, raw_theme.name
99 )));
100 }
101
102 raw_theme.into_theme()
104 }
105
106 pub fn load_theme_from_path(&self, path: &Path) -> Result<Theme, ThemeError> {
108 let content = fs::read_to_string(path)?;
109 let raw_theme: RawTheme = toml::from_str(&content)?;
110 raw_theme.into_theme()
111 }
112
113 pub fn list_themes(&self) -> Vec<String> {
115 let mut themes = Vec::new();
116
117 for (theme_name, _) in BUNDLED_THEMES {
119 themes.push((*theme_name).to_string());
120 }
121
122 for search_path in &self.search_paths {
124 if let Ok(entries) = fs::read_dir(search_path) {
125 for entry in entries.flatten() {
126 if let Ok(metadata) = entry.metadata()
127 && metadata.is_file()
128 && let Some(name) = entry.file_name().to_str()
129 && name.ends_with(".toml")
130 {
131 let theme_name = name.trim_end_matches(".toml");
132 if !themes.contains(&theme_name.to_string()) {
133 themes.push(theme_name.to_string());
134 }
135 }
136 }
137 }
138 }
139
140 themes.sort();
141 themes
142 }
143
144 fn find_theme_file(&self, name: &str) -> Result<PathBuf, ThemeError> {
146 let filename = format!("{name}.toml");
147
148 for search_path in &self.search_paths {
149 let theme_path = search_path.join(&filename);
150 if theme_path.exists() {
151 return Ok(theme_path);
152 }
153 }
154
155 Err(ThemeError::Validation(format!(
156 "Theme '{name}' not found in bundled themes or filesystem"
157 )))
158 }
159}
160
161impl Default for ThemeLoader {
162 fn default() -> Self {
163 Self::new()
164 }
165}
166
167#[cfg(test)]
168mod tests {
169 use super::*;
170 use tempfile::TempDir;
171
172 #[test]
173 fn test_load_theme() {
174 let temp_dir = TempDir::new().unwrap();
175 let theme_path = temp_dir.path().join("test-theme.toml");
176
177 let theme_content = r##"
178name = "test-theme"
179
180[palette]
181background = "#282828"
182foreground = "#ebdbb2"
183
184[components]
185status_bar = { fg = "foreground", bg = "background" }
186
187[colors]
188bg = "#282828"
189fg = "#ebdbb2"
190
191[styles]
192border = { fg = "fg" }
193text = { fg = "fg" }
194"##;
195 let mut file = std::fs::File::create(&theme_path).unwrap();
196 std::io::Write::write_all(&mut file, theme_content.as_bytes()).unwrap();
197
198 let mut loader = ThemeLoader::new();
199 loader.add_search_path(temp_dir.path().to_path_buf());
200
201 let theme = loader.load_theme("test-theme").unwrap();
202 assert_eq!(theme.name, "test-theme");
203 }
204
205 #[test]
206 fn test_load_bundled_themes() {
207 let loader = ThemeLoader::new();
208
209 let themes_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("themes");
211 let entries = std::fs::read_dir(&themes_dir)
212 .unwrap_or_else(|e| panic!("Failed to read themes directory: {e}"));
213
214 let mut errors = Vec::new();
215
216 for entry in entries {
217 let entry = entry.unwrap();
218 let path = entry.path();
219
220 if path.extension().is_none_or(|ext| ext != "toml") {
222 continue;
223 }
224
225 let theme_id = path
226 .file_stem()
227 .and_then(|s| s.to_str())
228 .expect("Invalid theme filename");
229
230 match loader.load_theme(theme_id) {
231 Ok(theme) => {
232 if theme.name.is_empty() {
234 errors.push(format!("Theme '{theme_id}' has empty name"));
235 }
236 }
237 Err(e) => {
238 let error_msg = match &e {
240 ThemeError::ColorNotFound(color) => {
241 format!(
242 "Theme '{theme_id}' references undefined color '{color}'. Check that this color is defined in either the 'palette' or 'colors' section."
243 )
244 }
245 ThemeError::InvalidColor(msg) => {
246 format!("Theme '{theme_id}' has invalid color: {msg}")
247 }
248 ThemeError::Parse(parse_err) => {
249 format!("Theme '{theme_id}' has invalid TOML syntax: {parse_err}")
250 }
251 ThemeError::Validation(msg) => {
252 format!("Theme '{theme_id}' validation error: {msg}")
253 }
254 ThemeError::Io(io_err) => {
255 format!("Theme '{theme_id}' I/O error: {io_err}")
256 }
257 };
258 errors.push(error_msg);
259 }
260 }
261 }
262
263 assert!(
264 errors.is_empty(),
265 "Theme loading errors:\n\n{}",
266 errors.join("\n\n")
267 );
268 }
269
270 #[test]
271 fn test_bundled_themes_load_via_loader() {
272 let loader = ThemeLoader::new();
273 let mut errors = Vec::new();
274
275 for (theme_name, _) in BUNDLED_THEMES {
276 if let Err(e) = loader.load_theme(theme_name) {
277 errors.push(format!("Theme '{theme_name}' failed to load: {e}"));
278 }
279 }
280
281 assert!(
282 errors.is_empty(),
283 "Bundled theme loading errors:\n\n{}",
284 errors.join("\n\n")
285 );
286 }
287
288 #[test]
289 fn test_list_themes() {
290 let temp_dir = TempDir::new().unwrap();
291 let theme1_path = temp_dir.path().join("theme1.toml");
292 let theme2_path = temp_dir.path().join("theme2.toml");
293
294 let theme_content = r#"name = "Test"
295[colors]
296[styles]
297"#;
298 std::fs::write(&theme1_path, theme_content).unwrap();
299 std::fs::write(&theme2_path, theme_content).unwrap();
300
301 let mut loader = ThemeLoader::new();
302 loader.add_search_path(temp_dir.path().to_path_buf());
303
304 let themes = loader.list_themes();
305 assert!(themes.contains(&"theme1".to_string()));
306 assert!(themes.contains(&"theme2".to_string()));
307 assert!(themes.contains(&"catppuccin-mocha".to_string()));
309 assert!(themes.contains(&"catppuccin-latte".to_string()));
310 }
311
312 #[test]
313 fn test_theme_not_found() {
314 let loader = ThemeLoader::new();
315 let result = loader.load_theme("non-existent-theme");
316 assert!(matches!(result, Err(ThemeError::Validation(_))));
317 }
318
319 #[test]
320 fn test_bundled_themes_validation() {
321 use super::super::{ColorValue, Component, RawTheme};
322
323 for (theme_name, theme_content) in BUNDLED_THEMES {
325 let raw_theme: RawTheme = toml::from_str(theme_content)
326 .unwrap_or_else(|e| panic!("Failed to parse theme '{theme_name}': {e}"));
327
328 assert!(
330 raw_theme.palette.contains_key("background"),
331 "Theme '{theme_name}' missing 'background' in palette"
332 );
333 assert!(
334 raw_theme.palette.contains_key("foreground"),
335 "Theme '{theme_name}' missing 'foreground' in palette"
336 );
337
338 for (component_name, style) in &raw_theme.components {
340 if let Some(fg) = &style.fg {
341 match fg {
342 ColorValue::Direct(color) if color.starts_with('#') => {
343 panic!(
344 "Theme '{theme_name}' component '{component_name:?}' uses direct hex color '{color}' instead of palette reference"
345 );
346 }
347 _ => {} }
349 }
350 if let Some(bg) = &style.bg {
351 match bg {
352 ColorValue::Direct(color) if color.starts_with('#') => {
353 panic!(
354 "Theme '{theme_name}' component '{component_name:?}' uses direct hex color '{color}' instead of palette reference"
355 );
356 }
357 _ => {} }
359 }
360 }
361
362 let theme = raw_theme
364 .into_theme()
365 .unwrap_or_else(|e| panic!("Failed to convert theme '{theme_name}': {e}"));
366
367 let critical_components = [
369 Component::StatusBar,
370 Component::ErrorText,
371 Component::AssistantMessage,
372 Component::UserMessage,
373 Component::InputPanelBorder,
374 Component::InputPanelBackground,
375 Component::ChatListBorder,
376 Component::SelectionHighlight,
377 ];
378
379 for component in critical_components {
380 assert!(
381 theme.styles.contains_key(&component),
382 "Theme '{theme_name}' missing critical component: {component:?}"
383 );
384 }
385 }
386 }
387
388 #[test]
389 fn test_theme_palette_references_resolve() {
390 let loader = ThemeLoader::new();
391
392 for theme_name in ["catppuccin-mocha", "gruvbox-dark", "solarized-light"] {
394 let theme = loader
395 .load_theme(theme_name)
396 .unwrap_or_else(|e| panic!("Failed to load theme '{theme_name}': {e}"));
397
398 let status_bar = theme
400 .styles
401 .get(&super::super::Component::StatusBar)
402 .expect("StatusBar component missing");
403
404 assert!(
406 !(status_bar.fg.is_none() && status_bar.bg.is_none()),
407 "Theme '{theme_name}' StatusBar has no colors defined"
408 );
409 }
410 }
411}