Skip to main content

zlayer_tui/widgets/
progress_bar.rs

1//! A unified progress bar widget for `ZLayer` TUI applications.
2//!
3//! Replaces the builder's `BuildProgress` and the deploy TUI's
4//! `render_progress_bar()` with a single configurable widget that supports
5//! both a trailing label (builder style) and a trailing percentage (deploy
6//! style).
7
8use ratatui::{
9    buffer::Buffer,
10    layout::Rect,
11    style::Style,
12    widgets::{Paragraph, Widget},
13};
14
15use crate::icons::{PROGRESS_EMPTY, PROGRESS_FILLED};
16use crate::palette::color::{ACCENT, TEXT};
17
18/// A configurable progress bar widget.
19///
20/// # Rendering modes
21///
22/// | Field              | Effect                                         |
23/// |--------------------|-------------------------------------------------|
24/// | `label`            | Rendered after the bar, right-aligned in a row  |
25/// | `show_percentage`  | Appends `" N%"` after the bar (and after label) |
26///
27/// Both may be enabled simultaneously.
28///
29/// # Examples
30///
31/// ```
32/// use zlayer_tui::widgets::progress_bar::ProgressBar;
33///
34/// // Minimal usage -- just a bar
35/// let bar = ProgressBar::new(3, 10);
36///
37/// // Builder-style with label
38/// let bar = ProgressBar::new(3, 10).with_label("Step 3/10");
39///
40/// // Deploy-style with percentage
41/// let bar = ProgressBar::new(3, 10).with_percentage();
42///
43/// // Compact string for embedding in table cells
44/// let text = ProgressBar::new(3, 10).with_percentage().to_string_compact(20);
45/// ```
46pub struct ProgressBar {
47    pub current: usize,
48    pub total: usize,
49    pub label: Option<String>,
50    pub show_percentage: bool,
51    pub bar_style: Style,
52    pub label_style: Style,
53}
54
55impl ProgressBar {
56    /// Create a new progress bar with sensible defaults.
57    ///
58    /// Defaults: no label, no percentage, bar styled with [`ACCENT`] foreground,
59    /// label styled with [`TEXT`] foreground.
60    #[must_use]
61    pub fn new(current: usize, total: usize) -> Self {
62        Self {
63            current,
64            total,
65            label: None,
66            show_percentage: false,
67            bar_style: Style::default().fg(ACCENT),
68            label_style: Style::default().fg(TEXT),
69        }
70    }
71
72    /// Attach a text label that will be rendered after the bar.
73    #[must_use]
74    pub fn with_label(mut self, label: impl Into<String>) -> Self {
75        self.label = Some(label.into());
76        self
77    }
78
79    /// Enable a trailing percentage indicator (e.g. `" 30%"`).
80    #[must_use]
81    pub fn with_percentage(mut self) -> Self {
82        self.show_percentage = true;
83        self
84    }
85
86    // ------------------------------------------------------------------
87    // Shared helpers
88    // ------------------------------------------------------------------
89
90    /// Compute the fill ratio, clamped to `[0.0, 1.0]`.
91    #[allow(clippy::cast_precision_loss)]
92    fn ratio(&self) -> f64 {
93        if self.total == 0 {
94            0.0
95        } else {
96            (self.current as f64 / self.total as f64).clamp(0.0, 1.0)
97        }
98    }
99
100    /// Build the bar characters for a given `width` (in columns).
101    #[allow(
102        clippy::cast_precision_loss,
103        clippy::cast_possible_truncation,
104        clippy::cast_sign_loss
105    )]
106    fn bar_string(ratio: f64, width: usize) -> String {
107        let filled = (width as f64 * ratio).round() as usize;
108        let empty = width.saturating_sub(filled);
109        std::iter::repeat_n(PROGRESS_FILLED, filled)
110            .chain(std::iter::repeat_n(PROGRESS_EMPTY, empty))
111            .take(width)
112            .collect()
113    }
114
115    /// Produce a compact string representation suitable for embedding inside
116    /// table cells or log lines.
117    ///
118    /// The returned string has the form `"████░░░░ label N%"` depending on
119    /// which display options are enabled.  The bar itself occupies exactly
120    /// `width` columns; the suffix is appended after a space.
121    ///
122    /// If `total` is zero the bar is empty but still occupies `width` columns.
123    #[must_use]
124    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
125    pub fn to_string_compact(&self, width: usize) -> String {
126        use std::fmt::Write;
127
128        let ratio = self.ratio();
129        let bar = Self::bar_string(ratio, width);
130
131        let mut suffix = String::new();
132        if let Some(ref label) = self.label {
133            suffix.push(' ');
134            suffix.push_str(label);
135        }
136        if self.show_percentage {
137            let percent = (ratio * 100.0) as u32;
138            suffix.push(' ');
139            let _ = write!(suffix, "{percent}%");
140        }
141
142        format!("{bar}{suffix}")
143    }
144}
145
146impl Widget for ProgressBar {
147    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
148    fn render(self, area: Rect, buf: &mut Buffer) {
149        use std::fmt::Write;
150
151        if area.width < 3 || area.height < 1 {
152            return;
153        }
154
155        let ratio = self.ratio();
156
157        // Calculate the suffix width so we know how much space the bar gets.
158        let mut suffix = String::new();
159        if let Some(ref label) = self.label {
160            // One space separator + label text
161            suffix.push(' ');
162            suffix.push_str(label);
163        }
164        if self.show_percentage {
165            let percent = (ratio * 100.0) as u32;
166            suffix.push(' ');
167            let _ = write!(suffix, "{percent}%");
168        }
169
170        let suffix_width = suffix.len() as u16;
171        let bar_width = area.width.saturating_sub(suffix_width);
172
173        // If there is not enough room for a meaningful bar, fall back to
174        // rendering just the label/suffix as text.
175        if bar_width < 5 {
176            let fallback = suffix.trim_start().to_string();
177            Paragraph::new(fallback)
178                .style(self.label_style)
179                .render(area, buf);
180            return;
181        }
182
183        // Draw the bar characters.
184        let bar = Self::bar_string(ratio, bar_width as usize);
185        buf.set_string(area.x, area.y, &bar, self.bar_style);
186
187        // Draw the suffix (label and/or percentage) to the right of the bar.
188        if !suffix.is_empty() {
189            let suffix_area = Rect {
190                x: area.x + bar_width,
191                y: area.y,
192                width: suffix_width,
193                height: 1,
194            };
195            Paragraph::new(suffix)
196                .style(self.label_style)
197                .render(suffix_area, buf);
198        }
199    }
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205    use ratatui::style::Color;
206
207    // -----------------------------------------------------------------
208    // Helper: render the widget into a fresh buffer and return it.
209    // -----------------------------------------------------------------
210
211    fn render_to_buffer(bar: ProgressBar, width: u16, height: u16) -> Buffer {
212        let area = Rect::new(0, 0, width, height);
213        let mut buf = Buffer::empty(area);
214        bar.render(area, &mut buf);
215        buf
216    }
217
218    // -----------------------------------------------------------------
219    // Construction / defaults
220    // -----------------------------------------------------------------
221
222    #[test]
223    fn new_sets_defaults() {
224        let bar = ProgressBar::new(5, 10);
225        assert_eq!(bar.current, 5);
226        assert_eq!(bar.total, 10);
227        assert!(bar.label.is_none());
228        assert!(!bar.show_percentage);
229        assert_eq!(bar.bar_style, Style::default().fg(Color::Cyan));
230        assert_eq!(bar.label_style, Style::default().fg(Color::White));
231    }
232
233    #[test]
234    fn builder_methods_set_fields() {
235        let bar = ProgressBar::new(1, 2).with_label("hello").with_percentage();
236        assert_eq!(bar.label.as_deref(), Some("hello"));
237        assert!(bar.show_percentage);
238    }
239
240    // -----------------------------------------------------------------
241    // Full bar rendering (Widget impl)
242    // -----------------------------------------------------------------
243
244    #[test]
245    fn full_bar_renders_all_filled() {
246        let bar = ProgressBar::new(10, 10);
247        let buf = render_to_buffer(bar, 20, 1);
248
249        let line: String = (0..20)
250            .map(|x| {
251                buf.cell((x, 0))
252                    .unwrap()
253                    .symbol()
254                    .chars()
255                    .next()
256                    .unwrap_or(' ')
257            })
258            .collect();
259        // Every cell should be the filled character.
260        assert!(
261            line.chars().all(|c| c == PROGRESS_FILLED),
262            "Expected all filled, got: {line:?}",
263        );
264    }
265
266    #[test]
267    fn empty_bar_renders_all_empty() {
268        let bar = ProgressBar::new(0, 10);
269        let buf = render_to_buffer(bar, 20, 1);
270
271        let line: String = (0..20)
272            .map(|x| {
273                buf.cell((x, 0))
274                    .unwrap()
275                    .symbol()
276                    .chars()
277                    .next()
278                    .unwrap_or(' ')
279            })
280            .collect();
281        assert!(
282            line.chars().all(|c| c == PROGRESS_EMPTY),
283            "Expected all empty, got: {line:?}",
284        );
285    }
286
287    #[test]
288    fn half_bar_renders_mixed() {
289        let bar = ProgressBar::new(5, 10);
290        let buf = render_to_buffer(bar, 20, 1);
291
292        let filled_count = (0..20)
293            .filter(|&x| {
294                buf.cell((x, 0))
295                    .unwrap()
296                    .symbol()
297                    .chars()
298                    .next()
299                    .unwrap_or(' ')
300                    == PROGRESS_FILLED
301            })
302            .count();
303        // 50% of 20 = 10 filled
304        assert_eq!(filled_count, 10);
305    }
306
307    #[test]
308    fn renders_with_label() {
309        let bar = ProgressBar::new(5, 10).with_label("OK");
310        let buf = render_to_buffer(bar, 30, 1);
311
312        // Collect the full rendered line.
313        let line: String = (0..30)
314            .map(|x| {
315                buf.cell((x, 0))
316                    .unwrap()
317                    .symbol()
318                    .chars()
319                    .next()
320                    .unwrap_or(' ')
321            })
322            .collect();
323        assert!(line.contains("OK"), "Label not found in: {line:?}");
324    }
325
326    #[test]
327    fn renders_with_percentage() {
328        let bar = ProgressBar::new(3, 10).with_percentage();
329        let buf = render_to_buffer(bar, 30, 1);
330
331        let line: String = (0..30)
332            .map(|x| {
333                buf.cell((x, 0))
334                    .unwrap()
335                    .symbol()
336                    .chars()
337                    .next()
338                    .unwrap_or(' ')
339            })
340            .collect();
341        assert!(line.contains("30%"), "Percentage not found in: {line:?}");
342    }
343
344    // -----------------------------------------------------------------
345    // Zero-total edge case
346    // -----------------------------------------------------------------
347
348    #[test]
349    fn zero_total_renders_empty_bar() {
350        let bar = ProgressBar::new(5, 0);
351        let buf = render_to_buffer(bar, 20, 1);
352
353        let line: String = (0..20)
354            .map(|x| {
355                buf.cell((x, 0))
356                    .unwrap()
357                    .symbol()
358                    .chars()
359                    .next()
360                    .unwrap_or(' ')
361            })
362            .collect();
363        assert!(
364            line.chars().all(|c| c == PROGRESS_EMPTY),
365            "Expected all empty for zero total, got: {line:?}",
366        );
367    }
368
369    #[test]
370    fn zero_total_with_percentage_shows_zero() {
371        let compact = ProgressBar::new(0, 0)
372            .with_percentage()
373            .to_string_compact(10);
374        assert!(compact.contains("0%"), "Expected 0%% in: {compact:?}");
375    }
376
377    // -----------------------------------------------------------------
378    // to_string_compact
379    // -----------------------------------------------------------------
380
381    #[test]
382    fn compact_bar_only() {
383        let s = ProgressBar::new(10, 10).to_string_compact(10);
384        assert_eq!(s.chars().count(), 10);
385        assert!(s.chars().all(|c| c == PROGRESS_FILLED));
386    }
387
388    #[test]
389    fn compact_with_label() {
390        let s = ProgressBar::new(5, 10)
391            .with_label("building")
392            .to_string_compact(10);
393        assert!(s.contains("building"), "Label not in compact: {s:?}");
394        // Bar should be exactly 10 chars, then " building"
395        assert!(s.starts_with(&std::iter::repeat_n(PROGRESS_FILLED, 5).collect::<String>()));
396    }
397
398    #[test]
399    fn compact_with_percentage() {
400        let s = ProgressBar::new(3, 10)
401            .with_percentage()
402            .to_string_compact(20);
403        assert!(s.contains("30%"), "Percentage not in compact: {s:?}");
404        // Bar portion should be exactly 20 characters wide.
405        let bar_part: String = s.chars().take(20).collect();
406        assert_eq!(bar_part.chars().count(), 20);
407    }
408
409    #[test]
410    fn compact_with_label_and_percentage() {
411        let s = ProgressBar::new(10, 10)
412            .with_label("done")
413            .with_percentage()
414            .to_string_compact(10);
415        assert!(s.contains("done"), "Label not found: {s:?}");
416        assert!(s.contains("100%"), "Percentage not found: {s:?}");
417    }
418
419    #[test]
420    fn compact_zero_width_produces_suffix_only() {
421        let s = ProgressBar::new(5, 10)
422            .with_label("hi")
423            .with_percentage()
424            .to_string_compact(0);
425        // Zero-width bar means the string is just the suffix.
426        assert!(s.contains("hi"));
427        assert!(s.contains("50%"));
428    }
429
430    // -----------------------------------------------------------------
431    // Label-only fallback for tiny areas
432    // -----------------------------------------------------------------
433
434    #[test]
435    fn tiny_area_falls_back_to_label_text() {
436        // With a label that occupies most of the width, the bar portion
437        // will be < 5 columns, triggering the fallback.
438        let bar = ProgressBar::new(5, 10).with_label("Building image step 3 of 10");
439        // Area only 10 wide -- the suffix is 28 chars, so bar_width = 0 < 5.
440        let buf = render_to_buffer(bar, 10, 1);
441
442        let line: String = (0..10)
443            .map(|x| {
444                buf.cell((x, 0))
445                    .unwrap()
446                    .symbol()
447                    .chars()
448                    .next()
449                    .unwrap_or(' ')
450            })
451            .collect();
452        // Should contain the beginning of the label, not bar characters.
453        assert!(
454            line.starts_with("Building"),
455            "Expected label fallback, got: {line:?}",
456        );
457    }
458
459    #[test]
460    fn percentage_fallback_on_tiny_area() {
461        let bar = ProgressBar::new(5, 10).with_percentage();
462        // Width 6: suffix " 50%" is 4 chars, bar_width = 2 which is < 5.
463        let buf = render_to_buffer(bar, 6, 1);
464
465        let line: String = (0..6)
466            .map(|x| {
467                buf.cell((x, 0))
468                    .unwrap()
469                    .symbol()
470                    .chars()
471                    .next()
472                    .unwrap_or(' ')
473            })
474            .collect();
475        assert!(
476            line.contains("50%"),
477            "Expected percentage fallback, got: {line:?}",
478        );
479    }
480
481    #[test]
482    fn zero_height_renders_nothing() {
483        let bar = ProgressBar::new(5, 10);
484        // Zero height -- render should bail immediately.
485        let area = Rect::new(0, 0, 20, 0);
486        let mut buf = Buffer::empty(area);
487        bar.render(area, &mut buf);
488        // No panic is the success criterion; buffer is empty.
489    }
490
491    #[test]
492    fn very_narrow_area_renders_nothing() {
493        let bar = ProgressBar::new(5, 10);
494        // Width 2 with no label/percentage -- bar_width < 5 and no suffix to
495        // fall back to, so it renders an empty fallback string.
496        let buf = render_to_buffer(bar, 2, 1);
497
498        let line: String = (0..2)
499            .map(|x| {
500                buf.cell((x, 0))
501                    .unwrap()
502                    .symbol()
503                    .chars()
504                    .next()
505                    .unwrap_or(' ')
506            })
507            .collect();
508        // Should not contain bar characters.
509        assert!(
510            !line.contains(PROGRESS_FILLED),
511            "Did not expect bar chars in narrow area: {line:?}",
512        );
513    }
514
515    // -----------------------------------------------------------------
516    // Ratio clamping
517    // -----------------------------------------------------------------
518
519    #[test]
520    fn current_exceeding_total_clamps_to_full() {
521        let bar = ProgressBar::new(999, 10);
522        let buf = render_to_buffer(bar, 20, 1);
523
524        let line: String = (0..20)
525            .map(|x| {
526                buf.cell((x, 0))
527                    .unwrap()
528                    .symbol()
529                    .chars()
530                    .next()
531                    .unwrap_or(' ')
532            })
533            .collect();
534        assert!(
535            line.chars().all(|c| c == PROGRESS_FILLED),
536            "Expected fully filled when current > total, got: {line:?}",
537        );
538    }
539
540    #[test]
541    fn compact_current_exceeding_total_shows_100_percent() {
542        let s = ProgressBar::new(999, 10)
543            .with_percentage()
544            .to_string_compact(10);
545        assert!(s.contains("100%"), "Expected 100%% in: {s:?}");
546    }
547}