Skip to main content

rich/
style.rs

1//! Text styles.
2//!
3//! Port of upstream `rich/style.py` (core attributes). A [`Style`] holds an
4//! optional foreground/background [`Color`] plus a set of boolean attributes
5//! (bold, italic, …). Each attribute is tri-state: `Some(true)` = on,
6//! `Some(false)` = explicitly off, `None` = unset — this preserves upstream's
7//! `_set_attributes`/`_attributes` bitmask semantics under [`Style::combine`].
8
9use crate::color::{Color, ColorSystem};
10use crate::errors::{Result, RichError};
11
12/// The 13 boolean attributes, in the SGR order upstream emits them.
13const ATTR_COUNT: usize = 13;
14
15/// SGR codes per attribute index (`rich.style._STYLE_MAP`).
16const ATTR_SGR: [&str; ATTR_COUNT] = [
17    "1", "2", "3", "4", "5", "6", "7", "8", "9", "21", "51", "52", "53",
18];
19
20/// Canonical attribute names per index.
21const ATTR_NAMES: [&str; ATTR_COUNT] = [
22    "bold",
23    "dim",
24    "italic",
25    "underline",
26    "blink",
27    "blink2",
28    "reverse",
29    "conceal",
30    "strike",
31    "underline2",
32    "frame",
33    "encircle",
34    "overline",
35];
36
37/// Map a style word (including upstream's short aliases) to its attribute index.
38fn attribute_index(word: &str) -> Option<usize> {
39    let canonical = match word {
40        "b" => "bold",
41        "d" => "dim",
42        "i" => "italic",
43        "u" => "underline",
44        "r" => "reverse",
45        "c" => "conceal",
46        "s" => "strike",
47        "uu" => "underline2",
48        "o" => "overline",
49        other => other,
50    };
51    ATTR_NAMES.iter().position(|&n| n == canonical)
52}
53
54/// A style, or the *name* of one to be looked up later. Port of upstream's
55/// `StyleType = Union[str, "Style"]` (`rich/style.py`).
56///
57/// A [`Span`](crate::text::Span) that holds a [`Name`](StyleType::Name) is
58/// resolved when it is rendered, against the theme of the console doing the
59/// rendering — so the same [`Text`](crate::text::Text) printed to two differently
60/// themed consoles comes out in two different colours, as it does upstream.
61/// Resolving eagerly instead would freeze the colours at construction time.
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub enum StyleType {
64    /// A theme key (`"repr.number"`) or a style definition (`"bold red"`),
65    /// resolved by [`Theme::get_style`](crate::theme::Theme::get_style).
66    Name(String),
67    /// An already-resolved style.
68    Style(Style),
69}
70
71impl Default for StyleType {
72    fn default() -> Self {
73        StyleType::Style(Style::new())
74    }
75}
76
77impl StyleType {
78    /// True when this is an already-resolved style that sets nothing. A
79    /// [`Name`](StyleType::Name) is never null — it may resolve to anything.
80    pub fn is_null_style(&self) -> bool {
81        matches!(self, StyleType::Style(style) if style.is_null())
82    }
83}
84
85impl From<Style> for StyleType {
86    fn from(style: Style) -> Self {
87        StyleType::Style(style)
88    }
89}
90
91impl From<&Style> for StyleType {
92    fn from(style: &Style) -> Self {
93        StyleType::Style(style.clone())
94    }
95}
96
97impl From<String> for StyleType {
98    fn from(name: String) -> Self {
99        StyleType::Name(name)
100    }
101}
102
103impl From<&str> for StyleType {
104    fn from(name: &str) -> Self {
105        StyleType::Name(name.to_string())
106    }
107}
108
109/// A terminal text style. Mirrors `rich.style.Style`.
110#[derive(Debug, Clone, PartialEq, Eq, Default)]
111pub struct Style {
112    color: Option<Color>,
113    bgcolor: Option<Color>,
114    attrs: [Option<bool>; ATTR_COUNT],
115    /// An OSC 8 hyperlink target, if any.
116    link: Option<String>,
117}
118
119impl Style {
120    /// The empty (null) style — sets nothing.
121    pub fn new() -> Self {
122        Style::default()
123    }
124
125    /// A style carrying only a foreground and/or background color.
126    /// Port of `Style.from_color`.
127    pub fn from_color(color: Option<Color>, bgcolor: Option<Color>) -> Self {
128        Style {
129            color,
130            bgcolor,
131            attrs: [None; ATTR_COUNT],
132            link: None,
133        }
134    }
135
136    pub fn with_color(mut self, color: Color) -> Self {
137        self.color = Some(color);
138        self
139    }
140
141    /// Attach an OSC 8 hyperlink target. Port of `Style(link=…)`.
142    pub fn with_link(mut self, url: impl Into<String>) -> Self {
143        self.link = Some(url.into());
144        self
145    }
146
147    /// The hyperlink target, if set.
148    pub fn link(&self) -> Option<&str> {
149        self.link.as_deref()
150    }
151
152    /// Return a copy with the hyperlink target set (`Some`) or cleared (`None`),
153    /// leaving every other attribute unchanged. Port of `Style.update_link`.
154    pub fn update_link(&self, link: Option<String>) -> Style {
155        let mut style = self.clone();
156        style.link = link;
157        style
158    }
159
160    pub fn with_bgcolor(mut self, color: Color) -> Self {
161        self.bgcolor = Some(color);
162        self
163    }
164
165    pub fn color(&self) -> Option<&Color> {
166        self.color.as_ref()
167    }
168
169    pub fn bgcolor(&self) -> Option<&Color> {
170        self.bgcolor.as_ref()
171    }
172
173    /// A copy with the foreground and background colours removed; attributes
174    /// and link survive. Port of `Style.without_color`.
175    pub fn without_color(&self) -> Style {
176        Style {
177            color: None,
178            bgcolor: None,
179            attrs: self.attrs,
180            link: self.link.clone(),
181        }
182    }
183
184    /// The tri-state value of attribute `index` (see the internal `attrs` order:
185    /// 0=bold, 1=dim, 2=italic, 3=underline, 6=reverse, 8=strike, …).
186    pub fn attr(&self, index: usize) -> Option<bool> {
187        self.attrs.get(index).copied().flatten()
188    }
189
190    /// True when nothing at all is set (renders as a no-op).
191    pub fn is_null(&self) -> bool {
192        self.color.is_none()
193            && self.bgcolor.is_none()
194            && self.link.is_none()
195            && self.attrs.iter().all(Option::is_none)
196    }
197
198    /// Regenerate the style definition string. Port of `Style.__str__`.
199    ///
200    /// Attributes come first in their canonical order, then the foreground
201    /// colour, then `on <bgcolour>`, then `link <url>`. A style that sets nothing
202    /// is `"none"` — never the empty string, which upstream reserves for "no
203    /// style at all".
204    pub fn definition(&self) -> String {
205        let mut parts: Vec<String> = Vec::new();
206        for (index, name) in ATTR_NAMES.iter().enumerate() {
207            match self.attrs[index] {
208                Some(true) => parts.push((*name).to_string()),
209                Some(false) => parts.push(format!("not {name}")),
210                None => {}
211            }
212        }
213        if let Some(color) = &self.color {
214            parts.push(color.name.clone());
215        }
216        if let Some(bgcolor) = &self.bgcolor {
217            parts.push("on".to_string());
218            parts.push(bgcolor.name.clone());
219        }
220        if let Some(link) = &self.link {
221            parts.push("link".to_string());
222            parts.push(link.clone());
223        }
224        if parts.is_empty() {
225            "none".to_string()
226        } else {
227            parts.join(" ")
228        }
229    }
230
231    /// Canonicalise a style definition so that definitions with the same effect
232    /// have the same string. Port of `Style.normalize`.
233    ///
234    /// A definition that parses round-trips through [`definition`](Self::definition),
235    /// so `"b"` and `"BOLD"` both become `"bold"`. One that does not parse is
236    /// merely trimmed and lowercased — that is the path a *theme name* like
237    /// `"repr.number"` takes, and it is why theme lookups are effectively
238    /// case-insensitive on the markup side while [`Theme::get_style`] itself is
239    /// case-sensitive.
240    ///
241    /// [`Theme::get_style`]: crate::theme::Theme::get_style
242    pub fn normalize(definition: &str) -> String {
243        match Style::parse(definition) {
244            Ok(style) => style.definition(),
245            Err(_) => definition.trim().to_lowercase(),
246        }
247    }
248
249    /// Parse a style definition such as `"bold red on blue"`.
250    ///
251    /// Port of `Style.parse` covering attributes, `not <attr>`, `link <url>`, and
252    /// `<color> on <color>`. (`meta` is deferred — see DIVERGENCES.)
253    pub fn parse(definition: &str) -> Result<Self> {
254        // Port of upstream's leading guard:
255        //     if style_definition.strip() == "none" or not style_definition
256        // `none` is only valid as the WHOLE definition — upstream raises on
257        // `"bold none"`, because inside the word loop `none` is treated as a
258        // colour name and fails to parse. Many `DEFAULT_STYLES` entries are
259        // exactly `"none"`, so without this they would all be dropped.
260        if definition.is_empty() || definition.trim() == "none" {
261            return Ok(Style::new());
262        }
263        let mut style = Style::new();
264        let mut words = definition.split_whitespace();
265        while let Some(raw) = words.next() {
266            let word = raw.to_ascii_lowercase();
267            match word.as_str() {
268                "on" => {
269                    let color_word = words.next().ok_or_else(|| {
270                        RichError::StyleSyntax("color expected after 'on'".to_string())
271                    })?;
272                    style.bgcolor = Some(Color::parse(color_word)?);
273                }
274                "not" => {
275                    let attr_word = words.next().ok_or_else(|| {
276                        RichError::StyleSyntax("attribute expected after 'not'".to_string())
277                    })?;
278                    // Deliberately NOT lowercased: upstream folds case only on
279                    // the loop word, and looks the `not` operand up verbatim —
280                    // so `"not BOLD"` is a syntax error there, and must be here.
281                    let idx = attribute_index(attr_word).ok_or_else(|| {
282                        RichError::StyleSyntax(format!(
283                            "{attr_word:?} is not a recognized attribute"
284                        ))
285                    })?;
286                    style.attrs[idx] = Some(false);
287                }
288                "link" => {
289                    // A bare `link` is a syntax error upstream, not an empty
290                    // link — accepting it would emit a hyperlink to nowhere.
291                    let url = words.next().filter(|url| !url.is_empty()).ok_or_else(|| {
292                        RichError::StyleSyntax("URL expected after 'link'".to_string())
293                    })?;
294                    style.link = Some(url.to_string());
295                }
296                _ => {
297                    if let Some(idx) = attribute_index(&word) {
298                        style.attrs[idx] = Some(true);
299                    } else {
300                        style.color = Some(Color::parse(&word)?);
301                    }
302                }
303            }
304        }
305        Ok(style)
306    }
307
308    /// Combine two styles, `other` winning wherever it sets a value.
309    ///
310    /// Port of `Style.__add__`.
311    pub fn combine(&self, other: &Style) -> Style {
312        let mut attrs = self.attrs;
313        for (slot, over) in attrs.iter_mut().zip(other.attrs.iter()) {
314            if over.is_some() {
315                *slot = *over;
316            }
317        }
318        Style {
319            color: other.color.clone().or_else(|| self.color.clone()),
320            bgcolor: other.bgcolor.clone().or_else(|| self.bgcolor.clone()),
321            attrs,
322            link: other.link.clone().or_else(|| self.link.clone()),
323        }
324    }
325
326    /// The SGR parameter list (e.g. `"1;31;44"`) for a given color system.
327    ///
328    /// Port of `Style._make_ansi_codes`.
329    pub fn ansi_codes(&self, system: ColorSystem) -> String {
330        let mut sgr: Vec<String> = Vec::new();
331        for (idx, attr) in self.attrs.iter().enumerate() {
332            if *attr == Some(true) {
333                sgr.push(ATTR_SGR[idx].to_string());
334            }
335        }
336        if let Some(color) = &self.color {
337            sgr.extend(color.downgrade(system).ansi_codes(true));
338        }
339        if let Some(bgcolor) = &self.bgcolor {
340            sgr.extend(bgcolor.downgrade(system).ansi_codes(false));
341        }
342        sgr.join(";")
343    }
344
345    /// The CSS declarations for this style under `theme` (for HTML export).
346    /// Port of `Style.get_html_style`.
347    pub fn get_html_style(&self, theme: &crate::terminal_theme::TerminalTheme) -> String {
348        use crate::terminal_theme::blend_rgb;
349        let mut css: Vec<String> = Vec::new();
350
351        let mut color = self.color.clone();
352        let mut bgcolor = self.bgcolor.clone();
353        // reverse (attr index 6): swap fore/background.
354        if self.attrs[6] == Some(true) {
355            std::mem::swap(&mut color, &mut bgcolor);
356        }
357        // dim (attr index 1): blend the foreground halfway to the background.
358        if self.attrs[1] == Some(true) {
359            let fg = match &color {
360                Some(c) => theme.resolve(c, true),
361                None => theme.foreground,
362            };
363            let blended = blend_rgb(fg, theme.background, 0.5);
364            color = Some(Color::from_rgb(blended.red, blended.green, blended.blue));
365        }
366
367        if let Some(c) = &color {
368            let hex = theme.resolve(c, true).hex();
369            css.push(format!("color: {hex}"));
370            css.push(format!("text-decoration-color: {hex}"));
371        }
372        if let Some(c) = &bgcolor {
373            let hex = theme.resolve(c, false).hex();
374            css.push(format!("background-color: {hex}"));
375        }
376        if self.attrs[0] == Some(true) {
377            css.push("font-weight: bold".to_string());
378        }
379        if self.attrs[2] == Some(true) {
380            css.push("font-style: italic".to_string());
381        }
382        if self.attrs[3] == Some(true) {
383            css.push("text-decoration: underline".to_string());
384        }
385        if self.attrs[8] == Some(true) {
386            css.push("text-decoration: line-through".to_string());
387        }
388        if self.attrs[12] == Some(true) {
389            css.push("text-decoration: overline".to_string());
390        }
391        css.join("; ")
392    }
393
394    /// The SVG `<text>` CSS declarations for this style under `theme`. Port of
395    /// the `get_svg_style` closure in `Console.export_svg`. Unlike
396    /// [`get_html_style`](Self::get_html_style), the colour is always resolved to
397    /// a concrete triplet (the theme fore/background stands in for a missing or
398    /// default colour), `dim` blends 40% toward the background (not 50%), and the
399    /// rules are joined with a bare `;`.
400    pub fn get_svg_style(&self, theme: &crate::terminal_theme::TerminalTheme) -> String {
401        use crate::terminal_theme::blend_rgb;
402        // Resolve fore/background to concrete triplets (theme defaults fill in for
403        // a None/default colour, exactly as `theme.resolve` does for `Default`).
404        let mut color = self
405            .color
406            .as_ref()
407            .map_or(theme.foreground, |c| theme.resolve(c, true));
408        let mut bgcolor = self
409            .bgcolor
410            .as_ref()
411            .map_or(theme.background, |c| theme.resolve(c, false));
412        if self.attrs[6] == Some(true) {
413            std::mem::swap(&mut color, &mut bgcolor);
414        }
415        if self.attrs[1] == Some(true) {
416            color = blend_rgb(color, bgcolor, 0.4);
417        }
418        let mut rules = vec![format!("fill: {}", color.hex())];
419        if self.attrs[0] == Some(true) {
420            rules.push("font-weight: bold".to_string());
421        }
422        if self.attrs[2] == Some(true) {
423            rules.push("font-style: italic;".to_string());
424        }
425        if self.attrs[3] == Some(true) {
426            rules.push("text-decoration: underline;".to_string());
427        }
428        if self.attrs[8] == Some(true) {
429            rules.push("text-decoration: line-through;".to_string());
430        }
431        rules.join(";")
432    }
433
434    /// Wrap `text` in this style's escape sequence for `system`.
435    ///
436    /// With `system == None` (no color) or a null style, `text` is returned
437    /// unchanged. A [`link`](Self::with_link) additionally wraps the result in an
438    /// OSC 8 hyperlink. Port of `Style.render`.
439    ///
440    /// **Divergence:** upstream tags each hyperlink with a random `id=` field (to
441    /// group multi-segment links for hover); we omit it so output is
442    /// deterministic. See docs/DIVERGENCES.md.
443    pub fn render(&self, text: &str, system: Option<ColorSystem>) -> String {
444        let Some(system) = system else {
445            return text.to_string();
446        };
447        if text.is_empty() {
448            return text.to_string();
449        }
450        let codes = self.ansi_codes(system);
451        let rendered = if codes.is_empty() {
452            text.to_string()
453        } else {
454            format!("\x1b[{codes}m{text}\x1b[0m")
455        };
456        match &self.link {
457            Some(url) => format!("\x1b]8;;{url}\x1b\\{rendered}\x1b]8;;\x1b\\"),
458            None => rendered,
459        }
460    }
461}
462
463#[cfg(test)]
464mod tests {
465    use super::*;
466
467    /// `normalize` round-trips a parseable definition through `definition()` and
468    /// merely trims+lowercases one that isn't. Every expectation here was taken
469    /// from real rich 15.0.0's `Style.normalize`.
470    #[test]
471    fn normalize_matches_upstream() {
472        for (input, expected) in [
473            ("b", "bold"),
474            ("bold", "bold"),
475            ("BOLD", "bold"),
476            ("  Bold  ", "bold"),
477            ("dim i", "dim italic"),
478            ("not bold", "not bold"),
479            ("bold red", "bold red"),
480            ("red on blue", "red on blue"),
481            ("link https://x", "link https://x"),
482            // Not a style definition, so it falls through to trim+lowercase —
483            // this is the path every theme name takes.
484            ("nope", "nope"),
485            ("REPR.Number", "repr.number"),
486            // `not` is case-sensitive upstream, so this fails to parse and takes
487            // the fallback, which happens to produce the same string.
488            ("not BOLD", "not bold"),
489        ] {
490            assert_eq!(Style::normalize(input), expected, "normalize({input:?})");
491        }
492    }
493
494    /// A style that sets nothing renders as `"none"`, never as an empty string.
495    #[test]
496    fn definition_of_null_style_is_none() {
497        assert_eq!(Style::new().definition(), "none");
498        assert_eq!(Style::parse("none").unwrap().definition(), "none");
499    }
500
501    /// `not <attr>` is case-sensitive, matching upstream, which looks the operand
502    /// up without folding and raises when it misses. Accepting `not BOLD` would
503    /// silently *cancel* an enclosing bold instead of being ignored.
504    #[test]
505    fn not_operand_is_case_sensitive() {
506        assert!(Style::parse("not bold").is_ok());
507        assert!(Style::parse("not BOLD").is_err());
508    }
509
510    /// `link <url>` is a style keyword, not a colour name. Without it the whole
511    /// definition failed to parse and the hyperlink was silently dropped.
512    #[test]
513    fn parse_understands_link() {
514        let style = Style::parse("link https://example.com").expect("link parses");
515        assert_eq!(style.link.as_deref(), Some("https://example.com"));
516        assert_eq!(style.definition(), "link https://example.com");
517        // A bare `link` is a syntax error, exactly as upstream — accepting it
518        // would emit a hyperlink to nowhere.
519        assert!(Style::parse("link").is_err());
520    }
521
522    #[test]
523    fn parse_bold_red() {
524        let style = Style::parse("bold red").unwrap();
525        assert_eq!(style.ansi_codes(ColorSystem::Truecolor), "1;31");
526        assert_eq!(
527            style.render("hello", Some(ColorSystem::Truecolor)),
528            "\x1b[1;31mhello\x1b[0m"
529        );
530    }
531
532    #[test]
533    fn svg_style_matches_upstream() {
534        // Captured from real rich 15.0.0 `export_svg`'s `get_svg_style` under
535        // `SVG_EXPORT_THEME`. Note: bgcolor is excluded from the fill style,
536        // `reverse` swaps to the background, and dim blends 40% toward it.
537        use crate::terminal_theme::SVG_EXPORT_THEME as theme;
538        let svg = |spec: &str| Style::parse(spec).unwrap().get_svg_style(&theme);
539        assert_eq!(Style::new().get_svg_style(&theme), "fill: #c5c8c6");
540        assert_eq!(svg("bold red"), "fill: #cc555a;font-weight: bold");
541        assert_eq!(svg("italic green"), "fill: #98a84b;font-style: italic;");
542        assert_eq!(svg("dim"), "fill: #868887");
543        assert_eq!(
544            svg("underline blue on yellow"),
545            "fill: #608ab1;text-decoration: underline;"
546        );
547        assert_eq!(svg("reverse"), "fill: #292929");
548    }
549
550    #[test]
551    fn link_wraps_in_osc8() {
552        let style = Style::parse("underline blue")
553            .unwrap()
554            .with_link("https://example.com");
555        assert_eq!(
556            style.render("click", Some(ColorSystem::Truecolor)),
557            "\x1b]8;;https://example.com\x1b\\\x1b[4;34mclick\x1b[0m\x1b]8;;\x1b\\"
558        );
559        // A link-only style still wraps (no SGR inside).
560        let bare = Style::new().with_link("https://x.com");
561        assert_eq!(
562            bare.render("y", Some(ColorSystem::Truecolor)),
563            "\x1b]8;;https://x.com\x1b\\y\x1b]8;;\x1b\\"
564        );
565        assert!(!bare.is_null());
566    }
567
568    #[test]
569    fn parse_fg_on_bg() {
570        let style = Style::parse("white on blue").unwrap();
571        assert_eq!(style.ansi_codes(ColorSystem::Truecolor), "37;44");
572    }
573
574    #[test]
575    fn combine_overrides() {
576        let base = Style::parse("bold red").unwrap();
577        let over = Style::parse("blue").unwrap();
578        let combined = base.combine(&over);
579        // bold retained from base, color replaced by blue (34)
580        assert_eq!(combined.ansi_codes(ColorSystem::Truecolor), "1;34");
581    }
582
583    #[test]
584    fn no_color_system_is_plaintext() {
585        let style = Style::parse("bold red").unwrap();
586        assert_eq!(style.render("hello", None), "hello");
587    }
588
589    #[test]
590    fn null_style_does_not_wrap() {
591        let style = Style::new();
592        assert_eq!(style.render("hello", Some(ColorSystem::Truecolor)), "hello");
593    }
594}