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 if metadata.is_file() {
128 if let Some(name) = entry.file_name().to_str() {
129 if name.ends_with(".toml") {
130 let theme_name = name.trim_end_matches(".toml");
131 if !themes.contains(&theme_name.to_string()) {
132 themes.push(theme_name.to_string());
133 }
134 }
135 }
136 }
137 }
138 }
139 }
140 }
141
142 themes.sort();
143 themes
144 }
145
146 fn find_theme_file(&self, name: &str) -> Result<PathBuf, ThemeError> {
148 let filename = format!("{name}.toml");
149
150 for search_path in &self.search_paths {
151 let theme_path = search_path.join(&filename);
152 if theme_path.exists() {
153 return Ok(theme_path);
154 }
155 }
156
157 Err(ThemeError::Validation(format!(
158 "Theme '{name}' not found in bundled themes or filesystem"
159 )))
160 }
161}
162
163impl Default for ThemeLoader {
164 fn default() -> Self {
165 Self::new()
166 }
167}
168
169#[cfg(test)]
170mod tests {
171 use super::*;
172 use tempfile::TempDir;
173
174 #[test]
175 fn test_load_theme() {
176 let temp_dir = TempDir::new().unwrap();
177 let theme_path = temp_dir.path().join("test-theme.toml");
178
179 let theme_content = r##"
180name = "test-theme"
181
182[palette]
183background = "#282828"
184foreground = "#ebdbb2"
185
186[components]
187status_bar = { fg = "foreground", bg = "background" }
188
189[colors]
190bg = "#282828"
191fg = "#ebdbb2"
192
193[styles]
194border = { fg = "fg" }
195text = { fg = "fg" }
196"##;
197 let mut file = std::fs::File::create(&theme_path).unwrap();
198 std::io::Write::write_all(&mut file, theme_content.as_bytes()).unwrap();
199
200 let mut loader = ThemeLoader::new();
201 loader.add_search_path(temp_dir.path().to_path_buf());
202
203 let theme = loader.load_theme("test-theme").unwrap();
204 assert_eq!(theme.name, "test-theme");
205 }
206
207 #[test]
208 fn test_load_bundled_themes() {
209 let loader = ThemeLoader::new();
210
211 let themes_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("themes");
213 let entries = std::fs::read_dir(&themes_dir)
214 .unwrap_or_else(|e| panic!("Failed to read themes directory: {e}"));
215
216 let mut errors = Vec::new();
217
218 for entry in entries {
219 let entry = entry.unwrap();
220 let path = entry.path();
221
222 if path.extension().is_none_or(|ext| ext != "toml") {
224 continue;
225 }
226
227 let theme_id = path
228 .file_stem()
229 .and_then(|s| s.to_str())
230 .expect("Invalid theme filename");
231
232 match loader.load_theme(theme_id) {
233 Ok(theme) => {
234 if theme.name.is_empty() {
236 errors.push(format!("Theme '{theme_id}' has empty name"));
237 }
238 }
239 Err(e) => {
240 let error_msg = match &e {
242 ThemeError::ColorNotFound(color) => {
243 format!(
244 "Theme '{theme_id}' references undefined color '{color}'. Check that this color is defined in either the 'palette' or 'colors' section."
245 )
246 }
247 ThemeError::InvalidColor(msg) => {
248 format!("Theme '{theme_id}' has invalid color: {msg}")
249 }
250 ThemeError::Parse(parse_err) => {
251 format!("Theme '{theme_id}' has invalid TOML syntax: {parse_err}")
252 }
253 ThemeError::Validation(msg) => {
254 format!("Theme '{theme_id}' validation error: {msg}")
255 }
256 ThemeError::Io(io_err) => {
257 format!("Theme '{theme_id}' I/O error: {io_err}")
258 }
259 };
260 errors.push(error_msg);
261 }
262 }
263 }
264
265 if !errors.is_empty() {
266 panic!("Theme loading errors:\n\n{}", errors.join("\n\n"));
267 }
268 }
269
270 #[test]
271 fn test_list_themes() {
272 let temp_dir = TempDir::new().unwrap();
273 let theme1_path = temp_dir.path().join("theme1.toml");
274 let theme2_path = temp_dir.path().join("theme2.toml");
275
276 let theme_content = r#"name = "Test"
277[colors]
278[styles]
279"#;
280 std::fs::write(&theme1_path, theme_content).unwrap();
281 std::fs::write(&theme2_path, theme_content).unwrap();
282
283 let mut loader = ThemeLoader::new();
284 loader.add_search_path(temp_dir.path().to_path_buf());
285
286 let themes = loader.list_themes();
287 assert!(themes.contains(&"theme1".to_string()));
288 assert!(themes.contains(&"theme2".to_string()));
289 assert!(themes.contains(&"catppuccin-mocha".to_string()));
291 assert!(themes.contains(&"catppuccin-latte".to_string()));
292 }
293
294 #[test]
295 fn test_theme_not_found() {
296 let loader = ThemeLoader::new();
297 let result = loader.load_theme("non-existent-theme");
298 assert!(matches!(result, Err(ThemeError::Validation(_))));
299 }
300
301 #[test]
302 fn test_bundled_themes_validation() {
303 use super::super::{ColorValue, Component, RawTheme};
304
305 for (theme_name, theme_content) in BUNDLED_THEMES {
307 let raw_theme: RawTheme = toml::from_str(theme_content)
308 .unwrap_or_else(|e| panic!("Failed to parse theme '{theme_name}': {e}"));
309
310 assert!(
312 raw_theme.palette.contains_key("background"),
313 "Theme '{theme_name}' missing 'background' in palette"
314 );
315 assert!(
316 raw_theme.palette.contains_key("foreground"),
317 "Theme '{theme_name}' missing 'foreground' in palette"
318 );
319
320 for (component_name, style) in &raw_theme.components {
322 if let Some(fg) = &style.fg {
323 match fg {
324 ColorValue::Direct(color) if color.starts_with('#') => {
325 panic!(
326 "Theme '{theme_name}' component '{component_name:?}' uses direct hex color '{color}' instead of palette reference"
327 );
328 }
329 _ => {} }
331 }
332 if let Some(bg) = &style.bg {
333 match bg {
334 ColorValue::Direct(color) if color.starts_with('#') => {
335 panic!(
336 "Theme '{theme_name}' component '{component_name:?}' uses direct hex color '{color}' instead of palette reference"
337 );
338 }
339 _ => {} }
341 }
342 }
343
344 let theme = raw_theme
346 .into_theme()
347 .unwrap_or_else(|e| panic!("Failed to convert theme '{theme_name}': {e}"));
348
349 let critical_components = [
351 Component::StatusBar,
352 Component::ErrorText,
353 Component::AssistantMessage,
354 Component::UserMessage,
355 Component::InputPanelBorder,
356 Component::ChatListBorder,
357 Component::SelectionHighlight,
358 ];
359
360 for component in critical_components {
361 assert!(
362 theme.styles.contains_key(&component),
363 "Theme '{theme_name}' missing critical component: {component:?}"
364 );
365 }
366 }
367 }
368
369 #[test]
370 fn test_theme_palette_references_resolve() {
371 let loader = ThemeLoader::new();
372
373 for theme_name in ["catppuccin-mocha", "gruvbox-dark", "solarized-light"] {
375 let theme = loader
376 .load_theme(theme_name)
377 .unwrap_or_else(|e| panic!("Failed to load theme '{theme_name}': {e}"));
378
379 let status_bar = theme
381 .styles
382 .get(&super::super::Component::StatusBar)
383 .expect("StatusBar component missing");
384
385 if status_bar.fg.is_none() && status_bar.bg.is_none() {
387 panic!("Theme '{theme_name}' StatusBar has no colors defined");
388 }
389 }
390 }
391}