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 pre-parsed theme variants.
254    pub fn from_variants(variants: ThemeVariants) -> Self {
255        Self {
256            name: None,
257            source_path: None,
258            base: variants.base().clone(),
259            light: variants.light().clone(),
260            dark: variants.dark().clone(),
261            aliases: variants.aliases().clone(),
262            icons: IconSet::new(),
263        }
264    }
265
266    /// Returns the theme name, if set.
267    ///
268    /// The name is typically derived from the source filename when using
269    /// [`from_file`](Theme::from_file), or set explicitly with [`named`](Theme::named).
270    pub fn name(&self) -> Option<&str> {
271        self.name.as_deref()
272    }
273
274    /// Returns the source file path, if this theme was loaded from a file.
275    pub fn source_path(&self) -> Option<&Path> {
276        self.source_path.as_deref()
277    }
278
279    /// Reloads the theme from its source file.
280    ///
281    /// This is useful for hot-reloading during development. If the theme
282    /// was not loaded from a file, this method returns an error.
283    ///
284    /// # Errors
285    ///
286    /// Returns a [`StylesheetError`] if:
287    /// - The theme has no source file (wasn't loaded with [`from_file`](Theme::from_file))
288    /// - The file cannot be read or parsed
289    ///
290    /// # Example
291    ///
292    /// ```rust,ignore
293    /// let mut theme = Theme::from_file("./themes/darcula.yaml")?;
294    ///
295    /// // After editing the file...
296    /// theme.refresh()?;
297    /// ```
298    pub fn refresh(&mut self) -> Result<(), StylesheetError> {
299        let path = self
300            .source_path
301            .as_ref()
302            .ok_or_else(|| StylesheetError::Load {
303                message: "Cannot refresh: theme has no source file".to_string(),
304            })?;
305
306        let content = std::fs::read_to_string(path).map_err(|e| StylesheetError::Load {
307            message: format!("Failed to read {}: {}", path.display(), e),
308        })?;
309
310        let icons = parse_icons_from_yaml_str(&content)?;
311        let variants = parse_stylesheet(&content)?;
312        self.base = variants.base().clone();
313        self.light = variants.light().clone();
314        self.dark = variants.dark().clone();
315        self.aliases = variants.aliases().clone();
316        self.icons = icons;
317
318        Ok(())
319    }
320
321    /// Adds a named style, returning an updated theme for chaining.
322    ///
323    /// The value can be either a concrete `Style` or a `&str`/`String` alias
324    /// to another style name, enabling layered styling.
325    ///
326    /// # Non-Adaptive
327    ///
328    /// Styles added via this method are non-adaptive (same in all modes).
329    /// For adaptive styles, use [`add_adaptive`](Self::add_adaptive) or YAML.
330    ///
331    /// # Example
332    ///
333    /// ```rust
334    /// use standout_render::Theme;
335    /// use console::Style;
336    ///
337    /// let theme = Theme::new()
338    ///     // Visual layer - concrete styles
339    ///     .add("muted", Style::new().dim())
340    ///     .add("accent", Style::new().cyan().bold())
341    ///     // Presentation layer - aliases
342    ///     .add("disabled", "muted")
343    ///     .add("highlighted", "accent")
344    ///     // Semantic layer - aliases to presentation
345    ///     .add("timestamp", "disabled");
346    /// ```
347    pub fn add<V: Into<StyleValue>>(mut self, name: &str, value: V) -> Self {
348        match value.into() {
349            StyleValue::Concrete(style) => {
350                self.base.insert(name.to_string(), style);
351            }
352            StyleValue::Alias(target) => {
353                self.aliases.insert(name.to_string(), target);
354            }
355        }
356        self
357    }
358
359    /// Adds an adaptive style with separate light and dark variants.
360    ///
361    /// The base style is used when no mode override exists or when mode
362    /// detection fails. Light and dark variants, if provided, override
363    /// the base in their respective modes.
364    ///
365    /// # Example
366    ///
367    /// ```rust
368    /// use standout_render::Theme;
369    /// use console::Style;
370    ///
371    /// let theme = Theme::new()
372    ///     .add_adaptive(
373    ///         "panel",
374    ///         Style::new().dim(),                    // Base
375    ///         Some(Style::new().fg(console::Color::Black)),  // Light mode
376    ///         Some(Style::new().fg(console::Color::White)),  // Dark mode
377    ///     );
378    /// ```
379    pub fn add_adaptive(
380        mut self,
381        name: &str,
382        base: Style,
383        light: Option<Style>,
384        dark: Option<Style>,
385    ) -> Self {
386        self.base.insert(name.to_string(), base);
387        if let Some(light_style) = light {
388            self.light.insert(name.to_string(), light_style);
389        }
390        if let Some(dark_style) = dark {
391            self.dark.insert(name.to_string(), dark_style);
392        }
393        self
394    }
395
396    /// Adds an icon definition to the theme, returning `self` for chaining.
397    ///
398    /// Icons are characters (not images) that adapt between classic Unicode
399    /// and Nerd Font glyphs. Each icon has a classic variant and an optional
400    /// Nerd Font variant.
401    ///
402    /// # Example
403    ///
404    /// ```rust
405    /// use standout_render::{Theme, IconDefinition};
406    ///
407    /// let theme = Theme::new()
408    ///     .add_icon("pending", IconDefinition::new("⚪"))
409    ///     .add_icon("done", IconDefinition::new("⚫").with_nerdfont("\u{f00c}"));
410    /// ```
411    pub fn add_icon(mut self, name: &str, def: IconDefinition) -> Self {
412        self.icons.insert(name.to_string(), def);
413        self
414    }
415
416    /// Resolves icons for the given icon mode.
417    ///
418    /// Returns a map of icon names to resolved strings for the given mode.
419    ///
420    /// # Example
421    ///
422    /// ```rust
423    /// use standout_render::{Theme, IconDefinition, IconMode};
424    ///
425    /// let theme = Theme::new()
426    ///     .add_icon("done", IconDefinition::new("⚫").with_nerdfont("\u{f00c}"));
427    ///
428    /// let classic = theme.resolve_icons(IconMode::Classic);
429    /// assert_eq!(classic.get("done").unwrap(), "⚫");
430    ///
431    /// let nerdfont = theme.resolve_icons(IconMode::NerdFont);
432    /// assert_eq!(nerdfont.get("done").unwrap(), "\u{f00c}");
433    /// ```
434    pub fn resolve_icons(&self, mode: IconMode) -> HashMap<String, String> {
435        self.icons.resolve(mode)
436    }
437
438    /// Returns a reference to the icon set.
439    pub fn icons(&self) -> &IconSet {
440        &self.icons
441    }
442
443    /// Resolves styles for the given color mode.
444    ///
445    /// Returns a [`Styles`] collection with the appropriate style for each
446    /// defined style name:
447    ///
448    /// - For styles with a mode-specific override, uses the override
449    /// - For styles without an override, uses the base style
450    /// - Aliases are preserved for resolution during rendering
451    ///
452    /// # Example
453    ///
454    /// ```rust
455    /// use standout_render::{Theme, ColorMode};
456    /// use console::Style;
457    ///
458    /// let theme = Theme::new()
459    ///     .add("header", Style::new().cyan())
460    ///     .add_adaptive(
461    ///         "panel",
462    ///         Style::new(),
463    ///         Some(Style::new().fg(console::Color::Black)),
464    ///         Some(Style::new().fg(console::Color::White)),
465    ///     );
466    ///
467    /// // Get styles for dark mode
468    /// let dark_styles = theme.resolve_styles(Some(ColorMode::Dark));
469    /// ```
470    pub fn resolve_styles(&self, mode: Option<ColorMode>) -> Styles {
471        let mut styles = Styles::new();
472
473        // Select the mode-specific overrides map
474        let mode_overrides = match mode {
475            Some(ColorMode::Light) => &self.light,
476            Some(ColorMode::Dark) => &self.dark,
477            None => &HashMap::new(),
478        };
479
480        // Add concrete styles (base, with mode overrides applied)
481        for (name, base_style) in &self.base {
482            let style = mode_overrides.get(name).unwrap_or(base_style);
483            styles = styles.add(name, style.clone());
484        }
485
486        // Add aliases
487        for (name, target) in &self.aliases {
488            styles = styles.add(name, target.clone());
489        }
490
491        styles
492    }
493
494    /// Validates that all style aliases in this theme resolve correctly.
495    ///
496    /// This is called automatically at render time, but can be called
497    /// explicitly for early error detection.
498    pub fn validate(&self) -> Result<(), StyleValidationError> {
499        // Validate using a resolved Styles instance
500        self.resolve_styles(None).validate()
501    }
502
503    /// Returns true if no styles are defined.
504    pub fn is_empty(&self) -> bool {
505        self.base.is_empty() && self.aliases.is_empty()
506    }
507
508    /// Returns the number of defined styles (base + aliases).
509    pub fn len(&self) -> usize {
510        self.base.len() + self.aliases.len()
511    }
512
513    /// Resolves a single style for the given mode.
514    ///
515    /// This is a convenience wrapper around [`resolve_styles`](Self::resolve_styles).
516    pub fn get_style(&self, name: &str, mode: Option<ColorMode>) -> Option<Style> {
517        let styles = self.resolve_styles(mode);
518        // Styles::resolve is crate-private, so we have to use to_resolved_map or check internal.
519        // Wait, Styles::resolve is pub(crate). We are in rendering/theme/theme.rs,
520        // Styles is in rendering/style/registry.rs. Same crate.
521        // But Theme is in `rendering::theme`, Styles in `rendering::style`.
522        // They are different modules. `pub(crate)` is visible.
523        styles.resolve(name).cloned()
524    }
525
526    /// Returns the number of light mode overrides.
527    pub fn light_override_count(&self) -> usize {
528        self.light.len()
529    }
530
531    /// Returns the number of dark mode overrides.
532    pub fn dark_override_count(&self) -> usize {
533        self.dark.len()
534    }
535
536    /// Merges another theme into this one.
537    ///
538    /// Styles from `other` take precedence over styles in `self`.
539    /// This allows layering themes, e.g., loading a base theme and applying user overrides.
540    ///
541    /// # Example
542    ///
543    /// ```rust
544    /// use standout_render::Theme;
545    /// use console::Style;
546    ///
547    /// let base = Theme::new().add("text", Style::new().dim());
548    /// let user = Theme::new().add("text", Style::new().bold());
549    ///
550    /// let merged = base.merge(user);
551    /// // "text" is now bold (from user)
552    /// ```
553    pub fn merge(mut self, other: Theme) -> Self {
554        self.base.extend(other.base);
555        self.light.extend(other.light);
556        self.dark.extend(other.dark);
557        self.aliases.extend(other.aliases);
558        self.icons = self.icons.merge(other.icons);
559        self
560    }
561}
562
563impl Default for Theme {
564    fn default() -> Self {
565        Self::new()
566    }
567}
568
569/// Parses icon definitions from a YAML string.
570///
571/// Extracts the `icons:` section from the YAML root mapping and
572/// parses each entry into an [`IconDefinition`].
573///
574/// # YAML Format
575///
576/// ```yaml
577/// icons:
578///   # String shorthand (classic only)
579///   pending: "⚪"
580///
581///   # Mapping with both variants
582///   done:
583///     classic: "⚫"
584///     nerdfont: "\uf00c"
585/// ```
586fn parse_icons_from_yaml_str(yaml: &str) -> Result<IconSet, StylesheetError> {
587    let root: serde_yaml::Value =
588        serde_yaml::from_str(yaml).map_err(|e| StylesheetError::Parse {
589            path: None,
590            message: e.to_string(),
591        })?;
592
593    parse_icons_from_yaml(&root)
594}
595
596/// Parses icon definitions from a parsed YAML value.
597fn parse_icons_from_yaml(root: &serde_yaml::Value) -> Result<IconSet, StylesheetError> {
598    let mut icon_set = IconSet::new();
599
600    let mapping = match root.as_mapping() {
601        Some(m) => m,
602        None => return Ok(icon_set),
603    };
604
605    let icons_value = match mapping.get(serde_yaml::Value::String("icons".into())) {
606        Some(v) => v,
607        None => return Ok(icon_set),
608    };
609
610    let icons_map = icons_value
611        .as_mapping()
612        .ok_or_else(|| StylesheetError::Parse {
613            path: None,
614            message: "'icons' must be a mapping".to_string(),
615        })?;
616
617    for (key, value) in icons_map {
618        let name = key.as_str().ok_or_else(|| StylesheetError::Parse {
619            path: None,
620            message: format!("Icon name must be a string, got {:?}", key),
621        })?;
622
623        let def = match value {
624            serde_yaml::Value::String(s) => {
625                // Shorthand: classic-only
626                IconDefinition::new(s.clone())
627            }
628            serde_yaml::Value::Mapping(map) => {
629                let classic = map
630                    .get(serde_yaml::Value::String("classic".into()))
631                    .and_then(|v| v.as_str())
632                    .ok_or_else(|| StylesheetError::InvalidDefinition {
633                        style: name.to_string(),
634                        message: "Icon mapping must have a 'classic' key".to_string(),
635                        path: None,
636                    })?;
637                let nerdfont = map
638                    .get(serde_yaml::Value::String("nerdfont".into()))
639                    .and_then(|v| v.as_str());
640                let mut def = IconDefinition::new(classic);
641                if let Some(nf) = nerdfont {
642                    def = def.with_nerdfont(nf);
643                }
644                def
645            }
646            _ => {
647                return Err(StylesheetError::InvalidDefinition {
648                    style: name.to_string(),
649                    message: "Icon must be a string or mapping with 'classic' key".to_string(),
650                    path: None,
651                });
652            }
653        };
654
655        icon_set.insert(name.to_string(), def);
656    }
657
658    Ok(icon_set)
659}
660
661#[cfg(test)]
662mod tests {
663    use super::*;
664
665    #[test]
666    fn test_theme_new_is_empty() {
667        let theme = Theme::new();
668        assert!(theme.is_empty());
669        assert_eq!(theme.len(), 0);
670    }
671
672    #[test]
673    fn test_theme_add_concrete() {
674        let theme = Theme::new().add("bold", Style::new().bold());
675        assert!(!theme.is_empty());
676        assert_eq!(theme.len(), 1);
677    }
678
679    #[test]
680    fn test_theme_add_alias_str() {
681        let theme = Theme::new()
682            .add("base", Style::new().dim())
683            .add("alias", "base");
684
685        assert_eq!(theme.len(), 2);
686
687        let styles = theme.resolve_styles(None);
688        assert!(styles.has("base"));
689        assert!(styles.has("alias"));
690    }
691
692    #[test]
693    fn test_theme_add_alias_string() {
694        let target = String::from("base");
695        let theme = Theme::new()
696            .add("base", Style::new().dim())
697            .add("alias", target);
698
699        let styles = theme.resolve_styles(None);
700        assert!(styles.has("alias"));
701    }
702
703    #[test]
704    fn test_theme_validate_valid() {
705        let theme = Theme::new()
706            .add("visual", Style::new().cyan())
707            .add("semantic", "visual");
708
709        assert!(theme.validate().is_ok());
710    }
711
712    #[test]
713    fn test_theme_validate_invalid() {
714        let theme = Theme::new().add("orphan", "missing");
715        assert!(theme.validate().is_err());
716    }
717
718    #[test]
719    fn test_theme_default() {
720        let theme = Theme::default();
721        assert!(theme.is_empty());
722    }
723
724    // =========================================================================
725    // Adaptive style tests
726    // =========================================================================
727
728    #[test]
729    fn test_theme_add_adaptive() {
730        let theme = Theme::new().add_adaptive(
731            "panel",
732            Style::new().dim(),
733            Some(Style::new().bold()),
734            Some(Style::new().italic()),
735        );
736
737        assert_eq!(theme.len(), 1);
738        assert_eq!(theme.light_override_count(), 1);
739        assert_eq!(theme.dark_override_count(), 1);
740    }
741
742    #[test]
743    fn test_theme_add_adaptive_light_only() {
744        let theme =
745            Theme::new().add_adaptive("panel", Style::new().dim(), Some(Style::new().bold()), None);
746
747        assert_eq!(theme.light_override_count(), 1);
748        assert_eq!(theme.dark_override_count(), 0);
749    }
750
751    #[test]
752    fn test_theme_add_adaptive_dark_only() {
753        let theme =
754            Theme::new().add_adaptive("panel", Style::new().dim(), None, Some(Style::new().bold()));
755
756        assert_eq!(theme.light_override_count(), 0);
757        assert_eq!(theme.dark_override_count(), 1);
758    }
759
760    #[test]
761    fn test_theme_resolve_styles_no_mode() {
762        let theme = Theme::new()
763            .add("header", Style::new().cyan())
764            .add_adaptive(
765                "panel",
766                Style::new().dim(),
767                Some(Style::new().bold()),
768                Some(Style::new().italic()),
769            );
770
771        let styles = theme.resolve_styles(None);
772        assert!(styles.has("header"));
773        assert!(styles.has("panel"));
774    }
775
776    #[test]
777    fn test_theme_resolve_styles_light_mode() {
778        let theme = Theme::new().add_adaptive(
779            "panel",
780            Style::new().dim(),
781            Some(Style::new().bold()),
782            Some(Style::new().italic()),
783        );
784
785        let styles = theme.resolve_styles(Some(ColorMode::Light));
786        assert!(styles.has("panel"));
787        // The style should be the light override, not base
788        // We can't easily check the actual style, but we verify resolution works
789    }
790
791    #[test]
792    fn test_theme_resolve_styles_dark_mode() {
793        let theme = Theme::new().add_adaptive(
794            "panel",
795            Style::new().dim(),
796            Some(Style::new().bold()),
797            Some(Style::new().italic()),
798        );
799
800        let styles = theme.resolve_styles(Some(ColorMode::Dark));
801        assert!(styles.has("panel"));
802    }
803
804    #[test]
805    fn test_theme_resolve_styles_preserves_aliases() {
806        let theme = Theme::new()
807            .add("base", Style::new().dim())
808            .add("alias", "base");
809
810        let styles = theme.resolve_styles(Some(ColorMode::Light));
811        assert!(styles.has("base"));
812        assert!(styles.has("alias"));
813
814        // Validate that alias resolution still works
815        assert!(styles.validate().is_ok());
816    }
817
818    // =========================================================================
819    // YAML parsing tests
820    // =========================================================================
821
822    #[test]
823    fn test_theme_from_yaml_simple() {
824        let theme = Theme::from_yaml(
825            r#"
826            header:
827                fg: cyan
828                bold: true
829            "#,
830        )
831        .unwrap();
832
833        assert_eq!(theme.len(), 1);
834        let styles = theme.resolve_styles(None);
835        assert!(styles.has("header"));
836    }
837
838    #[test]
839    fn test_theme_from_yaml_shorthand() {
840        let theme = Theme::from_yaml(
841            r#"
842            bold_text: bold
843            accent: cyan
844            warning: "yellow italic"
845            "#,
846        )
847        .unwrap();
848
849        assert_eq!(theme.len(), 3);
850    }
851
852    #[test]
853    fn test_theme_from_yaml_alias() {
854        let theme = Theme::from_yaml(
855            r#"
856            muted:
857                dim: true
858            disabled: muted
859            "#,
860        )
861        .unwrap();
862
863        assert_eq!(theme.len(), 2);
864        assert!(theme.validate().is_ok());
865    }
866
867    #[test]
868    fn test_theme_from_yaml_adaptive() {
869        let theme = Theme::from_yaml(
870            r#"
871            panel:
872                fg: gray
873                light:
874                    fg: black
875                dark:
876                    fg: white
877            "#,
878        )
879        .unwrap();
880
881        assert_eq!(theme.len(), 1);
882        assert_eq!(theme.light_override_count(), 1);
883        assert_eq!(theme.dark_override_count(), 1);
884    }
885
886    #[test]
887    fn test_theme_from_yaml_invalid() {
888        let result = Theme::from_yaml("not valid yaml: [");
889        assert!(result.is_err());
890    }
891
892    #[test]
893    fn test_theme_from_yaml_complete() {
894        let theme = Theme::from_yaml(
895            r##"
896            # Visual layer
897            muted:
898                dim: true
899
900            accent:
901                fg: cyan
902                bold: true
903
904            # Adaptive
905            background:
906                light:
907                    bg: "#f8f8f8"
908                dark:
909                    bg: "#1e1e1e"
910
911            # Aliases
912            header: accent
913            footer: muted
914            "##,
915        )
916        .unwrap();
917
918        // 3 concrete styles + 2 aliases = 5 total
919        assert_eq!(theme.len(), 5);
920        assert!(theme.validate().is_ok());
921
922        // background is adaptive
923        assert_eq!(theme.light_override_count(), 1);
924        assert_eq!(theme.dark_override_count(), 1);
925    }
926
927    // =========================================================================
928    // Name and source path tests
929    // =========================================================================
930
931    #[test]
932    fn test_theme_named() {
933        let theme = Theme::named("darcula");
934        assert_eq!(theme.name(), Some("darcula"));
935        assert!(theme.is_empty());
936    }
937
938    #[test]
939    fn test_theme_new_has_no_name() {
940        let theme = Theme::new();
941        assert_eq!(theme.name(), None);
942        assert_eq!(theme.source_path(), None);
943    }
944
945    #[test]
946    fn test_theme_from_file() {
947        use std::fs;
948        use tempfile::TempDir;
949
950        let temp_dir = TempDir::new().unwrap();
951        let theme_path = temp_dir.path().join("darcula.yaml");
952        fs::write(
953            &theme_path,
954            r#"
955            header:
956                fg: cyan
957                bold: true
958            muted:
959                dim: true
960            "#,
961        )
962        .unwrap();
963
964        let theme = Theme::from_file(&theme_path).unwrap();
965        assert_eq!(theme.name(), Some("darcula"));
966        assert_eq!(theme.source_path(), Some(theme_path.as_path()));
967        assert_eq!(theme.len(), 2);
968    }
969
970    #[test]
971    fn test_theme_from_file_not_found() {
972        let result = Theme::from_file("/nonexistent/path/theme.yaml");
973        assert!(result.is_err());
974    }
975
976    #[test]
977    fn test_theme_refresh() {
978        use std::fs;
979        use tempfile::TempDir;
980
981        let temp_dir = TempDir::new().unwrap();
982        let theme_path = temp_dir.path().join("dynamic.yaml");
983        fs::write(
984            &theme_path,
985            r#"
986            header:
987                fg: red
988            "#,
989        )
990        .unwrap();
991
992        let mut theme = Theme::from_file(&theme_path).unwrap();
993        assert_eq!(theme.len(), 1);
994
995        // Update the file
996        fs::write(
997            &theme_path,
998            r#"
999            header:
1000                fg: blue
1001            footer:
1002                dim: true
1003            "#,
1004        )
1005        .unwrap();
1006
1007        // Refresh
1008        theme.refresh().unwrap();
1009        assert_eq!(theme.len(), 2);
1010    }
1011
1012    #[test]
1013    fn test_theme_refresh_without_source() {
1014        let mut theme = Theme::new();
1015        let result = theme.refresh();
1016        assert!(result.is_err());
1017    }
1018
1019    #[test]
1020    fn test_theme_merge() {
1021        let base = Theme::new()
1022            .add("keep", Style::new().dim())
1023            .add("overwrite", Style::new().red());
1024
1025        let extension = Theme::new()
1026            .add("overwrite", Style::new().blue())
1027            .add("new", Style::new().bold());
1028
1029        let merged = base.merge(extension);
1030
1031        let styles = merged.resolve_styles(None);
1032
1033        // "keep" should be from base
1034        assert!(styles.has("keep"));
1035
1036        // "overwrite" should be from extension (blue, not red)
1037        assert!(styles.has("overwrite"));
1038
1039        // "new" should be from extension
1040        assert!(styles.has("new"));
1041
1042        assert_eq!(merged.len(), 3);
1043    }
1044
1045    // =========================================================================
1046    // Icon tests
1047    // =========================================================================
1048
1049    #[test]
1050    fn test_theme_add_icon() {
1051        let theme = Theme::new()
1052            .add_icon("pending", IconDefinition::new("⚪"))
1053            .add_icon("done", IconDefinition::new("⚫").with_nerdfont("\u{f00c}"));
1054
1055        assert_eq!(theme.icons().len(), 2);
1056        assert!(!theme.icons().is_empty());
1057    }
1058
1059    #[test]
1060    fn test_theme_resolve_icons_classic() {
1061        let theme = Theme::new()
1062            .add_icon("pending", IconDefinition::new("⚪"))
1063            .add_icon("done", IconDefinition::new("⚫").with_nerdfont("\u{f00c}"));
1064
1065        let resolved = theme.resolve_icons(IconMode::Classic);
1066        assert_eq!(resolved.get("pending").unwrap(), "⚪");
1067        assert_eq!(resolved.get("done").unwrap(), "⚫");
1068    }
1069
1070    #[test]
1071    fn test_theme_resolve_icons_nerdfont() {
1072        let theme = Theme::new()
1073            .add_icon("pending", IconDefinition::new("⚪"))
1074            .add_icon("done", IconDefinition::new("⚫").with_nerdfont("\u{f00c}"));
1075
1076        let resolved = theme.resolve_icons(IconMode::NerdFont);
1077        assert_eq!(resolved.get("pending").unwrap(), "⚪"); // No nerdfont, falls back
1078        assert_eq!(resolved.get("done").unwrap(), "\u{f00c}");
1079    }
1080
1081    #[test]
1082    fn test_theme_icons_empty_by_default() {
1083        let theme = Theme::new();
1084        assert!(theme.icons().is_empty());
1085    }
1086
1087    #[test]
1088    fn test_theme_merge_with_icons() {
1089        let base = Theme::new()
1090            .add_icon("keep", IconDefinition::new("K"))
1091            .add_icon("override", IconDefinition::new("OLD"));
1092
1093        let ext = Theme::new()
1094            .add_icon("override", IconDefinition::new("NEW"))
1095            .add_icon("added", IconDefinition::new("A"));
1096
1097        let merged = base.merge(ext);
1098        assert_eq!(merged.icons().len(), 3);
1099
1100        let resolved = merged.resolve_icons(IconMode::Classic);
1101        assert_eq!(resolved.get("keep").unwrap(), "K");
1102        assert_eq!(resolved.get("override").unwrap(), "NEW");
1103        assert_eq!(resolved.get("added").unwrap(), "A");
1104    }
1105
1106    #[test]
1107    fn test_theme_from_yaml_with_icons() {
1108        let theme = Theme::from_yaml(
1109            r#"
1110            header:
1111                fg: cyan
1112                bold: true
1113            icons:
1114                pending: "⚪"
1115                done:
1116                    classic: "⚫"
1117                    nerdfont: "nf_done"
1118            "#,
1119        )
1120        .unwrap();
1121
1122        // Styles
1123        assert_eq!(theme.len(), 1);
1124        let styles = theme.resolve_styles(None);
1125        assert!(styles.has("header"));
1126
1127        // Icons
1128        assert_eq!(theme.icons().len(), 2);
1129        let resolved = theme.resolve_icons(IconMode::Classic);
1130        assert_eq!(resolved.get("pending").unwrap(), "⚪");
1131        assert_eq!(resolved.get("done").unwrap(), "⚫");
1132
1133        let resolved = theme.resolve_icons(IconMode::NerdFont);
1134        assert_eq!(resolved.get("done").unwrap(), "nf_done");
1135    }
1136
1137    #[test]
1138    fn test_theme_from_yaml_no_icons() {
1139        let theme = Theme::from_yaml(
1140            r#"
1141            header:
1142                fg: cyan
1143            "#,
1144        )
1145        .unwrap();
1146
1147        assert!(theme.icons().is_empty());
1148    }
1149
1150    #[test]
1151    fn test_theme_from_yaml_icons_only() {
1152        let theme = Theme::from_yaml(
1153            r#"
1154            icons:
1155                check: "✓"
1156            "#,
1157        )
1158        .unwrap();
1159
1160        assert_eq!(theme.icons().len(), 1);
1161        assert_eq!(theme.len(), 0); // No styles
1162    }
1163
1164    #[test]
1165    fn test_theme_from_yaml_icons_invalid_type() {
1166        let result = Theme::from_yaml(
1167            r#"
1168            icons:
1169                bad: 42
1170            "#,
1171        );
1172        assert!(result.is_err());
1173    }
1174
1175    #[test]
1176    fn test_theme_from_yaml_icons_mapping_without_classic() {
1177        let result = Theme::from_yaml(
1178            r#"
1179            icons:
1180                bad:
1181                    nerdfont: "nf"
1182            "#,
1183        );
1184        assert!(result.is_err());
1185    }
1186
1187    #[test]
1188    fn test_theme_from_file_with_icons() {
1189        use std::fs;
1190        use tempfile::TempDir;
1191
1192        let temp_dir = TempDir::new().unwrap();
1193        let theme_path = temp_dir.path().join("iconic.yaml");
1194        fs::write(
1195            &theme_path,
1196            r#"
1197            header:
1198                fg: cyan
1199            icons:
1200                check:
1201                    classic: "[ok]"
1202                    nerdfont: "nf_check"
1203            "#,
1204        )
1205        .unwrap();
1206
1207        let theme = Theme::from_file(&theme_path).unwrap();
1208        assert_eq!(theme.icons().len(), 1);
1209        let resolved = theme.resolve_icons(IconMode::NerdFont);
1210        assert_eq!(resolved.get("check").unwrap(), "nf_check");
1211    }
1212
1213    #[test]
1214    fn test_theme_refresh_with_icons() {
1215        use std::fs;
1216        use tempfile::TempDir;
1217
1218        let temp_dir = TempDir::new().unwrap();
1219        let theme_path = temp_dir.path().join("refresh.yaml");
1220        fs::write(
1221            &theme_path,
1222            r#"
1223            icons:
1224                v1: "one"
1225            "#,
1226        )
1227        .unwrap();
1228
1229        let mut theme = Theme::from_file(&theme_path).unwrap();
1230        assert_eq!(theme.icons().len(), 1);
1231
1232        fs::write(
1233            &theme_path,
1234            r#"
1235            icons:
1236                v1: "one"
1237                v2: "two"
1238            "#,
1239        )
1240        .unwrap();
1241
1242        theme.refresh().unwrap();
1243        assert_eq!(theme.icons().len(), 2);
1244    }
1245}