nandi/jolt-nativepublic Fork 0
789bb134c91bfdee346df2dc9656bf2e7f9bbd1a
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 · 448 lines · 14.6 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
280 pub fn set(&mut self, x: u16, y: u16, ch: char, style: Style) {
281 if x < self.w && y < self.h {
282 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 ago283 self.cells[i] = Cell {
284 ch,
Keep a glyph whole, selector and all, and copy it across whole 3dd441e nandi 16d ago285 tail: None,
Paint a reaction, and give an emoji the two columns it takes dce285f nandi 16d ago286 style,
287 trail: false,
288 };
289 }
290 }
291
292 /// The cell a double-width character's right half sits in.
293 fn set_trail(&mut self, x: u16, y: u16, style: Style) {
294 if x < self.w && y < self.h {
295 let i = y as usize * self.w as usize + x as usize;
296 self.cells[i] = Cell {
297 ch: ' ',
Keep a glyph whole, selector and all, and copy it across whole 3dd441e nandi 16d ago298 tail: None,
Paint a reaction, and give an emoji the two columns it takes dce285f nandi 16d ago299 style,
300 trail: true,
301 };
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago302 }
303 }
304
305 /// Write `text` at `x, y`, clipped to `width` columns. Answers how many
306 /// columns were used.
307 pub fn text(&mut self, x: u16, y: u16, width: u16, text: &str, style: Style) -> u16 {
308 let mut col = 0u16;
Keep a glyph whole, selector and all, and copy it across whole 3dd441e nandi 16d ago309 for glyph in glyphs(text) {
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago310 if col >= width {
311 break;
312 }
Keep a glyph whole, selector and all, and copy it across whole 3dd441e nandi 16d ago313 let mut chars = glyph.chars();
314 let ch = chars.next().unwrap_or(' ');
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago315 // A control character in a label would move the cursor; show it as
316 // a dot instead of letting it rearrange the screen.
317 let ch = if (ch as u32) < 0x20 { '·' } else { ch };
Keep a glyph whole, selector and all, and copy it across whole 3dd441e nandi 16d ago318 let cols = glyph_cols(&glyph);
Paint a reaction, and give an emoji the two columns it takes dce285f nandi 16d ago319 if col + cols > width {
320 // Half of a wide glyph is a different character, so the last
321 // column stays blank rather than showing one.
322 break;
323 }
Keep a glyph whole, selector and all, and copy it across whole 3dd441e nandi 16d ago324 let at = x.saturating_add(col);
325 self.set(at, y, ch, style);
326 let rest: String = chars.collect();
327 if !rest.is_empty() {
328 self.set_tail(at, y, rest);
329 }
Paint a reaction, and give an emoji the two columns it takes dce285f nandi 16d ago330 if cols == 2 {
331 self.set_trail(x.saturating_add(col + 1), y, style);
332 }
333 col += cols;
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago334 }
335 col
336 }
337
Keep a glyph whole, selector and all, and copy it across whole 3dd441e nandi 16d ago338 /// Put a whole cell down as it is — what a scroll does when it copies the
339 /// visible window of its content across. Cell by character would drop the
340 /// rest of a glyph and the right half of a wide one, which is a reply
341 /// arrow painted as the small mono arrow and a row a column out.
342 pub fn put(&mut self, x: u16, y: u16, cell: Cell) {
343 if x < self.w && y < self.h {
344 let i = y as usize * self.w as usize + x as usize;
345 self.cells[i] = cell;
346 }
347 }
348
349 /// The rest of a glyph, on the cell its first character went in.
350 fn set_tail(&mut self, x: u16, y: u16, rest: String) {
351 if x < self.w && y < self.h {
352 let i = y as usize * self.w as usize + x as usize;
353 self.cells[i].tail = Some(rest.into_boxed_str());
354 }
355 }
356
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago357 /// Paint every cell of `rect` with `style`, keeping the characters — that
358 /// is what a background is: a colour behind whatever is already there.
359 pub fn fill(&mut self, rect: Rect, style: Style) {
360 for y in rect.y..rect.y.saturating_add(rect.h) {
361 for x in rect.x..rect.x.saturating_add(rect.w) {
362 if x < self.w && y < self.h {
363 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 ago364 // The character stays, and so does whether it is the half
365 // of one: a background is a colour, not a repaint.
366 self.cells[i].style = style;
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago367 }
368 }
369 }
370 }
371
372 /// One row as text, trailing blanks trimmed. The whole reason the grid is
373 /// addressable: a test asserts on lines, not on escape sequences.
374 pub fn line(&self, y: u16) -> String {
375 if y >= self.h {
376 return String::new();
377 }
378 let start = y as usize * self.w as usize;
Paint a reaction, and give an emoji the two columns it takes dce285f nandi 16d ago379 // Without the trailing halves: they hold no character, and a reader —
380 // 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 ago381 let mut row = String::new();
382 for cell in self.cells[start..start + self.w as usize].iter() {
383 if cell.trail {
384 continue;
385 }
386 row.push(cell.ch);
387 if let Some(tail) = &cell.tail {
388 row.push_str(tail);
389 }
390 }
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago391 row.trim_end().to_owned()
392 }
393
394 #[cfg(test)]
395 pub fn lines(&self) -> Vec<String> {
396 (0..self.h).map(|y| self.line(y)).collect()
397 }
398
399 pub(crate) fn cells(&self) -> &[Cell] {
400 &self.cells
401 }
402}
403
404#[cfg(test)]
405mod tests {
406 use super::*;
407
408 #[test]
409 fn colours_parse_in_every_shape_a_caller_writes_them() {
410 assert_eq!(Color::parse("red"), Some(Color::Indexed(1)));
411 assert_eq!(Color::parse("bright-blue"), Some(Color::Indexed(12)));
412 assert_eq!(Color::parse("default"), Some(Color::Default));
413 assert_eq!(Color::parse("33"), Some(Color::Indexed(33)));
414 assert_eq!(Color::parse("#ff6432"), Some(Color::Rgb(255, 100, 50)));
415 assert_eq!(Color::parse("#f64"), Some(Color::Rgb(255, 102, 68)));
416 assert_eq!(Color::parse("255,100,50"), Some(Color::Rgb(255, 100, 50)));
417 }
418
419 #[test]
420 fn an_unreadable_colour_is_no_colour_rather_than_an_error() {
421 assert_eq!(Color::parse("puce"), None);
422 assert_eq!(Color::parse("#gg0000"), None);
423 assert_eq!(Color::parse(""), None);
424 }
425
426 #[test]
427 fn text_clips_to_the_width_it_was_given() {
428 let mut screen = Screen::new(10, 2);
429 screen.text(0, 0, 4, "abcdefg", Style::default());
430 assert_eq!(screen.line(0), "abcd");
431 }
432
433 #[test]
434 fn a_control_character_cannot_move_the_cursor() {
435 let mut screen = Screen::new(6, 1);
436 screen.text(0, 0, 6, "a\rb", Style::default());
437 assert_eq!(screen.line(0), "a·b");
438 }
439
440 #[test]
441 fn filling_a_rect_keeps_the_characters_under_it() {
442 let mut screen = Screen::new(4, 1);
443 screen.text(0, 0, 4, "hi", Style::default());
444 screen.fill(screen.rect(), Style::default().with(attr::REVERSE));
445 assert_eq!(screen.line(0), "hi");
446 assert!(screen.cell(0, 0).unwrap().style.has(attr::REVERSE));
447 }
448}