1use std::io::{self, IsTerminal, Read, Write};
22use std::sync::atomic::Ordering::{AcqRel, Acquire, Relaxed};
23use std::sync::atomic::{AtomicU16, AtomicU64, AtomicU8};
24use std::sync::{Arc, Mutex, MutexGuard};
25use std::thread::{self, JoinHandle};
26use std::time::{Duration, Instant};
27
28use crossterm::{
29 cursor::{Hide, MoveTo, MoveToColumn, MoveToNextLine, MoveToPreviousLine, Show},
30 execute, queue,
31 style::{Print, ResetColor},
32 terminal::{Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen},
33};
34
35use crate::art::Art;
36use crate::easing::Easing;
37use crate::ordering::{Directional, Ordering};
38use crate::render::{queue_row, Scene, Style, Viewport};
39use crate::{frame, guard, width};
40
41const DEFAULT_ART: &str = include_str!("../assets/dragon.txt");
43const FPS: u64 = 30;
44
45const GLIDE_TAU: f32 = 0.12;
48
49const RUNNING: u8 = 0;
51const FINISH_KEEP: u8 = 1; const FINISH_CLEAR: u8 = 2; struct Shared {
56 pos: AtomicU64,
57 total: AtomicU64, state: AtomicU8,
59 drawn_lines: AtomicU16,
63 message: Mutex<String>,
64 painting: Mutex<()>,
67 art: Art,
68 ranks: crate::rank::RankMap,
69 style: Style,
70 easing: Easing,
71 started: Instant,
72}
73
74impl Shared {
75 fn inc(&self, delta: u64) {
76 self.pos.fetch_add(delta, Relaxed);
77 }
78 fn set(&self, pos: u64) {
79 self.pos.store(pos, Relaxed);
80 }
81 fn set_message(&self, msg: String) {
82 let msg = width::sanitize(&msg);
86 if let Ok(mut guard) = self.message.lock() {
87 *guard = msg;
88 }
89 }
90 fn message(&self) -> String {
91 self.message
92 .lock()
93 .map(|m| m.clone())
94 .unwrap_or_else(|e| e.into_inner().clone())
95 }
96 fn lock_paint(&self) -> MutexGuard<'_, ()> {
99 self.painting.lock().unwrap_or_else(|e| e.into_inner())
100 }
101 fn progress(&self, elapsed: f32) -> f32 {
103 let total = self.total.load(Relaxed);
104 if total == 0 {
105 0.1 + 0.9 * (0.5 - 0.5 * (elapsed * 1.5).cos()) } else {
107 let raw = (self.pos.load(Relaxed) as f32 / total as f32).clamp(0.0, 1.0);
108 self.easing.apply(raw)
109 }
110 }
111}
112
113pub struct Loader {
120 shared: Arc<Shared>,
121 joiner: Mutex<Option<JoinHandle<()>>>,
122 tty: bool,
123}
124
125impl Loader {
126 pub fn new(total: u64) -> Self {
128 Builder::new().total(total).start()
129 }
130
131 pub fn spinner() -> Self {
133 Builder::new().start()
134 }
135
136 pub fn builder() -> Builder {
138 Builder::new()
139 }
140
141 pub fn inc(&self, delta: u64) {
143 self.shared.inc(delta);
144 }
145
146 pub fn set(&self, pos: u64) {
148 self.shared.set(pos);
149 }
150
151 pub fn set_length(&self, total: u64) {
153 self.shared.total.store(total, Relaxed);
154 }
155
156 pub fn set_message<S: Into<String>>(&self, msg: S) {
158 self.shared.set_message(msg.into());
159 }
160
161 pub fn position(&self) -> u64 {
163 self.shared.pos.load(Relaxed)
164 }
165
166 pub fn length(&self) -> u64 {
168 self.shared.total.load(Relaxed)
169 }
170
171 pub fn elapsed(&self) -> Duration {
173 self.shared.started.elapsed()
174 }
175
176 pub fn rate(&self) -> f64 {
178 let secs = self.elapsed().as_secs_f64();
179 if secs <= 0.0 {
180 0.0
181 } else {
182 self.position() as f64 / secs
183 }
184 }
185
186 pub fn eta(&self) -> Option<Duration> {
191 let (total, pos) = (self.length(), self.position());
192 let rate = self.rate();
193 if total == 0 || pos == 0 || pos >= total || rate <= 0.0 {
194 return None;
195 }
196 Duration::try_from_secs_f64((total - pos) as f64 / rate).ok()
197 }
198
199 pub fn handle(&self) -> Handle {
202 Handle {
203 shared: Arc::clone(&self.shared),
204 }
205 }
206
207 pub fn wrap_read<R: Read>(&self, reader: R) -> ProgressReader<R> {
210 self.handle().wrap_read(reader)
211 }
212
213 pub fn suspend<T>(&self, f: impl FnOnce() -> T) -> T {
227 if !self.tty {
228 return f();
229 }
230 let _painting = self.shared.lock_paint();
233 let lines = self.shared.drawn_lines.swap(0, AcqRel);
234 if lines > 0 {
235 let mut out = io::stdout();
236 let _ = erase_block(&mut out, lines);
237 let _ = out.flush();
238 }
239 f()
240 }
241
242 pub fn println<S: AsRef<str>>(&self, line: S) {
246 self.suspend(|| {
247 let mut out = io::stdout();
248 let _ = writeln!(out, "{}", line.as_ref());
249 let _ = out.flush();
250 });
251 }
252
253 pub fn finish(&self) {
255 self.finalize(FINISH_KEEP);
256 }
257
258 pub fn finish_and_clear(&self) {
260 self.finalize(FINISH_CLEAR);
261 }
262
263 fn finalize(&self, how: u8) {
264 let won = self
265 .shared
266 .state
267 .compare_exchange(RUNNING, how, AcqRel, Relaxed)
268 .is_ok();
269 if self.tty {
270 if let Ok(mut guard) = self.joiner.lock() {
271 if let Some(handle) = guard.take() {
272 let _ = handle.join();
273 }
274 }
275 } else if won && how == FINISH_KEEP {
276 print!(
278 "{}",
279 frame::to_string(&self.shared.art, &self.shared.ranks, 1.0)
280 );
281 let _ = io::stdout().flush();
282 }
283 }
284}
285
286impl Drop for Loader {
287 fn drop(&mut self) {
288 self.finalize(FINISH_KEEP);
289 }
290}
291
292#[derive(Clone)]
295pub struct Handle {
296 shared: Arc<Shared>,
297}
298
299impl Handle {
300 pub fn inc(&self, delta: u64) {
302 self.shared.inc(delta);
303 }
304 pub fn set(&self, pos: u64) {
306 self.shared.set(pos);
307 }
308 pub fn set_message<S: Into<String>>(&self, msg: S) {
310 self.shared.set_message(msg.into());
311 }
312 pub fn position(&self) -> u64 {
314 self.shared.pos.load(Relaxed)
315 }
316 pub fn wrap_read<R: Read>(&self, reader: R) -> ProgressReader<R> {
318 ProgressReader {
319 inner: reader,
320 handle: self.clone(),
321 }
322 }
323}
324
325pub struct Builder {
327 total: u64,
328 art: Option<Art>,
329 ordering: Box<dyn Ordering>,
330 style: Style,
331 easing: Easing,
332 message: String,
333}
334
335impl Builder {
336 fn new() -> Self {
337 Builder {
338 total: 0,
339 art: None,
340 ordering: Box::new(Directional::default()),
341 style: Style::default(),
342 easing: Easing::default(),
343 message: String::new(),
344 }
345 }
346
347 pub fn total(mut self, total: u64) -> Self {
349 self.total = total;
350 self
351 }
352
353 pub fn art(mut self, art: Art) -> Self {
355 self.art = Some(art);
356 self
357 }
358
359 pub fn ordering(mut self, ordering: impl Ordering + 'static) -> Self {
361 self.ordering = Box::new(ordering);
362 self
363 }
364
365 pub fn style(mut self, style: Style) -> Self {
367 self.style = style;
368 self
369 }
370
371 pub fn easing(mut self, easing: Easing) -> Self {
374 self.easing = easing;
375 self
376 }
377
378 pub fn message<S: Into<String>>(mut self, message: S) -> Self {
380 self.message = width::sanitize(&message.into());
381 self
382 }
383
384 pub fn start(self) -> Loader {
386 let art = self.art.unwrap_or_else(|| Art::parse(DEFAULT_ART));
387 let ranks = self.ordering.rank(&art);
388 let shared = Arc::new(Shared {
389 pos: AtomicU64::new(0),
390 total: AtomicU64::new(self.total),
391 state: AtomicU8::new(RUNNING),
392 drawn_lines: AtomicU16::new(0),
393 message: Mutex::new(self.message),
394 painting: Mutex::new(()),
395 art,
396 ranks,
397 style: self.style,
398 easing: self.easing,
399 started: Instant::now(),
400 });
401 let tty = io::stdout().is_terminal();
402 let joiner = if tty {
403 let shared = Arc::clone(&shared);
404 Mutex::new(Some(thread::spawn(move || run(shared))))
405 } else {
406 Mutex::new(None)
407 };
408 Loader {
409 shared,
410 joiner,
411 tty,
412 }
413 }
414}
415
416pub trait ProgressIteratorExt: Iterator + Sized {
422 fn inkling(self) -> InklingIter<Self> {
424 let total = self.size_hint().1.unwrap_or(0) as u64;
425 let loader = if total > 0 {
426 Loader::new(total)
427 } else {
428 Loader::spinner()
429 };
430 InklingIter {
431 inner: self,
432 loader: Some(loader),
433 }
434 }
435
436 fn inkling_with(self, loader: Loader) -> InklingIter<Self> {
438 InklingIter {
439 inner: self,
440 loader: Some(loader),
441 }
442 }
443}
444
445impl<I: Iterator> ProgressIteratorExt for I {}
446
447pub struct InklingIter<I> {
449 inner: I,
450 loader: Option<Loader>,
451}
452
453impl<I> InklingIter<I> {
454 pub fn loader(&self) -> Option<&Loader> {
456 self.loader.as_ref()
457 }
458}
459
460impl<I: Iterator> Iterator for InklingIter<I> {
461 type Item = I::Item;
462
463 fn next(&mut self) -> Option<Self::Item> {
464 let next = self.inner.next();
465 match next {
466 Some(_) => {
467 if let Some(loader) = &self.loader {
468 loader.inc(1);
469 }
470 }
471 None => {
472 if let Some(loader) = self.loader.take() {
473 loader.finish();
474 }
475 }
476 }
477 next
478 }
479
480 fn size_hint(&self) -> (usize, Option<usize>) {
481 self.inner.size_hint()
482 }
483}
484
485impl<I> Drop for InklingIter<I> {
486 fn drop(&mut self) {
487 if let Some(loader) = self.loader.take() {
488 loader.finish();
489 }
490 }
491}
492
493pub struct ProgressReader<R> {
499 inner: R,
500 handle: Handle,
501}
502
503impl<R> ProgressReader<R> {
504 pub fn into_inner(self) -> R {
506 self.inner
507 }
508}
509
510impl<R: Read> Read for ProgressReader<R> {
511 fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
512 let n = self.inner.read(buf)?;
513 self.handle.inc(n as u64);
514 Ok(n)
515 }
516}
517
518impl<R: io::BufRead> io::BufRead for ProgressReader<R> {
519 fn fill_buf(&mut self) -> io::Result<&[u8]> {
520 self.inner.fill_buf()
521 }
522 fn consume(&mut self, amt: usize) {
523 self.inner.consume(amt);
524 self.handle.inc(amt as u64);
525 }
526}
527
528#[derive(Clone, Copy, PartialEq, Eq)]
534enum Placement {
535 Inline,
537 Fullscreen,
539}
540
541fn run(shared: Arc<Shared>) {
542 let mut out = io::stdout();
543 guard::arm();
544
545 let mut viewport = Viewport::detect();
546 let mut placement = choose_placement(&shared.art, viewport);
547 enter(&mut out, placement);
548
549 let frame_time = Duration::from_millis(1000 / FPS);
550 let start = Instant::now();
551 let mut displayed = 0.0f32;
552 let mut last_tick = Instant::now();
553
554 loop {
555 let finishing = shared.state.load(Acquire) != RUNNING;
556 let elapsed = start.elapsed().as_secs_f32();
557
558 let now = Viewport::detect();
561 if now != viewport {
562 let next = choose_placement(&shared.art, now);
563 if next != placement {
564 leave(&mut out, placement, &shared);
565 enter(&mut out, next);
566 placement = next;
567 }
568 shared.drawn_lines.store(0, Relaxed);
569 if placement == Placement::Fullscreen {
570 let _ = execute!(out, Clear(ClearType::All));
571 }
572 viewport = now;
573 }
574
575 let target = shared.progress(elapsed);
578 let dt = last_tick.elapsed().as_secs_f32();
579 last_tick = Instant::now();
580 displayed += (target - displayed) * (1.0 - (-dt / GLIDE_TAU).exp());
581 let progress = if finishing { 1.0 } else { displayed };
582
583 {
584 let _painting = shared.lock_paint();
585 let _ = draw(&mut out, &shared, viewport, placement, progress, elapsed);
586 }
587
588 if finishing {
589 let cleared = shared.state.load(Relaxed) == FINISH_CLEAR;
590 let _painting = shared.lock_paint();
591 match (placement, cleared) {
592 (Placement::Fullscreen, _) => {
593 leave(&mut out, placement, &shared);
594 if !cleared {
595 let _ = persist_final(&mut out, &shared, viewport);
596 }
597 }
598 (Placement::Inline, true) => {
599 let lines = shared.drawn_lines.swap(0, Relaxed);
600 let _ = erase_block(&mut out, lines);
601 let _ = execute!(out, Show);
602 guard::set_cursor_hidden(false);
603 }
604 (Placement::Inline, false) => {
605 let _ = queue!(out, Print("\r\n"));
607 let _ = execute!(out, Show);
608 guard::set_cursor_hidden(false);
609 }
610 }
611 let _ = out.flush();
612 break;
613 }
614 thread::sleep(frame_time);
615 }
616}
617
618fn choose_placement(art: &Art, viewport: Viewport) -> Placement {
623 let fits_height = viewport.rows >= art.height() + 2;
624 let fits_width = viewport.cols >= frame::art_cols(art);
625 if fits_height && fits_width {
626 Placement::Inline
627 } else {
628 Placement::Fullscreen
629 }
630}
631
632fn enter(out: &mut io::Stdout, placement: Placement) {
633 match placement {
634 Placement::Fullscreen => {
635 let _ = execute!(out, EnterAlternateScreen, Hide, Clear(ClearType::All));
636 guard::set_alt_screen(true);
637 }
638 Placement::Inline => {
639 let _ = execute!(out, Hide);
640 }
641 }
642 guard::set_cursor_hidden(true);
643}
644
645fn leave(out: &mut io::Stdout, placement: Placement, shared: &Shared) {
646 if placement == Placement::Fullscreen {
647 let _ = execute!(out, ResetColor, Show, LeaveAlternateScreen);
648 guard::set_alt_screen(false);
649 guard::set_cursor_hidden(false);
650 }
651 shared.drawn_lines.store(0, Relaxed);
652}
653
654fn draw(
657 out: &mut impl Write,
658 shared: &Shared,
659 viewport: Viewport,
660 placement: Placement,
661 progress: f32,
662 t: f32,
663) -> io::Result<()> {
664 let art = &shared.art;
665 let scene = Scene {
666 art,
667 ranks: &shared.ranks,
668 style: &shared.style,
669 };
670 let fit = viewport.fit(art, 1);
672
673 queue!(out, Print(crate::render::SYNC_BEGIN))?;
674
675 let previous = shared.drawn_lines.load(Relaxed);
681 if placement == Placement::Inline && previous > 1 {
682 queue!(out, MoveToPreviousLine(previous - 1))?;
683 }
684
685 for y in 0..fit.rows {
686 match placement {
687 Placement::Fullscreen => queue!(out, MoveTo(fit.ox, fit.oy + y))?,
688 Placement::Inline => queue!(out, MoveToColumn(0))?,
689 }
690 queue!(out, Clear(ClearType::UntilNewLine))?;
691 queue_row(out, scene, progress, t, y, fit.cols)?;
692 if placement == Placement::Inline {
693 queue!(out, MoveToNextLine(1))?;
694 }
695 }
696
697 match placement {
699 Placement::Fullscreen => queue!(out, MoveTo(fit.ox, fit.oy + fit.rows))?,
700 Placement::Inline => queue!(out, MoveToColumn(0))?,
701 }
702 queue!(out, Clear(ClearType::UntilNewLine))?;
703 let msg = shared.message();
704 if !msg.is_empty() {
705 let shown = width::truncate_to_cols(&msg, viewport.cols.saturating_sub(1));
706 match shared.style.depth.quantize(shared.style.caption) {
707 Some(c) => write!(out, "{c}{shown}{}", crate::render::FG_RESET)?,
708 None => write!(out, "{shown}")?,
709 }
710 }
711
712 shared.drawn_lines.store(fit.rows + 1, Relaxed);
715
716 queue!(out, Print(crate::render::SYNC_END))?;
717 out.flush()
718}
719
720fn erase_block(out: &mut impl Write, lines: u16) -> io::Result<()> {
723 if lines == 0 {
724 return Ok(());
725 }
726 if lines > 1 {
727 queue!(out, MoveToPreviousLine(lines - 1))?;
728 }
729 queue!(out, MoveToColumn(0))?;
730 for _ in 0..lines {
731 queue!(
732 out,
733 MoveToColumn(0),
734 Clear(ClearType::CurrentLine),
735 MoveToNextLine(1)
736 )?;
737 }
738 queue!(out, MoveToPreviousLine(lines))?;
739 out.flush()
740}
741
742fn persist_final(out: &mut impl Write, shared: &Shared, viewport: Viewport) -> io::Result<()> {
745 let art = &shared.art;
746 let style = Style {
749 feather: 0.0,
750 ..shared.style
751 };
752 let scene = Scene {
753 art,
754 ranks: &shared.ranks,
755 style: &style,
756 };
757 for y in 0..art.height() {
758 queue_row(out, scene, 1.0, 0.0, y, viewport.cols)?;
759 queue!(out, Print("\r\n"))?;
760 }
761 out.flush()
762}
763
764#[cfg(test)]
765mod tests {
766 use super::*;
767 use crate::art::Art;
768
769 #[test]
770 fn loader_and_handle_are_send_sync() {
771 fn assert_send_sync<T: Send + Sync>() {}
772 assert_send_sync::<Loader>();
773 assert_send_sync::<Handle>();
774 }
775
776 #[test]
777 fn position_tracks_updates() {
778 let loader = Loader::builder().total(10).message("x").start();
779 loader.inc(3);
780 loader.set(7);
781 assert_eq!(loader.position(), 7);
782 assert_eq!(loader.length(), 10);
783 loader.finish_and_clear();
784 }
785
786 #[test]
787 fn iterator_yields_every_item() {
788 let loader = Loader::builder().total(5).art(Art::parse("##")).start();
789 let collected: Vec<i32> = (0..5).inkling_with(loader).collect();
790 assert_eq!(collected, vec![0, 1, 2, 3, 4]);
791 }
792
793 #[test]
794 fn suspend_runs_the_closure_and_returns_its_value() {
795 let loader = Loader::builder().total(4).art(Art::parse("##")).start();
796 assert_eq!(loader.suspend(|| 41 + 1), 42);
797 loader.println("a line");
798 loader.finish_and_clear();
799 }
800
801 #[test]
802 fn eta_is_none_until_there_is_something_to_extrapolate() {
803 let loader = Loader::builder().total(100).art(Art::parse("##")).start();
804 assert_eq!(loader.eta(), None, "no progress yet");
805 loader.set(100);
806 assert_eq!(loader.eta(), None, "already done");
807 loader.finish_and_clear();
808 }
809
810 #[test]
811 fn spinner_has_no_length_and_no_eta() {
812 let loader = Loader::builder().art(Art::parse("##")).start();
813 assert_eq!(loader.length(), 0);
814 assert_eq!(loader.eta(), None);
815 loader.finish_and_clear();
816 }
817
818 #[test]
819 fn easing_shapes_the_reported_progress() {
820 let art = Art::parse("####");
821 let build = |easing| {
822 let ordering = Directional::default();
823 Shared {
824 pos: AtomicU64::new(50),
825 total: AtomicU64::new(100),
826 state: AtomicU8::new(RUNNING),
827 drawn_lines: AtomicU16::new(0),
828 message: Mutex::new(String::new()),
829 painting: Mutex::new(()),
830 ranks: ordering.rank(&art),
831 art: art.clone(),
832 style: Style::default(),
833 easing,
834 started: Instant::now(),
835 }
836 };
837 assert!((build(Easing::Linear).progress(0.0) - 0.5).abs() < 1e-6);
838 assert!(
839 build(Easing::EaseOutCubic).progress(0.0) > 0.8,
840 "ease-out should be well ahead at the midpoint"
841 );
842 }
843
844 #[test]
850 fn an_inline_frame_is_cursor_neutral() {
851 let art = Art::parse("##\n##\n##");
852 let ordering = Directional::default();
853 let shared = Shared {
854 pos: AtomicU64::new(50),
855 total: AtomicU64::new(100),
856 state: AtomicU8::new(RUNNING),
857 drawn_lines: AtomicU16::new(0),
858 message: Mutex::new("caption".into()),
859 painting: Mutex::new(()),
860 ranks: ordering.rank(&art),
861 art: art.clone(),
862 style: Style::monochrome(),
863 easing: Easing::Linear,
864 started: Instant::now(),
865 };
866 let viewport = Viewport { cols: 40, rows: 20 };
867
868 let mut first = Vec::new();
869 draw(&mut first, &shared, viewport, Placement::Inline, 0.5, 0.0).unwrap();
870 assert_eq!(shared.drawn_lines.load(Relaxed), 4);
873 let down = String::from_utf8(first)
874 .unwrap()
875 .matches("\u{1b}[1E")
876 .count();
877 assert_eq!(down, 3, "one step down per art row, none after the caption");
878
879 let mut second = Vec::new();
880 draw(&mut second, &shared, viewport, Placement::Inline, 0.6, 0.0).unwrap();
881 let text = String::from_utf8(second).unwrap();
882 assert!(
883 text.contains("\u{1b}[3F"),
884 "the next frame must step back over exactly what the last one stepped down"
885 );
886 assert!(
887 !text.contains("\u{1b}[4F"),
888 "stepping back one line too far"
889 );
890 }
891
892 #[test]
893 fn wrapped_reader_advances_the_loader() {
894 let loader = Loader::builder().total(11).art(Art::parse("##")).start();
895 let mut reader = loader.wrap_read(&b"hello world"[..]);
896 let mut sink = Vec::new();
897 io::copy(&mut reader, &mut sink).unwrap();
898 assert_eq!(loader.position(), 11);
899 loader.finish_and_clear();
900 }
901}