Skip to main content

pito_tui/
capture.rs

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}