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