Skip to main content

rich/
progress.rs

1//! Progress displays (static rendering).
2//!
3//! Port of `rich/progress.py`'s display: a grid of tasks, one row each, whose
4//! cells come from a list of [`ProgressColumn`]s. Upstream builds a `Table.grid`
5//! (`padding=(0, 1)`); we render the equivalent inline — fixed columns take their
6//! widest cell, the bar column flexes to fill (capped at 40), and columns are
7//! separated by a single unstyled space.
8//!
9//! The **deterministic** columns are ported: description, static text, the bar,
10//! percentage, and M-of-N. The time/rate/spinner columns depend on wall-clock
11//! elapsed (and the `Live` refresh loop) and remain deferred — see the
12//! Live/progress issue and docs/DIVERGENCES.md.
13
14use crate::cells::{cell_len, set_cell_size};
15use crate::console::{Console, ConsoleOptions};
16use crate::filesize;
17use crate::progress_bar::ProgressBar;
18use crate::protocol::Renderable;
19use crate::segment::Segment;
20use crate::style::Style;
21
22/// The default `BarColumn` width (upstream `bar_width=40`); the bar shrinks below
23/// this to fit, and never grows past it.
24const BAR_MAX_WIDTH: usize = 40;
25
26/// A column in a [`Progress`] display. Mirrors the deterministic subset of
27/// upstream's `ProgressColumn`s.
28pub enum ProgressColumn {
29    /// The task description (`progress.description` — no style).
30    Description,
31    /// A static text cell with an explicit style (a simplified `TextColumn`).
32    Text(String, Style),
33    /// The flexing progress bar (`BarColumn`).
34    Bar,
35    /// The completion percentage `"{pct:>3}%"` (`progress.percentage` — magenta).
36    Percentage,
37    /// `"{completed}/{total}"` (`MofNCompleteColumn`, `progress.download` — green).
38    MofN,
39    /// `"{completed}/{total} {unit}"` in shared SI byte units, e.g. `0.5/1.0 kB`
40    /// (`DownloadColumn`, `progress.download` — green).
41    Download,
42}
43
44impl ProgressColumn {
45    fn is_bar(&self) -> bool {
46        matches!(self, ProgressColumn::Bar)
47    }
48
49    /// The `(text, style)` cell for `task` (never called on [`ProgressColumn::Bar`]).
50    fn cell(&self, task: &Task) -> (String, Option<Style>) {
51        let style = |spec: &str| Style::parse(spec).expect("valid built-in style");
52        match self {
53            ProgressColumn::Description => (task.description.clone(), None),
54            ProgressColumn::Text(text, text_style) => (text.clone(), Some(text_style.clone())),
55            ProgressColumn::Percentage => (task.percentage_text(), Some(style("magenta"))),
56            ProgressColumn::MofN => (task.mofn_text(), Some(style("green"))),
57            ProgressColumn::Download => (task.download_text(), Some(style("green"))),
58            ProgressColumn::Bar => unreachable!("bar column has no text cell"),
59        }
60    }
61}
62
63/// A single tracked task. Mirrors the fields of `rich.progress.Task` this port
64/// renders.
65pub struct Task {
66    description: String,
67    total: f64,
68    completed: f64,
69}
70
71impl Task {
72    /// The clamped completion percentage (`Task.percentage`).
73    fn percentage(&self) -> f64 {
74        if self.total > 0.0 {
75            (self.completed / self.total * 100.0).clamp(0.0, 100.0)
76        } else {
77            0.0
78        }
79    }
80
81    /// The percentage cell text (`{task.percentage:>3.0f}%`).
82    fn percentage_text(&self) -> String {
83        format!("{:>3}%", self.percentage().round() as i64)
84    }
85
86    /// The M-of-N cell text: `completed` right-justified to the width of `total`,
87    /// then `/total`. Port of `MofNCompleteColumn.render`.
88    fn mofn_text(&self) -> String {
89        let completed = self.completed as i64;
90        let total = self.total as i64;
91        let total_width = total.to_string().len();
92        format!("{completed:>total_width$}/{total}")
93    }
94
95    /// The download cell text: `completed`/`total` in a shared SI byte unit, e.g.
96    /// `0.5/1.0 kB`. Port of `DownloadColumn.render` (decimal units). The ratio is
97    /// always `< base`, so upstream's `,` thousands grouping never triggers.
98    fn download_text(&self) -> String {
99        const SUFFIXES: &[&str] = &["bytes", "kB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
100        let completed = self.completed as u64;
101        let total = self.total as u64;
102        let (unit, suffix) = filesize::pick_unit_and_suffix(total, SUFFIXES, 1000);
103        let precision = if unit == 1 { 0 } else { 1 };
104        let completed_ratio = completed as f64 / unit as f64;
105        let total_ratio = total as f64 / unit as f64;
106        format!("{completed_ratio:.precision$}/{total_ratio:.precision$} {suffix}")
107    }
108}
109
110/// A progress display over one or more [`Task`]s. Mirrors `rich.progress.Progress`.
111pub struct Progress {
112    tasks: Vec<Task>,
113    columns: Vec<ProgressColumn>,
114}
115
116impl Default for Progress {
117    fn default() -> Self {
118        Progress {
119            tasks: Vec::new(),
120            // Upstream's default columns: description, bar, percentage.
121            columns: vec![
122                ProgressColumn::Description,
123                ProgressColumn::Bar,
124                ProgressColumn::Percentage,
125            ],
126        }
127    }
128}
129
130impl Progress {
131    pub fn new() -> Self {
132        Progress::default()
133    }
134
135    /// Replace the column list (default: description, bar, percentage).
136    pub fn columns(mut self, columns: Vec<ProgressColumn>) -> Self {
137        self.columns = columns;
138        self
139    }
140
141    /// Add a task with the given description, total, and current completion.
142    pub fn add_task(
143        &mut self,
144        description: impl Into<String>,
145        total: f64,
146        completed: f64,
147    ) -> &mut Self {
148        self.tasks.push(Task {
149            description: description.into(),
150            total,
151            completed,
152        });
153        self
154    }
155}
156
157impl Renderable for Progress {
158    fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
159        let width = options.max_width;
160        let ncols = self.columns.len();
161
162        // Fixed columns take their widest cell; bar columns flex.
163        let mut col_widths = vec![0usize; ncols];
164        for (index, column) in self.columns.iter().enumerate() {
165            if column.is_bar() {
166                continue;
167            }
168            col_widths[index] = self
169                .tasks
170                .iter()
171                .map(|task| cell_len(&column.cell(task).0))
172                .max()
173                .unwrap_or(0);
174        }
175
176        // The bar column(s) share whatever the fixed columns and the single-space
177        // gaps leave, each capped at the default bar width. Port of the grid's
178        // shrink-to-fit over `no_wrap` fixed columns + a flexing `BarColumn`.
179        let gaps = ncols.saturating_sub(1);
180        let fixed_sum: usize = col_widths.iter().sum();
181        let bar_count = self.columns.iter().filter(|c| c.is_bar()).count();
182        // Free width split across the bar column(s), each capped at the default.
183        let bar_width = width
184            .saturating_sub(fixed_sum + gaps)
185            .checked_div(bar_count)
186            .map_or(0, |per_bar| BAR_MAX_WIDTH.min(per_bar));
187        for (index, column) in self.columns.iter().enumerate() {
188            if column.is_bar() {
189                col_widths[index] = bar_width;
190            }
191        }
192
193        let mut lines: Vec<Vec<Segment>> = Vec::with_capacity(self.tasks.len());
194        for task in &self.tasks {
195            let mut row: Vec<Segment> = Vec::new();
196            for (index, column) in self.columns.iter().enumerate() {
197                if index > 0 {
198                    // Inter-column gap: one unstyled space (the grid's collapsed
199                    // padding, whose column style is null).
200                    row.push(Segment::new(" ", None));
201                }
202                if column.is_bar() {
203                    let bar = ProgressBar::new(task.total, task.completed).width(bar_width);
204                    row.extend(bar.rich_render(console, &options.update_width(bar_width)));
205                } else {
206                    let (text, style) = column.cell(task);
207                    row.push(Segment::new(set_cell_size(&text, col_widths[index]), style));
208                }
209            }
210            lines.push(row);
211        }
212
213        let mut segments = Vec::new();
214        let last = lines.len().saturating_sub(1);
215        for (index, line) in lines.into_iter().enumerate() {
216            segments.extend(line);
217            if index != last {
218                segments.push(Segment::line());
219            }
220        }
221        segments
222    }
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228    use crate::color::ColorSystem;
229
230    fn render(progress: &Progress) -> String {
231        Console::builder()
232            .force_terminal(true)
233            .color_system(Some(ColorSystem::Truecolor))
234            .width(50)
235            .no_color(false)
236            .build()
237            .render_to_string(progress)
238    }
239
240    #[test]
241    fn three_tasks_match_upstream() {
242        // Captured from real rich 15.0.0 (default columns, width 50).
243        let mut progress = Progress::new();
244        progress.add_task("Downloading", 100.0, 50.0);
245        progress.add_task("Processing", 100.0, 100.0);
246        progress.add_task("Waiting", 100.0, 0.0);
247        let expected = concat!(
248            "Downloading \x1b[38;2;249;38;114m━━━━━━━━━━━━━━━━\x1b[0m",
249            "\x1b[38;2;249;38;114m╸\x1b[0m\x1b[38;5;237m━━━━━━━━━━━━━━━━\x1b[0m \x1b[35m 50%\x1b[0m\n",
250            "Processing  \x1b[38;2;114;156;31m",
251            "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m \x1b[35m100%\x1b[0m\n",
252            "Waiting     \x1b[38;5;237m",
253            "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m \x1b[35m  0%\x1b[0m",
254        );
255        assert_eq!(render(&progress), expected);
256    }
257
258    #[test]
259    fn download_text_matches_upstream() {
260        // Captured from real rich 15.0.0 DownloadColumn.render (decimal units).
261        let dl = |completed: f64, total: f64| {
262            Task {
263                description: String::new(),
264                total,
265                completed,
266            }
267            .download_text()
268        };
269        assert_eq!(dl(500.0, 1000.0), "0.5/1.0 kB");
270        assert_eq!(dl(500.0, 999.0), "500/999 bytes");
271        assert_eq!(dl(1_500_000.0, 3_000_000.0), "1.5/3.0 MB");
272        assert_eq!(dl(0.0, 1024.0), "0.0/1.0 kB");
273        assert_eq!(dl(2_500_000_000.0, 10_000_000_000.0), "2.5/10.0 GB");
274        assert_eq!(dl(250.0, 250.0), "250/250 bytes");
275    }
276
277    #[test]
278    fn download_column_in_grid_matches_upstream() {
279        // Captured from real rich 15.0.0: description + bar + download at width 50.
280        let mut progress = Progress::new().columns(vec![
281            ProgressColumn::Description,
282            ProgressColumn::Bar,
283            ProgressColumn::Download,
284        ]);
285        progress.add_task("File", 1000.0, 500.0);
286        let expected = concat!(
287            "File \x1b[38;2;249;38;114m━━━━━━━━━━━━━━━━━\x1b[0m\x1b[38;5;237m╺\x1b[0m",
288            "\x1b[38;5;237m━━━━━━━━━━━━━━━━\x1b[0m \x1b[32m0.5/1.0 kB\x1b[0m",
289        );
290        assert_eq!(render(&progress), expected);
291    }
292
293    #[test]
294    fn custom_columns_with_mofn_match_upstream() {
295        // Captured from real rich 15.0.0: description + bar + M-of-N (differing
296        // M-of-N widths → the narrower cell left-justifies with green padding).
297        let mut progress = Progress::new().columns(vec![
298            ProgressColumn::Description,
299            ProgressColumn::Bar,
300            ProgressColumn::MofN,
301        ]);
302        progress.add_task("A", 5.0, 3.0);
303        progress.add_task("B", 100.0, 50.0);
304        let console = Console::builder()
305            .force_terminal(true)
306            .color_system(Some(ColorSystem::Truecolor))
307            .width(40)
308            .no_color(false)
309            .build();
310        let expected = concat!(
311            "A \x1b[38;2;249;38;114m━━━━━━━━━━━━━━━━━━\x1b[0m\x1b[38;5;237m╺\x1b[0m",
312            "\x1b[38;5;237m━━━━━━━━━━━\x1b[0m \x1b[32m3/5    \x1b[0m\n",
313            "B \x1b[38;2;249;38;114m━━━━━━━━━━━━━━━\x1b[0m\x1b[38;5;237m╺\x1b[0m",
314            "\x1b[38;5;237m━━━━━━━━━━━━━━\x1b[0m \x1b[32m 50/100\x1b[0m",
315        );
316        assert_eq!(console.render_to_string(&progress), expected);
317    }
318}