Skip to main content

makeover_tui/
piece.rs

1//! The pieces every terminal app draws, drawn once.
2//!
3//! # Called `widget` until 0.19.0
4//!
5//! Renamed because `makeover-layout` 0.20.0 took the word for something else,
6//! and the two meanings do not sit together. A `Region::Widget` there is
7//! host-agnostic: a named assembly of primitives that every renderer draws its
8//! own way. What is in this module is the opposite end — renderer-local, the
9//! answer to *what a meter looks like in cells*, taking a description plus what
10//! only a terminal knows.
11//!
12//! One word for both would have made the tier unreadable in the crate that
13//! implements it. This half moved because the other half is the ecosystem-facing
14//! one: a second or third party naming a widget is naming the layout kind, and
15//! nothing outside this tree ever needed a word for a drawing routine.
16//!
17//! `WidgetStyle` went with it and is `PieceStyle`.
18//!
19//! Arrived in 0.16.0 out of `quasi-tui`, which had written all of them and was
20//! the second consumer to do so. A meter, a badge, a control, a figure and a
21//! form field are what a screen is made of below the level [`table`](crate::table)
22//! works at, and every one of them had been hand-rolled at least twice in this
23//! tree before it was lifted.
24//!
25//! [`activity`] and [`awaiting`] joined them in 0.35.0, out of wiki
26//! `loading-and-progress-standard`. They are the one pair here that arrived
27//! before their second consumer rather than after it: nothing in the tree drew
28//! a wait at all, on any surface, which is why the crate that had the vocabulary
29//! for one had never been asked for the drawing.
30//!
31//! # What these take, and what they leave alone
32//!
33//! Each takes a `makeover-layout` description, a [`PieceStyle`], and whatever
34//! the *host* knows that a description never carries. That last part is the
35//! shape worth copying: [`field`] takes what is currently typed in the box as a
36//! separate argument, because [`Field`] deliberately does not carry a value and
37//! is not going to. `makeover-immediate` reached the same seam from the other
38//! side with its `Filling`, and [`Held`] is that seam here.
39//!
40//! Focus is the other one. Nothing in a description says which control the user
41//! is on, so every drawing here takes `focused` as an argument and the caller
42//! is what counts. What focus *looks like* is this crate's answer and not the
43//! caller's, which is the point of it being here: see
44//! [`PieceStyle::focused`].
45//!
46//! # What they do not do
47//!
48//! No layout. Each answers rows for a width, or draws into the rect it is
49//! given, top-aligned, and never below it. Nothing here measures twice and
50//! nothing here places anything relative to anything else, because the moment
51//! it did it would be a layout engine with one consumer's flow baked into it.
52
53use makeover_layout::{Act, Awaiting, Field, FieldKind, Figure, Heading, Meter, Token, Tone};
54use ratatui::buffer::Buffer;
55use ratatui::layout::Rect;
56use ratatui::style::{Modifier, Style};
57use ratatui::text::{Line, Span};
58
59use crate::text;
60use std::time::Duration;
61
62/// The colours and marks the drawings below use.
63///
64/// [`TableStyle`](crate::table::TableStyle)'s shape, for its reasons: an
65/// ungated struct of styles with a [`Default`], plus a
66/// [`from_theme`](Self::from_theme) that is what a consumer holding a loaded
67/// theme should reach for first. A consumer painting bevels and nothing else
68/// should not have to supply text tones it never uses, and gating the whole
69/// module on `theme` would make these unreachable to anyone hand-picking
70/// colours.
71///
72/// The default is the one that survives a terminal with no colour at all:
73/// modifiers only, no foreground anywhere. That is not a placeholder. A
74/// two-colour terminal is the case where a `Style` carrying a foreground is a
75/// foreground that will not land, and bold-and-reversed is what is left.
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub struct PieceStyle {
78    /// Ordinary content, and what [`Tone::Neutral`] reads as.
79    pub content: Style,
80    /// Content one step back: a field's label, a quoted run.
81    pub secondary: Style,
82    /// Content two steps back: a caption, a hint, a meter's reading.
83    pub muted: Style,
84    /// Something worth knowing and nothing to do about it.
85    pub info: Style,
86    /// Something finished and it worked.
87    pub success: Style,
88    /// Something the user should look at.
89    pub warning: Style,
90    /// Something broken, or about to be destroyed.
91    pub danger: Style,
92    /// A page title.
93    pub page: Style,
94    /// A section title.
95    pub section: Style,
96    /// A subsection title.
97    pub subsection: Style,
98    /// Text that goes somewhere, and a control's label.
99    pub action: Style,
100    /// A control filled with the action colour, for the one on a screen that is
101    /// the thing to press. A form's submit is the case that has it.
102    pub filled: Style,
103    /// A surface set back from the one it sits on, by colour and nothing else.
104    /// What a code run takes, since every cell is monospace and the thing a
105    /// webview says with a typeface cannot be said that way here.
106    pub sunken: Style,
107    /// What "you are on this one" adds to whatever it lands on.
108    ///
109    /// Reversed video by default, which is the affordance a cell has left once
110    /// colour is spent on tone and bold on weight. A webview says it with an
111    /// outline; a terminal has no outline that is not four more cells.
112    pub focus: Modifier,
113    /// How many cells [`meter`] spends on its bar.
114    pub meter_cells: u16,
115    /// The filled part of a bar.
116    pub meter_full: char,
117    /// The empty part of a bar.
118    pub meter_empty: char,
119    /// What marks a compulsory field, appended to its label.
120    ///
121    /// A knob for `makeover-immediate`'s reason: it is the one piece of *copy*
122    /// here, and copy is not a renderer's call.
123    pub required_marker: &'static str,
124}
125
126impl Default for PieceStyle {
127    /// Modifiers only, no foreground: what survives a terminal with two
128    /// colours.
129    fn default() -> Self {
130        Self {
131            content: Style::new(),
132            secondary: Style::new(),
133            muted: Style::new().add_modifier(Modifier::DIM),
134            info: Style::new(),
135            success: Style::new(),
136            warning: Style::new(),
137            danger: Style::new().add_modifier(Modifier::BOLD),
138            page: Style::new().add_modifier(Modifier::BOLD),
139            section: Style::new().add_modifier(Modifier::BOLD),
140            subsection: Style::new(),
141            action: Style::new().add_modifier(Modifier::UNDERLINED),
142            filled: Style::new().add_modifier(Modifier::REVERSED),
143            sunken: Style::new().add_modifier(Modifier::DIM),
144            focus: Modifier::REVERSED,
145            meter_cells: 10,
146            meter_full: '#',
147            meter_empty: '-',
148            required_marker: "*",
149        }
150    }
151}
152
153impl PieceStyle {
154    /// The house widgets, from a loaded theme.
155    ///
156    /// The lift this module exists for. `quasi-tui` carried every line of this
157    /// as private methods on its own renderer; a second terminal app wanting a
158    /// toned control had no way to reach them and would have picked its own
159    /// colours for the same five tones.
160    #[cfg(feature = "theme")]
161    #[must_use]
162    pub fn from_theme(theme: &crate::Theme) -> Self {
163        Self {
164            content: Style::new().fg(theme.content_primary),
165            secondary: Style::new().fg(theme.content_secondary),
166            muted: Style::new().fg(theme.content_muted),
167            info: Style::new().fg(theme.status_info),
168            success: Style::new().fg(theme.status_success),
169            warning: Style::new().fg(theme.status_warning),
170            danger: Style::new().fg(theme.status_danger),
171            // Three depths and two of them are bold, which is the whole of what
172            // a terminal has: there is no type scale in a grid of one cell
173            // size. A page title takes bold and the accent, a section bold, a
174            // subsection the secondary colour. That is the emphasis order a
175            // webview's type scale says with size, said with the two axes a
176            // cell has.
177            page: Style::new()
178                .fg(theme.action_primary)
179                .add_modifier(Modifier::BOLD),
180            section: Style::new()
181                .fg(theme.content_primary)
182                .add_modifier(Modifier::BOLD),
183            subsection: Style::new().fg(theme.content_secondary),
184            action: Style::new().fg(theme.action_primary),
185            filled: Style::new().fg(theme.selection_on).bg(theme.action_primary),
186            sunken: Style::new().bg(theme.surface_sunken),
187            focus: Modifier::REVERSED,
188            meter_cells: 10,
189            meter_full: '#',
190            meter_empty: '-',
191            required_marker: "*",
192        }
193    }
194
195    /// The style a tone reads as.
196    ///
197    /// [`Tone`] is closed and stays closed, so this is total and needs no
198    /// fallback arm.
199    #[must_use]
200    pub const fn tone(&self, tone: Tone) -> Style {
201        match tone {
202            Tone::Neutral => self.content,
203            Tone::Info => self.info,
204            Tone::Success => self.success,
205            Tone::Warning => self.warning,
206            Tone::Danger => self.danger,
207        }
208    }
209
210    /// The style a heading reads as.
211    #[must_use]
212    pub const fn heading(&self, level: Heading) -> Style {
213        match level {
214            Heading::Page => self.page,
215            Heading::Section => self.section,
216            Heading::Subsection => self.subsection,
217        }
218    }
219
220    /// `style`, plus the mark that says the user is on this one.
221    ///
222    /// Takes the flag rather than being called behind an `if`, because every
223    /// caller has a bool in hand and the branch is the part that gets forgotten.
224    #[must_use]
225    pub fn focused(&self, focused: bool, style: Style) -> Style {
226        if focused {
227            style.add_modifier(self.focus)
228        } else {
229            style
230        }
231    }
232}
233
234/// What a field currently holds, which a description never carries.
235///
236/// The terminal counterpart of `makeover_immediate::Filling`, and the same seam:
237/// there the widget writes through a `&mut` as the value is edited, and here the
238/// caller keeps an edit buffer and lends it out for the draw. Neither is
239/// something [`Field`] could carry without becoming a form model.
240///
241/// An enum rather than a bag of options, for `Filling`'s reason: a checkbox
242/// holding a string is unsayable here, where a struct would let it be said and
243/// then have to cope.
244#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
245pub enum Held<'a> {
246    /// Nothing typed and nothing chosen. The control draws empty.
247    #[default]
248    Absent,
249    /// What is in the box, or the `value` of the chosen [`Choice`].
250    ///
251    /// [`Choice`]: makeover_layout::Choice
252    Text(&'a str),
253    /// A checkbox, on or off.
254    On(bool),
255    /// Both ends of a [`FieldKind::Interval`], lower first.
256    ///
257    /// Two values rather than one string with a separator, which is
258    /// [`makeover_layout::Field::upper_name`]'s reason one level down: an
259    /// interval is submitted under two names, so it is held as two values, and
260    /// a delimiter this crate owned could appear inside either of them.
261    ///
262    /// Either end may be empty while the other stands. An open end is an
263    /// answer -- "over 120 BPM" -- rather than a half-filled box.
264    ///
265    /// Added 0.33.0 with makeover-layout 0.34.0.
266    Between {
267        /// What the lower box holds now.
268        lower: &'a str,
269        /// What the upper box holds now.
270        upper: &'a str,
271    },
272}
273
274impl<'a> Held<'a> {
275    /// What is typed, as a string. A checkbox has no text and answers empty.
276    #[must_use]
277    pub const fn text(self) -> &'a str {
278        match self {
279            Self::Text(text) | Self::Between { lower: text, .. } => text,
280            Self::Absent | Self::On(_) => "",
281        }
282    }
283
284    /// The upper end, for the one variant that has one.
285    #[must_use]
286    pub const fn upper(self) -> &'a str {
287        match self {
288            Self::Between { upper, .. } => upper,
289            Self::Absent | Self::Text(_) | Self::On(_) => "",
290        }
291    }
292
293    /// Whether a checkbox is ticked.
294    #[must_use]
295    pub const fn on(self) -> bool {
296        matches!(self, Self::On(true))
297    }
298}
299
300/// What a host can see about a wait that is running.
301///
302/// Neither half is derivable from a description, which is why both are here and
303/// not on [`Awaiting`]. That type says how big the payload is; how much of it
304/// has landed is a fact about a transfer in flight, and only whoever is running
305/// the transfer knows it.
306///
307/// The same shape `makeover-immediate` carries, deliberately: a wait is one
308/// reading on every surface and the two renderers should not disagree about
309/// what a host owes them.
310#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
311pub struct Progress {
312    /// How much has arrived, in whatever unit the description counted.
313    pub delivered: Option<u64>,
314    /// How long the wait has lasted so far.
315    ///
316    /// The one time value a wait may show. See [`awaiting`] for the three it
317    /// may not.
318    pub elapsed: Option<Duration>,
319}
320
321/// The activity mark: one cell, lit or dark.
322///
323/// Rule 2 of wiki `loading-and-progress-standard`, and the surface the metaphor
324/// came from. A hard-disk light is one cell that blinks, and a terminal draws
325/// that with no metaphor in the way — where a webview needs a keyframe and egui
326/// needs a repaint schedule, this is a character.
327///
328/// The two glyphs are [`PieceStyle::meter_full`] and
329/// [`PieceStyle::meter_empty`], not a third pair. A bar's filled cell and a lit
330/// mark are the same statement in the same alphabet, and a terminal that had to
331/// render two vocabularies of "on" would be saying there are two kinds of on.
332///
333/// **Dark, not absent.** A mark that is drawn half the time is a hole in the
334/// line, and the line reflows around it or the reader loses where to look. It
335/// occupies its cell either way.
336///
337/// `lit` is the caller's: this module holds no clock. [`crate::activity_lit`]
338/// is the one place the phase is worked out from the cadence, so a caller
339/// should reach for that rather than dividing by 500 itself.
340#[must_use]
341pub fn activity(style: &PieceStyle, lit: bool) -> Span<'static> {
342    if lit {
343        Span::styled(style.meter_full.to_string(), style.action)
344    } else {
345        Span::styled(style.meter_empty.to_string(), style.muted)
346    }
347}
348
349/// A wait as one line, drawn from what is actually known about it.
350///
351/// [`Awaiting::is_determinate`] is the first branch and there is a second the
352/// description cannot answer: whether anything is watching the transfer. A bar
353/// wants a total and a numerator both, so a described amount with no
354/// [`Progress::delivered`] beside it draws the mark and the size it is waiting
355/// on, rather than an empty trough implying somebody is counting.
356///
357/// So three drawings for three states, which is the point:
358///
359/// ```text
360/// unmeasured                       #            a blinking cell
361/// measured, nothing watching       # 41943040   the cell, and how much there is
362/// measured and observed            ####------ 17825792/41943040  4s
363/// ```
364///
365/// **What the bar may not do**, from rule 1 of the standard and from
366/// [`Awaiting`]'s own docs: what is done over what there is, plus the time it
367/// has taken. Never a remaining time, an arrival time, or a rate extrapolated
368/// forward. A prediction is wrong the moment the transfer stalls, and being
369/// confidently wrong is worse than being honestly indeterminate.
370///
371/// The numbers are raw. The unit is the app's — bytes for an upload, rows for
372/// an import — and a renderer that formatted one as a file size would be
373/// dressing up a quantity it was deliberately not told about.
374#[must_use]
375pub fn awaiting(
376    style: &PieceStyle,
377    awaiting: Awaiting,
378    progress: Progress,
379    lit: bool,
380) -> Line<'static> {
381    let Some(total) = awaiting.amount else {
382        return Line::from(vec![activity(style, lit)]);
383    };
384    let Some(done) = progress.delivered else {
385        return Line::from(vec![
386            activity(style, lit),
387            Span::styled(format!(" {total}"), style.muted),
388        ]);
389    };
390    let cells = u32::from(style.meter_cells);
391    // In cells rather than in floating point, the way `meter` does it: a
392    // terminal's bar has ten states and rounding through an f64 to reach one of
393    // ten is arithmetic nobody needs. Saturating rather than wrapping, because
394    // a transfer that over-delivers is a real case and a panicking bar is not
395    // the way to report it.
396    let filled = u32::try_from(
397        done.saturating_mul(u64::from(cells))
398            .checked_div(total)
399            .unwrap_or(0),
400    )
401    .unwrap_or(cells)
402    .min(cells);
403    let bar = format!(
404        "{}{}",
405        style.meter_full.to_string().repeat(filled as usize),
406        style
407            .meter_empty
408            .to_string()
409            .repeat((cells - filled) as usize)
410    );
411    let reading = match progress.elapsed {
412        Some(elapsed) => format!(" {done}/{total}  {}s", elapsed.as_secs()),
413        None => format!(" {done}/{total}"),
414    };
415    Line::from(vec![
416        Span::styled(bar, style.action),
417        Span::styled(reading, style.muted),
418    ])
419}
420
421/// A proportion as one line: the bar, then the reading beside it.
422///
423/// The reading is built here from the two numbers and the noun rather than
424/// taken assembled, which is what [`Meter::label`] carrying the noun alone is
425/// for: a terminal at one line and a tooltip want different sentence orders.
426#[must_use]
427pub fn meter(style: &PieceStyle, meter: &Meter<'_>) -> Line<'static> {
428    let cells = u32::from(style.meter_cells);
429    let filled = meter
430        .done
431        .checked_mul(cells)
432        .and_then(|reached| reached.checked_div(meter.total))
433        .unwrap_or(0)
434        .min(cells);
435    let bar = format!(
436        "{}{}",
437        style.meter_full.to_string().repeat(filled as usize),
438        style
439            .meter_empty
440            .to_string()
441            .repeat((cells - filled) as usize)
442    );
443    let reading = match meter.label {
444        Some(label) => format!(" {}/{} {label}", meter.done, meter.total),
445        None => format!(" {}/{}", meter.done, meter.total),
446    };
447    Line::from(vec![
448        Span::styled(bar, style.tone(meter.tone)),
449        Span::styled(reading, style.muted),
450    ])
451}
452
453/// A badge or a chip as one span.
454///
455/// Round for a badge, square for a chip. A chip answers a press and a badge does
456/// not, and the bracket is the only affordance a cell has left once colour is
457/// spent on the tone.
458///
459/// `latched` is a chip that is switched on, and it reads as reversed. So does
460/// focus, which is a collision a terminal cannot avoid: latched is "this filter
461/// is on" and focused is "you are here", and there is one spare axis for two
462/// facts. Said here rather than resolved by inventing a third look nobody would
463/// read.
464///
465/// A chip's removable half is not drawn. The `x` a webview hangs on a chip is a
466/// second control inside one span, and a terminal reaches a control by focusing
467/// it; two targets in one cell run is a question for whoever owns the
468/// interaction, not for a drawing.
469#[must_use]
470pub fn token(
471    style: &PieceStyle,
472    label: &str,
473    kind: Token,
474    tone: Tone,
475    latched: bool,
476    focused: bool,
477) -> Span<'static> {
478    let painted = style.tone(tone);
479    let painted = if latched {
480        painted.add_modifier(style.focus)
481    } else {
482        style.focused(focused, painted)
483    };
484    match kind {
485        Token::Badge => Span::styled(format!("({label})"), painted),
486        Token::Chip { .. } => Span::styled(format!("[{label}]"), painted),
487    }
488}
489
490/// A control as one line.
491///
492/// `< Label > (key)`, and the key only where the description named one. That
493/// member is the one place `makeover-layout` anticipated a terminal before there
494/// was one, and this is the renderer that reads it.
495///
496/// A disabled control is drawn muted and is not marked focused, whatever the
497/// caller passed: it is present, visible and not answering, so a focus mark on
498/// it would be an affordance that lies. Whether it is reachable at all is the
499/// caller's count to keep — ask [`Act::disabled`].
500#[must_use]
501pub fn act(style: &PieceStyle, act: &Act<'_>, focused: bool) -> Line<'static> {
502    let painted = if act.disabled() {
503        style.muted
504    } else {
505        style.focused(focused, style.tone(act.tone))
506    };
507    let label = match act.key {
508        Some(key) => format!("< {} > ({key})", act.label),
509        None => format!("< {} >", act.label),
510    };
511    Line::from(Span::styled(label, painted))
512}
513
514/// A control filled with the action colour, for the one press a screen is about.
515///
516/// `[ Label ]` rather than `< Label >`, which is the weight difference a webview
517/// carries as a primary-versus-secondary button. A form's submit is the case
518/// this exists for.
519#[must_use]
520pub fn filled_act(style: &PieceStyle, label: &str, focused: bool) -> Line<'static> {
521    Line::from(Span::styled(
522        format!("[ {label} ]"),
523        style.focused(focused, style.filled),
524    ))
525}
526
527/// The rows [`figure`] wants at `width`.
528#[must_use]
529pub fn figure_height(figure: &Figure<'_>, width: u16) -> u16 {
530    text::height(figure.value, width) + text::height(figure.caption, width)
531}
532
533/// A figure: the number, then what it counts under it.
534///
535/// The tone lands on the value and its change rather than on the caption, which
536/// is what [`Figure::tone`] means: the figure is an ordinary fact and it is the
537/// movement that reads as good or bad.
538pub fn figure(style: &PieceStyle, figure: &Figure<'_>, area: Rect, buf: &mut Buffer) -> u16 {
539    let value = match figure.change {
540        Some(change) => format!("{} {change}", figure.value),
541        None => figure.value.to_owned(),
542    };
543    let used = text::draw(
544        &value,
545        style.tone(figure.tone).add_modifier(Modifier::BOLD),
546        area,
547        buf,
548    );
549    used + text::draw(figure.caption, style.muted, below(area, used), buf)
550}
551
552/// The rows [`field`] wants at `width`.
553///
554/// A label row, the control's rows, and a row for whatever went wrong. A hidden
555/// field is nothing at all, which is the one field kind a terminal and a webview
556/// agree on completely.
557#[must_use]
558pub fn field_height(style: &PieceStyle, field: &Field<'_>, width: u16) -> u16 {
559    if !field.kind.visible() {
560        return 0;
561    }
562    let label = text::height(&label_of(style, field), width);
563    // A range is one row like every other single control: the bar, its two ends
564    // and the reading are one line by construction, and a bar that wrapped
565    // would stop being a bar.
566    let body = match field.kind {
567        // Both multi-line kinds get the same three rows, keyed on the
568        // description's own `multiline` rather than on the member: a markdown
569        // field falling through to the single-row arm is one line for a value
570        // whose whole point is that it has several. What a terminal does *with*
571        // the markdown is another question and the answer here is nothing --
572        // the source is the text, and drawing it as text is honest.
573        kind if kind.multiline() => 3,
574        kind if kind.offers_options() => u16::try_from(field.options.len()).unwrap_or(u16::MAX),
575        _ => 1,
576    };
577    let note = message_of(style, field).map_or(0, |(text, _)| text::height(text, width));
578    label + body + note
579}
580
581/// A question: its label, the box, and its standing help or what is wrong now.
582///
583/// `held` is what the user has done to it since the screen arrived, which is the
584/// argument a description cannot supply. See [`Held`].
585///
586/// `focused` marks the box rather than the label, because the box is where the
587/// typing lands.
588pub fn field(
589    style: &PieceStyle,
590    field: &Field<'_>,
591    held: Held<'_>,
592    focused: bool,
593    area: Rect,
594    buf: &mut Buffer,
595) -> u16 {
596    // A hidden field is data travelling with the form. There is nothing to
597    // draw, and whoever submits carries it.
598    if !field.kind.visible() || area.width == 0 || area.height == 0 {
599        return 0;
600    }
601
602    let mut used = text::draw(&label_of(style, field), style.secondary, area, buf);
603
604    let well = style.focused(focused, style.content);
605    let placeholder = field.placeholder.unwrap_or_default();
606
607    used += match field.kind {
608        FieldKind::Checkbox => text::draw(
609            if held.on() { "[x]" } else { "[ ]" },
610            well,
611            below(area, used),
612            buf,
613        ),
614        // A range's two ends are what the question means, so they are drawn
615        // rather than left to a hint. A terminal has the bar already: this is
616        // `meter`'s cells with the extent read out at either side of them.
617        //
618        // An unbounded range has no extent to draw and falls through to the
619        // text path, which is `makeover-immediate`'s answer as well and for the
620        // same reason: bounds this crate invented are bounds the user would
621        // then drag against.
622        FieldKind::Range if field.bounded() => {
623            let line = range_line(style, field, held.text(), well);
624            text::draw_line(&line, below(area, used), buf)
625        }
626        // One question, so one line. The two ends read left to right with the
627        // word between them, which is what a terminal has instead of two boxes
628        // side by side: a second row would read as a second question, and that
629        // is the reading the kind exists to prevent.
630        FieldKind::Interval => {
631            let line = interval_line(style, field, held, well);
632            text::draw_line(&line, below(area, used), buf)
633        }
634        kind if kind.offers_options() => {
635            let mut rows = 0;
636            for choice in field.options {
637                let chosen = held.text() == choice.value;
638                // An option that cannot be picked yet reads as inert, which is
639                // the one place muted is the truth rather than the lie below:
640                // it will not answer, and the reason it will not is on the row
641                // beside it rather than nowhere.
642                let (mark, painted, suffix) = match choice.unavailable {
643                    Some(reason) => ("( )", style.muted, format!(": {reason}")),
644                    None if chosen => ("(*)", well, String::new()),
645                    // An option that is not chosen is still an option: pressing
646                    // it chooses it. So it takes the secondary content intent
647                    // and not the muted one, which is what disabled looks like
648                    // (`State::Disabled` resolves to it). Muted here read as a
649                    // list of five where four were greyed out.
650                    None => ("( )", style.secondary, String::new()),
651                };
652                rows += text::draw(
653                    &format!("{mark} {}{suffix}", choice.label),
654                    painted,
655                    below(area, used + rows),
656                    buf,
657                );
658            }
659            rows
660        }
661        // A secret's dots come from the caller's buffer and can come from
662        // nowhere else: a password that comes back down the wire is a password
663        // in a page and in a proxy log, so a description carries nothing to dot
664        // out. This is the one control that would be undrawable without `held`.
665        FieldKind::Secret if !held.text().is_empty() => {
666            let dots = "*".repeat(held.text().chars().count());
667            text::draw(&dots, well, below(area, used), buf).max(1)
668        }
669        // A file field has no way back on a terminal any more than it has on an
670        // HTTP host. The name is drawn and picking one belongs to whoever owns
671        // the interaction.
672        //
673        // makeover-layout 0.31.0 gave the description an accept list and a
674        // multiplicity, and neither changes anything drawn here. Both are the
675        // picker's business, and the picker is the caller's: this crate draws
676        // what was picked. A terminal that grows its own picker reads them off
677        // `Field::accept` and `Field::multiple` at that point rather than
678        // through a second spelling invented here.
679        _ if held.text().is_empty() => {
680            empty_well(style, placeholder, well, focused, below(area, used), buf)
681        }
682        _ => text::draw(&measured(field, held.text()), well, below(area, used), buf),
683    };
684
685    // Error, then note, then hint -- the order `Field::note` names, and the
686    // order a webview draws them in. Once something has gone wrong that is the
687    // sentence worth the row; failing that, what the chosen answer costs beats
688    // standing help about how the field works.
689    match message_of(style, field) {
690        Some((text, painted)) => used + text::draw(text, painted, below(area, used), buf),
691        None => used,
692    }
693}
694
695/// A bounded number as one line: the low end, the bar, the high end, then what
696/// it currently reads.
697///
698/// The two ends are drawn because they are the question. A threshold of 0.72
699/// says nothing without them, which is the whole argument for
700/// [`FieldKind::Range`] being a kind rather than a number with bounds, and a
701/// terminal is where it would be easiest to quietly drop them and show a figure.
702///
703/// The bar is [`meter`]'s cells, so a range and a proportion read as the same
704/// object in the same app. What differs is the reading beside it: a meter counts
705/// something and a range holds a value.
706///
707/// A value the host cannot read as a number empties the bar and is still shown
708/// as itself. That is [`empty_well`]'s position on an unreadable value: the app
709/// put it there, and a terminal that silently rounded it to a bound would be
710/// reporting a value nobody set.
711fn range_line(style: &PieceStyle, field: &Field<'_>, value: &str, well: Style) -> Line<'static> {
712    let cells = usize::from(style.meter_cells);
713    let ends = field
714        .min
715        .zip(field.max)
716        .and_then(|(min, max)| Some((min.parse::<f64>().ok()?, max.parse::<f64>().ok()?)));
717    let filled = match (ends, value.parse::<f64>()) {
718        (Some((min, max)), Ok(number)) if max > min => {
719            // Where the value sits is the curve's answer, not a proportion of
720            // the extent (makeover-layout 0.32.0). Under `Curve::Linear` the two
721            // are the same number, which is why the bar was right before and is
722            // unchanged for every range described so far; under a constant ratio
723            // they are not, and a bar drawn linearly would put an envelope's
724            // whole useful half inside its first cell.
725            #[expect(
726                clippy::cast_possible_truncation,
727                clippy::cast_sign_loss,
728                reason = "`position_of` returns 0..=1, and the cell count came from a u16"
729            )]
730            let reached = (field.curve.position_of(number, min, max) * cells as f64) as usize;
731            reached.min(cells)
732        }
733        _ => 0,
734    };
735    let bar = format!(
736        "{}{}",
737        style.meter_full.to_string().repeat(filled),
738        style.meter_empty.to_string().repeat(cells - filled)
739    );
740    Line::from(vec![
741        Span::styled(format!("{} ", field.min.unwrap_or_default()), style.muted),
742        Span::styled(bar, well),
743        Span::styled(format!(" {}", field.max.unwrap_or_default()), style.muted),
744        Span::styled(format!(" {}", measured(field, value)), well),
745    ])
746}
747
748/// An interval as one line: the low end, the word, the high end.
749///
750/// One line because it is one question. Two rows would read as two questions,
751/// which is exactly what [`FieldKind::Interval`] exists to stop the description
752/// saying, and a terminal has no side-by-side boxes to fall back on.
753///
754/// # An open end draws the bound it falls back to
755///
756/// Muted, because it is where the axis ends rather than a value anybody set.
757/// With no bound to fall back on there is nothing honest to draw and the end
758/// stays blank: a terminal inventing a number here would report a filter the
759/// user never applied, which is [`range_line`]'s position on an unreadable
760/// value.
761///
762/// # The word, not a dash
763///
764/// A dash between two numbers is a minus sign to anyone reading a signed axis,
765/// and half the measured axes are signed -- audiofiles filters loudness in
766/// dBFS. `to` costs two cells and cannot be misread.
767fn interval_line(
768    style: &PieceStyle,
769    field: &Field<'_>,
770    held: Held<'_>,
771    well: Style,
772) -> Line<'static> {
773    let end = |value: &str, fallback: Option<&str>| match (value.is_empty(), fallback) {
774        (false, _) => Span::styled(measured(field, value), well),
775        (true, Some(bound)) => Span::styled(measured(field, bound), style.muted),
776        (true, None) => Span::styled(String::new(), style.muted),
777    };
778    Line::from(vec![
779        end(held.text(), field.min),
780        Span::styled(" to ", style.secondary),
781        end(held.upper(), field.max),
782    ])
783}
784
785/// The unit to draw beside this field's value, if there is one to draw.
786///
787/// Two conditions rather than one: the field has to carry a unit and its kind
788/// has to be one that means anything by it. `FieldKind::measurable` is the
789/// description answering the second, so this renderer keeps no list of its own
790/// of which kinds are quantities.
791fn unit_of<'a>(field: &Field<'a>) -> Option<&'a str> {
792    field.unit.filter(|_| field.kind.measurable())
793}
794
795/// A value with what it is measured in, as one string.
796///
797/// The unit rides on the value rather than on the label, which is
798/// `makeover-layout` 0.33.0's rule and is what a terminal wants anyway: the
799/// label is a line above and the number is the line the eye is on.
800fn measured(field: &Field<'_>, value: &str) -> String {
801    match unit_of(field) {
802        Some(unit) => format!("{value} {unit}"),
803        None => value.to_owned(),
804    }
805}
806
807/// The label, marked where the field is compulsory.
808fn label_of(style: &PieceStyle, field: &Field<'_>) -> String {
809    if field.required {
810        format!("{} {}", field.label, style.required_marker)
811    } else {
812        field.label.to_owned()
813    }
814}
815
816/// What goes under the box, and how it is painted.
817///
818/// A terminal field has room for exactly one line, so the three message
819/// channels compete for it and the precedence is decided in
820/// [`makeover_layout::Field::note`]'s docs rather than three times here:
821/// **error, then note, then hint**. What is wrong outranks what the answer
822/// costs, which outranks how the field works.
823///
824/// The tone comes with the note; an error is always danger and a hint is
825/// always muted, because neither carries one.
826fn message_of<'a>(style: &PieceStyle, field: &Field<'a>) -> Option<(&'a str, Style)> {
827    if let Some(error) = field.error {
828        return Some((error, style.danger));
829    }
830    if let Some((tone, note)) = field.note {
831        return Some((note, style.tone(tone)));
832    }
833    field.hint.map(|hint| (hint, style.muted))
834}
835
836/// A box with nothing in it: the ghost text, and the caret when it has focus.
837///
838/// The caret is not decoration. An empty field under a style is an empty field,
839/// so a focused one with no placeholder drew literally nothing and there was no
840/// way to tell the box was where the typing would go. A browser has a blinking
841/// bar for this and gets it without asking; a terminal has one cell of reversed
842/// video, put on the first column, which is where the first character lands.
843fn empty_well(
844    style: &PieceStyle,
845    placeholder: &str,
846    well: Style,
847    focused: bool,
848    area: Rect,
849    buf: &mut Buffer,
850) -> u16 {
851    let used = text::draw(placeholder, style.muted, area, buf).max(1);
852    if focused
853        && area.height > 0
854        && area.width > 0
855        && let Some(cell) = buf.cell_mut((area.x, area.y))
856    {
857        cell.set_style(well);
858    }
859    used
860}
861
862/// What is left of `area` after `used` rows from the top.
863fn below(area: Rect, used: u16) -> Rect {
864    let used = used.min(area.height);
865    Rect {
866        x: area.x,
867        y: area.y + used,
868        width: area.width,
869        height: area.height - used,
870    }
871}
872
873#[cfg(test)]
874mod tests {
875
876    #[test]
877    fn one_line_takes_the_error_then_the_note_then_the_hint() {
878        // A terminal field has room for exactly one message, so the three
879        // channels compete and `Field::note` decides the order.
880        let style = PieceStyle::default();
881        let mut f = Field::new(FieldKind::Text, "title", "Title");
882        f.hint = Some("how it works");
883        assert_eq!(message_of(&style, &f).unwrap().0, "how it works");
884
885        f.note = Some((Tone::Warning, "what it costs"));
886        assert_eq!(message_of(&style, &f).unwrap().0, "what it costs");
887        assert_eq!(message_of(&style, &f).unwrap().1, style.warning);
888
889        f.error = Some("what is wrong");
890        assert_eq!(message_of(&style, &f).unwrap().0, "what is wrong");
891        assert_eq!(message_of(&style, &f).unwrap().1, style.danger);
892
893        // A note carries its own tone, so a quiet one is not painted as a
894        // warning just for being a note.
895        f.error = None;
896        f.note = Some((Tone::Neutral, "an ordinary fact"));
897        assert_eq!(message_of(&style, &f).unwrap().1, style.content);
898    }
899    use super::*;
900    use makeover_layout::{Choice, State};
901
902    /// The style the drawings are read against: one distinguishable modifier
903    /// per role, so a test can say which style landed without a colour.
904    fn style() -> PieceStyle {
905        PieceStyle {
906            content: Style::new().add_modifier(Modifier::BOLD),
907            secondary: Style::new().add_modifier(Modifier::ITALIC),
908            muted: Style::new().add_modifier(Modifier::DIM),
909            danger: Style::new().add_modifier(Modifier::CROSSED_OUT),
910            ..PieceStyle::default()
911        }
912    }
913
914    fn buffer(width: u16, height: u16) -> Buffer {
915        Buffer::empty(Rect::new(0, 0, width, height))
916    }
917
918    /// Everything in the buffer, one string per row.
919    fn rows(buf: &Buffer) -> Vec<String> {
920        (0..buf.area.height)
921            .map(|y| {
922                (0..buf.area.width)
923                    .map(|x| {
924                        buf.cell((x, y))
925                            .map_or(' ', |c| c.symbol().chars().next().unwrap_or(' '))
926                    })
927                    .collect::<String>()
928                    .trim_end()
929                    .to_owned()
930            })
931            .collect()
932    }
933
934    #[test]
935    fn a_bar_fills_in_proportion_and_reads_out_the_two_numbers() {
936        let style = style();
937        let line = meter(&style, &Meter::new(3, 10).label("subtasks"));
938        let drawn: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
939        assert_eq!(drawn, "###------- 3/10 subtasks");
940        // The noun is optional and the ratio is not, because a bar with no
941        // reading is a bar you cannot check.
942        let bare = meter(&style, &Meter::new(3, 10));
943        let drawn: String = bare.spans.iter().map(|s| s.content.as_ref()).collect();
944        assert_eq!(drawn, "###------- 3/10");
945    }
946
947    #[test]
948    fn an_empty_set_is_an_empty_bar_rather_than_a_divide_by_zero() {
949        // `Meter::total` of zero means there is no set, and the checked
950        // division is what keeps that from being a panic in a draw.
951        let line = meter(&style(), &Meter::new(0, 0));
952        let drawn: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
953        assert_eq!(drawn, "---------- 0/0");
954    }
955
956    #[test]
957    fn an_over_run_fills_the_bar_and_still_reports_the_overflow() {
958        // The clamp is for drawing only. The reading is what keeps the fact
959        // `Meter::percent` destroys.
960        let line = meter(&style(), &Meter::new(14, 10));
961        let drawn: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
962        assert_eq!(drawn, "########## 14/10");
963    }
964
965    #[test]
966    fn a_badge_is_round_and_a_chip_is_square() {
967        // The one affordance a cell has left once colour is spent on the tone,
968        // and the whole of how a terminal says "this one answers a press".
969        let style = style();
970        let badge = token(&style, "draft", Token::Badge, Tone::Neutral, false, false);
971        assert_eq!(badge.content.as_ref(), "(draft)");
972        let chip = token(
973            &style,
974            "rust",
975            Token::Chip { removable: false },
976            Tone::Neutral,
977            false,
978            false,
979        );
980        assert_eq!(chip.content.as_ref(), "[rust]");
981    }
982
983    #[test]
984    fn a_latched_chip_reads_the_same_as_a_focused_one() {
985        // The collision a terminal cannot avoid, asserted rather than left to
986        // be rediscovered: latched is "this filter is on" and focused is "you
987        // are here", and there is one spare axis for two facts.
988        let style = style();
989        let kind = Token::Chip { removable: false };
990        let latched = token(&style, "rust", kind, Tone::Neutral, true, false);
991        let focused = token(&style, "rust", kind, Tone::Neutral, false, true);
992        assert_eq!(latched.style, focused.style);
993        assert!(latched.style.add_modifier.contains(Modifier::REVERSED));
994    }
995
996    #[test]
997    fn a_control_draws_its_key_only_where_one_was_named() {
998        let style = style();
999        let line = act(&style, &Act::new("Delete"), false);
1000        assert_eq!(line.spans[0].content.as_ref(), "< Delete >");
1001        let line = act(&style, &Act::new("Quit").key("q"), false);
1002        assert_eq!(line.spans[0].content.as_ref(), "< Quit > (q)");
1003    }
1004
1005    #[test]
1006    fn a_disabled_control_is_never_marked_focused() {
1007        // Present, visible, and not answering. A focus mark on it would be an
1008        // affordance that lies, so the flag is overridden rather than trusted.
1009        let style = style();
1010        let disabled = Act::new("Save").state(State::Disabled);
1011        let line = act(&style, &disabled, true);
1012        assert!(
1013            !line.spans[0]
1014                .style
1015                .add_modifier
1016                .contains(Modifier::REVERSED)
1017        );
1018        assert_eq!(line.spans[0].style, style.muted);
1019        // The same call on a control the description says nothing about: the
1020        // mark is this renderer's own focus flag and always was, which is why
1021        // only `Disabled` can override it.
1022        let unstated = Act::new("Save");
1023        let line = act(&style, &unstated, true);
1024        assert!(
1025            line.spans[0]
1026                .style
1027                .add_modifier
1028                .contains(Modifier::REVERSED)
1029        );
1030    }
1031
1032    #[test]
1033    fn a_danger_control_keeps_its_tone_under_focus() {
1034        // Focus adds a modifier rather than repainting, so the fact that this
1035        // is the button that destroys something survives being landed on.
1036        let style = style();
1037        let line = act(&style, &Act::new("Delete").tone(Tone::Danger), true);
1038        assert_eq!(
1039            line.spans[0].style.add_modifier,
1040            style.danger.add_modifier | Modifier::REVERSED
1041        );
1042    }
1043
1044    #[test]
1045    fn a_figure_puts_the_number_over_what_it_counts() {
1046        let style = style();
1047        let figure_ = Figure::new("42", "open tasks");
1048        let mut buf = buffer(20, 4);
1049        let used = figure(&style, &figure_, buf.area, &mut buf);
1050        assert_eq!(used, 2);
1051        assert_eq!(rows(&buf)[..2], ["42".to_owned(), "open tasks".to_owned()]);
1052        assert_eq!(figure_height(&figure_, 20), 2);
1053    }
1054
1055    #[test]
1056    fn a_figures_change_rides_on_the_value_row() {
1057        // The delta is the toned part and the value is an ordinary fact, so the
1058        // two share a row rather than the caption growing a second sentence.
1059        let style = style();
1060        let figure_ = Figure::new("42", "open tasks")
1061            .change("+3")
1062            .tone(Tone::Success);
1063        let mut buf = buffer(20, 4);
1064        figure(&style, &figure_, buf.area, &mut buf);
1065        assert_eq!(rows(&buf)[0], "42 +3");
1066    }
1067
1068    #[test]
1069    fn a_compulsory_field_says_so_in_its_label() {
1070        let style = style();
1071        let mut field_ = Field::new(FieldKind::Text, "email", "Email");
1072        field_.required = true;
1073        let mut buf = buffer(20, 4);
1074        field(&style, &field_, Held::Absent, false, buf.area, &mut buf);
1075        assert_eq!(rows(&buf)[0], "Email *");
1076    }
1077
1078    #[test]
1079    fn a_hidden_field_costs_no_rows_at_all() {
1080        // The one field kind a terminal and a webview agree on completely.
1081        let style = style();
1082        let field_ = Field::new(FieldKind::Hidden, "csrf", "Token");
1083        let mut buf = buffer(20, 4);
1084        assert_eq!(
1085            field(
1086                &style,
1087                &field_,
1088                Held::Text("abc"),
1089                false,
1090                buf.area,
1091                &mut buf
1092            ),
1093            0
1094        );
1095        assert_eq!(field_height(&style, &field_, 20), 0);
1096        assert_eq!(rows(&buf)[0], "");
1097    }
1098
1099    #[test]
1100    fn a_secret_is_dotted_from_the_callers_buffer_and_never_from_the_description() {
1101        // The one control that would be undrawable without `held`: a password
1102        // that came back down the wire is a password in a page and in a log.
1103        let style = style();
1104        let field_ = Field::new(FieldKind::Secret, "password", "Password");
1105        let mut buf = buffer(20, 4);
1106        field(
1107            &style,
1108            &field_,
1109            Held::Text("hunter2"),
1110            false,
1111            buf.area,
1112            &mut buf,
1113        );
1114        assert_eq!(rows(&buf)[1], "*******");
1115    }
1116
1117    #[test]
1118    fn an_error_takes_the_row_the_hint_would_have_had() {
1119        // Once something has gone wrong that is the sentence worth the row,
1120        // which is the order a webview uses too.
1121        let style = style();
1122        let mut field_ = Field::new(FieldKind::Text, "email", "Email");
1123        field_.hint = Some("work address");
1124        field_.error = Some("not an address");
1125        let mut buf = buffer(20, 5);
1126        field(
1127            &style,
1128            &field_,
1129            Held::Text("nope"),
1130            false,
1131            buf.area,
1132            &mut buf,
1133        );
1134        assert_eq!(rows(&buf)[2], "not an address");
1135        assert_eq!(field_height(&style, &field_, 20), 3);
1136    }
1137
1138    #[test]
1139    fn a_focused_empty_box_shows_where_the_typing_will_land() {
1140        // An empty field under a style is an empty field. Without the caret a
1141        // focused box with no placeholder drew literally nothing.
1142        let style = style();
1143        let field_ = Field::new(FieldKind::Text, "email", "Email");
1144        let mut buf = buffer(20, 4);
1145        field(&style, &field_, Held::Absent, true, buf.area, &mut buf);
1146        let caret = buf.cell((0, 1)).expect("the well's first cell").style();
1147        assert!(caret.add_modifier.contains(Modifier::REVERSED));
1148    }
1149
1150    #[test]
1151    fn a_choice_field_marks_the_chosen_option_and_costs_a_row_each() {
1152        let style = style();
1153        let mut field_ = Field::new(FieldKind::Radio, "size", "Size");
1154        let options = [Choice::plain("small"), Choice::plain("large")];
1155        field_.options = &options;
1156        let mut buf = buffer(20, 5);
1157        field(
1158            &style,
1159            &field_,
1160            Held::Text("large"),
1161            false,
1162            buf.area,
1163            &mut buf,
1164        );
1165        assert_eq!(rows(&buf)[1], "( ) small");
1166        assert_eq!(rows(&buf)[2], "(*) large");
1167        assert_eq!(field_height(&style, &field_, 20), 3);
1168    }
1169
1170    #[test]
1171    fn a_range_draws_its_two_ends_and_where_the_value_sits_between_them() {
1172        let style = style();
1173        let field_ = Field::range("review", "Review above", "0", "1");
1174        let mut buf = buffer(40, 3);
1175        field(
1176            &style,
1177            &field_,
1178            Held::Text("0.5"),
1179            false,
1180            buf.area,
1181            &mut buf,
1182        );
1183        // Ten cells by default, half of them filled, with the extent read out
1184        // at either side: 0.5 means nothing without the 0 and the 1.
1185        assert_eq!(rows(&buf)[1].trim_end(), "0 #####----- 1 0.5");
1186        assert_eq!(field_height(&style, &field_, 40), 2);
1187    }
1188
1189    #[test]
1190    fn a_unit_rides_on_the_value_and_not_on_the_label() {
1191        // The label is a line above; the number is the line the eye is on.
1192        let style = style();
1193        let field_ = Field {
1194            unit: Some("s"),
1195            ..Field::range("attack", "Attack", "0", "5")
1196        };
1197        let mut buf = buffer(40, 3);
1198        field(
1199            &style,
1200            &field_,
1201            Held::Text("2.5"),
1202            false,
1203            buf.area,
1204            &mut buf,
1205        );
1206        assert_eq!(rows(&buf)[0].trim_end(), "Attack");
1207        assert_eq!(rows(&buf)[1].trim_end(), "0 #####----- 5 2.5 s");
1208    }
1209
1210    #[test]
1211    fn a_typed_number_reads_with_its_unit_too() {
1212        let style = style();
1213        let field_ = Field {
1214            unit: Some("ms"),
1215            ..Field::new(FieldKind::Number, "fade", "Fade")
1216        };
1217        let mut buf = buffer(40, 3);
1218        field(&style, &field_, Held::Text("50"), false, buf.area, &mut buf);
1219        assert_eq!(rows(&buf)[1].trim_end(), "50 ms");
1220    }
1221
1222    #[test]
1223    fn a_unit_on_a_kind_that_is_not_a_quantity_is_ignored() {
1224        // Which kinds are quantities is the description's answer, not a
1225        // `matches!` kept in this crate.
1226        let style = style();
1227        let field_ = Field {
1228            unit: Some("s"),
1229            ..Field::new(FieldKind::Text, "name", "Name")
1230        };
1231        let mut buf = buffer(40, 3);
1232        field(
1233            &style,
1234            &field_,
1235            Held::Text("kick"),
1236            false,
1237            buf.area,
1238            &mut buf,
1239        );
1240        assert_eq!(rows(&buf)[1].trim_end(), "kick");
1241    }
1242
1243    #[test]
1244    fn an_interval_is_one_line_with_both_ends_on_it() {
1245        // One question, one line. Two rows would read as two questions, which
1246        // is the reading the kind exists to prevent.
1247        let style = style();
1248        let field_ = Field {
1249            min: Some("0"),
1250            max: Some("300"),
1251            unit: Some("BPM"),
1252            ..Field::interval("bpm_min", "bpm_max", "BPM range")
1253        };
1254        let mut buf = buffer(40, 3);
1255        field(
1256            &style,
1257            &field_,
1258            Held::Between {
1259                lower: "90",
1260                upper: "130",
1261            },
1262            false,
1263            buf.area,
1264            &mut buf,
1265        );
1266        assert_eq!(rows(&buf)[0].trim_end(), "BPM range");
1267        assert_eq!(rows(&buf)[1].trim_end(), "90 BPM to 130 BPM");
1268        assert_eq!(rows(&buf)[2].trim_end(), "");
1269    }
1270
1271    #[test]
1272    fn an_open_end_falls_back_to_the_bound_it_means() {
1273        // "Over 120" is an answer rather than a half-filled box, and where the
1274        // axis ends is what the empty end stands for.
1275        let style = style();
1276        let field_ = Field {
1277            min: Some("0"),
1278            max: Some("300"),
1279            ..Field::interval("bpm_min", "bpm_max", "BPM range")
1280        };
1281        let mut buf = buffer(40, 3);
1282        field(
1283            &style,
1284            &field_,
1285            Held::Between {
1286                lower: "120",
1287                upper: "",
1288            },
1289            false,
1290            buf.area,
1291            &mut buf,
1292        );
1293        assert_eq!(rows(&buf)[1].trim_end(), "120 to 300");
1294    }
1295
1296    #[test]
1297    fn an_unbounded_open_end_draws_nothing_rather_than_a_number() {
1298        // A terminal inventing a bound here would report a filter nobody
1299        // applied, which is `range_line`'s position on an unreadable value.
1300        // What is left reads as the sentence it is: up to 130.
1301        let style = style();
1302        let field_ = Field::interval("bpm_min", "bpm_max", "BPM range");
1303        let mut buf = buffer(40, 3);
1304        field(
1305            &style,
1306            &field_,
1307            Held::Between {
1308                lower: "",
1309                upper: "130",
1310            },
1311            false,
1312            buf.area,
1313            &mut buf,
1314        );
1315        assert_eq!(rows(&buf)[1].trim_end(), "to 130");
1316    }
1317
1318    #[test]
1319    fn a_range_holding_something_unreadable_still_shows_it() {
1320        // The app put the value there. A terminal that quietly rounded it to a
1321        // bound would be reporting a value nobody set, which is `empty_well`'s
1322        // position on the same problem.
1323        let style = style();
1324        let field_ = Field::range("review", "Review above", "0", "1");
1325        let mut buf = buffer(40, 3);
1326        field(
1327            &style,
1328            &field_,
1329            Held::Text("unset"),
1330            false,
1331            buf.area,
1332            &mut buf,
1333        );
1334        assert_eq!(rows(&buf)[1].trim_end(), "0 ---------- 1 unset");
1335    }
1336
1337    #[test]
1338    fn an_unbounded_range_is_typed_into_rather_than_dragged() {
1339        // Bounds this crate invented are bounds the user would then drag
1340        // against. The text path takes every answer the bar would.
1341        let style = style();
1342        let field_ = Field {
1343            max: Some("1"),
1344            ..Field::new(FieldKind::Range, "review", "Review above")
1345        };
1346        let mut buf = buffer(40, 3);
1347        field(
1348            &style,
1349            &field_,
1350            Held::Text("0.5"),
1351            false,
1352            buf.area,
1353            &mut buf,
1354        );
1355        assert_eq!(rows(&buf)[1].trim_end(), "0.5");
1356    }
1357
1358    #[test]
1359    fn an_unavailable_option_reads_as_inert_and_says_why() {
1360        // The one place muted is the truth rather than the lie the convention
1361        // warns about: this option will not answer, and the reason is on the
1362        // row rather than nowhere.
1363        let style = style();
1364        let options = [
1365            Choice::new("chromatic", "Chromatic"),
1366            Choice::new("multi", "Multi-sample").unless("Drop a second sample."),
1367        ];
1368        let mut field_ = Field::new(FieldKind::Radio, "mode", "Mode");
1369        field_.options = &options;
1370        let mut buf = buffer(46, 4);
1371        field(
1372            &style,
1373            &field_,
1374            Held::Text("chromatic"),
1375            false,
1376            buf.area,
1377            &mut buf,
1378        );
1379        assert_eq!(rows(&buf)[1].trim_end(), "(*) Chromatic");
1380        assert_eq!(
1381            rows(&buf)[2].trim_end(),
1382            "( ) Multi-sample: Drop a second sample."
1383        );
1384        let muted = buf.cell((0, 2)).expect("the unavailable row").style();
1385        assert!(muted.add_modifier.contains(Modifier::DIM));
1386    }
1387
1388    #[test]
1389    fn an_unchosen_option_does_not_read_as_disabled() {
1390        // The three-tone convention: muted is inert, and every option in this
1391        // list answers a press. Drawn muted, a five-option radio read as one
1392        // live row and four dead ones.
1393        let style = style();
1394        let mut field_ = Field::new(FieldKind::Radio, "size", "Size");
1395        let options = [Choice::plain("small"), Choice::plain("large")];
1396        field_.options = &options;
1397        let mut buf = buffer(20, 5);
1398        field(
1399            &style,
1400            &field_,
1401            Held::Text("large"),
1402            false,
1403            buf.area,
1404            &mut buf,
1405        );
1406        let unchosen = buf.cell((0, 1)).expect("the first option").style();
1407        assert_eq!(unchosen.add_modifier, style.secondary.add_modifier);
1408        assert_ne!(unchosen.add_modifier, style.muted.add_modifier);
1409    }
1410
1411    #[test]
1412    fn a_checkbox_reads_a_bool_rather_than_a_submitted_string() {
1413        // `Held::On` exists so a host's own submission convention -- quasi
1414        // sends "value" -- stays the host's and never reaches a drawing.
1415        let style = style();
1416        let field_ = Field::new(FieldKind::Checkbox, "agree", "Agree");
1417        let mut buf = buffer(20, 4);
1418        field(&style, &field_, Held::On(true), false, buf.area, &mut buf);
1419        assert_eq!(rows(&buf)[1], "[x]");
1420        let mut buf = buffer(20, 4);
1421        field(&style, &field_, Held::On(false), false, buf.area, &mut buf);
1422        assert_eq!(rows(&buf)[1], "[ ]");
1423    }
1424
1425    #[test]
1426    fn a_markdown_field_gets_the_rows_a_textarea_does() {
1427        // Keyed on `multiline`, so a member added upstream does not silently
1428        // land on the single-row arm. One row for a value whose whole point is
1429        // that it has several is the failure this replaced.
1430        let style = PieceStyle::default();
1431        let rich = Field::new(FieldKind::Rich, "body", "Body");
1432        let textarea = Field::new(FieldKind::Textarea, "body", "Body");
1433        let plain = Field::new(FieldKind::Text, "body", "Body");
1434
1435        assert_eq!(
1436            field_height(&style, &rich, 40),
1437            field_height(&style, &textarea, 40)
1438        );
1439        assert!(field_height(&style, &rich, 40) > field_height(&style, &plain, 40));
1440    }
1441
1442    #[test]
1443    fn a_tone_and_a_heading_map_without_a_fallback_arm() {
1444        // Both source enums are closed, which is what lets these be total. A
1445        // renderer that had to guess would be picking its own colours again.
1446        let style = style();
1447        assert_eq!(style.tone(Tone::Neutral), style.content);
1448        assert_eq!(style.tone(Tone::Danger), style.danger);
1449        assert_eq!(style.heading(Heading::Page), style.page);
1450        assert_eq!(style.heading(Heading::Subsection), style.subsection);
1451    }
1452
1453    #[test]
1454    fn the_default_style_carries_no_colour_at_all() {
1455        // A two-colour terminal is the case where a foreground will not land,
1456        // so the default is modifiers only rather than a placeholder palette.
1457        let style = PieceStyle::default();
1458        for painted in [style.content, style.danger, style.page, style.action] {
1459            assert_eq!(painted.fg, None);
1460            assert_eq!(painted.bg, None);
1461        }
1462    }
1463
1464    #[test]
1465    fn the_three_states_of_a_wait_are_three_drawings() {
1466        // The whole done condition of `5db1e0ed`: a measured wait and an
1467        // unmeasured one stopped being the same line.
1468        let style = PieceStyle::default();
1469        let bare = awaiting(&style, Awaiting::unmeasured(), Progress::default(), true);
1470        let sized = awaiting(&style, Awaiting::of(41_943_040), Progress::default(), true);
1471        let watched = awaiting(
1472            &style,
1473            Awaiting::of(40),
1474            Progress {
1475                delivered: Some(20),
1476                elapsed: Some(Duration::from_secs(4)),
1477            },
1478            true,
1479        );
1480        let read = |line: &Line<'_>| {
1481            line.spans
1482                .iter()
1483                .map(|s| s.content.to_string())
1484                .collect::<String>()
1485        };
1486        assert_eq!(read(&bare), "#");
1487        assert_eq!(read(&sized), "# 41943040");
1488        assert_eq!(read(&watched), "#####----- 20/40  4s");
1489    }
1490
1491    #[test]
1492    fn a_dark_mark_still_occupies_its_cell() {
1493        // Not absent. A line that reflowed every half second would move the
1494        // content beside it, and the reader would lose where to look.
1495        let style = PieceStyle::default();
1496        assert_eq!(activity(&style, true).content.chars().count(), 1);
1497        assert_eq!(activity(&style, false).content.chars().count(), 1);
1498    }
1499
1500    #[test]
1501    fn an_over_delivered_wait_clamps_and_does_not_panic() {
1502        // A transfer can hand over more than the size it announced, and the
1503        // bar has ten cells whatever happens.
1504        let style = PieceStyle::default();
1505        let over = awaiting(
1506            &style,
1507            Awaiting::of(4),
1508            Progress {
1509                delivered: Some(9),
1510                elapsed: None,
1511            },
1512            true,
1513        );
1514        assert!(over.spans[0].content.chars().all(|c| c == '#'));
1515        assert_eq!(over.spans[0].content.chars().count(), 10);
1516        // A zero payload is no payload rather than a finished one.
1517        let empty = awaiting(
1518            &style,
1519            Awaiting::of(0),
1520            Progress {
1521                delivered: Some(9),
1522                elapsed: None,
1523            },
1524            true,
1525        );
1526        assert!(empty.spans[0].content.starts_with('-'));
1527    }
1528}