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.

paint.rs · 509 lines · 20.1 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};
Paint a reaction, and give an emoji the two columns it takes dce285f nandi 16d ago13use crate::screen::{self, attr, Color, Rect, Screen, Style};
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago14use 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.
Let a backlog keep its place, and leave room for what is under it 68910bd nandi 17d ago28 /// Each `:scroll` painted, as (node, the offset it was painted at, the
Scroll the list under the pointer, and from where it actually is a785201 nandi 16d ago29 /// furthest it could have been, and the area it was painted into). The
30 /// second number is what tells a caller whether it is at the bottom, which
31 /// is what sticking to it means; the rect is what a wheel is aimed at.
32 pub scrolled: Vec<(u32, u16, u16, Rect)>,
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago33 /// Where the cursor should sit — the focused entry's caret, if any.
34 pub cursor: Option<(u16, u16)>,
35}
36
37struct Painter<'a> {
38 tree: &'a Tree,
39 screen: &'a mut Screen,
40 focus: u32,
41 /// Where the caret sits in the focused entry's text, in characters.
42 caret: usize,
43 tick: u64,
44 out: Painted,
45 overlays: Vec<u32>,
46}
47
48/// Paint the whole tree. `focus` is the node the ring is currently on and
49/// `tick` advances the spinners.
50pub fn frame(tree: &Tree, screen: &mut Screen, focus: u32, caret: usize, tick: u64) -> Painted {
51 screen.clear();
52 let mut painter = Painter {
53 tree,
54 screen,
55 focus,
56 caret,
57 tick,
58 out: Painted::default(),
59 overlays: Vec::new(),
60 };
61 let area = painter.screen.rect();
62 painter.node(tree.root(), area, Style::default(), true);
63
64 // Overlays float above the rest, so they are painted after it — and a
65 // click landing on one must beat a click on whatever it covers, which is
66 // what putting their hit rects first does.
67 let overlays = std::mem::take(&mut painter.overlays);
68 let below = std::mem::take(&mut painter.out.hits);
69 for id in overlays {
70 painter.overlay(id, area);
71 }
72 painter.out.hits.extend(below);
73 painter.out
74}
75
76impl Painter<'_> {
77 fn style_for(&self, props: &Props, inherited: Style, enabled: bool) -> Style {
78 let mut style = inherited;
79 if let Some(fg) = Color::parse(props.str("color")) {
80 style.fg = fg;
81 }
82 if let Some(bg) = Color::parse(props.str("bg")) {
83 style.bg = bg;
84 }
85 for (key, bit) in [
86 ("bold", attr::BOLD),
87 ("dim", attr::DIM),
88 ("underline", attr::UNDERLINE),
89 ("reverse", attr::REVERSE),
90 ("blink", attr::BLINK),
91 ("italic", attr::ITALIC),
92 ] {
93 if props.bool(key, false) {
94 style.attrs |= bit;
95 }
96 }
97 if !enabled {
98 // `:sensitive false` dims the widget *and its whole subtree*, which
99 // is what it means in every other glimmer backend.
100 style.attrs |= attr::DIM;
101 }
102 style
103 }
104
105 fn node(&mut self, id: u32, area: Rect, inherited: Style, enabled: bool) {
106 if area.is_empty() || !self.tree.exists(id) {
107 return;
108 }
109 let tag = self.tree.tag(id);
110 let props = self.tree.props(id);
111 let enabled = enabled && props.bool("sensitive", true);
112 let style = self.style_for(&props, inherited, enabled);
113 if props.has("bg") {
114 self.screen.fill(area, style);
115 }
116 if enabled && tag.focusable() {
117 self.out.ring.push(id);
118 self.out.hits.push((id, area));
119 }
120
121 let pad = layout::inset(&tag, &props);
122 let inner = area.shrink(pad);
123 match tag {
124 Tag::Overlay => self.overlays.push(id),
125 Tag::Frame => {
126 self.border(area, props.label(), style);
127 self.children(id, inner, style, enabled);
128 }
129 Tag::Scroll => self.scroll(id, inner, style, enabled),
Share a column's rows out at the width it has, not at its own height 5acc801 nandi 17d ago130 Tag::Box | Tag::Window => self.children(id, inner, style, enabled),
131 // A tag this backend has not learned paints as a vertical box, so
132 // whatever is under it still reaches the screen. When there is
133 // nothing under it, its own text does instead: an unknown *leaf*
134 // is a widget the caller has and this has not — frq's `:status`
135 // badge, its `:link` — and painting the box and not the label is
136 // the one outcome that loses the text altogether. A link vanishing
137 // out of the middle of a message is not a missing widget; it is a
138 // missing sentence.
139 Tag::Unknown(_) => {
Let a backlog keep its place, and leave room for what is under it 68910bd nandi 17d ago140 if self.tree.child_count(id) == 0 && !props.has("src") && !props.has("feed") {
Share a column's rows out at the width it has, not at its own height 5acc801 nandi 17d ago141 self.wrapped(inner, props.label(), style);
142 } else {
143 self.children(id, inner, style, enabled);
144 }
145 }
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago146 Tag::Label => self.wrapped(inner, props.label(), style),
147 Tag::Title => self.wrapped(inner, props.label(), style.with(attr::BOLD)),
148 Tag::DimLabel => self.wrapped(inner, props.label(), style.with(attr::DIM)),
149 Tag::Button => self.button(id, inner, &props, style),
150 Tag::CheckButton => self.check(id, inner, &props, style),
151 Tag::Entry => self.entry(id, inner, &props, style),
152 Tag::Separator => self.separator(inner, style),
153 Tag::Progress => self.progress(inner, &props, style),
154 Tag::Spinner => {
155 let ch = SPINNER[(self.tick as usize) % SPINNER.len()];
156 self.screen.set(inner.x, inner.y, ch, style);
157 }
Paint a reaction, and give an emoji the two columns it takes dce285f nandi 16d ago158 Tag::Reaction => self.reaction(id, inner, &props, style),
159 // The same glyph with nothing around it: a character in a line,
160 // and the line is what says anything about it.
161 Tag::Emoji => {
162 self.screen
163 .text(inner.x, inner.y, inner.w, props.str("emoji"), style);
164 }
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago165 Tag::Listbox => self.listbox(id, inner, &props, style, enabled),
166 // A spacer is the absence of anything; the clear at the top of the
167 // frame has already drawn it.
168 Tag::Spacer => {}
169 }
170 }
171
172 fn children(&mut self, id: u32, area: Rect, style: Style, enabled: bool) {
173 if area.is_empty() {
174 return;
175 }
176 let rects = layout::children_rects(self.tree, id, area);
177 for (child, rect) in self.tree.children(id).into_iter().zip(rects) {
178 // Clip to the parent: a child asking for more rows than are left
179 // paints what fits rather than over its neighbours.
180 let bottom = area.y.saturating_add(area.h);
181 let right = area.x.saturating_add(area.w);
182 if rect.y >= bottom || rect.x >= right {
183 continue;
184 }
185 let clipped = Rect::new(
186 rect.x,
187 rect.y,
188 rect.w.min(right - rect.x),
189 rect.h.min(bottom - rect.y),
190 );
191 self.node(child, clipped, style, enabled);
192 }
193 }
194
195 fn wrapped(&mut self, area: Rect, text: &str, style: Style) {
196 for (i, line) in wrap(text, area.w).into_iter().enumerate() {
197 if i as u16 >= area.h {
198 break;
199 }
200 self.screen
201 .text(area.x, area.y + i as u16, area.w, &line, style);
202 }
203 }
204
205 fn focused(&self, id: u32) -> bool {
206 self.focus == id
207 }
208
209 fn button(&mut self, id: u32, area: Rect, props: &Props, style: Style) {
210 let mut style = match props.str("kind") {
211 "primary" => style.with(attr::BOLD),
212 "destructive" => style.fg(Color::parse("red").unwrap_or_default()),
213 _ => style,
214 };
215 if self.focused(id) {
216 style = style.with(attr::REVERSE);
217 }
218 let label = format!("[ {} ]", props.label());
219 self.screen.text(area.x, area.y, area.w, &label, style);
220 }
221
Paint a reaction, and give an emoji the two columns it takes dce285f nandi 16d ago222 /// A reaction pill: the glyph, the tally where there is one, and whether
223 /// you are on it.
224 ///
225 /// No border around it. A window draws a lozenge because it has half-cells
226 /// to draw one in; here brackets would cost two columns of a row that
227 /// already carries three chips, and would say "button" about a thing whose
228 /// whole picture is the glyph. Yours is bold, which is the one bit of the
229 /// pill a reader actually reads off it.
230 fn reaction(&mut self, id: u32, area: Rect, props: &Props, style: Style) {
231 let mut style = if props.bool("mine", false) {
232 style.with(attr::BOLD)
233 } else {
234 style
235 };
236 if self.focused(id) {
237 style = style.with(attr::REVERSE);
238 }
239 self.screen
240 .text(area.x, area.y, area.w, &layout::pill_text(props), style);
241 }
242
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago243 fn check(&mut self, id: u32, area: Rect, props: &Props, style: Style) {
244 let style = if self.focused(id) {
245 style.with(attr::REVERSE)
246 } else {
247 style
248 };
249 let mark = if props.bool("active", false) {
250 'x'
251 } else {
252 ' '
253 };
254 let label = format!("[{mark}] {}", props.label());
255 self.screen.text(area.x, area.y, area.w, &label, style);
256 }
257
258 fn entry(&mut self, id: u32, area: Rect, props: &Props, style: Style) {
259 let focused = self.focused(id);
260 let text = props.str("text");
261 let showing_placeholder = text.is_empty();
262 let shown = layout::entry_text(props);
263 let mut style = style.with(attr::UNDERLINE);
264 if showing_placeholder {
265 style = style.with(attr::DIM);
266 }
267 if focused {
268 style = style.with(attr::REVERSE);
269 }
270 // The field is its whole rect, not just the text in it: a reader needs
271 // to see where it can type before it has typed anything.
272 self.screen.fill(area, style);
273 let rows = area.h.max(1);
274 let lines = if props.cells("rows", 1) > 1 {
275 wrap(&shown, area.w)
276 } else {
277 vec![shown.chars().collect::<String>()]
278 };
279 let caret = self.caret.min(text.chars().count());
280 // A line longer than the field scrolls sideways to keep the caret in
281 // view — the end of it is where someone is usually typing, but not
282 // always, so it follows the caret rather than the end.
283 for (i, line) in lines.iter().take(rows as usize).enumerate() {
284 let len = line.chars().count();
285 let last = i + 1 == lines.len().min(rows as usize);
286 let window = area.w.saturating_sub(1).max(1) as usize;
287 let from = if last && !showing_placeholder {
288 caret.saturating_sub(window)
289 } else {
290 len.saturating_sub(window)
291 };
292 let visible: String = line.chars().skip(from).collect();
293 self.screen
294 .text(area.x, area.y + i as u16, area.w, &visible, style);
295 if focused && last {
Paint a reaction, and give an emoji the two columns it takes dce285f nandi 16d ago296 // In columns rather than characters: an emoji typed into the
297 // line is two cells wide, and a caret counted in characters
298 // sits a column left of the text for each one.
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago299 let col = if showing_placeholder {
300 0
301 } else {
Paint a reaction, and give an emoji the two columns it takes dce285f nandi 16d ago302 let typed: String = visible
303 .chars()
304 .take(caret.saturating_sub(from))
305 .collect();
306 (screen::text_cols(&typed)).min(area.w.saturating_sub(1))
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago307 };
Paint a reaction, and give an emoji the two columns it takes dce285f nandi 16d ago308 self.out.cursor = Some((area.x.saturating_add(col), area.y + i as u16));
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago309 }
310 }
311 }
312
313 fn separator(&mut self, area: Rect, style: Style) {
314 for x in area.x..area.x.saturating_add(area.w) {
315 self.screen.set(x, area.y, '─', style);
316 }
317 }
318
319 fn progress(&mut self, area: Rect, props: &Props, style: Style) {
320 let value = props.num("value", 0.0).clamp(0.0, 1.0);
321 let filled = (value * area.w as f64).round() as u16;
322 for x in 0..area.w {
323 let ch = if x < filled { '█' } else { '░' };
324 self.screen.set(area.x + x, area.y, ch, style);
325 }
326 let label = props.label();
327 if !label.is_empty() {
328 let at = area.x + (area.w.saturating_sub(label.chars().count() as u16)) / 2;
329 self.screen.text(at, area.y, area.w, label, style);
330 }
331 }
332
333 fn listbox(&mut self, id: u32, area: Rect, props: &Props, style: Style, enabled: bool) {
334 let items = self.tree.children(id);
335 // No `:selected` at all means the cursor is on the first row: a list
336 // with no cursor cannot be moved with the arrows, and a caller that
337 // wants none says so with -1.
338 let selected = props.num("selected", 0.0);
339 let selected = if selected < 0.0 {
340 None
341 } else {
342 Some(selected as usize)
343 };
344 // Keep the cursor on screen: scroll only as far as it takes.
345 let rows = area.h as usize;
346 let first = match selected {
347 Some(sel) if rows > 0 && sel >= rows => sel + 1 - rows,
348 _ => 0,
349 };
350 for (row, item) in items.iter().skip(first).take(rows).enumerate() {
351 let y = area.y + row as u16;
352 let chosen = selected == Some(first + row);
353 let mut row_style = style;
354 if chosen {
355 row_style = row_style.with(if self.focused(id) {
356 attr::REVERSE
357 } else {
358 attr::BOLD
359 });
360 self.screen.fill(Rect::new(area.x, y, area.w, 1), row_style);
361 }
362 let marker = if chosen { "" } else { " " };
363 self.screen.text(area.x, y, area.w, marker, row_style);
364 let cell = Rect::new(area.x + 2, y, area.w.saturating_sub(2), 1);
365 self.node(*item, cell, row_style, enabled);
366 }
367 }
368
369 fn scroll(&mut self, id: u32, area: Rect, style: Style, enabled: bool) {
370 let props = self.tree.props(id);
371 // The content is painted at its full height into a screen of its own,
372 // then the visible window of it is copied across. Doing it this way
373 // means every widget inside a scroll paints exactly as it would
374 // outside one — nothing has to know it is being clipped.
375 let content_h = self
376 .tree
377 .children(id)
378 .iter()
379 .map(|c| layout::height_for_width(self.tree, *c, area.w))
380 .sum::<u16>()
381 .max(1);
382 let max_offset = content_h.saturating_sub(area.h);
383 let offset = props.cells("offset", 0).min(max_offset);
Scroll the list under the pointer, and from where it actually is a785201 nandi 16d ago384 self.out.scrolled.push((id, offset, max_offset, area));
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago385
386 let mut buffer = Screen::new(area.w, content_h);
387 let mut inner = Painter {
388 tree: self.tree,
389 screen: &mut buffer,
390 focus: self.focus,
391 caret: self.caret,
392 tick: self.tick,
393 out: Painted::default(),
394 overlays: Vec::new(),
395 };
396 let full = Rect::new(0, 0, area.w, content_h);
397 inner.children(id, full, style, enabled);
398 let learned = inner.out;
399
400 for y in 0..area.h {
401 for x in 0..area.w {
402 if let Some(cell) = buffer.cell(x, y + offset) {
403 self.screen.set(area.x + x, area.y + y, cell.ch, cell.style);
404 }
405 }
406 }
407 // Widgets inside keep their place in the focus ring; their rects move
408 // by the viewport, and the ones scrolled out of sight take no clicks.
409 self.out.ring.extend(learned.ring);
410 for (node, rect) in learned.hits {
411 if rect.y >= offset && rect.y < offset.saturating_add(area.h) {
412 self.out.hits.push((
413 node,
414 Rect::new(
415 area.x + rect.x,
416 area.y + rect.y - offset,
417 rect.w,
418 rect.h.min(area.h),
419 ),
420 ));
421 }
422 }
Scroll the list under the pointer, and from where it actually is a785201 nandi 16d ago423 // A scroll inside this one was painted into the buffer, so its area is
424 // in the buffer's coordinates: move it the way the hits above moved,
425 // and drop the ones the viewport is not showing. A wheel over a nested
426 // list has to land on the list under the pointer, and a rect left in
427 // the wrong space is a wheel aimed at whatever happens to be there.
428 for (node, inner_offset, max, rect) in learned.scrolled {
429 if rect.y >= offset && rect.y < offset.saturating_add(area.h) {
430 self.out.scrolled.push((
431 node,
432 inner_offset,
433 max,
434 Rect::new(
435 area.x + rect.x,
436 area.y + rect.y - offset,
437 rect.w,
438 rect.h.min(area.h),
439 ),
440 ));
441 }
442 }
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago443 if let Some((cx, cy)) = learned.cursor {
444 if cy >= offset && cy < offset.saturating_add(area.h) {
445 self.out.cursor = Some((area.x + cx, area.y + cy - offset));
446 }
447 }
448 }
449
450 fn overlay(&mut self, id: u32, screen: Rect) {
451 let props = self.tree.props(id);
452 let w = layout::width(self.tree, id, false).min(screen.w);
453 let h = layout::height_for_width(self.tree, id, w).min(screen.h);
454 let (x, y) = (
455 screen.x + Align::Center.offset_pub(w, screen.w),
456 screen.y + Align::Center.offset_pub(h, screen.h),
457 );
458 let area = Rect::new(x, y, w, h);
459 let style = self.style_for(&props, Style::default(), true);
460 // Blank what is under it: a floating panel that shows the screen
461 // through its gaps is unreadable.
462 for row in area.y..area.y + area.h {
463 for col in area.x..area.x + area.w {
464 self.screen.set(col, row, ' ', style);
465 }
466 }
467 self.border(area, props.label(), style);
468 let pad = layout::inset(&Tag::Overlay, &props);
469 self.children(id, area.shrink(pad), style, true);
470 }
471
472 /// A single-line box, with `label` set into the top edge when there is one.
473 fn border(&mut self, area: Rect, label: &str, style: Style) {
474 if area.w < 2 || area.h < 2 {
475 return;
476 }
477 let (x1, y1) = (area.x + area.w - 1, area.y + area.h - 1);
478 for x in area.x..=x1 {
479 self.screen.set(x, area.y, '─', style);
480 self.screen.set(x, y1, '─', style);
481 }
482 for y in area.y..=y1 {
483 self.screen.set(area.x, y, '│', style);
484 self.screen.set(x1, y, '│', style);
485 }
486 self.screen.set(area.x, area.y, '┌', style);
487 self.screen.set(x1, area.y, '┐', style);
488 self.screen.set(area.x, y1, '└', style);
489 self.screen.set(x1, y1, '┘', style);
490 if !label.is_empty() && area.w > 4 {
491 let text = format!(" {label} ");
492 self.screen.text(
493 area.x + 1,
494 area.y,
495 area.w - 2,
496 &text,
497 style.with(attr::BOLD),
498 );
499 }
500 }
501}
502
503impl Align {
504 /// [`Align::offset`] is private to the layout module; overlays are the one
505 /// caller outside it that centres something by hand.
506 fn offset_pub(self, size: u16, avail: u16) -> u16 {
507 layout::place(self, size, avail).0
508 }
509}