1use std::collections::HashMap;
5use std::time::{Duration, Instant};
6
7use crate::style::Style;
8
9#[derive(Debug, Clone)]
15pub struct ProgressBar {
16 pub total: Option<f64>,
18 pub completed: f64,
20 pub width: Option<usize>,
22 pub complete_char: char,
24 pub remaining_char: char,
26 pub pulse: bool,
28 pub complete_style: Style,
30 pub remaining_style: Style,
32 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 pub fn total(mut self, total: f64) -> Self { self.total = Some(total); self }
53
54 pub fn completed(mut self, completed: f64) -> Self { self.completed = completed; self }
56
57 pub fn width(mut self, width: usize) -> Self { self.width = Some(width); self }
59
60 pub fn complete_style(mut self, style: Style) -> Self { self.complete_style = style; self }
62
63 pub fn remaining_style(mut self, style: Style) -> Self { self.remaining_style = style; self }
65
66 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 pub fn render(&self, width: usize) -> String {
81 let w = self.width.unwrap_or(width).saturating_sub(2); if w < 3 {
83 return "[]".to_string();
84 }
85
86 if self.pulse || self.total.is_none() {
87 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#[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 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#[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 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 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 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 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 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 pub fn remove_task(&mut self, task_id: usize) {
246 self.tasks.retain(|t| t.id != task_id);
247 }
248
249 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 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 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 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 pub fn advance_bytes(&mut self, task_id: usize, bytes: u64) {
339 self.advance(task_id, bytes as f64);
340 }
341
342 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 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
373pub struct TrackIterator<I: Iterator> {
380 inner: I,
381 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 pub fn count(&self) -> usize { self.count }
406
407 pub fn total(&self) -> f64 { self.total }
409}
410
411#[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 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 pub fn bytes_read(&self) -> u64 { self.bytes_read }
432
433 pub fn total(&self) -> u64 { self.total }
435
436 pub fn task_id(&self) -> usize { self.task_id }
438
439 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 pub fn inner(&self) -> &std::fs::File { &self.inner }
448
449 pub fn inner_mut(&mut self) -> &mut std::fs::File { &mut self.inner }
451
452 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
464fn 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 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 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 pf.sync(&mut p);
528 assert_eq!(p.tasks[0].completed, 5.0);
529
530 let mut buf = Vec::new();
532 pf.read_to_end(&mut buf).unwrap();
533 assert_eq!(pf.bytes_read(), 11);
534
535 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}