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(¤t[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(¤t[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('<', "<")
60 .replace('>', ">")
61 .replace('"', """)
62 .replace('\'', "'")
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
176pub(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}