use crate::progress::{ProgressColumn, Task};
use crate::style::Style;
use crate::text::{JustifyMethod, Text};
#[derive(Debug, Clone)]
pub struct TextColumn {
pub text: String,
pub style: Option<Style>,
pub justify: JustifyMethod,
}
impl TextColumn {
pub fn new(text: &str) -> Self {
TextColumn {
text: text.to_string(),
style: None,
justify: JustifyMethod::Left,
}
}
#[must_use]
pub fn with_style(mut self, style: Style) -> Self {
self.style = Some(style);
self
}
#[must_use]
pub fn with_justify(mut self, justify: JustifyMethod) -> Self {
self.justify = justify;
self
}
fn substitute(&self, task: &Task) -> String {
let mut result = self.text.clone();
result = result.replace("{task.description}", &task.description);
if result.contains("{task.percentage") {
let pct = task.percentage();
if let Some(start) = result.find("{task.percentage:") {
if let Some(end) = result[start..].find('}') {
let spec = &result[start..start + end + 1];
let formatted = if spec.contains(".0f") {
format!("{pct:.0}")
} else if spec.contains(".1f") {
format!("{pct:.1}")
} else if spec.contains(".2f") {
format!("{pct:.2}")
} else {
format!("{pct:.1}")
};
result = result.replace(spec, &formatted);
}
}
result = result.replace("{task.percentage}", &format!("{pct:.1}"));
}
result = result.replace("{task.completed}", &format!("{}", task.completed));
let total_str = match task.total {
Some(t) => format!("{t}"),
None => "?".to_string(),
};
result = result.replace("{task.total}", &total_str);
let speed_str = match task.speed() {
Some(s) => format!("{s:.1}"),
None => "?".to_string(),
};
result = result.replace("{task.speed}", &speed_str);
for (key, value) in &task.fields {
let placeholder = format!("{{task.fields.{key}}}");
result = result.replace(&placeholder, value);
}
result
}
}
impl ProgressColumn for TextColumn {
fn render(&self, task: &Task) -> Text {
let content = self.substitute(task);
let style = self.style.clone().unwrap_or_else(Style::null);
let mut text =
Text::from_markup(&content).unwrap_or_else(|_| Text::new(&content, Style::null()));
if !style.is_null() {
text.stylize_before(style, 0, None);
}
text.justify = Some(self.justify);
text
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn text_column_parses_markup() {
let mut task = Task::new(0, "hello", Some(100.0));
task.fields.insert("msg".to_string(), "world".to_string());
let col = TextColumn::new("[bold]{task.description}[/bold]");
let text = col.render(&task);
assert_eq!(
text.plain(),
"hello",
"markup should be parsed, plain text preserved"
);
}
#[test]
fn text_column_invalid_markup_falls_back_gracefully() {
let task = Task::new(0, "oops [unclosed", Some(10.0));
let col = TextColumn::new("{task.description}");
let text = col.render(&task);
assert!(
text.plain().contains("oops"),
"fallback should preserve plain text, got: {}",
text.plain()
);
}
#[test]
fn text_column_applies_base_style_on_markup_success() {
let task = Task::new(0, "hello world", Some(100.0));
let bold = Style::parse("bold");
let col = TextColumn::new("{task.description}").with_style(bold.clone());
let text = col.render(&task);
assert_eq!(text.plain(), "hello world");
let spans = text.spans();
assert!(
!spans.is_empty(),
"expected at least one span carrying the column style, got none"
);
let has_bold = spans.iter().any(|s| s.style == bold);
assert!(
has_bold,
"expected a span with bold style, got spans: {:?}",
spans
);
}
#[test]
fn text_column_applies_base_style_on_markup_failure() {
let task = Task::new(0, "oops [unclosed", Some(10.0));
let bold = Style::parse("bold");
let col = TextColumn::new("{task.description}").with_style(bold.clone());
let text = col.render(&task);
assert!(text.plain().contains("oops"));
let has_bold = text.spans().iter().any(|s| s.style == bold);
assert!(
has_bold,
"expected bold span in fallback path, got spans: {:?}",
text.spans()
);
}
}