//! Drawing the tree into a grid of cells. //! //! One pass, top to bottom: each node is handed a rect by [`crate::layout`] and //! paints itself into it. Two things fall out of the walk and are kept — //! the focus ring, in the order the widgets were painted, and every focusable //! widget's rect, so a mouse click can be turned back into a node. //! //! Overlays are collected rather than drawn in place: a floating panel belongs //! over the whole screen, so it is painted after everything else at the size it //! asked for, in the middle. use crate::layout::{self, wrap, Align}; use crate::screen::{attr, Color, Rect, Screen, Style}; use crate::tree::{Props, Tag, Tree}; const SPINNER: [char; 8] = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧']; /// What one frame of painting learned about the tree, for the input half to /// use on the next key or click. #[derive(Clone, Debug, Default)] pub struct Painted { /// Focusable nodes in paint order — the order Tab walks. pub ring: Vec, /// Where each of them ended up. pub hits: Vec<(u32, Rect)>, /// How far each scroll node's viewport actually was, after clamping to the /// content it had. Written back so a caller cannot scroll past the end. pub scrolled: Vec<(u32, u16)>, /// Where the cursor should sit — the focused entry's caret, if any. pub cursor: Option<(u16, u16)>, } struct Painter<'a> { tree: &'a Tree, screen: &'a mut Screen, focus: u32, /// Where the caret sits in the focused entry's text, in characters. caret: usize, tick: u64, out: Painted, overlays: Vec, } /// Paint the whole tree. `focus` is the node the ring is currently on and /// `tick` advances the spinners. pub fn frame(tree: &Tree, screen: &mut Screen, focus: u32, caret: usize, tick: u64) -> Painted { screen.clear(); let mut painter = Painter { tree, screen, focus, caret, tick, out: Painted::default(), overlays: Vec::new(), }; let area = painter.screen.rect(); painter.node(tree.root(), area, Style::default(), true); // Overlays float above the rest, so they are painted after it — and a // click landing on one must beat a click on whatever it covers, which is // what putting their hit rects first does. let overlays = std::mem::take(&mut painter.overlays); let below = std::mem::take(&mut painter.out.hits); for id in overlays { painter.overlay(id, area); } painter.out.hits.extend(below); painter.out } impl Painter<'_> { fn style_for(&self, props: &Props, inherited: Style, enabled: bool) -> Style { let mut style = inherited; if let Some(fg) = Color::parse(props.str("color")) { style.fg = fg; } if let Some(bg) = Color::parse(props.str("bg")) { style.bg = bg; } for (key, bit) in [ ("bold", attr::BOLD), ("dim", attr::DIM), ("underline", attr::UNDERLINE), ("reverse", attr::REVERSE), ("blink", attr::BLINK), ("italic", attr::ITALIC), ] { if props.bool(key, false) { style.attrs |= bit; } } if !enabled { // `:sensitive false` dims the widget *and its whole subtree*, which // is what it means in every other glimmer backend. style.attrs |= attr::DIM; } style } fn node(&mut self, id: u32, area: Rect, inherited: Style, enabled: bool) { if area.is_empty() || !self.tree.exists(id) { return; } let tag = self.tree.tag(id); let props = self.tree.props(id); let enabled = enabled && props.bool("sensitive", true); let style = self.style_for(&props, inherited, enabled); if props.has("bg") { self.screen.fill(area, style); } if enabled && tag.focusable() { self.out.ring.push(id); self.out.hits.push((id, area)); } let pad = layout::inset(&tag, &props); let inner = area.shrink(pad); match tag { Tag::Overlay => self.overlays.push(id), Tag::Frame => { self.border(area, props.label(), style); self.children(id, inner, style, enabled); } Tag::Scroll => self.scroll(id, inner, style, enabled), Tag::Box | Tag::Window | Tag::Unknown(_) => self.children(id, inner, style, enabled), Tag::Label => self.wrapped(inner, props.label(), style), Tag::Title => self.wrapped(inner, props.label(), style.with(attr::BOLD)), Tag::DimLabel => self.wrapped(inner, props.label(), style.with(attr::DIM)), Tag::Button => self.button(id, inner, &props, style), Tag::CheckButton => self.check(id, inner, &props, style), Tag::Entry => self.entry(id, inner, &props, style), Tag::Separator => self.separator(inner, style), Tag::Progress => self.progress(inner, &props, style), Tag::Spinner => { let ch = SPINNER[(self.tick as usize) % SPINNER.len()]; self.screen.set(inner.x, inner.y, ch, style); } Tag::Listbox => self.listbox(id, inner, &props, style, enabled), // A spacer is the absence of anything; the clear at the top of the // frame has already drawn it. Tag::Spacer => {} } } fn children(&mut self, id: u32, area: Rect, style: Style, enabled: bool) { if area.is_empty() { return; } let rects = layout::children_rects(self.tree, id, area); for (child, rect) in self.tree.children(id).into_iter().zip(rects) { // Clip to the parent: a child asking for more rows than are left // paints what fits rather than over its neighbours. let bottom = area.y.saturating_add(area.h); let right = area.x.saturating_add(area.w); if rect.y >= bottom || rect.x >= right { continue; } let clipped = Rect::new( rect.x, rect.y, rect.w.min(right - rect.x), rect.h.min(bottom - rect.y), ); self.node(child, clipped, style, enabled); } } fn wrapped(&mut self, area: Rect, text: &str, style: Style) { for (i, line) in wrap(text, area.w).into_iter().enumerate() { if i as u16 >= area.h { break; } self.screen .text(area.x, area.y + i as u16, area.w, &line, style); } } fn focused(&self, id: u32) -> bool { self.focus == id } fn button(&mut self, id: u32, area: Rect, props: &Props, style: Style) { let mut style = match props.str("kind") { "primary" => style.with(attr::BOLD), "destructive" => style.fg(Color::parse("red").unwrap_or_default()), _ => style, }; if self.focused(id) { style = style.with(attr::REVERSE); } let label = format!("[ {} ]", props.label()); self.screen.text(area.x, area.y, area.w, &label, style); } fn check(&mut self, id: u32, area: Rect, props: &Props, style: Style) { let style = if self.focused(id) { style.with(attr::REVERSE) } else { style }; let mark = if props.bool("active", false) { 'x' } else { ' ' }; let label = format!("[{mark}] {}", props.label()); self.screen.text(area.x, area.y, area.w, &label, style); } fn entry(&mut self, id: u32, area: Rect, props: &Props, style: Style) { let focused = self.focused(id); let text = props.str("text"); let showing_placeholder = text.is_empty(); let shown = layout::entry_text(props); let mut style = style.with(attr::UNDERLINE); if showing_placeholder { style = style.with(attr::DIM); } if focused { style = style.with(attr::REVERSE); } // The field is its whole rect, not just the text in it: a reader needs // to see where it can type before it has typed anything. self.screen.fill(area, style); let rows = area.h.max(1); let lines = if props.cells("rows", 1) > 1 { wrap(&shown, area.w) } else { vec![shown.chars().collect::()] }; let caret = self.caret.min(text.chars().count()); // A line longer than the field scrolls sideways to keep the caret in // view — the end of it is where someone is usually typing, but not // always, so it follows the caret rather than the end. for (i, line) in lines.iter().take(rows as usize).enumerate() { let len = line.chars().count(); let last = i + 1 == lines.len().min(rows as usize); let window = area.w.saturating_sub(1).max(1) as usize; let from = if last && !showing_placeholder { caret.saturating_sub(window) } else { len.saturating_sub(window) }; let visible: String = line.chars().skip(from).collect(); self.screen .text(area.x, area.y + i as u16, area.w, &visible, style); if focused && last { let col = if showing_placeholder { 0 } else { caret .saturating_sub(from) .min(area.w.saturating_sub(1) as usize) }; self.out.cursor = Some((area.x.saturating_add(col as u16), area.y + i as u16)); } } } fn separator(&mut self, area: Rect, style: Style) { for x in area.x..area.x.saturating_add(area.w) { self.screen.set(x, area.y, '─', style); } } fn progress(&mut self, area: Rect, props: &Props, style: Style) { let value = props.num("value", 0.0).clamp(0.0, 1.0); let filled = (value * area.w as f64).round() as u16; for x in 0..area.w { let ch = if x < filled { '█' } else { '░' }; self.screen.set(area.x + x, area.y, ch, style); } let label = props.label(); if !label.is_empty() { let at = area.x + (area.w.saturating_sub(label.chars().count() as u16)) / 2; self.screen.text(at, area.y, area.w, label, style); } } fn listbox(&mut self, id: u32, area: Rect, props: &Props, style: Style, enabled: bool) { let items = self.tree.children(id); // No `:selected` at all means the cursor is on the first row: a list // with no cursor cannot be moved with the arrows, and a caller that // wants none says so with -1. let selected = props.num("selected", 0.0); let selected = if selected < 0.0 { None } else { Some(selected as usize) }; // Keep the cursor on screen: scroll only as far as it takes. let rows = area.h as usize; let first = match selected { Some(sel) if rows > 0 && sel >= rows => sel + 1 - rows, _ => 0, }; for (row, item) in items.iter().skip(first).take(rows).enumerate() { let y = area.y + row as u16; let chosen = selected == Some(first + row); let mut row_style = style; if chosen { row_style = row_style.with(if self.focused(id) { attr::REVERSE } else { attr::BOLD }); self.screen.fill(Rect::new(area.x, y, area.w, 1), row_style); } let marker = if chosen { "› " } else { " " }; self.screen.text(area.x, y, area.w, marker, row_style); let cell = Rect::new(area.x + 2, y, area.w.saturating_sub(2), 1); self.node(*item, cell, row_style, enabled); } } fn scroll(&mut self, id: u32, area: Rect, style: Style, enabled: bool) { let props = self.tree.props(id); // The content is painted at its full height into a screen of its own, // then the visible window of it is copied across. Doing it this way // means every widget inside a scroll paints exactly as it would // outside one — nothing has to know it is being clipped. let content_h = self .tree .children(id) .iter() .map(|c| layout::height_for_width(self.tree, *c, area.w)) .sum::() .max(1); let max_offset = content_h.saturating_sub(area.h); let offset = props.cells("offset", 0).min(max_offset); self.out.scrolled.push((id, offset)); let mut buffer = Screen::new(area.w, content_h); let mut inner = Painter { tree: self.tree, screen: &mut buffer, focus: self.focus, caret: self.caret, tick: self.tick, out: Painted::default(), overlays: Vec::new(), }; let full = Rect::new(0, 0, area.w, content_h); inner.children(id, full, style, enabled); let learned = inner.out; for y in 0..area.h { for x in 0..area.w { if let Some(cell) = buffer.cell(x, y + offset) { self.screen.set(area.x + x, area.y + y, cell.ch, cell.style); } } } // Widgets inside keep their place in the focus ring; their rects move // by the viewport, and the ones scrolled out of sight take no clicks. self.out.ring.extend(learned.ring); for (node, rect) in learned.hits { if rect.y >= offset && rect.y < offset.saturating_add(area.h) { self.out.hits.push(( node, Rect::new( area.x + rect.x, area.y + rect.y - offset, rect.w, rect.h.min(area.h), ), )); } } self.out.scrolled.extend(learned.scrolled); if let Some((cx, cy)) = learned.cursor { if cy >= offset && cy < offset.saturating_add(area.h) { self.out.cursor = Some((area.x + cx, area.y + cy - offset)); } } } fn overlay(&mut self, id: u32, screen: Rect) { let props = self.tree.props(id); let w = layout::width(self.tree, id, false).min(screen.w); let h = layout::height_for_width(self.tree, id, w).min(screen.h); let (x, y) = ( screen.x + Align::Center.offset_pub(w, screen.w), screen.y + Align::Center.offset_pub(h, screen.h), ); let area = Rect::new(x, y, w, h); let style = self.style_for(&props, Style::default(), true); // Blank what is under it: a floating panel that shows the screen // through its gaps is unreadable. for row in area.y..area.y + area.h { for col in area.x..area.x + area.w { self.screen.set(col, row, ' ', style); } } self.border(area, props.label(), style); let pad = layout::inset(&Tag::Overlay, &props); self.children(id, area.shrink(pad), style, true); } /// A single-line box, with `label` set into the top edge when there is one. fn border(&mut self, area: Rect, label: &str, style: Style) { if area.w < 2 || area.h < 2 { return; } let (x1, y1) = (area.x + area.w - 1, area.y + area.h - 1); for x in area.x..=x1 { self.screen.set(x, area.y, '─', style); self.screen.set(x, y1, '─', style); } for y in area.y..=y1 { self.screen.set(area.x, y, '│', style); self.screen.set(x1, y, '│', style); } self.screen.set(area.x, area.y, '┌', style); self.screen.set(x1, area.y, '┐', style); self.screen.set(area.x, y1, '└', style); self.screen.set(x1, y1, '┘', style); if !label.is_empty() && area.w > 4 { let text = format!(" {label} "); self.screen.text( area.x + 1, area.y, area.w - 2, &text, style.with(attr::BOLD), ); } } } impl Align { /// [`Align::offset`] is private to the layout module; overlays are the one /// caller outside it that centres something by hand. fn offset_pub(self, size: u16, avail: u16) -> u16 { layout::place(self, size, avail).0 } }