Async-first terminal UI spinners and progress bars
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194//! Spinner UI element.//!//! A [`Spinner`] holds a set of characters and a tick interval. Calling//! [`ticks()`](Spinner::ticks) returns a [`Stream`] that yields the next character every//! interval, cycling forever. Pre-defined variants live in the [`styles`] module.//!//! ```rust//! use std::time::Duration;//! use futures_lite::{StreamExt, future};//! use strides::spinner;//!//! let custom = spinner::Spinner::new("◐◓◑◒").with_interval(Duration::from_millis(120));//!//! # future::block_on(async {//! let first: Vec<char> = custom.ticks().take(4).collect().await;//! assert_eq!(first, vec!['◐', '◓', '◑', '◒']);//! # });//! ```
use std::future::Future;use std::pin::Pin;use std::task::{Context, Poll};use std::time::Duration;
use futures_lite::Stream;use futures_timer::Delay;
/// Pre-defined spinner styles.pub mod styles { use super::Spinner;
/// Arc segment circling: `◜◝◞◟`. pub const ARC: Spinner = Spinner::new("◜◝◞◟");
/// Braille dots: `⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏`. pub const DOTS: Spinner = Spinner::new("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏");
/// Braille dots variant 2: `⠋⠙⠚⠞⠖⠦⠴⠲⠳⠓`. pub const DOTS_2: Spinner = Spinner::new("⠋⠙⠚⠞⠖⠦⠴⠲⠳⠓");
/// Three braille dots circling: `⠖⠲⠴⠦`. pub const DOTS_3: Spinner = Spinner::new("⠖⠲⠴⠦");
/// Braille dots bouncing: `⠄⠆⠇⠋⠙⠸⠰⠠⠰⠸⠙⠋⠇⠆`. pub const DOTS_4: Spinner = Spinner::new("⠄⠆⠇⠋⠙⠸⠰⠠⠰⠸⠙⠋⠇⠆");
/// Braille dots wave: `⠋⠙⠚⠒⠂⠂⠒⠲⠴⠦⠖⠒⠐⠐⠒⠓`. pub const DOTS_5: Spinner = Spinner::new("⠋⠙⠚⠒⠂⠂⠒⠲⠴⠦⠖⠒⠐⠐⠒⠓");
/// Braille dots breathing: `⠁⠉⠙⠚⠒⠂⠂⠒⠲⠴⠤⠄⠄⠤⠴⠲⠒⠂⠂⠒⠚⠙⠉`. pub const DOTS_6: Spinner = Spinner::new("⠁⠉⠙⠚⠒⠂⠂⠒⠲⠴⠤⠄⠄⠤⠴⠲⠒⠂⠂⠒⠚⠙⠉");
/// Seven braille dots circling: `⣾⣽⣻⢿⡿⣟⣯⣷`. pub const DOTS_7: Spinner = Spinner::new("⣾⣽⣻⢿⡿⣟⣯⣷");
/// Braille dots pulsing: `⠁⠁⠉⠙⠚⠒⠂⠂⠒⠲⠴⠤⠄⠄⠤⠠⠠⠤⠦⠖⠒⠐⠐⠒⠓⠋⠉⠈⠈`. pub const DOTS_8: Spinner = Spinner::new("⠁⠁⠉⠙⠚⠒⠂⠂⠒⠲⠴⠤⠄⠄⠤⠠⠠⠤⠦⠖⠒⠐⠐⠒⠓⠋⠉⠈⠈");
/// Two braille dots circling: `⠃⠉⠘⠰⢠⣀⡄⠆`. pub const DOTS_CIRCLE: Spinner = Spinner::new("⠃⠉⠘⠰⢠⣀⡄⠆");
/// One dot circling in a large square: `⠁⠂⠄⡀⢀⠠⠐⠈`. pub const DOT_LARGE_SQUARE: Spinner = Spinner::new("⠁⠂⠄⡀⢀⠠⠐⠈");
/// Star: `✶✸✹✺✹✷`. pub const STAR: Spinner = Spinner::new("✶✸✹✺✹✷");
/// Falling sand: `⠁⠂⠄⡀⡈⡐⡠⣀⣁⣂⣄⣌⣔⣤⣥⣦⣮⣶⣷⣿⡿⠿⢟⠟⡛⠛⠫⢋⠋⠍⡉⠉⠑⠡⢁`. pub const SAND: Spinner = Spinner::new("⠁⠂⠄⡀⡈⡐⡠⣀⣁⣂⣄⣌⣔⣤⣥⣦⣮⣶⣷⣿⡿⠿⢟⠟⡛⠛⠫⢋⠋⠍⡉⠉⠑⠡⢁");}
/// A stream of spinner characters emitted at a set interval.pub struct Ticks<'a> { /// All characters to cycle through. all_chars: &'a str, /// Iterator over the current cycle. chars: std::str::Chars<'a>, /// One-shot delay that is reset after each tick. delay: Delay, /// Interval between ticks. interval: Duration,}
impl Stream for Ticks<'_> { type Item = char;
fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<char>> { let this = self.get_mut();
// Inactive spinner: never yield a character. if this.all_chars.is_empty() { return Poll::Pending; }
// Wait for the current delay to expire. match Pin::new(&mut this.delay).poll(cx) { Poll::Ready(()) => { this.delay.reset(this.interval); let _ = Pin::new(&mut this.delay).poll(cx); } Poll::Pending => return Poll::Pending, }
// Get the next character, cycling back to the start when exhausted. let ch = match this.chars.next() { Some(ch) => ch, None => { this.chars = this.all_chars.chars(); this.chars.next().expect("non-empty spinner chars") } };
Poll::Ready(Some(ch)) }}
/// A spinner that emits a character at a set interval.#[derive(Clone)]pub struct Spinner<'a> { /// Characters making up the spinner. chars: &'a str, /// Refresh interval. interval: Duration,}
impl<'a> Spinner<'a> { /// Create a new spinner with `chars`. See the [`styles`] module for pre-defined styles. pub const fn new(chars: &'a str) -> Self { Self { chars, interval: Duration::from_millis(80), } }
/// Create an inactive spinner that will not emit a character. pub const fn inactive() -> Self { Self { chars: "", interval: Duration::MAX, } }
/// Set an animation interval different from the default. pub const fn with_interval(mut self, interval: Duration) -> Self { self.interval = interval; self }
/// Return a stream of characters at the set interval. pub fn ticks(&self) -> Ticks<'a> { // An inactive spinner stores `Duration::MAX` as a sentinel. Construct the underlying // [`Delay`] with a finite but practically unreachable interval so `Instant + interval` // doesn't overflow; `poll_next` short-circuits to `Pending` regardless. let delay_interval = if self.chars.is_empty() { Duration::from_secs(60 * 60 * 24 * 365) } else { self.interval }; Ticks { all_chars: self.chars, chars: self.chars.chars(), delay: Delay::new(delay_interval), interval: self.interval, } }}
#[cfg(test)]mod tests { use std::time::Instant;
use super::*;
use futures_lite::{future, StreamExt};
#[test] fn spinner() { let interval = Duration::from_millis(20); let spinner = styles::DOTS_3.with_interval(interval); let num = spinner.chars.chars().count(); let ticks = spinner.ticks();
future::block_on(async move { let start = Instant::now(); let ticks = ticks.take(num + 1).collect::<Vec<_>>().await; let elapsed = start.elapsed(); let at_least = interval.saturating_mul(num as u32 + 1); assert!(elapsed >= at_least); assert_eq!(ticks[..num], spinner.chars.chars().collect::<Vec<_>>()); assert_eq!(ticks[0], ticks[num]); }); }}