nandi/jolt-nativepublic Fork 0
a78520161e25e26b1bb67e2553889eeaaf58d5ab
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 · 476 lines · 18.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.
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 }
158 Tag::Listbox => self.listbox(id, inner, &props, style, enabled),
159 // A spacer is the absence of anything; the clear at the top of the
160 // frame has already drawn it.
161 Tag::Spacer => {}
162 }
163 }
164
165 fn children(&mut self, id: u32, area: Rect, style: Style, enabled: bool) {
166 if area.is_empty() {
167 return;
168 }
169 let rects = layout::children_rects(self.tree, id, area);
170 for (child, rect) in self.tree.children(id).into_iter().zip(rects) {
171 // Clip to the parent: a child asking for more rows than are left
172 // paints what fits rather than over its neighbours.
173 let bottom = area.y.saturating_add(area.h);
174 let right = area.x.saturating_add(area.w);
175 if rect.y >= bottom || rect.x >= right {
176 continue;
177 }
178 let clipped = Rect::new(
179 rect.x,
180 rect.y,
181 rect.w.min(right - rect.x),
182 rect.h.min(bottom - rect.y),
183 );
184 self.node(child, clipped, style, enabled);
185 }
186 }
187
188 fn wrapped(&mut self, area: Rect, text: &str, style: Style) {
189 for (i, line) in wrap(text, area.w).into_iter().enumerate() {
190 if i as u16 >= area.h {
191 break;
192 }
193 self.screen
194 .text(area.x, area.y + i as u16, area.w, &line, style);
195 }
196 }
197
198 fn focused(&self, id: u32) -> bool {
199 self.focus == id
200 }
201
202 fn button(&mut self, id: u32, area: Rect, props: &Props, style: Style) {
203 let mut style = match props.str("kind") {
204 "primary" => style.with(attr::BOLD),
205 "destructive" => style.fg(Color::parse("red").unwrap_or_default()),
206 _ => style,
207 };
208 if self.focused(id) {
209 style = style.with(attr::REVERSE);
210 }
211 let label = format!("[ {} ]", props.label());
212 self.screen.text(area.x, area.y, area.w, &label, style);
213 }
214
215 fn check(&mut self, id: u32, area: Rect, props: &Props, style: Style) {
216 let style = if self.focused(id) {
217 style.with(attr::REVERSE)
218 } else {
219 style
220 };
221 let mark = if props.bool("active", false) {
222 'x'
223 } else {
224 ' '
225 };
226 let label = format!("[{mark}] {}", props.label());
227 self.screen.text(area.x, area.y, area.w, &label, style);
228 }
229
230 fn entry(&mut self, id: u32, area: Rect, props: &Props, style: Style) {
231 let focused = self.focused(id);
232 let text = props.str("text");
233 let showing_placeholder = text.is_empty();
234 let shown = layout::entry_text(props);
235 let mut style = style.with(attr::UNDERLINE);
236 if showing_placeholder {
237 style = style.with(attr::DIM);
238 }
239 if focused {
240 style = style.with(attr::REVERSE);
241 }
242 // The field is its whole rect, not just the text in it: a reader needs
243 // to see where it can type before it has typed anything.
244 self.screen.fill(area, style);
245 let rows = area.h.max(1);
246 let lines = if props.cells("rows", 1) > 1 {
247 wrap(&shown, area.w)
248 } else {
249 vec![shown.chars().collect::<String>()]
250 };
251 let caret = self.caret.min(text.chars().count());
252 // A line longer than the field scrolls sideways to keep the caret in
253 // view — the end of it is where someone is usually typing, but not
254 // always, so it follows the caret rather than the end.
255 for (i, line) in lines.iter().take(rows as usize).enumerate() {
256 let len = line.chars().count();
257 let last = i + 1 == lines.len().min(rows as usize);
258 let window = area.w.saturating_sub(1).max(1) as usize;
259 let from = if last && !showing_placeholder {
260 caret.saturating_sub(window)
261 } else {
262 len.saturating_sub(window)
263 };
264 let visible: String = line.chars().skip(from).collect();
265 self.screen
266 .text(area.x, area.y + i as u16, area.w, &visible, style);
267 if focused && last {
268 let col = if showing_placeholder {
269 0
270 } else {
271 caret
272 .saturating_sub(from)
273 .min(area.w.saturating_sub(1) as usize)
274 };
275 self.out.cursor = Some((area.x.saturating_add(col as u16), area.y + i as u16));
276 }
277 }
278 }
279
280 fn separator(&mut self, area: Rect, style: Style) {
281 for x in area.x..area.x.saturating_add(area.w) {
282 self.screen.set(x, area.y, '─', style);
283 }
284 }
285
286 fn progress(&mut self, area: Rect, props: &Props, style: Style) {
287 let value = props.num("value", 0.0).clamp(0.0, 1.0);
288 let filled = (value * area.w as f64).round() as u16;
289 for x in 0..area.w {
290 let ch = if x < filled { '█' } else { '░' };
291 self.screen.set(area.x + x, area.y, ch, style);
292 }
293 let label = props.label();
294 if !label.is_empty() {
295 let at = area.x + (area.w.saturating_sub(label.chars().count() as u16)) / 2;
296 self.screen.text(at, area.y, area.w, label, style);
297 }
298 }
299
300 fn listbox(&mut self, id: u32, area: Rect, props: &Props, style: Style, enabled: bool) {
301 let items = self.tree.children(id);
302 // No `:selected` at all means the cursor is on the first row: a list
303 // with no cursor cannot be moved with the arrows, and a caller that
304 // wants none says so with -1.
305 let selected = props.num("selected", 0.0);
306 let selected = if selected < 0.0 {
307 None
308 } else {
309 Some(selected as usize)
310 };
311 // Keep the cursor on screen: scroll only as far as it takes.
312 let rows = area.h as usize;
313 let first = match selected {
314 Some(sel) if rows > 0 && sel >= rows => sel + 1 - rows,
315 _ => 0,
316 };
317 for (row, item) in items.iter().skip(first).take(rows).enumerate() {
318 let y = area.y + row as u16;
319 let chosen = selected == Some(first + row);
320 let mut row_style = style;
321 if chosen {
322 row_style = row_style.with(if self.focused(id) {
323 attr::REVERSE
324 } else {
325 attr::BOLD
326 });
327 self.screen.fill(Rect::new(area.x, y, area.w, 1), row_style);
328 }
329 let marker = if chosen { "" } else { " " };
330 self.screen.text(area.x, y, area.w, marker, row_style);
331 let cell = Rect::new(area.x + 2, y, area.w.saturating_sub(2), 1);
332 self.node(*item, cell, row_style, enabled);
333 }
334 }
335
336 fn scroll(&mut self, id: u32, area: Rect, style: Style, enabled: bool) {
337 let props = self.tree.props(id);
338 // The content is painted at its full height into a screen of its own,
339 // then the visible window of it is copied across. Doing it this way
340 // means every widget inside a scroll paints exactly as it would
341 // outside one — nothing has to know it is being clipped.
342 let content_h = self
343 .tree
344 .children(id)
345 .iter()
346 .map(|c| layout::height_for_width(self.tree, *c, area.w))
347 .sum::<u16>()
348 .max(1);
349 let max_offset = content_h.saturating_sub(area.h);
350 let offset = props.cells("offset", 0).min(max_offset);
Scroll the list under the pointer, and from where it actually is a785201 nandi 16d ago351 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 ago352
353 let mut buffer = Screen::new(area.w, content_h);
354 let mut inner = Painter {
355 tree: self.tree,
356 screen: &mut buffer,
357 focus: self.focus,
358 caret: self.caret,
359 tick: self.tick,
360 out: Painted::default(),
361 overlays: Vec::new(),
362 };
363 let full = Rect::new(0, 0, area.w, content_h);
364 inner.children(id, full, style, enabled);
365 let learned = inner.out;
366
367 for y in 0..area.h {
368 for x in 0..area.w {
369 if let Some(cell) = buffer.cell(x, y + offset) {
370 self.screen.set(area.x + x, area.y + y, cell.ch, cell.style);
371 }
372 }
373 }
374 // Widgets inside keep their place in the focus ring; their rects move
375 // by the viewport, and the ones scrolled out of sight take no clicks.
376 self.out.ring.extend(learned.ring);
377 for (node, rect) in learned.hits {
378 if rect.y >= offset && rect.y < offset.saturating_add(area.h) {
379 self.out.hits.push((
380 node,
381 Rect::new(
382 area.x + rect.x,
383 area.y + rect.y - offset,
384 rect.w,
385 rect.h.min(area.h),
386 ),
387 ));
388 }
389 }
Scroll the list under the pointer, and from where it actually is a785201 nandi 16d ago390 // A scroll inside this one was painted into the buffer, so its area is
391 // in the buffer's coordinates: move it the way the hits above moved,
392 // and drop the ones the viewport is not showing. A wheel over a nested
393 // list has to land on the list under the pointer, and a rect left in
394 // the wrong space is a wheel aimed at whatever happens to be there.
395 for (node, inner_offset, max, rect) in learned.scrolled {
396 if rect.y >= offset && rect.y < offset.saturating_add(area.h) {
397 self.out.scrolled.push((
398 node,
399 inner_offset,
400 max,
401 Rect::new(
402 area.x + rect.x,
403 area.y + rect.y - offset,
404 rect.w,
405 rect.h.min(area.h),
406 ),
407 ));
408 }
409 }
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago410 if let Some((cx, cy)) = learned.cursor {
411 if cy >= offset && cy < offset.saturating_add(area.h) {
412 self.out.cursor = Some((area.x + cx, area.y + cy - offset));
413 }
414 }
415 }
416
417 fn overlay(&mut self, id: u32, screen: Rect) {
418 let props = self.tree.props(id);
419 let w = layout::width(self.tree, id, false).min(screen.w);
420 let h = layout::height_for_width(self.tree, id, w).min(screen.h);
421 let (x, y) = (
422 screen.x + Align::Center.offset_pub(w, screen.w),
423 screen.y + Align::Center.offset_pub(h, screen.h),
424 );
425 let area = Rect::new(x, y, w, h);
426 let style = self.style_for(&props, Style::default(), true);
427 // Blank what is under it: a floating panel that shows the screen
428 // through its gaps is unreadable.
429 for row in area.y..area.y + area.h {
430 for col in area.x..area.x + area.w {
431 self.screen.set(col, row, ' ', style);
432 }
433 }
434 self.border(area, props.label(), style);
435 let pad = layout::inset(&Tag::Overlay, &props);
436 self.children(id, area.shrink(pad), style, true);
437 }
438
439 /// A single-line box, with `label` set into the top edge when there is one.
440 fn border(&mut self, area: Rect, label: &str, style: Style) {
441 if area.w < 2 || area.h < 2 {
442 return;
443 }
444 let (x1, y1) = (area.x + area.w - 1, area.y + area.h - 1);
445 for x in area.x..=x1 {
446 self.screen.set(x, area.y, '─', style);
447 self.screen.set(x, y1, '─', style);
448 }
449 for y in area.y..=y1 {
450 self.screen.set(area.x, y, '│', style);
451 self.screen.set(x1, y, '│', style);
452 }
453 self.screen.set(area.x, area.y, '┌', style);
454 self.screen.set(x1, area.y, '┐', style);
455 self.screen.set(area.x, y1, '└', style);
456 self.screen.set(x1, y1, '┘', style);
457 if !label.is_empty() && area.w > 4 {
458 let text = format!(" {label} ");
459 self.screen.text(
460 area.x + 1,
461 area.y,
462 area.w - 2,
463 &text,
464 style.with(attr::BOLD),
465 );
466 }
467 }
468}
469
470impl Align {
471 /// [`Align::offset`] is private to the layout module; overlays are the one
472 /// caller outside it that centres something by hand.
473 fn offset_pub(self, size: u16, avail: u16) -> u16 {
474 layout::place(self, size, avail).0
475 }
476}