nandi/jolt-nativepublic Fork 0
4706c920e45ce80b11ee106d05c16d9eacc99fc7
Commits
Clone
git clone https://git.rickub.com/nandi/jolt-native.git
git clone ssh://git@rickub.com/nandi/jolt-native.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

screen.rs · 497 lines · 16.8 KBRust Blame HistoryRaw
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago1//! 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)]
14pub 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
22impl 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 ago83/// 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.
85fn 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 ago90///
Keep a glyph whole, selector and all, and copy it across whole 3dd441e nandi 16d ago91/// 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.
97pub 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.
121pub 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 ago128 0x1F000.. => 2,
Keep a glyph whole, selector and all, and copy it across whole 3dd441e nandi 16d ago129 0x2000..=0x2BFF if emoji_presentation => 2,
Paint a reaction, and give an emoji the two columns it takes dce285f nandi 16d ago130 _ => 1,
131 }
132}
133
134/// The columns `text` takes, the same way [`Screen::text`] spends them.
135pub fn text_cols(text: &str) -> u16 {
Keep a glyph whole, selector and all, and copy it across whole 3dd441e nandi 16d ago136 glyphs(text).iter().map(|g| glyph_cols(g)).sum()
Paint a reaction, and give an emoji the two columns it takes dce285f nandi 16d ago137}
138
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago139/// 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.
141pub 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)]
151pub struct Style {
152 pub fg: Color,
153 pub bg: Color,
154 pub attrs: u8,
155}
156
157impl 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 ago173#[derive(Clone, Debug, PartialEq, Eq)]
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago174pub struct Cell {
175 pub ch: char,
Keep a glyph whole, selector and all, and copy it across whole 3dd441e nandi 16d ago176 /// 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 ago181 pub style: Style,
Paint a reaction, and give an emoji the two columns it takes dce285f nandi 16d ago182 /// 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 ago186}
187
188impl 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 ago192 tail: None,
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago193 style: Style::default(),
Paint a reaction, and give an emoji the two columns it takes dce285f nandi 16d ago194 trail: false,
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago195 }
196 }
197}
198
199/// A rectangle in cells. Columns and rows, origin top left.
200#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
201pub struct Rect {
202 pub x: u16,
203 pub y: u16,
204 pub w: u16,
205 pub h: u16,
206}
207
208impl 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)]
235pub struct Screen {
236 w: u16,
237 h: u16,
238 cells: Vec<Cell>,
239}
240
241impl 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 ago280 /// 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 ago302 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 ago305 self.displace(i, false);
Paint a reaction, and give an emoji the two columns it takes dce285f nandi 16d ago306 self.cells[i] = Cell {
307 ch,
Keep a glyph whole, selector and all, and copy it across whole 3dd441e nandi 16d ago308 tail: None,
Paint a reaction, and give an emoji the two columns it takes dce285f nandi 16d ago309 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 ago319 self.displace(i, true);
Paint a reaction, and give an emoji the two columns it takes dce285f nandi 16d ago320 self.cells[i] = Cell {
321 ch: ' ',
Keep a glyph whole, selector and all, and copy it across whole 3dd441e nandi 16d ago322 tail: None,
Paint a reaction, and give an emoji the two columns it takes dce285f nandi 16d ago323 style,
324 trail: true,
325 };
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago326 }
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 ago333 for glyph in glyphs(text) {
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago334 if col >= width {
335 break;
336 }
Keep a glyph whole, selector and all, and copy it across whole 3dd441e nandi 16d ago337 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 ago339 // 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 ago342 let cols = glyph_cols(&glyph);
Paint a reaction, and give an emoji the two columns it takes dce285f nandi 16d ago343 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 ago348 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 ago354 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 ago358 }
359 col
360 }
361
Keep a glyph whole, selector and all, and copy it across whole 3dd441e nandi 16d ago362 /// 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 ago369 self.displace(i, cell.trail);
Keep a glyph whole, selector and all, and copy it across whole 3dd441e nandi 16d ago370 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 ago382 /// 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 ago389 // 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 ago392 }
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 ago404 // 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 ago406 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 ago416 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)]
430mod tests {
431 use super::*;
432
Take a wide glyph off the grid whole, and stop trusting the cursor after one 48212a7 nandi 9d ago433 #[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 ago457 #[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}