use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
use bubbletea::{Cmd, Message, Model};
use harmonica::Spring;
use lipgloss::Style;
const FPS: u32 = 60;
const DEFAULT_WIDTH: usize = 40;
const DEFAULT_FREQUENCY: f64 = 18.0;
const DEFAULT_DAMPING: f64 = 1.0;
static NEXT_ID: AtomicU64 = AtomicU64::new(1);
fn next_id() -> u64 {
NEXT_ID.fetch_add(1, Ordering::Relaxed)
}
#[derive(Debug, Clone, Copy)]
pub struct FrameMsg {
pub id: u64,
tag: u64,
}
#[derive(Debug, Clone)]
pub struct Gradient {
pub color_a: String,
pub color_b: String,
pub scaled: bool,
}
impl Default for Gradient {
fn default() -> Self {
Self {
color_a: "#5A56E0".to_string(),
color_b: "#EE6FF8".to_string(),
scaled: false,
}
}
}
#[derive(Debug, Clone)]
pub struct Progress {
id: u64,
tag: u64,
pub width: usize,
pub full_char: char,
pub full_color: String,
pub empty_char: char,
pub empty_color: String,
pub show_percentage: bool,
pub percent_format: String,
pub percentage_style: Style,
spring: Spring,
percent_shown: f64,
target_percent: f64,
velocity: f64,
gradient: Option<Gradient>,
}
impl Default for Progress {
fn default() -> Self {
Self::new()
}
}
impl Progress {
#[must_use]
pub fn new() -> Self {
Self {
id: next_id(),
tag: 0,
width: DEFAULT_WIDTH,
full_char: 'â–ˆ',
full_color: "#7571F9".to_string(),
empty_char: 'â–‘',
empty_color: "#606060".to_string(),
show_percentage: true,
percent_format: " {:3.0}%".to_string(),
percentage_style: Style::new(),
spring: Spring::new(FPS as f64, DEFAULT_FREQUENCY, DEFAULT_DAMPING),
percent_shown: 0.0,
target_percent: 0.0,
velocity: 0.0,
gradient: None,
}
}
#[must_use]
pub fn with_gradient() -> Self {
let mut p = Self::new();
p.gradient = Some(Gradient::default());
p
}
#[must_use]
pub fn with_gradient_colors(color_a: &str, color_b: &str) -> Self {
let mut p = Self::new();
p.gradient = Some(Gradient {
color_a: color_a.to_string(),
color_b: color_b.to_string(),
scaled: false,
});
p
}
#[must_use]
pub fn with_scaled_gradient(color_a: &str, color_b: &str) -> Self {
let mut p = Self::new();
p.gradient = Some(Gradient {
color_a: color_a.to_string(),
color_b: color_b.to_string(),
scaled: true,
});
p
}
#[must_use]
pub fn width(mut self, width: usize) -> Self {
self.width = width;
self
}
#[must_use]
pub fn fill_chars(mut self, full: char, empty: char) -> Self {
self.full_char = full;
self.empty_char = empty;
self
}
#[must_use]
pub fn solid_fill(mut self, color: &str) -> Self {
self.full_color = color.to_string();
self.gradient = None;
self
}
#[must_use]
pub fn without_percentage(mut self) -> Self {
self.show_percentage = false;
self
}
pub fn set_spring_options(&mut self, frequency: f64, damping: f64) {
self.spring = Spring::new(FPS as f64, frequency, damping);
}
#[must_use]
pub fn id(&self) -> u64 {
self.id
}
#[must_use]
pub fn percent(&self) -> f64 {
self.target_percent
}
pub fn set_percent(&mut self, p: f64) -> Option<Cmd> {
self.target_percent = if p.is_finite() {
p.clamp(0.0, 1.0)
} else {
0.0
};
self.tag = self.tag.wrapping_add(1);
self.next_frame()
}
pub fn incr_percent(&mut self, v: f64) -> Option<Cmd> {
self.set_percent(self.percent() + v)
}
pub fn decr_percent(&mut self, v: f64) -> Option<Cmd> {
self.set_percent(self.percent() - v)
}
#[must_use]
pub fn is_animating(&self) -> bool {
let dist = (self.percent_shown - self.target_percent).abs();
!(dist < 0.001 && self.velocity.abs() < 0.01)
}
fn next_frame(&self) -> Option<Cmd> {
let id = self.id;
let tag = self.tag;
let delay = Duration::from_secs_f64(1.0 / f64::from(FPS));
Some(Cmd::new(move || {
std::thread::sleep(delay);
Message::new(FrameMsg { id, tag })
}))
}
pub fn update(&mut self, msg: Message) -> Option<Cmd> {
if let Some(frame) = msg.downcast_ref::<FrameMsg>() {
if frame.id != self.id || frame.tag != self.tag {
return None;
}
if !self.is_animating() {
return None;
}
let (new_pos, new_vel) =
self.spring
.update(self.percent_shown, self.velocity, self.target_percent);
self.percent_shown = new_pos;
self.velocity = new_vel;
return self.next_frame();
}
None
}
#[must_use]
pub fn view(&self) -> String {
self.view_as(self.percent_shown)
}
#[must_use]
pub fn view_as(&self, percent: f64) -> String {
let mut result = String::new();
let percent_view = self.percentage_view(percent);
let percent_width = percent_view.chars().count();
self.bar_view(&mut result, percent, percent_width);
result.push_str(&percent_view);
result
}
fn bar_view(&self, buf: &mut String, percent: f64, text_width: usize) {
use unicode_width::UnicodeWidthChar;
let full_width = self.full_char.width().unwrap_or(1).max(1);
let empty_width = self.empty_char.width().unwrap_or(1).max(1);
let available_width = self.width.saturating_sub(text_width);
let filled_target_width =
((available_width as f64 * percent).round() as usize).min(available_width);
let filled_count = filled_target_width / full_width;
let filled_visual_width = filled_count * full_width;
let empty_target_width = available_width.saturating_sub(filled_visual_width);
let empty_count = empty_target_width / empty_width;
if let Some(ref gradient) = self.gradient {
for i in 0..filled_count {
let p = if filled_count <= 1 {
0.5
} else if gradient.scaled {
i as f64 / (filled_count - 1) as f64
} else {
(i * full_width) as f64 / (available_width.saturating_sub(1)).max(1) as f64
};
let color = interpolate_color(&gradient.color_a, &gradient.color_b, p);
buf.push_str(&format!("\x1b[38;2;{}m{}\x1b[0m", color, self.full_char));
}
} else {
let colored_char =
format_colored_char(self.full_char, &self.full_color).repeat(filled_count);
buf.push_str(&colored_char);
}
let empty_colored = format_colored_char(self.empty_char, &self.empty_color);
for _ in 0..empty_count {
buf.push_str(&empty_colored);
}
let used = (filled_count * full_width) + (empty_count * empty_width);
let remaining = available_width.saturating_sub(used);
if remaining > 0 {
buf.push_str(&" ".repeat(remaining));
}
}
fn percentage_view(&self, percent: f64) -> String {
if !self.show_percentage {
return String::new();
}
let percent = percent.clamp(0.0, 1.0) * 100.0;
let formatted = format!("{:3.0}", percent);
if self.percent_format.contains("{:3.0}") {
self.percent_format.replace("{:3.0}", &formatted)
} else {
self.percent_format.replace("{}", &formatted)
}
}
}
fn format_colored_char(c: char, hex_color: &str) -> String {
if let Some(rgb) = parse_hex_color(hex_color) {
format!("\x1b[38;2;{};{};{}m{}\x1b[0m", rgb.0, rgb.1, rgb.2, c)
} else {
c.to_string()
}
}
fn parse_hex_color(hex: &str) -> Option<(u8, u8, u8)> {
let hex = hex.trim_start_matches('#');
if hex.len() != 6 {
return None;
}
let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
Some((r, g, b))
}
fn interpolate_color(color_a: &str, color_b: &str, t: f64) -> String {
let a = parse_hex_color(color_a).unwrap_or((0, 0, 0));
let b = parse_hex_color(color_b).unwrap_or((0, 0, 0));
let r = (a.0 as f64 + (b.0 as f64 - a.0 as f64) * t).round() as u8;
let g = (a.1 as f64 + (b.1 as f64 - a.1 as f64) * t).round() as u8;
let bl = (a.2 as f64 + (b.2 as f64 - a.2 as f64) * t).round() as u8;
format!("{};{};{}", r, g, bl)
}
impl Model for Progress {
fn init(&self) -> Option<Cmd> {
None
}
fn update(&mut self, msg: Message) -> Option<Cmd> {
Progress::update(self, msg)
}
fn view(&self) -> String {
Progress::view(self)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_progress_new() {
let p = Progress::new();
assert_eq!(p.width, DEFAULT_WIDTH);
assert!(p.show_percentage);
assert_eq!(p.percent(), 0.0);
}
#[test]
fn test_progress_unique_ids() {
let p1 = Progress::new();
let p2 = Progress::new();
assert_ne!(p1.id(), p2.id());
}
#[test]
fn test_progress_set_percent() {
let mut p = Progress::new();
p.set_percent(0.5);
assert!((p.percent() - 0.5).abs() < 0.001);
}
#[test]
fn test_progress_percent_clamp() {
let mut p = Progress::new();
p.set_percent(1.5);
assert!((p.percent() - 1.0).abs() < 0.001);
p.set_percent(-0.5);
assert!(p.percent().abs() < 0.001);
}
#[test]
fn test_progress_view_as() {
let p = Progress::new().width(20).without_percentage();
let view = p.view_as(0.5);
assert!(!view.is_empty());
}
#[test]
fn test_progress_builder() {
let p = Progress::new()
.width(50)
.fill_chars('#', '-')
.without_percentage();
assert_eq!(p.width, 50);
assert_eq!(p.full_char, '#');
assert_eq!(p.empty_char, '-');
assert!(!p.show_percentage);
}
#[test]
fn test_progress_with_gradient() {
let p = Progress::with_gradient();
assert!(p.gradient.is_some());
}
#[test]
fn test_parse_hex_color() {
assert_eq!(parse_hex_color("#FF0000"), Some((255, 0, 0)));
assert_eq!(parse_hex_color("#00FF00"), Some((0, 255, 0)));
assert_eq!(parse_hex_color("#0000FF"), Some((0, 0, 255)));
assert_eq!(parse_hex_color("FFFFFF"), Some((255, 255, 255)));
assert_eq!(parse_hex_color("invalid"), None);
}
#[test]
fn test_interpolate_color() {
let mid = interpolate_color("#000000", "#FFFFFF", 0.5);
assert!(mid.contains("127") || mid.contains("128"));
}
#[test]
fn test_progress_animation_state() {
let mut p = Progress::new();
p.percent_shown = 0.5;
p.target_percent = 0.5;
p.velocity = 0.0;
assert!(!p.is_animating());
p.target_percent = 0.8;
assert!(p.is_animating());
}
#[test]
fn test_progress_animation_negative_velocity() {
let mut p = Progress::new();
p.percent_shown = 0.5;
p.target_percent = 0.5;
p.velocity = -0.5;
assert!(
p.is_animating(),
"Should be animating with significant negative velocity"
);
p.velocity = -0.001;
assert!(
!p.is_animating(),
"Should not be animating with tiny negative velocity at target"
);
}
#[test]
fn test_model_init() {
let p = Progress::new();
let cmd = Model::init(&p);
assert!(cmd.is_none());
}
#[test]
fn test_model_view() {
let p = Progress::new();
let model_view = Model::view(&p);
let progress_view = Progress::view(&p);
assert_eq!(model_view, progress_view);
}
#[test]
fn test_model_update_handles_frame_msg() {
let mut p = Progress::new();
p.target_percent = 1.0;
p.percent_shown = 0.0;
p.velocity = 0.0;
let id = p.id();
let tag = p.tag;
let frame_msg = Message::new(FrameMsg { id, tag });
let cmd = Model::update(&mut p, frame_msg);
assert!(
cmd.is_some(),
"Model::update should return next frame command when animating"
);
assert!(p.percent_shown > 0.0, "percent_shown should have advanced");
}
#[test]
fn test_model_update_ignores_wrong_id() {
let mut p = Progress::new();
p.target_percent = 1.0;
p.percent_shown = 0.0;
let original_percent = p.percent_shown;
let frame_msg = Message::new(FrameMsg { id: 99999, tag: 0 });
let cmd = Model::update(&mut p, frame_msg);
assert!(
cmd.is_none(),
"Should ignore messages for other progress bars"
);
assert!(
(p.percent_shown - original_percent).abs() < 0.001,
"percent_shown should not change"
);
}
#[test]
fn test_model_update_ignores_wrong_tag() {
let mut p = Progress::new();
p.target_percent = 1.0;
p.percent_shown = 0.0;
p.tag = 5;
let id = p.id();
let original_percent = p.percent_shown;
let frame_msg = Message::new(FrameMsg { id, tag: 3 });
let cmd = Model::update(&mut p, frame_msg);
assert!(cmd.is_none(), "Should ignore messages with old tag");
assert!(
(p.percent_shown - original_percent).abs() < 0.001,
"percent_shown should not change"
);
}
#[test]
fn test_progress_satisfies_model_bounds() {
fn accepts_model<M: Model + Send + 'static>(_model: M) {}
let p = Progress::new();
accepts_model(p);
}
}