1use std::sync::atomic::{AtomicU64, Ordering};
19use std::time::Duration;
20
21use bubbletea::{Cmd, Message, Model};
22use harmonica::Spring;
23use lipgloss::Style;
24
25const FPS: u32 = 60;
26const DEFAULT_WIDTH: usize = 40;
27const DEFAULT_FREQUENCY: f64 = 18.0;
28const DEFAULT_DAMPING: f64 = 1.0;
29
30static NEXT_ID: AtomicU64 = AtomicU64::new(1);
32
33fn next_id() -> u64 {
34 NEXT_ID.fetch_add(1, Ordering::Relaxed)
35}
36
37#[derive(Debug, Clone, Copy)]
39pub struct FrameMsg {
40 pub id: u64,
42 tag: u64,
44}
45
46#[derive(Debug, Clone)]
48pub struct Gradient {
49 pub color_a: String,
51 pub color_b: String,
53 pub scaled: bool,
55}
56
57impl Default for Gradient {
58 fn default() -> Self {
59 Self {
60 color_a: "#5A56E0".to_string(),
61 color_b: "#EE6FF8".to_string(),
62 scaled: false,
63 }
64 }
65}
66
67#[derive(Debug, Clone)]
69pub struct Progress {
70 id: u64,
72 tag: u64,
74 pub width: usize,
76 pub full_char: char,
78 pub full_color: String,
80 pub empty_char: char,
82 pub empty_color: String,
84 pub show_percentage: bool,
86 pub percent_format: String,
88 pub percentage_style: Style,
90 spring: Spring,
92 percent_shown: f64,
94 target_percent: f64,
96 velocity: f64,
98 gradient: Option<Gradient>,
100}
101
102impl Default for Progress {
103 fn default() -> Self {
104 Self::new()
105 }
106}
107
108impl Progress {
109 #[must_use]
111 pub fn new() -> Self {
112 Self {
113 id: next_id(),
114 tag: 0,
115 width: DEFAULT_WIDTH,
116 full_char: '█',
117 full_color: "#7571F9".to_string(),
118 empty_char: '░',
119 empty_color: "#606060".to_string(),
120 show_percentage: true,
121 percent_format: " {:3.0}%".to_string(),
122 percentage_style: Style::new(),
123 spring: Spring::new(FPS as f64, DEFAULT_FREQUENCY, DEFAULT_DAMPING),
124 percent_shown: 0.0,
125 target_percent: 0.0,
126 velocity: 0.0,
127 gradient: None,
128 }
129 }
130
131 #[must_use]
133 pub fn with_gradient() -> Self {
134 let mut p = Self::new();
135 p.gradient = Some(Gradient::default());
136 p
137 }
138
139 #[must_use]
141 pub fn with_gradient_colors(color_a: &str, color_b: &str) -> Self {
142 let mut p = Self::new();
143 p.gradient = Some(Gradient {
144 color_a: color_a.to_string(),
145 color_b: color_b.to_string(),
146 scaled: false,
147 });
148 p
149 }
150
151 #[must_use]
153 pub fn with_scaled_gradient(color_a: &str, color_b: &str) -> Self {
154 let mut p = Self::new();
155 p.gradient = Some(Gradient {
156 color_a: color_a.to_string(),
157 color_b: color_b.to_string(),
158 scaled: true,
159 });
160 p
161 }
162
163 #[must_use]
165 pub fn width(mut self, width: usize) -> Self {
166 self.width = width;
167 self
168 }
169
170 #[must_use]
172 pub fn fill_chars(mut self, full: char, empty: char) -> Self {
173 self.full_char = full;
174 self.empty_char = empty;
175 self
176 }
177
178 #[must_use]
180 pub fn solid_fill(mut self, color: &str) -> Self {
181 self.full_color = color.to_string();
182 self.gradient = None;
183 self
184 }
185
186 #[must_use]
188 pub fn without_percentage(mut self) -> Self {
189 self.show_percentage = false;
190 self
191 }
192
193 pub fn set_spring_options(&mut self, frequency: f64, damping: f64) {
195 self.spring = Spring::new(FPS as f64, frequency, damping);
196 }
197
198 #[must_use]
200 pub fn id(&self) -> u64 {
201 self.id
202 }
203
204 #[must_use]
206 pub fn percent(&self) -> f64 {
207 self.target_percent
208 }
209
210 pub fn set_percent(&mut self, p: f64) -> Option<Cmd> {
212 self.target_percent = if p.is_finite() {
213 p.clamp(0.0, 1.0)
214 } else {
215 0.0
216 };
217 self.tag = self.tag.wrapping_add(1);
218 self.next_frame()
219 }
220
221 pub fn incr_percent(&mut self, v: f64) -> Option<Cmd> {
223 self.set_percent(self.percent() + v)
224 }
225
226 pub fn decr_percent(&mut self, v: f64) -> Option<Cmd> {
228 self.set_percent(self.percent() - v)
229 }
230
231 #[must_use]
233 pub fn is_animating(&self) -> bool {
234 let dist = (self.percent_shown - self.target_percent).abs();
235 !(dist < 0.001 && self.velocity.abs() < 0.01)
236 }
237
238 fn next_frame(&self) -> Option<Cmd> {
240 let id = self.id;
241 let tag = self.tag;
242 let delay = Duration::from_secs_f64(1.0 / f64::from(FPS));
243
244 Some(Cmd::new(move || {
245 std::thread::sleep(delay);
246 Message::new(FrameMsg { id, tag })
247 }))
248 }
249
250 pub fn update(&mut self, msg: Message) -> Option<Cmd> {
252 if let Some(frame) = msg.downcast_ref::<FrameMsg>() {
253 if frame.id != self.id || frame.tag != self.tag {
254 return None;
255 }
256
257 if !self.is_animating() {
258 return None;
259 }
260
261 let (new_pos, new_vel) =
262 self.spring
263 .update(self.percent_shown, self.velocity, self.target_percent);
264 self.percent_shown = new_pos;
265 self.velocity = new_vel;
266
267 return self.next_frame();
268 }
269
270 None
271 }
272
273 #[must_use]
275 pub fn view(&self) -> String {
276 self.view_as(self.percent_shown)
277 }
278
279 #[must_use]
281 pub fn view_as(&self, percent: f64) -> String {
282 let mut result = String::new();
283 let percent_view = self.percentage_view(percent);
284 let percent_width = percent_view.chars().count();
285
286 self.bar_view(&mut result, percent, percent_width);
287 result.push_str(&percent_view);
288 result
289 }
290
291 fn bar_view(&self, buf: &mut String, percent: f64, text_width: usize) {
292 use unicode_width::UnicodeWidthChar;
293
294 let full_width = self.full_char.width().unwrap_or(1).max(1);
295 let empty_width = self.empty_char.width().unwrap_or(1).max(1);
296
297 let available_width = self.width.saturating_sub(text_width);
298 let filled_target_width =
299 ((available_width as f64 * percent).round() as usize).min(available_width);
300
301 let filled_count = filled_target_width / full_width;
302 let filled_visual_width = filled_count * full_width;
303
304 let empty_target_width = available_width.saturating_sub(filled_visual_width);
305 let empty_count = empty_target_width / empty_width;
306
307 if let Some(ref gradient) = self.gradient {
308 for i in 0..filled_count {
310 let p = if filled_count <= 1 {
311 0.5
312 } else if gradient.scaled {
313 i as f64 / (filled_count - 1) as f64
314 } else {
315 (i * full_width) as f64 / (available_width.saturating_sub(1)).max(1) as f64
316 };
317
318 let color = interpolate_color(&gradient.color_a, &gradient.color_b, p);
320 buf.push_str(&format!("\x1b[38;2;{}m{}\x1b[0m", color, self.full_char));
321 }
322 } else {
323 let colored_char =
325 format_colored_char(self.full_char, &self.full_color).repeat(filled_count);
326 buf.push_str(&colored_char);
327 }
328
329 let empty_colored = format_colored_char(self.empty_char, &self.empty_color);
331 for _ in 0..empty_count {
332 buf.push_str(&empty_colored);
333 }
334
335 let used = (filled_count * full_width) + (empty_count * empty_width);
337 let remaining = available_width.saturating_sub(used);
338 if remaining > 0 {
339 buf.push_str(&" ".repeat(remaining));
340 }
341 }
342
343 fn percentage_view(&self, percent: f64) -> String {
344 if !self.show_percentage {
345 return String::new();
346 }
347 let percent = percent.clamp(0.0, 1.0) * 100.0;
348 let formatted = format!("{:3.0}", percent);
351 if self.percent_format.contains("{:3.0}") {
352 self.percent_format.replace("{:3.0}", &formatted)
353 } else {
354 self.percent_format.replace("{}", &formatted)
355 }
356 }
357}
358
359fn format_colored_char(c: char, hex_color: &str) -> String {
361 if let Some(rgb) = parse_hex_color(hex_color) {
362 format!("\x1b[38;2;{};{};{}m{}\x1b[0m", rgb.0, rgb.1, rgb.2, c)
363 } else {
364 c.to_string()
365 }
366}
367
368fn parse_hex_color(hex: &str) -> Option<(u8, u8, u8)> {
370 let hex = hex.trim_start_matches('#');
371 if hex.len() != 6 {
372 return None;
373 }
374 let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
375 let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
376 let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
377 Some((r, g, b))
378}
379
380fn interpolate_color(color_a: &str, color_b: &str, t: f64) -> String {
382 let a = parse_hex_color(color_a).unwrap_or((0, 0, 0));
383 let b = parse_hex_color(color_b).unwrap_or((0, 0, 0));
384
385 let r = (a.0 as f64 + (b.0 as f64 - a.0 as f64) * t).round() as u8;
386 let g = (a.1 as f64 + (b.1 as f64 - a.1 as f64) * t).round() as u8;
387 let bl = (a.2 as f64 + (b.2 as f64 - a.2 as f64) * t).round() as u8;
388
389 format!("{};{};{}", r, g, bl)
390}
391
392impl Model for Progress {
393 fn init(&self) -> Option<Cmd> {
397 None
398 }
399
400 fn update(&mut self, msg: Message) -> Option<Cmd> {
402 Progress::update(self, msg)
403 }
404
405 fn view(&self) -> String {
407 Progress::view(self)
408 }
409}
410
411#[cfg(test)]
412mod tests {
413 use super::*;
414
415 #[test]
416 fn test_progress_new() {
417 let p = Progress::new();
418 assert_eq!(p.width, DEFAULT_WIDTH);
419 assert!(p.show_percentage);
420 assert_eq!(p.percent(), 0.0);
421 }
422
423 #[test]
424 fn test_progress_unique_ids() {
425 let p1 = Progress::new();
426 let p2 = Progress::new();
427 assert_ne!(p1.id(), p2.id());
428 }
429
430 #[test]
431 fn test_progress_set_percent() {
432 let mut p = Progress::new();
433 p.set_percent(0.5);
434 assert!((p.percent() - 0.5).abs() < 0.001);
435 }
436
437 #[test]
438 fn test_progress_percent_clamp() {
439 let mut p = Progress::new();
440 p.set_percent(1.5);
441 assert!((p.percent() - 1.0).abs() < 0.001);
442
443 p.set_percent(-0.5);
444 assert!(p.percent().abs() < 0.001);
445 }
446
447 #[test]
448 fn test_progress_view_as() {
449 let p = Progress::new().width(20).without_percentage();
450 let view = p.view_as(0.5);
451 assert!(!view.is_empty());
453 }
454
455 #[test]
456 fn test_progress_builder() {
457 let p = Progress::new()
458 .width(50)
459 .fill_chars('#', '-')
460 .without_percentage();
461
462 assert_eq!(p.width, 50);
463 assert_eq!(p.full_char, '#');
464 assert_eq!(p.empty_char, '-');
465 assert!(!p.show_percentage);
466 }
467
468 #[test]
469 fn test_progress_with_gradient() {
470 let p = Progress::with_gradient();
471 assert!(p.gradient.is_some());
472 }
473
474 #[test]
475 fn test_parse_hex_color() {
476 assert_eq!(parse_hex_color("#FF0000"), Some((255, 0, 0)));
477 assert_eq!(parse_hex_color("#00FF00"), Some((0, 255, 0)));
478 assert_eq!(parse_hex_color("#0000FF"), Some((0, 0, 255)));
479 assert_eq!(parse_hex_color("FFFFFF"), Some((255, 255, 255)));
480 assert_eq!(parse_hex_color("invalid"), None);
481 }
482
483 #[test]
484 fn test_interpolate_color() {
485 let mid = interpolate_color("#000000", "#FFFFFF", 0.5);
486 assert!(mid.contains("127") || mid.contains("128"));
488 }
489
490 #[test]
491 fn test_progress_animation_state() {
492 let mut p = Progress::new();
493 p.percent_shown = 0.5;
494 p.target_percent = 0.5;
495 p.velocity = 0.0;
496 assert!(!p.is_animating());
497
498 p.target_percent = 0.8;
499 assert!(p.is_animating());
500 }
501
502 #[test]
503 fn test_progress_animation_negative_velocity() {
504 let mut p = Progress::new();
508 p.percent_shown = 0.5;
509 p.target_percent = 0.5;
510 p.velocity = -0.5; assert!(
514 p.is_animating(),
515 "Should be animating with significant negative velocity"
516 );
517
518 p.velocity = -0.001;
520 assert!(
521 !p.is_animating(),
522 "Should not be animating with tiny negative velocity at target"
523 );
524 }
525
526 #[test]
528 fn test_model_init() {
529 let p = Progress::new();
530 let cmd = Model::init(&p);
532 assert!(cmd.is_none());
533 }
534
535 #[test]
536 fn test_model_view() {
537 let p = Progress::new();
538 let model_view = Model::view(&p);
540 let progress_view = Progress::view(&p);
541 assert_eq!(model_view, progress_view);
542 }
543
544 #[test]
545 fn test_model_update_handles_frame_msg() {
546 let mut p = Progress::new();
547 p.target_percent = 1.0;
548 p.percent_shown = 0.0;
549 p.velocity = 0.0;
550 let id = p.id();
551 let tag = p.tag;
552
553 let frame_msg = Message::new(FrameMsg { id, tag });
555 let cmd = Model::update(&mut p, frame_msg);
556
557 assert!(
559 cmd.is_some(),
560 "Model::update should return next frame command when animating"
561 );
562 assert!(p.percent_shown > 0.0, "percent_shown should have advanced");
564 }
565
566 #[test]
567 fn test_model_update_ignores_wrong_id() {
568 let mut p = Progress::new();
569 p.target_percent = 1.0;
570 p.percent_shown = 0.0;
571 let original_percent = p.percent_shown;
572
573 let frame_msg = Message::new(FrameMsg { id: 99999, tag: 0 });
575 let cmd = Model::update(&mut p, frame_msg);
576
577 assert!(
578 cmd.is_none(),
579 "Should ignore messages for other progress bars"
580 );
581 assert!(
582 (p.percent_shown - original_percent).abs() < 0.001,
583 "percent_shown should not change"
584 );
585 }
586
587 #[test]
588 fn test_model_update_ignores_wrong_tag() {
589 let mut p = Progress::new();
590 p.target_percent = 1.0;
591 p.percent_shown = 0.0;
592 p.tag = 5;
593 let id = p.id();
594 let original_percent = p.percent_shown;
595
596 let frame_msg = Message::new(FrameMsg { id, tag: 3 });
598 let cmd = Model::update(&mut p, frame_msg);
599
600 assert!(cmd.is_none(), "Should ignore messages with old tag");
601 assert!(
602 (p.percent_shown - original_percent).abs() < 0.001,
603 "percent_shown should not change"
604 );
605 }
606
607 #[test]
608 fn test_progress_satisfies_model_bounds() {
609 fn accepts_model<M: Model + Send + 'static>(_model: M) {}
611 let p = Progress::new();
612 accepts_model(p);
613 }
614}