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//! # What these take, and what they leave alone
26//!
27//! Each takes a `makeover-layout` description, a [`PieceStyle`], and whatever
28//! the *host* knows that a description never carries. That last part is the
29//! shape worth copying: [`field`] takes what is currently typed in the box as a
30//! separate argument, because [`Field`] deliberately does not carry a value and
31//! is not going to. `makeover-immediate` reached the same seam from the other
32//! side with its `Filling`, and [`Held`] is that seam here.
33//!
34//! Focus is the other one. Nothing in a description says which control the user
35//! is on, so every drawing here takes `focused` as an argument and the caller
36//! is what counts. What focus *looks like* is this crate's answer and not the
37//! caller's, which is the point of it being here: see
38//! [`PieceStyle::focused`].
39//!
40//! # What they do not do
41//!
42//! No layout. Each answers rows for a width, or draws into the rect it is
43//! given, top-aligned, and never below it. Nothing here measures twice and
44//! nothing here places anything relative to anything else, because the moment
45//! it did it would be a layout engine with one consumer's flow baked into it.
46
47use makeover_layout::{Act, Field, FieldKind, Figure, Heading, Meter, Token, Tone};
48use ratatui::buffer::Buffer;
49use ratatui::layout::Rect;
50use ratatui::style::{Modifier, Style};
51use ratatui::text::{Line, Span};
52
53use crate::text;
54
55/// The colours and marks the drawings below use.
56///
57/// [`TableStyle`](crate::table::TableStyle)'s shape, for its reasons: an
58/// ungated struct of styles with a [`Default`], plus a
59/// [`from_theme`](Self::from_theme) that is what a consumer holding a loaded
60/// theme should reach for first. A consumer painting bevels and nothing else
61/// should not have to supply text tones it never uses, and gating the whole
62/// module on `theme` would make these unreachable to anyone hand-picking
63/// colours.
64///
65/// The default is the one that survives a terminal with no colour at all:
66/// modifiers only, no foreground anywhere. That is not a placeholder. A
67/// two-colour terminal is the case where a `Style` carrying a foreground is a
68/// foreground that will not land, and bold-and-reversed is what is left.
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub struct PieceStyle {
71    /// Ordinary content, and what [`Tone::Neutral`] reads as.
72    pub content: Style,
73    /// Content one step back: a field's label, a quoted run.
74    pub secondary: Style,
75    /// Content two steps back: a caption, a hint, a meter's reading.
76    pub muted: Style,
77    /// Something worth knowing and nothing to do about it.
78    pub info: Style,
79    /// Something finished and it worked.
80    pub success: Style,
81    /// Something the user should look at.
82    pub warning: Style,
83    /// Something broken, or about to be destroyed.
84    pub danger: Style,
85    /// A page title.
86    pub page: Style,
87    /// A section title.
88    pub section: Style,
89    /// A subsection title.
90    pub subsection: Style,
91    /// Text that goes somewhere, and a control's label.
92    pub action: Style,
93    /// A control filled with the action colour, for the one on a screen that is
94    /// the thing to press. A form's submit is the case that has it.
95    pub filled: Style,
96    /// A surface set back from the one it sits on, by colour and nothing else.
97    /// What a code run takes, since every cell is monospace and the thing a
98    /// webview says with a typeface cannot be said that way here.
99    pub sunken: Style,
100    /// What "you are on this one" adds to whatever it lands on.
101    ///
102    /// Reversed video by default, which is the affordance a cell has left once
103    /// colour is spent on tone and bold on weight. A webview says it with an
104    /// outline; a terminal has no outline that is not four more cells.
105    pub focus: Modifier,
106    /// How many cells [`meter`] spends on its bar.
107    pub meter_cells: u16,
108    /// The filled part of a bar.
109    pub meter_full: char,
110    /// The empty part of a bar.
111    pub meter_empty: char,
112    /// What marks a compulsory field, appended to its label.
113    ///
114    /// A knob for `makeover-immediate`'s reason: it is the one piece of *copy*
115    /// here, and copy is not a renderer's call.
116    pub required_marker: &'static str,
117}
118
119impl Default for PieceStyle {
120    /// Modifiers only, no foreground: what survives a terminal with two
121    /// colours.
122    fn default() -> Self {
123        Self {
124            content: Style::new(),
125            secondary: Style::new(),
126            muted: Style::new().add_modifier(Modifier::DIM),
127            info: Style::new(),
128            success: Style::new(),
129            warning: Style::new(),
130            danger: Style::new().add_modifier(Modifier::BOLD),
131            page: Style::new().add_modifier(Modifier::BOLD),
132            section: Style::new().add_modifier(Modifier::BOLD),
133            subsection: Style::new(),
134            action: Style::new().add_modifier(Modifier::UNDERLINED),
135            filled: Style::new().add_modifier(Modifier::REVERSED),
136            sunken: Style::new().add_modifier(Modifier::DIM),
137            focus: Modifier::REVERSED,
138            meter_cells: 10,
139            meter_full: '#',
140            meter_empty: '-',
141            required_marker: "*",
142        }
143    }
144}
145
146impl PieceStyle {
147    /// The house widgets, from a loaded theme.
148    ///
149    /// The lift this module exists for. `quasi-tui` carried every line of this
150    /// as private methods on its own renderer; a second terminal app wanting a
151    /// toned control had no way to reach them and would have picked its own
152    /// colours for the same five tones.
153    #[cfg(feature = "theme")]
154    #[must_use]
155    pub fn from_theme(theme: &crate::Theme) -> Self {
156        Self {
157            content: Style::new().fg(theme.content_primary),
158            secondary: Style::new().fg(theme.content_secondary),
159            muted: Style::new().fg(theme.content_muted),
160            info: Style::new().fg(theme.status_info),
161            success: Style::new().fg(theme.status_success),
162            warning: Style::new().fg(theme.status_warning),
163            danger: Style::new().fg(theme.status_danger),
164            // Three depths and two of them are bold, which is the whole of what
165            // a terminal has: there is no type scale in a grid of one cell
166            // size. A page title takes bold and the accent, a section bold, a
167            // subsection the secondary colour. That is the emphasis order a
168            // webview's type scale says with size, said with the two axes a
169            // cell has.
170            page: Style::new()
171                .fg(theme.action_primary)
172                .add_modifier(Modifier::BOLD),
173            section: Style::new()
174                .fg(theme.content_primary)
175                .add_modifier(Modifier::BOLD),
176            subsection: Style::new().fg(theme.content_secondary),
177            action: Style::new().fg(theme.action_primary),
178            filled: Style::new().fg(theme.selection_on).bg(theme.action_primary),
179            sunken: Style::new().bg(theme.surface_sunken),
180            focus: Modifier::REVERSED,
181            meter_cells: 10,
182            meter_full: '#',
183            meter_empty: '-',
184            required_marker: "*",
185        }
186    }
187
188    /// The style a tone reads as.
189    ///
190    /// [`Tone`] is closed and stays closed, so this is total and needs no
191    /// fallback arm.
192    #[must_use]
193    pub const fn tone(&self, tone: Tone) -> Style {
194        match tone {
195            Tone::Neutral => self.content,
196            Tone::Info => self.info,
197            Tone::Success => self.success,
198            Tone::Warning => self.warning,
199            Tone::Danger => self.danger,
200        }
201    }
202
203    /// The style a heading reads as.
204    #[must_use]
205    pub const fn heading(&self, level: Heading) -> Style {
206        match level {
207            Heading::Page => self.page,
208            Heading::Section => self.section,
209            Heading::Subsection => self.subsection,
210        }
211    }
212
213    /// `style`, plus the mark that says the user is on this one.
214    ///
215    /// Takes the flag rather than being called behind an `if`, because every
216    /// caller has a bool in hand and the branch is the part that gets forgotten.
217    #[must_use]
218    pub fn focused(&self, focused: bool, style: Style) -> Style {
219        if focused {
220            style.add_modifier(self.focus)
221        } else {
222            style
223        }
224    }
225}
226
227/// What a field currently holds, which a description never carries.
228///
229/// The terminal counterpart of `makeover_immediate::Filling`, and the same seam:
230/// there the widget writes through a `&mut` as the value is edited, and here the
231/// caller keeps an edit buffer and lends it out for the draw. Neither is
232/// something [`Field`] could carry without becoming a form model.
233///
234/// An enum rather than a bag of options, for `Filling`'s reason: a checkbox
235/// holding a string is unsayable here, where a struct would let it be said and
236/// then have to cope.
237#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
238pub enum Held<'a> {
239    /// Nothing typed and nothing chosen. The control draws empty.
240    #[default]
241    Absent,
242    /// What is in the box, or the `value` of the chosen [`Choice`].
243    ///
244    /// [`Choice`]: makeover_layout::Choice
245    Text(&'a str),
246    /// A checkbox, on or off.
247    On(bool),
248}
249
250impl<'a> Held<'a> {
251    /// What is typed, as a string. A checkbox has no text and answers empty.
252    #[must_use]
253    pub const fn text(self) -> &'a str {
254        match self {
255            Self::Text(text) => text,
256            Self::Absent | Self::On(_) => "",
257        }
258    }
259
260    /// Whether a checkbox is ticked.
261    #[must_use]
262    pub const fn on(self) -> bool {
263        matches!(self, Self::On(true))
264    }
265}
266
267/// A proportion as one line: the bar, then the reading beside it.
268///
269/// The reading is built here from the two numbers and the noun rather than
270/// taken assembled, which is what [`Meter::label`] carrying the noun alone is
271/// for: a terminal at one line and a tooltip want different sentence orders.
272#[must_use]
273pub fn meter(style: &PieceStyle, meter: &Meter<'_>) -> Line<'static> {
274    let cells = u32::from(style.meter_cells);
275    let filled = meter
276        .done
277        .checked_mul(cells)
278        .and_then(|reached| reached.checked_div(meter.total))
279        .unwrap_or(0)
280        .min(cells);
281    let bar = format!(
282        "{}{}",
283        style.meter_full.to_string().repeat(filled as usize),
284        style
285            .meter_empty
286            .to_string()
287            .repeat((cells - filled) as usize)
288    );
289    let reading = match meter.label {
290        Some(label) => format!(" {}/{} {label}", meter.done, meter.total),
291        None => format!(" {}/{}", meter.done, meter.total),
292    };
293    Line::from(vec![
294        Span::styled(bar, style.tone(meter.tone)),
295        Span::styled(reading, style.muted),
296    ])
297}
298
299/// A badge or a chip as one span.
300///
301/// Round for a badge, square for a chip. A chip answers a press and a badge does
302/// not, and the bracket is the only affordance a cell has left once colour is
303/// spent on the tone.
304///
305/// `latched` is a chip that is switched on, and it reads as reversed. So does
306/// focus, which is a collision a terminal cannot avoid: latched is "this filter
307/// is on" and focused is "you are here", and there is one spare axis for two
308/// facts. Said here rather than resolved by inventing a third look nobody would
309/// read.
310///
311/// A chip's removable half is not drawn. The `x` a webview hangs on a chip is a
312/// second control inside one span, and a terminal reaches a control by focusing
313/// it; two targets in one cell run is a question for whoever owns the
314/// interaction, not for a drawing.
315#[must_use]
316pub fn token(
317    style: &PieceStyle,
318    label: &str,
319    kind: Token,
320    tone: Tone,
321    latched: bool,
322    focused: bool,
323) -> Span<'static> {
324    let painted = style.tone(tone);
325    let painted = if latched {
326        painted.add_modifier(style.focus)
327    } else {
328        style.focused(focused, painted)
329    };
330    match kind {
331        Token::Badge => Span::styled(format!("({label})"), painted),
332        Token::Chip { .. } => Span::styled(format!("[{label}]"), painted),
333    }
334}
335
336/// A control as one line.
337///
338/// `< Label > (key)`, and the key only where the description named one. That
339/// member is the one place `makeover-layout` anticipated a terminal before there
340/// was one, and this is the renderer that reads it.
341///
342/// A disabled control is drawn muted and is not marked focused, whatever the
343/// caller passed: it is present, visible and not answering, so a focus mark on
344/// it would be an affordance that lies. Whether it is reachable at all is the
345/// caller's count to keep — ask [`Act::disabled`].
346#[must_use]
347pub fn act(style: &PieceStyle, act: &Act<'_>, focused: bool) -> Line<'static> {
348    let painted = if act.disabled() {
349        style.muted
350    } else {
351        style.focused(focused, style.tone(act.tone))
352    };
353    let label = match act.key {
354        Some(key) => format!("< {} > ({key})", act.label),
355        None => format!("< {} >", act.label),
356    };
357    Line::from(Span::styled(label, painted))
358}
359
360/// A control filled with the action colour, for the one press a screen is about.
361///
362/// `[ Label ]` rather than `< Label >`, which is the weight difference a webview
363/// carries as a primary-versus-secondary button. A form's submit is the case
364/// this exists for.
365#[must_use]
366pub fn filled_act(style: &PieceStyle, label: &str, focused: bool) -> Line<'static> {
367    Line::from(Span::styled(
368        format!("[ {label} ]"),
369        style.focused(focused, style.filled),
370    ))
371}
372
373/// The rows [`figure`] wants at `width`.
374#[must_use]
375pub fn figure_height(figure: &Figure<'_>, width: u16) -> u16 {
376    text::height(figure.value, width) + text::height(figure.caption, width)
377}
378
379/// A figure: the number, then what it counts under it.
380///
381/// The tone lands on the value and its change rather than on the caption, which
382/// is what [`Figure::tone`] means: the figure is an ordinary fact and it is the
383/// movement that reads as good or bad.
384pub fn figure(style: &PieceStyle, figure: &Figure<'_>, area: Rect, buf: &mut Buffer) -> u16 {
385    let value = match figure.change {
386        Some(change) => format!("{} {change}", figure.value),
387        None => figure.value.to_owned(),
388    };
389    let used = text::draw(
390        &value,
391        style.tone(figure.tone).add_modifier(Modifier::BOLD),
392        area,
393        buf,
394    );
395    used + text::draw(figure.caption, style.muted, below(area, used), buf)
396}
397
398/// The rows [`field`] wants at `width`.
399///
400/// A label row, the control's rows, and a row for whatever went wrong. A hidden
401/// field is nothing at all, which is the one field kind a terminal and a webview
402/// agree on completely.
403#[must_use]
404pub fn field_height(style: &PieceStyle, field: &Field<'_>, width: u16) -> u16 {
405    if !field.kind.visible() {
406        return 0;
407    }
408    let label = text::height(&label_of(style, field), width);
409    // A range is one row like every other single control: the bar, its two ends
410    // and the reading are one line by construction, and a bar that wrapped
411    // would stop being a bar.
412    let body = match field.kind {
413        // Both multi-line kinds get the same three rows, keyed on the
414        // description's own `multiline` rather than on the member: a markdown
415        // field falling through to the single-row arm is one line for a value
416        // whose whole point is that it has several. What a terminal does *with*
417        // the markdown is another question and the answer here is nothing --
418        // the source is the text, and drawing it as text is honest.
419        kind if kind.multiline() => 3,
420        kind if kind.offers_options() => u16::try_from(field.options.len()).unwrap_or(u16::MAX),
421        _ => 1,
422    };
423    let note = note_of(field).map_or(0, |note| text::height(note, width));
424    label + body + note
425}
426
427/// A question: its label, the box, and its standing help or what is wrong now.
428///
429/// `held` is what the user has done to it since the screen arrived, which is the
430/// argument a description cannot supply. See [`Held`].
431///
432/// `focused` marks the box rather than the label, because the box is where the
433/// typing lands.
434pub fn field(
435    style: &PieceStyle,
436    field: &Field<'_>,
437    held: Held<'_>,
438    focused: bool,
439    area: Rect,
440    buf: &mut Buffer,
441) -> u16 {
442    // A hidden field is data travelling with the form. There is nothing to
443    // draw, and whoever submits carries it.
444    if !field.kind.visible() || area.width == 0 || area.height == 0 {
445        return 0;
446    }
447
448    let mut used = text::draw(&label_of(style, field), style.secondary, area, buf);
449
450    let well = style.focused(focused, style.content);
451    let placeholder = field.placeholder.unwrap_or_default();
452
453    used += match field.kind {
454        FieldKind::Checkbox => text::draw(
455            if held.on() { "[x]" } else { "[ ]" },
456            well,
457            below(area, used),
458            buf,
459        ),
460        // A range's two ends are what the question means, so they are drawn
461        // rather than left to a hint. A terminal has the bar already: this is
462        // `meter`'s cells with the extent read out at either side of them.
463        //
464        // An unbounded range has no extent to draw and falls through to the
465        // text path, which is `makeover-immediate`'s answer as well and for the
466        // same reason: bounds this crate invented are bounds the user would
467        // then drag against.
468        FieldKind::Range if field.bounded() => {
469            let line = range_line(style, field, held.text(), well);
470            text::draw_line(&line, below(area, used), buf)
471        }
472        kind if kind.offers_options() => {
473            let mut rows = 0;
474            for choice in field.options {
475                let chosen = held.text() == choice.value;
476                // An option that cannot be picked yet reads as inert, which is
477                // the one place muted is the truth rather than the lie below:
478                // it will not answer, and the reason it will not is on the row
479                // beside it rather than nowhere.
480                let (mark, painted, suffix) = match choice.unavailable {
481                    Some(reason) => ("( )", style.muted, format!(": {reason}")),
482                    None if chosen => ("(*)", well, String::new()),
483                    // An option that is not chosen is still an option: pressing
484                    // it chooses it. So it takes the secondary content intent
485                    // and not the muted one, which is what disabled looks like
486                    // (`State::Disabled` resolves to it). Muted here read as a
487                    // list of five where four were greyed out.
488                    None => ("( )", style.secondary, String::new()),
489                };
490                rows += text::draw(
491                    &format!("{mark} {}{suffix}", choice.label),
492                    painted,
493                    below(area, used + rows),
494                    buf,
495                );
496            }
497            rows
498        }
499        // A secret's dots come from the caller's buffer and can come from
500        // nowhere else: a password that comes back down the wire is a password
501        // in a page and in a proxy log, so a description carries nothing to dot
502        // out. This is the one control that would be undrawable without `held`.
503        FieldKind::Secret if !held.text().is_empty() => {
504            let dots = "*".repeat(held.text().chars().count());
505            text::draw(&dots, well, below(area, used), buf).max(1)
506        }
507        // A file field has no way back on a terminal any more than it has on an
508        // HTTP host. The name is drawn and picking one belongs to whoever owns
509        // the interaction.
510        //
511        // makeover-layout 0.31.0 gave the description an accept list and a
512        // multiplicity, and neither changes anything drawn here. Both are the
513        // picker's business, and the picker is the caller's: this crate draws
514        // what was picked. A terminal that grows its own picker reads them off
515        // `Field::accept` and `Field::multiple` at that point rather than
516        // through a second spelling invented here.
517        _ if held.text().is_empty() => {
518            empty_well(style, placeholder, well, focused, below(area, used), buf)
519        }
520        _ => text::draw(&measured(field, held.text()), well, below(area, used), buf),
521    };
522
523    // The error wins over the hint, the same order a webview uses: a hint is
524    // what to type and an error is what went wrong, and once something has gone
525    // wrong that is the sentence worth the row.
526    match note_of(field) {
527        Some(note) => {
528            let painted = if field.error.is_some() {
529                style.danger
530            } else {
531                style.muted
532            };
533            used + text::draw(note, painted, below(area, used), buf)
534        }
535        None => used,
536    }
537}
538
539/// A bounded number as one line: the low end, the bar, the high end, then what
540/// it currently reads.
541///
542/// The two ends are drawn because they are the question. A threshold of 0.72
543/// says nothing without them, which is the whole argument for
544/// [`FieldKind::Range`] being a kind rather than a number with bounds, and a
545/// terminal is where it would be easiest to quietly drop them and show a figure.
546///
547/// The bar is [`meter`]'s cells, so a range and a proportion read as the same
548/// object in the same app. What differs is the reading beside it: a meter counts
549/// something and a range holds a value.
550///
551/// A value the host cannot read as a number empties the bar and is still shown
552/// as itself. That is [`empty_well`]'s position on an unreadable value: the app
553/// put it there, and a terminal that silently rounded it to a bound would be
554/// reporting a value nobody set.
555fn range_line(style: &PieceStyle, field: &Field<'_>, value: &str, well: Style) -> Line<'static> {
556    let cells = usize::from(style.meter_cells);
557    let ends = field
558        .min
559        .zip(field.max)
560        .and_then(|(min, max)| Some((min.parse::<f64>().ok()?, max.parse::<f64>().ok()?)));
561    let filled = match (ends, value.parse::<f64>()) {
562        (Some((min, max)), Ok(number)) if max > min => {
563            // Where the value sits is the curve's answer, not a proportion of
564            // the extent (makeover-layout 0.32.0). Under `Curve::Linear` the two
565            // are the same number, which is why the bar was right before and is
566            // unchanged for every range described so far; under a constant ratio
567            // they are not, and a bar drawn linearly would put an envelope's
568            // whole useful half inside its first cell.
569            #[expect(
570                clippy::cast_possible_truncation,
571                clippy::cast_sign_loss,
572                reason = "`position_of` returns 0..=1, and the cell count came from a u16"
573            )]
574            let reached = (field.curve.position_of(number, min, max) * cells as f64) as usize;
575            reached.min(cells)
576        }
577        _ => 0,
578    };
579    let bar = format!(
580        "{}{}",
581        style.meter_full.to_string().repeat(filled),
582        style.meter_empty.to_string().repeat(cells - filled)
583    );
584    Line::from(vec![
585        Span::styled(format!("{} ", field.min.unwrap_or_default()), style.muted),
586        Span::styled(bar, well),
587        Span::styled(format!(" {}", field.max.unwrap_or_default()), style.muted),
588        Span::styled(format!(" {}", measured(field, value)), well),
589    ])
590}
591
592/// The unit to draw beside this field's value, if there is one to draw.
593///
594/// Two conditions rather than one: the field has to carry a unit and its kind
595/// has to be one that means anything by it. `FieldKind::measurable` is the
596/// description answering the second, so this renderer keeps no list of its own
597/// of which kinds are quantities.
598fn unit_of<'a>(field: &Field<'a>) -> Option<&'a str> {
599    field.unit.filter(|_| field.kind.measurable())
600}
601
602/// A value with what it is measured in, as one string.
603///
604/// The unit rides on the value rather than on the label, which is
605/// `makeover-layout` 0.33.0's rule and is what a terminal wants anyway: the
606/// label is a line above and the number is the line the eye is on.
607fn measured(field: &Field<'_>, value: &str) -> String {
608    match unit_of(field) {
609        Some(unit) => format!("{value} {unit}"),
610        None => value.to_owned(),
611    }
612}
613
614/// The label, marked where the field is compulsory.
615fn label_of(style: &PieceStyle, field: &Field<'_>) -> String {
616    if field.required {
617        format!("{} {}", field.label, style.required_marker)
618    } else {
619        field.label.to_owned()
620    }
621}
622
623/// What goes under the box: what is wrong now, or the standing help.
624fn note_of<'a>(field: &Field<'a>) -> Option<&'a str> {
625    field.error.or(field.hint)
626}
627
628/// A box with nothing in it: the ghost text, and the caret when it has focus.
629///
630/// The caret is not decoration. An empty field under a style is an empty field,
631/// so a focused one with no placeholder drew literally nothing and there was no
632/// way to tell the box was where the typing would go. A browser has a blinking
633/// bar for this and gets it without asking; a terminal has one cell of reversed
634/// video, put on the first column, which is where the first character lands.
635fn empty_well(
636    style: &PieceStyle,
637    placeholder: &str,
638    well: Style,
639    focused: bool,
640    area: Rect,
641    buf: &mut Buffer,
642) -> u16 {
643    let used = text::draw(placeholder, style.muted, area, buf).max(1);
644    if focused
645        && area.height > 0
646        && area.width > 0
647        && let Some(cell) = buf.cell_mut((area.x, area.y))
648    {
649        cell.set_style(well);
650    }
651    used
652}
653
654/// What is left of `area` after `used` rows from the top.
655fn below(area: Rect, used: u16) -> Rect {
656    let used = used.min(area.height);
657    Rect {
658        x: area.x,
659        y: area.y + used,
660        width: area.width,
661        height: area.height - used,
662    }
663}
664
665#[cfg(test)]
666mod tests {
667    use super::*;
668    use makeover_layout::{Choice, State};
669
670    /// The style the drawings are read against: one distinguishable modifier
671    /// per role, so a test can say which style landed without a colour.
672    fn style() -> PieceStyle {
673        PieceStyle {
674            content: Style::new().add_modifier(Modifier::BOLD),
675            secondary: Style::new().add_modifier(Modifier::ITALIC),
676            muted: Style::new().add_modifier(Modifier::DIM),
677            danger: Style::new().add_modifier(Modifier::CROSSED_OUT),
678            ..PieceStyle::default()
679        }
680    }
681
682    fn buffer(width: u16, height: u16) -> Buffer {
683        Buffer::empty(Rect::new(0, 0, width, height))
684    }
685
686    /// Everything in the buffer, one string per row.
687    fn rows(buf: &Buffer) -> Vec<String> {
688        (0..buf.area.height)
689            .map(|y| {
690                (0..buf.area.width)
691                    .map(|x| {
692                        buf.cell((x, y))
693                            .map_or(' ', |c| c.symbol().chars().next().unwrap_or(' '))
694                    })
695                    .collect::<String>()
696                    .trim_end()
697                    .to_owned()
698            })
699            .collect()
700    }
701
702    #[test]
703    fn a_bar_fills_in_proportion_and_reads_out_the_two_numbers() {
704        let style = style();
705        let line = meter(&style, &Meter::new(3, 10).label("subtasks"));
706        let drawn: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
707        assert_eq!(drawn, "###------- 3/10 subtasks");
708        // The noun is optional and the ratio is not, because a bar with no
709        // reading is a bar you cannot check.
710        let bare = meter(&style, &Meter::new(3, 10));
711        let drawn: String = bare.spans.iter().map(|s| s.content.as_ref()).collect();
712        assert_eq!(drawn, "###------- 3/10");
713    }
714
715    #[test]
716    fn an_empty_set_is_an_empty_bar_rather_than_a_divide_by_zero() {
717        // `Meter::total` of zero means there is no set, and the checked
718        // division is what keeps that from being a panic in a draw.
719        let line = meter(&style(), &Meter::new(0, 0));
720        let drawn: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
721        assert_eq!(drawn, "---------- 0/0");
722    }
723
724    #[test]
725    fn an_over_run_fills_the_bar_and_still_reports_the_overflow() {
726        // The clamp is for drawing only. The reading is what keeps the fact
727        // `Meter::percent` destroys.
728        let line = meter(&style(), &Meter::new(14, 10));
729        let drawn: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
730        assert_eq!(drawn, "########## 14/10");
731    }
732
733    #[test]
734    fn a_badge_is_round_and_a_chip_is_square() {
735        // The one affordance a cell has left once colour is spent on the tone,
736        // and the whole of how a terminal says "this one answers a press".
737        let style = style();
738        let badge = token(&style, "draft", Token::Badge, Tone::Neutral, false, false);
739        assert_eq!(badge.content.as_ref(), "(draft)");
740        let chip = token(
741            &style,
742            "rust",
743            Token::Chip { removable: false },
744            Tone::Neutral,
745            false,
746            false,
747        );
748        assert_eq!(chip.content.as_ref(), "[rust]");
749    }
750
751    #[test]
752    fn a_latched_chip_reads_the_same_as_a_focused_one() {
753        // The collision a terminal cannot avoid, asserted rather than left to
754        // be rediscovered: latched is "this filter is on" and focused is "you
755        // are here", and there is one spare axis for two facts.
756        let style = style();
757        let kind = Token::Chip { removable: false };
758        let latched = token(&style, "rust", kind, Tone::Neutral, true, false);
759        let focused = token(&style, "rust", kind, Tone::Neutral, false, true);
760        assert_eq!(latched.style, focused.style);
761        assert!(latched.style.add_modifier.contains(Modifier::REVERSED));
762    }
763
764    #[test]
765    fn a_control_draws_its_key_only_where_one_was_named() {
766        let style = style();
767        let line = act(&style, &Act::new("Delete"), false);
768        assert_eq!(line.spans[0].content.as_ref(), "< Delete >");
769        let line = act(&style, &Act::new("Quit").key("q"), false);
770        assert_eq!(line.spans[0].content.as_ref(), "< Quit > (q)");
771    }
772
773    #[test]
774    fn a_disabled_control_is_never_marked_focused() {
775        // Present, visible, and not answering. A focus mark on it would be an
776        // affordance that lies, so the flag is overridden rather than trusted.
777        let style = style();
778        let disabled = Act::new("Save").state(State::Disabled);
779        let line = act(&style, &disabled, true);
780        assert!(
781            !line.spans[0]
782                .style
783                .add_modifier
784                .contains(Modifier::REVERSED)
785        );
786        assert_eq!(line.spans[0].style, style.muted);
787        // The same call on a control the description says nothing about: the
788        // mark is this renderer's own focus flag and always was, which is why
789        // only `Disabled` can override it.
790        let unstated = Act::new("Save");
791        let line = act(&style, &unstated, true);
792        assert!(
793            line.spans[0]
794                .style
795                .add_modifier
796                .contains(Modifier::REVERSED)
797        );
798    }
799
800    #[test]
801    fn a_danger_control_keeps_its_tone_under_focus() {
802        // Focus adds a modifier rather than repainting, so the fact that this
803        // is the button that destroys something survives being landed on.
804        let style = style();
805        let line = act(&style, &Act::new("Delete").tone(Tone::Danger), true);
806        assert_eq!(
807            line.spans[0].style.add_modifier,
808            style.danger.add_modifier | Modifier::REVERSED
809        );
810    }
811
812    #[test]
813    fn a_figure_puts_the_number_over_what_it_counts() {
814        let style = style();
815        let figure_ = Figure::new("42", "open tasks");
816        let mut buf = buffer(20, 4);
817        let used = figure(&style, &figure_, buf.area, &mut buf);
818        assert_eq!(used, 2);
819        assert_eq!(rows(&buf)[..2], ["42".to_owned(), "open tasks".to_owned()]);
820        assert_eq!(figure_height(&figure_, 20), 2);
821    }
822
823    #[test]
824    fn a_figures_change_rides_on_the_value_row() {
825        // The delta is the toned part and the value is an ordinary fact, so the
826        // two share a row rather than the caption growing a second sentence.
827        let style = style();
828        let figure_ = Figure::new("42", "open tasks")
829            .change("+3")
830            .tone(Tone::Success);
831        let mut buf = buffer(20, 4);
832        figure(&style, &figure_, buf.area, &mut buf);
833        assert_eq!(rows(&buf)[0], "42 +3");
834    }
835
836    #[test]
837    fn a_compulsory_field_says_so_in_its_label() {
838        let style = style();
839        let mut field_ = Field::new(FieldKind::Text, "email", "Email");
840        field_.required = true;
841        let mut buf = buffer(20, 4);
842        field(&style, &field_, Held::Absent, false, buf.area, &mut buf);
843        assert_eq!(rows(&buf)[0], "Email *");
844    }
845
846    #[test]
847    fn a_hidden_field_costs_no_rows_at_all() {
848        // The one field kind a terminal and a webview agree on completely.
849        let style = style();
850        let field_ = Field::new(FieldKind::Hidden, "csrf", "Token");
851        let mut buf = buffer(20, 4);
852        assert_eq!(
853            field(
854                &style,
855                &field_,
856                Held::Text("abc"),
857                false,
858                buf.area,
859                &mut buf
860            ),
861            0
862        );
863        assert_eq!(field_height(&style, &field_, 20), 0);
864        assert_eq!(rows(&buf)[0], "");
865    }
866
867    #[test]
868    fn a_secret_is_dotted_from_the_callers_buffer_and_never_from_the_description() {
869        // The one control that would be undrawable without `held`: a password
870        // that came back down the wire is a password in a page and in a log.
871        let style = style();
872        let field_ = Field::new(FieldKind::Secret, "password", "Password");
873        let mut buf = buffer(20, 4);
874        field(
875            &style,
876            &field_,
877            Held::Text("hunter2"),
878            false,
879            buf.area,
880            &mut buf,
881        );
882        assert_eq!(rows(&buf)[1], "*******");
883    }
884
885    #[test]
886    fn an_error_takes_the_row_the_hint_would_have_had() {
887        // Once something has gone wrong that is the sentence worth the row,
888        // which is the order a webview uses too.
889        let style = style();
890        let mut field_ = Field::new(FieldKind::Text, "email", "Email");
891        field_.hint = Some("work address");
892        field_.error = Some("not an address");
893        let mut buf = buffer(20, 5);
894        field(
895            &style,
896            &field_,
897            Held::Text("nope"),
898            false,
899            buf.area,
900            &mut buf,
901        );
902        assert_eq!(rows(&buf)[2], "not an address");
903        assert_eq!(field_height(&style, &field_, 20), 3);
904    }
905
906    #[test]
907    fn a_focused_empty_box_shows_where_the_typing_will_land() {
908        // An empty field under a style is an empty field. Without the caret a
909        // focused box with no placeholder drew literally nothing.
910        let style = style();
911        let field_ = Field::new(FieldKind::Text, "email", "Email");
912        let mut buf = buffer(20, 4);
913        field(&style, &field_, Held::Absent, true, buf.area, &mut buf);
914        let caret = buf.cell((0, 1)).expect("the well's first cell").style();
915        assert!(caret.add_modifier.contains(Modifier::REVERSED));
916    }
917
918    #[test]
919    fn a_choice_field_marks_the_chosen_option_and_costs_a_row_each() {
920        let style = style();
921        let mut field_ = Field::new(FieldKind::Radio, "size", "Size");
922        let options = [Choice::plain("small"), Choice::plain("large")];
923        field_.options = &options;
924        let mut buf = buffer(20, 5);
925        field(
926            &style,
927            &field_,
928            Held::Text("large"),
929            false,
930            buf.area,
931            &mut buf,
932        );
933        assert_eq!(rows(&buf)[1], "( ) small");
934        assert_eq!(rows(&buf)[2], "(*) large");
935        assert_eq!(field_height(&style, &field_, 20), 3);
936    }
937
938    #[test]
939    fn a_range_draws_its_two_ends_and_where_the_value_sits_between_them() {
940        let style = style();
941        let field_ = Field::range("review", "Review above", "0", "1");
942        let mut buf = buffer(40, 3);
943        field(
944            &style,
945            &field_,
946            Held::Text("0.5"),
947            false,
948            buf.area,
949            &mut buf,
950        );
951        // Ten cells by default, half of them filled, with the extent read out
952        // at either side: 0.5 means nothing without the 0 and the 1.
953        assert_eq!(rows(&buf)[1].trim_end(), "0 #####----- 1 0.5");
954        assert_eq!(field_height(&style, &field_, 40), 2);
955    }
956
957    #[test]
958    fn a_unit_rides_on_the_value_and_not_on_the_label() {
959        // The label is a line above; the number is the line the eye is on.
960        let style = style();
961        let field_ = Field {
962            unit: Some("s"),
963            ..Field::range("attack", "Attack", "0", "5")
964        };
965        let mut buf = buffer(40, 3);
966        field(
967            &style,
968            &field_,
969            Held::Text("2.5"),
970            false,
971            buf.area,
972            &mut buf,
973        );
974        assert_eq!(rows(&buf)[0].trim_end(), "Attack");
975        assert_eq!(rows(&buf)[1].trim_end(), "0 #####----- 5 2.5 s");
976    }
977
978    #[test]
979    fn a_typed_number_reads_with_its_unit_too() {
980        let style = style();
981        let field_ = Field {
982            unit: Some("ms"),
983            ..Field::new(FieldKind::Number, "fade", "Fade")
984        };
985        let mut buf = buffer(40, 3);
986        field(&style, &field_, Held::Text("50"), false, buf.area, &mut buf);
987        assert_eq!(rows(&buf)[1].trim_end(), "50 ms");
988    }
989
990    #[test]
991    fn a_unit_on_a_kind_that_is_not_a_quantity_is_ignored() {
992        // Which kinds are quantities is the description's answer, not a
993        // `matches!` kept in this crate.
994        let style = style();
995        let field_ = Field {
996            unit: Some("s"),
997            ..Field::new(FieldKind::Text, "name", "Name")
998        };
999        let mut buf = buffer(40, 3);
1000        field(
1001            &style,
1002            &field_,
1003            Held::Text("kick"),
1004            false,
1005            buf.area,
1006            &mut buf,
1007        );
1008        assert_eq!(rows(&buf)[1].trim_end(), "kick");
1009    }
1010
1011    #[test]
1012    fn a_range_holding_something_unreadable_still_shows_it() {
1013        // The app put the value there. A terminal that quietly rounded it to a
1014        // bound would be reporting a value nobody set, which is `empty_well`'s
1015        // position on the same problem.
1016        let style = style();
1017        let field_ = Field::range("review", "Review above", "0", "1");
1018        let mut buf = buffer(40, 3);
1019        field(
1020            &style,
1021            &field_,
1022            Held::Text("unset"),
1023            false,
1024            buf.area,
1025            &mut buf,
1026        );
1027        assert_eq!(rows(&buf)[1].trim_end(), "0 ---------- 1 unset");
1028    }
1029
1030    #[test]
1031    fn an_unbounded_range_is_typed_into_rather_than_dragged() {
1032        // Bounds this crate invented are bounds the user would then drag
1033        // against. The text path takes every answer the bar would.
1034        let style = style();
1035        let field_ = Field {
1036            max: Some("1"),
1037            ..Field::new(FieldKind::Range, "review", "Review above")
1038        };
1039        let mut buf = buffer(40, 3);
1040        field(
1041            &style,
1042            &field_,
1043            Held::Text("0.5"),
1044            false,
1045            buf.area,
1046            &mut buf,
1047        );
1048        assert_eq!(rows(&buf)[1].trim_end(), "0.5");
1049    }
1050
1051    #[test]
1052    fn an_unavailable_option_reads_as_inert_and_says_why() {
1053        // The one place muted is the truth rather than the lie the convention
1054        // warns about: this option will not answer, and the reason is on the
1055        // row rather than nowhere.
1056        let style = style();
1057        let options = [
1058            Choice::new("chromatic", "Chromatic"),
1059            Choice::new("multi", "Multi-sample").unless("Drop a second sample."),
1060        ];
1061        let mut field_ = Field::new(FieldKind::Radio, "mode", "Mode");
1062        field_.options = &options;
1063        let mut buf = buffer(46, 4);
1064        field(
1065            &style,
1066            &field_,
1067            Held::Text("chromatic"),
1068            false,
1069            buf.area,
1070            &mut buf,
1071        );
1072        assert_eq!(rows(&buf)[1].trim_end(), "(*) Chromatic");
1073        assert_eq!(
1074            rows(&buf)[2].trim_end(),
1075            "( ) Multi-sample: Drop a second sample."
1076        );
1077        let muted = buf.cell((0, 2)).expect("the unavailable row").style();
1078        assert!(muted.add_modifier.contains(Modifier::DIM));
1079    }
1080
1081    #[test]
1082    fn an_unchosen_option_does_not_read_as_disabled() {
1083        // The three-tone convention: muted is inert, and every option in this
1084        // list answers a press. Drawn muted, a five-option radio read as one
1085        // live row and four dead ones.
1086        let style = style();
1087        let mut field_ = Field::new(FieldKind::Radio, "size", "Size");
1088        let options = [Choice::plain("small"), Choice::plain("large")];
1089        field_.options = &options;
1090        let mut buf = buffer(20, 5);
1091        field(
1092            &style,
1093            &field_,
1094            Held::Text("large"),
1095            false,
1096            buf.area,
1097            &mut buf,
1098        );
1099        let unchosen = buf.cell((0, 1)).expect("the first option").style();
1100        assert_eq!(unchosen.add_modifier, style.secondary.add_modifier);
1101        assert_ne!(unchosen.add_modifier, style.muted.add_modifier);
1102    }
1103
1104    #[test]
1105    fn a_checkbox_reads_a_bool_rather_than_a_submitted_string() {
1106        // `Held::On` exists so a host's own submission convention -- quasi
1107        // sends "value" -- stays the host's and never reaches a drawing.
1108        let style = style();
1109        let field_ = Field::new(FieldKind::Checkbox, "agree", "Agree");
1110        let mut buf = buffer(20, 4);
1111        field(&style, &field_, Held::On(true), false, buf.area, &mut buf);
1112        assert_eq!(rows(&buf)[1], "[x]");
1113        let mut buf = buffer(20, 4);
1114        field(&style, &field_, Held::On(false), false, buf.area, &mut buf);
1115        assert_eq!(rows(&buf)[1], "[ ]");
1116    }
1117
1118    #[test]
1119    fn a_markdown_field_gets_the_rows_a_textarea_does() {
1120        // Keyed on `multiline`, so a member added upstream does not silently
1121        // land on the single-row arm. One row for a value whose whole point is
1122        // that it has several is the failure this replaced.
1123        let style = PieceStyle::default();
1124        let rich = Field::new(FieldKind::Rich, "body", "Body");
1125        let textarea = Field::new(FieldKind::Textarea, "body", "Body");
1126        let plain = Field::new(FieldKind::Text, "body", "Body");
1127
1128        assert_eq!(
1129            field_height(&style, &rich, 40),
1130            field_height(&style, &textarea, 40)
1131        );
1132        assert!(field_height(&style, &rich, 40) > field_height(&style, &plain, 40));
1133    }
1134
1135    #[test]
1136    fn a_tone_and_a_heading_map_without_a_fallback_arm() {
1137        // Both source enums are closed, which is what lets these be total. A
1138        // renderer that had to guess would be picking its own colours again.
1139        let style = style();
1140        assert_eq!(style.tone(Tone::Neutral), style.content);
1141        assert_eq!(style.tone(Tone::Danger), style.danger);
1142        assert_eq!(style.heading(Heading::Page), style.page);
1143        assert_eq!(style.heading(Heading::Subsection), style.subsection);
1144    }
1145
1146    #[test]
1147    fn the_default_style_carries_no_colour_at_all() {
1148        // A two-colour terminal is the case where a foreground will not land,
1149        // so the default is modifiers only rather than a placeholder palette.
1150        let style = PieceStyle::default();
1151        for painted in [style.content, style.danger, style.page, style.action] {
1152            assert_eq!(painted.fg, None);
1153            assert_eq!(painted.bg, None);
1154        }
1155    }
1156}