fast_rich/progress/
columns.rs

1use crate::progress::Task;
2// use crate::progress::bar::ProgressBar;
3use crate::progress::spinner::{Spinner, SpinnerStyle};
4use crate::style::{Color, Style};
5use crate::text::Span;
6use std::fmt::Debug;
7use std::time::Duration;
8
9/// A trait for rendering a column in a progress bar.
10pub trait ProgressColumn: Send + Sync + Debug {
11    /// Render the column for the given task.
12    fn render(&self, task: &Task) -> Vec<Span>;
13
14    /// Render the column with an available width hint.
15    ///
16    /// Columns that can expand (like BarColumn with `expand: true`) should
17    /// override this to use the provided width. Default implementation
18    /// ignores the width and calls `render()`.
19    fn render_with_width(&self, task: &Task, _available_width: Option<usize>) -> Vec<Span> {
20        self.render(task)
21    }
22
23    /// Returns true if this column should expand to fill available width.
24    fn is_expandable(&self) -> bool {
25        false
26    }
27
28    /// Get the minimum width this column needs.
29    fn min_width(&self) -> usize {
30        0
31    }
32}
33
34/// Renders a static text string or text based on task properties.
35#[derive(Debug)]
36pub struct TextColumn {
37    text: String,
38    style: Style,
39}
40
41impl TextColumn {
42    pub fn new(text: &str) -> Self {
43        Self {
44            text: text.to_string(),
45            style: Style::new(),
46        }
47    }
48
49    pub fn styled(text: &str, style: Style) -> Self {
50        Self {
51            text: text.to_string(),
52            style,
53        }
54    }
55}
56
57impl ProgressColumn for TextColumn {
58    fn render(&self, task: &Task) -> Vec<Span> {
59        // Simple interpolation for now task.description
60        let text = if self.text == "[progress.description]" {
61            &task.description
62        } else {
63            &self.text
64        };
65
66        vec![Span::styled(text.clone(), self.style)]
67    }
68}
69
70/// Renders the progress bar with distinct filled/unfilled characters.
71///
72/// Uses Unicode box-drawing characters for clear visual distinction:
73/// - `━` (U+2501) for filled portion
74/// - `╸` (U+257A) for edge pointer (shows progress position)
75/// - `─` (U+2500) for unfilled portion
76///
77/// For indeterminate tasks (no total), shows a pulsing animation.
78///
79/// Use `expand(true)` to make the bar expand to fill available width.
80/// Use `use_sub_blocks(true)` for smoother progress using 8th-block Unicode characters.
81#[derive(Debug)]
82pub struct BarColumn {
83    /// Width of the bar in characters (used as min_width when expand is true)
84    pub bar_width: usize,
85    /// Character for filled portion (default: '━')
86    pub complete_char: char,
87    /// Character for unfilled portion (default: '─')
88    pub incomplete_char: char,
89    /// Optional edge pointer character (default: Some('╸'))
90    pub edge_char: Option<char>,
91    /// Style for completed portion
92    pub complete_style: Style,
93    /// Style for finished tasks
94    pub finished_style: Option<Style>,
95    /// Style for unfilled portion
96    pub incomplete_style: Style,
97    /// Style for pulse animation (indeterminate)
98    pub pulse_style: Style,
99    /// If true, expand to fill available width
100    pub expand: bool,
101    /// If true, use sub-character (8th-block) Unicode for smoother progress
102    pub use_sub_blocks: bool,
103}
104
105impl Default for BarColumn {
106    fn default() -> Self {
107        Self::new(40)
108    }
109}
110
111impl BarColumn {
112    pub fn new(bar_width: usize) -> Self {
113        Self {
114            bar_width,
115            complete_char: '━',   // Heavy horizontal
116            incomplete_char: '─', // Light horizontal (distinct!)
117            edge_char: Some('╸'), // Heavy left (pointer)
118            complete_style: Style::new().foreground(Color::Magenta),
119            finished_style: Some(Style::new().foreground(Color::Green)),
120            incomplete_style: Style::new().foreground(Color::Ansi256(237)), // Dark grey
121            pulse_style: Style::new().foreground(Color::Cyan),
122            expand: false,
123            use_sub_blocks: false,
124        }
125    }
126
127    /// Set the complete character
128    pub fn complete_char(mut self, c: char) -> Self {
129        self.complete_char = c;
130        self
131    }
132
133    /// Set the incomplete character
134    pub fn incomplete_char(mut self, c: char) -> Self {
135        self.incomplete_char = c;
136        self
137    }
138
139    /// Set the edge pointer character (or None to disable)
140    pub fn edge_char(mut self, c: Option<char>) -> Self {
141        self.edge_char = c;
142        self
143    }
144
145    /// Set the style for completed portion
146    pub fn complete_style(mut self, style: Style) -> Self {
147        self.complete_style = style;
148        self
149    }
150
151    /// Set the style for finished tasks
152    pub fn finished_style(mut self, style: Option<Style>) -> Self {
153        self.finished_style = style;
154        self
155    }
156
157    /// Set whether the bar should expand to fill available width.
158    ///
159    /// When expand is true, the bar will use the remaining terminal width
160    /// after other columns are rendered. The `bar_width` becomes the minimum width.
161    pub fn expand(mut self, expand: bool) -> Self {
162        self.expand = expand;
163        self
164    }
165
166    /// Enable sub-character progress using 8th-block Unicode characters.
167    ///
168    /// When enabled, uses characters like ▏▎▍▌▋▊▉█ for smoother
169    /// progress rendering with 8 levels of precision per character.
170    pub fn use_sub_blocks(mut self, enabled: bool) -> Self {
171        self.use_sub_blocks = enabled;
172        self
173    }
174
175    /// Get the sub-block character for a given fraction (0-7).
176    /// Returns full blocks for full progress, partial blocks otherwise.
177    fn sub_block_char(eighths: usize) -> char {
178        match eighths {
179            0 => ' ', // Empty
180            1 => '▏', // 1/8 block (U+258F)
181            2 => '▎', // 2/8 block (U+258E)
182            3 => '▍', // 3/8 block (U+258D)
183            4 => '▌', // 4/8 block (U+258C)
184            5 => '▋', // 5/8 block (U+258B)
185            6 => '▊', // 6/8 block (U+258A)
186            7 => '▉', // 7/8 block (U+2589)
187            _ => '█', // Full block (U+2588)
188        }
189    }
190
191    /// Render the bar with a specific width
192    fn render_bar(&self, task: &Task, width: usize) -> Vec<Span> {
193        // Handle indeterminate progress (no total)
194        if task.total.is_none() && !task.finished {
195            return self.render_pulse_with_width(task, width);
196        }
197
198        let total = task.total.unwrap_or(100) as f64;
199        let completed = task.completed as f64;
200        let percentage = (completed / total).clamp(0.0, 1.0);
201
202        let style = if task.finished {
203            self.finished_style.unwrap_or(self.complete_style)
204        } else {
205            self.complete_style
206        };
207
208        // Use sub-block rendering if enabled
209        if self.use_sub_blocks && !task.finished && percentage < 1.0 {
210            return self.render_sub_blocks(percentage, width, style);
211        }
212
213        // Calculate filled width, accounting for optional edge character
214        let has_edge = self.edge_char.is_some() && !task.finished && percentage < 1.0;
215        let effective_width = if has_edge {
216            width.saturating_sub(1)
217        } else {
218            width
219        };
220
221        let filled_width = (effective_width as f64 * percentage).round() as usize;
222        let empty_width = effective_width.saturating_sub(filled_width);
223
224        let mut spans = Vec::new();
225
226        // Filled portion
227        if filled_width > 0 {
228            spans.push(Span::styled(
229                self.complete_char.to_string().repeat(filled_width),
230                style,
231            ));
232        }
233
234        // Edge pointer (shows current progress position)
235        if has_edge && filled_width < width {
236            if let Some(edge) = self.edge_char {
237                spans.push(Span::styled(edge.to_string(), style));
238            }
239        }
240
241        // Unfilled portion (using DIFFERENT character)
242        if empty_width > 0 {
243            spans.push(Span::styled(
244                self.incomplete_char.to_string().repeat(empty_width),
245                self.incomplete_style,
246            ));
247        }
248
249        spans
250    }
251
252    /// Render progress bar with sub-character (8th-block) precision.
253    fn render_sub_blocks(&self, percentage: f64, width: usize, style: Style) -> Vec<Span> {
254        // Calculate the exact fractional position
255        let exact_filled = width as f64 * percentage;
256        let full_blocks = exact_filled as usize;
257        let fraction = exact_filled - full_blocks as f64;
258        let eighths = (fraction * 8.0).round() as usize;
259
260        let mut spans = Vec::new();
261
262        // Full filled blocks (using block character)
263        if full_blocks > 0 {
264            spans.push(Span::styled("█".repeat(full_blocks), style));
265        }
266
267        // Partial block for fractional progress
268        if eighths > 0 && full_blocks < width {
269            spans.push(Span::styled(
270                Self::sub_block_char(eighths).to_string(),
271                style,
272            ));
273        }
274
275        // Empty portion
276        let used_width = full_blocks + if eighths > 0 { 1 } else { 0 };
277        let empty_width = width.saturating_sub(used_width);
278        if empty_width > 0 {
279            spans.push(Span::styled(" ".repeat(empty_width), self.incomplete_style));
280        }
281
282        spans
283    }
284
285    /// Render pulse animation with a specific width
286    fn render_pulse_with_width(&self, task: &Task, width: usize) -> Vec<Span> {
287        let pulse_width = 6.min(width / 3);
288
289        let elapsed_ms = task.elapsed().as_millis() as usize;
290        let cycle_duration_ms = 1500;
291        let position_in_cycle = elapsed_ms % cycle_duration_ms;
292
293        let half_cycle = cycle_duration_ms / 2;
294        let normalized_pos = if position_in_cycle < half_cycle {
295            position_in_cycle as f64 / half_cycle as f64
296        } else {
297            1.0 - ((position_in_cycle - half_cycle) as f64 / half_cycle as f64)
298        };
299
300        let pulse_start =
301            ((width.saturating_sub(pulse_width)) as f64 * normalized_pos).round() as usize;
302        let pulse_end = pulse_start + pulse_width;
303
304        let mut spans = Vec::new();
305
306        if pulse_start > 0 {
307            spans.push(Span::styled(
308                self.incomplete_char.to_string().repeat(pulse_start),
309                self.incomplete_style,
310            ));
311        }
312
313        spans.push(Span::styled(
314            self.complete_char.to_string().repeat(pulse_width),
315            self.pulse_style,
316        ));
317
318        let after_pulse = width.saturating_sub(pulse_end);
319        if after_pulse > 0 {
320            spans.push(Span::styled(
321                self.incomplete_char.to_string().repeat(after_pulse),
322                self.incomplete_style,
323            ));
324        }
325
326        spans
327    }
328}
329
330impl ProgressColumn for BarColumn {
331    fn render(&self, task: &Task) -> Vec<Span> {
332        self.render_bar(task, self.bar_width)
333    }
334
335    fn render_with_width(&self, task: &Task, available_width: Option<usize>) -> Vec<Span> {
336        let width = if self.expand {
337            available_width
338                .unwrap_or(self.bar_width)
339                .max(self.bar_width)
340        } else {
341            self.bar_width
342        };
343        self.render_bar(task, width)
344    }
345
346    fn is_expandable(&self) -> bool {
347        self.expand
348    }
349
350    fn min_width(&self) -> usize {
351        self.bar_width
352    }
353}
354
355/// Renders the percentage complete (e.g. "50%").
356#[derive(Debug)]
357pub struct PercentageColumn(pub Style);
358
359impl Default for PercentageColumn {
360    fn default() -> Self {
361        Self::new()
362    }
363}
364
365impl PercentageColumn {
366    pub fn new() -> Self {
367        Self(Style::new().foreground(Color::Cyan))
368    }
369}
370
371impl ProgressColumn for PercentageColumn {
372    fn render(&self, task: &Task) -> Vec<Span> {
373        let percentage = task.percentage() * 100.0;
374        vec![Span::styled(format!("{:>3.0}%", percentage), self.0)]
375    }
376}
377
378/// Renders the spinner
379#[derive(Debug)]
380pub struct SpinnerColumn {
381    spinner: Spinner, // Use generic spinner for frames
382}
383
384impl Default for SpinnerColumn {
385    fn default() -> Self {
386        Self::new()
387    }
388}
389
390impl SpinnerColumn {
391    pub fn new() -> Self {
392        Self {
393            spinner: Spinner::new("").style(SpinnerStyle::Dots),
394        }
395    }
396
397    /// Set the spinner style.
398    ///
399    /// # Example
400    /// ```
401    /// use fast_rich::progress::{SpinnerColumn, SpinnerStyle};
402    /// let spinner = SpinnerColumn::new().with_style(SpinnerStyle::Moon);
403    /// ```
404    pub fn with_style(mut self, style: SpinnerStyle) -> Self {
405        self.spinner = Spinner::new("").style(style);
406        self
407    }
408}
409
410impl ProgressColumn for SpinnerColumn {
411    fn render(&self, task: &Task) -> Vec<Span> {
412        // We use the task's elapsed time to calculate the frame
413        // This keeps it stateless with respect to the column, but animated by the task's lifetime.
414        // For a global spinner independent of task start, we might need a shared start time.
415        // But usually spinners in task rows indicate THAT task's activity.
416
417        // However, generic Spinner uses its own start_time.
418        // We should probably rely on `SpinnerStyle` and manual calculation using task.elapsed()
419        // to avoid storing state that drifts.
420
421        // Let's copy logic from Spinner::current_frame but use task.elapsed()
422        let style = self.spinner.get_style();
423        let interval = style.interval_ms();
424        let frames = style.frames();
425        let elapsed_ms = task.elapsed().as_millis() as u64;
426        let idx = ((elapsed_ms / interval) as usize) % frames.len();
427
428        vec![Span::styled(
429            frames[idx].to_string(),
430            Style::new().foreground(Color::Green),
431        )]
432    }
433}
434
435/// Renders transfer speed
436#[derive(Debug)]
437pub struct TransferSpeedColumn;
438
439impl ProgressColumn for TransferSpeedColumn {
440    fn render(&self, task: &Task) -> Vec<Span> {
441        let speed = task.speed();
442        let speed_str = if speed >= 1_000_000.0 {
443            format!("{:.1} MB/s", speed / 1_000_000.0)
444        } else if speed >= 1_000.0 {
445            format!("{:.1} KB/s", speed / 1_000.0)
446        } else {
447            format!("{:.0} B/s", speed)
448        };
449        vec![Span::styled(speed_str, Style::new().foreground(Color::Red))]
450    }
451}
452
453/// Renders time remaining
454#[derive(Debug)]
455pub struct TimeRemainingColumn;
456
457impl ProgressColumn for TimeRemainingColumn {
458    fn render(&self, task: &Task) -> Vec<Span> {
459        let eta = match task.eta() {
460            Some(d) => format_duration(d),
461            None => "-:--:--".to_string(),
462        };
463        vec![Span::styled(eta, Style::new().foreground(Color::Cyan))]
464    }
465}
466
467fn format_duration(d: Duration) -> String {
468    let secs = d.as_secs();
469    if secs >= 3600 {
470        format!(
471            "{:02}:{:02}:{:02}",
472            secs / 3600,
473            (secs % 3600) / 60,
474            secs % 60
475        )
476    } else {
477        format!("{:02}:{:02}", secs / 60, secs % 60)
478    }
479}
480
481#[derive(Debug)]
482pub struct MofNColumn {
483    separator: String,
484}
485
486impl Default for MofNColumn {
487    fn default() -> Self {
488        Self::new()
489    }
490}
491
492impl MofNColumn {
493    pub fn new() -> Self {
494        Self {
495            separator: "/".to_string(),
496        }
497    }
498}
499
500impl ProgressColumn for MofNColumn {
501    fn render(&self, task: &Task) -> Vec<Span> {
502        let completed = task.completed;
503        let total = task.total.unwrap_or(0);
504        vec![Span::styled(
505            format!("{}{}{}", completed, self.separator, total),
506            Style::new().foreground(Color::Green),
507        )]
508    }
509}
510
511#[derive(Debug)]
512pub struct ElapsedColumn;
513
514impl ProgressColumn for ElapsedColumn {
515    fn render(&self, task: &Task) -> Vec<Span> {
516        let elapsed = task.elapsed();
517        vec![Span::styled(
518            format_duration(elapsed),
519            Style::new().foreground(Color::Cyan),
520        )]
521    }
522}
523
524/// Formats bytes into human-readable size string (e.g., "1.2 MB").
525fn format_bytes(bytes: u64) -> String {
526    const KB: f64 = 1024.0;
527    const MB: f64 = 1024.0 * 1024.0;
528    const GB: f64 = 1024.0 * 1024.0 * 1024.0;
529
530    let bytes_f = bytes as f64;
531    if bytes_f >= GB {
532        format!("{:.1} GB", bytes_f / GB)
533    } else if bytes_f >= MB {
534        format!("{:.1} MB", bytes_f / MB)
535    } else if bytes_f >= KB {
536        format!("{:.1} KB", bytes_f / KB)
537    } else {
538        format!("{} B", bytes)
539    }
540}
541
542/// Renders the completed file size (e.g., "1.2 MB").
543///
544/// Displays the current progress in human-readable bytes.
545#[derive(Debug)]
546pub struct FileSizeColumn {
547    style: Style,
548}
549
550impl Default for FileSizeColumn {
551    fn default() -> Self {
552        Self::new()
553    }
554}
555
556impl FileSizeColumn {
557    pub fn new() -> Self {
558        Self {
559            style: Style::new().foreground(Color::Green),
560        }
561    }
562
563    /// Set the style for the file size display.
564    pub fn style(mut self, style: Style) -> Self {
565        self.style = style;
566        self
567    }
568}
569
570impl ProgressColumn for FileSizeColumn {
571    fn render(&self, task: &Task) -> Vec<Span> {
572        let size_str = format_bytes(task.completed);
573        vec![Span::styled(size_str, self.style)]
574    }
575}
576
577/// Renders the file size as "completed / total" (e.g., "1.2 MB / 5.0 MB").
578///
579/// Shows both completed and total bytes in a single display.
580#[derive(Debug)]
581pub struct TotalFileSizeColumn {
582    separator: String,
583    completed_style: Style,
584    total_style: Style,
585}
586
587impl Default for TotalFileSizeColumn {
588    fn default() -> Self {
589        Self::new()
590    }
591}
592
593impl TotalFileSizeColumn {
594    pub fn new() -> Self {
595        Self {
596            separator: " / ".to_string(),
597            completed_style: Style::new().foreground(Color::Green),
598            total_style: Style::new().foreground(Color::Blue),
599        }
600    }
601
602    /// Set the separator between completed and total (default: " / ").
603    pub fn separator(mut self, sep: &str) -> Self {
604        self.separator = sep.to_string();
605        self
606    }
607
608    /// Set the style for completed bytes.
609    pub fn completed_style(mut self, style: Style) -> Self {
610        self.completed_style = style;
611        self
612    }
613
614    /// Set the style for total bytes.
615    pub fn total_style(mut self, style: Style) -> Self {
616        self.total_style = style;
617        self
618    }
619}
620
621impl ProgressColumn for TotalFileSizeColumn {
622    fn render(&self, task: &Task) -> Vec<Span> {
623        let completed_str = format_bytes(task.completed);
624        let total_str = match task.total {
625            Some(t) => format_bytes(t),
626            None => "?".to_string(),
627        };
628
629        vec![
630            Span::styled(completed_str, self.completed_style),
631            Span::styled(self.separator.clone(), Style::new()),
632            Span::styled(total_str, self.total_style),
633        ]
634    }
635}
636
637/// Renders a combined download display: "size @ speed" (e.g., "1.2 MB @ 500 KB/s").
638///
639/// Combines file size progress with transfer speed in one compact column.
640#[derive(Debug)]
641pub struct DownloadColumn {
642    size_style: Style,
643    speed_style: Style,
644}
645
646impl Default for DownloadColumn {
647    fn default() -> Self {
648        Self::new()
649    }
650}
651
652impl DownloadColumn {
653    pub fn new() -> Self {
654        Self {
655            size_style: Style::new().foreground(Color::Green),
656            speed_style: Style::new().foreground(Color::Red),
657        }
658    }
659
660    /// Set the style for file size.
661    pub fn size_style(mut self, style: Style) -> Self {
662        self.size_style = style;
663        self
664    }
665
666    /// Set the style for transfer speed.
667    pub fn speed_style(mut self, style: Style) -> Self {
668        self.speed_style = style;
669        self
670    }
671}
672
673impl ProgressColumn for DownloadColumn {
674    fn render(&self, task: &Task) -> Vec<Span> {
675        let size_str = format_bytes(task.completed);
676        let speed = task.speed();
677        let speed_str = if speed >= 1_000_000.0 {
678            format!("{:.1} MB/s", speed / 1_000_000.0)
679        } else if speed >= 1_000.0 {
680            format!("{:.1} KB/s", speed / 1_000.0)
681        } else {
682            format!("{:.0} B/s", speed)
683        };
684
685        vec![
686            Span::styled(size_str, self.size_style),
687            Span::raw(" @ "),
688            Span::styled(speed_str, self.speed_style),
689        ]
690    }
691}