Skip to main content

rusty_rich/
progress.rs

1//! Progress bars and task tracking. Equivalent to Rich's `progress.py`
2//! and `progress_bar.py`.
3//!
4//! # Overview
5//!
6//! [`Progress`] manages multiple concurrent tasks, each with its own
7//! description, total, and completed count. The display is built from
8//! configurable column types (see [`crate::progress_columns`]).
9//!
10//! # Quick Example
11//!
12//! ```rust
13//! use rusty_rich::Progress;
14//!
15//! let mut progress = Progress::new();
16//! let task = progress.add_task("Downloading...", Some(100.0));
17//! progress.update(task, 50.0);
18//! println!("{}", progress.render(80));
19//! ```
20//!
21//! # Tracking Iterables
22//!
23//! ```rust
24//! use rusty_rich::{Progress, TrackIterator};
25//!
26//! let mut progress = Progress::new();
27//! let items: Vec<i32> = (0..100).collect();
28//! let tracker = progress.track(items, "Processing", None);
29//! for item in tracker {
30//!     // item is yielded, progress auto-advances
31//! }
32//! ```
33//!
34//! # File Progress
35//!
36//! [`ProgressFile`] wraps a `std::io::Read` and tracks read progress via a
37//! [`Progress`] task. Use [`Progress::wrap_file`] to create one.
38
39use std::collections::HashMap;
40use std::time::{Duration, Instant};
41
42use crate::style::Style;
43
44// ---------------------------------------------------------------------------
45// ProgressBar
46// ---------------------------------------------------------------------------
47
48/// A single progress bar.
49#[derive(Debug, Clone)]
50pub struct ProgressBar {
51    /// Total steps (None = indeterminate).
52    pub total: Option<f64>,
53    /// Completed steps.
54    pub completed: f64,
55    /// Width in characters.
56    pub width: Option<usize>,
57    /// Characters for completed portion.
58    pub complete_char: char,
59    /// Characters for remaining portion.
60    pub remaining_char: char,
61    /// Optional pulse style (for indeterminate).
62    pub pulse: bool,
63    /// Style for completed portion.
64    pub complete_style: Style,
65    /// Style for remaining portion.
66    pub remaining_style: Style,
67    /// Style for the pulse cursor.
68    pub pulse_style: Style,
69}
70
71impl ProgressBar {
72    /// Create a new `ProgressBar` with default values (total=100, completed=0).
73    pub fn new() -> Self {
74        Self {
75            total: Some(100.0),
76            completed: 0.0,
77            width: None,
78            complete_char: '█',
79            remaining_char: '░',
80            pulse: false,
81            complete_style: Style::new(),
82            remaining_style: Style::new(),
83            pulse_style: Style::new(),
84        }
85    }
86
87    /// Set total.
88    pub fn total(mut self, total: f64) -> Self { self.total = Some(total); self }
89
90    /// Set completed.
91    pub fn completed(mut self, completed: f64) -> Self { self.completed = completed; self }
92
93    /// Set width.
94    pub fn width(mut self, width: usize) -> Self { self.width = Some(width); self }
95
96    /// Set complete style.
97    pub fn complete_style(mut self, style: Style) -> Self { self.complete_style = style; self }
98
99    /// Set remaining style.
100    pub fn remaining_style(mut self, style: Style) -> Self { self.remaining_style = style; self }
101
102    /// Get progress as a fraction (0.0–1.0).
103    pub fn percentage(&self) -> f64 {
104        if let Some(total) = self.total {
105            if total > 0.0 {
106                (self.completed / total).min(1.0).max(0.0)
107            } else {
108                0.0
109            }
110        } else {
111            0.0
112        }
113    }
114
115    /// Render the bar to a string.
116    pub fn render(&self, width: usize) -> String {
117        let w = self.width.unwrap_or(width).saturating_sub(2); // leave room for brackets
118        if w < 3 {
119            return "[]".to_string();
120        }
121
122        if self.pulse || self.total.is_none() {
123            // Indeterminate: pulsing animation
124            let pos = ((self.completed as usize / 8) % (w - 1)).min(w);
125            let left = " ".repeat(pos);
126            let right = " ".repeat(w.saturating_sub(pos + 1));
127            format!("[{left}⣿{right}]")
128        } else {
129            let pct = self.percentage();
130            let filled = (w as f64 * pct) as usize;
131            let empty = w - filled;
132            let complete_ansi = self.complete_style.to_ansi();
133            let complete_reset = if complete_ansi.is_empty() { "" } else { "\x1b[0m" };
134            format!(
135                "[{complete_ansi}{}{complete_reset}{}]",
136                self.complete_char.to_string().repeat(filled),
137                self.remaining_char.to_string().repeat(empty)
138            )
139        }
140    }
141}
142
143impl Default for ProgressBar {
144    fn default() -> Self {
145        Self::new()
146    }
147}
148
149// ---------------------------------------------------------------------------
150// Task
151// ---------------------------------------------------------------------------
152
153/// A tracked task within a Progress display.
154#[derive(Debug, Clone)]
155pub struct Task {
156    pub id: usize,
157    pub description: String,
158    pub total: Option<f64>,
159    pub completed: f64,
160    pub visible: bool,
161    pub start_time: Instant,
162    pub fields: HashMap<String, String>,
163}
164
165impl Task {
166    /// Create a new `Task` with the given id, description, and optional total.
167    pub fn new(id: usize, description: impl Into<String>, total: Option<f64>) -> Self {
168        Self {
169            id,
170            description: description.into(),
171            total,
172            completed: 0.0,
173            visible: true,
174            start_time: Instant::now(),
175            fields: HashMap::new(),
176        }
177    }
178
179    /// Return the progress fraction (0.0–1.0), or 0.0 if no total is set.
180    pub fn progress(&self) -> f64 {
181        if let Some(t) = self.total {
182            if t > 0.0 {
183                (self.completed / t).min(1.0).max(0.0)
184            } else {
185                0.0
186            }
187        } else {
188            0.0
189        }
190    }
191
192    /// Return the [`Duration`] since this task was created.
193    pub fn elapsed(&self) -> Duration {
194        self.start_time.elapsed()
195    }
196
197    /// Estimate the remaining [`Duration`] based on current progress, or
198    /// [`None`] if progress is zero or no total is set.
199    pub fn time_remaining(&self) -> Option<Duration> {
200        let pct = self.progress();
201        if pct > 0.0 {
202            let elapsed = self.elapsed();
203            let total = elapsed.div_f64(pct);
204            Some(total.saturating_sub(elapsed))
205        } else {
206            None
207        }
208    }
209
210    /// Check if the task is finished (completed >= total).
211    pub fn is_finished(&self) -> bool {
212        if let Some(t) = self.total {
213            self.completed >= t
214        } else {
215            false
216        }
217    }
218}
219
220// ---------------------------------------------------------------------------
221// Progress
222// ---------------------------------------------------------------------------
223
224/// A multi-task progress display.
225#[derive(Debug)]
226pub struct Progress {
227    pub tasks: Vec<Task>,
228    pub auto_refresh: bool,
229    pub refresh_per_second: f64,
230    pub transient: bool,
231    /// Columns to render for each task (if None, uses default columns).
232    pub columns: Option<Vec<Box<dyn crate::progress_columns::ProgressColumn>>>,
233    next_id: usize,
234}
235
236impl Progress {
237    /// Create a new `Progress` instance with no tasks.
238    pub fn new() -> Self {
239        Self {
240            tasks: Vec::new(),
241            auto_refresh: true,
242            refresh_per_second: 4.0,
243            transient: false,
244            columns: None,
245            next_id: 1,
246        }
247    }
248
249    /// Replace the default columns with a custom list of [`ProgressColumn`](crate::progress_columns::ProgressColumn)s.
250    ///
251    /// Each task is rendered as one row using the provided columns.
252    pub fn with_columns(mut self, columns: Vec<Box<dyn crate::progress_columns::ProgressColumn>>) -> Self {
253        self.columns = Some(columns);
254        self
255    }
256
257    /// Register a new task and return its numeric ID (used by `advance`, `update`, etc.).
258    pub fn add_task(
259        &mut self,
260        description: impl Into<String>,
261        total: Option<f64>,
262    ) -> usize {
263        let id = self.next_id;
264        self.next_id += 1;
265        self.tasks.push(Task::new(id, description, total));
266        id
267    }
268
269    /// Increase a task's completed count by `delta`.
270    pub fn advance(&mut self, task_id: usize, delta: f64) {
271        if let Some(task) = self.tasks.iter_mut().find(|t| t.id == task_id) {
272            task.completed += delta;
273            if let Some(total) = task.total {
274                if task.completed > total {
275                    task.completed = total;
276                }
277            }
278        }
279    }
280
281    /// Set a task's completed count directly (overwrites current value).
282    pub fn update(&mut self, task_id: usize, completed: f64) {
283        if let Some(task) = self.tasks.iter_mut().find(|t| t.id == task_id) {
284            task.completed = completed;
285        }
286    }
287
288    /// Remove a task by its ID. No-op if the task does not exist.
289    pub fn remove_task(&mut self, task_id: usize) {
290        self.tasks.retain(|t| t.id != task_id);
291    }
292
293    /// Render all visible tasks to a multi-line string at the given terminal width.
294    pub fn render(&self, width: usize) -> String {
295        if let Some(ref columns) = self.columns {
296            self.render_with_columns(width, columns)
297        } else {
298            self.render_default(width)
299        }
300    }
301
302    /// Render using custom columns.
303    fn render_with_columns(&self, _width: usize, columns: &[Box<dyn crate::progress_columns::ProgressColumn>]) -> String {
304        let mut out = String::new();
305        let now = std::time::Instant::now();
306        for task in &self.tasks {
307            if !task.visible {
308                continue;
309            }
310            let elapsed = now.duration_since(task.start_time);
311            let mut line = String::new();
312            for (i, col) in columns.iter().enumerate() {
313                if i > 0 { line.push(' '); }
314                line.push_str(&col.render(task, 20, elapsed));
315            }
316            out.push_str(&line);
317            out.push('\n');
318        }
319        out
320    }
321
322    /// Default render (no columns).
323    fn render_default(&self, width: usize) -> String {
324        let mut out = String::new();
325        for task in &self.tasks {
326            if !task.visible {
327                continue;
328            }
329            let bar_width = width.saturating_sub(30).max(10);
330            let bar = self.render_task_bar(task, bar_width);
331            let pct = (task.progress() * 100.0) as usize;
332            let elapsed = format_duration(&task.elapsed());
333            let remaining = task
334                .time_remaining()
335                .map(|d| format_duration(&d))
336                .unwrap_or_else(|| "?".to_string());
337
338            out.push_str(&format!(
339                "{desc:<20} {pct:>3}% {bar} {elapsed}<{remaining}\n",
340                desc = task.description.chars().take(20).collect::<String>(),
341            ));
342        }
343        out
344    }
345
346    fn render_task_bar(&self, task: &Task, width: usize) -> String {
347        let w = width.saturating_sub(2);
348        if w < 3 {
349            return "[]".to_string();
350        }
351        let pct = task.progress();
352        let filled = (w as f64 * pct) as usize;
353        let empty = w - filled;
354        format!("[{}░{}]",
355            "█".repeat(filled),
356            " ".repeat(empty.saturating_sub(1))
357        )
358    }
359
360    /// Wrap an iterator with progress tracking, returning a [`TrackIterator`].
361    ///
362    /// Equivalent to Python Rich's `track()`.
363    pub fn track<I: IntoIterator>(
364        &mut self,
365        sequence: I,
366        description: impl Into<String>,
367        total: Option<f64>,
368    ) -> TrackIterator<I::IntoIter> {
369        let iter = sequence.into_iter();
370        let (lower, upper) = iter.size_hint();
371        let total = total.unwrap_or(upper.unwrap_or(lower) as f64);
372        let task_id = self.add_task(description, Some(total));
373
374        TrackIterator {
375            inner: iter,
376            progress_id: task_id,
377            count: 0,
378            total,
379        }
380    }
381
382    /// Convenience: advance a task by a [`u64`] byte count (casts to `f64` internally).
383    pub fn advance_bytes(&mut self, task_id: usize, bytes: u64) {
384        self.advance(task_id, bytes as f64);
385    }
386
387    /// Open a file at the given path and wrap it with progress tracking.
388    ///
389    /// Returns a [`ProgressFile`] whose reads are recorded via this [`Progress`].
390    pub fn open(
391        &mut self,
392        path: impl AsRef<std::path::Path>,
393        description: impl Into<String>,
394    ) -> std::io::Result<ProgressFile> {
395        let path = path.as_ref();
396        let metadata = std::fs::metadata(path)?;
397        let total = metadata.len();
398        let file = std::fs::File::open(path)?;
399        Ok(self.wrap_file(file, total, description))
400    }
401
402    /// Wrap an already-open [`std::fs::File`] with progress tracking.
403    pub fn wrap_file(
404        &mut self,
405        file: std::fs::File,
406        total: u64,
407        description: impl Into<String>,
408    ) -> ProgressFile {
409        let task_id = self.add_task(description, Some(total as f64));
410        ProgressFile::new(file, task_id, total)
411    }
412}
413
414impl Default for Progress {
415    fn default() -> Self {
416        Self::new()
417    }
418}
419
420// ---------------------------------------------------------------------------
421// TrackIterator — wraps an iterator with progress updates
422// ---------------------------------------------------------------------------
423
424/// An iterator wrapper that updates progress as items are consumed.
425/// Equivalent to Python Rich's `track()`.
426pub struct TrackIterator<I: Iterator> {
427    inner: I,
428    /// The progress task ID (caller must update progress externally).
429    pub progress_id: usize,
430    count: usize,
431    total: f64,
432}
433
434impl<I: Iterator> Iterator for TrackIterator<I> {
435    type Item = I::Item;
436
437    fn next(&mut self) -> Option<Self::Item> {
438        let item = self.inner.next();
439        if item.is_some() {
440            self.count += 1;
441        }
442        item
443    }
444
445    fn size_hint(&self) -> (usize, Option<usize>) {
446        self.inner.size_hint()
447    }
448}
449
450impl<I: Iterator> TrackIterator<I> {
451    /// Get the current count.
452    pub fn count(&self) -> usize { self.count }
453
454    /// Get the total.
455    pub fn total(&self) -> f64 { self.total }
456}
457
458// ---------------------------------------------------------------------------
459// ProgressFile — wraps a File with progress tracking
460// ---------------------------------------------------------------------------
461
462/// A file wrapper that tracks read progress for use with a Progress instance.
463#[derive(Debug)]
464pub struct ProgressFile {
465    inner: std::fs::File,
466    task_id: usize,
467    total: u64,
468    bytes_read: u64,
469}
470
471impl ProgressFile {
472    /// Create a new ProgressFile.
473    pub fn new(file: std::fs::File, task_id: usize, total: u64) -> Self {
474        Self { inner: file, task_id, total, bytes_read: 0 }
475    }
476
477    /// Get the number of bytes read so far.
478    pub fn bytes_read(&self) -> u64 { self.bytes_read }
479
480    /// Get the total file size.
481    pub fn total(&self) -> u64 { self.total }
482
483    /// Get the task ID this ProgressFile is associated with.
484    pub fn task_id(&self) -> usize { self.task_id }
485
486    /// Sync the current read progress to a Progress instance.
487    pub fn sync(&self, progress: &mut Progress) {
488        if let Some(task) = progress.tasks.iter_mut().find(|t| t.id == self.task_id) {
489            task.completed = self.bytes_read as f64;
490        }
491    }
492
493    /// Get a reference to the inner file.
494    pub fn inner(&self) -> &std::fs::File { &self.inner }
495
496    /// Get a mutable reference to the inner file.
497    pub fn inner_mut(&mut self) -> &mut std::fs::File { &mut self.inner }
498
499    /// Consume this ProgressFile and return the inner file.
500    pub fn into_inner(self) -> std::fs::File { self.inner }
501}
502
503impl std::io::Read for ProgressFile {
504    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
505        let n = self.inner.read(buf)?;
506        self.bytes_read += n as u64;
507        Ok(n)
508    }
509}
510
511// ---------------------------------------------------------------------------
512
513fn format_duration(d: &Duration) -> String {
514    let secs = d.as_secs();
515    if secs < 60 {
516        format!("0:{secs:02}")
517    } else if secs < 3600 {
518        format!("{}:{:02}", secs / 60, secs % 60)
519    } else {
520        format!("{}:{:02}:{:02}", secs / 3600, (secs % 3600) / 60, secs % 60)
521    }
522}
523
524#[cfg(test)]
525mod tests {
526    use super::*;
527
528    #[test]
529    fn test_progress_bar_render() {
530        let bar = ProgressBar::new().total(100.0).completed(50.0);
531        let r = bar.render(20);
532        assert!(r.contains('█'));
533    }
534
535    #[test]
536    fn test_progress_add_task() {
537        let mut p = Progress::new();
538        let id = p.add_task("Download", Some(100.0));
539        assert_eq!(id, 1);
540        p.advance(1, 50.0);
541        assert_eq!(p.tasks[0].completed, 50.0);
542    }
543
544    #[test]
545    fn test_advance_bytes() {
546        let mut p = Progress::new();
547        let id = p.add_task("Download", Some(1000.0));
548        p.advance_bytes(id, 256);
549        assert_eq!(p.tasks[0].completed, 256.0);
550    }
551
552    #[test]
553    fn test_progress_file_wrap_and_read() {
554        use std::io::Read;
555        let data = b"hello world";
556        let dir = std::env::temp_dir();
557        let path = dir.join("rusty_rich_test_progress.txt");
558
559        // Write test data
560        std::fs::write(&path, data).unwrap();
561
562        let mut p = Progress::new();
563        let mut pf = p.open(&path, "test file").unwrap();
564        assert_eq!(pf.total(), 11);
565        assert_eq!(pf.bytes_read(), 0);
566
567        // Read a few bytes
568        let mut buf = [0u8; 5];
569        let n = pf.read(&mut buf).unwrap();
570        assert_eq!(n, 5);
571        assert_eq!(pf.bytes_read(), 5);
572
573        // Sync progress
574        pf.sync(&mut p);
575        assert_eq!(p.tasks[0].completed, 5.0);
576
577        // Read remaining bytes
578        let mut buf = Vec::new();
579        pf.read_to_end(&mut buf).unwrap();
580        assert_eq!(pf.bytes_read(), 11);
581
582        // Sync again
583        pf.sync(&mut p);
584        assert_eq!(p.tasks[0].completed, 11.0);
585
586        drop(pf);
587        std::fs::remove_file(&path).unwrap();
588    }
589
590    #[test]
591    fn test_progress_file_wrap_existing() {
592        let data = b"test data for wrap";
593        let dir = std::env::temp_dir();
594        let path = dir.join("rusty_rich_test_wrap.txt");
595        std::fs::write(&path, data).unwrap();
596
597        let file = std::fs::File::open(&path).unwrap();
598        let mut p = Progress::new();
599        let pf = p.wrap_file(file, data.len() as u64, "wrapped");
600        assert_eq!(pf.total(), data.len() as u64);
601        assert_eq!(pf.task_id(), 1);
602
603        drop(pf);
604        std::fs::remove_file(&path).unwrap();
605    }
606}