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}