Skip to main content

rich_rs/
progress_bar.rs

1//! ProgressBar: a progress bar renderable (used by Progress).
2//!
3//! Port of Python Rich's `progress_bar.py` (subset).
4
5use std::f64::consts::PI;
6
7use crate::Console;
8use crate::Renderable;
9use crate::color::{Color, ColorSystem, ColorTriplet, SimpleColor, blend_rgb};
10use crate::console::ConsoleOptions;
11use crate::measure::Measurement;
12use crate::segment::{Segment, Segments};
13use crate::style::Style;
14
15const PULSE_SIZE: usize = 20;
16
17#[derive(Debug, Clone)]
18pub struct ProgressBar {
19    pub total: Option<f64>,
20    pub completed: f64,
21    pub width: Option<usize>,
22    pub pulse: bool,
23    pub style: String,
24    pub complete_style: String,
25    pub finished_style: String,
26    pub pulse_style: String,
27    pub animation_time: Option<f64>,
28}
29
30impl Default for ProgressBar {
31    fn default() -> Self {
32        Self {
33            total: Some(100.0),
34            completed: 0.0,
35            width: None,
36            pulse: false,
37            style: "bar.back".to_string(),
38            complete_style: "bar.complete".to_string(),
39            finished_style: "bar.finished".to_string(),
40            pulse_style: "bar.pulse".to_string(),
41            animation_time: None,
42        }
43    }
44}
45
46impl ProgressBar {
47    pub fn new() -> Self {
48        Self::default()
49    }
50
51    /// Returns the completion percentage (0.0–100.0).
52    ///
53    /// Returns 0.0 if total is zero or None.
54    pub fn percentage_completed(&self) -> f64 {
55        let Some(total) = self.total else {
56            return 0.0;
57        };
58        if total <= 0.0 {
59            return 0.0;
60        }
61        ((self.completed / total) * 100.0).clamp(0.0, 100.0)
62    }
63
64    pub fn update(&mut self, completed: f64, total: Option<f64>) {
65        self.completed = completed;
66        if let Some(t) = total {
67            self.total = Some(t);
68        }
69    }
70
71    fn resolve_style(options: &ConsoleOptions, name: &str) -> Style {
72        options.get_style(name).unwrap_or_default()
73    }
74
75    fn truecolor_from_style(style: Style, foreground: bool) -> ColorTriplet {
76        let simple = if foreground {
77            style.color.unwrap_or(SimpleColor::Default)
78        } else {
79            style.bgcolor.unwrap_or(SimpleColor::Default)
80        };
81        Color::from(simple).get_truecolor(foreground)
82    }
83
84    fn get_pulse_segments(
85        options: &ConsoleOptions,
86        fore_style: Style,
87        back_style: Style,
88        ascii: bool,
89    ) -> Vec<Segment> {
90        let bar = if ascii { "-" } else { "━" };
91
92        let color_system = options.color_system.unwrap_or(ColorSystem::Standard);
93        let no_color = options.color_system.is_none();
94        if !matches!(
95            color_system,
96            ColorSystem::Standard | ColorSystem::EightBit | ColorSystem::TrueColor
97        ) || no_color
98        {
99            let mut segments: Vec<Segment> = Vec::new();
100            segments
101                .extend(std::iter::repeat(Segment::styled(bar, fore_style)).take(PULSE_SIZE / 2));
102            let back_char = if no_color { " " } else { bar };
103            segments.extend(
104                std::iter::repeat(Segment::styled(back_char, back_style))
105                    .take(PULSE_SIZE - (PULSE_SIZE / 2)),
106            );
107            return segments;
108        }
109
110        // Truecolor / indexed: compute a cosine blend across the pulse length.
111        let fore_color = Self::truecolor_from_style(fore_style, true);
112        let back_color = Self::truecolor_from_style(back_style, true);
113        let mut segments: Vec<Segment> = Vec::with_capacity(PULSE_SIZE);
114        for index in 0..PULSE_SIZE {
115            let position = index as f64 / (PULSE_SIZE - 1).max(1) as f64;
116            let fade = (1.0 - (position * 2.0 - 1.0).abs()).powf(1.0);
117            let cross_fade = (fade * PI).cos() * -0.5 + 0.5;
118            let blended = blend_rgb(fore_color, back_color, cross_fade);
119            let style = Style::new().with_color(SimpleColor::Rgb {
120                r: blended.red,
121                g: blended.green,
122                b: blended.blue,
123            });
124            segments.push(Segment::styled(bar, style));
125        }
126        segments
127    }
128
129    fn render_pulse(&self, options: &ConsoleOptions, width: usize, ascii: bool) -> Segments {
130        let fore_style = Self::resolve_style(options, &self.pulse_style);
131        let back_style = Self::resolve_style(options, &self.style);
132        let pulse_segments = Self::get_pulse_segments(options, fore_style, back_style, ascii);
133        let segment_count = pulse_segments.len().max(1);
134
135        let current_time = self.animation_time.unwrap_or(0.0);
136        let mut segments: Vec<Segment> = Vec::new();
137        let repeats = (width / segment_count) + 2;
138        for _ in 0..repeats {
139            segments.extend(pulse_segments.iter().cloned());
140        }
141        let offset = ((-current_time * 15.0) as isize).rem_euclid(segment_count as isize) as usize;
142        let slice = &segments[offset..offset + width];
143        Segments::from_iter(slice.iter().cloned())
144    }
145}
146
147impl Renderable for ProgressBar {
148    fn render(&self, _console: &Console, options: &ConsoleOptions) -> Segments {
149        let width = (self.width.unwrap_or(options.max_width))
150            .min(options.max_width)
151            .max(1);
152        let ascii = options.legacy_windows || !options.encoding.to_lowercase().starts_with("utf");
153        let should_pulse = self.pulse || self.total.is_none();
154        if should_pulse {
155            return self.render_pulse(options, width, ascii);
156        }
157
158        let total = self.total.unwrap_or(0.0);
159        let completed = self.completed.clamp(0.0, total.max(0.0));
160
161        let bar = if ascii { "-" } else { "━" };
162        let half_bar_right = if ascii { " " } else { "╸" };
163        let half_bar_left = if ascii { " " } else { "╺" };
164
165        let complete_halves = if total > 0.0 {
166            ((width as f64) * 2.0 * completed / total).floor() as usize
167        } else {
168            width * 2
169        };
170        let bar_count = complete_halves / 2;
171        let half_bar_count = complete_halves % 2;
172
173        let style = Self::resolve_style(options, &self.style);
174        let is_finished = self.total.is_none() || self.completed >= total;
175        let complete_style = Self::resolve_style(
176            options,
177            if is_finished {
178                &self.finished_style
179            } else {
180                &self.complete_style
181            },
182        );
183
184        let mut out = Segments::new();
185        if bar_count > 0 {
186            out.push(Segment::styled(bar.repeat(bar_count), complete_style));
187        }
188        if half_bar_count > 0 {
189            out.push(Segment::styled(half_bar_right.to_string(), complete_style));
190        }
191
192        // Match Rich: when colors are disabled, fill the remainder with unstyled spaces
193        // (so the bar doesn't "bleed" style into the padded region).
194        if options.color_system.is_none() {
195            let remaining = width.saturating_sub(bar_count + half_bar_count);
196            if remaining > 0 {
197                out.push(Segment::new(" ".repeat(remaining)));
198            }
199            return out;
200        }
201
202        // In Rich, remaining bars are only drawn if colors are enabled.
203        if options.color_system.is_some() {
204            let mut remaining_bars = width.saturating_sub(bar_count + half_bar_count);
205            if remaining_bars > 0 && half_bar_count == 0 && bar_count > 0 {
206                out.push(Segment::styled(half_bar_left.to_string(), style));
207                remaining_bars = remaining_bars.saturating_sub(1);
208            }
209            if remaining_bars > 0 {
210                out.push(Segment::styled(bar.repeat(remaining_bars), style));
211            }
212        }
213
214        out
215    }
216
217    fn measure(&self, _console: &Console, options: &ConsoleOptions) -> Measurement {
218        if let Some(w) = self.width {
219            Measurement::new(w, w)
220        } else {
221            Measurement::new(4, options.max_width)
222        }
223    }
224}