use std::f64::consts::PI;
use std::fmt;
use std::time::SystemTime;
use crate::color::{blend_rgb, Color, ColorSystem};
use crate::color_triplet::ColorTriplet;
use crate::console::{Console, ConsoleOptions, Renderable};
use crate::measure::Measurement;
use crate::segment::Segment;
use crate::style::Style;
const PULSE_SIZE: usize = 20;
#[derive(Debug, Clone)]
pub struct ProgressBar {
pub total: Option<f64>,
pub completed: f64,
pub width: Option<usize>,
pub pulse: bool,
pub style: String,
pub complete_style: String,
pub finished_style: String,
pub pulse_style: String,
pub animation_time: Option<f64>,
}
impl ProgressBar {
pub fn new() -> Self {
Self {
total: Some(100.0),
completed: 0.0,
width: None,
pulse: false,
style: "bar.back".to_string(),
complete_style: "bar.complete".to_string(),
finished_style: "bar.finished".to_string(),
pulse_style: "bar.pulse".to_string(),
animation_time: None,
}
}
#[must_use]
pub fn with_total(mut self, total: Option<f64>) -> Self {
self.total = total;
self
}
#[must_use]
pub fn with_completed(mut self, completed: f64) -> Self {
self.completed = completed;
self
}
#[must_use]
pub fn with_width(mut self, width: Option<usize>) -> Self {
self.width = width;
self
}
#[must_use]
pub fn with_pulse(mut self, pulse: bool) -> Self {
self.pulse = pulse;
self
}
#[must_use]
pub fn with_style(mut self, style: &str) -> Self {
self.style = style.to_string();
self
}
#[must_use]
pub fn with_complete_style(mut self, style: &str) -> Self {
self.complete_style = style.to_string();
self
}
#[must_use]
pub fn with_finished_style(mut self, style: &str) -> Self {
self.finished_style = style.to_string();
self
}
#[must_use]
pub fn with_pulse_style(mut self, style: &str) -> Self {
self.pulse_style = style.to_string();
self
}
#[must_use]
pub fn with_animation_time(mut self, time: Option<f64>) -> Self {
self.animation_time = time;
self
}
pub fn percentage_completed(&self) -> Option<f64> {
self.total.map(|total| {
let pct = (self.completed / total) * 100.0;
pct.clamp(0.0, 100.0)
})
}
pub fn update(&mut self, completed: f64, total: Option<f64>) {
self.completed = completed;
if let Some(t) = total {
self.total = Some(t);
}
}
pub fn measure(&self, _console: &Console, options: &ConsoleOptions) -> Measurement {
if let Some(w) = self.width {
Measurement::new(w, w)
} else {
Measurement::new(4, options.max_width)
}
}
fn get_pulse_segments(
&self,
fore_style: &Style,
back_style: &Style,
color_system: Option<ColorSystem>,
) -> Vec<Segment> {
let bar = "\u{2501}";
let has_color = matches!(
color_system,
Some(ColorSystem::Standard | ColorSystem::EightBit | ColorSystem::TrueColor)
);
if !has_color {
let mut segments = Vec::with_capacity(PULSE_SIZE);
let half = PULSE_SIZE / 2;
for _ in 0..half {
segments.push(Segment::styled(bar, fore_style.clone()));
}
let back_char = if color_system.is_none() { " " } else { bar };
for _ in 0..(PULSE_SIZE - half) {
segments.push(Segment::styled(back_char, back_style.clone()));
}
return segments;
}
let fore_color = fore_style
.color()
.map(|c| c.get_truecolor(None, true))
.unwrap_or(ColorTriplet::new(255, 0, 255));
let back_color = back_style
.color()
.map(|c| c.get_truecolor(None, true))
.unwrap_or(ColorTriplet::new(0, 0, 0));
let mut segments = Vec::with_capacity(PULSE_SIZE);
for index in 0..PULSE_SIZE {
let position = index as f64 / PULSE_SIZE as f64;
let fade = 0.5 + (position * PI * 2.0).cos() / 2.0;
let color = blend_rgb(fore_color, back_color, fade);
let style = Style::from_color(Some(Color::from_triplet(color)), None);
segments.push(Segment::styled(bar, style));
}
segments
}
fn render_pulse(&self, console: &Console, width: usize) -> Vec<Segment> {
let fore_style = console
.get_style(&self.pulse_style)
.unwrap_or_else(|_| Style::parse("white").unwrap_or_else(|_| Style::null()));
let back_style = console
.get_style(&self.style)
.unwrap_or_else(|_| Style::parse("black").unwrap_or_else(|_| Style::null()));
let color_system = console.color_system();
let pulse_segments = self.get_pulse_segments(&fore_style, &back_style, color_system);
let segment_count = pulse_segments.len();
if segment_count == 0 {
return Vec::new();
}
let current_time = self.animation_time.unwrap_or_else(|| {
SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map(|d| d.as_secs_f64())
.unwrap_or(0.0)
});
let repeats = width / segment_count + 2;
let mut tiled: Vec<Segment> = Vec::with_capacity(repeats * segment_count);
for _ in 0..repeats {
tiled.extend(pulse_segments.iter().cloned());
}
let offset = ((-current_time * 15.0) as isize).rem_euclid(segment_count as isize) as usize;
tiled[offset..offset + width].to_vec()
}
}
impl Default for ProgressBar {
fn default() -> Self {
Self::new()
}
}
impl fmt::Display for ProgressBar {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let mut console = Console::builder()
.width(f.width().unwrap_or(80))
.force_terminal(true)
.no_color(true)
.build();
console.begin_capture();
console.print(self);
let output = console.end_capture();
write!(f, "{}", output.trim_end_matches('\n'))
}
}
impl Renderable for ProgressBar {
fn rich_console(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
let width = match self.width {
Some(w) => w.min(options.max_width),
None => options.max_width,
};
let ascii = options.legacy_windows || options.ascii_only();
let should_pulse = self.pulse || self.total.is_none();
if should_pulse {
return self.render_pulse(console, width);
}
let total = self.total.unwrap_or(100.0);
let completed = self.completed.clamp(0.0, total);
let bar = if ascii { "-" } else { "\u{2501}" }; let half_bar_right = if ascii { " " } else { "\u{257A}" }; let half_bar_left = if ascii { " " } else { "\u{2578}" };
let complete_halves = if total > 0.0 {
(width as f64 * 2.0 * completed / total) as usize
} else {
width * 2
};
let bar_count = complete_halves / 2;
let half_bar_count = complete_halves % 2;
let back_style = console
.get_style(&self.style)
.unwrap_or_else(|_| Style::null());
let is_finished = completed >= total;
let complete_style = if is_finished {
console
.get_style(&self.finished_style)
.unwrap_or_else(|_| Style::null())
} else {
console
.get_style(&self.complete_style)
.unwrap_or_else(|_| Style::null())
};
let mut segments = Vec::new();
if bar_count > 0 {
segments.push(Segment::styled(
&bar.repeat(bar_count),
complete_style.clone(),
));
}
if half_bar_count > 0 {
segments.push(Segment::styled(half_bar_right, complete_style.clone()));
}
if console.color_system().is_some() {
let remaining_bars = width.saturating_sub(bar_count + half_bar_count);
if remaining_bars > 0 {
if half_bar_count == 0 && bar_count > 0 {
segments.push(Segment::styled(half_bar_left, back_style.clone()));
let after = remaining_bars.saturating_sub(1);
if after > 0 {
segments.push(Segment::styled(&bar.repeat(after), back_style));
}
} else {
segments.push(Segment::styled(&bar.repeat(remaining_bars), back_style));
}
}
}
segments
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::console::{ConsoleDimensions, ConsoleOptions};
fn make_options(max_width: usize) -> ConsoleOptions {
ConsoleOptions {
size: ConsoleDimensions {
width: max_width,
height: 25,
},
legacy_windows: false,
min_width: 1,
max_width,
is_terminal: false,
encoding: "utf-8".to_string(),
max_height: 25,
justify: None,
overflow: None,
no_wrap: false,
highlight: None,
markup: None,
height: None,
}
}
fn render_segments(bar: &ProgressBar, max_width: usize) -> Vec<Segment> {
let console = Console::builder()
.width(max_width)
.color_system("truecolor")
.build();
let opts = make_options(max_width);
bar.rich_console(&console, &opts)
}
fn render_text(bar: &ProgressBar, max_width: usize) -> String {
let segments = render_segments(bar, max_width);
segments.iter().map(|s| s.text.as_str()).collect()
}
#[test]
fn test_default_construction() {
let bar = ProgressBar::new();
assert_eq!(bar.total, Some(100.0));
assert_eq!(bar.completed, 0.0);
assert_eq!(bar.width, None);
assert!(!bar.pulse);
assert_eq!(bar.style, "bar.back");
assert_eq!(bar.complete_style, "bar.complete");
assert_eq!(bar.finished_style, "bar.finished");
assert_eq!(bar.pulse_style, "bar.pulse");
assert_eq!(bar.animation_time, None);
}
#[test]
fn test_default_trait() {
let bar = ProgressBar::default();
assert_eq!(bar.total, Some(100.0));
assert_eq!(bar.completed, 0.0);
}
#[test]
fn test_with_total() {
let bar = ProgressBar::new().with_total(Some(200.0));
assert_eq!(bar.total, Some(200.0));
}
#[test]
fn test_with_total_none() {
let bar = ProgressBar::new().with_total(None);
assert_eq!(bar.total, None);
}
#[test]
fn test_with_completed() {
let bar = ProgressBar::new().with_completed(42.0);
assert_eq!(bar.completed, 42.0);
}
#[test]
fn test_with_width() {
let bar = ProgressBar::new().with_width(Some(50));
assert_eq!(bar.width, Some(50));
}
#[test]
fn test_with_width_none() {
let bar = ProgressBar::new().with_width(None);
assert_eq!(bar.width, None);
}
#[test]
fn test_with_pulse() {
let bar = ProgressBar::new().with_pulse(true);
assert!(bar.pulse);
}
#[test]
fn test_with_style() {
let bar = ProgressBar::new().with_style("custom.back");
assert_eq!(bar.style, "custom.back");
}
#[test]
fn test_with_complete_style() {
let bar = ProgressBar::new().with_complete_style("custom.complete");
assert_eq!(bar.complete_style, "custom.complete");
}
#[test]
fn test_with_finished_style() {
let bar = ProgressBar::new().with_finished_style("custom.finished");
assert_eq!(bar.finished_style, "custom.finished");
}
#[test]
fn test_with_pulse_style() {
let bar = ProgressBar::new().with_pulse_style("custom.pulse");
assert_eq!(bar.pulse_style, "custom.pulse");
}
#[test]
fn test_with_animation_time() {
let bar = ProgressBar::new().with_animation_time(Some(1.5));
assert_eq!(bar.animation_time, Some(1.5));
}
#[test]
fn test_builder_chaining() {
let bar = ProgressBar::new()
.with_total(Some(200.0))
.with_completed(50.0)
.with_width(Some(40))
.with_pulse(false)
.with_style("red")
.with_animation_time(Some(2.0));
assert_eq!(bar.total, Some(200.0));
assert_eq!(bar.completed, 50.0);
assert_eq!(bar.width, Some(40));
assert!(!bar.pulse);
assert_eq!(bar.style, "red");
assert_eq!(bar.animation_time, Some(2.0));
}
#[test]
fn test_percentage_completed_normal() {
let bar = ProgressBar::new().with_completed(50.0);
assert_eq!(bar.percentage_completed(), Some(50.0));
}
#[test]
fn test_percentage_completed_zero() {
let bar = ProgressBar::new().with_completed(0.0);
assert_eq!(bar.percentage_completed(), Some(0.0));
}
#[test]
fn test_percentage_completed_full() {
let bar = ProgressBar::new().with_completed(100.0);
assert_eq!(bar.percentage_completed(), Some(100.0));
}
#[test]
fn test_percentage_completed_over_100() {
let bar = ProgressBar::new().with_completed(150.0);
assert_eq!(bar.percentage_completed(), Some(100.0));
}
#[test]
fn test_percentage_completed_negative() {
let bar = ProgressBar::new().with_completed(-10.0);
assert_eq!(bar.percentage_completed(), Some(0.0));
}
#[test]
fn test_percentage_completed_none_total() {
let bar = ProgressBar::new().with_total(None);
assert_eq!(bar.percentage_completed(), None);
}
#[test]
fn test_percentage_completed_custom_total() {
let bar = ProgressBar::new()
.with_total(Some(200.0))
.with_completed(100.0);
assert_eq!(bar.percentage_completed(), Some(50.0));
}
#[test]
fn test_display_with_total() {
let bar = ProgressBar::new().with_completed(50.0);
let s = format!("{bar}");
assert!(!s.is_empty());
}
#[test]
fn test_display_without_total() {
let bar = ProgressBar::new().with_total(None).with_completed(30.0);
let s = format!("{bar}");
assert!(!s.is_empty());
}
#[test]
fn test_display_zero() {
let bar = ProgressBar::new();
let s = format!("{bar}");
let _ = s;
}
#[test]
fn test_update_completed() {
let mut bar = ProgressBar::new();
bar.update(75.0, None);
assert_eq!(bar.completed, 75.0);
assert_eq!(bar.total, Some(100.0)); }
#[test]
fn test_update_completed_and_total() {
let mut bar = ProgressBar::new();
bar.update(50.0, Some(200.0));
assert_eq!(bar.completed, 50.0);
assert_eq!(bar.total, Some(200.0));
}
#[test]
fn test_update_preserves_total_when_none() {
let mut bar = ProgressBar::new().with_total(Some(50.0));
bar.update(25.0, None);
assert_eq!(bar.total, Some(50.0));
}
#[test]
fn test_render_empty_bar() {
let bar = ProgressBar::new().with_completed(0.0).with_width(Some(10));
let text = render_text(&bar, 10);
assert_eq!(text.chars().count(), 10);
assert!(!text.is_empty());
}
#[test]
fn test_render_half_bar() {
let bar = ProgressBar::new().with_completed(50.0).with_width(Some(10));
let text = render_text(&bar, 10);
assert_eq!(text.chars().count(), 10);
assert!(text.contains('\u{2501}')); }
#[test]
fn test_render_full_bar() {
let bar = ProgressBar::new()
.with_completed(100.0)
.with_width(Some(10));
let text = render_text(&bar, 10);
assert_eq!(text.chars().count(), 10);
let expected = "\u{2501}".repeat(10);
assert_eq!(text, expected);
}
#[test]
fn test_render_over_100_percent() {
let bar = ProgressBar::new()
.with_completed(150.0)
.with_width(Some(10));
let text = render_text(&bar, 10);
assert_eq!(text.chars().count(), 10);
let expected = "\u{2501}".repeat(10);
assert_eq!(text, expected);
}
#[test]
fn test_bar_uses_correct_characters() {
let bar = ProgressBar::new().with_completed(50.0).with_width(Some(20));
let text = render_text(&bar, 20);
for ch in text.chars() {
assert!(
ch == '\u{2501}' || ch == '\u{257A}' || ch == '\u{2578}',
"unexpected character: {:?} (U+{:04X})",
ch,
ch as u32
);
}
}
#[test]
fn test_half_bar_right_character() {
let bar = ProgressBar::new().with_completed(25.0).with_width(Some(10));
let text = render_text(&bar, 10);
assert!(text.contains('\u{257A}'), "expected ╸ in output: {text}");
}
#[test]
fn test_half_bar_left_character() {
let bar = ProgressBar::new().with_completed(40.0).with_width(Some(10));
let text = render_text(&bar, 10);
assert!(text.contains('\u{2578}'), "expected ╺ in output: {text}");
}
#[test]
fn test_finished_style_applied() {
let bar = ProgressBar::new()
.with_completed(100.0)
.with_width(Some(10))
.with_complete_style("red")
.with_finished_style("green");
let console = Console::builder()
.width(10)
.color_system("truecolor")
.build();
let opts = make_options(10);
let segments = bar.rich_console(&console, &opts);
assert!(!segments.is_empty());
let first = &segments[0];
let finished = console.get_style("green").unwrap();
assert_eq!(first.style, Some(finished));
}
#[test]
fn test_complete_style_when_not_finished() {
let bar = ProgressBar::new()
.with_completed(50.0)
.with_width(Some(10))
.with_complete_style("red")
.with_finished_style("green");
let console = Console::builder()
.width(10)
.color_system("truecolor")
.build();
let opts = make_options(10);
let segments = bar.rich_console(&console, &opts);
assert!(!segments.is_empty());
let first = &segments[0];
let complete = console.get_style("red").unwrap();
assert_eq!(first.style, Some(complete));
}
#[test]
fn test_pulse_rendering_width() {
let bar = ProgressBar::new()
.with_total(None)
.with_width(Some(30))
.with_animation_time(Some(0.0));
let segments = render_segments(&bar, 30);
assert_eq!(segments.len(), 30);
}
#[test]
fn test_pulse_rendering_enabled_by_flag() {
let bar = ProgressBar::new()
.with_pulse(true)
.with_width(Some(20))
.with_animation_time(Some(0.0));
let segments = render_segments(&bar, 20);
assert_eq!(segments.len(), 20);
}
#[test]
fn test_pulse_rendering_each_segment_is_bar_char() {
let bar = ProgressBar::new()
.with_total(None)
.with_width(Some(20))
.with_animation_time(Some(0.0));
let segments = render_segments(&bar, 20);
for seg in &segments {
assert_eq!(seg.text, "\u{2501}");
}
}
#[test]
fn test_pulse_color_blending_produces_gradient() {
let bar = ProgressBar::new()
.with_total(None)
.with_width(Some(PULSE_SIZE))
.with_animation_time(Some(0.0))
.with_pulse_style("white")
.with_style("black");
let console = Console::builder()
.width(PULSE_SIZE)
.color_system("truecolor")
.build();
let opts = make_options(PULSE_SIZE);
let segments = bar.rich_console(&console, &opts);
let styles: Vec<_> = segments.iter().map(|s| s.style.clone()).collect();
let unique_count = styles
.iter()
.collect::<std::collections::HashSet<_>>()
.len();
assert!(
unique_count > 1,
"pulse gradient should have more than 1 unique color, got {unique_count}"
);
}
#[test]
fn test_pulse_scrolling_offset() {
let bar1 = ProgressBar::new()
.with_total(None)
.with_width(Some(20))
.with_animation_time(Some(0.0));
let bar2 = ProgressBar::new()
.with_total(None)
.with_width(Some(20))
.with_animation_time(Some(1.0));
let seg1 = render_segments(&bar1, 20);
let seg2 = render_segments(&bar2, 20);
let styles1: Vec<_> = seg1.iter().map(|s| s.style.clone()).collect();
let styles2: Vec<_> = seg2.iter().map(|s| s.style.clone()).collect();
assert_ne!(
styles1, styles2,
"different animation times should produce different patterns"
);
}
#[test]
fn test_fixed_width() {
let bar = ProgressBar::new().with_completed(50.0).with_width(Some(15));
let text = render_text(&bar, 80);
assert_eq!(text.chars().count(), 15);
}
#[test]
fn test_max_width_used_when_no_fixed_width() {
let bar = ProgressBar::new().with_completed(50.0);
let text = render_text(&bar, 40);
assert_eq!(text.chars().count(), 40);
}
#[test]
fn test_fixed_width_capped_by_max_width() {
let bar = ProgressBar::new()
.with_completed(50.0)
.with_width(Some(100));
let text = render_text(&bar, 30);
assert_eq!(text.chars().count(), 30);
}
#[test]
fn test_measure_with_fixed_width() {
let bar = ProgressBar::new().with_width(Some(25));
let console = Console::new();
let opts = make_options(80);
let m = bar.measure(&console, &opts);
assert_eq!(m, Measurement::new(25, 25));
}
#[test]
fn test_measure_without_fixed_width() {
let bar = ProgressBar::new();
let console = Console::new();
let opts = make_options(80);
let m = bar.measure(&console, &opts);
assert_eq!(m, Measurement::new(4, 80));
}
#[test]
fn test_measure_max_width_varies() {
let bar = ProgressBar::new();
let console = Console::new();
let opts = make_options(120);
let m = bar.measure(&console, &opts);
assert_eq!(m, Measurement::new(4, 120));
}
#[test]
fn test_renderable_trait() {
let bar = ProgressBar::new().with_completed(50.0).with_width(Some(10));
let console = Console::builder()
.width(80)
.color_system("truecolor")
.build();
let opts = make_options(80);
let renderable: &dyn Renderable = &bar;
let segments = renderable.rich_console(&console, &opts);
assert!(!segments.is_empty());
}
#[test]
fn test_width_consistency() {
for pct in (0..=100).step_by(5) {
let bar = ProgressBar::new()
.with_completed(pct as f64)
.with_width(Some(20));
let text = render_text(&bar, 20);
assert_eq!(text.chars().count(), 20, "width mismatch at {}%", pct);
}
}
#[test]
fn test_width_consistency_odd_widths() {
for width in [1, 3, 7, 11, 13, 17, 19, 23] {
let bar = ProgressBar::new()
.with_completed(50.0)
.with_width(Some(width));
let text = render_text(&bar, width);
assert_eq!(
text.chars().count(),
width,
"width mismatch for width={}",
width
);
}
}
#[test]
fn test_clone() {
let bar = ProgressBar::new().with_completed(42.0).with_width(Some(30));
let cloned = bar.clone();
assert_eq!(cloned.total, bar.total);
assert_eq!(cloned.completed, bar.completed);
assert_eq!(cloned.width, bar.width);
assert_eq!(cloned.pulse, bar.pulse);
assert_eq!(cloned.style, bar.style);
}
#[test]
fn test_debug() {
let bar = ProgressBar::new().with_completed(50.0);
let debug = format!("{bar:?}");
assert!(debug.contains("ProgressBar"));
assert!(debug.contains("50"));
}
#[test]
fn test_zero_total() {
let bar = ProgressBar::new()
.with_total(Some(0.0))
.with_completed(0.0)
.with_width(Some(10));
let text = render_text(&bar, 10);
assert_eq!(text.chars().count(), 10);
}
#[test]
fn test_very_small_completed() {
let bar = ProgressBar::new()
.with_completed(0.001)
.with_width(Some(10));
let text = render_text(&bar, 10);
assert_eq!(text.chars().count(), 10);
}
#[test]
fn test_width_one() {
let bar = ProgressBar::new().with_completed(50.0).with_width(Some(1));
let text = render_text(&bar, 1);
assert_eq!(text.chars().count(), 1);
}
#[test]
fn test_pulse_with_width_one() {
let bar = ProgressBar::new()
.with_total(None)
.with_width(Some(1))
.with_animation_time(Some(0.0));
let segments = render_segments(&bar, 1);
assert_eq!(segments.len(), 1);
}
#[test]
fn test_display_trait() {
let bar = ProgressBar::new().with_completed(50.0);
let s = format!("{}", bar);
assert!(!s.is_empty());
}
#[test]
fn test_display_with_width() {
let bar = ProgressBar::new().with_completed(75.0);
let s = format!("{:40}", bar);
assert!(!s.is_empty());
}
}