Skip to main content

max / makeover-tui

49.7 KB · 1303 lines History Blame Raw
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
47 use makeover_layout::{Act, Field, FieldKind, Figure, Heading, Meter, Token, Tone};
48 use ratatui::buffer::Buffer;
49 use ratatui::layout::Rect;
50 use ratatui::style::{Modifier, Style};
51 use ratatui::text::{Line, Span};
52
53 use 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)]
70 pub 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
119 impl 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
146 impl 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)]
238 pub 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 /// Both ends of a [`FieldKind::Interval`], lower first.
249 ///
250 /// Two values rather than one string with a separator, which is
251 /// [`makeover_layout::Field::upper_name`]'s reason one level down: an
252 /// interval is submitted under two names, so it is held as two values, and
253 /// a delimiter this crate owned could appear inside either of them.
254 ///
255 /// Either end may be empty while the other stands. An open end is an
256 /// answer -- "over 120 BPM" -- rather than a half-filled box.
257 ///
258 /// Added 0.33.0 with makeover-layout 0.34.0.
259 Between {
260 /// What the lower box holds now.
261 lower: &'a str,
262 /// What the upper box holds now.
263 upper: &'a str,
264 },
265 }
266
267 impl<'a> Held<'a> {
268 /// What is typed, as a string. A checkbox has no text and answers empty.
269 #[must_use]
270 pub const fn text(self) -> &'a str {
271 match self {
272 Self::Text(text) | Self::Between { lower: text, .. } => text,
273 Self::Absent | Self::On(_) => "",
274 }
275 }
276
277 /// The upper end, for the one variant that has one.
278 #[must_use]
279 pub const fn upper(self) -> &'a str {
280 match self {
281 Self::Between { upper, .. } => upper,
282 Self::Absent | Self::Text(_) | Self::On(_) => "",
283 }
284 }
285
286 /// Whether a checkbox is ticked.
287 #[must_use]
288 pub const fn on(self) -> bool {
289 matches!(self, Self::On(true))
290 }
291 }
292
293 /// A proportion as one line: the bar, then the reading beside it.
294 ///
295 /// The reading is built here from the two numbers and the noun rather than
296 /// taken assembled, which is what [`Meter::label`] carrying the noun alone is
297 /// for: a terminal at one line and a tooltip want different sentence orders.
298 #[must_use]
299 pub fn meter(style: &PieceStyle, meter: &Meter<'_>) -> Line<'static> {
300 let cells = u32::from(style.meter_cells);
301 let filled = meter
302 .done
303 .checked_mul(cells)
304 .and_then(|reached| reached.checked_div(meter.total))
305 .unwrap_or(0)
306 .min(cells);
307 let bar = format!(
308 "{}{}",
309 style.meter_full.to_string().repeat(filled as usize),
310 style
311 .meter_empty
312 .to_string()
313 .repeat((cells - filled) as usize)
314 );
315 let reading = match meter.label {
316 Some(label) => format!(" {}/{} {label}", meter.done, meter.total),
317 None => format!(" {}/{}", meter.done, meter.total),
318 };
319 Line::from(vec![
320 Span::styled(bar, style.tone(meter.tone)),
321 Span::styled(reading, style.muted),
322 ])
323 }
324
325 /// A badge or a chip as one span.
326 ///
327 /// Round for a badge, square for a chip. A chip answers a press and a badge does
328 /// not, and the bracket is the only affordance a cell has left once colour is
329 /// spent on the tone.
330 ///
331 /// `latched` is a chip that is switched on, and it reads as reversed. So does
332 /// focus, which is a collision a terminal cannot avoid: latched is "this filter
333 /// is on" and focused is "you are here", and there is one spare axis for two
334 /// facts. Said here rather than resolved by inventing a third look nobody would
335 /// read.
336 ///
337 /// A chip's removable half is not drawn. The `x` a webview hangs on a chip is a
338 /// second control inside one span, and a terminal reaches a control by focusing
339 /// it; two targets in one cell run is a question for whoever owns the
340 /// interaction, not for a drawing.
341 #[must_use]
342 pub fn token(
343 style: &PieceStyle,
344 label: &str,
345 kind: Token,
346 tone: Tone,
347 latched: bool,
348 focused: bool,
349 ) -> Span<'static> {
350 let painted = style.tone(tone);
351 let painted = if latched {
352 painted.add_modifier(style.focus)
353 } else {
354 style.focused(focused, painted)
355 };
356 match kind {
357 Token::Badge => Span::styled(format!("({label})"), painted),
358 Token::Chip { .. } => Span::styled(format!("[{label}]"), painted),
359 }
360 }
361
362 /// A control as one line.
363 ///
364 /// `< Label > (key)`, and the key only where the description named one. That
365 /// member is the one place `makeover-layout` anticipated a terminal before there
366 /// was one, and this is the renderer that reads it.
367 ///
368 /// A disabled control is drawn muted and is not marked focused, whatever the
369 /// caller passed: it is present, visible and not answering, so a focus mark on
370 /// it would be an affordance that lies. Whether it is reachable at all is the
371 /// caller's count to keep — ask [`Act::disabled`].
372 #[must_use]
373 pub fn act(style: &PieceStyle, act: &Act<'_>, focused: bool) -> Line<'static> {
374 let painted = if act.disabled() {
375 style.muted
376 } else {
377 style.focused(focused, style.tone(act.tone))
378 };
379 let label = match act.key {
380 Some(key) => format!("< {} > ({key})", act.label),
381 None => format!("< {} >", act.label),
382 };
383 Line::from(Span::styled(label, painted))
384 }
385
386 /// A control filled with the action colour, for the one press a screen is about.
387 ///
388 /// `[ Label ]` rather than `< Label >`, which is the weight difference a webview
389 /// carries as a primary-versus-secondary button. A form's submit is the case
390 /// this exists for.
391 #[must_use]
392 pub fn filled_act(style: &PieceStyle, label: &str, focused: bool) -> Line<'static> {
393 Line::from(Span::styled(
394 format!("[ {label} ]"),
395 style.focused(focused, style.filled),
396 ))
397 }
398
399 /// The rows [`figure`] wants at `width`.
400 #[must_use]
401 pub fn figure_height(figure: &Figure<'_>, width: u16) -> u16 {
402 text::height(figure.value, width) + text::height(figure.caption, width)
403 }
404
405 /// A figure: the number, then what it counts under it.
406 ///
407 /// The tone lands on the value and its change rather than on the caption, which
408 /// is what [`Figure::tone`] means: the figure is an ordinary fact and it is the
409 /// movement that reads as good or bad.
410 pub fn figure(style: &PieceStyle, figure: &Figure<'_>, area: Rect, buf: &mut Buffer) -> u16 {
411 let value = match figure.change {
412 Some(change) => format!("{} {change}", figure.value),
413 None => figure.value.to_owned(),
414 };
415 let used = text::draw(
416 &value,
417 style.tone(figure.tone).add_modifier(Modifier::BOLD),
418 area,
419 buf,
420 );
421 used + text::draw(figure.caption, style.muted, below(area, used), buf)
422 }
423
424 /// The rows [`field`] wants at `width`.
425 ///
426 /// A label row, the control's rows, and a row for whatever went wrong. A hidden
427 /// field is nothing at all, which is the one field kind a terminal and a webview
428 /// agree on completely.
429 #[must_use]
430 pub fn field_height(style: &PieceStyle, field: &Field<'_>, width: u16) -> u16 {
431 if !field.kind.visible() {
432 return 0;
433 }
434 let label = text::height(&label_of(style, field), width);
435 // A range is one row like every other single control: the bar, its two ends
436 // and the reading are one line by construction, and a bar that wrapped
437 // would stop being a bar.
438 let body = match field.kind {
439 // Both multi-line kinds get the same three rows, keyed on the
440 // description's own `multiline` rather than on the member: a markdown
441 // field falling through to the single-row arm is one line for a value
442 // whose whole point is that it has several. What a terminal does *with*
443 // the markdown is another question and the answer here is nothing --
444 // the source is the text, and drawing it as text is honest.
445 kind if kind.multiline() => 3,
446 kind if kind.offers_options() => u16::try_from(field.options.len()).unwrap_or(u16::MAX),
447 _ => 1,
448 };
449 let note = note_of(field).map_or(0, |note| text::height(note, width));
450 label + body + note
451 }
452
453 /// A question: its label, the box, and its standing help or what is wrong now.
454 ///
455 /// `held` is what the user has done to it since the screen arrived, which is the
456 /// argument a description cannot supply. See [`Held`].
457 ///
458 /// `focused` marks the box rather than the label, because the box is where the
459 /// typing lands.
460 pub fn field(
461 style: &PieceStyle,
462 field: &Field<'_>,
463 held: Held<'_>,
464 focused: bool,
465 area: Rect,
466 buf: &mut Buffer,
467 ) -> u16 {
468 // A hidden field is data travelling with the form. There is nothing to
469 // draw, and whoever submits carries it.
470 if !field.kind.visible() || area.width == 0 || area.height == 0 {
471 return 0;
472 }
473
474 let mut used = text::draw(&label_of(style, field), style.secondary, area, buf);
475
476 let well = style.focused(focused, style.content);
477 let placeholder = field.placeholder.unwrap_or_default();
478
479 used += match field.kind {
480 FieldKind::Checkbox => text::draw(
481 if held.on() { "[x]" } else { "[ ]" },
482 well,
483 below(area, used),
484 buf,
485 ),
486 // A range's two ends are what the question means, so they are drawn
487 // rather than left to a hint. A terminal has the bar already: this is
488 // `meter`'s cells with the extent read out at either side of them.
489 //
490 // An unbounded range has no extent to draw and falls through to the
491 // text path, which is `makeover-immediate`'s answer as well and for the
492 // same reason: bounds this crate invented are bounds the user would
493 // then drag against.
494 FieldKind::Range if field.bounded() => {
495 let line = range_line(style, field, held.text(), well);
496 text::draw_line(&line, below(area, used), buf)
497 }
498 // One question, so one line. The two ends read left to right with the
499 // word between them, which is what a terminal has instead of two boxes
500 // side by side: a second row would read as a second question, and that
501 // is the reading the kind exists to prevent.
502 FieldKind::Interval => {
503 let line = interval_line(style, field, held, well);
504 text::draw_line(&line, below(area, used), buf)
505 }
506 kind if kind.offers_options() => {
507 let mut rows = 0;
508 for choice in field.options {
509 let chosen = held.text() == choice.value;
510 // An option that cannot be picked yet reads as inert, which is
511 // the one place muted is the truth rather than the lie below:
512 // it will not answer, and the reason it will not is on the row
513 // beside it rather than nowhere.
514 let (mark, painted, suffix) = match choice.unavailable {
515 Some(reason) => ("( )", style.muted, format!(": {reason}")),
516 None if chosen => ("(*)", well, String::new()),
517 // An option that is not chosen is still an option: pressing
518 // it chooses it. So it takes the secondary content intent
519 // and not the muted one, which is what disabled looks like
520 // (`State::Disabled` resolves to it). Muted here read as a
521 // list of five where four were greyed out.
522 None => ("( )", style.secondary, String::new()),
523 };
524 rows += text::draw(
525 &format!("{mark} {}{suffix}", choice.label),
526 painted,
527 below(area, used + rows),
528 buf,
529 );
530 }
531 rows
532 }
533 // A secret's dots come from the caller's buffer and can come from
534 // nowhere else: a password that comes back down the wire is a password
535 // in a page and in a proxy log, so a description carries nothing to dot
536 // out. This is the one control that would be undrawable without `held`.
537 FieldKind::Secret if !held.text().is_empty() => {
538 let dots = "*".repeat(held.text().chars().count());
539 text::draw(&dots, well, below(area, used), buf).max(1)
540 }
541 // A file field has no way back on a terminal any more than it has on an
542 // HTTP host. The name is drawn and picking one belongs to whoever owns
543 // the interaction.
544 //
545 // makeover-layout 0.31.0 gave the description an accept list and a
546 // multiplicity, and neither changes anything drawn here. Both are the
547 // picker's business, and the picker is the caller's: this crate draws
548 // what was picked. A terminal that grows its own picker reads them off
549 // `Field::accept` and `Field::multiple` at that point rather than
550 // through a second spelling invented here.
551 _ if held.text().is_empty() => {
552 empty_well(style, placeholder, well, focused, below(area, used), buf)
553 }
554 _ => text::draw(&measured(field, held.text()), well, below(area, used), buf),
555 };
556
557 // The error wins over the hint, the same order a webview uses: a hint is
558 // what to type and an error is what went wrong, and once something has gone
559 // wrong that is the sentence worth the row.
560 match note_of(field) {
561 Some(note) => {
562 let painted = if field.error.is_some() {
563 style.danger
564 } else {
565 style.muted
566 };
567 used + text::draw(note, painted, below(area, used), buf)
568 }
569 None => used,
570 }
571 }
572
573 /// A bounded number as one line: the low end, the bar, the high end, then what
574 /// it currently reads.
575 ///
576 /// The two ends are drawn because they are the question. A threshold of 0.72
577 /// says nothing without them, which is the whole argument for
578 /// [`FieldKind::Range`] being a kind rather than a number with bounds, and a
579 /// terminal is where it would be easiest to quietly drop them and show a figure.
580 ///
581 /// The bar is [`meter`]'s cells, so a range and a proportion read as the same
582 /// object in the same app. What differs is the reading beside it: a meter counts
583 /// something and a range holds a value.
584 ///
585 /// A value the host cannot read as a number empties the bar and is still shown
586 /// as itself. That is [`empty_well`]'s position on an unreadable value: the app
587 /// put it there, and a terminal that silently rounded it to a bound would be
588 /// reporting a value nobody set.
589 fn range_line(style: &PieceStyle, field: &Field<'_>, value: &str, well: Style) -> Line<'static> {
590 let cells = usize::from(style.meter_cells);
591 let ends = field
592 .min
593 .zip(field.max)
594 .and_then(|(min, max)| Some((min.parse::<f64>().ok()?, max.parse::<f64>().ok()?)));
595 let filled = match (ends, value.parse::<f64>()) {
596 (Some((min, max)), Ok(number)) if max > min => {
597 // Where the value sits is the curve's answer, not a proportion of
598 // the extent (makeover-layout 0.32.0). Under `Curve::Linear` the two
599 // are the same number, which is why the bar was right before and is
600 // unchanged for every range described so far; under a constant ratio
601 // they are not, and a bar drawn linearly would put an envelope's
602 // whole useful half inside its first cell.
603 #[expect(
604 clippy::cast_possible_truncation,
605 clippy::cast_sign_loss,
606 reason = "`position_of` returns 0..=1, and the cell count came from a u16"
607 )]
608 let reached = (field.curve.position_of(number, min, max) * cells as f64) as usize;
609 reached.min(cells)
610 }
611 _ => 0,
612 };
613 let bar = format!(
614 "{}{}",
615 style.meter_full.to_string().repeat(filled),
616 style.meter_empty.to_string().repeat(cells - filled)
617 );
618 Line::from(vec![
619 Span::styled(format!("{} ", field.min.unwrap_or_default()), style.muted),
620 Span::styled(bar, well),
621 Span::styled(format!(" {}", field.max.unwrap_or_default()), style.muted),
622 Span::styled(format!(" {}", measured(field, value)), well),
623 ])
624 }
625
626 /// An interval as one line: the low end, the word, the high end.
627 ///
628 /// One line because it is one question. Two rows would read as two questions,
629 /// which is exactly what [`FieldKind::Interval`] exists to stop the description
630 /// saying, and a terminal has no side-by-side boxes to fall back on.
631 ///
632 /// # An open end draws the bound it falls back to
633 ///
634 /// Muted, because it is where the axis ends rather than a value anybody set.
635 /// With no bound to fall back on there is nothing honest to draw and the end
636 /// stays blank: a terminal inventing a number here would report a filter the
637 /// user never applied, which is [`range_line`]'s position on an unreadable
638 /// value.
639 ///
640 /// # The word, not a dash
641 ///
642 /// A dash between two numbers is a minus sign to anyone reading a signed axis,
643 /// and half the measured axes are signed -- audiofiles filters loudness in
644 /// dBFS. `to` costs two cells and cannot be misread.
645 fn interval_line(
646 style: &PieceStyle,
647 field: &Field<'_>,
648 held: Held<'_>,
649 well: Style,
650 ) -> Line<'static> {
651 let end = |value: &str, fallback: Option<&str>| match (value.is_empty(), fallback) {
652 (false, _) => Span::styled(measured(field, value), well),
653 (true, Some(bound)) => Span::styled(measured(field, bound), style.muted),
654 (true, None) => Span::styled(String::new(), style.muted),
655 };
656 Line::from(vec![
657 end(held.text(), field.min),
658 Span::styled(" to ", style.secondary),
659 end(held.upper(), field.max),
660 ])
661 }
662
663 /// The unit to draw beside this field's value, if there is one to draw.
664 ///
665 /// Two conditions rather than one: the field has to carry a unit and its kind
666 /// has to be one that means anything by it. `FieldKind::measurable` is the
667 /// description answering the second, so this renderer keeps no list of its own
668 /// of which kinds are quantities.
669 fn unit_of<'a>(field: &Field<'a>) -> Option<&'a str> {
670 field.unit.filter(|_| field.kind.measurable())
671 }
672
673 /// A value with what it is measured in, as one string.
674 ///
675 /// The unit rides on the value rather than on the label, which is
676 /// `makeover-layout` 0.33.0's rule and is what a terminal wants anyway: the
677 /// label is a line above and the number is the line the eye is on.
678 fn measured(field: &Field<'_>, value: &str) -> String {
679 match unit_of(field) {
680 Some(unit) => format!("{value} {unit}"),
681 None => value.to_owned(),
682 }
683 }
684
685 /// The label, marked where the field is compulsory.
686 fn label_of(style: &PieceStyle, field: &Field<'_>) -> String {
687 if field.required {
688 format!("{} {}", field.label, style.required_marker)
689 } else {
690 field.label.to_owned()
691 }
692 }
693
694 /// What goes under the box: what is wrong now, or the standing help.
695 fn note_of<'a>(field: &Field<'a>) -> Option<&'a str> {
696 field.error.or(field.hint)
697 }
698
699 /// A box with nothing in it: the ghost text, and the caret when it has focus.
700 ///
701 /// The caret is not decoration. An empty field under a style is an empty field,
702 /// so a focused one with no placeholder drew literally nothing and there was no
703 /// way to tell the box was where the typing would go. A browser has a blinking
704 /// bar for this and gets it without asking; a terminal has one cell of reversed
705 /// video, put on the first column, which is where the first character lands.
706 fn empty_well(
707 style: &PieceStyle,
708 placeholder: &str,
709 well: Style,
710 focused: bool,
711 area: Rect,
712 buf: &mut Buffer,
713 ) -> u16 {
714 let used = text::draw(placeholder, style.muted, area, buf).max(1);
715 if focused
716 && area.height > 0
717 && area.width > 0
718 && let Some(cell) = buf.cell_mut((area.x, area.y))
719 {
720 cell.set_style(well);
721 }
722 used
723 }
724
725 /// What is left of `area` after `used` rows from the top.
726 fn below(area: Rect, used: u16) -> Rect {
727 let used = used.min(area.height);
728 Rect {
729 x: area.x,
730 y: area.y + used,
731 width: area.width,
732 height: area.height - used,
733 }
734 }
735
736 #[cfg(test)]
737 mod tests {
738 use super::*;
739 use makeover_layout::{Choice, State};
740
741 /// The style the drawings are read against: one distinguishable modifier
742 /// per role, so a test can say which style landed without a colour.
743 fn style() -> PieceStyle {
744 PieceStyle {
745 content: Style::new().add_modifier(Modifier::BOLD),
746 secondary: Style::new().add_modifier(Modifier::ITALIC),
747 muted: Style::new().add_modifier(Modifier::DIM),
748 danger: Style::new().add_modifier(Modifier::CROSSED_OUT),
749 ..PieceStyle::default()
750 }
751 }
752
753 fn buffer(width: u16, height: u16) -> Buffer {
754 Buffer::empty(Rect::new(0, 0, width, height))
755 }
756
757 /// Everything in the buffer, one string per row.
758 fn rows(buf: &Buffer) -> Vec<String> {
759 (0..buf.area.height)
760 .map(|y| {
761 (0..buf.area.width)
762 .map(|x| {
763 buf.cell((x, y))
764 .map_or(' ', |c| c.symbol().chars().next().unwrap_or(' '))
765 })
766 .collect::<String>()
767 .trim_end()
768 .to_owned()
769 })
770 .collect()
771 }
772
773 #[test]
774 fn a_bar_fills_in_proportion_and_reads_out_the_two_numbers() {
775 let style = style();
776 let line = meter(&style, &Meter::new(3, 10).label("subtasks"));
777 let drawn: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
778 assert_eq!(drawn, "###------- 3/10 subtasks");
779 // The noun is optional and the ratio is not, because a bar with no
780 // reading is a bar you cannot check.
781 let bare = meter(&style, &Meter::new(3, 10));
782 let drawn: String = bare.spans.iter().map(|s| s.content.as_ref()).collect();
783 assert_eq!(drawn, "###------- 3/10");
784 }
785
786 #[test]
787 fn an_empty_set_is_an_empty_bar_rather_than_a_divide_by_zero() {
788 // `Meter::total` of zero means there is no set, and the checked
789 // division is what keeps that from being a panic in a draw.
790 let line = meter(&style(), &Meter::new(0, 0));
791 let drawn: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
792 assert_eq!(drawn, "---------- 0/0");
793 }
794
795 #[test]
796 fn an_over_run_fills_the_bar_and_still_reports_the_overflow() {
797 // The clamp is for drawing only. The reading is what keeps the fact
798 // `Meter::percent` destroys.
799 let line = meter(&style(), &Meter::new(14, 10));
800 let drawn: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
801 assert_eq!(drawn, "########## 14/10");
802 }
803
804 #[test]
805 fn a_badge_is_round_and_a_chip_is_square() {
806 // The one affordance a cell has left once colour is spent on the tone,
807 // and the whole of how a terminal says "this one answers a press".
808 let style = style();
809 let badge = token(&style, "draft", Token::Badge, Tone::Neutral, false, false);
810 assert_eq!(badge.content.as_ref(), "(draft)");
811 let chip = token(
812 &style,
813 "rust",
814 Token::Chip { removable: false },
815 Tone::Neutral,
816 false,
817 false,
818 );
819 assert_eq!(chip.content.as_ref(), "[rust]");
820 }
821
822 #[test]
823 fn a_latched_chip_reads_the_same_as_a_focused_one() {
824 // The collision a terminal cannot avoid, asserted rather than left to
825 // be rediscovered: latched is "this filter is on" and focused is "you
826 // are here", and there is one spare axis for two facts.
827 let style = style();
828 let kind = Token::Chip { removable: false };
829 let latched = token(&style, "rust", kind, Tone::Neutral, true, false);
830 let focused = token(&style, "rust", kind, Tone::Neutral, false, true);
831 assert_eq!(latched.style, focused.style);
832 assert!(latched.style.add_modifier.contains(Modifier::REVERSED));
833 }
834
835 #[test]
836 fn a_control_draws_its_key_only_where_one_was_named() {
837 let style = style();
838 let line = act(&style, &Act::new("Delete"), false);
839 assert_eq!(line.spans[0].content.as_ref(), "< Delete >");
840 let line = act(&style, &Act::new("Quit").key("q"), false);
841 assert_eq!(line.spans[0].content.as_ref(), "< Quit > (q)");
842 }
843
844 #[test]
845 fn a_disabled_control_is_never_marked_focused() {
846 // Present, visible, and not answering. A focus mark on it would be an
847 // affordance that lies, so the flag is overridden rather than trusted.
848 let style = style();
849 let disabled = Act::new("Save").state(State::Disabled);
850 let line = act(&style, &disabled, true);
851 assert!(
852 !line.spans[0]
853 .style
854 .add_modifier
855 .contains(Modifier::REVERSED)
856 );
857 assert_eq!(line.spans[0].style, style.muted);
858 // The same call on a control the description says nothing about: the
859 // mark is this renderer's own focus flag and always was, which is why
860 // only `Disabled` can override it.
861 let unstated = Act::new("Save");
862 let line = act(&style, &unstated, true);
863 assert!(
864 line.spans[0]
865 .style
866 .add_modifier
867 .contains(Modifier::REVERSED)
868 );
869 }
870
871 #[test]
872 fn a_danger_control_keeps_its_tone_under_focus() {
873 // Focus adds a modifier rather than repainting, so the fact that this
874 // is the button that destroys something survives being landed on.
875 let style = style();
876 let line = act(&style, &Act::new("Delete").tone(Tone::Danger), true);
877 assert_eq!(
878 line.spans[0].style.add_modifier,
879 style.danger.add_modifier | Modifier::REVERSED
880 );
881 }
882
883 #[test]
884 fn a_figure_puts_the_number_over_what_it_counts() {
885 let style = style();
886 let figure_ = Figure::new("42", "open tasks");
887 let mut buf = buffer(20, 4);
888 let used = figure(&style, &figure_, buf.area, &mut buf);
889 assert_eq!(used, 2);
890 assert_eq!(rows(&buf)[..2], ["42".to_owned(), "open tasks".to_owned()]);
891 assert_eq!(figure_height(&figure_, 20), 2);
892 }
893
894 #[test]
895 fn a_figures_change_rides_on_the_value_row() {
896 // The delta is the toned part and the value is an ordinary fact, so the
897 // two share a row rather than the caption growing a second sentence.
898 let style = style();
899 let figure_ = Figure::new("42", "open tasks")
900 .change("+3")
901 .tone(Tone::Success);
902 let mut buf = buffer(20, 4);
903 figure(&style, &figure_, buf.area, &mut buf);
904 assert_eq!(rows(&buf)[0], "42 +3");
905 }
906
907 #[test]
908 fn a_compulsory_field_says_so_in_its_label() {
909 let style = style();
910 let mut field_ = Field::new(FieldKind::Text, "email", "Email");
911 field_.required = true;
912 let mut buf = buffer(20, 4);
913 field(&style, &field_, Held::Absent, false, buf.area, &mut buf);
914 assert_eq!(rows(&buf)[0], "Email *");
915 }
916
917 #[test]
918 fn a_hidden_field_costs_no_rows_at_all() {
919 // The one field kind a terminal and a webview agree on completely.
920 let style = style();
921 let field_ = Field::new(FieldKind::Hidden, "csrf", "Token");
922 let mut buf = buffer(20, 4);
923 assert_eq!(
924 field(
925 &style,
926 &field_,
927 Held::Text("abc"),
928 false,
929 buf.area,
930 &mut buf
931 ),
932 0
933 );
934 assert_eq!(field_height(&style, &field_, 20), 0);
935 assert_eq!(rows(&buf)[0], "");
936 }
937
938 #[test]
939 fn a_secret_is_dotted_from_the_callers_buffer_and_never_from_the_description() {
940 // The one control that would be undrawable without `held`: a password
941 // that came back down the wire is a password in a page and in a log.
942 let style = style();
943 let field_ = Field::new(FieldKind::Secret, "password", "Password");
944 let mut buf = buffer(20, 4);
945 field(
946 &style,
947 &field_,
948 Held::Text("hunter2"),
949 false,
950 buf.area,
951 &mut buf,
952 );
953 assert_eq!(rows(&buf)[1], "*******");
954 }
955
956 #[test]
957 fn an_error_takes_the_row_the_hint_would_have_had() {
958 // Once something has gone wrong that is the sentence worth the row,
959 // which is the order a webview uses too.
960 let style = style();
961 let mut field_ = Field::new(FieldKind::Text, "email", "Email");
962 field_.hint = Some("work address");
963 field_.error = Some("not an address");
964 let mut buf = buffer(20, 5);
965 field(
966 &style,
967 &field_,
968 Held::Text("nope"),
969 false,
970 buf.area,
971 &mut buf,
972 );
973 assert_eq!(rows(&buf)[2], "not an address");
974 assert_eq!(field_height(&style, &field_, 20), 3);
975 }
976
977 #[test]
978 fn a_focused_empty_box_shows_where_the_typing_will_land() {
979 // An empty field under a style is an empty field. Without the caret a
980 // focused box with no placeholder drew literally nothing.
981 let style = style();
982 let field_ = Field::new(FieldKind::Text, "email", "Email");
983 let mut buf = buffer(20, 4);
984 field(&style, &field_, Held::Absent, true, buf.area, &mut buf);
985 let caret = buf.cell((0, 1)).expect("the well's first cell").style();
986 assert!(caret.add_modifier.contains(Modifier::REVERSED));
987 }
988
989 #[test]
990 fn a_choice_field_marks_the_chosen_option_and_costs_a_row_each() {
991 let style = style();
992 let mut field_ = Field::new(FieldKind::Radio, "size", "Size");
993 let options = [Choice::plain("small"), Choice::plain("large")];
994 field_.options = &options;
995 let mut buf = buffer(20, 5);
996 field(
997 &style,
998 &field_,
999 Held::Text("large"),
1000 false,
1001 buf.area,
1002 &mut buf,
1003 );
1004 assert_eq!(rows(&buf)[1], "( ) small");
1005 assert_eq!(rows(&buf)[2], "(*) large");
1006 assert_eq!(field_height(&style, &field_, 20), 3);
1007 }
1008
1009 #[test]
1010 fn a_range_draws_its_two_ends_and_where_the_value_sits_between_them() {
1011 let style = style();
1012 let field_ = Field::range("review", "Review above", "0", "1");
1013 let mut buf = buffer(40, 3);
1014 field(
1015 &style,
1016 &field_,
1017 Held::Text("0.5"),
1018 false,
1019 buf.area,
1020 &mut buf,
1021 );
1022 // Ten cells by default, half of them filled, with the extent read out
1023 // at either side: 0.5 means nothing without the 0 and the 1.
1024 assert_eq!(rows(&buf)[1].trim_end(), "0 #####----- 1 0.5");
1025 assert_eq!(field_height(&style, &field_, 40), 2);
1026 }
1027
1028 #[test]
1029 fn a_unit_rides_on_the_value_and_not_on_the_label() {
1030 // The label is a line above; the number is the line the eye is on.
1031 let style = style();
1032 let field_ = Field {
1033 unit: Some("s"),
1034 ..Field::range("attack", "Attack", "0", "5")
1035 };
1036 let mut buf = buffer(40, 3);
1037 field(
1038 &style,
1039 &field_,
1040 Held::Text("2.5"),
1041 false,
1042 buf.area,
1043 &mut buf,
1044 );
1045 assert_eq!(rows(&buf)[0].trim_end(), "Attack");
1046 assert_eq!(rows(&buf)[1].trim_end(), "0 #####----- 5 2.5 s");
1047 }
1048
1049 #[test]
1050 fn a_typed_number_reads_with_its_unit_too() {
1051 let style = style();
1052 let field_ = Field {
1053 unit: Some("ms"),
1054 ..Field::new(FieldKind::Number, "fade", "Fade")
1055 };
1056 let mut buf = buffer(40, 3);
1057 field(&style, &field_, Held::Text("50"), false, buf.area, &mut buf);
1058 assert_eq!(rows(&buf)[1].trim_end(), "50 ms");
1059 }
1060
1061 #[test]
1062 fn a_unit_on_a_kind_that_is_not_a_quantity_is_ignored() {
1063 // Which kinds are quantities is the description's answer, not a
1064 // `matches!` kept in this crate.
1065 let style = style();
1066 let field_ = Field {
1067 unit: Some("s"),
1068 ..Field::new(FieldKind::Text, "name", "Name")
1069 };
1070 let mut buf = buffer(40, 3);
1071 field(
1072 &style,
1073 &field_,
1074 Held::Text("kick"),
1075 false,
1076 buf.area,
1077 &mut buf,
1078 );
1079 assert_eq!(rows(&buf)[1].trim_end(), "kick");
1080 }
1081
1082 #[test]
1083 fn an_interval_is_one_line_with_both_ends_on_it() {
1084 // One question, one line. Two rows would read as two questions, which
1085 // is the reading the kind exists to prevent.
1086 let style = style();
1087 let field_ = Field {
1088 min: Some("0"),
1089 max: Some("300"),
1090 unit: Some("BPM"),
1091 ..Field::interval("bpm_min", "bpm_max", "BPM range")
1092 };
1093 let mut buf = buffer(40, 3);
1094 field(
1095 &style,
1096 &field_,
1097 Held::Between {
1098 lower: "90",
1099 upper: "130",
1100 },
1101 false,
1102 buf.area,
1103 &mut buf,
1104 );
1105 assert_eq!(rows(&buf)[0].trim_end(), "BPM range");
1106 assert_eq!(rows(&buf)[1].trim_end(), "90 BPM to 130 BPM");
1107 assert_eq!(rows(&buf)[2].trim_end(), "");
1108 }
1109
1110 #[test]
1111 fn an_open_end_falls_back_to_the_bound_it_means() {
1112 // "Over 120" is an answer rather than a half-filled box, and where the
1113 // axis ends is what the empty end stands for.
1114 let style = style();
1115 let field_ = Field {
1116 min: Some("0"),
1117 max: Some("300"),
1118 ..Field::interval("bpm_min", "bpm_max", "BPM range")
1119 };
1120 let mut buf = buffer(40, 3);
1121 field(
1122 &style,
1123 &field_,
1124 Held::Between {
1125 lower: "120",
1126 upper: "",
1127 },
1128 false,
1129 buf.area,
1130 &mut buf,
1131 );
1132 assert_eq!(rows(&buf)[1].trim_end(), "120 to 300");
1133 }
1134
1135 #[test]
1136 fn an_unbounded_open_end_draws_nothing_rather_than_a_number() {
1137 // A terminal inventing a bound here would report a filter nobody
1138 // applied, which is `range_line`'s position on an unreadable value.
1139 // What is left reads as the sentence it is: up to 130.
1140 let style = style();
1141 let field_ = Field::interval("bpm_min", "bpm_max", "BPM range");
1142 let mut buf = buffer(40, 3);
1143 field(
1144 &style,
1145 &field_,
1146 Held::Between {
1147 lower: "",
1148 upper: "130",
1149 },
1150 false,
1151 buf.area,
1152 &mut buf,
1153 );
1154 assert_eq!(rows(&buf)[1].trim_end(), "to 130");
1155 }
1156
1157 #[test]
1158 fn a_range_holding_something_unreadable_still_shows_it() {
1159 // The app put the value there. A terminal that quietly rounded it to a
1160 // bound would be reporting a value nobody set, which is `empty_well`'s
1161 // position on the same problem.
1162 let style = style();
1163 let field_ = Field::range("review", "Review above", "0", "1");
1164 let mut buf = buffer(40, 3);
1165 field(
1166 &style,
1167 &field_,
1168 Held::Text("unset"),
1169 false,
1170 buf.area,
1171 &mut buf,
1172 );
1173 assert_eq!(rows(&buf)[1].trim_end(), "0 ---------- 1 unset");
1174 }
1175
1176 #[test]
1177 fn an_unbounded_range_is_typed_into_rather_than_dragged() {
1178 // Bounds this crate invented are bounds the user would then drag
1179 // against. The text path takes every answer the bar would.
1180 let style = style();
1181 let field_ = Field {
1182 max: Some("1"),
1183 ..Field::new(FieldKind::Range, "review", "Review above")
1184 };
1185 let mut buf = buffer(40, 3);
1186 field(
1187 &style,
1188 &field_,
1189 Held::Text("0.5"),
1190 false,
1191 buf.area,
1192 &mut buf,
1193 );
1194 assert_eq!(rows(&buf)[1].trim_end(), "0.5");
1195 }
1196
1197 #[test]
1198 fn an_unavailable_option_reads_as_inert_and_says_why() {
1199 // The one place muted is the truth rather than the lie the convention
1200 // warns about: this option will not answer, and the reason is on the
1201 // row rather than nowhere.
1202 let style = style();
1203 let options = [
1204 Choice::new("chromatic", "Chromatic"),
1205 Choice::new("multi", "Multi-sample").unless("Drop a second sample."),
1206 ];
1207 let mut field_ = Field::new(FieldKind::Radio, "mode", "Mode");
1208 field_.options = &options;
1209 let mut buf = buffer(46, 4);
1210 field(
1211 &style,
1212 &field_,
1213 Held::Text("chromatic"),
1214 false,
1215 buf.area,
1216 &mut buf,
1217 );
1218 assert_eq!(rows(&buf)[1].trim_end(), "(*) Chromatic");
1219 assert_eq!(
1220 rows(&buf)[2].trim_end(),
1221 "( ) Multi-sample: Drop a second sample."
1222 );
1223 let muted = buf.cell((0, 2)).expect("the unavailable row").style();
1224 assert!(muted.add_modifier.contains(Modifier::DIM));
1225 }
1226
1227 #[test]
1228 fn an_unchosen_option_does_not_read_as_disabled() {
1229 // The three-tone convention: muted is inert, and every option in this
1230 // list answers a press. Drawn muted, a five-option radio read as one
1231 // live row and four dead ones.
1232 let style = style();
1233 let mut field_ = Field::new(FieldKind::Radio, "size", "Size");
1234 let options = [Choice::plain("small"), Choice::plain("large")];
1235 field_.options = &options;
1236 let mut buf = buffer(20, 5);
1237 field(
1238 &style,
1239 &field_,
1240 Held::Text("large"),
1241 false,
1242 buf.area,
1243 &mut buf,
1244 );
1245 let unchosen = buf.cell((0, 1)).expect("the first option").style();
1246 assert_eq!(unchosen.add_modifier, style.secondary.add_modifier);
1247 assert_ne!(unchosen.add_modifier, style.muted.add_modifier);
1248 }
1249
1250 #[test]
1251 fn a_checkbox_reads_a_bool_rather_than_a_submitted_string() {
1252 // `Held::On` exists so a host's own submission convention -- quasi
1253 // sends "value" -- stays the host's and never reaches a drawing.
1254 let style = style();
1255 let field_ = Field::new(FieldKind::Checkbox, "agree", "Agree");
1256 let mut buf = buffer(20, 4);
1257 field(&style, &field_, Held::On(true), false, buf.area, &mut buf);
1258 assert_eq!(rows(&buf)[1], "[x]");
1259 let mut buf = buffer(20, 4);
1260 field(&style, &field_, Held::On(false), false, buf.area, &mut buf);
1261 assert_eq!(rows(&buf)[1], "[ ]");
1262 }
1263
1264 #[test]
1265 fn a_markdown_field_gets_the_rows_a_textarea_does() {
1266 // Keyed on `multiline`, so a member added upstream does not silently
1267 // land on the single-row arm. One row for a value whose whole point is
1268 // that it has several is the failure this replaced.
1269 let style = PieceStyle::default();
1270 let rich = Field::new(FieldKind::Rich, "body", "Body");
1271 let textarea = Field::new(FieldKind::Textarea, "body", "Body");
1272 let plain = Field::new(FieldKind::Text, "body", "Body");
1273
1274 assert_eq!(
1275 field_height(&style, &rich, 40),
1276 field_height(&style, &textarea, 40)
1277 );
1278 assert!(field_height(&style, &rich, 40) > field_height(&style, &plain, 40));
1279 }
1280
1281 #[test]
1282 fn a_tone_and_a_heading_map_without_a_fallback_arm() {
1283 // Both source enums are closed, which is what lets these be total. A
1284 // renderer that had to guess would be picking its own colours again.
1285 let style = style();
1286 assert_eq!(style.tone(Tone::Neutral), style.content);
1287 assert_eq!(style.tone(Tone::Danger), style.danger);
1288 assert_eq!(style.heading(Heading::Page), style.page);
1289 assert_eq!(style.heading(Heading::Subsection), style.subsection);
1290 }
1291
1292 #[test]
1293 fn the_default_style_carries_no_colour_at_all() {
1294 // A two-colour terminal is the case where a foreground will not land,
1295 // so the default is modifiers only rather than a placeholder palette.
1296 let style = PieceStyle::default();
1297 for painted in [style.content, style.danger, style.page, style.action] {
1298 assert_eq!(painted.fg, None);
1299 assert_eq!(painted.bg, None);
1300 }
1301 }
1302 }
1303