Skip to main content

standout_render/theme/
theme.rs

1//! Theme struct for building style collections.
2//!
3//! Themes are named collections of styles that can adapt to the user's
4//! display mode (light/dark). They support both programmatic construction
5//! and YAML-based file loading.
6//!
7//! # Adaptive Styles
8//!
9//! Individual styles can define mode-specific variations. When resolving
10//! styles for rendering, the theme selects the appropriate variant based
11//! on the current color mode:
12//!
13//! - Base styles: Used when no mode override exists
14//! - Light overrides: Applied in light mode
15//! - Dark overrides: Applied in dark mode
16//!
17//! # Construction Methods
18//!
19//! ## Programmatic (Builder API)
20//!
21//! ```rust
22//! use standout_render::Theme;
23//! use console::Style;
24//!
25//! let theme = Theme::new()
26//!     // Non-adaptive styles work in all modes
27//!     .add("muted", Style::new().dim())
28//!     .add("accent", Style::new().cyan().bold())
29//!     // Aliases reference other styles
30//!     .add("disabled", "muted");
31//! ```
32//!
33//! ## From YAML
34//!
35//! ```rust
36//! use standout_render::Theme;
37//!
38//! let theme = Theme::from_yaml(r#"
39//! header:
40//!   fg: cyan
41//!   bold: true
42//!
43//! footer:
44//!   fg: gray
45//!   light:
46//!     fg: black
47//!   dark:
48//!     fg: white
49//!
50//! muted:
51//!   dim: true
52//!
53//! disabled: muted
54//! "#).unwrap();
55//! ```
56//!
57//! # Mode Resolution
58//!
59//! Use [`resolve_styles`](Theme::resolve_styles) to get a `Styles` collection
60//! for a specific color mode. This is typically called during rendering.
61
62use std::collections::HashMap;
63use std::path::{Path, PathBuf};
64
65use console::Style;
66
67use super::super::style::{
68    parse_stylesheet, StyleValidationError, StyleValue, Styles, StylesheetError, ThemeVariants,
69};
70
71use super::adaptive::ColorMode;
72use super::icon_def::{IconDefinition, IconSet};
73use super::icon_mode::IconMode;
74
75/// A named collection of styles used when rendering templates.
76///
77/// Themes can be constructed programmatically or loaded from YAML files.
78/// They support adaptive styles that vary based on the user's color mode.
79///
80/// # Example: Programmatic Construction
81///
82/// ```rust
83/// use standout_render::Theme;
84/// use console::Style;
85///
86/// let theme = Theme::new()
87///     // Visual layer - concrete styles
88///     .add("muted", Style::new().dim())
89///     .add("accent", Style::new().cyan().bold())
90///     // Presentation layer - aliases
91///     .add("disabled", "muted")
92///     .add("highlighted", "accent")
93///     // Semantic layer - aliases to presentation
94///     .add("timestamp", "disabled");
95/// ```
96///
97/// # Example: From YAML
98///
99/// ```rust
100/// use standout_render::Theme;
101///
102/// let theme = Theme::from_yaml(r#"
103/// panel:
104///   bg: gray
105///   light:
106///     bg: white
107///   dark:
108///     bg: black
109/// header:
110///   fg: cyan
111///   bold: true
112/// "#).unwrap();
113/// ```
114#[derive(Debug, Clone)]
115pub struct Theme {
116    /// Theme name (optional, typically derived from filename).
117    name: Option<String>,
118    /// Source file path (for refresh support).
119    source_path: Option<PathBuf>,
120    /// Base styles (always populated).
121    base: HashMap<String, Style>,
122    /// Light mode style overrides.
123    light: HashMap<String, Style>,
124    /// Dark mode style overrides.
125    dark: HashMap<String, Style>,
126    /// Alias definitions (name → target).
127    aliases: HashMap<String, String>,
128    /// Icon definitions (classic + optional nerdfont variants).
129    icons: IconSet,
130}
131
132impl Theme {
133    /// Creates an empty, unnamed theme.
134    pub fn new() -> Self {
135        Self {
136            name: None,
137            source_path: None,
138            base: HashMap::new(),
139            light: HashMap::new(),
140            dark: HashMap::new(),
141            aliases: HashMap::new(),
142            icons: IconSet::new(),
143        }
144    }
145
146    /// Creates an empty theme with the given name.
147    pub fn named(name: impl Into<String>) -> Self {
148        Self {
149            name: Some(name.into()),
150            source_path: None,
151            base: HashMap::new(),
152            light: HashMap::new(),
153            dark: HashMap::new(),
154            aliases: HashMap::new(),
155            icons: IconSet::new(),
156        }
157    }
158
159    /// Sets the name on this theme, returning `self` for chaining.
160    ///
161    /// This is useful when loading themes from content where the name
162    /// is known separately (e.g., from a filename).
163    pub fn with_name(mut self, name: impl Into<String>) -> Self {
164        self.name = Some(name.into());
165        self
166    }
167
168    /// Loads a theme from a YAML file.
169    ///
170    /// The theme name is derived from the filename (without extension).
171    /// The source path is stored for [`refresh`](Theme::refresh) support.
172    ///
173    /// # Errors
174    ///
175    /// Returns a [`StylesheetError`] if the file cannot be read or parsed.
176    ///
177    /// # Example
178    ///
179    /// ```rust,ignore
180    /// use standout_render::Theme;
181    ///
182    /// let theme = Theme::from_file("./themes/darcula.yaml")?;
183    /// assert_eq!(theme.name(), Some("darcula"));
184    /// ```
185    pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self, StylesheetError> {
186        let path = path.as_ref();
187        let content = std::fs::read_to_string(path).map_err(|e| StylesheetError::Load {
188            message: format!("Failed to read {}: {}", path.display(), e),
189        })?;
190
191        let name = path
192            .file_stem()
193            .and_then(|s| s.to_str())
194            .map(|s| s.to_string());
195
196        let icons = parse_icons_from_yaml_str(&content)?;
197        let variants = parse_stylesheet(&content)?;
198        Ok(Self {
199            name,
200            source_path: Some(path.to_path_buf()),
201            base: variants.base().clone(),
202            light: variants.light().clone(),
203            dark: variants.dark().clone(),
204            aliases: variants.aliases().clone(),
205            icons,
206        })
207    }
208
209    /// Creates a theme from YAML content.
210    ///
211    /// The YAML format supports:
212    /// - Simple styles: `header: { fg: cyan, bold: true }`
213    /// - Shorthand: `bold_text: bold` or `warning: "yellow italic"`
214    /// - Aliases: `disabled: muted`
215    /// - Adaptive styles with `light:` and `dark:` sections
216    ///
217    /// # Errors
218    ///
219    /// Returns a [`StylesheetError`] if parsing fails.
220    ///
221    /// # Example
222    ///
223    /// ```rust
224    /// use standout_render::Theme;
225    ///
226    /// let theme = Theme::from_yaml(r#"
227    /// header:
228    ///   fg: cyan
229    ///   bold: true
230    ///
231    /// footer:
232    ///   dim: true
233    ///   light:
234    ///     fg: black
235    ///   dark:
236    ///     fg: white
237    /// "#).unwrap();
238    /// ```
239    pub fn from_yaml(yaml: &str) -> Result<Self, StylesheetError> {
240        let icons = parse_icons_from_yaml_str(yaml)?;
241        let variants = parse_stylesheet(yaml)?;
242        Ok(Self {
243            name: None,
244            source_path: None,
245            base: variants.base().clone(),
246            light: variants.light().clone(),
247            dark: variants.dark().clone(),
248            aliases: variants.aliases().clone(),
249            icons,
250        })
251    }
252
253    /// Creates a theme from CSS content.
254    ///
255    /// The CSS format supports a subset of CSS Level 3 tailored for terminals.
256    /// Class selectors map to style names (`.title { ... }` defines the `title` style).
257    /// Adaptive styles use `@media (prefers-color-scheme: light|dark)` queries.
258    ///
259    /// # Errors
260    ///
261    /// Returns a [`StylesheetError`] if parsing fails.
262    ///
263    /// # Example
264    ///
265    /// ```rust
266    /// use standout_render::Theme;
267    ///
268    /// let theme = Theme::from_css(r#"
269    /// .header { color: cyan; font-weight: bold; }
270    /// .muted { opacity: 0.5; }
271    /// "#).unwrap();
272    /// ```
273    pub fn from_css(css: &str) -> Result<Self, StylesheetError> {
274        let variants = crate::parse_css(css)?;
275        Ok(Self {
276            name: None,
277            source_path: None,
278            base: variants.base().clone(),
279            light: variants.light().clone(),
280            dark: variants.dark().clone(),
281            aliases: variants.aliases().clone(),
282            icons: IconSet::new(),
283        })
284    }
285
286    /// Loads a theme from a CSS file.
287    ///
288    /// The theme name is derived from the filename (without extension).
289    /// The source path is stored for [`refresh`](Theme::refresh) support.
290    ///
291    /// # Errors
292    ///
293    /// Returns a [`StylesheetError`] if the file cannot be read or parsed.
294    ///
295    /// # Example
296    ///
297    /// ```rust,ignore
298    /// use standout_render::Theme;
299    ///
300    /// let theme = Theme::from_css_file("./themes/default.css")?;
301    /// assert_eq!(theme.name(), Some("default"));
302    /// ```
303    pub fn from_css_file<P: AsRef<Path>>(path: P) -> Result<Self, StylesheetError> {
304        let path = path.as_ref();
305        let content = std::fs::read_to_string(path).map_err(|e| StylesheetError::Load {
306            message: format!("Failed to read {}: {}", path.display(), e),
307        })?;
308
309        let name = path
310            .file_stem()
311            .and_then(|s| s.to_str())
312            .map(|s| s.to_string());
313
314        let variants = crate::parse_css(&content)?;
315        Ok(Self {
316            name,
317            source_path: Some(path.to_path_buf()),
318            base: variants.base().clone(),
319            light: variants.light().clone(),
320            dark: variants.dark().clone(),
321            aliases: variants.aliases().clone(),
322            icons: IconSet::new(),
323        })
324    }
325
326    /// Creates a theme from pre-parsed theme variants.
327    pub fn from_variants(variants: ThemeVariants) -> Self {
328        Self {
329            name: None,
330            source_path: None,
331            base: variants.base().clone(),
332            light: variants.light().clone(),
333            dark: variants.dark().clone(),
334            aliases: variants.aliases().clone(),
335            icons: IconSet::new(),
336        }
337    }
338
339    /// Returns the theme name, if set.
340    ///
341    /// The name is typically derived from the source filename when using
342    /// [`from_file`](Theme::from_file), or set explicitly with [`named`](Theme::named).
343    pub fn name(&self) -> Option<&str> {
344        self.name.as_deref()
345    }
346
347    /// Returns the source file path, if this theme was loaded from a file.
348    pub fn source_path(&self) -> Option<&Path> {
349        self.source_path.as_deref()
350    }
351
352    /// Reloads the theme from its source file.
353    ///
354    /// This is useful for hot-reloading during development. If the theme
355    /// was not loaded from a file, this method returns an error.
356    ///
357    /// # Errors
358    ///
359    /// Returns a [`StylesheetError`] if:
360    /// - The theme has no source file (wasn't loaded with [`from_file`](Theme::from_file))
361    /// - The file cannot be read or parsed
362    ///
363    /// # Example
364    ///
365    /// ```rust,ignore
366    /// let mut theme = Theme::from_file("./themes/darcula.yaml")?;
367    ///
368    /// // After editing the file...
369    /// theme.refresh()?;
370    /// ```
371    pub fn refresh(&mut self) -> Result<(), StylesheetError> {
372        let path = self
373            .source_path
374            .as_ref()
375            .ok_or_else(|| StylesheetError::Load {
376                message: "Cannot refresh: theme has no source file".to_string(),
377            })?;
378
379        let content = std::fs::read_to_string(path).map_err(|e| StylesheetError::Load {
380            message: format!("Failed to read {}: {}", path.display(), e),
381        })?;
382
383        let icons = parse_icons_from_yaml_str(&content)?;
384        let variants = parse_stylesheet(&content)?;
385        self.base = variants.base().clone();
386        self.light = variants.light().clone();
387        self.dark = variants.dark().clone();
388        self.aliases = variants.aliases().clone();
389        self.icons = icons;
390
391        Ok(())
392    }
393
394    /// Adds a named style, returning an updated theme for chaining.
395    ///
396    /// The value can be either a concrete `Style` or a `&str`/`String` alias
397    /// to another style name, enabling layered styling.
398    ///
399    /// # Non-Adaptive
400    ///
401    /// Styles added via this method are non-adaptive (same in all modes).
402    /// For adaptive styles, use [`add_adaptive`](Self::add_adaptive) or YAML.
403    ///
404    /// # Example
405    ///
406    /// ```rust
407    /// use standout_render::Theme;
408    /// use console::Style;
409    ///
410    /// let theme = Theme::new()
411    ///     // Visual layer - concrete styles
412    ///     .add("muted", Style::new().dim())
413    ///     .add("accent", Style::new().cyan().bold())
414    ///     // Presentation layer - aliases
415    ///     .add("disabled", "muted")
416    ///     .add("highlighted", "accent")
417    ///     // Semantic layer - aliases to presentation
418    ///     .add("timestamp", "disabled");
419    /// ```
420    pub fn add<V: Into<StyleValue>>(mut self, name: &str, value: V) -> Self {
421        match value.into() {
422            StyleValue::Concrete(style) => {
423                self.base.insert(name.to_string(), style);
424            }
425            StyleValue::Alias(target) => {
426                self.aliases.insert(name.to_string(), target);
427            }
428        }
429        self
430    }
431
432    /// Adds an adaptive style with separate light and dark variants.
433    ///
434    /// The base style is used when no mode override exists or when mode
435    /// detection fails. Light and dark variants, if provided, override
436    /// the base in their respective modes.
437    ///
438    /// # Example
439    ///
440    /// ```rust
441    /// use standout_render::Theme;
442    /// use console::Style;
443    ///
444    /// let theme = Theme::new()
445    ///     .add_adaptive(
446    ///         "panel",
447    ///         Style::new().dim(),                    // Base
448    ///         Some(Style::new().fg(console::Color::Black)),  // Light mode
449    ///         Some(Style::new().fg(console::Color::White)),  // Dark mode
450    ///     );
451    /// ```
452    pub fn add_adaptive(
453        mut self,
454        name: &str,
455        base: Style,
456        light: Option<Style>,
457        dark: Option<Style>,
458    ) -> Self {
459        self.base.insert(name.to_string(), base);
460        if let Some(light_style) = light {
461            self.light.insert(name.to_string(), light_style);
462        }
463        if let Some(dark_style) = dark {
464            self.dark.insert(name.to_string(), dark_style);
465        }
466        self
467    }
468
469    /// Adds an icon definition to the theme, returning `self` for chaining.
470    ///
471    /// Icons are characters (not images) that adapt between classic Unicode
472    /// and Nerd Font glyphs. Each icon has a classic variant and an optional
473    /// Nerd Font variant.
474    ///
475    /// # Example
476    ///
477    /// ```rust
478    /// use standout_render::{Theme, IconDefinition};
479    ///
480    /// let theme = Theme::new()
481    ///     .add_icon("pending", IconDefinition::new("⚪"))
482    ///     .add_icon("done", IconDefinition::new("⚫").with_nerdfont("\u{f00c}"));
483    /// ```
484    pub fn add_icon(mut self, name: &str, def: IconDefinition) -> Self {
485        self.icons.insert(name.to_string(), def);
486        self
487    }
488
489    /// Resolves icons for the given icon mode.
490    ///
491    /// Returns a map of icon names to resolved strings for the given mode.
492    ///
493    /// # Example
494    ///
495    /// ```rust
496    /// use standout_render::{Theme, IconDefinition, IconMode};
497    ///
498    /// let theme = Theme::new()
499    ///     .add_icon("done", IconDefinition::new("⚫").with_nerdfont("\u{f00c}"));
500    ///
501    /// let classic = theme.resolve_icons(IconMode::Classic);
502    /// assert_eq!(classic.get("done").unwrap(), "⚫");
503    ///
504    /// let nerdfont = theme.resolve_icons(IconMode::NerdFont);
505    /// assert_eq!(nerdfont.get("done").unwrap(), "\u{f00c}");
506    /// ```
507    pub fn resolve_icons(&self, mode: IconMode) -> HashMap<String, String> {
508        self.icons.resolve(mode)
509    }
510
511    /// Returns a reference to the icon set.
512    pub fn icons(&self) -> &IconSet {
513        &self.icons
514    }
515
516    /// Resolves styles for the given color mode.
517    ///
518    /// Returns a [`Styles`] collection with the appropriate style for each
519    /// defined style name:
520    ///
521    /// - For styles with a mode-specific override, uses the override
522    /// - For styles without an override, uses the base style
523    /// - Aliases are preserved for resolution during rendering
524    ///
525    /// # Example
526    ///
527    /// ```rust
528    /// use standout_render::{Theme, ColorMode};
529    /// use console::Style;
530    ///
531    /// let theme = Theme::new()
532    ///     .add("header", Style::new().cyan())
533    ///     .add_adaptive(
534    ///         "panel",
535    ///         Style::new(),
536    ///         Some(Style::new().fg(console::Color::Black)),
537    ///         Some(Style::new().fg(console::Color::White)),
538    ///     );
539    ///
540    /// // Get styles for dark mode
541    /// let dark_styles = theme.resolve_styles(Some(ColorMode::Dark));
542    /// ```
543    pub fn resolve_styles(&self, mode: Option<ColorMode>) -> Styles {
544        let mut styles = Styles::new();
545
546        // Select the mode-specific overrides map
547        let mode_overrides = match mode {
548            Some(ColorMode::Light) => &self.light,
549            Some(ColorMode::Dark) => &self.dark,
550            None => &HashMap::new(),
551        };
552
553        // Add concrete styles (base, with mode overrides applied)
554        for (name, base_style) in &self.base {
555            let style = mode_overrides.get(name).unwrap_or(base_style);
556            styles = styles.add(name, style.clone());
557        }
558
559        // Add aliases
560        for (name, target) in &self.aliases {
561            styles = styles.add(name, target.clone());
562        }
563
564        styles
565    }
566
567    /// Validates that all style aliases in this theme resolve correctly.
568    ///
569    /// This is called automatically at render time, but can be called
570    /// explicitly for early error detection.
571    pub fn validate(&self) -> Result<(), StyleValidationError> {
572        // Validate using a resolved Styles instance
573        self.resolve_styles(None).validate()
574    }
575
576    /// Returns true if no styles are defined.
577    pub fn is_empty(&self) -> bool {
578        self.base.is_empty() && self.aliases.is_empty()
579    }
580
581    /// Returns the number of defined styles (base + aliases).
582    pub fn len(&self) -> usize {
583        self.base.len() + self.aliases.len()
584    }
585
586    /// Resolves a single style for the given mode.
587    ///
588    /// This is a convenience wrapper around [`resolve_styles`](Self::resolve_styles).
589    pub fn get_style(&self, name: &str, mode: Option<ColorMode>) -> Option<Style> {
590        let styles = self.resolve_styles(mode);
591        // Styles::resolve is crate-private, so we have to use to_resolved_map or check internal.
592        // Wait, Styles::resolve is pub(crate). We are in rendering/theme/theme.rs,
593        // Styles is in rendering/style/registry.rs. Same crate.
594        // But Theme is in `rendering::theme`, Styles in `rendering::style`.
595        // They are different modules. `pub(crate)` is visible.
596        styles.resolve(name).cloned()
597    }
598
599    /// Returns the number of light mode overrides.
600    pub fn light_override_count(&self) -> usize {
601        self.light.len()
602    }
603
604    /// Returns the number of dark mode overrides.
605    pub fn dark_override_count(&self) -> usize {
606        self.dark.len()
607    }
608
609    /// Merges another theme into this one.
610    ///
611    /// Styles from `other` take precedence over styles in `self`.
612    /// This allows layering themes, e.g., loading a base theme and applying user overrides.
613    ///
614    /// # Example
615    ///
616    /// ```rust
617    /// use standout_render::Theme;
618    /// use console::Style;
619    ///
620    /// let base = Theme::new().add("text", Style::new().dim());
621    /// let user = Theme::new().add("text", Style::new().bold());
622    ///
623    /// let merged = base.merge(user);
624    /// // "text" is now bold (from user)
625    /// ```
626    pub fn merge(mut self, other: Theme) -> Self {
627        self.base.extend(other.base);
628        self.light.extend(other.light);
629        self.dark.extend(other.dark);
630        self.aliases.extend(other.aliases);
631        self.icons = self.icons.merge(other.icons);
632        self
633    }
634}
635
636impl Default for Theme {
637    fn default() -> Self {
638        Self::new()
639    }
640}
641
642/// Parses icon definitions from a YAML string.
643///
644/// Extracts the `icons:` section from the YAML root mapping and
645/// parses each entry into an [`IconDefinition`].
646///
647/// # YAML Format
648///
649/// ```yaml
650/// icons:
651///   # String shorthand (classic only)
652///   pending: "⚪"
653///
654///   # Mapping with both variants
655///   done:
656///     classic: "⚫"
657///     nerdfont: "\uf00c"
658/// ```
659fn parse_icons_from_yaml_str(yaml: &str) -> Result<IconSet, StylesheetError> {
660    let root: serde_yaml::Value =
661        serde_yaml::from_str(yaml).map_err(|e| StylesheetError::Parse {
662            path: None,
663            message: e.to_string(),
664        })?;
665
666    parse_icons_from_yaml(&root)
667}
668
669/// Parses icon definitions from a parsed YAML value.
670fn parse_icons_from_yaml(root: &serde_yaml::Value) -> Result<IconSet, StylesheetError> {
671    let mut icon_set = IconSet::new();
672
673    let mapping = match root.as_mapping() {
674        Some(m) => m,
675        None => return Ok(icon_set),
676    };
677
678    let icons_value = match mapping.get(serde_yaml::Value::String("icons".into())) {
679        Some(v) => v,
680        None => return Ok(icon_set),
681    };
682
683    let icons_map = icons_value
684        .as_mapping()
685        .ok_or_else(|| StylesheetError::Parse {
686            path: None,
687            message: "'icons' must be a mapping".to_string(),
688        })?;
689
690    for (key, value) in icons_map {
691        let name = key.as_str().ok_or_else(|| StylesheetError::Parse {
692            path: None,
693            message: format!("Icon name must be a string, got {:?}", key),
694        })?;
695
696        let def = match value {
697            serde_yaml::Value::String(s) => {
698                // Shorthand: classic-only
699                IconDefinition::new(s.clone())
700            }
701            serde_yaml::Value::Mapping(map) => {
702                let classic = map
703                    .get(serde_yaml::Value::String("classic".into()))
704                    .and_then(|v| v.as_str())
705                    .ok_or_else(|| StylesheetError::InvalidDefinition {
706                        style: name.to_string(),
707                        message: "Icon mapping must have a 'classic' key".to_string(),
708                        path: None,
709                    })?;
710                let nerdfont = map
711                    .get(serde_yaml::Value::String("nerdfont".into()))
712                    .and_then(|v| v.as_str());
713                let mut def = IconDefinition::new(classic);
714                if let Some(nf) = nerdfont {
715                    def = def.with_nerdfont(nf);
716                }
717                def
718            }
719            _ => {
720                return Err(StylesheetError::InvalidDefinition {
721                    style: name.to_string(),
722                    message: "Icon must be a string or mapping with 'classic' key".to_string(),
723                    path: None,
724                });
725            }
726        };
727
728        icon_set.insert(name.to_string(), def);
729    }
730
731    Ok(icon_set)
732}
733
734#[cfg(test)]
735mod tests {
736    use super::*;
737
738    #[test]
739    fn test_theme_new_is_empty() {
740        let theme = Theme::new();
741        assert!(theme.is_empty());
742        assert_eq!(theme.len(), 0);
743    }
744
745    #[test]
746    fn test_theme_add_concrete() {
747        let theme = Theme::new().add("bold", Style::new().bold());
748        assert!(!theme.is_empty());
749        assert_eq!(theme.len(), 1);
750    }
751
752    #[test]
753    fn test_theme_add_alias_str() {
754        let theme = Theme::new()
755            .add("base", Style::new().dim())
756            .add("alias", "base");
757
758        assert_eq!(theme.len(), 2);
759
760        let styles = theme.resolve_styles(None);
761        assert!(styles.has("base"));
762        assert!(styles.has("alias"));
763    }
764
765    #[test]
766    fn test_theme_add_alias_string() {
767        let target = String::from("base");
768        let theme = Theme::new()
769            .add("base", Style::new().dim())
770            .add("alias", target);
771
772        let styles = theme.resolve_styles(None);
773        assert!(styles.has("alias"));
774    }
775
776    #[test]
777    fn test_theme_validate_valid() {
778        let theme = Theme::new()
779            .add("visual", Style::new().cyan())
780            .add("semantic", "visual");
781
782        assert!(theme.validate().is_ok());
783    }
784
785    #[test]
786    fn test_theme_validate_invalid() {
787        let theme = Theme::new().add("orphan", "missing");
788        assert!(theme.validate().is_err());
789    }
790
791    #[test]
792    fn test_theme_default() {
793        let theme = Theme::default();
794        assert!(theme.is_empty());
795    }
796
797    // =========================================================================
798    // Adaptive style tests
799    // =========================================================================
800
801    #[test]
802    fn test_theme_add_adaptive() {
803        let theme = Theme::new().add_adaptive(
804            "panel",
805            Style::new().dim(),
806            Some(Style::new().bold()),
807            Some(Style::new().italic()),
808        );
809
810        assert_eq!(theme.len(), 1);
811        assert_eq!(theme.light_override_count(), 1);
812        assert_eq!(theme.dark_override_count(), 1);
813    }
814
815    #[test]
816    fn test_theme_add_adaptive_light_only() {
817        let theme =
818            Theme::new().add_adaptive("panel", Style::new().dim(), Some(Style::new().bold()), None);
819
820        assert_eq!(theme.light_override_count(), 1);
821        assert_eq!(theme.dark_override_count(), 0);
822    }
823
824    #[test]
825    fn test_theme_add_adaptive_dark_only() {
826        let theme =
827            Theme::new().add_adaptive("panel", Style::new().dim(), None, Some(Style::new().bold()));
828
829        assert_eq!(theme.light_override_count(), 0);
830        assert_eq!(theme.dark_override_count(), 1);
831    }
832
833    #[test]
834    fn test_theme_resolve_styles_no_mode() {
835        let theme = Theme::new()
836            .add("header", Style::new().cyan())
837            .add_adaptive(
838                "panel",
839                Style::new().dim(),
840                Some(Style::new().bold()),
841                Some(Style::new().italic()),
842            );
843
844        let styles = theme.resolve_styles(None);
845        assert!(styles.has("header"));
846        assert!(styles.has("panel"));
847    }
848
849    #[test]
850    fn test_theme_resolve_styles_light_mode() {
851        let theme = Theme::new().add_adaptive(
852            "panel",
853            Style::new().dim(),
854            Some(Style::new().bold()),
855            Some(Style::new().italic()),
856        );
857
858        let styles = theme.resolve_styles(Some(ColorMode::Light));
859        assert!(styles.has("panel"));
860        // The style should be the light override, not base
861        // We can't easily check the actual style, but we verify resolution works
862    }
863
864    #[test]
865    fn test_theme_resolve_styles_dark_mode() {
866        let theme = Theme::new().add_adaptive(
867            "panel",
868            Style::new().dim(),
869            Some(Style::new().bold()),
870            Some(Style::new().italic()),
871        );
872
873        let styles = theme.resolve_styles(Some(ColorMode::Dark));
874        assert!(styles.has("panel"));
875    }
876
877    #[test]
878    fn test_theme_resolve_styles_preserves_aliases() {
879        let theme = Theme::new()
880            .add("base", Style::new().dim())
881            .add("alias", "base");
882
883        let styles = theme.resolve_styles(Some(ColorMode::Light));
884        assert!(styles.has("base"));
885        assert!(styles.has("alias"));
886
887        // Validate that alias resolution still works
888        assert!(styles.validate().is_ok());
889    }
890
891    // =========================================================================
892    // YAML parsing tests
893    // =========================================================================
894
895    #[test]
896    fn test_theme_from_yaml_simple() {
897        let theme = Theme::from_yaml(
898            r#"
899            header:
900                fg: cyan
901                bold: true
902            "#,
903        )
904        .unwrap();
905
906        assert_eq!(theme.len(), 1);
907        let styles = theme.resolve_styles(None);
908        assert!(styles.has("header"));
909    }
910
911    #[test]
912    fn test_theme_from_yaml_shorthand() {
913        let theme = Theme::from_yaml(
914            r#"
915            bold_text: bold
916            accent: cyan
917            warning: "yellow italic"
918            "#,
919        )
920        .unwrap();
921
922        assert_eq!(theme.len(), 3);
923    }
924
925    #[test]
926    fn test_theme_from_yaml_alias() {
927        let theme = Theme::from_yaml(
928            r#"
929            muted:
930                dim: true
931            disabled: muted
932            "#,
933        )
934        .unwrap();
935
936        assert_eq!(theme.len(), 2);
937        assert!(theme.validate().is_ok());
938    }
939
940    #[test]
941    fn test_theme_from_yaml_adaptive() {
942        let theme = Theme::from_yaml(
943            r#"
944            panel:
945                fg: gray
946                light:
947                    fg: black
948                dark:
949                    fg: white
950            "#,
951        )
952        .unwrap();
953
954        assert_eq!(theme.len(), 1);
955        assert_eq!(theme.light_override_count(), 1);
956        assert_eq!(theme.dark_override_count(), 1);
957    }
958
959    #[test]
960    fn test_theme_from_yaml_invalid() {
961        let result = Theme::from_yaml("not valid yaml: [");
962        assert!(result.is_err());
963    }
964
965    #[test]
966    fn test_theme_from_yaml_complete() {
967        let theme = Theme::from_yaml(
968            r##"
969            # Visual layer
970            muted:
971                dim: true
972
973            accent:
974                fg: cyan
975                bold: true
976
977            # Adaptive
978            background:
979                light:
980                    bg: "#f8f8f8"
981                dark:
982                    bg: "#1e1e1e"
983
984            # Aliases
985            header: accent
986            footer: muted
987            "##,
988        )
989        .unwrap();
990
991        // 3 concrete styles + 2 aliases = 5 total
992        assert_eq!(theme.len(), 5);
993        assert!(theme.validate().is_ok());
994
995        // background is adaptive
996        assert_eq!(theme.light_override_count(), 1);
997        assert_eq!(theme.dark_override_count(), 1);
998    }
999
1000    // =========================================================================
1001    // Name and source path tests
1002    // =========================================================================
1003
1004    #[test]
1005    fn test_theme_named() {
1006        let theme = Theme::named("darcula");
1007        assert_eq!(theme.name(), Some("darcula"));
1008        assert!(theme.is_empty());
1009    }
1010
1011    #[test]
1012    fn test_theme_new_has_no_name() {
1013        let theme = Theme::new();
1014        assert_eq!(theme.name(), None);
1015        assert_eq!(theme.source_path(), None);
1016    }
1017
1018    #[test]
1019    fn test_theme_from_file() {
1020        use std::fs;
1021        use tempfile::TempDir;
1022
1023        let temp_dir = TempDir::new().unwrap();
1024        let theme_path = temp_dir.path().join("darcula.yaml");
1025        fs::write(
1026            &theme_path,
1027            r#"
1028            header:
1029                fg: cyan
1030                bold: true
1031            muted:
1032                dim: true
1033            "#,
1034        )
1035        .unwrap();
1036
1037        let theme = Theme::from_file(&theme_path).unwrap();
1038        assert_eq!(theme.name(), Some("darcula"));
1039        assert_eq!(theme.source_path(), Some(theme_path.as_path()));
1040        assert_eq!(theme.len(), 2);
1041    }
1042
1043    #[test]
1044    fn test_theme_from_file_not_found() {
1045        let result = Theme::from_file("/nonexistent/path/theme.yaml");
1046        assert!(result.is_err());
1047    }
1048
1049    #[test]
1050    fn test_theme_refresh() {
1051        use std::fs;
1052        use tempfile::TempDir;
1053
1054        let temp_dir = TempDir::new().unwrap();
1055        let theme_path = temp_dir.path().join("dynamic.yaml");
1056        fs::write(
1057            &theme_path,
1058            r#"
1059            header:
1060                fg: red
1061            "#,
1062        )
1063        .unwrap();
1064
1065        let mut theme = Theme::from_file(&theme_path).unwrap();
1066        assert_eq!(theme.len(), 1);
1067
1068        // Update the file
1069        fs::write(
1070            &theme_path,
1071            r#"
1072            header:
1073                fg: blue
1074            footer:
1075                dim: true
1076            "#,
1077        )
1078        .unwrap();
1079
1080        // Refresh
1081        theme.refresh().unwrap();
1082        assert_eq!(theme.len(), 2);
1083    }
1084
1085    #[test]
1086    fn test_theme_refresh_without_source() {
1087        let mut theme = Theme::new();
1088        let result = theme.refresh();
1089        assert!(result.is_err());
1090    }
1091
1092    #[test]
1093    fn test_theme_merge() {
1094        let base = Theme::new()
1095            .add("keep", Style::new().dim())
1096            .add("overwrite", Style::new().red());
1097
1098        let extension = Theme::new()
1099            .add("overwrite", Style::new().blue())
1100            .add("new", Style::new().bold());
1101
1102        let merged = base.merge(extension);
1103
1104        let styles = merged.resolve_styles(None);
1105
1106        // "keep" should be from base
1107        assert!(styles.has("keep"));
1108
1109        // "overwrite" should be from extension (blue, not red)
1110        assert!(styles.has("overwrite"));
1111
1112        // "new" should be from extension
1113        assert!(styles.has("new"));
1114
1115        assert_eq!(merged.len(), 3);
1116    }
1117
1118    // =========================================================================
1119    // Icon tests
1120    // =========================================================================
1121
1122    #[test]
1123    fn test_theme_add_icon() {
1124        let theme = Theme::new()
1125            .add_icon("pending", IconDefinition::new("⚪"))
1126            .add_icon("done", IconDefinition::new("⚫").with_nerdfont("\u{f00c}"));
1127
1128        assert_eq!(theme.icons().len(), 2);
1129        assert!(!theme.icons().is_empty());
1130    }
1131
1132    #[test]
1133    fn test_theme_resolve_icons_classic() {
1134        let theme = Theme::new()
1135            .add_icon("pending", IconDefinition::new("⚪"))
1136            .add_icon("done", IconDefinition::new("⚫").with_nerdfont("\u{f00c}"));
1137
1138        let resolved = theme.resolve_icons(IconMode::Classic);
1139        assert_eq!(resolved.get("pending").unwrap(), "⚪");
1140        assert_eq!(resolved.get("done").unwrap(), "⚫");
1141    }
1142
1143    #[test]
1144    fn test_theme_resolve_icons_nerdfont() {
1145        let theme = Theme::new()
1146            .add_icon("pending", IconDefinition::new("⚪"))
1147            .add_icon("done", IconDefinition::new("⚫").with_nerdfont("\u{f00c}"));
1148
1149        let resolved = theme.resolve_icons(IconMode::NerdFont);
1150        assert_eq!(resolved.get("pending").unwrap(), "⚪"); // No nerdfont, falls back
1151        assert_eq!(resolved.get("done").unwrap(), "\u{f00c}");
1152    }
1153
1154    #[test]
1155    fn test_theme_icons_empty_by_default() {
1156        let theme = Theme::new();
1157        assert!(theme.icons().is_empty());
1158    }
1159
1160    #[test]
1161    fn test_theme_merge_with_icons() {
1162        let base = Theme::new()
1163            .add_icon("keep", IconDefinition::new("K"))
1164            .add_icon("override", IconDefinition::new("OLD"));
1165
1166        let ext = Theme::new()
1167            .add_icon("override", IconDefinition::new("NEW"))
1168            .add_icon("added", IconDefinition::new("A"));
1169
1170        let merged = base.merge(ext);
1171        assert_eq!(merged.icons().len(), 3);
1172
1173        let resolved = merged.resolve_icons(IconMode::Classic);
1174        assert_eq!(resolved.get("keep").unwrap(), "K");
1175        assert_eq!(resolved.get("override").unwrap(), "NEW");
1176        assert_eq!(resolved.get("added").unwrap(), "A");
1177    }
1178
1179    #[test]
1180    fn test_theme_from_yaml_with_icons() {
1181        let theme = Theme::from_yaml(
1182            r#"
1183            header:
1184                fg: cyan
1185                bold: true
1186            icons:
1187                pending: "⚪"
1188                done:
1189                    classic: "⚫"
1190                    nerdfont: "nf_done"
1191            "#,
1192        )
1193        .unwrap();
1194
1195        // Styles
1196        assert_eq!(theme.len(), 1);
1197        let styles = theme.resolve_styles(None);
1198        assert!(styles.has("header"));
1199
1200        // Icons
1201        assert_eq!(theme.icons().len(), 2);
1202        let resolved = theme.resolve_icons(IconMode::Classic);
1203        assert_eq!(resolved.get("pending").unwrap(), "⚪");
1204        assert_eq!(resolved.get("done").unwrap(), "⚫");
1205
1206        let resolved = theme.resolve_icons(IconMode::NerdFont);
1207        assert_eq!(resolved.get("done").unwrap(), "nf_done");
1208    }
1209
1210    #[test]
1211    fn test_theme_from_yaml_no_icons() {
1212        let theme = Theme::from_yaml(
1213            r#"
1214            header:
1215                fg: cyan
1216            "#,
1217        )
1218        .unwrap();
1219
1220        assert!(theme.icons().is_empty());
1221    }
1222
1223    #[test]
1224    fn test_theme_from_yaml_icons_only() {
1225        let theme = Theme::from_yaml(
1226            r#"
1227            icons:
1228                check: "✓"
1229            "#,
1230        )
1231        .unwrap();
1232
1233        assert_eq!(theme.icons().len(), 1);
1234        assert_eq!(theme.len(), 0); // No styles
1235    }
1236
1237    #[test]
1238    fn test_theme_from_yaml_icons_invalid_type() {
1239        let result = Theme::from_yaml(
1240            r#"
1241            icons:
1242                bad: 42
1243            "#,
1244        );
1245        assert!(result.is_err());
1246    }
1247
1248    #[test]
1249    fn test_theme_from_yaml_icons_mapping_without_classic() {
1250        let result = Theme::from_yaml(
1251            r#"
1252            icons:
1253                bad:
1254                    nerdfont: "nf"
1255            "#,
1256        );
1257        assert!(result.is_err());
1258    }
1259
1260    #[test]
1261    fn test_theme_from_file_with_icons() {
1262        use std::fs;
1263        use tempfile::TempDir;
1264
1265        let temp_dir = TempDir::new().unwrap();
1266        let theme_path = temp_dir.path().join("iconic.yaml");
1267        fs::write(
1268            &theme_path,
1269            r#"
1270            header:
1271                fg: cyan
1272            icons:
1273                check:
1274                    classic: "[ok]"
1275                    nerdfont: "nf_check"
1276            "#,
1277        )
1278        .unwrap();
1279
1280        let theme = Theme::from_file(&theme_path).unwrap();
1281        assert_eq!(theme.icons().len(), 1);
1282        let resolved = theme.resolve_icons(IconMode::NerdFont);
1283        assert_eq!(resolved.get("check").unwrap(), "nf_check");
1284    }
1285
1286    #[test]
1287    fn test_theme_refresh_with_icons() {
1288        use std::fs;
1289        use tempfile::TempDir;
1290
1291        let temp_dir = TempDir::new().unwrap();
1292        let theme_path = temp_dir.path().join("refresh.yaml");
1293        fs::write(
1294            &theme_path,
1295            r#"
1296            icons:
1297                v1: "one"
1298            "#,
1299        )
1300        .unwrap();
1301
1302        let mut theme = Theme::from_file(&theme_path).unwrap();
1303        assert_eq!(theme.icons().len(), 1);
1304
1305        fs::write(
1306            &theme_path,
1307            r#"
1308            icons:
1309                v1: "one"
1310                v2: "two"
1311            "#,
1312        )
1313        .unwrap();
1314
1315        theme.refresh().unwrap();
1316        assert_eq!(theme.icons().len(), 2);
1317    }
1318}