nandi/jolt-nativepublic Fork 0
c2d912f670b7a2e5808970d17cecf51c6c87cfbf
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 · 571 lines · 22.8 KBRust Blame HistoryRaw
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 18d 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};
Draw a picture in a terminal, over the Kitty graphics protocol c2d912f nandi 17d ago13use crate::graphics;
Paint a reaction, and give an emoji the two columns it takes dce285f nandi 17d ago14use crate::screen::{self, attr, Color, Rect, Screen, Style};
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 18d ago15use crate::tree::{Props, Tag, Tree};
16
17const SPINNER: [char; 8] = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧'];
18
19/// What one frame of painting learned about the tree, for the input half to
20/// use on the next key or click.
21#[derive(Clone, Debug, Default)]
22pub struct Painted {
23 /// Focusable nodes in paint order — the order Tab walks.
24 pub ring: Vec<u32>,
25 /// Where each of them ended up.
26 pub hits: Vec<(u32, Rect)>,
27 /// How far each scroll node's viewport actually was, after clamping to the
28 /// 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 18d ago29 /// 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 17d ago30 /// furthest it could have been, and the area it was painted into). The
31 /// second number is what tells a caller whether it is at the bottom, which
32 /// is what sticking to it means; the rect is what a wheel is aimed at.
33 pub scrolled: Vec<(u32, u16, u16, Rect)>,
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 18d ago34 /// Where the cursor should sit — the focused entry's caret, if any.
35 pub cursor: Option<(u16, u16)>,
Draw a picture in a terminal, over the Kitty graphics protocol c2d912f nandi 17d ago36 /// The pictures this frame wants on screen, in the cells they were given.
37 /// Nothing was painted for them: the grid has no pixels, and the terminal
38 /// is what draws one — see [`crate::graphics`].
39 pub images: Vec<graphics::Placement>,
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 18d ago40}
41
42struct Painter<'a> {
43 tree: &'a Tree,
44 screen: &'a mut Screen,
45 focus: u32,
46 /// Where the caret sits in the focused entry's text, in characters.
47 caret: usize,
48 tick: u64,
49 out: Painted,
50 overlays: Vec<u32>,
51}
52
53/// Paint the whole tree. `focus` is the node the ring is currently on and
54/// `tick` advances the spinners.
55pub fn frame(tree: &Tree, screen: &mut Screen, focus: u32, caret: usize, tick: u64) -> Painted {
56 screen.clear();
57 let mut painter = Painter {
58 tree,
59 screen,
60 focus,
61 caret,
62 tick,
63 out: Painted::default(),
64 overlays: Vec::new(),
65 };
66 let area = painter.screen.rect();
67 painter.node(tree.root(), area, Style::default(), true);
68
69 // Overlays float above the rest, so they are painted after it — and a
70 // click landing on one must beat a click on whatever it covers, which is
71 // what putting their hit rects first does.
72 let overlays = std::mem::take(&mut painter.overlays);
73 let below = std::mem::take(&mut painter.out.hits);
74 for id in overlays {
75 painter.overlay(id, area);
76 }
77 painter.out.hits.extend(below);
78 painter.out
79}
80
81impl Painter<'_> {
82 fn style_for(&self, props: &Props, inherited: Style, enabled: bool) -> Style {
83 let mut style = inherited;
84 if let Some(fg) = Color::parse(props.str("color")) {
85 style.fg = fg;
86 }
87 if let Some(bg) = Color::parse(props.str("bg")) {
88 style.bg = bg;
89 }
90 for (key, bit) in [
91 ("bold", attr::BOLD),
92 ("dim", attr::DIM),
93 ("underline", attr::UNDERLINE),
94 ("reverse", attr::REVERSE),
95 ("blink", attr::BLINK),
96 ("italic", attr::ITALIC),
97 ] {
98 if props.bool(key, false) {
99 style.attrs |= bit;
100 }
101 }
102 if !enabled {
103 // `:sensitive false` dims the widget *and its whole subtree*, which
104 // is what it means in every other glimmer backend.
105 style.attrs |= attr::DIM;
106 }
107 style
108 }
109
110 fn node(&mut self, id: u32, area: Rect, inherited: Style, enabled: bool) {
111 if area.is_empty() || !self.tree.exists(id) {
112 return;
113 }
114 let tag = self.tree.tag(id);
115 let props = self.tree.props(id);
116 let enabled = enabled && props.bool("sensitive", true);
117 let style = self.style_for(&props, inherited, enabled);
118 if props.has("bg") {
119 self.screen.fill(area, style);
120 }
121 if enabled && tag.focusable() {
122 self.out.ring.push(id);
123 self.out.hits.push((id, area));
124 }
125
126 let pad = layout::inset(&tag, &props);
127 let inner = area.shrink(pad);
128 match tag {
129 Tag::Overlay => self.overlays.push(id),
130 Tag::Frame => {
131 self.border(area, props.label(), style);
132 self.children(id, inner, style, enabled);
133 }
134 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 18d ago135 Tag::Box | Tag::Window => self.children(id, inner, style, enabled),
136 // A tag this backend has not learned paints as a vertical box, so
137 // whatever is under it still reaches the screen. When there is
138 // nothing under it, its own text does instead: an unknown *leaf*
139 // is a widget the caller has and this has not — frq's `:status`
140 // badge, its `:link` — and painting the box and not the label is
141 // the one outcome that loses the text altogether. A link vanishing
142 // out of the middle of a message is not a missing widget; it is a
143 // missing sentence.
144 Tag::Unknown(_) => {
Let a backlog keep its place, and leave room for what is under it 68910bd nandi 18d ago145 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 18d ago146 self.wrapped(inner, props.label(), style);
147 } else {
148 self.children(id, inner, style, enabled);
149 }
150 }
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 18d ago151 Tag::Label => self.wrapped(inner, props.label(), style),
152 Tag::Title => self.wrapped(inner, props.label(), style.with(attr::BOLD)),
153 Tag::DimLabel => self.wrapped(inner, props.label(), style.with(attr::DIM)),
154 Tag::Button => self.button(id, inner, &props, style),
155 Tag::CheckButton => self.check(id, inner, &props, style),
156 Tag::Entry => self.entry(id, inner, &props, style),
157 Tag::Separator => self.separator(inner, style),
158 Tag::Progress => self.progress(inner, &props, style),
159 Tag::Spinner => {
160 let ch = SPINNER[(self.tick as usize) % SPINNER.len()];
161 self.screen.set(inner.x, inner.y, ch, style);
162 }
Draw a picture in a terminal, over the Kitty graphics protocol c2d912f nandi 17d ago163 Tag::Image => self.image(id, inner, &props, style),
Paint a reaction, and give an emoji the two columns it takes dce285f nandi 17d ago164 Tag::Reaction => self.reaction(id, inner, &props, style),
165 // The same glyph with nothing around it: a character in a line,
166 // and the line is what says anything about it.
167 Tag::Emoji => {
168 self.screen
169 .text(inner.x, inner.y, inner.w, props.str("emoji"), style);
170 }
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 18d ago171 Tag::Listbox => self.listbox(id, inner, &props, style, enabled),
172 // A spacer is the absence of anything; the clear at the top of the
173 // frame has already drawn it.
174 Tag::Spacer => {}
175 }
176 }
177
178 fn children(&mut self, id: u32, area: Rect, style: Style, enabled: bool) {
179 if area.is_empty() {
180 return;
181 }
182 let rects = layout::children_rects(self.tree, id, area);
183 for (child, rect) in self.tree.children(id).into_iter().zip(rects) {
184 // Clip to the parent: a child asking for more rows than are left
185 // paints what fits rather than over its neighbours.
186 let bottom = area.y.saturating_add(area.h);
187 let right = area.x.saturating_add(area.w);
188 if rect.y >= bottom || rect.x >= right {
189 continue;
190 }
191 let clipped = Rect::new(
192 rect.x,
193 rect.y,
194 rect.w.min(right - rect.x),
195 rect.h.min(bottom - rect.y),
196 );
197 self.node(child, clipped, style, enabled);
198 }
199 }
200
201 fn wrapped(&mut self, area: Rect, text: &str, style: Style) {
202 for (i, line) in wrap(text, area.w).into_iter().enumerate() {
203 if i as u16 >= area.h {
204 break;
205 }
206 self.screen
207 .text(area.x, area.y + i as u16, area.w, &line, style);
208 }
209 }
210
211 fn focused(&self, id: u32) -> bool {
212 self.focus == id
213 }
214
215 fn button(&mut self, id: u32, area: Rect, props: &Props, style: Style) {
216 let mut style = match props.str("kind") {
217 "primary" => style.with(attr::BOLD),
218 "destructive" => style.fg(Color::parse("red").unwrap_or_default()),
219 _ => style,
220 };
221 if self.focused(id) {
222 style = style.with(attr::REVERSE);
223 }
224 let label = format!("[ {} ]", props.label());
225 self.screen.text(area.x, area.y, area.w, &label, style);
226 }
227
Draw a picture in a terminal, over the Kitty graphics protocol c2d912f nandi 17d ago228 /// A picture: the cells it was given, and a note of where they are.
229 ///
230 /// Nothing goes in them. The terminal draws the picture over the blank
231 /// cells when the frame is flushed, which is the only way pixels reach a
232 /// grid; where there is no protocol for that, the cells carry the note
233 /// that says a picture is here, and the link above it is the way to it.
234 fn image(&mut self, id: u32, area: Rect, props: &Props, style: Style) {
235 let path = props.str("src");
236 if path.is_empty() || area.is_empty() {
237 return;
238 }
239 if !graphics::supported() {
240 self.screen
241 .text(area.x, area.y, area.w, layout::PICTURE, style.with(attr::DIM));
242 return;
243 }
244 // The column hands a child its whole width; a picture takes only what
245 // its shape asks for out of that, so the placement is the picture and
246 // not the room around it.
247 let (cols, rows) = layout::image_cells(props, area.w);
248 let area = Rect::new(area.x, area.y, cols.min(area.w), rows.min(area.h));
249 if area.is_empty() {
250 return;
251 }
252 self.out.images.push(graphics::Placement {
253 node: id,
254 path: path.to_owned(),
255 area,
256 crop_top: 0,
257 crop_bottom: 0,
258 });
259 }
260
Paint a reaction, and give an emoji the two columns it takes dce285f nandi 17d ago261 /// A reaction pill: the glyph, the tally where there is one, and whether
262 /// you are on it.
263 ///
264 /// No border around it. A window draws a lozenge because it has half-cells
265 /// to draw one in; here brackets would cost two columns of a row that
266 /// already carries three chips, and would say "button" about a thing whose
267 /// whole picture is the glyph. Yours is bold, which is the one bit of the
268 /// pill a reader actually reads off it.
269 fn reaction(&mut self, id: u32, area: Rect, props: &Props, style: Style) {
270 let mut style = if props.bool("mine", false) {
271 style.with(attr::BOLD)
272 } else {
273 style
274 };
275 if self.focused(id) {
276 style = style.with(attr::REVERSE);
277 }
278 self.screen
279 .text(area.x, area.y, area.w, &layout::pill_text(props), style);
280 }
281
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 18d ago282 fn check(&mut self, id: u32, area: Rect, props: &Props, style: Style) {
283 let style = if self.focused(id) {
284 style.with(attr::REVERSE)
285 } else {
286 style
287 };
288 let mark = if props.bool("active", false) {
289 'x'
290 } else {
291 ' '
292 };
293 let label = format!("[{mark}] {}", props.label());
294 self.screen.text(area.x, area.y, area.w, &label, style);
295 }
296
297 fn entry(&mut self, id: u32, area: Rect, props: &Props, style: Style) {
298 let focused = self.focused(id);
299 let text = props.str("text");
300 let showing_placeholder = text.is_empty();
301 let shown = layout::entry_text(props);
302 let mut style = style.with(attr::UNDERLINE);
303 if showing_placeholder {
304 style = style.with(attr::DIM);
305 }
306 if focused {
307 style = style.with(attr::REVERSE);
308 }
309 // The field is its whole rect, not just the text in it: a reader needs
310 // to see where it can type before it has typed anything.
311 self.screen.fill(area, style);
312 let rows = area.h.max(1);
313 let lines = if props.cells("rows", 1) > 1 {
314 wrap(&shown, area.w)
315 } else {
316 vec![shown.chars().collect::<String>()]
317 };
318 let caret = self.caret.min(text.chars().count());
319 // A line longer than the field scrolls sideways to keep the caret in
320 // view — the end of it is where someone is usually typing, but not
321 // always, so it follows the caret rather than the end.
322 for (i, line) in lines.iter().take(rows as usize).enumerate() {
323 let len = line.chars().count();
324 let last = i + 1 == lines.len().min(rows as usize);
325 let window = area.w.saturating_sub(1).max(1) as usize;
326 let from = if last && !showing_placeholder {
327 caret.saturating_sub(window)
328 } else {
329 len.saturating_sub(window)
330 };
331 let visible: String = line.chars().skip(from).collect();
332 self.screen
333 .text(area.x, area.y + i as u16, area.w, &visible, style);
334 if focused && last {
Paint a reaction, and give an emoji the two columns it takes dce285f nandi 17d ago335 // In columns rather than characters: an emoji typed into the
336 // line is two cells wide, and a caret counted in characters
337 // 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 18d ago338 let col = if showing_placeholder {
339 0
340 } else {
Paint a reaction, and give an emoji the two columns it takes dce285f nandi 17d ago341 let typed: String = visible
342 .chars()
343 .take(caret.saturating_sub(from))
344 .collect();
345 (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 18d ago346 };
Paint a reaction, and give an emoji the two columns it takes dce285f nandi 17d ago347 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 18d ago348 }
349 }
350 }
351
352 fn separator(&mut self, area: Rect, style: Style) {
353 for x in area.x..area.x.saturating_add(area.w) {
354 self.screen.set(x, area.y, '─', style);
355 }
356 }
357
358 fn progress(&mut self, area: Rect, props: &Props, style: Style) {
359 let value = props.num("value", 0.0).clamp(0.0, 1.0);
360 let filled = (value * area.w as f64).round() as u16;
361 for x in 0..area.w {
362 let ch = if x < filled { '█' } else { '░' };
363 self.screen.set(area.x + x, area.y, ch, style);
364 }
365 let label = props.label();
366 if !label.is_empty() {
367 let at = area.x + (area.w.saturating_sub(label.chars().count() as u16)) / 2;
368 self.screen.text(at, area.y, area.w, label, style);
369 }
370 }
371
372 fn listbox(&mut self, id: u32, area: Rect, props: &Props, style: Style, enabled: bool) {
373 let items = self.tree.children(id);
374 // No `:selected` at all means the cursor is on the first row: a list
375 // with no cursor cannot be moved with the arrows, and a caller that
376 // wants none says so with -1.
377 let selected = props.num("selected", 0.0);
378 let selected = if selected < 0.0 {
379 None
380 } else {
381 Some(selected as usize)
382 };
383 // Keep the cursor on screen: scroll only as far as it takes.
384 let rows = area.h as usize;
385 let first = match selected {
386 Some(sel) if rows > 0 && sel >= rows => sel + 1 - rows,
387 _ => 0,
388 };
389 for (row, item) in items.iter().skip(first).take(rows).enumerate() {
390 let y = area.y + row as u16;
391 let chosen = selected == Some(first + row);
392 let mut row_style = style;
393 if chosen {
394 row_style = row_style.with(if self.focused(id) {
395 attr::REVERSE
396 } else {
397 attr::BOLD
398 });
399 self.screen.fill(Rect::new(area.x, y, area.w, 1), row_style);
400 }
401 let marker = if chosen { "" } else { " " };
402 self.screen.text(area.x, y, area.w, marker, row_style);
403 let cell = Rect::new(area.x + 2, y, area.w.saturating_sub(2), 1);
404 self.node(*item, cell, row_style, enabled);
405 }
406 }
407
408 fn scroll(&mut self, id: u32, area: Rect, style: Style, enabled: bool) {
409 let props = self.tree.props(id);
410 // The content is painted at its full height into a screen of its own,
411 // then the visible window of it is copied across. Doing it this way
412 // means every widget inside a scroll paints exactly as it would
413 // outside one — nothing has to know it is being clipped.
414 let content_h = self
415 .tree
416 .children(id)
417 .iter()
418 .map(|c| layout::height_for_width(self.tree, *c, area.w))
419 .sum::<u16>()
420 .max(1);
421 let max_offset = content_h.saturating_sub(area.h);
422 let offset = props.cells("offset", 0).min(max_offset);
Scroll the list under the pointer, and from where it actually is a785201 nandi 17d ago423 self.out.scrolled.push((id, offset, max_offset, area));
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 18d ago424
425 let mut buffer = Screen::new(area.w, content_h);
426 let mut inner = Painter {
427 tree: self.tree,
428 screen: &mut buffer,
429 focus: self.focus,
430 caret: self.caret,
431 tick: self.tick,
432 out: Painted::default(),
433 overlays: Vec::new(),
434 };
435 let full = Rect::new(0, 0, area.w, content_h);
436 inner.children(id, full, style, enabled);
437 let learned = inner.out;
438
439 for y in 0..area.h {
440 for x in 0..area.w {
441 if let Some(cell) = buffer.cell(x, y + offset) {
Keep a glyph whole, selector and all, and copy it across whole 3dd441e nandi 17d ago442 self.screen.put(area.x + x, area.y + y, cell.clone());
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 18d ago443 }
444 }
445 }
446 // Widgets inside keep their place in the focus ring; their rects move
447 // by the viewport, and the ones scrolled out of sight take no clicks.
448 self.out.ring.extend(learned.ring);
449 for (node, rect) in learned.hits {
450 if rect.y >= offset && rect.y < offset.saturating_add(area.h) {
451 self.out.hits.push((
452 node,
453 Rect::new(
454 area.x + rect.x,
455 area.y + rect.y - offset,
456 rect.w,
457 rect.h.min(area.h),
458 ),
459 ));
460 }
461 }
Scroll the list under the pointer, and from where it actually is a785201 nandi 17d ago462 // A scroll inside this one was painted into the buffer, so its area is
463 // in the buffer's coordinates: move it the way the hits above moved,
464 // and drop the ones the viewport is not showing. A wheel over a nested
465 // list has to land on the list under the pointer, and a rect left in
466 // the wrong space is a wheel aimed at whatever happens to be there.
Draw a picture in a terminal, over the Kitty graphics protocol c2d912f nandi 17d ago467 // A picture inside a scroll moves with it, and is cut off by the
468 // viewport rather than painted over what is above or below: the
469 // protocol crops from the source, so a backlog scrolls past a picture
470 // a row at a time instead of losing it whole at the edge.
471 let bottom = offset.saturating_add(area.h);
472 for mut placement in learned.images {
473 let top = placement.area.y;
474 let foot = top.saturating_add(placement.area.h);
475 let seen_top = top.max(offset);
476 let seen_foot = foot.min(bottom);
477 if seen_foot <= seen_top {
478 continue;
479 }
480 placement.crop_top += seen_top - top;
481 placement.crop_bottom += foot - seen_foot;
482 placement.area = Rect::new(
483 area.x + placement.area.x,
484 area.y + seen_top - offset,
485 placement.area.w,
486 seen_foot - seen_top,
487 );
488 self.out.images.push(placement);
489 }
Scroll the list under the pointer, and from where it actually is a785201 nandi 17d ago490 for (node, inner_offset, max, rect) in learned.scrolled {
491 if rect.y >= offset && rect.y < offset.saturating_add(area.h) {
492 self.out.scrolled.push((
493 node,
494 inner_offset,
495 max,
496 Rect::new(
497 area.x + rect.x,
498 area.y + rect.y - offset,
499 rect.w,
500 rect.h.min(area.h),
501 ),
502 ));
503 }
504 }
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 18d ago505 if let Some((cx, cy)) = learned.cursor {
506 if cy >= offset && cy < offset.saturating_add(area.h) {
507 self.out.cursor = Some((area.x + cx, area.y + cy - offset));
508 }
509 }
510 }
511
512 fn overlay(&mut self, id: u32, screen: Rect) {
513 let props = self.tree.props(id);
514 let w = layout::width(self.tree, id, false).min(screen.w);
515 let h = layout::height_for_width(self.tree, id, w).min(screen.h);
516 let (x, y) = (
517 screen.x + Align::Center.offset_pub(w, screen.w),
518 screen.y + Align::Center.offset_pub(h, screen.h),
519 );
520 let area = Rect::new(x, y, w, h);
521 let style = self.style_for(&props, Style::default(), true);
522 // Blank what is under it: a floating panel that shows the screen
523 // through its gaps is unreadable.
524 for row in area.y..area.y + area.h {
525 for col in area.x..area.x + area.w {
526 self.screen.set(col, row, ' ', style);
527 }
528 }
529 self.border(area, props.label(), style);
530 let pad = layout::inset(&Tag::Overlay, &props);
531 self.children(id, area.shrink(pad), style, true);
532 }
533
534 /// A single-line box, with `label` set into the top edge when there is one.
535 fn border(&mut self, area: Rect, label: &str, style: Style) {
536 if area.w < 2 || area.h < 2 {
537 return;
538 }
539 let (x1, y1) = (area.x + area.w - 1, area.y + area.h - 1);
540 for x in area.x..=x1 {
541 self.screen.set(x, area.y, '─', style);
542 self.screen.set(x, y1, '─', style);
543 }
544 for y in area.y..=y1 {
545 self.screen.set(area.x, y, '│', style);
546 self.screen.set(x1, y, '│', style);
547 }
548 self.screen.set(area.x, area.y, '┌', style);
549 self.screen.set(x1, area.y, '┐', style);
550 self.screen.set(area.x, y1, '└', style);
551 self.screen.set(x1, y1, '┘', style);
552 if !label.is_empty() && area.w > 4 {
553 let text = format!(" {label} ");
554 self.screen.text(
555 area.x + 1,
556 area.y,
557 area.w - 2,
558 &text,
559 style.with(attr::BOLD),
560 );
561 }
562 }
563}
564
565impl Align {
566 /// [`Align::offset`] is private to the layout module; overlays are the one
567 /// caller outside it that centres something by hand.
568 fn offset_pub(self, size: u16, avail: u16) -> u16 {
569 layout::place(self, size, avail).0
570 }
571}