Skip to main content

max / makeover-tui

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