use makeover_layout::{Act, Field, FieldKind, Figure, Heading, Meter, Token, Tone};
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use crate::text;
#[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),
}
impl<'a> Held<'a> {
#[must_use]
pub const fn text(self) -> &'a str {
match self {
Self::Text(text) => text,
Self::Absent | Self::On(_) => "",
}
}
#[must_use]
pub const fn on(self) -> bool {
matches!(self, Self::On(true))
}
}
#[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 {
FieldKind::Textarea => 3,
kind if kind.offers_options() => u16::try_from(field.options.len()).unwrap_or(u16::MAX),
_ => 1,
};
let note = note_of(field).map_or(0, |note| text::height(note, 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,
),
kind if kind.offers_options() => {
let mut rows = 0;
for choice in field.options {
let chosen = held.text() == choice.value;
let mark = if chosen { "(*)" } else { "( )" };
rows += text::draw(
&format!("{mark} {}", choice.label),
if chosen { well } else { style.muted },
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(held.text(), well, below(area, used), buf),
};
match note_of(field) {
Some(note) => {
let painted = if field.error.is_some() {
style.danger
} else {
style.muted
};
used + text::draw(note, painted, below(area, used), buf)
}
None => used,
}
}
fn label_of(style: &PieceStyle, field: &Field<'_>) -> String {
if field.required {
format!("{} {}", field.label, style.required_marker)
} else {
field.label.to_owned()
}
}
fn note_of<'a>(field: &Field<'a>) -> Option<&'a str> {
field.error.or(field.hint)
}
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 {
use super::*;
use makeover_layout::{Choice, State};
fn style() -> PieceStyle {
PieceStyle {
content: Style::new().add_modifier(Modifier::BOLD),
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_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_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);
}
}
}