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