cli/
spinner.rs

1// SPDX-License-Identifier: GPL-3-0-or-later
2// Copyright (c) 2025 Opinsys Oy
3// Copyright (c) 2024-2025 Jarkko Sakkinen
4
5//! Displays a spinner in the terminal.
6
7use clap::builder::styling::Style as AnsiStyle;
8use std::io::{self, IsTerminal, Write};
9
10/// A spinner for indicating ongoing progress in the terminal.
11pub(crate) struct Spinner {
12    chars: [char; 10],
13    index: usize,
14    message: &'static str,
15    started: bool,
16    enabled: bool,
17}
18
19impl Spinner {
20    /// Creates a new spinner.
21    #[must_use]
22    pub fn new(message: &'static str) -> Self {
23        Self {
24            chars: ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'],
25            index: 0,
26            message,
27            started: false,
28            enabled: io::stderr().is_terminal(),
29        }
30    }
31
32    /// Displays the first frame of the spinner if enabled, and hides the
33    /// cursor.
34    fn start(&mut self) {
35        if self.enabled && !self.started {
36            let mut stderr = io::stderr();
37            let _ = write!(stderr, "\x1B[?25l");
38            let _ = stderr.flush();
39            self.started = true;
40            self.tick_internal();
41        }
42    }
43
44    /// Updates the spinner animation by a frame. Does an implicit `start()` if
45    /// the spinner is not yet started.
46    pub fn tick(&mut self) {
47        if !self.enabled {
48            return;
49        }
50        if self.started {
51            self.index += 1;
52            self.tick_internal();
53        } else {
54            self.start();
55        }
56    }
57
58    fn tick_internal(&self) {
59        if !self.enabled || !self.started {
60            return;
61        }
62        let mut stderr = io::stderr();
63        let green = AnsiStyle::new().bold();
64        let spinner_char = self.chars[self.index % self.chars.len()];
65        let _ = write!(stderr, "\r{green}{spinner_char}{green:#} {}", self.message);
66        let _ = stderr.flush();
67    }
68
69    /// Removes the spinner from the screen and shows the cursor.
70    pub fn finish(&self) {
71        if self.enabled && self.started {
72            let mut stderr = io::stderr();
73            let len_to_clear = 1 + 1 + self.message.len();
74            let _ = write!(stderr, "\r{:len$}\r\x1B[?25h", " ", len = len_to_clear);
75            let _ = stderr.flush();
76        }
77    }
78}
79
80impl Drop for Spinner {
81    fn drop(&mut self) {
82        self.finish();
83    }
84}