Skip to main content

inkling/
art.rs

1//! The immutable art model: a rectangular grid of glyphs.
2//!
3//! Whitespace is *background* (never revealed); every other glyph is *ink*.
4//! Parsing is total, any string yields valid art.
5
6/// A single glyph of the art at a grid position.
7#[derive(Clone, Copy, Debug, PartialEq, Eq)]
8pub struct Cell {
9    pub x: u16,
10    pub y: u16,
11    pub glyph: char,
12}
13
14/// A parsed piece of ASCII art: a space-padded rectangular grid of glyphs.
15#[derive(Clone, Debug)]
16pub struct Art {
17    width: u16,
18    height: u16,
19    rows: Vec<Vec<char>>,
20}
21
22impl Art {
23    /// Parse text into art. Lines are right-padded with spaces to a common
24    /// width; fully blank rows at the top and bottom are trimmed so the canvas
25    /// hugs the drawing. Interior blank rows are preserved.
26    pub fn parse(text: &str) -> Self {
27        let mut rows: Vec<Vec<char>> = text
28            .split('\n')
29            .map(|line| line.strip_suffix('\r').unwrap_or(line).chars().collect())
30            .collect();
31
32        let width = rows.iter().map(|r| r.len()).max().unwrap_or(0);
33        for r in &mut rows {
34            if r.len() < width {
35                r.resize(width, ' ');
36            }
37        }
38
39        // Trim fully-blank rows at the top and bottom in one O(rows) pass.
40        let is_blank = |r: &Vec<char>| r.iter().all(|c| c.is_whitespace());
41        let rows: Vec<Vec<char>> = match rows.iter().position(|r| !is_blank(r)) {
42            Some(first) => {
43                let last = rows.iter().rposition(|r| !is_blank(r)).unwrap();
44                rows[first..=last].to_vec()
45            }
46            None => Vec::new(), // entirely blank
47        };
48
49        Art {
50            width: width as u16,
51            height: rows.len() as u16,
52            rows,
53        }
54    }
55
56    pub fn width(&self) -> u16 {
57        self.width
58    }
59
60    pub fn height(&self) -> u16 {
61        self.height
62    }
63
64    /// The glyph at `(x, y)`, or a space if out of bounds.
65    pub fn glyph(&self, x: u16, y: u16) -> char {
66        self.rows
67            .get(y as usize)
68            .and_then(|r| r.get(x as usize))
69            .copied()
70            .unwrap_or(' ')
71    }
72
73    /// True when `(x, y)` holds a non-whitespace glyph.
74    pub fn is_ink(&self, x: u16, y: u16) -> bool {
75        !self.glyph(x, y).is_whitespace()
76    }
77
78    /// Row-major flat index of `(x, y)`.
79    #[inline]
80    pub fn index(&self, x: u16, y: u16) -> usize {
81        y as usize * self.width as usize + x as usize
82    }
83
84    /// Total grid cells (`width * height`), ink and background alike.
85    pub fn cell_count(&self) -> usize {
86        self.width as usize * self.height as usize
87    }
88
89    /// Every ink cell, in row-major order.
90    pub fn ink_cells(&self) -> impl Iterator<Item = Cell> + '_ {
91        (0..self.height).flat_map(move |y| {
92            (0..self.width).filter_map(move |x| {
93                let glyph = self.glyph(x, y);
94                (!glyph.is_whitespace()).then_some(Cell { x, y, glyph })
95            })
96        })
97    }
98
99    /// Number of ink cells.
100    pub fn ink_count(&self) -> usize {
101        self.ink_cells().count()
102    }
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108
109    #[test]
110    fn trims_outer_blank_rows_and_pads_width() {
111        let art = Art::parse("\n  ab\nc\n\n");
112        assert_eq!(art.height(), 2); // leading + trailing blanks dropped
113        assert_eq!(art.width(), 4); // widened to "  ab"
114        assert_eq!(art.glyph(2, 0), 'a');
115        assert_eq!(art.glyph(0, 1), 'c');
116        assert!(art.is_ink(2, 0));
117        assert!(!art.is_ink(0, 0)); // padding space
118    }
119
120    #[test]
121    fn ink_count_ignores_whitespace() {
122        assert_eq!(Art::parse("a b\n c ").ink_count(), 3);
123    }
124}