Skip to main content

max / alloy

8.6 KB · 262 lines History Blame Raw
1 //! The cell grid and the diff that reaches the terminal.
2 //!
3 //! NO COLOUR, which is inherited from `usr/bin/alloy-backdrop` and is the rule
4 //! that matters most here. The backdrop emits SGR weight only — dim, normal,
5 //! bold — and shop resolves those out of the theme named in
6 //! `~/.config/shop/config.toml`, so `alloy theme apply` moves the animation and
7 //! the terminal together and no palette is ever spelled out in this repo
8 //! (docs/TOKENS.md: no hex outside the theme files). A backdrop that picked its
9 //! own green would also be the loudest thing on a screen whose whole design
10 //! language reserves colour for information (docs/DESIGN-LANGUAGE.md).
11 //!
12 //! THE GLYPHS ARE THE TILING TIER and nothing else: `░` (U+2591), `█` (U+2588)
13 //! and the two box-drawing diagonals `╱ ╲` (U+2571, U+2572). The first two are
14 //! in Alloy's eighteen; both diagonals come from quasi-type's generated
15 //! box-drawing block, which is cut cell-exact for the whole of U+2500-U+257F
16 //! precisely so it tiles without seams. Nothing here reaches for braille,
17 //! sextants or octants: docs/FONTS.md measures those as absent from the house
18 //! face, so a sub-cell canvas would draw tofu on every desktop.
19 //!
20 //! The diff is per row. Life and Langton's ant change a handful of cells per
21 //! frame and repainting the whole surface for them would be the difference
22 //! between a background that costs nothing and one that shows up in a power
23 //! measurement.
24
25 use std::io::{self, Write};
26
27 #[derive(Clone, Copy, PartialEq, Eq, Debug)]
28 pub(crate) enum Weight {
29 Dim,
30 Normal,
31 Bold,
32 }
33
34 #[derive(Clone, Copy, PartialEq, Eq, Debug)]
35 pub(crate) struct Cell {
36 pub(crate) glyph: char,
37 pub(crate) weight: Weight,
38 }
39
40 impl Cell {
41 pub(crate) const BLANK: Cell = Cell {
42 glyph: ' ',
43 weight: Weight::Normal,
44 };
45
46 pub(crate) fn dim(glyph: char) -> Self {
47 Self {
48 glyph,
49 weight: Weight::Dim,
50 }
51 }
52
53 pub(crate) fn bold(glyph: char) -> Self {
54 Self {
55 glyph,
56 weight: Weight::Bold,
57 }
58 }
59 }
60
61 pub(crate) struct Screen {
62 rows: usize,
63 cols: usize,
64 /// What the pattern has just drawn.
65 back: Vec<Cell>,
66 /// What the terminal is believed to be showing. Emptied by `invalidate`,
67 /// which is what a resize and a return from the legend both need: after
68 /// either, nothing about the previous contents is still true.
69 front: Vec<Cell>,
70 out: String,
71 }
72
73 impl Screen {
74 pub(crate) fn new(rows: usize, cols: usize) -> Self {
75 Self {
76 rows,
77 cols,
78 back: vec![Cell::BLANK; rows * cols],
79 front: Vec::new(),
80 out: String::new(),
81 }
82 }
83
84 pub(crate) fn resize(&mut self, rows: usize, cols: usize) {
85 self.rows = rows;
86 self.cols = cols;
87 self.back = vec![Cell::BLANK; rows * cols];
88 self.invalidate();
89 }
90
91 /// Forget what the terminal is showing, so the next flush is a full paint.
92 pub(crate) fn invalidate(&mut self) {
93 self.front.clear();
94 }
95
96 pub(crate) fn clear(&mut self) {
97 self.back.fill(Cell::BLANK);
98 }
99
100 pub(crate) fn set(&mut self, row: usize, col: usize, cell: Cell) {
101 if row < self.rows && col < self.cols {
102 self.back[row * self.cols + col] = cell;
103 }
104 }
105
106 /// Write the rows that changed, then remember them.
107 pub(crate) fn flush(&mut self, to: &mut impl Write) -> io::Result<()> {
108 let full = self.front.len() != self.back.len();
109 if full {
110 self.front = vec![Cell::BLANK; self.back.len()];
111 }
112 self.out.clear();
113 for row in 0..self.rows {
114 let span = row * self.cols..(row + 1) * self.cols;
115 if !full && self.front[span.clone()] == self.back[span.clone()] {
116 continue;
117 }
118 // 1-based, and the column is always 1: a changed row is repainted
119 // from its start. Finding the changed run inside the row would save
120 // bytes on a moving ant and cost them on everything that scrolls.
121 self.out.push_str("\x1b[");
122 self.out.push_str(&(row + 1).to_string());
123 self.out.push_str(";1H");
124 paint_row(&mut self.out, &self.back[span.clone()]);
125 self.front[span.clone()].copy_from_slice(&self.back[span]);
126 }
127 if self.out.is_empty() {
128 return Ok(());
129 }
130 to.write_all(self.out.as_bytes())?;
131 to.flush()
132 }
133
134 /// The visible glyphs, one string per row, with no escapes. The shape the
135 /// tests assert against.
136 #[cfg(test)]
137 pub(crate) fn text(&self) -> Vec<String> {
138 (0..self.rows)
139 .map(|row| {
140 self.back[row * self.cols..(row + 1) * self.cols]
141 .iter()
142 .map(|cell| cell.glyph)
143 .collect()
144 })
145 .collect()
146 }
147 }
148
149 /// One row, with the trailing blanks replaced by an erase-to-end-of-line.
150 ///
151 /// Sparse patterns are mostly trailing blank, and `\x1b[K` is four bytes
152 /// against however many columns the surface is wide.
153 ///
154 /// A free function rather than a method so the row can be read out of `back`
155 /// while `out` is written, which a `&mut self` taking a slice of itself cannot
156 /// do without copying the row first.
157 fn paint_row(out: &mut String, row: &[Cell]) {
158 let end = row
159 .iter()
160 .rposition(|cell| *cell != Cell::BLANK)
161 .map_or(0, |last| last + 1);
162 let mut weight = Weight::Normal;
163 out.push_str("\x1b[0m");
164 for cell in &row[..end] {
165 if cell.weight != weight {
166 out.push_str(match cell.weight {
167 Weight::Dim => "\x1b[0m\x1b[2m",
168 Weight::Normal => "\x1b[0m",
169 Weight::Bold => "\x1b[0m\x1b[1m",
170 });
171 weight = cell.weight;
172 }
173 out.push(cell.glyph);
174 }
175 out.push_str("\x1b[0m\x1b[K");
176 }
177
178 #[cfg(test)]
179 mod tests {
180 use super::{Cell, Screen};
181
182 #[test]
183 fn the_first_flush_paints_every_row_and_the_second_paints_none() {
184 let mut screen = Screen::new(4, 8);
185 let mut out = Vec::new();
186 screen.flush(&mut out).unwrap();
187 assert_eq!(
188 String::from_utf8(out.clone()).unwrap().matches('H').count(),
189 4
190 );
191
192 out.clear();
193 screen.flush(&mut out).unwrap();
194 assert!(
195 out.is_empty(),
196 "an unchanged screen wrote {} bytes",
197 out.len()
198 );
199 }
200
201 #[test]
202 fn only_the_changed_row_is_repainted() {
203 let mut screen = Screen::new(4, 8);
204 screen.flush(&mut Vec::new()).unwrap();
205
206 screen.set(2, 3, Cell::dim(''));
207 let mut out = Vec::new();
208 screen.flush(&mut out).unwrap();
209 let out = String::from_utf8(out).unwrap();
210 assert!(
211 out.starts_with("\x1b[3;1H"),
212 "repainted the wrong row: {out:?}"
213 );
214 assert_eq!(
215 out.matches('H').count(),
216 1,
217 "repainted more than one row: {out:?}"
218 );
219 assert!(out.contains(''));
220 }
221
222 /// A resize must not leave the previous surface's contents believed-drawn,
223 /// or the first frame at the new size is a diff against a screen that no
224 /// longer exists.
225 #[test]
226 fn a_resize_forces_a_full_repaint() {
227 let mut screen = Screen::new(4, 8);
228 screen.flush(&mut Vec::new()).unwrap();
229 screen.resize(6, 10);
230 let mut out = Vec::new();
231 screen.flush(&mut out).unwrap();
232 assert_eq!(String::from_utf8(out).unwrap().matches('H').count(), 6);
233 }
234
235 #[test]
236 fn writes_outside_the_grid_are_dropped_rather_than_panicking() {
237 let mut screen = Screen::new(2, 2);
238 screen.set(9, 0, Cell::bold(''));
239 screen.set(0, 9, Cell::bold(''));
240 assert_eq!(screen.text(), vec![" ".to_string(), " ".to_string()]);
241 }
242
243 /// Every escape this file emits has to be one shop can resolve out of a
244 /// theme. A hex colour here would be a token spelled out in Rust.
245 #[test]
246 fn nothing_emitted_names_a_colour() {
247 let mut screen = Screen::new(2, 4);
248 screen.set(0, 0, Cell::dim(''));
249 screen.set(1, 1, Cell::bold(''));
250 let mut out = Vec::new();
251 screen.flush(&mut out).unwrap();
252 let out = String::from_utf8(out).unwrap();
253 for sgr in out.split('\x1b').filter(|part| part.ends_with('m')) {
254 let code = sgr.trim_start_matches('[').trim_end_matches('m');
255 assert!(
256 matches!(code, "0" | "1" | "2"),
257 "emitted SGR {code:?}, which is not a weight"
258 );
259 }
260 }
261 }
262