strides 1.0.0-rc.2

Async-first terminal UI spinners and progress bars
//! Multi-line progress display for concurrent futures.

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::ProgressiveFuture;
use crate::spinner::Ticks;
use crate::term::{self, clear_line, CursorGuard};
use crate::Theme;

/// One slot in a [`Group`]: the wrapped future and its render line.
struct Slot<'a, O> {
    work: Pin<Box<dyn ProgressiveFuture<'a, Output = O> + 'a>>,
    line: Line<'a>,
}

/// A group of [`ProgressiveFuture`]s rendered as one line per task.
///
/// `Group` owns its work and polls every active slot on each `poll_next`. After polling, it reads
/// each slot's progress via the [`Progressive`](crate::progressive::Progressive) trait and repaints
/// the corresponding line. When a future resolves the line is removed and the output is yielded
/// from the [`Stream`] impl.
///
/// Push futures with [`push`](Group::push). The setters mirrored on
/// [`FutureExt`](crate::future::FutureExt) ([`with_label`](crate::future::FutureExt::with_label),
/// [`with_messages`](crate::future::FutureExt::with_messages),
/// [`with_progress`](crate::future::FutureExt::with_progress),
/// [`with_elapsed_time`](crate::future::FutureExt::with_elapsed_time)) lift a bare future into a
/// [`ProgressFuture`](crate::future::ProgressFuture); use
/// [`progressive()`](crate::future::FutureExt::progressive) to lift explicitly when pushing a
/// future with no configuration.
///
/// 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 set via [`ProgressFuture::with_theme`](crate::future::ProgressFuture::with_theme),
/// [`with_spinner_style`](crate::future::ProgressFuture::with_spinner_style),
/// [`with_annotation_style`](crate::future::ProgressFuture::with_annotation_style) and
/// [`with_elapsed_time`](crate::future::ProgressFuture::with_elapsed_time) take precedence on
/// that row.
///
/// ```rust,no_run
/// use std::time::Duration;
/// use futures_lite::{StreamExt, future};
/// use strides::future::{FutureExt, Group};
/// use strides::spinner;
///
/// future::block_on(async {
///     let mut group = Group::new(spinner::styles::DOTS_3);
///     group.push(async_io::Timer::after(Duration::from_secs(1)).with_label("fast"));
///     group.push(async_io::Timer::after(Duration::from_secs(3)).with_label("slow"));
///     group.for_each(|_| {}).await;
/// });
/// ```
pub struct Group<'a, O> {
    slots: Vec<Option<Slot<'a, O>>>,
    buffer: VecDeque<O>,
    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, O> Group<'a, O> {
    /// 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 via
    /// [`ProgressFuture::with_spinner_style`](crate::future::ProgressFuture::with_spinner_style).
    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 via
    /// [`ProgressFuture::with_annotation_style`](crate::future::ProgressFuture::with_annotation_style).
    pub fn with_annotation_style(mut self, annotation_style: Style) -> Self {
        self.annotation_style = annotation_style;
        self
    }

    /// Default for showing elapsed time. Rows can override by calling
    /// [`with_elapsed_time`](crate::future::ProgressFuture::with_elapsed_time) on the row itself.
    pub fn with_elapsed_time(mut self) -> Self {
        self.with_elapsed_time = true;
        self
    }

    /// Add a future to the group. The future must implement [`ProgressiveFuture`]; calling any of
    /// the [`FutureExt`](crate::future::FutureExt) setters
    /// ([`with_label`](crate::future::FutureExt::with_label),
    /// [`with_messages`](crate::future::FutureExt::with_messages),
    /// [`with_progress`](crate::future::FutureExt::with_progress),
    /// [`with_elapsed_time`](crate::future::FutureExt::with_elapsed_time)) on a bare future
    /// produces one. For a bare future with no configuration, call
    /// [`progressive()`](crate::future::FutureExt::progressive) to lift it explicitly.
    pub fn push<F>(&mut self, mut fut: F)
    where
        F: ProgressiveFuture<'a, Output = O> + 'a,
    {
        let line = match fut.theme() {
            Some(row_theme) => Line::new(row_theme),
            None => Line::new(&self.theme),
        };
        fut.detach_rendering();
        self.slots.push(Some(Slot {
            work: Box::pin(fut),
            line,
        }));
        self.dirty = true;
    }
}

impl<O> Stream for Group<'_, O>
where
    O: Unpin,
{
    type Item = O;

    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 every active slot; buffer every completion so simultaneous Readys aren't lost.
        for slot in this.slots.iter_mut() {
            if let Some(s) = slot {
                if let Poll::Ready(out) = s.work.as_mut().poll(cx) {
                    this.buffer.push_back(out);
                    *slot = None;
                    this.dirty = true;
                }
            }
        }

        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");
            }

            // Clear any leftover lines from a previous frame whose slots have since completed.
            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(out) = this.buffer.pop_front() {
            return Poll::Ready(Some(out));
        }

        if active_count == 0 {
            // All slots done and emitted; one final return.
            let _ = term::reset();
            return Poll::Ready(None);
        }

        Poll::Pending
    }
}