Skip to main content

rusty_rich/
progress_columns.rs

1//! Progress column types — equivalent to Python Rich's progress column
2//! system (SpinnerColumn, BarColumn, TextColumn, etc.).
3
4use crate::progress::{ProgressBar, Task};
5use crate::spinner::Spinner;
6use crate::style::Style;
7
8// ---------------------------------------------------------------------------
9// ProgressColumn trait
10// ---------------------------------------------------------------------------
11
12/// A column in a progress display. Each column renders one cell per task.
13pub trait ProgressColumn: std::fmt::Debug {
14    /// Render this column for the given task into a string.
15    fn render(&self, task: &Task, width: usize, elapsed: std::time::Duration) -> String;
16}
17
18// ---------------------------------------------------------------------------
19// TextColumn
20// ---------------------------------------------------------------------------
21
22/// Displays a formatted text field. The text is taken from `task.fields["key"]`
23/// and formatted with the given format string.
24#[derive(Debug, Clone)]
25pub struct TextColumn {
26    /// Key into the task's `fields` HashMap.
27    pub key: String,
28    /// Format string (e.g. "{:>10}").
29    pub format: String,
30    /// Style for the text.
31    pub style: Style,
32}
33
34impl TextColumn {
35    /// Create a new `TextColumn` that reads from the given task field key.
36    pub fn new(key: impl Into<String>) -> Self {
37        Self {
38            key: key.into(),
39            format: "{:>11}".to_string(),
40            style: Style::new(),
41        }
42    }
43
44    /// Builder: set the format string (e.g. `"{:>10}"`).
45    pub fn format(mut self, fmt: impl Into<String>) -> Self {
46        self.format = fmt.into();
47        self
48    }
49    /// Builder: set the text style.
50    pub fn style(mut self, s: Style) -> Self {
51        self.style = s;
52        self
53    }
54}
55
56impl ProgressColumn for TextColumn {
57    fn render(&self, task: &Task, _width: usize, _elapsed: std::time::Duration) -> String {
58        let value = task
59            .fields
60            .get(&self.key)
61            .map(|s| s.as_str())
62            .unwrap_or("?");
63        // Simple: just return the value (formatting could use format args but
64        // we keep it simple)
65        let ansi = self.style.to_ansi();
66        let reset = self.style.reset_ansi();
67        format!("{ansi}{value}{reset}")
68    }
69}
70
71// ---------------------------------------------------------------------------
72// BarColumn
73// ---------------------------------------------------------------------------
74
75/// Renders the progress bar itself.
76#[derive(Debug, Clone)]
77pub struct BarColumn {
78    /// The underlying progress bar template.
79    pub bar: ProgressBar,
80    /// Width override (None = auto from available space).
81    pub width: Option<usize>,
82}
83
84impl BarColumn {
85    /// Create a new `BarColumn` with a default [`ProgressBar`].
86    pub fn new() -> Self {
87        Self {
88            bar: ProgressBar::new(),
89            width: None,
90        }
91    }
92
93    /// Builder: set the style for the completed portion of the bar.
94    pub fn complete_style(mut self, s: Style) -> Self {
95        self.bar = self.bar.complete_style(s);
96        self
97    }
98    /// Builder: set the style for the remaining portion of the bar.
99    pub fn finished_style(mut self, s: Style) -> Self {
100        self.bar = self.bar.remaining_style(s);
101        self
102    }
103    /// Builder: set a fixed width for the bar (otherwise auto-sized).
104    pub fn width(mut self, w: usize) -> Self {
105        self.width = Some(w);
106        self
107    }
108}
109
110impl ProgressColumn for BarColumn {
111    fn render(&self, task: &Task, width: usize, _elapsed: std::time::Duration) -> String {
112        let w = self.width.unwrap_or(width.saturating_sub(2));
113        let mut bar = self.bar.clone();
114        bar.total = task.total;
115        bar.completed = task.completed;
116        bar.width = Some(w);
117        bar.render(w)
118    }
119}
120
121impl Default for BarColumn {
122    fn default() -> Self {
123        Self::new()
124    }
125}
126
127// ---------------------------------------------------------------------------
128// SpinnerColumn
129// ---------------------------------------------------------------------------
130
131/// Shows a spinner for tasks that are not finished, and "✓" when complete.
132#[derive(Debug, Clone)]
133pub struct SpinnerColumn {
134    pub spinner: Spinner,
135    pub style: Style,
136    pub finished_style: Style,
137    pub finished_text: String,
138}
139
140impl SpinnerColumn {
141    /// Create a new `SpinnerColumn` with a default [`Spinner`].
142    pub fn new() -> Self {
143        Self {
144            spinner: Spinner::default(),
145            style: Style::new(),
146            finished_style: Style::new()
147                .color(crate::color::Color::parse("green").unwrap())
148                .bold(true),
149            finished_text: "✓".to_string(),
150        }
151    }
152
153    /// Builder: set the spinner style (active animation).
154    pub fn style(mut self, s: Style) -> Self {
155        self.style = s;
156        self
157    }
158    /// Builder: set the style for the finished checkmark.
159    pub fn finished_style(mut self, s: Style) -> Self {
160        self.finished_style = s;
161        self
162    }
163}
164
165impl ProgressColumn for SpinnerColumn {
166    fn render(&self, task: &Task, _width: usize, elapsed: std::time::Duration) -> String {
167        if task.is_finished() {
168            let a = self.finished_style.to_ansi();
169            let r = self.finished_style.reset_ansi();
170            format!("{a}{}{r}", self.finished_text)
171        } else {
172            let frame = self.spinner.frame_at(elapsed);
173            let a = self.style.to_ansi();
174            let r = self.style.reset_ansi();
175            format!("{a}{frame}{r}")
176        }
177    }
178}
179
180impl Default for SpinnerColumn {
181    fn default() -> Self {
182        Self::new()
183    }
184}
185
186// ---------------------------------------------------------------------------
187// TimeElapsedColumn
188// ---------------------------------------------------------------------------
189
190/// Shows elapsed time since task started.
191#[derive(Debug, Clone)]
192pub struct TimeElapsedColumn {
193    pub style: Style,
194    pub paused_style: Style,
195}
196
197impl TimeElapsedColumn {
198    /// Create a new `TimeElapsedColumn` with default style.
199    pub fn new() -> Self {
200        Self {
201            style: Style::new(),
202            paused_style: Style::new().dim(true),
203        }
204    }
205}
206
207impl ProgressColumn for TimeElapsedColumn {
208    fn render(&self, task: &Task, _width: usize, _elapsed: std::time::Duration) -> String {
209        let d = task.elapsed();
210        let s = format_duration_short(&d);
211        let a = self.style.to_ansi();
212        let r = self.style.reset_ansi();
213        format!("{a}{s}{r}")
214    }
215}
216
217impl Default for TimeElapsedColumn {
218    fn default() -> Self {
219        Self::new()
220    }
221}
222
223// ---------------------------------------------------------------------------
224// TimeRemainingColumn
225// ---------------------------------------------------------------------------
226
227/// Shows estimated time remaining.
228#[derive(Debug, Clone)]
229pub struct TimeRemainingColumn {
230    pub style: Style,
231    pub elapsed_when_finished: bool,
232}
233
234impl TimeRemainingColumn {
235    pub fn new() -> Self {
236        Self {
237            style: Style::new(),
238            elapsed_when_finished: false,
239        }
240    }
241}
242
243impl ProgressColumn for TimeRemainingColumn {
244    fn render(&self, task: &Task, _width: usize, _elapsed: std::time::Duration) -> String {
245        let text = if task.is_finished() {
246            if self.elapsed_when_finished {
247                format_duration_short(&task.elapsed())
248            } else {
249                String::new()
250            }
251        } else {
252            task.time_remaining()
253                .map(|d| format_duration_short(&d))
254                .unwrap_or_else(|| "?".to_string())
255        };
256
257        let a = self.style.to_ansi();
258        let r = self.style.reset_ansi();
259        format!("{a}{text}{r}")
260    }
261}
262
263impl Default for TimeRemainingColumn {
264    fn default() -> Self {
265        Self::new()
266    }
267}
268
269// ---------------------------------------------------------------------------
270// TaskProgressColumn
271// ---------------------------------------------------------------------------
272
273/// Shows percentage complete as text.
274#[derive(Debug, Clone)]
275pub struct TaskProgressColumn {
276    pub style: Style,
277}
278
279impl TaskProgressColumn {
280    /// Create a new `TaskProgressColumn` with default style.
281    pub fn new() -> Self {
282        Self {
283            style: Style::new(),
284        }
285    }
286
287    /// Builder: set the percentage text style.
288    pub fn style(mut self, s: Style) -> Self {
289        self.style = s;
290        self
291    }
292}
293
294impl ProgressColumn for TaskProgressColumn {
295    fn render(&self, task: &Task, _width: usize, _elapsed: std::time::Duration) -> String {
296        if task.total.is_some() {
297            let pct = (task.progress() * 100.0) as usize;
298            let s = format!("{pct:>3}%");
299            let a = self.style.to_ansi();
300            let r = self.style.reset_ansi();
301            format!("{a}{s}{r}")
302        } else {
303            String::new()
304        }
305    }
306}
307
308impl Default for TaskProgressColumn {
309    fn default() -> Self {
310        Self::new()
311    }
312}
313
314// ---------------------------------------------------------------------------
315// MofNCompleteColumn
316// ---------------------------------------------------------------------------
317
318/// Shows "completed / total" style.
319#[derive(Debug, Clone)]
320pub struct MofNCompleteColumn {
321    pub style: Style,
322    pub separator: String,
323}
324
325impl MofNCompleteColumn {
326    /// Create a new `MofNCompleteColumn` with default style and `"/"` separator.
327    pub fn new() -> Self {
328        Self {
329            style: Style::new(),
330            separator: "/".to_string(),
331        }
332    }
333}
334
335impl ProgressColumn for MofNCompleteColumn {
336    fn render(&self, task: &Task, _width: usize, _elapsed: std::time::Duration) -> String {
337        let completed = task.completed as usize;
338        if let Some(total) = task.total {
339            let total = total as usize;
340            let s = format!("{completed}{}{total}", self.separator);
341            let a = self.style.to_ansi();
342            let r = self.style.reset_ansi();
343            format!("{a}{s}{r}")
344        } else {
345            format!("{completed}")
346        }
347    }
348}
349
350impl Default for MofNCompleteColumn {
351    fn default() -> Self {
352        Self::new()
353    }
354}
355
356// ---------------------------------------------------------------------------
357// Helper: short duration format
358// ---------------------------------------------------------------------------
359
360fn format_duration_short(d: &std::time::Duration) -> String {
361    let secs = d.as_secs();
362    if secs < 60 {
363        format!("0:{secs:02}")
364    } else if secs < 3600 {
365        format!("{}:{:02}", secs / 60, secs % 60)
366    } else {
367        format!("{}:{:02}:{:02}", secs / 3600, (secs % 3600) / 60, secs % 60)
368    }
369}
370
371// ---------------------------------------------------------------------------
372// Helper: file size formatting
373// ---------------------------------------------------------------------------
374
375/// Format bytes into human-readable form using decimal (1000-based) units.
376pub fn format_size(bytes: f64) -> String {
377    const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB", "PB"];
378    let mut value = bytes;
379    let mut unit_idx = 0;
380    while value >= 1000.0 && unit_idx < UNITS.len() - 1 {
381        value /= 1000.0;
382        unit_idx += 1;
383    }
384    if unit_idx == 0 {
385        format!("{:.0} {}", value, UNITS[unit_idx])
386    } else {
387        format!("{:.1} {}", value, UNITS[unit_idx])
388    }
389}
390
391/// Format a transfer speed (bytes per second) into human-readable form.
392pub fn format_speed(bytes_per_sec: f64) -> String {
393    format!("{}/s", format_size(bytes_per_sec))
394}
395
396// ---------------------------------------------------------------------------
397// FileSizeColumn
398// ---------------------------------------------------------------------------
399
400/// Shows the completed file size in human-readable format.
401#[derive(Debug, Clone)]
402pub struct FileSizeColumn {
403    pub style: Style,
404}
405
406impl FileSizeColumn {
407    /// Create a new `FileSizeColumn` with default style.
408    pub fn new() -> Self {
409        Self {
410            style: Style::new(),
411        }
412    }
413
414    /// Builder: set the text style.
415    pub fn style(mut self, s: Style) -> Self {
416        self.style = s;
417        self
418    }
419}
420
421impl ProgressColumn for FileSizeColumn {
422    fn render(&self, task: &Task, _width: usize, _elapsed: std::time::Duration) -> String {
423        let size = format_size(task.completed);
424        let a = self.style.to_ansi();
425        let r = self.style.reset_ansi();
426        format!("{a}{size}{r}")
427    }
428}
429
430impl Default for FileSizeColumn {
431    fn default() -> Self {
432        Self::new()
433    }
434}
435
436// ---------------------------------------------------------------------------
437// TotalFileSizeColumn
438// ---------------------------------------------------------------------------
439
440/// Shows the total file size in human-readable format.
441#[derive(Debug, Clone)]
442pub struct TotalFileSizeColumn {
443    pub style: Style,
444}
445
446impl TotalFileSizeColumn {
447    /// Create a new `TotalFileSizeColumn` with default style.
448    pub fn new() -> Self {
449        Self {
450            style: Style::new(),
451        }
452    }
453
454    /// Builder: set the text style.
455    pub fn style(mut self, s: Style) -> Self {
456        self.style = s;
457        self
458    }
459}
460
461impl ProgressColumn for TotalFileSizeColumn {
462    fn render(&self, task: &Task, _width: usize, _elapsed: std::time::Duration) -> String {
463        let a = self.style.to_ansi();
464        let r = self.style.reset_ansi();
465        if let Some(total) = task.total {
466            let size = format_size(total);
467            format!("{a}{size}{r}")
468        } else {
469            String::new()
470        }
471    }
472}
473
474impl Default for TotalFileSizeColumn {
475    fn default() -> Self {
476        Self::new()
477    }
478}
479
480// ---------------------------------------------------------------------------
481// DownloadColumn
482// ---------------------------------------------------------------------------
483
484/// Shows "completed/total" with file size formatting.
485#[derive(Debug, Clone)]
486pub struct DownloadColumn {
487    pub style: Style,
488    pub separator: String,
489}
490
491impl DownloadColumn {
492    /// Create a new `DownloadColumn` with default style and `"/"` separator.
493    pub fn new() -> Self {
494        Self {
495            style: Style::new(),
496            separator: "/".to_string(),
497        }
498    }
499
500    /// Builder: set the text style.
501    pub fn style(mut self, s: Style) -> Self {
502        self.style = s;
503        self
504    }
505    /// Builder: set the separator between completed and total (default `"/"`).
506    pub fn separator(mut self, sep: impl Into<String>) -> Self {
507        self.separator = sep.into();
508        self
509    }
510}
511
512impl ProgressColumn for DownloadColumn {
513    fn render(&self, task: &Task, _width: usize, _elapsed: std::time::Duration) -> String {
514        let a = self.style.to_ansi();
515        let r = self.style.reset_ansi();
516        let completed = format_size(task.completed);
517        if let Some(total) = task.total {
518            let total = format_size(total);
519            format!("{a}{completed}{}{total}{r}", self.separator)
520        } else {
521            format!("{a}{completed}{r}")
522        }
523    }
524}
525
526impl Default for DownloadColumn {
527    fn default() -> Self {
528        Self::new()
529    }
530}
531
532// ---------------------------------------------------------------------------
533// TransferSpeedColumn
534// ---------------------------------------------------------------------------
535
536/// Shows transfer speed in human-readable format (e.g., "1.5 MB/s").
537#[derive(Debug, Clone)]
538pub struct TransferSpeedColumn {
539    pub style: Style,
540}
541
542impl TransferSpeedColumn {
543    /// Create a new `TransferSpeedColumn` with default style.
544    pub fn new() -> Self {
545        Self {
546            style: Style::new(),
547        }
548    }
549
550    /// Builder: set the text style.
551    pub fn style(mut self, s: Style) -> Self {
552        self.style = s;
553        self
554    }
555}
556
557impl ProgressColumn for TransferSpeedColumn {
558    fn render(&self, task: &Task, _width: usize, elapsed: std::time::Duration) -> String {
559        let secs = elapsed.as_secs_f64();
560        let a = self.style.to_ansi();
561        let r = self.style.reset_ansi();
562        if secs > 0.0 && task.completed > 0.0 {
563            let speed = task.completed / secs;
564            let s = format_speed(speed);
565            format!("{a}{s}{r}")
566        } else {
567            format!("{a}0 B/s{r}")
568        }
569    }
570}
571
572impl Default for TransferSpeedColumn {
573    fn default() -> Self {
574        Self::new()
575    }
576}
577
578// ---------------------------------------------------------------------------
579// Tests
580// ---------------------------------------------------------------------------
581
582#[cfg(test)]
583mod tests {
584    use super::*;
585    use crate::progress::Task;
586
587    #[test]
588    fn test_text_column() {
589        let col = TextColumn::new("name");
590        let task = {
591            let mut t = Task::new(1, "test", Some(100.0));
592            t.fields.insert("name".into(), "Alice".into());
593            t
594        };
595        let result = col.render(&task, 20, std::time::Duration::from_secs(5));
596        assert!(result.contains("Alice"));
597    }
598
599    #[test]
600    fn test_spinner_column() {
601        let col = SpinnerColumn::new();
602        let task = Task::new(1, "test", Some(100.0));
603        let result = col.render(&task, 10, std::time::Duration::from_secs(1));
604        assert!(!result.is_empty());
605    }
606
607    #[test]
608    fn test_task_progress_column() {
609        let col = TaskProgressColumn::new();
610        let mut task = Task::new(1, "test", Some(100.0));
611        task.completed = 42.0;
612        let result = col.render(&task, 10, std::time::Duration::new(0, 0));
613        assert!(result.contains("42%"));
614    }
615
616    #[test]
617    fn test_format_size() {
618        assert_eq!(format_size(0.0), "0 B");
619        assert_eq!(format_size(500.0), "500 B");
620        assert_eq!(format_size(1500.0), "1.5 KB");
621        assert_eq!(format_size(2_500_000.0), "2.5 MB");
622    }
623
624    #[test]
625    fn test_format_speed() {
626        assert_eq!(format_speed(0.0), "0 B/s");
627        assert_eq!(format_speed(1500.0), "1.5 KB/s");
628    }
629
630    #[test]
631    fn test_file_size_column() {
632        let col = FileSizeColumn::new();
633        let mut task = Task::new(1, "test", Some(1000.0));
634        task.completed = 500.0;
635        let result = col.render(&task, 10, std::time::Duration::new(0, 0));
636        assert!(result.contains("500 B"));
637    }
638
639    #[test]
640    fn test_total_file_size_column() {
641        let col = TotalFileSizeColumn::new();
642        let task = Task::new(1, "test", Some(2_500_000.0));
643        let result = col.render(&task, 10, std::time::Duration::new(0, 0));
644        assert!(result.contains("2.5 MB"));
645    }
646
647    #[test]
648    fn test_download_column() {
649        let col = DownloadColumn::new();
650        let mut task = Task::new(1, "test", Some(1_500_000.0));
651        task.completed = 500_000.0;
652        let result = col.render(&task, 10, std::time::Duration::new(0, 0));
653        assert!(result.contains("500.0 KB"));
654        assert!(result.contains("1.5 MB"));
655    }
656
657    #[test]
658    fn test_transfer_speed_column() {
659        let col = TransferSpeedColumn::new();
660        let mut task = Task::new(1, "test", Some(1000.0));
661        task.completed = 500.0;
662        let result = col.render(&task, 10, std::time::Duration::from_secs(1));
663        assert!(result.contains("500 B/s"));
664    }
665}