| 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 | |
| Keep a glyph whole, selector and all, and copy it across whole 3dd441e nandi 16d ago | 83 | /// True for a character that is part of the glyph before it rather than one of |
| 84 | /// its own: a variation selector, a zero-width joiner, a skin tone. |
| 85 | fn continues_glyph(ch: char) -> bool { |
| 86 | matches!(ch as u32, 0xFE00..=0xFE0F | 0x200D | 0x1F3FB..=0x1F3FF) |
| 87 | } |
| 88 | |
| 89 | /// Split `text` into what a terminal draws as single glyphs. |
| Paint a reaction, and give an emoji the two columns it takes dce285f nandi 16d ago | 90 | /// |
| Keep a glyph whole, selector and all, and copy it across whole 3dd441e nandi 16d ago | 91 | /// One character is usually one glyph, but an emoji is often several: `↩️` is |
| 92 | /// an arrow and a variation selector saying to draw it as a picture, and a |
| 93 | /// family is three people and the joiners between them. Cutting between those |
| 94 | /// characters is what makes a reply arrow come out as the small mono arrow |
| 95 | /// rather than the emoji — the selector says which of the two a terminal |
| 96 | /// draws, and it has to travel with the character it is about. |
| 97 | pub fn glyphs(text: &str) -> Vec<String> { |
| 98 | let mut out: Vec<String> = Vec::new(); |
| 99 | let mut joining = false; |
| 100 | for ch in text.chars() { |
| 101 | let continues = continues_glyph(ch); |
| 102 | if (continues || joining) && !out.is_empty() { |
| 103 | out.last_mut().expect("not empty").push(ch); |
| 104 | } else { |
| 105 | out.push(ch.to_string()); |
| 106 | } |
| 107 | // A joiner welds what follows it onto what came before. |
| 108 | joining = ch as u32 == 0x200D; |
| 109 | } |
| 110 | out |
| 111 | } |
| 112 | |
| 113 | /// How many columns one glyph takes on screen. |
| 114 | /// |
| 115 | /// Two for the emoji a terminal draws double width, one for everything else. |
| 116 | /// A character out of the emoji blocks is drawn wide on its own; an older |
| 117 | /// symbol or dingbat is drawn wide when it carries the selector that asks for |
| 118 | /// the picture, and narrow — a character among characters — when it does not. |
| 119 | /// This is the whole of what this backend knows about width, and it is enough |
| 120 | /// for what a chat client puts on a screen: text, and the emoji in it. |
| 121 | pub fn glyph_cols(glyph: &str) -> u16 { |
| 122 | let mut chars = glyph.chars(); |
| 123 | let Some(base) = chars.next() else { |
| 124 | return 0; |
| 125 | }; |
| 126 | let emoji_presentation = glyph.chars().any(|c| c as u32 == 0xFE0F); |
| 127 | match base as u32 { |
| Paint a reaction, and give an emoji the two columns it takes dce285f nandi 16d ago | 128 | 0x1F000.. => 2, |
| Keep a glyph whole, selector and all, and copy it across whole 3dd441e nandi 16d ago | 129 | 0x2000..=0x2BFF if emoji_presentation => 2, |
| Paint a reaction, and give an emoji the two columns it takes dce285f nandi 16d ago | 130 | _ => 1, |
| 131 | } |
| 132 | } |
| 133 | |
| 134 | /// The columns `text` takes, the same way [`Screen::text`] spends them. |
| 135 | pub fn text_cols(text: &str) -> u16 { |
| Keep a glyph whole, selector and all, and copy it across whole 3dd441e nandi 16d ago | 136 | glyphs(text).iter().map(|g| glyph_cols(g)).sum() |
| Paint a reaction, and give an emoji the two columns it takes dce285f nandi 16d ago | 137 | } |
| 138 | |
| Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago | 139 | /// The attribute bits a cell can carry. A `u8` rather than a set of `bool`s |
| 140 | /// because a cell is copied a great many times a frame. |
| 141 | pub mod attr { |
| 142 | pub const BOLD: u8 = 1 << 0; |
| 143 | pub const DIM: u8 = 1 << 1; |
| 144 | pub const UNDERLINE: u8 = 1 << 2; |
| 145 | pub const REVERSE: u8 = 1 << 3; |
| 146 | pub const BLINK: u8 = 1 << 4; |
| 147 | pub const ITALIC: u8 = 1 << 5; |
| 148 | } |
| 149 | |
| 150 | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] |
| 151 | pub struct Style { |
| 152 | pub fg: Color, |
| 153 | pub bg: Color, |
| 154 | pub attrs: u8, |
| 155 | } |
| 156 | |
| 157 | impl Style { |
| 158 | pub fn with(mut self, bits: u8) -> Self { |
| 159 | self.attrs |= bits; |
| 160 | self |
| 161 | } |
| 162 | |
| 163 | pub fn fg(mut self, color: Color) -> Self { |
| 164 | self.fg = color; |
| 165 | self |
| 166 | } |
| 167 | |
| 168 | pub fn has(&self, bits: u8) -> bool { |
| 169 | self.attrs & bits != 0 |
| 170 | } |
| 171 | } |
| 172 | |
| Keep a glyph whole, selector and all, and copy it across whole 3dd441e nandi 16d ago | 173 | #[derive(Clone, Debug, PartialEq, Eq)] |
| Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago | 174 | pub struct Cell { |
| 175 | pub ch: char, |
| Keep a glyph whole, selector and all, and copy it across whole 3dd441e nandi 16d ago | 176 | /// The rest of the glyph, where it took more than one character: the |
| 177 | /// selector that asks for a picture, the joiners in a family. Printed |
| 178 | /// straight after `ch`, which is the only way a terminal draws them as |
| 179 | /// the one glyph they are. |
| 180 | pub tail: Option<Box<str>>, |
| Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago | 181 | pub style: Style, |
| Paint a reaction, and give an emoji the two columns it takes dce285f nandi 16d ago | 182 | /// The right half of a double-width character. It holds no character of |
| 183 | /// its own: the glyph in the cell to its left is drawn across both, and |
| 184 | /// writing anything here would print a second copy one column over. |
| 185 | pub trail: bool, |
| Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago | 186 | } |
| 187 | |
| 188 | impl Default for Cell { |
| 189 | fn default() -> Self { |
| 190 | Self { |
| 191 | ch: ' ', |
| Keep a glyph whole, selector and all, and copy it across whole 3dd441e nandi 16d ago | 192 | tail: None, |
| Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago | 193 | style: Style::default(), |
| Paint a reaction, and give an emoji the two columns it takes dce285f nandi 16d ago | 194 | trail: false, |
| Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago | 195 | } |
| 196 | } |
| 197 | } |
| 198 | |
| 199 | /// A rectangle in cells. Columns and rows, origin top left. |
| 200 | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] |
| 201 | pub struct Rect { |
| 202 | pub x: u16, |
| 203 | pub y: u16, |
| 204 | pub w: u16, |
| 205 | pub h: u16, |
| 206 | } |
| 207 | |
| 208 | impl Rect { |
| 209 | pub fn new(x: u16, y: u16, w: u16, h: u16) -> Self { |
| 210 | Self { x, y, w, h } |
| 211 | } |
| 212 | |
| 213 | pub fn is_empty(&self) -> bool { |
| 214 | self.w == 0 || self.h == 0 |
| 215 | } |
| 216 | |
| 217 | pub fn contains(&self, x: u16, y: u16) -> bool { |
| 218 | x >= self.x && x < self.x + self.w && y >= self.y && y < self.y + self.h |
| 219 | } |
| 220 | |
| 221 | /// The rect left after taking `n` cells off every side. Saturating, so |
| 222 | /// padding larger than the rect answers an empty one rather than wrapping. |
| 223 | pub fn shrink(&self, n: u16) -> Self { |
| 224 | let take = n.saturating_mul(2); |
| 225 | Self { |
| 226 | x: self.x.saturating_add(n), |
| 227 | y: self.y.saturating_add(n), |
| 228 | w: self.w.saturating_sub(take), |
| 229 | h: self.h.saturating_sub(take), |
| 230 | } |
| 231 | } |
| 232 | } |
| 233 | |
| 234 | #[derive(Clone, Debug)] |
| 235 | pub struct Screen { |
| 236 | w: u16, |
| 237 | h: u16, |
| 238 | cells: Vec<Cell>, |
| 239 | } |
| 240 | |
| 241 | impl Screen { |
| 242 | pub fn new(w: u16, h: u16) -> Self { |
| 243 | Self { |
| 244 | w, |
| 245 | h, |
| 246 | cells: vec![Cell::default(); w as usize * h as usize], |
| 247 | } |
| 248 | } |
| 249 | |
| 250 | pub fn width(&self) -> u16 { |
| 251 | self.w |
| 252 | } |
| 253 | |
| 254 | pub fn height(&self) -> u16 { |
| 255 | self.h |
| 256 | } |
| 257 | |
| 258 | pub fn rect(&self) -> Rect { |
| 259 | Rect::new(0, 0, self.w, self.h) |
| 260 | } |
| 261 | |
| 262 | pub fn resize(&mut self, w: u16, h: u16) { |
| 263 | if (w, h) != (self.w, self.h) { |
| 264 | *self = Self::new(w, h); |
| 265 | } |
| 266 | } |
| 267 | |
| 268 | pub fn clear(&mut self) { |
| 269 | self.cells.fill(Cell::default()); |
| 270 | } |
| 271 | |
| 272 | pub fn cell(&self, x: u16, y: u16) -> Option<&Cell> { |
| 273 | if x < self.w && y < self.h { |
| 274 | self.cells.get(y as usize * self.w as usize + x as usize) |
| 275 | } else { |
| 276 | None |
| 277 | } |
| 278 | } |
| 279 | |
| Take a wide glyph off the grid whole, and stop trusting the cursor after one 48212a7 nandi 9d ago | 280 | /// Make room at `i` for a cell that is, or is not, the right half of a |
| 281 | /// wide glyph. Painting over half of a wide glyph takes the whole glyph |
| 282 | /// off a terminal, so the grid has to lose the other half too: a lead |
| 283 | /// left without its trail, or a trail without its lead, is a cell the |
| 284 | /// flush believes is on screen and a terminal has already erased — and |
| 285 | /// since it never changes again, it is never repainted. That is the |
| 286 | /// stray pencil left behind once a row's chips are gone. |
| 287 | fn displace(&mut self, i: usize, trail: bool) { |
| 288 | let x = i % self.w as usize; |
| 289 | if !trail && self.cells[i].trail && x > 0 { |
| 290 | let lead = &mut self.cells[i - 1]; |
| 291 | lead.ch = ' '; |
| 292 | lead.tail = None; |
| 293 | } |
| 294 | if x + 1 < self.w as usize && self.cells[i + 1].trail { |
| 295 | let rest = &mut self.cells[i + 1]; |
| 296 | rest.ch = ' '; |
| 297 | rest.tail = None; |
| 298 | rest.trail = false; |
| 299 | } |
| 300 | } |
| 301 | |
| Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago | 302 | pub fn set(&mut self, x: u16, y: u16, ch: char, style: Style) { |
| 303 | if x < self.w && y < self.h { |
| 304 | let i = y as usize * self.w as usize + x as usize; |
| Take a wide glyph off the grid whole, and stop trusting the cursor after one 48212a7 nandi 9d ago | 305 | self.displace(i, false); |
| Paint a reaction, and give an emoji the two columns it takes dce285f nandi 16d ago | 306 | self.cells[i] = Cell { |
| 307 | ch, |
| Keep a glyph whole, selector and all, and copy it across whole 3dd441e nandi 16d ago | 308 | tail: None, |
| Paint a reaction, and give an emoji the two columns it takes dce285f nandi 16d ago | 309 | style, |
| 310 | trail: false, |
| 311 | }; |
| 312 | } |
| 313 | } |
| 314 | |
| 315 | /// The cell a double-width character's right half sits in. |
| 316 | fn set_trail(&mut self, x: u16, y: u16, style: Style) { |
| 317 | if x < self.w && y < self.h { |
| 318 | let i = y as usize * self.w as usize + x as usize; |
| Take a wide glyph off the grid whole, and stop trusting the cursor after one 48212a7 nandi 9d ago | 319 | self.displace(i, true); |
| Paint a reaction, and give an emoji the two columns it takes dce285f nandi 16d ago | 320 | self.cells[i] = Cell { |
| 321 | ch: ' ', |
| Keep a glyph whole, selector and all, and copy it across whole 3dd441e nandi 16d ago | 322 | tail: None, |
| Paint a reaction, and give an emoji the two columns it takes dce285f nandi 16d ago | 323 | style, |
| 324 | trail: true, |
| 325 | }; |
| Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago | 326 | } |
| 327 | } |
| 328 | |
| 329 | /// Write `text` at `x, y`, clipped to `width` columns. Answers how many |
| 330 | /// columns were used. |
| 331 | pub fn text(&mut self, x: u16, y: u16, width: u16, text: &str, style: Style) -> u16 { |
| 332 | let mut col = 0u16; |
| Keep a glyph whole, selector and all, and copy it across whole 3dd441e nandi 16d ago | 333 | for glyph in glyphs(text) { |
| Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago | 334 | if col >= width { |
| 335 | break; |
| 336 | } |
| Keep a glyph whole, selector and all, and copy it across whole 3dd441e nandi 16d ago | 337 | let mut chars = glyph.chars(); |
| 338 | let ch = chars.next().unwrap_or(' '); |
| Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago | 339 | // A control character in a label would move the cursor; show it as |
| 340 | // a dot instead of letting it rearrange the screen. |
| 341 | let ch = if (ch as u32) < 0x20 { '·' } else { ch }; |
| Keep a glyph whole, selector and all, and copy it across whole 3dd441e nandi 16d ago | 342 | let cols = glyph_cols(&glyph); |
| Paint a reaction, and give an emoji the two columns it takes dce285f nandi 16d ago | 343 | if col + cols > width { |
| 344 | // Half of a wide glyph is a different character, so the last |
| 345 | // column stays blank rather than showing one. |
| 346 | break; |
| 347 | } |
| Keep a glyph whole, selector and all, and copy it across whole 3dd441e nandi 16d ago | 348 | let at = x.saturating_add(col); |
| 349 | self.set(at, y, ch, style); |
| 350 | let rest: String = chars.collect(); |
| 351 | if !rest.is_empty() { |
| 352 | self.set_tail(at, y, rest); |
| 353 | } |
| Paint a reaction, and give an emoji the two columns it takes dce285f nandi 16d ago | 354 | if cols == 2 { |
| 355 | self.set_trail(x.saturating_add(col + 1), y, style); |
| 356 | } |
| 357 | col += cols; |
| Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago | 358 | } |
| 359 | col |
| 360 | } |
| 361 | |
| Keep a glyph whole, selector and all, and copy it across whole 3dd441e nandi 16d ago | 362 | /// Put a whole cell down as it is — what a scroll does when it copies the |
| 363 | /// visible window of its content across. Cell by character would drop the |
| 364 | /// rest of a glyph and the right half of a wide one, which is a reply |
| 365 | /// arrow painted as the small mono arrow and a row a column out. |
| 366 | pub fn put(&mut self, x: u16, y: u16, cell: Cell) { |
| 367 | if x < self.w && y < self.h { |
| 368 | let i = y as usize * self.w as usize + x as usize; |
| Take a wide glyph off the grid whole, and stop trusting the cursor after one 48212a7 nandi 9d ago | 369 | self.displace(i, cell.trail); |
| Keep a glyph whole, selector and all, and copy it across whole 3dd441e nandi 16d ago | 370 | self.cells[i] = cell; |
| 371 | } |
| 372 | } |
| 373 | |
| 374 | /// The rest of a glyph, on the cell its first character went in. |
| 375 | fn set_tail(&mut self, x: u16, y: u16, rest: String) { |
| 376 | if x < self.w && y < self.h { |
| 377 | let i = y as usize * self.w as usize + x as usize; |
| 378 | self.cells[i].tail = Some(rest.into_boxed_str()); |
| 379 | } |
| 380 | } |
| 381 | |
| Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago | 382 | /// Paint every cell of `rect` with `style`, keeping the characters — that |
| 383 | /// is what a background is: a colour behind whatever is already there. |
| 384 | pub fn fill(&mut self, rect: Rect, style: Style) { |
| 385 | for y in rect.y..rect.y.saturating_add(rect.h) { |
| 386 | for x in rect.x..rect.x.saturating_add(rect.w) { |
| 387 | if x < self.w && y < self.h { |
| 388 | 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 | 389 | // The character stays, and so does whether it is the half |
| 390 | // of one: a background is a colour, not a repaint. |
| 391 | self.cells[i].style = style; |
| Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago | 392 | } |
| 393 | } |
| 394 | } |
| 395 | } |
| 396 | |
| 397 | /// One row as text, trailing blanks trimmed. The whole reason the grid is |
| 398 | /// addressable: a test asserts on lines, not on escape sequences. |
| 399 | pub fn line(&self, y: u16) -> String { |
| 400 | if y >= self.h { |
| 401 | return String::new(); |
| 402 | } |
| 403 | 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 | 404 | // Without the trailing halves: they hold no character, and a reader — |
| 405 | // a test, a bug report — wants the line as it looks. |
| Keep a glyph whole, selector and all, and copy it across whole 3dd441e nandi 16d ago | 406 | let mut row = String::new(); |
| 407 | for cell in self.cells[start..start + self.w as usize].iter() { |
| 408 | if cell.trail { |
| 409 | continue; |
| 410 | } |
| 411 | row.push(cell.ch); |
| 412 | if let Some(tail) = &cell.tail { |
| 413 | row.push_str(tail); |
| 414 | } |
| 415 | } |
| Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago | 416 | row.trim_end().to_owned() |
| 417 | } |
| 418 | |
| 419 | #[cfg(test)] |
| 420 | pub fn lines(&self) -> Vec<String> { |
| 421 | (0..self.h).map(|y| self.line(y)).collect() |
| 422 | } |
| 423 | |
| 424 | pub(crate) fn cells(&self) -> &[Cell] { |
| 425 | &self.cells |
| 426 | } |
| 427 | } |
| 428 | |
| 429 | #[cfg(test)] |
| 430 | mod tests { |
| 431 | use super::*; |
| 432 | |
| Take a wide glyph off the grid whole, and stop trusting the cursor after one 48212a7 nandi 9d ago | 433 | #[test] |
| 434 | fn painting_over_half_of_a_wide_glyph_takes_the_other_half_with_it() { |
| 435 | // Over the left half: the pencil's right half must not stay behind as |
| 436 | // a trail with nothing to its left. |
| 437 | let mut screen = Screen::new(6, 1); |
| 438 | screen.text(0, 0, 6, "a✏️b", Style::default()); |
| 439 | screen.set(1, 0, 'x', Style::default()); |
| 440 | assert_eq!(screen.line(0), "ax b"); |
| 441 | assert!(!screen.cell(2, 0).unwrap().trail); |
| 442 | |
| 443 | // Over the right half: the left half is blank, as a terminal shows it. |
| 444 | let mut screen = Screen::new(6, 1); |
| 445 | screen.text(0, 0, 6, "a✏️b", Style::default()); |
| 446 | screen.set(2, 0, 'x', Style::default()); |
| 447 | assert_eq!(screen.line(0), "a xb"); |
| 448 | assert!(screen.cell(1, 0).unwrap().tail.is_none()); |
| 449 | |
| 450 | // A wide glyph laid over the right half of another keeps only itself. |
| 451 | let mut screen = Screen::new(6, 1); |
| 452 | screen.text(0, 0, 6, "a✏️b", Style::default()); |
| 453 | screen.text(2, 0, 4, "🙂", Style::default()); |
| 454 | assert_eq!(screen.line(0), "a 🙂"); |
| 455 | } |
| 456 | |
| Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago | 457 | #[test] |
| 458 | fn colours_parse_in_every_shape_a_caller_writes_them() { |
| 459 | assert_eq!(Color::parse("red"), Some(Color::Indexed(1))); |
| 460 | assert_eq!(Color::parse("bright-blue"), Some(Color::Indexed(12))); |
| 461 | assert_eq!(Color::parse("default"), Some(Color::Default)); |
| 462 | assert_eq!(Color::parse("33"), Some(Color::Indexed(33))); |
| 463 | assert_eq!(Color::parse("#ff6432"), Some(Color::Rgb(255, 100, 50))); |
| 464 | assert_eq!(Color::parse("#f64"), Some(Color::Rgb(255, 102, 68))); |
| 465 | assert_eq!(Color::parse("255,100,50"), Some(Color::Rgb(255, 100, 50))); |
| 466 | } |
| 467 | |
| 468 | #[test] |
| 469 | fn an_unreadable_colour_is_no_colour_rather_than_an_error() { |
| 470 | assert_eq!(Color::parse("puce"), None); |
| 471 | assert_eq!(Color::parse("#gg0000"), None); |
| 472 | assert_eq!(Color::parse(""), None); |
| 473 | } |
| 474 | |
| 475 | #[test] |
| 476 | fn text_clips_to_the_width_it_was_given() { |
| 477 | let mut screen = Screen::new(10, 2); |
| 478 | screen.text(0, 0, 4, "abcdefg", Style::default()); |
| 479 | assert_eq!(screen.line(0), "abcd"); |
| 480 | } |
| 481 | |
| 482 | #[test] |
| 483 | fn a_control_character_cannot_move_the_cursor() { |
| 484 | let mut screen = Screen::new(6, 1); |
| 485 | screen.text(0, 0, 6, "a\rb", Style::default()); |
| 486 | assert_eq!(screen.line(0), "a·b"); |
| 487 | } |
| 488 | |
| 489 | #[test] |
| 490 | fn filling_a_rect_keeps_the_characters_under_it() { |
| 491 | let mut screen = Screen::new(4, 1); |
| 492 | screen.text(0, 0, 4, "hi", Style::default()); |
| 493 | screen.fill(screen.rect(), Style::default().with(attr::REVERSE)); |
| 494 | assert_eq!(screen.line(0), "hi"); |
| 495 | assert!(screen.cell(0, 0).unwrap().style.has(attr::REVERSE)); |
| 496 | } |
| 497 | } |