1use 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
22const BAR_MAX_WIDTH: usize = 40;
25
26pub enum ProgressColumn {
29 Description,
31 Text(String, Style),
33 Bar,
35 Percentage,
37 MofN,
39 Download,
42}
43
44impl ProgressColumn {
45 fn is_bar(&self) -> bool {
46 matches!(self, ProgressColumn::Bar)
47 }
48
49 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
63pub struct Task {
66 description: String,
67 total: f64,
68 completed: f64,
69}
70
71impl Task {
72 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 fn percentage_text(&self) -> String {
83 format!("{:>3}%", self.percentage().round() as i64)
84 }
85
86 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 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
110pub 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 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 pub fn columns(mut self, columns: Vec<ProgressColumn>) -> Self {
137 self.columns = columns;
138 self
139 }
140
141 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 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 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 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 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 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 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 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 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}