Async-first terminal UI spinners and progress bars
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329//! Spinner UI element.//!//! A [`Spinner`] holds a sequence of animation frames and a tick interval. Calling//! [`ticks()`](Spinner::ticks) returns a [`Stream`] that yields the next frame every//! interval, cycling forever. Pre-defined variants live in the [`styles`] module.//!//! A frame is a string slice. The common single-glyph case is built with//! [`Spinner::new`], where each *character* of the passed string is one frame://!//! ```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<&str> = custom.ticks().take(4).collect().await;//! assert_eq!(first, vec!["◐", "◓", "◑", "◒"]);//! # });//! ```//!//! For multi-cell animations such as a Knight Rider / K.I.T.T. scanner band, where each//! frame spans several columns, build the spinner from explicit frames with//! [`Spinner::frames`]. All frames should share the same display width so the line does//! not jitter as it animates://!//! ```rust//! use futures_lite::{StreamExt, future};//! use strides::spinner;//!//! let kitt = spinner::Spinner::frames(&["▰▱▱", "▱▰▱", "▱▱▰"]);//!//! # future::block_on(async {//! let first: Vec<&str> = kitt.ticks().take(3).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("⠁⠂⠄⡀⡈⡐⡠⣀⣁⣂⣄⣌⣔⣤⣥⣦⣮⣶⣷⣿⡿⠿⢟⠟⡛⠛⠫⢋⠋⠍⡉⠉⠑⠡⢁");
/// Knight Rider / K.I.T.T. scanner: a single lit cell `▰` sweeping a six-cell track of dim /// `▱` and bouncing back. pub const KNIGHT: Spinner = Spinner::frames(&[ "▰▱▱▱▱▱", "▱▰▱▱▱▱", "▱▱▰▱▱▱", "▱▱▱▰▱▱", "▱▱▱▱▰▱", "▱▱▱▱▱▰", "▱▱▱▱▰▱", "▱▱▱▰▱▱", "▱▱▰▱▱▱", "▱▰▱▱▱▱", ]);
/// Knight Rider with a fading comet trail (`█` head, `▓▒` tail) sweeping a six-cell track and /// bouncing back. pub const KNIGHT_COMET: Spinner = Spinner::frames(&[ "█ ", "▓█ ", "▒▓█ ", " ▒▓█ ", " ▒▓█ ", " ▒▓█", " █▓", " █▓▒", " █▓▒ ", " █▓▒ ", ]);
/// A ball `●` bouncing back and forth. pub const BOUNCE: Spinner = Spinner::frames(&[ "● ", " ● ", " ● ", " ● ", " ●", " ● ", " ● ", " ● ", ]);
/// A block sliding back and forth through a dotted track: `█░░░` … `░░░█` and back. pub const BAR: Spinner = Spinner::frames(&["█░░░", "░█░░", "░░█░", "░░░█", "░░█░", "░█░░"]);
/// A centred thin line breathing in and out: `──` → `────` → `──────` → `────`. pub const PULSE: Spinner = Spinner::frames(&[" ── ", " ──── ", "──────", " ──── "]);}
/// The source of a spinner's animation frames.////// Either each `char` of a single string is a frame (the common single-glyph case built with/// [`Spinner::new`]) or each `&str` of a slice is a frame (multi-cell animations built with/// [`Spinner::frames`]). Frame slices borrow from the spinner's data, so a yielded frame lives as/// long as that data rather than the [`Ticks`] stream.#[derive(Clone, Copy)]enum Frames<'a> { /// Each `char` of the string is one frame. Chars(&'a str), /// Each `&str` of the slice is one frame. Strs(&'a [&'a str]),}
impl<'a> Frames<'a> { /// Whether there are no frames at all, the sentinel for an inactive spinner. const fn is_empty(&self) -> bool { match self { Frames::Chars(s) => s.is_empty(), Frames::Strs(f) => f.is_empty(), } }
/// Number of frames in one cycle. fn len(&self) -> usize { match self { Frames::Chars(s) => s.chars().count(), Frames::Strs(f) => f.len(), } }
/// The frame at `index`, or `None` when out of range. The returned slice borrows from the /// frame data (lifetime `'a`), not from `self`. fn get(&self, index: usize) -> Option<&'a str> { match self { Frames::Chars(s) => { let (start, ch) = s.char_indices().nth(index)?; Some(&s[start..start + ch.len_utf8()]) } Frames::Strs(f) => f.get(index).copied(), } }}
/// A stream of spinner frames emitted at a set interval.pub struct Ticks<'a> { /// All frames to cycle through. frames: Frames<'a>, /// Index of the next frame to yield. next: usize, /// One-shot delay that is reset after each tick. `None` for an inactive spinner, which never /// yields. delay: Option<Delay>, /// Interval between ticks. interval: Duration,}
impl<'a> Stream for Ticks<'a> { type Item = &'a str;
fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<&'a str>> { let this = self.get_mut();
// An inactive spinner carries no delay and never yields a frame. let Some(delay) = &mut this.delay else { return Poll::Pending; };
// Wait for the current delay to expire. match Pin::new(&mut *delay).poll(cx) { Poll::Ready(()) => { delay.reset(this.interval); let _ = Pin::new(delay).poll(cx); } Poll::Pending => return Poll::Pending, }
// Get the next frame, cycling back to the start when exhausted. let count = this.frames.len(); let frame = this .frames .get(this.next) .expect("index kept in range below"); this.next = (this.next + 1) % count;
Poll::Ready(Some(frame)) }}
/// A spinner that emits a frame at a set interval.#[derive(Clone)]pub struct Spinner<'a> { /// Frames making up the spinner. frames: Frames<'a>, /// Refresh interval. interval: Duration,}
impl<'a> Spinner<'a> { /// Create a spinner whose frames are the individual characters of `chars`. This is the /// ergonomic constructor for single-glyph spinners; see the [`styles`] module for pre-defined /// styles. For multi-cell animations use [`Spinner::frames`]. pub const fn new(chars: &'a str) -> Self { Self { frames: Frames::Chars(chars), interval: Duration::from_millis(80), } }
/// Create a spinner from explicit multi-character `frames`, one `&str` per frame. Use this for /// animations whose frames span several columns, such as a Knight Rider / K.I.T.T. band. All /// frames should share the same display width so the rendered line does not jitter. pub const fn frames(frames: &'a [&'a str]) -> Self { Self { frames: Frames::Strs(frames), interval: Duration::from_millis(80), } }
/// Create an inactive spinner that will not emit a frame. pub const fn inactive() -> Self { Self { frames: Frames::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 frames at the set interval. pub fn ticks(&self) -> Ticks<'a> { // An inactive spinner (no frames) carries no delay; `poll_next` short-circuits to // `Pending` so it never yields and `Instant + interval` is never computed. let delay = (!self.frames.is_empty()).then(|| Delay::new(self.interval)); Ticks { frames: self.frames, next: 0, delay, 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.frames.len(); 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); // Compare against the per-character frames of the underlying string. let Frames::Chars(s) = spinner.frames else { unreachable!("DOTS_3 is a char spinner"); }; let expected = s.char_indices().map(|(i, c)| &s[i..i + c.len_utf8()]); assert!(ticks[..num].iter().copied().eq(expected)); assert_eq!(ticks[0], ticks[num]); }); }
#[test] fn multi_cell_frames() { let interval = Duration::from_millis(5); let spinner = styles::KNIGHT.with_interval(interval); let num = spinner.frames.len(); let Frames::Strs(expected) = spinner.frames else { unreachable!("KNIGHT is a frame spinner"); };
future::block_on(async move { let ticks = spinner.ticks().take(num + 1).collect::<Vec<_>>().await; assert_eq!(&ticks[..num], expected); // Each frame is a multi-cell string and the cycle wraps. assert!(ticks[0].chars().count() > 1); assert_eq!(ticks[0], ticks[num]); }); }}