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#[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 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 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 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 pub fn fg_to_ansi256(&self) -> Option<u8> {
86 self.fg_color.and_then(|c| c.as_ansi256())
87 }
88
89 pub fn bg_to_ansi256(&self) -> Option<u8> {
91 self.bg_color.and_then(|c| c.as_ansi256())
92 }
93
94 pub fn write_to<W: Write>(&self, w: &mut W) -> io::Result<()> {
99 w.write_u32::<LittleEndian>(self.chr as u32)?;
100
101 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 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 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 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 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
205impl From<&str> for TerminalString {
207 fn from(s: &str) -> Self {
208 s.chars().map(TerminalChar::from).collect()
209 }
210}
211
212#[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 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 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 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 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 pub fn as_flat(&self) -> Vec<TerminalChar> {
279 self.pixels.clone()
280 }
281
282 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#[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 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 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 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 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 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 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 pub fn get_frame(&self, index: usize) -> Option<Vec<Vec<TerminalChar>>> {
400 self.frames.get(index).map(|s| s.as_grid())
401 }
402
403 pub fn get_frame_flat(&self, index: usize) -> Option<Vec<TerminalChar>> {
405 Some(self.frames.get(index)?.as_flat())
406 }
407
408 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}