bubbletea_widgets/progress.rs
1//! Progress component for Bubble Tea applications.
2//!
3//! Package progress provides a simple progress bar for Bubble Tea applications.
4//! It closely matches the Go bubbles progress component API for 1-1 compatibility.
5//!
6//! # Basic Usage
7//!
8//! ```rust
9//! use bubbletea_widgets::progress::{new, with_width, with_solid_fill};
10//!
11//! // Create a progress bar with default settings
12//! let progress = new(&[]);
13//!
14//! // Create a progress bar with custom settings using the option pattern
15//! let progress = new(&[
16//! with_width(50),
17//! with_solid_fill("#ff0000".to_string()),
18//! ]);
19//! ```
20//!
21//! # Animation and Control
22//!
23//! ```rust
24//! use bubbletea_widgets::progress::new;
25//!
26//! let mut progress = new(&[]);
27//!
28//! // Set progress (returns command for animation)
29//! let cmd = progress.set_percent(0.75); // 75%
30//! let cmd = progress.incr_percent(0.1); // Add 10%
31//! let cmd = progress.decr_percent(0.05); // Subtract 5%
32//! ```
33
34use bubbletea_rs::{tick as bubbletea_tick, Cmd, Model as BubbleTeaModel, Msg};
35use lipgloss_extras::lipgloss::blending::blend_1d;
36use lipgloss_extras::lipgloss::Color as LGColor;
37use lipgloss_extras::prelude::*;
38use std::sync::atomic::{AtomicI64, Ordering};
39use std::time::Duration;
40
41// Internal ID management for progress instances
42static LAST_ID: AtomicI64 = AtomicI64::new(0);
43
44fn next_id() -> i64 {
45 LAST_ID.fetch_add(1, Ordering::SeqCst) + 1
46}
47
48// Constants matching Go implementation
49const FPS: u32 = 60;
50const DEFAULT_WIDTH: i32 = 40;
51const DEFAULT_FREQUENCY: f64 = 18.0;
52const DEFAULT_DAMPING: f64 = 1.0;
53
54/// Configuration options for customizing progress bar behavior and appearance.
55///
56/// This enum provides a builder pattern for configuring progress bars with various
57/// visual and behavioral options. Options can be combined to create highly customized
58/// progress bars that match your application's design and functionality needs.
59///
60/// # Examples
61///
62/// ## Basic Customization
63/// ```rust
64/// use bubbletea_widgets::progress::{new, with_width, with_solid_fill};
65///
66/// let progress = new(&[
67/// with_width(60),
68/// with_solid_fill("#00ff00".to_string()),
69/// ]);
70/// ```
71///
72/// ## Advanced Gradient Configuration
73/// ```rust
74/// use bubbletea_widgets::progress::{new, with_gradient, with_width, without_percentage};
75///
76/// let gradient_progress = new(&[
77/// with_width(80),
78/// with_gradient("#ff4757".to_string(), "#5352ed".to_string()),
79/// without_percentage(),
80/// ]);
81/// ```
82pub enum ProgressOption {
83 /// Uses the default gradient colors (purple to pink).
84 /// Creates a smooth color transition from #5A56E0 to #EE6FF8.
85 WithDefaultGradient,
86 /// Creates a custom gradient between two specified colors.
87 /// The first string is the start color, the second is the end color.
88 /// Colors can be hex codes ("#ff0000") or named colors.
89 WithGradient(String, String),
90 /// Uses the default gradient colors but scales the gradient to fit only the filled portion.
91 /// This creates a more dynamic visual effect where the gradient adjusts based on progress.
92 WithDefaultScaledGradient,
93 /// Creates a custom scaled gradient that fits only the filled portion of the bar.
94 /// Combines custom colors with dynamic gradient scaling for maximum visual impact.
95 WithScaledGradient(String, String),
96 /// Sets a solid color fill instead of a gradient.
97 /// Provides consistent coloring across the entire filled portion.
98 WithSolidFill(String),
99 /// Customizes the characters used for filled and empty portions of the progress bar.
100 /// First character is for filled sections, second for empty sections.
101 WithFillCharacters(char, char),
102 /// Hides the percentage text display.
103 /// Useful when you want a cleaner look or when space is limited.
104 WithoutPercentage,
105 /// Sets the total width of the progress bar in characters.
106 /// This includes both the bar and percentage text if shown.
107 WithWidth(i32),
108 /// Configures the spring animation parameters for smooth transitions.
109 /// First value is frequency (speed), second is damping (bounciness).
110 WithSpringOptions(f64, f64),
111}
112
113impl ProgressOption {
114 fn apply(&self, m: &mut Model) {
115 match self {
116 ProgressOption::WithDefaultGradient => {
117 m.set_ramp("#5A56E0".to_string(), "#EE6FF8".to_string(), false);
118 }
119 ProgressOption::WithGradient(color_a, color_b) => {
120 m.set_ramp(color_a.clone(), color_b.clone(), false);
121 }
122 ProgressOption::WithDefaultScaledGradient => {
123 m.set_ramp("#5A56E0".to_string(), "#EE6FF8".to_string(), true);
124 }
125 ProgressOption::WithScaledGradient(color_a, color_b) => {
126 m.set_ramp(color_a.clone(), color_b.clone(), true);
127 }
128 ProgressOption::WithSolidFill(color) => {
129 m.full_color = color.clone();
130 m.use_ramp = false;
131 }
132 ProgressOption::WithFillCharacters(full, empty) => {
133 m.full = *full;
134 m.empty = *empty;
135 }
136 ProgressOption::WithoutPercentage => {
137 m.show_percentage = false;
138 }
139 ProgressOption::WithWidth(width) => {
140 m.width = *width;
141 }
142 ProgressOption::WithSpringOptions(frequency, damping) => {
143 m.set_spring_options(*frequency, *damping);
144 m.spring_customized = true;
145 }
146 }
147 }
148}
149
150/// Creates a gradient fill with default colors.
151///
152/// Uses the predefined gradient colors (#5A56E0 to #EE6FF8) that provide
153/// an attractive purple-to-pink transition. This is a convenient option
154/// for getting a professional-looking gradient without specifying colors.
155///
156/// # Examples
157///
158/// ```rust
159/// use bubbletea_widgets::progress::{new, with_default_gradient, with_width};
160///
161/// let progress = new(&[
162/// with_default_gradient(),
163/// with_width(50),
164/// ]);
165/// ```
166pub fn with_default_gradient() -> ProgressOption {
167 ProgressOption::WithDefaultGradient
168}
169
170/// Creates a custom gradient fill blending between two specified colors.
171///
172/// The gradient transitions smoothly from the first color to the second color
173/// across the width of the progress bar. Colors can be specified as hex codes
174/// (e.g., "#ff0000") or named colors supported by your terminal.
175///
176/// # Arguments
177///
178/// * `color_a` - The starting color of the gradient
179/// * `color_b` - The ending color of the gradient
180///
181/// # Examples
182///
183/// ```rust
184/// use bubbletea_widgets::progress::{new, with_gradient};
185///
186/// // Red to blue gradient
187/// let progress = new(&[
188/// with_gradient("#ff0000".to_string(), "#0000ff".to_string()),
189/// ]);
190///
191/// // Green to yellow gradient
192/// let warm_progress = new(&[
193/// with_gradient("#10ac84".to_string(), "#f9ca24".to_string()),
194/// ]);
195/// ```
196pub fn with_gradient(color_a: String, color_b: String) -> ProgressOption {
197 ProgressOption::WithGradient(color_a, color_b)
198}
199
200/// Creates a scaled gradient with default colors.
201///
202/// Similar to `with_default_gradient()` but scales the gradient to fit only
203/// the filled portion of the progress bar. This creates a more dynamic effect
204/// where the gradient adjusts its range based on the current progress level.
205///
206/// # Examples
207///
208/// ```rust
209/// use bubbletea_widgets::progress::{new, with_default_scaled_gradient};
210///
211/// let progress = new(&[
212/// with_default_scaled_gradient(),
213/// ]);
214///
215/// // At 50% progress, the gradient spans only the filled half
216/// // At 100% progress, the gradient spans the entire bar
217/// ```
218pub fn with_default_scaled_gradient() -> ProgressOption {
219 ProgressOption::WithDefaultScaledGradient
220}
221
222/// Creates a custom scaled gradient that fits the filled portion width.
223///
224/// Combines custom color selection with dynamic gradient scaling. The gradient
225/// transitions from the first color to the second color across only the filled
226/// portion of the progress bar, creating an adaptive visual effect.
227///
228/// # Arguments
229///
230/// * `color_a` - The starting color of the scaled gradient
231/// * `color_b` - The ending color of the scaled gradient
232///
233/// # Examples
234///
235/// ```rust
236/// use bubbletea_widgets::progress::{new, with_scaled_gradient};
237///
238/// let progress = new(&[
239/// with_scaled_gradient("#ee5a24".to_string(), "#feca57".to_string()),
240/// ]);
241///
242/// // The orange-to-yellow gradient will always span the filled portion,
243/// // regardless of progress percentage
244/// ```
245pub fn with_scaled_gradient(color_a: String, color_b: String) -> ProgressOption {
246 ProgressOption::WithScaledGradient(color_a, color_b)
247}
248
249/// Sets the progress bar to use a solid color fill.
250///
251/// Instead of a gradient, this option fills the progress bar with a single,
252/// consistent color. This provides a clean, minimalist appearance and can
253/// be useful for maintaining consistency with your application's color scheme.
254///
255/// # Arguments
256///
257/// * `color` - The color to use for the filled portion (hex code or named color)
258///
259/// # Examples
260///
261/// ```rust
262/// use bubbletea_widgets::progress::{new, with_solid_fill, with_width};
263///
264/// // Solid green progress bar
265/// let success_progress = new(&[
266/// with_solid_fill("#2ed573".to_string()),
267/// with_width(40),
268/// ]);
269///
270/// // Solid red for error states
271/// let error_progress = new(&[
272/// with_solid_fill("#ff3838".to_string()),
273/// ]);
274/// ```
275pub fn with_solid_fill(color: String) -> ProgressOption {
276 ProgressOption::WithSolidFill(color)
277}
278
279/// Customizes the characters used for filled and empty sections.
280///
281/// Allows you to change the visual representation of the progress bar by
282/// specifying different characters for the filled and empty portions. This
283/// can be used to create different visual styles or to match specific design requirements.
284///
285/// # Arguments
286///
287/// * `full` - Character to use for filled sections (default: '█')
288/// * `empty` - Character to use for empty sections (default: '░')
289///
290/// # Examples
291///
292/// ```rust
293/// use bubbletea_widgets::progress::{new, with_fill_characters};
294///
295/// // Classic ASCII style
296/// let ascii_progress = new(&[
297/// with_fill_characters('=', '-'),
298/// ]);
299///
300/// // Block style with different densities
301/// let block_progress = new(&[
302/// with_fill_characters('▓', '▒'),
303/// ]);
304///
305/// // Dot style
306/// let dot_progress = new(&[
307/// with_fill_characters('●', '○'),
308/// ]);
309/// ```
310pub fn with_fill_characters(full: char, empty: char) -> ProgressOption {
311 ProgressOption::WithFillCharacters(full, empty)
312}
313
314/// Hides the numeric percentage display.
315///
316/// By default, progress bars show a percentage (e.g., " 75%") alongside
317/// the visual bar. This option removes the percentage text, creating a
318/// cleaner appearance and saving horizontal space.
319///
320/// # Examples
321///
322/// ```rust
323/// use bubbletea_widgets::progress::{new, without_percentage, with_width, with_solid_fill};
324///
325/// // Clean progress bar without percentage text
326/// let minimal_progress = new(&[
327/// without_percentage(),
328/// with_width(30),
329/// ]);
330///
331/// // Useful for compact layouts
332/// let compact_progress = new(&[
333/// without_percentage(),
334/// with_solid_fill("#3742fa".to_string()),
335/// ]);
336/// ```
337pub fn without_percentage() -> ProgressOption {
338 ProgressOption::WithoutPercentage
339}
340
341/// Sets the total width of the progress bar in characters.
342///
343/// This width includes both the visual bar and the percentage text (if shown).
344/// You can also modify the width later using the `width` field, which is useful
345/// for responsive layouts that need to adjust to terminal size changes.
346///
347/// # Arguments
348///
349/// * `w` - Width in characters (must be positive)
350///
351/// # Examples
352///
353/// ```rust
354/// use bubbletea_widgets::progress::{new, with_width};
355///
356/// // Narrow progress bar for compact spaces
357/// let narrow = new(&[with_width(20)]);
358///
359/// // Wide progress bar for detailed view
360/// let wide = new(&[with_width(80)]);
361///
362/// // Responsive width (can be changed later)
363/// let mut responsive = new(&[with_width(40)]);
364/// responsive.width = 60; // Adjust based on terminal size
365/// ```
366pub fn with_width(w: i32) -> ProgressOption {
367 ProgressOption::WithWidth(w)
368}
369
370/// Configures the spring animation parameters for smooth progress transitions.
371///
372/// The progress bar uses a spring-based physics system to animate between
373/// different progress values. This creates natural-looking transitions that
374/// feel responsive and smooth.
375///
376/// # Arguments
377///
378/// * `frequency` - Animation speed (higher = faster, typical range: 10-30)
379/// * `damping` - Bounciness control (higher = less bouncy, typical range: 0.5-2.0)
380///
381/// # Examples
382///
383/// ```rust
384/// use bubbletea_widgets::progress::{new, with_spring_options};
385///
386/// // Fast, snappy animation
387/// let snappy = new(&[
388/// with_spring_options(25.0, 1.5),
389/// ]);
390///
391/// // Slow, bouncy animation
392/// let bouncy = new(&[
393/// with_spring_options(12.0, 0.7),
394/// ]);
395///
396/// // Smooth, professional animation
397/// let smooth = new(&[
398/// with_spring_options(18.0, 1.2),
399/// ]);
400/// ```
401pub fn with_spring_options(frequency: f64, damping: f64) -> ProgressOption {
402 ProgressOption::WithSpringOptions(frequency, damping)
403}
404
405/// Message indicating that an animation frame should be processed.
406///
407/// This message is used internally by the progress bar's animation system to
408/// trigger smooth transitions between progress values. Each `FrameMsg` is
409/// associated with a specific progress bar instance and animation sequence
410/// to ensure proper message routing and prevent timing conflicts.
411///
412/// The message contains identity information that allows the progress bar
413/// to validate that it should process the frame update, preventing issues
414/// with multiple progress bars or rapid state changes.
415///
416/// # Internal Usage
417///
418/// You typically won't create `FrameMsg` instances directly. They are
419/// generated automatically when you call methods like `set_percent()`,
420/// `incr_percent()`, or `decr_percent()` on a progress bar.
421///
422/// # Examples
423///
424/// ```rust
425/// use bubbletea_widgets::progress::new;
426///
427/// let mut progress = new(&[]);
428///
429/// // This automatically creates and schedules FrameMsg instances
430/// let cmd = progress.set_percent(0.75);
431///
432/// // The progress bar's update() method will handle the FrameMsg
433/// // to animate smoothly to 75%
434/// ```
435#[derive(Debug, Clone)]
436pub struct FrameMsg {
437 /// Unique identifier of the progress bar instance.
438 ///
439 /// This ensures that frame messages are only processed by the
440 /// correct progress bar when multiple bars exist in an application.
441 id: i64,
442 /// Animation sequence tag to prevent stale frame messages.
443 ///
444 /// The tag is incremented each time a new animation starts,
445 /// allowing the progress bar to ignore outdated frame messages
446 /// from previous animation sequences.
447 tag: i64,
448}
449
450/// Simple spring animation system (simplified version of harmonica)
451#[derive(Debug, Clone)]
452struct Spring {
453 frequency: f64,
454 damping: f64,
455 fps: f64,
456}
457
458impl Spring {
459 fn new(fps: f64, frequency: f64, damping: f64) -> Self {
460 Self {
461 frequency,
462 damping,
463 fps,
464 }
465 }
466
467 fn update(&self, position: f64, velocity: f64, target: f64) -> (f64, f64) {
468 let dt = 1.0 / self.fps;
469 let spring_force = -self.frequency * (position - target);
470 let damping_force = -self.damping * velocity;
471 let acceleration = spring_force + damping_force;
472
473 let new_velocity = velocity + acceleration * dt;
474 let new_position = position + new_velocity * dt;
475
476 (new_position, new_velocity)
477 }
478}
479
480/// The main progress bar model containing all state and configuration.
481///
482/// This structure holds all the data needed to render and animate a progress bar,
483/// including visual styling, animation state, and behavioral configuration. The model
484/// follows the Elm Architecture pattern used by bubbletea-rs, with separate methods
485/// for initialization, updates, and rendering.
486///
487/// # Key Features
488///
489/// - **Smooth animation** using spring-based physics for natural transitions
490/// - **Flexible styling** with support for gradients, solid colors, and custom characters
491/// - **Responsive design** with configurable width and percentage display options
492/// - **Thread safety** with unique identifiers for multi-instance usage
493///
494/// # Animation System
495///
496/// The progress bar uses a spring physics model to create smooth, natural-looking
497/// transitions between progress values. This provides better visual feedback than
498/// instant jumps and makes progress changes feel more responsive and polished.
499///
500/// # Examples
501///
502/// ## Basic Usage
503/// ```rust
504/// use bubbletea_widgets::progress::{new, with_width};
505///
506/// let mut progress = new(&[with_width(50)]);
507///
508/// // Set progress and get animation command
509/// let cmd = progress.set_percent(0.6);
510///
511/// // Render the current state
512/// let view = progress.view();
513/// println!("{}", view); // Shows animated progress bar
514/// ```
515///
516/// ## Advanced Configuration
517/// ```rust
518/// use bubbletea_widgets::progress::*;
519///
520/// let mut progress = new(&[
521/// with_width(60),
522/// with_gradient("#ff6b6b".to_string(), "#4ecdc4".to_string()),
523/// with_spring_options(20.0, 1.0),
524/// ]);
525///
526/// // Smooth animated increment
527/// let cmd = progress.incr_percent(0.1);
528/// ```
529///
530/// ## Integration with bubbletea-rs
531/// ```rust
532/// use bubbletea_widgets::progress;
533/// use bubbletea_rs::{Model as TeaModel, Cmd, Msg};
534///
535/// struct App {
536/// progress: progress::Model,
537/// }
538///
539/// impl TeaModel for App {
540/// fn init() -> (Self, Option<Cmd>) {
541/// let progress = progress::new(&[
542/// progress::with_width(40),
543/// progress::with_solid_fill("#2ecc71".to_string()),
544/// ]);
545/// (Self { progress }, None)
546/// }
547///
548/// fn update(&mut self, msg: Msg) -> Option<Cmd> {
549/// // Forward animation messages to progress bar
550/// self.progress.update(msg)
551/// }
552///
553/// fn view(&self) -> String {
554/// format!("Loading: {}\n", self.progress.view())
555/// }
556/// }
557/// ```
558#[derive(Debug, Clone)]
559pub struct Model {
560 /// An identifier to keep us from receiving messages intended for other
561 /// progress bars.
562 id: i64,
563
564 /// An identifier to keep us from receiving frame messages too quickly.
565 tag: i64,
566
567 /// Total width of the progress bar, including percentage, if set.
568 pub width: i32,
569
570 /// "Filled" sections of the progress bar.
571 pub full: char,
572 /// Color used for the filled portion (hex or named color string).
573 pub full_color: String,
574
575 /// "Empty" sections of the progress bar.
576 pub empty: char,
577 /// Color used for the empty portion (hex or named color string).
578 pub empty_color: String,
579
580 /// Settings for rendering the numeric percentage.
581 pub show_percentage: bool,
582 /// Format string for the percentage (e.g., " %3.0f%%").
583 pub percent_format: String,
584 /// Lipgloss style applied to the percentage text.
585 pub percentage_style: Style,
586
587 /// Members for animated transitions.
588 spring: Spring,
589 spring_customized: bool,
590 percent_shown: f64, // percent currently displaying
591 target_percent: f64, // percent to which we're animating
592 velocity: f64,
593
594 /// Gradient settings
595 use_ramp: bool,
596 ramp_color_a: String, // simplified color handling compared to Go's colorful
597 ramp_color_b: String,
598
599 /// When true, we scale the gradient to fit the width of the filled section
600 /// of the progress bar. When false, the width of the gradient will be set
601 /// to the full width of the progress bar.
602 scale_ramp: bool,
603}
604
605/// Creates a new progress bar with the specified configuration options.
606///
607/// This function initializes a progress bar with sensible defaults and applies
608/// any provided options to customize its appearance and behavior. The progress bar
609/// starts at 0% and is ready for animation and display.
610///
611/// # Arguments
612///
613/// * `opts` - A slice of `ProgressOption` values to configure the progress bar
614///
615/// # Default Configuration
616///
617/// - **Width**: 40 characters
618/// - **Fill character**: '█' (full block)
619/// - **Empty character**: '░' (light shade)
620/// - **Fill color**: "#7571F9" (purple)
621/// - **Empty color**: "#606060" (gray)
622/// - **Percentage**: Shown by default
623/// - **Animation**: Spring physics with frequency=18.0, damping=1.0
624///
625/// # Examples
626///
627/// ## Basic Progress Bar
628/// ```rust
629/// use bubbletea_widgets::progress::new;
630///
631/// // Create with all defaults
632/// let progress = new(&[]);
633/// assert_eq!(progress.width, 40);
634/// ```
635///
636/// ## Customized Progress Bar
637/// ```rust
638/// use bubbletea_widgets::progress::*;
639///
640/// let progress = new(&[
641/// with_width(60),
642/// with_solid_fill("#e74c3c".to_string()),
643/// without_percentage(),
644/// with_spring_options(25.0, 0.8),
645/// ]);
646/// ```
647///
648/// ## Gradient Progress Bar
649/// ```rust
650/// use bubbletea_widgets::progress::*;
651///
652/// let gradient_progress = new(&[
653/// with_gradient("#667eea".to_string(), "#764ba2".to_string()),
654/// with_width(50),
655/// ]);
656/// ```
657pub fn new(opts: &[ProgressOption]) -> Model {
658 let mut m = Model {
659 id: next_id(),
660 tag: 0,
661 width: DEFAULT_WIDTH,
662 full: '█',
663 full_color: "#7571F9".to_string(),
664 empty: '░',
665 empty_color: "#606060".to_string(),
666 show_percentage: true,
667 percent_format: " %3.0f%%".to_string(),
668 percentage_style: Style::new(),
669 spring: Spring::new(FPS as f64, DEFAULT_FREQUENCY, DEFAULT_DAMPING),
670 spring_customized: false,
671 percent_shown: 0.0,
672 target_percent: 0.0,
673 velocity: 0.0,
674 use_ramp: false,
675 ramp_color_a: String::new(),
676 ramp_color_b: String::new(),
677 scale_ramp: false,
678 };
679
680 for opt in opts {
681 opt.apply(&mut m);
682 }
683
684 if !m.spring_customized {
685 m.set_spring_options(DEFAULT_FREQUENCY, DEFAULT_DAMPING);
686 }
687
688 m
689}
690
691/// NewModel returns a model with default values.
692/// Deprecated: use [new] instead.
693#[deprecated(since = "0.0.7", note = "use new instead")]
694pub fn new_model(opts: &[ProgressOption]) -> Model {
695 new(opts)
696}
697
698impl Model {
699 /// Configures the spring animation parameters for smooth transitions.
700 ///
701 /// Updates the internal spring physics engine that controls how the progress bar
702 /// animates between different progress values. Higher frequency values make
703 /// animations faster, while higher damping values reduce bounciness.
704 ///
705 /// # Arguments
706 ///
707 /// * `frequency` - Animation speed (typical range: 10.0-30.0)
708 /// * `damping` - Bounciness control (typical range: 0.5-2.0)
709 ///
710 /// # Examples
711 ///
712 /// ```rust
713 /// use bubbletea_widgets::progress::new;
714 ///
715 /// let mut progress = new(&[]);
716 ///
717 /// // Fast, snappy animation
718 /// progress.set_spring_options(25.0, 1.5);
719 ///
720 /// // Slow, bouncy animation
721 /// progress.set_spring_options(12.0, 0.7);
722 ///
723 /// // Now progress changes will use the new animation style
724 /// let cmd = progress.set_percent(0.8);
725 /// ```
726 pub fn set_spring_options(&mut self, frequency: f64, damping: f64) {
727 self.spring = Spring::new(FPS as f64, frequency, damping);
728 }
729
730 /// Returns the target percentage that the progress bar is animating towards.
731 ///
732 /// This represents the logical progress value, not necessarily what's currently
733 /// being displayed. During animation, the visual progress gradually moves from
734 /// its current position toward this target value using spring physics.
735 ///
736 /// # Returns
737 ///
738 /// The target progress as a float between 0.0 and 1.0 (0% to 100%).
739 ///
740 /// # Examples
741 ///
742 /// ```rust
743 /// use bubbletea_widgets::progress::new;
744 ///
745 /// let mut progress = new(&[]);
746 /// assert_eq!(progress.percent(), 0.0);
747 ///
748 /// // Set target to 75%
749 /// progress.set_percent(0.75);
750 /// assert_eq!(progress.percent(), 0.75);
751 ///
752 /// // The visual bar will animate smoothly to reach this target
753 /// ```
754 pub fn percent(&self) -> f64 {
755 self.target_percent
756 }
757
758 /// Sets the progress to a specific percentage and returns an animation command.
759 ///
760 /// This method updates the target percentage and initiates a smooth animation
761 /// to the new value using spring physics. The returned command should be
762 /// handled by your bubbletea-rs application to drive the animation.
763 ///
764 /// # Arguments
765 ///
766 /// * `p` - Progress percentage as a float (will be clamped to 0.0-1.0 range)
767 ///
768 /// # Returns
769 ///
770 /// A `Cmd` that drives the animation. This must be returned from your
771 /// application's `update()` method to enable smooth progress transitions.
772 ///
773 /// # Examples
774 ///
775 /// ```rust
776 /// use bubbletea_widgets::progress::new;
777 /// use bubbletea_rs::{Model, Msg, Cmd};
778 ///
779 /// struct App {
780 /// progress: bubbletea_widgets::progress::Model,
781 /// }
782 ///
783 /// impl Model for App {
784 /// fn update(&mut self, msg: Msg) -> Option<Cmd> {
785 /// // Handle progress animation
786 /// if let Some(cmd) = self.progress.update(msg) {
787 /// return Some(cmd);
788 /// }
789 ///
790 /// // Set progress and return animation command
791 /// Some(self.progress.set_percent(0.6))
792 /// }
793 /// # fn init() -> (Self, Option<Cmd>) { (Self { progress: new(&[]) }, None) }
794 /// # fn view(&self) -> String { String::new() }
795 /// }
796 /// ```
797 ///
798 /// ## Direct Usage
799 /// ```rust
800 /// use bubbletea_widgets::progress::new;
801 ///
802 /// let mut progress = new(&[]);
803 ///
804 /// // Values are automatically clamped
805 /// let cmd1 = progress.set_percent(0.5); // 50%
806 /// let cmd2 = progress.set_percent(1.5); // Clamped to 100%
807 /// let cmd3 = progress.set_percent(-0.2); // Clamped to 0%
808 /// ```
809 pub fn set_percent(&mut self, p: f64) -> Cmd {
810 self.target_percent = p.clamp(0.0, 1.0);
811 self.tag += 1;
812 self.next_frame()
813 }
814
815 /// Increases the progress by a specified amount and returns an animation command.
816 ///
817 /// This is a convenience method that adds the given value to the current
818 /// progress percentage. The result is automatically clamped to the valid
819 /// range (0.0-1.0) and animated smoothly to the new position.
820 ///
821 /// # Arguments
822 ///
823 /// * `v` - Amount to add to current progress (can be negative for decrease)
824 ///
825 /// # Returns
826 ///
827 /// A `Cmd` that drives the animation to the new progress value.
828 ///
829 /// # Examples
830 ///
831 /// ```rust
832 /// use bubbletea_widgets::progress::new;
833 ///
834 /// let mut progress = new(&[]);
835 ///
836 /// // Start at 0%, increment by 25%
837 /// let cmd1 = progress.incr_percent(0.25);
838 /// assert_eq!(progress.percent(), 0.25);
839 ///
840 /// // Add another 30%
841 /// let cmd2 = progress.incr_percent(0.3);
842 /// assert_eq!(progress.percent(), 0.55);
843 ///
844 /// // Try to go over 100% (will be clamped)
845 /// let cmd3 = progress.incr_percent(0.7);
846 /// assert_eq!(progress.percent(), 1.0);
847 /// ```
848 ///
849 /// ## Use in Loading Scenarios
850 /// ```rust
851 /// use bubbletea_widgets::progress::new;
852 ///
853 /// let mut download_progress = new(&[]);
854 ///
855 /// // Simulate incremental progress updates
856 /// for _chunk in 0..10 {
857 /// let cmd = download_progress.incr_percent(0.1); // Add 10% each chunk
858 /// // Return cmd from your update() method
859 /// }
860 /// ```
861 pub fn incr_percent(&mut self, v: f64) -> Cmd {
862 self.set_percent(self.percent() + v)
863 }
864
865 /// Decreases the progress by a specified amount and returns an animation command.
866 ///
867 /// This is a convenience method that subtracts the given value from the current
868 /// progress percentage. The result is automatically clamped to the valid
869 /// range (0.0-1.0) and animated smoothly to the new position.
870 ///
871 /// # Arguments
872 ///
873 /// * `v` - Amount to subtract from current progress (positive values decrease progress)
874 ///
875 /// # Returns
876 ///
877 /// A `Cmd` that drives the animation to the new progress value.
878 ///
879 /// # Examples
880 ///
881 /// ```rust
882 /// use bubbletea_widgets::progress::new;
883 ///
884 /// let mut progress = new(&[]);
885 ///
886 /// // Start at 100%
887 /// progress.set_percent(1.0);
888 ///
889 /// // Decrease by 20%
890 /// let cmd1 = progress.decr_percent(0.2);
891 /// assert_eq!(progress.percent(), 0.8);
892 ///
893 /// // Decrease by another 30%
894 /// let cmd2 = progress.decr_percent(0.3);
895 /// assert_eq!(progress.percent(), 0.5);
896 ///
897 /// // Try to go below 0% (will be clamped)
898 /// let cmd3 = progress.decr_percent(0.8);
899 /// assert_eq!(progress.percent(), 0.0);
900 /// ```
901 ///
902 /// ## Use in Error Recovery
903 /// ```rust
904 /// use bubbletea_widgets::progress::new;
905 ///
906 /// let mut upload_progress = new(&[]);
907 /// upload_progress.set_percent(0.7); // 70% uploaded
908 ///
909 /// // Network error - need to retry from earlier point
910 /// let cmd = upload_progress.decr_percent(0.2); // Back to 50%
911 /// ```
912 pub fn decr_percent(&mut self, v: f64) -> Cmd {
913 self.set_percent(self.percent() - v)
914 }
915
916 /// Processes animation messages and updates the visual progress state.
917 ///
918 /// This method handles `FrameMsg` instances that drive the smooth animation
919 /// between progress values. It should be called from your application's
920 /// `update()` method to enable animated progress transitions.
921 ///
922 /// The method uses spring physics to gradually move the visual progress
923 /// from its current position toward the target percentage, creating
924 /// natural-looking animations.
925 ///
926 /// # Arguments
927 ///
928 /// * `msg` - A message that might contain animation frame data
929 ///
930 /// # Returns
931 ///
932 /// - `Some(Cmd)` if animation should continue (return this from your update method)
933 /// - `None` if the message wasn't relevant or animation has finished
934 ///
935 /// # Examples
936 ///
937 /// ## Integration with bubbletea-rs
938 /// ```rust
939 /// use bubbletea_widgets::progress;
940 /// use bubbletea_rs::{Model, Msg, Cmd};
941 ///
942 /// struct App {
943 /// progress: progress::Model,
944 /// }
945 ///
946 /// impl Model for App {
947 /// fn update(&mut self, msg: Msg) -> Option<Cmd> {
948 /// // Always forward messages to progress bar first
949 /// if let Some(cmd) = self.progress.update(msg) {
950 /// return Some(cmd);
951 /// }
952 ///
953 /// // Handle your own application messages
954 /// None
955 /// }
956 /// # fn init() -> (Self, Option<Cmd>) { (Self { progress: progress::new(&[]) }, None) }
957 /// # fn view(&self) -> String { String::new() }
958 /// }
959 /// ```
960 ///
961 /// ## Manual Animation Handling
962 /// ```rust
963 /// use bubbletea_widgets::progress::new;
964 ///
965 /// let mut progress = new(&[]);
966 ///
967 /// // Start an animation
968 /// let initial_cmd = progress.set_percent(0.8);
969 ///
970 /// // In a real app, you'd handle this through bubbletea-rs
971 /// // but here's what happens internally:
972 /// // 1. FrameMsg is sent after a delay
973 /// // 2. update() processes it and returns next FrameMsg
974 /// // 3. Process continues until animation completes
975 /// ```
976 pub fn update(&mut self, msg: Msg) -> std::option::Option<Cmd> {
977 if let Some(frame_msg) = msg.downcast_ref::<FrameMsg>() {
978 if frame_msg.id != self.id || frame_msg.tag != self.tag {
979 return std::option::Option::None;
980 }
981
982 // If we've more or less reached equilibrium, stop updating.
983 if !self.is_animating() {
984 return std::option::Option::None;
985 }
986
987 let (new_percent, new_velocity) =
988 self.spring
989 .update(self.percent_shown, self.velocity, self.target_percent);
990 self.percent_shown = new_percent;
991 self.velocity = new_velocity;
992
993 return std::option::Option::Some(self.next_frame());
994 }
995
996 std::option::Option::None
997 }
998
999 /// Renders the progress bar in its current animated state.
1000 ///
1001 /// This method displays the progress bar with the current visual progress,
1002 /// which may be different from the target percentage during animations.
1003 /// The output includes both the visual bar and percentage text (if enabled).
1004 ///
1005 /// For static rendering with a specific percentage, use `view_as()` instead.
1006 ///
1007 /// # Returns
1008 ///
1009 /// A formatted string containing the styled progress bar ready for terminal display.
1010 ///
1011 /// # Examples
1012 ///
1013 /// ## Basic Rendering
1014 /// ```rust
1015 /// use bubbletea_widgets::progress::new;
1016 ///
1017 /// let progress = new(&[]);
1018 /// let output = progress.view();
1019 /// println!("{}", output); // Displays: [░░░░░░░] 0%
1020 /// ```
1021 ///
1022 /// ## Animated Progress
1023 /// ```rust
1024 /// use bubbletea_widgets::progress::{new, with_width};
1025 ///
1026 /// let mut progress = new(&[with_width(20)]);
1027 ///
1028 /// // Set target percentage
1029 /// let cmd = progress.set_percent(0.6);
1030 ///
1031 /// // During animation, view() shows the current animated position
1032 /// let frame1 = progress.view(); // Might show: [██████░░░] 35%
1033 ///
1034 /// // After animation completes:
1035 /// let final_frame = progress.view(); // Shows: [███████████░] 60%
1036 /// ```
1037 ///
1038 /// ## Integration Example
1039 /// ```rust
1040 /// use bubbletea_widgets::progress::{new, with_solid_fill};
1041 ///
1042 /// let progress = new(&[
1043 /// with_solid_fill("#2ecc71".to_string()),
1044 /// ]);
1045 ///
1046 /// // Use in your application's view method
1047 /// fn render_ui(progress: &bubbletea_widgets::progress::Model) -> String {
1048 /// format!("Download Progress:\n{}\n", progress.view())
1049 /// }
1050 /// ```
1051 pub fn view(&self) -> String {
1052 self.view_as(self.percent_shown)
1053 }
1054
1055 /// Renders the progress bar with a specific percentage value.
1056 ///
1057 /// This method bypasses the animation system and renders the progress bar
1058 /// at exactly the specified percentage. This is useful for static displays,
1059 /// testing, or when you want to show progress without animation.
1060 ///
1061 /// # Arguments
1062 ///
1063 /// * `percent` - Progress percentage as a float between 0.0 and 1.0
1064 ///
1065 /// # Returns
1066 ///
1067 /// A formatted string containing the styled progress bar at the exact percentage.
1068 ///
1069 /// # Examples
1070 ///
1071 /// ## Static Progress Display
1072 /// ```rust
1073 /// use bubbletea_widgets::progress::new;
1074 ///
1075 /// let progress = new(&[]);
1076 ///
1077 /// // Show various progress levels without animation
1078 /// let empty = progress.view_as(0.0); // [░░░░░░░] 0%
1079 /// let half = progress.view_as(0.5); // [███░░░░] 50%
1080 /// let full = progress.view_as(1.0); // [███████] 100%
1081 /// ```
1082 ///
1083 /// ## Testing Different Progress Values
1084 /// ```rust
1085 /// use bubbletea_widgets::progress::{new, with_width, without_percentage};
1086 ///
1087 /// let progress = new(&[
1088 /// with_width(10),
1089 /// without_percentage(),
1090 /// ]);
1091 ///
1092 /// // Test various progress levels
1093 /// assert_eq!(progress.view_as(0.0).contains('█'), false);
1094 /// assert_eq!(progress.view_as(1.0).contains('░'), false);
1095 /// ```
1096 ///
1097 /// ## Dynamic Progress Calculation
1098 /// ```rust
1099 /// use bubbletea_widgets::progress::new;
1100 ///
1101 /// let progress = new(&[]);
1102 ///
1103 /// // Show progress based on calculation
1104 /// let completed_items = 7;
1105 /// let total_items = 10;
1106 /// let percentage = completed_items as f64 / total_items as f64;
1107 ///
1108 /// let view = progress.view_as(percentage);
1109 /// println!("Tasks: {}", view); // Shows 70% progress
1110 /// ```
1111 pub fn view_as(&self, percent: f64) -> String {
1112 let percent_view = self.percentage_view(percent);
1113 // Use visible width (ignoring ANSI escape sequences and wide chars)
1114 let percent_width = lipgloss::width_visible(&percent_view) as i32;
1115 let bar_view = self.bar_view(percent, percent_width);
1116
1117 format!("{}{}", bar_view, percent_view)
1118 }
1119
1120 /// Returns whether the progress bar is currently animating.
1121 ///
1122 /// This method checks if the progress bar is in the middle of an animated
1123 /// transition between progress values. It returns `false` when the visual
1124 /// progress has reached equilibrium with the target percentage.
1125 ///
1126 /// # Returns
1127 ///
1128 /// - `true` if the progress bar is currently animating
1129 /// - `false` if the animation has completed or no animation is in progress
1130 ///
1131 /// # Examples
1132 ///
1133 /// ## Checking Animation State
1134 /// ```rust
1135 /// use bubbletea_widgets::progress::new;
1136 ///
1137 /// let mut progress = new(&[]);
1138 ///
1139 /// // Initially not animating
1140 /// assert!(!progress.is_animating());
1141 ///
1142 /// // Start an animation
1143 /// let cmd = progress.set_percent(0.8);
1144 /// assert!(progress.is_animating()); // Now animating
1145 ///
1146 /// // After animation completes (in a real app, this happens via update())
1147 /// // assert!(!progress.is_animating());
1148 /// ```
1149 ///
1150 /// ## Conditional Rendering
1151 /// ```rust
1152 /// use bubbletea_widgets::progress::new;
1153 ///
1154 /// let progress = new(&[]);
1155 ///
1156 /// // Show different UI based on animation state
1157 /// if progress.is_animating() {
1158 /// println!("Progress updating...");
1159 /// } else {
1160 /// println!("Progress stable at {}%", (progress.percent() * 100.0) as i32);
1161 /// }
1162 /// ```
1163 ///
1164 /// ## Performance Optimization
1165 /// ```rust
1166 /// use bubbletea_widgets::progress::new;
1167 ///
1168 /// fn should_update_ui(progress: &bubbletea_widgets::progress::Model) -> bool {
1169 /// // Only redraw UI if progress is changing
1170 /// progress.is_animating()
1171 /// }
1172 /// ```
1173 pub fn is_animating(&self) -> bool {
1174 let dist = (self.percent_shown - self.target_percent).abs();
1175 // Match Go logic: stop when close to equilibrium and velocity is low
1176 !(dist < 0.001 && self.velocity < 0.01)
1177 }
1178
1179 /// Internal method to create next frame command
1180 fn next_frame(&self) -> Cmd {
1181 let id = self.id;
1182 let tag = self.tag;
1183 let duration = Duration::from_nanos(1_000_000_000 / FPS as u64);
1184
1185 bubbletea_tick(duration, move |_| Box::new(FrameMsg { id, tag }) as Msg)
1186 }
1187
1188 /// Internal method to render the progress bar
1189 fn bar_view(&self, percent: f64, text_width: i32) -> String {
1190 let tw = std::cmp::max(0, self.width - text_width); // total width
1191 let fw = std::cmp::max(0, std::cmp::min(tw, ((tw as f64) * percent).round() as i32)); // filled width
1192
1193 let mut result = String::new();
1194
1195 if self.use_ramp {
1196 // Proper gradient fill using perceptual blending via lipgloss
1197 let total_width_for_gradient = if self.scale_ramp { fw } else { tw };
1198 let grad_len = std::cmp::max(2, total_width_for_gradient) as usize;
1199
1200 let start = LGColor::from(self.ramp_color_a.as_str());
1201 let end = LGColor::from(self.ramp_color_b.as_str());
1202 let gradient_colors = blend_1d(grad_len, vec![start, end]);
1203
1204 if fw == 1 {
1205 // Choose middle of the gradient for width=1, matching Go's 0.5 choice
1206 let mid_idx = (grad_len as f64 * 0.5).floor() as usize;
1207 let mid_idx = std::cmp::min(mid_idx, grad_len - 1);
1208 let styled = Style::new()
1209 .foreground(gradient_colors[mid_idx].clone())
1210 .render(&self.full.to_string());
1211 result.push_str(&styled);
1212 } else {
1213 for i in 0..fw as usize {
1214 let idx = i; // gradient indexed from left
1215 let color_idx = std::cmp::min(idx, grad_len - 1);
1216 let styled = Style::new()
1217 .foreground(gradient_colors[color_idx].clone())
1218 .render(&self.full.to_string());
1219 result.push_str(&styled);
1220 }
1221 }
1222 } else {
1223 // Solid fill
1224 let styled = Style::new()
1225 .foreground(lipgloss::Color::from(self.full_color.as_str()))
1226 .render(&self.full.to_string());
1227 result.push_str(&styled.repeat(fw as usize));
1228 }
1229
1230 // Empty fill
1231 let empty_styled = Style::new()
1232 .foreground(lipgloss::Color::from(self.empty_color.as_str()))
1233 .render(&self.empty.to_string());
1234 let n = std::cmp::max(0, tw - fw);
1235 result.push_str(&empty_styled.repeat(n as usize));
1236
1237 result
1238 }
1239
1240 /// Internal method to render percentage view
1241 fn percentage_view(&self, percent: f64) -> String {
1242 if !self.show_percentage {
1243 return String::new();
1244 }
1245
1246 let percent = percent.clamp(0.0, 1.0);
1247 let percentage = format!(" {:3.0}%", percent * 100.0); // Simplified format
1248 self.percentage_style.render(&percentage)
1249 }
1250
1251 /// Internal method to set gradient colors
1252 fn set_ramp(&mut self, color_a: String, color_b: String, scaled: bool) {
1253 self.use_ramp = true;
1254 self.scale_ramp = scaled;
1255 self.ramp_color_a = color_a;
1256 self.ramp_color_b = color_b;
1257 }
1258}
1259
1260impl BubbleTeaModel for Model {
1261 fn init() -> (Self, std::option::Option<Cmd>) {
1262 let model = new(&[]);
1263 (model, std::option::Option::None)
1264 }
1265
1266 fn update(&mut self, msg: Msg) -> std::option::Option<Cmd> {
1267 self.update(msg)
1268 }
1269
1270 fn view(&self) -> String {
1271 self.view()
1272 }
1273}
1274
1275impl Default for Model {
1276 fn default() -> Self {
1277 new(&[])
1278 }
1279}
1280
1281#[cfg(test)]
1282#[allow(deprecated)]
1283mod tests {
1284 use super::*;
1285 use crate::progress::{
1286 new, new_model, with_default_gradient, with_fill_characters, with_gradient,
1287 with_solid_fill, with_spring_options, with_width, without_percentage, FrameMsg,
1288 };
1289
1290 #[test]
1291 fn test_new_with_no_options() {
1292 // Test Go's: New()
1293 let progress = new(&[]);
1294
1295 assert_eq!(progress.width, DEFAULT_WIDTH);
1296 assert_eq!(progress.full, '█');
1297 assert_eq!(progress.empty, '░');
1298 assert_eq!(progress.full_color, "#7571F9");
1299 assert_eq!(progress.empty_color, "#606060");
1300 assert!(progress.show_percentage);
1301 assert_eq!(progress.percent_format, " %3.0f%%");
1302 assert!(!progress.use_ramp);
1303 assert_eq!(progress.percent(), 0.0);
1304 }
1305
1306 #[test]
1307 fn test_new_with_width() {
1308 // Test Go's: New(WithWidth(60))
1309 let progress = new(&[with_width(60)]);
1310 assert_eq!(progress.width, 60);
1311 }
1312
1313 #[test]
1314 fn test_new_with_solid_fill() {
1315 // Test Go's: New(WithSolidFill("#ff0000"))
1316 let progress = new(&[with_solid_fill("#ff0000".to_string())]);
1317 assert_eq!(progress.full_color, "#ff0000");
1318 assert!(!progress.use_ramp);
1319 }
1320
1321 #[test]
1322 fn test_new_with_fill_characters() {
1323 // Test Go's: New(WithFillCharacters('▓', '▒'))
1324 let progress = new(&[with_fill_characters('▓', '▒')]);
1325 assert_eq!(progress.full, '▓');
1326 assert_eq!(progress.empty, '▒');
1327 }
1328
1329 #[test]
1330 fn test_new_without_percentage() {
1331 // Test Go's: New(WithoutPercentage())
1332 let progress = new(&[without_percentage()]);
1333 assert!(!progress.show_percentage);
1334 }
1335
1336 #[test]
1337 fn test_new_with_gradient() {
1338 // Test Go's: New(WithGradient("#ff0000", "#0000ff"))
1339 let progress = new(&[with_gradient("#ff0000".to_string(), "#0000ff".to_string())]);
1340 assert!(progress.use_ramp);
1341 assert_eq!(progress.ramp_color_a, "#ff0000");
1342 assert_eq!(progress.ramp_color_b, "#0000ff");
1343 assert!(!progress.scale_ramp);
1344 }
1345
1346 #[test]
1347 fn test_new_with_default_gradient() {
1348 // Test Go's: New(WithDefaultGradient())
1349 let progress = new(&[with_default_gradient()]);
1350 assert!(progress.use_ramp);
1351 assert_eq!(progress.ramp_color_a, "#5A56E0");
1352 assert_eq!(progress.ramp_color_b, "#EE6FF8");
1353 }
1354
1355 #[test]
1356 fn test_new_with_spring_options() {
1357 // Test Go's: New(WithSpringOptions(20.0, 0.8))
1358 let progress = new(&[with_spring_options(20.0, 0.8)]);
1359 assert!(progress.spring_customized);
1360 assert_eq!(progress.spring.frequency, 20.0);
1361 assert_eq!(progress.spring.damping, 0.8);
1362 }
1363
1364 #[test]
1365 fn test_new_with_multiple_options() {
1366 // Test Go's: New(WithWidth(80), WithSolidFill("#00ff00"), WithoutPercentage())
1367 let progress = new(&[
1368 with_width(80),
1369 with_solid_fill("#00ff00".to_string()),
1370 without_percentage(),
1371 ]);
1372
1373 assert_eq!(progress.width, 80);
1374 assert_eq!(progress.full_color, "#00ff00");
1375 assert!(!progress.show_percentage);
1376 assert!(!progress.use_ramp);
1377 }
1378
1379 #[test]
1380 fn test_deprecated_new_model() {
1381 // Test Go's deprecated: NewModel()
1382 #[allow(deprecated)]
1383 let progress = new_model(&[with_width(50)]);
1384 assert_eq!(progress.width, 50);
1385 }
1386
1387 #[test]
1388 fn test_percent_method() {
1389 // Test Go's: Percent() float64
1390 let mut progress = new(&[]);
1391 assert_eq!(progress.percent(), 0.0);
1392
1393 std::mem::drop(progress.set_percent(0.75));
1394 assert_eq!(progress.percent(), 0.75);
1395 }
1396
1397 #[test]
1398 fn test_set_percent() {
1399 // Test Go's: SetPercent(p float64) tea.Cmd
1400 let mut progress = new(&[]);
1401
1402 // Should clamp values
1403 std::mem::drop(progress.set_percent(1.5)); // Over 1.0
1404 assert_eq!(progress.percent(), 1.0);
1405
1406 std::mem::drop(progress.set_percent(-0.5)); // Under 0.0
1407 assert_eq!(progress.percent(), 0.0);
1408
1409 std::mem::drop(progress.set_percent(0.5)); // Normal value
1410 assert_eq!(progress.percent(), 0.5);
1411
1412 // Should increment tag
1413 let original_tag = progress.tag;
1414 std::mem::drop(progress.set_percent(0.6));
1415 assert_eq!(progress.tag, original_tag + 1);
1416 }
1417
1418 #[test]
1419 fn test_incr_percent() {
1420 // Test Go's: IncrPercent(v float64) tea.Cmd
1421 let mut progress = new(&[]);
1422 std::mem::drop(progress.set_percent(0.3));
1423
1424 std::mem::drop(progress.incr_percent(0.2));
1425 assert_eq!(progress.percent(), 0.5);
1426
1427 // Should clamp at 1.0
1428 std::mem::drop(progress.incr_percent(0.8));
1429 assert_eq!(progress.percent(), 1.0);
1430 }
1431
1432 #[test]
1433 fn test_decr_percent() {
1434 // Test Go's: DecrPercent(v float64) tea.Cmd
1435 let mut progress = new(&[]);
1436 std::mem::drop(progress.set_percent(0.7));
1437
1438 std::mem::drop(progress.decr_percent(0.2));
1439 assert!((progress.percent() - 0.5).abs() < 1e-9);
1440
1441 // Should clamp at 0.0
1442 std::mem::drop(progress.decr_percent(0.8));
1443 assert_eq!(progress.percent(), 0.0);
1444 }
1445
1446 #[test]
1447 fn test_set_spring_options() {
1448 // Test Go's: SetSpringOptions(frequency, damping float64)
1449 let mut progress = new(&[]);
1450 progress.set_spring_options(25.0, 1.5);
1451
1452 assert_eq!(progress.spring.frequency, 25.0);
1453 assert_eq!(progress.spring.damping, 1.5);
1454 assert_eq!(progress.spring.fps, FPS as f64);
1455 }
1456
1457 #[test]
1458 fn test_is_animating() {
1459 // Test Go's: IsAnimating() bool
1460 let mut progress = new(&[]);
1461
1462 // Initially not animating (at equilibrium)
1463 assert!(!progress.is_animating());
1464
1465 // After setting target, should be animating if there's a difference
1466 std::mem::drop(progress.set_percent(0.5));
1467 // Since percent_shown is still 0.0 and target is 0.5, should be animating
1468 assert!(progress.is_animating());
1469
1470 // When at equilibrium, should not be animating
1471 progress.percent_shown = 0.5;
1472 progress.velocity = 0.0;
1473 assert!(!progress.is_animating());
1474 }
1475
1476 #[test]
1477 fn test_update_with_frame_msg() {
1478 // Test Go's: Update with FrameMsg
1479 let mut progress = new(&[]);
1480 std::mem::drop(progress.set_percent(0.5)); // Set target
1481
1482 let frame_msg = FrameMsg {
1483 id: progress.id,
1484 tag: progress.tag,
1485 };
1486
1487 let result = progress.update(Box::new(frame_msg));
1488 assert!(result.is_some()); // Should return next frame command if animating
1489 }
1490
1491 #[test]
1492 fn test_update_with_wrong_id() {
1493 // Test that progress rejects FrameMsg with wrong ID
1494 let mut progress = new(&[]);
1495
1496 let wrong_frame = FrameMsg {
1497 id: progress.id + 999, // Wrong ID
1498 tag: progress.tag,
1499 };
1500
1501 let result = progress.update(Box::new(wrong_frame));
1502 assert!(result.is_none()); // Should reject
1503 }
1504
1505 #[test]
1506 fn test_update_with_wrong_tag() {
1507 // Test that progress rejects FrameMsg with wrong tag
1508 let mut progress = new(&[]);
1509
1510 let wrong_frame = FrameMsg {
1511 id: progress.id,
1512 tag: progress.tag + 999, // Wrong tag
1513 };
1514
1515 let result = progress.update(Box::new(wrong_frame));
1516 assert!(result.is_none()); // Should reject
1517 }
1518
1519 #[test]
1520 fn test_view_basic() {
1521 // Test Go's: View() string
1522 let progress = new(&[with_width(10)]);
1523 let view = progress.view();
1524
1525 // Should contain progress bar characters
1526 assert!(view.contains('░')); // Empty char
1527 // At 0%, should be mostly empty
1528 let empty_count = view.chars().filter(|&c| c == '░').count();
1529 assert!(empty_count > 0);
1530 }
1531
1532 #[test]
1533 fn test_view_as() {
1534 // Test Go's: ViewAs(percent float64) string
1535 let progress = new(&[with_width(10)]);
1536
1537 // Test with specific percentage
1538 let view_50 = progress.view_as(0.5);
1539 let view_100 = progress.view_as(1.0);
1540
1541 // 100% should have more filled chars than 50%
1542 let filled_50 = view_50.chars().filter(|&c| c == '█').count();
1543 let filled_100 = view_100.chars().filter(|&c| c == '█').count();
1544 assert!(filled_100 > filled_50);
1545 }
1546
1547 #[test]
1548 fn test_view_without_percentage() {
1549 // Test view without percentage display
1550 let progress = new(&[without_percentage(), with_width(10)]);
1551 let view = progress.view_as(0.5);
1552
1553 // Should not contain percentage text
1554 assert!(!view.contains('%'));
1555 }
1556
1557 #[test]
1558 fn test_view_with_percentage() {
1559 // Test view with percentage display
1560 let progress = new(&[with_width(10)]); // Default includes percentage
1561 let view = progress.view_as(0.75);
1562
1563 // Should contain percentage text
1564 assert!(view.contains('%'));
1565 assert!(view.contains("75")); // 75%
1566 }
1567
1568 #[test]
1569 fn test_spring_animation_physics() {
1570 // Test that spring physics work correctly
1571 let spring = Spring::new(60.0, 10.0, 1.0);
1572
1573 // Test movement towards target
1574 let (new_pos, _new_vel) = spring.update(0.0, 0.0, 1.0);
1575
1576 // Should move towards target (1.0) from 0.0
1577 assert!(new_pos > 0.0);
1578 assert!(new_pos < 1.0); // Shouldn't overshoot immediately
1579 }
1580
1581 #[test]
1582 fn test_bar_view_width_calculation() {
1583 // Test that bar width calculations match Go logic
1584 let progress = new(&[with_width(20), without_percentage()]);
1585
1586 let view_0 = progress.view_as(0.0); // 0%
1587 let view_50 = progress.view_as(0.5); // 50%
1588 let view_100 = progress.view_as(1.0); // 100%
1589
1590 // All views should have same total length based on visible characters
1591 assert_eq!(lipgloss::width_visible(&view_0), 20);
1592 assert_eq!(lipgloss::width_visible(&view_50), 20);
1593 assert_eq!(lipgloss::width_visible(&view_100), 20);
1594
1595 // 0% should be all empty, 100% should be all full
1596 let bar_0 = progress.bar_view(0.0, 0);
1597 let bar_100 = progress.bar_view(1.0, 0);
1598 let bar_0_clean = lipgloss::strip_ansi(&bar_0);
1599 let bar_100_clean = lipgloss::strip_ansi(&bar_100);
1600 assert!(bar_0_clean.chars().all(|c| c == '░' || c.is_whitespace()));
1601 assert!(bar_100_clean.chars().all(|c| c == '█' || c.is_whitespace()));
1602 }
1603
1604 #[test]
1605 fn test_gradient_vs_solid_fill() {
1606 // Test difference between gradient and solid fill
1607 let solid = new(&[
1608 with_solid_fill("#ff0000".to_string()),
1609 with_width(10),
1610 without_percentage(),
1611 ]);
1612 let gradient = new(&[
1613 with_gradient("#ff0000".to_string(), "#00ff00".to_string()),
1614 with_width(10),
1615 without_percentage(),
1616 ]);
1617
1618 assert!(!solid.use_ramp);
1619 assert!(gradient.use_ramp);
1620
1621 // Both should render something at 50%
1622 let solid_view = solid.view_as(0.5);
1623 let gradient_view = gradient.view_as(0.5);
1624
1625 assert!(!solid_view.is_empty());
1626 assert!(!gradient_view.is_empty());
1627 }
1628
1629 #[test]
1630 fn test_gradient_first_last_colors_match() {
1631 // Skip when colors are disabled in the environment (common in CI)
1632 if std::env::var("NO_COLOR").is_ok() || std::env::var("NOCOLOR").is_ok() {
1633 return;
1634 }
1635 // Parity with Go's TestGradient: first and last color at 100% should match endpoints
1636 const RESET: &str = "\x1b[0m";
1637 let col_a = "#FF0000";
1638 let col_b = "#00FF00";
1639
1640 for scale in [false, true] {
1641 for &w in &[3, 5, 50] {
1642 let mut opts = vec![without_percentage(), with_width(w)];
1643 if scale {
1644 opts.push(with_scaled_gradient(col_a.to_string(), col_b.to_string()));
1645 } else {
1646 opts.push(with_gradient(col_a.to_string(), col_b.to_string()));
1647 }
1648
1649 let p = new(&opts);
1650 let res = p.view_as(1.0);
1651
1652 // Extract color sequences by splitting at Full + RESET
1653 let splitter = format!("{}{}", p.full, RESET);
1654 let mut colors: Vec<&str> = res.split(&splitter).collect();
1655 if !colors.is_empty() {
1656 // Discard last empty part after the final split
1657 colors.pop();
1658 }
1659
1660 // Build expected first/last color sequences using style rendering
1661 let expected_first_full = lipgloss::Style::new()
1662 .foreground(lipgloss::Color::from(col_a))
1663 .render(&p.full.to_string());
1664 let expected_last_full = lipgloss::Style::new()
1665 .foreground(lipgloss::Color::from(col_b))
1666 .render(&p.full.to_string());
1667
1668 let exp_first = expected_first_full
1669 .split(&format!("{}{}", p.full, RESET))
1670 .next()
1671 .unwrap_or("");
1672 let exp_last = expected_last_full
1673 .split(&format!("{}{}", p.full, RESET))
1674 .next()
1675 .unwrap_or("");
1676
1677 // Sanity: need at least width items
1678 assert!(colors.len() >= (w as usize).saturating_sub(0));
1679
1680 // Compare first and last color control sequences
1681 let first_color = colors.first().copied().unwrap_or("");
1682 let last_color = colors.last().copied().unwrap_or("");
1683 assert_eq!(
1684 exp_first, first_color,
1685 "first gradient color should match start"
1686 );
1687 assert_eq!(exp_last, last_color, "last gradient color should match end");
1688 }
1689 }
1690 }
1691
1692 #[test]
1693 fn test_unique_ids() {
1694 // Test that multiple progress bars get unique IDs
1695 let progress1 = new(&[]);
1696 let progress2 = new(&[]);
1697
1698 assert_ne!(progress1.id, progress2.id);
1699 }
1700
1701 #[test]
1702 fn test_default_implementation() {
1703 // Test Default trait implementation
1704 let progress = Model::default();
1705 assert_eq!(progress.width, DEFAULT_WIDTH);
1706 assert_eq!(progress.percent(), 0.0);
1707 }
1708}