dampen_core/state/
theme_context.rs

1//! Runtime theme context for managing active themes.
2//!
3//! This module provides the [`ThemeContext`] struct that holds the current
4//! active theme and manages theme switching at runtime.
5
6use crate::ir::theme::{Theme, ThemeDocument, ThemeError, ThemeErrorKind};
7use std::collections::HashMap;
8
9/// Runtime theme context shared across all windows.
10///
11/// This struct manages theme state including:
12/// - The currently active theme
13/// - All loaded themes from theme.dampen
14/// - System preference detection
15/// - User preference persistence
16///
17/// # Examples
18///
19/// ```rust,ignore
20/// use dampen_core::{parse_theme_document, ThemeContext};
21///
22/// let xml = r#"<dampen><themes><theme name="light">...</theme></themes></dampen>"#;
23/// let doc = parse_theme_document(xml).unwrap();
24/// let ctx = ThemeContext::from_document(doc, Some("dark"));
25///
26/// assert_eq!(ctx.active().name, "dark");
27/// ```
28#[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    /// Create a ThemeContext from a parsed ThemeDocument.
39    ///
40    /// The active theme is determined by:
41    /// 1. User preference (if set)
42    /// 2. Document's default_theme (if set)
43    /// 3. System preference (if follow_system is true)
44    /// 4. "light" fallback
45    ///
46    /// # Arguments
47    ///
48    /// * `document` - The parsed theme document
49    /// * `system_preference` - Optional detected system theme preference
50    ///
51    /// # Errors
52    ///
53    /// Returns `ThemeError` if the document is invalid or has no themes.
54    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    /// Get the currently active theme.
91    ///
92    /// # Returns
93    ///
94    /// Reference to the active [`Theme`](crate::ir::Theme).
95    #[allow(clippy::unwrap_used)]
96    pub fn active(&self) -> &Theme {
97        self.themes.get(&self.active_theme).unwrap()
98    }
99
100    /// Get the name of the currently active theme.
101    pub fn active_name(&self) -> &str {
102        &self.active_theme
103    }
104
105    /// Switch to a different theme by name.
106    ///
107    /// # Arguments
108    ///
109    /// * `name` - The name of the theme to switch to
110    ///
111    /// # Errors
112    ///
113    /// Returns `ThemeError::ThemeNotFound` if the theme doesn't exist.
114    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    /// Update the system preference and potentially switch theme.
128    ///
129    /// If the document is configured to follow system preference,
130    /// this will switch to the system theme if it exists.
131    ///
132    /// # Arguments
133    ///
134    /// * `preference` - The new system preference ("light" or "dark")
135    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    /// Set whether to follow system preference.
147    pub fn set_follow_system(&mut self, follow: bool) {
148        self.follow_system = follow;
149
150        // If enabling, immediately apply system preference if available
151        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    /// Check if currently following system preference.
161    pub fn follow_system(&self) -> bool {
162        self.follow_system
163    }
164
165    /// Reload themes from a new document (for hot-reload).
166    ///
167    /// Preserves the current active theme if it exists in the new document,
168    /// otherwise falls back to the new document's default.
169    ///
170    /// # Arguments
171    ///
172    /// * `document` - The new parsed theme document
173    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    /// Get all available theme names.
187    pub fn available_themes(&self) -> Vec<&str> {
188        self.themes.keys().map(|s| s.as_str()).collect()
189    }
190
191    /// Check if a theme with the given name exists.
192    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        // Base theme
309        let base = create_test_theme("base");
310        themes.insert("base".to_string(), base);
311
312        // Derived theme
313        let mut derived = create_test_theme("derived");
314        derived.extends = Some("base".to_string());
315        derived.palette.primary = None; // Should be inherited
316        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}