dampen_core/state/
theme_context.rs1use crate::ir::theme::{Theme, ThemeDocument, ThemeError, ThemeErrorKind};
7use std::collections::HashMap;
8
9#[derive(Debug, Clone)]
29pub struct ThemeContext {
30 active_theme: String,
31 themes: HashMap<String, Theme>,
32 system_preference: Option<String>,
33 follow_system: bool,
34 user_preference: Option<String>,
35}
36
37impl ThemeContext {
38 pub fn from_document(
55 document: ThemeDocument,
56 system_preference: Option<&str>,
57 ) -> Result<Self, ThemeError> {
58 if document.themes.is_empty() {
59 return Err(ThemeError {
60 kind: ThemeErrorKind::NoThemesDefined,
61 message: "THEME_001: Cannot create ThemeContext with no themes".to_string(),
62 });
63 }
64
65 let active_theme = if let Some(user_pref) = document.themes.get("user_preference") {
66 user_pref.name.clone()
67 } else {
68 document.effective_default(system_preference).to_string()
69 };
70
71 if !document.themes.contains_key(&active_theme) {
72 return Err(ThemeError {
73 kind: ThemeErrorKind::ThemeNotFound,
74 message: format!(
75 "THEME_006: Active theme '{}' not found in document",
76 active_theme
77 ),
78 });
79 }
80
81 Ok(ThemeContext {
82 active_theme,
83 themes: document.resolve_inheritance(),
84 system_preference: system_preference.map(|s| s.to_string()),
85 follow_system: document.follow_system,
86 user_preference: None,
87 })
88 }
89
90 #[allow(clippy::unwrap_used)]
96 pub fn active(&self) -> &Theme {
97 self.themes.get(&self.active_theme).unwrap()
98 }
99
100 pub fn active_name(&self) -> &str {
102 &self.active_theme
103 }
104
105 pub fn set_theme(&mut self, name: &str) -> Result<(), ThemeError> {
115 if !self.themes.contains_key(name) {
116 return Err(ThemeError {
117 kind: ThemeErrorKind::ThemeNotFound,
118 message: format!("THEME_006: Theme '{}' not found", name),
119 });
120 }
121
122 self.active_theme = name.to_string();
123 self.user_preference = Some(name.to_string());
124 Ok(())
125 }
126
127 pub fn update_system_preference(&mut self, preference: &str) {
136 self.system_preference = Some(preference.to_string());
137
138 if self.follow_system
139 && self.user_preference.is_none()
140 && self.themes.contains_key(preference)
141 {
142 self.active_theme = preference.to_string();
143 }
144 }
145
146 pub fn set_follow_system(&mut self, follow: bool) {
148 self.follow_system = follow;
149
150 if follow {
152 if let Some(ref pref) = self.system_preference {
153 if self.themes.contains_key(pref) {
154 self.active_theme = pref.clone();
155 }
156 }
157 }
158 }
159
160 pub fn follow_system(&self) -> bool {
162 self.follow_system
163 }
164
165 pub fn reload(&mut self, document: ThemeDocument) {
174 let old_active = self.active_theme.clone();
175 let resolved_themes = document.resolve_inheritance();
176 let fallback_theme = document.effective_default(self.system_preference.as_deref());
177
178 self.themes = resolved_themes;
179 self.active_theme = if self.themes.contains_key(&old_active) {
180 old_active
181 } else {
182 fallback_theme.to_string()
183 };
184 }
185
186 pub fn available_themes(&self) -> Vec<&str> {
188 self.themes.keys().map(|s| s.as_str()).collect()
189 }
190
191 pub fn has_theme(&self, name: &str) -> bool {
193 self.themes.contains_key(name)
194 }
195}
196
197#[cfg(test)]
198mod tests {
199 use super::*;
200 use crate::ir::style::Color;
201 use crate::ir::theme::{SpacingScale, Typography};
202
203 fn create_test_theme(name: &str) -> Theme {
204 Theme {
205 name: name.to_string(),
206 palette: crate::ir::theme::ThemePalette {
207 primary: Some(Color::from_hex("#3498db").unwrap()),
208 secondary: Some(Color::from_hex("#2ecc71").unwrap()),
209 success: Some(Color::from_hex("#27ae60").unwrap()),
210 warning: Some(Color::from_hex("#f39c12").unwrap()),
211 danger: Some(Color::from_hex("#e74c3c").unwrap()),
212 background: Some(Color::from_hex("#ecf0f1").unwrap()),
213 surface: Some(Color::from_hex("#ffffff").unwrap()),
214 text: Some(Color::from_hex("#2c3e50").unwrap()),
215 text_secondary: Some(Color::from_hex("#7f8c8d").unwrap()),
216 },
217 typography: Typography {
218 font_family: Some("sans-serif".to_string()),
219 font_size_base: Some(16.0),
220 font_size_small: Some(12.0),
221 font_size_large: Some(24.0),
222 font_weight: crate::ir::theme::FontWeight::Normal,
223 line_height: Some(1.5),
224 },
225 spacing: SpacingScale { unit: Some(8.0) },
226 base_styles: HashMap::new(),
227 extends: None,
228 }
229 }
230
231 fn create_test_document() -> ThemeDocument {
232 ThemeDocument {
233 themes: HashMap::from([
234 ("light".to_string(), create_test_theme("light")),
235 ("dark".to_string(), create_test_theme("dark")),
236 ]),
237 default_theme: Some("light".to_string()),
238 follow_system: true,
239 }
240 }
241
242 #[test]
243 fn test_from_document_with_system_preference() {
244 let doc = create_test_document();
245 let ctx = ThemeContext::from_document(doc, Some("dark")).unwrap();
246
247 assert_eq!(ctx.active_name(), "dark");
248 }
249
250 #[test]
251 fn test_from_document_without_system_preference() {
252 let doc = create_test_document();
253 let ctx = ThemeContext::from_document(doc, None).unwrap();
254
255 assert_eq!(ctx.active_name(), "light");
256 }
257
258 #[test]
259 fn test_set_theme() {
260 let doc = create_test_document();
261 let mut ctx = ThemeContext::from_document(doc, None).unwrap();
262
263 assert_eq!(ctx.active_name(), "light");
264
265 ctx.set_theme("dark").unwrap();
266 assert_eq!(ctx.active_name(), "dark");
267
268 assert!(ctx.set_theme("nonexistent").is_err());
269 }
270
271 #[test]
272 fn test_update_system_preference() {
273 let doc = create_test_document();
274 let mut ctx = ThemeContext::from_document(doc.clone(), None).unwrap();
275
276 assert_eq!(ctx.active_name(), "light");
277
278 ctx.update_system_preference("dark");
279 assert_eq!(ctx.active_name(), "dark");
280
281 ctx.update_system_preference("light");
282 assert_eq!(ctx.active_name(), "light");
283
284 ctx.set_follow_system(false);
285 ctx.update_system_preference("dark");
286 assert_eq!(ctx.active_name(), "light");
287 }
288
289 #[test]
290 fn test_reload() {
291 let doc = create_test_document();
292 let mut ctx = ThemeContext::from_document(doc, None).unwrap();
293
294 assert_eq!(ctx.active_name(), "light");
295
296 let mut new_doc = create_test_document();
297 new_doc.default_theme = Some("dark".to_string());
298 new_doc.themes.remove("light");
299
300 ctx.reload(new_doc);
301 assert_eq!(ctx.active_name(), "dark");
302 }
303
304 #[test]
305 fn test_inheritance_resolution() {
306 let mut themes = HashMap::new();
307
308 let base = create_test_theme("base");
310 themes.insert("base".to_string(), base);
311
312 let mut derived = create_test_theme("derived");
314 derived.extends = Some("base".to_string());
315 derived.palette.primary = None; themes.insert("derived".to_string(), derived);
317
318 let doc = ThemeDocument {
319 themes,
320 default_theme: Some("derived".to_string()),
321 follow_system: false,
322 };
323
324 let ctx = ThemeContext::from_document(doc, None).unwrap();
325 let active = ctx.active();
326
327 assert_eq!(active.name, "derived");
328 assert!(
329 active.palette.primary.is_some(),
330 "Primary color should be inherited from base"
331 );
332 assert_eq!(
333 active.palette.primary,
334 create_test_theme("base").palette.primary
335 );
336 }
337}