Skip to main content

rich_rs/
theme.rs

1//! Theme system for named styles.
2//!
3//! Themes provide a mapping from style names (like "repr.number" or "markdown.h1")
4//! to `Style` objects. They can be stacked to create scoped style overrides.
5//!
6//! # Example
7//!
8//! ```
9//! use rich_rs::{Theme, Style};
10//!
11//! let mut theme = Theme::new();
12//! theme.add_style("error", Style::parse("bold red").unwrap());
13//! assert!(theme.get_style("error").is_some());
14//! ```
15//!
16//! # Named Themes
17//!
18//! Several themes are embedded and can be loaded by name:
19//!
20//! ```
21//! use rich_rs::Theme;
22//!
23//! let theme = Theme::from_name("dracula").unwrap();
24//! assert!(theme.get_style("repr.number").is_some());
25//! ```
26
27use std::collections::HashMap;
28use std::fs::File;
29use std::io::{self, BufRead, BufReader, Cursor};
30use std::path::Path;
31
32use once_cell::sync::Lazy;
33
34use crate::color::SimpleColor as Color;
35use crate::style::Style;
36
37// ============================================================================
38// Embedded Themes (generated from Pygments)
39// ============================================================================
40
41/// Dracula theme data (dark theme with purple accents)
42const DRACULA_THEME_DATA: &str = include_str!("themes/dracula.theme");
43
44/// Gruvbox Dark theme data (retro groove dark theme)
45const GRUVBOX_DARK_THEME_DATA: &str = include_str!("themes/gruvbox-dark.theme");
46
47/// Nord theme data (arctic, north-bluish color palette)
48const NORD_THEME_DATA: &str = include_str!("themes/nord.theme");
49
50/// Errors that can occur when working with themes.
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub enum ThemeError {
53    /// Unable to pop the base theme from the stack.
54    PopBaseTheme,
55    /// IO error when reading theme file.
56    IoError(String),
57    /// Invalid theme file format.
58    InvalidFormat(String),
59}
60
61impl std::fmt::Display for ThemeError {
62    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63        match self {
64            ThemeError::PopBaseTheme => write!(f, "Unable to pop base theme"),
65            ThemeError::IoError(msg) => write!(f, "IO error: {}", msg),
66            ThemeError::InvalidFormat(msg) => write!(f, "Invalid format: {}", msg),
67        }
68    }
69}
70
71impl std::error::Error for ThemeError {}
72
73impl From<io::Error> for ThemeError {
74    fn from(err: io::Error) -> Self {
75        ThemeError::IoError(err.to_string())
76    }
77}
78
79/// A container for style information.
80///
81/// Themes map style names (like "repr.number") to `Style` objects.
82/// They can optionally inherit from the default styles.
83#[derive(Debug, Clone)]
84pub struct Theme {
85    /// Named styles in this theme.
86    styles: HashMap<String, Style>,
87    /// Whether this theme inherits default styles.
88    inherit: bool,
89}
90
91impl Default for Theme {
92    fn default() -> Self {
93        Self::new()
94    }
95}
96
97impl Theme {
98    /// Create an empty theme that inherits default styles.
99    pub fn new() -> Self {
100        Theme {
101            styles: default_styles(),
102            inherit: true,
103        }
104    }
105
106    /// Create an empty theme without default styles.
107    pub fn empty() -> Self {
108        Theme {
109            styles: HashMap::new(),
110            inherit: false,
111        }
112    }
113
114    /// Create a theme with custom styles, optionally inheriting defaults.
115    pub fn with_styles(styles: HashMap<String, Style>, inherit: bool) -> Self {
116        let mut theme_styles = if inherit {
117            default_styles()
118        } else {
119            HashMap::new()
120        };
121        theme_styles.extend(styles);
122        Theme {
123            styles: theme_styles,
124            inherit,
125        }
126    }
127
128    /// Read a theme from an INI-like config file.
129    ///
130    /// # Format
131    ///
132    /// ```ini
133    /// [styles]
134    /// repr.number = bold cyan
135    /// repr.string = green
136    /// ```
137    pub fn read<P: AsRef<Path>>(path: P, inherit: bool) -> Result<Self, ThemeError> {
138        let file = File::open(path)?;
139        let reader = BufReader::new(file);
140        Self::from_reader(reader, inherit)
141    }
142
143    /// Read a theme from a file (convenience wrapper for `read`).
144    pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self, ThemeError> {
145        Self::read(path, true)
146    }
147
148    /// Parse a theme from a reader.
149    pub fn from_reader<R: BufRead>(reader: R, inherit: bool) -> Result<Self, ThemeError> {
150        let mut styles = HashMap::new();
151        let mut in_styles_section = false;
152
153        for line in reader.lines() {
154            let line = line?;
155            let line = line.trim();
156
157            // Skip empty lines and comments
158            if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
159                continue;
160            }
161
162            // Check for section header
163            if line.starts_with('[') && line.ends_with(']') {
164                let section = &line[1..line.len() - 1].trim().to_lowercase();
165                in_styles_section = section == "styles";
166                continue;
167            }
168
169            // Parse key = value in styles section
170            if in_styles_section && let Some((name, style_str)) = line.split_once('=') {
171                let name = name.trim().to_string();
172                let style_str = style_str.trim();
173                if let Some(style) = Style::parse(style_str) {
174                    styles.insert(name, style);
175                }
176            }
177        }
178
179        Ok(Self::with_styles(styles, inherit))
180    }
181
182    /// Get a style by name.
183    pub fn get_style(&self, name: &str) -> Option<Style> {
184        self.styles.get(name).copied()
185    }
186
187    /// Add or update a style.
188    pub fn add_style(&mut self, name: impl Into<String>, style: Style) {
189        self.styles.insert(name.into(), style);
190    }
191
192    /// Remove a style by name.
193    pub fn remove_style(&mut self, name: &str) -> Option<Style> {
194        self.styles.remove(name)
195    }
196
197    /// Check if a style exists.
198    pub fn has_style(&self, name: &str) -> bool {
199        self.styles.contains_key(name)
200    }
201
202    /// Get all style names.
203    pub fn style_names(&self) -> impl Iterator<Item = &str> {
204        self.styles.keys().map(String::as_str)
205    }
206
207    /// Get the number of styles.
208    pub fn len(&self) -> usize {
209        self.styles.len()
210    }
211
212    /// Check if the theme is empty.
213    pub fn is_empty(&self) -> bool {
214        self.styles.is_empty()
215    }
216
217    /// Whether this theme inherits default styles.
218    pub fn inherits(&self) -> bool {
219        self.inherit
220    }
221
222    /// Generate INI config file contents for this theme.
223    pub fn to_config(&self) -> String {
224        let mut lines = vec!["[styles]".to_string()];
225        let mut names: Vec<_> = self.styles.keys().collect();
226        names.sort();
227
228        for name in names {
229            if let Some(style) = self.styles.get(name) {
230                lines.push(format!("{} = {}", name, style.to_markup_string()));
231            }
232        }
233
234        lines.join("\n")
235    }
236
237    /// Load a theme by name.
238    ///
239    /// Available themes:
240    /// - `"default"` - The default rich-rs theme
241    /// - `"dracula"` - Dark theme with purple accents
242    /// - `"gruvbox-dark"` - Retro groove dark theme
243    /// - `"nord"` - Arctic, north-bluish color palette
244    ///
245    /// # Example
246    ///
247    /// ```
248    /// use rich_rs::Theme;
249    ///
250    /// let theme = Theme::from_name("dracula").unwrap();
251    /// assert!(theme.get_style("repr.number").is_some());
252    /// ```
253    pub fn from_name(name: &str) -> Option<Self> {
254        match name.to_lowercase().as_str() {
255            "default" => Some(Self::new()),
256            "dracula" => {
257                let reader = Cursor::new(DRACULA_THEME_DATA);
258                Self::from_reader(reader, true).ok()
259            }
260            "gruvbox-dark" | "gruvbox" => {
261                let reader = Cursor::new(GRUVBOX_DARK_THEME_DATA);
262                Self::from_reader(reader, true).ok()
263            }
264            "nord" => {
265                let reader = Cursor::new(NORD_THEME_DATA);
266                Self::from_reader(reader, true).ok()
267            }
268            _ => None,
269        }
270    }
271
272    /// List available theme names.
273    ///
274    /// # Example
275    ///
276    /// ```
277    /// use rich_rs::Theme;
278    ///
279    /// let themes = Theme::available_themes();
280    /// assert!(themes.contains(&"dracula"));
281    /// ```
282    pub fn available_themes() -> Vec<&'static str> {
283        vec!["default", "dracula", "gruvbox-dark", "nord"]
284    }
285}
286
287/// A stack of themes for scoped style overrides.
288///
289/// The stack allows pushing themes that temporarily override styles,
290/// then popping them to restore previous styles. Style lookups search
291/// from top to bottom.
292#[derive(Debug, Clone)]
293pub struct ThemeStack {
294    /// Merged style dictionaries at each level.
295    entries: Vec<HashMap<String, Style>>,
296}
297
298impl ThemeStack {
299    /// Create a new theme stack with a base theme.
300    pub fn new(theme: Theme) -> Self {
301        ThemeStack {
302            entries: vec![theme.styles],
303        }
304    }
305
306    /// Push a theme onto the stack.
307    ///
308    /// If `inherit` is true, styles from the current top are merged
309    /// with the new theme's styles.
310    pub fn push(&mut self, theme: Theme, inherit: bool) {
311        let styles = if inherit && !self.entries.is_empty() {
312            let mut merged = self.entries.last().unwrap().clone();
313            merged.extend(theme.styles);
314            merged
315        } else {
316            theme.styles
317        };
318        self.entries.push(styles);
319    }
320
321    /// Alias for `push` with inherit=true.
322    pub fn push_theme(&mut self, theme: Theme) {
323        self.push(theme, true);
324    }
325
326    /// Pop the top theme from the stack.
327    ///
328    /// Returns an error if attempting to pop the base theme.
329    pub fn pop(&mut self) -> Result<(), ThemeError> {
330        if self.entries.len() == 1 {
331            return Err(ThemeError::PopBaseTheme);
332        }
333        self.entries.pop();
334        Ok(())
335    }
336
337    /// Alias for `pop`.
338    pub fn pop_theme(&mut self) -> Result<(), ThemeError> {
339        self.pop()
340    }
341
342    /// Get a style by name from the top of the stack.
343    pub fn get_style(&self, name: &str) -> Option<Style> {
344        self.entries
345            .last()
346            .and_then(|styles| styles.get(name).copied())
347    }
348
349    /// Get the number of themes on the stack.
350    pub fn depth(&self) -> usize {
351        self.entries.len()
352    }
353}
354
355impl Default for ThemeStack {
356    fn default() -> Self {
357        Self::new(Theme::new())
358    }
359}
360
361// ============================================================================
362// Default Styles
363// ============================================================================
364
365/// Create the default styles map.
366///
367/// These correspond to Python Rich's DEFAULT_STYLES from default_styles.py.
368pub fn default_styles() -> HashMap<String, Style> {
369    let mut styles = HashMap::new();
370
371    // Helper to insert a style
372    macro_rules! add {
373        ($name:expr, $style:expr) => {
374            styles.insert($name.to_string(), $style);
375        };
376    }
377
378    // Basic styles
379    add!("none", Style::new());
380    add!(
381        "reset",
382        Style {
383            color: Some(Color::Default),
384            bgcolor: Some(Color::Default),
385            bold: Some(false),
386            dim: Some(false),
387            italic: Some(false),
388            underline: Some(false),
389            blink: Some(false),
390            blink2: Some(false),
391            reverse: Some(false),
392            conceal: Some(false),
393            strike: Some(false),
394            underline2: Some(false),
395            frame: Some(false),
396            encircle: Some(false),
397            overline: Some(false),
398        }
399    );
400    add!("dim", Style::new().with_dim(true));
401    add!(
402        "bright",
403        Style {
404            dim: Some(false),
405            ..Default::default()
406        }
407    );
408    add!("bold", Style::new().with_bold(true));
409    add!("strong", Style::new().with_bold(true));
410    add!(
411        "code",
412        Style {
413            reverse: Some(true),
414            bold: Some(true),
415            ..Default::default()
416        }
417    );
418    add!("italic", Style::new().with_italic(true));
419    add!("emphasize", Style::new().with_italic(true));
420    add!("underline", Style::new().with_underline(true));
421    add!(
422        "blink",
423        Style {
424            blink: Some(true),
425            ..Default::default()
426        }
427    );
428    add!(
429        "reverse",
430        Style {
431            reverse: Some(true),
432            ..Default::default()
433        }
434    );
435    add!("strike", Style::new().with_strike(true));
436
437    // Color styles
438    add!("black", Style::color(Color::Standard(0)));
439    add!("red", Style::color(Color::Standard(1)));
440    add!("green", Style::color(Color::Standard(2)));
441    add!("yellow", Style::color(Color::Standard(3)));
442    add!("blue", Style::color(Color::Standard(4)));
443    add!("magenta", Style::color(Color::Standard(5)));
444    add!("cyan", Style::color(Color::Standard(6)));
445    add!("white", Style::color(Color::Standard(7)));
446
447    // Inspect styles
448    add!(
449        "inspect.attr",
450        Style::color(Color::Standard(3)).with_italic(true)
451    );
452    add!(
453        "inspect.attr.dunder",
454        Style::color(Color::Standard(3))
455            .with_italic(true)
456            .with_dim(true)
457    );
458    add!(
459        "inspect.callable",
460        Style::color(Color::Standard(1)).with_bold(true)
461    );
462    add!(
463        "inspect.async_def",
464        Style::color(Color::Standard(14)).with_italic(true)
465    );
466    add!(
467        "inspect.def",
468        Style::color(Color::Standard(14)).with_italic(true)
469    );
470    add!(
471        "inspect.class",
472        Style::color(Color::Standard(14)).with_italic(true)
473    );
474    add!(
475        "inspect.error",
476        Style::color(Color::Standard(1)).with_bold(true)
477    );
478    add!("inspect.equals", Style::new());
479    add!("inspect.help", Style::color(Color::Standard(6)));
480    add!("inspect.doc", Style::new().with_dim(true));
481    add!("inspect.value.border", Style::color(Color::Standard(2)));
482
483    // Live styles
484    add!(
485        "live.ellipsis",
486        Style::color(Color::Standard(1)).with_bold(true)
487    );
488
489    // Layout styles
490    add!(
491        "layout.tree.row",
492        Style::color(Color::Standard(1)).with_dim(false)
493    );
494    add!(
495        "layout.tree.column",
496        Style::color(Color::Standard(4)).with_dim(false)
497    );
498
499    // Logging styles
500    add!(
501        "logging.keyword",
502        Style::color(Color::Standard(3)).with_bold(true)
503    );
504    add!("logging.level.notset", Style::new().with_dim(true));
505    add!("logging.level.debug", Style::color(Color::Standard(2)));
506    add!("logging.level.info", Style::color(Color::Standard(4)));
507    add!("logging.level.warning", Style::color(Color::Standard(3)));
508    add!(
509        "logging.level.error",
510        Style::color(Color::Standard(1)).with_bold(true)
511    );
512    add!(
513        "logging.level.critical",
514        Style {
515            color: Some(Color::Standard(1)),
516            bold: Some(true),
517            reverse: Some(true),
518            ..Default::default()
519        }
520    );
521
522    // Log styles
523    add!("log.level", Style::new());
524    add!("log.time", Style::color(Color::Standard(6)).with_dim(true));
525    add!("log.message", Style::new());
526    add!("log.path", Style::new().with_dim(true));
527
528    // Repr styles
529    add!("repr.ellipsis", Style::color(Color::Standard(3)));
530    add!(
531        "repr.indent",
532        Style::color(Color::Standard(2)).with_dim(true)
533    );
534    add!(
535        "repr.error",
536        Style::color(Color::Standard(1)).with_bold(true)
537    );
538    add!(
539        "repr.str",
540        Style::color(Color::Standard(2))
541            .with_italic(false)
542            .with_bold(false)
543    );
544    add!("repr.brace", Style::new().with_bold(true));
545    add!("repr.comma", Style::new().with_bold(true));
546    add!(
547        "repr.ipv4",
548        Style::color(Color::Standard(10)).with_bold(true)
549    );
550    add!(
551        "repr.ipv6",
552        Style::color(Color::Standard(10)).with_bold(true)
553    );
554    add!(
555        "repr.eui48",
556        Style::color(Color::Standard(10)).with_bold(true)
557    );
558    add!(
559        "repr.eui64",
560        Style::color(Color::Standard(10)).with_bold(true)
561    );
562    add!("repr.tag_start", Style::new().with_bold(true));
563    add!(
564        "repr.tag_name",
565        Style::color(Color::Standard(13)).with_bold(true)
566    );
567    add!("repr.tag_contents", Style::color(Color::Default));
568    add!("repr.tag_end", Style::new().with_bold(true));
569    add!(
570        "repr.attrib_name",
571        Style::color(Color::Standard(3)).with_italic(false)
572    );
573    add!("repr.attrib_equal", Style::new().with_bold(true));
574    add!(
575        "repr.attrib_value",
576        Style::color(Color::Standard(5)).with_italic(false)
577    );
578    add!(
579        "repr.number",
580        Style::color(Color::Standard(6))
581            .with_bold(true)
582            .with_italic(false)
583    );
584    add!(
585        "repr.number_complex",
586        Style::color(Color::Standard(6))
587            .with_bold(true)
588            .with_italic(false)
589    );
590    add!(
591        "repr.bool_true",
592        Style::color(Color::Standard(10)).with_italic(true)
593    );
594    add!(
595        "repr.bool_false",
596        Style::color(Color::Standard(9)).with_italic(true)
597    );
598    add!(
599        "repr.none",
600        Style::color(Color::Standard(5)).with_italic(true)
601    );
602    add!(
603        "repr.url",
604        Style::color(Color::Standard(12))
605            .with_underline(true)
606            .with_italic(false)
607            .with_bold(false)
608    );
609    add!(
610        "repr.uuid",
611        Style::color(Color::Standard(11)).with_bold(false)
612    );
613    add!(
614        "repr.call",
615        Style::color(Color::Standard(5)).with_bold(true)
616    );
617    add!("repr.path", Style::color(Color::Standard(5)));
618    add!("repr.filename", Style::color(Color::Standard(13)));
619
620    // Rule styles
621    add!("rule.line", Style::color(Color::Standard(10)));
622    add!("rule.text", Style::new());
623
624    // JSON styles
625    add!("json.brace", Style::new().with_bold(true));
626    add!(
627        "json.bool_true",
628        Style::color(Color::Standard(10)).with_italic(true)
629    );
630    add!(
631        "json.bool_false",
632        Style::color(Color::Standard(9)).with_italic(true)
633    );
634    add!(
635        "json.null",
636        Style::color(Color::Standard(5)).with_italic(true)
637    );
638    add!(
639        "json.number",
640        Style::color(Color::Standard(6))
641            .with_bold(true)
642            .with_italic(false)
643    );
644    add!(
645        "json.str",
646        Style::color(Color::Standard(2))
647            .with_italic(false)
648            .with_bold(false)
649    );
650    add!("json.key", Style::color(Color::Standard(4)).with_bold(true));
651
652    // Prompt styles
653    add!("prompt", Style::new());
654    add!(
655        "prompt.choices",
656        Style::color(Color::Standard(5)).with_bold(true)
657    );
658    add!(
659        "prompt.default",
660        Style::color(Color::Standard(6)).with_bold(true)
661    );
662    add!("prompt.invalid", Style::color(Color::Standard(1)));
663    add!("prompt.invalid.choice", Style::color(Color::Standard(1)));
664
665    // Pretty styles
666    add!("pretty", Style::new());
667
668    // Scope styles (for local variable display in tracebacks)
669    add!(
670        "scope.border",
671        Style::color(Color::Standard(4)).with_dim(true)
672    );
673    add!(
674        "scope.key",
675        Style::color(Color::Standard(6)).with_bold(true)
676    );
677    add!(
678        "scope.key.special",
679        Style::color(Color::Standard(3))
680            .with_italic(true)
681            .with_dim(true)
682    );
683    add!("scope.equals", Style::new());
684
685    // Table styles
686    add!("table.header", Style::new().with_bold(true));
687    add!("table.footer", Style::new().with_bold(true));
688    add!("table.cell", Style::new());
689    add!("table.title", Style::new().with_italic(true));
690    add!(
691        "table.caption",
692        Style::new().with_italic(true).with_dim(true)
693    );
694
695    // Traceback styles
696    add!("traceback.border", Style::color(Color::Standard(1)));
697    add!(
698        "traceback.border.syntax_error",
699        Style::color(Color::Standard(9))
700    );
701    add!(
702        "traceback.title",
703        Style::color(Color::Standard(1)).with_bold(true)
704    );
705    add!("traceback.text", Style::color(Color::Standard(1)));
706    add!(
707        "traceback.exc_type",
708        Style::color(Color::Standard(9)).with_bold(true)
709    );
710    add!("traceback.exc_value", Style::new());
711    add!(
712        "traceback.error",
713        Style::color(Color::Standard(1)).with_bold(true)
714    );
715    add!(
716        "traceback.error_range",
717        Style::color(Color::Standard(1))
718            .with_bold(true)
719            .with_underline(true)
720    );
721    add!("traceback.path", Style::color(Color::Standard(8)));
722    add!("traceback.filename", Style::color(Color::Standard(13)));
723    add!("traceback.lineno", Style::color(Color::Standard(13)));
724    add!(
725        "traceback.offset",
726        Style::color(Color::Standard(9)).with_bold(true)
727    );
728    add!(
729        "traceback.note",
730        Style::color(Color::Standard(2)).with_bold(true)
731    );
732    add!("traceback.group.border", Style::color(Color::Standard(5)));
733
734    // Bar/progress styles
735    add!("bar.back", Style::color(Color::EightBit(59))); // grey23
736    add!(
737        "bar.complete",
738        Style::color(Color::Rgb {
739            r: 249,
740            g: 38,
741            b: 114
742        })
743    );
744    add!(
745        "bar.finished",
746        Style::color(Color::Rgb {
747            r: 114,
748            g: 156,
749            b: 31
750        })
751    );
752    add!(
753        "bar.pulse",
754        Style::color(Color::Rgb {
755            r: 249,
756            g: 38,
757            b: 114
758        })
759    );
760
761    add!("progress.description", Style::new());
762    add!("progress.filesize", Style::color(Color::Standard(2)));
763    add!("progress.filesize.total", Style::color(Color::Standard(2)));
764    add!("progress.download", Style::color(Color::Standard(2)));
765    add!("progress.elapsed", Style::color(Color::Standard(3)));
766    add!("progress.percentage", Style::color(Color::Standard(5)));
767    add!("progress.remaining", Style::color(Color::Standard(6)));
768    add!("progress.data.speed", Style::color(Color::Standard(1)));
769    add!("progress.spinner", Style::color(Color::Standard(2)));
770
771    add!("status.spinner", Style::color(Color::Standard(2)));
772
773    // Tree styles
774    add!("tree", Style::new());
775    add!("tree.line", Style::new());
776
777    // Markdown styles
778    add!("markdown.paragraph", Style::new());
779    add!("markdown.text", Style::new());
780    add!("markdown.em", Style::new().with_italic(true));
781    add!("markdown.emph", Style::new().with_italic(true));
782    add!("markdown.strong", Style::new().with_bold(true));
783    add!(
784        "markdown.code",
785        Style::color(Color::Standard(6))
786            .with_bold(true)
787            .with_bgcolor(Color::Standard(0))
788    );
789    add!(
790        "markdown.code_block",
791        Style::color(Color::Standard(6)).with_bgcolor(Color::Standard(0))
792    );
793    add!("markdown.block_quote", Style::color(Color::Standard(5)));
794    add!("markdown.list", Style::color(Color::Standard(6)));
795    add!("markdown.item", Style::new());
796    add!(
797        "markdown.item.bullet",
798        Style::color(Color::Standard(3)).with_bold(true)
799    );
800    add!(
801        "markdown.item.number",
802        Style::color(Color::Standard(3)).with_bold(true)
803    );
804    add!("markdown.hr", Style::color(Color::Standard(3)));
805    add!("markdown.h1.border", Style::new());
806    add!("markdown.h1", Style::new().with_bold(true));
807    add!(
808        "markdown.h2",
809        Style::new().with_bold(true).with_underline(true)
810    );
811    add!("markdown.h3", Style::new().with_bold(true));
812    add!("markdown.h4", Style::new().with_bold(true).with_dim(true));
813    add!("markdown.h5", Style::new().with_underline(true));
814    add!("markdown.h6", Style::new().with_italic(true));
815    add!("markdown.h7", Style::new().with_italic(true).with_dim(true));
816    add!("markdown.link", Style::color(Color::Standard(12)));
817    add!(
818        "markdown.link_url",
819        Style::color(Color::Standard(4)).with_underline(true)
820    );
821    add!("markdown.s", Style::new().with_strike(true));
822    add!("markdown.table.border", Style::color(Color::Standard(6)));
823    add!(
824        "markdown.table.header",
825        Style::color(Color::Standard(6)).with_bold(false)
826    );
827
828    // Blink2 style
829    add!(
830        "blink2",
831        Style {
832            blink2: Some(true),
833            ..Default::default()
834        }
835    );
836
837    // ISO8601 styles
838    add!("iso8601.date", Style::color(Color::Standard(4)));
839    add!("iso8601.time", Style::color(Color::Standard(5)));
840    add!("iso8601.timezone", Style::color(Color::Standard(3)));
841
842    styles
843}
844
845static DEFAULT_STYLES_MAP: Lazy<HashMap<String, Style>> = Lazy::new(default_styles);
846
847/// Get a style from the built-in default styles by name.
848///
849/// This supports Rich's convention of style names such as `"progress.percentage"`.
850pub fn get_default_style(name: &str) -> Option<Style> {
851    DEFAULT_STYLES_MAP.get(name).copied()
852}
853
854#[cfg(test)]
855mod tests {
856    use super::*;
857
858    #[test]
859    fn test_theme_new() {
860        let theme = Theme::new();
861        // Should have default styles
862        assert!(theme.has_style("repr.number"));
863        assert!(theme.has_style("markdown.h1"));
864        assert!(theme.len() > 50);
865    }
866
867    #[test]
868    fn test_theme_empty() {
869        let theme = Theme::empty();
870        assert!(theme.is_empty());
871        assert!(!theme.has_style("repr.number"));
872    }
873
874    #[test]
875    fn test_theme_get_style() {
876        let theme = Theme::new();
877        let style = theme.get_style("repr.number");
878        assert!(style.is_some());
879        let style = style.unwrap();
880        assert_eq!(style.bold, Some(true));
881        assert_eq!(style.color, Some(Color::Standard(6)));
882    }
883
884    #[test]
885    fn test_theme_add_style() {
886        let mut theme = Theme::empty();
887        theme.add_style("custom", Style::new().with_bold(true));
888        assert!(theme.has_style("custom"));
889        assert_eq!(theme.get_style("custom").unwrap().bold, Some(true));
890    }
891
892    #[test]
893    fn test_theme_remove_style() {
894        let mut theme = Theme::new();
895        assert!(theme.has_style("repr.number"));
896        theme.remove_style("repr.number");
897        assert!(!theme.has_style("repr.number"));
898    }
899
900    #[test]
901    fn test_theme_with_styles() {
902        let mut custom = HashMap::new();
903        custom.insert("my.style".to_string(), Style::new().with_italic(true));
904
905        let theme = Theme::with_styles(custom, true);
906        // Should have both default and custom
907        assert!(theme.has_style("repr.number"));
908        assert!(theme.has_style("my.style"));
909    }
910
911    #[test]
912    fn test_theme_with_styles_no_inherit() {
913        let mut custom = HashMap::new();
914        custom.insert("my.style".to_string(), Style::new().with_italic(true));
915
916        let theme = Theme::with_styles(custom, false);
917        // Should only have custom
918        assert!(!theme.has_style("repr.number"));
919        assert!(theme.has_style("my.style"));
920    }
921
922    #[test]
923    fn test_theme_to_config() {
924        let mut theme = Theme::empty();
925        theme.add_style("test.bold", Style::new().with_bold(true));
926        theme.add_style("test.color", Style::color(Color::Standard(1)));
927
928        let config = theme.to_config();
929        assert!(config.starts_with("[styles]"));
930        assert!(config.contains("test.bold = bold"));
931        assert!(config.contains("test.color = red"));
932    }
933
934    #[test]
935    fn test_theme_from_reader() {
936        let config = r#"
937[styles]
938custom.style = bold red
939another = italic cyan
940"#;
941        let reader = std::io::Cursor::new(config);
942        let theme = Theme::from_reader(reader, false).unwrap();
943
944        assert!(theme.has_style("custom.style"));
945        let style = theme.get_style("custom.style").unwrap();
946        assert_eq!(style.bold, Some(true));
947        assert_eq!(style.color, Some(Color::Standard(1)));
948
949        let another = theme.get_style("another").unwrap();
950        assert_eq!(another.italic, Some(true));
951        assert_eq!(another.color, Some(Color::Standard(6)));
952    }
953
954    #[test]
955    fn test_theme_from_reader_with_comments() {
956        let config = r#"
957# Comment line
958[styles]
959; Another comment
960test = bold
961"#;
962        let reader = std::io::Cursor::new(config);
963        let theme = Theme::from_reader(reader, false).unwrap();
964        assert!(theme.has_style("test"));
965    }
966
967    #[test]
968    fn test_theme_stack_new() {
969        let stack = ThemeStack::new(Theme::new());
970        assert_eq!(stack.depth(), 1);
971        assert!(stack.get_style("repr.number").is_some());
972    }
973
974    #[test]
975    fn test_theme_stack_push_pop() {
976        let mut stack = ThemeStack::new(Theme::new());
977
978        // Create custom theme
979        let mut custom = Theme::empty();
980        custom.add_style("repr.number", Style::new().with_italic(true));
981
982        // Push custom theme
983        stack.push(custom, true);
984        assert_eq!(stack.depth(), 2);
985
986        // Custom style should override
987        let style = stack.get_style("repr.number").unwrap();
988        assert_eq!(style.italic, Some(true));
989
990        // Pop should restore original
991        stack.pop().unwrap();
992        assert_eq!(stack.depth(), 1);
993
994        let style = stack.get_style("repr.number").unwrap();
995        assert_eq!(style.bold, Some(true));
996        // Original style has italic = Some(false) (explicitly not italic)
997        assert_eq!(style.italic, Some(false));
998    }
999
1000    #[test]
1001    fn test_theme_stack_push_inherit() {
1002        let mut stack = ThemeStack::new(Theme::new());
1003
1004        let mut custom = Theme::empty();
1005        custom.add_style("custom.style", Style::new().with_bold(true));
1006
1007        // Push with inherit=true should keep existing styles
1008        stack.push(custom, true);
1009        assert!(stack.get_style("repr.number").is_some());
1010        assert!(stack.get_style("custom.style").is_some());
1011    }
1012
1013    #[test]
1014    fn test_theme_stack_push_no_inherit() {
1015        let mut stack = ThemeStack::new(Theme::new());
1016
1017        let mut custom = Theme::empty();
1018        custom.add_style("custom.style", Style::new().with_bold(true));
1019
1020        // Push with inherit=false should only have new styles
1021        stack.push(custom, false);
1022        assert!(stack.get_style("repr.number").is_none());
1023        assert!(stack.get_style("custom.style").is_some());
1024    }
1025
1026    #[test]
1027    fn test_theme_stack_pop_base_error() {
1028        let mut stack = ThemeStack::new(Theme::new());
1029        let result = stack.pop();
1030        assert!(matches!(result, Err(ThemeError::PopBaseTheme)));
1031    }
1032
1033    #[test]
1034    fn test_default_styles_count() {
1035        let styles = default_styles();
1036        // Should have roughly the same number as Python's DEFAULT_STYLES
1037        assert!(
1038            styles.len() >= 100,
1039            "Expected at least 100 default styles, got {}",
1040            styles.len()
1041        );
1042    }
1043
1044    #[test]
1045    fn test_default_styles_has_expected() {
1046        let styles = default_styles();
1047
1048        // Check some key styles exist
1049        let expected = [
1050            "none",
1051            "reset",
1052            "bold",
1053            "italic",
1054            "repr.number",
1055            "repr.str",
1056            "repr.bool_true",
1057            "markdown.h1",
1058            "markdown.code",
1059            "log.level",
1060            "log.time",
1061            "json.brace",
1062            "json.key",
1063            "table.header",
1064            "table.cell",
1065            "traceback.error",
1066            "traceback.title",
1067            "progress.spinner",
1068            "progress.percentage",
1069        ];
1070
1071        for name in expected {
1072            assert!(styles.contains_key(name), "Missing default style: {}", name);
1073        }
1074    }
1075
1076    #[test]
1077    fn test_style_to_string_roundtrip() {
1078        let style = Style::new().with_bold(true).with_color(Color::Standard(1));
1079
1080        let s = style.to_markup_string();
1081        assert!(s.contains("bold"));
1082        assert!(s.contains("red"));
1083
1084        let parsed = Style::parse(&s).unwrap();
1085        assert_eq!(parsed.bold, Some(true));
1086        assert_eq!(parsed.color, Some(Color::Standard(1)));
1087    }
1088
1089    #[test]
1090    fn test_theme_from_name() {
1091        // Default theme
1092        let default = Theme::from_name("default");
1093        assert!(default.is_some());
1094        assert!(default.unwrap().has_style("repr.number"));
1095
1096        // Dracula theme
1097        let dracula = Theme::from_name("dracula");
1098        assert!(dracula.is_some());
1099        let dracula = dracula.unwrap();
1100        assert!(dracula.has_style("repr.number"));
1101        // Should have custom colors from the theme file
1102        let style = dracula.get_style("repr.number").unwrap();
1103        assert!(style.color.is_some());
1104
1105        // Gruvbox dark theme
1106        let gruvbox = Theme::from_name("gruvbox-dark");
1107        assert!(gruvbox.is_some());
1108
1109        // Nord theme
1110        let nord = Theme::from_name("nord");
1111        assert!(nord.is_some());
1112
1113        // Unknown theme returns None
1114        assert!(Theme::from_name("nonexistent").is_none());
1115    }
1116
1117    #[test]
1118    fn test_theme_available_themes() {
1119        let themes = Theme::available_themes();
1120        assert!(themes.contains(&"default"));
1121        assert!(themes.contains(&"dracula"));
1122        assert!(themes.contains(&"gruvbox-dark"));
1123        assert!(themes.contains(&"nord"));
1124    }
1125}