Skip to main content

rich/
theme.rs

1//! Themes — named styles.
2//!
3//! Port of upstream `rich/theme.py` + `rich/default_styles.py`. A [`Theme`] maps
4//! style names (e.g. `"repr.number"`, `"markdown.h1"`) to [`Style`]s so that
5//! markup tags and highlighters can refer to styles by name.
6//!
7//! [`DEFAULT_STYLES`] is the complete upstream table — it is the single source
8//! of truth for named styles in this crate; nothing else should keep its own
9//! name→style map.
10
11use std::collections::HashMap;
12
13use crate::errors::Result;
14use crate::style::StyleType;
15
16use crate::style::Style;
17
18/// Upstream's `rich.default_styles.DEFAULT_STYLES`, verbatim.
19///
20/// Captured from real rich 15.0.0 (`str(style)` for each entry), so the specs
21/// are exactly what upstream parses. `theme_covers_upstream` asserts the count,
22/// and `every_default_style_parses` asserts we can actually parse all of them —
23/// a spec this crate's `Style::parse` cannot handle would otherwise be dropped
24/// silently and leave a named style resolving to nothing.
25pub const DEFAULT_STYLES: &[(&str, &str)] = &[
26    ("none", "none"),
27    (
28        "reset",
29        "not bold not dim not italic not underline not blink not blink2 \
30         not reverse not conceal not strike default on default",
31    ),
32    ("dim", "dim"),
33    ("bright", "not dim"),
34    ("bold", "bold"),
35    ("strong", "bold"),
36    ("code", "bold reverse"),
37    ("italic", "italic"),
38    ("emphasize", "italic"),
39    ("underline", "underline"),
40    ("blink", "blink"),
41    ("blink2", "blink2"),
42    ("reverse", "reverse"),
43    ("strike", "strike"),
44    ("black", "black"),
45    ("red", "red"),
46    ("green", "green"),
47    ("yellow", "yellow"),
48    ("magenta", "magenta"),
49    ("cyan", "cyan"),
50    ("white", "white"),
51    ("inspect.attr", "italic yellow"),
52    ("inspect.attr.dunder", "dim italic yellow"),
53    ("inspect.callable", "bold red"),
54    ("inspect.async_def", "italic bright_cyan"),
55    ("inspect.def", "italic bright_cyan"),
56    ("inspect.class", "italic bright_cyan"),
57    ("inspect.error", "bold red"),
58    ("inspect.equals", "none"),
59    ("inspect.help", "cyan"),
60    ("inspect.doc", "dim"),
61    ("inspect.value.border", "green"),
62    ("live.ellipsis", "bold red"),
63    ("layout.tree.row", "not dim red"),
64    ("layout.tree.column", "not dim blue"),
65    ("logging.keyword", "bold yellow"),
66    ("logging.level.notset", "dim"),
67    ("logging.level.debug", "green"),
68    ("logging.level.info", "blue"),
69    ("logging.level.warning", "yellow"),
70    ("logging.level.error", "bold red"),
71    ("logging.level.critical", "bold reverse red"),
72    ("log.level", "none"),
73    ("log.time", "dim cyan"),
74    ("log.message", "none"),
75    ("log.path", "dim"),
76    ("repr.ellipsis", "yellow"),
77    ("repr.indent", "dim green"),
78    ("repr.error", "bold red"),
79    ("repr.str", "not bold not italic green"),
80    ("repr.brace", "bold"),
81    ("repr.comma", "bold"),
82    ("repr.ipv4", "bold bright_green"),
83    ("repr.ipv6", "bold bright_green"),
84    ("repr.eui48", "bold bright_green"),
85    ("repr.eui64", "bold bright_green"),
86    ("repr.tag_start", "bold"),
87    ("repr.tag_name", "bold bright_magenta"),
88    ("repr.tag_contents", "default"),
89    ("repr.tag_end", "bold"),
90    ("repr.attrib_name", "not italic yellow"),
91    ("repr.attrib_equal", "bold"),
92    ("repr.attrib_value", "not italic magenta"),
93    ("repr.number", "bold not italic cyan"),
94    ("repr.number_complex", "bold not italic cyan"),
95    ("repr.bool_true", "italic bright_green"),
96    ("repr.bool_false", "italic bright_red"),
97    ("repr.none", "italic magenta"),
98    ("repr.url", "not bold not italic underline bright_blue"),
99    ("repr.uuid", "not bold bright_yellow"),
100    ("repr.call", "bold magenta"),
101    ("repr.path", "magenta"),
102    ("repr.filename", "bright_magenta"),
103    ("rule.line", "bright_green"),
104    ("rule.text", "none"),
105    ("json.brace", "bold"),
106    ("json.bool_true", "italic bright_green"),
107    ("json.bool_false", "italic bright_red"),
108    ("json.null", "italic magenta"),
109    ("json.number", "bold not italic cyan"),
110    ("json.str", "not bold not italic green"),
111    ("json.key", "bold blue"),
112    ("prompt", "none"),
113    ("prompt.choices", "bold magenta"),
114    ("prompt.default", "bold cyan"),
115    ("prompt.invalid", "red"),
116    ("prompt.invalid.choice", "red"),
117    ("pretty", "none"),
118    ("scope.border", "blue"),
119    ("scope.key", "italic yellow"),
120    ("scope.key.special", "dim italic yellow"),
121    ("scope.equals", "red"),
122    ("table.header", "bold"),
123    ("table.footer", "bold"),
124    ("table.cell", "none"),
125    ("table.title", "italic"),
126    ("table.caption", "dim italic"),
127    ("traceback.error", "italic red"),
128    ("traceback.border.syntax_error", "bright_red"),
129    ("traceback.border", "red"),
130    ("traceback.text", "none"),
131    ("traceback.title", "bold red"),
132    ("traceback.exc_type", "bold bright_red"),
133    ("traceback.exc_value", "none"),
134    ("traceback.offset", "bold bright_red"),
135    ("traceback.error_range", "bold underline"),
136    ("traceback.note", "bold green"),
137    ("traceback.group.border", "magenta"),
138    ("bar.back", "grey23"),
139    ("bar.complete", "rgb(249,38,114)"),
140    ("bar.finished", "rgb(114,156,31)"),
141    ("bar.pulse", "rgb(249,38,114)"),
142    ("progress.description", "none"),
143    ("progress.filesize", "green"),
144    ("progress.filesize.total", "green"),
145    ("progress.download", "green"),
146    ("progress.elapsed", "yellow"),
147    ("progress.percentage", "magenta"),
148    ("progress.remaining", "cyan"),
149    ("progress.data.speed", "red"),
150    ("progress.spinner", "green"),
151    ("status.spinner", "green"),
152    ("tree", "none"),
153    ("tree.line", "none"),
154    ("markdown.paragraph", "none"),
155    ("markdown.text", "none"),
156    ("markdown.em", "italic"),
157    ("markdown.emph", "italic"),
158    ("markdown.strong", "bold"),
159    ("markdown.code", "bold cyan on black"),
160    ("markdown.code_block", "cyan on black"),
161    ("markdown.block_quote", "magenta"),
162    ("markdown.list", "cyan"),
163    ("markdown.item", "none"),
164    ("markdown.item.bullet", "bold"),
165    ("markdown.item.number", "cyan"),
166    ("markdown.hr", "dim"),
167    ("markdown.h1.border", "none"),
168    ("markdown.h1", "bold underline"),
169    ("markdown.h2", "underline magenta"),
170    ("markdown.h3", "bold magenta"),
171    ("markdown.h4", "italic magenta"),
172    ("markdown.h5", "italic"),
173    ("markdown.h6", "dim"),
174    ("markdown.h7", "dim italic"),
175    ("markdown.link", "bright_blue"),
176    ("markdown.link_url", "underline blue"),
177    ("markdown.s", "strike"),
178    ("markdown.table.border", "cyan"),
179    ("markdown.table.header", "not bold cyan"),
180    ("markdown.kbd", "bold bright_yellow"),
181    ("iso8601.date", "blue"),
182    ("iso8601.time", "magenta"),
183    ("iso8601.timezone", "yellow"),
184];
185
186/// A named collection of styles. Mirrors `rich.theme.Theme`.
187#[derive(Debug, Clone, Default)]
188pub struct Theme {
189    styles: HashMap<String, Style>,
190}
191
192impl Theme {
193    pub fn new() -> Self {
194        Theme::default()
195    }
196
197    /// Look up a style by name.
198    pub fn get(&self, name: &str) -> Option<&Style> {
199        self.styles.get(name)
200    }
201
202    /// Insert or replace a named style.
203    pub fn insert(&mut self, name: impl Into<String>, style: Style) {
204        self.styles.insert(name.into(), style);
205    }
206
207    /// Resolve a [`StyleType`] against this theme. Port of `Console.get_style`.
208    ///
209    /// An already-resolved style passes straight through. A name is looked up in
210    /// the theme **first**, and only then parsed as a style definition — the
211    /// order matters, because the default theme itself defines bare words like
212    /// `none`, `bold` and `red`, and a custom theme has to be able to shadow
213    /// them.
214    ///
215    /// The lookup is case-sensitive while the parse fallback is not, which
216    /// reproduces an asymmetry upstream really has: `"BOLD"` misses the theme but
217    /// still parses to bold, whereas a theme key `"Danger"` is never found by a
218    /// span naming `"danger"`.
219    pub fn get_style(&self, style: &StyleType) -> Result<Style> {
220        match style {
221            StyleType::Style(style) => Ok(style.clone()),
222            StyleType::Name(name) => match self.styles.get(name) {
223                Some(style) => Ok(style.clone()),
224                None => Style::parse(name),
225            },
226        }
227    }
228
229    /// As [`get_style`](Self::get_style), but an unresolvable name yields the
230    /// null style instead of an error. Port of upstream's
231    /// `get_style(..., default=Style.null())`, which is what the render path
232    /// uses — an unknown name must not blow up a print.
233    pub fn get_style_or_null(&self, style: &StyleType) -> Style {
234        self.get_style(style).unwrap_or_default()
235    }
236
237    /// How many named styles this theme holds.
238    pub fn len(&self) -> usize {
239        self.styles.len()
240    }
241
242    /// Whether this theme holds no styles.
243    pub fn is_empty(&self) -> bool {
244        self.styles.is_empty()
245    }
246
247    /// The complete upstream default theme — every entry of [`DEFAULT_STYLES`].
248    pub fn default_theme() -> Self {
249        let mut theme = Theme::new();
250        for (name, spec) in DEFAULT_STYLES {
251            match Style::parse(spec) {
252                Ok(style) => theme.insert(*name, style),
253                // Unreachable in practice: `every_default_style_parses` fails
254                // the build if a spec stops parsing. Skipping keeps a bad spec
255                // from poisoning every other named style at runtime.
256                Err(_) => continue,
257            }
258        }
259        theme
260    }
261
262    /// The shared default theme, for callers that only need to resolve a name
263    /// (e.g. the built-in highlighters) and have no `Console` to hand.
264    pub fn default_shared() -> &'static Theme {
265        static DEFAULT: std::sync::OnceLock<Theme> = std::sync::OnceLock::new();
266        DEFAULT.get_or_init(Theme::default_theme)
267    }
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273
274    /// The theme is consulted *before* the style parser. The default theme
275    /// defines bare words like `red` itself, so parse-first would make a custom
276    /// theme unable to shadow them.
277    ///
278    /// Verified against real rich 15.0.0: with `Theme({"red": "blue"})`,
279    /// `Console.get_style("red")` returns blue.
280    #[test]
281    fn theme_lookup_beats_the_style_parser() {
282        let mut theme = Theme::default_theme();
283        theme.insert("red", Style::parse("blue").unwrap());
284        assert_eq!(
285            theme.get_style(&StyleType::Name("red".into())).unwrap(),
286            Style::parse("blue").unwrap()
287        );
288    }
289
290    /// Upstream's case handling is asymmetric, and this pins it: the theme
291    /// lookup is case-sensitive, but the parse fallback is not. So `"BOLD"`
292    /// misses the theme and still parses to bold, while a theme key `"Danger"`
293    /// is never found by a span naming `"danger"`.
294    #[test]
295    fn lookup_is_case_sensitive_but_parsing_is_not() {
296        let mut theme = Theme::new();
297        theme.insert("Danger", Style::parse("bold red").unwrap());
298
299        assert_eq!(
300            theme.get_style(&StyleType::Name("BOLD".into())).unwrap(),
301            Style::parse("bold").unwrap()
302        );
303        // "danger" is not a style definition either, so it does not resolve.
304        assert!(theme.get_style(&StyleType::Name("danger".into())).is_err());
305        assert_eq!(
306            theme.get_style(&StyleType::Name("Danger".into())).unwrap(),
307            Style::parse("bold red").unwrap()
308        );
309    }
310
311    /// The parse fallback inherits `Style::parse`'s single-letter aliases.
312    #[test]
313    fn parse_fallback_understands_aliases() {
314        let theme = Theme::new();
315        assert_eq!(
316            theme.get_style(&StyleType::Name("b".into())).unwrap(),
317            Style::parse("bold").unwrap()
318        );
319    }
320
321    /// An unknown name is an error from `get_style` and the null style from
322    /// `get_style_or_null` — the render path uses the latter so a typo cannot
323    /// blow up a print.
324    #[test]
325    fn unknown_names_error_but_render_null() {
326        let theme = Theme::default_theme();
327        let unknown = StyleType::Name("repr.nope".into());
328        assert!(theme.get_style(&unknown).is_err());
329        assert!(theme.get_style_or_null(&unknown).is_null());
330    }
331
332    /// An already-resolved style passes through untouched, theme or no theme.
333    #[test]
334    fn resolved_styles_pass_through() {
335        let mut theme = Theme::new();
336        theme.insert("bold", Style::parse("red").unwrap());
337        let style = Style::parse("bold").unwrap();
338        assert_eq!(
339            theme.get_style(&StyleType::Style(style.clone())).unwrap(),
340            style
341        );
342    }
343
344    #[test]
345    fn theme_covers_upstream() {
346        // rich 15.0.0 ships exactly this many named styles.
347        assert_eq!(DEFAULT_STYLES.len(), 154);
348        // No duplicate names (a duplicate would silently shadow).
349        let mut names: Vec<&str> = DEFAULT_STYLES.iter().map(|(n, _)| *n).collect();
350        names.sort_unstable();
351        let before = names.len();
352        names.dedup();
353        assert_eq!(
354            names.len(),
355            before,
356            "duplicate style names in DEFAULT_STYLES"
357        );
358    }
359
360    /// Every upstream spec must parse. A failure here means `Style::parse` is
361    /// missing syntax upstream uses, and that style would silently vanish from
362    /// the theme rather than resolving.
363    #[test]
364    fn every_default_style_parses() {
365        let unparsed: Vec<&str> = DEFAULT_STYLES
366            .iter()
367            .filter(|(_, spec)| Style::parse(spec).is_err())
368            .map(|(name, _)| *name)
369            .collect();
370        assert!(
371            unparsed.is_empty(),
372            "specs that failed to parse: {unparsed:?}"
373        );
374        assert_eq!(Theme::default_theme().len(), DEFAULT_STYLES.len());
375    }
376
377    #[test]
378    fn resolves_a_few_known_styles() {
379        let theme = Theme::default_theme();
380        assert_eq!(
381            theme.get("repr.number"),
382            Style::parse("bold not italic cyan").ok().as_ref()
383        );
384        assert_eq!(
385            theme.get("markdown.table.header"),
386            Style::parse("not bold cyan").ok().as_ref()
387        );
388        assert!(theme.get("no.such.style").is_none());
389    }
390}