dampen_core/ir/
theme.rs

1//! Theming system types for Dampen UI framework
2//!
3//! This module defines the IR types for theme definitions, color palettes,
4//! typography, spacing scales, and style classes with inheritance.
5//! All types are backend-agnostic and serializable.
6
7use super::layout::LayoutConstraints;
8use super::style::{Color, StyleProperties};
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11
12/// Theme definition containing all visual properties
13#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
14pub struct Theme {
15    pub name: String,
16    pub palette: ThemePalette,
17    pub typography: Typography,
18    pub spacing: SpacingScale,
19    /// Default styles per widget type
20    pub base_styles: HashMap<String, StyleProperties>,
21    /// Parent theme for inheritance
22    #[serde(default)]
23    pub extends: Option<String>,
24}
25
26impl Theme {
27    /// Validate theme with detailed error messages
28    ///
29    /// Returns an error if:
30    /// - Palette colors are invalid or missing
31    /// - Typography values are invalid
32    /// - Spacing unit is non-positive
33    ///
34    /// For themes with inheritance, validates only set colors
35    /// For themes without inheritance, validates all required colors
36    pub fn validate(&self, _allow_partial: bool) -> Result<(), String> {
37        let parent_name = self.extends.as_deref();
38
39        // Validate palette with detailed messages
40        // Always use validate_with_inheritance to get proper error messages with theme name
41        self.palette
42            .validate_with_inheritance(&self.name, parent_name)?;
43
44        // Validate typography with detailed messages
45        self.typography
46            .validate_with_inheritance(&self.name, parent_name)?;
47
48        // Validate spacing
49        if let Some(ref unit) = self.spacing.unit {
50            if *unit <= 0.0 {
51                return Err(format!(
52                    "Theme '{}': spacing unit must be positive, got {}\n\
53                     Hint: Use a positive value like unit=\"8\" or unit=\"4\"",
54                    self.name, unit
55                ));
56            }
57        }
58
59        // Validate base styles
60        for (widget_type, style) in &self.base_styles {
61            style.validate().map_err(|e| {
62                format!(
63                    "Theme '{}': Invalid base style for '{}': {}",
64                    self.name, widget_type, e
65                )
66            })?;
67        }
68
69        Ok(())
70    }
71
72    /// Validate theme inheritance
73    ///
74    /// Returns an error if:
75    /// - Parent theme doesn't exist
76    /// - Circular inheritance detected
77    /// - Inheritance depth exceeds 5 levels
78    pub fn validate_inheritance(
79        &self,
80        all_themes: &HashMap<String, Theme>,
81        visited: &mut Vec<String>,
82    ) -> Result<(), ThemeError> {
83        if let Some(ref parent_name) = self.extends {
84            if visited.contains(parent_name) {
85                let chain = visited.join(" → ");
86                return Err(ThemeError {
87                    kind: ThemeErrorKind::CircularInheritance,
88                    message: format!(
89                        "THEME_007: Circular theme inheritance detected: {} → {}",
90                        chain, parent_name
91                    ),
92                });
93            }
94
95            if !all_themes.contains_key(parent_name) {
96                return Err(ThemeError {
97                    kind: ThemeErrorKind::ThemeNotFound,
98                    message: format!(
99                        "THEME_006: Parent theme '{}' not found for theme '{}'",
100                        parent_name, self.name
101                    ),
102                });
103            }
104
105            visited.push(self.name.clone());
106            if visited.len() > 5 {
107                return Err(ThemeError {
108                    kind: ThemeErrorKind::ExceedsMaxDepth,
109                    message: format!(
110                        "THEME_008: Theme inheritance depth exceeds 5 levels for '{}'",
111                        self.name
112                    ),
113                });
114            }
115
116            if let Some(parent) = all_themes.get(parent_name) {
117                parent.validate_inheritance(all_themes, visited)?;
118            }
119            visited.pop();
120        }
121
122        Ok(())
123    }
124
125    /// Inherit properties from a parent theme
126    ///
127    /// The child theme's values take precedence, but missing values
128    /// are inherited from the parent theme.
129    pub fn inherit_from(&self, parent: &Theme) -> Self {
130        Theme {
131            name: self.name.clone(),
132            palette: self.palette.inherit_from(&parent.palette),
133            typography: self.typography.inherit_from(&parent.typography),
134            spacing: self.spacing.inherit_from(&parent.spacing),
135            base_styles: self.base_styles.clone(),
136            extends: self.extends.clone(),
137        }
138    }
139}
140
141/// Theme color palette
142#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
143pub struct ThemePalette {
144    pub primary: Option<Color>,
145    pub secondary: Option<Color>,
146    pub success: Option<Color>,
147    pub warning: Option<Color>,
148    pub danger: Option<Color>,
149    pub background: Option<Color>,
150    pub surface: Option<Color>,
151    pub text: Option<Color>,
152    pub text_secondary: Option<Color>,
153}
154
155impl ThemePalette {
156    /// Validate palette for themes with inheritance support
157    ///
158    /// For themes with inheritance, missing colors can be inherited from parent.
159    /// This method provides detailed error messages with suggestions.
160    pub fn validate_with_inheritance(
161        &self,
162        theme_name: &str,
163        parent_name: Option<&str>,
164    ) -> Result<(), String> {
165        let required = [
166            ("primary", "primary color for main UI elements"),
167            ("secondary", "secondary/accent color"),
168            ("success", "success state color"),
169            ("warning", "warning state color"),
170            ("danger", "danger/error state color"),
171            ("background", "background color for containers"),
172            ("surface", "surface color for cards, buttons, etc."),
173            ("text", "primary text color"),
174            ("text_secondary", "secondary/disabled text color"),
175        ];
176
177        let mut missing = Vec::new();
178        let mut invalid = Vec::new();
179
180        // Check missing colors
181        for (color, description) in &required {
182            let value = match *color {
183                "primary" => &self.primary,
184                "secondary" => &self.secondary,
185                "success" => &self.success,
186                "warning" => &self.warning,
187                "danger" => &self.danger,
188                "background" => &self.background,
189                "surface" => &self.surface,
190                "text" => &self.text,
191                "text_secondary" => &self.text_secondary,
192                _ => unreachable!(),
193            };
194
195            if value.is_none() {
196                missing.push((*color, *description));
197            }
198        }
199
200        // If we have a parent and some colors are missing, they're okay (will be inherited)
201        if parent_name.is_some() && !missing.is_empty() {
202            // Colors will be inherited from parent, no error needed for missing
203        } else if !missing.is_empty() {
204            let missing_list: Vec<_> = missing.iter().map(|(c, _)| *c).collect();
205            let mut message = format!(
206                "Theme '{}' is missing {} required color(s): {}",
207                theme_name,
208                missing.len(),
209                missing_list.join(", ")
210            );
211
212            if parent_name.is_none() {
213                message.push_str("\n\nTip: If you want to inherit colors from another theme, add 'extends=\"parent_theme\"' attribute to this theme.");
214                message.push_str("\nExample: <theme name=\"dark\" extends=\"base\">");
215            }
216
217            return Err(message);
218        }
219
220        // Validate color values (only for colors that are set)
221        for (color, _description) in &required {
222            let value = match *color {
223                "primary" => self.primary.as_ref(),
224                "secondary" => self.secondary.as_ref(),
225                "success" => self.success.as_ref(),
226                "warning" => self.warning.as_ref(),
227                "danger" => self.danger.as_ref(),
228                "background" => self.background.as_ref(),
229                "surface" => self.surface.as_ref(),
230                "text" => self.text.as_ref(),
231                "text_secondary" => self.text_secondary.as_ref(),
232                _ => unreachable!(),
233            };
234
235            if let Some(color_val) = value {
236                if let Err(e) = color_val.validate() {
237                    invalid.push((*color, e));
238                }
239            }
240        }
241
242        if !invalid.is_empty() {
243            let mut message = format!("Theme '{}' has invalid color values:\n", theme_name);
244            for (color, error) in &invalid {
245                message.push_str(&format!("  - {}: {}\n", color, error));
246            }
247            message.push_str("\nValid color formats:\n");
248            message.push_str("  - Hex: #RRGGBB or #RRGGBBAA\n");
249            message.push_str("  - RGB: rgb(r, g, b) or rgba(r, g, b, a)\n");
250            message.push_str("  - HSL: hsl(h, s%, l%) or hsla(h, s%, l%, a)\n");
251            message.push_str("  - Named: red, blue, transparent, etc.");
252            return Err(message);
253        }
254
255        Ok(())
256    }
257
258    /// Validate palette (legacy method, calls validate_with_inheritance with no parent)
259    pub fn validate(&self) -> Result<(), String> {
260        self.validate_with_inheritance("theme", None)
261    }
262
263    /// Merge with a parent palette, inheriting missing values
264    pub fn inherit_from(&self, parent: &ThemePalette) -> Self {
265        Self {
266            primary: self.primary.or(parent.primary),
267            secondary: self.secondary.or(parent.secondary),
268            success: self.success.or(parent.success),
269            warning: self.warning.or(parent.warning),
270            danger: self.danger.or(parent.danger),
271            background: self.background.or(parent.background),
272            surface: self.surface.or(parent.surface),
273            text: self.text.or(parent.text),
274            text_secondary: self.text_secondary.or(parent.text_secondary),
275        }
276    }
277
278    /// Get colors for Iced Palette (6 colors)
279    ///
280    /// Returns RGB tuples (r, g, b) in 0.0-1.0 range for Iced compatibility.
281    /// Panics if any required color is missing.
282    #[allow(clippy::expect_used)]
283    pub fn iced_colors(&self) -> IcedPaletteColors {
284        IcedPaletteColors {
285            primary: (
286                self.primary.expect("primary color must be set").r,
287                self.primary.expect("primary color must be set").g,
288                self.primary.expect("primary color must be set").b,
289            ),
290            background: (
291                self.background.expect("background color must be set").r,
292                self.background.expect("background color must be set").g,
293                self.background.expect("background color must be set").b,
294            ),
295            text: (
296                self.text.expect("text color must be set").r,
297                self.text.expect("text color must be set").g,
298                self.text.expect("text color must be set").b,
299            ),
300            success: (
301                self.success.expect("success color must be set").r,
302                self.success.expect("success color must be set").g,
303                self.success.expect("success color must be set").b,
304            ),
305            warning: (
306                self.warning.expect("warning color must be set").r,
307                self.warning.expect("warning color must be set").g,
308                self.warning.expect("warning color must be set").b,
309            ),
310            danger: (
311                self.danger.expect("danger color must be set").r,
312                self.danger.expect("danger color must be set").g,
313                self.danger.expect("danger color must be set").b,
314            ),
315        }
316    }
317
318    /// Create a light theme palette with standard colors
319    ///
320    /// # Example
321    ///
322    /// ```rust
323    /// use dampen_core::ir::theme::ThemePalette;
324    ///
325    /// let palette = ThemePalette::light();
326    /// ```
327    pub fn light() -> Self {
328        use crate::ir::style::Color;
329        Self {
330            primary: Some(Color::from_rgb8(0x34, 0x98, 0xDB)),
331            secondary: Some(Color::from_rgb8(0x2E, 0xCC, 0x71)),
332            success: Some(Color::from_rgb8(0x27, 0xAE, 0x60)),
333            warning: Some(Color::from_rgb8(0xF3, 0x9C, 0x12)),
334            danger: Some(Color::from_rgb8(0xE7, 0x4C, 0x3C)),
335            background: Some(Color::from_rgb8(0xEC, 0xF0, 0xF1)),
336            surface: Some(Color::from_rgb8(0xFF, 0xFF, 0xFF)),
337            text: Some(Color::from_rgb8(0x2C, 0x3E, 0x50)),
338            text_secondary: Some(Color::from_rgb8(0x7F, 0x8C, 0x8D)),
339        }
340    }
341
342    /// Create a dark theme palette with standard colors
343    ///
344    /// # Example
345    ///
346    /// ```rust
347    /// use dampen_core::ir::theme::ThemePalette;
348    ///
349    /// let palette = ThemePalette::dark();
350    /// ```
351    pub fn dark() -> Self {
352        use crate::ir::style::Color;
353        Self {
354            primary: Some(Color::from_rgb8(0x5D, 0xAD, 0xE2)),
355            secondary: Some(Color::from_rgb8(0x52, 0xBE, 0x80)),
356            success: Some(Color::from_rgb8(0x27, 0xAE, 0x60)),
357            warning: Some(Color::from_rgb8(0xF3, 0x9C, 0x12)),
358            danger: Some(Color::from_rgb8(0xEC, 0x70, 0x63)),
359            background: Some(Color::from_rgb8(0x2C, 0x3E, 0x50)),
360            surface: Some(Color::from_rgb8(0x34, 0x49, 0x5E)),
361            text: Some(Color::from_rgb8(0xEC, 0xF0, 0xF1)),
362            text_secondary: Some(Color::from_rgb8(0x95, 0xA5, 0xA6)),
363        }
364    }
365}
366
367/// Colors suitable for Iced Palette (0.0-1.0 RGB range)
368#[derive(Debug, Clone, Copy)]
369pub struct IcedPaletteColors {
370    pub primary: (f32, f32, f32),
371    pub background: (f32, f32, f32),
372    pub text: (f32, f32, f32),
373    pub success: (f32, f32, f32),
374    pub warning: (f32, f32, f32),
375    pub danger: (f32, f32, f32),
376}
377
378/// Typography configuration
379#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
380pub struct Typography {
381    pub font_family: Option<String>,
382    pub font_size_base: Option<f32>,
383    pub font_size_small: Option<f32>,
384    pub font_size_large: Option<f32>,
385    pub font_weight: FontWeight,
386    pub line_height: Option<f32>,
387}
388
389impl Typography {
390    /// Validate typography with detailed error messages
391    pub fn validate_with_inheritance(
392        &self,
393        theme_name: &str,
394        parent_name: Option<&str>,
395    ) -> Result<(), String> {
396        let mut errors = Vec::new();
397
398        if let Some(size) = self.font_size_base {
399            if size <= 0.0 {
400                errors.push(format!("font_size_base must be positive, got {}", size));
401            } else if size < 8.0 {
402                errors.push(format!(
403                    "font_size_base {} is very small (recommended: 14-18px)",
404                    size
405                ));
406            } else if size > 32.0 {
407                errors.push(format!(
408                    "font_size_base {} is very large (recommended: 14-18px)",
409                    size
410                ));
411            }
412        }
413
414        if let Some(size) = self.font_size_small {
415            if size <= 0.0 {
416                errors.push(format!("font_size_small must be positive, got {}", size));
417            } else if size >= self.font_size_base.unwrap_or(16.0) {
418                errors.push("font_size_small should be smaller than font_size_base".to_string());
419            }
420        }
421
422        if let Some(size) = self.font_size_large {
423            if size <= 0.0 {
424                errors.push(format!("font_size_large must be positive, got {}", size));
425            } else if size <= self.font_size_base.unwrap_or(16.0) {
426                errors.push("font_size_large should be larger than font_size_base".to_string());
427            }
428        }
429
430        if let Some(height) = self.line_height {
431            if height <= 0.0 {
432                errors.push(format!("line_height must be positive, got {}", height));
433            } else if height < 1.0 {
434                errors.push(format!(
435                    "line_height {} is too tight (recommended: 1.4-1.6)",
436                    height
437                ));
438            } else if height > 2.5 {
439                errors.push(format!(
440                    "line_height {} is too loose (recommended: 1.4-1.6)",
441                    height
442                ));
443            }
444        }
445
446        if !errors.is_empty() {
447            let mut message = format!("Typography validation failed for theme '{}':\n", theme_name);
448            for error in &errors {
449                message.push_str(&format!("  - {}\n", error));
450            }
451
452            if parent_name.is_none() {
453                message.push_str("\nTip: Missing typography values will inherit from parent theme if 'extends' is used.");
454            }
455
456            message.push_str("\nExample typography configuration:");
457            message.push_str("\n  <typography");
458            message.push_str("\n      font_family=\"Inter, sans-serif\"");
459            message.push_str("\n      font_size_base=\"16\"");
460            message.push_str("\n      font_size_small=\"12\"");
461            message.push_str("\n      font_size_large=\"20\"");
462            message.push_str("\n      font_weight=\"normal\"");
463            message.push_str("\n      line_height=\"1.5\" />");
464
465            return Err(message);
466        }
467
468        Ok(())
469    }
470
471    /// Validate typography (legacy method)
472    pub fn validate(&self) -> Result<(), String> {
473        self.validate_with_inheritance("theme", None)
474    }
475
476    /// Merge with a parent typography, inheriting missing values
477    pub fn inherit_from(&self, parent: &Typography) -> Self {
478        Self {
479            font_family: self
480                .font_family
481                .clone()
482                .or_else(|| parent.font_family.clone()),
483            font_size_base: self.font_size_base.or(parent.font_size_base),
484            font_size_small: self.font_size_small.or(parent.font_size_small),
485            font_size_large: self.font_size_large.or(parent.font_size_large),
486            font_weight: self.font_weight,
487            line_height: self.line_height.or(parent.line_height),
488        }
489    }
490}
491
492/// Font weight
493#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
494pub enum FontWeight {
495    Thin,
496    Light,
497    Normal,
498    Medium,
499    Bold,
500    Black,
501}
502
503impl FontWeight {
504    /// Parse from string
505    pub fn parse(s: &str) -> Result<Self, String> {
506        match s.trim().to_lowercase().as_str() {
507            "thin" => Ok(FontWeight::Thin),
508            "light" => Ok(FontWeight::Light),
509            "normal" => Ok(FontWeight::Normal),
510            "medium" => Ok(FontWeight::Medium),
511            "bold" => Ok(FontWeight::Bold),
512            "black" => Ok(FontWeight::Black),
513            _ => Err(format!(
514                "Invalid font weight: '{}'. Expected thin, light, normal, medium, bold, or black",
515                s
516            )),
517        }
518    }
519
520    /// Convert to CSS numeric value
521    pub fn to_css(&self) -> u16 {
522        match self {
523            FontWeight::Thin => 100,
524            FontWeight::Light => 300,
525            FontWeight::Normal => 400,
526            FontWeight::Medium => 500,
527            FontWeight::Bold => 700,
528            FontWeight::Black => 900,
529        }
530    }
531}
532
533/// Spacing scale configuration
534#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
535pub struct SpacingScale {
536    /// Base spacing unit (e.g., 4px)
537    pub unit: Option<f32>,
538}
539
540impl SpacingScale {
541    /// Validate spacing scale with detailed error messages
542    pub fn validate_with_inheritance(
543        &self,
544        theme_name: &str,
545        parent_name: Option<&str>,
546    ) -> Result<(), String> {
547        if let Some(unit) = self.unit {
548            if unit <= 0.0 {
549                let mut message = format!(
550                    "Theme '{}': spacing unit must be positive, got {}\n",
551                    theme_name, unit
552                );
553                message.push_str("Valid spacing examples:\n");
554                message.push_str("  - <spacing unit=\"4\" />   (4px base)\n");
555                message.push_str("  - <spacing unit=\"8\" />   (8px base, recommended)\n");
556                message.push_str("  - <spacing unit=\"16\" />  (16px base)\n");
557
558                if parent_name.is_none() {
559                    message.push_str("\nTip: Missing spacing will inherit from parent theme if 'extends' is used.");
560                }
561
562                return Err(message);
563            }
564
565            if unit > 32.0 {
566                // Consider using 4-8px for better visual consistency
567            }
568        }
569        Ok(())
570    }
571
572    /// Validate spacing scale (legacy method)
573    pub fn validate(&self) -> Result<(), String> {
574        self.validate_with_inheritance("theme", None)
575    }
576
577    /// Get spacing for a multiplier
578    pub fn get(&self, multiplier: u8) -> f32 {
579        (self.unit.unwrap_or(8.0)) * multiplier as f32
580    }
581
582    /// Merge with a parent spacing scale, inheriting missing values
583    pub fn inherit_from(&self, parent: &SpacingScale) -> Self {
584        Self {
585            unit: self.unit.or(parent.unit),
586        }
587    }
588}
589
590/// Style class definition
591#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
592pub struct StyleClass {
593    pub name: String,
594    pub style: StyleProperties,
595    pub layout: Option<LayoutConstraints>,
596    /// Inherit from other classes
597    pub extends: Vec<String>,
598    /// State-specific overrides (single states)
599    pub state_variants: HashMap<WidgetState, StyleProperties>,
600    /// Combined state overrides (e.g., hover:active)
601    #[serde(default)]
602    pub combined_state_variants: HashMap<StateSelector, StyleProperties>,
603}
604
605impl StyleClass {
606    /// Validate class definition
607    ///
608    /// Returns an error if:
609    /// - Style properties are invalid
610    /// - Layout constraints are invalid
611    /// - Circular dependency detected
612    /// - Inheritance depth exceeds 5 levels
613    /// - Referenced parent classes don't exist
614    pub fn validate(&self, all_classes: &HashMap<String, StyleClass>) -> Result<(), String> {
615        // Validate own properties
616        self.style
617            .validate()
618            .map_err(|e| format!("Invalid style: {}", e))?;
619
620        if let Some(layout) = &self.layout {
621            layout
622                .validate()
623                .map_err(|e| format!("Invalid layout: {}", e))?;
624        }
625
626        // Check inheritance depth
627        self.check_inheritance_depth(all_classes, 0)?;
628
629        // Check for circular dependencies
630        self.check_circular_dependency(all_classes, &mut Vec::new())?;
631
632        // Validate state variants
633        for (state, style) in &self.state_variants {
634            style
635                .validate()
636                .map_err(|e| format!("Invalid style for state {:?}: {}", state, e))?;
637        }
638
639        // Validate combined state variants
640        for (selector, style) in &self.combined_state_variants {
641            style
642                .validate()
643                .map_err(|e| format!("Invalid style for state selector {:?}: {}", selector, e))?;
644        }
645
646        // Verify all extended classes exist
647        for parent in &self.extends {
648            if !all_classes.contains_key(parent) {
649                return Err(format!("Parent class '{}' not found", parent));
650            }
651        }
652
653        Ok(())
654    }
655
656    fn check_inheritance_depth(
657        &self,
658        all_classes: &HashMap<String, StyleClass>,
659        depth: u8,
660    ) -> Result<(), String> {
661        if depth > 5 {
662            return Err(format!(
663                "Style class inheritance depth exceeds 5 levels (class: {})",
664                self.name
665            ));
666        }
667
668        for parent_name in &self.extends {
669            if let Some(parent) = all_classes.get(parent_name) {
670                parent.check_inheritance_depth(all_classes, depth + 1)?;
671            }
672        }
673
674        Ok(())
675    }
676
677    fn check_circular_dependency(
678        &self,
679        all_classes: &HashMap<String, StyleClass>,
680        path: &mut Vec<String>,
681    ) -> Result<(), String> {
682        if path.contains(&self.name) {
683            let chain = path.join(" → ");
684            return Err(format!(
685                "Circular style class dependency detected: {} → {}",
686                chain, self.name
687            ));
688        }
689
690        path.push(self.name.clone());
691
692        for parent_name in &self.extends {
693            if let Some(parent) = all_classes.get(parent_name) {
694                parent.check_circular_dependency(all_classes, path)?;
695            }
696        }
697
698        path.pop();
699        Ok(())
700    }
701}
702
703/// Widget interaction state
704#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, PartialOrd, Ord)]
705pub enum WidgetState {
706    Hover,
707    Focus,
708    Active,
709    Disabled,
710}
711
712impl WidgetState {
713    /// Parse from string prefix
714    pub fn from_prefix(s: &str) -> Option<Self> {
715        match s.trim().to_lowercase().as_str() {
716            "hover" => Some(WidgetState::Hover),
717            "focus" => Some(WidgetState::Focus),
718            "active" => Some(WidgetState::Active),
719            "disabled" => Some(WidgetState::Disabled),
720            _ => None,
721        }
722    }
723}
724
725/// State selector for style matching - can be single or combined states
726#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
727pub enum StateSelector {
728    /// Single state (e.g., "hover")
729    Single(WidgetState),
730    /// Combined states that must all be active (e.g., "hover:active")
731    /// Sorted for consistent comparison
732    Combined(Vec<WidgetState>),
733}
734
735impl StateSelector {
736    /// Create a single state selector
737    pub fn single(state: WidgetState) -> Self {
738        StateSelector::Single(state)
739    }
740
741    /// Create a combined state selector from multiple states
742    pub fn combined(mut states: Vec<WidgetState>) -> Self {
743        if states.len() == 1 {
744            StateSelector::Single(states[0])
745        } else {
746            // Sort for consistent comparison
747            states.sort();
748            states.dedup(); // Remove duplicates
749            StateSelector::Combined(states)
750        }
751    }
752
753    /// Check if this selector matches the given active states
754    pub fn matches(&self, active_states: &[WidgetState]) -> bool {
755        match self {
756            StateSelector::Single(state) => active_states.contains(state),
757            StateSelector::Combined(required_states) => {
758                required_states.iter().all(|s| active_states.contains(s))
759            }
760        }
761    }
762
763    /// Get specificity for cascade resolution (more specific = higher number)
764    pub fn specificity(&self) -> usize {
765        match self {
766            StateSelector::Single(_) => 1,
767            StateSelector::Combined(states) => states.len(),
768        }
769    }
770}
771
772/// Error codes for theme-related errors
773#[derive(Debug, Clone, PartialEq)]
774pub enum ThemeErrorKind {
775    NoThemesDefined,
776    InvalidDefaultTheme,
777    MissingPaletteColor,
778    InvalidColorValue,
779    DuplicateThemeName,
780    ThemeNotFound,
781    CircularInheritance,
782    ExceedsMaxDepth,
783}
784
785/// Theme-related errors
786#[derive(Debug, Clone, PartialEq)]
787pub struct ThemeError {
788    pub kind: ThemeErrorKind,
789    pub message: String,
790}
791
792impl std::fmt::Display for ThemeError {
793    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
794        write!(f, "{}", self.message)
795    }
796}
797
798impl std::error::Error for ThemeError {}
799
800impl std::fmt::Display for ThemeErrorKind {
801    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
802        match self {
803            ThemeErrorKind::NoThemesDefined => write!(f, "THEME_001"),
804            ThemeErrorKind::InvalidDefaultTheme => write!(f, "THEME_002"),
805            ThemeErrorKind::MissingPaletteColor => write!(f, "THEME_003"),
806            ThemeErrorKind::InvalidColorValue => write!(f, "THEME_004"),
807            ThemeErrorKind::DuplicateThemeName => write!(f, "THEME_005"),
808            ThemeErrorKind::ThemeNotFound => write!(f, "THEME_006"),
809            ThemeErrorKind::CircularInheritance => write!(f, "THEME_007"),
810            ThemeErrorKind::ExceedsMaxDepth => write!(f, "THEME_008"),
811        }
812    }
813}
814
815/// Root document for theme.dampen file
816#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
817pub struct ThemeDocument {
818    /// All defined themes (light, dark, custom, etc.)
819    pub themes: HashMap<String, Theme>,
820
821    /// Default theme name to use on startup
822    /// If None, follows system preference
823    pub default_theme: Option<String>,
824
825    /// Whether to auto-detect system dark/light mode
826    pub follow_system: bool,
827}
828
829impl ThemeDocument {
830    /// Validate the document
831    ///
832    /// Returns an error if:
833    /// - No themes are defined (THEME_001)
834    /// - Default theme is specified but doesn't exist (THEME_002)
835    /// - Any theme fails validation
836    pub fn validate(&self) -> Result<(), ThemeError> {
837        if self.themes.is_empty() {
838            return Err(ThemeError {
839                kind: ThemeErrorKind::NoThemesDefined,
840                message: "THEME_001: At least one theme must be defined in theme.dampen"
841                    .to_string(),
842            });
843        }
844
845        if let Some(ref default) = self.default_theme {
846            if !self.themes.contains_key(default) {
847                let available: Vec<_> = self.themes.keys().cloned().collect();
848                return Err(ThemeError {
849                    kind: ThemeErrorKind::InvalidDefaultTheme,
850                    message: format!(
851                        "THEME_002: Default theme '{}' not found. Available: {}",
852                        default,
853                        available.join(", ")
854                    ),
855                });
856            }
857        }
858
859        for (name, theme) in &self.themes {
860            let allow_partial = theme.extends.is_some();
861            theme.validate(allow_partial).map_err(|e| ThemeError {
862                kind: ThemeErrorKind::MissingPaletteColor,
863                message: format!("THEME_003: Invalid theme '{}': {}", name, e),
864            })?;
865        }
866
867        Ok(())
868    }
869
870    /// Validate inheritance for all themes
871    ///
872    /// Returns an error if:
873    /// - Parent theme doesn't exist (THEME_006)
874    /// - Circular inheritance detected (THEME_007)
875    /// - Inheritance depth exceeds 5 levels (THEME_008)
876    pub fn validate_inheritance(&self) -> Result<(), ThemeError> {
877        for theme in self.themes.values() {
878            let mut visited = Vec::new();
879            theme.validate_inheritance(&self.themes, &mut visited)?;
880        }
881        Ok(())
882    }
883
884    /// Resolve inheritance for all themes
885    ///
886    /// Creates a new HashMap where each theme inherits from its parent.
887    /// Themes without inheritance are copied as-is.
888    pub fn resolve_inheritance(&self) -> HashMap<String, Theme> {
889        let mut resolved = HashMap::new();
890
891        // Helper function to resolve a single theme recursively
892        fn resolve(
893            name: &str,
894            themes: &HashMap<String, Theme>,
895            resolved: &mut HashMap<String, Theme>,
896        ) {
897            if resolved.contains_key(name) {
898                return;
899            }
900
901            if let Some(theme) = themes.get(name) {
902                if let Some(ref parent_name) = theme.extends {
903                    resolve(parent_name, themes, resolved);
904                    if let Some(parent) = resolved.get(parent_name) {
905                        resolved.insert(name.to_string(), theme.inherit_from(parent));
906                    } else {
907                        // Parent not found (should be caught by validation)
908                        resolved.insert(name.to_string(), theme.clone());
909                    }
910                } else {
911                    resolved.insert(name.to_string(), theme.clone());
912                }
913            }
914        }
915
916        for name in self.themes.keys() {
917            resolve(name, &self.themes, &mut resolved);
918        }
919
920        resolved
921    }
922
923    /// Get the effective default theme name
924    ///
925    /// Priority: user_preference > default_theme > system_preference > "light"
926    pub fn effective_default<'a>(&'a self, system_preference: Option<&'a str>) -> &'a str {
927        if let Some(ref default) = self.default_theme {
928            if self.follow_system {
929                if let Some(sys) = system_preference {
930                    if self.themes.contains_key(sys) {
931                        return sys;
932                    }
933                }
934            }
935            return default;
936        } else if self.follow_system {
937            if let Some(sys) = system_preference {
938                if self.themes.contains_key(sys) {
939                    return sys;
940                }
941            }
942        }
943        "light"
944    }
945}
946
947#[cfg(test)]
948mod tests {
949    use super::*;
950
951    #[test]
952    fn test_widget_state_from_prefix_hover() {
953        assert_eq!(WidgetState::from_prefix("hover"), Some(WidgetState::Hover));
954    }
955
956    #[test]
957    fn test_widget_state_from_prefix_focus() {
958        assert_eq!(WidgetState::from_prefix("focus"), Some(WidgetState::Focus));
959    }
960
961    #[test]
962    fn test_widget_state_from_prefix_active() {
963        assert_eq!(
964            WidgetState::from_prefix("active"),
965            Some(WidgetState::Active)
966        );
967    }
968
969    #[test]
970    fn test_widget_state_from_prefix_disabled() {
971        assert_eq!(
972            WidgetState::from_prefix("disabled"),
973            Some(WidgetState::Disabled)
974        );
975    }
976
977    #[test]
978    fn test_widget_state_from_prefix_case_insensitive() {
979        assert_eq!(WidgetState::from_prefix("HOVER"), Some(WidgetState::Hover));
980        assert_eq!(WidgetState::from_prefix("Focus"), Some(WidgetState::Focus));
981        assert_eq!(
982            WidgetState::from_prefix("AcTiVe"),
983            Some(WidgetState::Active)
984        );
985    }
986
987    #[test]
988    fn test_widget_state_from_prefix_invalid() {
989        assert_eq!(WidgetState::from_prefix("unknown"), None);
990        assert_eq!(WidgetState::from_prefix("pressed"), None);
991        assert_eq!(WidgetState::from_prefix(""), None);
992    }
993
994    #[test]
995    fn test_widget_state_from_prefix_with_whitespace() {
996        // from_prefix uses trim() so whitespace should be handled
997        assert_eq!(
998            WidgetState::from_prefix("  hover  "),
999            Some(WidgetState::Hover)
1000        );
1001    }
1002}