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}