1use std::collections::HashSet;
2use std::fmt;
3use std::fs;
4use std::io::{self, Write};
5use std::path::{Path, PathBuf};
6use std::time::Duration;
7
8use crossterm::event::KeyEvent;
9use ratatui::buffer::{Buffer, Cell};
10use ratatui::style::Modifier;
11
12use crate::dump;
13use crate::shell::Tui;
14
15const HEAD: &str = "pito-tui frame 1";
16const SIZES: [(u16, u16); 4] = [(40, 12), (80, 24), (120, 34), (200, 60)];
17
18#[derive(Debug, Clone, PartialEq, Eq)]
19#[non_exhaustive]
20pub struct Script {
21 pub name: String,
22 pub keys: Vec<KeyEvent>,
23 pub settle: bool,
24 pub at: Option<Duration>,
25}
26
27impl Script {
28 pub fn new(name: impl Into<String>, keys: impl IntoIterator<Item = KeyEvent>) -> Self {
29 Script {
30 name: name.into(),
31 keys: keys.into_iter().collect(),
32 settle: true,
33 at: None,
34 }
35 }
36
37 pub fn loading(mut self) -> Self {
38 self.settle = false;
39 self
40 }
41
42 pub fn at(mut self, at: Duration) -> Self {
43 self.at = Some(at);
44 self
45 }
46}
47
48#[derive(Debug, Clone, PartialEq, Eq)]
49#[non_exhaustive]
50pub struct Walk {
51 pub sizes: Vec<(u16, u16)>,
52 pub screens: bool,
53 pub scripts: Vec<Script>,
54}
55
56impl Walk {
57 pub fn new() -> Self {
58 Walk {
59 sizes: SIZES.to_vec(),
60 screens: true,
61 scripts: Vec::new(),
62 }
63 }
64
65 pub fn sizes(mut self, sizes: impl IntoIterator<Item = (u16, u16)>) -> Self {
66 self.sizes = sizes.into_iter().collect();
67 self
68 }
69
70 pub fn screens(mut self, screens: bool) -> Self {
71 self.screens = screens;
72 self
73 }
74
75 pub fn script(mut self, script: Script) -> Self {
76 self.scripts.push(script);
77 self
78 }
79}
80
81impl Default for Walk {
82 fn default() -> Self {
83 Walk::new()
84 }
85}
86
87#[derive(Debug, Clone, Default, PartialEq, Eq)]
88#[non_exhaustive]
89pub struct Look {
90 pub symbol: String,
91 pub fg: String,
92 pub bg: String,
93 pub modifier: u16,
94}
95
96impl fmt::Display for Look {
97 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
98 let modifier = Modifier::from_bits_truncate(self.modifier);
99 write!(f, "{:?} {} {} {modifier:?}", self.symbol, self.fg, self.bg)
100 }
101}
102
103#[derive(Debug, Clone, PartialEq, Eq)]
104#[non_exhaustive]
105pub enum Difference {
106 Cell {
107 scenario: String,
108 size: (u16, u16),
109 x: u16,
110 y: u16,
111 before: Look,
112 after: Look,
113 },
114 Added {
115 scenario: String,
116 size: (u16, u16),
117 },
118 Removed {
119 scenario: String,
120 size: (u16, u16),
121 },
122}
123
124impl fmt::Display for Difference {
125 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
126 match self {
127 Difference::Cell {
128 scenario,
129 size: (width, height),
130 x,
131 y,
132 before,
133 after,
134 } => write!(f, "{scenario} {width}x{height} {x},{y}: {before} → {after}"),
135 Difference::Added {
136 scenario,
137 size: (width, height),
138 } => write!(f, "{scenario} {width}x{height}: +"),
139 Difference::Removed {
140 scenario,
141 size: (width, height),
142 } => write!(f, "{scenario} {width}x{height}: -"),
143 }
144 }
145}
146
147enum Run {
148 Screen(usize),
149 Script(usize),
150}
151
152fn slug(name: &str) -> String {
153 let mut out = String::new();
154 for c in name.chars().flat_map(char::to_lowercase) {
155 if c.is_alphanumeric() {
156 out.push(c);
157 } else if !out.is_empty() && !out.ends_with('-') {
158 out.push('-');
159 }
160 }
161 out.trim_end_matches('-').to_string()
162}
163
164fn screen(index: usize, name: &str) -> String {
165 format!("{index:02}-{}", slug(name))
166}
167
168fn file((width, height): (u16, u16)) -> String {
169 format!("{width}x{height}")
170}
171
172fn runs<E: Send + 'static>(walk: &Walk, app: &dyn Fn() -> Tui<E>) -> Vec<(String, Run)> {
173 let mut runs = Vec::new();
174 if walk.screens {
175 let tui = app();
176 for (index, name) in tui.names().into_iter().enumerate() {
177 if tui.walkable(index) {
178 runs.push((screen(index, name), Run::Screen(index)));
179 }
180 }
181 }
182 for (index, script) in walk.scripts.iter().enumerate() {
183 runs.push((slug(&script.name), Run::Script(index)));
184 }
185 runs
186}
187
188fn render<E: Send + 'static>(
189 app: &dyn Fn() -> Tui<E>,
190 walk: &Walk,
191 run: &Run,
192 (width, height): (u16, u16),
193) -> Buffer {
194 let mut tui = app();
195 match run {
196 Run::Screen(index) => {
197 tui.reveal();
198 tui.shot(width, height, &[], true);
199 if tui.go(*index) {
200 tui.settle();
201 }
202 tui.frame(width, height)
203 }
204 Run::Script(index) => {
205 let script = &walk.scripts[*index];
206 let buffer = tui.shot(width, height, &script.keys, script.settle);
207 let Some(at) = script.at else {
208 return buffer;
209 };
210 tui.advance(at);
211 if script.settle {
212 tui.settle();
213 }
214 tui.frame(width, height)
215 }
216 }
217}
218
219fn escape(symbol: &str) -> String {
220 let mut out = String::with_capacity(symbol.len());
221 for c in symbol.chars() {
222 match c {
223 '\\' => out.push_str("\\\\"),
224 '\t' => out.push_str("\\t"),
225 '\n' => out.push_str("\\n"),
226 '\r' => out.push_str("\\r"),
227 c => out.push(c),
228 }
229 }
230 out
231}
232
233fn unescape(text: &str) -> String {
234 let mut out = String::with_capacity(text.len());
235 let mut chars = text.chars();
236 while let Some(c) = chars.next() {
237 if c != '\\' {
238 out.push(c);
239 continue;
240 }
241 match chars.next() {
242 Some('t') => out.push('\t'),
243 Some('n') => out.push('\n'),
244 Some('r') => out.push('\r'),
245 Some(other) => out.push(other),
246 None => out.push('\\'),
247 }
248 }
249 out
250}
251
252fn style(cell: &Cell) -> String {
253 format!("{} {} {}", cell.fg, cell.bg, cell.modifier.bits())
254}
255
256fn encode(buffer: &Buffer) -> String {
257 let area = buffer.area;
258 let mut out = format!("{HEAD}\n{} {}\n", area.width, area.height);
259 for y in area.top()..area.bottom() {
260 let cells: Vec<&Cell> = (area.left()..area.right())
261 .map(|x| &buffer[(x, y)])
262 .collect();
263 let symbols: Vec<String> = cells.iter().map(|cell| escape(cell.symbol())).collect();
264 out.push_str(&symbols.join("\t"));
265 out.push('\n');
266 let mut runs: Vec<(usize, String)> = Vec::new();
267 for cell in &cells {
268 let look = style(cell);
269 match runs.last_mut() {
270 Some((count, last)) if *last == look => *count += 1,
271 _ => runs.push((1, look)),
272 }
273 }
274 let runs: Vec<String> = runs
275 .into_iter()
276 .map(|(count, look)| format!("{count} {look}"))
277 .collect();
278 out.push_str(&runs.join("\t"));
279 out.push('\n');
280 }
281 out
282}
283
284fn broken(why: &str) -> io::Error {
285 io::Error::new(io::ErrorKind::InvalidData, why.to_string())
286}
287
288struct Frame {
289 width: u16,
290 height: u16,
291 cells: Vec<Look>,
292}
293
294fn decode(text: &str) -> io::Result<Frame> {
295 let mut lines = text.lines();
296 if lines.next() != Some(HEAD) {
297 return Err(broken(HEAD));
298 }
299 let size = lines.next().ok_or_else(|| broken(HEAD))?;
300 let (width, height) = size
301 .split_once(' ')
302 .and_then(|(width, height)| Some((width.parse().ok()?, height.parse().ok()?)))
303 .ok_or_else(|| broken(size))?;
304 let mut cells = Vec::with_capacity(usize::from(width) * usize::from(height));
305 for _ in 0..height {
306 let symbols = lines.next().ok_or_else(|| broken(size))?;
307 let runs = lines.next().ok_or_else(|| broken(size))?;
308 let mut row: Vec<Look> = symbols
309 .split('\t')
310 .map(|symbol| Look {
311 symbol: unescape(symbol),
312 ..Look::default()
313 })
314 .collect();
315 let mut x = 0;
316 for run in runs.split('\t').filter(|run| !run.is_empty()) {
317 let mut parts = run.split(' ');
318 let (Some(count), Some(fg), Some(bg), Some(modifier)) =
319 (parts.next(), parts.next(), parts.next(), parts.next())
320 else {
321 return Err(broken(run));
322 };
323 let count: usize = count.parse().map_err(|_| broken(run))?;
324 let modifier: u16 = modifier.parse().map_err(|_| broken(run))?;
325 for look in row.iter_mut().skip(x).take(count) {
326 look.fg = fg.to_string();
327 look.bg = bg.to_string();
328 look.modifier = modifier;
329 }
330 x += count;
331 }
332 row.resize(usize::from(width), Look::default());
333 cells.extend(row);
334 }
335 Ok(Frame {
336 width,
337 height,
338 cells,
339 })
340}
341
342fn cells(scenario: &str, size: (u16, u16), before: &Frame, after: &Frame) -> Vec<Difference> {
343 let width = before.width.max(after.width);
344 let height = before.height.max(after.height);
345 let look = |frame: &Frame, x: u16, y: u16| {
346 if x >= frame.width || y >= frame.height {
347 return Look::default();
348 }
349 frame.cells[usize::from(y) * usize::from(frame.width) + usize::from(x)].clone()
350 };
351 let mut out = Vec::new();
352 for y in 0..height {
353 for x in 0..width {
354 let (was, now) = (look(before, x, y), look(after, x, y));
355 if was != now {
356 out.push(Difference::Cell {
357 scenario: scenario.to_string(),
358 size,
359 x,
360 y,
361 before: was,
362 after: now,
363 });
364 }
365 }
366 }
367 out
368}
369
370struct Saved {
371 scenario: String,
372 size: (u16, u16),
373 path: PathBuf,
374}
375
376fn frames(dir: &Path) -> io::Result<Vec<Saved>> {
377 let mut out = Vec::new();
378 for folder in fs::read_dir(dir)? {
379 let folder = folder?.path();
380 if !folder.is_dir() {
381 continue;
382 }
383 let scenario = folder
384 .file_name()
385 .map(|name| name.to_string_lossy().into_owned())
386 .unwrap_or_default();
387 for frame in fs::read_dir(&folder)? {
388 let path = frame?.path();
389 let size = path
390 .file_name()
391 .and_then(|name| dump::size(&name.to_string_lossy()));
392 if let Some(size) = size {
393 let scenario = scenario.clone();
394 out.push(Saved {
395 scenario,
396 size,
397 path,
398 });
399 }
400 }
401 }
402 out.sort_by(|a, b| (&a.scenario, a.size).cmp(&(&b.scenario, b.size)));
403 Ok(out)
404}
405
406#[derive(Debug)]
407struct Against {
408 dir: PathBuf,
409 seen: HashSet<PathBuf>,
410 differences: Vec<Difference>,
411}
412
413impl Against {
414 fn new(dir: &Path) -> Self {
415 Against {
416 dir: dir.to_path_buf(),
417 seen: HashSet::new(),
418 differences: Vec::new(),
419 }
420 }
421
422 fn path(&self, scenario: &str, size: (u16, u16)) -> PathBuf {
423 self.dir.join(scenario).join(file(size))
424 }
425
426 fn frame(&mut self, scenario: &str, size: (u16, u16), after: &Frame) -> io::Result<()> {
427 let path = self.path(scenario, size);
428 match fs::read_to_string(&path) {
429 Ok(text) => {
430 let before = decode(&text)?;
431 self.differences
432 .extend(cells(scenario, size, &before, after));
433 }
434 Err(error) if error.kind() == io::ErrorKind::NotFound => {
435 let scenario = scenario.to_string();
436 self.differences.push(Difference::Added { scenario, size });
437 }
438 Err(error) => return Err(error),
439 }
440 self.seen.insert(path);
441 Ok(())
442 }
443
444 fn finish(mut self) -> io::Result<Vec<Difference>> {
445 let mut removed: Vec<Difference> = frames(&self.dir)?
446 .into_iter()
447 .filter(|saved| !self.seen.contains(&saved.path))
448 .map(|Saved { scenario, size, .. }| Difference::Removed { scenario, size })
449 .collect();
450 removed.sort_by_key(ToString::to_string);
451 self.differences.extend(removed);
452 Ok(self.differences)
453 }
454}
455
456fn fresh(dir: &Path) -> io::Result<()> {
457 if dir.exists() && fs::read_dir(dir)?.next().is_some() {
458 let taken = dir.display().to_string();
459 return Err(io::Error::new(io::ErrorKind::AlreadyExists, taken));
460 }
461 Ok(())
462}
463
464#[derive(Debug)]
465pub struct Recorder {
466 dir: PathBuf,
467 frames: usize,
468 against: Option<Against>,
469}
470
471impl Recorder {
472 pub fn new(dir: impl Into<PathBuf>) -> io::Result<Self> {
473 let dir = dir.into();
474 fresh(&dir)?;
475 fs::create_dir_all(&dir)?;
476 Ok(Recorder {
477 dir,
478 frames: 0,
479 against: None,
480 })
481 }
482
483 pub fn against(dir: impl Into<PathBuf>) -> io::Result<Self> {
484 let dir = dir.into();
485 fs::read_dir(&dir)?;
486 Ok(Recorder {
487 against: Some(Against::new(&dir)),
488 dir,
489 frames: 0,
490 })
491 }
492
493 pub fn screen(&mut self, index: usize, name: &str, buffer: &Buffer) -> io::Result<()> {
494 self.write(&screen(index, name), buffer)
495 }
496
497 pub fn record(&mut self, name: &str, buffer: &Buffer) -> io::Result<()> {
498 self.write(&slug(name), buffer)
499 }
500
501 pub fn frames(&self) -> usize {
502 self.frames
503 }
504
505 pub fn finish(self) -> io::Result<Vec<Difference>> {
506 self.against.map_or(Ok(Vec::new()), Against::finish)
507 }
508
509 fn write(&mut self, scenario: &str, buffer: &Buffer) -> io::Result<()> {
510 let size = (buffer.area.width, buffer.area.height);
511 if let Some(against) = &mut self.against {
512 let path = against.path(scenario, size);
513 if against.seen.contains(&path) {
514 let taken = path.display().to_string();
515 return Err(io::Error::new(io::ErrorKind::AlreadyExists, taken));
516 }
517 against.frame(scenario, size, &decode(&encode(buffer))?)?;
518 self.frames += 1;
519 return Ok(());
520 }
521 let folder = self.dir.join(scenario);
522 fs::create_dir_all(&folder)?;
523 fs::OpenOptions::new()
524 .write(true)
525 .create_new(true)
526 .open(folder.join(file(size)))?
527 .write_all(encode(buffer).as_bytes())?;
528 self.frames += 1;
529 Ok(())
530 }
531}
532
533pub fn capture<E: Send + 'static>(
534 dir: &Path,
535 walk: &Walk,
536 app: impl Fn() -> Tui<E>,
537) -> io::Result<usize> {
538 fresh(dir)?;
539 let mut frames = 0;
540 for (scenario, run) in runs(walk, &app) {
541 let folder = dir.join(&scenario);
542 fs::create_dir_all(&folder)?;
543 for &size in &walk.sizes {
544 let buffer = render(&app, walk, &run, size);
545 fs::write(folder.join(file(size)), encode(&buffer))?;
546 frames += 1;
547 }
548 }
549 Ok(frames)
550}
551
552pub fn compare<E: Send + 'static>(
553 dir: &Path,
554 walk: &Walk,
555 app: impl Fn() -> Tui<E>,
556) -> io::Result<Vec<Difference>> {
557 let mut against = Against::new(dir);
558 for (scenario, run) in runs(walk, &app) {
559 for &size in &walk.sizes {
560 let after = decode(&encode(&render(&app, walk, &run, size)))?;
561 against.frame(&scenario, size, &after)?;
562 }
563 }
564 against.finish()
565}
566
567pub fn compare_dirs(before: &Path, after: &Path) -> io::Result<Vec<Difference>> {
568 fs::read_dir(before)?;
569 let mut against = Against::new(before);
570 for saved in frames(after)? {
571 let frame = decode(&fs::read_to_string(&saved.path)?)?;
572 against.frame(&saved.scenario, saved.size, &frame)?;
573 }
574 against.finish()
575}