use bubbletea_rs::{tick as bubbletea_tick, Cmd, Model as BubbleTeaModel, Msg};
use lipgloss_extras::lipgloss::blending::blend_1d;
use lipgloss_extras::lipgloss::Color as LGColor;
use lipgloss_extras::prelude::*;
use std::sync::atomic::{AtomicI64, Ordering};
use std::time::Duration;
static LAST_ID: AtomicI64 = AtomicI64::new(0);
fn next_id() -> i64 {
LAST_ID.fetch_add(1, Ordering::SeqCst) + 1
}
const FPS: u32 = 60;
const DEFAULT_WIDTH: i32 = 40;
const DEFAULT_FREQUENCY: f64 = 18.0;
const DEFAULT_DAMPING: f64 = 1.0;
pub enum ProgressOption {
WithDefaultGradient,
WithGradient(String, String),
WithDefaultScaledGradient,
WithScaledGradient(String, String),
WithSolidFill(String),
WithFillCharacters(char, char),
WithoutPercentage,
WithWidth(i32),
WithSpringOptions(f64, f64),
}
impl ProgressOption {
fn apply(&self, m: &mut Model) {
match self {
ProgressOption::WithDefaultGradient => {
m.set_ramp("#5A56E0".to_string(), "#EE6FF8".to_string(), false);
}
ProgressOption::WithGradient(color_a, color_b) => {
m.set_ramp(color_a.clone(), color_b.clone(), false);
}
ProgressOption::WithDefaultScaledGradient => {
m.set_ramp("#5A56E0".to_string(), "#EE6FF8".to_string(), true);
}
ProgressOption::WithScaledGradient(color_a, color_b) => {
m.set_ramp(color_a.clone(), color_b.clone(), true);
}
ProgressOption::WithSolidFill(color) => {
m.full_color = color.clone();
m.use_ramp = false;
}
ProgressOption::WithFillCharacters(full, empty) => {
m.full = *full;
m.empty = *empty;
}
ProgressOption::WithoutPercentage => {
m.show_percentage = false;
}
ProgressOption::WithWidth(width) => {
m.width = *width;
}
ProgressOption::WithSpringOptions(frequency, damping) => {
m.set_spring_options(*frequency, *damping);
m.spring_customized = true;
}
}
}
}
pub fn with_default_gradient() -> ProgressOption {
ProgressOption::WithDefaultGradient
}
pub fn with_gradient(color_a: String, color_b: String) -> ProgressOption {
ProgressOption::WithGradient(color_a, color_b)
}
pub fn with_default_scaled_gradient() -> ProgressOption {
ProgressOption::WithDefaultScaledGradient
}
pub fn with_scaled_gradient(color_a: String, color_b: String) -> ProgressOption {
ProgressOption::WithScaledGradient(color_a, color_b)
}
pub fn with_solid_fill(color: String) -> ProgressOption {
ProgressOption::WithSolidFill(color)
}
pub fn with_fill_characters(full: char, empty: char) -> ProgressOption {
ProgressOption::WithFillCharacters(full, empty)
}
pub fn without_percentage() -> ProgressOption {
ProgressOption::WithoutPercentage
}
pub fn with_width(w: i32) -> ProgressOption {
ProgressOption::WithWidth(w)
}
pub fn with_spring_options(frequency: f64, damping: f64) -> ProgressOption {
ProgressOption::WithSpringOptions(frequency, damping)
}
#[derive(Debug, Clone)]
pub struct FrameMsg {
id: i64,
tag: i64,
}
#[derive(Debug, Clone)]
struct Spring {
frequency: f64,
damping: f64,
fps: f64,
}
impl Spring {
fn new(fps: f64, frequency: f64, damping: f64) -> Self {
Self {
frequency,
damping,
fps,
}
}
fn update(&self, position: f64, velocity: f64, target: f64) -> (f64, f64) {
let dt = 1.0 / self.fps;
let spring_force = -self.frequency * (position - target);
let damping_force = -self.damping * velocity;
let acceleration = spring_force + damping_force;
let new_velocity = velocity + acceleration * dt;
let new_position = position + new_velocity * dt;
(new_position, new_velocity)
}
}
#[derive(Debug, Clone)]
pub struct Model {
id: i64,
tag: i64,
pub width: i32,
pub full: char,
pub full_color: String,
pub empty: char,
pub empty_color: String,
pub show_percentage: bool,
pub percent_format: String,
pub percentage_style: Style,
spring: Spring,
spring_customized: bool,
percent_shown: f64, target_percent: f64, velocity: f64,
use_ramp: bool,
ramp_color_a: String, ramp_color_b: String,
scale_ramp: bool,
}
pub fn new(opts: &[ProgressOption]) -> Model {
let mut m = Model {
id: next_id(),
tag: 0,
width: DEFAULT_WIDTH,
full: '█',
full_color: "#7571F9".to_string(),
empty: '░',
empty_color: "#606060".to_string(),
show_percentage: true,
percent_format: " %3.0f%%".to_string(),
percentage_style: Style::new(),
spring: Spring::new(FPS as f64, DEFAULT_FREQUENCY, DEFAULT_DAMPING),
spring_customized: false,
percent_shown: 0.0,
target_percent: 0.0,
velocity: 0.0,
use_ramp: false,
ramp_color_a: String::new(),
ramp_color_b: String::new(),
scale_ramp: false,
};
for opt in opts {
opt.apply(&mut m);
}
if !m.spring_customized {
m.set_spring_options(DEFAULT_FREQUENCY, DEFAULT_DAMPING);
}
m
}
#[deprecated(since = "0.0.7", note = "use new instead")]
pub fn new_model(opts: &[ProgressOption]) -> Model {
new(opts)
}
impl Model {
pub fn set_spring_options(&mut self, frequency: f64, damping: f64) {
self.spring = Spring::new(FPS as f64, frequency, damping);
}
pub fn percent(&self) -> f64 {
self.target_percent
}
pub fn set_percent(&mut self, p: f64) -> Cmd {
self.target_percent = p.clamp(0.0, 1.0);
self.tag += 1;
self.next_frame()
}
pub fn incr_percent(&mut self, v: f64) -> Cmd {
self.set_percent(self.percent() + v)
}
pub fn decr_percent(&mut self, v: f64) -> Cmd {
self.set_percent(self.percent() - v)
}
pub fn update(&mut self, msg: Msg) -> std::option::Option<Cmd> {
if let Some(frame_msg) = msg.downcast_ref::<FrameMsg>() {
if frame_msg.id != self.id || frame_msg.tag != self.tag {
return std::option::Option::None;
}
if !self.is_animating() {
return std::option::Option::None;
}
let (new_percent, new_velocity) =
self.spring
.update(self.percent_shown, self.velocity, self.target_percent);
self.percent_shown = new_percent;
self.velocity = new_velocity;
return std::option::Option::Some(self.next_frame());
}
std::option::Option::None
}
pub fn view(&self) -> String {
self.view_as(self.percent_shown)
}
pub fn view_as(&self, percent: f64) -> String {
let percent_view = self.percentage_view(percent);
let percent_width = lipgloss::width_visible(&percent_view) as i32;
let bar_view = self.bar_view(percent, percent_width);
format!("{}{}", bar_view, percent_view)
}
pub fn is_animating(&self) -> bool {
let dist = (self.percent_shown - self.target_percent).abs();
!(dist < 0.001 && self.velocity < 0.01)
}
fn next_frame(&self) -> Cmd {
let id = self.id;
let tag = self.tag;
let duration = Duration::from_nanos(1_000_000_000 / FPS as u64);
bubbletea_tick(duration, move |_| Box::new(FrameMsg { id, tag }) as Msg)
}
fn bar_view(&self, percent: f64, text_width: i32) -> String {
let tw = std::cmp::max(0, self.width - text_width); let fw = std::cmp::max(0, std::cmp::min(tw, ((tw as f64) * percent).round() as i32));
let mut result = String::new();
if self.use_ramp {
let total_width_for_gradient = if self.scale_ramp { fw } else { tw };
let grad_len = std::cmp::max(2, total_width_for_gradient) as usize;
let start = LGColor::from(self.ramp_color_a.as_str());
let end = LGColor::from(self.ramp_color_b.as_str());
let gradient_colors = blend_1d(grad_len, vec![start, end]);
if fw == 1 {
let mid_idx = (grad_len as f64 * 0.5).floor() as usize;
let mid_idx = std::cmp::min(mid_idx, grad_len - 1);
let styled = Style::new()
.foreground(gradient_colors[mid_idx].clone())
.render(&self.full.to_string());
result.push_str(&styled);
} else {
for i in 0..fw as usize {
let idx = i; let color_idx = std::cmp::min(idx, grad_len - 1);
let styled = Style::new()
.foreground(gradient_colors[color_idx].clone())
.render(&self.full.to_string());
result.push_str(&styled);
}
}
} else {
let styled = Style::new()
.foreground(lipgloss::Color::from(self.full_color.as_str()))
.render(&self.full.to_string());
result.push_str(&styled.repeat(fw as usize));
}
let empty_styled = Style::new()
.foreground(lipgloss::Color::from(self.empty_color.as_str()))
.render(&self.empty.to_string());
let n = std::cmp::max(0, tw - fw);
result.push_str(&empty_styled.repeat(n as usize));
result
}
fn percentage_view(&self, percent: f64) -> String {
if !self.show_percentage {
return String::new();
}
let percent = percent.clamp(0.0, 1.0);
let percentage = format!(" {:3.0}%", percent * 100.0); self.percentage_style.render(&percentage)
}
fn set_ramp(&mut self, color_a: String, color_b: String, scaled: bool) {
self.use_ramp = true;
self.scale_ramp = scaled;
self.ramp_color_a = color_a;
self.ramp_color_b = color_b;
}
}
impl BubbleTeaModel for Model {
fn init() -> (Self, std::option::Option<Cmd>) {
let model = new(&[]);
(model, std::option::Option::None)
}
fn update(&mut self, msg: Msg) -> std::option::Option<Cmd> {
self.update(msg)
}
fn view(&self) -> String {
self.view()
}
}
impl Default for Model {
fn default() -> Self {
new(&[])
}
}
#[cfg(test)]
#[allow(deprecated)]
mod tests {
use super::*;
use crate::progress::{
new, new_model, with_default_gradient, with_fill_characters, with_gradient,
with_solid_fill, with_spring_options, with_width, without_percentage, FrameMsg,
};
#[test]
fn test_new_with_no_options() {
let progress = new(&[]);
assert_eq!(progress.width, DEFAULT_WIDTH);
assert_eq!(progress.full, '█');
assert_eq!(progress.empty, '░');
assert_eq!(progress.full_color, "#7571F9");
assert_eq!(progress.empty_color, "#606060");
assert!(progress.show_percentage);
assert_eq!(progress.percent_format, " %3.0f%%");
assert!(!progress.use_ramp);
assert_eq!(progress.percent(), 0.0);
}
#[test]
fn test_new_with_width() {
let progress = new(&[with_width(60)]);
assert_eq!(progress.width, 60);
}
#[test]
fn test_new_with_solid_fill() {
let progress = new(&[with_solid_fill("#ff0000".to_string())]);
assert_eq!(progress.full_color, "#ff0000");
assert!(!progress.use_ramp);
}
#[test]
fn test_new_with_fill_characters() {
let progress = new(&[with_fill_characters('▓', '▒')]);
assert_eq!(progress.full, '▓');
assert_eq!(progress.empty, '▒');
}
#[test]
fn test_new_without_percentage() {
let progress = new(&[without_percentage()]);
assert!(!progress.show_percentage);
}
#[test]
fn test_new_with_gradient() {
let progress = new(&[with_gradient("#ff0000".to_string(), "#0000ff".to_string())]);
assert!(progress.use_ramp);
assert_eq!(progress.ramp_color_a, "#ff0000");
assert_eq!(progress.ramp_color_b, "#0000ff");
assert!(!progress.scale_ramp);
}
#[test]
fn test_new_with_default_gradient() {
let progress = new(&[with_default_gradient()]);
assert!(progress.use_ramp);
assert_eq!(progress.ramp_color_a, "#5A56E0");
assert_eq!(progress.ramp_color_b, "#EE6FF8");
}
#[test]
fn test_new_with_spring_options() {
let progress = new(&[with_spring_options(20.0, 0.8)]);
assert!(progress.spring_customized);
assert_eq!(progress.spring.frequency, 20.0);
assert_eq!(progress.spring.damping, 0.8);
}
#[test]
fn test_new_with_multiple_options() {
let progress = new(&[
with_width(80),
with_solid_fill("#00ff00".to_string()),
without_percentage(),
]);
assert_eq!(progress.width, 80);
assert_eq!(progress.full_color, "#00ff00");
assert!(!progress.show_percentage);
assert!(!progress.use_ramp);
}
#[test]
fn test_deprecated_new_model() {
#[allow(deprecated)]
let progress = new_model(&[with_width(50)]);
assert_eq!(progress.width, 50);
}
#[test]
fn test_percent_method() {
let mut progress = new(&[]);
assert_eq!(progress.percent(), 0.0);
std::mem::drop(progress.set_percent(0.75));
assert_eq!(progress.percent(), 0.75);
}
#[test]
fn test_set_percent() {
let mut progress = new(&[]);
std::mem::drop(progress.set_percent(1.5)); assert_eq!(progress.percent(), 1.0);
std::mem::drop(progress.set_percent(-0.5)); assert_eq!(progress.percent(), 0.0);
std::mem::drop(progress.set_percent(0.5)); assert_eq!(progress.percent(), 0.5);
let original_tag = progress.tag;
std::mem::drop(progress.set_percent(0.6));
assert_eq!(progress.tag, original_tag + 1);
}
#[test]
fn test_incr_percent() {
let mut progress = new(&[]);
std::mem::drop(progress.set_percent(0.3));
std::mem::drop(progress.incr_percent(0.2));
assert_eq!(progress.percent(), 0.5);
std::mem::drop(progress.incr_percent(0.8));
assert_eq!(progress.percent(), 1.0);
}
#[test]
fn test_decr_percent() {
let mut progress = new(&[]);
std::mem::drop(progress.set_percent(0.7));
std::mem::drop(progress.decr_percent(0.2));
assert!((progress.percent() - 0.5).abs() < 1e-9);
std::mem::drop(progress.decr_percent(0.8));
assert_eq!(progress.percent(), 0.0);
}
#[test]
fn test_set_spring_options() {
let mut progress = new(&[]);
progress.set_spring_options(25.0, 1.5);
assert_eq!(progress.spring.frequency, 25.0);
assert_eq!(progress.spring.damping, 1.5);
assert_eq!(progress.spring.fps, FPS as f64);
}
#[test]
fn test_is_animating() {
let mut progress = new(&[]);
assert!(!progress.is_animating());
std::mem::drop(progress.set_percent(0.5));
assert!(progress.is_animating());
progress.percent_shown = 0.5;
progress.velocity = 0.0;
assert!(!progress.is_animating());
}
#[test]
fn test_update_with_frame_msg() {
let mut progress = new(&[]);
std::mem::drop(progress.set_percent(0.5));
let frame_msg = FrameMsg {
id: progress.id,
tag: progress.tag,
};
let result = progress.update(Box::new(frame_msg));
assert!(result.is_some()); }
#[test]
fn test_update_with_wrong_id() {
let mut progress = new(&[]);
let wrong_frame = FrameMsg {
id: progress.id + 999, tag: progress.tag,
};
let result = progress.update(Box::new(wrong_frame));
assert!(result.is_none()); }
#[test]
fn test_update_with_wrong_tag() {
let mut progress = new(&[]);
let wrong_frame = FrameMsg {
id: progress.id,
tag: progress.tag + 999, };
let result = progress.update(Box::new(wrong_frame));
assert!(result.is_none()); }
#[test]
fn test_view_basic() {
let progress = new(&[with_width(10)]);
let view = progress.view();
assert!(view.contains('░')); let empty_count = view.chars().filter(|&c| c == '░').count();
assert!(empty_count > 0);
}
#[test]
fn test_view_as() {
let progress = new(&[with_width(10)]);
let view_50 = progress.view_as(0.5);
let view_100 = progress.view_as(1.0);
let filled_50 = view_50.chars().filter(|&c| c == '█').count();
let filled_100 = view_100.chars().filter(|&c| c == '█').count();
assert!(filled_100 > filled_50);
}
#[test]
fn test_view_without_percentage() {
let progress = new(&[without_percentage(), with_width(10)]);
let view = progress.view_as(0.5);
assert!(!view.contains('%'));
}
#[test]
fn test_view_with_percentage() {
let progress = new(&[with_width(10)]); let view = progress.view_as(0.75);
assert!(view.contains('%'));
assert!(view.contains("75")); }
#[test]
fn test_spring_animation_physics() {
let spring = Spring::new(60.0, 10.0, 1.0);
let (new_pos, _new_vel) = spring.update(0.0, 0.0, 1.0);
assert!(new_pos > 0.0);
assert!(new_pos < 1.0); }
#[test]
fn test_bar_view_width_calculation() {
let progress = new(&[with_width(20), without_percentage()]);
let view_0 = progress.view_as(0.0); let view_50 = progress.view_as(0.5); let view_100 = progress.view_as(1.0);
assert_eq!(lipgloss::width_visible(&view_0), 20);
assert_eq!(lipgloss::width_visible(&view_50), 20);
assert_eq!(lipgloss::width_visible(&view_100), 20);
let bar_0 = progress.bar_view(0.0, 0);
let bar_100 = progress.bar_view(1.0, 0);
let bar_0_clean = lipgloss::strip_ansi(&bar_0);
let bar_100_clean = lipgloss::strip_ansi(&bar_100);
assert!(bar_0_clean.chars().all(|c| c == '░' || c.is_whitespace()));
assert!(bar_100_clean.chars().all(|c| c == '█' || c.is_whitespace()));
}
#[test]
fn test_gradient_vs_solid_fill() {
let solid = new(&[
with_solid_fill("#ff0000".to_string()),
with_width(10),
without_percentage(),
]);
let gradient = new(&[
with_gradient("#ff0000".to_string(), "#00ff00".to_string()),
with_width(10),
without_percentage(),
]);
assert!(!solid.use_ramp);
assert!(gradient.use_ramp);
let solid_view = solid.view_as(0.5);
let gradient_view = gradient.view_as(0.5);
assert!(!solid_view.is_empty());
assert!(!gradient_view.is_empty());
}
#[test]
fn test_gradient_first_last_colors_match() {
if std::env::var("NO_COLOR").is_ok() || std::env::var("NOCOLOR").is_ok() {
return;
}
const RESET: &str = "\x1b[0m";
let col_a = "#FF0000";
let col_b = "#00FF00";
for scale in [false, true] {
for &w in &[3, 5, 50] {
let mut opts = vec![without_percentage(), with_width(w)];
if scale {
opts.push(with_scaled_gradient(col_a.to_string(), col_b.to_string()));
} else {
opts.push(with_gradient(col_a.to_string(), col_b.to_string()));
}
let p = new(&opts);
let res = p.view_as(1.0);
let splitter = format!("{}{}", p.full, RESET);
let mut colors: Vec<&str> = res.split(&splitter).collect();
if !colors.is_empty() {
colors.pop();
}
let expected_first_full = lipgloss::Style::new()
.foreground(lipgloss::Color::from(col_a))
.render(&p.full.to_string());
let expected_last_full = lipgloss::Style::new()
.foreground(lipgloss::Color::from(col_b))
.render(&p.full.to_string());
let exp_first = expected_first_full
.split(&format!("{}{}", p.full, RESET))
.next()
.unwrap_or("");
let exp_last = expected_last_full
.split(&format!("{}{}", p.full, RESET))
.next()
.unwrap_or("");
assert!(colors.len() >= (w as usize).saturating_sub(0));
let first_color = colors.first().copied().unwrap_or("");
let last_color = colors.last().copied().unwrap_or("");
assert_eq!(
exp_first, first_color,
"first gradient color should match start"
);
assert_eq!(exp_last, last_color, "last gradient color should match end");
}
}
}
#[test]
fn test_unique_ids() {
let progress1 = new(&[]);
let progress2 = new(&[]);
assert_ne!(progress1.id, progress2.id);
}
#[test]
fn test_default_implementation() {
let progress = Model::default();
assert_eq!(progress.width, DEFAULT_WIDTH);
assert_eq!(progress.percent(), 0.0);
}
}