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