use std::time::Instant;
use rich_rs::{Console, ConsoleOptions, Renderable, Segment, Segments};
use crate::event::AnimationLevel;
use crate::renderables::Bar;
use crate::style::Color;
use super::{
Widget, WidgetStyles,
helpers::{adjust_line_length_no_bg, empty_classes, fixed_height_from_constraints},
};
use crate::compose::ComposeResult;
use crate::reactive::{ReactiveChange, ReactiveCtx, ReactiveFlags, ReactiveWidget};
#[cfg(test)]
fn lerp_color(a: Color, b: Color, t: f64) -> Color {
let t = t.clamp(0.0, 1.0) as f32;
let inv = 1.0 - t;
Color::rgba(
(a.r as f32 * inv + b.r as f32 * t).round() as u8,
(a.g as f32 * inv + b.g as f32 * t).round() as u8,
(a.b as f32 * inv + b.b as f32 * t).round() as u8,
(a.a as f32 * inv + b.a as f32 * t).round() as u8,
)
}
#[derive(Debug, Clone)]
struct Eta {
estimation_period: f64,
max_extrapolate: f64,
samples: Vec<(f64, f64)>,
add_count: u64,
}
impl Eta {
fn new() -> Self {
Self {
estimation_period: 60.0,
max_extrapolate: 30.0,
samples: vec![(0.0, 0.0)],
add_count: 0,
}
}
fn reset(&mut self) {
self.samples.clear();
}
fn add_sample(&mut self, time: f64, progress: f64) {
if let Some(&(_, last_p)) = self.samples.last()
&& last_p > progress
{
self.reset();
}
self.samples.push((time, progress));
self.add_count += 1;
if self.add_count.is_multiple_of(100) {
self.prune();
}
}
fn prune(&mut self) {
if self.samples.len() <= 10 {
return;
}
let prune_time = self.samples.last().map(|s| s.0).unwrap_or(0.0) - self.estimation_period;
let index = self.samples.partition_point(|&(t, _)| t < prune_time);
if index > 0 {
self.samples.drain(..index);
}
}
fn progress_at(&self, time: f64) -> (f64, f64) {
if self.samples.is_empty() {
return (0.0, 0.0);
}
let index = self.samples.partition_point(|&(t, _)| t < time);
if index >= self.samples.len() {
return *self.samples.last().unwrap();
}
if index == 0 {
return self.samples[0];
}
let (t1, p1) = self.samples[index - 1];
let (t2, p2) = self.samples[index];
let factor = (time - t1) / (t2 - t1);
(time, p1 + (p2 - p1) * factor)
}
fn speed(&self) -> Option<f64> {
if self.samples.len() < 2 {
return None;
}
let &(recent_time, progress2) = self.samples.last().unwrap();
let (start_time, progress1) = self.progress_at(recent_time - self.estimation_period);
let time_delta = recent_time - start_time;
if time_delta < 1.0 {
return None;
}
let distance = progress2 - progress1;
let speed = if time_delta > 0.0 {
distance / time_delta
} else {
0.0
};
if speed <= 0.0 { None } else { Some(speed) }
}
fn get_eta(&self, time: f64) -> Option<u64> {
let speed = self.speed()?;
let &(recent_time, recent_progress) = self.samples.last()?;
let remaining = 1.0 - recent_progress;
if remaining <= 0.0 {
return Some(0);
}
let time_since_sample = (time - recent_time).min(self.max_extrapolate);
let extrapolated = speed * time_since_sample;
let eta = ((remaining - extrapolated) / speed).max(1.0);
Some(eta.ceil() as u64)
}
}
fn format_percentage(pct: Option<f64>) -> String {
match pct {
Some(p) => format!("{:>3}%", (p * 100.0).round() as u64),
None => "--%".to_string(),
}
}
fn format_eta(eta_secs: Option<u64>) -> String {
match eta_secs {
None => "--:--:--".to_string(),
Some(0) => "00:00:00".to_string(),
Some(secs) => {
let s = secs % 60;
let m = (secs / 60) % 60;
let h = secs / 3600;
if h > 999999 {
"+999999h".to_string()
} else if h > 99 {
format!("{}h", h)
} else {
format!("{:02}:{:02}:{:02}", h, m, s)
}
}
}
}
#[derive(Debug, Clone)]
pub struct ProgressBar {
total: Option<f64>,
progress: f64,
show_bar: bool,
show_percentage: bool,
show_eta: bool,
animation_level: AnimationLevel,
gradient: Option<(Color, Color)>,
eta: Eta,
start_instant: Instant,
classes: Vec<String>,
styles: WidgetStyles,
}
impl ProgressBar {
pub fn new(total: Option<f64>) -> Self {
Self {
total: total.map(|t| t.max(0.0)),
progress: 0.0,
show_bar: true,
show_percentage: true,
show_eta: true,
animation_level: AnimationLevel::Full,
gradient: None,
eta: Eta::new(),
start_instant: Instant::now(),
classes: vec!["progress-bar".to_string()],
styles: WidgetStyles::default(),
}
}
pub fn progress(&self) -> f64 {
self.progress
}
pub fn total(&self) -> Option<f64> {
self.total
}
pub fn percentage(&self) -> Option<f64> {
match self.total {
Some(total) if total > 0.0 => Some((self.progress / total).clamp(0.0, 1.0)),
Some(_) => Some(1.0), None => None,
}
}
pub fn advance(&mut self, amount: f64) {
self.progress += amount;
self.record_eta_sample();
}
pub fn set_total(&mut self, total: Option<f64>, ctx: &mut ReactiveCtx) {
let new_total = total.map(|t| t.max(0.0));
if self.total != new_total {
let old = self.total;
self.total = new_total;
ctx.record_change(
"total",
ReactiveFlags::reactive(),
Box::new(old),
Box::new(self.total),
);
}
}
pub fn set_progress(&mut self, progress: f64, ctx: &mut ReactiveCtx) {
if self.progress != progress {
let old = self.progress;
self.progress = progress;
ctx.record_change(
"progress",
ReactiveFlags::reactive(),
Box::new(old),
Box::new(self.progress),
);
}
}
pub fn set_show_bar(&mut self, show: bool, ctx: &mut ReactiveCtx) {
if self.show_bar != show {
let old = self.show_bar;
self.show_bar = show;
ctx.record_change(
"show_bar",
ReactiveFlags::reactive_layout(),
Box::new(old),
Box::new(show),
);
}
}
pub fn set_show_percentage(&mut self, show: bool, ctx: &mut ReactiveCtx) {
if self.show_percentage != show {
let old = self.show_percentage;
self.show_percentage = show;
ctx.record_change(
"show_percentage",
ReactiveFlags::reactive_layout(),
Box::new(old),
Box::new(show),
);
}
}
pub fn set_show_eta(&mut self, show: bool, ctx: &mut ReactiveCtx) {
if self.show_eta != show {
let old = self.show_eta;
self.show_eta = show;
ctx.record_change(
"show_eta",
ReactiveFlags::reactive_layout(),
Box::new(old),
Box::new(show),
);
}
}
pub fn show_bar(&self) -> bool {
self.show_bar
}
pub fn show_percentage(&self) -> bool {
self.show_percentage
}
pub fn show_eta(&self) -> bool {
self.show_eta
}
fn watch_total(&mut self, _old: &Option<f64>, _new: &Option<f64>, _ctx: &mut ReactiveCtx) {
self.eta.reset();
}
fn watch_progress(&mut self, _old: &f64, _new: &f64, _ctx: &mut ReactiveCtx) {
self.record_eta_sample();
}
pub fn update(
&mut self,
total: Option<Option<f64>>,
progress: Option<f64>,
advance: Option<f64>,
) {
if let Some(t) = total {
let new_total = t.map(|v| v.max(0.0));
if new_total != self.total {
self.eta.reset();
}
self.total = new_total;
}
if let Some(p) = progress {
self.progress = p;
self.record_eta_sample();
}
if let Some(a) = advance {
self.progress += a;
self.record_eta_sample();
}
}
pub fn animation_level(&self) -> AnimationLevel {
self.animation_level
}
pub fn set_animation_level(&mut self, level: AnimationLevel) {
self.animation_level = level;
}
pub fn gradient(&self) -> Option<(Color, Color)> {
self.gradient
}
pub fn set_gradient(&mut self, gradient: Option<(Color, Color)>) {
self.gradient = gradient;
}
pub fn with_gradient(mut self, start: Color, end: Color) -> Self {
self.gradient = Some((start, end));
self
}
pub fn eta_seconds(&self) -> Option<u64> {
self.total?;
let now = self.elapsed_secs();
self.eta.get_eta(now)
}
fn elapsed_secs(&self) -> f64 {
self.start_instant.elapsed().as_secs_f64()
}
fn record_eta_sample(&mut self) {
if let Some(total) = self.total
&& total > 0.0
{
let time = self.elapsed_secs();
let ratio = (self.progress / total).clamp(0.0, 1.0);
self.eta.add_sample(time, ratio);
}
}
fn render_determinate(&self, width: usize) -> (String, &str) {
let pct = self.percentage().unwrap_or(0.0);
let component = if pct >= 1.0 {
"bar--complete"
} else {
"bar--bar"
};
let style = crate::css::resolve_component_style(self, &[component])
.to_rich()
.unwrap_or_else(rich_rs::Style::new);
let filled = ((pct * width as f64).round() as usize).min(width);
let text: String = Bar::new((0.0, filled as f32), style, style)
.chars('█', ' ')
.half_chars(' ', ' ')
.width(width)
.render_for_width(width)
.iter()
.map(|seg| seg.text.as_ref())
.collect();
(text, component)
}
fn render_determinate_gradient(
&self,
width: usize,
start: Color,
end: Color,
) -> (Vec<Segment>, &str) {
let pct = self.percentage().unwrap_or(0.0);
let component = if pct >= 1.0 {
"bar--complete"
} else {
"bar--bar"
};
let style = crate::css::resolve_component_style(self, &[component])
.to_rich()
.unwrap_or_else(rich_rs::Style::new);
let filled = ((pct * width as f64).round() as usize).min(width);
let segments: Vec<Segment> = Bar::new((0.0, filled as f32), style, style)
.chars('█', ' ')
.half_chars(' ', ' ')
.width(width)
.gradient(start, end)
.render_for_width(width)
.into_iter()
.collect();
(segments, component)
}
fn render_indeterminate(&self, width: usize) -> (String, &str) {
let component = "bar--indeterminate";
if width == 0 {
return (String::new(), component);
}
let mut start;
let end;
let highlighted_bar_width = (0.25 * width as f32).max(1.0);
let total_imaginary_width = width as f32 + highlighted_bar_width;
if self.animation_level == AnimationLevel::None {
start = 0.0;
end = width as f32;
} else {
let speed = 30.0_f32;
start = if total_imaginary_width > 0.0 {
(speed * self.elapsed_secs() as f32) % (2.0 * total_imaginary_width)
} else {
0.0
};
if start > total_imaginary_width {
start = 2.0 * total_imaginary_width - start;
}
start -= highlighted_bar_width;
end = start + highlighted_bar_width;
}
let style = crate::css::resolve_component_style(self, &[component])
.to_rich()
.unwrap_or_else(rich_rs::Style::new);
let range = (start.max(0.0), end.min(width as f32));
let text: String = Bar::new(range, style, style)
.chars('█', ' ')
.half_chars(' ', ' ')
.width(width)
.render_for_width(width)
.iter()
.map(|seg| seg.text.as_ref())
.collect();
(text, component)
}
}
impl Widget for ProgressBar {
fn compose(&self) -> ComposeResult {
Vec::new()
}
fn take_composed_children(&mut self) -> Vec<Box<dyn Widget>> {
Vec::new()
}
fn focusable(&self) -> bool {
false
}
fn is_active(&self) -> bool {
self.total.is_none() && self.animation_level != AnimationLevel::None
}
fn on_tick(&mut self, tick: u64) {
let _ = tick;
}
fn render(&self, _console: &Console, options: &ConsoleOptions) -> Segments {
let total_width = options.size.0.max(1);
let mut suffix = String::new();
if self.show_percentage {
suffix.push_str(&format_percentage(self.percentage()));
}
if self.show_eta {
if !suffix.is_empty() {
suffix.push(' ');
}
let eta_secs = if self.total.is_some() {
let now = self.elapsed_secs();
self.eta.get_eta(now)
} else {
None
};
suffix.push_str(&format_eta(eta_secs));
}
let suffix_width = if suffix.is_empty() {
0
} else {
suffix.len() + 1
};
let bar_width = if self.show_bar {
total_width.saturating_sub(suffix_width)
} else {
0
};
let mut out = Segments::new();
if self.show_bar && bar_width > 0 {
if let Some((grad_start, grad_end)) = self.gradient {
if self.total.is_some() {
let (segments, _component) =
self.render_determinate_gradient(bar_width, grad_start, grad_end);
out.extend(segments);
} else {
let (text, component) = self.render_indeterminate(bar_width);
let style = crate::css::resolve_component_style(self, &[component])
.to_rich()
.unwrap_or_else(rich_rs::Style::new);
let line = adjust_line_length_no_bg(&[Segment::styled(text, style)], bar_width);
out.extend(line);
}
} else {
let (text, component) = if self.total.is_some() {
self.render_determinate(bar_width)
} else {
self.render_indeterminate(bar_width)
};
let style = crate::css::resolve_component_style(self, &[component])
.to_rich()
.unwrap_or_else(rich_rs::Style::new);
let line = adjust_line_length_no_bg(&[Segment::styled(text, style)], bar_width);
out.extend(line);
}
}
if !suffix.is_empty() {
let has_bar = self.show_bar && bar_width > 0;
if has_bar {
out.push(Segment::new(" "));
}
let sep = usize::from(has_bar);
let remaining = total_width.saturating_sub(bar_width + sep);
let padded = if suffix.len() < remaining {
format!("{:>width$}", suffix, width = remaining)
} else {
suffix[..remaining.min(suffix.len())].to_string()
};
out.push(Segment::new(padded));
}
out
}
fn layout_height(&self) -> Option<usize> {
fixed_height_from_constraints(self.layout_constraints()).or(Some(1))
}
fn content_width(&self) -> Option<usize> {
let mut width = if self.show_bar { 32 } else { 0 };
if self.show_percentage {
width += if width > 0 { 5 } else { 4 };
}
if self.show_eta {
width += if width > 0 { 9 } else { 8 };
}
Some(width.max(1))
}
fn style_classes(&self) -> &[String] {
if self.classes.is_empty() {
empty_classes()
} else {
&self.classes
}
}
fn style_type(&self) -> &'static str {
"ProgressBar"
}
fn styles(&self) -> Option<&WidgetStyles> {
Some(&self.styles)
}
fn styles_mut(&mut self) -> Option<&mut WidgetStyles> {
Some(&mut self.styles)
}
}
impl Renderable for ProgressBar {
fn render(&self, console: &Console, options: &ConsoleOptions) -> Segments {
Widget::render(self, console, options)
}
}
impl ReactiveWidget for ProgressBar {
fn reactive_dispatch(&mut self, changes: &[ReactiveChange], ctx: &mut ReactiveCtx) {
for change in changes {
match change.field_name {
"total" => {
if let (Some(old), Some(new)) = (
change.old_value.downcast_ref::<Option<f64>>(),
change.new_value.downcast_ref::<Option<f64>>(),
) {
self.watch_total(old, new, ctx);
}
}
"progress" => {
if let (Some(old), Some(new)) = (
change.old_value.downcast_ref::<f64>(),
change.new_value.downcast_ref::<f64>(),
) {
self.watch_progress(old, new, ctx);
}
}
_ => {}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::node_id::NodeId;
use crate::reactive::ReactiveCtx;
use slotmap::SlotMap;
fn make_node_id() -> NodeId {
let mut sm: SlotMap<NodeId, ()> = SlotMap::new();
sm.insert(())
}
#[test]
fn progress_bar_percentage_none_when_indeterminate() {
let bar = ProgressBar::new(None);
assert!(bar.percentage().is_none());
}
#[test]
fn progress_bar_percentage_zero_total() {
let bar = ProgressBar::new(Some(0.0));
assert_eq!(bar.percentage(), Some(1.0));
}
#[test]
fn progress_bar_percentage_half() {
let mut bar = ProgressBar::new(Some(100.0));
bar.advance(50.0);
assert_eq!(bar.percentage(), Some(0.5));
}
#[test]
fn progress_bar_percentage_clamped() {
let mut bar = ProgressBar::new(Some(100.0));
bar.advance(200.0);
assert_eq!(bar.percentage(), Some(1.0));
}
#[test]
fn progress_bar_advance() {
let mut bar = ProgressBar::new(Some(10.0));
bar.advance(3.0);
bar.advance(2.0);
assert_eq!(bar.progress(), 5.0);
}
#[test]
fn progress_bar_update_batch() {
let mut bar = ProgressBar::new(None);
bar.update(Some(Some(50.0)), Some(10.0), Some(5.0));
assert_eq!(bar.total(), Some(50.0));
assert_eq!(bar.progress(), 15.0);
}
#[test]
fn progress_bar_not_focusable() {
let bar = ProgressBar::new(Some(100.0));
assert!(!bar.focusable());
}
#[test]
fn progress_bar_determinate_render_text() {
let mut bar = ProgressBar::new(Some(100.0));
bar.advance(50.0);
let (text, component) = bar.render_determinate(10);
assert_eq!(text, "█████ ");
assert_eq!(component, "bar--bar");
}
#[test]
fn progress_bar_complete_component_class() {
let mut bar = ProgressBar::new(Some(100.0));
bar.advance(100.0);
let (_, component) = bar.render_determinate(10);
assert_eq!(component, "bar--complete");
}
#[test]
fn progress_bar_indeterminate_render_bounces() {
let bar = ProgressBar::new(None);
let (text0, component) = bar.render_indeterminate(20);
assert_eq!(component, "bar--indeterminate");
assert_eq!(text0.chars().count(), 20);
}
#[test]
fn progress_bar_negative_total_clamped() {
let bar = ProgressBar::new(Some(-5.0));
assert_eq!(bar.total(), Some(0.0));
assert_eq!(bar.percentage(), Some(1.0));
}
#[test]
fn show_toggles_default_true() {
let bar = ProgressBar::new(Some(100.0));
assert!(bar.show_bar());
assert!(bar.show_percentage());
assert!(bar.show_eta());
}
#[test]
fn show_toggles_setters() {
let mut bar = ProgressBar::new(Some(100.0));
let mut ctx = ReactiveCtx::new(make_node_id());
bar.set_show_bar(false, &mut ctx);
bar.set_show_percentage(false, &mut ctx);
bar.set_show_eta(false, &mut ctx);
assert!(!bar.show_bar());
assert!(!bar.show_percentage());
assert!(!bar.show_eta());
}
#[test]
fn animation_level_default_full() {
let bar = ProgressBar::new(None);
assert_eq!(bar.animation_level(), AnimationLevel::Full);
}
#[test]
fn animation_level_none_static_indeterminate() {
let mut bar = ProgressBar::new(None);
bar.set_animation_level(AnimationLevel::None);
let (text, component) = bar.render_indeterminate(10);
assert_eq!(text, "██████████");
assert_eq!(component, "bar--indeterminate");
}
#[test]
fn animation_level_none_not_active() {
let mut bar = ProgressBar::new(None);
bar.set_animation_level(AnimationLevel::None);
assert!(!bar.is_active());
}
#[test]
fn animation_level_full_indeterminate_is_active() {
let bar = ProgressBar::new(None);
assert!(bar.is_active());
}
#[test]
fn format_percentage_display() {
assert_eq!(format_percentage(Some(0.0)), " 0%");
assert_eq!(format_percentage(Some(0.5)), " 50%");
assert_eq!(format_percentage(Some(1.0)), "100%");
assert_eq!(format_percentage(None), "--%");
}
#[test]
fn format_eta_display() {
assert_eq!(format_eta(None), "--:--:--");
assert_eq!(format_eta(Some(0)), "00:00:00");
assert_eq!(format_eta(Some(61)), "00:01:01");
assert_eq!(format_eta(Some(3661)), "01:01:01");
assert_eq!(format_eta(Some(360000)), "100h");
assert_eq!(format_eta(Some(u64::MAX)), "+999999h");
}
#[test]
fn eta_no_estimate_without_samples() {
let bar = ProgressBar::new(Some(100.0));
assert!(bar.eta_seconds().is_none());
}
#[test]
fn eta_basic_estimation() {
let mut eta = Eta::new();
eta.add_sample(0.0, 0.0);
eta.add_sample(10.0, 0.5);
let result = eta.get_eta(10.0);
assert_eq!(result, Some(10));
}
#[test]
fn eta_complete_returns_zero() {
let mut eta = Eta::new();
eta.add_sample(0.0, 0.0);
eta.add_sample(10.0, 1.0);
let result = eta.get_eta(10.0);
assert_eq!(result, Some(0));
}
#[test]
fn eta_reset_on_backwards_progress() {
let mut eta = Eta::new();
eta.add_sample(0.0, 0.0);
eta.add_sample(5.0, 0.5);
eta.add_sample(6.0, 0.1);
assert!(eta.speed().is_none());
}
#[test]
fn eta_prunes_old_samples() {
let mut eta = Eta::new();
for i in 0..250 {
let t = i as f64;
let p = (i as f64 / 250.0).min(1.0);
eta.add_sample(t, p);
}
assert!(eta.samples.len() < 250);
}
#[test]
fn content_width_varies_with_toggles() {
let mut bar = ProgressBar::new(Some(100.0));
let mut ctx = ReactiveCtx::new(make_node_id());
assert_eq!(bar.content_width(), Some(46));
bar.set_show_percentage(false, &mut ctx);
assert_eq!(bar.content_width(), Some(41));
bar.set_show_eta(false, &mut ctx);
assert_eq!(bar.content_width(), Some(32));
bar.set_show_bar(false, &mut ctx);
assert_eq!(bar.content_width(), Some(1));
}
#[test]
fn set_total_resets_eta() {
let mut bar = ProgressBar::new(Some(100.0));
let mut ctx = ReactiveCtx::new(make_node_id());
bar.advance(50.0);
bar.set_total(Some(200.0), &mut ctx);
assert_eq!(bar.total(), Some(200.0));
assert!(bar.eta_seconds().is_none());
}
#[test]
fn update_resets_eta_on_total_change() {
let mut bar = ProgressBar::new(Some(100.0));
bar.advance(50.0);
bar.update(Some(Some(200.0)), None, None);
assert_eq!(bar.total(), Some(200.0));
}
#[test]
fn tiny_width_suffix_only_no_underfill() {
let mut bar = ProgressBar::new(Some(100.0));
let mut ctx = ReactiveCtx::new(make_node_id());
bar.set_show_bar(true, &mut ctx);
bar.set_show_percentage(true, &mut ctx);
bar.set_show_eta(false, &mut ctx);
let console = Console::new();
let mut opts = console.options().clone();
opts.size.0 = 4;
bar.advance(50.0);
let segs = Widget::render(&bar, &console, &opts);
let total_chars: usize = segs.iter().map(|s| s.text.chars().count()).sum();
assert_eq!(total_chars, 4);
}
#[test]
fn gradient_default_none() {
let bar = ProgressBar::new(Some(100.0));
assert!(bar.gradient().is_none());
}
#[test]
fn gradient_builder() {
let start = Color::rgb(255, 0, 0);
let end = Color::rgb(0, 0, 255);
let bar = ProgressBar::new(Some(100.0)).with_gradient(start, end);
assert_eq!(bar.gradient(), Some((start, end)));
}
#[test]
fn gradient_setter() {
let mut bar = ProgressBar::new(Some(100.0));
let start = Color::rgb(255, 0, 0);
let end = Color::rgb(0, 255, 0);
bar.set_gradient(Some((start, end)));
assert_eq!(bar.gradient(), Some((start, end)));
bar.set_gradient(None);
assert!(bar.gradient().is_none());
}
#[test]
fn gradient_interpolation_start_middle_end() {
let start = Color::rgb(0, 0, 0);
let end = Color::rgb(100, 200, 50);
let c0 = lerp_color(start, end, 0.0);
assert_eq!(c0.r, 0);
assert_eq!(c0.g, 0);
assert_eq!(c0.b, 0);
let c_mid = lerp_color(start, end, 0.5);
assert_eq!(c_mid.r, 50);
assert_eq!(c_mid.g, 100);
assert_eq!(c_mid.b, 25);
let c1 = lerp_color(start, end, 1.0);
assert_eq!(c1.r, 100);
assert_eq!(c1.g, 200);
assert_eq!(c1.b, 50);
}
#[test]
fn gradient_interpolation_clamped() {
let start = Color::rgb(100, 100, 100);
let end = Color::rgb(200, 200, 200);
let c = lerp_color(start, end, -1.0);
assert_eq!(c.r, 100);
let c = lerp_color(start, end, 2.0);
assert_eq!(c.r, 200);
}
#[test]
fn gradient_determinate_produces_correct_segments() {
let start = Color::rgb(255, 0, 0);
let end = Color::rgb(0, 0, 255);
let mut bar = ProgressBar::new(Some(100.0)).with_gradient(start, end);
bar.advance(100.0);
let (segments, component) = bar.render_determinate_gradient(5, start, end);
assert_eq!(component, "bar--complete");
assert_eq!(segments.len(), 5);
for seg in &segments {
assert_eq!(seg.text, "█");
}
}
#[test]
fn gradient_determinate_partial_fill() {
let start = Color::rgb(255, 0, 0);
let end = Color::rgb(0, 0, 255);
let mut bar = ProgressBar::new(Some(100.0)).with_gradient(start, end);
bar.advance(50.0);
let (segments, component) = bar.render_determinate_gradient(10, start, end);
assert_eq!(component, "bar--bar");
let text: String = segments.iter().map(|seg| seg.text.as_ref()).collect();
assert_eq!(text, "█████ ");
}
#[test]
fn compose_returns_empty() {
let bar = ProgressBar::new(Some(100.0));
let result = bar.compose();
assert!(result.is_empty());
}
#[test]
fn take_composed_children_returns_empty() {
let mut bar = ProgressBar::new(Some(100.0));
let children = bar.take_composed_children();
assert!(children.is_empty());
}
#[test]
fn compose_returns_empty_indeterminate() {
let bar = ProgressBar::new(None);
assert!(bar.compose().is_empty());
}
#[test]
fn no_gradient_still_works_normally() {
let mut bar = ProgressBar::new(Some(100.0));
bar.advance(50.0);
assert!(bar.gradient().is_none());
let (text, component) = bar.render_determinate(10);
assert_eq!(text, "█████ ");
assert_eq!(component, "bar--bar");
}
}