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}