Skip to main content

rich/
progress_bar.rs

1//! Progress bars.
2//!
3//! Port of upstream `rich/progress_bar.py`. A [`ProgressBar`] renders a
4//! determinate bar at half-cell resolution, or, when pulsing (`pulse=True` or
5//! no total), upstream's animated pulse: a cosine fade between `bar.pulse` and
6//! `bar.back` that scrolls with the animation time. ASCII-only and legacy
7//! Windows consoles get upstream's `-` glyphs.
8
9use crate::color::{Color, ColorSystem, ColorTriplet};
10use crate::console::{monotonic, Console, ConsoleOptions};
11use crate::measure::Measurement;
12use crate::protocol::Renderable;
13use crate::segment::Segment;
14use crate::style::{Style, StyleType};
15
16/// Segments in one pulse period. Upstream `PULSE_SIZE`.
17const PULSE_SIZE: usize = 20;
18
19/// A progress bar. Mirrors `rich.progress_bar.ProgressBar`.
20pub struct ProgressBar {
21    /// `None` renders the pulse, as upstream's `total=None` does.
22    total: Option<f64>,
23    completed: f64,
24    width: Option<usize>,
25    pulse: bool,
26    animation_time: Option<f64>,
27    style: StyleType,
28    complete_style: StyleType,
29    finished_style: StyleType,
30    pulse_style: StyleType,
31}
32
33impl ProgressBar {
34    /// A bar of `completed` out of `total`, with upstream's default `bar.*` styles.
35    pub fn new(total: f64, completed: f64) -> Self {
36        ProgressBar {
37            total: Some(total),
38            completed,
39            width: None,
40            pulse: false,
41            animation_time: None,
42            style: "bar.back".into(),
43            complete_style: "bar.complete".into(),
44            finished_style: "bar.finished".into(),
45            pulse_style: "bar.pulse".into(),
46        }
47    }
48
49    /// A bar with no total, which always pulses (upstream `total=None`).
50    pub fn indeterminate() -> Self {
51        ProgressBar {
52            total: None,
53            ..ProgressBar::new(100.0, 0.0)
54        }
55    }
56
57    /// Fix the bar width (otherwise it fills the available width).
58    pub fn width(mut self, width: usize) -> Self {
59        self.width = Some(width);
60        self
61    }
62
63    /// The background style (upstream `style`, default `bar.back`).
64    pub fn style(mut self, style: impl Into<StyleType>) -> Self {
65        self.style = style.into();
66        self
67    }
68
69    /// The completed-part style (upstream `complete_style`, default `bar.complete`).
70    pub fn complete_style(mut self, style: impl Into<StyleType>) -> Self {
71        self.complete_style = style.into();
72        self
73    }
74
75    /// The style once finished (upstream `finished_style`, default `bar.finished`).
76    pub fn finished_style(mut self, style: impl Into<StyleType>) -> Self {
77        self.finished_style = style.into();
78        self
79    }
80
81    /// The pulse style (upstream `pulse_style`, default `bar.pulse`).
82    pub fn pulse_style(mut self, style: impl Into<StyleType>) -> Self {
83        self.pulse_style = style.into();
84        self
85    }
86
87    /// Render the pulse animation instead of the completion (upstream `pulse`).
88    pub fn pulse(mut self, pulse: bool) -> Self {
89        self.pulse = pulse;
90        self
91    }
92
93    /// The time, in seconds, the pulse is drawn at (upstream `animation_time`).
94    /// Without it the pulse follows a monotonic clock.
95    pub fn animation_time(mut self, time: f64) -> Self {
96        self.animation_time = Some(time);
97        self
98    }
99
100    /// Port of `_get_pulse_segments`: one period of the pulse.
101    fn pulse_segments(
102        fore: &Style,
103        back: &Style,
104        color_system: Option<ColorSystem>,
105        no_color: bool,
106        ascii: bool,
107    ) -> Vec<Segment> {
108        let bar = if ascii { "-" } else { "\u{2501}" };
109        // Upstream tests `color_system not in ("standard", "eight_bit",
110        // "truecolor")`, but a 256-colour console reports `"256"`, so only
111        // standard and truecolor consoles get the blended pulse.
112        let colourful = matches!(
113            color_system,
114            Some(ColorSystem::Standard | ColorSystem::Truecolor)
115        );
116        if !colourful || no_color {
117            let fore_count = PULSE_SIZE / 2;
118            let mut segments = vec![Segment::new(bar, Some(fore.clone())); fore_count];
119            let back_bar = if no_color { " " } else { bar };
120            segments.extend(vec![
121                Segment::new(back_bar, Some(back.clone()));
122                PULSE_SIZE - fore_count
123            ]);
124            return segments;
125        }
126        let triplet = |style: &Style, fallback: ColorTriplet| {
127            style
128                .color()
129                .and_then(Color::get_truecolor)
130                .unwrap_or(fallback)
131        };
132        let fore_color = triplet(fore, ColorTriplet::new(255, 0, 255));
133        let back_color = triplet(back, ColorTriplet::new(0, 0, 0));
134        (0..PULSE_SIZE)
135            .map(|index| {
136                let position = index as f64 / PULSE_SIZE as f64;
137                let fade = 0.5 + (position * std::f64::consts::PI * 2.0).cos() / 2.0;
138                let color = blend_rgb(fore_color, back_color, fade);
139                Segment::new(
140                    bar,
141                    Some(Style::new().with_color(Color::from_rgb(
142                        color.red,
143                        color.green,
144                        color.blue,
145                    ))),
146                )
147            })
148            .collect()
149    }
150
151    /// Port of `_render_pulse`.
152    fn render_pulse(&self, console: &Console, width: usize, ascii: bool) -> Vec<Segment> {
153        let fore = style_or(console, &self.pulse_style, "white");
154        let back = style_or(console, &self.style, "black");
155        let pulse = Self::pulse_segments(
156            &fore,
157            &back,
158            console.color_system(),
159            console.no_color(),
160            ascii,
161        );
162        let count = pulse.len();
163        let time = self.animation_time.unwrap_or_else(monotonic);
164        // `int(-current_time * 15) % segment_count`, with Python's floor modulo.
165        let offset = ((-time * 15.0) as i64).rem_euclid(count as i64) as usize;
166        pulse
167            .iter()
168            .cycle()
169            .skip(offset)
170            .take(width)
171            .cloned()
172            .collect()
173    }
174}
175
176/// `console.get_style(name, default=…)`.
177fn style_or(console: &Console, style: &StyleType, default: &str) -> Style {
178    console
179        .get_style(style)
180        .unwrap_or_else(|_| Style::parse(default).expect("valid default style"))
181}
182
183/// Port of `rich.color.blend_rgb`.
184fn blend_rgb(first: ColorTriplet, second: ColorTriplet, cross_fade: f64) -> ColorTriplet {
185    let mix = |a: u8, b: u8| (f64::from(a) + (f64::from(b) - f64::from(a)) * cross_fade) as u8;
186    ColorTriplet::new(
187        mix(first.red, second.red),
188        mix(first.green, second.green),
189        mix(first.blue, second.blue),
190    )
191}
192
193impl Renderable for ProgressBar {
194    /// Port of `ProgressBar.__rich_measure__`: a fixed width measures exactly,
195    /// otherwise the bar takes 4 cells up to the whole width.
196    fn measure(&self, _console: &Console, options: &ConsoleOptions) -> Measurement {
197        match self.width {
198            Some(width) => Measurement::new(width, width),
199            None => Measurement::new(4, options.max_width),
200        }
201    }
202
203    fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
204        let width = self
205            .width
206            .filter(|width| *width > 0)
207            .unwrap_or(options.max_width)
208            .min(options.max_width);
209        let ascii = console.legacy_windows() || console.ascii_only();
210        if self.pulse || self.total.is_none() {
211            return self.render_pulse(console, width, ascii);
212        }
213        let total = self.total.unwrap_or(0.0);
214        let completed = total.min(self.completed.max(0.0));
215
216        let (bar, half_bar_right, half_bar_left) = if ascii {
217            ("-", " ", " ")
218        } else {
219            ("\u{2501}", "\u{2578}", "\u{257a}")
220        };
221        // `int(width * 2 * completed / total) if total else width * 2`.
222        // `completed <= total`, so the quotient never exceeds `width * 2`
223        // except when `width * 2 * completed` overflows to infinity for
224        // totals near `f64::MAX` (where upstream's `int(inf)` raises); the
225        // clamp keeps that from asking `repeat` for `usize::MAX` cells.
226        let complete_halves = if total != 0.0 {
227            ((width as f64 * 2.0 * completed / total) as usize).min(width * 2)
228        } else {
229            width * 2
230        };
231        let bar_count = complete_halves / 2;
232        let half_bar_count = complete_halves % 2;
233        let back = style_or(console, &self.style, "none");
234        let is_finished = self.completed >= total;
235        let complete = style_or(
236            console,
237            if is_finished {
238                &self.finished_style
239            } else {
240                &self.complete_style
241            },
242            "none",
243        );
244
245        let mut segments: Vec<Segment> = Vec::new();
246        if bar_count > 0 {
247            segments.push(Segment::new(bar.repeat(bar_count), Some(complete.clone())));
248        }
249        if half_bar_count > 0 {
250            segments.push(Segment::new(
251                half_bar_right.repeat(half_bar_count),
252                Some(complete),
253            ));
254        }
255        // The background only renders with colour: without it the empty part
256        // of the bar is simply left out.
257        if !console.no_color() && console.color_system().is_some() {
258            let mut remaining = width.saturating_sub(bar_count + half_bar_count);
259            if remaining > 0 {
260                if half_bar_count == 0 && bar_count > 0 {
261                    segments.push(Segment::new(half_bar_left, Some(back.clone())));
262                    remaining -= 1;
263                }
264                if remaining > 0 {
265                    segments.push(Segment::new(bar.repeat(remaining), Some(back)));
266                }
267            }
268        }
269        segments
270    }
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276    use crate::color::ColorSystem;
277
278    fn render(completed: f64) -> String {
279        let console = Console::builder()
280            .force_terminal(true)
281            .color_system(Some(ColorSystem::Truecolor))
282            .width(20)
283            .build();
284        console.render_to_string(&ProgressBar::new(100.0, completed).width(20))
285    }
286
287    #[test]
288    fn huge_totals_do_not_overflow_the_bar_width() {
289        // `width * 2 * completed` overflows to infinity for totals near
290        // `f64::MAX`; the bar must still render full rather than asking
291        // `repeat` for `usize::MAX` cells.
292        let console = Console::builder()
293            .force_terminal(true)
294            .color_system(Some(ColorSystem::Truecolor))
295            .width(20)
296            .build();
297        for (total, completed) in [(1e308, 1e308), (f64::MAX, f64::MAX), (1e308, 5e307)] {
298            let got = console.render_to_string(&ProgressBar::new(total, completed).width(20));
299            assert!(got.contains('\u{2501}'), "{total}/{completed}: {got:?}");
300        }
301        assert_eq!(render(100.0), {
302            let console = Console::builder()
303                .force_terminal(true)
304                .color_system(Some(ColorSystem::Truecolor))
305                .width(20)
306                .build();
307            console.render_to_string(&ProgressBar::new(1e308, 1e308).width(20))
308        });
309    }
310
311    #[test]
312    fn empty_bar_is_all_background() {
313        assert_eq!(
314            render(0.0),
315            format!("\x1b[38;5;237m{}\x1b[0m", "\u{2501}".repeat(20))
316        );
317    }
318
319    #[test]
320    fn full_bar_uses_finished_style() {
321        assert_eq!(
322            render(100.0),
323            format!("\x1b[38;2;114;156;31m{}\x1b[0m", "\u{2501}".repeat(20))
324        );
325    }
326
327    #[test]
328    fn half_bar_has_background_half_cell() {
329        // 10 complete, background ╺ + 9 background bars.
330        assert_eq!(
331            render(50.0),
332            "\x1b[38;2;249;38;114m━━━━━━━━━━\x1b[0m\x1b[38;5;237m╺\x1b[0m\x1b[38;5;237m━━━━━━━━━\x1b[0m"
333        );
334    }
335}