use ratatui::{
buffer::Buffer,
layout::Rect,
style::Style,
widgets::{Paragraph, Widget},
};
use crate::icons::{PROGRESS_EMPTY, PROGRESS_FILLED};
use crate::palette::color::{ACCENT, TEXT};
pub struct ProgressBar {
pub current: usize,
pub total: usize,
pub label: Option<String>,
pub show_percentage: bool,
pub bar_style: Style,
pub label_style: Style,
}
impl ProgressBar {
pub fn new(current: usize, total: usize) -> Self {
Self {
current,
total,
label: None,
show_percentage: false,
bar_style: Style::default().fg(ACCENT),
label_style: Style::default().fg(TEXT),
}
}
pub fn with_label(mut self, label: impl Into<String>) -> Self {
self.label = Some(label.into());
self
}
pub fn with_percentage(mut self) -> Self {
self.show_percentage = true;
self
}
fn ratio(&self) -> f64 {
if self.total == 0 {
0.0
} else {
(self.current as f64 / self.total as f64).clamp(0.0, 1.0)
}
}
fn bar_string(ratio: f64, width: usize) -> String {
let filled = (width as f64 * ratio).round() as usize;
let empty = width.saturating_sub(filled);
std::iter::repeat_n(PROGRESS_FILLED, filled)
.chain(std::iter::repeat_n(PROGRESS_EMPTY, empty))
.take(width)
.collect()
}
pub fn to_string_compact(&self, width: usize) -> String {
let ratio = self.ratio();
let bar = Self::bar_string(ratio, width);
let mut suffix = String::new();
if let Some(ref label) = self.label {
suffix.push(' ');
suffix.push_str(label);
}
if self.show_percentage {
let percent = (ratio * 100.0) as u32;
suffix.push(' ');
suffix.push_str(&format!("{}%", percent));
}
format!("{}{}", bar, suffix)
}
}
impl Widget for ProgressBar {
fn render(self, area: Rect, buf: &mut Buffer) {
if area.width < 3 || area.height < 1 {
return;
}
let ratio = self.ratio();
let mut suffix = String::new();
if let Some(ref label) = self.label {
suffix.push(' ');
suffix.push_str(label);
}
if self.show_percentage {
let percent = (ratio * 100.0) as u32;
suffix.push(' ');
suffix.push_str(&format!("{}%", percent));
}
let suffix_width = suffix.len() as u16;
let bar_width = area.width.saturating_sub(suffix_width);
if bar_width < 5 {
let fallback = suffix.trim_start().to_string();
Paragraph::new(fallback)
.style(self.label_style)
.render(area, buf);
return;
}
let bar = Self::bar_string(ratio, bar_width as usize);
buf.set_string(area.x, area.y, &bar, self.bar_style);
if !suffix.is_empty() {
let suffix_area = Rect {
x: area.x + bar_width,
y: area.y,
width: suffix_width,
height: 1,
};
Paragraph::new(suffix)
.style(self.label_style)
.render(suffix_area, buf);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::style::Color;
fn render_to_buffer(bar: ProgressBar, width: u16, height: u16) -> Buffer {
let area = Rect::new(0, 0, width, height);
let mut buf = Buffer::empty(area);
bar.render(area, &mut buf);
buf
}
#[test]
fn new_sets_defaults() {
let bar = ProgressBar::new(5, 10);
assert_eq!(bar.current, 5);
assert_eq!(bar.total, 10);
assert!(bar.label.is_none());
assert!(!bar.show_percentage);
assert_eq!(bar.bar_style, Style::default().fg(Color::Cyan));
assert_eq!(bar.label_style, Style::default().fg(Color::White));
}
#[test]
fn builder_methods_set_fields() {
let bar = ProgressBar::new(1, 2).with_label("hello").with_percentage();
assert_eq!(bar.label.as_deref(), Some("hello"));
assert!(bar.show_percentage);
}
#[test]
fn full_bar_renders_all_filled() {
let bar = ProgressBar::new(10, 10);
let buf = render_to_buffer(bar, 20, 1);
let line: String = (0..20)
.map(|x| {
buf.cell((x, 0))
.unwrap()
.symbol()
.chars()
.next()
.unwrap_or(' ')
})
.collect();
assert!(
line.chars().all(|c| c == PROGRESS_FILLED),
"Expected all filled, got: {:?}",
line,
);
}
#[test]
fn empty_bar_renders_all_empty() {
let bar = ProgressBar::new(0, 10);
let buf = render_to_buffer(bar, 20, 1);
let line: String = (0..20)
.map(|x| {
buf.cell((x, 0))
.unwrap()
.symbol()
.chars()
.next()
.unwrap_or(' ')
})
.collect();
assert!(
line.chars().all(|c| c == PROGRESS_EMPTY),
"Expected all empty, got: {:?}",
line,
);
}
#[test]
fn half_bar_renders_mixed() {
let bar = ProgressBar::new(5, 10);
let buf = render_to_buffer(bar, 20, 1);
let filled_count = (0..20)
.filter(|&x| {
buf.cell((x, 0))
.unwrap()
.symbol()
.chars()
.next()
.unwrap_or(' ')
== PROGRESS_FILLED
})
.count();
assert_eq!(filled_count, 10);
}
#[test]
fn renders_with_label() {
let bar = ProgressBar::new(5, 10).with_label("OK");
let buf = render_to_buffer(bar, 30, 1);
let line: String = (0..30)
.map(|x| {
buf.cell((x, 0))
.unwrap()
.symbol()
.chars()
.next()
.unwrap_or(' ')
})
.collect();
assert!(line.contains("OK"), "Label not found in: {:?}", line);
}
#[test]
fn renders_with_percentage() {
let bar = ProgressBar::new(3, 10).with_percentage();
let buf = render_to_buffer(bar, 30, 1);
let line: String = (0..30)
.map(|x| {
buf.cell((x, 0))
.unwrap()
.symbol()
.chars()
.next()
.unwrap_or(' ')
})
.collect();
assert!(line.contains("30%"), "Percentage not found in: {:?}", line);
}
#[test]
fn zero_total_renders_empty_bar() {
let bar = ProgressBar::new(5, 0);
let buf = render_to_buffer(bar, 20, 1);
let line: String = (0..20)
.map(|x| {
buf.cell((x, 0))
.unwrap()
.symbol()
.chars()
.next()
.unwrap_or(' ')
})
.collect();
assert!(
line.chars().all(|c| c == PROGRESS_EMPTY),
"Expected all empty for zero total, got: {:?}",
line,
);
}
#[test]
fn zero_total_with_percentage_shows_zero() {
let compact = ProgressBar::new(0, 0)
.with_percentage()
.to_string_compact(10);
assert!(compact.contains("0%"), "Expected 0%% in: {:?}", compact);
}
#[test]
fn compact_bar_only() {
let s = ProgressBar::new(10, 10).to_string_compact(10);
assert_eq!(s.chars().count(), 10);
assert!(s.chars().all(|c| c == PROGRESS_FILLED));
}
#[test]
fn compact_with_label() {
let s = ProgressBar::new(5, 10)
.with_label("building")
.to_string_compact(10);
assert!(s.contains("building"), "Label not in compact: {:?}", s);
assert!(s.starts_with(&std::iter::repeat_n(PROGRESS_FILLED, 5).collect::<String>()));
}
#[test]
fn compact_with_percentage() {
let s = ProgressBar::new(3, 10)
.with_percentage()
.to_string_compact(20);
assert!(s.contains("30%"), "Percentage not in compact: {:?}", s);
let bar_part: String = s.chars().take(20).collect();
assert_eq!(bar_part.chars().count(), 20);
}
#[test]
fn compact_with_label_and_percentage() {
let s = ProgressBar::new(10, 10)
.with_label("done")
.with_percentage()
.to_string_compact(10);
assert!(s.contains("done"), "Label not found: {:?}", s);
assert!(s.contains("100%"), "Percentage not found: {:?}", s);
}
#[test]
fn compact_zero_width_produces_suffix_only() {
let s = ProgressBar::new(5, 10)
.with_label("hi")
.with_percentage()
.to_string_compact(0);
assert!(s.contains("hi"));
assert!(s.contains("50%"));
}
#[test]
fn tiny_area_falls_back_to_label_text() {
let bar = ProgressBar::new(5, 10).with_label("Building image step 3 of 10");
let buf = render_to_buffer(bar, 10, 1);
let line: String = (0..10)
.map(|x| {
buf.cell((x, 0))
.unwrap()
.symbol()
.chars()
.next()
.unwrap_or(' ')
})
.collect();
assert!(
line.starts_with("Building"),
"Expected label fallback, got: {:?}",
line,
);
}
#[test]
fn percentage_fallback_on_tiny_area() {
let bar = ProgressBar::new(5, 10).with_percentage();
let buf = render_to_buffer(bar, 6, 1);
let line: String = (0..6)
.map(|x| {
buf.cell((x, 0))
.unwrap()
.symbol()
.chars()
.next()
.unwrap_or(' ')
})
.collect();
assert!(
line.contains("50%"),
"Expected percentage fallback, got: {:?}",
line,
);
}
#[test]
fn zero_height_renders_nothing() {
let bar = ProgressBar::new(5, 10);
let area = Rect::new(0, 0, 20, 0);
let mut buf = Buffer::empty(area);
bar.render(area, &mut buf);
}
#[test]
fn very_narrow_area_renders_nothing() {
let bar = ProgressBar::new(5, 10);
let buf = render_to_buffer(bar, 2, 1);
let line: String = (0..2)
.map(|x| {
buf.cell((x, 0))
.unwrap()
.symbol()
.chars()
.next()
.unwrap_or(' ')
})
.collect();
assert!(
!line.contains(PROGRESS_FILLED),
"Did not expect bar chars in narrow area: {:?}",
line,
);
}
#[test]
fn current_exceeding_total_clamps_to_full() {
let bar = ProgressBar::new(999, 10);
let buf = render_to_buffer(bar, 20, 1);
let line: String = (0..20)
.map(|x| {
buf.cell((x, 0))
.unwrap()
.symbol()
.chars()
.next()
.unwrap_or(' ')
})
.collect();
assert!(
line.chars().all(|c| c == PROGRESS_FILLED),
"Expected fully filled when current > total, got: {:?}",
line,
);
}
#[test]
fn compact_current_exceeding_total_shows_100_percent() {
let s = ProgressBar::new(999, 10)
.with_percentage()
.to_string_compact(10);
assert!(s.contains("100%"), "Expected 100%% in: {:?}", s);
}
}