Skip to main content

ctl_term/
encode.rs

1//! The only place an escape sequence is written.
2
3use anstyle::Style;
4use ctl_text::Line;
5
6use crate::color::ColorDepth;
7
8/// The SGR sequence that clears every attribute.
9pub const RESET: &str = "\u{1b}[0m";
10
11/// Turns lines of styled spans into the bytes a terminal reads.
12///
13/// Layout never sees an escape byte: it produces [`Line`]s, and this encoder
14/// adds the styling afterwards.
15///
16/// - It writes a style sequence only where the style changes between spans,
17///   with every colour converted to the encoder's depth.
18/// - It skips a span with no text.
19/// - It writes a reset where a style ends. At the end of a line it writes one
20///   only when a style is still open, so each line stands alone in `head`,
21///   `less -R`, and a log.
22///
23/// An encoder with no depth writes the text of each line byte for byte, with
24/// no escape.
25///
26/// It needs no terminal, because the depth is an argument. A program that
27/// renders to a string or to bytes builds one from a depth it chose.
28///
29/// ```
30/// use ctl_term::ctl_text::anstyle::AnsiColor;
31/// use ctl_term::ctl_text::{Line, Span, Style};
32/// use ctl_term::{ColorDepth, Encoder};
33///
34/// let red = Style::new().fg_color(Some(AnsiColor::Red.into()));
35/// let lines = [Line::from(Span::new("failed", red))];
36///
37/// let text = Encoder::new(Some(ColorDepth::TrueColor)).encode(&lines);
38/// assert_eq!(text, "\u{1b}[31mfailed\u{1b}[0m\n");
39/// assert_eq!(text.into_bytes(), b"\x1b[31mfailed\x1b[0m\n");
40/// ```
41#[derive(Clone, Copy, Debug, Eq, PartialEq)]
42pub struct Encoder {
43    depth: Option<ColorDepth>,
44}
45
46impl Encoder {
47    /// An encoder that writes colour at `depth`, or none for `None`.
48    #[must_use]
49    pub const fn new(depth: Option<ColorDepth>) -> Self {
50        Self { depth }
51    }
52
53    /// `lines` as text, each line ended by a newline.
54    ///
55    /// Each line starts with no style on and ends with none on.
56    #[must_use]
57    pub fn encode(self, lines: &[Line]) -> String {
58        let mut out = String::new();
59        for line in lines {
60            self.encode_line(line, &mut out);
61            out.push('\n');
62        }
63        out
64    }
65
66    fn encode_line(self, line: &Line, out: &mut String) {
67        let Some(depth) = self.depth else {
68            for span in line.spans() {
69                out.push_str(span.text());
70            }
71            return;
72        };
73        let mut current = Style::new();
74        for span in line.spans().iter().filter(|span| !span.text().is_empty()) {
75            let style = fit(depth, span.style());
76            if style != current {
77                if !current.is_plain() {
78                    out.push_str(RESET);
79                }
80                if !style.is_plain() {
81                    out.push_str(&style.render().to_string());
82                }
83                current = style;
84            }
85            out.push_str(span.text());
86        }
87        if !current.is_plain() {
88            out.push_str(RESET);
89        }
90    }
91}
92
93/// `style` with every colour converted to the nearest one `depth` can show.
94///
95/// At 16 colours the underline colour goes: SGR 58 takes a palette or 24-bit
96/// colour, and the 16-colour codes have no underline form.
97fn fit(depth: ColorDepth, style: Style) -> Style {
98    let underline = match depth {
99        ColorDepth::Ansi16 => None,
100        ColorDepth::Ansi256 | ColorDepth::TrueColor => style
101            .get_underline_color()
102            .map(|color| depth.downgrade(color)),
103    };
104    Style::new()
105        .fg_color(style.get_fg_color().map(|color| depth.downgrade(color)))
106        .bg_color(style.get_bg_color().map(|color| depth.downgrade(color)))
107        .underline_color(underline)
108        .effects(style.get_effects())
109}