Skip to main content

rusty_bubbles/
progress.rs

1//! Cleanroom Rust port of upstream Go source file: `progress/progress.go`
2//! Upstream Target Tag / Version: `v2.1.0`
3//!
4//! <public-docs>
5//! # Progress
6//!
7//! A simple progress bar for Bubble Tea applications.
8//!
9//! The spring-based animation is an inline port of
10//! `github.com/charmbracelet/harmonica` (a simplified damped harmonic
11//! oscillator), which the upstream progress component uses for animated
12//! transitions.
13//! </public-docs>
14
15use rusty_bubbletea::commands;
16use rusty_bubbletea::model::{Cmd, Msg};
17use rusty_lipgloss::{self, Color, Style};
18use rusty_x_ansi;
19use std::fmt;
20use std::sync::atomic::{AtomicI64, Ordering};
21use std::time::Duration;
22
23/// ColorFunc is a function that can be used to dynamically fill the progress
24/// bar based on the current percentage. total is the total filled percentage,
25/// and current is the current percentage that is actively being filled with a
26/// color.
27pub type ColorFunc = Box<dyn Fn(f64, f64) -> Color + Send + Sync>;
28
29/// Internal ID management. Used during animating to assure that frame
30/// messages can only be received by progress components that sent them.
31static LAST_ID: AtomicI64 = AtomicI64::new(0);
32
33fn next_id() -> i32 {
34    (LAST_ID.fetch_add(1, Ordering::SeqCst)) as i32
35}
36
37/// DefaultFullCharHalfBlock is the default character used to fill the
38/// progress bar. It is a half block, which allows more granular color
39/// blending control, by having a different foreground and background color,
40/// doubling blending resolution.
41pub const DEFAULT_FULL_CHAR_HALF_BLOCK: char = '▌';
42
43/// DefaultFullCharFullBlock can also be used as a fill character for the
44/// progress bar. Use this to disable the higher resolution blending which is
45/// enabled when using [`DEFAULT_FULL_CHAR_HALF_BLOCK`].
46pub const DEFAULT_FULL_CHAR_FULL_BLOCK: char = '█';
47
48/// DefaultEmptyCharBlock is the default character used to fill the empty
49/// portion of the progress bar.
50pub const DEFAULT_EMPTY_CHAR_BLOCK: char = '░';
51
52const FPS: u64 = 60;
53const DEFAULT_WIDTH: usize = 40;
54const DEFAULT_FREQUENCY: f64 = 18.0;
55const DEFAULT_DAMPING: f64 = 1.0;
56
57/// defaultBlendStart is the start of the default color blend (purple haze).
58pub fn default_blend_start() -> Color {
59    Color::parse("#5A56E0")
60}
61
62/// defaultBlendEnd is the end of the default color blend (neon pink).
63pub fn default_blend_end() -> Color {
64    Color::parse("#EE6FF8")
65}
66
67/// defaultFullColor is the default "filled" color (blueberry).
68pub fn default_full_color() -> Color {
69    Color::parse("#7571F9")
70}
71
72/// defaultEmptyColor is the default "empty" color (slate gray).
73pub fn default_empty_color() -> Color {
74    Color::parse("#606060")
75}
76
77/// Option is used to set options in [`new`]. For example:
78///
79/// ```rust
80/// # use rusty_bubbles::progress;
81/// # use rusty_lipgloss::Color;
82/// let progress = progress::new(vec![
83///     progress::with_colors(&[Color::parse("#5A56E0"), Color::parse("#EE6FF8")]),
84///     progress::without_percentage(),
85/// ]);
86/// ```
87/// Option is the type of configuration option that can be passed to [`new`].
88/// (Named `Option` to mirror upstream; use `std::option::Option` for
89/// optional values.)
90pub type Option = Box<dyn FnOnce(&mut Model)>;
91
92/// WithDefaultBlend sets a default blend of colors, which is a blend of
93/// purple haze to neon pink.
94pub fn with_default_blend() -> Option {
95    with_colors(&[default_blend_start(), default_blend_end()])
96}
97
98/// WithColors sets the colors to use to fill the progress bar. Depending on
99/// the number of colors passed in, will determine whether to use a solid fill
100/// or a blend of colors.
101///
102/// - 0 colors: clears all previously set colors, setting them back to
103///   defaults.
104/// - 1 color: uses a solid fill with the given color.
105/// - 2+ colors: uses a blend of the provided colors.
106pub fn with_colors(colors: &[Color]) -> Option {
107    let colors = colors.to_vec();
108    if colors.is_empty() {
109        return Box::new(|m: &mut Model| {
110            m.full_color = default_full_color();
111            m.blend = None;
112            m.color_func = None;
113        });
114    }
115    if colors.len() == 1 {
116        return Box::new(move |m: &mut Model| {
117            m.full_color = colors[0].clone();
118            m.color_func = None;
119            m.blend = None;
120        });
121    }
122    Box::new(move |m: &mut Model| {
123        m.blend = Some(colors.clone());
124    })
125}
126
127/// WithColorFunc sets a function that can be used to dynamically fill the
128/// progress bar based on the current percentage. total is the total filled
129/// percentage, and current is the current percentage that is actively being
130/// filled with a color. When specified, this overrides any other defined
131/// colors and scaling.
132///
133/// Example: A progress bar that changes color based on the total completed
134/// percentage:
135///
136/// ```rust
137/// # use rusty_bubbles::progress;
138/// # use rusty_lipgloss::Color;
139/// progress::with_color_func(Box::new(|total, _current| {
140///     if total <= 0.3 {
141///         return Color::parse("#FF0000");
142///     }
143///     if total <= 0.7 {
144///         return Color::parse("#00FF00");
145///     }
146///     Color::parse("#0000FF")
147/// }));
148/// ```
149pub fn with_color_func(fn_: ColorFunc) -> Option {
150    Box::new(move |m: &mut Model| {
151        m.color_func = Some(fn_);
152        m.blend = None;
153    })
154}
155
156/// WithFillCharacters sets the characters used to construct the full and
157/// empty components of the progress bar.
158pub fn with_fill_characters(full: char, empty: char) -> Option {
159    Box::new(move |m: &mut Model| {
160        m.full = full;
161        m.empty = empty;
162    })
163}
164
165/// WithoutPercentage hides the numeric percentage.
166pub fn without_percentage() -> Option {
167    Box::new(|m: &mut Model| {
168        m.show_percentage = false;
169    })
170}
171
172/// WithWidth sets the initial width of the progress bar. Note that you can
173/// also set the width via the `width` property, which can come in handy if
174/// you're waiting for a `tea.WindowSizeMsg`.
175pub fn with_width(w: usize) -> Option {
176    Box::new(move |m: &mut Model| {
177        m.set_width(w);
178    })
179}
180
181/// WithSpringOptions sets the initial frequency and damping options for the
182/// progress bar's built-in spring-based animation. Frequency corresponds to
183/// speed, and damping to bounciness.
184pub fn with_spring_options(frequency: f64, damping: f64) -> Option {
185    Box::new(move |m: &mut Model| {
186        m.set_spring_options(frequency, damping);
187        m.spring_customized = true;
188    })
189}
190
191/// WithScaled sets whether to scale the blend/gradient to fit the width of
192/// only the filled portion of the progress bar. The default is false, which
193/// means the percentage must be 100% to see the full color blend/gradient.
194///
195/// This is ignored when not using blending/multiple colors.
196pub fn with_scaled(enabled: bool) -> Option {
197    Box::new(move |m: &mut Model| {
198        m.scale_blend = enabled;
199    })
200}
201
202/// FrameMsg indicates that an animation step should occur.
203#[derive(Debug, Clone)]
204pub struct FrameMsg {
205    id: i32,
206    tag: i32,
207}
208
209/// Model stores values we'll use when rendering the progress bar.
210pub struct Model {
211    /// An identifier to keep us from receiving messages intended for other
212    /// progress bars.
213    id: i32,
214
215    /// An identifier to keep us from receiving frame messages too quickly.
216    tag: i32,
217
218    /// Total width of the progress bar, including percentage, if set.
219    width: usize,
220
221    /// "Filled" sections of the progress bar.
222    pub full: char,
223    /// The color used for the filled sections.
224    pub full_color: Color,
225
226    /// "Empty" sections of the progress bar.
227    pub empty: char,
228    /// The color used for the empty sections.
229    pub empty_color: Color,
230
231    /// Settings for rendering the numeric percentage.
232    pub show_percentage: bool,
233    /// A fmt string for a float.
234    pub percent_format: String,
235    /// The style used for the percentage.
236    pub percentage_style: Style,
237
238    /// Members for animated transitions.
239    spring: Spring,
240    spring_customized: bool,
241    /// percent currently displaying
242    percent_shown: f64,
243    /// percent to which we're animating
244    target_percent: f64,
245    velocity: f64,
246
247    /// Blend of colors to use. When None, we use full_color.
248    blend: std::option::Option<Vec<Color>>,
249
250    /// When true, we scale the blended colors to fit the width of the filled
251    /// section of the progress bar. When false, the width of the blend will
252    /// be set to the full width of the progress bar.
253    scale_blend: bool,
254
255    /// color_func is used to dynamically fill the progress bar based on the
256    /// current percentage.
257    color_func: std::option::Option<ColorFunc>,
258}
259
260impl fmt::Debug for Model {
261    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
262        f.debug_struct("progress::Model")
263            .field("id", &self.id)
264            .field("width", &self.width)
265            .field("target_percent", &self.target_percent)
266            .finish()
267    }
268}
269
270/// New returns a model with default values.
271pub fn new(opts: Vec<Option>) -> Model {
272    let mut m = Model {
273        id: next_id(),
274        tag: 0,
275        width: DEFAULT_WIDTH,
276        full: DEFAULT_FULL_CHAR_HALF_BLOCK,
277        full_color: default_full_color(),
278        empty: DEFAULT_EMPTY_CHAR_BLOCK,
279        empty_color: default_empty_color(),
280        show_percentage: true,
281        percent_format: " %3.0f%%".to_string(),
282        percentage_style: Style::new(),
283        spring: Spring::identity(),
284        spring_customized: false,
285        percent_shown: 0.0,
286        target_percent: 0.0,
287        velocity: 0.0,
288        blend: None,
289        scale_blend: false,
290        color_func: None,
291    };
292
293    for opt in opts {
294        opt(&mut m);
295    }
296
297    if !m.spring_customized {
298        m.set_spring_options(DEFAULT_FREQUENCY, DEFAULT_DAMPING);
299    }
300
301    m
302}
303
304impl Model {
305    /// Update is used to animate the progress bar during transitions. Use
306    /// [`set_percent`](Self::set_percent) to create the command you'll need
307    /// to trigger the animation.
308    ///
309    /// If you're rendering with [`view_as`](Self::view_as) you won't need
310    /// this.
311    pub fn update(&mut self, msg: &dyn Msg) -> Cmd {
312        if let Some(m) = msg.as_any().downcast_ref::<FrameMsg>() {
313            if m.id != self.id || m.tag != self.tag {
314                return None;
315            }
316
317            // If we've more or less reached equilibrium, stop updating.
318            if !self.is_animating() {
319                return None;
320            }
321
322            let (pos, vel) =
323                self.spring
324                    .update(self.percent_shown, self.velocity, self.target_percent);
325            self.percent_shown = pos;
326            self.velocity = vel;
327            return self.next_frame();
328        }
329        None
330    }
331
332    /// SetSpringOptions sets the frequency and damping for the current
333    /// spring. Frequency corresponds to speed, and damping to bounciness.
334    pub fn set_spring_options(&mut self, frequency: f64, damping: f64) {
335        self.spring = Spring::new(
336            Duration::from_secs(1).as_secs_f64() / FPS as f64,
337            frequency,
338            damping,
339        );
340    }
341
342    /// Percent returns the current visible percentage on the model. This is
343    /// only relevant when you're animating the progress bar.
344    ///
345    /// If you're rendering with [`view_as`](Self::view_as) you won't need
346    /// this.
347    pub fn percent(&self) -> f64 {
348        self.target_percent
349    }
350
351    /// SetPercent sets the percentage state of the model as well as a
352    /// command necessary for animating the progress bar to this new
353    /// percentage.
354    ///
355    /// If you're rendering with [`view_as`](Self::view_as) you won't need
356    /// this.
357    pub fn set_percent(&mut self, p: f64) -> Cmd {
358        self.target_percent = p.clamp(0.0, 1.0);
359        self.tag += 1;
360        self.next_frame()
361    }
362
363    /// IncrPercent increments the percentage by a given amount, returning a
364    /// command necessary to animate the progress bar to the new percentage.
365    ///
366    /// If you're rendering with [`view_as`](Self::view_as) you won't need
367    /// this.
368    pub fn incr_percent(&mut self, v: f64) -> Cmd {
369        self.set_percent(self.percent() + v)
370    }
371
372    /// DecrPercent decrements the percentage by a given amount, returning a
373    /// command necessary to animate the progress bar to the new percentage.
374    ///
375    /// If you're rendering with [`view_as`](Self::view_as) you won't need
376    /// this.
377    pub fn decr_percent(&mut self, v: f64) -> Cmd {
378        self.set_percent(self.percent() - v)
379    }
380
381    /// View renders an animated progress bar in its current state. To render
382    /// a static progress bar based on your own calculations use
383    /// [`view_as`](Self::view_as) instead.
384    pub fn view(&self) -> String {
385        self.view_as(self.percent_shown)
386    }
387
388    /// ViewAs renders the progress bar with a given percentage.
389    pub fn view_as(&self, percent: f64) -> String {
390        let mut b = String::new();
391        let percent_view = self.percentage_view(percent);
392        self.bar_view(&mut b, percent, rusty_x_ansi::string_width(&percent_view));
393        b.push_str(&percent_view);
394        b
395    }
396
397    /// SetWidth sets the width of the progress bar.
398    pub fn set_width(&mut self, w: usize) {
399        self.width = w;
400    }
401
402    /// Width returns the width of the progress bar.
403    pub fn width(&self) -> usize {
404        self.width
405    }
406
407    /// IsAnimating returns false if the progress bar reached equilibrium and
408    /// is no longer animating.
409    pub fn is_animating(&self) -> bool {
410        let dist = (self.percent_shown - self.target_percent).abs();
411        !(dist < 0.001 && self.velocity < 0.01)
412    }
413
414    fn next_frame(&self) -> Cmd {
415        let id = self.id;
416        let tag = self.tag;
417        commands::tick(Duration::from_secs(1) / (FPS as u32), move |_| {
418            Some(Box::new(FrameMsg { id, tag }))
419        })
420    }
421
422    fn bar_view(&self, b: &mut String, percent: f64, text_width: usize) {
423        let tw = self.width.saturating_sub(text_width); // total width
424        let mut fw = ((tw as f64) * percent).round() as usize; // filled width
425
426        fw = fw.min(tw);
427
428        let is_half_block = self.full == DEFAULT_FULL_CHAR_HALF_BLOCK;
429
430        if let Some(color_func) = &self.color_func {
431            let mut style = Style::new();
432            let mut current: f64;
433            let half_block_perc = 0.5 / (tw as f64);
434            for i in 0..fw {
435                current = (i as f64) / (tw as f64);
436                style = style.foreground_color(color_func(percent, current));
437                if is_half_block {
438                    let bg = color_func(percent, (current + half_block_perc).min(1.0));
439                    style = style.background_color(bg);
440                }
441                b.push_str(&style.render(&self.full.to_string()));
442            }
443        } else if let Some(blend) = &self.blend {
444            let mut multiplier = 1;
445            if is_half_block {
446                multiplier = 2;
447            }
448
449            let blend_colors = if self.scale_blend {
450                rusty_lipgloss::blending::blend_1d(fw * multiplier, blend)
451            } else {
452                rusty_lipgloss::blending::blend_1d(tw * multiplier, blend)
453            };
454
455            // Blend fill.
456            let mut blend_index = 0;
457            for i in 0..fw {
458                if !is_half_block {
459                    b.push_str(
460                        &Style::new()
461                            .foreground_color(blend_colors[i].clone())
462                            .render(&self.full.to_string()),
463                    );
464                    continue;
465                }
466
467                b.push_str(
468                    &Style::new()
469                        .foreground_color(blend_colors[blend_index].clone())
470                        .background_color(blend_colors[blend_index + 1].clone())
471                        .render(&self.full.to_string()),
472                );
473                blend_index += 2;
474            }
475        } else {
476            // Solid fill.
477            let repeat = self.full.to_string().repeat(fw);
478            b.push_str(
479                &Style::new()
480                    .foreground_color(self.full_color.clone())
481                    .render(&repeat),
482            );
483        }
484
485        // Empty fill.
486        let n = tw - fw;
487        let repeat = self.empty.to_string().repeat(n);
488        b.push_str(
489            &Style::new()
490                .foreground_color(self.empty_color.clone())
491                .render(&repeat),
492        );
493    }
494
495    fn percentage_view(&self, percent: f64) -> String {
496        if !self.show_percentage {
497            return String::new();
498        }
499        let percent = percent.clamp(0.0, 1.0);
500        // Go's fmt.Sprintf with ` %3.0f%%` (leading space, right-aligned,
501        // width 3, no decimals, escaped '%').
502        let percentage = format!(" {:3.0}%", percent * 100.0);
503        self.percentage_style
504            .clone()
505            .inline(true)
506            .render(&percentage)
507    }
508}
509
510/// A simplified damped harmonic oscillator, ported inline from
511/// `github.com/charmbracelet/harmonica` (itself ported from Ryan Juckett's
512/// simple damped harmonic motion).
513#[derive(Debug, Clone, Copy)]
514struct Spring {
515    pos_pos_coef: f64,
516    pos_vel_coef: f64,
517    vel_pos_coef: f64,
518    vel_vel_coef: f64,
519}
520
521impl Spring {
522    fn identity() -> Spring {
523        Spring {
524            pos_pos_coef: 1.0,
525            pos_vel_coef: 0.0,
526            vel_pos_coef: 0.0,
527            vel_vel_coef: 1.0,
528        }
529    }
530
531    /// NewSpring initializes a new Spring, computing the parameters needed to
532    /// simulate a damped spring over a given period of time.
533    fn new(delta_time: f64, angular_frequency: f64, damping_ratio: f64) -> Spring {
534        const EPSILON: f64 = f64::EPSILON;
535        // Keep values in a legal range.
536        let angular_frequency = angular_frequency.max(0.0);
537        let damping_ratio = damping_ratio.max(0.0);
538
539        // If there is no angular frequency, the spring will not move and we
540        // can return identity.
541        if angular_frequency < EPSILON {
542            return Spring::identity();
543        }
544
545        if damping_ratio > 1.0 + EPSILON {
546            // Over-damped.
547            let za = -angular_frequency * damping_ratio;
548            let zb = angular_frequency * (damping_ratio * damping_ratio - 1.0).sqrt();
549            let z1 = za - zb;
550            let z2 = za + zb;
551
552            let e1 = (z1 * delta_time).exp();
553            let e2 = (z2 * delta_time).exp();
554
555            let inv_two_zb = 1.0 / (2.0 * zb); // = 1 / (z2 - z1)
556
557            let e1_over_two_zb = e1 * inv_two_zb;
558            let e2_over_two_zb = e2 * inv_two_zb;
559
560            let z1e1_over_two_zb = z1 * e1_over_two_zb;
561            let z2e2_over_two_zb = z2 * e2_over_two_zb;
562
563            Spring {
564                pos_pos_coef: e1_over_two_zb * z2 - z2e2_over_two_zb + e2,
565                pos_vel_coef: -e1_over_two_zb + e2_over_two_zb,
566                vel_pos_coef: (z1e1_over_two_zb - z2e2_over_two_zb + e2) * z2,
567                vel_vel_coef: -z1e1_over_two_zb + z2e2_over_two_zb,
568            }
569        } else if damping_ratio < 1.0 - EPSILON {
570            // Under-damped.
571            let omega_zeta = angular_frequency * damping_ratio;
572            let alpha = angular_frequency * (1.0 - damping_ratio * damping_ratio).sqrt();
573
574            let exp_term = (-omega_zeta * delta_time).exp();
575            let cos_term = (alpha * delta_time).cos();
576            let sin_term = (alpha * delta_time).sin();
577
578            let inv_alpha = 1.0 / alpha;
579
580            let exp_sin = exp_term * sin_term;
581            let exp_cos = exp_term * cos_term;
582            let exp_omega_zeta_sin_over_alpha = exp_term * omega_zeta * sin_term * inv_alpha;
583
584            Spring {
585                pos_pos_coef: exp_cos + exp_omega_zeta_sin_over_alpha,
586                pos_vel_coef: exp_sin * inv_alpha,
587                vel_pos_coef: -exp_sin * alpha - omega_zeta * exp_omega_zeta_sin_over_alpha,
588                vel_vel_coef: exp_cos - exp_omega_zeta_sin_over_alpha,
589            }
590        } else {
591            // Critically damped.
592            let exp_term = (-angular_frequency * delta_time).exp();
593            let time_exp = delta_time * exp_term;
594            let time_exp_freq = time_exp * angular_frequency;
595
596            Spring {
597                pos_pos_coef: time_exp_freq + exp_term,
598                pos_vel_coef: time_exp,
599                vel_pos_coef: -angular_frequency * time_exp_freq,
600                vel_vel_coef: -time_exp_freq + exp_term,
601            }
602        }
603    }
604
605    /// Update updates position and velocity values against a given target
606    /// value.
607    fn update(&self, pos: f64, vel: f64, equilibrium_pos: f64) -> (f64, f64) {
608        let old_pos = pos - equilibrium_pos; // update in equilibrium relative space
609        let old_vel = vel;
610
611        let new_pos = old_pos * self.pos_pos_coef + old_vel * self.pos_vel_coef + equilibrium_pos;
612        let new_vel = old_pos * self.vel_pos_coef + old_vel * self.vel_vel_coef;
613
614        (new_pos, new_vel)
615    }
616}