nandi/jolt-nativepublic Fork 0
dce285fb5a5ec1f331b8afa7b2bdc4ed5e1bbd46
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 · 381 lines · 11.9 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
Paint a reaction, and give an emoji the two columns it takes dce285f nandi 16d ago83/// 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.
90pub 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.
101pub 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 ago105/// 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.
107pub 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)]
117pub struct Style {
118 pub fg: Color,
119 pub bg: Color,
120 pub attrs: u8,
121}
122
123impl 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)]
140pub 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 ago143 /// 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 ago147}
148
149impl 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 ago154 trail: false,
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago155 }
156 }
157}
158
159/// A rectangle in cells. Columns and rows, origin top left.
160#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
161pub struct Rect {
162 pub x: u16,
163 pub y: u16,
164 pub w: u16,
165 pub h: u16,
166}
167
168impl 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)]
195pub struct Screen {
196 w: u16,
197 h: u16,
198 cells: Vec<Cell>,
199}
200
201impl 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 ago243 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 ago260 }
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 ago274 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 ago286 self.set(x.saturating_add(col), y, ch, style);
Paint a reaction, and give an emoji the two columns it takes dce285f nandi 16d ago287 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 ago291 }
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 ago302 // 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 ago305 }
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 ago317 // 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 ago319 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 ago321 .filter(|c| !c.trail)
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago322 .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)]
338mod 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}