nandi/jolt-nativepublic Fork 0
258bbc5161b93d580a4c84363acabe63b0624e88
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 · 319 lines · 9.5 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
83/// The attribute bits a cell can carry. A `u8` rather than a set of `bool`s
84/// because a cell is copied a great many times a frame.
85pub mod attr {
86 pub const BOLD: u8 = 1 << 0;
87 pub const DIM: u8 = 1 << 1;
88 pub const UNDERLINE: u8 = 1 << 2;
89 pub const REVERSE: u8 = 1 << 3;
90 pub const BLINK: u8 = 1 << 4;
91 pub const ITALIC: u8 = 1 << 5;
92}
93
94#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
95pub struct Style {
96 pub fg: Color,
97 pub bg: Color,
98 pub attrs: u8,
99}
100
101impl Style {
102 pub fn with(mut self, bits: u8) -> Self {
103 self.attrs |= bits;
104 self
105 }
106
107 pub fn fg(mut self, color: Color) -> Self {
108 self.fg = color;
109 self
110 }
111
112 pub fn has(&self, bits: u8) -> bool {
113 self.attrs & bits != 0
114 }
115}
116
117#[derive(Clone, Copy, Debug, PartialEq, Eq)]
118pub struct Cell {
119 pub ch: char,
120 pub style: Style,
121}
122
123impl Default for Cell {
124 fn default() -> Self {
125 Self {
126 ch: ' ',
127 style: Style::default(),
128 }
129 }
130}
131
132/// A rectangle in cells. Columns and rows, origin top left.
133#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
134pub struct Rect {
135 pub x: u16,
136 pub y: u16,
137 pub w: u16,
138 pub h: u16,
139}
140
141impl Rect {
142 pub fn new(x: u16, y: u16, w: u16, h: u16) -> Self {
143 Self { x, y, w, h }
144 }
145
146 pub fn is_empty(&self) -> bool {
147 self.w == 0 || self.h == 0
148 }
149
150 pub fn contains(&self, x: u16, y: u16) -> bool {
151 x >= self.x && x < self.x + self.w && y >= self.y && y < self.y + self.h
152 }
153
154 /// The rect left after taking `n` cells off every side. Saturating, so
155 /// padding larger than the rect answers an empty one rather than wrapping.
156 pub fn shrink(&self, n: u16) -> Self {
157 let take = n.saturating_mul(2);
158 Self {
159 x: self.x.saturating_add(n),
160 y: self.y.saturating_add(n),
161 w: self.w.saturating_sub(take),
162 h: self.h.saturating_sub(take),
163 }
164 }
165}
166
167#[derive(Clone, Debug)]
168pub struct Screen {
169 w: u16,
170 h: u16,
171 cells: Vec<Cell>,
172}
173
174impl Screen {
175 pub fn new(w: u16, h: u16) -> Self {
176 Self {
177 w,
178 h,
179 cells: vec![Cell::default(); w as usize * h as usize],
180 }
181 }
182
183 pub fn width(&self) -> u16 {
184 self.w
185 }
186
187 pub fn height(&self) -> u16 {
188 self.h
189 }
190
191 pub fn rect(&self) -> Rect {
192 Rect::new(0, 0, self.w, self.h)
193 }
194
195 pub fn resize(&mut self, w: u16, h: u16) {
196 if (w, h) != (self.w, self.h) {
197 *self = Self::new(w, h);
198 }
199 }
200
201 pub fn clear(&mut self) {
202 self.cells.fill(Cell::default());
203 }
204
205 pub fn cell(&self, x: u16, y: u16) -> Option<&Cell> {
206 if x < self.w && y < self.h {
207 self.cells.get(y as usize * self.w as usize + x as usize)
208 } else {
209 None
210 }
211 }
212
213 pub fn set(&mut self, x: u16, y: u16, ch: char, style: Style) {
214 if x < self.w && y < self.h {
215 let i = y as usize * self.w as usize + x as usize;
216 self.cells[i] = Cell { ch, style };
217 }
218 }
219
220 /// Write `text` at `x, y`, clipped to `width` columns. Answers how many
221 /// columns were used.
222 pub fn text(&mut self, x: u16, y: u16, width: u16, text: &str, style: Style) -> u16 {
223 let mut col = 0u16;
224 for ch in text.chars() {
225 if col >= width {
226 break;
227 }
228 // A control character in a label would move the cursor; show it as
229 // a dot instead of letting it rearrange the screen.
230 let ch = if (ch as u32) < 0x20 { '·' } else { ch };
231 self.set(x.saturating_add(col), y, ch, style);
232 col += 1;
233 }
234 col
235 }
236
237 /// Paint every cell of `rect` with `style`, keeping the characters — that
238 /// is what a background is: a colour behind whatever is already there.
239 pub fn fill(&mut self, rect: Rect, style: Style) {
240 for y in rect.y..rect.y.saturating_add(rect.h) {
241 for x in rect.x..rect.x.saturating_add(rect.w) {
242 if x < self.w && y < self.h {
243 let i = y as usize * self.w as usize + x as usize;
244 let ch = self.cells[i].ch;
245 self.cells[i] = Cell { ch, style };
246 }
247 }
248 }
249 }
250
251 /// One row as text, trailing blanks trimmed. The whole reason the grid is
252 /// addressable: a test asserts on lines, not on escape sequences.
253 pub fn line(&self, y: u16) -> String {
254 if y >= self.h {
255 return String::new();
256 }
257 let start = y as usize * self.w as usize;
258 let row: String = self.cells[start..start + self.w as usize]
259 .iter()
260 .map(|c| c.ch)
261 .collect();
262 row.trim_end().to_owned()
263 }
264
265 #[cfg(test)]
266 pub fn lines(&self) -> Vec<String> {
267 (0..self.h).map(|y| self.line(y)).collect()
268 }
269
270 pub(crate) fn cells(&self) -> &[Cell] {
271 &self.cells
272 }
273}
274
275#[cfg(test)]
276mod tests {
277 use super::*;
278
279 #[test]
280 fn colours_parse_in_every_shape_a_caller_writes_them() {
281 assert_eq!(Color::parse("red"), Some(Color::Indexed(1)));
282 assert_eq!(Color::parse("bright-blue"), Some(Color::Indexed(12)));
283 assert_eq!(Color::parse("default"), Some(Color::Default));
284 assert_eq!(Color::parse("33"), Some(Color::Indexed(33)));
285 assert_eq!(Color::parse("#ff6432"), Some(Color::Rgb(255, 100, 50)));
286 assert_eq!(Color::parse("#f64"), Some(Color::Rgb(255, 102, 68)));
287 assert_eq!(Color::parse("255,100,50"), Some(Color::Rgb(255, 100, 50)));
288 }
289
290 #[test]
291 fn an_unreadable_colour_is_no_colour_rather_than_an_error() {
292 assert_eq!(Color::parse("puce"), None);
293 assert_eq!(Color::parse("#gg0000"), None);
294 assert_eq!(Color::parse(""), None);
295 }
296
297 #[test]
298 fn text_clips_to_the_width_it_was_given() {
299 let mut screen = Screen::new(10, 2);
300 screen.text(0, 0, 4, "abcdefg", Style::default());
301 assert_eq!(screen.line(0), "abcd");
302 }
303
304 #[test]
305 fn a_control_character_cannot_move_the_cursor() {
306 let mut screen = Screen::new(6, 1);
307 screen.text(0, 0, 6, "a\rb", Style::default());
308 assert_eq!(screen.line(0), "a·b");
309 }
310
311 #[test]
312 fn filling_a_rect_keeps_the_characters_under_it() {
313 let mut screen = Screen::new(4, 1);
314 screen.text(0, 0, 4, "hi", Style::default());
315 screen.fill(screen.rect(), Style::default().with(attr::REVERSE));
316 assert_eq!(screen.line(0), "hi");
317 assert!(screen.cell(0, 0).unwrap().style.has(attr::REVERSE));
318 }
319}