Skip to main content

terminal_control/
render.rs

1use std::path::Path;
2
3use anyhow::{Context, Result};
4
5use crate::frame::{Cell, Frame, Underline};
6
7#[derive(Clone, Debug)]
8pub struct Options {
9    pub cell_width: f32,
10    pub cell_height: f32,
11    pub font_size: f32,
12    pub padding: f32,
13    pub font_family: String,
14    pub show_cursor: bool,
15}
16
17impl Default for Options {
18    fn default() -> Self {
19        Self {
20            cell_width: 9.0,
21            cell_height: 18.0,
22            font_size: 14.0,
23            padding: 18.0,
24            font_family: "JetBrains Mono, SFMono-Regular, Menlo, monospace".to_owned(),
25            show_cursor: true,
26        }
27    }
28}
29
30pub fn svg(frame: &Frame, options: &Options) -> String {
31    let width = f32::from(frame.cols) * options.cell_width + options.padding * 2.0;
32    let height = f32::from(frame.rows) * options.cell_height + options.padding * 2.0;
33    let mut output = format!(
34        r#"<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" viewBox="0 0 {width} {height}"><rect width="100%" height="100%" rx="10" fill="{}"/><g font-family="{}" font-size="{}" xml:space="preserve">"#,
35        frame.background.css(),
36        xml(&options.font_family),
37        options.font_size,
38    );
39    for cell in &frame.cells {
40        if cell.background != frame.background {
41            output.push_str(&format!(
42                r#"<rect x="{}" y="{}" width="{}" height="{}" fill="{}"/>"#,
43                options.padding + f32::from(cell.x) * options.cell_width,
44                options.padding + f32::from(cell.y) * options.cell_height,
45                f32::from(cell.width) * options.cell_width,
46                options.cell_height,
47                cell.background.css(),
48            ));
49        }
50    }
51    for cell in &frame.cells {
52        if !cell.text.is_empty() && !cell.attributes.invisible {
53            output.push_str(&graphic(cell, options).unwrap_or_else(|| text(cell, options)));
54        }
55    }
56    if options.show_cursor
57        && let Some(cursor) = &frame.cursor
58    {
59        let x = options.padding + f32::from(cursor.x) * options.cell_width;
60        let y = options.padding + f32::from(cursor.y) * options.cell_height;
61        output.push_str(&format!(
62            r#"<rect x="{x}" y="{y}" width="{}" height="{}" fill="{}" opacity="0.32"/>"#,
63            options.cell_width,
64            options.cell_height,
65            cursor.color.css(),
66        ));
67    }
68    output.push_str("</g></svg>");
69    output
70}
71
72pub fn png(svg: &str, path: &Path, pixel_ratio: f32) -> Result<()> {
73    PngRenderer::new().render(svg, path, pixel_ratio)
74}
75
76pub struct PngRenderer {
77    options: resvg::usvg::Options<'static>,
78}
79
80impl PngRenderer {
81    pub fn new() -> Self {
82        let mut options = resvg::usvg::Options::default();
83        options.fontdb_mut().load_system_fonts();
84        Self { options }
85    }
86
87    pub fn render(&self, svg: &str, path: &Path, pixel_ratio: f32) -> Result<()> {
88        let tree = resvg::usvg::Tree::from_data(svg.as_bytes(), &self.options)
89            .context("parse rendered SVG")?;
90        let size = tree.size().to_int_size();
91        let width = ((size.width() as f32) * pixel_ratio).ceil() as u32;
92        let height = ((size.height() as f32) * pixel_ratio).ceil() as u32;
93        let mut pixmap =
94            resvg::tiny_skia::Pixmap::new(width, height).context("allocate PNG canvas")?;
95        resvg::render(
96            &tree,
97            resvg::tiny_skia::Transform::from_scale(pixel_ratio, pixel_ratio),
98            &mut pixmap.as_mut(),
99        );
100        pixmap.save_png(path).context("write PNG artifact")?;
101        Ok(())
102    }
103}
104
105impl Default for PngRenderer {
106    fn default() -> Self {
107        Self::new()
108    }
109}
110
111fn graphic(cell: &Cell, options: &Options) -> Option<String> {
112    let mut chars = cell.text.chars();
113    let char = chars.next()?;
114    if chars.next().is_some() {
115        return None;
116    }
117    let x = options.padding + f32::from(cell.x) * options.cell_width;
118    let y = options.padding + f32::from(cell.y) * options.cell_height;
119    let width = options.cell_width * f32::from(cell.width);
120    let height = options.cell_height;
121    let rect = |x: f32, y: f32, width: f32, height: f32, opacity: Option<f32>| {
122        format!(
123            r#"<rect x="{x}" y="{y}" width="{width}" height="{height}" fill="{}"{}/>"#,
124            cell.foreground.css(),
125            opacity.map_or_else(String::new, |value| format!(r#" opacity="{value}""#)),
126        )
127    };
128    let stroke_width = width.min(height) * 0.08;
129    let stroke_rect = |left: f32, top: f32, wide: f32, tall: f32| {
130        format!(
131            r#"<rect x="{}" y="{}" width="{}" height="{}" fill="none" stroke="{}" stroke-width="{stroke_width}"/>"#,
132            x + width * left,
133            y + height * top,
134            width * wide,
135            height * tall,
136            cell.foreground.css(),
137        )
138    };
139    let single = |left: f32, top: f32, wide: f32, tall: f32| {
140        rect(
141            x + width * left,
142            y + height * top,
143            width * wide,
144            height * tall,
145            None,
146        )
147    };
148    let circle = |center_x: f32, center_y: f32, radius: f32| {
149        format!(
150            r#"<circle cx="{}" cy="{}" r="{}" fill="{}"/>"#,
151            x + width * center_x,
152            y + height * center_y,
153            radius,
154            cell.foreground.css(),
155        )
156    };
157    let ring = |center_x: f32, center_y: f32, radius: f32| {
158        format!(
159            r#"<circle cx="{}" cy="{}" r="{}" fill="none" stroke="{}" stroke-width="{stroke_width}"/>"#,
160            x + width * center_x,
161            y + height * center_y,
162            radius,
163            cell.foreground.css(),
164        )
165    };
166    let diamond = |scale: f32, filled: bool| {
167        let center_x = x + width * 0.5;
168        let center_y = y + height * 0.5;
169        let half_width = width * 0.42 * scale;
170        let half_height = height * 0.36 * scale;
171        let points = format!(
172            "{center_x},{} {},{center_y} {center_x},{} {},{center_y}",
173            center_y - half_height,
174            center_x + half_width,
175            center_y + half_height,
176            center_x - half_width,
177        );
178        if filled {
179            return format!(
180                r#"<polygon points="{points}" fill="{}"/>"#,
181                cell.foreground.css()
182            );
183        }
184        format!(
185            r#"<polygon points="{points}" fill="none" stroke="{}" stroke-width="{stroke_width}"/>"#,
186            cell.foreground.css(),
187        )
188    };
189    let codepoint = char as u32;
190    if (0x2800..=0x28ff).contains(&codepoint) {
191        return Some(braille_dots(
192            codepoint - 0x2800,
193            &circle,
194            width.min(height) * 0.09,
195        ));
196    }
197    Some(match char {
198        '█' => single(0.0, 0.0, 1.0, 1.0),
199        '▀' => single(0.0, 0.0, 1.0, 0.5),
200        '▄' => single(0.0, 0.5, 1.0, 0.5),
201        '▌' => single(0.0, 0.0, 0.5, 1.0),
202        '▐' => single(0.5, 0.0, 0.5, 1.0),
203        '▁' => single(0.0, 7.0 / 8.0, 1.0, 1.0 / 8.0),
204        '▂' => single(0.0, 6.0 / 8.0, 1.0, 2.0 / 8.0),
205        '▃' => single(0.0, 5.0 / 8.0, 1.0, 3.0 / 8.0),
206        '▅' => single(0.0, 3.0 / 8.0, 1.0, 5.0 / 8.0),
207        '▆' => single(0.0, 2.0 / 8.0, 1.0, 6.0 / 8.0),
208        '▇' => single(0.0, 1.0 / 8.0, 1.0, 7.0 / 8.0),
209        '▏' => single(0.0, 0.0, 1.0 / 8.0, 1.0),
210        '▎' => single(0.0, 0.0, 2.0 / 8.0, 1.0),
211        '▍' => single(0.0, 0.0, 3.0 / 8.0, 1.0),
212        '▋' => single(0.0, 0.0, 5.0 / 8.0, 1.0),
213        '▊' => single(0.0, 0.0, 6.0 / 8.0, 1.0),
214        '▉' => single(0.0, 0.0, 7.0 / 8.0, 1.0),
215        '▔' => single(0.0, 0.0, 1.0, 1.0 / 8.0),
216        '▖' => single(0.0, 0.5, 0.5, 0.5),
217        '▗' => single(0.5, 0.5, 0.5, 0.5),
218        '▘' => single(0.0, 0.0, 0.5, 0.5),
219        '▝' => single(0.5, 0.0, 0.5, 0.5),
220        '▚' => single(0.0, 0.0, 0.5, 0.5) + &single(0.5, 0.5, 0.5, 0.5),
221        '▞' => single(0.5, 0.0, 0.5, 0.5) + &single(0.0, 0.5, 0.5, 0.5),
222        '▙' => single(0.0, 0.0, 0.5, 1.0) + &single(0.5, 0.5, 0.5, 0.5),
223        '▛' => single(0.0, 0.0, 0.5, 1.0) + &single(0.5, 0.0, 0.5, 0.5),
224        '▜' => single(0.5, 0.0, 0.5, 1.0) + &single(0.0, 0.0, 0.5, 0.5),
225        '▟' => single(0.5, 0.0, 0.5, 1.0) + &single(0.0, 0.5, 0.5, 0.5),
226        '▣' => stroke_rect(0.18, 0.18, 0.64, 0.64) + &single(0.38, 0.38, 0.24, 0.24),
227        '■' => single(0.1, 0.18, 0.8, 0.64),
228        '⬝' => single(0.32, 0.38, 0.36, 0.28),
229        '◆' => diamond(1.0, true),
230        '◇' => diamond(1.0, false),
231        '◈' => diamond(1.0, false) + &diamond(0.42, true),
232        '⬥' => diamond(0.82, true),
233        '⬩' | '⬪' | '⬖' => diamond(0.52, true),
234        '●' => circle(0.5, 0.52, width.min(height) * 0.32),
235        '○' => ring(0.5, 0.52, width.min(height) * 0.32),
236        '◉' | '◍' => {
237            ring(0.5, 0.52, width.min(height) * 0.32) + &circle(0.5, 0.52, width.min(height) * 0.15)
238        }
239        '◔' => ring(0.5, 0.52, width.min(height) * 0.32) + &single(0.5, 0.2, 0.32, 0.32),
240        _ => return None,
241    })
242}
243
244fn braille_dots(pattern: u32, circle: &impl Fn(f32, f32, f32) -> String, radius: f32) -> String {
245    [
246        (0x01, 0.34, 0.2),
247        (0x02, 0.34, 0.38),
248        (0x04, 0.34, 0.56),
249        (0x40, 0.34, 0.74),
250        (0x08, 0.66, 0.2),
251        (0x10, 0.66, 0.38),
252        (0x20, 0.66, 0.56),
253        (0x80, 0.66, 0.74),
254    ]
255    .into_iter()
256    .filter(|(bit, _, _)| pattern & bit != 0)
257    .map(|(_, x, y)| circle(x, y, radius))
258    .collect::<String>()
259}
260
261fn text(cell: &Cell, options: &Options) -> String {
262    let x = options.padding + f32::from(cell.x) * options.cell_width;
263    let y = options.padding + f32::from(cell.y) * options.cell_height + options.cell_height * 0.78;
264    let decorations = [
265        cell.attributes
266            .underline
267            .map(|Underline::Single| "underline"),
268        cell.attributes.strikethrough.then_some("line-through"),
269        cell.attributes.overline.then_some("overline"),
270    ]
271    .into_iter()
272    .flatten()
273    .collect::<Vec<_>>()
274    .join(" ");
275    format!(
276        r#"<text x="{x}" y="{y}" fill="{}"{}{}{}{}>{}</text>"#,
277        cell.foreground.css(),
278        if cell.attributes.bold {
279            " font-weight=\"700\""
280        } else {
281            ""
282        },
283        if cell.attributes.italic {
284            " font-style=\"italic\""
285        } else {
286            ""
287        },
288        if cell.attributes.faint {
289            " opacity=\"0.55\""
290        } else {
291            ""
292        },
293        if decorations.is_empty() {
294            String::new()
295        } else {
296            format!(" text-decoration=\"{decorations}\"")
297        },
298        xml(&cell.text),
299    )
300}
301
302fn xml(value: &str) -> String {
303    value
304        .replace('&', "&amp;")
305        .replace('<', "&lt;")
306        .replace('>', "&gt;")
307        .replace('"', "&quot;")
308        .replace('\'', "&apos;")
309}
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314    use crate::frame::{Attributes, Color, Frame, Underline};
315
316    #[test]
317    fn emits_background_and_text_styles_in_svg() {
318        let frame = Frame {
319            version: 1,
320            cols: 4,
321            rows: 1,
322            foreground: Color {
323                r: 255,
324                g: 255,
325                b: 255,
326            },
327            background: Color { r: 0, g: 0, b: 0 },
328            cursor: None,
329            cells: vec![crate::frame::Cell {
330                x: 0,
331                y: 0,
332                text: "Hi".to_owned(),
333                width: 2,
334                foreground: Color { r: 1, g: 2, b: 3 },
335                background: Color { r: 4, g: 5, b: 6 },
336                attributes: Attributes {
337                    bold: true,
338                    underline: Some(Underline::Single),
339                    ..Attributes::default()
340                },
341            }],
342        };
343
344        let output = svg(&frame, &Options::default());
345
346        assert!(output.contains("#040506"));
347        assert!(output.contains("#010203"));
348        assert!(output.contains("font-weight=\"700\""));
349        assert!(output.contains("text-decoration=\"underline\""));
350    }
351
352    #[test]
353    fn renders_block_elements_as_geometry_instead_of_font_glyphs() {
354        let frame = Frame {
355            version: 1,
356            cols: 1,
357            rows: 1,
358            foreground: Color {
359                r: 255,
360                g: 255,
361                b: 255,
362            },
363            background: Color { r: 0, g: 0, b: 0 },
364            cursor: None,
365            cells: vec![crate::frame::Cell {
366                x: 0,
367                y: 0,
368                text: "▀".to_owned(),
369                width: 1,
370                foreground: Color {
371                    r: 255,
372                    g: 255,
373                    b: 255,
374                },
375                background: Color { r: 0, g: 0, b: 0 },
376                attributes: Attributes::default(),
377            }],
378        };
379
380        let output = svg(&frame, &Options::default());
381
382        assert!(output.contains("height=\"9\""));
383        assert!(!output.contains(">▀</text>"));
384    }
385
386    #[test]
387    fn renders_opencode_status_glyphs_as_geometry() {
388        let frame = Frame {
389            version: 1,
390            cols: 16,
391            rows: 1,
392            foreground: Color {
393                r: 80,
394                g: 140,
395                b: 220,
396            },
397            background: Color { r: 0, g: 0, b: 0 },
398            cursor: None,
399            cells: [
400                "■", "⬝", "▣", "◆", "◇", "◈", "⬥", "⬩", "⬪", "⬖", "●", "○", "◉", "◍", "◔",
401            ]
402            .into_iter()
403            .enumerate()
404            .map(|(x, text)| crate::frame::Cell {
405                x: x as u16,
406                y: 0,
407                text: text.to_owned(),
408                width: 1,
409                foreground: Color {
410                    r: 80,
411                    g: 140,
412                    b: 220,
413                },
414                background: Color { r: 0, g: 0, b: 0 },
415                attributes: Attributes::default(),
416            })
417            .collect(),
418        };
419
420        let output = svg(&frame, &Options::default());
421
422        for text in [
423            "■", "⬝", "▣", "◆", "◇", "◈", "⬥", "⬩", "⬪", "⬖", "●", "○", "◉", "◍", "◔",
424        ] {
425            assert!(!output.contains(&format!(">{text}</text>")));
426        }
427    }
428
429    #[test]
430    fn renders_braille_spinner_frames_as_geometry() {
431        let frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
432        let frame = Frame {
433            version: 1,
434            cols: frames.len() as u16,
435            rows: 1,
436            foreground: Color {
437                r: 180,
438                g: 130,
439                b: 255,
440            },
441            background: Color { r: 0, g: 0, b: 0 },
442            cursor: None,
443            cells: frames
444                .into_iter()
445                .enumerate()
446                .map(|(x, text)| crate::frame::Cell {
447                    x: x as u16,
448                    y: 0,
449                    text: text.to_owned(),
450                    width: 1,
451                    foreground: Color {
452                        r: 180,
453                        g: 130,
454                        b: 255,
455                    },
456                    background: Color { r: 0, g: 0, b: 0 },
457                    attributes: Attributes::default(),
458                })
459                .collect(),
460        };
461
462        let output = svg(&frame, &Options::default());
463
464        assert!(output.contains("<circle"));
465        for text in frames {
466            assert!(!output.contains(&format!(">{text}</text>")));
467        }
468    }
469
470    #[test]
471    fn renders_shade_characters_as_font_glyphs() {
472        let frame = Frame {
473            version: 1,
474            cols: 3,
475            rows: 1,
476            foreground: Color {
477                r: 255,
478                g: 255,
479                b: 255,
480            },
481            background: Color { r: 0, g: 0, b: 0 },
482            cursor: None,
483            cells: ["░", "▒", "▓"]
484                .into_iter()
485                .enumerate()
486                .map(|(x, text)| crate::frame::Cell {
487                    x: x as u16,
488                    y: 0,
489                    text: text.to_owned(),
490                    width: 1,
491                    foreground: Color {
492                        r: 255,
493                        g: 255,
494                        b: 255,
495                    },
496                    background: Color { r: 0, g: 0, b: 0 },
497                    attributes: Attributes::default(),
498                })
499                .collect(),
500        };
501
502        let output = svg(&frame, &Options::default());
503
504        assert!(output.contains(">░</text>"));
505        assert!(output.contains(">▒</text>"));
506        assert!(output.contains(">▓</text>"));
507        assert!(!output.contains("opacity=\"0.25\""));
508        assert!(!output.contains("opacity=\"0.5\""));
509        assert!(!output.contains("opacity=\"0.75\""));
510    }
511}