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