use makeover_layout::{
Act, Awaiting, Field, FieldKind, Figure, Heading, Meter, ThemeVariant, Token, Tone,
};
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use crate::text;
use std::time::Duration;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PieceStyle {
pub content: Style,
pub secondary: Style,
pub muted: Style,
pub info: Style,
pub success: Style,
pub warning: Style,
pub danger: Style,
pub page: Style,
pub section: Style,
pub subsection: Style,
pub action: Style,
pub filled: Style,
pub sunken: Style,
pub focus: Modifier,
pub meter_cells: u16,
pub meter_full: char,
pub meter_empty: char,
pub required_marker: &'static str,
}
impl Default for PieceStyle {
fn default() -> Self {
Self {
content: Style::new(),
secondary: Style::new(),
muted: Style::new().add_modifier(Modifier::DIM),
info: Style::new(),
success: Style::new(),
warning: Style::new(),
danger: Style::new().add_modifier(Modifier::BOLD),
page: Style::new().add_modifier(Modifier::BOLD),
section: Style::new().add_modifier(Modifier::BOLD),
subsection: Style::new(),
action: Style::new().add_modifier(Modifier::UNDERLINED),
filled: Style::new().add_modifier(Modifier::REVERSED),
sunken: Style::new().add_modifier(Modifier::DIM),
focus: Modifier::REVERSED,
meter_cells: 10,
meter_full: '#',
meter_empty: '-',
required_marker: "*",
}
}
}
impl PieceStyle {
#[cfg(feature = "theme")]
#[must_use]
pub fn from_theme(theme: &crate::Theme) -> Self {
Self {
content: Style::new().fg(theme.content_primary),
secondary: Style::new().fg(theme.content_secondary),
muted: Style::new().fg(theme.content_muted),
info: Style::new().fg(theme.status_info),
success: Style::new().fg(theme.status_success),
warning: Style::new().fg(theme.status_warning),
danger: Style::new().fg(theme.status_danger),
page: Style::new()
.fg(theme.action_primary)
.add_modifier(Modifier::BOLD),
section: Style::new()
.fg(theme.content_primary)
.add_modifier(Modifier::BOLD),
subsection: Style::new().fg(theme.content_secondary),
action: Style::new().fg(theme.action_primary),
filled: Style::new().fg(theme.selection_on).bg(theme.action_primary),
sunken: Style::new().bg(theme.surface_sunken),
focus: Modifier::REVERSED,
meter_cells: 10,
meter_full: '#',
meter_empty: '-',
required_marker: "*",
}
}
#[must_use]
pub const fn tone(&self, tone: Tone) -> Style {
match tone {
Tone::Neutral => self.content,
Tone::Info => self.info,
Tone::Success => self.success,
Tone::Warning => self.warning,
Tone::Danger => self.danger,
}
}
#[must_use]
pub const fn heading(&self, level: Heading) -> Style {
match level {
Heading::Page => self.page,
Heading::Section => self.section,
Heading::Subsection => self.subsection,
}
}
#[must_use]
pub fn focused(&self, focused: bool, style: Style) -> Style {
if focused {
style.add_modifier(self.focus)
} else {
style
}
}
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub enum Held<'a> {
#[default]
Absent,
Text(&'a str),
On(bool),
Between {
lower: &'a str,
upper: &'a str,
},
}
impl<'a> Held<'a> {
#[must_use]
pub const fn text(self) -> &'a str {
match self {
Self::Text(text) | Self::Between { lower: text, .. } => text,
Self::Absent | Self::On(_) => "",
}
}
#[must_use]
pub const fn upper(self) -> &'a str {
match self {
Self::Between { upper, .. } => upper,
Self::Absent | Self::Text(_) | Self::On(_) => "",
}
}
#[must_use]
pub const fn on(self) -> bool {
matches!(self, Self::On(true))
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct Progress {
pub delivered: Option<u64>,
pub elapsed: Option<Duration>,
}
#[must_use]
pub fn activity(style: &PieceStyle, lit: bool) -> Span<'static> {
if lit {
Span::styled(style.meter_full.to_string(), style.action)
} else {
Span::styled(style.meter_empty.to_string(), style.muted)
}
}
#[must_use]
pub fn awaiting(
style: &PieceStyle,
awaiting: Awaiting,
progress: Progress,
lit: bool,
) -> Line<'static> {
let Some(total) = awaiting.amount else {
return Line::from(vec![activity(style, lit)]);
};
let Some(done) = progress.delivered else {
return Line::from(vec![
activity(style, lit),
Span::styled(format!(" {total}"), style.muted),
]);
};
let cells = u32::from(style.meter_cells);
let filled = u32::try_from(
done.saturating_mul(u64::from(cells))
.checked_div(total)
.unwrap_or(0),
)
.unwrap_or(cells)
.min(cells);
let bar = format!(
"{}{}",
style.meter_full.to_string().repeat(filled as usize),
style
.meter_empty
.to_string()
.repeat((cells - filled) as usize)
);
let reading = match progress.elapsed {
Some(elapsed) => format!(" {done}/{total} {}s", elapsed.as_secs()),
None => format!(" {done}/{total}"),
};
Line::from(vec![
Span::styled(bar, style.action),
Span::styled(reading, style.muted),
])
}
#[must_use]
pub fn meter(style: &PieceStyle, meter: &Meter<'_>) -> Line<'static> {
let cells = u32::from(style.meter_cells);
let filled = meter
.done
.checked_mul(cells)
.and_then(|reached| reached.checked_div(meter.total))
.unwrap_or(0)
.min(cells);
let bar = format!(
"{}{}",
style.meter_full.to_string().repeat(filled as usize),
style
.meter_empty
.to_string()
.repeat((cells - filled) as usize)
);
let reading = match meter.label {
Some(label) => format!(" {}/{} {label}", meter.done, meter.total),
None => format!(" {}/{}", meter.done, meter.total),
};
Line::from(vec![
Span::styled(bar, style.tone(meter.tone)),
Span::styled(reading, style.muted),
])
}
#[must_use]
pub fn token(
style: &PieceStyle,
label: &str,
kind: Token,
tone: Tone,
latched: bool,
focused: bool,
) -> Span<'static> {
let painted = style.tone(tone);
let painted = if latched {
painted.add_modifier(style.focus)
} else {
style.focused(focused, painted)
};
match kind {
Token::Badge => Span::styled(format!("({label})"), painted),
Token::Chip { .. } => Span::styled(format!("[{label}]"), painted),
}
}
#[must_use]
pub fn act(style: &PieceStyle, act: &Act<'_>, focused: bool) -> Line<'static> {
let painted = if act.disabled() {
style.muted
} else {
style.focused(focused, style.tone(act.tone))
};
let label = match act.key {
Some(key) => format!("< {} > ({key})", act.label),
None => format!("< {} >", act.label),
};
Line::from(Span::styled(label, painted))
}
#[must_use]
pub fn filled_act(style: &PieceStyle, label: &str, focused: bool) -> Line<'static> {
Line::from(Span::styled(
format!("[ {label} ]"),
style.focused(focused, style.filled),
))
}
#[must_use]
pub fn figure_height(figure: &Figure<'_>, width: u16) -> u16 {
text::height(figure.value, width) + text::height(figure.caption, width)
}
pub fn figure(style: &PieceStyle, figure: &Figure<'_>, area: Rect, buf: &mut Buffer) -> u16 {
let value = match figure.change {
Some(change) => format!("{} {change}", figure.value),
None => figure.value.to_owned(),
};
let used = text::draw(
&value,
style.tone(figure.tone).add_modifier(Modifier::BOLD),
area,
buf,
);
used + text::draw(figure.caption, style.muted, below(area, used), buf)
}
#[must_use]
pub fn field_height(style: &PieceStyle, field: &Field<'_>, width: u16) -> u16 {
if !field.kind.visible() {
return 0;
}
let label = text::height(&label_of(style, field), width);
let body = match field.kind {
kind if kind.multiline() => 3,
kind if kind.offers_options() => u16::try_from(field.options.len()).unwrap_or(u16::MAX),
kind if kind.offers_themes() => {
let mut variants = 0u16;
let mut open: Option<ThemeVariant> = None;
for theme in field.themes {
if open != Some(theme.variant) {
variants = variants.saturating_add(1);
open = Some(theme.variant);
}
}
let rows = u16::try_from(field.themes.len()).unwrap_or(u16::MAX);
rows.saturating_add(variants)
.saturating_add(u16::from(field.follows.is_some()))
}
_ => 1,
};
let note = message_of(style, field).map_or(0, |(text, _)| text::height(text, width));
label + body + note
}
pub fn field(
style: &PieceStyle,
field: &Field<'_>,
held: Held<'_>,
focused: bool,
area: Rect,
buf: &mut Buffer,
) -> u16 {
if !field.kind.visible() || area.width == 0 || area.height == 0 {
return 0;
}
let mut used = text::draw(&label_of(style, field), style.secondary, area, buf);
let well = style.focused(focused, style.content);
let placeholder = field.placeholder.unwrap_or_default();
used += match field.kind {
FieldKind::Checkbox => text::draw(
if held.on() { "[x]" } else { "[ ]" },
well,
below(area, used),
buf,
),
FieldKind::Range if field.bounded() => {
let line = range_line(style, field, held.text(), well);
text::draw_line(&line, below(area, used), buf)
}
FieldKind::Interval => {
let line = interval_line(style, field, held, well);
text::draw_line(&line, below(area, used), buf)
}
kind if kind.offers_themes() => {
let mut rows = 0;
if let Some(follow) = field.follows {
let chosen = held.text() == follow.value;
let (mark, painted) = if chosen {
("(*)", well)
} else {
("( )", style.secondary)
};
rows += text::draw(
&format!("{mark} {}", follow.label),
painted,
below(area, used + rows),
buf,
);
}
let mut open: Option<ThemeVariant> = None;
for theme in field.themes {
if open != Some(theme.variant) {
rows += text::draw(
theme.variant.heading(),
style.muted,
below(area, used + rows),
buf,
);
open = Some(theme.variant);
}
let chosen = held.text() == theme.id;
let (mark, painted) = if chosen {
("(*)", well)
} else {
("( )", style.secondary)
};
rows += text::draw(
&format!("{mark} {} [{}]", theme.name, theme.contrast.badge()),
painted,
below(area, used + rows),
buf,
);
}
rows
}
kind if kind.offers_options() => {
let mut rows = 0;
for choice in field.options {
let chosen = held.text() == choice.value;
let (mark, painted, suffix) = match choice.unavailable {
Some(reason) => ("( )", style.muted, format!(": {reason}")),
None if chosen => ("(*)", well, String::new()),
None => ("( )", style.secondary, String::new()),
};
rows += text::draw(
&format!("{mark} {}{suffix}", choice.label),
painted,
below(area, used + rows),
buf,
);
}
rows
}
FieldKind::Secret if !held.text().is_empty() => {
let dots = "*".repeat(held.text().chars().count());
text::draw(&dots, well, below(area, used), buf).max(1)
}
_ if held.text().is_empty() => {
empty_well(style, placeholder, well, focused, below(area, used), buf)
}
_ => text::draw(&measured(field, held.text()), well, below(area, used), buf),
};
match message_of(style, field) {
Some((text, painted)) => used + text::draw(text, painted, below(area, used), buf),
None => used,
}
}
fn range_line(style: &PieceStyle, field: &Field<'_>, value: &str, well: Style) -> Line<'static> {
let cells = usize::from(style.meter_cells);
let ends = field
.min
.zip(field.max)
.and_then(|(min, max)| Some((min.parse::<f64>().ok()?, max.parse::<f64>().ok()?)));
let filled = match (ends, value.parse::<f64>()) {
(Some((min, max)), Ok(number)) if max > min => {
#[expect(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
reason = "`position_of` returns 0..=1, and the cell count came from a u16"
)]
let reached = (field.curve.position_of(number, min, max) * cells as f64) as usize;
reached.min(cells)
}
_ => 0,
};
let bar = format!(
"{}{}",
style.meter_full.to_string().repeat(filled),
style.meter_empty.to_string().repeat(cells - filled)
);
Line::from(vec![
Span::styled(format!("{} ", field.min.unwrap_or_default()), style.muted),
Span::styled(bar, well),
Span::styled(format!(" {}", field.max.unwrap_or_default()), style.muted),
Span::styled(format!(" {}", measured(field, value)), well),
])
}
fn interval_line(
style: &PieceStyle,
field: &Field<'_>,
held: Held<'_>,
well: Style,
) -> Line<'static> {
let end = |value: &str, fallback: Option<&str>| match (value.is_empty(), fallback) {
(false, _) => Span::styled(measured(field, value), well),
(true, Some(bound)) => Span::styled(measured(field, bound), style.muted),
(true, None) => Span::styled(String::new(), style.muted),
};
Line::from(vec![
end(held.text(), field.min),
Span::styled(" to ", style.secondary),
end(held.upper(), field.max),
])
}
fn unit_of<'a>(field: &Field<'a>) -> Option<&'a str> {
field.unit.filter(|_| field.kind.measurable())
}
fn measured(field: &Field<'_>, value: &str) -> String {
match unit_of(field) {
Some(unit) => format!("{value} {unit}"),
None => value.to_owned(),
}
}
fn label_of(style: &PieceStyle, field: &Field<'_>) -> String {
if field.required {
format!("{} {}", field.label, style.required_marker)
} else {
field.label.to_owned()
}
}
fn message_of<'a>(style: &PieceStyle, field: &Field<'a>) -> Option<(&'a str, Style)> {
if let Some(error) = field.error {
return Some((error, style.danger));
}
if let Some((tone, note)) = field.note {
return Some((note, style.tone(tone)));
}
field.hint.map(|hint| (hint, style.muted))
}
fn empty_well(
style: &PieceStyle,
placeholder: &str,
well: Style,
focused: bool,
area: Rect,
buf: &mut Buffer,
) -> u16 {
let used = text::draw(placeholder, style.muted, area, buf).max(1);
if focused
&& area.height > 0
&& area.width > 0
&& let Some(cell) = buf.cell_mut((area.x, area.y))
{
cell.set_style(well);
}
used
}
fn below(area: Rect, used: u16) -> Rect {
let used = used.min(area.height);
Rect {
x: area.x,
y: area.y + used,
width: area.width,
height: area.height - used,
}
}
#[cfg(test)]
mod tests {
#[test]
fn one_line_takes_the_error_then_the_note_then_the_hint() {
let style = PieceStyle::default();
let mut f = Field::new(FieldKind::Text, "title", "Title");
f.hint = Some("how it works");
assert_eq!(message_of(&style, &f).unwrap().0, "how it works");
f.note = Some((Tone::Warning, "what it costs"));
assert_eq!(message_of(&style, &f).unwrap().0, "what it costs");
assert_eq!(message_of(&style, &f).unwrap().1, style.warning);
f.error = Some("what is wrong");
assert_eq!(message_of(&style, &f).unwrap().0, "what is wrong");
assert_eq!(message_of(&style, &f).unwrap().1, style.danger);
f.error = None;
f.note = Some((Tone::Neutral, "an ordinary fact"));
assert_eq!(message_of(&style, &f).unwrap().1, style.content);
}
use super::*;
use makeover_layout::{Choice, State};
fn style() -> PieceStyle {
PieceStyle {
content: Style::new().add_modifier(Modifier::BOLD),
secondary: Style::new().add_modifier(Modifier::ITALIC),
muted: Style::new().add_modifier(Modifier::DIM),
danger: Style::new().add_modifier(Modifier::CROSSED_OUT),
..PieceStyle::default()
}
}
fn buffer(width: u16, height: u16) -> Buffer {
Buffer::empty(Rect::new(0, 0, width, height))
}
fn rows(buf: &Buffer) -> Vec<String> {
(0..buf.area.height)
.map(|y| {
(0..buf.area.width)
.map(|x| {
buf.cell((x, y))
.map_or(' ', |c| c.symbol().chars().next().unwrap_or(' '))
})
.collect::<String>()
.trim_end()
.to_owned()
})
.collect()
}
#[test]
fn a_bar_fills_in_proportion_and_reads_out_the_two_numbers() {
let style = style();
let line = meter(&style, &Meter::new(3, 10).label("subtasks"));
let drawn: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
assert_eq!(drawn, "###------- 3/10 subtasks");
let bare = meter(&style, &Meter::new(3, 10));
let drawn: String = bare.spans.iter().map(|s| s.content.as_ref()).collect();
assert_eq!(drawn, "###------- 3/10");
}
#[test]
fn an_empty_set_is_an_empty_bar_rather_than_a_divide_by_zero() {
let line = meter(&style(), &Meter::new(0, 0));
let drawn: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
assert_eq!(drawn, "---------- 0/0");
}
#[test]
fn an_over_run_fills_the_bar_and_still_reports_the_overflow() {
let line = meter(&style(), &Meter::new(14, 10));
let drawn: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
assert_eq!(drawn, "########## 14/10");
}
#[test]
fn a_badge_is_round_and_a_chip_is_square() {
let style = style();
let badge = token(&style, "draft", Token::Badge, Tone::Neutral, false, false);
assert_eq!(badge.content.as_ref(), "(draft)");
let chip = token(
&style,
"rust",
Token::Chip { removable: false },
Tone::Neutral,
false,
false,
);
assert_eq!(chip.content.as_ref(), "[rust]");
}
#[test]
fn a_latched_chip_reads_the_same_as_a_focused_one() {
let style = style();
let kind = Token::Chip { removable: false };
let latched = token(&style, "rust", kind, Tone::Neutral, true, false);
let focused = token(&style, "rust", kind, Tone::Neutral, false, true);
assert_eq!(latched.style, focused.style);
assert!(latched.style.add_modifier.contains(Modifier::REVERSED));
}
#[test]
fn a_control_draws_its_key_only_where_one_was_named() {
let style = style();
let line = act(&style, &Act::new("Delete"), false);
assert_eq!(line.spans[0].content.as_ref(), "< Delete >");
let line = act(&style, &Act::new("Quit").key("q"), false);
assert_eq!(line.spans[0].content.as_ref(), "< Quit > (q)");
}
#[test]
fn a_disabled_control_is_never_marked_focused() {
let style = style();
let disabled = Act::new("Save").state(State::Disabled);
let line = act(&style, &disabled, true);
assert!(
!line.spans[0]
.style
.add_modifier
.contains(Modifier::REVERSED)
);
assert_eq!(line.spans[0].style, style.muted);
let unstated = Act::new("Save");
let line = act(&style, &unstated, true);
assert!(
line.spans[0]
.style
.add_modifier
.contains(Modifier::REVERSED)
);
}
#[test]
fn a_danger_control_keeps_its_tone_under_focus() {
let style = style();
let line = act(&style, &Act::new("Delete").tone(Tone::Danger), true);
assert_eq!(
line.spans[0].style.add_modifier,
style.danger.add_modifier | Modifier::REVERSED
);
}
#[test]
fn a_figure_puts_the_number_over_what_it_counts() {
let style = style();
let figure_ = Figure::new("42", "open tasks");
let mut buf = buffer(20, 4);
let used = figure(&style, &figure_, buf.area, &mut buf);
assert_eq!(used, 2);
assert_eq!(rows(&buf)[..2], ["42".to_owned(), "open tasks".to_owned()]);
assert_eq!(figure_height(&figure_, 20), 2);
}
#[test]
fn a_figures_change_rides_on_the_value_row() {
let style = style();
let figure_ = Figure::new("42", "open tasks")
.change("+3")
.tone(Tone::Success);
let mut buf = buffer(20, 4);
figure(&style, &figure_, buf.area, &mut buf);
assert_eq!(rows(&buf)[0], "42 +3");
}
#[test]
fn a_compulsory_field_says_so_in_its_label() {
let style = style();
let mut field_ = Field::new(FieldKind::Text, "email", "Email");
field_.required = true;
let mut buf = buffer(20, 4);
field(&style, &field_, Held::Absent, false, buf.area, &mut buf);
assert_eq!(rows(&buf)[0], "Email *");
}
#[test]
fn a_hidden_field_costs_no_rows_at_all() {
let style = style();
let field_ = Field::new(FieldKind::Hidden, "csrf", "Token");
let mut buf = buffer(20, 4);
assert_eq!(
field(
&style,
&field_,
Held::Text("abc"),
false,
buf.area,
&mut buf
),
0
);
assert_eq!(field_height(&style, &field_, 20), 0);
assert_eq!(rows(&buf)[0], "");
}
#[test]
fn a_secret_is_dotted_from_the_callers_buffer_and_never_from_the_description() {
let style = style();
let field_ = Field::new(FieldKind::Secret, "password", "Password");
let mut buf = buffer(20, 4);
field(
&style,
&field_,
Held::Text("hunter2"),
false,
buf.area,
&mut buf,
);
assert_eq!(rows(&buf)[1], "*******");
}
#[test]
fn an_error_takes_the_row_the_hint_would_have_had() {
let style = style();
let mut field_ = Field::new(FieldKind::Text, "email", "Email");
field_.hint = Some("work address");
field_.error = Some("not an address");
let mut buf = buffer(20, 5);
field(
&style,
&field_,
Held::Text("nope"),
false,
buf.area,
&mut buf,
);
assert_eq!(rows(&buf)[2], "not an address");
assert_eq!(field_height(&style, &field_, 20), 3);
}
#[test]
fn a_focused_empty_box_shows_where_the_typing_will_land() {
let style = style();
let field_ = Field::new(FieldKind::Text, "email", "Email");
let mut buf = buffer(20, 4);
field(&style, &field_, Held::Absent, true, buf.area, &mut buf);
let caret = buf.cell((0, 1)).expect("the well's first cell").style();
assert!(caret.add_modifier.contains(Modifier::REVERSED));
}
#[test]
fn a_choice_field_marks_the_chosen_option_and_costs_a_row_each() {
let style = style();
let mut field_ = Field::new(FieldKind::Radio, "size", "Size");
let options = [Choice::plain("small"), Choice::plain("large")];
field_.options = &options;
let mut buf = buffer(20, 5);
field(
&style,
&field_,
Held::Text("large"),
false,
buf.area,
&mut buf,
);
assert_eq!(rows(&buf)[1], "( ) small");
assert_eq!(rows(&buf)[2], "(*) large");
assert_eq!(field_height(&style, &field_, 20), 3);
}
#[test]
fn a_range_draws_its_two_ends_and_where_the_value_sits_between_them() {
let style = style();
let field_ = Field::range("review", "Review above", "0", "1");
let mut buf = buffer(40, 3);
field(
&style,
&field_,
Held::Text("0.5"),
false,
buf.area,
&mut buf,
);
assert_eq!(rows(&buf)[1].trim_end(), "0 #####----- 1 0.5");
assert_eq!(field_height(&style, &field_, 40), 2);
}
#[test]
fn a_unit_rides_on_the_value_and_not_on_the_label() {
let style = style();
let field_ = Field {
unit: Some("s"),
..Field::range("attack", "Attack", "0", "5")
};
let mut buf = buffer(40, 3);
field(
&style,
&field_,
Held::Text("2.5"),
false,
buf.area,
&mut buf,
);
assert_eq!(rows(&buf)[0].trim_end(), "Attack");
assert_eq!(rows(&buf)[1].trim_end(), "0 #####----- 5 2.5 s");
}
#[test]
fn a_typed_number_reads_with_its_unit_too() {
let style = style();
let field_ = Field {
unit: Some("ms"),
..Field::new(FieldKind::Number, "fade", "Fade")
};
let mut buf = buffer(40, 3);
field(&style, &field_, Held::Text("50"), false, buf.area, &mut buf);
assert_eq!(rows(&buf)[1].trim_end(), "50 ms");
}
#[test]
fn a_unit_on_a_kind_that_is_not_a_quantity_is_ignored() {
let style = style();
let field_ = Field {
unit: Some("s"),
..Field::new(FieldKind::Text, "name", "Name")
};
let mut buf = buffer(40, 3);
field(
&style,
&field_,
Held::Text("kick"),
false,
buf.area,
&mut buf,
);
assert_eq!(rows(&buf)[1].trim_end(), "kick");
}
#[test]
fn an_interval_is_one_line_with_both_ends_on_it() {
let style = style();
let field_ = Field {
min: Some("0"),
max: Some("300"),
unit: Some("BPM"),
..Field::interval("bpm_min", "bpm_max", "BPM range")
};
let mut buf = buffer(40, 3);
field(
&style,
&field_,
Held::Between {
lower: "90",
upper: "130",
},
false,
buf.area,
&mut buf,
);
assert_eq!(rows(&buf)[0].trim_end(), "BPM range");
assert_eq!(rows(&buf)[1].trim_end(), "90 BPM to 130 BPM");
assert_eq!(rows(&buf)[2].trim_end(), "");
}
#[test]
fn an_open_end_falls_back_to_the_bound_it_means() {
let style = style();
let field_ = Field {
min: Some("0"),
max: Some("300"),
..Field::interval("bpm_min", "bpm_max", "BPM range")
};
let mut buf = buffer(40, 3);
field(
&style,
&field_,
Held::Between {
lower: "120",
upper: "",
},
false,
buf.area,
&mut buf,
);
assert_eq!(rows(&buf)[1].trim_end(), "120 to 300");
}
#[test]
fn an_unbounded_open_end_draws_nothing_rather_than_a_number() {
let style = style();
let field_ = Field::interval("bpm_min", "bpm_max", "BPM range");
let mut buf = buffer(40, 3);
field(
&style,
&field_,
Held::Between {
lower: "",
upper: "130",
},
false,
buf.area,
&mut buf,
);
assert_eq!(rows(&buf)[1].trim_end(), "to 130");
}
#[test]
fn a_range_holding_something_unreadable_still_shows_it() {
let style = style();
let field_ = Field::range("review", "Review above", "0", "1");
let mut buf = buffer(40, 3);
field(
&style,
&field_,
Held::Text("unset"),
false,
buf.area,
&mut buf,
);
assert_eq!(rows(&buf)[1].trim_end(), "0 ---------- 1 unset");
}
#[test]
fn an_unbounded_range_is_typed_into_rather_than_dragged() {
let style = style();
let field_ = Field {
max: Some("1"),
..Field::new(FieldKind::Range, "review", "Review above")
};
let mut buf = buffer(40, 3);
field(
&style,
&field_,
Held::Text("0.5"),
false,
buf.area,
&mut buf,
);
assert_eq!(rows(&buf)[1].trim_end(), "0.5");
}
#[test]
fn an_unavailable_option_reads_as_inert_and_says_why() {
let style = style();
let options = [
Choice::new("chromatic", "Chromatic"),
Choice::new("multi", "Multi-sample").unless("Drop a second sample."),
];
let mut field_ = Field::new(FieldKind::Radio, "mode", "Mode");
field_.options = &options;
let mut buf = buffer(46, 4);
field(
&style,
&field_,
Held::Text("chromatic"),
false,
buf.area,
&mut buf,
);
assert_eq!(rows(&buf)[1].trim_end(), "(*) Chromatic");
assert_eq!(
rows(&buf)[2].trim_end(),
"( ) Multi-sample: Drop a second sample."
);
let muted = buf.cell((0, 2)).expect("the unavailable row").style();
assert!(muted.add_modifier.contains(Modifier::DIM));
}
#[test]
fn an_unchosen_option_does_not_read_as_disabled() {
let style = style();
let mut field_ = Field::new(FieldKind::Radio, "size", "Size");
let options = [Choice::plain("small"), Choice::plain("large")];
field_.options = &options;
let mut buf = buffer(20, 5);
field(
&style,
&field_,
Held::Text("large"),
false,
buf.area,
&mut buf,
);
let unchosen = buf.cell((0, 1)).expect("the first option").style();
assert_eq!(unchosen.add_modifier, style.secondary.add_modifier);
assert_ne!(unchosen.add_modifier, style.muted.add_modifier);
}
#[test]
fn a_checkbox_reads_a_bool_rather_than_a_submitted_string() {
let style = style();
let field_ = Field::new(FieldKind::Checkbox, "agree", "Agree");
let mut buf = buffer(20, 4);
field(&style, &field_, Held::On(true), false, buf.area, &mut buf);
assert_eq!(rows(&buf)[1], "[x]");
let mut buf = buffer(20, 4);
field(&style, &field_, Held::On(false), false, buf.area, &mut buf);
assert_eq!(rows(&buf)[1], "[ ]");
}
#[test]
fn a_markdown_field_gets_the_rows_a_textarea_does() {
let style = PieceStyle::default();
let rich = Field::new(FieldKind::Rich, "body", "Body");
let textarea = Field::new(FieldKind::Textarea, "body", "Body");
let plain = Field::new(FieldKind::Text, "body", "Body");
assert_eq!(
field_height(&style, &rich, 40),
field_height(&style, &textarea, 40)
);
assert!(field_height(&style, &rich, 40) > field_height(&style, &plain, 40));
}
#[test]
fn a_tone_and_a_heading_map_without_a_fallback_arm() {
let style = style();
assert_eq!(style.tone(Tone::Neutral), style.content);
assert_eq!(style.tone(Tone::Danger), style.danger);
assert_eq!(style.heading(Heading::Page), style.page);
assert_eq!(style.heading(Heading::Subsection), style.subsection);
}
#[test]
fn the_default_style_carries_no_colour_at_all() {
let style = PieceStyle::default();
for painted in [style.content, style.danger, style.page, style.action] {
assert_eq!(painted.fg, None);
assert_eq!(painted.bg, None);
}
}
#[test]
fn the_three_states_of_a_wait_are_three_drawings() {
let style = PieceStyle::default();
let bare = awaiting(&style, Awaiting::unmeasured(), Progress::default(), true);
let sized = awaiting(&style, Awaiting::of(41_943_040), Progress::default(), true);
let watched = awaiting(
&style,
Awaiting::of(40),
Progress {
delivered: Some(20),
elapsed: Some(Duration::from_secs(4)),
},
true,
);
let read = |line: &Line<'_>| {
line.spans
.iter()
.map(|s| s.content.to_string())
.collect::<String>()
};
assert_eq!(read(&bare), "#");
assert_eq!(read(&sized), "# 41943040");
assert_eq!(read(&watched), "#####----- 20/40 4s");
}
#[test]
fn a_dark_mark_still_occupies_its_cell() {
let style = PieceStyle::default();
assert_eq!(activity(&style, true).content.chars().count(), 1);
assert_eq!(activity(&style, false).content.chars().count(), 1);
}
#[test]
fn an_over_delivered_wait_clamps_and_does_not_panic() {
let style = PieceStyle::default();
let over = awaiting(
&style,
Awaiting::of(4),
Progress {
delivered: Some(9),
elapsed: None,
},
true,
);
assert!(over.spans[0].content.chars().all(|c| c == '#'));
assert_eq!(over.spans[0].content.chars().count(), 10);
let empty = awaiting(
&style,
Awaiting::of(0),
Progress {
delivered: Some(9),
elapsed: None,
},
true,
);
assert!(empty.spans[0].content.starts_with('-'));
}
#[test]
fn a_theme_picker_heads_each_group_and_marks_each_tier() {
const THEMES: &[makeover_layout::ThemeChoice<'_>] = &[
makeover_layout::ThemeChoice::new(
"goingson",
"GoingsOn",
ThemeVariant::Light,
makeover_layout::Contrast::High,
),
makeover_layout::ThemeChoice::new(
"carbonfox",
"Carbonfox",
ThemeVariant::Dark,
makeover_layout::Contrast::Standard,
),
];
let style = style();
let field_ = Field::theme("theme", "Theme", THEMES)
.following(makeover_layout::Choice::new("system", "Follow System"));
let mut buf = buffer(32, 8);
field(
&style,
&field_,
Held::Text("carbonfox"),
false,
buf.area,
&mut buf,
);
let rows = rows(&buf);
assert_eq!(rows[1], "( ) Follow System");
assert_eq!(rows[2], "Light");
assert_eq!(rows[3], "( ) GoingsOn [AA]");
assert_eq!(rows[4], "Dark");
assert_eq!(rows[5], "(*) Carbonfox [OK]");
}
#[test]
fn a_theme_picker_asks_for_the_rows_it_draws() {
const THEMES: &[makeover_layout::ThemeChoice<'_>] = &[
makeover_layout::ThemeChoice::new(
"goingson",
"GoingsOn",
ThemeVariant::Light,
makeover_layout::Contrast::High,
),
makeover_layout::ThemeChoice::new(
"carbonfox",
"Carbonfox",
ThemeVariant::Dark,
makeover_layout::Contrast::Standard,
),
];
let style = style();
let field_ = Field::theme("theme", "Theme", THEMES)
.following(makeover_layout::Choice::new("system", "Follow System"));
assert_eq!(field_height(&style, &field_, 32), 6);
let one = Field::theme("theme", "Theme", &THEMES[..1]);
assert_eq!(field_height(&style, &one, 32), 3);
}
}