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}