//! The cell grid and the diff that reaches the terminal. //! //! NO COLOUR, which is inherited from `usr/bin/alloy-backdrop` and is the rule //! that matters most here. The backdrop emits SGR weight only — dim, normal, //! bold — and shop resolves those out of the theme named in //! `~/.config/shop/config.toml`, so `alloy theme apply` moves the animation and //! the terminal together and no palette is ever spelled out in this repo //! (docs/TOKENS.md: no hex outside the theme files). A backdrop that picked its //! own green would also be the loudest thing on a screen whose whole design //! language reserves colour for information (docs/DESIGN-LANGUAGE.md). //! //! THE GLYPHS ARE THE TILING TIER and nothing else: `░` (U+2591), `█` (U+2588) //! and the two box-drawing diagonals `╱ ╲` (U+2571, U+2572). The first two are //! in Alloy's eighteen; both diagonals come from quasi-type's generated //! box-drawing block, which is cut cell-exact for the whole of U+2500-U+257F //! precisely so it tiles without seams. Nothing here reaches for braille, //! sextants or octants: docs/FONTS.md measures those as absent from the house //! face, so a sub-cell canvas would draw tofu on every desktop. //! //! The diff is per row. Life and Langton's ant change a handful of cells per //! frame and repainting the whole surface for them would be the difference //! between a background that costs nothing and one that shows up in a power //! measurement. use std::io::{self, Write}; #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub(crate) enum Weight { Dim, Normal, Bold, } #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub(crate) struct Cell { pub(crate) glyph: char, pub(crate) weight: Weight, } impl Cell { pub(crate) const BLANK: Cell = Cell { glyph: ' ', weight: Weight::Normal, }; pub(crate) fn dim(glyph: char) -> Self { Self { glyph, weight: Weight::Dim, } } pub(crate) fn bold(glyph: char) -> Self { Self { glyph, weight: Weight::Bold, } } } pub(crate) struct Screen { rows: usize, cols: usize, /// What the pattern has just drawn. back: Vec, /// What the terminal is believed to be showing. Emptied by `invalidate`, /// which is what a resize and a return from the legend both need: after /// either, nothing about the previous contents is still true. front: Vec, out: String, } impl Screen { pub(crate) fn new(rows: usize, cols: usize) -> Self { Self { rows, cols, back: vec![Cell::BLANK; rows * cols], front: Vec::new(), out: String::new(), } } pub(crate) fn resize(&mut self, rows: usize, cols: usize) { self.rows = rows; self.cols = cols; self.back = vec![Cell::BLANK; rows * cols]; self.invalidate(); } /// Forget what the terminal is showing, so the next flush is a full paint. pub(crate) fn invalidate(&mut self) { self.front.clear(); } pub(crate) fn clear(&mut self) { self.back.fill(Cell::BLANK); } pub(crate) fn set(&mut self, row: usize, col: usize, cell: Cell) { if row < self.rows && col < self.cols { self.back[row * self.cols + col] = cell; } } /// Write the rows that changed, then remember them. pub(crate) fn flush(&mut self, to: &mut impl Write) -> io::Result<()> { let full = self.front.len() != self.back.len(); if full { self.front = vec![Cell::BLANK; self.back.len()]; } self.out.clear(); for row in 0..self.rows { let span = row * self.cols..(row + 1) * self.cols; if !full && self.front[span.clone()] == self.back[span.clone()] { continue; } // 1-based, and the column is always 1: a changed row is repainted // from its start. Finding the changed run inside the row would save // bytes on a moving ant and cost them on everything that scrolls. self.out.push_str("\x1b["); self.out.push_str(&(row + 1).to_string()); self.out.push_str(";1H"); paint_row(&mut self.out, &self.back[span.clone()]); self.front[span.clone()].copy_from_slice(&self.back[span]); } if self.out.is_empty() { return Ok(()); } to.write_all(self.out.as_bytes())?; to.flush() } /// The visible glyphs, one string per row, with no escapes. The shape the /// tests assert against. #[cfg(test)] pub(crate) fn text(&self) -> Vec { (0..self.rows) .map(|row| { self.back[row * self.cols..(row + 1) * self.cols] .iter() .map(|cell| cell.glyph) .collect() }) .collect() } } /// One row, with the trailing blanks replaced by an erase-to-end-of-line. /// /// Sparse patterns are mostly trailing blank, and `\x1b[K` is four bytes /// against however many columns the surface is wide. /// /// A free function rather than a method so the row can be read out of `back` /// while `out` is written, which a `&mut self` taking a slice of itself cannot /// do without copying the row first. fn paint_row(out: &mut String, row: &[Cell]) { let end = row .iter() .rposition(|cell| *cell != Cell::BLANK) .map_or(0, |last| last + 1); let mut weight = Weight::Normal; out.push_str("\x1b[0m"); for cell in &row[..end] { if cell.weight != weight { out.push_str(match cell.weight { Weight::Dim => "\x1b[0m\x1b[2m", Weight::Normal => "\x1b[0m", Weight::Bold => "\x1b[0m\x1b[1m", }); weight = cell.weight; } out.push(cell.glyph); } out.push_str("\x1b[0m\x1b[K"); } #[cfg(test)] mod tests { use super::{Cell, Screen}; #[test] fn the_first_flush_paints_every_row_and_the_second_paints_none() { let mut screen = Screen::new(4, 8); let mut out = Vec::new(); screen.flush(&mut out).unwrap(); assert_eq!( String::from_utf8(out.clone()).unwrap().matches('H').count(), 4 ); out.clear(); screen.flush(&mut out).unwrap(); assert!( out.is_empty(), "an unchanged screen wrote {} bytes", out.len() ); } #[test] fn only_the_changed_row_is_repainted() { let mut screen = Screen::new(4, 8); screen.flush(&mut Vec::new()).unwrap(); screen.set(2, 3, Cell::dim('░')); let mut out = Vec::new(); screen.flush(&mut out).unwrap(); let out = String::from_utf8(out).unwrap(); assert!( out.starts_with("\x1b[3;1H"), "repainted the wrong row: {out:?}" ); assert_eq!( out.matches('H').count(), 1, "repainted more than one row: {out:?}" ); assert!(out.contains('░')); } /// A resize must not leave the previous surface's contents believed-drawn, /// or the first frame at the new size is a diff against a screen that no /// longer exists. #[test] fn a_resize_forces_a_full_repaint() { let mut screen = Screen::new(4, 8); screen.flush(&mut Vec::new()).unwrap(); screen.resize(6, 10); let mut out = Vec::new(); screen.flush(&mut out).unwrap(); assert_eq!(String::from_utf8(out).unwrap().matches('H').count(), 6); } #[test] fn writes_outside_the_grid_are_dropped_rather_than_panicking() { let mut screen = Screen::new(2, 2); screen.set(9, 0, Cell::bold('█')); screen.set(0, 9, Cell::bold('█')); assert_eq!(screen.text(), vec![" ".to_string(), " ".to_string()]); } /// Every escape this file emits has to be one shop can resolve out of a /// theme. A hex colour here would be a token spelled out in Rust. #[test] fn nothing_emitted_names_a_colour() { let mut screen = Screen::new(2, 4); screen.set(0, 0, Cell::dim('░')); screen.set(1, 1, Cell::bold('█')); let mut out = Vec::new(); screen.flush(&mut out).unwrap(); let out = String::from_utf8(out).unwrap(); for sgr in out.split('\x1b').filter(|part| part.ends_with('m')) { let code = sgr.trim_start_matches('[').trim_end_matches('m'); assert!( matches!(code, "0" | "1" | "2"), "emitted SGR {code:?}, which is not a weight" ); } } }