term_transcript/
style.rs

1//! `Style` and related types.
2
3use std::{error::Error as StdError, fmt, io, num::ParseIntError, str::FromStr};
4
5/// RGB color with 8-bit channels.
6///
7/// A color [can be parsed](FromStr) from a hex string like `#fed` or `#de382b`.
8#[cfg_attr(not(feature = "svg"), allow(unreachable_pub))] // re-exported publicly from the `svg` module
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
10pub struct RgbColor(pub u8, pub u8, pub u8);
11
12impl fmt::LowerHex for RgbColor {
13    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
14        write!(formatter, "#{:02x}{:02x}{:02x}", self.0, self.1, self.2)
15    }
16}
17
18/// Errors that can occur when [parsing](FromStr) an [`RgbColor`] from a string.
19#[derive(Debug)]
20#[non_exhaustive]
21#[cfg_attr(not(feature = "svg"), allow(unreachable_pub))] // re-exported publicly from the `svg` module
22pub enum RgbColorParseError {
23    /// Color string contains non-ASCII chars.
24    NotAscii,
25    /// The color does not have a `#` prefix.
26    NoHashPrefix,
27    /// The color has incorrect string length (not 1 or 2 chars per color channel).
28    /// The byte length of the string (including 1 char for the `#` prefix)
29    /// is provided within this variant.
30    IncorrectLen(usize),
31    /// Error parsing color channel value.
32    IncorrectDigit(ParseIntError),
33}
34
35impl fmt::Display for RgbColorParseError {
36    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
37        match self {
38            Self::NotAscii => formatter.write_str("color string contains non-ASCII chars"),
39            Self::NoHashPrefix => formatter.write_str("missing '#' prefix"),
40            Self::IncorrectLen(len) => write!(
41                formatter,
42                "unexpected byte length {len} of color string, expected 4 or 7"
43            ),
44            Self::IncorrectDigit(err) => write!(formatter, "error parsing hex digit: {err}"),
45        }
46    }
47}
48
49impl StdError for RgbColorParseError {
50    fn source(&self) -> Option<&(dyn StdError + 'static)> {
51        match self {
52            Self::IncorrectDigit(err) => Some(err),
53            _ => None,
54        }
55    }
56}
57
58impl FromStr for RgbColor {
59    type Err = RgbColorParseError;
60
61    fn from_str(s: &str) -> Result<Self, Self::Err> {
62        if s.is_empty() || s.as_bytes()[0] != b'#' {
63            Err(RgbColorParseError::NoHashPrefix)
64        } else if s.len() == 4 {
65            if !s.is_ascii() {
66                return Err(RgbColorParseError::NotAscii);
67            }
68
69            let r = u8::from_str_radix(&s[1..2], 16).map_err(RgbColorParseError::IncorrectDigit)?;
70            let g = u8::from_str_radix(&s[2..3], 16).map_err(RgbColorParseError::IncorrectDigit)?;
71            let b = u8::from_str_radix(&s[3..], 16).map_err(RgbColorParseError::IncorrectDigit)?;
72            Ok(Self(r * 17, g * 17, b * 17))
73        } else if s.len() == 7 {
74            if !s.is_ascii() {
75                return Err(RgbColorParseError::NotAscii);
76            }
77
78            let r = u8::from_str_radix(&s[1..3], 16).map_err(RgbColorParseError::IncorrectDigit)?;
79            let g = u8::from_str_radix(&s[3..5], 16).map_err(RgbColorParseError::IncorrectDigit)?;
80            let b = u8::from_str_radix(&s[5..], 16).map_err(RgbColorParseError::IncorrectDigit)?;
81            Ok(Self(r, g, b))
82        } else {
83            Err(RgbColorParseError::IncorrectLen(s.len()))
84        }
85    }
86}
87
88#[derive(Debug, Clone, Copy, PartialEq)]
89#[cfg_attr(feature = "svg", derive(serde::Serialize))]
90#[cfg_attr(feature = "svg", serde(untagged))]
91pub(crate) enum Color {
92    Index(u8),
93    Rgb(RgbColor),
94}
95
96impl Color {
97    pub(crate) const BLACK: Self = Self::Index(0);
98    pub(crate) const RED: Self = Self::Index(1);
99    pub(crate) const GREEN: Self = Self::Index(2);
100    pub(crate) const YELLOW: Self = Self::Index(3);
101    pub(crate) const BLUE: Self = Self::Index(4);
102    pub(crate) const MAGENTA: Self = Self::Index(5);
103    pub(crate) const CYAN: Self = Self::Index(6);
104    pub(crate) const WHITE: Self = Self::Index(7);
105
106    pub(crate) const INTENSE_BLACK: Self = Self::Index(8);
107    pub(crate) const INTENSE_RED: Self = Self::Index(9);
108    pub(crate) const INTENSE_GREEN: Self = Self::Index(10);
109    pub(crate) const INTENSE_YELLOW: Self = Self::Index(11);
110    pub(crate) const INTENSE_BLUE: Self = Self::Index(12);
111    pub(crate) const INTENSE_MAGENTA: Self = Self::Index(13);
112    pub(crate) const INTENSE_CYAN: Self = Self::Index(14);
113    pub(crate) const INTENSE_WHITE: Self = Self::Index(15);
114}
115
116#[cfg(any(feature = "svg", feature = "test"))]
117impl Color {
118    fn index(value: u8) -> Self {
119        debug_assert!(value < 16);
120        Self::Index(value)
121    }
122
123    fn normalize(&mut self) {
124        if let Self::Index(index) = *self {
125            if index >= 16 {
126                *self = Color::indexed_color(index);
127            }
128        }
129    }
130
131    pub(crate) fn indexed_color(index: u8) -> Self {
132        match index {
133            0..=15 => Self::index(index),
134
135            16..=231 => {
136                let index = index - 16;
137                let r = Self::color_cube_color(index / 36);
138                let g = Self::color_cube_color((index / 6) % 6);
139                let b = Self::color_cube_color(index % 6);
140                Self::Rgb(RgbColor(r, g, b))
141            }
142
143            _ => {
144                let gray = 10 * (index - 232) + 8;
145                Self::Rgb(RgbColor(gray, gray, gray))
146            }
147        }
148    }
149
150    fn color_cube_color(index: u8) -> u8 {
151        match index {
152            0 => 0,
153            1 => 0x5f,
154            2 => 0x87,
155            3 => 0xaf,
156            4 => 0xd7,
157            5 => 0xff,
158            _ => unreachable!(),
159        }
160    }
161}
162
163/// Serializable `ColorSpec` representation.
164#[derive(Debug, Default, Clone, Copy, PartialEq)]
165#[cfg_attr(feature = "svg", derive(serde::Serialize))]
166#[allow(clippy::struct_excessive_bools)] // makes serialization simpler
167pub(crate) struct Style {
168    #[cfg_attr(feature = "svg", serde(skip_serializing_if = "Style::is_false"))]
169    pub(crate) bold: bool,
170    #[cfg_attr(feature = "svg", serde(skip_serializing_if = "Style::is_false"))]
171    pub(crate) italic: bool,
172    #[cfg_attr(feature = "svg", serde(skip_serializing_if = "Style::is_false"))]
173    pub(crate) underline: bool,
174    #[cfg_attr(feature = "svg", serde(skip_serializing_if = "Style::is_false"))]
175    pub(crate) dimmed: bool,
176    /// Not supported by all terminals.
177    #[cfg_attr(feature = "svg", serde(skip_serializing_if = "Style::is_false"))]
178    pub(crate) strikethrough: bool,
179    /// Swap background and foreground.
180    #[cfg_attr(feature = "svg", serde(skip_serializing_if = "Style::is_false"))]
181    pub(crate) inverted: bool,
182    /// Not supported on all terminals, sometimes intentionally switched off.
183    #[cfg_attr(feature = "svg", serde(skip_serializing_if = "Style::is_false"))]
184    pub(crate) blink: bool,
185    /// Not supported on all terminals.
186    #[cfg_attr(feature = "svg", serde(skip_serializing_if = "Style::is_false"))]
187    pub(crate) concealed: bool,
188    #[cfg_attr(feature = "svg", serde(skip_serializing_if = "Option::is_none"))]
189    pub(crate) fg: Option<Color>,
190    #[cfg_attr(feature = "svg", serde(skip_serializing_if = "Option::is_none"))]
191    pub(crate) bg: Option<Color>,
192}
193
194// Use `anstyle`-compatible API:
195impl fmt::Display for Style {
196    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
197        let is_terminal = formatter.alternate();
198        if self.is_none() {
199            Ok(()) // don't write anything
200        } else if is_terminal {
201            write!(formatter, "\u{1b}[0m")
202        } else {
203            if self.bold {
204                write!(formatter, "\u{1b}[1m")?;
205            }
206            if self.dimmed {
207                write!(formatter, "\u{1b}[2m")?;
208            }
209            if self.italic {
210                write!(formatter, "\u{1b}[3m")?;
211            }
212            if self.underline {
213                write!(formatter, "\u{1b}[4m")?;
214            }
215            if self.strikethrough {
216                write!(formatter, "\u{1b}[9m")?;
217            }
218            if self.inverted {
219                write!(formatter, "\u{1b}[7m")?;
220            }
221            if self.blink {
222                write!(formatter, "\u{1b}[5m")?;
223            }
224            if self.concealed {
225                write!(formatter, "\u{1b}[8m")?;
226            }
227
228            if let Some(fg) = &self.fg {
229                fg.write_params(formatter, false)?;
230            }
231            if let Some(bg) = &self.bg {
232                bg.write_params(formatter, true)?;
233            }
234            Ok(())
235        }
236    }
237}
238
239impl Style {
240    #[cfg(feature = "test")]
241    pub(crate) const NONE: Self = Self {
242        bold: false,
243        italic: false,
244        underline: false,
245        dimmed: false,
246        strikethrough: false,
247        inverted: false,
248        blink: false,
249        concealed: false,
250        fg: None,
251        bg: None,
252    };
253
254    #[cfg(feature = "svg")]
255    #[allow(clippy::trivially_copy_pass_by_ref)] // required by `serde`
256    fn is_false(&val: &bool) -> bool {
257        !val
258    }
259
260    pub(crate) fn is_none(&self) -> bool {
261        !self.bold
262            && !self.italic
263            && !self.underline
264            && !self.dimmed
265            && !self.strikethrough
266            && !self.inverted
267            && !self.blink
268            && !self.concealed
269            && self.fg.is_none()
270            && self.bg.is_none()
271    }
272
273    #[cfg(any(feature = "svg", feature = "test"))]
274    pub(crate) fn normalize(&mut self) {
275        if let Some(color) = &mut self.fg {
276            color.normalize();
277        }
278        if let Some(color) = &mut self.bg {
279            color.normalize();
280        }
281    }
282}
283
284impl Color {
285    fn write_params(self, formatter: &mut fmt::Formatter<'_>, is_bg: bool) -> fmt::Result {
286        match self {
287            Self::Index(idx) if idx < 8 => {
288                let offset = if is_bg { 40 } else { 30 };
289                write!(formatter, "\u{1b}[{}m", offset + idx)
290            }
291            Self::Index(idx) => {
292                let prefix = if is_bg { 48 } else { 38 };
293                write!(formatter, "\u{1b}[{prefix};5;{idx}m")
294            }
295            Self::Rgb(RgbColor(r, g, b)) => {
296                let prefix = if is_bg { 48 } else { 38 };
297                write!(formatter, "\u{1b}[{prefix};2;{r};{g};{b}m")
298            }
299        }
300    }
301}
302
303/// Span of text with associated [`Style`].
304#[cfg(any(feature = "svg", feature = "test"))]
305#[derive(Debug, Clone, Copy, Default, PartialEq)]
306#[cfg_attr(feature = "svg", derive(serde::Serialize))]
307pub(crate) struct StyledSpan<T = String> {
308    #[cfg_attr(feature = "svg", serde(flatten))]
309    pub(crate) style: Style,
310    pub(crate) text: T,
311}
312
313/// Writer similar to `io::Write`, but with separated writing of `Style`s.
314pub(crate) trait WriteStyled {
315    /// The style is completely reset on each call.
316    fn write_style(&mut self, style: &Style) -> io::Result<()>;
317
318    fn write_text(&mut self, text: &str) -> io::Result<()>;
319
320    #[cfg(test)]
321    fn reset(&mut self) -> io::Result<()> {
322        self.write_style(&Style::default())
323    }
324}
325
326// No-op implementation
327impl WriteStyled for io::Sink {
328    fn write_style(&mut self, _style: &Style) -> io::Result<()> {
329        Ok(())
330    }
331
332    fn write_text(&mut self, _text: &str) -> io::Result<()> {
333        Ok(())
334    }
335}
336
337impl WriteStyled for String {
338    fn write_style(&mut self, _style: &Style) -> io::Result<()> {
339        Ok(())
340    }
341
342    fn write_text(&mut self, text: &str) -> io::Result<()> {
343        self.push_str(text);
344        Ok(())
345    }
346}
347
348impl<T: WriteStyled + ?Sized> WriteStyled for &mut T {
349    fn write_style(&mut self, style: &Style) -> io::Result<()> {
350        (**self).write_style(style)
351    }
352
353    fn write_text(&mut self, text: &str) -> io::Result<()> {
354        (**self).write_text(text)
355    }
356}
357
358#[cfg(test)]
359mod tests {
360    use assert_matches::assert_matches;
361
362    use super::*;
363
364    #[test]
365    fn parsing_color() {
366        let RgbColor(r, g, b) = "#fed".parse().unwrap();
367        assert_eq!((r, g, b), (0xff, 0xee, 0xdd));
368        let RgbColor(r, g, b) = "#c0ffee".parse().unwrap();
369        assert_eq!((r, g, b), (0xc0, 0xff, 0xee));
370    }
371
372    #[test]
373    fn errors_parsing_color() {
374        let err = "123".parse::<RgbColor>().unwrap_err();
375        assert_matches!(err, RgbColorParseError::NoHashPrefix);
376        let err = "#12".parse::<RgbColor>().unwrap_err();
377        assert_matches!(err, RgbColorParseError::IncorrectLen(3));
378        let err = "#тэг".parse::<RgbColor>().unwrap_err();
379        assert_matches!(err, RgbColorParseError::NotAscii);
380        let err = "#coffee".parse::<RgbColor>().unwrap_err();
381        assert_matches!(err, RgbColorParseError::IncorrectDigit(_));
382    }
383}