Repository: pkolaczk/fclones (language: rust)

Issue:
fclones group partially clears the terminal
Running `fclones group .` works fine, but redirecting paints over existing output. In both xterm and konsole the following terminal state

```bash
/foo$ for i in {1..25}; do echo $i; done
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/foo$ fclones group . > /dev/null
```
becomes this after running the `group` command:

```shell
/foo$ for i in {1..25}; do echo $i; done
1
2
3
/foo$ 
``` 

Redirecting stderr (e.g. `2> /dev/null`) prevents this.


Discussion / hints:
Are you on arch linux?
Mine does that too
On archlinux can confirm. Also `fclones group | fclones remove --dry-run` results in `fclones: error: Failed to write report: Broken pipe (os error 32)`
I guess that might be something related to printing the progress bar. 
Adding `--quiet` option fixes it `fclones --quiet group ... >/dev/null`.

Thank's for reporting.
@pkolaczk might [this commit](https://github.com/pkolaczk/fclones/commit/2a68ac4deb6dfe91dc619d7c05beecd775159f78) be the cause?
I don't know, this indeed looks like `indicatif` issue. I have no idea if downgrading it fixes it. Upgrading to the newest version didn't help. 

For sure I can also reproduce it both on macOS and Ubuntu.

Fix patch:
diff --git a/Cargo.lock b/Cargo.lock
index 8ae927a..cc58708 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -26,6 +26,12 @@ dependencies = [
  "libc",
 ]
 
+[[package]]
+name = "ansi-escapes"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e3c0daaaae24df5995734b689627f8fa02101bc5bbc768be3055b66a010d7af"
+
 [[package]]
 name = "anstream"
 version = "0.6.4"
@@ -93,10 +99,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "9b34d609dfbaf33d6889b2b7106d3ca345eacad44200913df5ba02bfd31d2ba9"
 
 [[package]]
-name = "atomic-counter"
-version = "1.0.1"
+name = "atty"
+version = "0.2.14"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "62f447d68cfa5a9ab0c1c862a703da2a65b5ed1b7ce1153c9eb0169506d56019"
+checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8"
+dependencies = [
+ "hermit-abi 0.1.19",
+ "libc",
+ "winapi",
+]
 
 [[package]]
 name = "autocfg"
@@ -474,7 +485,6 @@ name = "fclones"
 version = "0.33.0"
 dependencies = [
  "assert_matches",
- "atomic-counter",
  "bincode",
  "blake3",
  "byte-unit",
@@ -496,7 +506,6 @@ dependencies = [
  "hex",
  "ignore",
  "indexmap 2.0.2",
- "indicatif",
  "itertools",
  "lazy-init",
  "lazy_static",
@@ -519,6 +528,7 @@ dependencies = [
  "sha3",
  "sled",
  "smallvec",
+ "status-line",
  "stfu8",
  "sysinfo",
  "tempfile",
@@ -646,6 +656,15 @@ version = "0.4.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"
 
+[[package]]
+name = "hermit-abi"
+version = "0.1.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33"
+dependencies = [
+ "libc",
+]
+
 [[package]]
 name = "hermit-abi"
 version = "0.3.3"
@@ -718,19 +737,6 @@ dependencies = [
  "hashbrown 0.14.2",
 ]
 
-[[package]]
-name = "indicatif"
-version = "0.17.7"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fb28741c9db9a713d93deb3bb9515c20788cef5815265bee4980e87bde7e0f25"
-dependencies = [
- "console",
- "instant",
- "number_prefix",
- "portable-atomic",
- "unicode-width",
-]
-
 [[package]]
 name = "instant"
 version = "0.1.12"
@@ -901,16 +907,10 @@ version = "1.16.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43"
 dependencies = [
- "hermit-abi",
+ "hermit-abi 0.3.3",
  "libc",
 ]
 
-[[package]]
-name = "number_prefix"
-version = "0.4.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3"
-
 [[package]]
 name = "once_cell"
 version = "1.18.0"
@@ -981,12 +981,6 @@ dependencies = [
  "syn",
 ]
 
-[[package]]
-name = "portable-atomic"
-version = "1.4.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "31114a898e107c51bb1609ffaf55a0e011cf6a4d7f1170d0015a165082c0338b"
-
 [[package]]
 name = "ppv-lite86"
 version = "0.2.17"
@@ -1275,6 +1269,16 @@ version = "1.11.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "942b4a808e05215192e39f4ab80813e599068285906cc91aa64f923db842bd5a"
 
+[[package]]
+name = "status-line"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a20cc99bbe608305546a850ec4352907279a8b8044f9c13ae58bd0a8ab46ebc1"
+dependencies = [
+ "ansi-escapes",
+ "atty",
+]
+
 [[package]]
 name = "stfu8"
 version = "0.2.6"
diff --git a/fclones/Cargo.toml b/fclones/Cargo.toml
index 25fc311..9407fee 100644
--- a/fclones/Cargo.toml
+++ b/fclones/Cargo.toml
@@ -17,7 +17,6 @@ exclude = [
 rust-version = "1.70"
 
 [dependencies]
-atomic-counter = "1.0"
 bincode = "1.3"
 blake3 = { version = "1.3", optional = true }
 byteorder = "1.4"
@@ -36,7 +35,6 @@ fallible-iterator = "0.3"
 filetime = "0.2"
 hex = "0.4"
 ignore = "0.4.18"
-indicatif = "0.17"
 indexmap = "2"
 itertools = "0.11"
 lazy-init = "0.5"
@@ -56,6 +54,7 @@ sha2 = { version = "0.10", optional = true }
 sha3 = { version = "0.10", optional = true }
 sled = "0.34"
 smallvec = "1.8"
+status-line = "0.2.0"
 stfu8 = "0.2"
 sysinfo = "0.29"
 thread_local = "1.1"
diff --git a/fclones/src/log.rs b/fclones/src/log.rs
index 765c74a..e68ab9f 100644
--- a/fclones/src/log.rs
+++ b/fclones/src/log.rs
@@ -5,7 +5,7 @@ use std::sync::{Arc, Mutex, Weak};
 use console::style;
 use nom::lib::std::fmt::Display;
 
-use crate::progress::{FastProgressBar, ProgressTracker};
+use crate::progress::{ProgressBar, ProgressTracker};
 use chrono::Local;
 
 /// Determines the size of the task tracked by ProgressTracker.
@@ -63,7 +63,7 @@ impl<L: Log + ?Sized> LogExt for L {
 /// A logger that uses standard error stream to communicate with the user.
 pub struct StdLog {
     program_name: String,
-    progress_bar: Mutex<Weak<FastProgressBar>>,
+    progress_bar: Mutex<Weak<ProgressBar>>,
     pub log_stderr_to_stdout: bool,
     pub no_progress: bool,
 }
@@ -84,9 +84,9 @@ impl StdLog {
     }
 
     /// Clears any previous progress bar or spinner and installs a new spinner.
-    pub fn spinner(&self, msg: &str) -> Arc<FastProgressBar> {
+    pub fn spinner(&self, msg: &str) -> Arc<ProgressBar> {
         if self.no_progress {
-            return Arc::new(FastProgressBar::new_hidden());
+            return Arc::new(ProgressBar::new_hidden());
         }
         self.progress_bar
             .lock()
@@ -94,30 +94,30 @@ impl StdLog {
             .upgrade()
             .iter()
             .for_each(|pb| pb.finish_and_clear());
-        let result = Arc::new(FastProgressBar::new_spinner(msg));
+        let result = Arc::new(ProgressBar::new_spinner(msg));
         *self.progress_bar.lock().unwrap() = Arc::downgrade(&result);
         result
     }
 
     /// Clears any previous progress bar or spinner and installs a new progress bar.
-    pub fn progress_bar(&self, msg: &str, len: u64) -> Arc<FastProgressBar> {
+    pub fn progress_bar(&self, msg: &str, len: u64) -> Arc<ProgressBar> {
         if self.no_progress {
-            return Arc::new(FastProgressBar::new_hidden());
+            return Arc::new(ProgressBar::new_hidden());
         }
-        let result = Arc::new(FastProgressBar::new_progress_bar(msg, len));
+        let result = Arc::new(ProgressBar::new_progress_bar(msg, len));
         *self.progress_bar.lock().unwrap() = Arc::downgrade(&result);
         result
     }
 
     /// Creates a no-op progressbar that doesn't display itself.
-    pub fn hidden(&self) -> Arc<FastProgressBar> {
-        Arc::new(FastProgressBar::new_hidden())
+    pub fn hidden(&self) -> Arc<ProgressBar> {
+        Arc::new(ProgressBar::new_hidden())
     }
 
     /// Clears any previous progress bar or spinner and installs a new progress bar.
-    pub fn bytes_progress_bar(&self, msg: &str, len: u64) -> Arc<FastProgressBar> {
+    pub fn bytes_progress_bar(&self, msg: &str, len: u64) -> Arc<ProgressBar> {
         if self.no_progress {
-            return Arc::new(FastProgressBar::new_hidden());
+            return Arc::new(ProgressBar::new_hidden());
         }
         self.progress_bar
             .lock()
@@ -125,7 +125,7 @@ impl StdLog {
             .upgrade()
             .iter()
             .for_each(|pb| pb.finish_and_clear());
-        let result = Arc::new(FastProgressBar::new_bytes_progress_bar(msg, len));
+        let result = Arc::new(ProgressBar::new_bytes_progress_bar(msg, len));
         *self.progress_bar.lock().unwrap() = Arc::downgrade(&result);
         result
     }
diff --git a/fclones/src/progress.rs b/fclones/src/progress.rs
index 716dcdc..6877d43 100644
--- a/fclones/src/progress.rs
+++ b/fclones/src/progress.rs
@@ -1,11 +1,11 @@
 //! Fast, concurrent, lockless progress bars.
 
-use atomic_counter::{AtomicCounter, RelaxedCounter};
+use crate::FileLen;
 use console::style;
-use indicatif::{ProgressBar, ProgressDrawTarget, ProgressStyle};
-use std::sync::Arc;
-use std::thread;
-use std::time::Duration;
+use status_line::{Options, StatusLine};
+use std::fmt::{Display, Formatter};
+use std::sync::atomic::{AtomicU64, Ordering};
+use std::time::Instant;
 
 /// Common interface for components that can show progress of a task. E.g. progress bars.
 pub trait ProgressTracker: Sync + Send {
@@ -21,210 +21,248 @@ impl ProgressTracker for NoProgressBar {
     fn inc(&self, _delta: u64) {}
 }
 
-/// Console-based progress bar that renders to standard error.
-///
-/// Implemented as a wrapper over `indicatif::ProgressBar` that makes updating its progress
-/// lockless.
-/// Unfortunately `indicatif::ProgressBar` wraps state in a `Mutex`, so updates are slow
-/// and can become a bottleneck in multithreaded context.
-/// This wrapper uses `atomic_counter::RelaxedCounter` to keep shared state without ever blocking
-/// writers. That state is copied repeatedly by a background thread to an underlying
-/// `ProgressBar` at a low rate.
-pub struct FastProgressBar {
-    counter: Arc<RelaxedCounter>,
-    progress_bar: Arc<ProgressBar>,
+#[derive(Debug, Default)]
+enum ProgressUnit {
+    #[default]
+    Item,
+    Bytes,
 }
 
-impl FastProgressBar {
-    /// Width of the progress bar in characters
-    const WIDTH: usize = 50;
-    /// Spinner animation looks like this (moves right and left):
-    const SPACESHIP: &'static str = "<===>";
-    /// Progress bar looks like this:
-    const PROGRESS_CHARS: &'static str = "=> ";
-    /// How much time to wait between refreshes, in milliseconds
-    const REFRESH_PERIOD_MS: u64 = 50;
-
-    /// Wrap an existing `ProgressBar` and start the background updater-thread.
-    /// The thread periodically copies the `FastProgressBar` position into the wrapped
-    /// `ProgressBar` instance.
-    pub fn wrap(progress_bar: ProgressBar) -> FastProgressBar {
-        let pb = Arc::new(progress_bar);
-        let pb2 = pb.clone();
-        let counter = Arc::new(RelaxedCounter::new(0));
-        let counter2 = counter.clone();
-        thread::spawn(move || {
-            while Arc::strong_count(&counter2) > 1 && !pb2.is_finished() {
-                pb2.set_position(counter2.get() as u64);
-                thread::sleep(Duration::from_millis(Self::REFRESH_PERIOD_MS));
-            }
-        });
-        FastProgressBar {
-            counter,
-            progress_bar: pb,
+/// Keeps state of the progress bar and controls how it is rendered to a string
+#[derive(Debug)]
+struct Progress {
+    msg: String,         // message shown before the progress bar
+    value: AtomicU64,    // controls the length of the progress bar
+    max: Option<u64>,    // maximum expected value, if not set an animated spinner is shown
+    unit: ProgressUnit,  // how to format the numbers
+    start_time: Instant, // needed for the animation
+    color: bool,
+}
+
+impl Progress {
+    fn fmt_value(&self, value: u64) -> String {
+        match self.unit {
+            ProgressUnit::Item => value.to_string(),
+            ProgressUnit::Bytes => FileLen(value).to_string(),
         }
     }
 
-    /// Generate spinner animation strings.
-    /// The spinner moves to the next string from the returned vector with every tick.
-    /// The spinner is rendered as a SPACESHIP that bounces right and left from the
-    /// ends of the spinner bar.
-    fn gen_tick_strings() -> Vec<String> {
-        let mut tick_strings = vec![];
-        for i in 0..(Self::WIDTH - Self::SPACESHIP.len()) {
-            let prefix_len = i;
-            let suffix_len = Self::WIDTH - i - Self::SPACESHIP.len();
-            let tick_str = " ".repeat(prefix_len) + Self::SPACESHIP + &" ".repeat(suffix_len);
-            tick_strings.push(tick_str);
+    /// Draws the progress bar alone (without message and numbers)
+    fn bar(&self, length: usize) -> String {
+        let mut bar = "=".repeat(length);
+        if !bar.is_empty() {
+            bar.pop();
+            bar.push('>');
         }
-        let mut tick_strings_2 = tick_strings.clone();
-        tick_strings_2.reverse();
-        tick_strings.extend(tick_strings_2);
-        tick_strings
+        bar.truncate(MAX_BAR_LEN);
+        bar
     }
 
+    fn animate_spinner(&self, frame: u64) -> String {
+        let spaceship = "<===>";
+        let max_pos = (MAX_BAR_LEN - spaceship.len()) as u64;
+        let pos = ((frame + max_pos) % (max_pos * 2)).abs_diff(max_pos);
+        assert!(pos < MAX_BAR_LEN as u64);
+        " ".repeat(pos as usize) + spaceship
+    }
+}
+
+impl Default for Progress {
+    fn default() -> Self {
+        Progress {
+            msg: "".to_owned(),
+            value: AtomicU64::default(),
+            max: None,
+            unit: ProgressUnit::default(),
+            start_time: Instant::now(),
+            color: true,
+        }
+    }
+}
+
+const MAX_BAR_LEN: usize = 50;
+
+impl Display for Progress {
+    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
+        let value = self.value.load(Ordering::Relaxed);
+        let value_str = self.fmt_value(value);
+        let msg = if self.color {
+            style(self.msg.clone()).for_stderr().cyan().bold()
+        } else {
+            style(self.msg.clone())
+        };
+
+        match self.max {
+            Some(max) => {
+                let max_str = self.fmt_value(max);
+                let bar_len = (MAX_BAR_LEN as u64 * value / max.max(1)) as usize;
+                let bar = self.bar(bar_len);
+                write!(f, "{msg:32}[{bar:MAX_BAR_LEN$}]{value_str:>14} / {max_str}")
+            }
+            None => {
+                let frame = (self.start_time.elapsed().as_millis() / 50) as u64;
+                let bar = self.animate_spinner(frame);
+                write!(f, "{msg:32}[{bar:MAX_BAR_LEN$}]{value_str:>14}")
+            }
+        }
+    }
+}
+
+/// Console-based progress bar that renders to standard error.
+pub struct ProgressBar {
+    status_line: StatusLine<Progress>,
+}
+
+impl ProgressBar {
     /// Create a new preconfigured animated spinner with given message.
-    pub fn new_spinner(msg: &str) -> FastProgressBar {
-        let inner = ProgressBar::new_spinner();
-        let template =
-            style("{msg:32}").cyan().bold().for_stderr().to_string() + "[{spinner}] {pos:>10}";
-        let tick_strings = Self::gen_tick_strings();
-        let tick_strings: Vec<&str> = tick_strings.iter().map(|s| s as &str).collect();
-        inner.set_style(
-            ProgressStyle::default_spinner()
-                .template(template.as_str())
-                .unwrap()
-                .tick_strings(tick_strings.as_slice()),
-        );
-        inner.set_message(msg.to_string());
-        Self::wrap(inner)
+    pub fn new_spinner(msg: &str) -> ProgressBar {
+        let progress = Progress {
+            msg: msg.to_string(),
+            ..Default::default()
+        };
+        ProgressBar {
+            status_line: StatusLine::new(progress),
+        }
     }
 
     /// Create a new preconfigured progress bar with given message.
-    pub fn new_progress_bar(msg: &str, len: u64) -> FastProgressBar {
-        let inner = ProgressBar::new(len);
-        let template = style("{msg:32}").cyan().bold().for_stderr().to_string()
-            + &"[{bar:WIDTH}] {pos:>10}/{len}".replace("WIDTH", Self::WIDTH.to_string().as_str());
-
-        inner.set_style(
-            ProgressStyle::default_bar()
-                .template(template.as_str())
-                .unwrap()
-                .progress_chars(Self::PROGRESS_CHARS),
-        );
-        inner.set_message(msg.to_string());
-        FastProgressBar::wrap(inner)
+    pub fn new_progress_bar(msg: &str, len: u64) -> ProgressBar {
+        let progress = Progress {
+            msg: msg.to_string(),
+            max: Some(len),
+            ..Default::default()
+        };
+        ProgressBar {
+            status_line: StatusLine::new(progress),
+        }
     }
 
     /// Create a new preconfigured progress bar with given message.
     /// Displays progress in bytes.
-    pub fn new_bytes_progress_bar(msg: &str, len: u64) -> FastProgressBar {
-        let inner = ProgressBar::new(len);
-        let template = style("{msg:32}").cyan().bold().for_stderr().to_string()
-            + &"[{bar:WIDTH}] {bytes:>10}/{total_bytes}"
-                .replace("WIDTH", Self::WIDTH.to_string().as_str());
-
-        inner.set_style(
-            ProgressStyle::default_bar()
-                .template(template.as_str())
-                .unwrap()
-                .progress_chars(Self::PROGRESS_CHARS),
-        );
-        inner.set_message(msg.to_string());
-
-        FastProgressBar::wrap(inner)
+    pub fn new_bytes_progress_bar(msg: &str, len: u64) -> ProgressBar {
+        let progress = Progress {
+            msg: msg.to_string(),
+            max: Some(len),
+            unit: ProgressUnit::Bytes,
+            ..Default::default()
+        };
+        ProgressBar {
+            status_line: StatusLine::new(progress),
+        }
     }
 
     /// Creates a new invisible progress bar.
     /// This is useful when you need to disable progress bar, but you need to pass an instance
     /// of a `ProgressBar` to something that expects it.
-    pub fn new_hidden() -> FastProgressBar {
-        let inner = ProgressBar::new(u64::MAX);
-        inner.set_draw_target(ProgressDrawTarget::hidden());
-        FastProgressBar::wrap(inner)
-    }
-
-    fn update_progress(&self) {
-        let value = self.counter.get() as u64;
-        self.progress_bar.set_position(value);
-    }
-
-    pub fn set_draw_target(&self, target: ProgressDrawTarget) {
-        self.progress_bar.set_draw_target(target)
+    pub fn new_hidden() -> ProgressBar {
+        ProgressBar {
+            status_line: StatusLine::with_options(
+                Progress::default(),
+                Options {
+                    refresh_period: Default::default(),
+                    initially_visible: false,
+                    enable_ansi_escapes: false,
+                },
+            ),
+        }
     }
 
     pub fn is_visible(&self) -> bool {
-        !self.progress_bar.is_hidden()
+        self.status_line.is_visible()
     }
 
     pub fn println<I: AsRef<str>>(&self, msg: I) {
-        self.progress_bar.println(msg);
+        let was_visible = self.status_line.is_visible();
+        self.status_line.set_visible(false);
+        println!("{}", msg.as_ref());
+        self.status_line.set_visible(was_visible);
     }
 
     pub fn tick(&self) {
-        self.counter.inc();
+        self.status_line.value.fetch_add(1, Ordering::Relaxed);
     }
 
-    pub fn position(&self) -> usize {
-        self.counter.get()
+    pub fn finish_and_clear(&self) {
+        self.status_line.set_visible(false);
     }
+}
 
-    pub fn last_displayed_position(&self) -> u64 {
-        self.progress_bar.position()
+impl ProgressTracker for ProgressBar {
+    fn inc(&self, delta: u64) {
+        self.status_line.value.fetch_add(delta, Ordering::Relaxed);
     }
+}
 
-    pub fn finish(&self) {
-        self.update_progress();
-        self.progress_bar.finish();
-    }
+#[cfg(test)]
+mod test {
+    use crate::progress::{Progress, ProgressUnit};
+    use crate::regex::Regex;
+    use std::sync::atomic::{AtomicU64, Ordering};
 
-    pub fn finish_and_clear(&self) {
-        self.update_progress();
-        self.progress_bar.finish_and_clear();
-    }
+    #[test]
+    fn draw_progress_bar() {
+        let p = Progress {
+            msg: "Message".to_string(),
+            max: Some(100),
+            color: false,
+            ..Default::default()
+        };
 
-    pub fn abandon(&self) {
-        self.update_progress();
-        self.progress_bar.abandon();
+        assert_eq!(p.to_string(), "Message                         [                                                  ]             0 / 100");
+        p.value.fetch_add(2, Ordering::Relaxed);
+        assert_eq!(p.to_string(), "Message                         [>                                                 ]             2 / 100");
+        p.value.fetch_add(50, Ordering::Relaxed);
+        assert_eq!(p.to_string(), "Message                         [=========================>                        ]            52 / 100");
+        p.value.fetch_add(48, Ordering::Relaxed);
+        assert_eq!(p.to_string(), "Message                         [=================================================>]           100 / 100");
     }
 
-    pub fn is_finished(&self) -> bool {
-        self.progress_bar.is_finished()
-    }
-}
+    #[test]
+    fn draw_progress_bar_bytes() {
+        let p = Progress {
+            msg: "Message".to_string(),
+            max: Some(1000000000),
+            value: AtomicU64::new(12000),
+            unit: ProgressUnit::Bytes,
+            color: false,
+            ..Default::default()
+        };
 
-impl ProgressTracker for FastProgressBar {
-    fn inc(&self, delta: u64) {
-        self.counter.add(delta as usize);
+        assert_eq!(p.to_string(), "Message                         [                                                  ]       12.0 KB / 1000.0 MB");
     }
-}
 
-impl Drop for FastProgressBar {
-    fn drop(&mut self) {
-        if !self.is_finished() {
-            self.finish_and_clear();
-        }
-    }
-}
+    #[test]
+    fn animate_spinner() {
+        let p = Progress {
+            msg: "Message".to_string(),
+            color: false,
+            ..Default::default()
+        };
 
-#[cfg(test)]
-mod test {
+        let pattern = Regex::new(
+            "^Message                         \\[ *<===> *\\]             0$",
+            false,
+        )
+        .unwrap();
+        let s = p.to_string();
+        assert!(
+            pattern.is_match(s.as_str()),
+            "Spinner doesn't match pattern: {}",
+            s
+        );
 
-    use super::*;
-    use rayon::prelude::*;
+        assert_eq!(p.animate_spinner(0), "<===>");
+        assert_eq!(p.animate_spinner(1), " <===>");
+        assert_eq!(p.animate_spinner(2), "  <===>");
+        assert_eq!(p.animate_spinner(3), "   <===>");
 
-    #[test]
-    fn all_ticks_should_be_counted() {
-        let collection = vec![0; 100000];
-        let pb = ProgressBar::new(collection.len() as u64);
-        pb.set_draw_target(ProgressDrawTarget::hidden());
-        let pb = FastProgressBar::wrap(pb);
-        collection
-            .par_iter()
-            .inspect(|_| pb.tick())
-            .for_each(|_| ());
-        pb.abandon();
-        assert_eq!(pb.position(), 100000);
-        assert_eq!(pb.last_displayed_position(), 100000);
+        assert_eq!(p.animate_spinner(85), "     <===>");
+        assert_eq!(p.animate_spinner(86), "    <===>");
+        assert_eq!(p.animate_spinner(87), "   <===>");
+        assert_eq!(p.animate_spinner(88), "  <===>");
+        assert_eq!(p.animate_spinner(89), " <===>");
+        assert_eq!(p.animate_spinner(90), "<===>");
+        assert_eq!(p.animate_spinner(91), " <===>");
+        assert_eq!(p.animate_spinner(92), "  <===>");
     }
 }


Test patch: