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 programmatic construction and
5//! file loading from CSS (preferred) or YAML (legacy) stylesheets.
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            // ── Framework warnings ──────────────────────────────────────
690            // Banner shown before the per-warning list when the framework
691            // emits deferred warnings (e.g. stylesheet hot-reload failures).
692            // Black text on orange (ANSI 256 #208) with bold for prominence.
693            // Items are rendered without extra styling; the leading tab is
694            // applied by the CLI flush path, not the style.
695            .add(
696                "standout_warning_banner",
697                Style::new()
698                    .fg(Color::Black)
699                    .bg(Color::Color256(208))
700                    .bold(),
701            )
702            .add("standout_warning_item", Style::new())
703            // ── Base (gray) ─────────────────────────────────────────────
704            .add("table_row_even", Style::new())
705            .add_adaptive(
706                "table_row_odd",
707                Style::new(),
708                Some(Style::new().bg(Color::Color256(254))),
709                Some(Style::new().bg(Color::Color256(236))),
710            )
711            // gray is an alias for the base variant
712            .add("table_row_even_gray", "table_row_even")
713            .add("table_row_odd_gray", "table_row_odd")
714            // ── Blue ────────────────────────────────────────────────────
715            .add("table_row_even_blue", Style::new())
716            .add_adaptive(
717                "table_row_odd_blue",
718                Style::new(),
719                Some(Style::new().bg(Color::Color256(189))),
720                Some(Style::new().bg(Color::Color256(17))),
721            )
722            // ── Red ─────────────────────────────────────────────────────
723            .add("table_row_even_red", Style::new())
724            .add_adaptive(
725                "table_row_odd_red",
726                Style::new(),
727                Some(Style::new().bg(Color::Color256(224))),
728                Some(Style::new().bg(Color::Color256(52))),
729            )
730            // ── Green ───────────────────────────────────────────────────
731            .add("table_row_even_green", Style::new())
732            .add_adaptive(
733                "table_row_odd_green",
734                Style::new(),
735                Some(Style::new().bg(Color::Color256(194))),
736                Some(Style::new().bg(Color::Color256(22))),
737            )
738            // ── Purple ──────────────────────────────────────────────────
739            .add("table_row_even_purple", Style::new())
740            .add_adaptive(
741                "table_row_odd_purple",
742                Style::new(),
743                Some(Style::new().bg(Color::Color256(225))),
744                Some(Style::new().bg(Color::Color256(53))),
745            )
746    }
747}
748
749/// Parses icon definitions from a YAML string.
750///
751/// Extracts the `icons:` section from the YAML root mapping and
752/// parses each entry into an [`IconDefinition`].
753///
754/// # YAML Format
755///
756/// ```yaml
757/// icons:
758///   # String shorthand (classic only)
759///   pending: "⚪"
760///
761///   # Mapping with both variants
762///   done:
763///     classic: "⚫"
764///     nerdfont: "\uf00c"
765/// ```
766fn parse_icons_from_yaml_str(yaml: &str) -> Result<IconSet, StylesheetError> {
767    let root: serde_yaml::Value =
768        serde_yaml::from_str(yaml).map_err(|e| StylesheetError::Parse {
769            path: None,
770            message: e.to_string(),
771        })?;
772
773    parse_icons_from_yaml(&root)
774}
775
776/// Parses icon definitions from a parsed YAML value.
777fn parse_icons_from_yaml(root: &serde_yaml::Value) -> Result<IconSet, StylesheetError> {
778    let mut icon_set = IconSet::new();
779
780    let mapping = match root.as_mapping() {
781        Some(m) => m,
782        None => return Ok(icon_set),
783    };
784
785    let icons_value = match mapping.get(serde_yaml::Value::String("icons".into())) {
786        Some(v) => v,
787        None => return Ok(icon_set),
788    };
789
790    let icons_map = icons_value
791        .as_mapping()
792        .ok_or_else(|| StylesheetError::Parse {
793            path: None,
794            message: "'icons' must be a mapping".to_string(),
795        })?;
796
797    for (key, value) in icons_map {
798        let name = key.as_str().ok_or_else(|| StylesheetError::Parse {
799            path: None,
800            message: format!("Icon name must be a string, got {:?}", key),
801        })?;
802
803        let def = match value {
804            serde_yaml::Value::String(s) => {
805                // Shorthand: classic-only
806                IconDefinition::new(s.clone())
807            }
808            serde_yaml::Value::Mapping(map) => {
809                let classic = map
810                    .get(serde_yaml::Value::String("classic".into()))
811                    .and_then(|v| v.as_str())
812                    .ok_or_else(|| StylesheetError::InvalidDefinition {
813                        style: name.to_string(),
814                        message: "Icon mapping must have a 'classic' key".to_string(),
815                        path: None,
816                    })?;
817                let nerdfont = map
818                    .get(serde_yaml::Value::String("nerdfont".into()))
819                    .and_then(|v| v.as_str());
820                let mut def = IconDefinition::new(classic);
821                if let Some(nf) = nerdfont {
822                    def = def.with_nerdfont(nf);
823                }
824                def
825            }
826            _ => {
827                return Err(StylesheetError::InvalidDefinition {
828                    style: name.to_string(),
829                    message: "Icon must be a string or mapping with 'classic' key".to_string(),
830                    path: None,
831                });
832            }
833        };
834
835        icon_set.insert(name.to_string(), def);
836    }
837
838    Ok(icon_set)
839}
840
841#[cfg(test)]
842mod tests {
843    use super::*;
844
845    #[test]
846    fn test_theme_new_is_empty() {
847        let theme = Theme::new();
848        assert!(theme.is_empty());
849        assert_eq!(theme.len(), 0);
850    }
851
852    #[test]
853    fn test_theme_add_concrete() {
854        let theme = Theme::new().add("bold", Style::new().bold());
855        assert!(!theme.is_empty());
856        assert_eq!(theme.len(), 1);
857    }
858
859    #[test]
860    fn test_theme_add_alias_str() {
861        let theme = Theme::new()
862            .add("base", Style::new().dim())
863            .add("alias", "base");
864
865        assert_eq!(theme.len(), 2);
866
867        let styles = theme.resolve_styles(None);
868        assert!(styles.has("base"));
869        assert!(styles.has("alias"));
870    }
871
872    #[test]
873    fn test_theme_add_alias_string() {
874        let target = String::from("base");
875        let theme = Theme::new()
876            .add("base", Style::new().dim())
877            .add("alias", target);
878
879        let styles = theme.resolve_styles(None);
880        assert!(styles.has("alias"));
881    }
882
883    #[test]
884    fn test_theme_validate_valid() {
885        let theme = Theme::new()
886            .add("visual", Style::new().cyan())
887            .add("semantic", "visual");
888
889        assert!(theme.validate().is_ok());
890    }
891
892    #[test]
893    fn test_theme_validate_invalid() {
894        let theme = Theme::new().add("orphan", "missing");
895        assert!(theme.validate().is_err());
896    }
897
898    #[test]
899    fn test_theme_default() {
900        let theme = Theme::default();
901        // Default theme includes built-in table row styles
902        assert!(!theme.is_empty());
903        let styles = theme.resolve_styles(Some(crate::ColorMode::Dark));
904        assert!(styles.resolve("table_row_even").is_some());
905        assert!(styles.resolve("table_row_odd").is_some());
906    }
907
908    // =========================================================================
909    // Adaptive style tests
910    // =========================================================================
911
912    #[test]
913    fn test_theme_add_adaptive() {
914        let theme = Theme::new().add_adaptive(
915            "panel",
916            Style::new().dim(),
917            Some(Style::new().bold()),
918            Some(Style::new().italic()),
919        );
920
921        assert_eq!(theme.len(), 1);
922        assert_eq!(theme.light_override_count(), 1);
923        assert_eq!(theme.dark_override_count(), 1);
924    }
925
926    #[test]
927    fn test_theme_add_adaptive_light_only() {
928        let theme =
929            Theme::new().add_adaptive("panel", Style::new().dim(), Some(Style::new().bold()), None);
930
931        assert_eq!(theme.light_override_count(), 1);
932        assert_eq!(theme.dark_override_count(), 0);
933    }
934
935    #[test]
936    fn test_theme_add_adaptive_dark_only() {
937        let theme =
938            Theme::new().add_adaptive("panel", Style::new().dim(), None, Some(Style::new().bold()));
939
940        assert_eq!(theme.light_override_count(), 0);
941        assert_eq!(theme.dark_override_count(), 1);
942    }
943
944    #[test]
945    fn test_theme_resolve_styles_no_mode() {
946        let theme = Theme::new()
947            .add("header", Style::new().cyan())
948            .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(None);
956        assert!(styles.has("header"));
957        assert!(styles.has("panel"));
958    }
959
960    #[test]
961    fn test_theme_resolve_styles_light_mode() {
962        let theme = Theme::new().add_adaptive(
963            "panel",
964            Style::new().dim(),
965            Some(Style::new().bold()),
966            Some(Style::new().italic()),
967        );
968
969        let styles = theme.resolve_styles(Some(ColorMode::Light));
970        assert!(styles.has("panel"));
971        // The style should be the light override, not base
972        // We can't easily check the actual style, but we verify resolution works
973    }
974
975    #[test]
976    fn test_theme_resolve_styles_dark_mode() {
977        let theme = Theme::new().add_adaptive(
978            "panel",
979            Style::new().dim(),
980            Some(Style::new().bold()),
981            Some(Style::new().italic()),
982        );
983
984        let styles = theme.resolve_styles(Some(ColorMode::Dark));
985        assert!(styles.has("panel"));
986    }
987
988    #[test]
989    fn test_theme_resolve_styles_preserves_aliases() {
990        let theme = Theme::new()
991            .add("base", Style::new().dim())
992            .add("alias", "base");
993
994        let styles = theme.resolve_styles(Some(ColorMode::Light));
995        assert!(styles.has("base"));
996        assert!(styles.has("alias"));
997
998        // Validate that alias resolution still works
999        assert!(styles.validate().is_ok());
1000    }
1001
1002    // =========================================================================
1003    // YAML parsing tests
1004    // =========================================================================
1005
1006    #[test]
1007    fn test_theme_from_yaml_simple() {
1008        let theme = Theme::from_yaml(
1009            r#"
1010            header:
1011                fg: cyan
1012                bold: true
1013            "#,
1014        )
1015        .unwrap();
1016
1017        assert_eq!(theme.len(), 1);
1018        let styles = theme.resolve_styles(None);
1019        assert!(styles.has("header"));
1020    }
1021
1022    #[test]
1023    fn test_theme_from_yaml_shorthand() {
1024        let theme = Theme::from_yaml(
1025            r#"
1026            bold_text: bold
1027            accent: cyan
1028            warning: "yellow italic"
1029            "#,
1030        )
1031        .unwrap();
1032
1033        assert_eq!(theme.len(), 3);
1034    }
1035
1036    #[test]
1037    fn test_theme_from_yaml_alias() {
1038        let theme = Theme::from_yaml(
1039            r#"
1040            muted:
1041                dim: true
1042            disabled: muted
1043            "#,
1044        )
1045        .unwrap();
1046
1047        assert_eq!(theme.len(), 2);
1048        assert!(theme.validate().is_ok());
1049    }
1050
1051    #[test]
1052    fn test_theme_from_yaml_adaptive() {
1053        let theme = Theme::from_yaml(
1054            r#"
1055            panel:
1056                fg: gray
1057                light:
1058                    fg: black
1059                dark:
1060                    fg: white
1061            "#,
1062        )
1063        .unwrap();
1064
1065        assert_eq!(theme.len(), 1);
1066        assert_eq!(theme.light_override_count(), 1);
1067        assert_eq!(theme.dark_override_count(), 1);
1068    }
1069
1070    #[test]
1071    fn test_theme_from_yaml_invalid() {
1072        let result = Theme::from_yaml("not valid yaml: [");
1073        assert!(result.is_err());
1074    }
1075
1076    #[test]
1077    fn test_theme_from_yaml_complete() {
1078        let theme = Theme::from_yaml(
1079            r##"
1080            # Visual layer
1081            muted:
1082                dim: true
1083
1084            accent:
1085                fg: cyan
1086                bold: true
1087
1088            # Adaptive
1089            background:
1090                light:
1091                    bg: "#f8f8f8"
1092                dark:
1093                    bg: "#1e1e1e"
1094
1095            # Aliases
1096            header: accent
1097            footer: muted
1098            "##,
1099        )
1100        .unwrap();
1101
1102        // 3 concrete styles + 2 aliases = 5 total
1103        assert_eq!(theme.len(), 5);
1104        assert!(theme.validate().is_ok());
1105
1106        // background is adaptive
1107        assert_eq!(theme.light_override_count(), 1);
1108        assert_eq!(theme.dark_override_count(), 1);
1109    }
1110
1111    // =========================================================================
1112    // Name and source path tests
1113    // =========================================================================
1114
1115    #[test]
1116    fn test_theme_named() {
1117        let theme = Theme::named("darcula");
1118        assert_eq!(theme.name(), Some("darcula"));
1119        assert!(theme.is_empty());
1120    }
1121
1122    #[test]
1123    fn test_theme_new_has_no_name() {
1124        let theme = Theme::new();
1125        assert_eq!(theme.name(), None);
1126        assert_eq!(theme.source_path(), None);
1127    }
1128
1129    #[test]
1130    fn test_theme_from_file() {
1131        use std::fs;
1132        use tempfile::TempDir;
1133
1134        let temp_dir = TempDir::new().unwrap();
1135        let theme_path = temp_dir.path().join("darcula.yaml");
1136        fs::write(
1137            &theme_path,
1138            r#"
1139            header:
1140                fg: cyan
1141                bold: true
1142            muted:
1143                dim: true
1144            "#,
1145        )
1146        .unwrap();
1147
1148        let theme = Theme::from_file(&theme_path).unwrap();
1149        assert_eq!(theme.name(), Some("darcula"));
1150        assert_eq!(theme.source_path(), Some(theme_path.as_path()));
1151        assert_eq!(theme.len(), 2);
1152    }
1153
1154    #[test]
1155    fn test_theme_from_file_not_found() {
1156        let result = Theme::from_file("/nonexistent/path/theme.yaml");
1157        assert!(result.is_err());
1158    }
1159
1160    #[test]
1161    fn test_theme_refresh() {
1162        use std::fs;
1163        use tempfile::TempDir;
1164
1165        let temp_dir = TempDir::new().unwrap();
1166        let theme_path = temp_dir.path().join("dynamic.yaml");
1167        fs::write(
1168            &theme_path,
1169            r#"
1170            header:
1171                fg: red
1172            "#,
1173        )
1174        .unwrap();
1175
1176        let mut theme = Theme::from_file(&theme_path).unwrap();
1177        assert_eq!(theme.len(), 1);
1178
1179        // Update the file
1180        fs::write(
1181            &theme_path,
1182            r#"
1183            header:
1184                fg: blue
1185            footer:
1186                dim: true
1187            "#,
1188        )
1189        .unwrap();
1190
1191        // Refresh
1192        theme.refresh().unwrap();
1193        assert_eq!(theme.len(), 2);
1194    }
1195
1196    #[test]
1197    fn test_theme_refresh_without_source() {
1198        let mut theme = Theme::new();
1199        let result = theme.refresh();
1200        assert!(result.is_err());
1201    }
1202
1203    #[test]
1204    fn test_theme_merge() {
1205        let base = Theme::new()
1206            .add("keep", Style::new().dim())
1207            .add("overwrite", Style::new().red());
1208
1209        let extension = Theme::new()
1210            .add("overwrite", Style::new().blue())
1211            .add("new", Style::new().bold());
1212
1213        let merged = base.merge(extension);
1214
1215        let styles = merged.resolve_styles(None);
1216
1217        // "keep" should be from base
1218        assert!(styles.has("keep"));
1219
1220        // "overwrite" should be from extension (blue, not red)
1221        assert!(styles.has("overwrite"));
1222
1223        // "new" should be from extension
1224        assert!(styles.has("new"));
1225
1226        assert_eq!(merged.len(), 3);
1227    }
1228
1229    // =========================================================================
1230    // Icon tests
1231    // =========================================================================
1232
1233    #[test]
1234    fn test_theme_add_icon() {
1235        let theme = Theme::new()
1236            .add_icon("pending", IconDefinition::new("⚪"))
1237            .add_icon("done", IconDefinition::new("⚫").with_nerdfont("\u{f00c}"));
1238
1239        assert_eq!(theme.icons().len(), 2);
1240        assert!(!theme.icons().is_empty());
1241    }
1242
1243    #[test]
1244    fn test_theme_resolve_icons_classic() {
1245        let theme = Theme::new()
1246            .add_icon("pending", IconDefinition::new("⚪"))
1247            .add_icon("done", IconDefinition::new("⚫").with_nerdfont("\u{f00c}"));
1248
1249        let resolved = theme.resolve_icons(IconMode::Classic);
1250        assert_eq!(resolved.get("pending").unwrap(), "⚪");
1251        assert_eq!(resolved.get("done").unwrap(), "⚫");
1252    }
1253
1254    #[test]
1255    fn test_theme_resolve_icons_nerdfont() {
1256        let theme = Theme::new()
1257            .add_icon("pending", IconDefinition::new("⚪"))
1258            .add_icon("done", IconDefinition::new("⚫").with_nerdfont("\u{f00c}"));
1259
1260        let resolved = theme.resolve_icons(IconMode::NerdFont);
1261        assert_eq!(resolved.get("pending").unwrap(), "⚪"); // No nerdfont, falls back
1262        assert_eq!(resolved.get("done").unwrap(), "\u{f00c}");
1263    }
1264
1265    #[test]
1266    fn test_theme_icons_empty_by_default() {
1267        let theme = Theme::new();
1268        assert!(theme.icons().is_empty());
1269    }
1270
1271    #[test]
1272    fn test_theme_merge_with_icons() {
1273        let base = Theme::new()
1274            .add_icon("keep", IconDefinition::new("K"))
1275            .add_icon("override", IconDefinition::new("OLD"));
1276
1277        let ext = Theme::new()
1278            .add_icon("override", IconDefinition::new("NEW"))
1279            .add_icon("added", IconDefinition::new("A"));
1280
1281        let merged = base.merge(ext);
1282        assert_eq!(merged.icons().len(), 3);
1283
1284        let resolved = merged.resolve_icons(IconMode::Classic);
1285        assert_eq!(resolved.get("keep").unwrap(), "K");
1286        assert_eq!(resolved.get("override").unwrap(), "NEW");
1287        assert_eq!(resolved.get("added").unwrap(), "A");
1288    }
1289
1290    #[test]
1291    fn test_theme_from_yaml_with_icons() {
1292        let theme = Theme::from_yaml(
1293            r#"
1294            header:
1295                fg: cyan
1296                bold: true
1297            icons:
1298                pending: "⚪"
1299                done:
1300                    classic: "⚫"
1301                    nerdfont: "nf_done"
1302            "#,
1303        )
1304        .unwrap();
1305
1306        // Styles
1307        assert_eq!(theme.len(), 1);
1308        let styles = theme.resolve_styles(None);
1309        assert!(styles.has("header"));
1310
1311        // Icons
1312        assert_eq!(theme.icons().len(), 2);
1313        let resolved = theme.resolve_icons(IconMode::Classic);
1314        assert_eq!(resolved.get("pending").unwrap(), "⚪");
1315        assert_eq!(resolved.get("done").unwrap(), "⚫");
1316
1317        let resolved = theme.resolve_icons(IconMode::NerdFont);
1318        assert_eq!(resolved.get("done").unwrap(), "nf_done");
1319    }
1320
1321    #[test]
1322    fn test_theme_from_yaml_no_icons() {
1323        let theme = Theme::from_yaml(
1324            r#"
1325            header:
1326                fg: cyan
1327            "#,
1328        )
1329        .unwrap();
1330
1331        assert!(theme.icons().is_empty());
1332    }
1333
1334    #[test]
1335    fn test_theme_from_yaml_icons_only() {
1336        let theme = Theme::from_yaml(
1337            r#"
1338            icons:
1339                check: "✓"
1340            "#,
1341        )
1342        .unwrap();
1343
1344        assert_eq!(theme.icons().len(), 1);
1345        assert_eq!(theme.len(), 0); // No styles
1346    }
1347
1348    #[test]
1349    fn test_theme_from_yaml_icons_invalid_type() {
1350        let result = Theme::from_yaml(
1351            r#"
1352            icons:
1353                bad: 42
1354            "#,
1355        );
1356        assert!(result.is_err());
1357    }
1358
1359    #[test]
1360    fn test_theme_from_yaml_icons_mapping_without_classic() {
1361        let result = Theme::from_yaml(
1362            r#"
1363            icons:
1364                bad:
1365                    nerdfont: "nf"
1366            "#,
1367        );
1368        assert!(result.is_err());
1369    }
1370
1371    #[test]
1372    fn test_theme_from_file_with_icons() {
1373        use std::fs;
1374        use tempfile::TempDir;
1375
1376        let temp_dir = TempDir::new().unwrap();
1377        let theme_path = temp_dir.path().join("iconic.yaml");
1378        fs::write(
1379            &theme_path,
1380            r#"
1381            header:
1382                fg: cyan
1383            icons:
1384                check:
1385                    classic: "[ok]"
1386                    nerdfont: "nf_check"
1387            "#,
1388        )
1389        .unwrap();
1390
1391        let theme = Theme::from_file(&theme_path).unwrap();
1392        assert_eq!(theme.icons().len(), 1);
1393        let resolved = theme.resolve_icons(IconMode::NerdFont);
1394        assert_eq!(resolved.get("check").unwrap(), "nf_check");
1395    }
1396
1397    #[test]
1398    fn test_theme_refresh_with_icons() {
1399        use std::fs;
1400        use tempfile::TempDir;
1401
1402        let temp_dir = TempDir::new().unwrap();
1403        let theme_path = temp_dir.path().join("refresh.yaml");
1404        fs::write(
1405            &theme_path,
1406            r#"
1407            icons:
1408                v1: "one"
1409            "#,
1410        )
1411        .unwrap();
1412
1413        let mut theme = Theme::from_file(&theme_path).unwrap();
1414        assert_eq!(theme.icons().len(), 1);
1415
1416        fs::write(
1417            &theme_path,
1418            r#"
1419            icons:
1420                v1: "one"
1421                v2: "two"
1422            "#,
1423        )
1424        .unwrap();
1425
1426        theme.refresh().unwrap();
1427        assert_eq!(theme.icons().len(), 2);
1428    }
1429
1430    // =========================================================================
1431    // Palette tests
1432    // =========================================================================
1433
1434    #[test]
1435    fn test_theme_no_palette_by_default() {
1436        let theme = Theme::new();
1437        assert!(theme.palette().is_none());
1438    }
1439
1440    #[test]
1441    fn test_theme_with_palette() {
1442        use crate::colorspace::{Rgb, ThemePalette};
1443
1444        let palette = ThemePalette::new([
1445            Rgb(40, 40, 40),
1446            Rgb(204, 36, 29),
1447            Rgb(152, 151, 26),
1448            Rgb(215, 153, 33),
1449            Rgb(69, 133, 136),
1450            Rgb(177, 98, 134),
1451            Rgb(104, 157, 106),
1452            Rgb(168, 153, 132),
1453        ]);
1454
1455        let theme = Theme::new().with_palette(palette);
1456        assert!(theme.palette().is_some());
1457    }
1458
1459    #[test]
1460    fn test_theme_merge_palette_from_other() {
1461        use crate::colorspace::ThemePalette;
1462
1463        let base = Theme::new();
1464        let other = Theme::new().with_palette(ThemePalette::default_xterm());
1465
1466        let merged = base.merge(other);
1467        assert!(merged.palette().is_some());
1468    }
1469
1470    #[test]
1471    fn test_theme_merge_keeps_own_palette() {
1472        use crate::colorspace::ThemePalette;
1473
1474        let base = Theme::new().with_palette(ThemePalette::default_xterm());
1475        let other = Theme::new();
1476
1477        let merged = base.merge(other);
1478        assert!(merged.palette().is_some());
1479    }
1480}