Skip to main content

lv_tui/widgets/
progressbar.rs

1use crate::component::{Component, EventCx, MeasureCx};
2use crate::event::Event;
3use crate::geom::{Rect, Size};
4use crate::layout::Constraint;
5use crate::render::RenderCx;
6use crate::style::Style;
7
8const BLOCKS: &[&str] = &[" ", "▏", "▎", "▍", "▌", "▋", "▊", "▉", "█"];
9
10/// A progress bar using Unicode 8-segment block characters.
11///
12/// Displays a bar of `width` cells filled to `ratio` (0.0–1.0). Purely visual —
13/// not focusable, no keyboard interaction. Combine with `#[reactive]` and
14/// [`Event::Tick`] for animated progress.
15pub struct ProgressBar {
16    ratio: f64,
17    width: u16,
18    style: Style,
19    track_style: Style,
20    label: Option<String>,
21}
22
23impl ProgressBar {
24    /// Creates a new progress bar at 0%, 20 cells wide.
25    pub fn new() -> Self {
26        Self {
27            ratio: 0.0,
28            width: 20,
29            style: Style::default().fg(crate::style::Color::Cyan),
30            track_style: Style::default().fg(crate::style::Color::Gray),
31            label: None,
32        }
33    }
34
35    /// Builder: shows a percentage label (e.g. "75%").
36    pub fn label(mut self, show: bool) -> Self {
37        self.label = if show { Some(String::new()) } else { None };
38        self
39    }
40
41    /// Builder: sets the fill ratio (0.0–1.0).
42    pub fn ratio(mut self, ratio: f64) -> Self {
43        self.ratio = ratio.clamp(0.0, 1.0);
44        self
45    }
46
47    /// Builder: sets the bar width in character cells.
48    pub fn width(mut self, width: u16) -> Self {
49        self.width = width;
50        self
51    }
52
53    /// Builder: sets the filled-portion style.
54    pub fn style(mut self, style: Style) -> Self {
55        self.style = style;
56        self
57    }
58
59    /// Builder: sets the unfilled-portion (track) style.
60    pub fn track_style(mut self, style: Style) -> Self {
61        self.track_style = style;
62        self
63    }
64
65    /// Sets the fill ratio and marks paint dirty.
66    pub fn set_ratio(&mut self, ratio: f64, cx: &mut EventCx) {
67        let r = ratio.clamp(0.0, 1.0);
68        if (self.ratio - r).abs() > f64::EPSILON {
69            self.ratio = r;
70            cx.invalidate_paint();
71        }
72    }
73}
74
75impl Component for ProgressBar {
76    fn render(&self, cx: &mut RenderCx) {
77        let has_label = self.label.is_some();
78        let bar_width = if has_label { self.width.saturating_sub(5) } else { self.width };
79
80        let filled = (self.ratio * bar_width as f64) as u16;
81        let whole = filled.min(bar_width);
82        let frac = ((self.ratio * bar_width as f64) - whole as f64) * 8.0;
83        let frac_idx = (frac as usize).min(BLOCKS.len() - 1);
84
85        // Filled portion
86        if whole > 0 {
87            cx.set_style(self.style.clone());
88            cx.text("█".repeat(whole as usize));
89        }
90
91        // Fractional character + track
92        if whole < bar_width {
93            if frac_idx > 0 {
94                cx.set_style(self.style.clone());
95                cx.text(BLOCKS[frac_idx]);
96            }
97            let frac_used = if frac_idx > 0 { 1 } else { 0 };
98            let remaining = bar_width.saturating_sub(whole).saturating_sub(frac_used);
99            if remaining > 0 {
100                cx.set_style(self.track_style.clone());
101                cx.text("░".repeat(remaining as usize));
102            }
103        }
104
105        // Percentage label
106        if has_label {
107            let pct = format!(" {:3}%", (self.ratio * 100.0) as u8);
108            cx.set_style(self.style.clone());
109            cx.text(&pct);
110        }
111
112        cx.set_style(self.track_style.clone());
113        cx.line("");
114    }
115
116    fn measure(&self, _constraint: Constraint, _cx: &mut MeasureCx) -> Size {
117        Size { width: self.width, height: 1 }
118    }
119
120    fn event(&mut self, _event: &Event, _cx: &mut EventCx) {}
121    fn layout(&mut self, _rect: Rect, _cx: &mut crate::component::LayoutCx) {}
122    fn focusable(&self) -> bool { false }
123    fn style(&self) -> Style { self.style.clone() }
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129    use crate::testbuffer::TestBuffer;
130
131    #[test]
132    fn test_empty() {
133        let mut tb = TestBuffer::new(30, 1);
134        tb.render(&ProgressBar::new().ratio(0.0).width(30));
135        // 0% should render all track characters (░)
136        let line = (0..30).map(|_| "░").collect::<String>();
137        tb.assert_line(0, &line);
138    }
139
140    #[test]
141    fn test_full() {
142        let mut tb = TestBuffer::new(30, 1);
143        tb.render(&ProgressBar::new().ratio(1.0).width(30));
144        let line = (0..30).map(|_| "█").collect::<String>();
145        tb.assert_line(0, &line);
146    }
147
148    #[test]
149    fn test_with_label() {
150        let mut tb = TestBuffer::new(30, 1);
151        tb.render(&ProgressBar::new().ratio(0.5).width(30).label(true));
152        // Should contain percentage text " 50%"
153        assert!(tb.buffer.cells.iter().any(|c| c.symbol == "%"));
154    }
155}