Skip to main content

max / makeover-tui

30.6 KB · 787 lines History Blame Raw
1 //! The pieces every terminal app draws, drawn once.
2 //!
3 //! Arrived in 0.16.0 out of `quasi-tui`, which had written all of them and was
4 //! the second consumer to do so. A meter, a badge, a control, a figure and a
5 //! form field are what a screen is made of below the level [`table`](crate::table)
6 //! works at, and every one of them had been hand-rolled at least twice in this
7 //! tree before it was lifted.
8 //!
9 //! # What these take, and what they leave alone
10 //!
11 //! Each takes a `makeover-layout` description, a [`WidgetStyle`], and whatever
12 //! the *host* knows that a description never carries. That last part is the
13 //! shape worth copying: [`field`] takes what is currently typed in the box as a
14 //! separate argument, because [`Field`] deliberately does not carry a value and
15 //! is not going to. `makeover-immediate` reached the same seam from the other
16 //! side with its `Filling`, and [`Held`] is that seam here.
17 //!
18 //! Focus is the other one. Nothing in a description says which control the user
19 //! is on, so every drawing here takes `focused` as an argument and the caller
20 //! is what counts. What focus *looks like* is this crate's answer and not the
21 //! caller's, which is the point of it being here: see
22 //! [`WidgetStyle::focused`].
23 //!
24 //! # What they do not do
25 //!
26 //! No layout. Each answers rows for a width, or draws into the rect it is
27 //! given, top-aligned, and never below it. Nothing here measures twice and
28 //! nothing here places anything relative to anything else, because the moment
29 //! it did it would be a layout engine with one consumer's flow baked into it.
30
31 use makeover_layout::{Act, Field, FieldKind, Figure, Heading, Meter, Token, Tone};
32 use ratatui::buffer::Buffer;
33 use ratatui::layout::Rect;
34 use ratatui::style::{Modifier, Style};
35 use ratatui::text::{Line, Span};
36
37 use crate::text;
38
39 /// The colours and marks the drawings below use.
40 ///
41 /// [`TableStyle`](crate::table::TableStyle)'s shape, for its reasons: an
42 /// ungated struct of styles with a [`Default`], plus a
43 /// [`from_theme`](Self::from_theme) that is what a consumer holding a loaded
44 /// theme should reach for first. A consumer painting bevels and nothing else
45 /// should not have to supply text tones it never uses, and gating the whole
46 /// module on `theme` would make these unreachable to anyone hand-picking
47 /// colours.
48 ///
49 /// The default is the one that survives a terminal with no colour at all:
50 /// modifiers only, no foreground anywhere. That is not a placeholder. A
51 /// two-colour terminal is the case where a `Style` carrying a foreground is a
52 /// foreground that will not land, and bold-and-reversed is what is left.
53 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
54 pub struct WidgetStyle {
55 /// Ordinary content, and what [`Tone::Neutral`] reads as.
56 pub content: Style,
57 /// Content one step back: a field's label, a quoted run.
58 pub secondary: Style,
59 /// Content two steps back: a caption, a hint, a meter's reading.
60 pub muted: Style,
61 /// Something worth knowing and nothing to do about it.
62 pub info: Style,
63 /// Something finished and it worked.
64 pub success: Style,
65 /// Something the user should look at.
66 pub warning: Style,
67 /// Something broken, or about to be destroyed.
68 pub danger: Style,
69 /// A page title.
70 pub page: Style,
71 /// A section title.
72 pub section: Style,
73 /// A subsection title.
74 pub subsection: Style,
75 /// Text that goes somewhere, and a control's label.
76 pub action: Style,
77 /// A control filled with the action colour, for the one on a screen that is
78 /// the thing to press. A form's submit is the case that has it.
79 pub filled: Style,
80 /// A surface set back from the one it sits on, by colour and nothing else.
81 /// What a code run takes, since every cell is monospace and the thing a
82 /// webview says with a typeface cannot be said that way here.
83 pub sunken: Style,
84 /// What "you are on this one" adds to whatever it lands on.
85 ///
86 /// Reversed video by default, which is the affordance a cell has left once
87 /// colour is spent on tone and bold on weight. A webview says it with an
88 /// outline; a terminal has no outline that is not four more cells.
89 pub focus: Modifier,
90 /// How many cells [`meter`] spends on its bar.
91 pub meter_cells: u16,
92 /// The filled part of a bar.
93 pub meter_full: char,
94 /// The empty part of a bar.
95 pub meter_empty: char,
96 /// What marks a compulsory field, appended to its label.
97 ///
98 /// A knob for `makeover-immediate`'s reason: it is the one piece of *copy*
99 /// here, and copy is not a renderer's call.
100 pub required_marker: &'static str,
101 }
102
103 impl Default for WidgetStyle {
104 /// Modifiers only, no foreground: what survives a terminal with two
105 /// colours.
106 fn default() -> Self {
107 Self {
108 content: Style::new(),
109 secondary: Style::new(),
110 muted: Style::new().add_modifier(Modifier::DIM),
111 info: Style::new(),
112 success: Style::new(),
113 warning: Style::new(),
114 danger: Style::new().add_modifier(Modifier::BOLD),
115 page: Style::new().add_modifier(Modifier::BOLD),
116 section: Style::new().add_modifier(Modifier::BOLD),
117 subsection: Style::new(),
118 action: Style::new().add_modifier(Modifier::UNDERLINED),
119 filled: Style::new().add_modifier(Modifier::REVERSED),
120 sunken: Style::new().add_modifier(Modifier::DIM),
121 focus: Modifier::REVERSED,
122 meter_cells: 10,
123 meter_full: '#',
124 meter_empty: '-',
125 required_marker: "*",
126 }
127 }
128 }
129
130 impl WidgetStyle {
131 /// The house widgets, from a loaded theme.
132 ///
133 /// The lift this module exists for. `quasi-tui` carried every line of this
134 /// as private methods on its own renderer; a second terminal app wanting a
135 /// toned control had no way to reach them and would have picked its own
136 /// colours for the same five tones.
137 #[cfg(feature = "theme")]
138 #[must_use]
139 pub fn from_theme(theme: &crate::Theme) -> Self {
140 Self {
141 content: Style::new().fg(theme.content_primary),
142 secondary: Style::new().fg(theme.content_secondary),
143 muted: Style::new().fg(theme.content_muted),
144 info: Style::new().fg(theme.status_info),
145 success: Style::new().fg(theme.status_success),
146 warning: Style::new().fg(theme.status_warning),
147 danger: Style::new().fg(theme.status_danger),
148 // Three depths and two of them are bold, which is the whole of what
149 // a terminal has: there is no type scale in a grid of one cell
150 // size. A page title takes bold and the accent, a section bold, a
151 // subsection the secondary colour. That is the emphasis order a
152 // webview's type scale says with size, said with the two axes a
153 // cell has.
154 page: Style::new()
155 .fg(theme.action_primary)
156 .add_modifier(Modifier::BOLD),
157 section: Style::new()
158 .fg(theme.content_primary)
159 .add_modifier(Modifier::BOLD),
160 subsection: Style::new().fg(theme.content_secondary),
161 action: Style::new().fg(theme.action_primary),
162 filled: Style::new()
163 .fg(theme.selection_on)
164 .bg(theme.action_primary),
165 sunken: Style::new().bg(theme.surface_sunken),
166 focus: Modifier::REVERSED,
167 meter_cells: 10,
168 meter_full: '#',
169 meter_empty: '-',
170 required_marker: "*",
171 }
172 }
173
174 /// The style a tone reads as.
175 ///
176 /// [`Tone`] is closed and stays closed, so this is total and needs no
177 /// fallback arm.
178 #[must_use]
179 pub const fn tone(&self, tone: Tone) -> Style {
180 match tone {
181 Tone::Neutral => self.content,
182 Tone::Info => self.info,
183 Tone::Success => self.success,
184 Tone::Warning => self.warning,
185 Tone::Danger => self.danger,
186 }
187 }
188
189 /// The style a heading reads as.
190 #[must_use]
191 pub const fn heading(&self, level: Heading) -> Style {
192 match level {
193 Heading::Page => self.page,
194 Heading::Section => self.section,
195 Heading::Subsection => self.subsection,
196 }
197 }
198
199 /// `style`, plus the mark that says the user is on this one.
200 ///
201 /// Takes the flag rather than being called behind an `if`, because every
202 /// caller has a bool in hand and the branch is the part that gets forgotten.
203 #[must_use]
204 pub fn focused(&self, focused: bool, style: Style) -> Style {
205 if focused {
206 style.add_modifier(self.focus)
207 } else {
208 style
209 }
210 }
211 }
212
213 /// What a field currently holds, which a description never carries.
214 ///
215 /// The terminal counterpart of `makeover_immediate::Filling`, and the same seam:
216 /// there the widget writes through a `&mut` as the value is edited, and here the
217 /// caller keeps an edit buffer and lends it out for the draw. Neither is
218 /// something [`Field`] could carry without becoming a form model.
219 ///
220 /// An enum rather than a bag of options, for `Filling`'s reason: a checkbox
221 /// holding a string is unsayable here, where a struct would let it be said and
222 /// then have to cope.
223 #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
224 pub enum Held<'a> {
225 /// Nothing typed and nothing chosen. The control draws empty.
226 #[default]
227 Absent,
228 /// What is in the box, or the `value` of the chosen [`Choice`].
229 ///
230 /// [`Choice`]: makeover_layout::Choice
231 Text(&'a str),
232 /// A checkbox, on or off.
233 On(bool),
234 }
235
236 impl<'a> Held<'a> {
237 /// What is typed, as a string. A checkbox has no text and answers empty.
238 #[must_use]
239 pub const fn text(self) -> &'a str {
240 match self {
241 Self::Text(text) => text,
242 Self::Absent | Self::On(_) => "",
243 }
244 }
245
246 /// Whether a checkbox is ticked.
247 #[must_use]
248 pub const fn on(self) -> bool {
249 matches!(self, Self::On(true))
250 }
251 }
252
253 /// A proportion as one line: the bar, then the reading beside it.
254 ///
255 /// The reading is built here from the two numbers and the noun rather than
256 /// taken assembled, which is what [`Meter::label`] carrying the noun alone is
257 /// for: a terminal at one line and a tooltip want different sentence orders.
258 #[must_use]
259 pub fn meter(style: &WidgetStyle, meter: &Meter<'_>) -> Line<'static> {
260 let cells = u32::from(style.meter_cells);
261 let filled = meter
262 .done
263 .checked_mul(cells)
264 .and_then(|reached| reached.checked_div(meter.total))
265 .unwrap_or(0)
266 .min(cells);
267 let bar = format!(
268 "{}{}",
269 style.meter_full.to_string().repeat(filled as usize),
270 style.meter_empty.to_string().repeat((cells - filled) as usize)
271 );
272 let reading = match meter.label {
273 Some(label) => format!(" {}/{} {label}", meter.done, meter.total),
274 None => format!(" {}/{}", meter.done, meter.total),
275 };
276 Line::from(vec![
277 Span::styled(bar, style.tone(meter.tone)),
278 Span::styled(reading, style.muted),
279 ])
280 }
281
282 /// A badge or a chip as one span.
283 ///
284 /// Round for a badge, square for a chip. A chip answers a press and a badge does
285 /// not, and the bracket is the only affordance a cell has left once colour is
286 /// spent on the tone.
287 ///
288 /// `latched` is a chip that is switched on, and it reads as reversed. So does
289 /// focus, which is a collision a terminal cannot avoid: latched is "this filter
290 /// is on" and focused is "you are here", and there is one spare axis for two
291 /// facts. Said here rather than resolved by inventing a third look nobody would
292 /// read.
293 ///
294 /// A chip's removable half is not drawn. The `x` a webview hangs on a chip is a
295 /// second control inside one span, and a terminal reaches a control by focusing
296 /// it; two targets in one cell run is a question for whoever owns the
297 /// interaction, not for a drawing.
298 #[must_use]
299 pub fn token(
300 style: &WidgetStyle,
301 label: &str,
302 kind: Token,
303 tone: Tone,
304 latched: bool,
305 focused: bool,
306 ) -> Span<'static> {
307 let painted = style.tone(tone);
308 let painted = if latched {
309 painted.add_modifier(style.focus)
310 } else {
311 style.focused(focused, painted)
312 };
313 match kind {
314 Token::Badge => Span::styled(format!("({label})"), painted),
315 Token::Chip { .. } => Span::styled(format!("[{label}]"), painted),
316 }
317 }
318
319 /// A control as one line.
320 ///
321 /// `< Label > (key)`, and the key only where the description named one. That
322 /// member is the one place `makeover-layout` anticipated a terminal before there
323 /// was one, and this is the renderer that reads it.
324 ///
325 /// A disabled control is drawn muted and is not marked focused, whatever the
326 /// caller passed: it is present, visible and not answering, so a focus mark on
327 /// it would be an affordance that lies. Whether it is reachable at all is the
328 /// caller's count to keep — ask [`Act::disabled`].
329 #[must_use]
330 pub fn act(style: &WidgetStyle, act: &Act<'_>, focused: bool) -> Line<'static> {
331 let painted = if act.disabled() {
332 style.muted
333 } else {
334 style.focused(focused, style.tone(act.tone))
335 };
336 let label = match act.key {
337 Some(key) => format!("< {} > ({key})", act.label),
338 None => format!("< {} >", act.label),
339 };
340 Line::from(Span::styled(label, painted))
341 }
342
343 /// A control filled with the action colour, for the one press a screen is about.
344 ///
345 /// `[ Label ]` rather than `< Label >`, which is the weight difference a webview
346 /// carries as a primary-versus-secondary button. A form's submit is the case
347 /// this exists for.
348 #[must_use]
349 pub fn filled_act(style: &WidgetStyle, label: &str, focused: bool) -> Line<'static> {
350 Line::from(Span::styled(
351 format!("[ {label} ]"),
352 style.focused(focused, style.filled),
353 ))
354 }
355
356 /// The rows [`figure`] wants at `width`.
357 #[must_use]
358 pub fn figure_height(figure: &Figure<'_>, width: u16) -> u16 {
359 text::height(figure.value, width) + text::height(figure.caption, width)
360 }
361
362 /// A figure: the number, then what it counts under it.
363 ///
364 /// The tone lands on the value and its change rather than on the caption, which
365 /// is what [`Figure::tone`] means: the figure is an ordinary fact and it is the
366 /// movement that reads as good or bad.
367 pub fn figure(style: &WidgetStyle, figure: &Figure<'_>, area: Rect, buf: &mut Buffer) -> u16 {
368 let value = match figure.change {
369 Some(change) => format!("{} {change}", figure.value),
370 None => figure.value.to_owned(),
371 };
372 let used = text::draw(
373 &value,
374 style.tone(figure.tone).add_modifier(Modifier::BOLD),
375 area,
376 buf,
377 );
378 used + text::draw(figure.caption, style.muted, below(area, used), buf)
379 }
380
381 /// The rows [`field`] wants at `width`.
382 ///
383 /// A label row, the control's rows, and a row for whatever went wrong. A hidden
384 /// field is nothing at all, which is the one field kind a terminal and a webview
385 /// agree on completely.
386 #[must_use]
387 pub fn field_height(style: &WidgetStyle, field: &Field<'_>, width: u16) -> u16 {
388 if !field.kind.visible() {
389 return 0;
390 }
391 let label = text::height(&label_of(style, field), width);
392 let body = match field.kind {
393 FieldKind::Textarea => 3,
394 kind if kind.offers_options() => u16::try_from(field.options.len()).unwrap_or(u16::MAX),
395 _ => 1,
396 };
397 let note = note_of(field).map_or(0, |note| text::height(note, width));
398 label + body + note
399 }
400
401 /// A question: its label, the box, and its standing help or what is wrong now.
402 ///
403 /// `held` is what the user has done to it since the screen arrived, which is the
404 /// argument a description cannot supply. See [`Held`].
405 ///
406 /// `focused` marks the box rather than the label, because the box is where the
407 /// typing lands.
408 pub fn field(
409 style: &WidgetStyle,
410 field: &Field<'_>,
411 held: Held<'_>,
412 focused: bool,
413 area: Rect,
414 buf: &mut Buffer,
415 ) -> u16 {
416 // A hidden field is data travelling with the form. There is nothing to
417 // draw, and whoever submits carries it.
418 if !field.kind.visible() || area.width == 0 || area.height == 0 {
419 return 0;
420 }
421
422 let mut used = text::draw(&label_of(style, field), style.secondary, area, buf);
423
424 let well = style.focused(focused, style.content);
425 let placeholder = field.placeholder.unwrap_or_default();
426
427 used += match field.kind {
428 FieldKind::Checkbox => text::draw(
429 if held.on() { "[x]" } else { "[ ]" },
430 well,
431 below(area, used),
432 buf,
433 ),
434 kind if kind.offers_options() => {
435 let mut rows = 0;
436 for choice in field.options {
437 let chosen = held.text() == choice.value;
438 let mark = if chosen { "(*)" } else { "( )" };
439 rows += text::draw(
440 &format!("{mark} {}", choice.label),
441 if chosen { well } else { style.muted },
442 below(area, used + rows),
443 buf,
444 );
445 }
446 rows
447 }
448 // A secret's dots come from the caller's buffer and can come from
449 // nowhere else: a password that comes back down the wire is a password
450 // in a page and in a proxy log, so a description carries nothing to dot
451 // out. This is the one control that would be undrawable without `held`.
452 FieldKind::Secret if !held.text().is_empty() => {
453 let dots = "*".repeat(held.text().chars().count());
454 text::draw(&dots, well, below(area, used), buf).max(1)
455 }
456 // A file field has no way back on a terminal any more than it has on an
457 // HTTP host. The name is drawn and picking one belongs to whoever owns
458 // the interaction.
459 _ if held.text().is_empty() => empty_well(style, placeholder, well, focused, below(area, used), buf),
460 _ => text::draw(held.text(), well, below(area, used), buf),
461 };
462
463 // The error wins over the hint, the same order a webview uses: a hint is
464 // what to type and an error is what went wrong, and once something has gone
465 // wrong that is the sentence worth the row.
466 match note_of(field) {
467 Some(note) => {
468 let painted = if field.error.is_some() {
469 style.danger
470 } else {
471 style.muted
472 };
473 used + text::draw(note, painted, below(area, used), buf)
474 }
475 None => used,
476 }
477 }
478
479 /// The label, marked where the field is compulsory.
480 fn label_of(style: &WidgetStyle, field: &Field<'_>) -> String {
481 if field.required {
482 format!("{} {}", field.label, style.required_marker)
483 } else {
484 field.label.to_owned()
485 }
486 }
487
488 /// What goes under the box: what is wrong now, or the standing help.
489 fn note_of<'a>(field: &Field<'a>) -> Option<&'a str> {
490 field.error.or(field.hint)
491 }
492
493 /// A box with nothing in it: the ghost text, and the caret when it has focus.
494 ///
495 /// The caret is not decoration. An empty field under a style is an empty field,
496 /// so a focused one with no placeholder drew literally nothing and there was no
497 /// way to tell the box was where the typing would go. A browser has a blinking
498 /// bar for this and gets it without asking; a terminal has one cell of reversed
499 /// video, put on the first column, which is where the first character lands.
500 fn empty_well(
501 style: &WidgetStyle,
502 placeholder: &str,
503 well: Style,
504 focused: bool,
505 area: Rect,
506 buf: &mut Buffer,
507 ) -> u16 {
508 let used = text::draw(placeholder, style.muted, area, buf).max(1);
509 if focused
510 && area.height > 0
511 && area.width > 0
512 && let Some(cell) = buf.cell_mut((area.x, area.y))
513 {
514 cell.set_style(well);
515 }
516 used
517 }
518
519 /// What is left of `area` after `used` rows from the top.
520 fn below(area: Rect, used: u16) -> Rect {
521 let used = used.min(area.height);
522 Rect {
523 x: area.x,
524 y: area.y + used,
525 width: area.width,
526 height: area.height - used,
527 }
528 }
529
530 #[cfg(test)]
531 mod tests {
532 use super::*;
533 use makeover_layout::{Choice, State};
534
535 /// The style the drawings are read against: one distinguishable modifier
536 /// per role, so a test can say which style landed without a colour.
537 fn style() -> WidgetStyle {
538 WidgetStyle {
539 content: Style::new().add_modifier(Modifier::BOLD),
540 muted: Style::new().add_modifier(Modifier::DIM),
541 danger: Style::new().add_modifier(Modifier::CROSSED_OUT),
542 ..WidgetStyle::default()
543 }
544 }
545
546 fn buffer(width: u16, height: u16) -> Buffer {
547 Buffer::empty(Rect::new(0, 0, width, height))
548 }
549
550 /// Everything in the buffer, one string per row.
551 fn rows(buf: &Buffer) -> Vec<String> {
552 (0..buf.area.height)
553 .map(|y| {
554 (0..buf.area.width)
555 .map(|x| buf.cell((x, y)).map_or(' ', |c| c.symbol().chars().next().unwrap_or(' ')))
556 .collect::<String>()
557 .trim_end()
558 .to_owned()
559 })
560 .collect()
561 }
562
563 #[test]
564 fn a_bar_fills_in_proportion_and_reads_out_the_two_numbers() {
565 let style = style();
566 let line = meter(&style, &Meter::new(3, 10).label("subtasks"));
567 let drawn: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
568 assert_eq!(drawn, "###------- 3/10 subtasks");
569 // The noun is optional and the ratio is not, because a bar with no
570 // reading is a bar you cannot check.
571 let bare = meter(&style, &Meter::new(3, 10));
572 let drawn: String = bare.spans.iter().map(|s| s.content.as_ref()).collect();
573 assert_eq!(drawn, "###------- 3/10");
574 }
575
576 #[test]
577 fn an_empty_set_is_an_empty_bar_rather_than_a_divide_by_zero() {
578 // `Meter::total` of zero means there is no set, and the checked
579 // division is what keeps that from being a panic in a draw.
580 let line = meter(&style(), &Meter::new(0, 0));
581 let drawn: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
582 assert_eq!(drawn, "---------- 0/0");
583 }
584
585 #[test]
586 fn an_over_run_fills_the_bar_and_still_reports_the_overflow() {
587 // The clamp is for drawing only. The reading is what keeps the fact
588 // `Meter::percent` destroys.
589 let line = meter(&style(), &Meter::new(14, 10));
590 let drawn: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
591 assert_eq!(drawn, "########## 14/10");
592 }
593
594 #[test]
595 fn a_badge_is_round_and_a_chip_is_square() {
596 // The one affordance a cell has left once colour is spent on the tone,
597 // and the whole of how a terminal says "this one answers a press".
598 let style = style();
599 let badge = token(&style, "draft", Token::Badge, Tone::Neutral, false, false);
600 assert_eq!(badge.content.as_ref(), "(draft)");
601 let chip = token(
602 &style,
603 "rust",
604 Token::Chip { removable: false },
605 Tone::Neutral,
606 false,
607 false,
608 );
609 assert_eq!(chip.content.as_ref(), "[rust]");
610 }
611
612 #[test]
613 fn a_latched_chip_reads_the_same_as_a_focused_one() {
614 // The collision a terminal cannot avoid, asserted rather than left to
615 // be rediscovered: latched is "this filter is on" and focused is "you
616 // are here", and there is one spare axis for two facts.
617 let style = style();
618 let kind = Token::Chip { removable: false };
619 let latched = token(&style, "rust", kind, Tone::Neutral, true, false);
620 let focused = token(&style, "rust", kind, Tone::Neutral, false, true);
621 assert_eq!(latched.style, focused.style);
622 assert!(latched.style.add_modifier.contains(Modifier::REVERSED));
623 }
624
625 #[test]
626 fn a_control_draws_its_key_only_where_one_was_named() {
627 let style = style();
628 let line = act(&style, &Act::new("Delete"), false);
629 assert_eq!(line.spans[0].content.as_ref(), "< Delete >");
630 let line = act(&style, &Act::new("Quit").key("q"), false);
631 assert_eq!(line.spans[0].content.as_ref(), "< Quit > (q)");
632 }
633
634 #[test]
635 fn a_disabled_control_is_never_marked_focused() {
636 // Present, visible, and not answering. A focus mark on it would be an
637 // affordance that lies, so the flag is overridden rather than trusted.
638 let style = style();
639 let disabled = Act::new("Save").state(State::Disabled);
640 let line = act(&style, &disabled, true);
641 assert!(!line.spans[0].style.add_modifier.contains(Modifier::REVERSED));
642 assert_eq!(line.spans[0].style, style.muted);
643 // Focus is a state and does not suppress anything.
644 let focused_state = Act::new("Save").state(State::Focus);
645 let line = act(&style, &focused_state, true);
646 assert!(line.spans[0].style.add_modifier.contains(Modifier::REVERSED));
647 }
648
649 #[test]
650 fn a_danger_control_keeps_its_tone_under_focus() {
651 // Focus adds a modifier rather than repainting, so the fact that this
652 // is the button that destroys something survives being landed on.
653 let style = style();
654 let line = act(&style, &Act::new("Delete").tone(Tone::Danger), true);
655 assert_eq!(line.spans[0].style.add_modifier, style.danger.add_modifier | Modifier::REVERSED);
656 }
657
658 #[test]
659 fn a_figure_puts_the_number_over_what_it_counts() {
660 let style = style();
661 let figure_ = Figure::new("42", "open tasks");
662 let mut buf = buffer(20, 4);
663 let used = figure(&style, &figure_, buf.area, &mut buf);
664 assert_eq!(used, 2);
665 assert_eq!(rows(&buf)[..2], ["42".to_owned(), "open tasks".to_owned()]);
666 assert_eq!(figure_height(&figure_, 20), 2);
667 }
668
669 #[test]
670 fn a_figures_change_rides_on_the_value_row() {
671 // The delta is the toned part and the value is an ordinary fact, so the
672 // two share a row rather than the caption growing a second sentence.
673 let style = style();
674 let figure_ = Figure::new("42", "open tasks").change("+3").tone(Tone::Success);
675 let mut buf = buffer(20, 4);
676 figure(&style, &figure_, buf.area, &mut buf);
677 assert_eq!(rows(&buf)[0], "42 +3");
678 }
679
680 #[test]
681 fn a_compulsory_field_says_so_in_its_label() {
682 let style = style();
683 let mut field_ = Field::new(FieldKind::Text, "email", "Email");
684 field_.required = true;
685 let mut buf = buffer(20, 4);
686 field(&style, &field_, Held::Absent, false, buf.area, &mut buf);
687 assert_eq!(rows(&buf)[0], "Email *");
688 }
689
690 #[test]
691 fn a_hidden_field_costs_no_rows_at_all() {
692 // The one field kind a terminal and a webview agree on completely.
693 let style = style();
694 let field_ = Field::new(FieldKind::Hidden, "csrf", "Token");
695 let mut buf = buffer(20, 4);
696 assert_eq!(field(&style, &field_, Held::Text("abc"), false, buf.area, &mut buf), 0);
697 assert_eq!(field_height(&style, &field_, 20), 0);
698 assert_eq!(rows(&buf)[0], "");
699 }
700
701 #[test]
702 fn a_secret_is_dotted_from_the_callers_buffer_and_never_from_the_description() {
703 // The one control that would be undrawable without `held`: a password
704 // that came back down the wire is a password in a page and in a log.
705 let style = style();
706 let field_ = Field::new(FieldKind::Secret, "password", "Password");
707 let mut buf = buffer(20, 4);
708 field(&style, &field_, Held::Text("hunter2"), false, buf.area, &mut buf);
709 assert_eq!(rows(&buf)[1], "*******");
710 }
711
712 #[test]
713 fn an_error_takes_the_row_the_hint_would_have_had() {
714 // Once something has gone wrong that is the sentence worth the row,
715 // which is the order a webview uses too.
716 let style = style();
717 let mut field_ = Field::new(FieldKind::Text, "email", "Email");
718 field_.hint = Some("work address");
719 field_.error = Some("not an address");
720 let mut buf = buffer(20, 5);
721 field(&style, &field_, Held::Text("nope"), false, buf.area, &mut buf);
722 assert_eq!(rows(&buf)[2], "not an address");
723 assert_eq!(field_height(&style, &field_, 20), 3);
724 }
725
726 #[test]
727 fn a_focused_empty_box_shows_where_the_typing_will_land() {
728 // An empty field under a style is an empty field. Without the caret a
729 // focused box with no placeholder drew literally nothing.
730 let style = style();
731 let field_ = Field::new(FieldKind::Text, "email", "Email");
732 let mut buf = buffer(20, 4);
733 field(&style, &field_, Held::Absent, true, buf.area, &mut buf);
734 let caret = buf.cell((0, 1)).expect("the well's first cell").style();
735 assert!(caret.add_modifier.contains(Modifier::REVERSED));
736 }
737
738 #[test]
739 fn a_choice_field_marks_the_chosen_option_and_costs_a_row_each() {
740 let style = style();
741 let mut field_ = Field::new(FieldKind::Radio, "size", "Size");
742 let options = [Choice::plain("small"), Choice::plain("large")];
743 field_.options = &options;
744 let mut buf = buffer(20, 5);
745 field(&style, &field_, Held::Text("large"), false, buf.area, &mut buf);
746 assert_eq!(rows(&buf)[1], "( ) small");
747 assert_eq!(rows(&buf)[2], "(*) large");
748 assert_eq!(field_height(&style, &field_, 20), 3);
749 }
750
751 #[test]
752 fn a_checkbox_reads_a_bool_rather_than_a_submitted_string() {
753 // `Held::On` exists so a host's own submission convention -- quasi
754 // sends "value" -- stays the host's and never reaches a drawing.
755 let style = style();
756 let field_ = Field::new(FieldKind::Checkbox, "agree", "Agree");
757 let mut buf = buffer(20, 4);
758 field(&style, &field_, Held::On(true), false, buf.area, &mut buf);
759 assert_eq!(rows(&buf)[1], "[x]");
760 let mut buf = buffer(20, 4);
761 field(&style, &field_, Held::On(false), false, buf.area, &mut buf);
762 assert_eq!(rows(&buf)[1], "[ ]");
763 }
764
765 #[test]
766 fn a_tone_and_a_heading_map_without_a_fallback_arm() {
767 // Both source enums are closed, which is what lets these be total. A
768 // renderer that had to guess would be picking its own colours again.
769 let style = style();
770 assert_eq!(style.tone(Tone::Neutral), style.content);
771 assert_eq!(style.tone(Tone::Danger), style.danger);
772 assert_eq!(style.heading(Heading::Page), style.page);
773 assert_eq!(style.heading(Heading::Subsection), style.subsection);
774 }
775
776 #[test]
777 fn the_default_style_carries_no_colour_at_all() {
778 // A two-colour terminal is the case where a foreground will not land,
779 // so the default is modifiers only rather than a placeholder palette.
780 let style = WidgetStyle::default();
781 for painted in [style.content, style.danger, style.page, style.action] {
782 assert_eq!(painted.fg, None);
783 assert_eq!(painted.bg, None);
784 }
785 }
786 }
787