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::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::Theme;
37//!
38//! let theme = Theme::from_yaml(r#"
39//! header:
40//!   fg: cyan
41//!   bold: true
42//!
43//! footer:
44//!   fg: gray
45//!   light:
46//!     fg: black
47//!   dark:
48//!     fg: white
49//!
50//! muted:
51//!   dim: true
52//!
53//! disabled: muted
54//! "#).unwrap();
55//! ```
56//!
57//! # Mode Resolution
58//!
59//! Use [`resolve_styles`](Theme::resolve_styles) to get a `Styles` collection
60//! for a specific color mode. This is typically called during rendering.
61
62use std::collections::HashMap;
63use std::path::{Path, PathBuf};
64
65use console::Style;
66
67use super::super::style::{
68    parse_stylesheet, StyleValidationError, StyleValue, Styles, StylesheetError, ThemeVariants,
69};
70
71use super::adaptive::ColorMode;
72
73/// A named collection of styles used when rendering templates.
74///
75/// Themes can be constructed programmatically or loaded from YAML files.
76/// They support adaptive styles that vary based on the user's color mode.
77///
78/// # Example: Programmatic Construction
79///
80/// ```rust
81/// use standout::Theme;
82/// use console::Style;
83///
84/// let theme = Theme::new()
85///     // Visual layer - concrete styles
86///     .add("muted", Style::new().dim())
87///     .add("accent", Style::new().cyan().bold())
88///     // Presentation layer - aliases
89///     .add("disabled", "muted")
90///     .add("highlighted", "accent")
91///     // Semantic layer - aliases to presentation
92///     .add("timestamp", "disabled");
93/// ```
94///
95/// # Example: From YAML
96///
97/// ```rust
98/// use standout::Theme;
99///
100/// let theme = Theme::from_yaml(r#"
101/// panel:
102///   bg: gray
103///   light:
104///     bg: white
105///   dark:
106///     bg: black
107/// header:
108///   fg: cyan
109///   bold: true
110/// "#).unwrap();
111/// ```
112#[derive(Debug, Clone)]
113pub struct Theme {
114    /// Theme name (optional, typically derived from filename).
115    name: Option<String>,
116    /// Source file path (for refresh support).
117    source_path: Option<PathBuf>,
118    /// Base styles (always populated).
119    base: HashMap<String, Style>,
120    /// Light mode style overrides.
121    light: HashMap<String, Style>,
122    /// Dark mode style overrides.
123    dark: HashMap<String, Style>,
124    /// Alias definitions (name → target).
125    aliases: HashMap<String, String>,
126}
127
128impl Theme {
129    /// Creates an empty, unnamed theme.
130    pub fn new() -> Self {
131        Self {
132            name: None,
133            source_path: None,
134            base: HashMap::new(),
135            light: HashMap::new(),
136            dark: HashMap::new(),
137            aliases: HashMap::new(),
138        }
139    }
140
141    /// Creates an empty theme with the given name.
142    pub fn named(name: impl Into<String>) -> Self {
143        Self {
144            name: Some(name.into()),
145            source_path: None,
146            base: HashMap::new(),
147            light: HashMap::new(),
148            dark: HashMap::new(),
149            aliases: HashMap::new(),
150        }
151    }
152
153    /// Sets the name on this theme, returning `self` for chaining.
154    ///
155    /// This is useful when loading themes from content where the name
156    /// is known separately (e.g., from a filename).
157    pub fn with_name(mut self, name: impl Into<String>) -> Self {
158        self.name = Some(name.into());
159        self
160    }
161
162    /// Loads a theme from a YAML file.
163    ///
164    /// The theme name is derived from the filename (without extension).
165    /// The source path is stored for [`refresh`](Theme::refresh) support.
166    ///
167    /// # Errors
168    ///
169    /// Returns a [`StylesheetError`] if the file cannot be read or parsed.
170    ///
171    /// # Example
172    ///
173    /// ```rust,ignore
174    /// use standout::Theme;
175    ///
176    /// let theme = Theme::from_file("./themes/darcula.yaml")?;
177    /// assert_eq!(theme.name(), Some("darcula"));
178    /// ```
179    pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self, StylesheetError> {
180        let path = path.as_ref();
181        let content = std::fs::read_to_string(path).map_err(|e| StylesheetError::Load {
182            message: format!("Failed to read {}: {}", path.display(), e),
183        })?;
184
185        let name = path
186            .file_stem()
187            .and_then(|s| s.to_str())
188            .map(|s| s.to_string());
189
190        let variants = parse_stylesheet(&content)?;
191        Ok(Self {
192            name,
193            source_path: Some(path.to_path_buf()),
194            base: variants.base().clone(),
195            light: variants.light().clone(),
196            dark: variants.dark().clone(),
197            aliases: variants.aliases().clone(),
198        })
199    }
200
201    /// Creates a theme from YAML content.
202    ///
203    /// The YAML format supports:
204    /// - Simple styles: `header: { fg: cyan, bold: true }`
205    /// - Shorthand: `bold_text: bold` or `warning: "yellow italic"`
206    /// - Aliases: `disabled: muted`
207    /// - Adaptive styles with `light:` and `dark:` sections
208    ///
209    /// # Errors
210    ///
211    /// Returns a [`StylesheetError`] if parsing fails.
212    ///
213    /// # Example
214    ///
215    /// ```rust
216    /// use standout::Theme;
217    ///
218    /// let theme = Theme::from_yaml(r#"
219    /// header:
220    ///   fg: cyan
221    ///   bold: true
222    ///
223    /// footer:
224    ///   dim: true
225    ///   light:
226    ///     fg: black
227    ///   dark:
228    ///     fg: white
229    /// "#).unwrap();
230    /// ```
231    pub fn from_yaml(yaml: &str) -> Result<Self, StylesheetError> {
232        let variants = parse_stylesheet(yaml)?;
233        Ok(Self::from_variants(variants))
234    }
235
236    /// Creates a theme from pre-parsed theme variants.
237    pub fn from_variants(variants: ThemeVariants) -> Self {
238        Self {
239            name: None,
240            source_path: None,
241            base: variants.base().clone(),
242            light: variants.light().clone(),
243            dark: variants.dark().clone(),
244            aliases: variants.aliases().clone(),
245        }
246    }
247
248    /// Returns the theme name, if set.
249    ///
250    /// The name is typically derived from the source filename when using
251    /// [`from_file`](Theme::from_file), or set explicitly with [`named`](Theme::named).
252    pub fn name(&self) -> Option<&str> {
253        self.name.as_deref()
254    }
255
256    /// Returns the source file path, if this theme was loaded from a file.
257    pub fn source_path(&self) -> Option<&Path> {
258        self.source_path.as_deref()
259    }
260
261    /// Reloads the theme from its source file.
262    ///
263    /// This is useful for hot-reloading during development. If the theme
264    /// was not loaded from a file, this method returns an error.
265    ///
266    /// # Errors
267    ///
268    /// Returns a [`StylesheetError`] if:
269    /// - The theme has no source file (wasn't loaded with [`from_file`](Theme::from_file))
270    /// - The file cannot be read or parsed
271    ///
272    /// # Example
273    ///
274    /// ```rust,ignore
275    /// let mut theme = Theme::from_file("./themes/darcula.yaml")?;
276    ///
277    /// // After editing the file...
278    /// theme.refresh()?;
279    /// ```
280    pub fn refresh(&mut self) -> Result<(), StylesheetError> {
281        let path = self
282            .source_path
283            .as_ref()
284            .ok_or_else(|| StylesheetError::Load {
285                message: "Cannot refresh: theme has no source file".to_string(),
286            })?;
287
288        let content = std::fs::read_to_string(path).map_err(|e| StylesheetError::Load {
289            message: format!("Failed to read {}: {}", path.display(), e),
290        })?;
291
292        let variants = parse_stylesheet(&content)?;
293        self.base = variants.base().clone();
294        self.light = variants.light().clone();
295        self.dark = variants.dark().clone();
296        self.aliases = variants.aliases().clone();
297
298        Ok(())
299    }
300
301    /// Adds a named style, returning an updated theme for chaining.
302    ///
303    /// The value can be either a concrete `Style` or a `&str`/`String` alias
304    /// to another style name, enabling layered styling.
305    ///
306    /// # Non-Adaptive
307    ///
308    /// Styles added via this method are non-adaptive (same in all modes).
309    /// For adaptive styles, use [`add_adaptive`](Self::add_adaptive) or YAML.
310    ///
311    /// # Example
312    ///
313    /// ```rust
314    /// use standout::Theme;
315    /// use console::Style;
316    ///
317    /// let theme = Theme::new()
318    ///     // Visual layer - concrete styles
319    ///     .add("muted", Style::new().dim())
320    ///     .add("accent", Style::new().cyan().bold())
321    ///     // Presentation layer - aliases
322    ///     .add("disabled", "muted")
323    ///     .add("highlighted", "accent")
324    ///     // Semantic layer - aliases to presentation
325    ///     .add("timestamp", "disabled");
326    /// ```
327    pub fn add<V: Into<StyleValue>>(mut self, name: &str, value: V) -> Self {
328        match value.into() {
329            StyleValue::Concrete(style) => {
330                self.base.insert(name.to_string(), style);
331            }
332            StyleValue::Alias(target) => {
333                self.aliases.insert(name.to_string(), target);
334            }
335        }
336        self
337    }
338
339    /// Adds an adaptive style with separate light and dark variants.
340    ///
341    /// The base style is used when no mode override exists or when mode
342    /// detection fails. Light and dark variants, if provided, override
343    /// the base in their respective modes.
344    ///
345    /// # Example
346    ///
347    /// ```rust
348    /// use standout::Theme;
349    /// use console::Style;
350    ///
351    /// let theme = Theme::new()
352    ///     .add_adaptive(
353    ///         "panel",
354    ///         Style::new().dim(),                    // Base
355    ///         Some(Style::new().fg(console::Color::Black)),  // Light mode
356    ///         Some(Style::new().fg(console::Color::White)),  // Dark mode
357    ///     );
358    /// ```
359    pub fn add_adaptive(
360        mut self,
361        name: &str,
362        base: Style,
363        light: Option<Style>,
364        dark: Option<Style>,
365    ) -> Self {
366        self.base.insert(name.to_string(), base);
367        if let Some(light_style) = light {
368            self.light.insert(name.to_string(), light_style);
369        }
370        if let Some(dark_style) = dark {
371            self.dark.insert(name.to_string(), dark_style);
372        }
373        self
374    }
375
376    /// Resolves styles for the given color mode.
377    ///
378    /// Returns a [`Styles`] collection with the appropriate style for each
379    /// defined style name:
380    ///
381    /// - For styles with a mode-specific override, uses the override
382    /// - For styles without an override, uses the base style
383    /// - Aliases are preserved for resolution during rendering
384    ///
385    /// # Example
386    ///
387    /// ```rust
388    /// use standout::{Theme, ColorMode};
389    /// use console::Style;
390    ///
391    /// let theme = Theme::new()
392    ///     .add("header", Style::new().cyan())
393    ///     .add_adaptive(
394    ///         "panel",
395    ///         Style::new(),
396    ///         Some(Style::new().fg(console::Color::Black)),
397    ///         Some(Style::new().fg(console::Color::White)),
398    ///     );
399    ///
400    /// // Get styles for dark mode
401    /// let dark_styles = theme.resolve_styles(Some(ColorMode::Dark));
402    /// ```
403    pub fn resolve_styles(&self, mode: Option<ColorMode>) -> Styles {
404        let mut styles = Styles::new();
405
406        // Select the mode-specific overrides map
407        let mode_overrides = match mode {
408            Some(ColorMode::Light) => &self.light,
409            Some(ColorMode::Dark) => &self.dark,
410            None => &HashMap::new(),
411        };
412
413        // Add concrete styles (base, with mode overrides applied)
414        for (name, base_style) in &self.base {
415            let style = mode_overrides.get(name).unwrap_or(base_style);
416            styles = styles.add(name, style.clone());
417        }
418
419        // Add aliases
420        for (name, target) in &self.aliases {
421            styles = styles.add(name, target.clone());
422        }
423
424        styles
425    }
426
427    /// Validates that all style aliases in this theme resolve correctly.
428    ///
429    /// This is called automatically at render time, but can be called
430    /// explicitly for early error detection.
431    pub fn validate(&self) -> Result<(), StyleValidationError> {
432        // Validate using a resolved Styles instance
433        self.resolve_styles(None).validate()
434    }
435
436    /// Returns true if no styles are defined.
437    pub fn is_empty(&self) -> bool {
438        self.base.is_empty() && self.aliases.is_empty()
439    }
440
441    /// Returns the number of defined styles (base + aliases).
442    pub fn len(&self) -> usize {
443        self.base.len() + self.aliases.len()
444    }
445
446    /// Resolves a single style for the given mode.
447    ///
448    /// This is a convenience wrapper around [`resolve_styles`](Self::resolve_styles).
449    pub fn get_style(&self, name: &str, mode: Option<ColorMode>) -> Option<Style> {
450        let styles = self.resolve_styles(mode);
451        // Styles::resolve is crate-private, so we have to use to_resolved_map or check internal.
452        // Wait, Styles::resolve is pub(crate). We are in rendering/theme/theme.rs,
453        // Styles is in rendering/style/registry.rs. Same crate.
454        // But Theme is in `rendering::theme`, Styles in `rendering::style`.
455        // They are different modules. `pub(crate)` is visible.
456        styles.resolve(name).cloned()
457    }
458
459    /// Returns the number of light mode overrides.
460    pub fn light_override_count(&self) -> usize {
461        self.light.len()
462    }
463
464    /// Returns the number of dark mode overrides.
465    pub fn dark_override_count(&self) -> usize {
466        self.dark.len()
467    }
468
469    /// Merges another theme into this one.
470    ///
471    /// Styles from `other` take precedence over styles in `self`.
472    /// This allows layering themes, e.g., loading a base theme and applying user overrides.
473    ///
474    /// # Example
475    ///
476    /// ```rust
477    /// use standout::Theme;
478    /// use console::Style;
479    ///
480    /// let base = Theme::new().add("text", Style::new().dim());
481    /// let user = Theme::new().add("text", Style::new().bold());
482    ///
483    /// let merged = base.merge(user);
484    /// // "text" is now bold (from user)
485    /// ```
486    pub fn merge(mut self, other: Theme) -> Self {
487        self.base.extend(other.base);
488        self.light.extend(other.light);
489        self.dark.extend(other.dark);
490        self.aliases.extend(other.aliases);
491        self
492    }
493}
494
495impl Default for Theme {
496    fn default() -> Self {
497        Self::new()
498    }
499}
500
501#[cfg(test)]
502mod tests {
503    use super::*;
504
505    #[test]
506    fn test_theme_new_is_empty() {
507        let theme = Theme::new();
508        assert!(theme.is_empty());
509        assert_eq!(theme.len(), 0);
510    }
511
512    #[test]
513    fn test_theme_add_concrete() {
514        let theme = Theme::new().add("bold", Style::new().bold());
515        assert!(!theme.is_empty());
516        assert_eq!(theme.len(), 1);
517    }
518
519    #[test]
520    fn test_theme_add_alias_str() {
521        let theme = Theme::new()
522            .add("base", Style::new().dim())
523            .add("alias", "base");
524
525        assert_eq!(theme.len(), 2);
526
527        let styles = theme.resolve_styles(None);
528        assert!(styles.has("base"));
529        assert!(styles.has("alias"));
530    }
531
532    #[test]
533    fn test_theme_add_alias_string() {
534        let target = String::from("base");
535        let theme = Theme::new()
536            .add("base", Style::new().dim())
537            .add("alias", target);
538
539        let styles = theme.resolve_styles(None);
540        assert!(styles.has("alias"));
541    }
542
543    #[test]
544    fn test_theme_validate_valid() {
545        let theme = Theme::new()
546            .add("visual", Style::new().cyan())
547            .add("semantic", "visual");
548
549        assert!(theme.validate().is_ok());
550    }
551
552    #[test]
553    fn test_theme_validate_invalid() {
554        let theme = Theme::new().add("orphan", "missing");
555        assert!(theme.validate().is_err());
556    }
557
558    #[test]
559    fn test_theme_default() {
560        let theme = Theme::default();
561        assert!(theme.is_empty());
562    }
563
564    // =========================================================================
565    // Adaptive style tests
566    // =========================================================================
567
568    #[test]
569    fn test_theme_add_adaptive() {
570        let theme = Theme::new().add_adaptive(
571            "panel",
572            Style::new().dim(),
573            Some(Style::new().bold()),
574            Some(Style::new().italic()),
575        );
576
577        assert_eq!(theme.len(), 1);
578        assert_eq!(theme.light_override_count(), 1);
579        assert_eq!(theme.dark_override_count(), 1);
580    }
581
582    #[test]
583    fn test_theme_add_adaptive_light_only() {
584        let theme =
585            Theme::new().add_adaptive("panel", Style::new().dim(), Some(Style::new().bold()), None);
586
587        assert_eq!(theme.light_override_count(), 1);
588        assert_eq!(theme.dark_override_count(), 0);
589    }
590
591    #[test]
592    fn test_theme_add_adaptive_dark_only() {
593        let theme =
594            Theme::new().add_adaptive("panel", Style::new().dim(), None, Some(Style::new().bold()));
595
596        assert_eq!(theme.light_override_count(), 0);
597        assert_eq!(theme.dark_override_count(), 1);
598    }
599
600    #[test]
601    fn test_theme_resolve_styles_no_mode() {
602        let theme = Theme::new()
603            .add("header", Style::new().cyan())
604            .add_adaptive(
605                "panel",
606                Style::new().dim(),
607                Some(Style::new().bold()),
608                Some(Style::new().italic()),
609            );
610
611        let styles = theme.resolve_styles(None);
612        assert!(styles.has("header"));
613        assert!(styles.has("panel"));
614    }
615
616    #[test]
617    fn test_theme_resolve_styles_light_mode() {
618        let theme = Theme::new().add_adaptive(
619            "panel",
620            Style::new().dim(),
621            Some(Style::new().bold()),
622            Some(Style::new().italic()),
623        );
624
625        let styles = theme.resolve_styles(Some(ColorMode::Light));
626        assert!(styles.has("panel"));
627        // The style should be the light override, not base
628        // We can't easily check the actual style, but we verify resolution works
629    }
630
631    #[test]
632    fn test_theme_resolve_styles_dark_mode() {
633        let theme = Theme::new().add_adaptive(
634            "panel",
635            Style::new().dim(),
636            Some(Style::new().bold()),
637            Some(Style::new().italic()),
638        );
639
640        let styles = theme.resolve_styles(Some(ColorMode::Dark));
641        assert!(styles.has("panel"));
642    }
643
644    #[test]
645    fn test_theme_resolve_styles_preserves_aliases() {
646        let theme = Theme::new()
647            .add("base", Style::new().dim())
648            .add("alias", "base");
649
650        let styles = theme.resolve_styles(Some(ColorMode::Light));
651        assert!(styles.has("base"));
652        assert!(styles.has("alias"));
653
654        // Validate that alias resolution still works
655        assert!(styles.validate().is_ok());
656    }
657
658    // =========================================================================
659    // YAML parsing tests
660    // =========================================================================
661
662    #[test]
663    fn test_theme_from_yaml_simple() {
664        let theme = Theme::from_yaml(
665            r#"
666            header:
667                fg: cyan
668                bold: true
669            "#,
670        )
671        .unwrap();
672
673        assert_eq!(theme.len(), 1);
674        let styles = theme.resolve_styles(None);
675        assert!(styles.has("header"));
676    }
677
678    #[test]
679    fn test_theme_from_yaml_shorthand() {
680        let theme = Theme::from_yaml(
681            r#"
682            bold_text: bold
683            accent: cyan
684            warning: "yellow italic"
685            "#,
686        )
687        .unwrap();
688
689        assert_eq!(theme.len(), 3);
690    }
691
692    #[test]
693    fn test_theme_from_yaml_alias() {
694        let theme = Theme::from_yaml(
695            r#"
696            muted:
697                dim: true
698            disabled: muted
699            "#,
700        )
701        .unwrap();
702
703        assert_eq!(theme.len(), 2);
704        assert!(theme.validate().is_ok());
705    }
706
707    #[test]
708    fn test_theme_from_yaml_adaptive() {
709        let theme = Theme::from_yaml(
710            r#"
711            panel:
712                fg: gray
713                light:
714                    fg: black
715                dark:
716                    fg: white
717            "#,
718        )
719        .unwrap();
720
721        assert_eq!(theme.len(), 1);
722        assert_eq!(theme.light_override_count(), 1);
723        assert_eq!(theme.dark_override_count(), 1);
724    }
725
726    #[test]
727    fn test_theme_from_yaml_invalid() {
728        let result = Theme::from_yaml("not valid yaml: [");
729        assert!(result.is_err());
730    }
731
732    #[test]
733    fn test_theme_from_yaml_complete() {
734        let theme = Theme::from_yaml(
735            r##"
736            # Visual layer
737            muted:
738                dim: true
739
740            accent:
741                fg: cyan
742                bold: true
743
744            # Adaptive
745            background:
746                light:
747                    bg: "#f8f8f8"
748                dark:
749                    bg: "#1e1e1e"
750
751            # Aliases
752            header: accent
753            footer: muted
754            "##,
755        )
756        .unwrap();
757
758        // 3 concrete styles + 2 aliases = 5 total
759        assert_eq!(theme.len(), 5);
760        assert!(theme.validate().is_ok());
761
762        // background is adaptive
763        assert_eq!(theme.light_override_count(), 1);
764        assert_eq!(theme.dark_override_count(), 1);
765    }
766
767    // =========================================================================
768    // Name and source path tests
769    // =========================================================================
770
771    #[test]
772    fn test_theme_named() {
773        let theme = Theme::named("darcula");
774        assert_eq!(theme.name(), Some("darcula"));
775        assert!(theme.is_empty());
776    }
777
778    #[test]
779    fn test_theme_new_has_no_name() {
780        let theme = Theme::new();
781        assert_eq!(theme.name(), None);
782        assert_eq!(theme.source_path(), None);
783    }
784
785    #[test]
786    fn test_theme_from_file() {
787        use std::fs;
788        use tempfile::TempDir;
789
790        let temp_dir = TempDir::new().unwrap();
791        let theme_path = temp_dir.path().join("darcula.yaml");
792        fs::write(
793            &theme_path,
794            r#"
795            header:
796                fg: cyan
797                bold: true
798            muted:
799                dim: true
800            "#,
801        )
802        .unwrap();
803
804        let theme = Theme::from_file(&theme_path).unwrap();
805        assert_eq!(theme.name(), Some("darcula"));
806        assert_eq!(theme.source_path(), Some(theme_path.as_path()));
807        assert_eq!(theme.len(), 2);
808    }
809
810    #[test]
811    fn test_theme_from_file_not_found() {
812        let result = Theme::from_file("/nonexistent/path/theme.yaml");
813        assert!(result.is_err());
814    }
815
816    #[test]
817    fn test_theme_refresh() {
818        use std::fs;
819        use tempfile::TempDir;
820
821        let temp_dir = TempDir::new().unwrap();
822        let theme_path = temp_dir.path().join("dynamic.yaml");
823        fs::write(
824            &theme_path,
825            r#"
826            header:
827                fg: red
828            "#,
829        )
830        .unwrap();
831
832        let mut theme = Theme::from_file(&theme_path).unwrap();
833        assert_eq!(theme.len(), 1);
834
835        // Update the file
836        fs::write(
837            &theme_path,
838            r#"
839            header:
840                fg: blue
841            footer:
842                dim: true
843            "#,
844        )
845        .unwrap();
846
847        // Refresh
848        theme.refresh().unwrap();
849        assert_eq!(theme.len(), 2);
850    }
851
852    #[test]
853    fn test_theme_refresh_without_source() {
854        let mut theme = Theme::new();
855        let result = theme.refresh();
856        assert!(result.is_err());
857    }
858
859    #[test]
860    fn test_theme_merge() {
861        let base = Theme::new()
862            .add("keep", Style::new().dim())
863            .add("overwrite", Style::new().red());
864
865        let extension = Theme::new()
866            .add("overwrite", Style::new().blue())
867            .add("new", Style::new().bold());
868
869        let merged = base.merge(extension);
870
871        let styles = merged.resolve_styles(None);
872
873        // "keep" should be from base
874        assert!(styles.has("keep"));
875
876        // "overwrite" should be from extension (blue, not red)
877        assert!(styles.has("overwrite"));
878
879        // "new" should be from extension
880        assert!(styles.has("new"));
881
882        assert_eq!(merged.len(), 3);
883    }
884}