//! 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 = 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> { 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::>().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::>()); assert_eq!(ticks[0], ticks[num]); }); } }