1pub const MAX_DIM: usize = u16::MAX as usize;
9
10const TAB_WIDTH: usize = 8;
12
13#[derive(Clone, Copy, Debug, PartialEq, Eq)]
15pub struct Cell {
16 pub x: u16,
17 pub y: u16,
18 pub glyph: char,
19}
20
21#[derive(Clone, Debug, Default)]
23pub struct Art {
24 width: u16,
25 height: u16,
26 rows: Vec<Vec<char>>,
27}
28
29impl Art {
30 pub fn parse(text: &str) -> Self {
38 let mut rows: Vec<Vec<char>> = text
39 .split('\n')
40 .map(|line| expand_tabs(line.strip_suffix('\r').unwrap_or(line)))
41 .collect();
42
43 rows.truncate(MAX_DIM);
45 let width = rows.iter().map(|r| r.len()).max().unwrap_or(0).min(MAX_DIM);
46 for r in &mut rows {
47 r.truncate(width);
48 r.resize(width, ' ');
49 }
50
51 let ink_span = |r: &Vec<char>| {
56 let first = r.iter().position(|c| !c.is_whitespace())?;
57 let last = r.iter().rposition(|c| !c.is_whitespace())?;
58 Some((first, last))
59 };
60 let spans: Vec<Option<(usize, usize)>> = rows.iter().map(ink_span).collect();
61
62 let (rows, width) = match spans.iter().position(|s| s.is_some()) {
63 Some(top) => {
64 let bottom = spans.iter().rposition(|s| s.is_some()).unwrap();
65 let live = &spans[top..=bottom];
66 let left = live.iter().flatten().map(|(l, _)| *l).min().unwrap();
67 let right = live.iter().flatten().map(|(_, r)| *r).max().unwrap();
68 let cropped: Vec<Vec<char>> = rows[top..=bottom]
69 .iter()
70 .map(|r| r[left..=right].to_vec())
71 .collect();
72 (cropped, right - left + 1)
73 }
74 None => (Vec::new(), 0),
76 };
77
78 Art {
79 width: width as u16,
80 height: rows.len() as u16,
81 rows,
82 }
83 }
84
85 pub fn width(&self) -> u16 {
86 self.width
87 }
88
89 pub fn height(&self) -> u16 {
90 self.height
91 }
92
93 pub fn is_empty(&self) -> bool {
95 self.width == 0 || self.height == 0
96 }
97
98 pub fn glyph(&self, x: u16, y: u16) -> char {
100 if x >= self.width {
101 return ' ';
102 }
103 self.rows
104 .get(y as usize)
105 .and_then(|r| r.get(x as usize))
106 .copied()
107 .unwrap_or(' ')
108 }
109
110 pub fn is_ink(&self, x: u16, y: u16) -> bool {
112 !self.glyph(x, y).is_whitespace()
113 }
114
115 #[inline]
117 pub fn index(&self, x: u16, y: u16) -> usize {
118 y as usize * self.width as usize + x as usize
119 }
120
121 pub fn cell_count(&self) -> usize {
123 self.width as usize * self.height as usize
124 }
125
126 pub fn ink_cells(&self) -> impl Iterator<Item = Cell> + '_ {
128 (0..self.height).flat_map(move |y| {
129 (0..self.width).filter_map(move |x| {
130 let glyph = self.glyph(x, y);
131 (!glyph.is_whitespace()).then_some(Cell { x, y, glyph })
132 })
133 })
134 }
135
136 pub fn ink_count(&self) -> usize {
138 self.ink_cells().count()
139 }
140}
141
142fn expand_tabs(line: &str) -> Vec<char> {
146 let mut out = Vec::with_capacity(line.len());
147 for c in line.chars() {
148 if c == '\t' {
149 let stop = (out.len() / TAB_WIDTH + 1) * TAB_WIDTH;
150 out.resize(stop, ' ');
151 } else if c.is_control() {
152 out.push(' ');
157 } else {
158 out.push(c);
159 }
160 }
161 out
162}
163
164#[cfg(test)]
165mod tests {
166 use super::*;
167
168 #[test]
169 fn crops_to_the_ink_bounding_box() {
170 let art = Art::parse("\n ab\n c\n\n");
171 assert_eq!(art.height(), 2); assert_eq!(art.width(), 3); assert_eq!(art.glyph(1, 0), 'a');
174 assert_eq!(art.glyph(0, 1), 'c');
175 assert!(art.is_ink(1, 0));
176 assert!(!art.is_ink(0, 0)); }
178
179 #[test]
180 fn ink_count_ignores_whitespace() {
181 assert_eq!(Art::parse("a b\n c ").ink_count(), 3);
182 }
183
184 #[test]
187 fn padding_does_not_change_the_canvas() {
188 let bare = Art::parse("##\n##");
189 let padded = Art::parse(" ## \n ## ");
190 assert_eq!((bare.width(), bare.height()), (2, 2));
191 assert_eq!((padded.width(), padded.height()), (2, 2));
192 }
193
194 #[test]
195 fn interior_blanks_are_preserved() {
196 let art = Art::parse("# #\n\n# #");
197 assert_eq!((art.width(), art.height()), (4, 3));
198 assert!(!art.is_ink(1, 0));
199 assert_eq!(art.ink_count(), 4);
200 }
201
202 #[test]
203 fn blank_input_is_an_empty_canvas() {
204 for text in ["", " \n ", "\n\n\n"] {
205 let art = Art::parse(text);
206 assert!(art.is_empty(), "{text:?} should parse empty");
207 assert_eq!((art.width(), art.height()), (0, 0));
208 assert_eq!(art.cell_count(), 0);
209 }
210 }
211
212 #[test]
213 fn tabs_expand_to_eight_column_stops() {
214 let art = Art::parse("a\tb");
215 assert_eq!(art.width(), 9); assert_eq!(art.glyph(0, 0), 'a');
217 assert_eq!(art.glyph(8, 0), 'b');
218 assert_eq!(art.ink_count(), 2);
219 }
220
221 #[test]
224 fn control_characters_are_background() {
225 let art = Art::parse("a\x1b[2Jb");
226 assert!(!art.glyph(1, 0).is_control());
227 assert!(!art.is_ink(1, 0));
228 assert_eq!(art.glyph(0, 0), 'a');
229 assert_eq!(art.width(), 6);
231 assert_eq!(art.ink_count(), 5);
232 }
233
234 #[test]
237 fn oversized_input_is_cropped_not_wrapped() {
238 let art = Art::parse(&"#".repeat(MAX_DIM + 5_000));
239 assert_eq!(art.width() as usize, MAX_DIM);
240 assert_eq!(art.glyph(art.width() - 1, 0), '#');
241 }
242
243 #[test]
244 fn glyph_is_a_space_out_of_bounds() {
245 let art = Art::parse("ab\ncd");
246 assert_eq!(art.glyph(9, 0), ' '); assert_eq!(art.glyph(0, 9), ' ');
248 assert!(!art.is_ink(9, 0));
249 }
250}