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