dampen_dev/theme_loader.rs
1//! Theme file discovery and loading for dampen-dev.
2//!
3//! This module provides functions for discovering theme.dampen files
4//! and loading them into ThemeContext for runtime use.
5//!
6//! Note: System theme detection is handled by Iced's built-in `system::theme_changes()`
7//! subscription, which uses winit's native theme detection. The initial theme is
8//! determined by the theme document's `default_theme` setting, and then updated
9//! reactively when the system theme changes.
10
11use dampen_core::ir::theme::ThemeDocument;
12use dampen_core::parser::theme_parser::parse_theme_document;
13use dampen_core::state::ThemeContext;
14use std::fs;
15use std::path::{Path, PathBuf};
16
17/// Discover and load theme.dampen from a project directory.
18///
19/// This function looks for `src/ui/theme/theme.dampen` in the given
20/// project directory and loads it if found.
21///
22/// Note: The initial theme is set to the document's `default_theme`. System theme
23/// preference will be applied reactively via the `watch_system_theme()` subscription
24/// once the application starts.
25///
26/// # Arguments
27///
28/// * `project_dir` - The root directory of the Dampen project
29///
30/// # Returns
31///
32/// * `Ok(Some(ThemeContext))` - If a valid theme file was found and loaded
33/// * `Ok(None)` - If no theme file was found (use default Iced theme)
34/// * `Err(_)` - If a theme file exists but couldn't be parsed
35pub fn load_theme_context(project_dir: &Path) -> Result<Option<ThemeContext>, ThemeLoadError> {
36 load_theme_context_with_preference(project_dir, None)
37}
38
39/// Discover and load theme.dampen with an optional system preference.
40///
41/// This variant allows passing a pre-detected system preference for cases
42/// where it's already known (e.g., from a previous theme change event).
43///
44/// # Arguments
45///
46/// * `project_dir` - The root directory of the Dampen project
47/// * `system_preference` - Optional system theme preference ("light" or "dark")
48///
49/// # Returns
50///
51/// * `Ok(Some(ThemeContext))` - If a valid theme file was found and loaded
52/// * `Ok(None)` - If no theme file was found (use default Iced theme)
53/// * `Err(_)` - If a theme file exists but couldn't be parsed
54pub fn load_theme_context_with_preference(
55 project_dir: &Path,
56 system_preference: Option<&str>,
57) -> Result<Option<ThemeContext>, ThemeLoadError> {
58 match discover_theme_file(project_dir) {
59 Some(Ok(doc)) => {
60 let ctx = ThemeContext::from_document(doc, system_preference)
61 .map_err(ThemeLoadError::InvalidDocument)?;
62 Ok(Some(ctx))
63 }
64 Some(Err(e)) => Err(ThemeLoadError::ParseError(e)),
65 None => Ok(None),
66 }
67}
68
69/// Discover theme.dampen file in a project directory.
70///
71/// Looks for `src/ui/theme/theme.dampen` in the given project directory.
72///
73/// # Arguments
74///
75/// * `project_dir` - The root directory of the Dampen project
76///
77/// # Returns
78///
79/// * `Some(Ok(ThemeDocument))` - If a valid theme file was found
80/// * `Some(Err(_))` - If the file exists but couldn't be parsed
81/// * `None` - If no theme file was found
82pub fn discover_theme_file(project_dir: &Path) -> Option<Result<ThemeDocument, String>> {
83 let theme_path = find_theme_file_path(project_dir)?;
84
85 if !theme_path.exists() {
86 return None;
87 }
88
89 match fs::read_to_string(&theme_path) {
90 Ok(content) => match parse_theme_document(&content) {
91 Ok(doc) => Some(Ok(doc)),
92 Err(e) => Some(Err(format!("Failed to parse theme: {}", e))),
93 },
94 Err(e) => Some(Err(format!("Failed to read theme file: {}", e))),
95 }
96}
97
98/// Find the path to theme.dampen in a project directory.
99///
100/// Searches in order:
101/// 1. `src/ui/theme/theme.dampen`
102///
103/// # Arguments
104///
105/// * `project_dir` - The root directory of the Dampen project
106///
107/// # Returns
108///
109/// The path to theme.dampen if found, None otherwise
110pub fn find_theme_file_path(project_dir: &Path) -> Option<PathBuf> {
111 let paths = vec![project_dir.join("src/ui/theme/theme.dampen")];
112
113 paths.into_iter().find(|path| path.exists())
114}
115
116/// Find the project root by searching for theme.dampen in multiple locations.
117///
118/// This function implements a robust search strategy for finding the project root
119/// when the application is run from various locations (e.g., target/release).
120///
121/// Search order:
122/// 1. `CARGO_MANIFEST_DIR` environment variable (available during `cargo run`)
123/// 2. Ancestors of the executable location (for binaries in target/release)
124/// 3. Workspace examples directory (for examples in a workspace)
125/// 4. Ancestors of the current working directory
126///
127/// # Returns
128///
129/// The project root path if found, None otherwise
130pub fn find_project_root() -> Option<PathBuf> {
131 // 1. Try CARGO_MANIFEST_DIR (available during cargo run)
132 if let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR") {
133 let path = PathBuf::from(&manifest_dir);
134 if path.join("src/ui/theme/theme.dampen").exists() {
135 return Some(path);
136 }
137 }
138
139 // 2. Try relative to the executable location
140 // This handles the case where the binary is in target/release or target/debug
141 if let Ok(exe_path) = std::env::current_exe() {
142 // Get the executable name (e.g., "todo-app")
143 let exe_name = exe_path
144 .file_stem()
145 .and_then(|s| s.to_str())
146 .map(|s| s.to_string());
147
148 if let Some(exe_dir) = exe_path.parent() {
149 // Walk up the directory tree
150 for ancestor in exe_dir.ancestors() {
151 // Direct check: src/ui/theme/theme.dampen
152 let theme_path = ancestor.join("src/ui/theme/theme.dampen");
153 if theme_path.exists() {
154 return Some(ancestor.to_path_buf());
155 }
156
157 // Workspace check: Look for the project in examples/ directory
158 // This handles cases where the exe is in workspace/target/release
159 // but the project is in workspace/examples/project-name/
160 if let Some(ref name) = exe_name {
161 let examples_path = ancestor.join("examples").join(name);
162 let theme_in_examples = examples_path.join("src/ui/theme/theme.dampen");
163 if theme_in_examples.exists() {
164 return Some(examples_path);
165 }
166 }
167 }
168 }
169 }
170
171 // 3. Try from current directory upwards
172 if let Ok(cwd) = std::env::current_dir() {
173 for ancestor in cwd.ancestors() {
174 let theme_path = ancestor.join("src/ui/theme/theme.dampen");
175 if theme_path.exists() {
176 return Some(ancestor.to_path_buf());
177 }
178 }
179 }
180
181 None
182}
183
184/// Create a minimal Dampen application structure for testing.
185///
186/// This helper function creates the basic directory structure of a
187/// Dampen application, optionally with or without a theme file.
188///
189/// # Arguments
190///
191/// * `dir` - The temporary directory to create the app structure in
192pub fn create_minimal_dampen_app(dir: &Path) {
193 let src_dir = dir.join("src");
194 let ui_dir = src_dir.join("ui");
195 let theme_dir = ui_dir.join("theme");
196
197 let _ = fs::create_dir_all(&theme_dir);
198}
199
200/// Errors that can occur when loading themes
201#[derive(Debug, thiserror::Error)]
202pub enum ThemeLoadError {
203 /// The theme file exists but couldn't be parsed
204 #[error("Failed to parse theme file: {0}")]
205 ParseError(String),
206
207 /// The parsed theme document is invalid
208 #[error("Invalid theme document: {0}")]
209 InvalidDocument(#[from] dampen_core::ir::theme::ThemeError),
210}