Skip to main content

makeover_tui/
piece.rs

1//! The pieces every terminal app draws, drawn once.
2//!
3//! # Not `widget`
4//!
5//! `makeover-layout` owns that word for something else, and the two meanings do
6//! not sit together. A `Region::Widget` there is host-agnostic: a named
7//! assembly of primitives that every renderer draws its own way. What is in
8//! this module is the opposite end, renderer-local, the answer to *what a meter
9//! looks like in cells*, taking a description plus what only a terminal knows.
10//! The style type is `PieceStyle`.
11//!
12//! A meter, a badge, a control, a figure and a form field are what a screen is
13//! made of below the level [`table`](crate::table) works at. [`activity`] and
14//! [`awaiting`] draw a wait, out of wiki `loading-and-progress-standard`.
15//!
16//! # What these take, and what they leave alone
17//!
18//! Each takes a `makeover-layout` description, a [`PieceStyle`], and whatever
19//! the *host* knows that a description never carries. That last part is the
20//! shape worth copying: [`field`] takes what is currently typed in the box as a
21//! separate argument, because [`Field`] deliberately does not carry a value and
22//! is not going to. `makeover-immediate` reached the same seam from the other
23//! side with its `Filling`, and [`Held`] is that seam here.
24//!
25//! Focus is the other one. Nothing in a description says which control the user
26//! is on, so every drawing here takes `focused` as an argument and the caller
27//! is what counts. What focus *looks like* is this crate's answer and not the
28//! caller's, which is the point of it being here: see
29//! [`PieceStyle::focused`].
30//!
31//! # What they do not do
32//!
33//! No layout. Each answers rows for a width, or draws into the rect it is
34//! given, top-aligned, and never below it. Nothing here measures twice and
35//! nothing here places anything relative to anything else, because the moment
36//! it did it would be a layout engine with one consumer's flow baked into it.
37
38use makeover_layout::{
39    Act, Awaiting, Bar, Chart, Fact, Field, FieldKind, Figure, Heading, Meter, ThemeVariant, Token,
40    Tone,
41};
42use ratatui::buffer::Buffer;
43use ratatui::layout::Rect;
44use ratatui::style::{Modifier, Style};
45use ratatui::text::{Line, Span};
46
47use crate::text;
48use std::time::Duration;
49
50/// What a badge of one tone is drawn in, when it is drawn filled.
51///
52/// Two styles and not one, because a filled badge is three spans: an edge, the
53/// label, an edge. The label takes [`fill`](Self::fill) whole. An edge takes
54/// the fill's background with [`edge`](Self::edge)'s foreground over it, so
55/// the half of the cell the glyph leaves empty is the badge's own ground.
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
57pub struct BadgeStyle {
58    /// The ground and the ink.
59    pub fill: Style,
60    /// The edge, as a foreground.
61    pub edge: Style,
62}
63
64/// The colours and marks the drawings below use.
65///
66/// [`TableStyle`](crate::table::TableStyle)'s shape, for its reasons: an
67/// ungated struct of styles with a [`Default`], plus a
68/// [`from_theme`](Self::from_theme) that is what a consumer holding a loaded
69/// theme should reach for first. A consumer painting bevels and nothing else
70/// should not have to supply text tones it never uses, and gating the whole
71/// module on `theme` would make these unreachable to anyone hand-picking
72/// colours.
73///
74/// The default is the one that survives a terminal with no colour at all:
75/// modifiers only, no foreground anywhere. That is not a placeholder. A
76/// two-colour terminal is the case where a `Style` carrying a foreground is a
77/// foreground that will not land, and bold-and-reversed is what is left.
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub struct PieceStyle {
80    /// Ordinary content, and what [`Tone::Neutral`] reads as.
81    pub content: Style,
82    /// Content one step back: a field's label, a quoted run.
83    pub secondary: Style,
84    /// Content two steps back: a caption, a hint, a meter's reading.
85    pub muted: Style,
86    /// Something worth knowing and nothing to do about it.
87    pub info: Style,
88    /// Something finished and it worked.
89    pub success: Style,
90    /// Something the user should look at.
91    pub warning: Style,
92    /// Something broken, or about to be destroyed.
93    pub danger: Style,
94    /// A page title.
95    pub page: Style,
96    /// A section title.
97    pub section: Style,
98    /// A subsection title.
99    pub subsection: Style,
100    /// Text that goes somewhere, and a control's label.
101    pub action: Style,
102    /// A control filled with the action colour, for the one on a screen that is
103    /// the thing to press. A form's submit is the case that has it.
104    pub filled: Style,
105    /// A surface set back from the one it sits on, by colour and nothing else.
106    /// What a code run takes, since every cell is monospace and the thing a
107    /// webview says with a typeface cannot be said that way here.
108    pub sunken: Style,
109    /// A badge of each tone, in [`Tone`]'s order: neutral, info, success,
110    /// warning, danger. Read through [`badge`](Self::badge).
111    pub badges: [BadgeStyle; 5],
112    /// The glyphs either side of a filled badge, or `None` to draw a badge as
113    /// its label in parentheses, in its tone.
114    ///
115    /// `None` by default, since a fill is a colour and the default has none to
116    /// spend.
117    pub badge_edges: Option<[&'static str; 2]>,
118    /// What "you are on this one" adds to whatever it lands on.
119    ///
120    /// Reversed video by default, which is the affordance a cell has left once
121    /// colour is spent on tone and bold on weight. A webview says it with an
122    /// outline; a terminal has no outline that is not four more cells.
123    pub focus: Modifier,
124    /// How many cells [`meter`] spends on its bar.
125    pub meter_cells: u16,
126    /// The filled part of a bar.
127    pub meter_full: char,
128    /// The empty part of a bar.
129    pub meter_empty: char,
130    /// What marks a compulsory field, appended to its label.
131    ///
132    /// A knob for `makeover-immediate`'s reason: it is the one piece of *copy*
133    /// here, and copy is not a renderer's call.
134    pub required_marker: &'static str,
135}
136
137impl Default for PieceStyle {
138    /// Modifiers only, no foreground: what survives a terminal with two
139    /// colours.
140    fn default() -> Self {
141        Self {
142            content: Style::new(),
143            secondary: Style::new(),
144            muted: Style::new().add_modifier(Modifier::DIM),
145            info: Style::new(),
146            success: Style::new(),
147            warning: Style::new(),
148            danger: Style::new().add_modifier(Modifier::BOLD),
149            page: Style::new().add_modifier(Modifier::BOLD),
150            section: Style::new().add_modifier(Modifier::BOLD),
151            subsection: Style::new(),
152            action: Style::new().add_modifier(Modifier::UNDERLINED),
153            filled: Style::new().add_modifier(Modifier::REVERSED),
154            sunken: Style::new().add_modifier(Modifier::DIM),
155            badges: [BadgeStyle::default(); 5],
156            badge_edges: None,
157            focus: Modifier::REVERSED,
158            meter_cells: 10,
159            meter_full: '#',
160            meter_empty: '-',
161            required_marker: "*",
162        }
163    }
164}
165
166impl PieceStyle {
167    /// The house widgets, from a loaded theme.
168    ///
169    /// The lift this module exists for. `quasi-tui` carried every line of this
170    /// as private methods on its own renderer; a second terminal app wanting a
171    /// toned control had no way to reach them and would have picked its own
172    /// colours for the same five tones.
173    #[cfg(feature = "theme")]
174    #[must_use]
175    pub fn from_theme(theme: &crate::Theme) -> Self {
176        Self {
177            content: Style::new().fg(theme.content_primary),
178            secondary: Style::new().fg(theme.content_secondary),
179            muted: Style::new().fg(theme.content_muted),
180            info: Style::new().fg(theme.status_info),
181            success: Style::new().fg(theme.status_success),
182            warning: Style::new().fg(theme.status_warning),
183            danger: Style::new().fg(theme.status_danger),
184            // Three depths and two of them are bold, which is the whole of what
185            // a terminal has: there is no type scale in a grid of one cell
186            // size. A page title takes bold and the accent, a section bold, a
187            // subsection the secondary colour. That is the emphasis order a
188            // webview's type scale says with size, said with the two axes a
189            // cell has.
190            page: Style::new()
191                .fg(theme.action_primary)
192                .add_modifier(Modifier::BOLD),
193            section: Style::new()
194                .fg(theme.content_primary)
195                .add_modifier(Modifier::BOLD),
196            subsection: Style::new().fg(theme.content_secondary),
197            action: Style::new().fg(theme.action_primary),
198            filled: Style::new().fg(theme.selection_on).bg(theme.action_primary),
199            sunken: Style::new().bg(theme.surface_sunken),
200            // The chip of wiki `table-model`: a fill, an edge in the tone, and
201            // the label in content. A status fills with makeover's
202            // `<tone>-surface` and a neutral badge with the hover's step, which
203            // is the weight the tone fills sit at and shows on a striped row
204            // as well as a plain one. The raised surface the webview fills a
205            // neutral chip with would vanish into the table ground here, where
206            // no hairline edge is thin enough to draw around it.
207            badges: {
208                let badge = |fill, edge| BadgeStyle {
209                    fill: Style::new().fg(theme.content_primary).bg(fill),
210                    edge: Style::new().fg(edge),
211                };
212                [
213                    badge(theme.row_hover, theme.line_border),
214                    badge(theme.status_info_surface, theme.status_info),
215                    badge(theme.status_success_surface, theme.status_success),
216                    badge(theme.status_warning_surface, theme.status_warning),
217                    badge(theme.status_danger_surface, theme.status_danger),
218                ]
219            },
220            // Half blocks, the bevel's glyphs: the outer half of each end cell
221            // is the edge and the inner half is fill, so the label sits half a
222            // cell in. The same two cells the parentheses took, so a badge
223            // gaining its fill moves nothing beside it.
224            badge_edges: Some(["\u{258C}", "\u{2590}"]),
225            focus: Modifier::REVERSED,
226            meter_cells: 10,
227            meter_full: '#',
228            meter_empty: '-',
229            required_marker: "*",
230        }
231    }
232
233    /// The style a tone reads as.
234    ///
235    /// [`Tone`] is closed and stays closed, so this is total and needs no
236    /// fallback arm.
237    #[must_use]
238    pub const fn tone(&self, tone: Tone) -> Style {
239        match tone {
240            Tone::Neutral => self.content,
241            Tone::Info => self.info,
242            Tone::Success => self.success,
243            Tone::Warning => self.warning,
244            Tone::Danger => self.danger,
245        }
246    }
247
248    /// What a badge of this tone is drawn in.
249    #[must_use]
250    pub const fn badge(&self, tone: Tone) -> BadgeStyle {
251        self.badges[match tone {
252            Tone::Neutral => 0,
253            Tone::Info => 1,
254            Tone::Success => 2,
255            Tone::Warning => 3,
256            Tone::Danger => 4,
257        }]
258    }
259
260    /// The style a heading reads as.
261    #[must_use]
262    pub const fn heading(&self, level: Heading) -> Style {
263        match level {
264            Heading::Page => self.page,
265            Heading::Section => self.section,
266            Heading::Subsection => self.subsection,
267        }
268    }
269
270    /// `style`, plus the mark that says the user is on this one.
271    ///
272    /// Takes the flag rather than being called behind an `if`, because every
273    /// caller has a bool in hand and the branch is the part that gets forgotten.
274    #[must_use]
275    pub fn focused(&self, focused: bool, style: Style) -> Style {
276        if focused {
277            style.add_modifier(self.focus)
278        } else {
279            style
280        }
281    }
282}
283
284/// What a field currently holds, which a description never carries.
285///
286/// The terminal counterpart of `makeover_immediate::Filling`, and the same seam:
287/// there the widget writes through a `&mut` as the value is edited, and here the
288/// caller keeps an edit buffer and lends it out for the draw. Neither is
289/// something [`Field`] could carry without becoming a form model.
290///
291/// An enum rather than a bag of options, for `Filling`'s reason: a checkbox
292/// holding a string is unsayable here, where a struct would let it be said and
293/// then have to cope.
294#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
295pub enum Held<'a> {
296    /// Nothing typed and nothing chosen. The control draws empty.
297    #[default]
298    Absent,
299    /// What is in the box, or the `value` of the chosen [`Choice`].
300    ///
301    /// [`Choice`]: makeover_layout::Choice
302    Text(&'a str),
303    /// A checkbox, on or off.
304    On(bool),
305    /// Both ends of a [`FieldKind::Interval`], lower first.
306    ///
307    /// Two values rather than one string with a separator, which is
308    /// [`makeover_layout::Field::upper_name`]'s reason one level down: an
309    /// interval is submitted under two names, so it is held as two values, and
310    /// a delimiter this crate owned could appear inside either of them.
311    ///
312    /// Either end may be empty while the other stands. An open end is an
313    /// answer -- "over 120 BPM" -- rather than a half-filled box.
314    Between {
315        /// What the lower box holds now.
316        lower: &'a str,
317        /// What the upper box holds now.
318        upper: &'a str,
319    },
320}
321
322impl<'a> Held<'a> {
323    /// What is typed, as a string. A checkbox has no text and answers empty.
324    #[must_use]
325    pub const fn text(self) -> &'a str {
326        match self {
327            Self::Text(text) | Self::Between { lower: text, .. } => text,
328            Self::Absent | Self::On(_) => "",
329        }
330    }
331
332    /// The upper end, for the one variant that has one.
333    #[must_use]
334    pub const fn upper(self) -> &'a str {
335        match self {
336            Self::Between { upper, .. } => upper,
337            Self::Absent | Self::Text(_) | Self::On(_) => "",
338        }
339    }
340
341    /// Whether a checkbox is ticked.
342    #[must_use]
343    pub const fn on(self) -> bool {
344        matches!(self, Self::On(true))
345    }
346}
347
348/// What a host can see about a wait that is running.
349///
350/// Neither half is derivable from a description, which is why both are here and
351/// not on [`Awaiting`]. That type says how big the payload is; how much of it
352/// has landed is a fact about a transfer in flight, and only whoever is running
353/// the transfer knows it.
354///
355/// The same shape `makeover-immediate` carries, deliberately: a wait is one
356/// reading on every surface and the two renderers should not disagree about
357/// what a host owes them.
358#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
359pub struct Progress {
360    /// How much has arrived, in whatever unit the description counted.
361    pub delivered: Option<u64>,
362    /// How long the wait has lasted so far.
363    ///
364    /// The one time value a wait may show. See [`awaiting`] for the three it
365    /// may not.
366    pub elapsed: Option<Duration>,
367}
368
369/// The activity mark: one cell, lit or dark.
370///
371/// Rule 2 of wiki `loading-and-progress-standard`, and the surface the metaphor
372/// came from. A hard-disk light is one cell that blinks, and a terminal draws
373/// that with no metaphor in the way — where a webview needs a keyframe and egui
374/// needs a repaint schedule, this is a character.
375///
376/// The two glyphs are [`PieceStyle::meter_full`] and
377/// [`PieceStyle::meter_empty`], not a third pair. A bar's filled cell and a lit
378/// mark are the same statement in the same alphabet, and a terminal that had to
379/// render two vocabularies of "on" would be saying there are two kinds of on.
380///
381/// **Dark, not absent.** A mark that is drawn half the time is a hole in the
382/// line, and the line reflows around it or the reader loses where to look. It
383/// occupies its cell either way.
384///
385/// `lit` is the caller's: this module holds no clock. [`crate::activity_lit`]
386/// is the one place the phase is worked out from the cadence, so a caller
387/// should reach for that rather than dividing by 500 itself.
388#[must_use]
389pub fn activity(style: &PieceStyle, lit: bool) -> Span<'static> {
390    if lit {
391        Span::styled(style.meter_full.to_string(), style.action)
392    } else {
393        Span::styled(style.meter_empty.to_string(), style.muted)
394    }
395}
396
397/// A wait as one line, drawn from what is actually known about it.
398///
399/// [`Awaiting::is_determinate`] is the first branch and there is a second the
400/// description cannot answer: whether anything is watching the transfer. A bar
401/// wants a total and a numerator both, so a described amount with no
402/// [`Progress::delivered`] beside it draws the mark and the size it is waiting
403/// on, rather than an empty trough implying somebody is counting.
404///
405/// So three drawings for three states, which is the point:
406///
407/// ```text
408/// unmeasured                       #            a blinking cell
409/// measured, nothing watching       # 41943040   the cell, and how much there is
410/// measured and observed            ####------ 17825792/41943040  4s
411/// ```
412///
413/// **What the bar may not do**, from rule 1 of the standard and from
414/// [`Awaiting`]'s own docs: what is done over what there is, plus the time it
415/// has taken. Never a remaining time, an arrival time, or a rate extrapolated
416/// forward. A prediction is wrong the moment the transfer stalls, and being
417/// confidently wrong is worse than being honestly indeterminate.
418///
419/// The numbers are raw. The unit is the app's — bytes for an upload, rows for
420/// an import — and a renderer that formatted one as a file size would be
421/// dressing up a quantity it was deliberately not told about.
422#[must_use]
423pub fn awaiting(
424    style: &PieceStyle,
425    awaiting: Awaiting,
426    progress: Progress,
427    lit: bool,
428) -> Line<'static> {
429    let Some(total) = awaiting.amount else {
430        return Line::from(vec![activity(style, lit)]);
431    };
432    let Some(done) = progress.delivered else {
433        return Line::from(vec![
434            activity(style, lit),
435            Span::styled(format!(" {total}"), style.muted),
436        ]);
437    };
438    let cells = u32::from(style.meter_cells);
439    // In cells rather than in floating point, the way `meter` does it: a
440    // terminal's bar has ten states and rounding through an f64 to reach one of
441    // ten is arithmetic nobody needs. Saturating rather than wrapping, because
442    // a transfer that over-delivers is a real case and a panicking bar is not
443    // the way to report it.
444    let filled = u32::try_from(
445        done.saturating_mul(u64::from(cells))
446            .checked_div(total)
447            .unwrap_or(0),
448    )
449    .unwrap_or(cells)
450    .min(cells);
451    let bar = format!(
452        "{}{}",
453        style.meter_full.to_string().repeat(filled as usize),
454        style
455            .meter_empty
456            .to_string()
457            .repeat((cells - filled) as usize)
458    );
459    let reading = match progress.elapsed {
460        Some(elapsed) => format!(" {done}/{total}  {}s", elapsed.as_secs()),
461        None => format!(" {done}/{total}"),
462    };
463    Line::from(vec![
464        Span::styled(bar, style.action),
465        Span::styled(reading, style.muted),
466    ])
467}
468
469/// A proportion as one line: the bar, then the reading beside it.
470///
471/// The reading is built here from the two numbers and the noun rather than
472/// taken assembled, which is what [`Meter::label`] carrying the noun alone is
473/// for: a terminal at one line and a tooltip want different sentence orders.
474#[must_use]
475pub fn meter(style: &PieceStyle, meter: &Meter<'_>) -> Line<'static> {
476    // `usize` the whole way, which is what `Meter`'s widening bought rather
477    // than cost. The counts are `usize` now, `str::repeat` wants `usize`, and
478    // `meter_cells` converts infallibly from `u16`, so the two `as usize`
479    // casts this held before are gone and there is no narrowing anywhere: the
480    // proportion is computed in the counts' own domain and lands directly in
481    // the unit the bar is drawn in.
482    //
483    // `checked_mul` still earns its place. A stand-in count crossing a
484    // residual is around 9.09e15, and multiplying that by the cell width is
485    // the one place here that can still overflow.
486    let cells = usize::from(style.meter_cells);
487    let filled = meter
488        .done
489        .checked_mul(cells)
490        .and_then(|reached| reached.checked_div(meter.total))
491        .unwrap_or(0)
492        .min(cells);
493    let bar = format!(
494        "{}{}",
495        style.meter_full.to_string().repeat(filled),
496        style.meter_empty.to_string().repeat(cells - filled)
497    );
498    let reading = match meter.label {
499        Some(label) => format!(" {}/{} {label}", meter.done, meter.total),
500        None => format!(" {}/{}", meter.done, meter.total),
501    };
502    Line::from(vec![
503        Span::styled(bar, style.tone(meter.tone)),
504        Span::styled(reading, style.muted),
505    ])
506}
507
508/// A badge or a chip as a line.
509///
510/// A chip is its label in square brackets, in its tone: it answers a press, and
511/// the bracket says so. A badge answers nothing and is drawn filled where the
512/// style has [`badge_edges`](PieceStyle::badge_edges), as an edge, the label on
513/// its fill, and an edge (wiki `table-model`), and in round brackets in its tone
514/// where it has none. Every spelling is the label and two cells, so the width
515/// does not depend on which one a terminal gets.
516///
517/// `latched` is a chip that is switched on, and it reads as reversed. So does
518/// focus, which is a collision a terminal cannot avoid: latched is "this filter
519/// is on" and focused is "you are here", and there is one spare axis for two
520/// facts. Said here rather than resolved by inventing a third look nobody would
521/// read.
522///
523/// A chip's removable half is not drawn. The `x` a webview hangs on a chip is a
524/// second control inside one span, and a terminal reaches a control by focusing
525/// it; two targets in one cell run is a question for whoever owns the
526/// interaction, not for a drawing.
527#[must_use]
528pub fn token(
529    style: &PieceStyle,
530    label: &str,
531    kind: Token,
532    tone: Tone,
533    latched: bool,
534    focused: bool,
535) -> Line<'static> {
536    let mark = |painted: Style| {
537        if latched {
538            painted.add_modifier(style.focus)
539        } else {
540            style.focused(focused, painted)
541        }
542    };
543    match (kind, style.badge_edges) {
544        (Token::Badge, Some([open, close])) => {
545            let badge = style.badge(tone);
546            let edge = mark(badge.fill.patch(badge.edge));
547            Line::from(vec![
548                Span::styled(open, edge),
549                Span::styled(label.to_owned(), mark(badge.fill)),
550                Span::styled(close, edge),
551            ])
552        }
553        (Token::Badge, None) => {
554            Line::from(Span::styled(format!("({label})"), mark(style.tone(tone))))
555        }
556        (Token::Chip { .. }, _) => {
557            Line::from(Span::styled(format!("[{label}]"), mark(style.tone(tone))))
558        }
559    }
560}
561
562/// A control as one line.
563///
564/// `< Label > (key)`, and the key only where the description named one. That
565/// member is the one place `makeover-layout` anticipated a terminal before there
566/// was one, and this is the renderer that reads it.
567///
568/// A control that commits ([`Act::commits`]) is `[ Label ]` filled with the
569/// action colour, which is the weight difference a webview carries as its
570/// default-button ring. Its tone still says what pressing it means: a
571/// committing delete is filled in the danger colour, not the action colour.
572///
573/// A disabled control is drawn muted and is not marked focused, whatever the
574/// caller passed: it is present, visible and not answering, so a focus mark on
575/// it would be an affordance that lies. Whether it is reachable at all is the
576/// caller's count to keep — ask [`Act::disabled`]. A disabled commit keeps its
577/// brackets, for the reason a disabled button keeps its bevel.
578///
579/// # The brackets are the commit and the fill is the lead
580///
581/// Two marks for two questions, which is what lets a control wear both:
582/// `[ Label ]` says pressing this commits what you staged, and the reversed
583/// fill says this is the act the screen is for. The suite's rule is that
584/// leading fills and committing outlines, and a terminal's outline is its
585/// brackets.
586///
587/// The fill sat on `commits` until [`Act::leading`] existed, which made every
588/// sub-form's submit the brightest thing on its screen. On goingson's task
589/// overview that was a subtask's Add, drawn louder than the Start and Complete
590/// the screen is actually for. The brackets have not moved; only the fill has.
591///
592/// A toned act keeps its tone rather than taking the fill, unchanged from
593/// before: a leading Danger control stays the danger colour, because what
594/// pressing it means outranks how badly the screen wants it pressed.
595#[must_use]
596pub fn act(style: &PieceStyle, act: &Act<'_>, focused: bool) -> Line<'static> {
597    let painted = if act.disabled() {
598        style.muted
599    } else if act.leading && act.tone == Tone::Neutral {
600        style.focused(focused, style.filled)
601    } else {
602        style.focused(focused, style.tone(act.tone))
603    };
604    let (open, close) = if act.commits { ("[", "]") } else { ("<", ">") };
605    let label = match act.key {
606        Some(key) => format!("{open} {} {close} ({key})", act.label),
607        None => format!("{open} {} {close}", act.label),
608    };
609    Line::from(Span::styled(label, painted))
610}
611
612/// The muted line a control's [`Act::hint`] draws as, or `None` where it has
613/// none.
614///
615/// A terminal has no pointer, so the hover the other two renderers spend a hint
616/// on is not available and is not the thing anyway: what the description says
617/// is that the sentence is true, never that it is hidden. A row under the
618/// control is this renderer's answer, and it is the same muted row
619/// [`field`] gives a field's note, so the two read alike wherever they land.
620///
621/// Its own function rather than extra lines out of [`act`], because a control
622/// is one [`Line`] everywhere it is drawn and a caller laying out a run needs
623/// to know it is placing two things.
624#[must_use]
625pub fn act_note(style: &PieceStyle, act: &Act<'_>) -> Option<Line<'static>> {
626    act.hint
627        .map(|hint| Line::from(Span::styled(hint.to_owned(), style.muted)))
628}
629
630/// The rows [`figure`] wants at `width`.
631#[must_use]
632pub fn figure_height(figure: &Figure<'_>, width: u16) -> u16 {
633    text::height(figure.value, width) + text::height(figure.caption, width)
634}
635
636/// A figure: the number, then what it counts under it.
637///
638/// The tone lands on the value and its change rather than on the caption, which
639/// is what [`Figure::tone`] means: the figure is an ordinary fact and it is the
640/// movement that reads as good or bad.
641pub fn figure(style: &PieceStyle, figure: &Figure<'_>, area: Rect, buf: &mut Buffer) -> u16 {
642    let value = match figure.change {
643        Some(change) => format!("{} {change}", figure.value),
644        None => figure.value.to_owned(),
645    };
646    let used = text::draw(
647        &value,
648        style.tone(figure.tone).add_modifier(Modifier::BOLD),
649        area,
650        buf,
651    );
652    used + text::draw(figure.caption, style.muted, below(area, used), buf)
653}
654
655/// The rows [`facts`] wants at `width`.
656///
657/// One per fact, plus whatever a long value wraps to. The label never wraps:
658/// the column is sized to the longest label, so a label that would not fit is a
659/// pane whose labels are too long for this terminal, and wrapping it would
660/// destroy the alignment that is the whole member.
661#[must_use]
662pub fn facts_height(facts: &[Fact<'_>], width: u16) -> u16 {
663    let label = label_column(facts, width);
664    let rest = width.saturating_sub(label).saturating_sub(1);
665    facts
666        .iter()
667        .map(|fact| text::height(fact.value, rest).max(1))
668        .sum()
669}
670
671/// How wide the label column is: the longest label, and never past half.
672///
673/// The cap is the terminal's own answer to a problem a webview does not have.
674/// `max-content` in a grid is bounded by the box; here a 40-character label in
675/// an 80-column pane would leave nothing for the values, so the column stops at
676/// half the width and a longer label is cut. A cut label beside a readable
677/// value is the better of the two losses.
678fn label_column(facts: &[Fact<'_>], width: u16) -> u16 {
679    let longest = facts
680        .iter()
681        .map(|fact| u16::try_from(fact.label.chars().count()).unwrap_or(u16::MAX))
682        .max()
683        .unwrap_or(0);
684    longest.min(width / 2)
685}
686
687/// Labelled facts, with their values in one column.
688///
689/// **The alignment is the member.** Five goingson panes were drawing this as a
690/// row each and every value started after its own label, so no column formed.
691/// Here that is a label column sized to the longest label, every value starting
692/// at the same cell.
693///
694/// The label reads back and the value takes content, which is the rule
695/// [`figure`] states for a value against its caption. A fact with nothing to
696/// say never reaches this: `Node::facts` drops it as it builds.
697pub fn facts(style: &PieceStyle, facts: &[Fact<'_>], area: Rect, buf: &mut Buffer) -> u16 {
698    let column = label_column(facts, area.width);
699    let mut used = 0;
700    for fact in facts {
701        if used >= area.height {
702            break;
703        }
704        let line = below(area, used);
705        let mut label: String = fact.label.chars().take(usize::from(column)).collect();
706        // Padded rather than positioned, so the value column is one place and
707        // not two arithmetics that can disagree.
708        while u16::try_from(label.chars().count()).unwrap_or(u16::MAX) < column {
709            label.push(' ');
710        }
711        text::draw(&label, style.muted, line, buf);
712        let values = Rect {
713            x: line.x.saturating_add(column).saturating_add(1),
714            width: line.width.saturating_sub(column).saturating_sub(1),
715            ..line
716        };
717        used += text::draw(fact.value, style.content, values, buf).max(1);
718    }
719    used
720}
721
722/// The rows [`field`] wants at `width`.
723///
724/// A label row, the control's rows, and a row for whatever went wrong. A hidden
725/// field is nothing at all, which is the one field kind a terminal and a webview
726/// agree on completely.
727#[must_use]
728pub fn field_height(style: &PieceStyle, field: &Field<'_>, width: u16) -> u16 {
729    if !field.kind.visible() {
730        return 0;
731    }
732    let label = text::height(&label_of(style, field), width);
733    // A range is one row like every other single control: the bar, its two ends
734    // and the reading are one line by construction, and a bar that wrapped
735    // would stop being a bar.
736    let body = match field.kind {
737        // Both multi-line kinds get the same three rows, keyed on the
738        // description's own `multiline` rather than on the member: a markdown
739        // field falling through to the single-row arm is one line for a value
740        // whose whole point is that it has several. What a terminal does *with*
741        // the markdown is another question and the answer here is nothing --
742        // the source is the text, and drawing it as text is honest.
743        kind if kind.multiline() => 3,
744        kind if kind.offers_options() => u16::try_from(field.options.len()).unwrap_or(u16::MAX),
745        // A row per theme, a row per group heading, and a row for the follow
746        // entry when there is one. The headings are counted by walking the
747        // variants rather than by assuming three, because a machine with only
748        // dark themes installed draws one heading and reserving three would
749        // leave two blank rows under every picker.
750        kind if kind.offers_themes() => {
751            let mut variants = 0u16;
752            let mut open: Option<ThemeVariant> = None;
753            for theme in field.themes {
754                if open != Some(theme.variant) {
755                    variants = variants.saturating_add(1);
756                    open = Some(theme.variant);
757                }
758            }
759            let rows = u16::try_from(field.themes.len()).unwrap_or(u16::MAX);
760            rows.saturating_add(variants)
761                .saturating_add(u16::from(field.follows.is_some()))
762        }
763        _ => 1,
764    };
765    let note = message_of(style, field).map_or(0, |(text, _)| text::height(text, width));
766    label + body + note
767}
768
769/// A question: its label, the box, and its standing help or what is wrong now.
770///
771/// `held` is what the user has done to it since the screen arrived, which is the
772/// argument a description cannot supply. See [`Held`].
773///
774/// `focused` marks the box rather than the label, because the box is where the
775/// typing lands.
776///
777/// [`makeover_layout::Field::as_instant`] is carried and not honoured. It asks
778/// for a wall-clock value to be submitted as the moment it names, and this
779/// renderer has no submission: it draws the box and the runtime above it
780/// gathers what a submit sends, so the conversion belongs where that gathering
781/// happens. The value drawn and read here is the local one, in
782/// `makeover_layout::DATETIME_FORMAT`.
783pub fn field(
784    style: &PieceStyle,
785    field: &Field<'_>,
786    held: Held<'_>,
787    focused: bool,
788    area: Rect,
789    buf: &mut Buffer,
790) -> u16 {
791    field_at(style, field, held, focused, None, area, buf)
792}
793
794/// [`field`], with the option the caret is on.
795///
796/// `cursor` is an index into [`Field::options`], and it is what a terminal has
797/// instead of a pointer: a reader picking from a list walks along it, and the
798/// row being walked past has to be visible or the key that ticks it ticks a row
799/// nobody can see. While there is a cursor it takes the focus mark, and a
800/// chosen option is told by its box alone, so the two never read alike.
801///
802/// Only an option-taking field reads it, and only while `focused`. Beside
803/// [`field`] rather than an argument on it, because every caller with no list
804/// to walk would otherwise pass `None` to say so.
805pub fn field_at(
806    style: &PieceStyle,
807    field: &Field<'_>,
808    held: Held<'_>,
809    focused: bool,
810    cursor: Option<usize>,
811    area: Rect,
812    buf: &mut Buffer,
813) -> u16 {
814    // A hidden field is data travelling with the form. There is nothing to
815    // draw, and whoever submits carries it.
816    if !field.kind.visible() || area.width == 0 || area.height == 0 {
817        return 0;
818    }
819
820    let mut used = text::draw(&label_of(style, field), style.secondary, area, buf);
821
822    let well = style.focused(focused, style.content);
823    let placeholder = field.placeholder.unwrap_or_default();
824
825    used += match field.kind {
826        FieldKind::Checkbox => text::draw(
827            if held.on() { "[x]" } else { "[ ]" },
828            well,
829            below(area, used),
830            buf,
831        ),
832        // A range's two ends are what the question means, so they are drawn
833        // rather than left to a hint. A terminal has the bar already: this is
834        // `meter`'s cells with the extent read out at either side of them.
835        //
836        // An unbounded range has no extent to draw and falls through to the
837        // text path, which is `makeover-immediate`'s answer as well and for the
838        // same reason: bounds this crate invented are bounds the user would
839        // then drag against.
840        FieldKind::Range if field.bounded() => {
841            let line = range_line(style, field, held.text(), well);
842            text::draw_line(&line, below(area, used), buf)
843        }
844        // One question, so one line. The two ends read left to right with the
845        // word between them, which is what a terminal has instead of two boxes
846        // side by side: a second row would read as a second question, and that
847        // is the reading the kind exists to prevent.
848        FieldKind::Interval => {
849            let line = interval_line(style, field, held, well);
850            text::draw_line(&line, below(area, used), buf)
851        }
852        // The grouping comes out of the order, not out of a group list:
853        // `Field::themes` arrives sorted by variant, so the run of one variant
854        // is the group and a heading opens whenever the variant changes. Same
855        // walk the other two renderers do, which is what keeps three renderers
856        // from disagreeing about where a group starts.
857        //
858        // Drawn as the radio group above rather than as a closed control,
859        // because a terminal has no closed control: the list is already on
860        // screen and always was, so the group headings cost a row each and buy
861        // the structure the description finally carries.
862        kind if kind.offers_themes() => {
863            let mut rows = 0;
864            if let Some(follow) = field.follows {
865                // First, and under no heading. It names no theme and sits in no
866                // variant, so a heading over it would be inventing a fourth
867                // variant for one row.
868                let chosen = held.text() == follow.value;
869                let (mark, painted) = if chosen {
870                    ("(*)", well)
871                } else {
872                    ("( )", style.secondary)
873                };
874                rows += text::draw(
875                    &format!("{mark} {}", follow.label),
876                    painted,
877                    below(area, used + rows),
878                    buf,
879                );
880            }
881            let mut open: Option<ThemeVariant> = None;
882            for theme in field.themes {
883                if open != Some(theme.variant) {
884                    // Muted, which is the one place it is the truth rather than
885                    // the lie: a heading will not answer, exactly as an
886                    // unavailable option will not.
887                    rows += text::draw(
888                        theme.variant.heading(),
889                        style.muted,
890                        below(area, used + rows),
891                        buf,
892                    );
893                    open = Some(theme.variant);
894                }
895                let chosen = held.text() == theme.id;
896                let (mark, painted) = if chosen {
897                    ("(*)", well)
898                } else {
899                    ("( )", style.secondary)
900                };
901                rows += text::draw(
902                    &format!("{mark} {} [{}]", theme.name, theme.contrast.badge()),
903                    painted,
904                    below(area, used + rows),
905                    buf,
906                );
907            }
908            rows
909        }
910        kind if kind.offers_options() => {
911            // A checklist's answer is a set, so its options mark themselves
912            // with `Choice::chosen` and no one held value names any of them. A
913            // single answer is marked either way, which is `chosen`'s rule for
914            // every renderer. The box says which of the two the question is.
915            let several = kind.takes_several();
916            let (open, ticked) = if several {
917                ("[ ]", "[x]")
918            } else {
919                ("( )", "(*)")
920            };
921            let walking = focused && cursor.is_some();
922            let marked = if walking { style.content } else { well };
923            let mut rows = 0;
924            for (index, choice) in field.options.iter().enumerate() {
925                let chosen = choice.chosen || (!several && held.text() == choice.value);
926                let walked = walking && cursor == Some(index);
927                // An option that cannot be picked yet reads as inert, which is
928                // the one place muted is the truth rather than the lie below:
929                // it will not answer, and the reason it will not is on the row
930                // beside it rather than nowhere.
931                let (mark, painted, suffix) = match choice.unavailable {
932                    Some(reason) => (open, style.muted, format!(": {reason}")),
933                    None if chosen => (ticked, marked, String::new()),
934                    // An option that is not chosen is still an option: pressing
935                    // it chooses it. So it takes the secondary content intent
936                    // and not the muted one, which is what disabled looks like
937                    // (`State::Disabled` resolves to it). Muted here read as a
938                    // list of five where four were greyed out.
939                    None => (open, style.secondary, String::new()),
940                };
941                // The row the caret is on takes the focus mark, so the key that
942                // ticks or picks lands on a row the reader can see.
943                rows += text::draw(
944                    &format!("{mark} {}{suffix}", choice.label),
945                    style.focused(walked, painted),
946                    below(area, used + rows),
947                    buf,
948                );
949                // What picking it means, on a row of its own under the option.
950                // makeover-layout 0.39.0, and this is the host with the most
951                // room of the three: a browser's `<select>` has to run the line
952                // into the option's text and a terminal does not, so it does
953                // not.
954                //
955                // Indented past the mark, so the line reads as belonging to the
956                // option above it rather than as another option. Muted, which
957                // is the truth here rather than the lie the arms above are
958                // careful about: the row is not a thing to press.
959                if let Some(detail) = choice.detail {
960                    rows += text::draw(detail, style.muted, indented(area, used + rows), buf);
961                }
962            }
963            rows
964        }
965        // A secret's dots come from the caller's buffer and can come from
966        // nowhere else: a password that comes back down the wire is a password
967        // in a page and in a proxy log, so a description carries nothing to dot
968        // out. This is the one control that would be undrawable without `held`.
969        FieldKind::Secret if !held.text().is_empty() => {
970            let dots = "*".repeat(held.text().chars().count());
971            text::draw(&dots, well, below(area, used), buf).max(1)
972        }
973        // A file field has no way back on a terminal any more than it has on an
974        // HTTP host. The name is drawn and picking one belongs to whoever owns
975        // the interaction.
976        //
977        // makeover-layout 0.31.0 gave the description an accept list and a
978        // multiplicity, and neither changes anything drawn here. Both are the
979        // picker's business, and the picker is the caller's: this crate draws
980        // what was picked. A terminal that grows its own picker reads them off
981        // `Field::accept` and `Field::multiple` at that point rather than
982        // through a second spelling invented here.
983        _ if held.text().is_empty() => {
984            empty_well(style, placeholder, well, focused, below(area, used), buf)
985        }
986        _ => text::draw(&measured(field, held.text()), well, below(area, used), buf),
987    };
988
989    // Error, then note, then hint -- the order `Field::note` names, and the
990    // order a webview draws them in. Once something has gone wrong that is the
991    // sentence worth the row; failing that, what the chosen answer costs beats
992    // standing help about how the field works.
993    match message_of(style, field) {
994        Some((text, painted)) => used + text::draw(text, painted, below(area, used), buf),
995        None => used,
996    }
997}
998
999/// A bounded number as one line: the low end, the bar, the high end, then what
1000/// it currently reads.
1001///
1002/// The two ends are drawn because they are the question. A threshold of 0.72
1003/// says nothing without them, which is the whole argument for
1004/// [`FieldKind::Range`] being a kind rather than a number with bounds, and a
1005/// terminal is where it would be easiest to quietly drop them and show a figure.
1006///
1007/// The bar is [`meter`]'s cells, so a range and a proportion read as the same
1008/// object in the same app. What differs is the reading beside it: a meter counts
1009/// something and a range holds a value.
1010///
1011/// A value the host cannot read as a number empties the bar and is still shown
1012/// as itself. That is [`empty_well`]'s position on an unreadable value: the app
1013/// put it there, and a terminal that silently rounded it to a bound would be
1014/// reporting a value nobody set.
1015fn range_line(style: &PieceStyle, field: &Field<'_>, value: &str, well: Style) -> Line<'static> {
1016    let cells = usize::from(style.meter_cells);
1017    let ends = field
1018        .min
1019        .zip(field.max)
1020        .and_then(|(min, max)| Some((min.parse::<f64>().ok()?, max.parse::<f64>().ok()?)));
1021    let filled = match (ends, value.parse::<f64>()) {
1022        (Some((min, max)), Ok(number)) if max > min => {
1023            // Where the value sits is the curve's answer, not a proportion of
1024            // the extent (makeover-layout 0.32.0). Under `Curve::Linear` the two
1025            // are the same number, which is why the bar was right before and is
1026            // unchanged for every range described so far; under a constant ratio
1027            // they are not, and a bar drawn linearly would put an envelope's
1028            // whole useful half inside its first cell.
1029            #[expect(
1030                clippy::cast_possible_truncation,
1031                clippy::cast_sign_loss,
1032                reason = "`position_of` returns 0..=1, and the cell count came from a u16"
1033            )]
1034            let reached = (field.curve.position_of(number, min, max) * cells as f64) as usize;
1035            reached.min(cells)
1036        }
1037        _ => 0,
1038    };
1039    let bar = format!(
1040        "{}{}",
1041        style.meter_full.to_string().repeat(filled),
1042        style.meter_empty.to_string().repeat(cells - filled)
1043    );
1044    Line::from(vec![
1045        Span::styled(format!("{} ", field.min.unwrap_or_default()), style.muted),
1046        Span::styled(bar, well),
1047        Span::styled(format!(" {}", field.max.unwrap_or_default()), style.muted),
1048        Span::styled(format!(" {}", measured(field, value)), well),
1049    ])
1050}
1051
1052/// An interval as one line: the low end, the word, the high end.
1053///
1054/// One line because it is one question. Two rows would read as two questions,
1055/// which is exactly what [`FieldKind::Interval`] exists to stop the description
1056/// saying, and a terminal has no side-by-side boxes to fall back on.
1057///
1058/// # An open end draws the bound it falls back to
1059///
1060/// Muted, because it is where the axis ends rather than a value anybody set.
1061/// With no bound to fall back on there is nothing honest to draw and the end
1062/// stays blank: a terminal inventing a number here would report a filter the
1063/// user never applied, which is [`range_line`]'s position on an unreadable
1064/// value.
1065///
1066/// # The word, not a dash
1067///
1068/// A dash between two numbers is a minus sign to anyone reading a signed axis,
1069/// and half the measured axes are signed -- audiofiles filters loudness in
1070/// dBFS. `to` costs two cells and cannot be misread.
1071fn interval_line(
1072    style: &PieceStyle,
1073    field: &Field<'_>,
1074    held: Held<'_>,
1075    well: Style,
1076) -> Line<'static> {
1077    let end = |value: &str, fallback: Option<&str>| match (value.is_empty(), fallback) {
1078        (false, _) => Span::styled(measured(field, value), well),
1079        (true, Some(bound)) => Span::styled(measured(field, bound), style.muted),
1080        (true, None) => Span::styled(String::new(), style.muted),
1081    };
1082    Line::from(vec![
1083        end(held.text(), field.min),
1084        Span::styled(" to ", style.secondary),
1085        end(held.upper(), field.max),
1086    ])
1087}
1088
1089/// The unit to draw beside this field's value, if there is one to draw.
1090///
1091/// Two conditions rather than one: the field has to carry a unit and its kind
1092/// has to be one that means anything by it. `FieldKind::measurable` is the
1093/// description answering the second, so this renderer keeps no list of its own
1094/// of which kinds are quantities.
1095fn unit_of<'a>(field: &Field<'a>) -> Option<&'a str> {
1096    field.unit.filter(|_| field.kind.measurable())
1097}
1098
1099/// A value with what it is measured in, as one string.
1100///
1101/// The unit rides on the value rather than on the label, which is what a
1102/// terminal wants: the label is a line above and the number is the line the eye
1103/// is on.
1104fn measured(field: &Field<'_>, value: &str) -> String {
1105    match unit_of(field) {
1106        Some(unit) => format!("{value} {unit}"),
1107        None => value.to_owned(),
1108    }
1109}
1110
1111/// The label, marked where the field is compulsory.
1112fn label_of(style: &PieceStyle, field: &Field<'_>) -> String {
1113    if field.required {
1114        format!("{} {}", field.label, style.required_marker)
1115    } else {
1116        field.label.to_owned()
1117    }
1118}
1119
1120/// What goes under the box, and how it is painted.
1121///
1122/// A terminal field has room for exactly one line, so the three message
1123/// channels compete for it and the precedence is decided in
1124/// [`makeover_layout::Field::note`]'s docs rather than three times here:
1125/// **error, then note, then hint**. What is wrong outranks what the answer
1126/// costs, which outranks how the field works.
1127///
1128/// The tone comes with the note; an error is always danger and a hint is
1129/// always muted, because neither carries one.
1130fn message_of<'a>(style: &PieceStyle, field: &Field<'a>) -> Option<(&'a str, Style)> {
1131    if let Some(error) = field.error {
1132        return Some((error, style.danger));
1133    }
1134    if let Some((tone, note)) = field.note {
1135        return Some((note, style.tone(tone)));
1136    }
1137    field.hint.map(|hint| (hint, style.muted))
1138}
1139
1140/// A box with nothing in it: the ghost text, and the caret when it has focus.
1141///
1142/// The caret is not decoration. An empty field under a style is an empty field,
1143/// so a focused one with no placeholder drew literally nothing and there was no
1144/// way to tell the box was where the typing would go. A browser has a blinking
1145/// bar for this and gets it without asking; a terminal has one cell of reversed
1146/// video, put on the first column, which is where the first character lands.
1147fn empty_well(
1148    style: &PieceStyle,
1149    placeholder: &str,
1150    well: Style,
1151    focused: bool,
1152    area: Rect,
1153    buf: &mut Buffer,
1154) -> u16 {
1155    let used = text::draw(placeholder, style.muted, area, buf).max(1);
1156    if focused
1157        && area.height > 0
1158        && area.width > 0
1159        && let Some(cell) = buf.cell_mut((area.x, area.y))
1160    {
1161        cell.set_style(well);
1162    }
1163    used
1164}
1165
1166/// What is left of `area` after `used` rows from the top.
1167/// The rows under what has been drawn, inset by the width of an option's mark.
1168///
1169/// An option's second line has to read as belonging to the option above it rather than as another option, and the only thing that
1170/// says so on a terminal is where it starts. The inset is `text::draw`'s to
1171/// honour as an area rather than as spaces in the string: the drawing wraps on
1172/// words, so leading spaces would survive the first line and vanish from every
1173/// one after it.
1174///
1175/// Four columns, which is `"( ) "`. Named against the mark rather than picked,
1176/// so a mark that changes width takes this with it.
1177fn indented(area: Rect, used: u16) -> Rect {
1178    const MARK: u16 = 4;
1179    let area = below(area, used);
1180    Rect {
1181        x: area.x + MARK.min(area.width),
1182        width: area.width.saturating_sub(MARK),
1183        ..area
1184    }
1185}
1186
1187fn below(area: Rect, used: u16) -> Rect {
1188    let used = used.min(area.height);
1189    Rect {
1190        x: area.x,
1191        y: area.y + used,
1192        width: area.width,
1193        height: area.height - used,
1194    }
1195}
1196
1197#[cfg(test)]
1198mod tests;
1199
1200/// A chart, one line per bar.
1201///
1202/// # Why the bars lie down here
1203///
1204/// A webview draws a chart as columns standing on an axis, and a terminal has
1205/// one glyph per cell and a handful of rows. Standing the bars up would mean
1206/// drawing each one as a stack of partial blocks and giving up the labels,
1207/// which are the half a reader actually reads. Laid down, every bar keeps its
1208/// place on the axis, its magnitude and its reading, and the drawing is
1209/// [`meter`]'s repeated -- which is the honest answer for the same reason
1210/// `quasi-tui`'s timeline draws no gridlines: a terminal draws what a terminal
1211/// draws rather than an impression of the other renderer.
1212///
1213/// The axis is not drawn as a rule or a scale, for that same reason. It is
1214/// stated instead: every bar is `meter_cells` wide and full means
1215/// [`Chart::most`], so the widths are comparable across the run, which is the
1216/// one thing a chart has to get right.
1217///
1218/// # What is left out
1219///
1220/// [`Chart::label`] is not drawn. It names what the magnitudes are and every
1221/// bar's own [`Bar::reading`] already carries the units, so drawing it would be
1222/// a heading this function does not own the room for. A caller that wants it
1223/// says it as a heading, which is what a description does anyway.
1224///
1225/// Labels are padded to the widest, so the bars line up. That is measured in
1226/// characters rather than in display cells, which is wrong for a label holding
1227/// a wide glyph and is what [`crate::text`] would cost to bring in for a case
1228/// that has not turned up.
1229#[must_use]
1230pub fn chart(style: &PieceStyle, chart: &Chart<'_>, bars: &[Bar<'_>]) -> Vec<Line<'static>> {
1231    let widest = bars
1232        .iter()
1233        .map(|bar| bar.at.chars().count())
1234        .max()
1235        .unwrap_or(0);
1236    bars.iter()
1237        .map(|bar| chart_line(style, chart, bar, widest))
1238        .collect()
1239}
1240
1241/// One bar's line: where it sits, how far it reaches, and what it says.
1242fn chart_line(
1243    style: &PieceStyle,
1244    chart: &Chart<'_>,
1245    bar: &Bar<'_>,
1246    widest: usize,
1247) -> Line<'static> {
1248    let cells = usize::from(style.meter_cells);
1249    // Rounded rather than truncated, so a bar that is nearly full does not read
1250    // as one cell short of every other. The multiplication is done before the
1251    // division for the reason it is in `meter`: in integers, the other order is
1252    // zero.
1253    let filled = if chart.most == 0 {
1254        0
1255    } else {
1256        let scaled = (bar.value as u128 * cells as u128).div_ceil(chart.most as u128);
1257        (scaled as usize).min(cells)
1258    };
1259
1260    let mut spans = vec![Span::styled(
1261        format!("{:width$} ", bar.at, width = widest),
1262        style.secondary,
1263    )];
1264    spans.push(Span::styled(
1265        format!(
1266            "{}{}",
1267            style.meter_full.to_string().repeat(filled),
1268            style.meter_empty.to_string().repeat(cells - filled)
1269        ),
1270        style.tone(chart.tone),
1271    ));
1272    if let Some(reading) = chart_reading(bar) {
1273        spans.push(Span::styled(reading, style.muted));
1274    }
1275    Line::from(spans)
1276}
1277
1278/// What a bar says beside its own drawing, or nothing.
1279///
1280/// The webview's `bar_text` in this renderer's spelling. Both facts joined the
1281/// same way, and both left out when the description carried neither.
1282fn chart_reading(bar: &Bar<'_>) -> Option<String> {
1283    match (bar.reading, bar.note) {
1284        (Some(reading), Some(note)) => Some(format!(" {reading} / {note}")),
1285        (Some(only), None) | (None, Some(only)) => Some(format!(" {only}")),
1286        (None, None) => None,
1287    }
1288}