use std::io::{self, IsTerminal, Read, Write};
use std::sync::atomic::Ordering::{AcqRel, Acquire, Relaxed};
use std::sync::atomic::{AtomicU16, AtomicU64, AtomicU8};
use std::sync::{Arc, Mutex, MutexGuard};
use std::thread::{self, JoinHandle};
use std::time::{Duration, Instant};
use crossterm::{
cursor::{Hide, MoveTo, MoveToColumn, MoveToNextLine, MoveToPreviousLine, Show},
execute, queue,
style::{Print, ResetColor},
terminal::{Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen},
};
use crate::art::Art;
use crate::easing::Easing;
use crate::ordering::{Directional, Ordering};
use crate::render::{queue_row, Scene, Style, Viewport};
use crate::{frame, guard, width};
const DEFAULT_ART: &str = include_str!("../assets/dragon.txt");
const FPS: u64 = 30;
const GLIDE_TAU: f32 = 0.12;
const RUNNING: u8 = 0;
const FINISH_KEEP: u8 = 1; const FINISH_CLEAR: u8 = 2;
struct Shared {
pos: AtomicU64,
total: AtomicU64, state: AtomicU8,
drawn_lines: AtomicU16,
message: Mutex<String>,
painting: Mutex<()>,
art: Art,
ranks: crate::rank::RankMap,
style: Style,
easing: Easing,
started: Instant,
}
impl Shared {
fn inc(&self, delta: u64) {
self.pos.fetch_add(delta, Relaxed);
}
fn set(&self, pos: u64) {
self.pos.store(pos, Relaxed);
}
fn set_message(&self, msg: String) {
let msg = width::sanitize(&msg);
if let Ok(mut guard) = self.message.lock() {
*guard = msg;
}
}
fn message(&self) -> String {
self.message
.lock()
.map(|m| m.clone())
.unwrap_or_else(|e| e.into_inner().clone())
}
fn lock_paint(&self) -> MutexGuard<'_, ()> {
self.painting.lock().unwrap_or_else(|e| e.into_inner())
}
fn progress(&self, elapsed: f32) -> f32 {
let total = self.total.load(Relaxed);
if total == 0 {
0.1 + 0.9 * (0.5 - 0.5 * (elapsed * 1.5).cos()) } else {
let raw = (self.pos.load(Relaxed) as f32 / total as f32).clamp(0.0, 1.0);
self.easing.apply(raw)
}
}
}
pub struct Loader {
shared: Arc<Shared>,
joiner: Mutex<Option<JoinHandle<()>>>,
tty: bool,
}
impl Loader {
pub fn new(total: u64) -> Self {
Builder::new().total(total).start()
}
pub fn spinner() -> Self {
Builder::new().start()
}
pub fn builder() -> Builder {
Builder::new()
}
pub fn inc(&self, delta: u64) {
self.shared.inc(delta);
}
pub fn set(&self, pos: u64) {
self.shared.set(pos);
}
pub fn set_length(&self, total: u64) {
self.shared.total.store(total, Relaxed);
}
pub fn set_message<S: Into<String>>(&self, msg: S) {
self.shared.set_message(msg.into());
}
pub fn position(&self) -> u64 {
self.shared.pos.load(Relaxed)
}
pub fn length(&self) -> u64 {
self.shared.total.load(Relaxed)
}
pub fn elapsed(&self) -> Duration {
self.shared.started.elapsed()
}
pub fn rate(&self) -> f64 {
let secs = self.elapsed().as_secs_f64();
if secs <= 0.0 {
0.0
} else {
self.position() as f64 / secs
}
}
pub fn eta(&self) -> Option<Duration> {
let (total, pos) = (self.length(), self.position());
let rate = self.rate();
if total == 0 || pos == 0 || pos >= total || rate <= 0.0 {
return None;
}
Duration::try_from_secs_f64((total - pos) as f64 / rate).ok()
}
pub fn handle(&self) -> Handle {
Handle {
shared: Arc::clone(&self.shared),
}
}
pub fn wrap_read<R: Read>(&self, reader: R) -> ProgressReader<R> {
self.handle().wrap_read(reader)
}
pub fn suspend<T>(&self, f: impl FnOnce() -> T) -> T {
if !self.tty {
return f();
}
let _painting = self.shared.lock_paint();
let lines = self.shared.drawn_lines.swap(0, AcqRel);
if lines > 0 {
let mut out = io::stdout();
let _ = erase_block(&mut out, lines);
let _ = out.flush();
}
f()
}
pub fn println<S: AsRef<str>>(&self, line: S) {
self.suspend(|| {
let mut out = io::stdout();
let _ = writeln!(out, "{}", line.as_ref());
let _ = out.flush();
});
}
pub fn finish(&self) {
self.finalize(FINISH_KEEP);
}
pub fn finish_and_clear(&self) {
self.finalize(FINISH_CLEAR);
}
fn finalize(&self, how: u8) {
let won = self
.shared
.state
.compare_exchange(RUNNING, how, AcqRel, Relaxed)
.is_ok();
if self.tty {
if let Ok(mut guard) = self.joiner.lock() {
if let Some(handle) = guard.take() {
let _ = handle.join();
}
}
} else if won && how == FINISH_KEEP {
print!(
"{}",
frame::to_string(&self.shared.art, &self.shared.ranks, 1.0)
);
let _ = io::stdout().flush();
}
}
}
impl Drop for Loader {
fn drop(&mut self) {
self.finalize(FINISH_KEEP);
}
}
#[derive(Clone)]
pub struct Handle {
shared: Arc<Shared>,
}
impl Handle {
pub fn inc(&self, delta: u64) {
self.shared.inc(delta);
}
pub fn set(&self, pos: u64) {
self.shared.set(pos);
}
pub fn set_message<S: Into<String>>(&self, msg: S) {
self.shared.set_message(msg.into());
}
pub fn position(&self) -> u64 {
self.shared.pos.load(Relaxed)
}
pub fn wrap_read<R: Read>(&self, reader: R) -> ProgressReader<R> {
ProgressReader {
inner: reader,
handle: self.clone(),
}
}
}
pub struct Builder {
total: u64,
art: Option<Art>,
ordering: Box<dyn Ordering>,
style: Style,
easing: Easing,
message: String,
}
impl Builder {
fn new() -> Self {
Builder {
total: 0,
art: None,
ordering: Box::new(Directional::default()),
style: Style::default(),
easing: Easing::default(),
message: String::new(),
}
}
pub fn total(mut self, total: u64) -> Self {
self.total = total;
self
}
pub fn art(mut self, art: Art) -> Self {
self.art = Some(art);
self
}
pub fn ordering(mut self, ordering: impl Ordering + 'static) -> Self {
self.ordering = Box::new(ordering);
self
}
pub fn style(mut self, style: Style) -> Self {
self.style = style;
self
}
pub fn easing(mut self, easing: Easing) -> Self {
self.easing = easing;
self
}
pub fn message<S: Into<String>>(mut self, message: S) -> Self {
self.message = width::sanitize(&message.into());
self
}
pub fn start(self) -> Loader {
let art = self.art.unwrap_or_else(|| Art::parse(DEFAULT_ART));
let ranks = self.ordering.rank(&art);
let shared = Arc::new(Shared {
pos: AtomicU64::new(0),
total: AtomicU64::new(self.total),
state: AtomicU8::new(RUNNING),
drawn_lines: AtomicU16::new(0),
message: Mutex::new(self.message),
painting: Mutex::new(()),
art,
ranks,
style: self.style,
easing: self.easing,
started: Instant::now(),
});
let tty = io::stdout().is_terminal();
let joiner = if tty {
let shared = Arc::clone(&shared);
Mutex::new(Some(thread::spawn(move || run(shared))))
} else {
Mutex::new(None)
};
Loader {
shared,
joiner,
tty,
}
}
}
pub trait ProgressIteratorExt: Iterator + Sized {
fn inkling(self) -> InklingIter<Self> {
let total = self.size_hint().1.unwrap_or(0) as u64;
let loader = if total > 0 {
Loader::new(total)
} else {
Loader::spinner()
};
InklingIter {
inner: self,
loader: Some(loader),
}
}
fn inkling_with(self, loader: Loader) -> InklingIter<Self> {
InklingIter {
inner: self,
loader: Some(loader),
}
}
}
impl<I: Iterator> ProgressIteratorExt for I {}
pub struct InklingIter<I> {
inner: I,
loader: Option<Loader>,
}
impl<I> InklingIter<I> {
pub fn loader(&self) -> Option<&Loader> {
self.loader.as_ref()
}
}
impl<I: Iterator> Iterator for InklingIter<I> {
type Item = I::Item;
fn next(&mut self) -> Option<Self::Item> {
let next = self.inner.next();
match next {
Some(_) => {
if let Some(loader) = &self.loader {
loader.inc(1);
}
}
None => {
if let Some(loader) = self.loader.take() {
loader.finish();
}
}
}
next
}
fn size_hint(&self) -> (usize, Option<usize>) {
self.inner.size_hint()
}
}
impl<I> Drop for InklingIter<I> {
fn drop(&mut self) {
if let Some(loader) = self.loader.take() {
loader.finish();
}
}
}
pub struct ProgressReader<R> {
inner: R,
handle: Handle,
}
impl<R> ProgressReader<R> {
pub fn into_inner(self) -> R {
self.inner
}
}
impl<R: Read> Read for ProgressReader<R> {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
let n = self.inner.read(buf)?;
self.handle.inc(n as u64);
Ok(n)
}
}
impl<R: io::BufRead> io::BufRead for ProgressReader<R> {
fn fill_buf(&mut self) -> io::Result<&[u8]> {
self.inner.fill_buf()
}
fn consume(&mut self, amt: usize) {
self.inner.consume(amt);
self.handle.inc(amt as u64);
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Placement {
Inline,
Fullscreen,
}
fn run(shared: Arc<Shared>) {
let mut out = io::stdout();
guard::arm();
let mut viewport = Viewport::detect();
let mut placement = choose_placement(&shared.art, viewport);
enter(&mut out, placement);
let frame_time = Duration::from_millis(1000 / FPS);
let start = Instant::now();
let mut displayed = 0.0f32;
let mut last_tick = Instant::now();
loop {
let finishing = shared.state.load(Acquire) != RUNNING;
let elapsed = start.elapsed().as_secs_f32();
let now = Viewport::detect();
if now != viewport {
let next = choose_placement(&shared.art, now);
if next != placement {
leave(&mut out, placement, &shared);
enter(&mut out, next);
placement = next;
}
shared.drawn_lines.store(0, Relaxed);
if placement == Placement::Fullscreen {
let _ = execute!(out, Clear(ClearType::All));
}
viewport = now;
}
let target = shared.progress(elapsed);
let dt = last_tick.elapsed().as_secs_f32();
last_tick = Instant::now();
displayed += (target - displayed) * (1.0 - (-dt / GLIDE_TAU).exp());
let progress = if finishing { 1.0 } else { displayed };
{
let _painting = shared.lock_paint();
let _ = draw(&mut out, &shared, viewport, placement, progress, elapsed);
}
if finishing {
let cleared = shared.state.load(Relaxed) == FINISH_CLEAR;
let _painting = shared.lock_paint();
match (placement, cleared) {
(Placement::Fullscreen, _) => {
leave(&mut out, placement, &shared);
if !cleared {
let _ = persist_final(&mut out, &shared, viewport);
}
}
(Placement::Inline, true) => {
let lines = shared.drawn_lines.swap(0, Relaxed);
let _ = erase_block(&mut out, lines);
let _ = execute!(out, Show);
guard::set_cursor_hidden(false);
}
(Placement::Inline, false) => {
let _ = queue!(out, Print("\r\n"));
let _ = execute!(out, Show);
guard::set_cursor_hidden(false);
}
}
let _ = out.flush();
break;
}
thread::sleep(frame_time);
}
}
fn choose_placement(art: &Art, viewport: Viewport) -> Placement {
let fits_height = viewport.rows >= art.height() + 2;
let fits_width = viewport.cols >= frame::art_cols(art);
if fits_height && fits_width {
Placement::Inline
} else {
Placement::Fullscreen
}
}
fn enter(out: &mut io::Stdout, placement: Placement) {
match placement {
Placement::Fullscreen => {
let _ = execute!(out, EnterAlternateScreen, Hide, Clear(ClearType::All));
guard::set_alt_screen(true);
}
Placement::Inline => {
let _ = execute!(out, Hide);
}
}
guard::set_cursor_hidden(true);
}
fn leave(out: &mut io::Stdout, placement: Placement, shared: &Shared) {
if placement == Placement::Fullscreen {
let _ = execute!(out, ResetColor, Show, LeaveAlternateScreen);
guard::set_alt_screen(false);
guard::set_cursor_hidden(false);
}
shared.drawn_lines.store(0, Relaxed);
}
fn draw(
out: &mut impl Write,
shared: &Shared,
viewport: Viewport,
placement: Placement,
progress: f32,
t: f32,
) -> io::Result<()> {
let art = &shared.art;
let scene = Scene {
art,
ranks: &shared.ranks,
style: &shared.style,
};
let fit = viewport.fit(art, 1);
queue!(out, Print(crate::render::SYNC_BEGIN))?;
let previous = shared.drawn_lines.load(Relaxed);
if placement == Placement::Inline && previous > 1 {
queue!(out, MoveToPreviousLine(previous - 1))?;
}
for y in 0..fit.rows {
match placement {
Placement::Fullscreen => queue!(out, MoveTo(fit.ox, fit.oy + y))?,
Placement::Inline => queue!(out, MoveToColumn(0))?,
}
queue!(out, Clear(ClearType::UntilNewLine))?;
queue_row(out, scene, progress, t, y, fit.cols)?;
if placement == Placement::Inline {
queue!(out, MoveToNextLine(1))?;
}
}
match placement {
Placement::Fullscreen => queue!(out, MoveTo(fit.ox, fit.oy + fit.rows))?,
Placement::Inline => queue!(out, MoveToColumn(0))?,
}
queue!(out, Clear(ClearType::UntilNewLine))?;
let msg = shared.message();
if !msg.is_empty() {
let shown = width::truncate_to_cols(&msg, viewport.cols.saturating_sub(1));
match shared.style.depth.quantize(shared.style.caption) {
Some(c) => write!(out, "{c}{shown}{}", crate::render::FG_RESET)?,
None => write!(out, "{shown}")?,
}
}
shared.drawn_lines.store(fit.rows + 1, Relaxed);
queue!(out, Print(crate::render::SYNC_END))?;
out.flush()
}
fn erase_block(out: &mut impl Write, lines: u16) -> io::Result<()> {
if lines == 0 {
return Ok(());
}
if lines > 1 {
queue!(out, MoveToPreviousLine(lines - 1))?;
}
queue!(out, MoveToColumn(0))?;
for _ in 0..lines {
queue!(
out,
MoveToColumn(0),
Clear(ClearType::CurrentLine),
MoveToNextLine(1)
)?;
}
queue!(out, MoveToPreviousLine(lines))?;
out.flush()
}
fn persist_final(out: &mut impl Write, shared: &Shared, viewport: Viewport) -> io::Result<()> {
let art = &shared.art;
let style = Style {
feather: 0.0,
..shared.style
};
let scene = Scene {
art,
ranks: &shared.ranks,
style: &style,
};
for y in 0..art.height() {
queue_row(out, scene, 1.0, 0.0, y, viewport.cols)?;
queue!(out, Print("\r\n"))?;
}
out.flush()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::art::Art;
#[test]
fn loader_and_handle_are_send_sync() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<Loader>();
assert_send_sync::<Handle>();
}
#[test]
fn position_tracks_updates() {
let loader = Loader::builder().total(10).message("x").start();
loader.inc(3);
loader.set(7);
assert_eq!(loader.position(), 7);
assert_eq!(loader.length(), 10);
loader.finish_and_clear();
}
#[test]
fn iterator_yields_every_item() {
let loader = Loader::builder().total(5).art(Art::parse("##")).start();
let collected: Vec<i32> = (0..5).inkling_with(loader).collect();
assert_eq!(collected, vec![0, 1, 2, 3, 4]);
}
#[test]
fn suspend_runs_the_closure_and_returns_its_value() {
let loader = Loader::builder().total(4).art(Art::parse("##")).start();
assert_eq!(loader.suspend(|| 41 + 1), 42);
loader.println("a line");
loader.finish_and_clear();
}
#[test]
fn eta_is_none_until_there_is_something_to_extrapolate() {
let loader = Loader::builder().total(100).art(Art::parse("##")).start();
assert_eq!(loader.eta(), None, "no progress yet");
loader.set(100);
assert_eq!(loader.eta(), None, "already done");
loader.finish_and_clear();
}
#[test]
fn spinner_has_no_length_and_no_eta() {
let loader = Loader::builder().art(Art::parse("##")).start();
assert_eq!(loader.length(), 0);
assert_eq!(loader.eta(), None);
loader.finish_and_clear();
}
#[test]
fn easing_shapes_the_reported_progress() {
let art = Art::parse("####");
let build = |easing| {
let ordering = Directional::default();
Shared {
pos: AtomicU64::new(50),
total: AtomicU64::new(100),
state: AtomicU8::new(RUNNING),
drawn_lines: AtomicU16::new(0),
message: Mutex::new(String::new()),
painting: Mutex::new(()),
ranks: ordering.rank(&art),
art: art.clone(),
style: Style::default(),
easing,
started: Instant::now(),
}
};
assert!((build(Easing::Linear).progress(0.0) - 0.5).abs() < 1e-6);
assert!(
build(Easing::EaseOutCubic).progress(0.0) > 0.8,
"ease-out should be well ahead at the midpoint"
);
}
#[test]
fn an_inline_frame_is_cursor_neutral() {
let art = Art::parse("##\n##\n##");
let ordering = Directional::default();
let shared = Shared {
pos: AtomicU64::new(50),
total: AtomicU64::new(100),
state: AtomicU8::new(RUNNING),
drawn_lines: AtomicU16::new(0),
message: Mutex::new("caption".into()),
painting: Mutex::new(()),
ranks: ordering.rank(&art),
art: art.clone(),
style: Style::monochrome(),
easing: Easing::Linear,
started: Instant::now(),
};
let viewport = Viewport { cols: 40, rows: 20 };
let mut first = Vec::new();
draw(&mut first, &shared, viewport, Placement::Inline, 0.5, 0.0).unwrap();
assert_eq!(shared.drawn_lines.load(Relaxed), 4);
let down = String::from_utf8(first)
.unwrap()
.matches("\u{1b}[1E")
.count();
assert_eq!(down, 3, "one step down per art row, none after the caption");
let mut second = Vec::new();
draw(&mut second, &shared, viewport, Placement::Inline, 0.6, 0.0).unwrap();
let text = String::from_utf8(second).unwrap();
assert!(
text.contains("\u{1b}[3F"),
"the next frame must step back over exactly what the last one stepped down"
);
assert!(
!text.contains("\u{1b}[4F"),
"stepping back one line too far"
);
}
#[test]
fn wrapped_reader_advances_the_loader() {
let loader = Loader::builder().total(11).art(Art::parse("##")).start();
let mut reader = loader.wrap_read(&b"hello world"[..]);
let mut sink = Vec::new();
io::copy(&mut reader, &mut sink).unwrap();
assert_eq!(loader.position(), 11);
loader.finish_and_clear();
}
}