//! 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. //! //! [`Node`] is `#[non_exhaustive]`, so "every member" now means every member //! this renderer has learned. The wildcard arm is the gap and [`UNDRAWN`] is //! what it draws; a member that lands there is a member still owed the //! paragraph above, not a member that has been declined. use makeover_layout as layout; use makeover_tui::{piece, table, text}; use std::time::SystemTime; use quasi_router::{ Act, Action, Bar, Cell, Chart, Clock, Field, Figure, Image, Meter, Node, Outline, Rest, Row, Tag, }; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; use crate::{Local, Pass, Tui, View, below}; /// The rows `node` wants at `width`. pub(crate) fn height(tui: &Tui, node: &Node, width: u16, local: &Local<'_>) -> u16 { match node { Node::Heading { text: content, .. } | Node::Text { text: content, .. } => { text::height(content, width) } // Neither axis is read here, and both are deliberately ignored rather // than forgotten. `Trust` protects a document from an author -- follow // rules, raw markup, fetchable schemes -- and a terminal has no document // to protect: it paints spans, follows nothing and fetches nothing. // `Richness` would matter for a table, which `rich_spans` does not draw // in any case. Same shape as a renderer ignoring `Discovery::image`. Node::Rich { source, .. } => { text::spans_height(&rich_spans(tui, source, rich_base(tui)), width) } // The questions a control asks stand above it here, so they are rows of // its own height. See the drawing arm for why a terminal shows them // rather than hiding them behind the press. Node::Act(act) => { act.asks .iter() .map(|field| field_height(tui, &field.as_asked(), width, local)) .sum::() + text::line_height(&act_line(tui, act, false), width) + act_note_height(tui, act, 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), // A readout of the clock is one line whatever it says, and it is sized // without asking what the clock says: the widest it ever gets is a // wrapped line, and a height that moved with the seconds would make the // screen jump under the reader once a minute. Node::Since { .. } | Node::Until { .. } | Node::Age { .. } => 1, Node::Image(picture) => image_height(picture, 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, local), Node::Form { fields, .. } => { fields .iter() .map(|field| field_height(tui, field, width, local)) .sum::() // The submit button, on its own row under the last question. + 1 } // A table that declared no columns is a list. One node since the // 2026-09-06 collapse, and the guard is where the two arrangements part // company; the arm below measures the grid. Node::Table { columns, rows, more, .. } if columns.is_empty() => { let gutter = list_gutter(rows); // A row under a shut branch is not on the screen and takes no // lines. The same reading the focus walk and the drawing make, from // the same function: three walks that disagreed about which rows // are there is a caret painted on the wrong line. let branches = rows.iter().any(|row| row.open.is_some()); crate::outline::showing(rows, local.view()) .map(|(_, row)| { let body = width .saturating_sub(gutter) .saturating_sub(crate::outline::lead(row.depth, branches)); let cap = row_lines(row); let kept = fitted(tui, row, &[], body, cap); text::line_height(&row_line_of(tui, row, &[], &kept), body).clamp(1, cap) }) .sum::() + u16::from(more.is_some()) } // A terminal cannot place by percentage, and it does not have to. What // the description said is that these things happen at these times; a // clock column and one line each says exactly that, and is what a // terminal is good at. The geometry a webview draws is presentation, // which is the half this renderer is entitled to answer differently. // // What is genuinely lost is duration and overlap as *shapes*: two // things at once are two adjacent lines here rather than two boxes side // by side. The times are on every line, so the fact survives even // though the picture does not. A gantt-style bar column would be this // renderer's own expression and is worth having; it is not a finding // about the description. Node::Timeline { entries, .. } => entries .iter() .map(|entry| { let body = width.saturating_sub(TIMELINE_GUTTER); let cap = row_lines(&entry.row); let kept = fitted(tui, &entry.row, &[], body, cap); text::line_height(&row_line_of(tui, &entry.row, &[], &kept), body).clamp(1, cap) }) .sum::(), Node::Table { columns, rows, more, .. } => { // Only the rows a shut branch is not covering, the same reading the // drawing makes. table_height(columns, crate::outline::showing(rows, local.view()).count()) + u16::from(more.is_some()) } // Source is drawn as it was written, so its height is its own lines // wrapped. The runs concatenate back to the file exactly, which is what // `Lexeme::text` guarantees, so measuring the joined text and drawing // the spans cannot disagree. Node::Code { runs, .. } => { text::spans_height(&code_spans(tui, runs, rich_base(tui)), width) } Node::Meter(meter) => text::line_height(&meter_line(tui, meter), width), // One line per bar. The lines are built to a fixed width -- the place, // the bar and the reading -- so this measures them rather than assuming // one row each, which stops being true the moment a reading wraps. Node::Chart { axis, bars, .. } => chart_lines(tui, axis, bars) .iter() .map(|line| text::line_height(line, width)) .sum(), Node::Stats { figures, .. } => figures .iter() .map(|(figure, _)| figure_height(tui, figure, width)) .sum(), Node::Region(slot) => crate::region::height(tui, slot, width, local), // A member added since this renderer last learned the vocabulary. _ => text::height(UNDRAWN, width), } } /// How much of a row a node wants, in cells. /// /// The counterpart to [`height`] for the one axis a column never had to think /// about. A region that says its members share a row (`Slot::across`) needs to /// know how wide each one is before it can put two of them side by side, and /// every measurement here is derived from what the node holds rather than /// authored: a label's characters, a tag's, a meter's line. /// /// [`Want::Rest`] is the honest answer for two different things, and both of /// them mean "do not try to measure me": a control the description said should /// absorb what is left ([`layout::Width::Fill`]), and a node whose shape is a /// block rather than a line -- a list, a table, a form -- which has no business /// being a member of a row and is given the whole of one if it is. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum Want { /// This many cells, which is what the node holds. Cells(u16), /// Whatever the line has left. Rest, } /// The cells `node` wants as a member of a row. pub(crate) fn want(tui: &Tui, node: &Node) -> Want { /// The cells a line of spans occupies, unwrapped. fn spans_wide(line: &Line<'_>) -> u16 { u16::try_from( line.spans .iter() .map(|span| span.content.chars().count()) .sum::(), ) .unwrap_or(u16::MAX) } fn chars_wide(text: &str) -> u16 { u16::try_from(text.chars().count()).unwrap_or(u16::MAX) } match node { Node::Heading { text, .. } | Node::Text { text, .. } | Node::Notice { text, .. } => { Want::Cells(chars_wide(text)) } Node::Link { text, .. } => Want::Cells(chars_wide(text)), // A control with questions on it is not a line: the questions stand // above it here, which is what `height`'s arm says, so it takes a row // of its own rather than a place in one. Node::Act(act) if act.asks.is_empty() => { Want::Cells(spans_wide(&act_line(tui, act, false))) } Node::Token(tag) => Want::Cells(spans_wide(&Line::from(tag_span(tui, tag, false)))), Node::Figure(figure) => { Want::Cells(chars_wide(&figure.value) + 1 + chars_wide(&figure.caption)) } Node::Since { .. } | Node::Until { .. } | Node::Age { .. } => Want::Cells(CLOCK_CELLS), Node::Meter(meter) => Want::Cells(spans_wide(&meter_line(tui, meter))), // The widest bar's line, because they are drawn in a column and the // narrow ones are padded to line up with it. Node::Chart { axis, bars, .. } => Want::Cells( chart_lines(tui, axis, bars) .iter() .map(spans_wide) .max() .unwrap_or(0), ), // What the description said about this one, which is the only place a // width is stated rather than derived. `Fill` is the search box in a // toolbar; `Content` and `Fixed` are a box that should be as wide as // what goes in it, and a terminal has no better number for that than // the label plus room to type. Node::Field(field) => match field.width { layout::Width::Fill => Want::Rest, _ => Want::Cells(chars_wide(&field.label) + FIELD_BOX_CELLS), }, _ => Want::Rest, } } /// The cells a clock readout is given. /// /// `height`'s arm has the argument: the readout is sized without asking what it /// says, or the row would shuffle under the reader once a minute. const CLOCK_CELLS: u16 = 16; /// The cells a box gets beyond its label, when the description did not say the /// box absorbs the line. const FIELD_BOX_CELLS: u16 = 12; /// What a node this renderer has not learned yet draws instead of itself. /// /// `Node` is `#[non_exhaustive]` so that a new member is not a lockstep release /// across three renderers, and this is the price of that: one line, muted, in /// the place the thing would have been. /// /// A line rather than nothing, because nothing is a lie by omission. The reader /// would see a screen with a part missing and no way to tell that it was /// missing, which is worse than seeing a part that says so -- and it is exactly /// what [`Node::StandIn`] already exists to say in the description's own voice. /// So this borrows its tone: same situation, said by the renderer instead of by /// the handler. pub(crate) const UNDRAWN: &str = "(not drawn: this terminal does not know this yet)"; /// What a [`RegionKind::Handover`] with no fill says. /// /// A handover is a fill the app owes every host, so a terminal with none is /// looking at a hole rather than at a region that is finished. Saying so is the /// same bargain [`UNDRAWN`] strikes: a reader who can see that something is /// missing can go and get it elsewhere, and one shown an empty box cannot. /// /// [`RegionKind::Ceded`] deliberately gets no equivalent. Nothing is owed /// there, so silence is the correct drawing and a notice would be this renderer /// inventing a gap the app already ruled on. pub(crate) const UNFILLED: &str = "(not drawn: this host has no fill for this)"; /// 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::Code { runs, .. } => { text::draw_spans(&code_spans(tui, runs, rich_base(tui)), area, buf) } Node::Act(act) => { // What the press asks for first, drawn above the control rather // than behind it. A webview hides these in a `details` the verb // opens; a terminal has no such affordance, and two boxes standing // in the open are worth more here than a disclosure this renderer // would have to invent a key for. The values are the same either // way, which is the half the description states. let mut used = 0; for field in &act.asks { used += draw_field(pass, &field.as_asked(), below(area, used), buf); } let area = below(area, used); let focused = claim_act(pass, act); // The control this screen is waiting on is drawn as the thing it is: // pressed, working, and not answering another press. The runtime // refuses that press whether or not this is drawn, so what is here // is the saying rather than the guard. if pass.view.busy(&act.action) { used += text::draw_line(&busy_line(pass, tui, act, focused), area, buf); return used + draw_act_note(tui, act, below(area, used), buf); } let chosen = commit_count(pass, act.over.as_deref()); used += text::draw_line(&commit_line(tui, act, chosen, focused), area, buf); used + draw_act_note(tui, act, below(area, used), 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, ) } // The readouts the renderer derives from the current time. The instant // is the description's; the words and the cadence are this crate's, and // `crate::clock` holds both. `Runtime::tick_in` is what tells a host to // draw again before the number goes stale. Node::Since { at } => clock_draw(tui, Clock::Since, *at, area, buf), Node::Until { at } => clock_draw(tui, Clock::Until, *at, area, buf), Node::Age { at } => clock_draw(tui, Clock::Age, *at, 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), Node::Image(picture) => draw_image(tui, picture, 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, act, .. } => { let style = tui.style().tone(*tone).add_modifier(Modifier::BOLD); let used = text::draw(content, style, area, buf); // The one thing to do about it, under the sentence saying what // happened. `Node::StandIn`'s arm below, verbatim: the two are a // situation and the way out of it, and drawing them two ways would // be this renderer inventing a difference between them. match act { Some(act) => { let focused = claim_act(pass, act); let line = if pass.view.busy(&act.action) { busy_line(pass, tui, act, focused) } else { act_line(tui, act, focused) }; used + text::draw_line(&line, below(area, used), buf) } None => used, } } 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); let line = if pass.view.busy(&act.action) { busy_line(pass, tui, act, focused) } else { act_line(tui, act, focused) }; used + text::draw_line(&line, 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, ) } // A table that declared no columns is a list, and draws as one: a run // per row rather than a grid. The arm below draws the grid, and both // hold the same `Row`. Node::Table { columns, rows, more, .. } if columns.is_empty() => { // 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); // The chevron column, spent on every row of a list that holds a // branch so the labels line up. See `outline::lead`. let branches = rows.iter().any(|row| row.open.is_some()); let folded = crate::outline::folds(rows, Some(pass.view)); let mut used = 0; for (row, folded) in rows.iter().zip(folded) { // A row a shut branch covers is not drawn, claims nothing and // takes no room. Nothing is counted past for it either: the // focus walk skipped it too, so the two orders agree. if folded { continue; } // 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 at = below(area, used); if at.height == 0 { continue; } // The row's own indent, after the list's gutter and before its // run. Per row rather than per list, which is what a flat list // of rows carrying their depth means. let lead = crate::outline::lead(row.depth, branches); // What fits is decided at the width the row will actually get, // and the height pass above answers the same question the same // way. A drop the two disagreed about is a row drawing over the // one under it. let cap = row_lines(row); let kept = fitted( tui, row, &parts, at.width.saturating_sub(gutter).saturating_sub(lead), cap, ); let line = row_line_of(tui, row, &parts, &kept); draw_gutter(tui, pass.view, row, focused, at, buf); // The chevron, at the end of the row's indent and immediately // before its words. A separate mark from the caret in the // gutter: that one says where the reader is and this one says // what the row holds, and a terminal that drew them in one // column would have to choose between the two facts. if let Some(described) = row.open { let open = pass.view.open(&row.key(), described); buf.set_stringn( at.x + gutter + lead.saturating_sub(crate::outline::STEP), at.y, crate::outline::chevron(open), 1, tui.style() .focused(focused, Style::default().fg(tui.theme().content_secondary)), ); } // The run is capped here, which is the whole of what `Flow` // buys a terminal: `draw_line` stops at `area.height`, so // giving it the row's own budget is what turns "as many lines // as the words need" into "as many as the description allowed". let body = Rect { x: at.x + gutter + lead, width: at.width.saturating_sub(gutter).saturating_sub(lead), height: at.height.min(cap), ..at }; let drew = text::draw_line(&line, body, buf).max(1); ellipsis_if_cut(tui, &line, body, buf); used += drew; } match more { Some(rest) => used + draw_rest(pass, tui, rest, below(area, used), buf), None => used, } } // The clock column, then the row. See `height` for what this renderer // keeps and what it gives up. // // Drawn in the order the description gave, not sorted by start. Sorting // is presentation and this renderer could do it, but a caller that // built its entries in a deliberate order would find them silently // rearranged, and the description has no way to say which it meant. // `Node::Timeline`'s doc records that a renderer must not assume the // entries are sorted; quietly sorting them is the same assumption from // the other side. Node::Timeline { track, entries, .. } => { let mut used = 0; for entry in entries { let focused = claim_row(pass, &entry.row); let parts = claim_parts(pass, &entry.row); let entry_cap = row_lines(&entry.row); let kept = fitted( tui, &entry.row, &parts, area.width.saturating_sub(TIMELINE_GUTTER), entry_cap, ); let line = row_line_of(tui, &entry.row, &parts, &kept); let at = below(area, used); if at.height == 0 { continue; } // Wall clock, wrapped past midnight, matching what the webview // writes into its ruler. A span may legitimately count past // 1440 so that it needs no date. let minute = entry.placement.at(); let clock = format!("{:02}:{:02} ", (minute / 60) % 24, minute % 60); let style = if focused { tui.style().muted.add_modifier(Modifier::REVERSED) } else { tui.style().muted }; buf.set_span( at.x, at.y, &Span::styled(clock, style), TIMELINE_GUTTER.min(at.width), ); let body = Rect { x: at.x + TIMELINE_GUTTER, width: at.width.saturating_sub(TIMELINE_GUTTER), height: at.height.min(entry_cap), ..at }; let drew = text::draw_line(&line, body, buf).max(1); ellipsis_if_cut(tui, &line, body, buf); used += drew; } // `track` is read for nothing here, and that is the honest state: // the span, the slot and the tick are all questions about where to // put a gridline, and this renderer draws none. Named rather than // ignored with `..` so that a member added to `Track` has to come // past this comment. let _ = track; used } Node::Table { columns, rows, more, .. } => { let used = draw_table(pass, columns, rows, area, buf); // Under the table and belonging to it, which is the adjacency the // task list lost when its paging had to be a separate `Node::Act`. match more { Some(rest) => used + draw_rest(pass, tui, rest, below(area, used), buf), None => used, } } Node::Meter(meter) => text::draw_line(&meter_line(tui, meter), area, buf), Node::Chart { axis, bars, .. } => { let mut used = 0; for line in chart_lines(tui, axis, bars) { used += text::draw_line(&line, below(area, used), buf); } used } 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), // A member added since this renderer last learned the vocabulary. See // [`UNDRAWN`] for why it says so rather than drawing nothing, and // `focus.rs` for why it is not reachable while it says it. _ => text::draw( UNDRAWN, Style::default().fg(tui.theme().content_muted), 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, "", &pass.local(), &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.cells .iter() .map(|cell| { cell.content.iter().any(|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. /// The clock column a timeline draws its rows against. /// /// `HH:MM ` is six cells. Fixed rather than measured, because a ragged clock /// column is worse than a wide one and every entry on an axis has a time by /// construction — there is no "some rows have one" case the way there is for a /// tick. const TIMELINE_GUTTER: u16 = 6; /// One piece of the line under a partial set: its words, and what pressing it /// calls. pub(crate) struct RestPiece<'a> { /// What is printed. pub words: String, /// What a caret on it calls, when it is somewhere to go rather than a /// readout. pub action: Option<&'a Action>, } /// The one line under a partial list, as the pieces it is made of. /// /// One line whatever the description carries, because a terminal has no room to /// spend on chrome and because a pager that grows a row on page two moves /// everything below it. That is "first paint is final paint" in a renderer that /// measures in cells: [`height`] adds exactly one for this whatever this /// returns, and the drawing truncates rather than wrapping. /// /// # Pieces rather than a string, and why the focus walk reads it too /// /// The drawing and the focus walk both read this, so they cannot count the /// stops on a line differently. A walk that pushes a stop the drawing claims no /// position for puts every caret below it one place early, which is the defect /// `the_drawing_counts_the_same_table_rows_the_walk_stops_on` records. /// /// Prev and Next are printed whether or not they can be pressed, for the reason /// the webview draws them disabled: a pager that gains a word on page two moves /// what is beside it. /// /// A page count is the same "3 / 8" the webview prints, so a bug report reads /// the same from either host -- and where the description offered jumps the /// numbered pages replace it, for the webview's reason: the strip already says /// which page and how many, and printing the position twice is a control /// arguing with itself. pub(crate) fn rest_pieces(rest: &Rest) -> Vec> { let mut pieces = vec![RestPiece { words: "Prev".to_string(), action: rest.back.as_ref(), }]; if rest.jumps.is_empty() { let paging = rest.as_layout(); pieces.push(RestPiece { words: match (paging.page(), paging.pages_total()) { (Some(page), Some(total)) => format!("{page} / {total}"), _ => match paging.remaining() { Some(0) => "No more".to_string(), Some(remaining) => format!("{remaining} more"), None => "More".to_string(), }, }, action: None, }); } else { for jump in &rest.jumps { pieces.push(RestPiece { words: jump.page.to_string(), // The page the reader is on is a readout and not somewhere to // go. A control that reloads the page it is on is the lying // control the webview refuses for the same reason. action: if jump.here { None } else { Some(&jump.action) }, }); } } pieces.push(RestPiece { words: "Next".to_string(), action: rest.forward.as_ref(), }); pieces } /// The pager line, claiming one position per piece a caret can land on. /// /// The claim order is [`rest_pieces`]' order, which is the order /// `focus::reaches` pushes them in, which is what keeps the caret and the /// highlight on the same word. fn draw_rest(pass: &mut Pass<'_>, tui: &Tui, rest: &Rest, area: Rect, buf: &mut Buffer) -> u16 { let mut spans = Vec::new(); for piece in rest_pieces(rest) { if !spans.is_empty() { spans.push(Span::raw(" ")); } let style = match piece.action { Some(_) => { let focused = pass.claim(); tui.style() .focused(focused, Style::default().fg(tui.theme().action_primary)) } // Not reachable, so it takes no position and never lights: the // readout between the ends, and the page already being read. None => Style::default().fg(tui.theme().content_muted), }; spans.push(Span::styled(piece.words, style)); } text::draw_line(&Line::from(spans), area, buf) } 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; } // A row of a live selection, marked in the same gutter and never as a box: // a box is the affordance for staging a set, and this set is already in // force. `1894e95d`. The caret's own `>` still wins where they coincide, // because "you are here" is the more perishable fact of the two. if row.chosen == Some(true) && !(row.current || focused) { buf.set_stringn( area.x, area.y, "*", 1, tui.style() .focused(focused, Style::default().fg(tui.theme().action_primary)), ); 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 terminal reads what the description says, in the order it says it, and /// the role picks the style. No fixed sequence of parts is hardcoded here. /// `focus` carries one answer per part, in the run's own order, and is empty /// for the callers that are measuring rather than drawing. /// The run as a line, from the parts a caller decided to keep. /// /// `kept` holds indices into `row.cells`, so focus stays keyed to the part it /// was claimed for even when parts before it were dropped. Claims happen before /// layout and must not be renumbered by it. fn row_line_of(tui: &Tui, row: &Row, focus: &[bool], kept: &[usize]) -> Line<'static> { let mut spans = Vec::new(); for &index in kept { let Some(cell) = row.cells.get(index) else { continue; }; if !spans.is_empty() { spans.push(Span::raw(" ")); } let focused = focus.get(index).copied().unwrap_or(false); // A list draws over the default column set, so every cell in one is // keyed by role. A cell keyed to a declared column has reached a list // row, which is a description error rather than something to style: // the table constructors produce those keys and a list row is built by // `Row::new` and its siblings. // // Loud in debug and benign in release, matching the webview and the two // assertions `Table::row` already carries (`d41d00a`). A panic in a // description is worse than a line drawn under the wrong style. debug_assert!( matches!(cell.key, quasi_router::CellKey::Role(_)), "a cell keyed to a declared column reached a list row, which draws over the \ default column set and has no column to style it from. Key: {:?}", cell.key, ); let style = match &cell.key { quasi_router::CellKey::Role(role) => part_style(tui, *role), _ => part_style(tui, quasi_router::layout::RowPart::Primary), }; for node in &cell.content { spans.extend(inline_spans(tui, node, style, 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) } /// Every part, in order: what a row draws when there is room for all of it. fn kept_all(row: &Row) -> Vec { (0..row.cells.len()).collect() } /// The parts that survive a run too tall for its cap. /// /// Drops by [`quasi_router::Cell::worth`], least valuable first, and only when /// dropping earns something: if the run still does not fit with every droppable /// part gone, the whole run comes back and the cap cuts it. That is the rule /// that keeps this honest. Dropping a badge to make room for a title that /// overflows on its own changes nothing a reader can see -- the badge was past /// the cut either way -- so it would be spending the description's words for /// no picture. /// /// `Essential` is never dropped, so a row still identifies itself and still /// offers what it offers however narrow the terminal gets. /// /// **A control is never dropped whatever it is worth.** Focus is claimed per /// part before any of this runs, so removing an `Act` would leave a claim /// pointing at something nobody drew: a key that moves the cursor onto a /// control that is not there. A description marking its own action `Optional` /// is saying something about the picture, and this is the one place where the /// picture is not the whole story. /// /// Whole parts rather than characters. Cutting the tail is what the cap already /// does and it takes whatever happens to be last; this takes what the /// description said it could spare, which is the difference the ladder is for. fn fitted(tui: &Tui, row: &Row, focus: &[bool], width: u16, cap: u16) -> Vec { let all = kept_all(row); let fits = |kept: &[usize]| text::line_height(&row_line_of(tui, row, focus, kept), width) <= cap; if fits(&all) { return all; } let mut kept = all.clone(); for tier in [layout::Priority::Optional, layout::Priority::Secondary] { kept.retain(|&index| { row.cells.get(index).is_none_or(|cell| { cell.content.iter().any(|n| matches!(n, Node::Act(_))) || cell.worth() != tier }) }); if fits(&kept) { return kept; } } all } /// Mark a run that the cap cut short. /// /// A clamp that just stops is a row that looks complete and is not, and the /// reader has no way to tell the difference. The webview does not have this /// problem -- `-webkit-line-clamp` writes the ellipsis itself -- so this is the /// terminal paying for the same honesty by hand. /// /// Overwrites the last cell of the last line the cap allowed. That cell already /// holds content, which is the point: there is no room to append to a full /// line, and a character of the cut text is the right thing to spend. fn ellipsis_if_cut(tui: &Tui, line: &Line<'_>, body: Rect, buf: &mut Buffer) { if body.width == 0 || body.height == 0 { return; } if text::line_height(line, body.width) <= body.height { return; } buf.set_stringn( body.x + body.width - 1, body.y + body.height - 1, "\u{2026}", 1, tui.style().muted, ); } /// How many lines a row may take, from what its parts asked for. /// /// The max rather than the sum. A row is an inline run: its parts share one /// wrapped flow here rather than stacking the way a webview's spans do, so a /// part asking for two lines is asking *the run* for a second line, and two /// relaxed parts in one row are still asking for the same second line. Summing /// would give a row of five tight parts five lines, which is the unbounded /// behaviour this cap exists to end. /// /// `layout::Flow::lines` owns the numbers, so a tier added upstream arrives /// here without this function being edited. fn row_lines(row: &Row) -> u16 { row.cells .iter() .map(|cell| u16::from(cell.room().lines())) .max() .unwrap_or(1) .max(1) } /// 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 run of source, as a span under this terminal's palette. /// /// `19d7602d`. The classification arrived with the description and this is the /// half a terminal owes: mapping eight names onto the colours it actually has. /// /// # It spends status colours, and that is deliberate /// /// This theme has no syntax palette and is not going to grow one: a /// highlighting palette is held fixed while everything around it changes, which /// is the opposite of what a theme is for. So the mapping reaches for the /// status colours, and a red variable does not mean an error here any more than /// a red variable means one in any editor. The alternative was drawing every /// file in one colour, which loses the whole of what the description carried. /// /// The pairing follows base16 Tomorrow, which is the palette the measured /// consumer already fixed: comments quiet, strings green, constants amber, /// definitions blue, uses red. fn lexeme_span(tui: &Tui, run: &quasi_router::screen::Lexeme, base: Style) -> Span<'static> { let theme = tui.theme(); let style = match run.syntax { layout::Syntax::Plain => base, layout::Syntax::Comment => base.fg(theme.content_muted), layout::Syntax::String => base.fg(theme.status_success), layout::Syntax::Keyword => base.fg(theme.action_primary), layout::Syntax::Constant => base.fg(theme.status_warning), layout::Syntax::Entity => base.fg(theme.status_info), layout::Syntax::Variable => base.fg(theme.status_danger), layout::Syntax::Support => base.fg(theme.content_secondary), // A class added to this `#[non_exhaustive]` axis since this renderer // last learned the vocabulary. Ordinary code is the safe reading: the // text is drawn either way and only the colour is lost. _ => base, }; Span::styled(run.text.clone(), style) } /// Every run of a code node, as spans. fn code_spans(tui: &Tui, runs: &[quasi_router::screen::Lexeme], base: Style) -> Vec> { runs.iter().map(|run| lexeme_span(tui, run, base)).collect() } /// 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), // An inline literal in a row or a cell: a clone URL, a fingerprint, a // config line. Every cell is monospace here, so what a webview says // with a typeface is already true and only the colouring is left. Node::Code { runs, .. } => code_spans(tui, runs, 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, )], // In the run rather than on a line of its own, which is where the // measured one is: goingson's elapsed time sits on the task row beside // the title. It takes the role's colour like any other part, because // what it says is a value and not a state. Node::Since { at } => vec![Span::styled(clock_text(Clock::Since, *at), inherited)], Node::Until { at } => vec![Span::styled(clock_text(Clock::Until, *at), inherited)], Node::Age { at } => vec![Span::styled(clock_text(Clock::Age, *at), 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), )], } } /// A time-derived readout, drawn on its own line. fn clock_draw(tui: &Tui, clock: Clock, at: SystemTime, area: Rect, buf: &mut Buffer) -> u16 { text::draw( &clock_text(clock, at), tui.style().tone(layout::Tone::Neutral), area, buf, ) } /// What a readout of this kind says at this instant. /// /// The clock is read here rather than passed in, which is the shape the ruling /// asks for: the renderer owns it. A test that needs a fixed answer calls /// [`crate::clock::text`] with a `now` of its own. fn clock_text(clock: Clock, at: SystemTime) -> String { crate::clock::text(clock, at, SystemTime::now()) } /// 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. /// # What this renderer does with a hint /// /// Drops it.: a terminal has no hover and no second surface to put standing /// help on, and the alternatives are both worse than nothing -- appending it /// to the label turns a badge into a sentence and defeats the reason a badge /// is short, and a status line borrowed for it would be competing with what /// the runtime already puts there. /// /// This is the graceful degradation [`Tag::hint`] describes rather than a gap, /// and it is why that field says nothing may live only there. Stated here so /// that a reader comparing the three renderers finds an answer rather than an /// omission. fn tag_span(tui: &Tui, tag: &Tag, focused: bool) -> Span<'static> { piece::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> { piece::act(tui.style(), &act.as_layout(), focused) } /// A control that has been pressed and has not been answered yet. /// /// [`layout::State::Disabled`], and the wait beside it. /// /// **The lock is rule 4 and stays.** A control that refuses a second press is /// already saying something, and on most waits it says the whole of it. What it /// cannot say is whether the wait has a size, which is what the mark adds. /// /// # The reflow this accepts /// /// Appending the mark widens the control the moment it is pressed, which is the /// reflow "first paint is final paint" otherwise forbids. It is drawn anyway, /// on the standard's own reading: a wait is a state of the control rather than /// an ornament beside it, and a state that cannot be seen is not being /// reported. The widening is bounded and it happens on a press the reader just /// made, which is the one moment they are looking at that control. fn busy_line(pass: &Pass<'_>, tui: &Tui, act: &Act, focused: bool) -> Line<'static> { let mut described = act.as_layout(); described.state = Some(layout::State::Disabled); let mut line = piece::act(tui.style(), &described, focused); let Some(awaiting) = act.action.awaiting else { return line; }; let progress = pass.view.progress_at(std::time::Instant::now()); let lit = makeover_tui::activity_lit(progress.elapsed.unwrap_or_default(), tui.reduced_motion()); line.spans.push(Span::raw(" ")); line.spans .extend(piece::awaiting(tui.style(), awaiting, progress, lit).spans); line } /// How many are ticked, for a control that acts on the selection. /// /// `None` for a control that does not, which is nearly all of them. fn commit_count(pass: &Pass<'_>, over: Option<&str>) -> Option { over.map(|_| pass.view.ticks().count()) } /// A control over the screen's selection, drawn with the set it would act on. /// /// Two things the description cannot say and this renderer can. **How many are /// ticked**: the set is the host's until something submits it, so the store has /// no idea and a description built from the store cannot carry the number. And /// **that pressing it now would do nothing**: a commit control over an empty /// selection is offered, pressed, and answers "0 tasks completed", which is a /// screen letting a user find out by trying. /// /// `bulk-actions.js` says both by hiding its bar and writing "3 selected" into /// it. Hiding is not the move here — a bar that vanishes takes with it the only /// evidence that bulk actions exist, and the shipped screen can hide it because /// its rows carry checkboxes that stay put. Disabled says the same thing and /// keeps the affordance on screen, which is the rule the row actions already /// follow. fn commit_line(tui: &Tui, act: &Act, chosen: Option, focused: bool) -> Line<'static> { let Some(chosen) = chosen else { return act_line(tui, act, focused); }; // The count rides on the label rather than in a status line of its own, // because a status line needs somewhere to go and nothing in the // description says where. On the control it is unambiguous besides: it is // the number this press would act on. let label = if chosen == 0 { act.label.clone() } else { format!("{} ({chosen})", act.label) }; let state = if chosen == 0 { Some(layout::State::Disabled) } else { act.state }; piece::act( tui.style(), &layout::Act { label: &label, key: act.key.as_deref(), tone: act.tone, state, // Read by `piece::act_note` rather than by `piece::act`: a control // is one line and its note is another, so the caller places both. hint: act.hint.as_deref(), }, focused, ) } /// The rows a control's standing help takes under it. /// /// Zero for the control that has none, which is nearly all of them, so a /// screen written before the member existed measures exactly as it did. fn act_note_height(tui: &Tui, act: &Act, width: u16) -> u16 { act_note(tui, act).map_or(0, |note| text::line_height(¬e, width)) } /// Standing help, as the muted row under the control. /// /// A row rather than a hover, because a terminal has no pointer to hang one on. /// `makeover_immediate::widget::act` keeps the hover, which is that host reading /// itself correctly; both draw the sentence the description states. /// /// Only from the block arm. A control inside a run is one line by construction /// and has nowhere to put a second, which is the same reason its /// [`Act::asks`](quasi_router::Act::asks) are dropped there. fn draw_act_note(tui: &Tui, act: &Act, area: Rect, buf: &mut Buffer) -> u16 { act_note(tui, act).map_or(0, |note| text::draw_line(¬e, area, buf)) } /// The muted line a control's [`Act::hint`](quasi_router::Act::hint) draws as. /// /// `piece::act_note`'s since makeover-layout 0.40.0 moved the member down and /// makeover-tui grew somewhere to read it. It was built here for three /// releases because `makeover_layout::Act` carried no hint, which is the same /// line drawn in the same style -- what changes is that a makeover host that /// is not quasi gets it too. fn act_note(tui: &Tui, act: &Act) -> Option> { piece::act_note(tui.style(), &act.as_layout()) } /// A meter as a line, bar and label. fn meter_line(tui: &Tui, meter: &Meter) -> Line<'static> { piece::meter(tui.style(), &meter.as_layout()) } /// A chart's bars, one line each. /// /// The borrow has to be built here rather than passed through, because the /// description owns its bars and `makeover-tui` draws the borrowed ones. Every /// other compound member in this renderer does the same. fn chart_lines(tui: &Tui, axis: &Chart, bars: &[Bar]) -> Vec> { let borrowed: Vec<_> = bars.iter().map(Bar::as_layout).collect(); piece::chart(tui.style(), &axis.as_layout(), &borrowed) } /// A figure takes two rows: the number, then what it counts. fn figure_height(_tui: &Tui, figure: &Figure, width: u16) -> u16 { piece::figure_height(&figure.as_layout(), width) } fn draw_figure(tui: &Tui, figure: &Figure, area: Rect, buf: &mut Buffer) -> u16 { piece::figure(tui.style(), &figure.as_layout(), area, buf) } /// A picture is its alt text here, and a decorative one is nothing. /// /// The terminal's honest answer, and the reason `layout::Image::alt` is not an /// `Option`. There is no graphics protocol in this renderer -- ratatui draws /// cells -- so what a reader gets is the words the picture stands for. An empty /// alt is the description saying the picture adds nothing to the text around /// it, and repeating "image" in its place would be worse than the gap. /// /// `Fit` is read and deliberately not honoured, the way `Notice`'s kind is: /// fitting is about a box with proportions, and a run of words has none. fn image_height(picture: &Image, width: u16) -> u16 { if !picture.speaks() { return 0; } text::height(&picture.alt, width) + picture .caption .as_ref() .map_or(0, |c| text::height(c, width)) } fn draw_image(tui: &Tui, picture: &Image, area: Rect, buf: &mut Buffer) -> u16 { if !picture.speaks() { return 0; } // Muted, because this is standing in for something rather than being it. let used = text::draw(&picture.alt, tui.style().muted, area, buf); let Some(caption) = &picture.caption else { return used; }; // A caption is ordinary content that happens to sit under a picture, so it // is not muted: it reads the same whether or not the picture arrived. used + text::draw(caption, tui.style().secondary, below(area, used), 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, local: &Local<'_>) -> u16 { // Nothing, for a question that does not apply: the drawing leaves it out // and this walk counts what the drawing paints. `8fdb814c`. if local.field_out(&field.name) { return 0; } let Some(repeat) = &field.repeats else { return one_field_height(tui, field, width); }; // A question answered N times: the message about the set, then a box per // slot with its remove control under it, then the add control. // `60d1753c`. Counted here exactly as `draw_field` paints it, because the // caret is an index into a walk that reads the same numbers. let standing = local.standing(field); let slots: u16 = (0..standing) .map(|at| { field .instance_fields(at) .iter() .map(|slot| one_field_height(tui, slot, width)) .sum::() + repeat_slot_extra(tui, field, at, width) + u16::from(repeat.fewer(standing)) }) .sum(); field .error .as_ref() .map_or(0, |error| text::height(error, width)) + slots + u16::from(repeat.add.label().is_some() && repeat.more(standing)) } fn one_field_height(tui: &Tui, field: &Field, width: u16) -> u16 { field.with_layout(|field| piece::field_height(tui.style(), &field, width)) } fn draw_field(pass: &mut Pass<'_>, field: &Field, area: Rect, buf: &mut Buffer) -> u16 { // Left out rather than dimmed or explained, which is the answer this // renderer already gives a region that does not apply. `8fdb814c`. if pass.local().field_out(&field.name) { return 0; } let Some(repeat) = field.repeats.clone() else { return draw_one_field(pass, field, area, buf); }; // A question answered N times. `60d1753c`. Each slot is // `Field::instance`, so a slot is drawn by exactly what this renderer // already does to a field, and the two controls are stops of their own for // the reason `Spot::Repeat` gives. let tui = pass.tui; let standing = pass.view.standing(field); // What is wrong with the *set*, which no slot's own message can carry. A // slot's error is drawn against its own box, by the field drawing. let mut used = field.error.as_ref().map_or(0, |error| { text::draw(error, tui.style().tone(layout::Tone::Danger), area, buf) }); for at in 0..standing { for slot in field.instance_fields(at) { used += draw_one_field(pass, &slot, crate::below(area, used), buf); } // What is wrong with the slot as a whole, which no part's own message // carries, and how far the work on it has got. Counted by // `repeat_slot_extra`, which is the same walk this paints. if let Some(slot) = repeat.instances.get(at) { if let Some(error) = &slot.error { used += text::draw( error, tui.style().tone(layout::Tone::Danger), crate::below(area, used), buf, ); } if let quasi_router::Progress::Working(Some(meter)) = &slot.progress { used += text::draw_line(&meter_line(tui, meter), crate::below(area, used), buf); } } if repeat.fewer(standing) { used += draw_repeat_control(pass, &repeat.remove, crate::below(area, used), buf); } } // Nothing for a question whose slots come from another control: this // renderer may not have that control at all, and inventing one would offer // a blank the reader cannot fill. if let Some(label) = repeat.add.label().filter(|_| repeat.more(standing)) { used += draw_repeat_control(pass, label, crate::below(area, used), buf); } used } /// The rows one slot takes beyond its own boxes: its message, and how far the /// work on it has got. /// /// Counted apart from the boxes so that `field_height` and `draw_field` cannot /// disagree about a slot that failed, which is the same reason the two read /// `instance_fields` rather than looping the parts themselves. fn repeat_slot_extra(tui: &Tui, field: &Field, at: usize, width: u16) -> u16 { let Some(slot) = field .repeats .as_ref() .and_then(|repeat| repeat.instances.get(at)) else { return 0; }; slot.error .as_ref() .map_or(0, |error| text::height(error, width)) + match &slot.progress { quasi_router::Progress::Working(Some(meter)) => { text::line_height(&meter_line(tui, meter), width) } _ => 0, } } /// One of the two controls a repeating question offers, drawn as the control it /// is. /// /// Named by the description -- "Add reminder", "Remove" -- rather than bound to /// a chord, because a key this renderer picked is a fact no description could /// have written down and a reader could not have read off the screen. fn draw_repeat_control(pass: &mut Pass<'_>, label: &str, area: Rect, buf: &mut Buffer) -> u16 { let focused = pass.claim(); let tui = pass.tui; text::draw_line( &Line::from(vec![Span::styled( format!("[ {label} ]"), tui.style() .focused(focused, Style::default().fg(tui.theme().action_primary)), )]), area, buf, ) } fn draw_one_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. // An interval is held as two values, under the two names it submits under. // Either end may be empty while the other stands, which is an open interval // rather than a half-filled box. let upper = field .upper_name .as_deref() .map(|name| pass.view.showing(name, field.upper_value.as_deref())); let held = match (field.kind, upper) { (layout::FieldKind::Checkbox, _) => piece::Held::On(held == Node::SELECTED), (layout::FieldKind::Interval, Some(upper)) => piece::Held::Between { lower: held, upper }, _ => piece::Held::Text(held), }; let used = field .with_layout(|described| piece::field(tui.style(), &described, held, focused, area, buf)); // The room under the box, remembered rather than drawn into: the regions // after this one would paint over a list drawn here. See `Pass::suggesting`. // Only for the field the list belongs to, which is the field being typed // into, so a second field's stale candidates cannot appear under a box // nobody is in. if pass.view.suggesting(&field.name).is_some() { pass.suggesting = Some(crate::below(area, used)); } used } /// The open suggestion list, drawn over whatever is under the box. /// /// One row per candidate, in the order the route offered them, cut off at the /// bottom of the room there is — which is what a terminal does with /// everything, and is why the highlight is not scrolled to: a list long enough /// to need scrolling is a route answering with more than a reader can take in, /// and the floor and the wait are what the description has to say about that. /// /// A candidate that cannot be picked is drawn muted with its reason beside it, /// which is the same pair the webview draws and the same pair /// [`Choice::unavailable`] carries. pub(crate) fn draw_suggestions(tui: &Tui, view: &View, area: Rect, buf: &mut Buffer) { let Some(open) = view.suggesting_here() else { return; }; for (at, choice) in open.options.iter().enumerate() { let Ok(row) = u16::try_from(at) else { return; }; if row >= area.height { return; } let here = Rect { height: 1, ..crate::below(area, row) }; // Painted before the label, because the list sits over content that is // already drawn and a row of it that is shorter than the list's width // would leave the old screen showing through. buf.set_style(here, tui.style().sunken); let style = tui .style() .focused(open.at == Some(at), tui.style().content); let mut spans = vec![Span::styled(choice.label.clone(), style)]; // The second line, which in a terminal is the rest of the row rather // than a second line. `1fcf2e9b`: this is what tells two candidates // apart when their labels read alike, and it is why a suggestion is not // a `Choice`. Muted, because it orients rather than answers. if let Some(detail) = &choice.detail { spans.push(Span::styled(format!(" {detail}"), tui.style().muted)); } text::draw_line(&Line::from(spans), here, buf); } } /// A header row plus one row per row of cells. /// What the tick column calls itself. /// /// A name rather than an empty string because `makeover_tui::table` addresses /// cells by their column's name, so two unnamed columns would be one column /// twice. It is never shown: the heading a user reads is blank, the same way /// the webview's is. const TICK_COLUMN: &str = "select"; fn table_height(_columns: &[quasi_router::Column], shown: usize) -> u16 { 1 + u16::try_from(shown).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: &[Row], area: Rect, buf: &mut Buffer, ) -> u16 { use ratatui::widgets::{StatefulWidget, TableState}; // A row is one stop 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. A control in a cell is reached by stepping into the row, // and `lit` below is where that shows. // Every reachable 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. // // The test has to be the one `focus.rs` pushes a `Spot::Row` on, exactly: // that walk decides what the caret can land on and this one decides what the // drawing counts, so a row counted in one and not the other shifts every // stop below the table. It read `activate.is_some()` alone until 2026-08-17, // which was already wrong for a table whose rows are tickable but do not // open — goingson's task list is one — and adding `menu` to the walk without // this would have widened the same gap to a third case. It is one function // in `focus.rs` now, so the two cannot drift again. // The rows a shut branch is not covering, which is what the table has for // this frame. Taken before anything is counted: the focus walk skipped the // folded ones too, so an index here is an index there. let shown: Vec<&Row> = crate::outline::showing(rows, Some(pass.view)) .map(|(_, cells)| cells) .collect(); let rows = shown.as_slice(); let mut focused = None; for (index, cells) in rows.iter().enumerate() { if crate::focus::row_reachable(cells) && pass.claim() { focused = Some(index); } } // Which part of which cell the caret has stepped onto, when it has stepped // into a row at all. The same `(column, part)` list the reach walk built its // `inside` from, so the ring lands on the control Enter would press. let lit = focused.and_then(|index| { let at = pass.view.inside()?; let cells = rows.get(index)?; crate::focus::inside(cells).get(at).copied() }); let tui = pass.tui; // A tick takes no column in the description, and a terminal table has no // gutter to put one in, so it is drawn as a leading column this renderer // adds. Essential, because a column that can drop is one a narrow terminal // silently makes unselectable; three cells wide, matching the `[x]` a list // row draws in its own gutter, so the two read alike on one screen. // Whether any row in the table is a branch, which is what buys the leading // column its chevron and every row its indent. `outline::lead`'s rule, in // the one place a table can spend the room. let branches = rows.iter().any(|row| row.open.is_some()); let ticks = rows.iter().any(|row| row.selected.is_some()); // The same narrow column serves a live selection, because it is the same // question in the same place: what is this row's standing in the set. Only // the mark differs -- a box for a tick, which is an affordance to press, and // a bare mark for a choice, which is a state already in force. `1894e95d`. let chooses = rows.iter().any(|row| row.chosen.is_some()); let gutter = ticks || chooses; let mut named: Vec> = Vec::with_capacity(columns.len() + usize::from(gutter)); if gutter { named.push(layout::Column { width: layout::Width::Fixed, priority: layout::Priority::Essential, ..layout::Column::new(TICK_COLUMN) }); } named.extend(columns.iter().map(quasi_router::Column::as_layout)); // 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() .enumerate() .map(|(index, cells)| { let mut row: Vec> = Vec::with_capacity(columns.len() + usize::from(gutter)); if gutter { // The set the view is holding decides it, not the description, // which is the rule `39057019` settled for a field and // `draw_gutter` follows for a list row: the description says // what arrived and the view says what the user has done since. // A redraw reading the description would undo the tick the // moment anything else on the screen changed. let drawn = match (cells.selected, cells.value.as_deref()) { (Some(_), Some(value)) if pass.view.is_ticked(value) => "[x]", (Some(_), _) => "[ ]", // A live selection is the description's own answer and not // the view's, which is the whole difference from the two // above: the app holds the set, so a redraw reading the // description is reading the truth rather than undoing it. (None, _) if cells.chosen == Some(true) => " * ", (None, _) => "", }; row.push(table::Cell::new(TICK_COLUMN, Line::from(drawn))); } row.extend(columns.iter().zip(&cells.cells).enumerate().map( |(at, (column, cell))| { // The lit part, and only in the row the caret is on: // `lit` is already `None` unless this row is focused, so // the column test is all that is left to do here. let within = lit.filter(|(col, _)| Some(index) == focused && *col == at); let mut line = cell_line(tui, cell, within.map(|(_, part)| part)); // The outline, in the first column and nowhere else. A // table has no gutter to indent in and the indent is not a // value in the grid, so it rides in front of the row's // leading text -- which is the cell the eye reads the // hierarchy from anyway. if at == 0 && branches { let mark = match cells.open { Some(described) => { let open = pass.view.open(&cells.key(), described); format!("{} ", crate::outline::chevron(open)) } None => " ".to_string(), }; let indent = " ".repeat( usize::from(cells.depth.level) * usize::from(crate::outline::STEP), ); line.spans.insert(0, Span::raw(format!("{indent}{mark}"))); } // Which side of a change this line is on, when the table is // a diff. `19d7602d`. A marker in front of the row rather // than a tint behind it, which is what every terminal diff // has always done and what a reader already reads: the sign // survives a monochrome terminal, a colour does not, and // this renderer has no per-row background to spend anyway. // // The colour goes on beside it, off `Change`'s own intent, // so a terminal with status colours gets both. if at == 0 && let Some(change) = cells.change { let (sign, style) = match change { layout::Change::Added => { ("+", Style::default().fg(tui.theme().status_success)) } layout::Change::Removed => { ("-", Style::default().fg(tui.theme().status_danger)) } // Including a kind this renderer has not learned: // an unchanged line, which is the reading that // draws the text and loses only the sign. _ => (" ", Style::default()), }; line.spans.insert(0, Span::styled(sign, style)); } table::Cell::new(column.name.as_str(), line).part(cell_part(cell)) }, )); row }) .collect(); let widget = table::table(&named, &body, &sizing, &tui.table, area.width); let height = table_height(columns, rows.len()).min(area.height); let within = Rect { height, ..area }; // `Row::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. /// /// `lit` is the part the caret has stepped onto, as an index into the cell's /// run, and `None` on every cell of every row but the one it is in. It is what /// the two-step focus order has instead of a ring around a rect: the row takes /// the table's own row highlight and the control inside it takes the focus /// style, so the reader can see which of a row's buttons Enter would press. fn cell_line(tui: &Tui, cell: &Cell, lit: Option) -> Line<'static> { let mut spans = Vec::new(); for (at, part) in cell.content.iter().enumerate() { if !spans.is_empty() { spans.push(Span::raw(" ")); } spans.extend(inline_spans( tui, part, Style::default().fg(tui.theme().content_primary), lit == Some(at), )); } 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.content.iter().any(|part| matches!(part, Node::Act(_))) { return layout::CellPart::Actions; } if cell .content .iter() .any(|part| matches!(part, Node::Link { .. })) { return layout::CellPart::Link; } if cell .content .iter() .any(|part| matches!(part, Node::Token(_))) { return layout::CellPart::Tokens; } layout::CellPart::Value }