//! A grid of styled cells, and the colours that go in it. //! //! Everything this crate paints goes here first, and only [`crate::term`] ever //! turns it into escape sequences. That split is deliberate and is the same one //! glimmer-tui makes on the jolt side: painting into a grid needs no terminal, //! no raw mode and no TTY, so the whole widget layer is testable in a unit test //! and in CI — `tui_headless` opens a screen and nothing else. /// A terminal colour, in the three ways a caller can write one. /// /// `Default` is not black: it is "whatever the terminal was using", which is /// what a theme-respecting TUI wants for most of its surface. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum Color { #[default] Default, /// An index into the xterm 256-colour palette. 0–15 are the named ones. Indexed(u8), Rgb(u8, u8, u8), } impl Color { /// Parse a colour prop: a name (`red`, `bright-blue`, `default`), a palette /// index (`"33"`), a hex triple (`#ff6432` or `#f64`), or `r,g,b`. /// /// Answers `None` for anything else, which the caller reads as "leave the /// colour alone" rather than as an error — a typo'd colour should not take /// the widget away. pub fn parse(text: &str) -> Option { let text = text.trim(); if text.is_empty() { return None; } if let Some(hex) = text.strip_prefix('#') { return Self::from_hex(hex); } if let Ok(index) = text.parse::() { return Some(Self::Indexed(index)); } if text.contains(',') { let parts: Vec<&str> = text.split(',').map(str::trim).collect(); if let [r, g, b] = parts[..] { return Some(Self::Rgb(r.parse().ok()?, g.parse().ok()?, b.parse().ok()?)); } return None; } let (name, bright) = match text.strip_prefix("bright-") { Some(rest) => (rest, true), None => (text, false), }; let base = match name { "black" => 0, "red" => 1, "green" => 2, "yellow" => 3, "blue" => 4, "magenta" => 5, "cyan" => 6, "white" => 7, "default" if !bright => return Some(Self::Default), _ => return None, }; Some(Self::Indexed(base + if bright { 8 } else { 0 })) } fn from_hex(hex: &str) -> Option { let bytes = hex.as_bytes(); let nib = |c: u8| (c as char).to_digit(16).map(|d| d as u8); match bytes.len() { 3 => { let (r, g, b) = (nib(bytes[0])?, nib(bytes[1])?, nib(bytes[2])?); Some(Self::Rgb(r * 17, g * 17, b * 17)) } 6 => { let pair = |i: usize| Some(nib(bytes[i])? * 16 + nib(bytes[i + 1])?); Some(Self::Rgb(pair(0)?, pair(2)?, pair(4)?)) } _ => None, } } } /// True for a character that is part of the glyph before it rather than one of /// its own: a variation selector, a zero-width joiner, a skin tone. fn continues_glyph(ch: char) -> bool { matches!(ch as u32, 0xFE00..=0xFE0F | 0x200D | 0x1F3FB..=0x1F3FF) } /// Split `text` into what a terminal draws as single glyphs. /// /// One character is usually one glyph, but an emoji is often several: `↩️` is /// an arrow and a variation selector saying to draw it as a picture, and a /// family is three people and the joiners between them. Cutting between those /// characters is what makes a reply arrow come out as the small mono arrow /// rather than the emoji — the selector says which of the two a terminal /// draws, and it has to travel with the character it is about. pub fn glyphs(text: &str) -> Vec { let mut out: Vec = Vec::new(); let mut joining = false; for ch in text.chars() { let continues = continues_glyph(ch); if (continues || joining) && !out.is_empty() { out.last_mut().expect("not empty").push(ch); } else { out.push(ch.to_string()); } // A joiner welds what follows it onto what came before. joining = ch as u32 == 0x200D; } out } /// How many columns one glyph takes on screen. /// /// Two for the emoji a terminal draws double width, one for everything else. /// A character out of the emoji blocks is drawn wide on its own; an older /// symbol or dingbat is drawn wide when it carries the selector that asks for /// the picture, and narrow — a character among characters — when it does not. /// This is the whole of what this backend knows about width, and it is enough /// for what a chat client puts on a screen: text, and the emoji in it. pub fn glyph_cols(glyph: &str) -> u16 { let mut chars = glyph.chars(); let Some(base) = chars.next() else { return 0; }; let emoji_presentation = glyph.chars().any(|c| c as u32 == 0xFE0F); match base as u32 { 0x1F000.. => 2, 0x2000..=0x2BFF if emoji_presentation => 2, _ => 1, } } /// The columns `text` takes, the same way [`Screen::text`] spends them. pub fn text_cols(text: &str) -> u16 { glyphs(text).iter().map(|g| glyph_cols(g)).sum() } /// The attribute bits a cell can carry. A `u8` rather than a set of `bool`s /// because a cell is copied a great many times a frame. pub mod attr { pub const BOLD: u8 = 1 << 0; pub const DIM: u8 = 1 << 1; pub const UNDERLINE: u8 = 1 << 2; pub const REVERSE: u8 = 1 << 3; pub const BLINK: u8 = 1 << 4; pub const ITALIC: u8 = 1 << 5; } #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub struct Style { pub fg: Color, pub bg: Color, pub attrs: u8, } impl Style { pub fn with(mut self, bits: u8) -> Self { self.attrs |= bits; self } pub fn fg(mut self, color: Color) -> Self { self.fg = color; self } pub fn has(&self, bits: u8) -> bool { self.attrs & bits != 0 } } #[derive(Clone, Debug, PartialEq, Eq)] pub struct Cell { pub ch: char, /// The rest of the glyph, where it took more than one character: the /// selector that asks for a picture, the joiners in a family. Printed /// straight after `ch`, which is the only way a terminal draws them as /// the one glyph they are. pub tail: Option>, pub style: Style, /// The right half of a double-width character. It holds no character of /// its own: the glyph in the cell to its left is drawn across both, and /// writing anything here would print a second copy one column over. pub trail: bool, } impl Default for Cell { fn default() -> Self { Self { ch: ' ', tail: None, style: Style::default(), trail: false, } } } /// A rectangle in cells. Columns and rows, origin top left. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub struct Rect { pub x: u16, pub y: u16, pub w: u16, pub h: u16, } impl Rect { pub fn new(x: u16, y: u16, w: u16, h: u16) -> Self { Self { x, y, w, h } } pub fn is_empty(&self) -> bool { self.w == 0 || self.h == 0 } pub fn contains(&self, x: u16, y: u16) -> bool { x >= self.x && x < self.x + self.w && y >= self.y && y < self.y + self.h } /// The rect left after taking `n` cells off every side. Saturating, so /// padding larger than the rect answers an empty one rather than wrapping. pub fn shrink(&self, n: u16) -> Self { let take = n.saturating_mul(2); Self { x: self.x.saturating_add(n), y: self.y.saturating_add(n), w: self.w.saturating_sub(take), h: self.h.saturating_sub(take), } } } #[derive(Clone, Debug)] pub struct Screen { w: u16, h: u16, cells: Vec, } impl Screen { pub fn new(w: u16, h: u16) -> Self { Self { w, h, cells: vec![Cell::default(); w as usize * h as usize], } } pub fn width(&self) -> u16 { self.w } pub fn height(&self) -> u16 { self.h } pub fn rect(&self) -> Rect { Rect::new(0, 0, self.w, self.h) } pub fn resize(&mut self, w: u16, h: u16) { if (w, h) != (self.w, self.h) { *self = Self::new(w, h); } } pub fn clear(&mut self) { self.cells.fill(Cell::default()); } pub fn cell(&self, x: u16, y: u16) -> Option<&Cell> { if x < self.w && y < self.h { self.cells.get(y as usize * self.w as usize + x as usize) } else { None } } pub fn set(&mut self, x: u16, y: u16, ch: char, style: Style) { if x < self.w && y < self.h { let i = y as usize * self.w as usize + x as usize; self.cells[i] = Cell { ch, tail: None, style, trail: false, }; } } /// The cell a double-width character's right half sits in. fn set_trail(&mut self, x: u16, y: u16, style: Style) { if x < self.w && y < self.h { let i = y as usize * self.w as usize + x as usize; self.cells[i] = Cell { ch: ' ', tail: None, style, trail: true, }; } } /// Write `text` at `x, y`, clipped to `width` columns. Answers how many /// columns were used. pub fn text(&mut self, x: u16, y: u16, width: u16, text: &str, style: Style) -> u16 { let mut col = 0u16; for glyph in glyphs(text) { if col >= width { break; } let mut chars = glyph.chars(); let ch = chars.next().unwrap_or(' '); // A control character in a label would move the cursor; show it as // a dot instead of letting it rearrange the screen. let ch = if (ch as u32) < 0x20 { '·' } else { ch }; let cols = glyph_cols(&glyph); if col + cols > width { // Half of a wide glyph is a different character, so the last // column stays blank rather than showing one. break; } let at = x.saturating_add(col); self.set(at, y, ch, style); let rest: String = chars.collect(); if !rest.is_empty() { self.set_tail(at, y, rest); } if cols == 2 { self.set_trail(x.saturating_add(col + 1), y, style); } col += cols; } col } /// Put a whole cell down as it is — what a scroll does when it copies the /// visible window of its content across. Cell by character would drop the /// rest of a glyph and the right half of a wide one, which is a reply /// arrow painted as the small mono arrow and a row a column out. pub fn put(&mut self, x: u16, y: u16, cell: Cell) { if x < self.w && y < self.h { let i = y as usize * self.w as usize + x as usize; self.cells[i] = cell; } } /// The rest of a glyph, on the cell its first character went in. fn set_tail(&mut self, x: u16, y: u16, rest: String) { if x < self.w && y < self.h { let i = y as usize * self.w as usize + x as usize; self.cells[i].tail = Some(rest.into_boxed_str()); } } /// Paint every cell of `rect` with `style`, keeping the characters — that /// is what a background is: a colour behind whatever is already there. pub fn fill(&mut self, rect: Rect, style: Style) { for y in rect.y..rect.y.saturating_add(rect.h) { for x in rect.x..rect.x.saturating_add(rect.w) { if x < self.w && y < self.h { let i = y as usize * self.w as usize + x as usize; // The character stays, and so does whether it is the half // of one: a background is a colour, not a repaint. self.cells[i].style = style; } } } } /// One row as text, trailing blanks trimmed. The whole reason the grid is /// addressable: a test asserts on lines, not on escape sequences. pub fn line(&self, y: u16) -> String { if y >= self.h { return String::new(); } let start = y as usize * self.w as usize; // Without the trailing halves: they hold no character, and a reader — // a test, a bug report — wants the line as it looks. let mut row = String::new(); for cell in self.cells[start..start + self.w as usize].iter() { if cell.trail { continue; } row.push(cell.ch); if let Some(tail) = &cell.tail { row.push_str(tail); } } row.trim_end().to_owned() } #[cfg(test)] pub fn lines(&self) -> Vec { (0..self.h).map(|y| self.line(y)).collect() } pub(crate) fn cells(&self) -> &[Cell] { &self.cells } } #[cfg(test)] mod tests { use super::*; #[test] fn colours_parse_in_every_shape_a_caller_writes_them() { assert_eq!(Color::parse("red"), Some(Color::Indexed(1))); assert_eq!(Color::parse("bright-blue"), Some(Color::Indexed(12))); assert_eq!(Color::parse("default"), Some(Color::Default)); assert_eq!(Color::parse("33"), Some(Color::Indexed(33))); assert_eq!(Color::parse("#ff6432"), Some(Color::Rgb(255, 100, 50))); assert_eq!(Color::parse("#f64"), Some(Color::Rgb(255, 102, 68))); assert_eq!(Color::parse("255,100,50"), Some(Color::Rgb(255, 100, 50))); } #[test] fn an_unreadable_colour_is_no_colour_rather_than_an_error() { assert_eq!(Color::parse("puce"), None); assert_eq!(Color::parse("#gg0000"), None); assert_eq!(Color::parse(""), None); } #[test] fn text_clips_to_the_width_it_was_given() { let mut screen = Screen::new(10, 2); screen.text(0, 0, 4, "abcdefg", Style::default()); assert_eq!(screen.line(0), "abcd"); } #[test] fn a_control_character_cannot_move_the_cursor() { let mut screen = Screen::new(6, 1); screen.text(0, 0, 6, "a\rb", Style::default()); assert_eq!(screen.line(0), "a·b"); } #[test] fn filling_a_rect_keeps_the_characters_under_it() { let mut screen = Screen::new(4, 1); screen.text(0, 0, 4, "hi", Style::default()); screen.fill(screen.rect(), Style::default().with(attr::REVERSE)); assert_eq!(screen.line(0), "hi"); assert!(screen.cell(0, 0).unwrap().style.has(attr::REVERSE)); } }