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    /// The tri-state value of attribute `index` (see the internal `attrs` order:
174    /// 0=bold, 1=dim, 2=italic, 3=underline, 6=reverse, 8=strike, …).
175    pub fn attr(&self, index: usize) -> Option<bool> {
176        self.attrs.get(index).copied().flatten()
177    }
178
179    /// True when nothing at all is set (renders as a no-op).
180    pub fn is_null(&self) -> bool {
181        self.color.is_none()
182            && self.bgcolor.is_none()
183            && self.link.is_none()
184            && self.attrs.iter().all(Option::is_none)
185    }
186
187    /// Regenerate the style definition string. Port of `Style.__str__`.
188    ///
189    /// Attributes come first in their canonical order, then the foreground
190    /// colour, then `on <bgcolour>`, then `link <url>`. A style that sets nothing
191    /// is `"none"` — never the empty string, which upstream reserves for "no
192    /// style at all".
193    pub fn definition(&self) -> String {
194        let mut parts: Vec<String> = Vec::new();
195        for (index, name) in ATTR_NAMES.iter().enumerate() {
196            match self.attrs[index] {
197                Some(true) => parts.push((*name).to_string()),
198                Some(false) => parts.push(format!("not {name}")),
199                None => {}
200            }
201        }
202        if let Some(color) = &self.color {
203            parts.push(color.name.clone());
204        }
205        if let Some(bgcolor) = &self.bgcolor {
206            parts.push("on".to_string());
207            parts.push(bgcolor.name.clone());
208        }
209        if let Some(link) = &self.link {
210            parts.push("link".to_string());
211            parts.push(link.clone());
212        }
213        if parts.is_empty() {
214            "none".to_string()
215        } else {
216            parts.join(" ")
217        }
218    }
219
220    /// Canonicalise a style definition so that definitions with the same effect
221    /// have the same string. Port of `Style.normalize`.
222    ///
223    /// A definition that parses round-trips through [`definition`](Self::definition),
224    /// so `"b"` and `"BOLD"` both become `"bold"`. One that does not parse is
225    /// merely trimmed and lowercased — that is the path a *theme name* like
226    /// `"repr.number"` takes, and it is why theme lookups are effectively
227    /// case-insensitive on the markup side while [`Theme::get_style`] itself is
228    /// case-sensitive.
229    ///
230    /// [`Theme::get_style`]: crate::theme::Theme::get_style
231    pub fn normalize(definition: &str) -> String {
232        match Style::parse(definition) {
233            Ok(style) => style.definition(),
234            Err(_) => definition.trim().to_lowercase(),
235        }
236    }
237
238    /// Parse a style definition such as `"bold red on blue"`.
239    ///
240    /// Port of `Style.parse` covering attributes, `not <attr>`, `link <url>`, and
241    /// `<color> on <color>`. (`meta` is deferred — see DIVERGENCES.)
242    pub fn parse(definition: &str) -> Result<Self> {
243        // Port of upstream's leading guard:
244        //     if style_definition.strip() == "none" or not style_definition
245        // `none` is only valid as the WHOLE definition — upstream raises on
246        // `"bold none"`, because inside the word loop `none` is treated as a
247        // colour name and fails to parse. Many `DEFAULT_STYLES` entries are
248        // exactly `"none"`, so without this they would all be dropped.
249        if definition.is_empty() || definition.trim() == "none" {
250            return Ok(Style::new());
251        }
252        let mut style = Style::new();
253        let mut words = definition.split_whitespace();
254        while let Some(raw) = words.next() {
255            let word = raw.to_ascii_lowercase();
256            match word.as_str() {
257                "on" => {
258                    let color_word = words.next().ok_or_else(|| {
259                        RichError::StyleSyntax("color expected after 'on'".to_string())
260                    })?;
261                    style.bgcolor = Some(Color::parse(color_word)?);
262                }
263                "not" => {
264                    let attr_word = words.next().ok_or_else(|| {
265                        RichError::StyleSyntax("attribute expected after 'not'".to_string())
266                    })?;
267                    // Deliberately NOT lowercased: upstream folds case only on
268                    // the loop word, and looks the `not` operand up verbatim —
269                    // so `"not BOLD"` is a syntax error there, and must be here.
270                    let idx = attribute_index(attr_word).ok_or_else(|| {
271                        RichError::StyleSyntax(format!(
272                            "{attr_word:?} is not a recognized attribute"
273                        ))
274                    })?;
275                    style.attrs[idx] = Some(false);
276                }
277                "link" => {
278                    // A bare `link` is a syntax error upstream, not an empty
279                    // link — accepting it would emit a hyperlink to nowhere.
280                    let url = words.next().filter(|url| !url.is_empty()).ok_or_else(|| {
281                        RichError::StyleSyntax("URL expected after 'link'".to_string())
282                    })?;
283                    style.link = Some(url.to_string());
284                }
285                _ => {
286                    if let Some(idx) = attribute_index(&word) {
287                        style.attrs[idx] = Some(true);
288                    } else {
289                        style.color = Some(Color::parse(&word)?);
290                    }
291                }
292            }
293        }
294        Ok(style)
295    }
296
297    /// Combine two styles, `other` winning wherever it sets a value.
298    ///
299    /// Port of `Style.__add__`.
300    pub fn combine(&self, other: &Style) -> Style {
301        let mut attrs = self.attrs;
302        for (slot, over) in attrs.iter_mut().zip(other.attrs.iter()) {
303            if over.is_some() {
304                *slot = *over;
305            }
306        }
307        Style {
308            color: other.color.clone().or_else(|| self.color.clone()),
309            bgcolor: other.bgcolor.clone().or_else(|| self.bgcolor.clone()),
310            attrs,
311            link: other.link.clone().or_else(|| self.link.clone()),
312        }
313    }
314
315    /// The SGR parameter list (e.g. `"1;31;44"`) for a given color system.
316    ///
317    /// Port of `Style._make_ansi_codes`.
318    pub fn ansi_codes(&self, system: ColorSystem) -> String {
319        let mut sgr: Vec<String> = Vec::new();
320        for (idx, attr) in self.attrs.iter().enumerate() {
321            if *attr == Some(true) {
322                sgr.push(ATTR_SGR[idx].to_string());
323            }
324        }
325        if let Some(color) = &self.color {
326            sgr.extend(color.downgrade(system).ansi_codes(true));
327        }
328        if let Some(bgcolor) = &self.bgcolor {
329            sgr.extend(bgcolor.downgrade(system).ansi_codes(false));
330        }
331        sgr.join(";")
332    }
333
334    /// The CSS declarations for this style under `theme` (for HTML export).
335    /// Port of `Style.get_html_style`.
336    pub fn get_html_style(&self, theme: &crate::terminal_theme::TerminalTheme) -> String {
337        use crate::terminal_theme::blend_rgb;
338        let mut css: Vec<String> = Vec::new();
339
340        let mut color = self.color.clone();
341        let mut bgcolor = self.bgcolor.clone();
342        // reverse (attr index 6): swap fore/background.
343        if self.attrs[6] == Some(true) {
344            std::mem::swap(&mut color, &mut bgcolor);
345        }
346        // dim (attr index 1): blend the foreground halfway to the background.
347        if self.attrs[1] == Some(true) {
348            let fg = match &color {
349                Some(c) => theme.resolve(c, true),
350                None => theme.foreground,
351            };
352            let blended = blend_rgb(fg, theme.background, 0.5);
353            color = Some(Color::from_rgb(blended.red, blended.green, blended.blue));
354        }
355
356        if let Some(c) = &color {
357            let hex = theme.resolve(c, true).hex();
358            css.push(format!("color: {hex}"));
359            css.push(format!("text-decoration-color: {hex}"));
360        }
361        if let Some(c) = &bgcolor {
362            let hex = theme.resolve(c, false).hex();
363            css.push(format!("background-color: {hex}"));
364        }
365        if self.attrs[0] == Some(true) {
366            css.push("font-weight: bold".to_string());
367        }
368        if self.attrs[2] == Some(true) {
369            css.push("font-style: italic".to_string());
370        }
371        if self.attrs[3] == Some(true) {
372            css.push("text-decoration: underline".to_string());
373        }
374        if self.attrs[8] == Some(true) {
375            css.push("text-decoration: line-through".to_string());
376        }
377        if self.attrs[12] == Some(true) {
378            css.push("text-decoration: overline".to_string());
379        }
380        css.join("; ")
381    }
382
383    /// The SVG `<text>` CSS declarations for this style under `theme`. Port of
384    /// the `get_svg_style` closure in `Console.export_svg`. Unlike
385    /// [`get_html_style`](Self::get_html_style), the colour is always resolved to
386    /// a concrete triplet (the theme fore/background stands in for a missing or
387    /// default colour), `dim` blends 40% toward the background (not 50%), and the
388    /// rules are joined with a bare `;`.
389    pub fn get_svg_style(&self, theme: &crate::terminal_theme::TerminalTheme) -> String {
390        use crate::terminal_theme::blend_rgb;
391        // Resolve fore/background to concrete triplets (theme defaults fill in for
392        // a None/default colour, exactly as `theme.resolve` does for `Default`).
393        let mut color = self
394            .color
395            .as_ref()
396            .map_or(theme.foreground, |c| theme.resolve(c, true));
397        let mut bgcolor = self
398            .bgcolor
399            .as_ref()
400            .map_or(theme.background, |c| theme.resolve(c, false));
401        if self.attrs[6] == Some(true) {
402            std::mem::swap(&mut color, &mut bgcolor);
403        }
404        if self.attrs[1] == Some(true) {
405            color = blend_rgb(color, bgcolor, 0.4);
406        }
407        let mut rules = vec![format!("fill: {}", color.hex())];
408        if self.attrs[0] == Some(true) {
409            rules.push("font-weight: bold".to_string());
410        }
411        if self.attrs[2] == Some(true) {
412            rules.push("font-style: italic;".to_string());
413        }
414        if self.attrs[3] == Some(true) {
415            rules.push("text-decoration: underline;".to_string());
416        }
417        if self.attrs[8] == Some(true) {
418            rules.push("text-decoration: line-through;".to_string());
419        }
420        rules.join(";")
421    }
422
423    /// Wrap `text` in this style's escape sequence for `system`.
424    ///
425    /// With `system == None` (no color) or a null style, `text` is returned
426    /// unchanged. A [`link`](Self::with_link) additionally wraps the result in an
427    /// OSC 8 hyperlink. Port of `Style.render`.
428    ///
429    /// **Divergence:** upstream tags each hyperlink with a random `id=` field (to
430    /// group multi-segment links for hover); we omit it so output is
431    /// deterministic. See docs/DIVERGENCES.md.
432    pub fn render(&self, text: &str, system: Option<ColorSystem>) -> String {
433        let Some(system) = system else {
434            return text.to_string();
435        };
436        if text.is_empty() {
437            return text.to_string();
438        }
439        let codes = self.ansi_codes(system);
440        let rendered = if codes.is_empty() {
441            text.to_string()
442        } else {
443            format!("\x1b[{codes}m{text}\x1b[0m")
444        };
445        match &self.link {
446            Some(url) => format!("\x1b]8;;{url}\x1b\\{rendered}\x1b]8;;\x1b\\"),
447            None => rendered,
448        }
449    }
450}
451
452#[cfg(test)]
453mod tests {
454    use super::*;
455
456    /// `normalize` round-trips a parseable definition through `definition()` and
457    /// merely trims+lowercases one that isn't. Every expectation here was taken
458    /// from real rich 15.0.0's `Style.normalize`.
459    #[test]
460    fn normalize_matches_upstream() {
461        for (input, expected) in [
462            ("b", "bold"),
463            ("bold", "bold"),
464            ("BOLD", "bold"),
465            ("  Bold  ", "bold"),
466            ("dim i", "dim italic"),
467            ("not bold", "not bold"),
468            ("bold red", "bold red"),
469            ("red on blue", "red on blue"),
470            ("link https://x", "link https://x"),
471            // Not a style definition, so it falls through to trim+lowercase —
472            // this is the path every theme name takes.
473            ("nope", "nope"),
474            ("REPR.Number", "repr.number"),
475            // `not` is case-sensitive upstream, so this fails to parse and takes
476            // the fallback, which happens to produce the same string.
477            ("not BOLD", "not bold"),
478        ] {
479            assert_eq!(Style::normalize(input), expected, "normalize({input:?})");
480        }
481    }
482
483    /// A style that sets nothing renders as `"none"`, never as an empty string.
484    #[test]
485    fn definition_of_null_style_is_none() {
486        assert_eq!(Style::new().definition(), "none");
487        assert_eq!(Style::parse("none").unwrap().definition(), "none");
488    }
489
490    /// `not <attr>` is case-sensitive, matching upstream, which looks the operand
491    /// up without folding and raises when it misses. Accepting `not BOLD` would
492    /// silently *cancel* an enclosing bold instead of being ignored.
493    #[test]
494    fn not_operand_is_case_sensitive() {
495        assert!(Style::parse("not bold").is_ok());
496        assert!(Style::parse("not BOLD").is_err());
497    }
498
499    /// `link <url>` is a style keyword, not a colour name. Without it the whole
500    /// definition failed to parse and the hyperlink was silently dropped.
501    #[test]
502    fn parse_understands_link() {
503        let style = Style::parse("link https://example.com").expect("link parses");
504        assert_eq!(style.link.as_deref(), Some("https://example.com"));
505        assert_eq!(style.definition(), "link https://example.com");
506        // A bare `link` is a syntax error, exactly as upstream — accepting it
507        // would emit a hyperlink to nowhere.
508        assert!(Style::parse("link").is_err());
509    }
510
511    #[test]
512    fn parse_bold_red() {
513        let style = Style::parse("bold red").unwrap();
514        assert_eq!(style.ansi_codes(ColorSystem::Truecolor), "1;31");
515        assert_eq!(
516            style.render("hello", Some(ColorSystem::Truecolor)),
517            "\x1b[1;31mhello\x1b[0m"
518        );
519    }
520
521    #[test]
522    fn svg_style_matches_upstream() {
523        // Captured from real rich 15.0.0 `export_svg`'s `get_svg_style` under
524        // `SVG_EXPORT_THEME`. Note: bgcolor is excluded from the fill style,
525        // `reverse` swaps to the background, and dim blends 40% toward it.
526        use crate::terminal_theme::SVG_EXPORT_THEME as theme;
527        let svg = |spec: &str| Style::parse(spec).unwrap().get_svg_style(&theme);
528        assert_eq!(Style::new().get_svg_style(&theme), "fill: #c5c8c6");
529        assert_eq!(svg("bold red"), "fill: #cc555a;font-weight: bold");
530        assert_eq!(svg("italic green"), "fill: #98a84b;font-style: italic;");
531        assert_eq!(svg("dim"), "fill: #868887");
532        assert_eq!(
533            svg("underline blue on yellow"),
534            "fill: #608ab1;text-decoration: underline;"
535        );
536        assert_eq!(svg("reverse"), "fill: #292929");
537    }
538
539    #[test]
540    fn link_wraps_in_osc8() {
541        let style = Style::parse("underline blue")
542            .unwrap()
543            .with_link("https://example.com");
544        assert_eq!(
545            style.render("click", Some(ColorSystem::Truecolor)),
546            "\x1b]8;;https://example.com\x1b\\\x1b[4;34mclick\x1b[0m\x1b]8;;\x1b\\"
547        );
548        // A link-only style still wraps (no SGR inside).
549        let bare = Style::new().with_link("https://x.com");
550        assert_eq!(
551            bare.render("y", Some(ColorSystem::Truecolor)),
552            "\x1b]8;;https://x.com\x1b\\y\x1b]8;;\x1b\\"
553        );
554        assert!(!bare.is_null());
555    }
556
557    #[test]
558    fn parse_fg_on_bg() {
559        let style = Style::parse("white on blue").unwrap();
560        assert_eq!(style.ansi_codes(ColorSystem::Truecolor), "37;44");
561    }
562
563    #[test]
564    fn combine_overrides() {
565        let base = Style::parse("bold red").unwrap();
566        let over = Style::parse("blue").unwrap();
567        let combined = base.combine(&over);
568        // bold retained from base, color replaced by blue (34)
569        assert_eq!(combined.ansi_codes(ColorSystem::Truecolor), "1;34");
570    }
571
572    #[test]
573    fn no_color_system_is_plaintext() {
574        let style = Style::parse("bold red").unwrap();
575        assert_eq!(style.render("hello", None), "hello");
576    }
577
578    #[test]
579    fn null_style_does_not_wrap() {
580        let style = Style::new();
581        assert_eq!(style.render("hello", Some(ColorSystem::Truecolor)), "hello");
582    }
583}