nandi/jolt-nativepublic Fork 0
a7f62025fc9a5a5db4edb9a6ba6808dc31f7596b
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.

paint.rs · 438 lines · 16.6 KBRust Blame HistoryRaw
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago1//! Drawing the tree into a grid of cells.
2//!
3//! One pass, top to bottom: each node is handed a rect by [`crate::layout`] and
4//! paints itself into it. Two things fall out of the walk and are kept —
5//! the focus ring, in the order the widgets were painted, and every focusable
6//! widget's rect, so a mouse click can be turned back into a node.
7//!
8//! Overlays are collected rather than drawn in place: a floating panel belongs
9//! over the whole screen, so it is painted after everything else at the size it
10//! asked for, in the middle.
11
12use crate::layout::{self, wrap, Align};
13use crate::screen::{attr, Color, Rect, Screen, Style};
14use crate::tree::{Props, Tag, Tree};
15
16const SPINNER: [char; 8] = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧'];
17
18/// What one frame of painting learned about the tree, for the input half to
19/// use on the next key or click.
20#[derive(Clone, Debug, Default)]
21pub struct Painted {
22 /// Focusable nodes in paint order — the order Tab walks.
23 pub ring: Vec<u32>,
24 /// Where each of them ended up.
25 pub hits: Vec<(u32, Rect)>,
26 /// How far each scroll node's viewport actually was, after clamping to the
27 /// content it had. Written back so a caller cannot scroll past the end.
28 pub scrolled: Vec<(u32, u16)>,
29 /// Where the cursor should sit — the focused entry's caret, if any.
30 pub cursor: Option<(u16, u16)>,
31}
32
33struct Painter<'a> {
34 tree: &'a Tree,
35 screen: &'a mut Screen,
36 focus: u32,
37 /// Where the caret sits in the focused entry's text, in characters.
38 caret: usize,
39 tick: u64,
40 out: Painted,
41 overlays: Vec<u32>,
42}
43
44/// Paint the whole tree. `focus` is the node the ring is currently on and
45/// `tick` advances the spinners.
46pub fn frame(tree: &Tree, screen: &mut Screen, focus: u32, caret: usize, tick: u64) -> Painted {
47 screen.clear();
48 let mut painter = Painter {
49 tree,
50 screen,
51 focus,
52 caret,
53 tick,
54 out: Painted::default(),
55 overlays: Vec::new(),
56 };
57 let area = painter.screen.rect();
58 painter.node(tree.root(), area, Style::default(), true);
59
60 // Overlays float above the rest, so they are painted after it — and a
61 // click landing on one must beat a click on whatever it covers, which is
62 // what putting their hit rects first does.
63 let overlays = std::mem::take(&mut painter.overlays);
64 let below = std::mem::take(&mut painter.out.hits);
65 for id in overlays {
66 painter.overlay(id, area);
67 }
68 painter.out.hits.extend(below);
69 painter.out
70}
71
72impl Painter<'_> {
73 fn style_for(&self, props: &Props, inherited: Style, enabled: bool) -> Style {
74 let mut style = inherited;
75 if let Some(fg) = Color::parse(props.str("color")) {
76 style.fg = fg;
77 }
78 if let Some(bg) = Color::parse(props.str("bg")) {
79 style.bg = bg;
80 }
81 for (key, bit) in [
82 ("bold", attr::BOLD),
83 ("dim", attr::DIM),
84 ("underline", attr::UNDERLINE),
85 ("reverse", attr::REVERSE),
86 ("blink", attr::BLINK),
87 ("italic", attr::ITALIC),
88 ] {
89 if props.bool(key, false) {
90 style.attrs |= bit;
91 }
92 }
93 if !enabled {
94 // `:sensitive false` dims the widget *and its whole subtree*, which
95 // is what it means in every other glimmer backend.
96 style.attrs |= attr::DIM;
97 }
98 style
99 }
100
101 fn node(&mut self, id: u32, area: Rect, inherited: Style, enabled: bool) {
102 if area.is_empty() || !self.tree.exists(id) {
103 return;
104 }
105 let tag = self.tree.tag(id);
106 let props = self.tree.props(id);
107 let enabled = enabled && props.bool("sensitive", true);
108 let style = self.style_for(&props, inherited, enabled);
109 if props.has("bg") {
110 self.screen.fill(area, style);
111 }
112 if enabled && tag.focusable() {
113 self.out.ring.push(id);
114 self.out.hits.push((id, area));
115 }
116
117 let pad = layout::inset(&tag, &props);
118 let inner = area.shrink(pad);
119 match tag {
120 Tag::Overlay => self.overlays.push(id),
121 Tag::Frame => {
122 self.border(area, props.label(), style);
123 self.children(id, inner, style, enabled);
124 }
125 Tag::Scroll => self.scroll(id, inner, style, enabled),
126 Tag::Box | Tag::Window | Tag::Unknown(_) => self.children(id, inner, style, enabled),
127 Tag::Label => self.wrapped(inner, props.label(), style),
128 Tag::Title => self.wrapped(inner, props.label(), style.with(attr::BOLD)),
129 Tag::DimLabel => self.wrapped(inner, props.label(), style.with(attr::DIM)),
130 Tag::Button => self.button(id, inner, &props, style),
131 Tag::CheckButton => self.check(id, inner, &props, style),
132 Tag::Entry => self.entry(id, inner, &props, style),
133 Tag::Separator => self.separator(inner, style),
134 Tag::Progress => self.progress(inner, &props, style),
135 Tag::Spinner => {
136 let ch = SPINNER[(self.tick as usize) % SPINNER.len()];
137 self.screen.set(inner.x, inner.y, ch, style);
138 }
139 Tag::Listbox => self.listbox(id, inner, &props, style, enabled),
140 // A spacer is the absence of anything; the clear at the top of the
141 // frame has already drawn it.
142 Tag::Spacer => {}
143 }
144 }
145
146 fn children(&mut self, id: u32, area: Rect, style: Style, enabled: bool) {
147 if area.is_empty() {
148 return;
149 }
150 let rects = layout::children_rects(self.tree, id, area);
151 for (child, rect) in self.tree.children(id).into_iter().zip(rects) {
152 // Clip to the parent: a child asking for more rows than are left
153 // paints what fits rather than over its neighbours.
154 let bottom = area.y.saturating_add(area.h);
155 let right = area.x.saturating_add(area.w);
156 if rect.y >= bottom || rect.x >= right {
157 continue;
158 }
159 let clipped = Rect::new(
160 rect.x,
161 rect.y,
162 rect.w.min(right - rect.x),
163 rect.h.min(bottom - rect.y),
164 );
165 self.node(child, clipped, style, enabled);
166 }
167 }
168
169 fn wrapped(&mut self, area: Rect, text: &str, style: Style) {
170 for (i, line) in wrap(text, area.w).into_iter().enumerate() {
171 if i as u16 >= area.h {
172 break;
173 }
174 self.screen
175 .text(area.x, area.y + i as u16, area.w, &line, style);
176 }
177 }
178
179 fn focused(&self, id: u32) -> bool {
180 self.focus == id
181 }
182
183 fn button(&mut self, id: u32, area: Rect, props: &Props, style: Style) {
184 let mut style = match props.str("kind") {
185 "primary" => style.with(attr::BOLD),
186 "destructive" => style.fg(Color::parse("red").unwrap_or_default()),
187 _ => style,
188 };
189 if self.focused(id) {
190 style = style.with(attr::REVERSE);
191 }
192 let label = format!("[ {} ]", props.label());
193 self.screen.text(area.x, area.y, area.w, &label, style);
194 }
195
196 fn check(&mut self, id: u32, area: Rect, props: &Props, style: Style) {
197 let style = if self.focused(id) {
198 style.with(attr::REVERSE)
199 } else {
200 style
201 };
202 let mark = if props.bool("active", false) {
203 'x'
204 } else {
205 ' '
206 };
207 let label = format!("[{mark}] {}", props.label());
208 self.screen.text(area.x, area.y, area.w, &label, style);
209 }
210
211 fn entry(&mut self, id: u32, area: Rect, props: &Props, style: Style) {
212 let focused = self.focused(id);
213 let text = props.str("text");
214 let showing_placeholder = text.is_empty();
215 let shown = layout::entry_text(props);
216 let mut style = style.with(attr::UNDERLINE);
217 if showing_placeholder {
218 style = style.with(attr::DIM);
219 }
220 if focused {
221 style = style.with(attr::REVERSE);
222 }
223 // The field is its whole rect, not just the text in it: a reader needs
224 // to see where it can type before it has typed anything.
225 self.screen.fill(area, style);
226 let rows = area.h.max(1);
227 let lines = if props.cells("rows", 1) > 1 {
228 wrap(&shown, area.w)
229 } else {
230 vec![shown.chars().collect::<String>()]
231 };
232 let caret = self.caret.min(text.chars().count());
233 // A line longer than the field scrolls sideways to keep the caret in
234 // view — the end of it is where someone is usually typing, but not
235 // always, so it follows the caret rather than the end.
236 for (i, line) in lines.iter().take(rows as usize).enumerate() {
237 let len = line.chars().count();
238 let last = i + 1 == lines.len().min(rows as usize);
239 let window = area.w.saturating_sub(1).max(1) as usize;
240 let from = if last && !showing_placeholder {
241 caret.saturating_sub(window)
242 } else {
243 len.saturating_sub(window)
244 };
245 let visible: String = line.chars().skip(from).collect();
246 self.screen
247 .text(area.x, area.y + i as u16, area.w, &visible, style);
248 if focused && last {
249 let col = if showing_placeholder {
250 0
251 } else {
252 caret
253 .saturating_sub(from)
254 .min(area.w.saturating_sub(1) as usize)
255 };
256 self.out.cursor = Some((area.x.saturating_add(col as u16), area.y + i as u16));
257 }
258 }
259 }
260
261 fn separator(&mut self, area: Rect, style: Style) {
262 for x in area.x..area.x.saturating_add(area.w) {
263 self.screen.set(x, area.y, '─', style);
264 }
265 }
266
267 fn progress(&mut self, area: Rect, props: &Props, style: Style) {
268 let value = props.num("value", 0.0).clamp(0.0, 1.0);
269 let filled = (value * area.w as f64).round() as u16;
270 for x in 0..area.w {
271 let ch = if x < filled { '█' } else { '░' };
272 self.screen.set(area.x + x, area.y, ch, style);
273 }
274 let label = props.label();
275 if !label.is_empty() {
276 let at = area.x + (area.w.saturating_sub(label.chars().count() as u16)) / 2;
277 self.screen.text(at, area.y, area.w, label, style);
278 }
279 }
280
281 fn listbox(&mut self, id: u32, area: Rect, props: &Props, style: Style, enabled: bool) {
282 let items = self.tree.children(id);
283 // No `:selected` at all means the cursor is on the first row: a list
284 // with no cursor cannot be moved with the arrows, and a caller that
285 // wants none says so with -1.
286 let selected = props.num("selected", 0.0);
287 let selected = if selected < 0.0 {
288 None
289 } else {
290 Some(selected as usize)
291 };
292 // Keep the cursor on screen: scroll only as far as it takes.
293 let rows = area.h as usize;
294 let first = match selected {
295 Some(sel) if rows > 0 && sel >= rows => sel + 1 - rows,
296 _ => 0,
297 };
298 for (row, item) in items.iter().skip(first).take(rows).enumerate() {
299 let y = area.y + row as u16;
300 let chosen = selected == Some(first + row);
301 let mut row_style = style;
302 if chosen {
303 row_style = row_style.with(if self.focused(id) {
304 attr::REVERSE
305 } else {
306 attr::BOLD
307 });
308 self.screen.fill(Rect::new(area.x, y, area.w, 1), row_style);
309 }
310 let marker = if chosen { "" } else { " " };
311 self.screen.text(area.x, y, area.w, marker, row_style);
312 let cell = Rect::new(area.x + 2, y, area.w.saturating_sub(2), 1);
313 self.node(*item, cell, row_style, enabled);
314 }
315 }
316
317 fn scroll(&mut self, id: u32, area: Rect, style: Style, enabled: bool) {
318 let props = self.tree.props(id);
319 // The content is painted at its full height into a screen of its own,
320 // then the visible window of it is copied across. Doing it this way
321 // means every widget inside a scroll paints exactly as it would
322 // outside one — nothing has to know it is being clipped.
323 let content_h = self
324 .tree
325 .children(id)
326 .iter()
327 .map(|c| layout::height_for_width(self.tree, *c, area.w))
328 .sum::<u16>()
329 .max(1);
330 let max_offset = content_h.saturating_sub(area.h);
331 let offset = props.cells("offset", 0).min(max_offset);
332 self.out.scrolled.push((id, offset));
333
334 let mut buffer = Screen::new(area.w, content_h);
335 let mut inner = Painter {
336 tree: self.tree,
337 screen: &mut buffer,
338 focus: self.focus,
339 caret: self.caret,
340 tick: self.tick,
341 out: Painted::default(),
342 overlays: Vec::new(),
343 };
344 let full = Rect::new(0, 0, area.w, content_h);
345 inner.children(id, full, style, enabled);
346 let learned = inner.out;
347
348 for y in 0..area.h {
349 for x in 0..area.w {
350 if let Some(cell) = buffer.cell(x, y + offset) {
351 self.screen.set(area.x + x, area.y + y, cell.ch, cell.style);
352 }
353 }
354 }
355 // Widgets inside keep their place in the focus ring; their rects move
356 // by the viewport, and the ones scrolled out of sight take no clicks.
357 self.out.ring.extend(learned.ring);
358 for (node, rect) in learned.hits {
359 if rect.y >= offset && rect.y < offset.saturating_add(area.h) {
360 self.out.hits.push((
361 node,
362 Rect::new(
363 area.x + rect.x,
364 area.y + rect.y - offset,
365 rect.w,
366 rect.h.min(area.h),
367 ),
368 ));
369 }
370 }
371 self.out.scrolled.extend(learned.scrolled);
372 if let Some((cx, cy)) = learned.cursor {
373 if cy >= offset && cy < offset.saturating_add(area.h) {
374 self.out.cursor = Some((area.x + cx, area.y + cy - offset));
375 }
376 }
377 }
378
379 fn overlay(&mut self, id: u32, screen: Rect) {
380 let props = self.tree.props(id);
381 let w = layout::width(self.tree, id, false).min(screen.w);
382 let h = layout::height_for_width(self.tree, id, w).min(screen.h);
383 let (x, y) = (
384 screen.x + Align::Center.offset_pub(w, screen.w),
385 screen.y + Align::Center.offset_pub(h, screen.h),
386 );
387 let area = Rect::new(x, y, w, h);
388 let style = self.style_for(&props, Style::default(), true);
389 // Blank what is under it: a floating panel that shows the screen
390 // through its gaps is unreadable.
391 for row in area.y..area.y + area.h {
392 for col in area.x..area.x + area.w {
393 self.screen.set(col, row, ' ', style);
394 }
395 }
396 self.border(area, props.label(), style);
397 let pad = layout::inset(&Tag::Overlay, &props);
398 self.children(id, area.shrink(pad), style, true);
399 }
400
401 /// A single-line box, with `label` set into the top edge when there is one.
402 fn border(&mut self, area: Rect, label: &str, style: Style) {
403 if area.w < 2 || area.h < 2 {
404 return;
405 }
406 let (x1, y1) = (area.x + area.w - 1, area.y + area.h - 1);
407 for x in area.x..=x1 {
408 self.screen.set(x, area.y, '─', style);
409 self.screen.set(x, y1, '─', style);
410 }
411 for y in area.y..=y1 {
412 self.screen.set(area.x, y, '│', style);
413 self.screen.set(x1, y, '│', style);
414 }
415 self.screen.set(area.x, area.y, '┌', style);
416 self.screen.set(x1, area.y, '┐', style);
417 self.screen.set(area.x, y1, '└', style);
418 self.screen.set(x1, y1, '┘', style);
419 if !label.is_empty() && area.w > 4 {
420 let text = format!(" {label} ");
421 self.screen.text(
422 area.x + 1,
423 area.y,
424 area.w - 2,
425 &text,
426 style.with(attr::BOLD),
427 );
428 }
429 }
430}
431
432impl Align {
433 /// [`Align::offset`] is private to the layout module; overlays are the one
434 /// caller outside it that centres something by hand.
435 fn offset_pub(self, size: u16, avail: u16) -> u16 {
436 layout::place(self, size, avail).0
437 }
438}