use std::collections::HashMap;
use std::time::{Duration, Instant};
use crate::console::{ConsoleOptions, DynRenderable, Renderable};
use crate::progress_columns::{
BarColumn, ProgressColumn, SpinnerColumn, TaskProgressColumn, TextColumn,
TimeElapsedColumn,
};
use crate::style::Style;
use crate::table::{Cell, Table};
#[derive(Debug, Clone)]
pub struct ProgressBar {
pub total: Option<f64>,
pub completed: f64,
pub width: Option<usize>,
pub complete_char: char,
pub remaining_char: char,
pub pulse: bool,
pub complete_style: Style,
pub remaining_style: Style,
pub pulse_style: Style,
}
impl ProgressBar {
pub fn new() -> Self {
Self {
total: Some(100.0),
completed: 0.0,
width: None,
complete_char: '█',
remaining_char: '░',
pulse: false,
complete_style: Style::new(),
remaining_style: Style::new(),
pulse_style: Style::new(),
}
}
pub fn total(mut self, total: f64) -> Self { self.total = Some(total); self }
pub fn completed(mut self, completed: f64) -> Self { self.completed = completed; self }
pub fn width(mut self, width: usize) -> Self { self.width = Some(width); self }
pub fn complete_style(mut self, style: Style) -> Self { self.complete_style = style; self }
pub fn remaining_style(mut self, style: Style) -> Self { self.remaining_style = style; self }
pub fn percentage(&self) -> f64 {
if let Some(total) = self.total {
if total > 0.0 {
(self.completed / total).min(1.0).max(0.0)
} else {
0.0
}
} else {
0.0
}
}
pub fn render(&self, width: usize) -> String {
let w = self.width.unwrap_or(width).saturating_sub(2); if w < 3 {
return "[]".to_string();
}
if self.pulse || self.total.is_none() {
let pos = ((self.completed as usize / 8) % (w - 1)).min(w);
let left = " ".repeat(pos);
let right = " ".repeat(w.saturating_sub(pos + 1));
format!("[{left}⣿{right}]")
} else {
let pct = self.percentage();
let filled = (w as f64 * pct) as usize;
let empty = w - filled;
let complete_ansi = self.complete_style.to_ansi();
let complete_reset = if complete_ansi.is_empty() { "" } else { "\x1b[0m" };
format!(
"[{complete_ansi}{}{complete_reset}{}]",
self.complete_char.to_string().repeat(filled),
self.remaining_char.to_string().repeat(empty)
)
}
}
}
impl Default for ProgressBar {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone)]
pub struct Task {
pub id: usize,
pub description: String,
pub total: Option<f64>,
pub completed: f64,
pub visible: bool,
pub start_time: Instant,
pub stop_time: Option<Instant>,
pub fields: HashMap<String, String>,
pub renderable: Option<DynRenderable>,
}
impl Task {
pub fn new(id: usize, description: impl Into<String>, total: Option<f64>) -> Self {
Self {
id,
description: description.into(),
total,
completed: 0.0,
visible: true,
start_time: Instant::now(),
stop_time: None,
fields: HashMap::new(),
renderable: None,
}
}
pub fn progress(&self) -> f64 {
if let Some(t) = self.total {
if t > 0.0 {
(self.completed / t).min(1.0).max(0.0)
} else {
0.0
}
} else {
0.0
}
}
pub fn elapsed(&self) -> Duration {
self.start_time.elapsed()
}
pub fn time_remaining(&self) -> Option<Duration> {
let pct = self.progress();
if pct > 0.0 {
let elapsed = self.elapsed();
let total = elapsed.div_f64(pct);
Some(total.saturating_sub(elapsed))
} else {
None
}
}
pub fn is_finished(&self) -> bool {
if let Some(t) = self.total {
self.completed >= t
} else {
false
}
}
}
pub struct RenderableColumn {
pub format: Box<dyn Fn(&Task) -> DynRenderable + Send + Sync>,
}
impl RenderableColumn {
pub fn new<F: Fn(&Task) -> DynRenderable + Send + Sync + 'static>(format: F) -> Self {
Self { format: Box::new(format) }
}
}
impl std::fmt::Debug for RenderableColumn {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RenderableColumn").finish()
}
}
impl ProgressColumn for RenderableColumn {
fn render(&self, task: &Task, _width: usize, _elapsed: Duration) -> String {
let renderable = (self.format)(task);
renderable.render(&ConsoleOptions::default()).to_ansi()
}
}
#[derive(Debug)]
pub struct Progress {
pub tasks: Vec<Task>,
pub auto_refresh: bool,
pub refresh_per_second: f64,
pub transient: bool,
pub columns: Option<Vec<Box<dyn crate::progress_columns::ProgressColumn>>>,
next_id: usize,
}
impl Progress {
pub fn new() -> Self {
Self {
tasks: Vec::new(),
auto_refresh: true,
refresh_per_second: 10.0,
transient: false,
columns: None,
next_id: 1,
}
}
pub fn with_columns(mut self, columns: Vec<Box<dyn crate::progress_columns::ProgressColumn>>) -> Self {
self.columns = Some(columns);
self
}
pub fn add_task(
&mut self,
description: impl Into<String>,
total: Option<f64>,
) -> usize {
let id = self.next_id;
self.next_id += 1;
self.tasks.push(Task::new(id, description, total));
id
}
pub fn advance(&mut self, task_id: usize, delta: f64) {
if let Some(task) = self.tasks.iter_mut().find(|t| t.id == task_id) {
task.completed += delta;
if let Some(total) = task.total {
if task.completed > total {
task.completed = total;
}
}
}
}
pub fn update(&mut self, task_id: usize, completed: f64) {
if let Some(task) = self.tasks.iter_mut().find(|t| t.id == task_id) {
task.completed = completed;
}
}
pub fn remove_task(&mut self, task_id: usize) {
self.tasks.retain(|t| t.id != task_id);
}
pub fn refresh(&mut self) {
}
pub fn start_task(&mut self, task_id: usize) {
if let Some(task) = self.tasks.iter_mut().find(|t| t.id == task_id) {
task.start_time = Instant::now();
}
}
pub fn stop_task(&mut self, task_id: usize) {
if let Some(task) = self.tasks.iter_mut().find(|t| t.id == task_id) {
task.stop_time = Some(Instant::now());
}
}
pub fn reset(&mut self, task_id: usize, total: Option<f64>) {
if let Some(task) = self.tasks.iter_mut().find(|t| t.id == task_id) {
task.completed = 0.0;
if let Some(t) = total {
task.total = Some(t);
}
}
}
pub fn finished(&self) -> bool {
self.tasks.iter().all(|t| t.is_finished())
}
pub fn get_default_columns(&self) -> Vec<Box<dyn ProgressColumn>> {
vec![
Box::new(TextColumn::new("description")),
Box::new(SpinnerColumn::new()),
Box::new(BarColumn::new()),
Box::new(TaskProgressColumn::new()),
Box::new(TimeElapsedColumn::new()),
]
}
pub fn get_renderable(&self, task_id: usize) -> Option<&dyn Renderable> {
self.tasks
.iter()
.find(|t| t.id == task_id)
.and_then(|t| t.renderable.as_ref())
.map(|dr| dr as &dyn Renderable)
}
pub fn get_renderables(&self) -> Vec<&dyn Renderable> {
self.tasks
.iter()
.filter_map(|t| t.renderable.as_ref())
.map(|dr| dr as &dyn Renderable)
.collect()
}
pub fn make_tasks_table(&self, columns: &[Box<dyn ProgressColumn>]) -> Table {
let now = Instant::now();
let mut table = Table::new();
table.show_header = false;
table.show_edge = false;
table.padding = (0, 1, 0, 0);
for (i, _col) in columns.iter().enumerate() {
table.add_column(crate::table::Column::new(format!("Col {}", i)));
}
for task in &self.tasks {
if !task.visible {
continue;
}
let elapsed = now.duration_since(task.start_time);
let cells: Vec<Cell> = columns
.iter()
.map(|col| Cell::new(col.render(task, 20, elapsed)))
.collect();
table.add_row(cells);
}
table
}
pub fn render(&self, width: usize) -> String {
if let Some(ref columns) = self.columns {
self.render_with_columns(width, columns)
} else {
self.render_default(width)
}
}
fn render_with_columns(&self, _width: usize, columns: &[Box<dyn crate::progress_columns::ProgressColumn>]) -> String {
let mut out = String::new();
let now = std::time::Instant::now();
for task in &self.tasks {
if !task.visible {
continue;
}
let elapsed = now.duration_since(task.start_time);
let mut line = String::new();
for (i, col) in columns.iter().enumerate() {
if i > 0 { line.push(' '); }
line.push_str(&col.render(task, 20, elapsed));
}
out.push_str(&line);
out.push('\n');
}
out
}
fn render_default(&self, width: usize) -> String {
let mut out = String::new();
for task in &self.tasks {
if !task.visible {
continue;
}
let bar_width = width.saturating_sub(30).max(10);
let bar = self.render_task_bar(task, bar_width);
let pct = (task.progress() * 100.0) as usize;
let elapsed = format_duration(&task.elapsed());
let remaining = task
.time_remaining()
.map(|d| format_duration(&d))
.unwrap_or_else(|| "?".to_string());
out.push_str(&format!(
"{desc:<20} {pct:>3}% {bar} {elapsed}<{remaining}\n",
desc = task.description.chars().take(20).collect::<String>(),
));
}
out
}
fn render_task_bar(&self, task: &Task, width: usize) -> String {
let w = width.saturating_sub(2);
if w < 3 {
return "[]".to_string();
}
let pct = task.progress();
let filled = (w as f64 * pct) as usize;
let empty = w - filled;
format!("[{}░{}]",
"█".repeat(filled),
" ".repeat(empty.saturating_sub(1))
)
}
pub fn track<I: IntoIterator>(
&mut self,
sequence: I,
description: &str,
total: Option<f64>,
) -> TrackIterator<I::IntoIter> {
let iter = sequence.into_iter();
let (lower, upper) = iter.size_hint();
let total = total.unwrap_or(upper.unwrap_or(lower) as f64);
let task_id = self.add_task(description, Some(total));
TrackIterator {
inner: iter,
progress_id: task_id,
count: 0,
total,
}
}
pub fn advance_bytes(&mut self, task_id: usize, bytes: u64) {
self.advance(task_id, bytes as f64);
}
pub fn open(
&mut self,
path: impl AsRef<std::path::Path>,
description: impl Into<String>,
) -> std::io::Result<ProgressFile> {
let path = path.as_ref();
let metadata = std::fs::metadata(path)?;
let total = metadata.len();
let file = std::fs::File::open(path)?;
let desc = description.into();
Ok(self.wrap_file(file, &desc, Some(total)))
}
pub fn wrap_file(
&mut self,
file: std::fs::File,
description: &str,
total: Option<u64>,
) -> ProgressFile {
let total_val = total.unwrap_or(0) as f64;
let task_id = self.add_task(description, Some(total_val));
ProgressFile::new(file, task_id, total.unwrap_or(0))
}
}
impl Default for Progress {
fn default() -> Self {
Self::new()
}
}
pub fn track<T: IntoIterator>(sequence: T, _description: &str, total: Option<f64>) -> TrackIterator<T::IntoIter> {
let iter = sequence.into_iter();
let (lower, upper) = iter.size_hint();
let total_val = total.unwrap_or(upper.unwrap_or(lower) as f64);
TrackIterator {
inner: iter,
progress_id: 0,
count: 0,
total: total_val,
}
}
pub fn wrap_file(file: std::fs::File, _description: &str, total: Option<u64>) -> ProgressFile {
ProgressFile::new(file, 0, total.unwrap_or(0))
}
pub struct TrackIterator<I: Iterator> {
inner: I,
pub progress_id: usize,
count: usize,
total: f64,
}
impl<I: Iterator> Iterator for TrackIterator<I> {
type Item = I::Item;
fn next(&mut self) -> Option<Self::Item> {
let item = self.inner.next();
if item.is_some() {
self.count += 1;
}
item
}
fn size_hint(&self) -> (usize, Option<usize>) {
self.inner.size_hint()
}
}
impl<I: Iterator> TrackIterator<I> {
pub fn count(&self) -> usize { self.count }
pub fn total(&self) -> f64 { self.total }
}
#[derive(Debug)]
pub struct ProgressFile {
inner: std::fs::File,
task_id: usize,
total: u64,
bytes_read: u64,
}
impl ProgressFile {
pub fn new(file: std::fs::File, task_id: usize, total: u64) -> Self {
Self { inner: file, task_id, total, bytes_read: 0 }
}
pub fn bytes_read(&self) -> u64 { self.bytes_read }
pub fn total(&self) -> u64 { self.total }
pub fn task_id(&self) -> usize { self.task_id }
pub fn sync(&self, progress: &mut Progress) {
if let Some(task) = progress.tasks.iter_mut().find(|t| t.id == self.task_id) {
task.completed = self.bytes_read as f64;
}
}
pub fn inner(&self) -> &std::fs::File { &self.inner }
pub fn inner_mut(&mut self) -> &mut std::fs::File { &mut self.inner }
pub fn into_inner(self) -> std::fs::File { self.inner }
}
impl std::io::Read for ProgressFile {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
let n = self.inner.read(buf)?;
self.bytes_read += n as u64;
Ok(n)
}
}
fn format_duration(d: &Duration) -> String {
let secs = d.as_secs();
if secs < 60 {
format!("0:{secs:02}")
} else if secs < 3600 {
format!("{}:{:02}", secs / 60, secs % 60)
} else {
format!("{}:{:02}:{:02}", secs / 3600, (secs % 3600) / 60, secs % 60)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_progress_bar_render() {
let bar = ProgressBar::new().total(100.0).completed(50.0);
let r = bar.render(20);
assert!(r.contains('█'));
}
#[test]
fn test_progress_add_task() {
let mut p = Progress::new();
let id = p.add_task("Download", Some(100.0));
assert_eq!(id, 1);
p.advance(1, 50.0);
assert_eq!(p.tasks[0].completed, 50.0);
}
#[test]
fn test_advance_bytes() {
let mut p = Progress::new();
let id = p.add_task("Download", Some(1000.0));
p.advance_bytes(id, 256);
assert_eq!(p.tasks[0].completed, 256.0);
}
#[test]
fn test_progress_file_wrap_and_read() {
use std::io::Read;
let data = b"hello world";
let dir = std::env::temp_dir();
let path = dir.join("rusty_rich_test_progress.txt");
std::fs::write(&path, data).unwrap();
let mut p = Progress::new();
let mut pf = p.open(&path, "test file").unwrap();
assert_eq!(pf.total(), 11);
assert_eq!(pf.bytes_read(), 0);
let mut buf = [0u8; 5];
let n = pf.read(&mut buf).unwrap();
assert_eq!(n, 5);
assert_eq!(pf.bytes_read(), 5);
pf.sync(&mut p);
assert_eq!(p.tasks[0].completed, 5.0);
let mut buf = Vec::new();
pf.read_to_end(&mut buf).unwrap();
assert_eq!(pf.bytes_read(), 11);
pf.sync(&mut p);
assert_eq!(p.tasks[0].completed, 11.0);
drop(pf);
std::fs::remove_file(&path).unwrap();
}
#[test]
fn test_progress_file_wrap_existing() {
let data = b"test data for wrap";
let dir = std::env::temp_dir();
let path = dir.join("rusty_rich_test_wrap.txt");
std::fs::write(&path, data).unwrap();
let file = std::fs::File::open(&path).unwrap();
let mut p = Progress::new();
let pf = p.wrap_file(file, "wrapped", Some(data.len() as u64));
assert_eq!(pf.total(), data.len() as u64);
assert_eq!(pf.task_id(), 1);
drop(pf);
std::fs::remove_file(&path).unwrap();
}
#[test]
fn test_start_task() {
let mut p = Progress::new();
let id = p.add_task("test", Some(100.0));
p.start_task(id);
assert!(!p.tasks[0].elapsed().is_zero());
}
#[test]
fn test_stop_task() {
let mut p = Progress::new();
let id = p.add_task("test", Some(100.0));
p.stop_task(id);
assert!(p.tasks[0].stop_time.is_some());
}
#[test]
fn test_reset_task() {
let mut p = Progress::new();
let id = p.add_task("test", Some(100.0));
p.advance(id, 50.0);
assert_eq!(p.tasks[0].completed, 50.0);
p.reset(id, Some(200.0));
assert_eq!(p.tasks[0].completed, 0.0);
assert_eq!(p.tasks[0].total, Some(200.0));
}
#[test]
fn test_finished() {
let mut p = Progress::new();
p.add_task("a", Some(100.0));
p.add_task("b", Some(100.0));
assert!(!p.finished());
p.update(1, 100.0);
p.update(2, 100.0);
assert!(p.finished());
}
#[test]
fn test_get_default_columns() {
let p = Progress::new();
let cols = p.get_default_columns();
assert_eq!(cols.len(), 5);
}
#[test]
fn test_refresh() {
let mut p = Progress::new();
p.add_task("test", Some(100.0));
p.refresh();
}
#[test]
fn test_track_method() {
let mut p = Progress::new();
let items = vec![1, 2, 3];
let tracker = p.track(items, "counting", Some(3.0));
assert_eq!(tracker.progress_id, 1);
assert_eq!(p.tasks.len(), 1);
}
#[test]
fn test_standalone_track() {
let items = vec![1, 2, 3];
let tracker = track(items, "counting", Some(3.0));
assert_eq!(tracker.progress_id, 0);
}
#[test]
fn test_standalone_wrap_file() {
let data = b"hello";
let dir = std::env::temp_dir();
let path = dir.join("rusty_rich_test_standalone_wrap.txt");
std::fs::write(&path, data).unwrap();
let file = std::fs::File::open(&path).unwrap();
let pf = wrap_file(file, "standalone", Some(data.len() as u64));
assert_eq!(pf.total(), 5);
std::fs::remove_file(&path).unwrap();
}
#[test]
fn test_renderable_column() {
let col = RenderableColumn::new(|task: &Task| {
DynRenderable::new(task.description.clone())
});
let task = Task::new(1, "hello", Some(100.0));
let result = col.render(&task, 20, Duration::from_secs(0));
assert!(result.contains("hello"));
}
#[test]
fn test_make_tasks_table() {
let mut p = Progress::new();
p.add_task("task1", Some(100.0));
p.add_task("task2", Some(50.0));
let cols = p.get_default_columns();
let table = p.make_tasks_table(&cols);
assert_eq!(table.row_count(), 2);
}
#[test]
fn test_get_renderable() {
let mut p = Progress::new();
let id = p.add_task("test", Some(100.0));
assert!(p.get_renderable(id).is_none());
}
#[test]
fn test_get_renderables() {
let mut p = Progress::new();
p.add_task("a", Some(100.0));
p.add_task("b", Some(50.0));
let renderables = p.get_renderables();
assert!(renderables.is_empty());
}
#[test]
fn test_auto_refresh_default() {
let p = Progress::new();
assert!(p.auto_refresh);
}
#[test]
fn test_refresh_per_second_default() {
let p = Progress::new();
assert!((p.refresh_per_second - 10.0).abs() < f64::EPSILON);
}
}