| Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago | 1 | //! A grid of styled cells, and the colours that go in it. |
| 2 | //! |
| 3 | //! Everything this crate paints goes here first, and only [`crate::term`] ever |
| 4 | //! turns it into escape sequences. That split is deliberate and is the same one |
| 5 | //! glimmer-tui makes on the jolt side: painting into a grid needs no terminal, |
| 6 | //! no raw mode and no TTY, so the whole widget layer is testable in a unit test |
| 7 | //! and in CI — `tui_headless` opens a screen and nothing else. |
| 8 | |
| 9 | /// A terminal colour, in the three ways a caller can write one. |
| 10 | /// |
| 11 | /// `Default` is not black: it is "whatever the terminal was using", which is |
| 12 | /// what a theme-respecting TUI wants for most of its surface. |
| 13 | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] |
| 14 | pub enum Color { |
| 15 | #[default] |
| 16 | Default, |
| 17 | /// An index into the xterm 256-colour palette. 0–15 are the named ones. |
| 18 | Indexed(u8), |
| 19 | Rgb(u8, u8, u8), |
| 20 | } |
| 21 | |
| 22 | impl Color { |
| 23 | /// Parse a colour prop: a name (`red`, `bright-blue`, `default`), a palette |
| 24 | /// index (`"33"`), a hex triple (`#ff6432` or `#f64`), or `r,g,b`. |
| 25 | /// |
| 26 | /// Answers `None` for anything else, which the caller reads as "leave the |
| 27 | /// colour alone" rather than as an error — a typo'd colour should not take |
| 28 | /// the widget away. |
| 29 | pub fn parse(text: &str) -> Option<Self> { |
| 30 | let text = text.trim(); |
| 31 | if text.is_empty() { |
| 32 | return None; |
| 33 | } |
| 34 | if let Some(hex) = text.strip_prefix('#') { |
| 35 | return Self::from_hex(hex); |
| 36 | } |
| 37 | if let Ok(index) = text.parse::<u8>() { |
| 38 | return Some(Self::Indexed(index)); |
| 39 | } |
| 40 | if text.contains(',') { |
| 41 | let parts: Vec<&str> = text.split(',').map(str::trim).collect(); |
| 42 | if let [r, g, b] = parts[..] { |
| 43 | return Some(Self::Rgb(r.parse().ok()?, g.parse().ok()?, b.parse().ok()?)); |
| 44 | } |
| 45 | return None; |
| 46 | } |
| 47 | let (name, bright) = match text.strip_prefix("bright-") { |
| 48 | Some(rest) => (rest, true), |
| 49 | None => (text, false), |
| 50 | }; |
| 51 | let base = match name { |
| 52 | "black" => 0, |
| 53 | "red" => 1, |
| 54 | "green" => 2, |
| 55 | "yellow" => 3, |
| 56 | "blue" => 4, |
| 57 | "magenta" => 5, |
| 58 | "cyan" => 6, |
| 59 | "white" => 7, |
| 60 | "default" if !bright => return Some(Self::Default), |
| 61 | _ => return None, |
| 62 | }; |
| 63 | Some(Self::Indexed(base + if bright { 8 } else { 0 })) |
| 64 | } |
| 65 | |
| 66 | fn from_hex(hex: &str) -> Option<Self> { |
| 67 | let bytes = hex.as_bytes(); |
| 68 | let nib = |c: u8| (c as char).to_digit(16).map(|d| d as u8); |
| 69 | match bytes.len() { |
| 70 | 3 => { |
| 71 | let (r, g, b) = (nib(bytes[0])?, nib(bytes[1])?, nib(bytes[2])?); |
| 72 | Some(Self::Rgb(r * 17, g * 17, b * 17)) |
| 73 | } |
| 74 | 6 => { |
| 75 | let pair = |i: usize| Some(nib(bytes[i])? * 16 + nib(bytes[i + 1])?); |
| 76 | Some(Self::Rgb(pair(0)?, pair(2)?, pair(4)?)) |
| 77 | } |
| 78 | _ => None, |
| 79 | } |
| 80 | } |
| 81 | } |
| 82 | |
| 83 | /// The attribute bits a cell can carry. A `u8` rather than a set of `bool`s |
| 84 | /// because a cell is copied a great many times a frame. |
| 85 | pub mod attr { |
| 86 | pub const BOLD: u8 = 1 << 0; |
| 87 | pub const DIM: u8 = 1 << 1; |
| 88 | pub const UNDERLINE: u8 = 1 << 2; |
| 89 | pub const REVERSE: u8 = 1 << 3; |
| 90 | pub const BLINK: u8 = 1 << 4; |
| 91 | pub const ITALIC: u8 = 1 << 5; |
| 92 | } |
| 93 | |
| 94 | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] |
| 95 | pub struct Style { |
| 96 | pub fg: Color, |
| 97 | pub bg: Color, |
| 98 | pub attrs: u8, |
| 99 | } |
| 100 | |
| 101 | impl Style { |
| 102 | pub fn with(mut self, bits: u8) -> Self { |
| 103 | self.attrs |= bits; |
| 104 | self |
| 105 | } |
| 106 | |
| 107 | pub fn fg(mut self, color: Color) -> Self { |
| 108 | self.fg = color; |
| 109 | self |
| 110 | } |
| 111 | |
| 112 | pub fn has(&self, bits: u8) -> bool { |
| 113 | self.attrs & bits != 0 |
| 114 | } |
| 115 | } |
| 116 | |
| 117 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
| 118 | pub struct Cell { |
| 119 | pub ch: char, |
| 120 | pub style: Style, |
| 121 | } |
| 122 | |
| 123 | impl Default for Cell { |
| 124 | fn default() -> Self { |
| 125 | Self { |
| 126 | ch: ' ', |
| 127 | style: Style::default(), |
| 128 | } |
| 129 | } |
| 130 | } |
| 131 | |
| 132 | /// A rectangle in cells. Columns and rows, origin top left. |
| 133 | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] |
| 134 | pub struct Rect { |
| 135 | pub x: u16, |
| 136 | pub y: u16, |
| 137 | pub w: u16, |
| 138 | pub h: u16, |
| 139 | } |
| 140 | |
| 141 | impl Rect { |
| 142 | pub fn new(x: u16, y: u16, w: u16, h: u16) -> Self { |
| 143 | Self { x, y, w, h } |
| 144 | } |
| 145 | |
| 146 | pub fn is_empty(&self) -> bool { |
| 147 | self.w == 0 || self.h == 0 |
| 148 | } |
| 149 | |
| 150 | pub fn contains(&self, x: u16, y: u16) -> bool { |
| 151 | x >= self.x && x < self.x + self.w && y >= self.y && y < self.y + self.h |
| 152 | } |
| 153 | |
| 154 | /// The rect left after taking `n` cells off every side. Saturating, so |
| 155 | /// padding larger than the rect answers an empty one rather than wrapping. |
| 156 | pub fn shrink(&self, n: u16) -> Self { |
| 157 | let take = n.saturating_mul(2); |
| 158 | Self { |
| 159 | x: self.x.saturating_add(n), |
| 160 | y: self.y.saturating_add(n), |
| 161 | w: self.w.saturating_sub(take), |
| 162 | h: self.h.saturating_sub(take), |
| 163 | } |
| 164 | } |
| 165 | } |
| 166 | |
| 167 | #[derive(Clone, Debug)] |
| 168 | pub struct Screen { |
| 169 | w: u16, |
| 170 | h: u16, |
| 171 | cells: Vec<Cell>, |
| 172 | } |
| 173 | |
| 174 | impl Screen { |
| 175 | pub fn new(w: u16, h: u16) -> Self { |
| 176 | Self { |
| 177 | w, |
| 178 | h, |
| 179 | cells: vec![Cell::default(); w as usize * h as usize], |
| 180 | } |
| 181 | } |
| 182 | |
| 183 | pub fn width(&self) -> u16 { |
| 184 | self.w |
| 185 | } |
| 186 | |
| 187 | pub fn height(&self) -> u16 { |
| 188 | self.h |
| 189 | } |
| 190 | |
| 191 | pub fn rect(&self) -> Rect { |
| 192 | Rect::new(0, 0, self.w, self.h) |
| 193 | } |
| 194 | |
| 195 | pub fn resize(&mut self, w: u16, h: u16) { |
| 196 | if (w, h) != (self.w, self.h) { |
| 197 | *self = Self::new(w, h); |
| 198 | } |
| 199 | } |
| 200 | |
| 201 | pub fn clear(&mut self) { |
| 202 | self.cells.fill(Cell::default()); |
| 203 | } |
| 204 | |
| 205 | pub fn cell(&self, x: u16, y: u16) -> Option<&Cell> { |
| 206 | if x < self.w && y < self.h { |
| 207 | self.cells.get(y as usize * self.w as usize + x as usize) |
| 208 | } else { |
| 209 | None |
| 210 | } |
| 211 | } |
| 212 | |
| 213 | pub fn set(&mut self, x: u16, y: u16, ch: char, style: Style) { |
| 214 | if x < self.w && y < self.h { |
| 215 | let i = y as usize * self.w as usize + x as usize; |
| 216 | self.cells[i] = Cell { ch, style }; |
| 217 | } |
| 218 | } |
| 219 | |
| 220 | /// Write `text` at `x, y`, clipped to `width` columns. Answers how many |
| 221 | /// columns were used. |
| 222 | pub fn text(&mut self, x: u16, y: u16, width: u16, text: &str, style: Style) -> u16 { |
| 223 | let mut col = 0u16; |
| 224 | for ch in text.chars() { |
| 225 | if col >= width { |
| 226 | break; |
| 227 | } |
| 228 | // A control character in a label would move the cursor; show it as |
| 229 | // a dot instead of letting it rearrange the screen. |
| 230 | let ch = if (ch as u32) < 0x20 { '·' } else { ch }; |
| 231 | self.set(x.saturating_add(col), y, ch, style); |
| 232 | col += 1; |
| 233 | } |
| 234 | col |
| 235 | } |
| 236 | |
| 237 | /// Paint every cell of `rect` with `style`, keeping the characters — that |
| 238 | /// is what a background is: a colour behind whatever is already there. |
| 239 | pub fn fill(&mut self, rect: Rect, style: Style) { |
| 240 | for y in rect.y..rect.y.saturating_add(rect.h) { |
| 241 | for x in rect.x..rect.x.saturating_add(rect.w) { |
| 242 | if x < self.w && y < self.h { |
| 243 | let i = y as usize * self.w as usize + x as usize; |
| 244 | let ch = self.cells[i].ch; |
| 245 | self.cells[i] = Cell { ch, style }; |
| 246 | } |
| 247 | } |
| 248 | } |
| 249 | } |
| 250 | |
| 251 | /// One row as text, trailing blanks trimmed. The whole reason the grid is |
| 252 | /// addressable: a test asserts on lines, not on escape sequences. |
| 253 | pub fn line(&self, y: u16) -> String { |
| 254 | if y >= self.h { |
| 255 | return String::new(); |
| 256 | } |
| 257 | let start = y as usize * self.w as usize; |
| 258 | let row: String = self.cells[start..start + self.w as usize] |
| 259 | .iter() |
| 260 | .map(|c| c.ch) |
| 261 | .collect(); |
| 262 | row.trim_end().to_owned() |
| 263 | } |
| 264 | |
| 265 | #[cfg(test)] |
| 266 | pub fn lines(&self) -> Vec<String> { |
| 267 | (0..self.h).map(|y| self.line(y)).collect() |
| 268 | } |
| 269 | |
| 270 | pub(crate) fn cells(&self) -> &[Cell] { |
| 271 | &self.cells |
| 272 | } |
| 273 | } |
| 274 | |
| 275 | #[cfg(test)] |
| 276 | mod tests { |
| 277 | use super::*; |
| 278 | |
| 279 | #[test] |
| 280 | fn colours_parse_in_every_shape_a_caller_writes_them() { |
| 281 | assert_eq!(Color::parse("red"), Some(Color::Indexed(1))); |
| 282 | assert_eq!(Color::parse("bright-blue"), Some(Color::Indexed(12))); |
| 283 | assert_eq!(Color::parse("default"), Some(Color::Default)); |
| 284 | assert_eq!(Color::parse("33"), Some(Color::Indexed(33))); |
| 285 | assert_eq!(Color::parse("#ff6432"), Some(Color::Rgb(255, 100, 50))); |
| 286 | assert_eq!(Color::parse("#f64"), Some(Color::Rgb(255, 102, 68))); |
| 287 | assert_eq!(Color::parse("255,100,50"), Some(Color::Rgb(255, 100, 50))); |
| 288 | } |
| 289 | |
| 290 | #[test] |
| 291 | fn an_unreadable_colour_is_no_colour_rather_than_an_error() { |
| 292 | assert_eq!(Color::parse("puce"), None); |
| 293 | assert_eq!(Color::parse("#gg0000"), None); |
| 294 | assert_eq!(Color::parse(""), None); |
| 295 | } |
| 296 | |
| 297 | #[test] |
| 298 | fn text_clips_to_the_width_it_was_given() { |
| 299 | let mut screen = Screen::new(10, 2); |
| 300 | screen.text(0, 0, 4, "abcdefg", Style::default()); |
| 301 | assert_eq!(screen.line(0), "abcd"); |
| 302 | } |
| 303 | |
| 304 | #[test] |
| 305 | fn a_control_character_cannot_move_the_cursor() { |
| 306 | let mut screen = Screen::new(6, 1); |
| 307 | screen.text(0, 0, 6, "a\rb", Style::default()); |
| 308 | assert_eq!(screen.line(0), "a·b"); |
| 309 | } |
| 310 | |
| 311 | #[test] |
| 312 | fn filling_a_rect_keeps_the_characters_under_it() { |
| 313 | let mut screen = Screen::new(4, 1); |
| 314 | screen.text(0, 0, 4, "hi", Style::default()); |
| 315 | screen.fill(screen.rect(), Style::default().with(attr::REVERSE)); |
| 316 | assert_eq!(screen.line(0), "hi"); |
| 317 | assert!(screen.cell(0, 0).unwrap().style.has(attr::REVERSE)); |
| 318 | } |
| 319 | } |