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 FieldKind::Textarea => 3,
414 kind if kind.offers_options() => u16::try_from(field.options.len()).unwrap_or(u16::MAX),
415 _ => 1,
416 };
417 let note = note_of(field).map_or(0, |note| text::height(note, width));
418 label + body + note
419}
420
421/// A question: its label, the box, and its standing help or what is wrong now.
422///
423/// `held` is what the user has done to it since the screen arrived, which is the
424/// argument a description cannot supply. See [`Held`].
425///
426/// `focused` marks the box rather than the label, because the box is where the
427/// typing lands.
428pub fn field(
429 style: &PieceStyle,
430 field: &Field<'_>,
431 held: Held<'_>,
432 focused: bool,
433 area: Rect,
434 buf: &mut Buffer,
435) -> u16 {
436 // A hidden field is data travelling with the form. There is nothing to
437 // draw, and whoever submits carries it.
438 if !field.kind.visible() || area.width == 0 || area.height == 0 {
439 return 0;
440 }
441
442 let mut used = text::draw(&label_of(style, field), style.secondary, area, buf);
443
444 let well = style.focused(focused, style.content);
445 let placeholder = field.placeholder.unwrap_or_default();
446
447 used += match field.kind {
448 FieldKind::Checkbox => text::draw(
449 if held.on() { "[x]" } else { "[ ]" },
450 well,
451 below(area, used),
452 buf,
453 ),
454 // A range's two ends are what the question means, so they are drawn
455 // rather than left to a hint. A terminal has the bar already: this is
456 // `meter`'s cells with the extent read out at either side of them.
457 //
458 // An unbounded range has no extent to draw and falls through to the
459 // text path, which is `makeover-immediate`'s answer as well and for the
460 // same reason: bounds this crate invented are bounds the user would
461 // then drag against.
462 FieldKind::Range if field.bounded() => {
463 let line = range_line(style, field, held.text(), well);
464 text::draw_line(&line, below(area, used), buf)
465 }
466 kind if kind.offers_options() => {
467 let mut rows = 0;
468 for choice in field.options {
469 let chosen = held.text() == choice.value;
470 // An option that cannot be picked yet reads as inert, which is
471 // the one place muted is the truth rather than the lie below:
472 // it will not answer, and the reason it will not is on the row
473 // beside it rather than nowhere.
474 let (mark, painted, suffix) = match choice.unavailable {
475 Some(reason) => ("( )", style.muted, format!(": {reason}")),
476 None if chosen => ("(*)", well, String::new()),
477 // An option that is not chosen is still an option: pressing
478 // it chooses it. So it takes the secondary content intent
479 // and not the muted one, which is what disabled looks like
480 // (`State::Disabled` resolves to it). Muted here read as a
481 // list of five where four were greyed out.
482 None => ("( )", style.secondary, String::new()),
483 };
484 rows += text::draw(
485 &format!("{mark} {}{suffix}", choice.label),
486 painted,
487 below(area, used + rows),
488 buf,
489 );
490 }
491 rows
492 }
493 // A secret's dots come from the caller's buffer and can come from
494 // nowhere else: a password that comes back down the wire is a password
495 // in a page and in a proxy log, so a description carries nothing to dot
496 // out. This is the one control that would be undrawable without `held`.
497 FieldKind::Secret if !held.text().is_empty() => {
498 let dots = "*".repeat(held.text().chars().count());
499 text::draw(&dots, well, below(area, used), buf).max(1)
500 }
501 // A file field has no way back on a terminal any more than it has on an
502 // HTTP host. The name is drawn and picking one belongs to whoever owns
503 // the interaction.
504 _ if held.text().is_empty() => {
505 empty_well(style, placeholder, well, focused, below(area, used), buf)
506 }
507 _ => text::draw(held.text(), well, below(area, used), buf),
508 };
509
510 // The error wins over the hint, the same order a webview uses: a hint is
511 // what to type and an error is what went wrong, and once something has gone
512 // wrong that is the sentence worth the row.
513 match note_of(field) {
514 Some(note) => {
515 let painted = if field.error.is_some() {
516 style.danger
517 } else {
518 style.muted
519 };
520 used + text::draw(note, painted, below(area, used), buf)
521 }
522 None => used,
523 }
524}
525
526/// A bounded number as one line: the low end, the bar, the high end, then what
527/// it currently reads.
528///
529/// The two ends are drawn because they are the question. A threshold of 0.72
530/// says nothing without them, which is the whole argument for
531/// [`FieldKind::Range`] being a kind rather than a number with bounds, and a
532/// terminal is where it would be easiest to quietly drop them and show a figure.
533///
534/// The bar is [`meter`]'s cells, so a range and a proportion read as the same
535/// object in the same app. What differs is the reading beside it: a meter counts
536/// something and a range holds a value.
537///
538/// A value the host cannot read as a number empties the bar and is still shown
539/// as itself. That is [`empty_well`]'s position on an unreadable value: the app
540/// put it there, and a terminal that silently rounded it to a bound would be
541/// reporting a value nobody set.
542fn range_line(style: &PieceStyle, field: &Field<'_>, value: &str, well: Style) -> Line<'static> {
543 let cells = usize::from(style.meter_cells);
544 let ends = field
545 .min
546 .zip(field.max)
547 .and_then(|(min, max)| Some((min.parse::<f64>().ok()?, max.parse::<f64>().ok()?)));
548 let filled = match (ends, value.parse::<f64>()) {
549 (Some((min, max)), Ok(number)) if max > min => {
550 #[expect(
551 clippy::cast_possible_truncation,
552 clippy::cast_sign_loss,
553 reason = "the proportion is clamped to 0..=1 before it is scaled by a cell count \
554 that came from a u16"
555 )]
556 let reached = (((number - min) / (max - min)).clamp(0.0, 1.0) * cells as f64) as usize;
557 reached
558 }
559 _ => 0,
560 };
561 let bar = format!(
562 "{}{}",
563 style.meter_full.to_string().repeat(filled),
564 style.meter_empty.to_string().repeat(cells - filled)
565 );
566 Line::from(vec![
567 Span::styled(format!("{} ", field.min.unwrap_or_default()), style.muted),
568 Span::styled(bar, well),
569 Span::styled(format!(" {}", field.max.unwrap_or_default()), style.muted),
570 Span::styled(format!(" {value}"), well),
571 ])
572}
573
574/// The label, marked where the field is compulsory.
575fn label_of(style: &PieceStyle, field: &Field<'_>) -> String {
576 if field.required {
577 format!("{} {}", field.label, style.required_marker)
578 } else {
579 field.label.to_owned()
580 }
581}
582
583/// What goes under the box: what is wrong now, or the standing help.
584fn note_of<'a>(field: &Field<'a>) -> Option<&'a str> {
585 field.error.or(field.hint)
586}
587
588/// A box with nothing in it: the ghost text, and the caret when it has focus.
589///
590/// The caret is not decoration. An empty field under a style is an empty field,
591/// so a focused one with no placeholder drew literally nothing and there was no
592/// way to tell the box was where the typing would go. A browser has a blinking
593/// bar for this and gets it without asking; a terminal has one cell of reversed
594/// video, put on the first column, which is where the first character lands.
595fn empty_well(
596 style: &PieceStyle,
597 placeholder: &str,
598 well: Style,
599 focused: bool,
600 area: Rect,
601 buf: &mut Buffer,
602) -> u16 {
603 let used = text::draw(placeholder, style.muted, area, buf).max(1);
604 if focused
605 && area.height > 0
606 && area.width > 0
607 && let Some(cell) = buf.cell_mut((area.x, area.y))
608 {
609 cell.set_style(well);
610 }
611 used
612}
613
614/// What is left of `area` after `used` rows from the top.
615fn below(area: Rect, used: u16) -> Rect {
616 let used = used.min(area.height);
617 Rect {
618 x: area.x,
619 y: area.y + used,
620 width: area.width,
621 height: area.height - used,
622 }
623}
624
625#[cfg(test)]
626mod tests {
627 use super::*;
628 use makeover_layout::{Choice, State};
629
630 /// The style the drawings are read against: one distinguishable modifier
631 /// per role, so a test can say which style landed without a colour.
632 fn style() -> PieceStyle {
633 PieceStyle {
634 content: Style::new().add_modifier(Modifier::BOLD),
635 secondary: Style::new().add_modifier(Modifier::ITALIC),
636 muted: Style::new().add_modifier(Modifier::DIM),
637 danger: Style::new().add_modifier(Modifier::CROSSED_OUT),
638 ..PieceStyle::default()
639 }
640 }
641
642 fn buffer(width: u16, height: u16) -> Buffer {
643 Buffer::empty(Rect::new(0, 0, width, height))
644 }
645
646 /// Everything in the buffer, one string per row.
647 fn rows(buf: &Buffer) -> Vec<String> {
648 (0..buf.area.height)
649 .map(|y| {
650 (0..buf.area.width)
651 .map(|x| {
652 buf.cell((x, y))
653 .map_or(' ', |c| c.symbol().chars().next().unwrap_or(' '))
654 })
655 .collect::<String>()
656 .trim_end()
657 .to_owned()
658 })
659 .collect()
660 }
661
662 #[test]
663 fn a_bar_fills_in_proportion_and_reads_out_the_two_numbers() {
664 let style = style();
665 let line = meter(&style, &Meter::new(3, 10).label("subtasks"));
666 let drawn: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
667 assert_eq!(drawn, "###------- 3/10 subtasks");
668 // The noun is optional and the ratio is not, because a bar with no
669 // reading is a bar you cannot check.
670 let bare = meter(&style, &Meter::new(3, 10));
671 let drawn: String = bare.spans.iter().map(|s| s.content.as_ref()).collect();
672 assert_eq!(drawn, "###------- 3/10");
673 }
674
675 #[test]
676 fn an_empty_set_is_an_empty_bar_rather_than_a_divide_by_zero() {
677 // `Meter::total` of zero means there is no set, and the checked
678 // division is what keeps that from being a panic in a draw.
679 let line = meter(&style(), &Meter::new(0, 0));
680 let drawn: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
681 assert_eq!(drawn, "---------- 0/0");
682 }
683
684 #[test]
685 fn an_over_run_fills_the_bar_and_still_reports_the_overflow() {
686 // The clamp is for drawing only. The reading is what keeps the fact
687 // `Meter::percent` destroys.
688 let line = meter(&style(), &Meter::new(14, 10));
689 let drawn: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
690 assert_eq!(drawn, "########## 14/10");
691 }
692
693 #[test]
694 fn a_badge_is_round_and_a_chip_is_square() {
695 // The one affordance a cell has left once colour is spent on the tone,
696 // and the whole of how a terminal says "this one answers a press".
697 let style = style();
698 let badge = token(&style, "draft", Token::Badge, Tone::Neutral, false, false);
699 assert_eq!(badge.content.as_ref(), "(draft)");
700 let chip = token(
701 &style,
702 "rust",
703 Token::Chip { removable: false },
704 Tone::Neutral,
705 false,
706 false,
707 );
708 assert_eq!(chip.content.as_ref(), "[rust]");
709 }
710
711 #[test]
712 fn a_latched_chip_reads_the_same_as_a_focused_one() {
713 // The collision a terminal cannot avoid, asserted rather than left to
714 // be rediscovered: latched is "this filter is on" and focused is "you
715 // are here", and there is one spare axis for two facts.
716 let style = style();
717 let kind = Token::Chip { removable: false };
718 let latched = token(&style, "rust", kind, Tone::Neutral, true, false);
719 let focused = token(&style, "rust", kind, Tone::Neutral, false, true);
720 assert_eq!(latched.style, focused.style);
721 assert!(latched.style.add_modifier.contains(Modifier::REVERSED));
722 }
723
724 #[test]
725 fn a_control_draws_its_key_only_where_one_was_named() {
726 let style = style();
727 let line = act(&style, &Act::new("Delete"), false);
728 assert_eq!(line.spans[0].content.as_ref(), "< Delete >");
729 let line = act(&style, &Act::new("Quit").key("q"), false);
730 assert_eq!(line.spans[0].content.as_ref(), "< Quit > (q)");
731 }
732
733 #[test]
734 fn a_disabled_control_is_never_marked_focused() {
735 // Present, visible, and not answering. A focus mark on it would be an
736 // affordance that lies, so the flag is overridden rather than trusted.
737 let style = style();
738 let disabled = Act::new("Save").state(State::Disabled);
739 let line = act(&style, &disabled, true);
740 assert!(
741 !line.spans[0]
742 .style
743 .add_modifier
744 .contains(Modifier::REVERSED)
745 );
746 assert_eq!(line.spans[0].style, style.muted);
747 // The same call on a control the description says nothing about: the
748 // mark is this renderer's own focus flag and always was, which is why
749 // only `Disabled` can override it.
750 let unstated = Act::new("Save");
751 let line = act(&style, &unstated, true);
752 assert!(
753 line.spans[0]
754 .style
755 .add_modifier
756 .contains(Modifier::REVERSED)
757 );
758 }
759
760 #[test]
761 fn a_danger_control_keeps_its_tone_under_focus() {
762 // Focus adds a modifier rather than repainting, so the fact that this
763 // is the button that destroys something survives being landed on.
764 let style = style();
765 let line = act(&style, &Act::new("Delete").tone(Tone::Danger), true);
766 assert_eq!(
767 line.spans[0].style.add_modifier,
768 style.danger.add_modifier | Modifier::REVERSED
769 );
770 }
771
772 #[test]
773 fn a_figure_puts_the_number_over_what_it_counts() {
774 let style = style();
775 let figure_ = Figure::new("42", "open tasks");
776 let mut buf = buffer(20, 4);
777 let used = figure(&style, &figure_, buf.area, &mut buf);
778 assert_eq!(used, 2);
779 assert_eq!(rows(&buf)[..2], ["42".to_owned(), "open tasks".to_owned()]);
780 assert_eq!(figure_height(&figure_, 20), 2);
781 }
782
783 #[test]
784 fn a_figures_change_rides_on_the_value_row() {
785 // The delta is the toned part and the value is an ordinary fact, so the
786 // two share a row rather than the caption growing a second sentence.
787 let style = style();
788 let figure_ = Figure::new("42", "open tasks")
789 .change("+3")
790 .tone(Tone::Success);
791 let mut buf = buffer(20, 4);
792 figure(&style, &figure_, buf.area, &mut buf);
793 assert_eq!(rows(&buf)[0], "42 +3");
794 }
795
796 #[test]
797 fn a_compulsory_field_says_so_in_its_label() {
798 let style = style();
799 let mut field_ = Field::new(FieldKind::Text, "email", "Email");
800 field_.required = true;
801 let mut buf = buffer(20, 4);
802 field(&style, &field_, Held::Absent, false, buf.area, &mut buf);
803 assert_eq!(rows(&buf)[0], "Email *");
804 }
805
806 #[test]
807 fn a_hidden_field_costs_no_rows_at_all() {
808 // The one field kind a terminal and a webview agree on completely.
809 let style = style();
810 let field_ = Field::new(FieldKind::Hidden, "csrf", "Token");
811 let mut buf = buffer(20, 4);
812 assert_eq!(
813 field(
814 &style,
815 &field_,
816 Held::Text("abc"),
817 false,
818 buf.area,
819 &mut buf
820 ),
821 0
822 );
823 assert_eq!(field_height(&style, &field_, 20), 0);
824 assert_eq!(rows(&buf)[0], "");
825 }
826
827 #[test]
828 fn a_secret_is_dotted_from_the_callers_buffer_and_never_from_the_description() {
829 // The one control that would be undrawable without `held`: a password
830 // that came back down the wire is a password in a page and in a log.
831 let style = style();
832 let field_ = Field::new(FieldKind::Secret, "password", "Password");
833 let mut buf = buffer(20, 4);
834 field(
835 &style,
836 &field_,
837 Held::Text("hunter2"),
838 false,
839 buf.area,
840 &mut buf,
841 );
842 assert_eq!(rows(&buf)[1], "*******");
843 }
844
845 #[test]
846 fn an_error_takes_the_row_the_hint_would_have_had() {
847 // Once something has gone wrong that is the sentence worth the row,
848 // which is the order a webview uses too.
849 let style = style();
850 let mut field_ = Field::new(FieldKind::Text, "email", "Email");
851 field_.hint = Some("work address");
852 field_.error = Some("not an address");
853 let mut buf = buffer(20, 5);
854 field(
855 &style,
856 &field_,
857 Held::Text("nope"),
858 false,
859 buf.area,
860 &mut buf,
861 );
862 assert_eq!(rows(&buf)[2], "not an address");
863 assert_eq!(field_height(&style, &field_, 20), 3);
864 }
865
866 #[test]
867 fn a_focused_empty_box_shows_where_the_typing_will_land() {
868 // An empty field under a style is an empty field. Without the caret a
869 // focused box with no placeholder drew literally nothing.
870 let style = style();
871 let field_ = Field::new(FieldKind::Text, "email", "Email");
872 let mut buf = buffer(20, 4);
873 field(&style, &field_, Held::Absent, true, buf.area, &mut buf);
874 let caret = buf.cell((0, 1)).expect("the well's first cell").style();
875 assert!(caret.add_modifier.contains(Modifier::REVERSED));
876 }
877
878 #[test]
879 fn a_choice_field_marks_the_chosen_option_and_costs_a_row_each() {
880 let style = style();
881 let mut field_ = Field::new(FieldKind::Radio, "size", "Size");
882 let options = [Choice::plain("small"), Choice::plain("large")];
883 field_.options = &options;
884 let mut buf = buffer(20, 5);
885 field(
886 &style,
887 &field_,
888 Held::Text("large"),
889 false,
890 buf.area,
891 &mut buf,
892 );
893 assert_eq!(rows(&buf)[1], "( ) small");
894 assert_eq!(rows(&buf)[2], "(*) large");
895 assert_eq!(field_height(&style, &field_, 20), 3);
896 }
897
898 #[test]
899 fn a_range_draws_its_two_ends_and_where_the_value_sits_between_them() {
900 let style = style();
901 let field_ = Field::range("review", "Review above", "0", "1");
902 let mut buf = buffer(40, 3);
903 field(
904 &style,
905 &field_,
906 Held::Text("0.5"),
907 false,
908 buf.area,
909 &mut buf,
910 );
911 // Ten cells by default, half of them filled, with the extent read out
912 // at either side: 0.5 means nothing without the 0 and the 1.
913 assert_eq!(rows(&buf)[1].trim_end(), "0 #####----- 1 0.5");
914 assert_eq!(field_height(&style, &field_, 40), 2);
915 }
916
917 #[test]
918 fn a_range_holding_something_unreadable_still_shows_it() {
919 // The app put the value there. A terminal that quietly rounded it to a
920 // bound would be reporting a value nobody set, which is `empty_well`'s
921 // position on the same problem.
922 let style = style();
923 let field_ = Field::range("review", "Review above", "0", "1");
924 let mut buf = buffer(40, 3);
925 field(
926 &style,
927 &field_,
928 Held::Text("unset"),
929 false,
930 buf.area,
931 &mut buf,
932 );
933 assert_eq!(rows(&buf)[1].trim_end(), "0 ---------- 1 unset");
934 }
935
936 #[test]
937 fn an_unbounded_range_is_typed_into_rather_than_dragged() {
938 // Bounds this crate invented are bounds the user would then drag
939 // against. The text path takes every answer the bar would.
940 let style = style();
941 let field_ = Field {
942 max: Some("1"),
943 ..Field::new(FieldKind::Range, "review", "Review above")
944 };
945 let mut buf = buffer(40, 3);
946 field(
947 &style,
948 &field_,
949 Held::Text("0.5"),
950 false,
951 buf.area,
952 &mut buf,
953 );
954 assert_eq!(rows(&buf)[1].trim_end(), "0.5");
955 }
956
957 #[test]
958 fn an_unavailable_option_reads_as_inert_and_says_why() {
959 // The one place muted is the truth rather than the lie the convention
960 // warns about: this option will not answer, and the reason is on the
961 // row rather than nowhere.
962 let style = style();
963 let options = [
964 Choice::new("chromatic", "Chromatic"),
965 Choice::new("multi", "Multi-sample").unless("Drop a second sample."),
966 ];
967 let mut field_ = Field::new(FieldKind::Radio, "mode", "Mode");
968 field_.options = &options;
969 let mut buf = buffer(46, 4);
970 field(
971 &style,
972 &field_,
973 Held::Text("chromatic"),
974 false,
975 buf.area,
976 &mut buf,
977 );
978 assert_eq!(rows(&buf)[1].trim_end(), "(*) Chromatic");
979 assert_eq!(
980 rows(&buf)[2].trim_end(),
981 "( ) Multi-sample: Drop a second sample."
982 );
983 let muted = buf.cell((0, 2)).expect("the unavailable row").style();
984 assert!(muted.add_modifier.contains(Modifier::DIM));
985 }
986
987 #[test]
988 fn an_unchosen_option_does_not_read_as_disabled() {
989 // The three-tone convention: muted is inert, and every option in this
990 // list answers a press. Drawn muted, a five-option radio read as one
991 // live row and four dead ones.
992 let style = style();
993 let mut field_ = Field::new(FieldKind::Radio, "size", "Size");
994 let options = [Choice::plain("small"), Choice::plain("large")];
995 field_.options = &options;
996 let mut buf = buffer(20, 5);
997 field(
998 &style,
999 &field_,
1000 Held::Text("large"),
1001 false,
1002 buf.area,
1003 &mut buf,
1004 );
1005 let unchosen = buf.cell((0, 1)).expect("the first option").style();
1006 assert_eq!(unchosen.add_modifier, style.secondary.add_modifier);
1007 assert_ne!(unchosen.add_modifier, style.muted.add_modifier);
1008 }
1009
1010 #[test]
1011 fn a_checkbox_reads_a_bool_rather_than_a_submitted_string() {
1012 // `Held::On` exists so a host's own submission convention -- quasi
1013 // sends "value" -- stays the host's and never reaches a drawing.
1014 let style = style();
1015 let field_ = Field::new(FieldKind::Checkbox, "agree", "Agree");
1016 let mut buf = buffer(20, 4);
1017 field(&style, &field_, Held::On(true), false, buf.area, &mut buf);
1018 assert_eq!(rows(&buf)[1], "[x]");
1019 let mut buf = buffer(20, 4);
1020 field(&style, &field_, Held::On(false), false, buf.area, &mut buf);
1021 assert_eq!(rows(&buf)[1], "[ ]");
1022 }
1023
1024 #[test]
1025 fn a_tone_and_a_heading_map_without_a_fallback_arm() {
1026 // Both source enums are closed, which is what lets these be total. A
1027 // renderer that had to guess would be picking its own colours again.
1028 let style = style();
1029 assert_eq!(style.tone(Tone::Neutral), style.content);
1030 assert_eq!(style.tone(Tone::Danger), style.danger);
1031 assert_eq!(style.heading(Heading::Page), style.page);
1032 assert_eq!(style.heading(Heading::Subsection), style.subsection);
1033 }
1034
1035 #[test]
1036 fn the_default_style_carries_no_colour_at_all() {
1037 // A two-colour terminal is the case where a foreground will not land,
1038 // so the default is modifiers only rather than a placeholder palette.
1039 let style = PieceStyle::default();
1040 for painted in [style.content, style.danger, style.page, style.action] {
1041 assert_eq!(painted.fg, None);
1042 assert_eq!(painted.bg, None);
1043 }
1044 }
1045}