Skip to main content

command_stream/terminal/
artifacts.rs

1use super::raster::render_recording_gif;
2use super::types::{
3    Asciicast, AsciicastEvent, AsciicastHeader, TerminalArtifact, TerminalCaptureError,
4    TerminalFrame,
5};
6use std::fs;
7use std::path::Path;
8
9fn trim_blank_edges(lines: &[String]) -> &[String] {
10    let first = lines
11        .iter()
12        .position(|line| !line.is_empty())
13        .unwrap_or(lines.len());
14    let last = lines
15        .iter()
16        .rposition(|line| !line.is_empty())
17        .map_or(first, |index| index + 1);
18    &lines[first..last]
19}
20
21fn overlap_length(previous: &[String], current: &[String]) -> usize {
22    let maximum = previous.len().min(current.len());
23    (1..=maximum)
24        .rev()
25        .find(|size| previous[previous.len() - size..] == current[..*size])
26        .unwrap_or(0)
27}
28
29pub fn unroll_terminal_frames(frames: &[TerminalFrame]) -> String {
30    let mut transcript = Vec::new();
31    let mut previous: &[String] = &[];
32
33    for frame in frames {
34        let current = trim_blank_edges(&frame.lines);
35        if current == previous {
36            continue;
37        }
38
39        let overlap = overlap_length(previous, current);
40        if overlap > 0 {
41            transcript.extend_from_slice(&current[overlap..]);
42        } else {
43            let common_prefix = previous
44                .iter()
45                .zip(current)
46                .take_while(|(left, right)| left == right)
47                .count();
48            transcript.extend_from_slice(&current[common_prefix..]);
49        }
50        previous = current;
51    }
52
53    trim_blank_edges(&transcript).join("\n")
54}
55
56fn escape_xml(value: &str) -> String {
57    value
58        .replace('&', "&")
59        .replace('<', "&lt;")
60        .replace('>', "&gt;")
61        .replace('"', "&quot;")
62        .replace('\'', "&apos;")
63}
64
65fn svg_text(lines: &[String]) -> String {
66    lines
67        .iter()
68        .enumerate()
69        .map(|(index, line)| {
70            format!(
71                r#"<text x="12" y="{}">{}</text>"#,
72                24 + index * 18,
73                escape_xml(if line.is_empty() { " " } else { line })
74            )
75        })
76        .collect()
77}
78
79fn svg_shell(width: usize, height: usize, body: &str) -> String {
80    format!(
81        concat!(
82            r#"<?xml version="1.0" encoding="UTF-8"?>"#,
83            r#"<svg xmlns="http://www.w3.org/2000/svg" width="{0}" height="{1}" viewBox="0 0 {0} {1}">"#,
84            r##"<rect width="100%" height="100%" rx="6" fill="#111827"/>"##,
85            r##"<g fill="#e5e7eb" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="14">"##,
86            "{2}</g></svg>"
87        ),
88        width, height, body
89    )
90}
91
92fn render_snapshot_svg(frame: &TerminalFrame) -> String {
93    svg_shell(
94        usize::from(frame.cols) * 9 + 24,
95        usize::from(frame.rows) * 18 + 18,
96        &svg_text(&frame.screen),
97    )
98}
99
100fn render_recording_svg(frames: &[TerminalFrame]) -> String {
101    let columns = frames.iter().map(|frame| frame.cols).max().unwrap_or(1);
102    let rows = frames.iter().map(|frame| frame.rows).max().unwrap_or(1);
103    let count = frames.len().max(1);
104    let key_times = (0..=count)
105        .map(|index| (index as f64 / count as f64).to_string())
106        .collect::<Vec<_>>()
107        .join(";");
108    let duration = (count as f64 * 0.35).max(0.35);
109    let groups = frames
110        .iter()
111        .enumerate()
112        .map(|(frame_index, frame)| {
113            let values = (0..=count)
114                .map(|index| {
115                    if index == frame_index || (index == count && frame_index == 0) {
116                        "1"
117                    } else {
118                        "0"
119                    }
120                })
121                .collect::<Vec<_>>()
122                .join(";");
123            format!(
124                concat!(
125                    "<g>",
126                    r#"<animate attributeName="opacity" calcMode="discrete" values="{}" keyTimes="{}" dur="{}s" repeatCount="indefinite"/>"#,
127                    "{}</g>"
128                ),
129                values,
130                key_times,
131                duration,
132                svg_text(&frame.screen)
133            )
134        })
135        .collect::<String>();
136    svg_shell(
137        usize::from(columns) * 9 + 24,
138        usize::from(rows) * 18 + 18,
139        &groups,
140    )
141}
142
143pub fn serialize_asciicast(asciicast: &Asciicast) -> Result<String, TerminalCaptureError> {
144    let mut lines = vec![serde_json::to_string(&asciicast.header)
145        .map_err(|error| TerminalCaptureError::new(error.to_string(), None))?];
146    for event in &asciicast.events {
147        lines.push(
148            serde_json::to_string(&(event.time, &event.code, &event.data))
149                .map_err(|error| TerminalCaptureError::new(error.to_string(), None))?,
150        );
151    }
152    Ok(format!("{}\n", lines.join("\n")))
153}
154
155pub fn read_asciicast(path: impl AsRef<Path>) -> Result<Asciicast, TerminalCaptureError> {
156    let contents = fs::read_to_string(path)
157        .map_err(|error| TerminalCaptureError::new(error.to_string(), None))?;
158    let mut lines = contents.lines();
159    let header: AsciicastHeader = serde_json::from_str(
160        lines
161            .next()
162            .ok_or_else(|| TerminalCaptureError::new("empty asciicast", None))?,
163    )
164    .map_err(|error| TerminalCaptureError::new(error.to_string(), None))?;
165    let events = lines
166        .filter(|line| !line.is_empty())
167        .map(|line| {
168            let (time, code, data): (f64, String, String) = serde_json::from_str(line)
169                .map_err(|error| TerminalCaptureError::new(error.to_string(), None))?;
170            Ok(AsciicastEvent { time, code, data })
171        })
172        .collect::<Result<Vec<_>, TerminalCaptureError>>()?;
173    Ok(Asciicast { header, events })
174}
175
176/// Write the `selected` artifacts (all of them for `None`) to `directory`.
177/// Each renderer runs only when its artifact is selected.
178pub(crate) fn write_terminal_artifacts(
179    directory: &Path,
180    selected: Option<&[TerminalArtifact]>,
181    frames: &[TerminalFrame],
182    transcript: &str,
183    asciicast: &Asciicast,
184) -> Result<(), TerminalCaptureError> {
185    fs::create_dir_all(directory)
186        .map_err(|error| TerminalCaptureError::new(error.to_string(), None))?;
187    let fallback = [TerminalFrame {
188        time: 0.0,
189        cols: asciicast.header.width,
190        rows: asciicast.header.height,
191        cursor: super::types::TerminalCursor { x: 0, y: 0 },
192        alternate: false,
193        lines: Vec::new(),
194        screen: Vec::new(),
195    }];
196    let rendered = if frames.is_empty() { &fallback } else { frames };
197    let final_frame = &rendered[rendered.len() - 1];
198    let mut written = Vec::new();
199    for artifact in selected.unwrap_or(&TerminalArtifact::ALL) {
200        if written.contains(artifact) {
201            continue;
202        }
203        written.push(*artifact);
204        let contents = match artifact {
205            TerminalArtifact::Transcript => format!("{transcript}\n").into_bytes(),
206            TerminalArtifact::Frames => format!(
207                "{}\n",
208                serde_json::to_string_pretty(frames)
209                    .map_err(|error| TerminalCaptureError::new(error.to_string(), None))?
210            )
211            .into_bytes(),
212            TerminalArtifact::Cast => serialize_asciicast(asciicast)?.into_bytes(),
213            TerminalArtifact::Snapshot => render_snapshot_svg(final_frame).into_bytes(),
214            TerminalArtifact::Recording => render_recording_svg(frames).into_bytes(),
215            TerminalArtifact::Gif => render_recording_gif(rendered)?,
216        };
217        fs::write(directory.join(artifact.file_name()), contents)
218            .map_err(|error| TerminalCaptureError::new(error.to_string(), None))?;
219    }
220    Ok(())
221}