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