//! One node into cells. //! //! Every member of [`Node`] is drawn here or declined here in writing, which is //! what `179b088d` asks for. A decline is a comment saying what a terminal has //! no way to honour, and each one is a finding rather than an omission. use makeover_layout as layout; use makeover_tui::{table, text, widget}; use quasi_router::{Act, Cell, Cells, Field, Figure, Meter, Node, Part, Row, Tag}; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; use crate::{Pass, Tui, View, below}; /// The rows `node` wants at `width`. pub(crate) fn height(tui: &Tui, node: &Node, width: u16) -> u16 { match node { Node::Heading { text: content, .. } | Node::Text { text: content, .. } => { text::height(content, width) } Node::Rich { source } => { text::spans_height(&rich_spans(tui, source, rich_base(tui)), width) } Node::Act(act) => text::line_height(&act_line(tui, act, false), width), Node::Link { text: label, .. } => text::height(label, width), Node::Token(tag) => text::line_height(&Line::from(tag_span(tui, tag, false)), width), Node::Figure(figure) => figure_height(tui, figure, width), Node::Notice { text: content, .. } => text::height(content, width), Node::StandIn { message, act, .. } => { text::height(message, width) + act.as_ref().map_or(0, |_| 1) } Node::Field(field) => field_height(tui, field, width), Node::Form { fields, .. } => { fields .iter() .map(|field| field_height(tui, field, width)) .sum::() // The submit button, on its own row under the last question. + 1 } Node::List { rows, more } => { let gutter = list_gutter(rows); rows.iter() .map(|row| { text::line_height(&row_line(tui, row, &[]), width.saturating_sub(gutter)).max(1) }) .sum::() + u16::from(more.is_some()) } Node::Table { columns, rows } => table_height(columns, rows), Node::Select { options, .. } => { text::line_height(&Line::from(select_spans(tui, options, None, &[])), width) } Node::Meter(meter) => text::line_height(&meter_line(tui, meter), width), Node::Stats { figures } => figures .iter() .map(|(figure, _)| figure_height(tui, figure, width)) .sum(), Node::Region(slot) => crate::region::height(tui, slot, width), } } /// Draw `node` at the top of `area`, and answer the rows it used. /// /// The reachable things are counted as they are passed, in the order /// [`crate::focus::spots`] records them, so that the one whose number matches /// the view's focus can be drawn lit. A node that is not reachable does not /// count, and a node that is drawn but unreachable — a disabled control, a /// hidden field — does not count either. pub(crate) fn draw(pass: &mut Pass<'_>, node: &Node, area: Rect, buf: &mut Buffer) -> u16 { if area.width == 0 || area.height == 0 { // A node with no room still holds its place in the count. The screen is // the same screen whether or not the terminal is tall enough to show // all of it, and a focus order that changed as the window was resized // would move the user's place under them. count(pass, node); return 0; } let tui = pass.tui; match node { Node::Heading { level, text: title } => { text::draw(title, tui.style().heading(*level), area, buf) } Node::Text { text: content, tone, } => text::draw(content, tui.style().tone(*tone), area, buf), // Markdown source, and a terminal has no markup to hand it to. It takes // the runs: the words, each still carrying the marks that were over it, // which is the answer docengine grew for exactly this caller. A webview // draws `**ship it**` bold and so does this. // // What is still lost is block structure. A heading inside a rich node // comes through as its text at the weight of the prose around it, // because `render_runs` carries inline marks and nothing else, and a // terminal has no second type size to spend on the difference anyway. Node::Rich { source } => { text::draw_spans(&rich_spans(tui, source, rich_base(tui)), area, buf) } Node::Act(act) => { let focused = claim_act(pass, act); text::draw_line(&act_line(tui, act, focused), area, buf) } // A link is text and an address, and a terminal cannot put the address // under the words the way an anchor does. Underlined, which is the one // affordance a cell has that says "this goes somewhere", and the // address is the runtime's to follow when the link has focus. Node::Link { text: label, .. } => { let focused = pass.claim(); text::draw( label, tui.style().focused(focused, link_style(tui)), area, buf, ) } Node::Token(tag) => { let focused = claim_tag(pass, tag); text::draw_line(&Line::from(tag_span(tui, tag, focused)), area, buf) } Node::Figure(figure) => draw_figure(tui, figure, area, buf), // A banner and a toast are the same rows here. A toast is a message // that goes away on its own, which is a clock the description does not // carry and the drawing has no way to keep, so the kind is read and // deliberately not honoured. Filed. Node::Notice { tone, text: content, .. } => { let style = tui.style().tone(*tone).add_modifier(Modifier::BOLD); text::draw(content, style, area, buf) } Node::StandIn { state, message, act, } => { let style = match state { layout::Readiness::Failed => tui.style().tone(layout::Tone::Danger), _ => Style::default().fg(tui.theme().content_muted), }; let used = text::draw(message, style, area, buf); match act { Some(act) => { let focused = claim_act(pass, act); used + text::draw_line(&act_line(tui, act, focused), below(area, used), buf) } None => used, } } Node::Field(field) => draw_field(pass, field, area, buf), Node::Form { submit, fields, .. } => { let mut used = 0; for field in fields { used += draw_field(pass, field, below(area, used), buf); } // The submit, drawn as the act it is. The form's own action is not // drawn: an address is not a thing a cell can show, and the runtime // is what follows it. let focused = pass.claim(); used + text::draw_line( &Line::from(vec![Span::styled( format!("[ {submit} ]"), tui.style().focused( focused, Style::default() .fg(tui.theme().selection_on) .bg(tui.theme().action_primary), ), )]), below(area, used), buf, ) } Node::List { rows, more } => { // A gutter for the tick and the current marker, and only when some // row in the list has one. Both are facts about the row that a // webview says with a checkbox and an `aria-current`, and neither // is content, so neither belongs in the run. A list where no row is // tickable spends no columns on the possibility. let gutter = list_gutter(rows); let mut used = 0; for row in rows { // Claimed before the room is checked, because the count is a // fact about the description and the room is a fact about the // window. let focused = claim_row(pass, row); let parts = claim_parts(pass, row); let line = row_line(tui, row, &parts); let at = below(area, used); if at.height == 0 { continue; } draw_gutter(tui, pass.view, row, focused, at, buf); let body = Rect { x: at.x + gutter, width: at.width.saturating_sub(gutter), ..at }; used += text::draw_line(&line, body, buf).max(1); } match more { Some(rest) => { let focused = pass.claim(); let label = rest.remaining.map_or_else( || "More".to_string(), |remaining| format!("{remaining} more"), ); used + text::draw( &label, tui.style() .focused(focused, Style::default().fg(tui.theme().action_primary)), below(area, used), buf, ) } None => used, } } Node::Table { columns, rows } => draw_table(pass, columns, rows, area, buf), Node::Select { options, chosen, action, .. } => { // Segmented, toggle and tabs draw the same here: a row of labels // with the chosen one lit. The three differ in how much room they // claim and how they are grouped, which is a geometry question, and // a terminal has one cell size and no groups. let reachable: Vec = options .iter() .map(|(_, own)| { let calls = own.is_some() || action.is_some(); calls && pass.claim() }) .collect(); text::draw_line( &Line::from(select_spans(tui, options, chosen.as_deref(), &reachable)), area, buf, ) } Node::Meter(meter) => text::draw_line(&meter_line(tui, meter), area, buf), Node::Stats { figures } => { // Down and not across. A strip of tiles is a row on a webview // because a webview has room to the right; a terminal that put four // figures on one line would have five cells for each caption. // Stacking is the renderer deciding, and the node still says "these // belong together", which is what it is for. let mut used = 0; for (figure, _) in figures { used += draw_figure(tui, figure, below(area, used), buf); } used } Node::Region(slot) => crate::region::draw(pass, slot, area, buf), } } /// Advance the count past a node that was not drawn, so that a screen too tall /// for its terminal keeps the focus order it had when it fit. fn count(pass: &mut Pass<'_>, node: &Node) { let mut found = Vec::new(); // The region's name does not matter here: only how many things were passed. crate::focus::node_spots(node, "", &mut found); pass.seq += found.len(); } /// Claim a control, unless it is disabled and therefore unreachable. fn claim_act(pass: &mut Pass<'_>, act: &Act) -> bool { !act.state.is_some_and(layout::State::suppresses_interaction) && pass.claim() } /// Claim a chip, which is the only tag that answers anything. fn claim_tag(pass: &mut Pass<'_>, tag: &Tag) -> bool { matches!(tag.kind, layout::Token::Chip { .. }) && tag.action.is_some() && pass.claim() } /// Claim a row, when the description gives it something to do. fn claim_row(pass: &mut Pass<'_>, row: &Row) -> bool { let reachable = row.activate.is_some() || row.toggle.is_some() || row.selected.is_some() || !row.menu.is_empty(); reachable && pass.claim() } /// Claim whatever the row's own run carries, one answer per part. fn claim_parts(pass: &mut Pass<'_>, row: &Row) -> Vec { row.parts .iter() .map(|Part { node, .. }| match node { Node::Act(act) => claim_act(pass, act), Node::Link { .. } => pass.claim(), Node::Token(tag) => claim_tag(pass, tag), _ => false, }) .collect() } /// The style text that goes somewhere takes. fn link_style(tui: &Tui) -> Style { Style::default() .fg(tui.theme().action_primary) .add_modifier(Modifier::UNDERLINED) } /// The columns a list spends before its rows. /// /// Four for a tick, because `[x] ` is four cells; two for the marker alone; /// none when the list needs neither. /// /// A row that can be reached takes the marker's two columns whether or not it /// is the current one, because focus is drawn there and a gutter of zero would /// put the caret over the first word. That is the drawing paying for an /// interaction, which is what a gutter is: the description says the row can be /// opened, and this is the terminal's way of showing which one is about to be. fn list_gutter(rows: &[Row]) -> u16 { if rows.iter().any(|row| row.selected.is_some()) { 4 } else if rows .iter() .any(|row| row.current || row.activate.is_some() || !row.menu.is_empty()) { 2 } else { 0 } } /// The tick and the current marker, in the columns before a row. /// /// The focus lands here rather than on the row's words. A row is a whole line /// and reversing all of it turns a list into a slab; the gutter is the column /// the affordances already live in, so it is where "you are on this one" can be /// said without repainting the content. fn draw_gutter(tui: &Tui, view: &View, row: &Row, focused: bool, area: Rect, buf: &mut Buffer) { // A tickable row that names a value is drawn from the set the view is // holding, and only an unnamed one falls back to what the description // said. That is the same rule `39057019` settled for a field: the // description says what arrived, the view says what the user has done // since, and a redraw that went back to the description would undo the // tick the moment anything else on the screen changed. let tick = match (row.selected, row.value.as_deref()) { (Some(_), Some(value)) if view.is_ticked(value) => "[x]", (Some(_), Some(_)) => "[ ]", (Some(true), None) => "[x]", (Some(false), None) => "[ ]", (None, _) => "", }; if !tick.is_empty() { buf.set_stringn( area.x, area.y, tick, 3, tui.style() .focused(focused, Style::default().fg(tui.theme().content_secondary)), ); return; } if row.current || focused { buf.set_stringn( area.x, area.y, ">", 1, tui.style() .focused(focused, Style::default().fg(tui.theme().action_primary)), ); } } /// A row's run as one line of spans. /// /// The run is what made this possible to write at all. Under the old members a /// terminal had to know the fixed sequence -- primary, secondary, meta, bar, /// tokens, actions -- and hardcode it; here it reads what the description says, /// in the order it says it, and the role picks the style. /// `focus` carries one answer per part, in the run's own order, and is empty /// for the callers that are measuring rather than drawing. fn row_line(tui: &Tui, row: &Row, focus: &[bool]) -> Line<'static> { let mut spans = Vec::new(); for (index, Part { role, node }) in row.parts.iter().enumerate() { if !spans.is_empty() { spans.push(Span::raw(" ")); } let focused = focus.get(index).copied().unwrap_or(false); spans.extend(inline_spans(tui, node, part_style(tui, *role), focused)); } // `Row::menu` is not drawn, and that is the description's own instruction: // a menu is reached by right-click on a pointer host, long-press on a touch // one, and a key in a terminal. The key is the runtime's. Line::from(spans) } /// The style a row part takes. fn part_style(tui: &Tui, role: layout::RowPart) -> Style { let theme = tui.theme(); match role { layout::RowPart::Primary => Style::default().fg(theme.content_primary), layout::RowPart::Secondary => Style::default().fg(theme.content_secondary), layout::RowPart::Meta => Style::default().fg(theme.content_muted), // Tokens, actions and a proportion each carry their own tone, so the // part inherits rather than tinting what sits on it. That is exactly // what `RowPart::intent` answers for a webview, said in colours. _ => Style::default().fg(theme.content_primary), } } /// One leaf of a run as spans, under the run's own style. fn inline_spans(tui: &Tui, node: &Node, inherited: Style, focused: bool) -> Vec> { match node { Node::Text { text, tone } => { let style = match tone { layout::Tone::Neutral => inherited, other => tui.style().tone(*other), }; vec![Span::styled(text.clone(), style)] } Node::Rich { source } => rich_spans(tui, source, inherited), Node::Token(tag) => vec![tag_span(tui, tag, focused)], Node::Act(act) => act_line(tui, act, focused).spans, Node::Link { text, .. } => vec![Span::styled( text.clone(), tui.style().focused(focused, link_style(tui)), )], Node::Meter(meter) => meter_line(tui, meter).spans, Node::Figure(figure) => vec![Span::styled( format!("{} {}", figure.value, figure.caption), inherited, )], // Everything else is a block, and the containment bound is what // guarantees one cannot be here. Drawing the text is the honest answer // to a case the type system says is unreachable. other => vec![Span::styled( format!("{other:?}"), Style::default().fg(tui.theme().status_danger), )], } } /// The style a rich node's unmarked prose takes when it stands on its own, /// rather than inside a run that has already picked one. fn rich_base(tui: &Tui) -> Style { Style::default().fg(tui.theme().content_primary) } /// Markdown source as spans: the words, each under the marks that were over it /// and in the shape of the block it came from. /// /// `base` is what the prose takes where the source said nothing, so the same /// function serves a rich node standing alone and one sitting inside a row's /// run, where the part's role has already decided the colour. fn rich_spans(tui: &Tui, source: &str, base: Style) -> Vec> { let mut spans = Vec::new(); // A marker belongs at the head of a line and nowhere else, and a run knows // its block but not its position. The separator runs are what carry the // breaks, so the run before this one is what says whether a line just // started. let mut starting = true; for run in docengine::render_runs(source) { if starting && let Some(marker) = marker(run.block) { spans.push(Span::styled( marker, Style::default().fg(tui.theme().content_muted), )); } starting = run.text.ends_with('\n'); let style = style_of(tui, base, &run); spans.push(Span::styled(run.text, style)); } spans } /// What a block puts in front of its first line, where a webview would have used /// a bullet glyph or an indent. /// /// The description carries no marker of its own, deliberately: what a bullet /// looks like is the renderer's answer, and this is a terminal's. fn marker(block: docengine::Block) -> Option<&'static str> { match block { docengine::Block::Item => Some("- "), docengine::Block::Quote => Some("> "), docengine::Block::Prose | docengine::Block::Heading(_) => None, } } /// One run's block and marks as a style over `base`. /// /// The block decides the ground the run is drawn on and the marks are added to /// it, which is the order a stylesheet uses: a heading with `**bold**` inside it /// is bold on top of heading weight rather than instead of it. fn style_of(tui: &Tui, base: Style, run: &docengine::TextRun) -> Style { let ground = match run.block { // The three markdown levels a terminal can tell apart, which is as many // as `layout::Heading` has: a rich node's `######` and its `###` land in // the same place because a cell has one size and only so much colour. docengine::Block::Heading(1) => tui.style().heading(layout::Heading::Page), docengine::Block::Heading(2) => tui.style().heading(layout::Heading::Section), docengine::Block::Heading(_) => tui.style().heading(layout::Heading::Subsection), docengine::Block::Quote => Style::default().fg(tui.theme().content_secondary), docengine::Block::Prose | docengine::Block::Item => base, }; mark(tui, ground, run.emphasis) } /// One run's marks as a style over `base`. /// /// Three of the four are the modifier a terminal already has for them. Code is /// the one with no modifier to take -- every cell is monospace, so the thing a /// webview says with a typeface cannot be said that way here -- and it takes /// the sunken surface instead, which is what the theme has for "this is set /// into the page rather than on it". fn mark(tui: &Tui, base: Style, emphasis: docengine::Emphasis) -> Style { if emphasis.is_plain() { return base; } let mut style = base; if emphasis.strong { style = style.add_modifier(Modifier::BOLD); } if emphasis.italic { style = style.add_modifier(Modifier::ITALIC); } if emphasis.struck { style = style.add_modifier(Modifier::CROSSED_OUT); } if emphasis.code { style = style.bg(tui.theme().surface_sunken); } style } /// A tag as one span. /// /// The bracket, the latch and the collision between latched and focused are all /// `makeover-tui`'s answers now. What is left here is the translation: our /// owned [`Tag`] into the parts the shared drawing takes. fn tag_span(tui: &Tui, tag: &Tag, focused: bool) -> Span<'static> { widget::token( tui.style(), &tag.label, tag.kind, tag.tone, tag.latched, focused, ) } /// A control as a line. /// /// `Act::confirm` and `Act::action` do not cross into the description layer's /// [`layout::Act`] and so are not drawn: an address is not a thing a cell can /// show, and a confirmation is a question asked after the press, which is the /// runtime's. [`quasi_router::Act::as_layout`] says the same at the seam. fn act_line(tui: &Tui, act: &Act, focused: bool) -> Line<'static> { widget::act(tui.style(), &act.as_layout(), focused) } /// A meter as a line, bar and label. fn meter_line(tui: &Tui, meter: &Meter) -> Line<'static> { widget::meter(tui.style(), &meter.as_layout()) } /// A figure takes two rows: the number, then what it counts. fn figure_height(_tui: &Tui, figure: &Figure, width: u16) -> u16 { widget::figure_height(&figure.as_layout(), width) } fn draw_figure(tui: &Tui, figure: &Figure, area: Rect, buf: &mut Buffer) -> u16 { widget::figure(tui.style(), &figure.as_layout(), area, buf) } /// A question takes its label row, its value row, and a row for whatever went /// wrong. fn field_height(tui: &Tui, field: &Field, width: u16) -> u16 { field.with_layout(|field| widget::field_height(tui.style(), &field, width)) } fn draw_field(pass: &mut Pass<'_>, field: &Field, area: Rect, buf: &mut Buffer) -> u16 { // Claimed before the room is checked and before the kind is looked at, so // the count is a fact about the description rather than about the window. // A hidden field is the one kind that is not reachable at all. if matches!(field.kind, layout::FieldKind::Hidden) { return 0; } let focused = pass.claim(); let tui = pass.tui; // What is in the box, which is the view's answer and not the description's, // and the whole reason drawing takes two arguments. See this crate's // header, and `39057019`. `Field::value` drops what it is handed when the // kind is `Secret`, deliberately -- a password that comes back down the // wire is a password in a page and in a proxy log -- so for that one kind // the view's buffer is the only source there is. let held = pass.view.showing(&field.name, field.value.as_deref()); // A checkbox is a bool to the shared drawing rather than a string, because // `Node::SELECTED` is quasi's submission convention and not a fact about // what a tick looks like. let held = if matches!(field.kind, layout::FieldKind::Checkbox) { widget::Held::On(held == Node::SELECTED) } else { widget::Held::Text(held) }; field.with_layout(|described| widget::field(tui.style(), &described, held, focused, area, buf)) } /// A tabs strip, a segmented control and a toggle, all as one row of labels. fn select_spans( tui: &Tui, options: &[(quasi_router::Choice, Option)], chosen: Option<&str>, focus: &[bool], ) -> Vec> { let mut spans = Vec::new(); for (index, (choice, _)) in options.iter().enumerate() { if !spans.is_empty() { spans.push(Span::raw(" ")); } let picked = chosen == Some(choice.value.as_str()); let style = if picked { Style::default() .fg(tui.theme().selection_on) .bg(tui.theme().action_primary) } else { Style::default().fg(tui.theme().content_secondary) }; // The chosen option and the focused one are different facts and both // have to show: which tab you are reading, and which one Enter would // open. The chosen one takes the filled label and focus adds the // brackets around whichever the caret is on. let focused = focus.get(index).copied().unwrap_or(false); let label = if focused { format!("[{}]", choice.label) } else { format!(" {} ", choice.label) }; spans.push(Span::styled(label, tui.style().focused(focused, style))); } spans } /// A header row plus one row per row of cells. fn table_height(_columns: &[quasi_router::Column], rows: &[Cells]) -> u16 { 1 + u16::try_from(rows.len()).unwrap_or(u16::MAX) } /// A table, through makeover-tui's own table. /// /// The one node this crate does not draw itself, and the reason the shared /// crate has a table at all: column sizing, the priority cutoff that drops /// columns a narrow terminal has no room for, and the sort marker are all /// decided there, so a described table narrows the same way an undescribed one /// does. fn draw_table( pass: &mut Pass<'_>, columns: &[quasi_router::Column], rows: &[Cells], area: Rect, buf: &mut Buffer, ) -> u16 { use ratatui::widgets::{StatefulWidget, TableState}; // A row that opens is reachable and a cell inside one is not, which // `focus.rs` explains: the table is laid out by `makeover_tui::table`, which // answers no coordinates back, so there is nothing here that could say where // in a row a control ended up. // Every openable row is claimed, not just the ones before the focused one, // or the count would end early and every control below the table would be // off by the difference. let mut focused = None; for (index, cells) in rows.iter().enumerate() { if cells.activate.is_some() && pass.claim() { focused = Some(index); } } let tui = pass.tui; let named: Vec> = columns .iter() .map(quasi_router::Column::as_layout) .collect(); // No authored track lengths. `Width::Fixed` is the description's way of // saying a column has one, and it names no number, so the fallback is this // renderer's guess and the sizing table stays empty until the vocabulary // carries a measure. let sizing = table::Sizing { lengths: &[], fallback: 12, }; let body: Vec>> = rows .iter() .map(|cells| { columns .iter() .zip(&cells.values) .map(|(column, cell)| { table::Cell::new(column.name.as_str(), cell_line(tui, cell)) .part(cell_part(cell)) }) .collect() }) .collect(); let widget = table::table(&named, &body, &sizing, &tui.table, area.width); let height = table_height(columns, rows).min(area.height); let within = Rect { height, ..area }; // `Cells::current` through ratatui's own selection, so the row takes the // highlight style `TableStyle` already carries rather than a second // emphasis invented here. It is the one place a drawing needs a widget's // state, and the state is read straight off the description. // // Focus wins over current when they disagree. Both end up in the same // one-row selection because a table has one highlight to give, and of the // two facts the one the user is steering is the one they need to see. let mut state = TableState::default(); if let Some(index) = focused.or_else(|| rows.iter().position(|cells| cells.current)) { state.select(Some(index)); } StatefulWidget::render(widget, within, buf, &mut state); height } /// A cell's run as one line, which is what makeover-tui's table takes. fn cell_line(tui: &Tui, cell: &Cell) -> Line<'static> { let mut spans = Vec::new(); for part in &cell.parts { if !spans.is_empty() { spans.push(Span::raw(" ")); } spans.extend(inline_spans( tui, part, Style::default().fg(tui.theme().content_primary), false, )); } Line::from(spans) } /// Which `CellPart` a cell's run reads as. /// /// The table style wants one part for the whole cell where the run has one per /// entry, so a mixed cell has to answer with the part that decides its colour. /// A control wins, then a link, then a chip, then the value: a cell whose last /// word is a button should not be painted as prose. fn cell_part(cell: &Cell) -> layout::CellPart { if cell.parts.iter().any(|part| matches!(part, Node::Act(_))) { return layout::CellPart::Actions; } if cell .parts .iter() .any(|part| matches!(part, Node::Link { .. })) { return layout::CellPart::Link; } if cell.parts.iter().any(|part| matches!(part, Node::Token(_))) { return layout::CellPart::Tokens; } layout::CellPart::Value }