Skip to main content

max / makeover-tui

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