Skip to main content

rich/
progress_bar.rs

1//! Progress bars (static rendering).
2//!
3//! Port of the `__rich_console__` core of upstream `rich/progress_bar.py`. A
4//! [`ProgressBar`] renders a determinate bar at a given completion using
5//! half-cell resolution for a smooth edge.
6//!
7//! Slice scope: determinate bars in a color-capable terminal. The indeterminate
8//! "pulse" animation and ASCII/legacy fallbacks are deferred.
9
10use crate::color::Color;
11use crate::console::{Console, ConsoleOptions};
12use crate::protocol::Renderable;
13use crate::segment::Segment;
14use crate::style::Style;
15
16const BAR: &str = "━"; // U+2501
17const HALF_BAR_RIGHT: &str = "╸"; // U+2578 — trailing edge of the completed run
18const HALF_BAR_LEFT: &str = "╺"; // U+257A — leading edge of the background run
19
20/// A determinate progress bar. Mirrors `rich.progress_bar.ProgressBar`.
21pub struct ProgressBar {
22    total: f64,
23    completed: f64,
24    width: Option<usize>,
25    complete_style: Style,
26    finished_style: Style,
27    back_style: Style,
28}
29
30impl ProgressBar {
31    /// A bar of `completed` out of `total`, using upstream's default `bar.*` styles.
32    pub fn new(total: f64, completed: f64) -> Self {
33        let color = |spec: &str| Style::new().with_color(Color::parse(spec).expect("valid color"));
34        ProgressBar {
35            total,
36            completed,
37            width: None,
38            complete_style: color("rgb(249,38,114)"), // bar.complete
39            finished_style: color("rgb(114,156,31)"), // bar.finished
40            back_style: color("color(237)"),          // bar.back (grey23)
41        }
42    }
43
44    /// Fix the bar width (otherwise it fills the available width).
45    pub fn width(mut self, width: usize) -> Self {
46        self.width = Some(width);
47        self
48    }
49}
50
51impl Renderable for ProgressBar {
52    fn rich_render(&self, _console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
53        let width = self
54            .width
55            .unwrap_or(options.max_width)
56            .min(options.max_width);
57        if width == 0 || self.total <= 0.0 {
58            return vec![Segment::new(
59                BAR.repeat(width),
60                Some(self.back_style.clone()),
61            )];
62        }
63
64        let completed = self.completed.clamp(0.0, self.total);
65        // Completion measured in half-cells (double resolution). Port of
66        // `int(width * 2 * completed / total)`.
67        let complete_halves = (width as f64 * 2.0 * completed / self.total) as usize;
68        let bar_count = complete_halves / 2;
69        let half_bar_count = complete_halves % 2;
70
71        let is_finished = completed >= self.total;
72        let complete_style = if is_finished {
73            &self.finished_style
74        } else {
75            &self.complete_style
76        };
77
78        let mut segments: Vec<Segment> = Vec::new();
79        if bar_count > 0 {
80            segments.push(Segment::new(
81                BAR.repeat(bar_count),
82                Some(complete_style.clone()),
83            ));
84        }
85        if half_bar_count > 0 {
86            segments.push(Segment::new(
87                HALF_BAR_RIGHT.repeat(half_bar_count),
88                Some(complete_style.clone()),
89            ));
90        }
91
92        // Background.
93        let mut remaining = width - bar_count - half_bar_count;
94        if remaining > 0 {
95            if half_bar_count == 0 && bar_count > 0 {
96                segments.push(Segment::new(
97                    HALF_BAR_LEFT.to_string(),
98                    Some(self.back_style.clone()),
99                ));
100                remaining -= 1;
101            }
102            if remaining > 0 {
103                segments.push(Segment::new(
104                    BAR.repeat(remaining),
105                    Some(self.back_style.clone()),
106                ));
107            }
108        }
109        segments
110    }
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116    use crate::color::ColorSystem;
117
118    fn render(completed: f64) -> String {
119        let console = Console::builder()
120            .force_terminal(true)
121            .color_system(Some(ColorSystem::Truecolor))
122            .width(20)
123            .build();
124        console.render_to_string(&ProgressBar::new(100.0, completed).width(20))
125    }
126
127    #[test]
128    fn empty_bar_is_all_background() {
129        assert_eq!(
130            render(0.0),
131            format!("\x1b[38;5;237m{}\x1b[0m", BAR.repeat(20))
132        );
133    }
134
135    #[test]
136    fn full_bar_uses_finished_style() {
137        assert_eq!(
138            render(100.0),
139            format!("\x1b[38;2;114;156;31m{}\x1b[0m", BAR.repeat(20))
140        );
141    }
142
143    #[test]
144    fn half_bar_has_background_half_cell() {
145        // 10 complete, background ╺ + 9 background bars.
146        assert_eq!(
147            render(50.0),
148            "\x1b[38;2;249;38;114m━━━━━━━━━━\x1b[0m\x1b[38;5;237m╺\x1b[0m\x1b[38;5;237m━━━━━━━━━\x1b[0m"
149        );
150    }
151}