Skip to main content

rich/
progress.rs

1//! Progress displays.
2//!
3//! Port of `rich/progress.py`'s display and task model: a grid of tasks, one
4//! row each, whose cells come from a list of [`ProgressColumn`]s. Upstream builds
5//! a `Table.grid` (`padding=(0, 1)`); we render the equivalent inline — fixed
6//! columns take their widest cell, the bar column flexes to fill (capped at 40),
7//! and columns are separated by a single unstyled space.
8//!
9//! Time is read from an injectable clock ([`Progress::clock`], upstream's
10//! `get_time`), so elapsed time, speed, ETA and spinner frames are
11//! deterministic under test. The default clock is monotonic.
12//!
13//! Not ported yet (see docs/DIVERGENCES.md §16): the auto-refreshing `Live`
14//! integration and `track()`, the pulsing bar for unstarted or indeterminate
15//! tasks, `RenderableColumn`, per-task custom `fields` and table-column options.
16
17use std::cell::RefCell;
18use std::collections::{HashMap, VecDeque};
19use std::sync::Arc;
20
21use crate::cells::cell_len;
22use crate::console::{Console, ConsoleOptions};
23use crate::filesize;
24use crate::progress_bar::ProgressBar;
25use crate::protocol::Renderable;
26use crate::segment::Segment;
27use crate::spinner::Spinner;
28use crate::style::{Style, StyleType};
29use crate::text::Text;
30
31/// The default `BarColumn` width (upstream `bar_width=40`); the bar shrinks below
32/// this to fit, and never grows past it.
33const BAR_MAX_WIDTH: usize = 40;
34
35/// Upstream keeps at most this many speed samples per task (`deque(maxlen=1000)`).
36const MAX_SAMPLES: usize = 1000;
37
38/// A source of the current time in seconds. Upstream's `GetTimeCallable`.
39pub type GetTime = Arc<dyn Fn() -> f64 + Send + Sync>;
40
41/// The default clock: seconds on a monotonic clock (upstream `time.monotonic`).
42fn monotonic() -> f64 {
43    static START: std::sync::OnceLock<std::time::Instant> = std::sync::OnceLock::new();
44    START
45        .get_or_init(std::time::Instant::now)
46        .elapsed()
47        .as_secs_f64()
48}
49
50/// Identifies a task within one [`Progress`]. Upstream's `TaskID`.
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
52pub struct TaskId(pub usize);
53
54/// Estimated time remaining. Port of `TimeRemainingColumn`, including its
55/// half-second render cache (`max_refresh = 0.5`).
56pub struct TimeRemainingColumn {
57    compact: bool,
58    elapsed_when_finished: bool,
59    cache: RefCell<HashMap<TaskId, (f64, Text)>>,
60}
61
62impl TimeRemainingColumn {
63    /// `compact` drops the hours when there are none (`05:03`);
64    /// `elapsed_when_finished` shows the elapsed time once a task finishes.
65    pub fn new(compact: bool, elapsed_when_finished: bool) -> Self {
66        TimeRemainingColumn {
67            compact,
68            elapsed_when_finished,
69            cache: RefCell::new(HashMap::new()),
70        }
71    }
72}
73
74/// An animated spinner. Port of `SpinnerColumn`: one spinner shared by every
75/// row, whose animation starts at its first render.
76pub struct SpinnerColumn {
77    spinner: Spinner,
78    style: StyleType,
79    finished_text: String,
80}
81
82impl SpinnerColumn {
83    /// A spinner by name (e.g. `"dots"`), styled `progress.spinner`, showing
84    /// `finished_text` (console markup) once a task finishes.
85    pub fn new(name: &str, finished_text: impl Into<String>) -> Self {
86        SpinnerColumn {
87            spinner: Spinner::new(name),
88            style: StyleType::Name("progress.spinner".to_string()),
89            finished_text: finished_text.into(),
90        }
91    }
92
93    /// Animation speed multiplier (default 1.0).
94    pub fn speed(mut self, speed: f64) -> Self {
95        self.spinner = self.spinner.speed(speed);
96        self
97    }
98
99    /// Style of the spinner frame (default `progress.spinner`).
100    pub fn style(mut self, style: impl Into<StyleType>) -> Self {
101        self.style = style.into();
102        self
103    }
104}
105
106/// A column in a [`Progress`] display. Mirrors upstream's `ProgressColumn`s.
107pub enum ProgressColumn {
108    /// The task description as console markup
109    /// (`TextColumn("[progress.description]{task.description}")`).
110    Description,
111    /// A static text cell with an explicit style (a simplified `TextColumn`).
112    Text(String, Style),
113    /// The flexing progress bar (`BarColumn`).
114    Bar,
115    /// The completion percentage `"{pct:>3}%"` (default `TaskProgressColumn`).
116    Percentage,
117    /// `TaskProgressColumn(show_speed=…)`: the percentage, or for a task with no
118    /// total and `show_speed`, the rate in `it/s`.
119    TaskProgress { show_speed: bool },
120    /// `"{completed}/{total}"` (`MofNCompleteColumn`, `progress.download`).
121    MofN,
122    /// `"{completed}/{total} {unit}"` in shared SI byte units, e.g. `0.5/1.0 kB`
123    /// (`DownloadColumn`, `progress.download`).
124    Download,
125    /// As [`Download`](Self::Download) in binary units (`DownloadColumn(binary_units=True)`).
126    BinaryDownload,
127    /// Elapsed time `H:MM:SS` (`TimeElapsedColumn`, `progress.elapsed`).
128    TimeElapsed,
129    /// Estimated time remaining (`TimeRemainingColumn`, `progress.remaining`).
130    TimeRemaining(TimeRemainingColumn),
131    /// Data speed, e.g. `1.2 MB/s` (`TransferSpeedColumn`, `progress.data.speed`).
132    TransferSpeed,
133    /// Completed size in decimal units (`FileSizeColumn`, `progress.filesize`).
134    FileSize,
135    /// Total size in decimal units (`TotalFileSizeColumn`, `progress.filesize.total`).
136    TotalFileSize,
137    /// An animated spinner (`SpinnerColumn`).
138    Spinner(SpinnerColumn),
139}
140
141impl ProgressColumn {
142    /// `TimeRemainingColumn()` with upstream's defaults.
143    pub fn time_remaining() -> Self {
144        ProgressColumn::TimeRemaining(TimeRemainingColumn::new(false, false))
145    }
146
147    /// `SpinnerColumn()` with upstream's defaults (`dots`, finished text `" "`).
148    pub fn spinner() -> Self {
149        ProgressColumn::Spinner(SpinnerColumn::new("dots", " "))
150    }
151
152    fn is_bar(&self) -> bool {
153        matches!(self, ProgressColumn::Bar)
154    }
155
156    /// The cell for `task` (never called on [`ProgressColumn::Bar`]).
157    fn cell(&self, task: &Task) -> Text {
158        let named = |plain: String, style: &str| Text::styled(plain, style);
159        match self {
160            ProgressColumn::Description => {
161                let markup = format!("[progress.description]{}", task.description);
162                Text::from_markup(&markup).unwrap_or_else(|_| Text::new(task.description.clone()))
163            }
164            ProgressColumn::Text(text, style) => Text::styled(text.clone(), style.clone()),
165            ProgressColumn::Bar => unreachable!("bar column has no text cell"),
166            ProgressColumn::Percentage => task.percentage_cell(),
167            ProgressColumn::TaskProgress { show_speed } => {
168                if task.total.is_none() && *show_speed {
169                    render_speed(
170                        task.finished_speed
171                            .filter(|s| *s != 0.0)
172                            .or_else(|| task.speed()),
173                    )
174                } else {
175                    task.percentage_cell()
176                }
177            }
178            ProgressColumn::MofN => named(task.mofn_text(), "progress.download"),
179            ProgressColumn::Download => named(task.download_text(false), "progress.download"),
180            ProgressColumn::BinaryDownload => named(task.download_text(true), "progress.download"),
181            ProgressColumn::TimeElapsed => {
182                let elapsed = if task.finished() {
183                    task.finished_time
184                } else {
185                    task.elapsed()
186                };
187                let text = match elapsed {
188                    None => "-:--:--".to_string(),
189                    Some(elapsed) => timedelta(elapsed.max(0.0) as i64),
190                };
191                named(text, "progress.elapsed")
192            }
193            ProgressColumn::TimeRemaining(column) => column.render(task),
194            ProgressColumn::TransferSpeed => {
195                let speed = task
196                    .finished_speed
197                    .filter(|s| *s != 0.0)
198                    .or_else(|| task.speed());
199                let text = match speed {
200                    None => "?".to_string(),
201                    Some(speed) => format!("{}/s", filesize::decimal(speed as u64)),
202                };
203                named(text, "progress.data.speed")
204            }
205            ProgressColumn::FileSize => named(
206                filesize::decimal(task.completed as u64),
207                "progress.filesize",
208            ),
209            ProgressColumn::TotalFileSize => named(
210                task.total
211                    .map_or_else(String::new, |total| filesize::decimal(total as u64)),
212                "progress.filesize.total",
213            ),
214            ProgressColumn::Spinner(column) => {
215                if task.finished() {
216                    Text::from_markup(&column.finished_text)
217                        .unwrap_or_else(|_| Text::new(column.finished_text.clone()))
218                } else {
219                    // Upstream's `self.spinner.render(task.get_time())`: the
220                    // spinner itself starts its animation at the first render.
221                    let mut frame = column.spinner.render(task.now());
222                    frame.set_base_style(column.style.clone());
223                    frame
224                }
225            }
226        }
227    }
228}
229
230impl TimeRemainingColumn {
231    fn render(&self, task: &Task) -> Text {
232        // `ProgressColumn.__call__`: reuse a render younger than max_refresh,
233        // but only while the task has completed nothing (`not task.completed`).
234        let now = task.now();
235        if task.completed == 0.0 {
236            if let Some((timestamp, text)) = self.cache.borrow().get(&task.id) {
237                if timestamp + 0.5 > now {
238                    return text.clone();
239                }
240            }
241        }
242        let (task_time, style) = if self.elapsed_when_finished && task.finished() {
243            (task.finished_time, "progress.elapsed")
244        } else {
245            (task.time_remaining(), "progress.remaining")
246        };
247        let text = if task.total.is_none() {
248            Text::styled("", style)
249        } else {
250            match task_time {
251                None => Text::styled(if self.compact { "--:--" } else { "-:--:--" }, style),
252                Some(task_time) => {
253                    let whole = task_time as i64;
254                    let (minutes, seconds) = (whole.div_euclid(60), whole.rem_euclid(60));
255                    let (hours, minutes) = (minutes.div_euclid(60), minutes.rem_euclid(60));
256                    let formatted = if self.compact && hours == 0 {
257                        format!("{minutes:02}:{seconds:02}")
258                    } else {
259                        format!("{hours}:{minutes:02}:{seconds:02}")
260                    };
261                    Text::styled(formatted, style)
262                }
263            }
264        };
265        self.cache.borrow_mut().insert(task.id, (now, text.clone()));
266        text
267    }
268}
269
270/// `TaskProgressColumn.render_speed`: iterations per second with a power-of-ten
271/// suffix, e.g. `2.5×10³ it/s`.
272fn render_speed(speed: Option<f64>) -> Text {
273    let Some(speed) = speed else {
274        return Text::styled("", "progress.percentage");
275    };
276    let (unit, suffix) =
277        filesize::pick_unit_and_suffix(speed as u64, &["", "×10³", "×10⁶", "×10⁹", "×10¹²"], 1000);
278    let data_speed = speed / unit as f64;
279    Text::styled(
280        format!("{data_speed:.1}{suffix} it/s"),
281        "progress.percentage",
282    )
283}
284
285/// Python's `str(timedelta(seconds=n))` for `n >= 0`: `H:MM:SS`, prefixed by
286/// `N day(s), ` from a day upward.
287fn timedelta(total_seconds: i64) -> String {
288    let days = total_seconds / 86_400;
289    let rest = total_seconds % 86_400;
290    let clock = format!("{}:{:02}:{:02}", rest / 3600, rest % 3600 / 60, rest % 60);
291    match days {
292        0 => clock,
293        1 => format!("1 day, {clock}"),
294        days => format!("{days} days, {clock}"),
295    }
296}
297
298/// Python's `f"{value:,.{precision}f}"`: fixed precision with `,` grouping.
299fn grouped(value: f64, precision: usize) -> String {
300    let formatted = format!("{value:.precision$}");
301    let (sign, digits) = match formatted.strip_prefix('-') {
302        Some(rest) => ("-", rest),
303        None => ("", formatted.as_str()),
304    };
305    let (integer, fraction) = match digits.split_once('.') {
306        Some((integer, fraction)) => (integer, Some(fraction)),
307        None => (digits, None),
308    };
309    let mut grouped = String::new();
310    for (index, digit) in integer.chars().enumerate() {
311        if index > 0 && (integer.len() - index) % 3 == 0 {
312            grouped.push(',');
313        }
314        grouped.push(digit);
315    }
316    match fraction {
317        Some(fraction) => format!("{sign}{grouped}.{fraction}"),
318        None => format!("{sign}{grouped}"),
319    }
320}
321
322/// A single tracked task. Mirrors `rich.progress.Task`; read-only outside
323/// [`Progress`].
324pub struct Task {
325    id: TaskId,
326    description: String,
327    total: Option<f64>,
328    completed: f64,
329    visible: bool,
330    start_time: Option<f64>,
331    stop_time: Option<f64>,
332    finished_time: Option<f64>,
333    finished_speed: Option<f64>,
334    /// `(timestamp, completed)` speed samples (upstream `ProgressSample`).
335    samples: VecDeque<(f64, f64)>,
336    get_time: GetTime,
337}
338
339impl Task {
340    fn now(&self) -> f64 {
341        (self.get_time)()
342    }
343
344    /// This task's id.
345    pub fn id(&self) -> TaskId {
346        self.id
347    }
348
349    /// The description (console markup).
350    pub fn description(&self) -> &str {
351        &self.description
352    }
353
354    /// The total number of steps, or `None` when indeterminate.
355    pub fn total(&self) -> Option<f64> {
356        self.total
357    }
358
359    /// The number of steps completed.
360    pub fn completed(&self) -> f64 {
361        self.completed
362    }
363
364    /// Whether the task is shown.
365    pub fn visible(&self) -> bool {
366        self.visible
367    }
368
369    /// Whether the task has been started.
370    pub fn started(&self) -> bool {
371        self.start_time.is_some()
372    }
373
374    /// Steps left, or `None` when indeterminate.
375    pub fn remaining(&self) -> Option<f64> {
376        self.total.map(|total| total - self.completed)
377    }
378
379    /// Seconds since the task started (to its stop time, if stopped).
380    pub fn elapsed(&self) -> Option<f64> {
381        let start = self.start_time?;
382        Some(self.stop_time.unwrap_or_else(|| self.now()) - start)
383    }
384
385    /// Whether the task has reached its total.
386    pub fn finished(&self) -> bool {
387        self.finished_time.is_some()
388    }
389
390    /// The elapsed time recorded when the task finished.
391    pub fn finished_time(&self) -> Option<f64> {
392        self.finished_time
393    }
394
395    /// The completion percentage, clamped to 0–100 (0 without a total).
396    pub fn percentage(&self) -> f64 {
397        match self.total {
398            Some(total) if total != 0.0 => (self.completed / total * 100.0).clamp(0.0, 100.0),
399            _ => 0.0,
400        }
401    }
402
403    /// Steps per second over the sample window, or `None` without enough samples.
404    pub fn speed(&self) -> Option<f64> {
405        self.start_time?;
406        let (first, _) = *self.samples.front()?;
407        let (last, _) = *self.samples.back()?;
408        let total_time = last - first;
409        if total_time == 0.0 {
410            return None;
411        }
412        let total_completed: f64 = self.samples.iter().skip(1).map(|(_, done)| done).sum();
413        Some(total_completed / total_time)
414    }
415
416    /// Estimated seconds remaining (rounded up), 0 once finished.
417    pub fn time_remaining(&self) -> Option<f64> {
418        if self.finished() {
419            return Some(0.0);
420        }
421        let speed = self.speed().filter(|speed| *speed != 0.0)?;
422        let remaining = self.remaining()?;
423        Some((remaining / speed).ceil())
424    }
425
426    /// `Task._reset`.
427    fn clear_progress(&mut self) {
428        self.samples.clear();
429        self.finished_time = None;
430        self.finished_speed = None;
431    }
432
433    /// The percentage cell: `[progress.percentage]{percentage:>3.0f}%`, empty
434    /// without a total (`text_format_no_percentage`).
435    fn percentage_cell(&self) -> Text {
436        if self.total.is_none() {
437            return Text::new("");
438        }
439        let mut text = Text::new(format!("{:>3.0}%", self.percentage()));
440        let len = text.plain().len();
441        text.stylize("progress.percentage", 0, len);
442        text
443    }
444
445    /// The M-of-N cell text: `completed` right-justified to the width of `total`
446    /// (`?` when indeterminate), then `/total`. Port of `MofNCompleteColumn.render`.
447    fn mofn_text(&self) -> String {
448        let completed = self.completed as i64;
449        let total = self
450            .total
451            .map_or_else(|| "?".to_string(), |total| (total as i64).to_string());
452        let total_width = total.chars().count();
453        format!("{completed:>total_width$}/{total}")
454    }
455
456    /// The download cell text: `completed`/`total` in a shared byte unit, e.g.
457    /// `0.5/1.0 kB`. Port of `DownloadColumn.render`.
458    fn download_text(&self, binary: bool) -> String {
459        const DECIMAL: &[&str] = &["bytes", "kB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
460        const BINARY: &[&str] = &[
461            "bytes", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB",
462        ];
463        let completed = self.completed as u64;
464        let base_size = self.total.map_or(completed, |total| total as u64);
465        let (unit, suffix) = if binary {
466            filesize::pick_unit_and_suffix(base_size, BINARY, 1024)
467        } else {
468            filesize::pick_unit_and_suffix(base_size, DECIMAL, 1000)
469        };
470        let precision = if unit == 1 { 0 } else { 1 };
471        let completed_str = grouped(completed as f64 / unit as f64, precision);
472        let total_str = self.total.map_or_else(
473            || "?".to_string(),
474            |total| grouped((total as u64) as f64 / unit as f64, precision),
475        );
476        format!("{completed_str}/{total_str} {suffix}")
477    }
478}
479
480/// Changes for [`Progress::update`]; unset fields are left alone. Upstream's
481/// keyword arguments to `Progress.update`.
482#[derive(Debug, Clone, Default)]
483pub struct TaskUpdate {
484    pub total: Option<f64>,
485    pub completed: Option<f64>,
486    pub advance: Option<f64>,
487    pub description: Option<String>,
488    pub visible: Option<bool>,
489}
490
491impl TaskUpdate {
492    pub fn total(mut self, total: f64) -> Self {
493        self.total = Some(total);
494        self
495    }
496
497    pub fn completed(mut self, completed: f64) -> Self {
498        self.completed = Some(completed);
499        self
500    }
501
502    pub fn advance(mut self, advance: f64) -> Self {
503        self.advance = Some(advance);
504        self
505    }
506
507    pub fn description(mut self, description: impl Into<String>) -> Self {
508        self.description = Some(description.into());
509        self
510    }
511
512    pub fn visible(mut self, visible: bool) -> Self {
513        self.visible = Some(visible);
514        self
515    }
516}
517
518/// A progress display over one or more [`Task`]s. Mirrors `rich.progress.Progress`.
519pub struct Progress {
520    tasks: Vec<Task>,
521    next_id: usize,
522    columns: Vec<ProgressColumn>,
523    get_time: GetTime,
524    speed_estimate_period: f64,
525}
526
527impl Default for Progress {
528    fn default() -> Self {
529        Progress {
530            tasks: Vec::new(),
531            next_id: 0,
532            columns: Progress::default_columns(),
533            get_time: Arc::new(monotonic),
534            speed_estimate_period: 30.0,
535        }
536    }
537}
538
539impl Progress {
540    pub fn new() -> Self {
541        Progress::default()
542    }
543
544    /// Upstream's `Progress.get_default_columns()`: description, bar,
545    /// percentage and time remaining.
546    pub fn default_columns() -> Vec<ProgressColumn> {
547        vec![
548            ProgressColumn::Description,
549            ProgressColumn::Bar,
550            ProgressColumn::Percentage,
551            ProgressColumn::time_remaining(),
552        ]
553    }
554
555    /// Replace the column list (default: [`default_columns`](Self::default_columns)).
556    pub fn columns(mut self, columns: Vec<ProgressColumn>) -> Self {
557        self.columns = columns;
558        self
559    }
560
561    /// Read time from `clock` (seconds) instead of the monotonic clock.
562    /// Upstream's `get_time`; makes time-based columns deterministic.
563    pub fn clock(mut self, clock: impl Fn() -> f64 + Send + Sync + 'static) -> Self {
564        self.get_time = Arc::new(clock);
565        for task in &mut self.tasks {
566            task.get_time = self.get_time.clone();
567        }
568        self
569    }
570
571    /// Seconds of history used for speed estimates (default 30).
572    pub fn speed_estimate_period(mut self, seconds: f64) -> Self {
573        self.speed_estimate_period = seconds;
574        self
575    }
576
577    fn now(&self) -> f64 {
578        (self.get_time)()
579    }
580
581    fn task_mut(&mut self, id: TaskId) -> Option<&mut Task> {
582        self.tasks.iter_mut().find(|task| task.id == id)
583    }
584
585    /// Add a started task and return its id. Port of `Progress.add_task`
586    /// (`start=True`); `total` of `None` is an indeterminate task.
587    pub fn add_task(
588        &mut self,
589        description: impl Into<String>,
590        total: impl Into<Option<f64>>,
591        completed: f64,
592    ) -> TaskId {
593        let id = self.push_task(description.into(), total.into(), completed);
594        self.start_task(id);
595        id
596    }
597
598    /// Add a task that has not started (`add_task(start=False)`): it shows no
599    /// elapsed time until [`start_task`](Self::start_task).
600    pub fn add_unstarted_task(
601        &mut self,
602        description: impl Into<String>,
603        total: impl Into<Option<f64>>,
604        completed: f64,
605    ) -> TaskId {
606        self.push_task(description.into(), total.into(), completed)
607    }
608
609    fn push_task(&mut self, description: String, total: Option<f64>, completed: f64) -> TaskId {
610        let id = TaskId(self.next_id);
611        self.next_id += 1;
612        self.tasks.push(Task {
613            id,
614            description,
615            total,
616            completed,
617            visible: true,
618            start_time: None,
619            stop_time: None,
620            finished_time: None,
621            finished_speed: None,
622            samples: VecDeque::new(),
623            get_time: self.get_time.clone(),
624        });
625        id
626    }
627
628    /// The task with this id, if it has not been removed.
629    pub fn task(&self, id: TaskId) -> Option<&Task> {
630        self.tasks.iter().find(|task| task.id == id)
631    }
632
633    /// Every task, in the order added.
634    pub fn tasks(&self) -> &[Task] {
635        &self.tasks
636    }
637
638    /// Whether every task has finished. Port of `Progress.finished`.
639    pub fn finished(&self) -> bool {
640        self.tasks.iter().all(Task::finished)
641    }
642
643    /// Start a task's clock if it has not started. Port of `start_task`.
644    pub fn start_task(&mut self, id: TaskId) {
645        let now = self.now();
646        if let Some(task) = self.task_mut(id) {
647            task.start_time.get_or_insert(now);
648        }
649    }
650
651    /// Stop a task's clock; its elapsed time freezes. Port of `stop_task`.
652    pub fn stop_task(&mut self, id: TaskId) {
653        let now = self.now();
654        if let Some(task) = self.task_mut(id) {
655            task.start_time.get_or_insert(now);
656            task.stop_time = Some(now);
657        }
658    }
659
660    /// Update a task. Port of `Progress.update`: a new total clears the speed
661    /// samples; positive progress adds a sample; reaching the total records the
662    /// finish time.
663    pub fn update(&mut self, id: TaskId, update: TaskUpdate) {
664        let now = self.now();
665        let period = self.speed_estimate_period;
666        let Some(task) = self.task_mut(id) else {
667            return;
668        };
669        let completed_start = task.completed;
670        if let Some(total) = update.total {
671            if Some(total) != task.total {
672                task.total = Some(total);
673                task.clear_progress();
674            }
675        }
676        if let Some(advance) = update.advance {
677            task.completed += advance;
678        }
679        if let Some(completed) = update.completed {
680            task.completed = completed;
681        }
682        if let Some(description) = update.description {
683            task.description = description;
684        }
685        if let Some(visible) = update.visible {
686            task.visible = visible;
687        }
688        let update_completed = task.completed - completed_start;
689        let old_sample_time = now - period;
690        while task
691            .samples
692            .front()
693            .is_some_and(|(time, _)| *time < old_sample_time)
694        {
695            task.samples.pop_front();
696        }
697        if update_completed > 0.0 {
698            task.samples.push_back((now, update_completed));
699            if task.samples.len() > MAX_SAMPLES {
700                task.samples.pop_front();
701            }
702        }
703        if task.total.is_some_and(|total| task.completed >= total) && task.finished_time.is_none() {
704            task.finished_time = task.elapsed();
705        }
706    }
707
708    /// Advance a task by `amount` steps. Port of `Progress.advance`, which
709    /// (unlike `update`) always records a sample and the finish speed.
710    pub fn advance(&mut self, id: TaskId, amount: f64) {
711        let now = self.now();
712        let period = self.speed_estimate_period;
713        let Some(task) = self.task_mut(id) else {
714            return;
715        };
716        let completed_start = task.completed;
717        task.completed += amount;
718        let update_completed = task.completed - completed_start;
719        let old_sample_time = now - period;
720        while task
721            .samples
722            .front()
723            .is_some_and(|(time, _)| *time < old_sample_time)
724        {
725            task.samples.pop_front();
726        }
727        while task.samples.len() > MAX_SAMPLES {
728            task.samples.pop_front();
729        }
730        task.samples.push_back((now, update_completed));
731        if task.samples.len() > MAX_SAMPLES {
732            task.samples.pop_front();
733        }
734        if task.total.is_some_and(|total| task.completed >= total) && task.finished_time.is_none() {
735            task.finished_time = task.elapsed();
736            task.finished_speed = task.speed();
737        }
738    }
739
740    /// Reset a task to `completed`, optionally restarting its clock and
741    /// changing its total. Port of `Progress.reset`. Like upstream, a stop
742    /// time set earlier is kept.
743    pub fn reset(&mut self, id: TaskId, start: bool, total: Option<f64>, completed: f64) {
744        let now = self.now();
745        let Some(task) = self.task_mut(id) else {
746            return;
747        };
748        task.clear_progress();
749        task.start_time = start.then_some(now);
750        if let Some(total) = total {
751            task.total = Some(total);
752        }
753        task.completed = completed;
754        task.finished_time = None;
755    }
756
757    /// Remove a task. Port of `Progress.remove_task`.
758    pub fn remove_task(&mut self, id: TaskId) {
759        self.tasks.retain(|task| task.id != id);
760    }
761}
762
763impl Renderable for Progress {
764    fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
765        let width = options.max_width;
766        let ncols = self.columns.len();
767        let tasks: Vec<&Task> = self.tasks.iter().filter(|task| task.visible).collect();
768
769        // Render every cell once: spinners and the remaining-time cache are
770        // stateful, as upstream's columns are (each is called once per row).
771        let cells: Vec<Vec<Option<Text>>> = tasks
772            .iter()
773            .map(|task| {
774                self.columns
775                    .iter()
776                    .map(|column| (!column.is_bar()).then(|| column.cell(task)))
777                    .collect()
778            })
779            .collect();
780
781        // Fixed columns take their widest cell; bar columns flex.
782        let mut col_widths = vec![0usize; ncols];
783        for row in &cells {
784            for (index, cell) in row.iter().enumerate() {
785                if let Some(cell) = cell {
786                    col_widths[index] = col_widths[index].max(cell_len(cell.plain()));
787                }
788            }
789        }
790
791        // The bar column(s) share whatever the fixed columns and the single-space
792        // gaps leave, each capped at the default bar width. Port of the grid's
793        // shrink-to-fit over `no_wrap` fixed columns + a flexing `BarColumn`.
794        let gaps = ncols.saturating_sub(1);
795        let fixed_sum: usize = col_widths.iter().sum();
796        let bar_count = self.columns.iter().filter(|c| c.is_bar()).count();
797        let bar_width = width
798            .saturating_sub(fixed_sum + gaps)
799            .checked_div(bar_count)
800            .map_or(0, |per_bar| BAR_MAX_WIDTH.min(per_bar));
801        for (index, column) in self.columns.iter().enumerate() {
802            if column.is_bar() {
803                col_widths[index] = bar_width;
804            }
805        }
806
807        let theme = console.theme();
808        let mut lines: Vec<Vec<Segment>> = Vec::with_capacity(tasks.len());
809        for (task, row_cells) in tasks.iter().zip(cells) {
810            let mut row: Vec<Segment> = Vec::new();
811            for (index, (column, cell)) in self.columns.iter().zip(row_cells).enumerate() {
812                if index > 0 {
813                    // Inter-column gap: one unstyled space (the grid's collapsed
814                    // padding, whose column style is null).
815                    row.push(Segment::new(" ", None));
816                }
817                if column.is_bar() {
818                    let bar = ProgressBar::new(
819                        task.total.unwrap_or(0.0).max(0.0),
820                        task.completed.max(0.0),
821                    )
822                    .width(bar_width);
823                    row.extend(bar.rich_render(console, &options.update_width(bar_width)));
824                } else if let Some(mut cell) = cell {
825                    // Padding takes the cell's base style, not its spans — as a
826                    // grid pads a `Text` cell upstream.
827                    cell.truncate(col_widths[index], None, true);
828                    row.extend(cell.render(theme, &Style::new()));
829                }
830            }
831            lines.push(row);
832        }
833
834        let mut segments = Vec::new();
835        let last = lines.len().saturating_sub(1);
836        for (index, line) in lines.into_iter().enumerate() {
837            segments.extend(line);
838            if index != last {
839                segments.push(Segment::line());
840            }
841        }
842        segments
843    }
844}
845
846#[cfg(test)]
847mod tests {
848    use super::*;
849    use crate::color::ColorSystem;
850
851    fn render(progress: &Progress) -> String {
852        Console::builder()
853            .force_terminal(true)
854            .color_system(Some(ColorSystem::Truecolor))
855            .width(50)
856            .no_color(false)
857            .build()
858            .render_to_string(progress)
859    }
860
861    #[test]
862    fn three_tasks_match_upstream() {
863        // Captured from real rich 15.0.0 (default columns, width 50).
864        let mut progress = Progress::new().columns(vec![
865            ProgressColumn::Description,
866            ProgressColumn::Bar,
867            ProgressColumn::Percentage,
868        ]);
869        progress.add_task("Downloading", 100.0, 50.0);
870        progress.add_task("Processing", 100.0, 100.0);
871        progress.add_task("Waiting", 100.0, 0.0);
872        let expected = concat!(
873            "Downloading \x1b[38;2;249;38;114m━━━━━━━━━━━━━━━━\x1b[0m",
874            "\x1b[38;2;249;38;114m╸\x1b[0m\x1b[38;5;237m━━━━━━━━━━━━━━━━\x1b[0m \x1b[35m 50%\x1b[0m\n",
875            "Processing  \x1b[38;2;114;156;31m",
876            "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m \x1b[35m100%\x1b[0m\n",
877            "Waiting     \x1b[38;5;237m",
878            "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m \x1b[35m  0%\x1b[0m",
879        );
880        assert_eq!(render(&progress), expected);
881    }
882
883    #[test]
884    fn download_text_matches_upstream() {
885        // Captured from real rich 15.0.0 DownloadColumn.render (decimal units).
886        let dl = |completed: f64, total: f64| {
887            let mut progress = Progress::new();
888            let id = progress.add_task("", total, completed);
889            progress.task(id).unwrap().download_text(false)
890        };
891        assert_eq!(dl(500.0, 1000.0), "0.5/1.0 kB");
892        assert_eq!(dl(500.0, 999.0), "500/999 bytes");
893        assert_eq!(dl(1_500_000.0, 3_000_000.0), "1.5/3.0 MB");
894        assert_eq!(dl(0.0, 1024.0), "0.0/1.0 kB");
895        assert_eq!(dl(2_500_000_000.0, 10_000_000_000.0), "2.5/10.0 GB");
896        assert_eq!(dl(250.0, 250.0), "250/250 bytes");
897    }
898
899    #[test]
900    fn download_column_in_grid_matches_upstream() {
901        // Captured from real rich 15.0.0: description + bar + download at width 50.
902        let mut progress = Progress::new().columns(vec![
903            ProgressColumn::Description,
904            ProgressColumn::Bar,
905            ProgressColumn::Download,
906        ]);
907        progress.add_task("File", 1000.0, 500.0);
908        let expected = concat!(
909            "File \x1b[38;2;249;38;114m━━━━━━━━━━━━━━━━━\x1b[0m\x1b[38;5;237m╺\x1b[0m",
910            "\x1b[38;5;237m━━━━━━━━━━━━━━━━\x1b[0m \x1b[32m0.5/1.0 kB\x1b[0m",
911        );
912        assert_eq!(render(&progress), expected);
913    }
914
915    #[test]
916    fn custom_columns_with_mofn_match_upstream() {
917        // Captured from real rich 15.0.0: description + bar + M-of-N (differing
918        // M-of-N widths → the narrower cell left-justifies with green padding).
919        let mut progress = Progress::new().columns(vec![
920            ProgressColumn::Description,
921            ProgressColumn::Bar,
922            ProgressColumn::MofN,
923        ]);
924        progress.add_task("A", 5.0, 3.0);
925        progress.add_task("B", 100.0, 50.0);
926        let console = Console::builder()
927            .force_terminal(true)
928            .color_system(Some(ColorSystem::Truecolor))
929            .width(40)
930            .no_color(false)
931            .build();
932        let expected = concat!(
933            "A \x1b[38;2;249;38;114m━━━━━━━━━━━━━━━━━━\x1b[0m\x1b[38;5;237m╺\x1b[0m",
934            "\x1b[38;5;237m━━━━━━━━━━━\x1b[0m \x1b[32m3/5    \x1b[0m\n",
935            "B \x1b[38;2;249;38;114m━━━━━━━━━━━━━━━\x1b[0m\x1b[38;5;237m╺\x1b[0m",
936            "\x1b[38;5;237m━━━━━━━━━━━━━━\x1b[0m \x1b[32m 50/100\x1b[0m",
937        );
938        assert_eq!(console.render_to_string(&progress), expected);
939    }
940}