Async-first terminal UI spinners and progress bars
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203//! Multi-line progress display for concurrent streams.
use std::collections::VecDeque;use std::io::{IsTerminal, Write};use std::pin::Pin;use std::task::{Context, Poll};use std::time::Instant;
use futures_lite::Stream;use owo_colors::Style;
use crate::line::{FrameContext, Line};use crate::progressive::ProgressiveStream;use crate::spinner::Ticks;use crate::term::{self, clear_line, CursorGuard};use crate::Theme;
/// One slot in a [`Group`]: the wrapped stream and its render line.struct Slot<'a, I> { work: Pin<Box<dyn ProgressiveStream<'a, Item = I> + 'a>>, line: Line<'a>,}
/// A group of [`ProgressiveStream`]s rendered as one line per stream.////// Each [`poll_next`](Stream::poll_next) call polls every active slot once. Items yielded by the/// inner streams are queued and drained one-per-call from the [`Stream`] impl. When an inner/// stream returns `Ready(None)` its line is removed. The Group itself returns `Ready(None)` once/// every slot has terminated.////// Group-wide defaults set via [`with_spinner_style`](Group::with_spinner_style),/// [`with_annotation_style`](Group::with_annotation_style) and/// [`with_elapsed_time`](Group::with_elapsed_time) apply to any row that doesn't supply its own./// Per-row overrides via the matching setters on/// [`ProgressStream`](crate::stream::ProgressStream) //// [`ProgressBytesStream`](crate::stream::ProgressBytesStream) take precedence on that row.pub struct Group<'a, I> { slots: Vec<Option<Slot<'a, I>>>, buffer: VecDeque<I>, theme: Theme<'a>, ticks: Ticks<'a>, spinner_char: Option<char>, spinner_style: Style, annotation_style: Style, with_elapsed_time: bool, start: Option<Instant>, is_tty: bool, rendered_lines: usize, dirty: bool, _guard: CursorGuard,}
impl<'a, I> Group<'a, I> { /// Create a new group using `theme` as the default for rows that don't supply their own. pub fn new(theme: impl Into<Theme<'a>>) -> Self { let theme = theme.into(); let is_tty = std::io::stdout().is_terminal(); let ticks = theme.spinner.ticks(); Self { slots: Vec::new(), buffer: VecDeque::new(), theme, ticks, spinner_char: None, spinner_style: Style::new(), annotation_style: Style::new(), with_elapsed_time: false, start: None, is_tty, rendered_lines: 0, dirty: true, _guard: CursorGuard { is_tty }, } }
/// Default spinner style for rows that don't supply their own. pub fn with_spinner_style(mut self, spinner_style: Style) -> Self { self.spinner_style = spinner_style; self }
/// Default annotation (label) style for rows that don't supply their own. pub fn with_annotation_style(mut self, annotation_style: Style) -> Self { self.annotation_style = annotation_style; self }
/// Default for showing elapsed time on rows that don't supply their own. pub fn with_elapsed_time(mut self) -> Self { self.with_elapsed_time = true; self }
/// Add a stream to the group. The stream must implement [`ProgressiveStream`]; use /// [`progressive`](crate::stream::StreamExt::progressive) or /// [`progressive_bytes`](crate::stream::StreamExt::progressive_bytes) on a bare stream to /// obtain one. pub fn push<S>(&mut self, mut stream: S) where S: ProgressiveStream<'a, Item = I> + 'a, { let line = match stream.theme() { Some(row_theme) => Line::new(row_theme), None => Line::new(&self.theme), }; stream.detach_rendering(); self.slots.push(Some(Slot { work: Box::pin(stream), line, })); self.dirty = true; }}
impl<I> Stream for Group<'_, I>where I: Unpin,{ type Item = I;
fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> { let this = self.get_mut();
if this.slots.is_empty() { return Poll::Ready(None); }
this.start.get_or_insert_with(Instant::now);
if let Poll::Ready(ch) = Pin::new(&mut this.ticks).poll_next(cx) { this.spinner_char = ch; this.dirty = true; }
// Poll each active slot once, collecting any newly-yielded items into the buffer. for slot in this.slots.iter_mut() { if let Some(s) = slot { match s.work.as_mut().poll_next(cx) { Poll::Ready(Some(item)) => { this.buffer.push_back(item); this.dirty = true; } Poll::Ready(None) => { *slot = None; this.dirty = true; } Poll::Pending => {} } } }
let active_count = this.slots.iter().filter(|s| s.is_some()).count();
if this.is_tty && this.dirty && (active_count > 0 || this.rendered_lines > 0) { this.dirty = false; let elapsed = this.start.expect("start initialised above").elapsed();
let mut stdout = std::io::stdout().lock(); let _ = stdout.write_all(term::HIDE_CURSOR);
for slot in this.slots.iter_mut().flatten() { let _ = clear_line(&mut stdout); let item = slot.work.as_ref().get_ref(); let frame = FrameContext { spinner_char: this.spinner_char, elapsed, show_elapsed: item.show_elapsed_time().unwrap_or(this.with_elapsed_time), spinner_style: item.spinner_style().unwrap_or(this.spinner_style), annotation_style: item.annotation_style().unwrap_or(this.annotation_style), }; let rendered = slot.line.render_into(item, &frame); let _ = stdout.write_all(rendered.as_bytes()); let _ = stdout.write_all(b"\n"); }
let stale = this.rendered_lines.saturating_sub(active_count); for _ in 0..stale { let _ = clear_line(&mut stdout); let _ = stdout.write_all(b"\n"); }
let total = active_count + stale; if total > 0 { let _ = term::move_up(&mut stdout, total as u16); }
let _ = stdout.flush(); this.rendered_lines = active_count; }
if let Some(item) = this.buffer.pop_front() { return Poll::Ready(Some(item)); }
if active_count == 0 { let _ = term::reset(); return Poll::Ready(None); }
Poll::Pending }}