Skip to main content

retroglyph_recorder/
cast.rs

1//! [`write_cast`]: exports captured frames as asciicast v3 newline-delimited JSON.
2
3use crate::OwnedCell;
4use crate::frame_recorder::CapturedFrame;
5use retroglyph_core::color::Style;
6use retroglyph_core::color::sgr::push_sgr;
7use retroglyph_core::grid::{HasSize, Size};
8use retroglyph_core::tile::TileFlags;
9use std::collections::BTreeMap;
10use std::fmt::Write as _;
11use std::io::{self, Write};
12
13/// Exports `frames` as [asciicast v3](https://docs.asciinema.org/manual/asciicast/v3/)
14/// newline-delimited JSON, written to `writer`.
15///
16/// `frames` comes from [`FrameRecorder`](crate::FrameRecorder) or the `pty` feature's capture
17/// source. Writes a header line (`{"version": 3, "term": {"cols", "rows"}}`) sized to `size`, then one
18/// `[interval_seconds, "o", ansi_text]` event line per captured frame: `interval_seconds` is the
19/// time since the previous event (since [`FrameRecorder::new`](crate::FrameRecorder::new) for the
20/// first), and `ansi_text` repositions the cursor to
21/// each changed cell (`\x1b[{row};{col}H`, grouping horizontally-adjacent runs to avoid a move
22/// per cell) and writes its glyph, switching [SGR](retroglyph_core::color::sgr) state only when
23/// the style actually changes -- exactly what a real terminal session emitting that frame would
24/// have sent, and exactly what an asciicast player (`asciinema-player`) or converter (`agg`,
25/// `svg-term`) expects to replay.
26///
27/// Hand-rolled (via `serde_json`, not the `asciicast`/`asciicast-rs` crates) because this crate
28/// only ever *writes* v3, never parses any version -- a parser's surface would be pure dead
29/// weight here.
30///
31/// No `timestamp` header field is emitted: it would be wall-clock-dependent, and this exists for
32/// deterministic, `insta`-snapshottable captures.
33///
34/// # Known limitation
35///
36/// Buffers and serializes every frame at once rather than streaming events to `writer` as they're
37/// captured, so a session's full cost (memory in [`FrameRecorder`](crate::FrameRecorder), then
38/// this function's formatting work) is paid up front. Matches
39/// [`FrameRecorder`](crate::FrameRecorder)'s own whole-session-in-memory limitation; fine for
40/// docs/demo-length captures.
41///
42/// # Errors
43///
44/// Returns an error if `writer` fails to write.
45///
46/// # Examples
47///
48/// ```
49/// use retroglyph_core::backend::{DrawCell, Headless, Output};
50/// use retroglyph_core::color::Style;
51/// use retroglyph_core::grid::Pos;
52/// use retroglyph_core::tile::Tile;
53/// use retroglyph_recorder::{write_cast, FrameRecorder};
54///
55/// let mut recorder = FrameRecorder::new(Headless::new(3, 1));
56/// let tile = Tile::new('a', Style::default());
57/// recorder
58///     .draw_layers(std::iter::once(DrawCell::new(Pos::new(0, 0), &tile)))
59///     .unwrap();
60///
61/// let mut out = Vec::new();
62/// write_cast(&mut out, recorder.inner().size(), &recorder.frames()).unwrap();
63/// let text = String::from_utf8(out).unwrap();
64/// assert!(text.contains("\"version\":3"));
65/// assert!(text.contains("\"o\""));
66/// ```
67pub fn write_cast(writer: &mut impl Write, size: Size, frames: &[CapturedFrame]) -> io::Result<()> {
68    let header = serde_json::json!({
69        "version": 3,
70        "term": { "cols": size.width(), "rows": size.height() },
71    });
72    writeln!(writer, "{header}")?;
73
74    let mut previous_at = std::time::Duration::ZERO;
75    // Persists across frames, not reset per-frame: this mirrors what a real terminal session
76    // emitting these events would see -- SGR state is a property of the terminal's pen, not of
77    // any single write.
78    let mut current_style: Option<Style> = None;
79    for frame in frames {
80        let interval = frame.at.saturating_sub(previous_at).as_secs_f64();
81        previous_at = frame.at;
82        let ansi = render_frame(frame, &mut current_style);
83        let line = serde_json::to_string(&(interval, "o", ansi))?;
84        writeln!(writer, "{line}")?;
85    }
86    Ok(())
87}
88
89/// Renders one frame's changed cells as raw ANSI: cursor moves plus SGR-styled text, in row-major
90/// order. `current_style` carries SGR state in from (and out to) the previous frame so a run that
91/// happens to already match the active style emits no redundant escape.
92fn render_frame(frame: &CapturedFrame, current_style: &mut Option<Style>) -> String {
93    // Last write wins for a position hit more than once in the same `draw_layers` call (multiple
94    // layers landing on the same cell): a text export has no layer compositing of its own to fall
95    // back on, so it shows whatever the stream's own last word on that cell was.
96    let mut by_pos: BTreeMap<(u16, u16), &OwnedCell> = BTreeMap::new();
97    for cell in &frame.cells {
98        by_pos.insert((cell.pos.y, cell.pos.x), cell);
99    }
100
101    let mut out = String::new();
102    let mut cursor_row = None;
103    let mut cursor_col = None;
104    for ((row, col), cell) in &by_pos {
105        if cursor_row != Some(*row) || cursor_col != Some(*col) {
106            let _ = write!(out, "\x1b[{};{}H", row + 1, col + 1);
107        }
108        let (text, is_spacer) = display_text(cell);
109        let style = if is_spacer {
110            Style::default()
111        } else {
112            cell.tile.style()
113        };
114        if *current_style != Some(style) {
115            out.push_str("\x1b[0m");
116            push_sgr(&mut out, style);
117            *current_style = Some(style);
118        }
119        out.push_str(&text);
120        cursor_row = Some(*row);
121        cursor_col = Some(col + 1);
122    }
123    out
124}
125
126/// The text `render_frame` writes for `cell`, and whether it's a wide-glyph spacer (rendered
127/// blank, with no style of its own -- matching
128/// [`Headless::display_glyph`](retroglyph_core::backend::Headless)'s convention for the same
129/// case).
130fn display_text(cell: &OwnedCell) -> (String, bool) {
131    let is_spacer = cell.tile.flags().contains(TileFlags::WIDE_CHAR_SPACER);
132    if is_spacer {
133        return (" ".to_owned(), true);
134    }
135    let text = cell
136        .grapheme
137        .clone()
138        .unwrap_or_else(|| cell.tile.glyph().to_string());
139    (text, false)
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145    use crate::FrameRecorder;
146    use retroglyph_core::backend::{DrawCell, Headless, Output};
147    use retroglyph_core::color::Color;
148    use retroglyph_core::grid::Pos;
149    use retroglyph_core::tile::Tile;
150
151    #[test]
152    fn header_carries_the_backend_size() {
153        let recorder = FrameRecorder::new(Headless::new(7, 4));
154        let mut out = Vec::new();
155        write_cast(&mut out, recorder.inner().size(), &recorder.frames()).unwrap();
156        let text = String::from_utf8(out).unwrap();
157        let header: serde_json::Value = serde_json::from_str(text.lines().next().unwrap()).unwrap();
158        assert_eq!(header["version"], 3);
159        assert_eq!(header["term"]["cols"], 7);
160        assert_eq!(header["term"]["rows"], 4);
161    }
162
163    #[test]
164    fn one_event_line_per_captured_frame() {
165        let mut recorder = FrameRecorder::new(Headless::new(3, 1));
166        let tile = Tile::new('a', Style::default());
167        recorder
168            .draw_layers(std::iter::once(DrawCell::new(Pos::new(0, 0), &tile)))
169            .unwrap();
170        recorder
171            .draw_layers(std::iter::once(DrawCell::new(Pos::new(1, 0), &tile)))
172            .unwrap();
173
174        let mut out = Vec::new();
175        write_cast(&mut out, recorder.inner().size(), &recorder.frames()).unwrap();
176        let text = String::from_utf8(out).unwrap();
177        assert_eq!(text.lines().count(), 3); // header + 2 events
178    }
179
180    #[test]
181    fn emits_sgr_for_a_styled_cell() {
182        let mut recorder = FrameRecorder::new(Headless::new(3, 1));
183        let tile = Tile::new('a', Style::new().fg(Color::RED));
184        recorder
185            .draw_layers(std::iter::once(DrawCell::new(Pos::new(0, 0), &tile)))
186            .unwrap();
187
188        let mut out = Vec::new();
189        write_cast(&mut out, recorder.inner().size(), &recorder.frames()).unwrap();
190        let text = String::from_utf8(out).unwrap();
191        assert!(text.contains("\\u001b[0m\\u001b[31m") || text.contains("\\u001b[31m"));
192    }
193
194    /// Snapshot of a small, multi-frame, multi-style capture -- built directly from
195    /// [`CapturedFrame`]s with fixed `at` durations (rather than a live [`FrameRecorder`], whose
196    /// timestamps come from the wall clock) so the exported `.cast` text is fully deterministic
197    /// and diffable across runs.
198    #[test]
199    fn write_cast_snapshot() {
200        use retroglyph_core::grid::Pos;
201        use std::time::Duration;
202
203        let cell = |x: u16, y: u16, glyph: char, style: Style| OwnedCell {
204            layer: 0,
205            pos: Pos::new(x, y),
206            tile: Tile::new(glyph, style),
207            grapheme: None,
208            tint: retroglyph_core::color::Tint::None,
209        };
210        let frames = vec![
211            CapturedFrame {
212                at: Duration::from_millis(0),
213                cells: vec![
214                    cell(0, 0, 'H', Style::new().fg(Color::GREEN)),
215                    cell(1, 0, 'i', Style::new().fg(Color::GREEN)),
216                ],
217            },
218            CapturedFrame {
219                at: Duration::from_millis(250),
220                cells: vec![cell(0, 1, '!', Style::default())],
221            },
222        ];
223
224        let mut out = Vec::new();
225        write_cast(&mut out, Size::new(10, 2), &frames).unwrap();
226        insta::assert_snapshot!(String::from_utf8(out).unwrap());
227    }
228
229    #[test]
230    fn first_event_interval_is_time_since_the_recorder_was_created() {
231        let mut recorder = FrameRecorder::new(Headless::new(3, 1));
232        let tile = Tile::new('a', Style::default());
233        recorder
234            .draw_layers(std::iter::once(DrawCell::new(Pos::new(0, 0), &tile)))
235            .unwrap();
236
237        let mut out = Vec::new();
238        write_cast(&mut out, recorder.inner().size(), &recorder.frames()).unwrap();
239        let text = String::from_utf8(out).unwrap();
240        let event: serde_json::Value = serde_json::from_str(text.lines().nth(1).unwrap()).unwrap();
241        // Should be a small, non-negative elapsed time -- not exactly zero, since some real time
242        // passes between `FrameRecorder::new` and the first `draw_layers` call.
243        let interval = event[0].as_f64().unwrap();
244        assert!((0.0..1.0).contains(&interval), "interval was {interval}");
245    }
246}