1use 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
13pub 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 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
89fn render_frame(frame: &CapturedFrame, current_style: &mut Option<Style>) -> String {
93 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
126fn 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); }
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 #[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 let interval = event[0].as_f64().unwrap();
244 assert!((0.0..1.0).contains(&interval), "interval was {interval}");
245 }
246}