Skip to main content

max / makeover-tui

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