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