Skip to main content

makeover_tui/
widget.rs

1//! The pieces every terminal app draws, drawn once.
2//!
3//! Arrived in 0.16.0 out of `quasi-tui`, which had written all of them and was
4//! the second consumer to do so. A meter, a badge, a control, a figure and a
5//! form field are what a screen is made of below the level [`table`](crate::table)
6//! works at, and every one of them had been hand-rolled at least twice in this
7//! tree before it was lifted.
8//!
9//! # What these take, and what they leave alone
10//!
11//! Each takes a `makeover-layout` description, a [`WidgetStyle`], and whatever
12//! the *host* knows that a description never carries. That last part is the
13//! shape worth copying: [`field`] takes what is currently typed in the box as a
14//! separate argument, because [`Field`] deliberately does not carry a value and
15//! is not going to. `makeover-immediate` reached the same seam from the other
16//! side with its `Filling`, and [`Held`] is that seam here.
17//!
18//! Focus is the other one. Nothing in a description says which control the user
19//! is on, so every drawing here takes `focused` as an argument and the caller
20//! is what counts. What focus *looks like* is this crate's answer and not the
21//! caller's, which is the point of it being here: see
22//! [`WidgetStyle::focused`].
23//!
24//! # What they do not do
25//!
26//! No layout. Each answers rows for a width, or draws into the rect it is
27//! given, top-aligned, and never below it. Nothing here measures twice and
28//! nothing here places anything relative to anything else, because the moment
29//! it did it would be a layout engine with one consumer's flow baked into it.
30
31use makeover_layout::{Act, Field, FieldKind, Figure, Heading, Meter, Token, Tone};
32use ratatui::buffer::Buffer;
33use ratatui::layout::Rect;
34use ratatui::style::{Modifier, Style};
35use ratatui::text::{Line, Span};
36
37use crate::text;
38
39/// The colours and marks the drawings below use.
40///
41/// [`TableStyle`](crate::table::TableStyle)'s shape, for its reasons: an
42/// ungated struct of styles with a [`Default`], plus a
43/// [`from_theme`](Self::from_theme) that is what a consumer holding a loaded
44/// theme should reach for first. A consumer painting bevels and nothing else
45/// should not have to supply text tones it never uses, and gating the whole
46/// module on `theme` would make these unreachable to anyone hand-picking
47/// colours.
48///
49/// The default is the one that survives a terminal with no colour at all:
50/// modifiers only, no foreground anywhere. That is not a placeholder. A
51/// two-colour terminal is the case where a `Style` carrying a foreground is a
52/// foreground that will not land, and bold-and-reversed is what is left.
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub struct WidgetStyle {
55    /// Ordinary content, and what [`Tone::Neutral`] reads as.
56    pub content: Style,
57    /// Content one step back: a field's label, a quoted run.
58    pub secondary: Style,
59    /// Content two steps back: a caption, a hint, a meter's reading.
60    pub muted: Style,
61    /// Something worth knowing and nothing to do about it.
62    pub info: Style,
63    /// Something finished and it worked.
64    pub success: Style,
65    /// Something the user should look at.
66    pub warning: Style,
67    /// Something broken, or about to be destroyed.
68    pub danger: Style,
69    /// A page title.
70    pub page: Style,
71    /// A section title.
72    pub section: Style,
73    /// A subsection title.
74    pub subsection: Style,
75    /// Text that goes somewhere, and a control's label.
76    pub action: Style,
77    /// A control filled with the action colour, for the one on a screen that is
78    /// the thing to press. A form's submit is the case that has it.
79    pub filled: Style,
80    /// A surface set back from the one it sits on, by colour and nothing else.
81    /// What a code run takes, since every cell is monospace and the thing a
82    /// webview says with a typeface cannot be said that way here.
83    pub sunken: Style,
84    /// What "you are on this one" adds to whatever it lands on.
85    ///
86    /// Reversed video by default, which is the affordance a cell has left once
87    /// colour is spent on tone and bold on weight. A webview says it with an
88    /// outline; a terminal has no outline that is not four more cells.
89    pub focus: Modifier,
90    /// How many cells [`meter`] spends on its bar.
91    pub meter_cells: u16,
92    /// The filled part of a bar.
93    pub meter_full: char,
94    /// The empty part of a bar.
95    pub meter_empty: char,
96    /// What marks a compulsory field, appended to its label.
97    ///
98    /// A knob for `makeover-immediate`'s reason: it is the one piece of *copy*
99    /// here, and copy is not a renderer's call.
100    pub required_marker: &'static str,
101}
102
103impl Default for WidgetStyle {
104    /// Modifiers only, no foreground: what survives a terminal with two
105    /// colours.
106    fn default() -> Self {
107        Self {
108            content: Style::new(),
109            secondary: Style::new(),
110            muted: Style::new().add_modifier(Modifier::DIM),
111            info: Style::new(),
112            success: Style::new(),
113            warning: Style::new(),
114            danger: Style::new().add_modifier(Modifier::BOLD),
115            page: Style::new().add_modifier(Modifier::BOLD),
116            section: Style::new().add_modifier(Modifier::BOLD),
117            subsection: Style::new(),
118            action: Style::new().add_modifier(Modifier::UNDERLINED),
119            filled: Style::new().add_modifier(Modifier::REVERSED),
120            sunken: Style::new().add_modifier(Modifier::DIM),
121            focus: Modifier::REVERSED,
122            meter_cells: 10,
123            meter_full: '#',
124            meter_empty: '-',
125            required_marker: "*",
126        }
127    }
128}
129
130impl WidgetStyle {
131    /// The house widgets, from a loaded theme.
132    ///
133    /// The lift this module exists for. `quasi-tui` carried every line of this
134    /// as private methods on its own renderer; a second terminal app wanting a
135    /// toned control had no way to reach them and would have picked its own
136    /// colours for the same five tones.
137    #[cfg(feature = "theme")]
138    #[must_use]
139    pub fn from_theme(theme: &crate::Theme) -> Self {
140        Self {
141            content: Style::new().fg(theme.content_primary),
142            secondary: Style::new().fg(theme.content_secondary),
143            muted: Style::new().fg(theme.content_muted),
144            info: Style::new().fg(theme.status_info),
145            success: Style::new().fg(theme.status_success),
146            warning: Style::new().fg(theme.status_warning),
147            danger: Style::new().fg(theme.status_danger),
148            // Three depths and two of them are bold, which is the whole of what
149            // a terminal has: there is no type scale in a grid of one cell
150            // size. A page title takes bold and the accent, a section bold, a
151            // subsection the secondary colour. That is the emphasis order a
152            // webview's type scale says with size, said with the two axes a
153            // cell has.
154            page: Style::new()
155                .fg(theme.action_primary)
156                .add_modifier(Modifier::BOLD),
157            section: Style::new()
158                .fg(theme.content_primary)
159                .add_modifier(Modifier::BOLD),
160            subsection: Style::new().fg(theme.content_secondary),
161            action: Style::new().fg(theme.action_primary),
162            filled: Style::new().fg(theme.selection_on).bg(theme.action_primary),
163            sunken: Style::new().bg(theme.surface_sunken),
164            focus: Modifier::REVERSED,
165            meter_cells: 10,
166            meter_full: '#',
167            meter_empty: '-',
168            required_marker: "*",
169        }
170    }
171
172    /// The style a tone reads as.
173    ///
174    /// [`Tone`] is closed and stays closed, so this is total and needs no
175    /// fallback arm.
176    #[must_use]
177    pub const fn tone(&self, tone: Tone) -> Style {
178        match tone {
179            Tone::Neutral => self.content,
180            Tone::Info => self.info,
181            Tone::Success => self.success,
182            Tone::Warning => self.warning,
183            Tone::Danger => self.danger,
184        }
185    }
186
187    /// The style a heading reads as.
188    #[must_use]
189    pub const fn heading(&self, level: Heading) -> Style {
190        match level {
191            Heading::Page => self.page,
192            Heading::Section => self.section,
193            Heading::Subsection => self.subsection,
194        }
195    }
196
197    /// `style`, plus the mark that says the user is on this one.
198    ///
199    /// Takes the flag rather than being called behind an `if`, because every
200    /// caller has a bool in hand and the branch is the part that gets forgotten.
201    #[must_use]
202    pub fn focused(&self, focused: bool, style: Style) -> Style {
203        if focused {
204            style.add_modifier(self.focus)
205        } else {
206            style
207        }
208    }
209}
210
211/// What a field currently holds, which a description never carries.
212///
213/// The terminal counterpart of `makeover_immediate::Filling`, and the same seam:
214/// there the widget writes through a `&mut` as the value is edited, and here the
215/// caller keeps an edit buffer and lends it out for the draw. Neither is
216/// something [`Field`] could carry without becoming a form model.
217///
218/// An enum rather than a bag of options, for `Filling`'s reason: a checkbox
219/// holding a string is unsayable here, where a struct would let it be said and
220/// then have to cope.
221#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
222pub enum Held<'a> {
223    /// Nothing typed and nothing chosen. The control draws empty.
224    #[default]
225    Absent,
226    /// What is in the box, or the `value` of the chosen [`Choice`].
227    ///
228    /// [`Choice`]: makeover_layout::Choice
229    Text(&'a str),
230    /// A checkbox, on or off.
231    On(bool),
232}
233
234impl<'a> Held<'a> {
235    /// What is typed, as a string. A checkbox has no text and answers empty.
236    #[must_use]
237    pub const fn text(self) -> &'a str {
238        match self {
239            Self::Text(text) => text,
240            Self::Absent | Self::On(_) => "",
241        }
242    }
243
244    /// Whether a checkbox is ticked.
245    #[must_use]
246    pub const fn on(self) -> bool {
247        matches!(self, Self::On(true))
248    }
249}
250
251/// A proportion as one line: the bar, then the reading beside it.
252///
253/// The reading is built here from the two numbers and the noun rather than
254/// taken assembled, which is what [`Meter::label`] carrying the noun alone is
255/// for: a terminal at one line and a tooltip want different sentence orders.
256#[must_use]
257pub fn meter(style: &WidgetStyle, meter: &Meter<'_>) -> Line<'static> {
258    let cells = u32::from(style.meter_cells);
259    let filled = meter
260        .done
261        .checked_mul(cells)
262        .and_then(|reached| reached.checked_div(meter.total))
263        .unwrap_or(0)
264        .min(cells);
265    let bar = format!(
266        "{}{}",
267        style.meter_full.to_string().repeat(filled as usize),
268        style
269            .meter_empty
270            .to_string()
271            .repeat((cells - filled) as usize)
272    );
273    let reading = match meter.label {
274        Some(label) => format!(" {}/{} {label}", meter.done, meter.total),
275        None => format!(" {}/{}", meter.done, meter.total),
276    };
277    Line::from(vec![
278        Span::styled(bar, style.tone(meter.tone)),
279        Span::styled(reading, style.muted),
280    ])
281}
282
283/// A badge or a chip as one span.
284///
285/// Round for a badge, square for a chip. A chip answers a press and a badge does
286/// not, and the bracket is the only affordance a cell has left once colour is
287/// spent on the tone.
288///
289/// `latched` is a chip that is switched on, and it reads as reversed. So does
290/// focus, which is a collision a terminal cannot avoid: latched is "this filter
291/// is on" and focused is "you are here", and there is one spare axis for two
292/// facts. Said here rather than resolved by inventing a third look nobody would
293/// read.
294///
295/// A chip's removable half is not drawn. The `x` a webview hangs on a chip is a
296/// second control inside one span, and a terminal reaches a control by focusing
297/// it; two targets in one cell run is a question for whoever owns the
298/// interaction, not for a drawing.
299#[must_use]
300pub fn token(
301    style: &WidgetStyle,
302    label: &str,
303    kind: Token,
304    tone: Tone,
305    latched: bool,
306    focused: bool,
307) -> Span<'static> {
308    let painted = style.tone(tone);
309    let painted = if latched {
310        painted.add_modifier(style.focus)
311    } else {
312        style.focused(focused, painted)
313    };
314    match kind {
315        Token::Badge => Span::styled(format!("({label})"), painted),
316        Token::Chip { .. } => Span::styled(format!("[{label}]"), painted),
317    }
318}
319
320/// A control as one line.
321///
322/// `< Label > (key)`, and the key only where the description named one. That
323/// member is the one place `makeover-layout` anticipated a terminal before there
324/// was one, and this is the renderer that reads it.
325///
326/// A disabled control is drawn muted and is not marked focused, whatever the
327/// caller passed: it is present, visible and not answering, so a focus mark on
328/// it would be an affordance that lies. Whether it is reachable at all is the
329/// caller's count to keep — ask [`Act::disabled`].
330#[must_use]
331pub fn act(style: &WidgetStyle, act: &Act<'_>, focused: bool) -> Line<'static> {
332    let painted = if act.disabled() {
333        style.muted
334    } else {
335        style.focused(focused, style.tone(act.tone))
336    };
337    let label = match act.key {
338        Some(key) => format!("< {} > ({key})", act.label),
339        None => format!("< {} >", act.label),
340    };
341    Line::from(Span::styled(label, painted))
342}
343
344/// A control filled with the action colour, for the one press a screen is about.
345///
346/// `[ Label ]` rather than `< Label >`, which is the weight difference a webview
347/// carries as a primary-versus-secondary button. A form's submit is the case
348/// this exists for.
349#[must_use]
350pub fn filled_act(style: &WidgetStyle, label: &str, focused: bool) -> Line<'static> {
351    Line::from(Span::styled(
352        format!("[ {label} ]"),
353        style.focused(focused, style.filled),
354    ))
355}
356
357/// The rows [`figure`] wants at `width`.
358#[must_use]
359pub fn figure_height(figure: &Figure<'_>, width: u16) -> u16 {
360    text::height(figure.value, width) + text::height(figure.caption, width)
361}
362
363/// A figure: the number, then what it counts under it.
364///
365/// The tone lands on the value and its change rather than on the caption, which
366/// is what [`Figure::tone`] means: the figure is an ordinary fact and it is the
367/// movement that reads as good or bad.
368pub fn figure(style: &WidgetStyle, figure: &Figure<'_>, area: Rect, buf: &mut Buffer) -> u16 {
369    let value = match figure.change {
370        Some(change) => format!("{} {change}", figure.value),
371        None => figure.value.to_owned(),
372    };
373    let used = text::draw(
374        &value,
375        style.tone(figure.tone).add_modifier(Modifier::BOLD),
376        area,
377        buf,
378    );
379    used + text::draw(figure.caption, style.muted, below(area, used), buf)
380}
381
382/// The rows [`field`] wants at `width`.
383///
384/// A label row, the control's rows, and a row for whatever went wrong. A hidden
385/// field is nothing at all, which is the one field kind a terminal and a webview
386/// agree on completely.
387#[must_use]
388pub fn field_height(style: &WidgetStyle, field: &Field<'_>, width: u16) -> u16 {
389    if !field.kind.visible() {
390        return 0;
391    }
392    let label = text::height(&label_of(style, field), width);
393    let body = match field.kind {
394        FieldKind::Textarea => 3,
395        kind if kind.offers_options() => u16::try_from(field.options.len()).unwrap_or(u16::MAX),
396        _ => 1,
397    };
398    let note = note_of(field).map_or(0, |note| text::height(note, width));
399    label + body + note
400}
401
402/// A question: its label, the box, and its standing help or what is wrong now.
403///
404/// `held` is what the user has done to it since the screen arrived, which is the
405/// argument a description cannot supply. See [`Held`].
406///
407/// `focused` marks the box rather than the label, because the box is where the
408/// typing lands.
409pub fn field(
410    style: &WidgetStyle,
411    field: &Field<'_>,
412    held: Held<'_>,
413    focused: bool,
414    area: Rect,
415    buf: &mut Buffer,
416) -> u16 {
417    // A hidden field is data travelling with the form. There is nothing to
418    // draw, and whoever submits carries it.
419    if !field.kind.visible() || area.width == 0 || area.height == 0 {
420        return 0;
421    }
422
423    let mut used = text::draw(&label_of(style, field), style.secondary, area, buf);
424
425    let well = style.focused(focused, style.content);
426    let placeholder = field.placeholder.unwrap_or_default();
427
428    used += match field.kind {
429        FieldKind::Checkbox => text::draw(
430            if held.on() { "[x]" } else { "[ ]" },
431            well,
432            below(area, used),
433            buf,
434        ),
435        kind if kind.offers_options() => {
436            let mut rows = 0;
437            for choice in field.options {
438                let chosen = held.text() == choice.value;
439                let mark = if chosen { "(*)" } else { "( )" };
440                rows += text::draw(
441                    &format!("{mark} {}", choice.label),
442                    if chosen { well } else { style.muted },
443                    below(area, used + rows),
444                    buf,
445                );
446            }
447            rows
448        }
449        // A secret's dots come from the caller's buffer and can come from
450        // nowhere else: a password that comes back down the wire is a password
451        // in a page and in a proxy log, so a description carries nothing to dot
452        // out. This is the one control that would be undrawable without `held`.
453        FieldKind::Secret if !held.text().is_empty() => {
454            let dots = "*".repeat(held.text().chars().count());
455            text::draw(&dots, well, below(area, used), buf).max(1)
456        }
457        // A file field has no way back on a terminal any more than it has on an
458        // HTTP host. The name is drawn and picking one belongs to whoever owns
459        // the interaction.
460        _ if held.text().is_empty() => {
461            empty_well(style, placeholder, well, focused, below(area, used), buf)
462        }
463        _ => text::draw(held.text(), well, below(area, used), buf),
464    };
465
466    // The error wins over the hint, the same order a webview uses: a hint is
467    // what to type and an error is what went wrong, and once something has gone
468    // wrong that is the sentence worth the row.
469    match note_of(field) {
470        Some(note) => {
471            let painted = if field.error.is_some() {
472                style.danger
473            } else {
474                style.muted
475            };
476            used + text::draw(note, painted, below(area, used), buf)
477        }
478        None => used,
479    }
480}
481
482/// The label, marked where the field is compulsory.
483fn label_of(style: &WidgetStyle, field: &Field<'_>) -> String {
484    if field.required {
485        format!("{} {}", field.label, style.required_marker)
486    } else {
487        field.label.to_owned()
488    }
489}
490
491/// What goes under the box: what is wrong now, or the standing help.
492fn note_of<'a>(field: &Field<'a>) -> Option<&'a str> {
493    field.error.or(field.hint)
494}
495
496/// A box with nothing in it: the ghost text, and the caret when it has focus.
497///
498/// The caret is not decoration. An empty field under a style is an empty field,
499/// so a focused one with no placeholder drew literally nothing and there was no
500/// way to tell the box was where the typing would go. A browser has a blinking
501/// bar for this and gets it without asking; a terminal has one cell of reversed
502/// video, put on the first column, which is where the first character lands.
503fn empty_well(
504    style: &WidgetStyle,
505    placeholder: &str,
506    well: Style,
507    focused: bool,
508    area: Rect,
509    buf: &mut Buffer,
510) -> u16 {
511    let used = text::draw(placeholder, style.muted, area, buf).max(1);
512    if focused
513        && area.height > 0
514        && area.width > 0
515        && let Some(cell) = buf.cell_mut((area.x, area.y))
516    {
517        cell.set_style(well);
518    }
519    used
520}
521
522/// What is left of `area` after `used` rows from the top.
523fn below(area: Rect, used: u16) -> Rect {
524    let used = used.min(area.height);
525    Rect {
526        x: area.x,
527        y: area.y + used,
528        width: area.width,
529        height: area.height - used,
530    }
531}
532
533#[cfg(test)]
534mod tests {
535    use super::*;
536    use makeover_layout::{Choice, State};
537
538    /// The style the drawings are read against: one distinguishable modifier
539    /// per role, so a test can say which style landed without a colour.
540    fn style() -> WidgetStyle {
541        WidgetStyle {
542            content: Style::new().add_modifier(Modifier::BOLD),
543            muted: Style::new().add_modifier(Modifier::DIM),
544            danger: Style::new().add_modifier(Modifier::CROSSED_OUT),
545            ..WidgetStyle::default()
546        }
547    }
548
549    fn buffer(width: u16, height: u16) -> Buffer {
550        Buffer::empty(Rect::new(0, 0, width, height))
551    }
552
553    /// Everything in the buffer, one string per row.
554    fn rows(buf: &Buffer) -> Vec<String> {
555        (0..buf.area.height)
556            .map(|y| {
557                (0..buf.area.width)
558                    .map(|x| {
559                        buf.cell((x, y))
560                            .map_or(' ', |c| c.symbol().chars().next().unwrap_or(' '))
561                    })
562                    .collect::<String>()
563                    .trim_end()
564                    .to_owned()
565            })
566            .collect()
567    }
568
569    #[test]
570    fn a_bar_fills_in_proportion_and_reads_out_the_two_numbers() {
571        let style = style();
572        let line = meter(&style, &Meter::new(3, 10).label("subtasks"));
573        let drawn: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
574        assert_eq!(drawn, "###------- 3/10 subtasks");
575        // The noun is optional and the ratio is not, because a bar with no
576        // reading is a bar you cannot check.
577        let bare = meter(&style, &Meter::new(3, 10));
578        let drawn: String = bare.spans.iter().map(|s| s.content.as_ref()).collect();
579        assert_eq!(drawn, "###------- 3/10");
580    }
581
582    #[test]
583    fn an_empty_set_is_an_empty_bar_rather_than_a_divide_by_zero() {
584        // `Meter::total` of zero means there is no set, and the checked
585        // division is what keeps that from being a panic in a draw.
586        let line = meter(&style(), &Meter::new(0, 0));
587        let drawn: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
588        assert_eq!(drawn, "---------- 0/0");
589    }
590
591    #[test]
592    fn an_over_run_fills_the_bar_and_still_reports_the_overflow() {
593        // The clamp is for drawing only. The reading is what keeps the fact
594        // `Meter::percent` destroys.
595        let line = meter(&style(), &Meter::new(14, 10));
596        let drawn: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
597        assert_eq!(drawn, "########## 14/10");
598    }
599
600    #[test]
601    fn a_badge_is_round_and_a_chip_is_square() {
602        // The one affordance a cell has left once colour is spent on the tone,
603        // and the whole of how a terminal says "this one answers a press".
604        let style = style();
605        let badge = token(&style, "draft", Token::Badge, Tone::Neutral, false, false);
606        assert_eq!(badge.content.as_ref(), "(draft)");
607        let chip = token(
608            &style,
609            "rust",
610            Token::Chip { removable: false },
611            Tone::Neutral,
612            false,
613            false,
614        );
615        assert_eq!(chip.content.as_ref(), "[rust]");
616    }
617
618    #[test]
619    fn a_latched_chip_reads_the_same_as_a_focused_one() {
620        // The collision a terminal cannot avoid, asserted rather than left to
621        // be rediscovered: latched is "this filter is on" and focused is "you
622        // are here", and there is one spare axis for two facts.
623        let style = style();
624        let kind = Token::Chip { removable: false };
625        let latched = token(&style, "rust", kind, Tone::Neutral, true, false);
626        let focused = token(&style, "rust", kind, Tone::Neutral, false, true);
627        assert_eq!(latched.style, focused.style);
628        assert!(latched.style.add_modifier.contains(Modifier::REVERSED));
629    }
630
631    #[test]
632    fn a_control_draws_its_key_only_where_one_was_named() {
633        let style = style();
634        let line = act(&style, &Act::new("Delete"), false);
635        assert_eq!(line.spans[0].content.as_ref(), "< Delete >");
636        let line = act(&style, &Act::new("Quit").key("q"), false);
637        assert_eq!(line.spans[0].content.as_ref(), "< Quit > (q)");
638    }
639
640    #[test]
641    fn a_disabled_control_is_never_marked_focused() {
642        // Present, visible, and not answering. A focus mark on it would be an
643        // affordance that lies, so the flag is overridden rather than trusted.
644        let style = style();
645        let disabled = Act::new("Save").state(State::Disabled);
646        let line = act(&style, &disabled, true);
647        assert!(
648            !line.spans[0]
649                .style
650                .add_modifier
651                .contains(Modifier::REVERSED)
652        );
653        assert_eq!(line.spans[0].style, style.muted);
654        // The same call on a control the description says nothing about: the
655        // mark is this renderer's own focus flag and always was, which is why
656        // only `Disabled` can override it.
657        let unstated = Act::new("Save");
658        let line = act(&style, &unstated, true);
659        assert!(
660            line.spans[0]
661                .style
662                .add_modifier
663                .contains(Modifier::REVERSED)
664        );
665    }
666
667    #[test]
668    fn a_danger_control_keeps_its_tone_under_focus() {
669        // Focus adds a modifier rather than repainting, so the fact that this
670        // is the button that destroys something survives being landed on.
671        let style = style();
672        let line = act(&style, &Act::new("Delete").tone(Tone::Danger), true);
673        assert_eq!(
674            line.spans[0].style.add_modifier,
675            style.danger.add_modifier | Modifier::REVERSED
676        );
677    }
678
679    #[test]
680    fn a_figure_puts_the_number_over_what_it_counts() {
681        let style = style();
682        let figure_ = Figure::new("42", "open tasks");
683        let mut buf = buffer(20, 4);
684        let used = figure(&style, &figure_, buf.area, &mut buf);
685        assert_eq!(used, 2);
686        assert_eq!(rows(&buf)[..2], ["42".to_owned(), "open tasks".to_owned()]);
687        assert_eq!(figure_height(&figure_, 20), 2);
688    }
689
690    #[test]
691    fn a_figures_change_rides_on_the_value_row() {
692        // The delta is the toned part and the value is an ordinary fact, so the
693        // two share a row rather than the caption growing a second sentence.
694        let style = style();
695        let figure_ = Figure::new("42", "open tasks")
696            .change("+3")
697            .tone(Tone::Success);
698        let mut buf = buffer(20, 4);
699        figure(&style, &figure_, buf.area, &mut buf);
700        assert_eq!(rows(&buf)[0], "42 +3");
701    }
702
703    #[test]
704    fn a_compulsory_field_says_so_in_its_label() {
705        let style = style();
706        let mut field_ = Field::new(FieldKind::Text, "email", "Email");
707        field_.required = true;
708        let mut buf = buffer(20, 4);
709        field(&style, &field_, Held::Absent, false, buf.area, &mut buf);
710        assert_eq!(rows(&buf)[0], "Email *");
711    }
712
713    #[test]
714    fn a_hidden_field_costs_no_rows_at_all() {
715        // The one field kind a terminal and a webview agree on completely.
716        let style = style();
717        let field_ = Field::new(FieldKind::Hidden, "csrf", "Token");
718        let mut buf = buffer(20, 4);
719        assert_eq!(
720            field(
721                &style,
722                &field_,
723                Held::Text("abc"),
724                false,
725                buf.area,
726                &mut buf
727            ),
728            0
729        );
730        assert_eq!(field_height(&style, &field_, 20), 0);
731        assert_eq!(rows(&buf)[0], "");
732    }
733
734    #[test]
735    fn a_secret_is_dotted_from_the_callers_buffer_and_never_from_the_description() {
736        // The one control that would be undrawable without `held`: a password
737        // that came back down the wire is a password in a page and in a log.
738        let style = style();
739        let field_ = Field::new(FieldKind::Secret, "password", "Password");
740        let mut buf = buffer(20, 4);
741        field(
742            &style,
743            &field_,
744            Held::Text("hunter2"),
745            false,
746            buf.area,
747            &mut buf,
748        );
749        assert_eq!(rows(&buf)[1], "*******");
750    }
751
752    #[test]
753    fn an_error_takes_the_row_the_hint_would_have_had() {
754        // Once something has gone wrong that is the sentence worth the row,
755        // which is the order a webview uses too.
756        let style = style();
757        let mut field_ = Field::new(FieldKind::Text, "email", "Email");
758        field_.hint = Some("work address");
759        field_.error = Some("not an address");
760        let mut buf = buffer(20, 5);
761        field(
762            &style,
763            &field_,
764            Held::Text("nope"),
765            false,
766            buf.area,
767            &mut buf,
768        );
769        assert_eq!(rows(&buf)[2], "not an address");
770        assert_eq!(field_height(&style, &field_, 20), 3);
771    }
772
773    #[test]
774    fn a_focused_empty_box_shows_where_the_typing_will_land() {
775        // An empty field under a style is an empty field. Without the caret a
776        // focused box with no placeholder drew literally nothing.
777        let style = style();
778        let field_ = Field::new(FieldKind::Text, "email", "Email");
779        let mut buf = buffer(20, 4);
780        field(&style, &field_, Held::Absent, true, buf.area, &mut buf);
781        let caret = buf.cell((0, 1)).expect("the well's first cell").style();
782        assert!(caret.add_modifier.contains(Modifier::REVERSED));
783    }
784
785    #[test]
786    fn a_choice_field_marks_the_chosen_option_and_costs_a_row_each() {
787        let style = style();
788        let mut field_ = Field::new(FieldKind::Radio, "size", "Size");
789        let options = [Choice::plain("small"), Choice::plain("large")];
790        field_.options = &options;
791        let mut buf = buffer(20, 5);
792        field(
793            &style,
794            &field_,
795            Held::Text("large"),
796            false,
797            buf.area,
798            &mut buf,
799        );
800        assert_eq!(rows(&buf)[1], "( ) small");
801        assert_eq!(rows(&buf)[2], "(*) large");
802        assert_eq!(field_height(&style, &field_, 20), 3);
803    }
804
805    #[test]
806    fn a_checkbox_reads_a_bool_rather_than_a_submitted_string() {
807        // `Held::On` exists so a host's own submission convention -- quasi
808        // sends "value" -- stays the host's and never reaches a drawing.
809        let style = style();
810        let field_ = Field::new(FieldKind::Checkbox, "agree", "Agree");
811        let mut buf = buffer(20, 4);
812        field(&style, &field_, Held::On(true), false, buf.area, &mut buf);
813        assert_eq!(rows(&buf)[1], "[x]");
814        let mut buf = buffer(20, 4);
815        field(&style, &field_, Held::On(false), false, buf.area, &mut buf);
816        assert_eq!(rows(&buf)[1], "[ ]");
817    }
818
819    #[test]
820    fn a_tone_and_a_heading_map_without_a_fallback_arm() {
821        // Both source enums are closed, which is what lets these be total. A
822        // renderer that had to guess would be picking its own colours again.
823        let style = style();
824        assert_eq!(style.tone(Tone::Neutral), style.content);
825        assert_eq!(style.tone(Tone::Danger), style.danger);
826        assert_eq!(style.heading(Heading::Page), style.page);
827        assert_eq!(style.heading(Heading::Subsection), style.subsection);
828    }
829
830    #[test]
831    fn the_default_style_carries_no_colour_at_all() {
832        // A two-colour terminal is the case where a foreground will not land,
833        // so the default is modifiers only rather than a placeholder palette.
834        let style = WidgetStyle::default();
835        for painted in [style.content, style.danger, style.page, style.action] {
836            assert_eq!(painted.fg, None);
837            assert_eq!(painted.bg, None);
838        }
839    }
840}