use makeover_layout::{
Act, Awaiting, Bar, Chart, 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, Default)]
pub struct BadgeStyle {
pub fill: Style,
pub edge: Style,
}
#[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 badges: [BadgeStyle; 5],
pub badge_edges: Option<[&'static str; 2]>,
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),
badges: [BadgeStyle::default(); 5],
badge_edges: None,
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),
badges: {
let badge = |fill, edge| BadgeStyle {
fill: Style::new().fg(theme.content_primary).bg(fill),
edge: Style::new().fg(edge),
};
[
badge(theme.row_hover, theme.line_border),
badge(theme.status_info_surface, theme.status_info),
badge(theme.status_success_surface, theme.status_success),
badge(theme.status_warning_surface, theme.status_warning),
badge(theme.status_danger_surface, theme.status_danger),
]
},
badge_edges: Some(["\u{258C}", "\u{2590}"]),
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 badge(&self, tone: Tone) -> BadgeStyle {
self.badges[match tone {
Tone::Neutral => 0,
Tone::Info => 1,
Tone::Success => 2,
Tone::Warning => 3,
Tone::Danger => 4,
}]
}
#[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,
) -> Line<'static> {
let mark = |painted: Style| {
if latched {
painted.add_modifier(style.focus)
} else {
style.focused(focused, painted)
}
};
match (kind, style.badge_edges) {
(Token::Badge, Some([open, close])) => {
let badge = style.badge(tone);
let edge = mark(badge.fill.patch(badge.edge));
Line::from(vec![
Span::styled(open, edge),
Span::styled(label.to_owned(), mark(badge.fill)),
Span::styled(close, edge),
])
}
(Token::Badge, None) => {
Line::from(Span::styled(format!("({label})"), mark(style.tone(tone))))
}
(Token::Chip { .. }, _) => {
Line::from(Span::styled(format!("[{label}]"), mark(style.tone(tone))))
}
}
}
#[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 act_note(style: &PieceStyle, act: &Act<'_>) -> Option<Line<'static>> {
act.hint
.map(|hint| Line::from(Span::styled(hint.to_owned(), style.muted)))
}
#[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,
);
if let Some(detail) = choice.detail {
rows += text::draw(detail, style.muted, indented(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 indented(area: Rect, used: u16) -> Rect {
const MARK: u16 = 4;
let area = below(area, used);
Rect {
x: area.x + MARK.min(area.width),
width: area.width.saturating_sub(MARK),
..area
}
}
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;
#[must_use]
pub fn chart(style: &PieceStyle, chart: &Chart<'_>, bars: &[Bar<'_>]) -> Vec<Line<'static>> {
let widest = bars
.iter()
.map(|bar| bar.at.chars().count())
.max()
.unwrap_or(0);
bars.iter()
.map(|bar| chart_line(style, chart, bar, widest))
.collect()
}
fn chart_line(
style: &PieceStyle,
chart: &Chart<'_>,
bar: &Bar<'_>,
widest: usize,
) -> Line<'static> {
let cells = usize::from(style.meter_cells);
let filled = if chart.most == 0 {
0
} else {
let scaled = (bar.value as u128 * cells as u128).div_ceil(chart.most as u128);
(scaled as usize).min(cells)
};
let mut spans = vec![Span::styled(
format!("{:width$} ", bar.at, width = widest),
style.secondary,
)];
spans.push(Span::styled(
format!(
"{}{}",
style.meter_full.to_string().repeat(filled),
style.meter_empty.to_string().repeat(cells - filled)
),
style.tone(chart.tone),
));
if let Some(reading) = chart_reading(bar) {
spans.push(Span::styled(reading, style.muted));
}
Line::from(spans)
}
fn chart_reading(bar: &Bar<'_>) -> Option<String> {
match (bar.reading, bar.note) {
(Some(reading), Some(note)) => Some(format!(" {reading} / {note}")),
(Some(only), None) | (None, Some(only)) => Some(format!(" {only}")),
(None, None) => None,
}
}