Skip to main content

bubbles/
progress.rs

1//! Progress bar component.
2//!
3//! This module provides a progress bar with optional gradient fill and
4//! spring-based animations.
5//!
6//! # Example
7//!
8//! ```rust
9//! use bubbles::progress::Progress;
10//!
11//! // Create a progress bar
12//! let progress = Progress::new();
13//!
14//! // Render at a specific percentage
15//! let view = progress.view_as(0.5);
16//! ```
17
18use 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
30/// Global ID counter for progress instances.
31static NEXT_ID: AtomicU64 = AtomicU64::new(1);
32
33fn next_id() -> u64 {
34    NEXT_ID.fetch_add(1, Ordering::Relaxed)
35}
36
37/// Message indicating that an animation frame should occur.
38#[derive(Debug, Clone, Copy)]
39pub struct FrameMsg {
40    /// The progress bar ID.
41    pub id: u64,
42    /// Tag for message ordering.
43    tag: u64,
44}
45
46/// Progress bar gradient configuration.
47#[derive(Debug, Clone)]
48pub struct Gradient {
49    /// Start color (hex).
50    pub color_a: String,
51    /// End color (hex).
52    pub color_b: String,
53    /// Whether to scale the gradient to the filled portion.
54    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/// Progress bar model.
68#[derive(Debug, Clone)]
69pub struct Progress {
70    /// Unique identifier.
71    id: u64,
72    /// Tag for frame message ordering.
73    tag: u64,
74    /// Total width of the progress bar.
75    pub width: usize,
76    /// Character for filled sections.
77    pub full_char: char,
78    /// Color for filled sections (when not using gradient).
79    pub full_color: String,
80    /// Character for empty sections.
81    pub empty_char: char,
82    /// Color for empty sections.
83    pub empty_color: String,
84    /// Whether to show percentage text.
85    pub show_percentage: bool,
86    /// Format string for percentage.
87    pub percent_format: String,
88    /// Style for percentage text.
89    pub percentage_style: Style,
90    /// Spring for animations.
91    spring: Spring,
92    /// Currently displayed percentage (for animation).
93    percent_shown: f64,
94    /// Target percentage (for animation).
95    target_percent: f64,
96    /// Animation velocity.
97    velocity: f64,
98    /// Gradient configuration (if using gradient).
99    gradient: Option<Gradient>,
100}
101
102impl Default for Progress {
103    fn default() -> Self {
104        Self::new()
105    }
106}
107
108impl Progress {
109    /// Creates a new progress bar with default settings.
110    #[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    /// Creates a progress bar with default gradient colors.
132    #[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    /// Creates a progress bar with custom gradient colors.
140    #[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    /// Creates a progress bar with a scaled gradient.
152    #[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    /// Sets the width of the progress bar.
164    #[must_use]
165    pub fn width(mut self, width: usize) -> Self {
166        self.width = width;
167        self
168    }
169
170    /// Sets the fill characters.
171    #[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    /// Sets the solid fill color (disables gradient).
179    #[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    /// Disables percentage display.
187    #[must_use]
188    pub fn without_percentage(mut self) -> Self {
189        self.show_percentage = false;
190        self
191    }
192
193    /// Sets the spring animation parameters.
194    pub fn set_spring_options(&mut self, frequency: f64, damping: f64) {
195        self.spring = Spring::new(FPS as f64, frequency, damping);
196    }
197
198    /// Returns the progress bar's unique ID.
199    #[must_use]
200    pub fn id(&self) -> u64 {
201        self.id
202    }
203
204    /// Returns the current target percentage.
205    #[must_use]
206    pub fn percent(&self) -> f64 {
207        self.target_percent
208    }
209
210    /// Sets the percentage and returns a command to start animation.
211    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    /// Increments the percentage.
222    pub fn incr_percent(&mut self, v: f64) -> Option<Cmd> {
223        self.set_percent(self.percent() + v)
224    }
225
226    /// Decrements the percentage.
227    pub fn decr_percent(&mut self, v: f64) -> Option<Cmd> {
228        self.set_percent(self.percent() - v)
229    }
230
231    /// Returns whether the progress bar is still animating.
232    #[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    /// Creates a command for the next animation frame.
239    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    /// Updates the progress bar state.
251    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    /// Renders the progress bar at the current animated position.
274    #[must_use]
275    pub fn view(&self) -> String {
276        self.view_as(self.percent_shown)
277    }
278
279    /// Renders the progress bar at a specific percentage.
280    #[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            // Gradient fill
309            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                // Simple linear interpolation between colors
319                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            // Solid fill
324            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        // Empty fill
330        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        // Pad remaining space if chars don't divide width evenly
336        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        // Use the configurable percent_format field, replacing the placeholder
349        // Supports {:3.0} (default) and {} (simple) format placeholders
350        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
359/// Format a character with ANSI color.
360fn 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
368/// Parse a hex color string to RGB.
369fn 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
380/// Interpolate between two hex colors.
381fn 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    /// Initialize the progress bar.
394    ///
395    /// Progress bars don't require initialization commands.
396    fn init(&self) -> Option<Cmd> {
397        None
398    }
399
400    /// Update the progress bar state based on incoming messages.
401    fn update(&mut self, msg: Message) -> Option<Cmd> {
402        Progress::update(self, msg)
403    }
404
405    /// Render the progress bar at the current animated position.
406    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        // Should have some filled and empty chars
452        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        // Should be approximately middle gray
487        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        // Test that negative velocity is correctly detected as animating.
505        // In an under-damped spring, velocity oscillates and can be negative
506        // even when close to the target position.
507        let mut p = Progress::new();
508        p.percent_shown = 0.5;
509        p.target_percent = 0.5;
510        p.velocity = -0.5; // Significant negative velocity (moving backward)
511
512        // Should still be animating because of momentum, even though at target
513        assert!(
514            p.is_animating(),
515            "Should be animating with significant negative velocity"
516        );
517
518        // Small negative velocity should not be animating
519        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    // Model trait implementation tests
527    #[test]
528    fn test_model_init() {
529        let p = Progress::new();
530        // Progress bars don't require init commands
531        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        // Model::view should return same result as Progress::view
539        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        // Use Model::update explicitly
554        let frame_msg = Message::new(FrameMsg { id, tag });
555        let cmd = Model::update(&mut p, frame_msg);
556
557        // Should return a command for the next frame (animating)
558        assert!(
559            cmd.is_some(),
560            "Model::update should return next frame command when animating"
561        );
562        // Percent should have changed
563        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        // Send frame message with wrong ID
574        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        // Send frame message with wrong tag
597        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        // Verify Progress can be used where Model + Send + 'static is required
610        fn accepts_model<M: Model + Send + 'static>(_model: M) {}
611        let p = Progress::new();
612        accepts_model(p);
613    }
614}