Skip to main content

ascii_assets/
lib.rs

1use std::{
2    fs::File,
3    io::{self, BufReader, BufWriter, Read, Write},
4};
5
6pub mod colour;
7pub use colour::Color;
8
9use byteorder::{LittleEndian, ReadBytesExt, WriteBytesExt};
10/// A single character together with optional foreground / background colours
11#[derive(Debug, PartialEq, Clone, Copy)]
12pub struct TerminalChar {
13    pub chr: char,
14    pub fg_color: Option<Color>,
15    pub bg_color: Option<Color>,
16}
17
18impl From<char> for TerminalChar {
19    fn from(chr: char) -> Self {
20        TerminalChar::from_char(chr)
21    }
22}
23
24impl From<(char, Color)> for TerminalChar {
25    fn from((chr, fg): (char, Color)) -> Self {
26        TerminalChar::with_fg(chr, fg)
27    }
28}
29
30impl TerminalChar {
31    fn default() -> Self {
32        Self {
33            chr: ' ',
34            fg_color: None,
35            bg_color: None,
36        }
37    }
38
39    pub fn from_char<C: Into<char>>(chr: C) -> Self {
40        Self {
41            chr: chr.into(),
42            fg_color: None,
43            bg_color: None,
44        }
45    }
46
47    pub fn set_fg(mut self, fg: Color) -> Self {
48        self.fg_color = Some(fg);
49        self
50    }
51
52    pub fn set_bg(mut self, bg: Color) -> Self {
53        self.bg_color = Some(bg);
54        self
55    }
56
57    /// Create a TerminalChar with foreground color.
58    pub fn with_fg<C: Into<char>>(chr: C, fg: Color) -> Self {
59        Self {
60            chr: chr.into(),
61            fg_color: Some(fg),
62            bg_color: None,
63        }
64    }
65
66    /// Create a TerminalChar with background color.
67    pub fn with_bg<C: Into<char>>(chr: C, bg: Color) -> Self {
68        Self {
69            chr: chr.into(),
70            fg_color: None,
71            bg_color: Some(bg),
72        }
73    }
74
75    /// Create a TerminalChar with both foreground and background colors.
76    pub fn with_colors<C: Into<char>>(chr: C, fg: Color, bg: Color) -> Self {
77        Self {
78            chr: chr.into(),
79            fg_color: Some(fg),
80            bg_color: Some(bg),
81        }
82    }
83
84    /// Convert the foreground colour to an ANSI-256 index if possible.
85    pub fn fg_to_ansi256(&self) -> Option<u8> {
86        self.fg_color.and_then(|c| c.as_ansi256())
87    }
88
89    /// Convert the background colour to an ANSI-256 index if possible.
90    pub fn bg_to_ansi256(&self) -> Option<u8> {
91        self.bg_color.and_then(|c| c.as_ansi256())
92    }
93
94    /// Write a character to the writer
95    ///   u32 little-endian code point
96    ///   u8 flag + 3×u8 for optional foreground RGB
97    ///   u8 flag + 3×u8 for optional background RGB
98    pub fn write_to<W: Write>(&self, w: &mut W) -> io::Result<()> {
99        w.write_u32::<LittleEndian>(self.chr as u32)?;
100
101        // Foreground colour
102        if let Some(col) = self.fg_color {
103            if !col.reset {
104                w.write_u8(1)?;
105                let (r, g, b) = col.rgb;
106                w.write_u8(r)?;
107                w.write_u8(g)?;
108                w.write_u8(b)?;
109            } else {
110                w.write_u8(0)?;
111            }
112        } else {
113            w.write_u8(0)?;
114        }
115
116        // Background colour
117        if let Some(col) = self.bg_color {
118            if !col.reset {
119                w.write_u8(1)?;
120                let (r, g, b) = col.rgb;
121                w.write_u8(r)?;
122                w.write_u8(g)?;
123                w.write_u8(b)?;
124            } else {
125                w.write_u8(0)?;
126            }
127        } else {
128            w.write_u8(0)?;
129        }
130
131        Ok(())
132    }
133
134    /// Read a character from the same binary format.
135    pub fn read_from<R: Read>(r: &mut R) -> io::Result<Self> {
136        let code = r.read_u32::<LittleEndian>()?;
137        let chr = std::char::from_u32(code).ok_or_else(|| {
138            io::Error::new(io::ErrorKind::InvalidData, "invalid Unicode scalar value")
139        })?;
140
141        // Foreground colour
142        let fg_color = if r.read_u8()? == 1 {
143            let r8 = r.read_u8()?;
144            let g8 = r.read_u8()?;
145            let b8 = r.read_u8()?;
146            Some(Color::rgb(r8, g8, b8))
147        } else {
148            None
149        };
150
151        // Background colour
152        let bg_color = if r.read_u8()? == 1 {
153            let r8 = r.read_u8()?;
154            let g8 = r.read_u8()?;
155            let b8 = r.read_u8()?;
156            Some(Color::rgb(r8, g8, b8))
157        } else {
158            None
159        };
160
161        Ok(Self {
162            chr,
163            fg_color,
164            bg_color,
165        })
166    }
167}
168
169#[derive(Debug, Clone, PartialEq)]
170pub struct TerminalString(pub Vec<TerminalChar>);
171
172impl FromIterator<TerminalChar> for TerminalString {
173    fn from_iter<I: IntoIterator<Item = TerminalChar>>(iter: I) -> Self {
174        Self(iter.into_iter().collect())
175    }
176}
177
178impl IntoIterator for TerminalString {
179    type Item = TerminalChar;
180    type IntoIter = std::vec::IntoIter<TerminalChar>;
181
182    fn into_iter(self) -> Self::IntoIter {
183        self.0.into_iter()
184    }
185}
186
187impl<'a> IntoIterator for &'a TerminalString {
188    type Item = &'a TerminalChar;
189    type IntoIter = std::slice::Iter<'a, TerminalChar>;
190
191    fn into_iter(self) -> Self::IntoIter {
192        self.0.iter()
193    }
194}
195
196impl<'a> IntoIterator for &'a mut TerminalString {
197    type Item = &'a mut TerminalChar;
198    type IntoIter = std::slice::IterMut<'a, TerminalChar>;
199
200    fn into_iter(self) -> Self::IntoIter {
201        self.0.iter_mut()
202    }
203}
204
205// Convenience: create TerminalString from a &str, all default colors
206impl From<&str> for TerminalString {
207    fn from(s: &str) -> Self {
208        s.chars().map(TerminalChar::from).collect()
209    }
210}
211
212/// A single frame (sprite) of ASCII art.
213#[derive(Debug, PartialEq, Clone)]
214pub struct AsciiSprite {
215    pub width: u16,
216    pub height: u16,
217    pub pixels: Vec<TerminalChar>,
218}
219
220impl AsciiSprite {
221    /// Create a sprite,
222    ///
223    /// ## Error
224    /// if `width * height` doesn't match with the size of the pixel-vector
225    pub fn new(width: u16, height: u16, pixels: Vec<TerminalChar>) -> io::Result<Self> {
226        if pixels.len() != (width as usize) * (height as usize) {
227            return Err(io::Error::new(
228                io::ErrorKind::InvalidInput,
229                format!(
230                    "pixel count {} does not match width*height ({})",
231                    pixels.len(),
232                    (width as usize) * (height as usize)
233                ),
234            ));
235        }
236        Ok(Self {
237            width,
238            height,
239            pixels,
240        })
241    }
242
243    /// Serialise the sprite
244    pub fn write_to<W: Write>(&self, w: &mut W) -> io::Result<()> {
245        for p in &self.pixels {
246            p.write_to(w)?;
247        }
248        Ok(())
249    }
250
251    /// Deserialise a sprite given its dimensions
252    pub fn read_from<R: Read>(r: &mut R, width: u16, height: u16) -> io::Result<Self> {
253        let mut pixels = Vec::with_capacity((width as usize) * (height as usize));
254        for _ in 0..(width as usize * height as usize) {
255            pixels.push(TerminalChar::read_from(r)?);
256        }
257        Ok(Self {
258            width,
259            height,
260            pixels,
261        })
262    }
263
264    /// Return the sprites pixel buffer as a two-dimensional grid.
265    pub fn as_grid(&self) -> Vec<Vec<TerminalChar>> {
266        let mut grid = Vec::with_capacity(self.height as usize);
267        for row in 0..self.height {
268            let mut rvec = Vec::with_capacity(self.width as usize);
269            for col in 0..self.width {
270                let idx = (row as usize) * self.width as usize + col as usize;
271                rvec.push(self.pixels[idx]);
272            }
273            grid.push(rvec);
274        }
275        grid
276    }
277    /// Return the sprites Pixel buffer as a flat vector.
278    pub fn as_flat(&self) -> Vec<TerminalChar> {
279        self.pixels.clone()
280    }
281
282    /// Get a character at the given coordinates, or ``None`` if out of bounds
283    pub fn get_char(&self, x: u16, y: u16) -> Option<TerminalChar> {
284        if x >= self.width || y >= self.height {
285            return None;
286        }
287        let idx = (y as usize) * self.width as usize + x as usize;
288        Some(self.pixels[idx])
289    }
290}
291
292/// A collection of frames that share the same dimensions.
293#[derive(Debug, PartialEq, Clone)]
294pub struct AsciiVideo {
295    pub width: u16,
296    pub height: u16,
297    pub frames: Vec<AsciiSprite>,
298}
299
300impl AsciiVideo {
301    const MAGIC: [u8; 4] = *b"ASCV";
302    const VERSION: u8 = 1;
303
304    /// Create a new video
305    pub fn new(width: u16, height: u16, frames: Vec<AsciiSprite>) -> io::Result<Self> {
306        for (i, f) in frames.iter().enumerate() {
307            if f.width != width || f.height != height {
308                return Err(io::Error::new(
309                    io::ErrorKind::InvalidInput,
310                    format!(
311                        "Frame {} has size {}x{} but expected {}x{}",
312                        i, f.width, f.height, width, height
313                    ),
314                ));
315            }
316        }
317        Ok(Self {
318            width,
319            height,
320            frames,
321        })
322    }
323
324    /// Return the number of frames and the dimensions.
325    /// (frame_count, height, width)
326    pub fn size(&self) -> (usize, usize, usize) {
327        (self.frames.len(), self.height as usize, self.width as usize)
328    }
329
330    pub fn write_to_file(&self, path: &str) -> io::Result<()> {
331        let f = File::create(path)?;
332        let mut w = BufWriter::new(f);
333
334        // Header
335        w.write_all(&Self::MAGIC)?;
336        w.write_u8(Self::VERSION)?;
337        w.write_u16::<LittleEndian>(self.width)?;
338        w.write_u16::<LittleEndian>(self.height)?;
339        w.write_u32::<LittleEndian>(self.frames.len() as u32)?;
340
341        // Frames
342        for f in &self.frames {
343            f.write_to(&mut w)?;
344        }
345
346        w.flush()
347    }
348
349    pub fn read_from_file(path: &str) -> io::Result<Self> {
350        let f = File::open(path)?;
351        let mut r = BufReader::new(f);
352
353        // Header
354        let mut magic = [0u8; 4];
355        r.read_exact(&mut magic)?;
356        if magic != Self::MAGIC {
357            return Err(io::Error::new(
358                io::ErrorKind::InvalidData,
359                "bad magic number",
360            ));
361        }
362
363        let ver = r.read_u8()?;
364        if ver != Self::VERSION {
365            return Err(io::Error::new(
366                io::ErrorKind::InvalidData,
367                format!("unsupported version {}", ver),
368            ));
369        }
370
371        let width = r.read_u16::<LittleEndian>()?;
372        let height = r.read_u16::<LittleEndian>()?;
373        let frame_count = r.read_u32::<LittleEndian>()? as usize;
374
375        if width == 0 || height == 0 || width > 4096 || height > 4096 {
376            return Err(io::Error::new(
377                io::ErrorKind::InvalidData,
378                "dimensions out of range, max 4096x4096",
379            ));
380        }
381
382        if frame_count > 100_000 {
383            return Err(io::Error::new(
384                io::ErrorKind::InvalidData,
385                format!("too many frames: {} (max {})", frame_count, 100_000),
386            ));
387        }
388
389        // frames
390        let mut frames = Vec::with_capacity(frame_count);
391        for _ in 0..frame_count {
392            frames.push(AsciiSprite::read_from(&mut r, width, height)?);
393        }
394
395        Self::new(width, height, frames)
396    }
397
398    /// Return a single frame as a two-dimensional grid.
399    pub fn get_frame(&self, index: usize) -> Option<Vec<Vec<TerminalChar>>> {
400        self.frames.get(index).map(|s| s.as_grid())
401    }
402
403    /// Return a single frame as a flat vector.
404    pub fn get_frame_flat(&self, index: usize) -> Option<Vec<TerminalChar>> {
405        Some(self.frames.get(index)?.as_flat())
406    }
407
408    /// Convert all frames to grids.    
409    ///
410    /// ### Warning
411    /// Use only when you really need a two-dimensional representation
412    /// the operation is O(n^2)
413    pub fn frames_as_grid(&self) -> Vec<Vec<Vec<TerminalChar>>> {
414        self.frames.iter().map(|s| s.as_grid()).collect()
415    }
416}
417
418#[cfg(test)]
419mod tests {
420    use super::*;
421    use rand::Rng;
422
423    #[test]
424    fn test_video_size() {
425        let pixels = vec![
426            TerminalChar {
427                chr: 'x',
428                fg_color: None,
429                bg_color: None
430            };
431            6
432        ];
433        let sprite1 = AsciiSprite::new(2, 3, pixels.clone()).unwrap();
434        let sprite2 = AsciiSprite::new(2, 3, pixels).unwrap();
435
436        let video = AsciiVideo::new(2, 3, vec![sprite1, sprite2]).unwrap();
437        assert_eq!(video.size(), (2, 3, 2));
438    }
439
440    #[test]
441    fn test_sprite_grid_access() {
442        let pixels = vec![
443            TerminalChar {
444                chr: 'a',
445                fg_color: None,
446                bg_color: None,
447            },
448            TerminalChar {
449                chr: 'b',
450                fg_color: None,
451                bg_color: None,
452            },
453            TerminalChar {
454                chr: 'c',
455                fg_color: None,
456                bg_color: None,
457            },
458            TerminalChar {
459                chr: 'd',
460                fg_color: None,
461                bg_color: None,
462            },
463        ];
464        let sprite = AsciiSprite::new(2, 2, pixels).unwrap();
465
466        let grid = sprite.as_grid();
467        assert_eq!(grid[0][0].chr, 'a');
468        assert_eq!(grid[0][1].chr, 'b');
469        assert_eq!(grid[1][0].chr, 'c');
470        assert_eq!(grid[1][1].chr, 'd');
471
472        assert_eq!(sprite.get_char(0, 0).unwrap().chr, 'a');
473        assert_eq!(sprite.get_char(1, 0).unwrap().chr, 'b');
474        assert_eq!(sprite.get_char(0, 1).unwrap().chr, 'c');
475        assert_eq!(sprite.get_char(1, 1).unwrap().chr, 'd');
476        assert_eq!(sprite.get_char(2, 0), None);
477        assert_eq!(sprite.get_char(0, 2), None);
478    }
479
480    #[test]
481    fn fuzz_terminal_char_roundtrip() {
482        let mut rng = rand::rng();
483
484        for _ in 0..1000 {
485            let u = rng.random_range(32u8..=126u8);
486            let chr = char::from(u);
487
488            let fg_color = if rng.random_bool(0.5) {
489                Some(Color::rgb(
490                    rng.random_range(0..=255),
491                    rng.random_range(0..=255),
492                    rng.random_range(0..=255),
493                ))
494            } else {
495                None
496            };
497
498            let bg_color = if rng.random_bool(0.5) {
499                Some(Color::rgb(
500                    rng.random_range(0..=255),
501                    rng.random_range(0..=255),
502                    rng.random_range(0..=255),
503                ))
504            } else {
505                None
506            };
507
508            let pc = TerminalChar {
509                chr,
510                fg_color,
511                bg_color,
512            };
513
514            let mut buf = Vec::new();
515            pc.write_to(&mut buf).unwrap();
516            let mut cur = std::io::Cursor::new(buf);
517            let pc2 = TerminalChar::read_from(&mut cur).unwrap();
518            assert_eq!(pc, pc2);
519        }
520    }
521
522    #[test]
523    fn fuzz_ascii_video_roundtrip() {
524        let mut rng = rand::rng();
525
526        for _ in 0..200 {
527            let width = rng.random_range(1u16..5);
528            let height = rng.random_range(1u16..5);
529            let mut frames = Vec::new();
530
531            for _ in 0..rng.random_range(1usize..5) {
532                let mut frame = Vec::new();
533                for _ in 0..(width * height) {
534                    let u = rng.random_range(32u8..=126u8);
535                    let chr = char::from(u);
536
537                    let fg_color = if rng.random_bool(0.5) {
538                        Some(Color::rgb(
539                            rng.random_range(0..=255),
540                            rng.random_range(0..=255),
541                            rng.random_range(0..=255),
542                        ))
543                    } else {
544                        None
545                    };
546
547                    let bg_color = if rng.random_bool(0.5) {
548                        Some(Color::rgb(
549                            rng.random_range(0..=255),
550                            rng.random_range(0..=255),
551                            rng.random_range(0..=255),
552                        ))
553                    } else {
554                        None
555                    };
556
557                    frame.push(TerminalChar {
558                        chr,
559                        fg_color,
560                        bg_color,
561                    });
562                }
563                frames.push(AsciiSprite::new(width, height, frame).unwrap());
564            }
565
566            let video = AsciiVideo {
567                width,
568                height,
569                frames,
570            };
571            let path = "test_fuzz_video.bin";
572            video.write_to_file(path).unwrap();
573            let loaded = AsciiVideo::read_from_file(path).unwrap();
574            std::fs::remove_file(path).unwrap();
575            assert_eq!(video, loaded);
576        }
577    }
578}