Skip to main content

rich_rust/renderables/
progress.rs

1//! Progress bar renderable.
2//!
3//! This module provides progress bar components for displaying task progress
4//! in the terminal with various styles and features.
5
6use crate::cells;
7use crate::console::{Console, ConsoleOptions};
8use crate::filesize::{self, SizeUnit, binary, binary_speed, decimal, decimal_speed};
9use crate::renderables::Renderable;
10use crate::segment::Segment;
11use crate::style::Style;
12use crate::text::Text;
13use std::time::{Duration, Instant};
14
15/// Bar style variants for the progress bar.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
17pub enum BarStyle {
18    /// Standard ASCII bar using # and -
19    Ascii,
20    /// Unicode block characters (█▓░)
21    #[default]
22    Block,
23    /// Line style (━╺)
24    Line,
25    /// Dots style (●○)
26    Dots,
27    /// Shaded gradient style (█▇▆▅▄▃▂▁░)
28    Gradient,
29}
30
31impl BarStyle {
32    /// Get the completed character for this style.
33    #[must_use]
34    pub const fn completed_char(&self) -> &'static str {
35        match self {
36            Self::Ascii => "#",
37            Self::Block => "\u{2588}",    // █
38            Self::Line => "\u{2501}",     // ━
39            Self::Dots => "\u{25CF}",     // ●
40            Self::Gradient => "\u{2588}", // █
41        }
42    }
43
44    /// Get the remaining character for this style.
45    #[must_use]
46    pub const fn remaining_char(&self) -> &'static str {
47        match self {
48            Self::Ascii => "-",
49            Self::Block => "\u{2591}",    // ░
50            Self::Line => "\u{2501}",     // ━
51            Self::Dots => "\u{25CB}",     // ○
52            Self::Gradient => "\u{2591}", // ░
53        }
54    }
55
56    /// Get the pulse character for this style (edge of completion).
57    #[must_use]
58    pub const fn pulse_char(&self) -> &'static str {
59        match self {
60            Self::Ascii => ">",
61            Self::Block => "\u{2593}",    // ▓
62            Self::Line => "\u{257A}",     // ╺
63            Self::Dots => "\u{25CF}",     // ●
64            Self::Gradient => "\u{2593}", // ▓
65        }
66    }
67}
68
69/// Spinner animation frames.
70#[derive(Debug, Clone)]
71pub struct Spinner {
72    /// Animation frames.
73    frames: Vec<&'static str>,
74    /// Current frame index.
75    frame_index: usize,
76    /// Style for the spinner.
77    style: Style,
78}
79
80impl Default for Spinner {
81    fn default() -> Self {
82        Self::dots()
83    }
84}
85
86impl Spinner {
87    /// Create a dots spinner (⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏).
88    #[must_use]
89    pub fn dots() -> Self {
90        Self {
91            frames: vec!["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"],
92            frame_index: 0,
93            style: Style::new(),
94        }
95    }
96
97    /// Create a line spinner (⎺⎻⎼⎽⎼⎻).
98    #[must_use]
99    pub fn line() -> Self {
100        Self {
101            frames: vec!["⎺", "⎻", "⎼", "⎽", "⎼", "⎻"],
102            frame_index: 0,
103            style: Style::new(),
104        }
105    }
106
107    /// Create a simple spinner (|/-\).
108    #[must_use]
109    pub fn simple() -> Self {
110        Self {
111            frames: vec!["|", "/", "-", "\\"],
112            frame_index: 0,
113            style: Style::new(),
114        }
115    }
116
117    /// Create a bouncing ball spinner (⠁⠂⠄⠂).
118    #[must_use]
119    pub fn bounce() -> Self {
120        Self {
121            frames: vec!["⠁", "⠂", "⠄", "⠂"],
122            frame_index: 0,
123            style: Style::new(),
124        }
125    }
126
127    /// Create a growing dots spinner (⣾⣽⣻⢿⡿⣟⣯⣷).
128    #[must_use]
129    pub fn growing() -> Self {
130        Self {
131            frames: vec!["⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"],
132            frame_index: 0,
133            style: Style::new(),
134        }
135    }
136
137    /// Create a moon phase spinner (🌑🌒🌓🌔🌕🌖🌗🌘).
138    #[must_use]
139    pub fn moon() -> Self {
140        Self {
141            frames: vec!["🌑", "🌒", "🌓", "🌔", "🌕", "🌖", "🌗", "🌘"],
142            frame_index: 0,
143            style: Style::new(),
144        }
145    }
146
147    /// Create a clock spinner (🕐🕑🕒🕓🕔🕕🕖🕗🕘🕙🕚🕛).
148    #[must_use]
149    pub fn clock() -> Self {
150        Self {
151            frames: vec![
152                "🕐", "🕑", "🕒", "🕓", "🕔", "🕕", "🕖", "🕗", "🕘", "🕙", "🕚", "🕛",
153            ],
154            frame_index: 0,
155            style: Style::new(),
156        }
157    }
158
159    /// Create a spinner from custom frames.
160    #[must_use]
161    pub fn custom(frames: Vec<&'static str>) -> Self {
162        Self {
163            frames,
164            frame_index: 0,
165            style: Style::new(),
166        }
167    }
168
169    /// Set the spinner style.
170    #[must_use]
171    pub fn style(mut self, style: Style) -> Self {
172        self.style = style;
173        self
174    }
175
176    /// Advance to the next frame and return the current frame.
177    pub fn next_frame(&mut self) -> &'static str {
178        if self.frames.is_empty() {
179            return " ";
180        }
181        let frame = self.frames[self.frame_index];
182        self.frame_index = (self.frame_index + 1) % self.frames.len();
183        frame
184    }
185
186    /// Get the current frame without advancing.
187    #[must_use]
188    pub fn current_frame(&self) -> &'static str {
189        if self.frames.is_empty() {
190            return " ";
191        }
192        self.frames[self.frame_index]
193    }
194
195    /// Render the current spinner frame as a segment.
196    #[must_use]
197    pub fn render(&self) -> Segment<'static> {
198        Segment::new(self.current_frame(), Some(self.style.clone()))
199    }
200}
201
202/// A progress bar with percentage, ETA, and customizable appearance.
203#[derive(Debug, Clone)]
204pub struct ProgressBar {
205    /// Current progress (0.0 - 1.0).
206    completed: f64,
207    /// Total expected count (for ETA calculation).
208    total: Option<u64>,
209    /// Current count (for ETA calculation).
210    current: u64,
211    /// Bar width in cells.
212    width: usize,
213    /// Bar style.
214    bar_style: BarStyle,
215    /// Style for completed portion.
216    completed_style: Style,
217    /// Style for remaining portion.
218    remaining_style: Style,
219    /// Style for the pulse character.
220    pulse_style: Style,
221    /// Show percentage.
222    show_percentage: bool,
223    /// Show ETA.
224    show_eta: bool,
225    /// Show elapsed time.
226    show_elapsed: bool,
227    /// Show speed (items/sec).
228    show_speed: bool,
229    /// Task description.
230    description: Option<Text>,
231    /// Start time for ETA calculation.
232    start_time: Option<Instant>,
233    /// Whether to show brackets around the bar.
234    show_brackets: bool,
235    /// Finished message (replaces bar when complete).
236    finished_message: Option<String>,
237    /// Whether the task is complete.
238    is_finished: bool,
239    /// Total bytes for file transfer (optional).
240    total_bytes: Option<u64>,
241    /// Bytes transferred so far.
242    transferred_bytes: u64,
243    /// Show file size (current/total).
244    show_file_size: bool,
245    /// Show transfer speed (bytes/sec).
246    show_transfer_speed: bool,
247    /// Use binary (1024-based) units for file sizes, or decimal (1000-based).
248    use_binary_units: bool,
249}
250
251impl Default for ProgressBar {
252    fn default() -> Self {
253        Self {
254            completed: 0.0,
255            total: None,
256            current: 0,
257            width: 40,
258            bar_style: BarStyle::default(),
259            completed_style: Style::new().color_str("green").unwrap_or_default(),
260            remaining_style: Style::new().color_str("bright_black").unwrap_or_default(),
261            pulse_style: Style::new().color_str("cyan").unwrap_or_default(),
262            show_percentage: true,
263            show_eta: false,
264            show_elapsed: false,
265            show_speed: false,
266            description: None,
267            start_time: None,
268            show_brackets: true,
269            finished_message: None,
270            is_finished: false,
271            total_bytes: None,
272            transferred_bytes: 0,
273            show_file_size: false,
274            show_transfer_speed: false,
275            use_binary_units: false,
276        }
277    }
278}
279
280impl ProgressBar {
281    /// Create a new progress bar.
282    #[must_use]
283    pub fn new() -> Self {
284        Self::default()
285    }
286
287    /// Create a progress bar with a known total.
288    #[must_use]
289    pub fn with_total(total: u64) -> Self {
290        Self {
291            total: Some(total),
292            show_eta: true,
293            start_time: Some(Instant::now()),
294            ..Self::default()
295        }
296    }
297
298    /// Set the bar width.
299    #[must_use]
300    pub fn width(mut self, width: usize) -> Self {
301        self.width = width;
302        self
303    }
304
305    /// Set the bar style.
306    #[must_use]
307    pub fn bar_style(mut self, style: BarStyle) -> Self {
308        self.bar_style = style;
309        self
310    }
311
312    /// Set the completed portion style.
313    #[must_use]
314    pub fn completed_style(mut self, style: Style) -> Self {
315        self.completed_style = style;
316        self
317    }
318
319    /// Set the remaining portion style.
320    #[must_use]
321    pub fn remaining_style(mut self, style: Style) -> Self {
322        self.remaining_style = style;
323        self
324    }
325
326    /// Set the pulse character style.
327    #[must_use]
328    pub fn pulse_style(mut self, style: Style) -> Self {
329        self.pulse_style = style;
330        self
331    }
332
333    /// Set whether to show percentage.
334    #[must_use]
335    pub fn show_percentage(mut self, show: bool) -> Self {
336        self.show_percentage = show;
337        self
338    }
339
340    /// Set whether to show ETA.
341    #[must_use]
342    pub fn show_eta(mut self, show: bool) -> Self {
343        self.show_eta = show;
344        if show && self.start_time.is_none() {
345            self.start_time = Some(Instant::now());
346        }
347        self
348    }
349
350    /// Set whether to show elapsed time.
351    #[must_use]
352    pub fn show_elapsed(mut self, show: bool) -> Self {
353        self.show_elapsed = show;
354        if show && self.start_time.is_none() {
355            self.start_time = Some(Instant::now());
356        }
357        self
358    }
359
360    /// Set whether to show speed.
361    #[must_use]
362    pub fn show_speed(mut self, show: bool) -> Self {
363        self.show_speed = show;
364        if show && self.start_time.is_none() {
365            self.start_time = Some(Instant::now());
366        }
367        self
368    }
369
370    /// Set the task description.
371    ///
372    /// Passing a `&str` uses `Text::new()` and does **NOT** parse markup.
373    /// For styled descriptions, pass a pre-styled `Text` (e.g. from
374    /// [`crate::markup::render_or_plain`]).
375    #[must_use]
376    pub fn description(mut self, desc: impl Into<Text>) -> Self {
377        self.description = Some(desc.into());
378        self
379    }
380
381    /// Set whether to show brackets around the bar.
382    #[must_use]
383    pub fn show_brackets(mut self, show: bool) -> Self {
384        self.show_brackets = show;
385        self
386    }
387
388    /// Set the finished message.
389    ///
390    /// This takes a plain string and does **NOT** parse markup.
391    #[must_use]
392    pub fn finished_message(mut self, msg: impl Into<String>) -> Self {
393        self.finished_message = Some(msg.into());
394        self
395    }
396
397    /// Update progress directly (0.0 - 1.0).
398    pub fn set_progress(&mut self, progress: f64) {
399        self.completed = progress.clamp(0.0, 1.0);
400        if self.completed >= 1.0 {
401            self.is_finished = true;
402        }
403    }
404
405    /// Update progress with current/total counts.
406    pub fn update(&mut self, current: u64) {
407        self.current = current;
408        if let Some(total) = self.total
409            && total > 0
410        {
411            #[allow(clippy::cast_precision_loss)]
412            {
413                self.completed = (current as f64) / (total as f64);
414            }
415            self.completed = self.completed.clamp(0.0, 1.0);
416        }
417        if self.completed >= 1.0 {
418            self.is_finished = true;
419        }
420    }
421
422    /// Advance progress by a delta.
423    pub fn advance(&mut self, delta: u64) {
424        self.update(self.current + delta);
425    }
426
427    /// Mark the progress bar as finished.
428    pub fn finish(&mut self) {
429        self.completed = 1.0;
430        self.is_finished = true;
431    }
432
433    /// Get the current progress (0.0 - 1.0).
434    #[must_use]
435    pub fn progress(&self) -> f64 {
436        self.completed
437    }
438
439    /// Check if the progress bar is finished.
440    #[must_use]
441    pub fn is_finished(&self) -> bool {
442        self.is_finished
443    }
444
445    /// Get the elapsed time since start.
446    #[must_use]
447    pub fn elapsed(&self) -> Option<Duration> {
448        self.start_time.map(|start| start.elapsed())
449    }
450
451    /// Calculate estimated time remaining.
452    #[must_use]
453    pub fn eta(&self) -> Option<Duration> {
454        if self.completed <= 0.0 || self.completed >= 1.0 {
455            return None;
456        }
457
458        let elapsed = self.elapsed()?;
459        let elapsed_secs = elapsed.as_secs_f64();
460        if elapsed_secs < 0.1 {
461            return None; // Not enough data
462        }
463
464        let remaining_ratio = (1.0 - self.completed) / self.completed;
465        let eta_secs = elapsed_secs * remaining_ratio;
466
467        Some(Duration::from_secs_f64(eta_secs))
468    }
469
470    /// Calculate items per second.
471    #[must_use]
472    pub fn speed(&self) -> Option<f64> {
473        let elapsed = self.elapsed()?;
474        let elapsed_secs = elapsed.as_secs_f64();
475        if elapsed_secs < 0.1 {
476            return None;
477        }
478
479        #[allow(clippy::cast_precision_loss)]
480        Some((self.current as f64) / elapsed_secs)
481    }
482
483    // -------------------------------------------------------------------------
484    // File Size / Transfer Progress Methods
485    // -------------------------------------------------------------------------
486
487    /// Create a progress bar for file transfers with a known total size.
488    ///
489    /// This automatically enables file size display and transfer speed.
490    ///
491    /// # Example
492    ///
493    /// ```rust
494    /// use rich_rust::renderables::ProgressBar;
495    ///
496    /// let mut bar = ProgressBar::for_download(1_000_000); // 1 MB download
497    /// bar.update_bytes(500_000); // 500 KB transferred
498    /// ```
499    #[must_use]
500    pub fn for_download(total_bytes: u64) -> Self {
501        Self {
502            total_bytes: Some(total_bytes),
503            total: Some(total_bytes),
504            show_file_size: true,
505            show_transfer_speed: true,
506            show_percentage: true,
507            show_eta: true,
508            start_time: Some(Instant::now()),
509            ..Self::default()
510        }
511    }
512
513    /// Set the total bytes for file transfer progress.
514    #[must_use]
515    pub fn total_bytes(mut self, bytes: u64) -> Self {
516        self.total_bytes = Some(bytes);
517        self.total = Some(bytes);
518        self
519    }
520
521    /// Set whether to show file size (current/total bytes).
522    #[must_use]
523    pub fn show_file_size(mut self, show: bool) -> Self {
524        self.show_file_size = show;
525        self
526    }
527
528    /// Set whether to show transfer speed (bytes/sec).
529    #[must_use]
530    pub fn show_transfer_speed(mut self, show: bool) -> Self {
531        self.show_transfer_speed = show;
532        if show && self.start_time.is_none() {
533            self.start_time = Some(Instant::now());
534        }
535        self
536    }
537
538    /// Set whether to use binary (1024-based: KiB, MiB) or decimal (1000-based: KB, MB) units.
539    ///
540    /// By default, decimal units are used.
541    #[must_use]
542    pub fn use_binary_units(mut self, use_binary: bool) -> Self {
543        self.use_binary_units = use_binary;
544        self
545    }
546
547    /// Update the transferred bytes and recalculate progress.
548    pub fn update_bytes(&mut self, bytes: u64) {
549        self.transferred_bytes = bytes;
550        self.current = bytes;
551        if let Some(total) = self.total_bytes
552            && total > 0
553        {
554            #[allow(clippy::cast_precision_loss)]
555            {
556                self.completed = (bytes as f64) / (total as f64);
557            }
558            self.completed = self.completed.clamp(0.0, 1.0);
559        }
560        if self.completed >= 1.0 {
561            self.is_finished = true;
562        }
563    }
564
565    /// Advance the transferred bytes by a delta.
566    pub fn advance_bytes(&mut self, delta: u64) {
567        self.update_bytes(self.transferred_bytes + delta);
568    }
569
570    /// Get the current transferred bytes.
571    #[must_use]
572    pub fn transferred_bytes(&self) -> u64 {
573        self.transferred_bytes
574    }
575
576    /// Get the total bytes (if set).
577    #[must_use]
578    pub fn total_bytes_value(&self) -> Option<u64> {
579        self.total_bytes
580    }
581
582    /// Calculate transfer speed in bytes per second.
583    #[must_use]
584    pub fn transfer_speed(&self) -> Option<f64> {
585        let elapsed = self.elapsed()?;
586        let elapsed_secs = elapsed.as_secs_f64();
587        if elapsed_secs < 0.1 {
588            return None;
589        }
590
591        #[allow(clippy::cast_precision_loss)]
592        Some((self.transferred_bytes as f64) / elapsed_secs)
593    }
594
595    /// Format the current file size as a human-readable string.
596    #[must_use]
597    pub fn format_file_size(&self) -> String {
598        if self.use_binary_units {
599            binary(self.transferred_bytes)
600        } else {
601            decimal(self.transferred_bytes)
602        }
603    }
604
605    /// Format the total file size as a human-readable string.
606    #[must_use]
607    pub fn format_total_size(&self) -> Option<String> {
608        self.total_bytes.map(|total| {
609            if self.use_binary_units {
610                binary(total)
611            } else {
612                decimal(total)
613            }
614        })
615    }
616
617    /// Format the transfer speed as a human-readable string.
618    #[must_use]
619    pub fn format_transfer_speed(&self) -> Option<String> {
620        self.transfer_speed().map(|speed| {
621            if self.use_binary_units {
622                binary_speed(speed)
623            } else {
624                decimal_speed(speed)
625            }
626        })
627    }
628
629    /// Format a duration as a human-readable string.
630    #[must_use]
631    fn format_duration(duration: Duration) -> String {
632        let total_secs = duration.as_secs();
633        if total_secs < 60 {
634            format!("{total_secs}s")
635        } else if total_secs < 3600 {
636            let mins = total_secs / 60;
637            let secs = total_secs % 60;
638            format!("{mins}:{secs:02}")
639        } else {
640            let hours = total_secs / 3600;
641            let mins = (total_secs % 3600) / 60;
642            let secs = total_secs % 60;
643            format!("{hours}:{mins:02}:{secs:02}")
644        }
645    }
646
647    /// Render the progress bar to segments for a given width.
648    #[must_use]
649    pub fn render(&self, available_width: usize) -> Vec<Segment<'static>> {
650        let mut segments = Vec::new();
651
652        // If finished and has a finished message, show that
653        if self.is_finished
654            && let Some(ref msg) = self.finished_message
655        {
656            let style = Style::new().color_str("green").unwrap_or_default();
657            segments.push(Segment::new(format!("✓ {msg}"), Some(style)));
658            segments.push(Segment::line());
659            return segments;
660        }
661
662        // Description
663        let mut used_width = 0;
664        if let Some(ref desc) = self.description {
665            let mut desc_text = desc.clone();
666            desc_text.append(" ");
667            let desc_width = desc_text.cell_len();
668            segments.extend(
669                desc_text
670                    .render("")
671                    .into_iter()
672                    .map(super::super::segment::Segment::into_owned),
673            );
674            used_width += desc_width;
675        }
676
677        // Calculate bar width
678        let mut suffix_parts: Vec<String> = Vec::new();
679
680        if self.show_percentage {
681            #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
682            let pct = (self.completed * 100.0) as u32;
683            suffix_parts.push(format!("{pct:3}%"));
684        }
685
686        if self.show_elapsed
687            && let Some(elapsed) = self.elapsed()
688        {
689            suffix_parts.push(Self::format_duration(elapsed));
690        }
691
692        if self.show_eta
693            && !self.is_finished
694            && let Some(eta) = self.eta()
695        {
696            suffix_parts.push(format!("ETA {}", Self::format_duration(eta)));
697        }
698
699        if self.show_speed
700            && let Some(speed) = self.speed()
701        {
702            if speed >= 1.0 {
703                #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
704                let speed_int = speed as u64;
705                suffix_parts.push(format!("{speed_int}/s"));
706            } else {
707                suffix_parts.push(format!("{speed:.2}/s"));
708            }
709        }
710
711        // File size display (e.g., "1.5 MB / 10.0 MB")
712        if self.show_file_size {
713            let current_size = self.format_file_size();
714            if let Some(total_size) = self.format_total_size() {
715                suffix_parts.push(format!("{current_size}/{total_size}"));
716            } else {
717                suffix_parts.push(current_size);
718            }
719        }
720
721        // Transfer speed display (e.g., "1.5 MB/s")
722        if self.show_transfer_speed
723            && let Some(speed_str) = self.format_transfer_speed()
724        {
725            suffix_parts.push(speed_str);
726        }
727
728        let suffix = if suffix_parts.is_empty() {
729            String::new()
730        } else {
731            format!(" {}", suffix_parts.join(" "))
732        };
733        let suffix_width = cells::cell_len(&suffix);
734
735        let bracket_width = if self.show_brackets { 2 } else { 0 };
736        let bar_width = available_width
737            .saturating_sub(used_width)
738            .saturating_sub(suffix_width)
739            .saturating_sub(bracket_width)
740            .min(self.width);
741
742        if bar_width < 3 {
743            // Not enough space for a bar, just show percentage
744            if self.show_percentage {
745                #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
746                let pct = (self.completed * 100.0) as u32;
747                segments.push(Segment::new(format!("{pct}%"), None));
748            }
749            segments.push(Segment::line());
750            return segments;
751        }
752
753        // Render the bar
754        if self.show_brackets {
755            segments.push(Segment::new("[", None));
756        }
757
758        #[allow(
759            clippy::cast_possible_truncation,
760            clippy::cast_sign_loss,
761            clippy::cast_precision_loss
762        )]
763        let completed_width = ((self.completed * bar_width as f64).floor() as usize).min(bar_width);
764        let remaining_width = bar_width.saturating_sub(completed_width);
765
766        // Completed portion
767        if completed_width > 0 {
768            let completed_chars = self.bar_style.completed_char().repeat(completed_width);
769            segments.push(Segment::new(
770                completed_chars,
771                Some(self.completed_style.clone()),
772            ));
773        }
774
775        // Pulse character (at the edge)
776        // Show pulse if we have remaining space and we are active (progress > 0 and < 1)
777        // or if we have calculated some completion but still have space.
778        let show_pulse = remaining_width > 0 && self.completed > 0.0 && self.completed < 1.0;
779
780        if show_pulse {
781            // Replace first remaining char with pulse
782            let remaining_after_pulse = remaining_width.saturating_sub(1);
783            segments.push(Segment::new(
784                self.bar_style.pulse_char(),
785                Some(self.pulse_style.clone()),
786            ));
787
788            if remaining_after_pulse > 0 {
789                let remaining_chars = self
790                    .bar_style
791                    .remaining_char()
792                    .repeat(remaining_after_pulse);
793                segments.push(Segment::new(
794                    remaining_chars,
795                    Some(self.remaining_style.clone()),
796                ));
797            }
798        } else if remaining_width > 0 {
799            let remaining_chars = self.bar_style.remaining_char().repeat(remaining_width);
800            segments.push(Segment::new(
801                remaining_chars,
802                Some(self.remaining_style.clone()),
803            ));
804        }
805
806        if self.show_brackets {
807            segments.push(Segment::new("]", None));
808        }
809
810        // Suffix (percentage, ETA, etc.)
811        if !suffix.is_empty() {
812            segments.push(Segment::new(suffix, None));
813        }
814
815        segments.push(Segment::line());
816        segments
817    }
818
819    /// Render the progress bar as a plain string.
820    #[must_use]
821    pub fn render_plain(&self, width: usize) -> String {
822        self.render(width)
823            .into_iter()
824            .map(|seg| seg.text.into_owned())
825            .collect()
826    }
827}
828
829impl Renderable for ProgressBar {
830    fn render<'a>(&'a self, _console: &Console, options: &ConsoleOptions) -> Vec<Segment<'a>> {
831        self.render(options.max_width).into_iter().collect()
832    }
833}
834
835/// Create an ASCII-style progress bar.
836#[must_use]
837pub fn ascii_bar() -> ProgressBar {
838    ProgressBar::new().bar_style(BarStyle::Ascii)
839}
840
841/// Create a line-style progress bar.
842#[must_use]
843pub fn line_bar() -> ProgressBar {
844    ProgressBar::new().bar_style(BarStyle::Line)
845}
846
847/// Create a dots-style progress bar.
848#[must_use]
849pub fn dots_bar() -> ProgressBar {
850    ProgressBar::new().bar_style(BarStyle::Dots)
851}
852
853/// Create a gradient-style progress bar.
854#[must_use]
855pub fn gradient_bar() -> ProgressBar {
856    ProgressBar::new().bar_style(BarStyle::Gradient)
857}
858
859// =============================================================================
860// Standalone File Size and Transfer Speed Columns
861// =============================================================================
862
863/// A renderable that displays a file size in human-readable format.
864#[derive(Debug, Clone)]
865pub struct FileSizeColumn {
866    size: u64,
867    unit: SizeUnit,
868    precision: usize,
869    style: Style,
870}
871
872impl FileSizeColumn {
873    #[must_use]
874    pub fn new(size: u64) -> Self {
875        Self {
876            size,
877            unit: SizeUnit::Decimal,
878            precision: 1,
879            style: Style::new().color_str("green").unwrap_or_default(),
880        }
881    }
882
883    #[must_use]
884    pub fn unit(mut self, unit: SizeUnit) -> Self {
885        self.unit = unit;
886        self
887    }
888
889    #[must_use]
890    pub fn precision(mut self, precision: usize) -> Self {
891        self.precision = precision;
892        self
893    }
894
895    #[must_use]
896    pub fn style(mut self, style: Style) -> Self {
897        self.style = style;
898        self
899    }
900
901    pub fn set_size(&mut self, size: u64) {
902        self.size = size;
903    }
904
905    #[must_use]
906    pub fn size(&self) -> u64 {
907        self.size
908    }
909
910    #[must_use]
911    pub fn render_plain(&self) -> String {
912        #[allow(clippy::cast_possible_wrap)]
913        filesize::format_size(self.size as i64, self.unit, self.precision)
914    }
915
916    #[must_use]
917    pub fn render(&self) -> Vec<Segment<'static>> {
918        vec![Segment::new(self.render_plain(), Some(self.style.clone()))]
919    }
920}
921
922impl Default for FileSizeColumn {
923    fn default() -> Self {
924        Self::new(0)
925    }
926}
927
928/// A renderable that displays a total file size.
929#[derive(Debug, Clone)]
930pub struct TotalFileSizeColumn {
931    inner: FileSizeColumn,
932}
933
934impl TotalFileSizeColumn {
935    #[must_use]
936    pub fn new(size: u64) -> Self {
937        Self {
938            inner: FileSizeColumn::new(size),
939        }
940    }
941
942    #[must_use]
943    pub fn unit(mut self, unit: SizeUnit) -> Self {
944        self.inner = self.inner.unit(unit);
945        self
946    }
947
948    #[must_use]
949    pub fn precision(mut self, precision: usize) -> Self {
950        self.inner = self.inner.precision(precision);
951        self
952    }
953
954    #[must_use]
955    pub fn style(mut self, style: Style) -> Self {
956        self.inner = self.inner.style(style);
957        self
958    }
959
960    #[must_use]
961    pub fn render_plain(&self) -> String {
962        self.inner.render_plain()
963    }
964
965    #[must_use]
966    pub fn render(&self) -> Vec<Segment<'static>> {
967        self.inner.render()
968    }
969}
970
971impl Default for TotalFileSizeColumn {
972    fn default() -> Self {
973        Self::new(0)
974    }
975}
976
977/// A renderable that displays download progress as "current/total unit".
978#[derive(Debug, Clone)]
979pub struct DownloadColumn {
980    current: u64,
981    total: u64,
982    unit: SizeUnit,
983    precision: usize,
984    current_style: Style,
985    separator_style: Style,
986    total_style: Style,
987}
988
989impl DownloadColumn {
990    #[must_use]
991    pub fn new(current: u64, total: u64) -> Self {
992        let green_style = Style::new().color_str("green").unwrap_or_default();
993        Self {
994            current,
995            total,
996            unit: SizeUnit::Decimal,
997            precision: 1,
998            current_style: green_style.clone(),
999            separator_style: Style::new(),
1000            total_style: green_style,
1001        }
1002    }
1003
1004    #[must_use]
1005    pub fn unit(mut self, unit: SizeUnit) -> Self {
1006        self.unit = unit;
1007        self
1008    }
1009
1010    #[must_use]
1011    pub fn precision(mut self, precision: usize) -> Self {
1012        self.precision = precision;
1013        self
1014    }
1015
1016    #[must_use]
1017    pub fn current_style(mut self, style: Style) -> Self {
1018        self.current_style = style;
1019        self
1020    }
1021
1022    #[must_use]
1023    pub fn total_style(mut self, style: Style) -> Self {
1024        self.total_style = style;
1025        self
1026    }
1027
1028    pub fn set_current(&mut self, current: u64) {
1029        self.current = current;
1030    }
1031
1032    pub fn set_total(&mut self, total: u64) {
1033        self.total = total;
1034    }
1035
1036    #[must_use]
1037    pub fn current(&self) -> u64 {
1038        self.current
1039    }
1040
1041    #[must_use]
1042    pub fn total(&self) -> u64 {
1043        self.total
1044    }
1045
1046    #[must_use]
1047    pub fn render_plain(&self) -> String {
1048        #[allow(clippy::cast_possible_wrap)]
1049        let current_str = filesize::format_size(self.current as i64, self.unit, self.precision);
1050        #[allow(clippy::cast_possible_wrap)]
1051        let total_str = filesize::format_size(self.total as i64, self.unit, self.precision);
1052        let parts: Vec<&str> = total_str.rsplitn(2, ' ').collect();
1053        if parts.len() == 2 {
1054            let unit_str = parts[0];
1055            let total_value = parts[1];
1056            let current_parts: Vec<&str> = current_str.rsplitn(2, ' ').collect();
1057            let current_value = if current_parts.len() == 2 {
1058                current_parts[1]
1059            } else {
1060                &current_str
1061            };
1062            format!("{current_value}/{total_value} {unit_str}")
1063        } else {
1064            format!("{current_str}/{total_str}")
1065        }
1066    }
1067
1068    #[must_use]
1069    pub fn render(&self) -> Vec<Segment<'static>> {
1070        #[allow(clippy::cast_possible_wrap)]
1071        let current_str = filesize::format_size(self.current as i64, self.unit, self.precision);
1072        #[allow(clippy::cast_possible_wrap)]
1073        let total_str = filesize::format_size(self.total as i64, self.unit, self.precision);
1074        let parts: Vec<&str> = total_str.rsplitn(2, ' ').collect();
1075        if parts.len() == 2 {
1076            let unit_str = parts[0];
1077            let total_value = parts[1];
1078            let current_parts: Vec<&str> = current_str.rsplitn(2, ' ').collect();
1079            let current_value = if current_parts.len() == 2 {
1080                current_parts[1]
1081            } else {
1082                &current_str
1083            };
1084            vec![
1085                Segment::new(current_value.to_string(), Some(self.current_style.clone())),
1086                Segment::new("/", Some(self.separator_style.clone())),
1087                Segment::new(
1088                    format!("{total_value} {unit_str}"),
1089                    Some(self.total_style.clone()),
1090                ),
1091            ]
1092        } else {
1093            vec![
1094                Segment::new(current_str, Some(self.current_style.clone())),
1095                Segment::new("/", Some(self.separator_style.clone())),
1096                Segment::new(total_str, Some(self.total_style.clone())),
1097            ]
1098        }
1099    }
1100}
1101
1102impl Default for DownloadColumn {
1103    fn default() -> Self {
1104        Self::new(0, 0)
1105    }
1106}
1107
1108/// A renderable that displays a transfer speed in human-readable format.
1109#[derive(Debug, Clone)]
1110pub struct TransferSpeedColumn {
1111    speed: f64,
1112    unit: SizeUnit,
1113    precision: usize,
1114    style: Style,
1115}
1116
1117impl TransferSpeedColumn {
1118    #[must_use]
1119    pub fn new(speed: f64) -> Self {
1120        Self {
1121            speed,
1122            unit: SizeUnit::Decimal,
1123            precision: 1,
1124            style: Style::new().color_str("red").unwrap_or_default(),
1125        }
1126    }
1127
1128    #[must_use]
1129    pub fn from_transfer(bytes: u64, duration: Duration) -> Self {
1130        let secs = duration.as_secs_f64();
1131        #[allow(clippy::cast_precision_loss)]
1132        let speed = if secs > 0.0 { bytes as f64 / secs } else { 0.0 };
1133        Self::new(speed)
1134    }
1135
1136    #[must_use]
1137    pub fn unit(mut self, unit: SizeUnit) -> Self {
1138        self.unit = unit;
1139        self
1140    }
1141
1142    #[must_use]
1143    pub fn precision(mut self, precision: usize) -> Self {
1144        self.precision = precision;
1145        self
1146    }
1147
1148    #[must_use]
1149    pub fn style(mut self, style: Style) -> Self {
1150        self.style = style;
1151        self
1152    }
1153
1154    pub fn set_speed(&mut self, speed: f64) {
1155        self.speed = speed;
1156    }
1157
1158    pub fn update_from_transfer(&mut self, bytes: u64, duration: Duration) {
1159        let secs = duration.as_secs_f64();
1160        #[allow(clippy::cast_precision_loss)]
1161        {
1162            self.speed = if secs > 0.0 { bytes as f64 / secs } else { 0.0 };
1163        }
1164    }
1165
1166    #[must_use]
1167    pub fn speed(&self) -> f64 {
1168        self.speed
1169    }
1170
1171    #[must_use]
1172    pub fn render_plain(&self) -> String {
1173        filesize::format_speed(self.speed, self.unit, self.precision)
1174    }
1175
1176    #[must_use]
1177    pub fn render(&self) -> Vec<Segment<'static>> {
1178        vec![Segment::new(self.render_plain(), Some(self.style.clone()))]
1179    }
1180}
1181
1182impl Default for TransferSpeedColumn {
1183    fn default() -> Self {
1184        Self::new(0.0)
1185    }
1186}
1187
1188#[cfg(test)]
1189mod tests {
1190    use super::*;
1191    use crate::style::Attributes;
1192
1193    #[test]
1194    fn test_progress_bar_new() {
1195        let bar = ProgressBar::new();
1196        assert!((bar.progress() - 0.0).abs() < f64::EPSILON);
1197        assert!(!bar.is_finished());
1198    }
1199
1200    #[test]
1201    fn test_progress_bar_with_total() {
1202        let mut bar = ProgressBar::with_total(100);
1203        bar.update(50);
1204        assert!((bar.progress() - 0.5).abs() < f64::EPSILON);
1205    }
1206
1207    #[test]
1208    fn test_progress_bar_set_progress() {
1209        let mut bar = ProgressBar::new();
1210        bar.set_progress(0.75);
1211        assert!((bar.progress() - 0.75).abs() < f64::EPSILON);
1212    }
1213
1214    #[test]
1215    fn test_progress_bar_advance() {
1216        let mut bar = ProgressBar::with_total(10);
1217        bar.advance(3);
1218        assert!((bar.progress() - 0.3).abs() < f64::EPSILON);
1219        bar.advance(2);
1220        assert!((bar.progress() - 0.5).abs() < f64::EPSILON);
1221    }
1222
1223    #[test]
1224    fn test_progress_bar_finish() {
1225        let mut bar = ProgressBar::new();
1226        bar.finish();
1227        assert!(bar.is_finished());
1228        assert!((bar.progress() - 1.0).abs() < f64::EPSILON);
1229    }
1230
1231    #[test]
1232    fn test_progress_bar_render() {
1233        let mut bar = ProgressBar::new().width(20).show_brackets(true);
1234        bar.set_progress(0.5);
1235        let segments = bar.render(80);
1236        assert!(!segments.is_empty());
1237        let text: String = segments.iter().map(|s| s.text.as_ref()).collect();
1238        assert!(text.contains('['));
1239        assert!(text.contains(']'));
1240        assert!(text.contains('%'));
1241    }
1242
1243    #[test]
1244    fn test_progress_bar_render_plain() {
1245        let mut bar = ProgressBar::new().width(10).show_brackets(false);
1246        bar.set_progress(0.5);
1247        let plain = bar.render_plain(40);
1248        assert!(!plain.is_empty());
1249    }
1250
1251    #[test]
1252    fn test_progress_bar_styles() {
1253        for style in [
1254            BarStyle::Ascii,
1255            BarStyle::Block,
1256            BarStyle::Line,
1257            BarStyle::Dots,
1258        ] {
1259            let mut bar = ProgressBar::new().bar_style(style).width(10);
1260            bar.set_progress(0.5);
1261            let segments = bar.render(40);
1262            assert!(!segments.is_empty());
1263        }
1264    }
1265
1266    #[test]
1267    fn test_progress_bar_with_description() {
1268        let mut bar = ProgressBar::new().description("Downloading").width(20);
1269        bar.set_progress(0.5);
1270        let plain = bar.render_plain(80);
1271        assert!(plain.contains("Downloading"));
1272    }
1273
1274    #[test]
1275    fn test_progress_bar_description_preserves_spans() {
1276        let mut desc = Text::new("Download");
1277        desc.stylize(0, 8, Style::new().bold());
1278        let bar = ProgressBar::new().description(desc).width(20);
1279        let segments = bar.render(80);
1280        let has_bold = segments.iter().any(|seg| {
1281            seg.text.contains("Download")
1282                && seg
1283                    .style
1284                    .as_ref()
1285                    .is_some_and(|style| style.attributes.contains(Attributes::BOLD))
1286        });
1287        assert!(has_bold, "description should preserve span styles");
1288    }
1289
1290    #[test]
1291    fn test_progress_bar_finished_message() {
1292        let mut bar = ProgressBar::new().finished_message("Done!").width(20);
1293        bar.finish();
1294        let plain = bar.render_plain(80);
1295        assert!(plain.contains("Done!"));
1296        assert!(plain.contains('✓'));
1297    }
1298
1299    #[test]
1300    fn test_spinner_next_frame() {
1301        let mut spinner = Spinner::simple();
1302        assert_eq!(spinner.next_frame(), "|");
1303        assert_eq!(spinner.next_frame(), "/");
1304        assert_eq!(spinner.next_frame(), "-");
1305        assert_eq!(spinner.next_frame(), "\\");
1306        assert_eq!(spinner.next_frame(), "|"); // Wraps around
1307    }
1308
1309    #[test]
1310    fn test_spinner_current_frame() {
1311        let spinner = Spinner::simple();
1312        assert_eq!(spinner.current_frame(), "|");
1313        assert_eq!(spinner.current_frame(), "|"); // Doesn't advance
1314    }
1315
1316    #[test]
1317    fn test_spinner_render() {
1318        let spinner = Spinner::dots();
1319        let segment = spinner.render();
1320        assert!(!segment.text.is_empty());
1321    }
1322
1323    #[test]
1324    fn test_bar_style_chars() {
1325        assert_eq!(BarStyle::Ascii.completed_char(), "#");
1326        assert_eq!(BarStyle::Ascii.remaining_char(), "-");
1327        assert_eq!(BarStyle::Block.completed_char(), "\u{2588}");
1328        assert_eq!(BarStyle::Block.remaining_char(), "\u{2591}");
1329    }
1330
1331    #[test]
1332    fn test_ascii_bar() {
1333        let mut bar = ascii_bar();
1334        bar.set_progress(0.5);
1335        let plain = bar.render_plain(40);
1336        assert!(plain.contains('#'));
1337        assert!(plain.contains('-'));
1338    }
1339
1340    #[test]
1341    fn test_format_duration() {
1342        assert_eq!(ProgressBar::format_duration(Duration::from_secs(30)), "30s");
1343        assert_eq!(
1344            ProgressBar::format_duration(Duration::from_secs(90)),
1345            "1:30"
1346        );
1347        assert_eq!(
1348            ProgressBar::format_duration(Duration::from_secs(3661)),
1349            "1:01:01"
1350        );
1351    }
1352
1353    #[test]
1354    fn test_progress_clamp() {
1355        let mut bar = ProgressBar::new();
1356        bar.set_progress(-0.5);
1357        assert!((bar.progress() - 0.0).abs() < f64::EPSILON);
1358        bar.set_progress(1.5);
1359        assert!((bar.progress() - 1.0).abs() < f64::EPSILON);
1360    }
1361
1362    #[test]
1363    fn test_update_clamps_progress() {
1364        let mut bar = ProgressBar::with_total(10);
1365        bar.update(15);
1366        assert!((bar.progress() - 1.0).abs() < f64::EPSILON);
1367        assert!(bar.is_finished());
1368    }
1369
1370    // -------------------------------------------------------------------------
1371    // File Size / Transfer Progress Tests
1372    // -------------------------------------------------------------------------
1373
1374    #[test]
1375    fn test_for_download() {
1376        let bar = ProgressBar::for_download(1_000_000);
1377        assert_eq!(bar.total_bytes_value(), Some(1_000_000));
1378        assert!(bar.show_file_size);
1379        assert!(bar.show_transfer_speed);
1380    }
1381
1382    #[test]
1383    fn test_update_bytes() {
1384        let mut bar = ProgressBar::for_download(1_000_000);
1385        bar.update_bytes(500_000);
1386        assert_eq!(bar.transferred_bytes(), 500_000);
1387        assert!((bar.progress() - 0.5).abs() < f64::EPSILON);
1388    }
1389
1390    #[test]
1391    fn test_advance_bytes() {
1392        let mut bar = ProgressBar::for_download(1_000_000);
1393        bar.advance_bytes(250_000);
1394        bar.advance_bytes(250_000);
1395        assert_eq!(bar.transferred_bytes(), 500_000);
1396        assert!((bar.progress() - 0.5).abs() < f64::EPSILON);
1397    }
1398
1399    #[test]
1400    fn test_format_file_size_decimal() {
1401        let mut bar = ProgressBar::for_download(10_000_000);
1402        bar.update_bytes(1_500_000);
1403        assert_eq!(bar.format_file_size(), "1.5 MB");
1404        assert_eq!(bar.format_total_size(), Some("10.0 MB".to_string()));
1405    }
1406
1407    #[test]
1408    fn test_format_file_size_binary() {
1409        let mut bar = ProgressBar::for_download(10_485_760) // 10 MiB
1410            .use_binary_units(true);
1411        bar.update_bytes(1_572_864); // 1.5 MiB
1412        assert_eq!(bar.format_file_size(), "1.5 MiB");
1413        assert_eq!(bar.format_total_size(), Some("10.0 MiB".to_string()));
1414    }
1415
1416    #[test]
1417    fn test_render_with_file_size() {
1418        let mut bar = ProgressBar::for_download(10_000_000)
1419            .width(20)
1420            .show_percentage(false)
1421            .show_eta(false);
1422        bar.update_bytes(5_000_000);
1423        let plain = bar.render_plain(100);
1424        // Should contain file size info
1425        assert!(plain.contains("MB") || plain.contains("bytes"));
1426    }
1427
1428    #[test]
1429    fn test_total_bytes_builder() {
1430        let bar = ProgressBar::new()
1431            .total_bytes(2_000_000)
1432            .show_file_size(true)
1433            .show_transfer_speed(true);
1434        assert_eq!(bar.total_bytes_value(), Some(2_000_000));
1435        assert!(bar.show_file_size);
1436        assert!(bar.show_transfer_speed);
1437    }
1438
1439    #[test]
1440    fn test_use_binary_units() {
1441        let bar = ProgressBar::for_download(1024).use_binary_units(true);
1442        assert!(bar.use_binary_units);
1443
1444        let bar_decimal = ProgressBar::for_download(1000).use_binary_units(false);
1445        assert!(!bar_decimal.use_binary_units);
1446    }
1447
1448    #[test]
1449    fn test_download_finishes_at_100() {
1450        let mut bar = ProgressBar::for_download(1_000_000);
1451        bar.update_bytes(1_000_000);
1452        assert!(bar.is_finished());
1453        assert!((bar.progress() - 1.0).abs() < f64::EPSILON);
1454    }
1455
1456    // =========================================================================
1457    // Standalone Column Tests
1458    // =========================================================================
1459
1460    #[test]
1461    fn test_file_size_column_decimal() {
1462        let column = FileSizeColumn::new(1_500_000);
1463        assert_eq!(column.render_plain(), "1.5 MB");
1464        assert_eq!(column.size(), 1_500_000);
1465    }
1466
1467    #[test]
1468    fn test_file_size_column_binary() {
1469        let column = FileSizeColumn::new(1_048_576).unit(SizeUnit::Binary);
1470        assert_eq!(column.render_plain(), "1.0 MiB");
1471    }
1472
1473    #[test]
1474    fn test_file_size_column_precision() {
1475        let column = FileSizeColumn::new(1_234_567).precision(2);
1476        assert_eq!(column.render_plain(), "1.23 MB");
1477    }
1478
1479    #[test]
1480    fn test_file_size_column_set_size() {
1481        let mut column = FileSizeColumn::new(1000);
1482        column.set_size(2_000_000);
1483        assert_eq!(column.size(), 2_000_000);
1484        assert_eq!(column.render_plain(), "2.0 MB");
1485    }
1486
1487    #[test]
1488    fn test_total_file_size_column() {
1489        let column = TotalFileSizeColumn::new(10_000_000);
1490        assert_eq!(column.render_plain(), "10.0 MB");
1491    }
1492
1493    #[test]
1494    fn test_download_column() {
1495        let column = DownloadColumn::new(1_500_000, 10_000_000);
1496        assert_eq!(column.render_plain(), "1.5/10.0 MB");
1497        assert_eq!(column.current(), 1_500_000);
1498        assert_eq!(column.total(), 10_000_000);
1499    }
1500
1501    #[test]
1502    fn test_download_column_binary() {
1503        let column = DownloadColumn::new(1_048_576, 10_485_760).unit(SizeUnit::Binary);
1504        assert_eq!(column.render_plain(), "1.0/10.0 MiB");
1505    }
1506
1507    #[test]
1508    fn test_download_column_update() {
1509        let mut column = DownloadColumn::new(0, 1000);
1510        column.set_current(500);
1511        assert_eq!(column.current(), 500);
1512        column.set_total(2000);
1513        assert_eq!(column.total(), 2000);
1514    }
1515
1516    #[test]
1517    fn test_transfer_speed_column() {
1518        let column = TransferSpeedColumn::new(1_500_000.0);
1519        assert_eq!(column.render_plain(), "1.5 MB/s");
1520        assert!((column.speed() - 1_500_000.0).abs() < f64::EPSILON);
1521    }
1522
1523    #[test]
1524    fn test_transfer_speed_column_binary() {
1525        let column = TransferSpeedColumn::new(1_048_576.0).unit(SizeUnit::Binary);
1526        assert_eq!(column.render_plain(), "1.0 MiB/s");
1527    }
1528
1529    #[test]
1530    fn test_transfer_speed_from_transfer() {
1531        let column = TransferSpeedColumn::from_transfer(1_000_000, Duration::from_secs(1));
1532        assert!((column.speed() - 1_000_000.0).abs() < f64::EPSILON);
1533    }
1534
1535    #[test]
1536    fn test_transfer_speed_update() {
1537        let mut column = TransferSpeedColumn::new(0.0);
1538        column.set_speed(5_000_000.0);
1539        assert!((column.speed() - 5_000_000.0).abs() < f64::EPSILON);
1540    }
1541
1542    #[test]
1543    fn test_column_default_impls() {
1544        assert_eq!(FileSizeColumn::default().size(), 0);
1545        assert_eq!(TotalFileSizeColumn::default().render_plain(), "0 bytes");
1546        assert_eq!(DownloadColumn::default().current(), 0);
1547        assert!((TransferSpeedColumn::default().speed() - 0.0).abs() < f64::EPSILON);
1548    }
1549}