| 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 | |
| Paint a reaction, and give an emoji the two columns it takes dce285f nandi 16d ago | 83 | /// How many columns one character takes on screen. |
| 84 | /// |
| 85 | /// Zero for the parts of a glyph that are not drawn — a variation selector, a |
| 86 | /// zero-width joiner, a skin tone — two for the emoji a terminal draws double |
| 87 | /// width, and one for everything else. This is the whole of what this backend |
| 88 | /// knows about character width, and it is enough for what a chat client puts |
| 89 | /// on a screen: text, and the emoji in it. |
| 90 | pub fn char_cols(ch: char) -> u16 { |
| 91 | let c = ch as u32; |
| 92 | match c { |
| 93 | 0xFE00..=0xFE0F | 0x200D | 0x1F3FB..=0x1F3FF => 0, |
| 94 | 0x1F000.. => 2, |
| 95 | 0x2600..=0x27BF => 2, |
| 96 | _ => 1, |
| 97 | } |
| 98 | } |
| 99 | |
| 100 | /// The columns `text` takes, the same way [`Screen::text`] spends them. |
| 101 | pub fn text_cols(text: &str) -> u16 { |
| 102 | text.chars().map(char_cols).sum::<u16>().max(0) |
| 103 | } |
| 104 | |
| Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago | 105 | /// The attribute bits a cell can carry. A `u8` rather than a set of `bool`s |
| 106 | /// because a cell is copied a great many times a frame. |
| 107 | pub mod attr { |
| 108 | pub const BOLD: u8 = 1 << 0; |
| 109 | pub const DIM: u8 = 1 << 1; |
| 110 | pub const UNDERLINE: u8 = 1 << 2; |
| 111 | pub const REVERSE: u8 = 1 << 3; |
| 112 | pub const BLINK: u8 = 1 << 4; |
| 113 | pub const ITALIC: u8 = 1 << 5; |
| 114 | } |
| 115 | |
| 116 | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] |
| 117 | pub struct Style { |
| 118 | pub fg: Color, |
| 119 | pub bg: Color, |
| 120 | pub attrs: u8, |
| 121 | } |
| 122 | |
| 123 | impl Style { |
| 124 | pub fn with(mut self, bits: u8) -> Self { |
| 125 | self.attrs |= bits; |
| 126 | self |
| 127 | } |
| 128 | |
| 129 | pub fn fg(mut self, color: Color) -> Self { |
| 130 | self.fg = color; |
| 131 | self |
| 132 | } |
| 133 | |
| 134 | pub fn has(&self, bits: u8) -> bool { |
| 135 | self.attrs & bits != 0 |
| 136 | } |
| 137 | } |
| 138 | |
| 139 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
| 140 | pub struct Cell { |
| 141 | pub ch: char, |
| 142 | pub style: Style, |
| Paint a reaction, and give an emoji the two columns it takes dce285f nandi 16d ago | 143 | /// The right half of a double-width character. It holds no character of |
| 144 | /// its own: the glyph in the cell to its left is drawn across both, and |
| 145 | /// writing anything here would print a second copy one column over. |
| 146 | pub trail: bool, |
| Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago | 147 | } |
| 148 | |
| 149 | impl Default for Cell { |
| 150 | fn default() -> Self { |
| 151 | Self { |
| 152 | ch: ' ', |
| 153 | style: Style::default(), |
| Paint a reaction, and give an emoji the two columns it takes dce285f nandi 16d ago | 154 | trail: false, |
| Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago | 155 | } |
| 156 | } |
| 157 | } |
| 158 | |
| 159 | /// A rectangle in cells. Columns and rows, origin top left. |
| 160 | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] |
| 161 | pub struct Rect { |
| 162 | pub x: u16, |
| 163 | pub y: u16, |
| 164 | pub w: u16, |
| 165 | pub h: u16, |
| 166 | } |
| 167 | |
| 168 | impl Rect { |
| 169 | pub fn new(x: u16, y: u16, w: u16, h: u16) -> Self { |
| 170 | Self { x, y, w, h } |
| 171 | } |
| 172 | |
| 173 | pub fn is_empty(&self) -> bool { |
| 174 | self.w == 0 || self.h == 0 |
| 175 | } |
| 176 | |
| 177 | pub fn contains(&self, x: u16, y: u16) -> bool { |
| 178 | x >= self.x && x < self.x + self.w && y >= self.y && y < self.y + self.h |
| 179 | } |
| 180 | |
| 181 | /// The rect left after taking `n` cells off every side. Saturating, so |
| 182 | /// padding larger than the rect answers an empty one rather than wrapping. |
| 183 | pub fn shrink(&self, n: u16) -> Self { |
| 184 | let take = n.saturating_mul(2); |
| 185 | Self { |
| 186 | x: self.x.saturating_add(n), |
| 187 | y: self.y.saturating_add(n), |
| 188 | w: self.w.saturating_sub(take), |
| 189 | h: self.h.saturating_sub(take), |
| 190 | } |
| 191 | } |
| 192 | } |
| 193 | |
| 194 | #[derive(Clone, Debug)] |
| 195 | pub struct Screen { |
| 196 | w: u16, |
| 197 | h: u16, |
| 198 | cells: Vec<Cell>, |
| 199 | } |
| 200 | |
| 201 | impl Screen { |
| 202 | pub fn new(w: u16, h: u16) -> Self { |
| 203 | Self { |
| 204 | w, |
| 205 | h, |
| 206 | cells: vec![Cell::default(); w as usize * h as usize], |
| 207 | } |
| 208 | } |
| 209 | |
| 210 | pub fn width(&self) -> u16 { |
| 211 | self.w |
| 212 | } |
| 213 | |
| 214 | pub fn height(&self) -> u16 { |
| 215 | self.h |
| 216 | } |
| 217 | |
| 218 | pub fn rect(&self) -> Rect { |
| 219 | Rect::new(0, 0, self.w, self.h) |
| 220 | } |
| 221 | |
| 222 | pub fn resize(&mut self, w: u16, h: u16) { |
| 223 | if (w, h) != (self.w, self.h) { |
| 224 | *self = Self::new(w, h); |
| 225 | } |
| 226 | } |
| 227 | |
| 228 | pub fn clear(&mut self) { |
| 229 | self.cells.fill(Cell::default()); |
| 230 | } |
| 231 | |
| 232 | pub fn cell(&self, x: u16, y: u16) -> Option<&Cell> { |
| 233 | if x < self.w && y < self.h { |
| 234 | self.cells.get(y as usize * self.w as usize + x as usize) |
| 235 | } else { |
| 236 | None |
| 237 | } |
| 238 | } |
| 239 | |
| 240 | pub fn set(&mut self, x: u16, y: u16, ch: char, style: Style) { |
| 241 | if x < self.w && y < self.h { |
| 242 | let i = y as usize * self.w as usize + x as usize; |
| Paint a reaction, and give an emoji the two columns it takes dce285f nandi 16d ago | 243 | self.cells[i] = Cell { |
| 244 | ch, |
| 245 | style, |
| 246 | trail: false, |
| 247 | }; |
| 248 | } |
| 249 | } |
| 250 | |
| 251 | /// The cell a double-width character's right half sits in. |
| 252 | fn set_trail(&mut self, x: u16, y: u16, style: Style) { |
| 253 | if x < self.w && y < self.h { |
| 254 | let i = y as usize * self.w as usize + x as usize; |
| 255 | self.cells[i] = Cell { |
| 256 | ch: ' ', |
| 257 | style, |
| 258 | trail: true, |
| 259 | }; |
| Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago | 260 | } |
| 261 | } |
| 262 | |
| 263 | /// Write `text` at `x, y`, clipped to `width` columns. Answers how many |
| 264 | /// columns were used. |
| 265 | pub fn text(&mut self, x: u16, y: u16, width: u16, text: &str, style: Style) -> u16 { |
| 266 | let mut col = 0u16; |
| 267 | for ch in text.chars() { |
| 268 | if col >= width { |
| 269 | break; |
| 270 | } |
| 271 | // A control character in a label would move the cursor; show it as |
| 272 | // a dot instead of letting it rearrange the screen. |
| 273 | let ch = if (ch as u32) < 0x20 { '·' } else { ch }; |
| Paint a reaction, and give an emoji the two columns it takes dce285f nandi 16d ago | 274 | let cols = char_cols(ch); |
| 275 | if cols == 0 { |
| 276 | // A joiner or a variation selector: part of the glyph before |
| 277 | // it, and drawn with it. Keeping it in a cell of its own would |
| 278 | // spend a column on something with no picture. |
| 279 | continue; |
| 280 | } |
| 281 | if col + cols > width { |
| 282 | // Half of a wide glyph is a different character, so the last |
| 283 | // column stays blank rather than showing one. |
| 284 | break; |
| 285 | } |
| Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago | 286 | self.set(x.saturating_add(col), y, ch, style); |
| Paint a reaction, and give an emoji the two columns it takes dce285f nandi 16d ago | 287 | if cols == 2 { |
| 288 | self.set_trail(x.saturating_add(col + 1), y, style); |
| 289 | } |
| 290 | col += cols; |
| Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago | 291 | } |
| 292 | col |
| 293 | } |
| 294 | |
| 295 | /// Paint every cell of `rect` with `style`, keeping the characters — that |
| 296 | /// is what a background is: a colour behind whatever is already there. |
| 297 | pub fn fill(&mut self, rect: Rect, style: Style) { |
| 298 | for y in rect.y..rect.y.saturating_add(rect.h) { |
| 299 | for x in rect.x..rect.x.saturating_add(rect.w) { |
| 300 | if x < self.w && y < self.h { |
| 301 | let i = y as usize * self.w as usize + x as usize; |
| Paint a reaction, and give an emoji the two columns it takes dce285f nandi 16d ago | 302 | // The character stays, and so does whether it is the half |
| 303 | // of one: a background is a colour, not a repaint. |
| 304 | self.cells[i].style = style; |
| Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago | 305 | } |
| 306 | } |
| 307 | } |
| 308 | } |
| 309 | |
| 310 | /// One row as text, trailing blanks trimmed. The whole reason the grid is |
| 311 | /// addressable: a test asserts on lines, not on escape sequences. |
| 312 | pub fn line(&self, y: u16) -> String { |
| 313 | if y >= self.h { |
| 314 | return String::new(); |
| 315 | } |
| 316 | let start = y as usize * self.w as usize; |
| Paint a reaction, and give an emoji the two columns it takes dce285f nandi 16d ago | 317 | // Without the trailing halves: they hold no character, and a reader — |
| 318 | // a test, a bug report — wants the line as it looks. |
| Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago | 319 | let row: String = self.cells[start..start + self.w as usize] |
| 320 | .iter() |
| Paint a reaction, and give an emoji the two columns it takes dce285f nandi 16d ago | 321 | .filter(|c| !c.trail) |
| Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago | 322 | .map(|c| c.ch) |
| 323 | .collect(); |
| 324 | row.trim_end().to_owned() |
| 325 | } |
| 326 | |
| 327 | #[cfg(test)] |
| 328 | pub fn lines(&self) -> Vec<String> { |
| 329 | (0..self.h).map(|y| self.line(y)).collect() |
| 330 | } |
| 331 | |
| 332 | pub(crate) fn cells(&self) -> &[Cell] { |
| 333 | &self.cells |
| 334 | } |
| 335 | } |
| 336 | |
| 337 | #[cfg(test)] |
| 338 | mod tests { |
| 339 | use super::*; |
| 340 | |
| 341 | #[test] |
| 342 | fn colours_parse_in_every_shape_a_caller_writes_them() { |
| 343 | assert_eq!(Color::parse("red"), Some(Color::Indexed(1))); |
| 344 | assert_eq!(Color::parse("bright-blue"), Some(Color::Indexed(12))); |
| 345 | assert_eq!(Color::parse("default"), Some(Color::Default)); |
| 346 | assert_eq!(Color::parse("33"), Some(Color::Indexed(33))); |
| 347 | assert_eq!(Color::parse("#ff6432"), Some(Color::Rgb(255, 100, 50))); |
| 348 | assert_eq!(Color::parse("#f64"), Some(Color::Rgb(255, 102, 68))); |
| 349 | assert_eq!(Color::parse("255,100,50"), Some(Color::Rgb(255, 100, 50))); |
| 350 | } |
| 351 | |
| 352 | #[test] |
| 353 | fn an_unreadable_colour_is_no_colour_rather_than_an_error() { |
| 354 | assert_eq!(Color::parse("puce"), None); |
| 355 | assert_eq!(Color::parse("#gg0000"), None); |
| 356 | assert_eq!(Color::parse(""), None); |
| 357 | } |
| 358 | |
| 359 | #[test] |
| 360 | fn text_clips_to_the_width_it_was_given() { |
| 361 | let mut screen = Screen::new(10, 2); |
| 362 | screen.text(0, 0, 4, "abcdefg", Style::default()); |
| 363 | assert_eq!(screen.line(0), "abcd"); |
| 364 | } |
| 365 | |
| 366 | #[test] |
| 367 | fn a_control_character_cannot_move_the_cursor() { |
| 368 | let mut screen = Screen::new(6, 1); |
| 369 | screen.text(0, 0, 6, "a\rb", Style::default()); |
| 370 | assert_eq!(screen.line(0), "a·b"); |
| 371 | } |
| 372 | |
| 373 | #[test] |
| 374 | fn filling_a_rect_keeps_the_characters_under_it() { |
| 375 | let mut screen = Screen::new(4, 1); |
| 376 | screen.text(0, 0, 4, "hi", Style::default()); |
| 377 | screen.fill(screen.rect(), Style::default().with(attr::REVERSE)); |
| 378 | assert_eq!(screen.line(0), "hi"); |
| 379 | assert!(screen.cell(0, 0).unwrap().style.has(attr::REVERSE)); |
| 380 | } |
| 381 | } |