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