Skip to main content

term_transcript/write/
mod.rs

1//! Rendering logic for terminal outputs.
2
3use std::{fmt, io, str};
4
5use termcolor::{Color, ColorSpec};
6use unicode_width::UnicodeWidthChar;
7
8mod html;
9#[cfg(feature = "svg")]
10mod svg;
11#[cfg(test)]
12mod tests;
13
14pub(crate) use self::html::HtmlWriter;
15#[cfg(feature = "svg")]
16pub(crate) use self::svg::{SvgLine, SvgWriter};
17
18fn fmt_to_io_error(err: fmt::Error) -> io::Error {
19    io::Error::other(err)
20}
21
22/// HTML `<span>` / SVG `<tspan>` containing styling info.
23#[derive(Debug)]
24struct StyledSpan {
25    classes: Vec<String>,
26    styles: Vec<String>,
27}
28
29impl StyledSpan {
30    fn new(spec: &ColorSpec, fg_property: &str) -> io::Result<Self> {
31        let mut classes = vec![];
32        if spec.bold() {
33            classes.push("bold".to_owned());
34        }
35        if spec.dimmed() {
36            classes.push("dimmed".to_owned());
37        }
38        if spec.italic() {
39            classes.push("italic".to_owned());
40        }
41        if spec.underline() {
42            classes.push("underline".to_owned());
43        }
44
45        let mut this = Self {
46            classes,
47            styles: vec![],
48        };
49        if let Some(color) = spec.fg() {
50            let color = IndexOrRgb::new(*color)?;
51            this.set_fg(color, spec.intense(), &[fg_property]);
52        }
53        Ok(this)
54    }
55
56    fn set_fg(&mut self, color: IndexOrRgb, intense: bool, fg_properties: &[&str]) {
57        use fmt::Write as _;
58
59        let mut fore_color_class = String::with_capacity(4);
60        fore_color_class.push_str("fg");
61        match color {
62            IndexOrRgb::Index(idx) => {
63                let final_idx = if intense { idx | 8 } else { idx };
64                write!(&mut fore_color_class, "{final_idx}").unwrap();
65                // ^-- `unwrap` is safe; writing to a string never fails.
66                self.classes.push(fore_color_class);
67            }
68            IndexOrRgb::Rgb(r, g, b) => {
69                for property in fg_properties {
70                    self.styles
71                        .push(format!("{property}: #{r:02x}{g:02x}{b:02x}"));
72                }
73            }
74        }
75    }
76
77    fn write_tag(self, output: &mut impl WriteStr, tag: &str) -> io::Result<()> {
78        output.write_str("<")?;
79        output.write_str(tag)?;
80        if !self.classes.is_empty() {
81            output.write_str(" class=\"")?;
82            for (i, class) in self.classes.iter().enumerate() {
83                output.write_str(class)?;
84                if i + 1 < self.classes.len() {
85                    output.write_str(" ")?;
86                }
87            }
88            output.write_str("\"")?;
89        }
90        if !self.styles.is_empty() {
91            output.write_str(" style=\"")?;
92            for (i, style) in self.styles.iter().enumerate() {
93                output.write_str(style)?;
94                if i + 1 < self.styles.len() {
95                    output.write_str("; ")?;
96                }
97            }
98            output.write_str(";\"")?;
99        }
100        output.write_str(">")
101    }
102}
103
104/// Analogue of `std::fmt::Write`, but with `io::Error`s.
105trait WriteStr {
106    fn write_str(&mut self, s: &str) -> io::Result<()>;
107}
108
109impl WriteStr for String {
110    fn write_str(&mut self, s: &str) -> io::Result<()> {
111        <Self as fmt::Write>::write_str(self, s).map_err(fmt_to_io_error)
112    }
113}
114
115/// Shared logic between `HtmlWriter` and `SvgWriter`.
116trait WriteLines: WriteStr {
117    fn line_splitter_mut(&mut self) -> Option<&mut LineSplitter>;
118
119    /// Writes a [`LineBreak`] to this writer. The char width of the line preceding the break
120    /// is `char_width`.
121    fn write_line_break(&mut self, br: LineBreak, char_width: usize) -> io::Result<()>;
122
123    /// Writes a newline `\n` to this writer.
124    fn write_new_line(&mut self, char_width: usize) -> io::Result<()>;
125
126    /// Writes the specified text displayed to the user that should be subjected to wrapping.
127    #[allow(clippy::option_if_let_else)] // false positive
128    fn write_text(&mut self, s: &str) -> io::Result<()> {
129        if let Some(splitter) = self.line_splitter_mut() {
130            let lines = splitter.split_lines(s);
131            self.write_lines(lines)
132        } else {
133            self.write_str(s)
134        }
135    }
136
137    fn write_lines(&mut self, lines: Vec<Line<'_>>) -> io::Result<()> {
138        let lines_count = lines.len();
139        let it = lines.into_iter().enumerate();
140        for (i, line) in it {
141            self.write_str(line.text)?;
142            if let Some(br) = line.br {
143                self.write_line_break(br, line.char_width)?;
144            } else if i + 1 < lines_count {
145                self.write_new_line(line.char_width)?;
146            }
147        }
148        Ok(())
149    }
150
151    /// Writes the specified HTML `entity` as if it were displayed as a single char.
152    #[allow(clippy::option_if_let_else)] // false positive
153    fn write_html_entity(&mut self, entity: &str) -> io::Result<()> {
154        if let Some(splitter) = self.line_splitter_mut() {
155            let lines = splitter.write_as_char(entity);
156            self.write_lines(lines)
157        } else {
158            self.write_str(entity)
159        }
160    }
161
162    /// Implements `io::Write::write()`.
163    fn io_write(&mut self, buffer: &[u8], convert_spaces: bool) -> io::Result<usize> {
164        let mut last_escape = 0;
165        for (i, &byte) in buffer.iter().enumerate() {
166            let escaped = match byte {
167                b'>' => "&gt;",
168                b'<' => "&lt;",
169                b'&' => "&amp;",
170                b' ' if convert_spaces => "\u{a0}", // non-breakable space
171                _ => continue,
172            };
173            let saved_str = str::from_utf8(&buffer[last_escape..i])
174                .map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?;
175            self.write_text(saved_str)?;
176            self.write_html_entity(escaped)?;
177            last_escape = i + 1;
178        }
179
180        let saved_str = str::from_utf8(&buffer[last_escape..])
181            .map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?;
182        self.write_text(saved_str)?;
183        Ok(buffer.len())
184    }
185}
186
187#[derive(Debug, Clone, Copy)]
188pub(crate) enum IndexOrRgb {
189    Index(u8),
190    Rgb(u8, u8, u8),
191}
192
193impl IndexOrRgb {
194    #[allow(clippy::match_wildcard_for_single_variants)]
195    // ^-- `Color` is an old-school non-exhaustive enum
196    fn new(color: Color) -> io::Result<Self> {
197        Ok(match color {
198            Color::Black => Self::index(0),
199            Color::Red => Self::index(1),
200            Color::Green => Self::index(2),
201            Color::Yellow => Self::index(3),
202            Color::Blue => Self::index(4),
203            Color::Magenta => Self::index(5),
204            Color::Cyan => Self::index(6),
205            Color::White => Self::index(7),
206            Color::Ansi256(idx) => Self::indexed_color(idx),
207            Color::Rgb(r, g, b) => Self::Rgb(r, g, b),
208            _ => return Err(io::Error::other("Unsupported color")),
209        })
210    }
211
212    fn index(value: u8) -> Self {
213        debug_assert!(value < 16);
214        Self::Index(value)
215    }
216
217    pub fn indexed_color(index: u8) -> Self {
218        match index {
219            0..=15 => Self::index(index),
220
221            16..=231 => {
222                let index = index - 16;
223                let r = Self::color_cube_color(index / 36);
224                let g = Self::color_cube_color((index / 6) % 6);
225                let b = Self::color_cube_color(index % 6);
226                Self::Rgb(r, g, b)
227            }
228
229            _ => {
230                let gray = 10 * (index - 232) + 8;
231                Self::Rgb(gray, gray, gray)
232            }
233        }
234    }
235
236    fn color_cube_color(index: u8) -> u8 {
237        match index {
238            0 => 0,
239            1 => 0x5f,
240            2 => 0x87,
241            3 => 0xaf,
242            4 => 0xd7,
243            5 => 0xff,
244            _ => unreachable!(),
245        }
246    }
247}
248
249#[derive(Debug)]
250struct LineSplitter {
251    max_width: usize,
252    current_width: usize,
253}
254
255impl Default for LineSplitter {
256    fn default() -> Self {
257        Self {
258            max_width: usize::MAX,
259            current_width: 0,
260        }
261    }
262}
263
264impl LineSplitter {
265    fn new(max_width: usize) -> Self {
266        Self {
267            max_width,
268            current_width: 0,
269        }
270    }
271
272    fn split_lines<'a>(&mut self, text: &'a str) -> Vec<Line<'a>> {
273        text.lines()
274            .chain(if text.ends_with('\n') { Some("") } else { None })
275            .enumerate()
276            .flat_map(|(i, line)| {
277                if i > 0 {
278                    self.current_width = 0;
279                }
280                self.process_line(line)
281            })
282            .collect()
283    }
284
285    fn write_as_char<'a>(&mut self, text: &'a str) -> Vec<Line<'a>> {
286        if self.current_width + 1 > self.max_width {
287            let char_width = self.current_width;
288            self.current_width = 1;
289            vec![
290                Line {
291                    text: "",
292                    br: Some(LineBreak::Hard),
293                    char_width,
294                },
295                Line {
296                    text,
297                    br: None,
298                    char_width: 1,
299                },
300            ]
301        } else {
302            self.current_width += 1;
303            vec![Line {
304                text,
305                br: None,
306                char_width: self.current_width,
307            }]
308        }
309    }
310
311    fn process_line<'a>(&mut self, line: &'a str) -> Vec<Line<'a>> {
312        let mut output_lines = vec![];
313        let mut line_start = 0;
314
315        for (pos, char) in line.char_indices() {
316            let char_width = char.width().unwrap_or(0);
317            if self.current_width + char_width > self.max_width {
318                output_lines.push(Line {
319                    text: &line[line_start..pos],
320                    br: Some(LineBreak::Hard),
321                    char_width: self.current_width,
322                });
323                line_start = pos;
324                self.current_width = char_width;
325            } else {
326                self.current_width += char_width;
327            }
328        }
329
330        output_lines.push(Line {
331            text: &line[line_start..],
332            br: None,
333            char_width: self.current_width,
334        });
335        output_lines
336    }
337}
338
339#[derive(Debug, PartialEq)]
340struct Line<'a> {
341    text: &'a str,
342    br: Option<LineBreak>,
343    char_width: usize,
344}
345
346#[derive(Debug, Clone, Copy, PartialEq)]
347enum LineBreak {
348    Hard,
349}