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/// One value in a style's [`Meta`]. Upstream's meta is any marshal-able
110/// Python value; this port keeps the scalar subset plus lists.
111///
112/// Equality follows marshal's bytes: `true` is not `1`, `1` is not `1.0`, and
113/// floats compare by bit pattern (so `NaN == NaN` and `0.0 != -0.0`).
114#[derive(Debug, Clone)]
115pub enum MetaValue {
116    /// Python `None`.
117    None,
118    Bool(bool),
119    Int(i64),
120    Float(f64),
121    Str(String),
122    /// A list of values.
123    List(Vec<MetaValue>),
124    /// A tuple of values (markup handler tags produce these). Not equal to a
125    /// list of the same values, as in Python.
126    Tuple(Vec<MetaValue>),
127}
128
129impl MetaValue {
130    /// Python's `repr()` of the value.
131    pub fn repr(&self) -> String {
132        let items =
133            |values: &[MetaValue]| -> Vec<String> { values.iter().map(MetaValue::repr).collect() };
134        match self {
135            MetaValue::None => "None".to_string(),
136            MetaValue::Bool(true) => "True".to_string(),
137            MetaValue::Bool(false) => "False".to_string(),
138            MetaValue::Int(value) => value.to_string(),
139            MetaValue::Float(value) => crate::pyformat::float_repr(*value),
140            MetaValue::Str(value) => crate::markup::py_repr_str(value),
141            MetaValue::List(values) => format!("[{}]", items(values).join(", ")),
142            MetaValue::Tuple(values) if values.len() == 1 => format!("({},)", values[0].repr()),
143            MetaValue::Tuple(values) => format!("({})", items(values).join(", ")),
144        }
145    }
146}
147
148impl PartialEq for MetaValue {
149    fn eq(&self, other: &Self) -> bool {
150        match (self, other) {
151            (MetaValue::None, MetaValue::None) => true,
152            (MetaValue::Bool(a), MetaValue::Bool(b)) => a == b,
153            (MetaValue::Int(a), MetaValue::Int(b)) => a == b,
154            (MetaValue::Float(a), MetaValue::Float(b)) => a.to_bits() == b.to_bits(),
155            (MetaValue::Str(a), MetaValue::Str(b)) => a == b,
156            (MetaValue::List(a), MetaValue::List(b)) => a == b,
157            (MetaValue::Tuple(a), MetaValue::Tuple(b)) => a == b,
158            _ => false,
159        }
160    }
161}
162
163impl Eq for MetaValue {}
164
165impl std::hash::Hash for MetaValue {
166    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
167        std::mem::discriminant(self).hash(state);
168        match self {
169            MetaValue::None => {}
170            MetaValue::Bool(value) => value.hash(state),
171            MetaValue::Int(value) => value.hash(state),
172            MetaValue::Float(value) => value.to_bits().hash(state),
173            MetaValue::Str(value) => value.hash(state),
174            MetaValue::List(values) | MetaValue::Tuple(values) => values.hash(state),
175        }
176    }
177}
178
179/// A style's metadata: upstream's `Style.meta` dict. Upstream stores it
180/// marshal-encoded; this port keeps the entries themselves, **in insertion
181/// order**, because marshal's bytes (and so style equality) depend on it.
182/// Assigning an existing key replaces its value in place, as a `dict` does.
183#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)]
184pub struct Meta {
185    entries: Vec<(String, MetaValue)>,
186}
187
188impl Meta {
189    /// Python's `repr()` of the dict.
190    pub fn repr(&self) -> String {
191        let entries: Vec<String> = self
192            .entries
193            .iter()
194            .map(|(key, value)| format!("{}: {}", crate::markup::py_repr_str(key), value.repr()))
195            .collect();
196        format!("{{{}}}", entries.join(", "))
197    }
198
199    /// An empty map.
200    pub fn new() -> Self {
201        Meta::default()
202    }
203
204    /// Set `key` (`meta[key] = value`).
205    pub fn insert(&mut self, key: impl Into<String>, value: MetaValue) {
206        let key = key.into();
207        match self
208            .entries
209            .iter_mut()
210            .find(|(existing, _)| *existing == key)
211        {
212            Some((_, slot)) => *slot = value,
213            None => self.entries.push((key, value)),
214        }
215    }
216
217    /// The value for `key`.
218    pub fn get(&self, key: &str) -> Option<&MetaValue> {
219        self.entries
220            .iter()
221            .find(|(existing, _)| existing == key)
222            .map(|(_, value)| value)
223    }
224
225    /// Merge `other` in (`dict.update`): its values win.
226    pub fn update(&mut self, other: &Meta) {
227        for (key, value) in &other.entries {
228            self.insert(key.clone(), value.clone());
229        }
230    }
231
232    /// The entries in insertion order.
233    pub fn iter(&self) -> impl Iterator<Item = (&str, &MetaValue)> {
234        self.entries
235            .iter()
236            .map(|(key, value)| (key.as_str(), value))
237    }
238
239    pub fn len(&self) -> usize {
240        self.entries.len()
241    }
242
243    pub fn is_empty(&self) -> bool {
244        self.entries.is_empty()
245    }
246}
247
248impl<K: Into<String>> FromIterator<(K, MetaValue)> for Meta {
249    fn from_iter<I: IntoIterator<Item = (K, MetaValue)>>(iter: I) -> Self {
250        let mut meta = Meta::new();
251        for (key, value) in iter {
252            meta.insert(key, value);
253        }
254        meta
255    }
256}
257
258/// FNV-1a, for caches keyed by short strings such as tag names, which
259/// SipHash's setup would cost more to hash than the strings themselves.
260pub(crate) struct ShortKeyHasher(u64);
261
262impl Default for ShortKeyHasher {
263    fn default() -> Self {
264        ShortKeyHasher(0xcbf2_9ce4_8422_2325)
265    }
266}
267
268impl std::hash::Hasher for ShortKeyHasher {
269    fn finish(&self) -> u64 {
270        self.0
271    }
272
273    fn write(&mut self, bytes: &[u8]) {
274        for &byte in bytes {
275            self.0 = (self.0 ^ u64::from(byte)).wrapping_mul(0x0100_0000_01b3);
276        }
277    }
278}
279
280/// A terminal text style. Mirrors `rich.style.Style`.
281#[derive(Debug, Clone, PartialEq, Eq, Default)]
282pub struct Style {
283    color: Option<Color>,
284    bgcolor: Option<Color>,
285    attrs: [Option<bool>; ATTR_COUNT],
286    /// An OSC 8 hyperlink target, if any.
287    link: Option<String>,
288    /// Upstream's `_meta`: metadata that never renders but takes part in
289    /// equality and combination (`None` is upstream's `_meta = None`).
290    meta: Option<Meta>,
291}
292
293impl Style {
294    /// The empty (null) style — sets nothing.
295    pub fn new() -> Self {
296        Style::default()
297    }
298
299    /// A style carrying only a foreground and/or background color.
300    /// Port of `Style.from_color`.
301    pub fn from_color(color: Option<Color>, bgcolor: Option<Color>) -> Self {
302        Style {
303            color,
304            bgcolor,
305            attrs: [None; ATTR_COUNT],
306            link: None,
307            meta: None,
308        }
309    }
310
311    pub fn with_color(mut self, color: Color) -> Self {
312        self.color = Some(color);
313        self
314    }
315
316    /// Attach an OSC 8 hyperlink target. Port of `Style(link=…)`.
317    pub fn with_link(mut self, url: impl Into<String>) -> Self {
318        self.link = Some(url.into());
319        self
320    }
321
322    /// The hyperlink target, if set.
323    pub fn link(&self) -> Option<&str> {
324        self.link.as_deref()
325    }
326
327    /// Return a copy with the hyperlink target set (`Some`) or cleared (`None`),
328    /// leaving every other attribute unchanged. Port of `Style.update_link`.
329    pub fn update_link(&self, link: Option<String>) -> Style {
330        let mut style = self.clone();
331        style.link = link;
332        style
333    }
334
335    pub fn with_bgcolor(mut self, color: Color) -> Self {
336        self.bgcolor = Some(color);
337        self
338    }
339
340    pub fn color(&self) -> Option<&Color> {
341        self.color.as_ref()
342    }
343
344    pub fn bgcolor(&self) -> Option<&Color> {
345        self.bgcolor.as_ref()
346    }
347
348    /// A copy with the foreground and background colours removed; attributes
349    /// and link survive. Port of `Style.without_color`.
350    pub fn without_color(&self) -> Style {
351        // Upstream's `without_color` builds a new style with `_meta = None`,
352        // and a null style comes back as the null style.
353        if self.is_null() {
354            return Style::new();
355        }
356        Style {
357            color: None,
358            bgcolor: None,
359            attrs: self.attrs,
360            link: self.link.clone(),
361            meta: None,
362        }
363    }
364
365    /// The tri-state value of attribute `index` (see the internal `attrs` order:
366    /// 0=bold, 1=dim, 2=italic, 3=underline, 6=reverse, 8=strike, …).
367    pub fn attr(&self, index: usize) -> Option<bool> {
368        self.attrs.get(index).copied().flatten()
369    }
370
371    /// True when nothing at all is set (renders as a no-op). Non-empty
372    /// metadata counts, as upstream's `_null` includes `meta`.
373    pub fn is_null(&self) -> bool {
374        self.color.is_none()
375            && self.bgcolor.is_none()
376            && self.link.is_none()
377            && self.meta.as_ref().is_none_or(Meta::is_empty)
378            && self.attrs.iter().all(Option::is_none)
379    }
380
381    /// Attach metadata. Port of `Style(meta=…)`. An empty map leaves the style
382    /// null, but it still differs from one with no metadata at all (upstream
383    /// hashes the marshal-encoded `{}`).
384    pub fn with_meta(mut self, meta: Meta) -> Self {
385        self.meta = Some(meta);
386        self
387    }
388
389    /// The metadata, empty when none is set. Port of the `Style.meta`
390    /// property.
391    pub fn meta(&self) -> Meta {
392        self.meta.clone().unwrap_or_default()
393    }
394
395    /// The metadata as stored: `None` when the style carries none.
396    pub fn meta_ref(&self) -> Option<&Meta> {
397        self.meta.as_ref()
398    }
399
400    /// A style carrying only `meta`. Port of `Style.from_meta` (null when
401    /// `meta` is empty). Upstream also gives it a random `link_id`, which
402    /// this port does not model (see DIVERGENCES #20).
403    pub fn from_meta(meta: Meta) -> Style {
404        Style::new().with_meta(meta)
405    }
406
407    /// A style with event-handler metadata. Port of `Style.on`: each handler
408    /// is stored as `"@name"` over `meta` (default empty).
409    pub fn on(meta: Option<Meta>, handlers: &[(&str, MetaValue)]) -> Style {
410        let mut meta = meta.unwrap_or_default();
411        for (name, value) in handlers {
412            meta.insert(format!("@{name}"), value.clone());
413        }
414        Style::from_meta(meta)
415    }
416
417    /// A copy without metadata or link. Port of `Style.clear_meta_and_links`.
418    pub fn clear_meta_and_links(&self) -> Style {
419        let mut style = self.clone();
420        style.link = None;
421        style.meta = None;
422        style
423    }
424
425    /// The `bold` attribute: `Some(true)` on, `Some(false)` off, `None` unset.
426    /// Upstream's `Style.bold`.
427    pub fn bold(&self) -> Option<bool> {
428        self.attrs[0]
429    }
430
431    /// The `dim` attribute: `Some(true)` on, `Some(false)` off, `None` unset.
432    /// Upstream's `Style.dim`.
433    pub fn dim(&self) -> Option<bool> {
434        self.attrs[1]
435    }
436
437    /// The `italic` attribute: `Some(true)` on, `Some(false)` off, `None` unset.
438    /// Upstream's `Style.italic`.
439    pub fn italic(&self) -> Option<bool> {
440        self.attrs[2]
441    }
442
443    /// The `underline` attribute: `Some(true)` on, `Some(false)` off, `None` unset.
444    /// Upstream's `Style.underline`.
445    pub fn underline(&self) -> Option<bool> {
446        self.attrs[3]
447    }
448
449    /// The `blink` attribute: `Some(true)` on, `Some(false)` off, `None` unset.
450    /// Upstream's `Style.blink`.
451    pub fn blink(&self) -> Option<bool> {
452        self.attrs[4]
453    }
454
455    /// The `blink2` attribute: `Some(true)` on, `Some(false)` off, `None` unset.
456    /// Upstream's `Style.blink2`.
457    pub fn blink2(&self) -> Option<bool> {
458        self.attrs[5]
459    }
460
461    /// The `reverse` attribute: `Some(true)` on, `Some(false)` off, `None` unset.
462    /// Upstream's `Style.reverse`.
463    pub fn reverse(&self) -> Option<bool> {
464        self.attrs[6]
465    }
466
467    /// The `conceal` attribute: `Some(true)` on, `Some(false)` off, `None` unset.
468    /// Upstream's `Style.conceal`.
469    pub fn conceal(&self) -> Option<bool> {
470        self.attrs[7]
471    }
472
473    /// The `strike` attribute: `Some(true)` on, `Some(false)` off, `None` unset.
474    /// Upstream's `Style.strike`.
475    pub fn strike(&self) -> Option<bool> {
476        self.attrs[8]
477    }
478
479    /// The `underline2` attribute: `Some(true)` on, `Some(false)` off, `None` unset.
480    /// Upstream's `Style.underline2`.
481    pub fn underline2(&self) -> Option<bool> {
482        self.attrs[9]
483    }
484
485    /// The `frame` attribute: `Some(true)` on, `Some(false)` off, `None` unset.
486    /// Upstream's `Style.frame`.
487    pub fn frame(&self) -> Option<bool> {
488        self.attrs[10]
489    }
490
491    /// The `encircle` attribute: `Some(true)` on, `Some(false)` off, `None` unset.
492    /// Upstream's `Style.encircle`.
493    pub fn encircle(&self) -> Option<bool> {
494        self.attrs[11]
495    }
496
497    /// The `overline` attribute: `Some(true)` on, `Some(false)` off, `None` unset.
498    /// Upstream's `Style.overline`.
499    pub fn overline(&self) -> Option<bool> {
500        self.attrs[12]
501    }
502
503    /// The null style, which sets nothing. Port of `Style.null`.
504    pub fn null() -> Style {
505        Style::new()
506    }
507
508    /// A copy. Port of `Style.copy`.
509    pub fn copy(&self) -> Style {
510        self.clone()
511    }
512
513    /// Whether the background is unset or the terminal default. Port of the
514    /// `Style.transparent_background` property.
515    pub fn transparent_background(&self) -> bool {
516        self.bgcolor.as_ref().is_none_or(Color::is_default)
517    }
518
519    /// A style with only this one's background color. Port of the
520    /// `Style.background_style` property.
521    pub fn background_style(&self) -> Style {
522        Style::from_color(None, self.bgcolor.clone())
523    }
524
525    /// Combine `styles` in order, later ones winning. Port of
526    /// `Style.combine` (and `Style.chain`); upstream raises on no styles,
527    /// this returns `None`.
528    pub fn combine_all<'a>(styles: impl IntoIterator<Item = &'a Style>) -> Option<Style> {
529        let mut styles = styles.into_iter();
530        let first = styles.next()?.clone();
531        Some(styles.fold(first, |combined, style| combined.combine(style)))
532    }
533
534    /// The first style that is set. Port of `Style.pick_first`; upstream
535    /// raises `ValueError` when all are `None`, this returns `None`.
536    pub fn pick_first<T: Clone>(values: &[Option<T>]) -> Option<T> {
537        values.iter().flatten().next().cloned()
538    }
539
540    /// A copy with attribute `index` set to `value` (`None` unsets it). The
541    /// index follows upstream's bit order (0 = bold … 12 = overline).
542    pub fn with_attr(mut self, index: usize, value: Option<bool>) -> Style {
543        if let Some(slot) = self.attrs.get_mut(index) {
544            *slot = value;
545        }
546        self
547    }
548
549    /// Write `text` (this style's definition when `None`) to stdout in this
550    /// style, for testing. Port of `Style.test`.
551    pub fn test(&self, text: Option<&str>) {
552        let definition = self.definition();
553        let text = text.filter(|text| !text.is_empty()).unwrap_or(&definition);
554        println!("{}", self.render(text, Some(ColorSystem::Truecolor)));
555    }
556
557    ///
558    /// Attributes come first in their canonical order, then the foreground
559    /// colour, then `on <bgcolour>`, then `link <url>`. A style that sets nothing
560    /// is `"none"` — never the empty string, which upstream reserves for "no
561    /// style at all".
562    pub fn definition(&self) -> String {
563        let mut parts: Vec<String> = Vec::new();
564        for (index, name) in ATTR_NAMES.iter().enumerate() {
565            match self.attrs[index] {
566                Some(true) => parts.push((*name).to_string()),
567                Some(false) => parts.push(format!("not {name}")),
568                None => {}
569            }
570        }
571        if let Some(color) = &self.color {
572            parts.push(color.name.to_string());
573        }
574        if let Some(bgcolor) = &self.bgcolor {
575            parts.push("on".to_string());
576            parts.push(bgcolor.name.to_string());
577        }
578        if let Some(link) = self.link.as_ref().filter(|link| !link.is_empty()) {
579            parts.push("link".to_string());
580            parts.push(link.clone());
581        }
582        if parts.is_empty() {
583            "none".to_string()
584        } else {
585            parts.join(" ")
586        }
587    }
588
589    /// Canonicalise a style definition so that definitions with the same effect
590    /// have the same string. Port of `Style.normalize`.
591    ///
592    /// A definition that parses round-trips through [`definition`](Self::definition),
593    /// so `"b"` and `"BOLD"` both become `"bold"`. One that does not parse is
594    /// merely trimmed and lowercased — that is the path a *theme name* like
595    /// `"repr.number"` takes, and it is why theme lookups are effectively
596    /// case-insensitive on the markup side while [`Theme::get_style`] itself is
597    /// case-sensitive.
598    ///
599    /// [`Theme::get_style`]: crate::theme::Theme::get_style
600    pub fn normalize(definition: &str) -> String {
601        // Upstream memoises this with `@lru_cache(maxsize=1024)`; markup
602        // normalises every tag it opens or closes, so the cache keeps a
603        // frame of markup from parsing the same few names again
604        // (docs/PORTING.md, "Performance notes").
605        type Cache = std::collections::HashMap<
606            String,
607            String,
608            std::hash::BuildHasherDefault<crate::style::ShortKeyHasher>,
609        >;
610        thread_local! {
611            static CACHE: std::cell::RefCell<Cache> = std::cell::RefCell::new(Cache::default());
612        }
613        if let Some(hit) = CACHE.with(|cache| cache.borrow().get(definition).cloned()) {
614            return hit;
615        }
616        let normalized = Style::normalize_uncached(definition);
617        CACHE.with(|cache| {
618            let mut cache = cache.borrow_mut();
619            if cache.len() >= 1024 {
620                cache.clear();
621            }
622            cache.insert(definition.to_string(), normalized.clone());
623        });
624        normalized
625    }
626
627    /// [`normalize`](Self::normalize) without the cache: the port of the
628    /// function upstream's cache wraps.
629    fn normalize_uncached(definition: &str) -> String {
630        match Style::parse(definition) {
631            Ok(style) => style.definition(),
632            Err(_) => definition.trim().to_lowercase(),
633        }
634    }
635
636    /// Parse a style definition such as `"bold red on blue"`.
637    ///
638    /// Port of `Style.parse` covering attributes, `not <attr>`, `link <url>`, and
639    /// `<color> on <color>`. (`meta` is deferred — see DIVERGENCES.)
640    pub fn parse(definition: &str) -> Result<Self> {
641        // Port of upstream's leading guard:
642        //     if style_definition.strip() == "none" or not style_definition
643        // `none` is only valid as the WHOLE definition — upstream raises on
644        // `"bold none"`, because inside the word loop `none` is treated as a
645        // colour name and fails to parse. Many `DEFAULT_STYLES` entries are
646        // exactly `"none"`, so without this they would all be dropped.
647        if definition.is_empty() || definition.trim() == "none" {
648            return Ok(Style::new());
649        }
650        let mut style = Style::new();
651        let mut words = definition.split_whitespace();
652        while let Some(raw) = words.next() {
653            let word = raw.to_lowercase();
654            match word.as_str() {
655                "on" => {
656                    let color_word = words.next().ok_or_else(|| {
657                        RichError::StyleSyntax("color expected after 'on'".to_string())
658                    })?;
659                    style.bgcolor = Some(Color::parse(color_word).map_err(|error| {
660                        RichError::StyleSyntax(format!(
661                            "unable to parse {} as background color; {}",
662                            crate::markup::py_repr_str(color_word),
663                            error.message()
664                        ))
665                    })?);
666                }
667                "not" => {
668                    let attr_word = words.next().unwrap_or("");
669                    // Deliberately NOT lowercased: upstream folds case only on
670                    // the loop word, and looks the `not` operand up verbatim —
671                    // so `"not BOLD"` is a syntax error there, and must be here.
672                    let idx = attribute_index(attr_word).ok_or_else(|| {
673                        RichError::StyleSyntax(format!(
674                            "expected style attribute after 'not', found {}",
675                            crate::markup::py_repr_str(attr_word)
676                        ))
677                    })?;
678                    style.attrs[idx] = Some(false);
679                }
680                "link" => {
681                    // A bare `link` is a syntax error upstream, not an empty
682                    // link — accepting it would emit a hyperlink to nowhere.
683                    let url = words.next().filter(|url| !url.is_empty()).ok_or_else(|| {
684                        RichError::StyleSyntax("URL expected after 'link'".to_string())
685                    })?;
686                    style.link = Some(url.to_string());
687                }
688                _ => {
689                    if let Some(idx) = attribute_index(&word) {
690                        style.attrs[idx] = Some(true);
691                    } else {
692                        style.color = Some(Color::parse(&word).map_err(|error| {
693                            RichError::StyleSyntax(format!(
694                                "unable to parse {} as color; {}",
695                                crate::markup::py_repr_str(&word),
696                                error.message()
697                            ))
698                        })?);
699                    }
700                }
701            }
702        }
703        Ok(style)
704    }
705
706    /// Combine two styles, `other` winning wherever it sets a value.
707    ///
708    /// Port of `Style.__add__`.
709    pub fn combine(&self, other: &Style) -> Style {
710        // `_add`: a null style on either side leaves the other as it is.
711        if other.is_null() {
712            return self.clone();
713        }
714        if self.is_null() {
715            return other.clone();
716        }
717        let mut attrs = self.attrs;
718        for (slot, over) in attrs.iter_mut().zip(other.attrs.iter()) {
719            if over.is_some() {
720                *slot = *over;
721            }
722        }
723        Style {
724            color: other.color.clone().or_else(|| self.color.clone()),
725            bgcolor: other.bgcolor.clone().or_else(|| self.bgcolor.clone()),
726            attrs,
727            // `style._link or self._link`: an empty link does not win.
728            link: other
729                .link
730                .clone()
731                .filter(|link| !link.is_empty())
732                .or_else(|| self.link.clone()),
733            // `if self._meta and style._meta: {**self.meta, **style.meta}`,
734            // else `style._meta or self._meta`.
735            meta: match (&self.meta, &other.meta) {
736                (Some(mine), Some(theirs)) => {
737                    let mut merged = mine.clone();
738                    merged.update(theirs);
739                    Some(merged)
740                }
741                (mine, theirs) => theirs.clone().or_else(|| mine.clone()),
742            },
743        }
744    }
745
746    /// The SGR parameter list (e.g. `"1;31;44"`) for a given color system.
747    ///
748    /// Port of `Style._make_ansi_codes`.
749    pub fn ansi_codes(&self, system: ColorSystem) -> String {
750        let mut sgr: Vec<String> = Vec::new();
751        for (idx, attr) in self.attrs.iter().enumerate() {
752            if *attr == Some(true) {
753                sgr.push(ATTR_SGR[idx].to_string());
754            }
755        }
756        if let Some(color) = &self.color {
757            sgr.extend(color.downgrade(system).ansi_codes(true));
758        }
759        if let Some(bgcolor) = &self.bgcolor {
760            sgr.extend(bgcolor.downgrade(system).ansi_codes(false));
761        }
762        sgr.join(";")
763    }
764
765    /// The CSS declarations for this style under `theme` (for HTML export).
766    /// Port of `Style.get_html_style`.
767    pub fn get_html_style(&self, theme: &crate::terminal_theme::TerminalTheme) -> String {
768        use crate::terminal_theme::blend_rgb;
769        let mut css: Vec<String> = Vec::new();
770
771        let mut color = self.color.clone();
772        let mut bgcolor = self.bgcolor.clone();
773        // reverse (attr index 6): swap fore/background.
774        if self.attrs[6] == Some(true) {
775            std::mem::swap(&mut color, &mut bgcolor);
776        }
777        // dim (attr index 1): blend the foreground halfway to the background.
778        if self.attrs[1] == Some(true) {
779            let fg = match &color {
780                Some(c) => theme.resolve(c, true),
781                None => theme.foreground,
782            };
783            let blended = blend_rgb(fg, theme.background, 0.5);
784            color = Some(Color::from_rgb(blended.red, blended.green, blended.blue));
785        }
786
787        if let Some(c) = &color {
788            let hex = theme.resolve(c, true).hex();
789            css.push(format!("color: {hex}"));
790            css.push(format!("text-decoration-color: {hex}"));
791        }
792        if let Some(c) = &bgcolor {
793            let hex = theme.resolve(c, false).hex();
794            css.push(format!("background-color: {hex}"));
795        }
796        if self.attrs[0] == Some(true) {
797            css.push("font-weight: bold".to_string());
798        }
799        if self.attrs[2] == Some(true) {
800            css.push("font-style: italic".to_string());
801        }
802        if self.attrs[3] == Some(true) {
803            css.push("text-decoration: underline".to_string());
804        }
805        if self.attrs[8] == Some(true) {
806            css.push("text-decoration: line-through".to_string());
807        }
808        if self.attrs[12] == Some(true) {
809            css.push("text-decoration: overline".to_string());
810        }
811        css.join("; ")
812    }
813
814    /// The SVG `<text>` CSS declarations for this style under `theme`. Port of
815    /// the `get_svg_style` closure in `Console.export_svg`. Unlike
816    /// [`get_html_style`](Self::get_html_style), the colour is always resolved to
817    /// a concrete triplet (the theme fore/background stands in for a missing or
818    /// default colour), `dim` blends 40% toward the background (not 50%), and the
819    /// rules are joined with a bare `;`.
820    pub fn get_svg_style(&self, theme: &crate::terminal_theme::TerminalTheme) -> String {
821        use crate::terminal_theme::blend_rgb;
822        // Resolve fore/background to concrete triplets (theme defaults fill in for
823        // a None/default colour, exactly as `theme.resolve` does for `Default`).
824        let mut color = self
825            .color
826            .as_ref()
827            .map_or(theme.foreground, |c| theme.resolve(c, true));
828        let mut bgcolor = self
829            .bgcolor
830            .as_ref()
831            .map_or(theme.background, |c| theme.resolve(c, false));
832        if self.attrs[6] == Some(true) {
833            std::mem::swap(&mut color, &mut bgcolor);
834        }
835        if self.attrs[1] == Some(true) {
836            color = blend_rgb(color, bgcolor, 0.4);
837        }
838        let mut rules = vec![format!("fill: {}", color.hex())];
839        if self.attrs[0] == Some(true) {
840            rules.push("font-weight: bold".to_string());
841        }
842        if self.attrs[2] == Some(true) {
843            rules.push("font-style: italic;".to_string());
844        }
845        if self.attrs[3] == Some(true) {
846            rules.push("text-decoration: underline;".to_string());
847        }
848        if self.attrs[8] == Some(true) {
849            rules.push("text-decoration: line-through;".to_string());
850        }
851        rules.join(";")
852    }
853
854    /// Wrap `text` in this style's escape sequence for `system`.
855    ///
856    /// With `system == None` (no color) or a null style, `text` is returned
857    /// unchanged. A [`link`](Self::with_link) additionally wraps the result in an
858    /// OSC 8 hyperlink. Port of `Style.render`.
859    ///
860    /// **Divergence:** upstream tags each hyperlink with a random `id=` field (to
861    /// group multi-segment links for hover); we omit it so output is
862    /// deterministic. See docs/DIVERGENCES.md.
863    pub fn render(&self, text: &str, system: Option<ColorSystem>) -> String {
864        let Some(system) = system else {
865            return text.to_string();
866        };
867        if text.is_empty() {
868            return text.to_string();
869        }
870        let codes = self.ansi_codes(system);
871        let rendered = if codes.is_empty() {
872            text.to_string()
873        } else {
874            format!("\x1b[{codes}m{text}\x1b[0m")
875        };
876        match &self.link {
877            Some(url) => format!("\x1b]8;;{url}\x1b\\{rendered}\x1b]8;;\x1b\\"),
878            None => rendered,
879        }
880    }
881}
882
883/// A stack of styles, each combined with the one below it. Port of
884/// `rich.style.StyleStack`.
885#[derive(Debug, Clone)]
886pub struct StyleStack {
887    stack: Vec<Style>,
888}
889
890impl StyleStack {
891    /// A stack holding `default_style`.
892    pub fn new(default_style: Style) -> Self {
893        StyleStack {
894            stack: vec![default_style],
895        }
896    }
897
898    /// The style at the top of the stack. Port of `StyleStack.current`.
899    pub fn current(&self) -> &Style {
900        self.stack
901            .last()
902            .expect("the stack keeps its default style")
903    }
904
905    /// Push `style` combined with the current one. Port of `StyleStack.push`.
906    pub fn push(&mut self, style: &Style) {
907        let combined = self.current().combine(style);
908        self.stack.push(combined);
909    }
910
911    /// Pop the top style and return the new current one. Port of
912    /// `StyleStack.pop`; popping the default style is an error (upstream
913    /// raises `IndexError` on the next access).
914    pub fn pop(&mut self) -> Result<&Style> {
915        if self.stack.len() == 1 {
916            return Err(RichError::StyleStack(
917                "pop from empty style stack".to_string(),
918            ));
919        }
920        self.stack.pop();
921        Ok(self.current())
922    }
923}
924
925#[cfg(test)]
926mod tests {
927    use super::*;
928
929    /// The cached `normalize` answers what the uncached one does, on a miss
930    /// and on a hit, for definitions that parse and ones that do not.
931    #[test]
932    fn normalize_cache_is_transparent() {
933        let definitions = [
934            "b",
935            "BOLD",
936            " bold red on blue ",
937            "repr.number",
938            "Repr.Number ",
939            "not bold",
940            "not BOLD",
941            "link https://x.io",
942            "#ff0000",
943            "rgb(1,2,3)",
944            "",
945            "none",
946            "bold none",
947            "on",
948            "color(300)",
949            "@click",
950            "b i u",
951            "u2",
952        ];
953        for _ in 0..2 {
954            for definition in definitions {
955                assert_eq!(
956                    Style::normalize(definition),
957                    Style::normalize_uncached(definition),
958                    "{definition:?}"
959                );
960            }
961        }
962        // Past the cache's size it starts again, still answering the same.
963        for n in 0..1100 {
964            let definition = format!("tag{n}");
965            assert_eq!(Style::normalize(&definition), definition);
966        }
967        assert_eq!(Style::normalize("B"), "bold");
968    }
969
970    /// Expectations captured from real rich 15.0.0.
971    #[test]
972    fn meta_matches_upstream() {
973        let meta = |entries: &[(&str, MetaValue)]| -> Meta { entries.iter().cloned().collect() };
974        let a = Style::parse("bold")
975            .unwrap()
976            .with_meta(meta(&[("x", MetaValue::Int(1)), ("y", MetaValue::Int(2))]));
977        let b = Style::new().with_meta(meta(&[("y", MetaValue::Int(3)), ("z", MetaValue::Int(4))]));
978        // `{'x': 1, 'y': 3, 'z': 4}`
979        assert_eq!(
980            a.combine(&b).meta(),
981            meta(&[
982                ("x", MetaValue::Int(1)),
983                ("y", MetaValue::Int(3)),
984                ("z", MetaValue::Int(4))
985            ])
986        );
987        assert!(Style::new().with_meta(Meta::new()).is_null());
988        assert!(Style::from_meta(Meta::new()).is_null());
989        let ab = meta(&[("a", MetaValue::Int(1)), ("b", MetaValue::Int(2))]);
990        let ba = meta(&[("b", MetaValue::Int(2)), ("a", MetaValue::Int(1))]);
991        assert_ne!(Style::new().with_meta(ab), Style::new().with_meta(ba));
992        assert_ne!(
993            Style::new().with_meta(meta(&[("a", MetaValue::Bool(true))])),
994            Style::new().with_meta(meta(&[("a", MetaValue::Int(1))]))
995        );
996        assert_eq!(
997            Style::on(
998                Some(meta(&[("k", MetaValue::Int(1))])),
999                &[("click", MetaValue::Str("go".into()))]
1000            )
1001            .meta(),
1002            meta(&[
1003                ("k", MetaValue::Int(1)),
1004                ("@click", MetaValue::Str("go".into()))
1005            ])
1006        );
1007        let rich = Style::parse("bold link x")
1008            .unwrap()
1009            .with_meta(meta(&[("a", MetaValue::Int(1))]));
1010        assert_eq!(rich.definition(), "bold link x");
1011        assert_eq!(rich.clear_meta_and_links(), Style::parse("bold").unwrap());
1012        // Meta never renders.
1013        assert_eq!(
1014            rich.ansi_codes(ColorSystem::Truecolor),
1015            Style::parse("bold")
1016                .unwrap()
1017                .ansi_codes(ColorSystem::Truecolor)
1018        );
1019    }
1020
1021    /// `normalize` round-trips a parseable definition through `definition()` and
1022    /// merely trims+lowercases one that isn't. Every expectation here was taken
1023    /// from real rich 15.0.0's `Style.normalize`.
1024    #[test]
1025    fn normalize_matches_upstream() {
1026        for (input, expected) in [
1027            ("b", "bold"),
1028            ("bold", "bold"),
1029            ("BOLD", "bold"),
1030            ("  Bold  ", "bold"),
1031            ("dim i", "dim italic"),
1032            ("not bold", "not bold"),
1033            ("bold red", "bold red"),
1034            ("red on blue", "red on blue"),
1035            ("link https://x", "link https://x"),
1036            // Not a style definition, so it falls through to trim+lowercase —
1037            // this is the path every theme name takes.
1038            ("nope", "nope"),
1039            ("REPR.Number", "repr.number"),
1040            // `not` is case-sensitive upstream, so this fails to parse and takes
1041            // the fallback, which happens to produce the same string.
1042            ("not BOLD", "not bold"),
1043        ] {
1044            assert_eq!(Style::normalize(input), expected, "normalize({input:?})");
1045        }
1046    }
1047
1048    /// A style that sets nothing renders as `"none"`, never as an empty string.
1049    #[test]
1050    fn definition_of_null_style_is_none() {
1051        assert_eq!(Style::new().definition(), "none");
1052        assert_eq!(Style::parse("none").unwrap().definition(), "none");
1053    }
1054
1055    /// `not <attr>` is case-sensitive, matching upstream, which looks the operand
1056    /// up without folding and raises when it misses. Accepting `not BOLD` would
1057    /// silently *cancel* an enclosing bold instead of being ignored.
1058    #[test]
1059    fn not_operand_is_case_sensitive() {
1060        assert!(Style::parse("not bold").is_ok());
1061        assert!(Style::parse("not BOLD").is_err());
1062    }
1063
1064    /// `link <url>` is a style keyword, not a colour name. Without it the whole
1065    /// definition failed to parse and the hyperlink was silently dropped.
1066    #[test]
1067    fn parse_understands_link() {
1068        let style = Style::parse("link https://example.com").expect("link parses");
1069        assert_eq!(style.link.as_deref(), Some("https://example.com"));
1070        assert_eq!(style.definition(), "link https://example.com");
1071        // A bare `link` is a syntax error, exactly as upstream — accepting it
1072        // would emit a hyperlink to nowhere.
1073        assert!(Style::parse("link").is_err());
1074    }
1075
1076    #[test]
1077    fn parse_bold_red() {
1078        let style = Style::parse("bold red").unwrap();
1079        assert_eq!(style.ansi_codes(ColorSystem::Truecolor), "1;31");
1080        assert_eq!(
1081            style.render("hello", Some(ColorSystem::Truecolor)),
1082            "\x1b[1;31mhello\x1b[0m"
1083        );
1084    }
1085
1086    #[test]
1087    fn svg_style_matches_upstream() {
1088        // Captured from real rich 15.0.0 `export_svg`'s `get_svg_style` under
1089        // `SVG_EXPORT_THEME`. Note: bgcolor is excluded from the fill style,
1090        // `reverse` swaps to the background, and dim blends 40% toward it.
1091        use crate::terminal_theme::SVG_EXPORT_THEME as theme;
1092        let svg = |spec: &str| Style::parse(spec).unwrap().get_svg_style(&theme);
1093        assert_eq!(Style::new().get_svg_style(&theme), "fill: #c5c8c6");
1094        assert_eq!(svg("bold red"), "fill: #cc555a;font-weight: bold");
1095        assert_eq!(svg("italic green"), "fill: #98a84b;font-style: italic;");
1096        assert_eq!(svg("dim"), "fill: #868887");
1097        assert_eq!(
1098            svg("underline blue on yellow"),
1099            "fill: #608ab1;text-decoration: underline;"
1100        );
1101        assert_eq!(svg("reverse"), "fill: #292929");
1102    }
1103
1104    #[test]
1105    fn link_wraps_in_osc8() {
1106        let style = Style::parse("underline blue")
1107            .unwrap()
1108            .with_link("https://example.com");
1109        assert_eq!(
1110            style.render("click", Some(ColorSystem::Truecolor)),
1111            "\x1b]8;;https://example.com\x1b\\\x1b[4;34mclick\x1b[0m\x1b]8;;\x1b\\"
1112        );
1113        // A link-only style still wraps (no SGR inside).
1114        let bare = Style::new().with_link("https://x.com");
1115        assert_eq!(
1116            bare.render("y", Some(ColorSystem::Truecolor)),
1117            "\x1b]8;;https://x.com\x1b\\y\x1b]8;;\x1b\\"
1118        );
1119        assert!(!bare.is_null());
1120    }
1121
1122    #[test]
1123    fn parse_fg_on_bg() {
1124        let style = Style::parse("white on blue").unwrap();
1125        assert_eq!(style.ansi_codes(ColorSystem::Truecolor), "37;44");
1126    }
1127
1128    #[test]
1129    fn combine_overrides() {
1130        let base = Style::parse("bold red").unwrap();
1131        let over = Style::parse("blue").unwrap();
1132        let combined = base.combine(&over);
1133        // bold retained from base, color replaced by blue (34)
1134        assert_eq!(combined.ansi_codes(ColorSystem::Truecolor), "1;34");
1135    }
1136
1137    #[test]
1138    fn no_color_system_is_plaintext() {
1139        let style = Style::parse("bold red").unwrap();
1140        assert_eq!(style.render("hello", None), "hello");
1141    }
1142
1143    #[test]
1144    fn null_style_does_not_wrap() {
1145        let style = Style::new();
1146        assert_eq!(style.render("hello", Some(ColorSystem::Truecolor)), "hello");
1147    }
1148}