Skip to main content

atomic_progress/frontends/
terminal.rs

1//! A standard terminal frontend that uses ANSI escape sequences to render
2//! progress bars in-place.
3//!
4//! This module provides the [`TerminalFrontend`] struct, which is responsible for
5//! converting a [`ProgressSnapshot`] into a human-readable, visual representation
6//! suitable for standard output streams (e.g., `stderr` or `stdout`).
7
8use std::{
9    io::{self, Write},
10    time::Duration,
11};
12
13use prettier_bytes::ByteFormatter;
14
15use crate::{ProgressSnapshot, ProgressStackSnapshot, ProgressType};
16
17/// A theme for customizing the appearance of the [`TerminalFrontend`].
18///
19/// This struct dictates the characters used to paint the progress indicators.
20/// It provides defaults suitable for modern terminals supporting UTF-8.
21#[derive(Clone, Debug, PartialEq, Eq)]
22pub struct Theme {
23    /// The character used for the filled portion of a progress bar (e.g., '█').
24    pub bar_filled: char,
25    /// The character used for the empty portion of a progress bar (e.g., '░').
26    pub bar_empty: char,
27    /// The sequence of characters used to animate spinner frames.
28    pub spinner_frames: &'static [char],
29}
30
31impl Default for Theme {
32    /// Provides a modern, UTF-8 based default theme.
33    fn default() -> Self {
34        Self::modern()
35    }
36}
37
38impl Theme {
39    /// A simple ASCII-only theme for environments with limited character support.
40    ///
41    /// Use this in CI environments, basic command prompts, or when UTF-8
42    /// support cannot be guaranteed.
43    #[must_use]
44    pub const fn ascii() -> Self {
45        Self {
46            bar_filled: '#',
47            bar_empty: '-',
48            spinner_frames: &['|', '/', '-', '\\'],
49        }
50    }
51
52    /// Provides a modern, UTF-8 based default theme.
53    ///
54    /// This theme uses block characters for bars and braille patterns for spinners,
55    /// offering a visually rich experience in compatible terminals.
56    #[must_use]
57    pub const fn modern() -> Self {
58        Self {
59            bar_filled: '█',
60            bar_empty: '░',
61            spinner_frames: &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'],
62        }
63    }
64}
65
66/// Encapsulates pre-formatted string metrics for a given progress snapshot.
67///
68/// We compute these shared metrics once per frame to avoid duplicate allocations
69/// and to simplify the signatures of the specific rendering methods (`format_bar`, `format_spinner`).
70struct FormattedMetrics {
71    /// The elapsed time formatted as `MM:SS` or `HH:MM:SS`.
72    elapsed: String,
73    /// The current position, optionally byte-formatted.
74    pos: String,
75    /// The total target count, optionally byte-formatted.
76    total: String,
77    /// The current throughput rate per second.
78    rate: String,
79    /// The estimated time remaining, including prefix formatting.
80    eta: String,
81}
82
83/// A standard terminal frontend that uses ANSI escape sequences.
84///
85/// This frontend renders output in-place by issuing cursor-up ANSI commands
86/// (`\x1b[{n}A`) and clearing the current line (`\x1b[2K\r`).
87pub struct TerminalFrontend<W> {
88    /// The underlying writable stream (typically `stderr`).
89    writer: W,
90    /// Tracks the number of lines rendered in the last frame to move the cursor correctly.
91    last_lines: usize,
92    /// The visual width of the progress bar component, in characters.
93    width: usize,
94    /// The visual theme containing the characters used for rendering.
95    theme: Theme,
96    /// The current animation frame index for spinners.
97    spinner_tick: usize,
98    /// An optional formatter used to convert raw integers into human-readable byte sizes.
99    byte_formatter: Option<ByteFormatter>,
100}
101
102impl<W: Write> TerminalFrontend<W> {
103    /// Creates a new `TerminalFrontend` wrapping the given stream.
104    ///
105    /// # Default Configuration
106    /// * **Bar Width:** 40 characters.
107    /// * **Theme:** Modern UTF-8 defaults.
108    /// * **Byte Formatting:** Disabled.
109    ///
110    /// # Parameters
111    /// * `writer`: The I/O stream to render to.
112    pub const fn new(writer: W) -> Self {
113        Self {
114            writer,
115            last_lines: 0,
116            width: 40,
117            theme: Theme {
118                bar_filled: '█',
119                bar_empty: '░',
120                spinner_frames: &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'],
121            },
122            spinner_tick: 0,
123            byte_formatter: None,
124        }
125    }
126
127    /// Customizes the visual theme.
128    #[must_use]
129    pub const fn with_theme(mut self, theme: Theme) -> Self {
130        self.theme = theme;
131        self
132    }
133
134    /// Sets the width of the progress bar (in characters).
135    #[must_use]
136    pub const fn with_width(mut self, width: usize) -> Self {
137        self.width = width;
138        self
139    }
140
141    /// Enables automatic byte formatting for position, total, and throughput
142    /// using the provided `prettier_bytes::ByteFormatter` rules.
143    #[must_use]
144    pub const fn with_byte_formatting(mut self, formatter: ByteFormatter) -> Self {
145        self.byte_formatter = Some(formatter);
146        self
147    }
148
149    /// Moves the terminal cursor up by `n` lines using ANSI escape sequences.
150    ///
151    /// # Errors
152    /// Returns an [`io::Error`] if the underlying writer fails.
153    fn move_cursor_up(&mut self, n: usize) -> io::Result<()> {
154        if n > 0 {
155            // \x1b[{n}A is the standard ANSI code for cursor up.
156            write!(self.writer, "\x1b[{n}A")?;
157        }
158        Ok(())
159    }
160
161    /// Formats a complete progress line based on the snapshot type.
162    ///
163    /// This delegates to specific formatting routines depending on whether
164    /// the progress indicator is a Bar or a Spinner.
165    fn format_line(&self, snapshot: &ProgressSnapshot) -> String {
166        let metrics = self.format_metrics(snapshot);
167
168        match snapshot.kind {
169            ProgressType::Bar => self.format_bar(snapshot, &metrics),
170            ProgressType::Spinner => self.format_spinner(snapshot, &metrics),
171        }
172    }
173
174    /// Computes and formats the shared mathematical metrics for the snapshot.
175    ///
176    /// We extract this logic to keep the rendering cleanly separated from string allocations.
177    fn format_metrics(&self, snapshot: &ProgressSnapshot) -> FormattedMetrics {
178        let elapsed = snapshot
179            .elapsed
180            .map_or_else(|| "--:--".to_string(), format_duration);
181
182        // Pre-calculate human-readable numbers vs bytes.
183        let (pos, total) = self.byte_formatter.as_ref().map_or_else(
184            || (snapshot.position.to_string(), snapshot.total.to_string()),
185            |bf| {
186                (
187                    bf.format(snapshot.position).to_string(),
188                    bf.format(snapshot.total).to_string(),
189                )
190            },
191        );
192
193        let rate_val = snapshot.throughput();
194        let rate = if rate_val > 0.0 {
195            self.byte_formatter.as_ref().map_or_else(
196                || format!("{rate_val:.1}/s"),
197                |bf| format!("{}/s", bf.format(rate_val as u64)),
198            )
199        } else if self.byte_formatter.is_some() {
200            // Fallback for byte-formatted speeds when stalled.
201            "--.- B/s".to_string()
202        } else {
203            // Fallback for standard item speeds when stalled.
204            "--.-/s".to_string()
205        };
206
207        let eta = snapshot.eta().map_or_else(String::new, |eta_val| {
208            format!(" | ETA {}", format_duration(eta_val))
209        });
210
211        FormattedMetrics {
212            elapsed,
213            pos,
214            total,
215            rate,
216            eta,
217        }
218    }
219
220    /// Renders a deterministic progress bar (where the total is known).
221    fn format_bar(&self, snapshot: &ProgressSnapshot, metrics: &FormattedMetrics) -> String {
222        use std::fmt::Write as _;
223
224        #[allow(clippy::cast_precision_loss)]
225        let percent = if snapshot.total == 0 {
226            0.0
227        } else {
228            (snapshot.position as f64 / snapshot.total as f64) * 100.0
229        };
230
231        // Ensure we never render >100% or <0% visually.
232        let percent = percent.clamp(0.0, 100.0);
233
234        // Determine how many characters of the bar should be "filled".
235        #[allow(clippy::cast_precision_loss)]
236        let filled_float = (percent / 100.0) * (self.width as f64);
237
238        // Guard against NaN or Infinity from edge-case calculations to prevent panic/overflow.
239        let filled = if filled_float.is_nan() || filled_float.is_infinite() || filled_float < 0.0 {
240            0
241        } else {
242            filled_float as usize
243        }
244        .min(self.width);
245
246        let empty = self.width.saturating_sub(filled);
247
248        let filled_str = self.theme.bar_filled.to_string().repeat(filled);
249        let empty_str = self.theme.bar_empty.to_string().repeat(empty);
250
251        // Determine completion or error status icons.
252        let status = if snapshot.finished {
253            if snapshot.error.is_some() {
254                "✖"
255            } else {
256                "✔"
257            }
258        } else {
259            ""
260        };
261
262        // Construct the trailing info string (name, item, errors).
263        let mut info = String::new();
264        if !snapshot.name.is_empty() {
265            info.push_str(&snapshot.name);
266        }
267        if !snapshot.item.is_empty() {
268            if !info.is_empty() {
269                info.push(' ');
270            }
271            let _ = write!(info, "[{}]", snapshot.item);
272        }
273        if let Some(err) = &snapshot.error {
274            if !info.is_empty() {
275                info.push(' ');
276            }
277            let _ = write!(info, "ERROR: {err}");
278        }
279
280        format!(
281            "{status}{}[{filled_str}{empty_str}] {percent:>5.1}% ({}/{}) | {}{} | {} | {info}",
282            if status.is_empty() { "" } else { " " },
283            metrics.pos,
284            metrics.total,
285            metrics.elapsed,
286            metrics.eta,
287            metrics.rate,
288        )
289    }
290
291    /// Renders an indeterminate spinner (where the total is unknown).
292    fn format_spinner(&self, snapshot: &ProgressSnapshot, metrics: &FormattedMetrics) -> String {
293        use std::fmt::Write as _;
294
295        // Select the appropriate frame or status character.
296        let frame = if snapshot.finished {
297            if snapshot.error.is_some() {
298                '✖'
299            } else {
300                '✔'
301            }
302        } else if self.theme.spinner_frames.is_empty() {
303            ' '
304        } else {
305            self.theme.spinner_frames[self.spinner_tick % self.theme.spinner_frames.len()]
306        };
307
308        let name_prefix = if snapshot.name.is_empty() {
309            String::new()
310        } else {
311            format!("{} ", snapshot.name)
312        };
313
314        // Construct the trailing info string.
315        let mut info = String::new();
316        if !snapshot.item.is_empty() {
317            let _ = write!(info, " [{}]", snapshot.item);
318        }
319        if let Some(err) = &snapshot.error {
320            let _ = write!(info, " ERROR: {err}");
321        }
322
323        let items_label = if self.byte_formatter.is_some() {
324            ""
325        } else {
326            " items"
327        };
328
329        format!(
330            "{frame} {name_prefix}{}{items_label} | {} | {}{info}",
331            metrics.pos, metrics.elapsed, metrics.rate
332        )
333    }
334}
335
336impl<W: Write> super::Frontend for TerminalFrontend<W> {
337    fn render(&mut self, snapshot: &ProgressSnapshot) -> io::Result<()> {
338        self.move_cursor_up(self.last_lines)?;
339
340        let line = self.format_line(snapshot);
341        // \x1b[2K clears the entire current line. \r returns cursor to column 1.
342        writeln!(self.writer, "\x1b[2K\r{line}")?;
343
344        // wrapping_add is strictly used here to prevent panic on very long-running spinners.
345        self.spinner_tick = self.spinner_tick.wrapping_add(1);
346        self.last_lines = 1;
347        self.writer.flush()?;
348        Ok(())
349    }
350
351    fn render_stack(&mut self, stack: &ProgressStackSnapshot) -> io::Result<()> {
352        self.move_cursor_up(self.last_lines)?;
353
354        for snapshot in &stack.0 {
355            let line = self.format_line(snapshot);
356            writeln!(self.writer, "\x1b[2K\r{line}")?;
357        }
358
359        // Clean up "ghost" lines if the stack shrunk since the last render pass.
360        if self.last_lines > stack.0.len() {
361            let diff = self.last_lines - stack.0.len();
362            for _ in 0..diff {
363                writeln!(self.writer, "\x1b[2K\r")?;
364            }
365            self.move_cursor_up(diff)?;
366        }
367
368        self.spinner_tick = self.spinner_tick.wrapping_add(1);
369        self.last_lines = stack.0.len();
370        self.writer.flush()?;
371        Ok(())
372    }
373
374    fn clear(&mut self) -> io::Result<()> {
375        self.move_cursor_up(self.last_lines)?;
376        for _ in 0..self.last_lines {
377            writeln!(self.writer, "\x1b[2K\r")?;
378        }
379        self.move_cursor_up(self.last_lines)?;
380
381        self.last_lines = 0;
382        self.writer.flush()?;
383        Ok(())
384    }
385
386    fn finish(&mut self) -> io::Result<()> {
387        self.last_lines = 0;
388        self.writer.flush()?;
389        Ok(())
390    }
391}
392
393/// Helper function to format a `Duration` into `HH:MM:SS` or `MM:SS`.
394fn format_duration(d: Duration) -> String {
395    let secs = d.as_secs();
396    if secs >= 3600 {
397        format!(
398            "{:02}:{:02}:{:02}",
399            secs / 3600,
400            (secs % 3600) / 60,
401            secs % 60
402        )
403    } else {
404        format!("{:02}:{:02}", secs / 60, secs % 60)
405    }
406}
407
408#[cfg(test)]
409mod tests {
410    use std::thread;
411
412    use compact_str::CompactString;
413
414    use super::*;
415    use crate::{ProgressBuilder, ProgressStack, ProgressType, frontends::Frontend};
416
417    #[test]
418    fn test_format_duration() {
419        assert_eq!(format_duration(Duration::from_secs(45)), "00:45");
420        assert_eq!(format_duration(Duration::from_secs(125)), "02:05");
421        assert_eq!(format_duration(Duration::from_secs(3665)), "01:01:05");
422    }
423
424    #[test]
425    fn test_terminal_frontend_rendering() {
426        let mut buf = Vec::new();
427        {
428            let mut frontend = TerminalFrontend::new(&mut buf).with_theme(Theme::ascii());
429
430            let snap = ProgressSnapshot {
431                name: CompactString::new("Test"),
432                kind: ProgressType::Bar,
433                position: 50,
434                total: 100,
435                ..Default::default()
436            };
437
438            frontend.render(&snap).unwrap();
439        }
440
441        let out = String::from_utf8(buf).unwrap();
442        assert!(out.contains("[####################--------------------]"));
443        assert!(out.contains("50.0%"));
444        assert!(out.contains("\x1b[2K\r")); // Verifies standard terminal clears
445    }
446
447    /// Real Terminal Output
448    /// Ignored by default. Run manually to see the live progress bar in your terminal:
449    /// `cargo test test_real_terminal_output -- --ignored --nocapture`
450    #[test]
451    #[ignore = "Visual test that writes to stderr and sleeps"]
452    fn test_real_terminal_output() {
453        let stack = ProgressStack::new();
454
455        // Use ProgressBuilder to explicitly start the timers, then push to the stack
456        let bar = ProgressBuilder::new_bar("Downloading", 100u64)
457            .with_start_time_now()
458            .build();
459        stack.push(bar.clone());
460        let spinner = ProgressBuilder::new_spinner("Processing")
461            .with_start_time_now()
462            .build();
463        stack.push(spinner.clone());
464
465        // Worker thread: updates progress and sleeps between iterations
466        let worker = thread::spawn(move || {
467            for i in 0..=100 {
468                bar.set_pos(i);
469                bar.set_item(format!("chunk_{i}.bin"));
470
471                spinner.bump();
472                spinner.set_item(format!("tasks: {i}"));
473
474                thread::sleep(Duration::from_millis(30));
475            }
476
477            bar.finish_with_item("Complete!");
478            spinner.finish_with_item("Done!");
479        });
480
481        // Renderer thread (main): writes to stderr and sleeps between frames
482        let mut frontend = TerminalFrontend::new(std::io::stderr());
483
484        while !stack.is_all_finished() {
485            let snapshot = stack.snapshot();
486            frontend.render_stack(&snapshot).unwrap();
487
488            // ~30fps rendering
489            thread::sleep(Duration::from_millis(33));
490        }
491
492        // Ensure the final 100% state is rendered before exiting
493        let final_snapshot = stack.snapshot();
494        frontend.render_stack(&final_snapshot).unwrap();
495        frontend.finish().unwrap();
496
497        worker.join().unwrap();
498    }
499}