use crate::bar::state::BarState;
use crate::utils::duration::{
format_duration_millis, format_duration_precise, format_elapsed, format_eta,
};
use crate::utils::format::{format_bytes, format_bytes_rate, format_count, format_rate};
use std::collections::HashMap;
use std::error::Error;
use std::fmt;
use std::num::NonZeroUsize;
use std::sync::Arc;
pub type BoxedTemplateKey = Box<dyn Fn(&BarState) -> String + Send + Sync>;
pub(crate) type SharedTemplateKey = Arc<dyn Fn(&BarState) -> String + Send + Sync>;
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum TemplatePart {
Literal(String),
Variable(TemplateVar),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TemplateVar {
pub name: String,
pub format_spec: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ParsedTemplate {
pub parts: Vec<TemplatePart>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TemplateError(pub String);
impl fmt::Display for TemplateError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl Error for TemplateError {}
pub fn parse_template(template: &str) -> Result<ParsedTemplate, TemplateError> {
let mut parts = Vec::new();
let mut literal = String::new();
let mut chars = template.chars().peekable();
while let Some(ch) = chars.next() {
match ch {
'{' => {
if !literal.is_empty() {
parts.push(TemplatePart::Literal(std::mem::take(&mut literal)));
}
let mut body = String::new();
let mut closed = false;
for inner in chars.by_ref() {
if inner == '}' {
closed = true;
break;
}
if inner == '{' {
return Err(TemplateError(
"nested template braces are not supported".into(),
));
}
body.push(inner);
}
if !closed {
return Err(TemplateError("unclosed template variable".into()));
}
if body.is_empty() {
return Err(TemplateError("empty template variable".into()));
}
let (name, format_spec) = match body.split_once(':') {
Some((name, spec)) => (name.to_string(), Some(spec.to_string())),
None => (body, None),
};
parts.push(TemplatePart::Variable(TemplateVar { name, format_spec }));
}
'}' => return Err(TemplateError("unmatched closing brace".into())),
_ => literal.push(ch),
}
}
if !literal.is_empty() {
parts.push(TemplatePart::Literal(literal));
}
Ok(ParsedTemplate { parts })
}
pub fn render_template(
parsed: &ParsedTemplate,
state: &BarState,
term_width: usize,
custom_keys: &HashMap<String, BoxedTemplateKey>,
) -> String {
render_parts(parsed, state, term_width, &|name, state| {
custom_keys.get(name).map(|f| f(state))
})
}
pub(crate) fn render_template_with_arc_keys(
parsed: &ParsedTemplate,
state: &BarState,
term_width: usize,
custom_keys: &HashMap<String, SharedTemplateKey>,
) -> String {
render_parts(parsed, state, term_width, &|name, state| {
custom_keys.get(name).map(|f| f(state))
})
}
fn render_parts(
parsed: &ParsedTemplate,
state: &BarState,
term_width: usize,
custom_key: &dyn Fn(&str, &BarState) -> Option<String>,
) -> String {
let dynamic_wide_bar_count = parsed
.parts
.iter()
.filter(|part| matches!(part, TemplatePart::Variable(var) if is_dynamic_wide_bar(var)))
.count();
let dynamic_wide_widths =
if let Some(dynamic_wide_bar_count) = NonZeroUsize::new(dynamic_wide_bar_count) {
let dynamic_wide_bar_count = dynamic_wide_bar_count.get();
let mut static_width = 0usize;
for part in &parsed.parts {
match part {
TemplatePart::Literal(text) => static_width += text.chars().count(),
TemplatePart::Variable(var) if is_dynamic_wide_bar(var) => {}
TemplatePart::Variable(var) => {
static_width += render_var(var, state, term_width, custom_key)
.chars()
.count()
}
}
}
let available = term_width.saturating_sub(static_width);
let mut widths = vec![available / dynamic_wide_bar_count; dynamic_wide_bar_count];
for width in widths.iter_mut().take(available % dynamic_wide_bar_count) {
*width += 1;
}
for width in &mut widths {
*width = (*width).max(1);
}
widths
} else {
Vec::new()
};
let mut out = String::new();
let mut dynamic_wide_index = 0usize;
for part in &parsed.parts {
match part {
TemplatePart::Literal(text) => out.push_str(text),
TemplatePart::Variable(var) if is_dynamic_wide_bar(var) => {
let width = dynamic_wide_widths
.get(dynamic_wide_index)
.copied()
.unwrap_or(1);
out.push_str(&render_bar(None, state, width));
dynamic_wide_index += 1;
}
TemplatePart::Variable(var) => {
out.push_str(&render_var(var, state, term_width, custom_key));
}
}
}
if let Some(spec) = &state.style.color_spec {
spec.render(&out)
} else {
out
}
}
fn render_var(
var: &TemplateVar,
state: &BarState,
term_width: usize,
custom_key: &dyn Fn(&str, &BarState) -> Option<String>,
) -> String {
if let Some(value) = custom_key(&var.name, state) {
return render_text_with_spec(&value, var.format_spec.as_deref());
}
match var.name.as_str() {
"bar" => render_bar(var.format_spec.as_deref(), state, 40),
"wide_bar" => render_bar(
var.format_spec.as_deref(),
state,
term_width.saturating_sub(30).max(1),
),
"pos" => state.pos.to_string(),
"count" => format_count(state.pos),
"human_pos" => format_count(state.pos),
"len" => state
.len
.map_or_else(|| "?".to_string(), |len| len.to_string()),
"total_count" => state.len.map_or_else(|| "?".to_string(), format_count),
"human_len" => state.len.map_or_else(|| "?".to_string(), format_count),
"remaining" => state.len.map_or_else(
|| "?".to_string(),
|len| len.saturating_sub(state.pos).to_string(),
),
"human_remaining" => state.len.map_or_else(
|| "?".to_string(),
|len| format_count(len.saturating_sub(state.pos)),
),
"ratio" => render_number(state.fraction(), var.format_spec.as_deref(), 2),
"percent" => render_percent(state, var.format_spec.as_deref()),
"elapsed" => format_elapsed(state.started_at.elapsed()),
"elapsed_precise" => format_duration_precise(state.started_at.elapsed()),
"elapsed_millis" => format_duration_millis(state.started_at.elapsed()),
"eta" => format_eta(state.eta()),
"eta_precise" => format_duration_precise(state.eta()),
"eta_millis" => format_duration_millis(state.eta()),
"per_sec" => render_number(state.per_sec(), var.format_spec.as_deref(), 2),
"rate" => format_rate(state.per_sec(), "items"),
"bytes" => format_bytes(state.pos),
"total_bytes" => state.len.map_or_else(|| "?".to_string(), format_bytes),
"bytes_per_sec" => format_bytes_rate(state.per_sec()),
"spinner" => state
.style
.get_tick_frame(state.spinner_frame_index)
.to_string(),
"msg" => render_text_with_spec(&state.message, var.format_spec.as_deref()),
"prefix" => render_text_with_spec(&state.prefix, var.format_spec.as_deref()),
"postfix" => render_text_with_spec(&state.postfix, var.format_spec.as_deref()),
"status" => render_text_with_spec(&render_status(state), var.format_spec.as_deref()),
"wide_msg" => render_wide_msg(&state.message, term_width, var.format_spec.as_deref()),
_ => String::new(),
}
}
fn render_status(state: &BarState) -> String {
if state.finished {
"finished".to_string()
} else if state.abandoned {
"abandoned".to_string()
} else if state.len.is_some() {
"running".to_string()
} else {
"spinning".to_string()
}
}
fn render_percent(state: &BarState, spec: Option<&str>) -> String {
let decimals = parse_decimal_spec(spec).unwrap_or(0);
if decimals == 0 {
format!("{:.0}", state.fraction() * 100.0)
} else {
format!("{:.*}", decimals, state.fraction() * 100.0)
}
}
fn render_number(value: f64, spec: Option<&str>, default_decimals: usize) -> String {
let decimals = parse_decimal_spec(spec).unwrap_or(default_decimals);
if value.is_finite() {
format!("{value:.decimals$}")
} else {
format!("{:.decimals$}", 0.0)
}
}
fn parse_decimal_spec(spec: Option<&str>) -> Option<usize> {
spec.and_then(|s| s.strip_prefix('.'))
.and_then(|s| s.parse::<usize>().ok())
}
#[derive(Clone, Copy)]
enum TextAlign {
Left,
Right,
Center,
}
fn parse_text_width_spec(spec: Option<&str>) -> Option<(TextAlign, usize)> {
let spec = spec?;
let (align, width) = if let Some(rest) = spec.strip_prefix('<') {
(TextAlign::Left, rest)
} else if let Some(rest) = spec.strip_prefix('>') {
(TextAlign::Right, rest)
} else if let Some(rest) = spec.strip_prefix('^') {
(TextAlign::Center, rest)
} else {
(TextAlign::Left, spec)
};
width.parse::<usize>().ok().map(|w| (align, w))
}
fn render_text_with_spec(value: &str, spec: Option<&str>) -> String {
if let Some((align, width)) = parse_text_width_spec(spec) {
fit_text(value, width, align)
} else {
value.to_string()
}
}
fn fit_text(value: &str, width: usize, align: TextAlign) -> String {
if width == 0 {
return String::new();
}
let count = value.chars().count();
if count > width {
return match align {
TextAlign::Right => value
.chars()
.rev()
.take(width)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect(),
TextAlign::Left | TextAlign::Center => value.chars().take(width).collect(),
};
}
if count == width {
return value.to_string();
}
let padding = width - count;
match align {
TextAlign::Left => format!("{value}{}", " ".repeat(padding)),
TextAlign::Right => format!("{}{}", " ".repeat(padding), value),
TextAlign::Center => {
let left = padding / 2;
let right = padding - left;
format!("{}{}{}", " ".repeat(left), value, " ".repeat(right))
}
}
}
fn render_bar(spec: Option<&str>, state: &BarState, default_width: usize) -> String {
let (width, chars) = parse_bar_spec(spec, default_width);
if let Some((fill, empty)) = chars {
render_custom_bar(width, state.fraction(), fill, empty)
} else {
state.style.render_bar_segment(width, state.fraction())
}
}
fn parse_bar_spec(spec: Option<&str>, default_width: usize) -> (usize, Option<(char, char)>) {
let Some(spec) = spec else {
return (default_width, None);
};
let (width_part, rest) = match spec.split_once('.') {
Some((width, rest)) => (width, Some(rest)),
None => (spec, None),
};
let width = width_part.parse::<usize>().unwrap_or(default_width);
let chars = rest.and_then(|rest| {
let (fill, empty) = rest.split_once('/')?;
let mut fill_chars = fill.chars();
let mut empty_chars = empty.chars();
let fill_char = fill_chars.next()?;
let empty_char = empty_chars.next()?;
if fill_chars.next().is_none() && empty_chars.next().is_none() {
Some((fill_char, empty_char))
} else {
None
}
});
(width, chars)
}
fn render_custom_bar(width: usize, fraction: f64, fill: char, empty: char) -> String {
if width == 0 {
return String::new();
}
let fraction = fraction.clamp(0.0, 1.0);
if fraction <= 0.0 {
return empty.to_string().repeat(width);
}
if fraction >= 1.0 {
return fill.to_string().repeat(width);
}
let filled = ((width as f64) * fraction).floor() as usize;
let filled = filled.min(width);
format!(
"{}{}",
fill.to_string().repeat(filled),
empty.to_string().repeat(width.saturating_sub(filled))
)
}
fn render_wide_msg(msg: &str, term_width: usize, spec: Option<&str>) -> String {
if let Some((align, width)) = parse_text_width_spec(spec) {
return fit_text(msg, width.max(1), align);
}
fit_text(msg, term_width.max(1), TextAlign::Left)
}
fn is_dynamic_wide_bar(var: &TemplateVar) -> bool {
var.name == "wide_bar" && var.format_spec.is_none()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::style::style::ProgressStyle;
fn state_with_len(pos: u64, len: Option<u64>) -> BarState {
let mut state = BarState::new(len, ProgressStyle::default_bar());
state.pos = pos;
state
}
#[test]
fn test_parse_literal_only() -> Result<(), TemplateError> {
let parsed = parse_template("hello")?;
assert_eq!(parsed.parts, vec![TemplatePart::Literal("hello".into())]);
Ok(())
}
#[test]
fn test_parse_single_variable() -> Result<(), TemplateError> {
let parsed = parse_template("{pos}")?;
assert_eq!(parsed.parts.len(), 1);
Ok(())
}
#[test]
fn test_parse_mixed() -> Result<(), TemplateError> {
let parsed = parse_template("x {pos} y")?;
assert_eq!(parsed.parts.len(), 3);
Ok(())
}
#[test]
fn test_parse_bar_with_width() -> Result<(), TemplateError> {
let parsed = parse_template("{bar:20}")?;
match &parsed.parts[0] {
TemplatePart::Variable(var) => assert_eq!(var.format_spec.as_deref(), Some("20")),
TemplatePart::Literal(_) => return Err(TemplateError("expected variable".into())),
}
Ok(())
}
#[test]
fn test_parse_bar_with_chars() -> Result<(), TemplateError> {
let parsed = parse_template("{bar:20.#/-}")?;
match &parsed.parts[0] {
TemplatePart::Variable(var) => assert_eq!(var.format_spec.as_deref(), Some("20.#/-")),
TemplatePart::Literal(_) => return Err(TemplateError("expected variable".into())),
}
Ok(())
}
#[test]
fn test_parse_unknown_variable_passes() -> Result<(), TemplateError> {
let parsed = parse_template("{unknown}")?;
let rendered = render_template(&parsed, &state_with_len(0, Some(1)), 80, &HashMap::new());
assert_eq!(rendered, "");
Ok(())
}
#[test]
fn test_render_pos() -> Result<(), TemplateError> {
let parsed = parse_template("{pos}")?;
assert_eq!(
render_template(&parsed, &state_with_len(3, Some(5)), 80, &HashMap::new()),
"3"
);
Ok(())
}
#[test]
fn test_render_len_unknown() -> Result<(), TemplateError> {
let parsed = parse_template("{len}")?;
assert_eq!(
render_template(&parsed, &state_with_len(0, None), 80, &HashMap::new()),
"?"
);
Ok(())
}
#[test]
fn test_render_percent() -> Result<(), TemplateError> {
let parsed = parse_template("{percent}")?;
assert_eq!(
render_template(&parsed, &state_with_len(1, Some(4)), 80, &HashMap::new()),
"25"
);
Ok(())
}
#[test]
fn test_render_count_variables() -> Result<(), TemplateError> {
let parsed = parse_template("{count}/{total_count}/{human_remaining}")?;
assert_eq!(
render_template(
&parsed,
&state_with_len(1_200, Some(2_000_000)),
80,
&HashMap::new()
),
"1.2k/2.0M/2.0M"
);
Ok(())
}
#[test]
fn test_render_ratio_and_remaining() -> Result<(), TemplateError> {
let parsed = parse_template("{ratio:.3} {remaining}")?;
assert_eq!(
render_template(&parsed, &state_with_len(1, Some(4)), 80, &HashMap::new()),
"0.250 3"
);
Ok(())
}
#[test]
fn test_render_bar_fill() -> Result<(), TemplateError> {
let parsed = parse_template("{bar:5.#/-}")?;
assert_eq!(
render_template(&parsed, &state_with_len(5, Some(5)), 80, &HashMap::new()),
"#####"
);
Ok(())
}
#[test]
fn test_render_bar_empty() -> Result<(), TemplateError> {
let parsed = parse_template("{bar:5.#/-}")?;
assert_eq!(
render_template(&parsed, &state_with_len(0, Some(5)), 80, &HashMap::new()),
"-----"
);
Ok(())
}
#[test]
fn test_render_wide_bar_uses_terminal_width() -> Result<(), TemplateError> {
let parsed = parse_template("{wide_bar}")?;
let rendered = render_template(&parsed, &state_with_len(5, Some(10)), 52, &HashMap::new());
assert_eq!(rendered.chars().count(), 52);
Ok(())
}
#[test]
fn test_render_wide_bar_fills_remaining_width() -> Result<(), TemplateError> {
let parsed = parse_template("pre {wide_bar} post")?;
let rendered = render_template(&parsed, &state_with_len(5, Some(10)), 24, &HashMap::new());
assert_eq!(rendered.chars().count(), 24);
Ok(())
}
#[test]
fn test_render_wide_bar_respects_explicit_width() -> Result<(), TemplateError> {
let parsed = parse_template("{wide_bar:12.#/-}")?;
let rendered = render_template(&parsed, &state_with_len(5, Some(10)), 120, &HashMap::new());
assert_eq!(rendered, "######------");
Ok(())
}
#[test]
fn test_render_spinner() -> Result<(), TemplateError> {
let parsed = parse_template("{spinner}")?;
let state = BarState::new(None, ProgressStyle::default_spinner());
assert!(!render_template(&parsed, &state, 80, &HashMap::new()).is_empty());
Ok(())
}
#[test]
fn test_render_elapsed() -> Result<(), TemplateError> {
let parsed = parse_template("{elapsed}")?;
let state = state_with_len(0, Some(1));
assert!(render_template(&parsed, &state, 80, &HashMap::new()).ends_with('s'));
Ok(())
}
#[test]
fn test_render_precise_millis_and_status() -> Result<(), TemplateError> {
let parsed = parse_template("{elapsed_millis} {eta_millis} {status}")?;
let mut state = state_with_len(0, Some(1));
state.finished = true;
let rendered = render_template(&parsed, &state, 80, &HashMap::new());
assert!(rendered.contains("00:00:00."));
assert!(rendered.ends_with("finished"));
Ok(())
}
#[test]
fn test_render_rate_variable() -> Result<(), TemplateError> {
let parsed = parse_template("{rate}")?;
let state = state_with_len(0, Some(1));
assert!(render_template(&parsed, &state, 80, &HashMap::new()).ends_with("items/s"));
Ok(())
}
#[test]
fn test_render_message_padding_right_align() -> Result<(), TemplateError> {
let parsed = parse_template("{msg:>8}")?;
let mut state = state_with_len(0, Some(1));
state.message = "ok".into();
assert_eq!(
render_template(&parsed, &state, 80, &HashMap::new()),
" ok"
);
Ok(())
}
#[test]
fn test_render_message_truncation() -> Result<(), TemplateError> {
let parsed = parse_template("{msg:4}")?;
let mut state = state_with_len(0, Some(1));
state.message = "loading".into();
assert_eq!(
render_template(&parsed, &state, 80, &HashMap::new()),
"load"
);
Ok(())
}
#[test]
fn test_render_custom_key_with_padding() -> Result<(), TemplateError> {
let parsed = parse_template("{phase:^7}")?;
let mut keys: HashMap<String, BoxedTemplateKey> = HashMap::new();
keys.insert("phase".into(), Box::new(|_| "step".to_string()));
assert_eq!(
render_template(&parsed, &state_with_len(0, Some(1)), 80, &keys),
" step "
);
Ok(())
}
#[test]
fn test_render_prefix_postfix() -> Result<(), TemplateError> {
let parsed = parse_template("{prefix}:{postfix}")?;
let mut state = state_with_len(0, Some(1));
state.prefix = "pre".into();
state.postfix = "post".into();
assert_eq!(
render_template(&parsed, &state, 80, &HashMap::new()),
"pre:post"
);
Ok(())
}
#[test]
fn test_render_bytes() -> Result<(), TemplateError> {
let parsed = parse_template("{bytes}")?;
assert_eq!(
render_template(
&parsed,
&state_with_len(1024, Some(2048)),
80,
&HashMap::new()
),
"1.00 KB"
);
Ok(())
}
#[test]
fn test_render_custom_key() -> Result<(), TemplateError> {
let parsed = parse_template("{phase}")?;
let mut keys: HashMap<String, BoxedTemplateKey> = HashMap::new();
keys.insert("phase".into(), Box::new(|state| format!("p{}", state.pos)));
assert_eq!(
render_template(&parsed, &state_with_len(7, Some(10)), 80, &keys),
"p7"
);
Ok(())
}
}